자바 백엔드 입문 33편. Phase 4 Web MVC 마무리. @ExceptionHandler·@ControllerAdvice로 컨트롤러 예외를 일관된 JSON 오류 응답으로 변환하는 표준 패턴을 호텔 컴플레인 데스크 비유로 풀어쓴 학습 노트.
이 글은 자바 백엔드 입문 시리즈 59편 중 33편이에요. Phase 4 Web MVC 마지막 글입니다. 29편까지 "요청을 받고 응답을 보내는" 흐름을 다 다뤘는데, 이번 33편은 그 흐름 중에 "예외가 터졌을 때 어떻게 처리하는가" 의 표준 패턴을 들여다봅니다.
예외 처리가 헷갈리는 이유
처음 컨트롤러를 짜다 보면 — orderService.findById(id) 호출에서 "주문 없음" 예외가 터졌을 때 어떻게 클라이언트에 알릴지가 안 잡혀요. 컨트롤러마다 try-catch 박으면 코드가 너무 더러워지고, 안 박으면 500 에러가 그대로 나가요.
이 글에서는 호텔 컴플레인 데스크 비유로 풀어요. 모든 객실에서 일어난 문제(예외)를 — 객실 매니저(컨트롤러)가 직접 처리하지 않고 — 별도 컴플레인 데스크(@ControllerAdvice) 한 곳에 모아 일관된 응답으로 처리하는 그림. 끝까지 따라오시면 한국 회사 백엔드의 예외 처리 표준 골격이 한 번에 들어와요.
예외를 그냥 두면 어떻게 되나
먼저 "예외 처리 안 했을 때" 의 디폴트 동작.
@GetMapping("/orders/{id}")
public Order get(@PathVariable Long id) {
return orderService.findById(id); // 없으면 NoSuchElementException
}
NoSuchElementException 이 터지면 — Spring Boot 기본 처리기가 받아서 500 Internal Server Error + Whitelabel 페이지를 응답해요. 클라이언트가 보기엔 "서버가 망가졌나?". 실제로는 "주문이 없다" 라는 정상 케이스인데도.
문제 — 모든 예외가 500으로 통합 되어 클라이언트가 원인을 못 알아요. "주문 없음" 은 404, "권한 없음" 은 403, "검증 실패" 는 400처럼 — 상황에 맞는 상태 코드 + 일관된 JSON 오류 응답이 필요해요.
@ExceptionHandler — 컨트롤러 안에서 예외 처리
가장 기본 패턴. @ExceptionHandler 메서드를 컨트롤러 안에 박아 "이 컨트롤러 안에서 이 예외 터지면 이 메서드로 처리해" 를 선언.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
public Order get(@PathVariable Long id) {
return orderService.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException e) {
ErrorResponse body = new ErrorResponse("ORDER_NOT_FOUND", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
}
이렇게 박아두면 — OrderNotFoundException 이 발생할 때마다 자동으로 404 + JSON 오류 응답이 나가요. 컨트롤러 메서드 안에 try-catch를 박을 필요 없어요. Spring이 알아서 가로채 핸들러를 호출.
다만 한계 — 이 컨트롤러 안에서만 동작해요. 다른 컨트롤러에서 같은 예외가 터지면 또 핸들러를 박아야 함. 회사 시스템에 컨트롤러 50개 있으면 50번 박는 셈.
@ControllerAdvice — 글로벌 예외 처리
해결책이 @ControllerAdvice. "모든 컨트롤러에 공통으로 적용되는 예외 처리 컴포넌트" 선언.
@RestControllerAdvice // @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException e) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("ORDER_NOT_FOUND", e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class) // @Valid 실패
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getAllErrors().stream()
.map(err -> err.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("VALIDATION_FAILED", message));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccess(AccessDeniedException e) {
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(new ErrorResponse("ACCESS_DENIED", "권한이 없습니다"));
}
@ExceptionHandler(Exception.class) // 모든 나머지
public ResponseEntity<ErrorResponse> handleUnknown(Exception e) {
log.error("Unhandled exception", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "서버 오류"));
}
}
이 클래스 한 개로 모든 컨트롤러의 예외가 한 곳에서 처리돼요. 새 컨트롤러를 추가해도 이 글로벌 핸들러가 자동 적용. 컴플레인 데스크 비유 정확히 일치.
@RestControllerAdvice = @ControllerAdvice + @ResponseBody. JSON 응답 반환에 표준.
예외 클래스 직접 만들기
비즈니스 예외는 별도 클래스로 정의하는 게 표준이에요.
// 1. 비즈니스 예외 정의
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("주문을 찾을 수 없습니다: " + id);
}
}
// 2. 서비스에서 throw
@Service
public class OrderService {
public Order findById(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
// 3. 글로벌 핸들러에서 처리 (위 GlobalExceptionHandler)
RuntimeException 상속이 표준. Checked Exception(Exception 직접 상속)은 매번 throws 선언이 필요해서 코드가 더러워지고, Spring 트랜잭션 롤백 기본 동작도 RuntimeException 기반이라.
@ResponseStatus로 간단 처리
예외 클래스 자체에 @ResponseStatus 한 줄 박으면 — 따로 핸들러 없이도 상태 코드 자동 매핑.
@ResponseStatus(HttpStatus.NOT_FOUND)
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("주문을 찾을 수 없습니다: " + id);
}
}
이 예외가 터지면 — Spring이 자동으로 404 응답. 다만 응답 본문 포맷을 일관되게 만들기 어려워서, 실무는 거의 @RestControllerAdvice + ResponseEntity<ErrorResponse> 패턴.
ErrorResponse DTO — 일관된 오류 응답 포맷
회사 시스템 표준은 "모든 오류 응답이 동일한 JSON 구조" 여야 클라이언트가 처리하기 쉬워요. 보통 다음 같은 DTO.
@Getter
@AllArgsConstructor
public class ErrorResponse {
private String code; // "ORDER_NOT_FOUND" 같은 식별자
private String message; // 사람이 읽는 메시지
private LocalDateTime timestamp = LocalDateTime.now();
private List<FieldError> fieldErrors; // 검증 실패 시 필드별
@Getter @AllArgsConstructor
public static class FieldError {
private String field;
private String message;
}
}
// JSON 응답:
// {
// "code": "ORDER_NOT_FOUND",
// "message": "주문을 찾을 수 없습니다: 123",
// "timestamp": "2026-05-16T12:30:45",
// "fieldErrors": null
// }
코드(ORDER_NOT_FOUND)는 "클라이언트가 분기 로직에 쓰는 식별자", 메시지는 "사람이 화면에 표시". 두 가지를 분리하는 게 표준.
Spring 6의 ProblemDetail — RFC 7807 표준
Spring Framework 6(Spring Boot 3) 부터 ProblemDetail 이라는 RFC 7807 표준 응답 형식을 지원해요. 직접 ErrorResponse를 만들지 않아도 표준 형식 사용 가능.
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
pd.setTitle("Order Not Found");
pd.setType(URI.create("/errors/order-not-found"));
return pd;
}
// JSON 응답:
// {
// "type": "/errors/order-not-found",
// "title": "Order Not Found",
// "status": 404,
// "detail": "주문을 찾을 수 없습니다: 123"
// }
RFC 7807 표준이라 — "국제 표준 호환 API" 를 만들 때 유리. 다만 한국 회사 백엔드는 자체 ErrorResponse DTO를 쓰는 곳이 여전히 많아요.
예외 처리 우선순위 — 가장 구체적인 게 이김
@ControllerAdvice 에 여러 @ExceptionHandler 가 박혀 있을 때, Spring이 가장 구체적인 예외 타입과 매칭되는 핸들러를 골라요.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class) // 1순위 — 정확
public ... handleSpecific(...) { ... }
@ExceptionHandler(RuntimeException.class) // 2순위 — 부모
public ... handleRuntime(...) { ... }
@ExceptionHandler(Exception.class) // 3순위 — 최상위
public ... handleAll(...) { ... }
}
OrderNotFoundException 터지면 — 1순위가 맞으면 1순위로 처리. 매칭되는 핸들러 없으면 부모 클래스 핸들러로 fallback. 마지막 안전망으로 Exception.class 박아 "예상 못 한 예외도 잡기" 가 표준 패턴.
5XX 서버 오류 핸들러(Exception.class)에서는 반드시 로그를 남기세요. 클라이언트엔 일반화된 메시지만 보내고, 실제 스택 트레이스는 서버 로그에 남겨야 디버깅 가능. log.error("Unhandled", e) 한 줄이 시스템 트러블슈팅의 생명선.
한국 회사 표준 패턴 — 그림으로 정리
지금까지 다룬 모든 게 합쳐진 표준 골격.
// 1. 비즈니스 예외
public class OrderNotFoundException extends RuntimeException { ... }
// 2. 오류 응답 DTO
public class ErrorResponse { ... }
// 3. 서비스
@Service
public class OrderService {
public Order findById(Long id) {
return repo.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
}
}
// 4. 컨트롤러 — try-catch 없음, 깨끗
@RestController
public class OrderController {
@GetMapping("/{id}")
public Order get(@PathVariable Long id) {
return orderService.findById(id);
}
}
// 5. 글로벌 예외 핸들러
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(...) { ... }
// ... 다른 예외들
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnknown(Exception e) {
log.error("Unhandled", e);
return ResponseEntity.status(500).body(...);
}
}
5단계 패턴. 컨트롤러에서 예외 처리 코드 없음 이 핵심. 글로벌 핸들러가 다 처리.
한 줄 정리 — @ExceptionHandler 메서드 + @RestControllerAdvice 글로벌 핸들러로 모든 예외를 한 곳에서 일관된 JSON 응답으로 변환. 컨트롤러는 깨끗하게.
시험 직전 한 번 더 — 예외 처리 입문자가 매번 헷갈리는 것
- 예외 처리 안 하면 = 500 Internal Server Error + Whitelabel 페이지 기본
@ExceptionHandler= 한 컨트롤러 안 예외 처리@ControllerAdvice= 모든 컨트롤러에 공통 적용되는 예외 처리@RestControllerAdvice=@ControllerAdvice+@ResponseBody(JSON 반환)- 한국 회사 표준 =
@RestControllerAdvice패턴 - 비즈니스 예외는
RuntimeException상속 표준 - Checked Exception(
Exception직접 상속) 비권장 @ResponseStatus= 예외 클래스에 상태 코드 매핑ResponseEntity<ErrorResponse>반환이 가장 유연- ErrorResponse DTO =
code+message+timestamp+fieldErrors - code (식별자) ≠ message (사람 메시지) — 분리
- Spring Boot 3 / Spring Framework 6 =
ProblemDetail(RFC 7807) 지원 - 예외 처리 우선순위 = 가장 구체적인 예외 타입이 먼저
- 마지막 안전망으로
Exception.class핸들러 필수 - 5XX 핸들러에서는 반드시 로그 남기기 (
log.error("...", e)) - 클라이언트엔 일반 메시지, 서버 로그엔 스택 트레이스
@Valid실패 =MethodArgumentNotValidException(전용 핸들러)- Spring Security
AccessDeniedException도 따로 처리 - 글로벌 핸들러가 있어도 컨트롤러별
@ExceptionHandler가 우선 - 컨트롤러에 try-catch 박지 X — 예외는 throw, 핸들러가 처리
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 28편 — @RestController와 JSON 응답
- 29편 — RequestParam PathVariable RequestBody
- 30편 — ArgumentResolver와 @LoginUser
- 31편 — 파일 업로드 @RequestPart MultipartFile
- 32편 — CORS 설정
다음 글: