2023년 1월 1일
08:00 AM
Buffering ...

최근 글 👑

예외 처리 전략 @ControllerAdvice · @ExceptionHandler · 공통 에러 응답 설계

2026. 4. 20. 11:53ㆍ스프링
Today I Learned · 2026 · Spring Backend

예외 처리 전략
@ControllerAdvice · @ExceptionHandler · 공통 에러 응답 설계

스프링에서 예외를 잘 처리하는 법 — 흩어진 try-catch 없애고 전역 예외 처리로 일관성 있는 API 설계하기

Java Spring Boot REST API 예외처리 설계

01 스프링의 예외 처리 흐름

스프링 MVC에서 예외가 발생하면 DispatcherServlet이 이를 받아 HandlerExceptionResolver에게 처리를 위임합니다. 처리 우선순위는 다음과 같습니다.

Controller
예외 발생
Dispatcher
Servlet
Handler
ExceptionResolver
에러 응답
클라이언트
1
ExceptionHandlerExceptionResolver

@ExceptionHandler가 붙은 메서드를 찾아 처리. 가장 높은 우선순위. @ControllerAdvice도 여기서 처리.

2
ResponseStatusExceptionResolver

@ResponseStatus가 붙은 예외 클래스를 처리. 예외 클래스에 HTTP 상태코드를 직접 지정할 때 사용.

3
DefaultHandlerExceptionResolver

스프링 내부 표준 예외 처리. MethodArgumentNotValidException, HttpRequestMethodNotSupportedException 등을 처리.

⚠ 아무도 처리하지 않으면?
위 세 가지 Resolver가 모두 처리하지 못하면, 예외가 서블릿 컨테이너(WAS)까지 전파됩니다. 스프링 부트는 기본적으로 /error로 재요청을 보내 BasicErrorController가 처리하며, HTML 또는 JSON 에러 페이지를 반환합니다.

02 @ExceptionHandler — 컨트롤러 레벨

컨트롤러 클래스 내부에 @ExceptionHandler를 붙이면 해당 컨트롤러에서 발생한 예외만 처리합니다. 범위가 좁아 다른 컨트롤러에는 적용되지 않습니다.

