@ExceptionHandler — BookNotFoundException 이 404 가 된다
Article
빚이 셋 쌓였다.
| 강의 | 무슨 일이 있었나 | 그때 뭐라고 했나 |
|---|---|---|
| 12강 | /books/99 가 IndexOutOfBoundsException 으로 500 | 구멍만 짚고 넘어갔다 |
| 17강 | orElseThrow 를 쓰면 500 | orElseGet 으로 되돌렸다 |
| 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강에서 본 그림이다. 부른 순서를 거슬러 올라간다.
- 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 을 냈던 그 코드다. 갚았다.
- one()없으면 BookNotFoundException 을 던진다
- Spring컨트롤러에서 예외가 튀어나온다. 받을 사람을 찾는다
- @ExceptionHandler이 예외를 받겠다고 적어둔 메서드
- 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 다.
당연하다. @ExceptionHandler 를 BookController 안에 적었다. 그 컨트롤러의 것이다. AuthorController 는 자기 핸들러가 없으니 예외가 밖으로 새어 나가고, Spring 이 500 을 만든다.
고치는 방법은 둘이다. AuthorController 에도 같은 메서드를 복사하거나 — 컨트롤러 밖으로 빼거나.
20강에서 같은 갈림길을 봤다. 검사 규칙을 POST 와 PUT 에 각각 복사할 것인가, 한 곳에 적을 것인가.
밖으로 뺀다
파일을 하나 만든다.
@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강이 남긴 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 은 이제 예외를 던진다. 그런데 PUT 과 DELETE 는 아직 17강 · 16강의 코드 그대로다.
.orElseGet(() -> ResponseEntity.notFound().build()); // PUT
if (!repository.existsById(id)) return ResponseEntity.notFound().build(); // DELETE
상태코드는 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(...) 을 정성껏 쌓았던 것을 기억하는지. 그 코드가 틀렸던 게 아니다. 받을 사람이 없어서 그렇게 쌓아야 했던 것이다.
delete 는 ResponseEntity 를 남겼다. 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강에서 세운 계약은 그대로다. 정상 POST 는 201, DELETE 는 204, 고치면 200 이다.
정리하면
예외는 던지는 자리와 받는 자리가 다르다. 컨트롤러는 던지고, 핸들러가 응답으로 옮긴다.
- 01
@ExceptionHandler는 예외를 매핑한다@GetMapping이 주소를 매핑하듯이, 이것은 예외를 매핑한다. 그 예외가 튀어나오면 이 메서드가 불린다.@ExceptionHandler(BookNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(BookNotFoundException e) { return ResponseEntity.status(404).body(new ErrorResponse(404, e.getMessage())); } - 02
컨트롤러 안에 두면 그 컨트롤러의 것이다
같은 예외인데 옆 컨트롤러에서는 여전히
500이 난다. 직접 확인했다. - 03
@RestControllerAdvice로 빼면 전부 받는다메서드는 그대로고 자리만 옮겼다. 컨트롤러가 늘어도 이 파일 하나가 받는다.
@RestControllerAdvice public class ApiExceptionHandler { ... } - 04
에러도 JSON 이다
ErrorResponse를record로 만들었다. 19강의 원칙이 실패한 응답에도 그대로 적용된다. - 05
20강의
message가 손님에게 갔다MethodArgumentNotValidException을 받아getFieldErrors()에서 문장을 꺼낸다. 여러 개일 때 오는 순서는 매번 다르다. - 06
컨트롤러에서 상태코드가 사라졌다
one()과update()에ResponseEntity가 없다. 없으면 던질 뿐이고, 숫자는 핸들러가 정한다.
실패도 약속대로 답한다
19강에서 문을 세웠고, 20강에서 문지기를 세웠고, 21강에서 사고가 났을 때 손님에게 뭐라고 말할지 정했다.
- 19강DTO — 엔티티가 밖으로 안 나간다
- 20강@Valid — 이상한 값이 안 들어온다
- 21강핸들러 — 실패도 약속대로 답한다
그런데 22강 제목에도 @Transactional 이 들어 있다. 18강에서 이미 배운 그것이다.
18강에서 update() 위에 @Transactional 을 붙이며 이렇게 적어뒀다.
22강에서 컨트롤러와 리포지토리 사이에 층을 하나 세우고, 이 표식을 거기로 옮긴다.
지금 우리 컨트롤러를 보면 이상한 것들이 붙어 있다. 요청을 받고 응답을 돌려주는 일만 해야 하는데, 트랜잭션을 열고 닫는다. 방금 에러 처리를 밖으로 뺐듯이, 이것도 있을 자리가 따로 있다.
22강에서 층을 하나 세운다. Part 5 의 마지막이다.