콘텐츠 대표 이미지 - Spring @ControllerAdvice로 전역 예외 처리 마스터하기 🛡️ - 실전 가이드

Spring @ControllerAdvice로 전역 예외 처리 마스터하기 🛡️ - 실전 가이드

📅 2025년 03월 27일 기준 최신 Spring 버전 정보 반영

안녕하세요 여러분! 오늘은 Spring 개발할 때 진짜 꿀팁인 @ControllerAdvice를 활용한 전역 예외 처리에 대해 함께 알아볼게요. 개발하다 보면 예외 처리가 은근 귀찮고 코드도 지저분해지기 쉽잖아요? 이걸 깔끔하게 해결할 수 있는 방법을 소개해드릴게요! 😊

📚 목차

  1. 전역 예외 처리가 필요한 이유
  2. @ControllerAdvice와 @ExceptionHandler 이해하기
  3. 기본적인 전역 예외 처리 구현 방법
  4. 다양한 예외 유형별 처리 전략
  5. 실전 예제: REST API에서의 예외 처리
  6. 응답 형식 표준화하기
  7. 로깅과 모니터링 연동
  8. 테스트 코드 작성법
  9. 성능 최적화 팁
  10. 마무리 및 추가 자료

1. 전역 예외 처리가 필요한 이유 🤔

여러분, 개발하다가 이런 경험 있으시죠? 컨트롤러마다 try-catch 블록 넣고, 똑같은 예외 처리 코드 복붙하고... 진짜 노가다 작업이잖아요! ㅋㅋㅋ 코드는 점점 비대해지고 가독성은 떨어지고... 😫

기존 예외 처리의 문제점

1. 중복 코드가 너무 많아요! (DRY 원칙 위반) 🔄

2. 비즈니스 로직과 예외 처리 로직이 섞여서 코드가 복잡해져요 🍝

3. 일관된 예외 응답 형식을 유지하기 어려워요 📏

4. 새로운 예외 타입이 추가될 때마다 여러 곳을 수정해야 해요 🔨

이런 문제를 해결하기 위해 Spring은 @ControllerAdvice라는 강력한 기능을 제공해요. 이걸 사용하면 애플리케이션 전체에서 발생하는 예외를 한 곳에서 깔끔하게 처리할 수 있어요! 👍

재능넷 같은 복잡한 플랫폼을 개발할 때도 이런 전역 예외 처리는 정말 필수적이에요. 다양한 API와 서비스가 있을 때 일관된 에러 응답을 제공하는 건 사용자 경험에도 큰 영향을 주거든요! 🌟

UserController ProductController @ControllerAdvice GlobalExceptionHandler Exception! Exception! 표준화된 오류 응답 ErrorResponse

2. @ControllerAdvice와 @ExceptionHandler 이해하기 🧠

자, 이제 본격적으로 @ControllerAdvice가 뭔지 알아볼게요! 간단히 말하면 컨트롤러에서 발생하는 예외를 잡아서 한 곳에서 처리할 수 있게 해주는 어노테이션이에요.

핵심 개념 정리 📝

@ControllerAdvice: 여러 컨트롤러에 전역적으로 적용되는 코드를 작성할 수 있게 해주는 어노테이션이에요. Spring 3.2부터 도입되었어요.

@RestControllerAdvice: @ControllerAdvice + @ResponseBody의 조합이에요. REST API에서 주로 사용해요.

@ExceptionHandler: 특정 예외가 발생했을 때 실행될 메서드를 지정하는 어노테이션이에요.

Spring 6.0 버전(2025년 3월 기준 최신)에서는 이런 기능들이 더 강화되었어요. 특히 비동기 처리와의 통합이 개선되었답니다! 😎

@ControllerAdvice vs @RestControllerAdvice

이 둘의 차이점이 뭘까요? 간단해요!

@ControllerAdvice

- 주로 MVC 애플리케이션에서 사용해요

- View를 반환할 수 있어요

- @ResponseBody를 메서드에 추가로 붙여야 JSON 응답을 반환할 수 있어요

@RestControllerAdvice

- 주로 REST API에서 사용해요

- 기본적으로 모든 메서드에 @ResponseBody가 적용돼요

- JSON/XML 같은 데이터를 직접 반환하기 좋아요

요즘 트렌드는 마이크로서비스 아키텍처와 REST API가 대세라 @RestControllerAdvice를 더 많이 사용하는 추세예요! 재능넷 같은 현대적인 플랫폼도 아마 REST API를 많이 사용할 거예요. ㅎㅎ

3. 기본적인 전역 예외 처리 구현 방법 🛠️

이론은 충분히 알아봤으니 이제 실제로 코드를 작성해볼까요? 기본적인 전역 예외 처리기를 만드는 방법을 단계별로 알려드릴게요!

Step 1: 기본 예외 처리 클래스 만들기


import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.http.ResponseEntity;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception ex) {
        ErrorResponse error = new ErrorResponse("SERVER_ERROR", ex.getMessage());
        return ResponseEntity.internalServerError().body(error);
    }
}
    

이렇게 하면 애플리케이션에서 발생하는 모든 처리되지 않은 예외를 이 핸들러가 잡아줄 거예요! 진짜 편하죠? ㅋㅋㅋ

Step 2: 표준 에러 응답 객체 만들기


public class ErrorResponse {
    private String code;
    private String message;
    private LocalDateTime timestamp;
    
    public ErrorResponse(String code, String message) {
        this.code = code;
        this.message = message;
        this.timestamp = LocalDateTime.now();
    }
    
    // Getters and setters
}
    

이렇게 표준화된 응답 형식을 만들면 클라이언트 측에서도 일관된 방식으로 에러를 처리할 수 있어요. 프론트엔드 개발자들이 여러분에게 감사할 거예요! 😉

Step 3: 특정 예외 유형에 대한 핸들러 추가하기


@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception ex) {
        ErrorResponse error = new ErrorResponse("SERVER_ERROR", ex.getMessage());
        return ResponseEntity.internalServerError().body(error);
    }
    
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse("RESOURCE_NOT_FOUND", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
    
    @ExceptionHandler(ValidationException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(ValidationException ex) {
        ErrorResponse error = new ErrorResponse("VALIDATION_ERROR", ex.getMessage());
        return ResponseEntity.badRequest().body(error);
    }
}
    

이런 식으로 다양한 예외 유형에 맞게 핸들러를 추가할 수 있어요. 예외 유형별로 다른 HTTP 상태 코드와 응답 메시지를 반환할 수 있다는 게 정말 좋죠! 👍

"좋은 예외 처리는 사용자에게는 친절한 메시지를, 개발자에게는 디버깅에 필요한 정보를 제공해야 합니다."

- 어느 현명한 개발자 🧙‍♂️

4. 다양한 예외 유형별 처리 전략 🎯

실제 애플리케이션에서는 다양한 예외가 발생할 수 있어요. 각 예외 유형별로 어떻게 처리하는 게 좋을지 알아볼게요!

4.1 유효성 검증 예외 처리하기

Spring의 @Valid 어노테이션을 사용할 때 발생하는 MethodArgumentNotValidException을 처리해볼까요?


@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException ex) {
    // 모든 유효성 검증 오류 메시지 수집
    List<String> errors = ex.getBindingResult()
                            .getFieldErrors()
                            .stream()
                            .map(error -> error.getField() + ": " + error.getDefaultMessage())
                            .collect(Collectors.toList());
    
    ErrorResponse errorResponse = new ErrorResponse("VALIDATION_FAILED", "입력값 검증에 실패했습니다.");
    errorResponse.setErrors(errors);
    
    return ResponseEntity.badRequest().body(errorResponse);
}
    

이렇게 하면 폼 유효성 검증 실패 시 어떤 필드가 문제인지 자세히 알려줄 수 있어요! 사용자 친화적이죠? 😊

4.2 권한 관련 예외 처리하기


@ExceptionHandler({AccessDeniedException.class})
public ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException ex) {
    ErrorResponse errorResponse = new ErrorResponse(
        "ACCESS_DENIED", 
        "이 작업을 수행할 권한이 없습니다."
    );
    
    return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse);
}
    

보안 관련 예외는 민감한 정보를 노출하지 않도록 주의해야 해요. 실제 예외 메시지를 그대로 반환하면 보안 취약점이 될 수 있어요! 🔒

4.3 비즈니스 로직 예외 처리하기

비즈니스 규칙 위반 같은 예외는 커스텀 예외를 만들어 처리하는 게 좋아요.


// 커스텀 예외 클래스
public class BusinessException extends RuntimeException {
    private String errorCode;
    
    public BusinessException(String message, String errorCode) {
        super(message);
        this.errorCode = errorCode;
    }
    
    public String getErrorCode() {
        return errorCode;
    }
}

// 예외 처리기에 추가
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException ex) {
    ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage());
    return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
}
    

이런 식으로 비즈니스 로직에 특화된 예외를 정의하면 코드의 의도가 더 명확해져요! 👌

4.4 외부 API 통신 예외 처리하기


@ExceptionHandler({HttpClientErrorException.class, HttpServerErrorException.class})
public ResponseEntity<ErrorResponse> handleHttpClientException(Exception ex) {
    String message = "외부 서비스 연동 중 오류가 발생했습니다.";
    ErrorResponse errorResponse = new ErrorResponse("EXTERNAL_API_ERROR", message);
    
    // 로그에는 상세 정보 기록
    log.error("External API error: {}", ex.getMessage(), ex);
    
    return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(errorResponse);
}
    

