SpringMedium#79
@ExceptionHandler와 @ControllerAdvice를 이용한 전역 예외 처리 방법을 설명해주세요.
#Spring#예외처리#ExceptionHandler#REST
힌트
컨트롤러 단위 vs 전역 예외 처리, @RestControllerAdvice를 생각해보세요.
정답 및 해설
@ExceptionHandler와 @ControllerAdvice를 이용한 전역 예외 처리 방법을 설명해주세요.
Spring MVC에서 예외 처리를 각 컨트롤러마다 따로 작성하면 코드 중복이 발생하고 일관성을 유지하기 어렵습니다. @ExceptionHandler와 @ControllerAdvice를 활용하면 예외 처리 코드를 한 곳에 집중하여 일관된 에러 응답 형식을 유지할 수 있습니다. 이는 유지보수성과 가독성을 크게 향상시킵니다.
@ExceptionHandler 기본 사용
컨트롤러 내부에서의 예외 처리
@ExceptionHandler를 특정 컨트롤러 클래스 내부에 선언하면, 해당 컨트롤러에서 발생한 예외만 처리합니다.
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public UserDto getUser(@PathVariable Long id) {
return userService.findById(id); // UserNotFoundException 발생 가능
}
// 이 컨트롤러에서만 적용
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
ErrorResponse error = new ErrorResponse("USER_NOT_FOUND", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
}
한계점
// 문제: 모든 컨트롤러에 동일한 예외 처리 코드를 반복해야 함
@RestController
public class OrderController {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(...) { /* 중복! */ }
}
@RestController
public class ProductController {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(...) { /* 중복! */ }
}
@ControllerAdvice / @RestControllerAdvice
전역 예외 처리 클래스 구성
@ControllerAdvice(또는 @RestControllerAdvice)를 사용하면 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리할 수 있습니다.
// @ControllerAdvice + @ResponseBody = @RestControllerAdvice
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// 특정 예외 처리
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
log.warn("사용자를 찾을 수 없음: {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("USER_NOT_FOUND", e.getMessage()));
}
// 유효성 검증 예외 처리
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationError(
MethodArgumentNotValidException e) {
List<String> errors = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("VALIDATION_ERROR", "입력값 검증 실패", errors));
}
// 최상위 예외 처리 (모든 예상치 못한 예외)
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception e) {
log.error("예상치 못한 오류 발생", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다"));
}
}
구조화된 에러 응답 설계
ErrorResponse DTO
@Getter
@Builder
public class ErrorResponse {
private final String code; // 에러 코드 (비즈니스 에러 식별용)
private final String message; // 사용자 친화적 메시지
private final List<String> details; // 상세 오류 목록 (검증 오류 등)
private final LocalDateTime timestamp;
private final String path;
public ErrorResponse(String code, String message) {
this(code, message, null, LocalDateTime.now(), null);
}
public ErrorResponse(String code, String message, List<String> details) {
this(code, message, details, LocalDateTime.now(), null);
}
@JsonCreator
public ErrorResponse(String code, String message,
List<String> details, LocalDateTime timestamp, String path) {
this.code = code;
this.message = message;
this.details = details;
this.timestamp = timestamp;
this.path = path;
}
}
응답 예시
// 404 Not Found
{
"code": "USER_NOT_FOUND",
"message": "ID 42에 해당하는 사용자를 찾을 수 없습니다",
"details": null,
"timestamp": "2025-03-01T10:30:00",
"path": "/api/users/42"
}
// 400 Bad Request (유효성 검증 실패)
{
"code": "VALIDATION_ERROR",
"message": "입력값 검증 실패",
"details": [
"email: 올바른 이메일 형식이어야 합니다",
"name: 이름은 필수입니다"
],
"timestamp": "2025-03-01T10:30:00",
"path": "/api/users"
}
커스텀 비즈니스 예외 계층 구조
예외 계층 설계
// 최상위 비즈니스 예외
public abstract class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
protected BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
// 에러 코드 Enum
@Getter
@AllArgsConstructor
public enum ErrorCode {
// 사용자 관련
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_001", "사용자를 찾을 수 없습니다"),
USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER_002", "이미 존재하는 이메일입니다"),
INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "USER_003", "비밀번호가 일치하지 않습니다"),
// 주문 관련
ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_001", "주문을 찾을 수 없습니다"),
INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "ORDER_002", "재고가 부족합니다"),
// 공통
INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 입력값입니다"),
ACCESS_DENIED(HttpStatus.FORBIDDEN, "COMMON_002", "접근 권한이 없습니다");
private final HttpStatus httpStatus;
private final String code;
private final String message;
}
// 구체적인 예외 클래스
public class UserNotFoundException extends BusinessException {
public UserNotFoundException(Long userId) {
super(ErrorCode.USER_NOT_FOUND);
}
}
public class InsufficientStockException extends BusinessException {
private final int availableStock;
private final int requestedQuantity;
public InsufficientStockException(int available, int requested) {
super(ErrorCode.INSUFFICIENT_STOCK);
this.availableStock = available;
this.requestedQuantity = requested;
}
}
ErrorCode 기반 전역 예외 처리
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 비즈니스 예외 통합 처리
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(
BusinessException e, HttpServletRequest request) {
ErrorCode errorCode = e.getErrorCode();
log.warn("비즈니스 예외 발생 [{}] {} - URI: {}",
errorCode.getCode(), e.getMessage(), request.getRequestURI());
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ErrorResponse.builder()
.code(errorCode.getCode())
.message(errorCode.getMessage())
.timestamp(LocalDateTime.now())
.path(request.getRequestURI())
.build());
}
// @Valid 검증 실패
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException e, HttpServletRequest request) {
List<String> details = e.getBindingResult().getFieldErrors()
.stream()
.map(fe -> String.format("[%s] %s", fe.getField(), fe.getDefaultMessage()))
.collect(Collectors.toList());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse.builder()
.code("VALIDATION_FAILED")
.message("요청 데이터 검증에 실패했습니다")
.details(details)
.timestamp(LocalDateTime.now())
.path(request.getRequestURI())
.build());
}
// @PathVariable, @RequestParam 타입 불일치
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleTypeMismatch(
MethodArgumentTypeMismatchException e) {
String message = String.format("파라미터 '%s'의 값 '%s'은(는) '%s' 타입이 아닙니다",
e.getName(), e.getValue(),
e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "Unknown");
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("TYPE_MISMATCH", message));
}
// 지원하지 않는 HTTP 메서드
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleMethodNotSupported(
HttpRequestMethodNotSupportedException e) {
return ResponseEntity
.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(new ErrorResponse("METHOD_NOT_ALLOWED",
e.getMethod() + " 메서드는 지원하지 않습니다"));
}
// 최상위 예외 (예상치 못한 모든 예외)
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(
Exception e, HttpServletRequest request) {
log.error("처리되지 않은 예외 발생 - URI: {}", request.getRequestURI(), e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.builder()
.code("INTERNAL_SERVER_ERROR")
.message("서버 내부 오류가 발생했습니다")
.timestamp(LocalDateTime.now())
.path(request.getRequestURI())
.build());
}
}
@ControllerAdvice 적용 범위 제한
// 특정 패키지에만 적용
@RestControllerAdvice(basePackages = "com.example.api")
public class ApiExceptionHandler { }
// 특정 컨트롤러 클래스에만 적용
@RestControllerAdvice(assignableTypes = {UserController.class, OrderController.class})
public class UserOrderExceptionHandler { }
// 특정 어노테이션이 있는 컨트롤러에만 적용
@RestControllerAdvice(annotations = RestController.class)
public class RestApiExceptionHandler { }
@ResponseStatus 어노테이션
간단한 경우 예외 클래스에 직접 HTTP 상태 코드를 지정할 수 있습니다.
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
// @ControllerAdvice 없이도 404 응답 자동 반환
// (단, 응답 본문 커스터마이징이 어렵기 때문에 @ControllerAdvice 방식 권장)
예외 처리 우선순위
예외 발생
│
▼
해당 컨트롤러의 @ExceptionHandler 탐색
│
있음?
┌──┴──┐
YES NO
│ │
처리 ▼
@ControllerAdvice의 @ExceptionHandler 탐색
│
있음?
┌──┴──┐
YES NO
│ │
처리 ▼
Spring Boot의 기본 에러 처리 (/error)
요약 표
| 구분 | @ExceptionHandler (컨트롤러 내) | @ControllerAdvice |
|---|---|---|
| 적용 범위 | 해당 컨트롤러만 | 전체 (또는 지정 범위) |
| 코드 위치 | 컨트롤러 클래스 내부 | 별도 클래스 |
| 재사용성 | 낮음 | 높음 |
| 우선순위 | 높음 (먼저 적용) | 낮음 (폴백) |
| 사용 권장 | 컨트롤러 특화 예외 | 공통 예외 처리 |
| 처리 대상 | 권장 HTTP 상태 코드 | 에러 코드 예시 |
|---|---|---|
| 리소스 없음 | 404 Not Found | USER_NOT_FOUND |
| 유효성 검증 실패 | 400 Bad Request | VALIDATION_FAILED |
| 인증 실패 | 401 Unauthorized | UNAUTHORIZED |
| 권한 없음 | 403 Forbidden | ACCESS_DENIED |
| 비즈니스 규칙 위반 | 409 Conflict | DUPLICATE_EMAIL |
| 서버 내부 오류 | 500 Internal Server Error | INTERNAL_ERROR |