@RestController @RequiredArgsConstructor public class MemberController { private final MemberService memberService; @GetMapping("/members/{id}") public MemberDto getMember(@PathVariable Long id) { return memberService.findById(id); // MemberNotFoundException 발생 가능 } // 이 컨트롤러에서 발생한 MemberNotFoundException만 처리 @ExceptionHandler(MemberNotFoundException.class) public ResponseEntity<ErrorResponse> handleMemberNotFound(MemberNotFoundException e) { return ResponseEntity .status(HttpStatus.NOT_FOUND) .body(new ErrorResponse("MEMBER_NOT_FOUND", e.getMessage())); } // 여러 예외를 하나의 핸들러로 처리 @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class}) public ResponseEntity<ErrorResponse> handleBadRequest(RuntimeException e) { return ResponseEntity.badRequest() .body(new ErrorResponse("BAD_REQUEST", e.getMessage())); } }Java
⚠ 컨트롤러 레벨의 한계
각 컨트롤러마다 동일한 예외 처리 코드를 반복해야 합니다. 10개의 컨트롤러가 있으면 같은 핸들러를 10번 작성해야 합니다. 실무에서는 컨트롤러 레벨 @ExceptionHandler는 거의 사용하지 않고, @ControllerAdvice로 전역 처리합니다.

03 @ControllerAdvice — 전역 예외 처리

@ControllerAdvice모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리하는 전역 예외 처리 클래스입니다. REST API에서는 @RestControllerAdvice(@ControllerAdvice + @ResponseBody)를 사용합니다.

@RestControllerAdvice // = @ControllerAdvice + @ResponseBody public class GlobalExceptionHandler { // 커스텀 비즈니스 예외 처리 @ExceptionHandler(BusinessException.class) public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) { ErrorCode errorCode = e.getErrorCode(); return ResponseEntity .status(errorCode.getStatus()) .body(ErrorResponse.of(errorCode)); } // 유효성 검증 실패 — @Valid @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) { List<String> errors = e.getBindingResult() .getFieldErrors() .stream() .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) .toList(); return ResponseEntity.badRequest() .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, errors)); } // 잘못된 인자 — PathVariable, RequestParam 타입 불일치 등 @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) { return ResponseEntity.badRequest() .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, e.getMessage())); } // 최후의 방어선 — 예상치 못한 모든 예외 @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleException(Exception e) { log.error("Unhandled exception: {}", e.getMessage(), e); // 반드시 로깅 return ResponseEntity.internalServerError() .body(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR)); } }Java

@ControllerAdvice 적용 범위 제한

// 특정 패키지 하위 컨트롤러에만 적용 @RestControllerAdvice("com.example.api") public class ApiExceptionHandler { ... } // 특정 컨트롤러 클래스에만 적용 @RestControllerAdvice(assignableTypes = {MemberController.class, OrderController.class}) public class SpecificExceptionHandler { ... } // 특정 어노테이션이 붙은 컨트롤러에만 적용 @RestControllerAdvice(annotations = RestController.class) public class RestExceptionHandler { ... }Java

04 커스텀 예외 클래스 설계

비즈니스 로직에서 발생하는 예외를 직접 정의해서 사용합니다. 계층 구조로 설계하면 전역 핸들러에서 최상위 예외 하나만 잡아도 하위 예외를 모두 처리할 수 있습니다.

// 1단계 — 최상위 비즈니스 예외 (모든 커스텀 예외의 부모) public class BusinessException extends RuntimeException { private final ErrorCode errorCode; public BusinessException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } public ErrorCode getErrorCode() { return errorCode; } } // 2단계 — 도메인별 구체 예외 (BusinessException 상속) public class MemberNotFoundException extends BusinessException { public MemberNotFoundException() { super(ErrorCode.MEMBER_NOT_FOUND); } } public class OrderNotFoundException extends BusinessException { public OrderNotFoundException() { super(ErrorCode.ORDER_NOT_FOUND); } } public class InsufficientStockException extends BusinessException { public InsufficientStockException() { super(ErrorCode.INSUFFICIENT_STOCK); } } // 서비스 레이어에서 사용 public Member findById(Long id) { return memberRepository.findById(id) .orElseThrow(MemberNotFoundException::new); }Java
예외 계층 구조
RuntimeException
BusinessException
← 전역 핸들러가 이것만 잡으면 됨
MemberNotFoundException
OrderNotFoundException
InsufficientStockException

05 공통 에러 응답 포맷 설계

API 응답에서 에러도 일관된 포맷을 갖춰야 클라이언트가 예측 가능하게 에러를 처리할 수 있습니다.

에러 응답 예시 (JSON)

// 단순 에러 { "code": "MEMBER_NOT_FOUND", "message": "회원을 찾을 수 없습니다.", "timestamp": "2025-04-20T10:30:00" } // 유효성 검증 실패 — 여러 필드 에러 포함 { "code": "INVALID_INPUT", "message": "입력값이 올바르지 않습니다.", "errors": [ { "field": "name", "message": "이름은 필수입니다." }, { "field": "age", "message": "나이는 0보다 커야 합니다." } ], "timestamp": "2025-04-20T10:30:00" }JSON

ErrorResponse 클래스

@Getter public class ErrorResponse { private final String code; private final String message; private final List<FieldError> errors; private final LocalDateTime timestamp; private ErrorResponse(ErrorCode errorCode, List<FieldError> errors) { this.code = errorCode.getCode(); this.message = errorCode.getMessage(); this.errors = errors; this.timestamp = LocalDateTime.now(); } // 단순 에러 public static ErrorResponse of(ErrorCode errorCode) { return new ErrorResponse(errorCode, List.of()); } // 유효성 검증 실패 (다중 필드 에러) public static ErrorResponse of(ErrorCode errorCode, List<String> errors) { List<FieldError> fieldErrors = errors.stream() .map(FieldError::new) .toList(); return new ErrorResponse(errorCode, fieldErrors); } @Getter @AllArgsConstructor public static class FieldError { private final String message; } }Java

06 ResponseEntityExceptionHandler

스프링이 기본 제공하는 ResponseEntityExceptionHandler를 상속하면 스프링 내부 표준 예외들을 직접 오버라이딩해서 공통 에러 포맷으로 통일할 수 있습니다.

@RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { // @Valid 실패 — MethodArgumentNotValidException 오버라이딩 @Override protected ResponseEntity<Object> handleMethodArgumentNotValid( MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { List<String> errors = ex.getBindingResult().getFieldErrors().stream() .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) .toList(); return ResponseEntity.badRequest() .body((Object) ErrorResponse.of(ErrorCode.INVALID_INPUT, errors)); } // 지원하지 않는 HTTP 메서드 — 405 @Override protected ResponseEntity<Object> handleHttpRequestMethodNotSupported( HttpRequestMethodNotSupportedException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { return ResponseEntity .status(HttpStatus.METHOD_NOT_ALLOWED) .body((Object) ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED)); } }Java
ResponseEntityExceptionHandler가 처리하는 주요 예외들
MethodArgumentNotValidException (@Valid 실패) · HttpRequestMethodNotSupportedException (405) · HttpMediaTypeNotSupportedException (415) · MissingServletRequestParameterException (필수 파라미터 누락) 등

07 유효성 검증 예외 처리 — @Valid

@Valid로 요청 DTO를 검증하면 실패 시 MethodArgumentNotValidException이 발생합니다. 전역 핸들러에서 이 예외를 잡아 어떤 필드가 왜 실패했는지 상세히 응답합니다.

// 요청 DTO — Bean Validation 어노테이션 @Getter public class MemberCreateRequest { @NotBlank(message = "이름은 필수입니다.") @Size(max = 20, message = "이름은 20자 이하여야 합니다.") private String name; @NotNull(message = "나이는 필수입니다.") @Min(value = 1, message = "나이는 1 이상이어야 합니다.") private Integer age; @Email(message = "올바른 이메일 형식이 아닙니다.") private String email; } // 컨트롤러 — @Valid 붙이면 자동 검증 @PostMapping("/members") public ResponseEntity<MemberDto> create(@RequestBody @Valid MemberCreateRequest request) { // 검증 실패 시 MethodArgumentNotValidException 발생 → 전역 핸들러로 이동 return ResponseEntity.ok(memberService.create(request)); } // 전역 핸들러에서 상세 에러 메시지 추출 @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) { List<ErrorResponse.FieldError> fieldErrors = e.getBindingResult() .getFieldErrors() .stream() .map(fe -> new ErrorResponse.FieldError( fe.getField(), fe.getRejectedValue(), fe.getDefaultMessage() )) .toList(); return ResponseEntity.badRequest() .body(ErrorResponse.ofFieldErrors(ErrorCode.INVALID_INPUT, fieldErrors)); }Java

08 실무 패턴 — ErrorCode Enum 전략

에러 코드를 Enum으로 관리하면 에러 코드와 메시지, HTTP 상태코드를 한 곳에서 관리할 수 있습니다. 흩어진 에러 정보를 응집시키는 실무 표준 패턴입니다.

@Getter @RequiredArgsConstructor public enum ErrorCode { // 공통 INVALID_INPUT (HttpStatus.BAD_REQUEST, "INVALID_INPUT", "입력값이 올바르지 않습니다."), METHOD_NOT_ALLOWED (HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "지원하지 않는 HTTP 메서드입니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_ERROR", "서버 내부 오류가 발생했습니다."), // 회원 MEMBER_NOT_FOUND (HttpStatus.NOT_FOUND, "MEMBER_NOT_FOUND", "회원을 찾을 수 없습니다."), DUPLICATE_EMAIL (HttpStatus.CONFLICT, "DUPLICATE_EMAIL", "이미 사용 중인 이메일입니다."), // 주문 ORDER_NOT_FOUND (HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "주문을 찾을 수 없습니다."), INSUFFICIENT_STOCK (HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "재고가 부족합니다."), ALREADY_CANCELLED (HttpStatus.BAD_REQUEST, "ALREADY_CANCELLED", "이미 취소된 주문입니다."); private final HttpStatus status; private final String code; private final String message; }Java

전체 흐름 — 한눈에 보기

Service Layer
throw new MemberNotFoundException()
→ ErrorCode.MEMBER_NOT_FOUND 보유
GlobalExceptionHandler
handleBusinessException(e)
→ e.getErrorCode() 추출
HTTP 응답
404 NOT_FOUND
ErrorResponse
✅ ErrorCode Enum 패턴의 장점
HTTP 상태코드, 에러 코드 문자열, 에러 메시지가 한 곳(Enum)에 응집되어 있어 새 에러를 추가할 때 Enum에 한 줄만 추가하면 됩니다. 에러 코드가 흩어지지 않아 관리가 쉽고, 프론트엔드와 에러 코드 문서를 공유하기도 편합니다.

09 면접 예상 질문

Q1. @ControllerAdvice와 @ExceptionHandler의 차이점은?

@ExceptionHandler는 특정 컨트롤러 내부에 선언하면 그 컨트롤러에서 발생한 예외만 처리합니다. @ControllerAdvice는 모든 컨트롤러에 적용되는 전역 예외 처리 클래스에 선언합니다. REST API에서는 @RestControllerAdvice를 사용하며, 실무에서는 전역 예외 처리 클래스 하나에 모든 예외 핸들러를 모아 관리하는 것이 표준입니다.

Q2. 커스텀 예외를 만드는 이유와 설계 방법을 설명하세요.

커스텀 예외를 사용하면 비즈니스 규칙 위반을 명확히 표현할 수 있고, 예외 이름 자체가 문서가 됩니다. 설계는 계층 구조로 합니다. 모든 비즈니스 예외의 부모인 BusinessException을 만들고 내부에 ErrorCode를 포함시킵니다. 도메인별 구체 예외는 BusinessException을 상속하면, 전역 핸들러에서 BusinessException 하나만 잡아도 모든 비즈니스 예외를 처리할 수 있습니다.

Q3. 스프링 예외 처리 우선순위를 설명하세요.

세 단계입니다. 첫째 ExceptionHandlerExceptionResolver@ExceptionHandler@ControllerAdvice를 처리합니다. 둘째 ResponseStatusExceptionResolver@ResponseStatus가 붙은 예외를 처리합니다. 셋째 DefaultHandlerExceptionResolver — 스프링 표준 예외를 처리합니다. 세 가지 모두 처리하지 못하면 서블릿 컨테이너로 전파됩니다.

Q4. @Valid 검증 실패 시 어떻게 처리하나요?

@Valid 검증이 실패하면 MethodArgumentNotValidException이 발생합니다. 전역 핸들러에서 이 예외를 잡아 getBindingResult().getFieldErrors()로 실패한 필드 목록을 추출합니다. 어떤 필드가 왜 실패했는지 상세 정보를 포함한 400 응답을 반환합니다. ResponseEntityExceptionHandler를 상속하면 handleMethodArgumentNotValid를 오버라이딩해서 공통 포맷으로 통일할 수 있습니다.

Q5. ErrorCode를 Enum으로 관리하는 이유는?

HTTP 상태코드, 에러 코드 문자열, 에러 메시지 세 가지 정보가 Enum 하나에 응집되어 있어 관리가 편합니다. 새로운 에러를 추가할 때 Enum에 한 줄만 추가하면 되고, 에러 코드가 여러 파일에 흩어지는 것을 방지합니다. 또한 컴파일 타임에 유효하지 않은 에러 코드 사용을 막을 수 있고, 프론트엔드와 에러 코드를 문서로 공유하기도 편합니다.
핵심 요약
  • 예외 처리 우선순위 — ExceptionHandler → ResponseStatus → Default 순. 모두 처리 못하면 서블릿 컨테이너로 전파
  • @RestControllerAdvice — 전역 예외 처리의 표준. 컨트롤러 레벨 @ExceptionHandler는 실무에서 거의 안 씀
  • 커스텀 예외 계층 구조 — BusinessException(부모) → 도메인별 예외(자식). 전역 핸들러는 부모만 잡으면 됨
  • ErrorCode Enum — HTTP 상태코드 + 에러 코드 + 메시지를 한 곳에 응집. 새 에러 추가 시 Enum 한 줄만 추가
  • @Valid 검증 실패 — MethodArgumentNotValidException 발생 → getFieldErrors()로 필드별 에러 추출
  • 최후 방어선 — Exception.class 핸들러에서 반드시 로깅 + 500 응답. 내부 에러 정보는 외부에 노출 금지
오늘의 핵심 — try-catch를 컨트롤러에 흩뿌리지 말고, @RestControllerAdvice 하나에 모아라. ErrorCode Enum으로 에러를 응집시키면 유지보수가 훨씬 쉬워진다.
728x90