외부 API 호출 시 발생하는 예외는 사용자에게는 간단한 메시지를 보여주고, 로그에는 상세 정보를 남기는 게 좋아요. 이렇게 하면 디버깅도 쉬워지죠! 🔍

💡 개발자 꿀팁!

예외 처리할 때 로그 레벨을 적절히 사용하는 것도 중요해요:

- ERROR: 실제 오류 상황 (서버 내부 오류, 데이터베이스 연결 실패 등)

- WARN: 잠재적인 문제 상황 (deprecated API 사용, 성능 이슈 등)

- INFO: 일반적인 정보 (애플리케이션 시작/종료, 중요 프로세스 완료 등)

- DEBUG: 개발 시 디버깅에 유용한 상세 정보

- TRACE: 가장 상세한 정보 (거의 사용하지 않음)

5. 실전 예제: REST API에서의 예외 처리 🚀

이제 실제 REST API 프로젝트에서 어떻게 전역 예외 처리를 구현하는지 더 자세한 예제를 살펴볼게요!

5.1 전체 예외 처리 구조 설계

먼저 전체적인 구조를 설계해볼게요. 계층화된 예외 처리 구조를 만드는 게 좋아요!


// 기본 예외 클래스
public abstract class BaseException extends RuntimeException {
    private final ErrorCode errorCode;
    
    public BaseException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
    
    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

// 에러 코드 열거형
public enum ErrorCode {
    // 클라이언트 오류 (4xx)
    INVALID_INPUT_VALUE(400, "C001", "잘못된 입력값입니다."),
    RESOURCE_NOT_FOUND(404, "C002", "요청한 리소스를 찾을 수 없습니다."),
    METHOD_NOT_ALLOWED(405, "C003", "지원하지 않는 HTTP 메서드입니다."),
    ACCESS_DENIED(403, "C004", "접근 권한이 없습니다."),
    
    // 서버 오류 (5xx)
    INTERNAL_SERVER_ERROR(500, "S001", "서버 내부 오류가 발생했습니다."),
    DATABASE_ERROR(500, "S002", "데이터베이스 오류가 발생했습니다.");
    
    private final int status;
    private final String code;
    private final String message;
    
    ErrorCode(int status, String code, String message) {
        this.status = status;
        this.code = code;
        this.message = message;
    }
    
    // Getters
    public int getStatus() { return status; }
    public String getCode() { return code; }
    public String getMessage() { return message; }
}
    

이런 식으로 에러 코드를 열거형으로 관리하면 일관된 에러 처리가 가능해요. 코드 가독성도 좋아지고요! 👍

5.2 구체적인 예외 클래스 구현


// 리소스를 찾을 수 없을 때 사용하는 예외
public class ResourceNotFoundException extends BaseException {
    public ResourceNotFoundException() {
        super(ErrorCode.RESOURCE_NOT_FOUND);
    }
    
    public ResourceNotFoundException(String message) {
        super(ErrorCode.RESOURCE_NOT_FOUND);
    }
}

// 입력값 검증 실패 시 사용하는 예외
public class InvalidValueException extends BaseException {
    public InvalidValueException() {
        super(ErrorCode.INVALID_INPUT_VALUE);
    }
    
    public InvalidValueException(String message) {
        super(ErrorCode.INVALID_INPUT_VALUE);
    }
}
    

이렇게 구체적인 예외 클래스를 만들면 코드에서 예외를 던질 때 의도가 명확해져요! 가독성이 훨씬 좋아지죠. ㅎㅎ

5.3 완성된 전역 예외 처리기


@RestControllerAdvice
public class GlobalExceptionHandler {

    // 기본 예외 처리
    @ExceptionHandler(BaseException.class)
    public ResponseEntity<ErrorResponse> handleBaseException(BaseException ex) {
        ErrorCode errorCode = ex.getErrorCode();
        ErrorResponse response = new ErrorResponse(errorCode.getCode(), errorCode.getMessage());
        return ResponseEntity.status(errorCode.getStatus()).body(response);
    }
    
    // 유효성 검증 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult()
                            .getFieldErrors()
                            .stream()
                            .map(error -> error.getField() + ": " + error.getDefaultMessage())
                            .collect(Collectors.toList());
        
        ErrorResponse response = new ErrorResponse(
            ErrorCode.INVALID_INPUT_VALUE.getCode(), 
            ErrorCode.INVALID_INPUT_VALUE.getMessage()
        );
        response.setErrors(errors);
        
