학습 자료

@ExceptionHandler — BookNotFoundException 이 404 가 된다


Article

빚이 셋 쌓였다.

미뤄둔 것들
강의무슨 일이 있었나그때 뭐라고 했나
12강/books/99IndexOutOfBoundsException 으로 500구멍만 짚고 넘어갔다
17강orElseThrow 를 쓰면 500orElseGet 으로 되돌렸다
20강400 은 맞는데 왜 틀렸는지 안 실린다message 는 예외 안에만 있다
셋 다 같은 병이다. 예외는 있는데, 그것을 응답으로 옮겨줄 사람이 없다.

17강이 이렇게 적었다.

orElseThrow 가 틀린 게 아니다. 던진 예외를 받아 404 로 옮겨줄 사람이 아직 없을 뿐이다.

그 사람을 만든다.

예외부터 되찾는다

자바 편 18강에서 예외를 하나 만들어뒀다.

class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(String title) {
        super(title + " 을(를) 찾을 수 없다");
    }
}

그때는 제목으로 책을 찾았으니 제목을 받았다. 우리는 번호로 찾는다. 번호를 받게만 고친다. 17강에서 이미 한 번 이렇게 썼다.

public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(Long id) {
        super(id + "번 책을 찾을 수 없다");
    }
}

RuntimeException 을 상속했다. 자바 편 18강이 그 이유를 적어뒀다 — unchecked 라서 throws 를 안 적어도 된다.

이제 one() 을 17강이 원했던 모양으로 되돌린다.

@GetMapping("/{id}")
public BookResponse one(@PathVariable Long id) {
    return repository.findById(id)
            .map(BookResponse::from)
            .orElseThrow(() -> new BookNotFoundException(id));
}

ResponseEntity 가 사라졌다. orElseGet 도 사라졌다. 없으면 없다고 던질 뿐이다.

지금 이대로 띄우면 17강에서 본 그 500 이 나온다. Spring 은 우리가 만든 예외를 모른다.

던진 예외는 어디로 가는가. 자바 편 18강에서 본 그림이다. 부른 순서를 거슬러 올라간다.

GET /books/99 — 예외가 올라가는 길
  • Optional.orElseThrow(...)BookNotFoundException 을 던진다
  • BookController.one(99)그냥 통과한다
  • DispatcherServlet받을 사람이 없다 → 500

톰캣

맨 위에서 던진 예외가 아무에게도 안 잡히고 톰캣까지 올라간다. 중간에 받을 사람을 하나 세우면 된다.

예외를 받을 사람

컨트롤러 안에 메서드를 하나 더 만든다. 주소를 매핑하지 않는다. 예외를 매핑한다.

@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ErrorResponse> handle(BookNotFoundException e) {
    return ResponseEntity.status(404).body(new ErrorResponse(404, e.getMessage()));
}

@GetMapping("/{id}") 이 "이 주소로 오면 이 메서드"였다면, @ExceptionHandler(BookNotFoundException.class) 는 **"이 예외가 튀어나오면 이 메서드"**다.

ErrorResponse 는 19강에서 배운 그것이다. 밖으로 나가는 객체는 record 로 만든다.

public record ErrorResponse(int status, String message) { }

에러도 JSON 이다. 응답의 생김새를 우리가 정한다는 원칙은 성공했을 때만 적용되는 게 아니다.

돌려본다.

curl -i http://localhost:8080/books/99
터미널
  • HTTP/1.1 404
  • {"status":404,"message":"99번 책을 찾을 수 없다"}

404 다. 그리고 왜 없는지까지 적혀 있다.

12강이 500 을 냈던 그 주소다. 17강이 500 을 냈던 그 코드다. 갚았다.

예외가 응답이 되기까지
  1. one()없으면 BookNotFoundException 을 던진다
  2. Spring컨트롤러에서 예외가 튀어나온다. 받을 사람을 찾는다
  3. @ExceptionHandler이 예외를 받겠다고 적어둔 메서드
  4. 404 + JSONErrorResponse 가 본문이 된다
컨트롤러 메서드는 상태코드를 몰라도 된다. 없으면 없다고 던지기만 한다.

컨트롤러가 하나 더 생기면

책의 저자를 돌려주는 컨트롤러를 하나 만들었다고 하자. 여기서도 없는 책이면 같은 예외를 던진다.

@RestController
@RequestMapping("/authors")
public class AuthorController {

    @GetMapping("/{id}")
    public String of(@PathVariable Long id) {
        return repository.findById(id)
                .map(Book::getTitle)
                .orElseThrow(() -> new BookNotFoundException(id));
    }
}

똑같은 예외다. 그러니 똑같이 404 가 나가야 한다.

