예외 처리 전략
@ControllerAdvice · @ExceptionHandler · 공통 에러 응답 설계
스프링에서 예외를 잘 처리하는 법 — 흩어진 try-catch 없애고 전역 예외 처리로 일관성 있는 API 설계하기
01 스프링의 예외 처리 흐름
스프링 MVC에서 예외가 발생하면 DispatcherServlet이 이를 받아 HandlerExceptionResolver에게 처리를 위임합니다. 처리 우선순위는 다음과 같습니다.
예외 발생
Servlet
ExceptionResolver
클라이언트
@ExceptionHandler가 붙은 메서드를 찾아 처리. 가장 높은 우선순위. @ControllerAdvice도 여기서 처리.
@ResponseStatus가 붙은 예외 클래스를 처리. 예외 클래스에 HTTP 상태코드를 직접 지정할 때 사용.
스프링 내부 표준 예외 처리. 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 { ... }Java04 커스텀 예외 클래스 설계
비즈니스 로직에서 발생하는 예외를 직접 정의해서 사용합니다. 계층 구조로 설계하면 전역 핸들러에서 최상위 예외 하나만 잡아도 하위 예외를 모두 처리할 수 있습니다.
// 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);
}Java05 공통 에러 응답 포맷 설계
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"
}JSONErrorResponse 클래스
@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;
}
}Java06 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));
}
}JavaMethodArgumentNotValidException (@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));
}Java08 실무 패턴 — 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전체 흐름 — 한눈에 보기
HTTP 상태코드, 에러 코드 문자열, 에러 메시지가 한 곳(Enum)에 응집되어 있어 새 에러를 추가할 때 Enum에 한 줄만 추가하면 됩니다. 에러 코드가 흩어지지 않아 관리가 쉽고, 프론트엔드와 에러 코드 문서를 공유하기도 편합니다.
09 면접 예상 질문
Q1. @ControllerAdvice와 @ExceptionHandler의 차이점은?
@ExceptionHandler는 특정 컨트롤러 내부에 선언하면 그 컨트롤러에서 발생한 예외만 처리합니다. @ControllerAdvice는 모든 컨트롤러에 적용되는 전역 예외 처리 클래스에 선언합니다. REST API에서는 @RestControllerAdvice를 사용하며, 실무에서는 전역 예외 처리 클래스 하나에 모든 예외 핸들러를 모아 관리하는 것이 표준입니다.Q2. 커스텀 예외를 만드는 이유와 설계 방법을 설명하세요.
BusinessException을 만들고 내부에 ErrorCode를 포함시킵니다. 도메인별 구체 예외는 BusinessException을 상속하면, 전역 핸들러에서 BusinessException 하나만 잡아도 모든 비즈니스 예외를 처리할 수 있습니다.Q3. 스프링 예외 처리 우선순위를 설명하세요.
@ExceptionHandler와 @ControllerAdvice를 처리합니다. 둘째 ResponseStatusExceptionResolver — @ResponseStatus가 붙은 예외를 처리합니다. 셋째 DefaultHandlerExceptionResolver — 스프링 표준 예외를 처리합니다. 세 가지 모두 처리하지 못하면 서블릿 컨테이너로 전파됩니다.Q4. @Valid 검증 실패 시 어떻게 처리하나요?
@Valid 검증이 실패하면 MethodArgumentNotValidException이 발생합니다. 전역 핸들러에서 이 예외를 잡아 getBindingResult().getFieldErrors()로 실패한 필드 목록을 추출합니다. 어떤 필드가 왜 실패했는지 상세 정보를 포함한 400 응답을 반환합니다. ResponseEntityExceptionHandler를 상속하면 handleMethodArgumentNotValid를 오버라이딩해서 공통 포맷으로 통일할 수 있습니다.Q5. ErrorCode를 Enum으로 관리하는 이유는?
- 예외 처리 우선순위 — ExceptionHandler → ResponseStatus → Default 순. 모두 처리 못하면 서블릿 컨테이너로 전파
- @RestControllerAdvice — 전역 예외 처리의 표준. 컨트롤러 레벨 @ExceptionHandler는 실무에서 거의 안 씀
- 커스텀 예외 계층 구조 — BusinessException(부모) → 도메인별 예외(자식). 전역 핸들러는 부모만 잡으면 됨
- ErrorCode Enum — HTTP 상태코드 + 에러 코드 + 메시지를 한 곳에 응집. 새 에러 추가 시 Enum 한 줄만 추가
- @Valid 검증 실패 — MethodArgumentNotValidException 발생 → getFieldErrors()로 필드별 에러 추출
- 최후 방어선 — Exception.class 핸들러에서 반드시 로깅 + 500 응답. 내부 에러 정보는 외부에 노출 금지
'스프링' 카테고리의 다른 글
| Spring Bean 생명주기 (0) | 2026.04.27 |
|---|---|
| Spring MVC 동작 원리 (0) | 2026.04.22 |
| JPQL & QueryDSL객체지향 쿼리부터 동적 쿼리까지 (1) | 2026.04.17 |
| @Transactional 동작 원리 / AOP 프록시 · self-invocation · readOnly 최적화 (0) | 2026.04.16 |
| @Transactional 동작 원리AOP 프록시 · self-invocation · readOnly 최적화 (0) | 2026.04.15 |