        return ResponseEntity.status(ErrorCode.INVALID_INPUT_VALUE.getStatus()).body(response);
    }
    
    // 그 외 모든 예외 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAllExceptions(Exception ex) {
        // 로그에 상세 정보 기록
        log.error("Unhandled exception occurred: ", ex);
        
        ErrorResponse response = new ErrorResponse(
            ErrorCode.INTERNAL_SERVER_ERROR.getCode(),
            ErrorCode.INTERNAL_SERVER_ERROR.getMessage()
        );
        
        return ResponseEntity.status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus()).body(response);
    }
}
    

이제 모든 예외가 일관된 형식으로 처리되고 있어요! 클라이언트는 항상 동일한 형식의 에러 응답을 받게 되죠. 😎

예외 처리 흐름도 클라이언트 컨트롤러 서비스 예외 발생! @ControllerAdvice GlobalExceptionHandler ErrorResponse {code, message, data}

6. 응답 형식 표준화하기 📋

API 응답 형식을 표준화하는 것은 일관된 사용자 경험을 제공하는 데 매우 중요해요. 에러 응답뿐만 아니라 성공 응답도 일관된 형식으로 제공하면 좋아요!

6.1 표준 응답 형식 설계


// 기본 응답 클래스
public class ApiResponse<T> {
    private boolean success;
    private T data;
    private ErrorInfo error;
    
    // 성공 응답 생성
    public static <T> ApiResponse<T> success(T data) {
        ApiResponse<T> response = new ApiResponse<>();
        response.success = true;
        response.data = data;
        return response;
    }
    
    // 에러 응답 생성
    public static <T> ApiResponse<T> error(String code, String message) {
        ApiResponse<T> response = new ApiResponse<>();
        response.success = false;
        response.error = new ErrorInfo(code, message);
        return response;
    }
    
    // 내부 에러 정보 클래스
    @Getter @Setter
    public static class ErrorInfo {
        private String code;
        private String message;
        private List<String> details;
        
        public ErrorInfo(String code, String message) {
            this.code = code;
            this.message = message;
        }
    }
    
    // Getters and setters
}
    

이런 식으로 성공과 실패 응답을 동일한 구조로 제공하면 클라이언트 측에서 처리하기가 훨씬 편해져요! 😊

6.2 응답 래퍼 메서드 추가

GlobalExceptionHandler에 응답 래퍼 메서드를 추가해볼게요.


@RestControllerAdvice
public class GlobalExceptionHandler {

    // 기본 예외 처리
    @ExceptionHandler(BaseException.class)
    public ResponseEntity<ApiResponse<Void>> handleBaseException(BaseException ex) {
        ErrorCode errorCode = ex.getErrorCode();
        ApiResponse<Void> response = ApiResponse.error(
            errorCode.getCode(), 
            errorCode.getMessage()
        );
        
        return ResponseEntity.status(errorCode.getStatus()).body(response);
    }
    
    // 다른 예외 핸들러들...
}
    

이제 컨트롤러에서도 동일한 형식으로 응답을 반환하면 완벽한 일관성을 유지할 수 있어요!


@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    public ResponseEntity<ApiResponse<UserDto>> getUser(@PathVariable Long id) {
        UserDto user = userService.findById(id);
        return ResponseEntity.ok(ApiResponse.success(user));
    }
    
    // 다른 엔드포인트들...
}
    

이렇게 하면 성공이든 실패든 항상 동일한 구조의 응답이 반환되어 클라이언트 측 코드가 훨씬 단순해져요! 👍

6.3 응답 예시

성공 응답 예시


{
  "success": true,
  "data": {
    "id": 123,
    "name": "홍길동",
    "email": "hong@example.com"
  },
  "error": null
}
        

에러 응답 예시


{
  "success": false,
  "data": null,
  "error": {
    "code": "C002",
    "message": "요청한 리소스를 찾을 수 없습니다.",
    "details": ["사용자 ID 123은 존재하지 않습니다."]
  }
}
        

이런 식으로 일관된 응답 형식을 유지하면 프론트엔드 개발자들이 정말 좋아할 거예요! 재능넷 같은 플랫폼에서도 이런 표준화된 응답 형식이 API 개발에 큰 도움이 될 거예요. 😉

7. 로깅과 모니터링 연동 📊

예외 처리는 단순히 사용자에게 에러 메시지를 보여주는 것 이상의 의미가 있어요. 시스템 모니터링과 문제 해결에도 중요한 역할을 해요!

7.1 효과적인 로깅 전략


@RestControllerAdvice
public class GlobalExceptionHandler {