터미널
  • $ curl -i http://localhost:8080/books/99
  • HTTP/1.1 404
  • {"status":404,"message":"99번 책을 찾을 수 없다"}
  • $ curl -i http://localhost:8080/authors/99
  • HTTP/1.1 500
  • {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/authors/99"}

같은 예외인데 한쪽만 404 다.

당연하다. @ExceptionHandlerBookController 안에 적었다. 그 컨트롤러의 것이다. AuthorController 는 자기 핸들러가 없으니 예외가 밖으로 새어 나가고, Spring 이 500 을 만든다.

고치는 방법은 둘이다. AuthorController 에도 같은 메서드를 복사하거나 — 컨트롤러 밖으로 빼거나.

20강에서 같은 갈림길을 봤다. 검사 규칙을 POSTPUT 에 각각 복사할 것인가, 한 곳에 적을 것인가.

밖으로 뺀다

파일을 하나 만든다.

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(BookNotFoundException e) {
        return ResponseEntity.status(404).body(new ErrorResponse(404, e.getMessage()));
    }
}

메서드는 그대로 옮겨왔다. 바뀐 것은 어디에 있는가뿐이다. BookController 에서는 지웠다.

@RestControllerAdvice 는 "모든 @RestController 에 대해 조언하는 자"라는 뜻이다. 7강에서 배운 대로 이것도 표식이고, Spring 이 스캔해서 찾아낸다.

터미널
  • $ curl -i http://localhost:8080/books/99
  • HTTP/1.1 404
  • {"status":404,"message":"99번 책을 찾을 수 없다"}
  • $ curl -i http://localhost:8080/authors/99
  • HTTP/1.1 404
  • {"status":404,"message":"99번 책을 찾을 수 없다"}

둘 다 404 다. 컨트롤러가 열 개로 늘어도 이 파일 하나가 받는다.

핸들러를 어디에 두는가

컨트롤러 안

@ExceptionHandler 만

  • 그 컨트롤러에서 난 예외만 받는다
  • 옆 컨트롤러는 여전히 500 이다
  • 컨트롤러마다 복사해야 한다
  • 특정 컨트롤러만 다르게 처리할 때는 쓸모가 있다

컨트롤러 밖

@RestControllerAdvice

  • 모든 컨트롤러의 예외를 받는다
  • 에러 응답의 모양이 한 곳에 있다
  • 컨트롤러가 늘어도 안 고친다
  • 규칙이 한 곳에 모인다
20강에서 규칙을 BookRequest 한 곳에 모았듯이, 에러 처리도 한 곳에 모은다.

20강이 남긴 400 도 여기서 갚는다

20강 끝에서 이런 응답을 봤다.

{"timestamp":"...","status":400,"error":"Bad Request","path":"/books"}

400 은 맞는데 왜 틀렸는지 없다. 우리가 message 에 적어둔 "제목은 비어 있을 수 없다"가 예외 안에만 있었다.

그 예외의 이름을 20강에서 확인해뒀다. MethodArgumentNotValidException 이다. 받을 수 있다.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleInvalid(MethodArgumentNotValidException e) {
    List<String> reasons = e.getFieldErrors().stream()
            .map(error -> error.getDefaultMessage())
            .toList();
    return ResponseEntity.badRequest().body(new ErrorResponse(400, String.join(", ", reasons)));
}

getFieldErrors() 는 어긴 필드마다 하나씩 준다. getDefaultMessage() 가 우리가 @NotBlank(message = ...) 에 적어둔 그 문장이다.

curl -X POST http://localhost:8080/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"","price":100}'
터미널
  • {"status":400,"message":"제목은 비어 있을 수 없다"}

우리가 적은 문장이 손님에게 갔다.

404 가 두 종류였다

GET 은 이제 예외를 던진다. 그런데 PUTDELETE 는 아직 17강 · 16강의 코드 그대로다.

.orElseGet(() -> ResponseEntity.notFound().build());   // PUT
if (!repository.existsById(id)) return ResponseEntity.notFound().build();   // DELETE

상태코드는 404 로 맞다. 그런데 응답을 나란히 놓아보면 이상하다.

터미널 — 같은 404 인데
  • $ curl http://localhost:8080/books/99
  • {"status":404,"message":"99번 책을 찾을 수 없다"}
  • $ curl -X PUT http://localhost:8080/books/99 -d '...'
  • (본문이 비어 있다)
  • $ curl -X DELETE http://localhost:8080/books/99
  • (본문이 비어 있다)

손님 입장에서 같은 404 인데 어떤 것은 사유가 있고 어떤 것은 없다. 통일한다. 없으면 던진다.

