Spring 예외 처리 전략이 중요한 이유
REST API에서 일관된 에러 응답은 클라이언트 개발 경험을 좌우합니다. Spring Boot는 @ControllerAdvice와 @ExceptionHandler를 통해 전역 예외 처리를 제공하며, Spring Boot 3.x에서는 RFC 7807 ProblemDetail을 네이티브로 지원합니다.
이 글에서는 예외 처리 아키텍처, 커스텀 예외 계층 설계, ProblemDetail 활용, Bean Validation 에러 포맷팅, 그리고 실전 운영 패턴을 다룹니다.
@ControllerAdvice 기본 구조
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 특정 예외 타입 처리
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ProblemDetail> handleNotFound(ResourceNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("리소스를 찾을 수 없습니다");
problem.setProperty("resourceType", ex.getResourceType());
problem.setProperty("resourceId", ex.getResourceId());
return ResponseEntity.of(problem).build();
}
// 여러 예외를 하나의 핸들러로
@ExceptionHandler({
IllegalArgumentException.class,
IllegalStateException.class
})
public ResponseEntity<ProblemDetail> handleBadRequest(RuntimeException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, ex.getMessage());
problem.setTitle("잘못된 요청");
return ResponseEntity.of(problem).build();
}
// 최종 폴백: 처리되지 않은 모든 예외
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Exception ex) {
log.error("예상치 못한 오류 발생", ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다");
problem.setTitle("Internal Server Error");
// 스택트레이스는 절대 클라이언트에 노출하지 않음
return ResponseEntity.of(problem).build();
}
}
커스텀 예외 계층 설계
비즈니스 예외를 체계적으로 관리하려면 계층 구조가 필요합니다.
// 최상위 비즈니스 예외
@Getter
public abstract class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
protected BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
protected BusinessException(ErrorCode errorCode, String detail) {
super(detail);
this.errorCode = errorCode;
}
}
// ErrorCode Enum
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
// 공통
INVALID_INPUT(HttpStatus.BAD_REQUEST, "C001", "잘못된 입력값"),
RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "C002", "리소스를 찾을 수 없음"),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "C003", "인증 필요"),
FORBIDDEN(HttpStatus.FORBIDDEN, "C004", "권한 부족"),
// 유저 도메인
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없음"),
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "U002", "이미 사용 중인 이메일"),
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U003", "비밀번호 조건 불일치"),
// 주문 도메인
ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "O001", "주문을 찾을 수 없음"),
INSUFFICIENT_STOCK(HttpStatus.CONFLICT, "O002", "재고 부족"),
ORDER_ALREADY_CANCELLED(HttpStatus.CONFLICT, "O003", "이미 취소된 주문");
private final HttpStatus status;
private final String code;
private final String message;
}
// 구체적 예외
public class UserNotFoundException extends BusinessException {
public UserNotFoundException(Long userId) {
super(ErrorCode.USER_NOT_FOUND, "사용자 ID: " + userId);
}
}
public class DuplicateEmailException extends BusinessException {
public DuplicateEmailException(String email) {
super(ErrorCode.DUPLICATE_EMAIL, "이메일: " + email);
}
}
public class InsufficientStockException extends BusinessException {
private final Long productId;
private final int requested;
private final int available;
public InsufficientStockException(Long productId, int requested, int available) {
super(ErrorCode.INSUFFICIENT_STOCK,
String.format("상품 %d: 요청 %d, 재고 %d", productId, requested, available));
this.productId = productId;
this.requested = requested;
this.available = available;
}
}
ProblemDetail: RFC 7807 표준 에러 응답
Spring Boot 3.x에서는 ProblemDetail을 1급 시민으로 지원합니다.
# application.yml — ProblemDetail 활성화
spring:
mvc:
problemdetails:
enabled: true # Spring Boot 3.x 기본값
// 응답 예시 (JSON)
{
"type": "https://api.example.com/errors/insufficient-stock",
"title": "재고 부족",
"status": 409,
"detail": "상품 42: 요청 5, 재고 2",
"instance": "/api/orders",
"code": "O002",
"productId": 42,
"requested": 5,
"available": 2,
"timestamp": "2026-02-27T19:00:00Z"
}
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ProblemDetail> handleBusiness(
BusinessException ex, HttpServletRequest request) {
ErrorCode code = ex.getErrorCode();
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
code.getStatus(), ex.getMessage());
problem.setTitle(code.getMessage());
problem.setType(URI.create("https://api.example.com/errors/" + code.name().toLowerCase().replace('_', '-')));
problem.setInstance(URI.create(request.getRequestURI()));
problem.setProperty("code", code.getCode());
problem.setProperty("timestamp", Instant.now());
// 추가 속성 (InsufficientStockException 등)
if (ex instanceof InsufficientStockException stockEx) {
problem.setProperty("productId", stockEx.getProductId());
problem.setProperty("requested", stockEx.getRequested());
problem.setProperty("available", stockEx.getAvailable());
}
return ResponseEntity.of(problem).build();
}
}
Bean Validation 에러 처리
NestJS의 Pipe 검증처럼, Spring에서도 @Valid 검증 실패 시 일관된 에러 응답이 필요합니다.
// DTO
public record CreateUserRequest(
@NotBlank(message = "이름은 필수입니다")
@Size(min = 2, max = 50, message = "이름은 2~50자")
String name,
@NotBlank(message = "이메일은 필수입니다")
@Email(message = "올바른 이메일 형식이 아닙니다")
String email,
@NotBlank(message = "비밀번호는 필수입니다")
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,}$",
message = "비밀번호는 8자 이상, 영문+숫자 포함")
String password
) {}
// Controller
@PostMapping("/users")
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
// Validation 에러 핸들러
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidation(
MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("입력값 검증 실패");
problem.setProperty("code", "C001");
List<Map<String, String>> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.map(error -> Map.of(
"field", error.getField(),
"message", error.getDefaultMessage(),
"rejectedValue", String.valueOf(error.getRejectedValue())
))
.toList();
problem.setProperty("errors", fieldErrors);
return ResponseEntity.of(problem).build();
}
// 응답 예시
// {
// "title": "입력값 검증 실패",
// "status": 400,
// "code": "C001",
// "errors": [
// {"field": "email", "message": "올바른 이메일 형식이 아닙니다", "rejectedValue": "invalid"},
// {"field": "password", "message": "비밀번호는 8자 이상, 영문+숫자 포함", "rejectedValue": "123"}
// ]
// }
// @RequestParam 검증 실패 (ConstraintViolationException)
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ProblemDetail> handleConstraint(
ConstraintViolationException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("파라미터 검증 실패");
List<Map<String, String>> violations = ex.getConstraintViolations().stream()
.map(v -> Map.of(
"field", extractFieldName(v.getPropertyPath()),
"message", v.getMessage()
))
.toList();
problem.setProperty("errors", violations);
return ResponseEntity.of(problem).build();
}
운영 환경: 에러 로깅 전략
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 4xx → WARN 레벨 (클라이언트 실수)
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ProblemDetail> handleBusiness(BusinessException ex) {
log.warn("비즈니스 예외: {} — {}", ex.getErrorCode(), ex.getMessage());
// ...
}
// 5xx → ERROR 레벨 + 스택트레이스
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Exception ex) {
String errorId = UUID.randomUUID().toString().substring(0, 8);
log.error("서버 오류 [{}]: {}", errorId, ex.getMessage(), ex);
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
problem.setDetail("서버 오류가 발생했습니다. 오류 ID: " + errorId);
// errorId로 로그 추적 가능
problem.setProperty("errorId", errorId);
return ResponseEntity.of(problem).build();
}
}
Actuator + Micrometer와 연동하면 에러 코드별 발생 빈도를 메트릭으로 수집할 수 있습니다:
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ProblemDetail> handleBusiness(
BusinessException ex, MeterRegistry registry) {
// 에러 코드별 카운터 증가
registry.counter("api.errors",
"code", ex.getErrorCode().getCode(),
"status", String.valueOf(ex.getErrorCode().getStatus().value())
).increment();
// ...
}
Spring Security 예외 통합
// Spring Security 예외는 @ControllerAdvice보다 먼저 처리됨
// → SecurityFilterChain에서 직접 설정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authEx) -> {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNAUTHORIZED, "인증이 필요합니다");
problem.setProperty("code", "C003");
response.setStatus(401);
response.setContentType("application/problem+json");
new ObjectMapper().writeValue(response.getWriter(), problem);
})
.accessDeniedHandler((request, response, accessEx) -> {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN, "접근 권한이 없습니다");
problem.setProperty("code", "C004");
response.setStatus(403);
response.setContentType("application/problem+json");
new ObjectMapper().writeValue(response.getWriter(), problem);
})
)
.build();
}
마무리
Spring 전역 예외 처리의 핵심은 일관성입니다. ErrorCode enum으로 코드를 중앙 관리하고, ProblemDetail(RFC 7807)로 표준화된 응답을 반환하며, 4xx는 WARN, 5xx는 ERROR + errorId로 로깅하세요. AOP와 결합하면 예외 발생 전후에 추가 횡단 로직도 삽입할 수 있습니다. 잘 설계된 에러 응답은 API 소비자와 운영팀 모두의 생산성을 높입니다.