    private final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleAllExceptions(Exception ex, WebRequest request) {
        // 요청 정보 추출
        String requestURI = ((ServletWebRequest)request).getRequest().getRequestURI();
        String method = ((ServletWebRequest)request).getRequest().getMethod();
        
        // MDC에 요청 정보 추가 (로그 추적용)
        MDC.put("requestId", UUID.randomUUID().toString());
        MDC.put("requestURI", requestURI);
        MDC.put("method", method);
        
        // 상세 로그 기록
        log.error("Unhandled exception occurred: URI={}, Method={}", requestURI, method, ex);
        
        // 응답 생성
        ApiResponse<Void> response = ApiResponse.error(
            ErrorCode.INTERNAL_SERVER_ERROR.getCode(),
            ErrorCode.INTERNAL_SERVER_ERROR.getMessage()
        );
        
        // MDC 정리
        MDC.clear();
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}
    

이런 식으로 MDC(Mapped Diagnostic Context)를 활용하면 로그에 요청 정보를 포함시킬 수 있어요. 나중에 로그 분석할 때 정말 유용하죠! 👍

7.2 모니터링 시스템 연동

Prometheus나 Grafana 같은 모니터링 도구와 연동하면 예외 발생 현황을 실시간으로 모니터링할 수 있어요.


@Component
public class ExceptionMetrics {
    private final Counter totalExceptionCounter;
    private final Counter validationExceptionCounter;
    private final Counter resourceNotFoundCounter;
    private final Counter serverErrorCounter;
    
    public ExceptionMetrics(MeterRegistry registry) {
        this.totalExceptionCounter = Counter.builder("app.exceptions.total")
            .description("Total number of exceptions")
            .register(registry);
            
        this.validationExceptionCounter = Counter.builder("app.exceptions.validation")
            .description("Number of validation exceptions")
            .register(registry);
            
        this.resourceNotFoundCounter = Counter.builder("app.exceptions.not_found")
            .description("Number of resource not found exceptions")
            .register(registry);
            
        this.serverErrorCounter = Counter.builder("app.exceptions.server")
            .description("Number of server error exceptions")
            .register(registry);
    }
    
    public void incrementTotalExceptions() {
        this.totalExceptionCounter.increment();
    }
    
    public void incrementValidationExceptions() {
        this.validationExceptionCounter.increment();
    }
    
    // 다른 메서드들...
}
    

이제 GlobalExceptionHandler에서 이 메트릭을 사용하면 돼요!


@RestControllerAdvice
public class GlobalExceptionHandler {

    private final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    private final ExceptionMetrics metrics;
    
    public GlobalExceptionHandler(ExceptionMetrics metrics) {
        this.metrics = metrics;
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        // 메트릭 증가
        metrics.incrementTotalExceptions();
        metrics.incrementValidationExceptions();
        
        // 로깅 및 응답 생성 로직...
    }
    
    // 다른 핸들러들...
}
    

이렇게 하면 예외 발생 현황을 실시간으로 모니터링할 수 있어요. 어떤 유형의 예외가 많이 발생하는지 파악하고 개선할 수 있죠! 📈

7.3 알림 시스템 연동

심각한 예외가 발생했을 때 개발팀에 알림을 보내는 기능도 추가할 수 있어요.


@Component
public class AlertService {
    private final SlackClient slackClient;
    
    public AlertService(SlackClient slackClient) {
        this.slackClient = slackClient;
    }
    
