API는 항상 성공만 하지 않아요. 잘못된 요청·없는 데이터엔 적절한 상태코드와 메시지로 답해야 해요. 이번 장은 예외 처리(@ExceptionHandler·@ControllerAdvice)와 검증(@Valid)을 다뤄요. CH05의 HTTP 상태코드와 자연스럽게 이어져요.
API는 항상 성공하지 않아요. 없는 회원을 조회하거나, 필수 값이 빠진 요청이 들어올 수 있어요. 이럴 때 서버는 적절한 에러 응답(상태코드 + 메시지)을 줘야 클라이언트가 무엇이 잘못됐는지 알 수 있어요.
IOException), 언체크 예외는 처리를 강제하지 않는 예외(예: IllegalArgumentException·RuntimeException 계열)예요. 스프링 웹에서 우리가 직접 만드는 예외는 대개 언체크 예외로 두고, 아래에서 배울 방법으로 잡아서 응답으로 바꿔요. 지금은 "예외에도 종류가 있구나" 정도만 알면 충분해요.앞에서 "예외를 잡아 알맞게 응답하자"고 했죠. 그 "잡는 손"이 바로 @ExceptionHandler예요. 특정 예외가 터졌을 때 "그 예외는 내가 맡을게" 하고 나서서, 우리가 정한 상태코드와 메시지로 바꿔 돌려줘요.
@ExceptionHandler(예외.class)라고 적으면 "이 예외가 나면 이 메서드를 대신 실행해줘"라는 뜻이 돼요. 놀이공원에서 "미아 발생 시 → 미아보호소 담당자가 대응"처럼, 특정 상황에 나설 전담 담당자를 지정해두는 셈이에요.UserNotFoundException이 나면 404를 반환하도록 만들 수 있어요.
@RestController
public class UserController {
@GetMapping("/users/{id}")
public User find(@PathVariable Long id) {
// 없으면 예외 발생
return userService.findById(id);
}
// UserNotFoundException이 나면 → 404로 변환
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<String> handleNotFound(UserNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND) // 404
.body(e.getMessage());
}
}
UserNotFoundException처럼 상황을 콕 집어 우리가 직접 만든 예외예요. 이름만 봐도 "회원을 못 찾음"을 알 수 있어, 어떤 상태코드로 바꿀지 정하기 쉬워요.ResponseEntity.status(404).body("...")처럼 원하는 코드와 메시지를 정확히 지정해 돌려줘요.@ExceptionHandler(예외.class)가 붙은 메서드는 그 예외가 발생했을 때만 실행돼요. 여기서 ResponseEntity로 상태코드(404)와 메시지를 직접 정해 돌려줘요.그런데 컨트롤러가 수십 개라면, 매번 같은 @ExceptionHandler를 복사해 붙여야 할까요? 그러면 코드가 중복되고, 규칙을 바꿀 때 수십 군데를 다 고쳐야 해요. 그래서 예외 처리만 모아두는 전용 창구를 하나 만드는데, 그게 @ControllerAdvice예요.
@ControllerAdvice를 붙인 클래스가 바로 그 고객센터예요. 모든 컨트롤러의 예외를 이 한 곳으로 모아 처리해요.@ControllerAdvice에 "응답을 JSON으로 만들어 줘"(@ResponseBody)가 더해진 버전이에요. 요즘처럼 화면(HTML) 대신 데이터(JSON)를 주고받는 REST API에서는 이걸 주로 써요.@ExceptionHandler를 컨트롤러마다 똑같이 쓰면 중복이 심해져요. @ControllerAdvice(또는 @RestControllerAdvice)를 붙인 클래스에 모아두면, 모든 컨트롤러에 공통으로 적용되는 전역 예외 처리기가 돼요.
@RestControllerAdvice // 전역 예외 처리 (모든 컨트롤러 공통)
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<String> notFound(UserNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage()); // 404
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> badRequest(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage()); // 400
}
}
이렇게 하면 컨트롤러마다 예외 처리를 반복하지 않아요. 한 곳에서 정의하면 앱 전체에 적용돼 중복이 사라지고 일관성이 생겨요.
| 어노테이션 | 적용 범위 |
|---|---|
| @ExceptionHandler | 해당 컨트롤러 안에서만 예외 처리. |
| @ControllerAdvice | 모든 컨트롤러 공통(전역). 뷰(HTML) 반환에 적합. |
| @RestControllerAdvice | 전역 + JSON 응답용(@ControllerAdvice + @ResponseBody). REST API에 적합. |
지금까지는 이미 터진 문제(예외)를 수습하는 이야기였어요. 이제는 방향을 바꿔서, 아예 문제가 될 입력을 입구에서 막는 이야기예요. 공연장 입구의 검표원을 떠올려봐요. 표가 없거나 좌석이 틀린 사람을 입장 전에 걸러내면, 공연장 안은 늘 질서가 유지되죠. 검증(Validation)이 바로 이 검표원 역할이에요.
@Valid를 붙이면 스프링이 자동으로 검증해줘요.
public class UserRequest {
@NotBlank // 빈 문자열/공백 불가
private String name;
@Email // 이메일 형식이어야 함
private String email;
@Size(min = 8, max = 20) // 길이 제약
private String password;
}
@NotNull·@NotBlank·@Size·@Email 같은 어노테이션으로 선언하는 표준 방식이고, @Valid는 "이 규칙들로 검사해줘"라고 스프링에게 시키는 스위치예요.@Valid를 붙이면, 요청이 규칙을 어겼을 때 스프링이 검증 실패로 처리하고 400 Bad Request를 돌려줘요.
@PostMapping("/users")
public User create(@Valid @RequestBody UserRequest request) {
// 여기 도달하면 이미 검증을 통과한 상태
return userService.save(request);
}
// name이 비었거나 email 형식이 틀리면 → 400 (Bad Request)
즉 잘못된 입력은 비즈니스 로직에 닿기 전에 걸러지고, 클라이언트는 "요청이 잘못됐다"는 400을 받아요.
@Valid 뒤에 BindingResult 파라미터를 함께 두면, 스프링이 바로 400을 던지지 않고 "어떤 필드가 왜 틀렸는지"를 이 상자에 모아 넘겨줘요. 그러면 개발자가 그 내용을 꺼내 직접 응답 메시지를 꾸밀 수 있어요. 검표원이 "왜 입장 불가인지" 사유를 적어 건네주는 메모지라고 생각하면 돼요.| 제약 어노테이션 | 의미 |
|---|---|
| @NotNull | null이면 안 됨. |
| @NotBlank | null·빈 문자열·공백이면 안 됨(문자열용). |
| @Size(min, max) | 길이(문자열·컬렉션)가 범위 안이어야 함. |
| 이메일 형식이어야 함. |
| 상황 | 돌려줄 상태코드 |
|---|---|
| 정상 처리 | 200 OK / 201 Created |
| 검증 실패(필수 값 누락 등) | 400 Bad Request |
| 없는 자원 조회 | 404 Not Found |
| 중복 등 충돌 | 409 Conflict |