@PutMapping("/{id}")
@Transactional
public BookResponse update(@PathVariable Long id, @Valid @RequestBody BookRequest request) {
    Book found = repository.findById(id)
            .orElseThrow(() -> new BookNotFoundException(id));
    found.changeTo(request.title(), request.price());
    return BookResponse.from(found);
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    if (!repository.existsById(id)) throw new BookNotFoundException(id);
    repository.deleteById(id);
    return ResponseEntity.noContent().build();
}

update 에서 ResponseEntity 가 통째로 사라졌다. map 사슬도 사라졌다. 없으면 던지고, 있으면 고친 것을 돌려준다. 그것뿐이다.

17강에서 map(...).orElseGet(...) 을 정성껏 쌓았던 것을 기억하는지. 그 코드가 틀렸던 게 아니다. 받을 사람이 없어서 그렇게 쌓아야 했던 것이다.

deleteResponseEntity 를 남겼다. 204 를 돌려줘야 하기 때문이다. 13강에서 정한 그대로다.

터미널 — 이제 네 자리가 같다
  • $ curl http://localhost:8080/books/99
  • {"status":404,"message":"99번 책을 찾을 수 없다"}
  • $ curl -X PUT http://localhost:8080/books/99 -d '{"title":"정상","price":10000}'
  • {"status":404,"message":"99번 책을 찾을 수 없다"}
  • $ curl -X DELETE http://localhost:8080/books/99
  • {"status":404,"message":"99번 책을 찾을 수 없다"}
  • $ curl http://localhost:8080/authors/99
  • {"status":404,"message":"99번 책을 찾을 수 없다"}

13강에서 세운 계약은 그대로다. 정상 POST201, DELETE204, 고치면 200 이다.

정리하면

예외는 던지는 자리와 받는 자리가 다르다. 컨트롤러는 던지고, 핸들러가 응답으로 옮긴다.

다시 짚어보기
  1. 01

    @ExceptionHandler 는 예외를 매핑한다

    @GetMapping 이 주소를 매핑하듯이, 이것은 예외를 매핑한다. 그 예외가 튀어나오면 이 메서드가 불린다.

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(BookNotFoundException e) {
        return ResponseEntity.status(404).body(new ErrorResponse(404, e.getMessage()));
    }
  2. 02

    컨트롤러 안에 두면 그 컨트롤러의 것이다

    같은 예외인데 옆 컨트롤러에서는 여전히 500 이 난다. 직접 확인했다.

  3. 03

    @RestControllerAdvice 로 빼면 전부 받는다

    메서드는 그대로고 자리만 옮겼다. 컨트롤러가 늘어도 이 파일 하나가 받는다.

    @RestControllerAdvice
    public class ApiExceptionHandler { ... }
  4. 04

    에러도 JSON 이다

    ErrorResponserecord 로 만들었다. 19강의 원칙이 실패한 응답에도 그대로 적용된다.

  5. 05

    20강의 message 가 손님에게 갔다

    MethodArgumentNotValidException 을 받아 getFieldErrors() 에서 문장을 꺼낸다. 여러 개일 때 오는 순서는 매번 다르다.

  6. 06

    컨트롤러에서 상태코드가 사라졌다

    one()update()ResponseEntity 가 없다. 없으면 던질 뿐이고, 숫자는 핸들러가 정한다.

실패도 약속대로 답한다

19강에서 문을 세웠고, 20강에서 문지기를 세웠고, 21강에서 사고가 났을 때 손님에게 뭐라고 말할지 정했다.

제대로 된 API — 세 강
  1. 19강DTO — 엔티티가 밖으로 안 나간다
  2. 20강@Valid — 이상한 값이 안 들어온다
  3. 21강핸들러 — 실패도 약속대로 답한다
3강에서 배운 상태코드의 약속을 이제 성공할 때도 실패할 때도 지킨다.

그런데 22강 제목에도 @Transactional 이 들어 있다. 18강에서 이미 배운 그것이다.

18강에서 update() 위에 @Transactional 을 붙이며 이렇게 적어뒀다.

22강에서 컨트롤러와 리포지토리 사이에 층을 하나 세우고, 이 표식을 거기로 옮긴다.

지금 우리 컨트롤러를 보면 이상한 것들이 붙어 있다. 요청을 받고 응답을 돌려주는 일만 해야 하는데, 트랜잭션을 열고 닫는다. 방금 에러 처리를 밖으로 뺐듯이, 이것도 있을 자리가 따로 있다.

22강에서 층을 하나 세운다. Part 5 의 마지막이다.

@ExceptionHandler — BookNotFoundException 이 404 가 된다 — 디코드랩(DCODELAB)