    public void sendCriticalErrorAlert(String errorMessage, String stackTrace) {
        SlackMessage message = SlackMessage.builder()
            .text("❗ 심각한 오류 발생")
            .attachment(SlackAttachment.builder()
                .color("danger")
                .title("오류 정보")
                .text("메시지: " + errorMessage + "\n스택트레이스: " + stackTrace)
                .build())
            .build();
            
        slackClient.sendMessage(message);
    }
}
    

이제 GlobalExceptionHandler에서 심각한 예외가 발생했을 때 알림을 보낼 수 있어요!


@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ApiResponse<Void>> handleDataAccessException(DataAccessException ex) {
    // 로깅
    log.error("Database error occurred", ex);
    
    // 메트릭 증가
    metrics.incrementTotalExceptions();
    metrics.incrementDatabaseExceptions();
    
    // 알림 전송
    String stackTrace = ExceptionUtils.getStackTrace(ex);
    alertService.sendCriticalErrorAlert(ex.getMessage(), stackTrace);
    
    // 응답 생성
    ApiResponse<Void> response = ApiResponse.error(
        ErrorCode.DATABASE_ERROR.getCode(),
        ErrorCode.DATABASE_ERROR.getMessage()
    );
    
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
    

이런 식으로 중요한 예외가 발생했을 때 즉시 알림을 받을 수 있어요. 문제를 빠르게 인지하고 대응할 수 있죠! 🚨

8. 테스트 코드 작성법 🧪

전역 예외 처리기가 제대로 동작하는지 테스트하는 것도 중요해요! 단위 테스트와 통합 테스트를 통해 예외 처리 로직을 검증해볼게요.

8.1 단위 테스트 작성하기


@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {

    @InjectMocks
    private GlobalExceptionHandler exceptionHandler;
    
    @Mock
    private ExceptionMetrics metrics;
    
    @Test
    void handleResourceNotFoundException_ShouldReturnNotFound() {
        // Given
        ResourceNotFoundException ex = new ResourceNotFoundException("리소스를 찾을 수 없습니다.");
        
        // When
        ResponseEntity<ApiResponse<Void>> response = exceptionHandler.handleResourceNotFoundException(ex);
        
        // Then
        assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
        assertFalse(response.getBody().isSuccess());
        assertEquals(ErrorCode.RESOURCE_NOT_FOUND.getCode(), response.getBody().getError().getCode());
        assertEquals(ErrorCode.RESOURCE_NOT_FOUND.getMessage(), response.getBody().getError().getMessage());
        
        // 메트릭 증가 확인
        verify(metrics).incrementTotalExceptions();
        verify(metrics).incrementResourceNotFoundExceptions();
    }
    
    // 다른 테스트 메서드들...
}
    

이런 식으로 각 예외 핸들러 메서드를 개별적으로 테스트할 수 있어요. 단위 테스트는 빠르게 실행되고 문제를 빨리 발견할 수 있어서 좋아요! 👍

8.2 통합 테스트 작성하기


@SpringBootTest
@AutoConfigureMockMvc
class ExceptionHandlingIntegrationTest {

    @Autowired
    private MockMvc mockMvc;
    
    @Test
    void whenResourceNotFound_thenReturns404() throws Exception {
        // Given
        long nonExistentId = 9999L;
        
        // When & Then
        mockMvc.perform(get("/api/users/{id}", nonExistentId))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.success").value(false))
            .andExpect(jsonPath("$.error.code").value(ErrorCode.RESOURCE_NOT_FOUND.getCode()))
            .andExpect(jsonPath("$.error.message").value(ErrorCode.RESOURCE_NOT_FOUND.getMessage()));
    }
    
    @Test
    void whenInvalidInput_thenReturns400() throws Exception {
        // Given
        UserCreateDto invalidUser = new UserCreateDto();
        // name이 null이라 유효성 검증 실패
        
        // When & Then
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(new ObjectMapper().writeValueAsString(invalidUser)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.success").value(false))
            .andExpect(jsonPath("$.error.code").value(ErrorCode.INVALID_INPUT_VALUE.getCode()))
            .andExpect(jsonPath("$.error.details").isArray())
            .andExpect(jsonPath("$.error.details[0]").isNotEmpty());
    }
    
    // 다른 테스트 메서드들...
}
    

통합 테스트를 통해 실제 HTTP 요청 흐름에서 예외 처리가 제대로 동작하는지 확인할 수 있어요. 실제 상황과 유사한 테스트라 더 신뢰할 수 있죠! 🔍

8.3 테스트 커버리지 확인하기

JaCoCo 같은 도구를 사용하면 테스트 커버리지를 확인할 수 있어요.


// build.gradle
plugins {
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.9"
}

jacocoTestReport {
    reports {
        xml.enabled true
        html.enabled true
    }
    
    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it, exclude: [
                "com/example/demo/config/**",
                "com/example/demo/DemoApplication.class"
            ])
        }))
    }
}

test {
    finalizedBy jacocoTestReport
}
    

이렇게 설정하면 테스트 실행 후 자동으로 커버리지 리포트가 생성돼요. 예외 처리 코드가 얼마나 테스트되고 있는지 확인할 수 있죠! 📊

🔥 실전 예제: 완전한 GlobalExceptionHandler 구현


@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    private final ExceptionMetrics metrics;
    private final AlertService alertService;
    
    public GlobalExceptionHandler(ExceptionMetrics metrics, AlertService alertService) {
        this.metrics = metrics;
        this.alertService = alertService;
    }
    
    // 애플리케이션 예외 처리
    @ExceptionHandler(BaseException.class)
    public ResponseEntity<ApiResponse<Void>> handleBaseException(BaseException ex) {
        ErrorCode errorCode = ex.getErrorCode();
        log.error("[{}] {}", errorCode.getCode(), ex.getMessage());
        
        metrics.incrementTotalExceptions();
        
        ApiResponse<Void> response = ApiResponse.error(
            errorCode.getCode(), 
            errorCode.getMessage()
        );
        
        return ResponseEntity.status(errorCode.getStatus()).body(response);
    }
    
    // 유효성 검증 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        log.error("Validation error: {}", ex.getMessage());
        
        metrics.incrementTotalExceptions();
        metrics.incrementValidationExceptions();
        
        List<String> errors = ex.getBindingResult()
                            .getFieldErrors()
                            .stream()
                            .map(error -> error.getField() + ": " + error.getDefaultMessage())
                            .collect(Collectors.toList());
        
        ApiResponse<Void> response = ApiResponse.error(
            ErrorCode.INVALID_INPUT_VALUE.getCode(), 
            ErrorCode.INVALID_INPUT_VALUE.getMessage()
        );
        response.getError().setDetails(errors);
        
        return ResponseEntity.status(ErrorCode.INVALID_INPUT_VALUE.getStatus()).body(response);
    }
    
    // 데이터베이스 예외 처리
    @ExceptionHandler(DataAccessException.class)
    public ResponseEntity<ApiResponse<Void>> handleDataAccessException(DataAccessException ex) {
        log.error("Database error: ", ex);
        
        metrics.incrementTotalExceptions();
        metrics.incrementDatabaseExceptions();
        
        // 심각한 오류는 알림 전송
        String stackTrace = ExceptionUtils.getStackTrace(ex);
        alertService.sendCriticalErrorAlert("데이터베이스 오류", ex.getMessage(), stackTrace);
        
        ApiResponse<Void> response = ApiResponse.error(
            ErrorCode.DATABASE_ERROR.getCode(),
            ErrorCode.DATABASE_ERROR.getMessage()
        );
        
        return ResponseEntity.status(ErrorCode.DATABASE_ERROR.getStatus()).body(response);
    }
    
    // 권한 관련 예외 처리
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ApiResponse<Void>> handleAccessDeniedException(AccessDeniedException ex) {
        log.error("Access denied: {}", ex.getMessage());
        
        metrics.incrementTotalExceptions();
        metrics.incrementSecurityExceptions();
        
        ApiResponse<Void> response = ApiResponse.error(
            ErrorCode.ACCESS_DENIED.getCode(),
            ErrorCode.ACCESS_DENIED.getMessage()
        );
        
        return ResponseEntity.status(ErrorCode.ACCESS_DENIED.getStatus()).body(response);
    }
    
    // 그 외 모든 예외 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleAllExceptions(Exception ex, WebRequest request) {
        String requestURI = ((ServletWebRequest)request).getRequest().getRequestURI();
        String method = ((ServletWebRequest)request).getRequest().getMethod();
        
        log.error("Unhandled exception: URI={}, Method={}", requestURI, method, ex);
        
        metrics.incrementTotalExceptions();
        metrics.incrementServerExceptions();
        
        // 예상치 못한 오류는 알림 전송
        String stackTrace = ExceptionUtils.getStackTrace(ex);
        alertService.sendCriticalErrorAlert("예상치 못한 서버 오류", ex.getMessage(), stackTrace);
        
        ApiResponse<Void> response = ApiResponse.error(
            ErrorCode.INTERNAL_SERVER_ERROR.getCode(),
            ErrorCode.INTERNAL_SERVER_ERROR.getMessage()
        );
        
        return ResponseEntity.status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus()).body(response);
    }
}
    

9. 성능 최적화 팁 🚀

전역 예외 처리도 성능에 영향을 줄 수 있어요. 몇 가지 최적화 팁을 알아볼게요!

9.1 예외 생성 비용 줄이기

예외 객체를 생성하고 스택 트레이스를 수집하는 것은 생각보다 비용이 많이 들어요.


// 비효율적인 방법
if (user != null) {
    return user;
} else {
    throw new ResourceNotFoundException("사용자를 찾을 수 없습니다: " + id);
}

// 더 효율적인 방법
if (user == null) {
    throw new ResourceNotFoundException("사용자를 찾을 수 없습니다: " + id);
}
return user;
    

두 번째 방법이 예외 경로와 정상 경로를 명확히 분리해서 더 효율적이에요. 예외는 정말 예외적인 상황에서만 사용하는 게 좋아요! ⚡

9.2 예외 래핑 최소화하기

예외를 여러 번 래핑하면 성능이 저하될 수 있어요.


// 비효율적인 방법
try {
    return userRepository.findById(id);
} catch (DataAccessException ex) {
    throw new ServiceException("데이터베이스 오류", ex);
}

// 더 효율적인 방법 (필요한 경우에만 래핑)
try {
    return userRepository.findById(id);
} catch (DataAccessException ex) {
    log.error("데이터베이스 오류: {}", ex.getMessage());
    throw ex; // 원래 예외를 그대로 전파
}
    

꼭 필요한 경우에만 예외를 래핑하고, 가능하면 원래 예외를 그대로 전파하는 게 좋아요. 스택 트레이스가 더 깔끔해지고 성능도 좋아져요! 👍

9.3 로깅 최적화하기


// 비효율적인 방법 (항상 문자열 연산 발생)
log.error("User not found: " + userId + ", requestId: " + requestId);

// 더 효율적인 방법 (로그 레벨이 활성화된 경우에만 문자열 연산 발생)
log.error("User not found: {}, requestId: {}", userId, requestId);
    

SLF4J의 파라미터화된 로깅을 사용하면 불필요한 문자열 연산을 줄일 수 있어요. 로그 양이 많을 때 성능 차이가 꽤 커요! 📝

9.4 캐싱 활용하기

자주 발생하는 예외에 대한 응답을 캐싱할 수도 있어요.


@Component
public class ErrorResponseCache {
    private final Map<String, ApiResponse<Void>> cache = new ConcurrentHashMap<>();
    
    public ApiResponse<Void> getErrorResponse(String errorCode, String message) {
        return cache.computeIfAbsent(
            errorCode, 
            code -> ApiResponse.error(code, message)
        );
    }
}

// 예외 핸들러에서 사용
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleResourceNotFoundException(ResourceNotFoundException ex) {
    String errorCode = ErrorCode.RESOURCE_NOT_FOUND.getCode();
    String message = ErrorCode.RESOURCE_NOT_FOUND.getMessage();
    
    ApiResponse<Void> response = errorResponseCache.getErrorResponse(errorCode, message);
    
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
    

이런 식으로 자주 사용되는 에러 응답을 캐싱하면 객체 생성 비용을 줄일 수 있어요. 특히 트래픽이 많은 시스템에서 효과적이에요! 🚀

10. 마무리 및 추가 자료 📚

지금까지 Spring의 @ControllerAdvice를 이용한 전역 예외 처리에 대해 알아봤어요! 깔끔하고 일관된 예외 처리는 애플리케이션의 품질을 크게 향상시킬 수 있어요. 😊

10.1 정리

  1. @ControllerAdvice와 @ExceptionHandler를 사용하면 애플리케이션 전체의 예외를 한 곳에서 처리할 수 있어요.
  2. 표준화된 응답 형식을 사용하면 클라이언트 측에서 일관된 방식으로 에러를 처리할 수 있어요.
  3. 다양한 예외 유형에 맞는 처리 전략을 구현하면 더 정확한 피드백을 제공할 수 있어요.
  4. 로깅과 모니터링을 연동하면 예외 발생 현황을 실시간으로 파악할 수 있어요.
  5. 테스트 코드를 작성하여 예외 처리 로직이 제대로 동작하는지 검증할 수 있어요.
  6. 성능 최적화 기법을 적용하여 예외 처리로 인한 성능 저하를 최소화할 수 있어요.

이런 기법들을 적용하면 재능넷과 같은 복잡한 플랫폼에서도 안정적이고 사용자 친화적인 예외 처리가 가능해져요! 👍

10.2 추가 학습 자료

  1. Spring 공식 문서: Exception Handling
  2. Baeldung 튜토리얼: Error Handling for REST with Spring
  3. GitHub 예제 프로젝트: Spring Boot Samples
  4. Spring Boot Actuator: Production-ready Features
  5. Martin Fowler의 블로그: Replace Throwing Exceptions with Notification in Validations

10.3 마무리 메시지

예외 처리는 단순한 기술적 문제가 아니라 사용자 경험과 직결되는 중요한 부분이에요. 잘 설계된 예외 처리 시스템은 사용자에게 친절한 메시지를 제공하고, 개발자에게는 디버깅에 필요한 정보를 제공하며, 시스템 안정성을 높여줘요.

Spring의 @ControllerAdvice는 이런 목표를 달성하는 데 아주 유용한 도구예요. 여러분의 프로젝트에 이 글에서 소개한 패턴들을 적용해보세요! 더 안정적이고 유지보수하기 쉬운 애플리케이션을 만들 수 있을 거예요. 😄

혹시 이 글이 도움이 되셨다면, 재능넷에서 더 많은 개발 관련 지식을 찾아보세요! 다양한 개발자들이 자신의 지식과 경험을 공유하고 있답니다. 함께 성장해요! 🌱

✍️ 작성자 정보

이 글은 2025년 03월 27일에 작성되었으며, Spring Framework 6.0 및 Spring Boot 3.2 버전을 기준으로 합니다.

더 많은 Java 및 Spring 관련 콘텐츠는 재능넷의 '지식인의 숲' 메뉴에서 찾아보실 수 있습니다.

질문이나 의견이 있으시면 댓글로 남겨주세요! 함께 배우고 성장해요! 🌟

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.