ErrorHandling 완벽 마스터

ErrorHandling의 핵심 개념과 실전 활용법

Python중급
12시간
12개 항목
학습 진행률0 / 12 (0%)

학습 항목

1. Java
Spring|예외|처리|전략|완벽|가이드
퀴즈튜토리얼
2. JavaScript
고급
Debugging|실전|프로젝트|개발|기법
퀴즈튜토리얼
3. JavaScript
초급
Debugging|실전|프로젝트|완벽|가이드
퀴즈튜토리얼
4. JavaScript
중급
Edge|Computing|트러블슈팅|완벽|가이드
퀴즈튜토리얼
5. JavaScript
JavaScript|Promise|비동기|처리|완벽|가이드
퀴즈튜토리얼
6. Python
초급
Bash|디자인|패턴|완벽|가이드
퀴즈튜토리얼
7. Python
초급
GCP|트러블슈팅|완벽|가이드
퀴즈튜토리얼
8. Python
초급
Logging|트러블슈팅|완벽|가이드
퀴즈튜토리얼
9. Python
고급
RabbitMQ|실무|활용|팁
퀴즈튜토리얼
10. TypeScript
초급
Chain of Responsibility|패턴|트러블슈팅|가이드
퀴즈튜토리얼
11. TypeScript
초급
Rust|실무|활용|팁|가이드
퀴즈튜토리얼
12. TypeScript
TypeScript|에러|처리|패턴|완벽|가이드
퀴즈튜토리얼
1 / 12

이미지 로딩 중...

Spring 예외 처리 전략 완벽 가이드 - 슬라이드 1/11

Spring 예외 처리 전략 완벽 가이드

Spring 애플리케이션에서 예외를 효과적으로 처리하는 방법을 알아봅니다. @ExceptionHandler부터 Global Exception Handler까지, 실무에서 바로 적용 가능한 예외 처리 패턴과 모범 사례를 다룹니다.


목차

  1. 기본 예외 처리와 @ExceptionHandler
  2. Global Exception Handler와 @ControllerAdvice
  3. 커스텀 예외 클래스 설계
  4. 유효성 검증 예외 처리
  5. 예외 응답 형식 표준화
  6. ResponseEntityExceptionHandler 활용
  7. 비동기 예외 처리
  8. 트랜잭션 예외 처리
  9. 서비스 간 통신 예외 처리
  10. 로깅과 모니터링 전략

1. 기본 예외 처리와 @ExceptionHandler

시작하며

여러분이 Spring API를 개발할 때 이런 상황을 겪어본 적 있나요? 사용자가 존재하지 않는 데이터를 조회했을 때 500 에러가 발생하고, 프론트엔드 개발자가 "도대체 무슨 에러인지 알 수가 없어요"라고 말하는 상황 말이죠.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 예외가 발생했을 때 적절한 HTTP 상태 코드와 명확한 에러 메시지를 제공하지 않으면, 클라이언트는 문제의 원인을 파악할 수 없고 사용자 경험이 크게 저하됩니다.

바로 이럴 때 필요한 것이 Spring의 @ExceptionHandler입니다. 이 어노테이션을 사용하면 컨트롤러에서 발생하는 특정 예외를 깔끔하게 처리하고, 클라이언트에게 의미 있는 응답을 제공할 수 있습니다.

개요

간단히 말해서, @ExceptionHandler는 컨트롤러 내에서 발생하는 특정 예외를 잡아서 처리하는 메서드를 정의할 수 있게 해주는 어노테이션입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, API 응답의 일관성과 명확성이 중요하기 때문입니다.

예를 들어, 사용자를 찾을 수 없을 때 404 상태 코드와 함께 "사용자를 찾을 수 없습니다"라는 명확한 메시지를 반환해야 프론트엔드에서 적절한 UI를 보여줄 수 있습니다. 기존에는 try-catch 블록을 컨트롤러 메서드마다 작성해야 했다면, 이제는 @ExceptionHandler를 사용하여 예외 처리 로직을 한 곳에 모아 관리할 수 있습니다.

이 개념의 핵심 특징은 첫째, 특정 예외 타입에 대한 처리를 선언적으로 정의할 수 있다는 점, 둘째, 컨트롤러 로직과 예외 처리 로직을 분리할 수 있다는 점, 셋째, 응답 상태 코드와 본문을 자유롭게 커스터마이징할 수 있다는 점입니다. 이러한 특징들이 코드의 가독성을 높이고 유지보수를 쉽게 만듭니다.

코드 예제

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

    @GetMapping("/{id}")
    public UserResponse getUser(@PathVariable Long id) {
        // 사용자를 찾지 못하면 예외 발생
        User user = userService.findById(id)
            .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
        return new UserResponse(user);
    }

    // 이 컨트롤러에서 발생하는 UserNotFoundException을 처리
    @ExceptionHandler(UserNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleUserNotFound(UserNotFoundException ex) {
        return new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
    }
}

설명

이것이 하는 일: 컨트롤러 메서드에서 예외가 발생하면 해당 예외 타입에 맞는 @ExceptionHandler 메서드가 자동으로 호출되어, 클라이언트에게 적절한 에러 응답을 반환합니다. 첫 번째로, getUser 메서드에서 사용자를 찾지 못하면 UserNotFoundException이 발생합니다.

이때 Spring이 자동으로 같은 컨트롤러 내에서 이 예외를 처리할 수 있는 @ExceptionHandler 메서드를 찾습니다. 왜 이렇게 하는지는, 비즈니스 로직과 예외 처리 로직을 분리하여 코드의 책임을 명확히 하기 위함입니다.

그 다음으로, handleUserNotFound 메서드가 실행되면서 예외 객체를 파라미터로 받습니다. @ResponseStatus(HttpStatus.NOT_FOUND)가 설정되어 있어 자동으로 404 상태 코드가 응답에 포함됩니다.

내부에서는 예외 메시지를 추출하여 ErrorResponse 객체를 생성합니다. 마지막으로, 생성된 ErrorResponse 객체가 JSON으로 직렬화되어 클라이언트에게 전달됩니다.

최종적으로 클라이언트는 {"errorCode": "USER_NOT_FOUND", "message": "User not found with id: 123"} 형태의 명확한 에러 정보를 받게 됩니다. 여러분이 이 코드를 사용하면 첫째, 컨트롤러 메서드가 깔끔해지고, 둘째, 모든 예외에 대해 일관된 형식의 응답을 제공할 수 있으며, 셋째, 예외 처리 로직의 재사용성이 높아집니다.

실무에서는 클라이언트와 약속한 에러 응답 형식을 정확히 지킬 수 있어 프론트엔드 개발자와의 협업이 훨씬 수월해집니다.

실전 팁

💡 하나의 @ExceptionHandler에서 여러 예외를 처리하려면 @ExceptionHandler({Exception1.class, Exception2.class}) 형태로 배열을 전달하세요.

💡 ResponseEntity를 반환하면 @ResponseStatus 없이도 상태 코드를 동적으로 설정할 수 있어 더 유연합니다.

💡 예외 객체뿐만 아니라 HttpServletRequest, WebRequest 등도 파라미터로 받을 수 있어 요청 정보를 로깅할 때 유용합니다.

💡 @ExceptionHandler는 같은 컨트롤러 내에서만 동작하므로, 여러 컨트롤러에서 공통으로 사용하려면 @ControllerAdvice를 사용해야 합니다.

💡 개발 환경에서는 스택 트레이스를 포함하고, 운영 환경에서는 보안을 위해 제외하는 방식으로 환경별로 응답을 다르게 구성하세요.


2. Global Exception Handler와 @ControllerAdvice

시작하며

여러분이 여러 컨트롤러를 개발하다 보면 이런 상황을 겪어본 적 있나요? 각 컨트롤러마다 동일한 예외 처리 코드를 복사-붙여넣기하고, 나중에 에러 응답 형식이 바뀌면 모든 컨트롤러를 일일이 수정해야 하는 상황 말이죠.

이런 문제는 실제 개발 현장에서 유지보수의 악몽이 됩니다. 예외 처리 로직이 여기저기 흩어져 있으면 일관성을 유지하기 어렵고, 수정할 때마다 누락되는 부분이 생길 수 있습니다.

특히 팀 프로젝트에서는 각 개발자가 다른 방식으로 예외를 처리하여 API 응답이 중구난방이 되는 경우도 많습니다. 바로 이럴 때 필요한 것이 @ControllerAdvice입니다.

이 어노테이션을 사용하면 애플리케이션 전체에서 발생하는 예외를 한 곳에서 통합 관리할 수 있어, 코드 중복을 제거하고 일관성을 보장할 수 있습니다.

개요

간단히 말해서, @ControllerAdvice는 모든 컨트롤러에 적용되는 전역 예외 처리기를 만들 수 있게 해주는 어노테이션입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 대규모 애플리케이션에서는 수십 개의 컨트롤러가 존재하고 각각 다양한 예외를 던지는데, 이를 일관되게 처리하는 것이 매우 중요하기 때문입니다.

예를 들어, 인증 실패, 권한 부족, 잘못된 요청 형식 등 공통적으로 발생하는 예외들을 한 곳에서 처리하면 코드 관리가 훨씬 쉬워집니다. 기존에는 각 컨트롤러마다 @ExceptionHandler를 반복해서 작성해야 했다면, 이제는 @ControllerAdvice 클래스 하나에 모든 공통 예외 처리를 모아둘 수 있습니다.

이 개념의 핵심 특징은 첫째, 모든 컨트롤러에 자동으로 적용된다는 점, 둘째, 패키지나 어노테이션을 기준으로 적용 범위를 제한할 수 있다는 점, 셋째, 예외 처리뿐만 아니라 @InitBinder, @ModelAttribute 같은 다른 기능도 전역으로 적용할 수 있다는 점입니다. 이러한 특징들이 애플리케이션의 일관성과 유지보수성을 크게 향상시킵니다.

코드 예제

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 비즈니스 예외 처리
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException ex) {
        log.error("Business exception occurred: {}", ex.getMessage());
        ErrorResponse error = new ErrorResponse(ex.getErrorCode(), ex.getMessage());
        return new ResponseEntity<>(error, ex.getHttpStatus());
    }

    // 유효성 검증 실패 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.joining(", "));
        ErrorResponse error = new ErrorResponse("VALIDATION_FAILED", message);
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    // 예상하지 못한 예외 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpectedException(Exception ex) {
        log.error("Unexpected exception occurred", ex);
        ErrorResponse error = new ErrorResponse("INTERNAL_SERVER_ERROR", "An unexpected error occurred");
        return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

설명

이것이 하는 일: 애플리케이션의 어느 컨트롤러에서든 예외가 발생하면, @ControllerAdvice가 붙은 클래스의 적절한 @ExceptionHandler 메서드가 호출되어 통합된 방식으로 예외를 처리합니다. 첫 번째로, @RestControllerAdvice는 @ControllerAdvice와 @ResponseBody를 합친 것으로, 모든 응답이 자동으로 JSON으로 변환됩니다.

Spring Boot 애플리케이션이 시작될 때 이 클래스를 스캔하여 전역 예외 처리기로 등록합니다. 왜 이렇게 하는지는, REST API에서는 대부분 JSON 응답을 사용하므로 매번 @ResponseBody를 붙이는 수고를 덜기 위함입니다.

그 다음으로, 각 @ExceptionHandler 메서드가 실행되면서 예외 타입에 따라 적절한 처리를 수행합니다. BusinessException은 비즈니스 로직에서 의도적으로 던진 예외이므로 로그를 남기고 예외에 정의된 상태 코드를 사용합니다.

MethodArgumentNotValidException은 @Valid 검증 실패 시 발생하며, 어떤 필드가 잘못되었는지 상세한 메시지를 조합합니다. 세 번째로, Exception.class를 처리하는 핸들러는 모든 예상하지 못한 예외를 캐치하는 최후의 방어선 역할을 합니다.

실제 에러 내용은 로그에 기록하지만, 클라이언트에게는 보안을 위해 일반적인 메시지만 전달합니다. 이렇게 하면 시스템 내부 정보가 외부에 노출되는 것을 방지할 수 있습니다.

마지막으로, ResponseEntity를 반환하여 HTTP 상태 코드와 응답 본문을 함께 제어합니다. 최종적으로 클라이언트는 예외 종류와 관계없이 일관된 형식의 에러 응답을 받게 되어, 프론트엔드에서 에러 처리 로직을 단순화할 수 있습니다.

여러분이 이 코드를 사용하면 첫째, 모든 컨트롤러에서 예외 처리 코드를 작성할 필요가 없어지고, 둘째, 새로운 예외 타입이 추가되어도 이 클래스만 수정하면 되며, 셋째, 에러 응답 형식을 변경할 때 한 곳만 수정하면 전체에 적용됩니다. 실무에서는 API 문서화 시에도 에러 응답 형식이 일관되어 있어 문서 작성과 유지보수가 훨씬 수월해집니다.

실전 팁

💡 @ControllerAdvice(basePackages = "com.example.api")로 특정 패키지에만 적용하거나, @RestControllerAdvice(annotations = RestController.class)로 특정 어노테이션이 붙은 컨트롤러에만 적용할 수 있습니다.

💡 예외 처리 우선순위는 구체적인 예외가 상위이므로, Exception.class 핸들러는 항상 마지막에 배치하여 catch-all 역할을 하게 하세요.

💡 로깅 시 민감한 정보(비밀번호, 토큰 등)가 포함되지 않도록 주의하고, 운영 환경에서는 스택 트레이스를 클라이언트에 노출하지 마세요.

💡 여러 @ControllerAdvice가 존재할 때는 @Order 어노테이션으로 우선순위를 지정할 수 있으며, 숫자가 작을수록 먼저 적용됩니다.

💡 테스트 시 MockMvc를 사용하여 예외가 발생했을 때 올바른 응답이 반환되는지 검증하는 통합 테스트를 작성하세요.


3. 커스텀 예외 클래스 설계

시작하며

여러분이 비즈니스 로직을 구현할 때 이런 상황을 겪어본 적 있나요? "상품 재고가 부족합니다", "이미 존재하는 이메일입니다", "권한이 없습니다" 같은 다양한 에러 상황을 표현하려는데, RuntimeException만으로는 각 상황을 명확히 구분할 수 없는 상황 말이죠.

이런 문제는 실제 개발 현장에서 코드의 가독성과 유지보수성을 떨어뜨립니다. 예외 메시지만으로 에러를 구분하면 에러 코드가 일관되지 않고, 같은 상황에 대해 개발자마다 다른 메시지를 사용하게 됩니다.

또한 클라이언트가 특정 에러에 대해 특별한 처리를 해야 할 때, 문자열 비교로는 안정적인 처리가 불가능합니다. 바로 이럴 때 필요한 것이 커스텀 예외 클래스입니다.

비즈니스 도메인에 맞는 명확한 예외 클래스를 설계하면, 코드의 의도가 분명해지고 에러 처리가 체계적으로 이루어집니다.

개요

간단히 말해서, 커스텀 예외 클래스는 애플리케이션의 비즈니스 상황에 맞게 직접 정의한 예외 타입으로, 표준 예외보다 더 구체적이고 의미 있는 정보를 담을 수 있습니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 도메인 특화된 예외를 사용하면 코드를 읽는 사람이 "왜 이 예외가 발생했는지"를 즉시 이해할 수 있기 때문입니다.

예를 들어, InsufficientStockException을 보면 재고 부족 상황임을 바로 알 수 있지만, RuntimeException("재고가 부족합니다")는 예외를 직접 열어봐야 알 수 있습니다. 기존에는 표준 예외에 메시지만 담아서 던지고, 메시지 내용을 파싱하여 처리했다면, 이제는 예외 클래스 자체가 비즈니스 의미를 담고 있어 타입 안전성이 보장되고 컴파일 타임에 검증이 가능합니다.

이 개념의 핵심 특징은 첫째, 에러 코드와 HTTP 상태 코드를 예외 클래스에 포함시킬 수 있다는 점, 둘째, 예외 계층 구조를 만들어 공통 처리와 개별 처리를 분리할 수 있다는 점, 셋째, 추가 컨텍스트 정보(예: 요청한 ID, 현재 재고 수량 등)를 필드로 담을 수 있다는 점입니다. 이러한 특징들이 에러 처리를 더욱 정교하고 유지보수하기 쉽게 만듭니다.

코드 예제

// 비즈니스 예외의 기본 클래스
@Getter
public abstract class BusinessException extends RuntimeException {
    private final String errorCode;
    private final HttpStatus httpStatus;

    protected BusinessException(String errorCode, String message, HttpStatus httpStatus) {
        super(message);
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
    }
}

// 리소스를 찾을 수 없을 때
public class ResourceNotFoundException extends BusinessException {
    public ResourceNotFoundException(String resource, Long id) {
        super("RESOURCE_NOT_FOUND",
              String.format("%s not found with id: %d", resource, id),
              HttpStatus.NOT_FOUND);
    }
}

// 재고 부족 상황
@Getter
public class InsufficientStockException extends BusinessException {
    private final Long productId;
    private final int requestedQuantity;
    private final int availableQuantity;

    public InsufficientStockException(Long productId, int requestedQuantity, int availableQuantity) {
        super("INSUFFICIENT_STOCK",
              String.format("Requested %d but only %d available", requestedQuantity, availableQuantity),
              HttpStatus.BAD_REQUEST);
        this.productId = productId;
        this.requestedQuantity = requestedQuantity;
        this.availableQuantity = availableQuantity;
    }
}

설명

이것이 하는 일: 비즈니스 로직에서 특정 상황이 발생하면 의미 있는 커스텀 예외를 던지고, GlobalExceptionHandler가 이를 잡아서 예외에 포함된 정보를 기반으로 적절한 응답을 생성합니다. 첫 번째로, BusinessException 추상 클래스는 모든 비즈니스 예외의 부모 역할을 합니다.

에러 코드와 HTTP 상태 코드를 필드로 가지고 있어, 하위 예외들이 이를 상속받아 사용할 수 있습니다. 왜 이렇게 하는지는, 공통 속성을 한 곳에 정의하여 중복을 제거하고 일관성을 보장하기 위함입니다.

그 다음으로, ResourceNotFoundException은 리소스를 찾을 수 없는 일반적인 상황을 표현합니다. 생성자에서 리소스 이름과 ID를 받아 명확한 메시지를 자동으로 생성하므로, 사용하는 쪽에서는 new ResourceNotFoundException("User", 123L)처럼 간단히 호출할 수 있습니다.

내부적으로는 "RESOURCE_NOT_FOUND" 에러 코드와 404 상태 코드가 자동으로 설정됩니다. 세 번째로, InsufficientStockException은 더 구체적인 정보를 담습니다.

단순히 "재고가 부족하다"는 메시지만이 아니라, 요청한 수량과 실제 재고 수량을 필드로 가지고 있어, 클라이언트가 정확히 얼마나 부족한지 알 수 있습니다. 이런 정보는 프론트엔드에서 "현재 3개만 구매 가능합니다" 같은 구체적인 안내를 제공하는 데 사용됩니다.

마지막으로, GlobalExceptionHandler에서 BusinessException을 처리할 때, 예외 객체에서 errorCode와 httpStatus를 꺼내서 응답을 만들면 됩니다. 최종적으로 모든 비즈니스 예외가 일관된 형식으로 처리되면서도, 각 예외마다 고유한 정보를 담을 수 있게 됩니다.

여러분이 이 코드를 사용하면 첫째, 예외 이름만 봐도 어떤 상황인지 즉시 알 수 있고, 둘째, IDE의 자동완성으로 필요한 예외를 쉽게 찾을 수 있으며, 셋째, 에러 코드와 상태 코드가 항상 일관되게 관리됩니다. 실무에서는 새로운 팀원이 합류했을 때도 예외 클래스 이름만 보고 비즈니스 로직을 이해할 수 있어 온보딩이 빨라집니다.

실전 팁

💡 예외 클래스는 도메인별로 패키지를 나누어 관리하세요. 예를 들어 user.exception, product.exception처럼 구조화하면 찾기 쉽습니다.

💡 RuntimeException을 상속하면 체크 예외가 아니므로 메서드 시그니처에 throws를 붙이지 않아도 되어 코드가 깔끔해집니다.

💡 예외 생성 비용을 줄이려면 자주 발생하는 예외는 미리 인스턴스를 만들어두고 재사용하거나, 생성자에서 super(message, null, false, false)로 스택 트레이스를 비활성화할 수 있습니다.

💡 에러 코드는 Enum으로 관리하면 오타를 방지하고 문서화가 쉬워집니다. ErrorCode.INSUFFICIENT_STOCK.getCode() 형태로 사용하세요.

💡 예외에 @JsonIgnore를 사용하여 직렬화 시 불필요한 필드(스택 트레이스 등)를 제외하고, 필요한 정보만 클라이언트에 노출하세요.


4. 유효성 검증 예외 처리

시작하며

여러분이 API의 요청 본문을 검증할 때 이런 상황을 겪어본 적 있나요? 이메일 형식이 잘못되었거나, 필수 필드가 비어있거나, 값의 범위가 올바르지 않을 때, 어떤 필드가 문제인지 클라이언트에게 정확히 알려주고 싶은데 일일이 if문으로 검사하기에는 너무 번거로운 상황 말이죠.

이런 문제는 실제 개발 현장에서 코드 중복과 휴먼 에러를 유발합니다. 각 필드를 수동으로 검증하다 보면 검증 로직이 비즈니스 로직과 뒤섞이고, 새로운 필드가 추가될 때마다 검증 코드를 빼먹기 쉽습니다.

또한 에러 메시지가 일관되지 않아 클라이언트 개발자가 혼란스러워합니다. 바로 이럴 때 필요한 것이 Bean Validation과 @Valid입니다.

이를 활용하면 선언적으로 검증 규칙을 정의하고, 검증 실패 시 발생하는 MethodArgumentNotValidException을 GlobalExceptionHandler에서 통합 처리할 수 있습니다.

개요

간단히 말해서, Bean Validation은 어노테이션을 사용하여 객체의 필드에 검증 규칙을 선언하고, Spring이 자동으로 이를 검증하게 하는 표준 방식입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 대부분의 API는 클라이언트로부터 받은 데이터의 유효성을 검증해야 하는데, 이를 코드로 일일이 작성하면 유지보수가 어렵고 실수가 발생하기 쉽기 때문입니다.

예를 들어, 사용자 등록 API에서 이메일 형식, 비밀번호 길이, 나이 범위 등을 검증할 때, @Email, @Size, @Min 같은 어노테이션만 붙이면 자동으로 검증됩니다. 기존에는 컨트롤러나 서비스 메서드 초반에 if문으로 각 필드를 검사했다면, 이제는 DTO 클래스에 검증 어노테이션을 붙이고 컨트롤러 파라미터에 @Valid만 추가하면 Spring이 자동으로 검증을 수행합니다.

이 개념의 핵심 특징은 첫째, 검증 로직이 DTO 클래스에 집중되어 있어 재사용성이 높다는 점, 둘째, 표준 어노테이션 외에 커스텀 검증기를 만들 수 있다는 점, 셋째, 검증 실패 시 어떤 필드가 어떻게 잘못되었는지 상세한 정보를 얻을 수 있다는 점입니다. 이러한 특징들이 안전하고 유지보수하기 쉬운 API를 만드는 데 기여합니다.

코드 예제

// DTO 클래스에 검증 규칙 선언
@Getter
@Setter
public class CreateUserRequest {

    @NotBlank(message = "이름은 필수입니다")
    @Size(min = 2, max = 50, message = "이름은 2-50자 사이여야 합니다")
    private String name;

    @NotBlank(message = "이메일은 필수입니다")
    @Email(message = "올바른 이메일 형식이 아닙니다")
    private String email;

    @NotBlank(message = "비밀번호는 필수입니다")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,}$",
             message = "비밀번호는 8자 이상, 영문, 숫자, 특수문자를 포함해야 합니다")
    private String password;

    @Min(value = 18, message = "18세 이상만 가입 가능합니다")
    @Max(value = 120, message = "올바른 나이를 입력해주세요")
    private Integer age;
}

// 컨트롤러에서 @Valid 사용
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
    // 여기 도달했다면 모든 검증을 통과한 것
    User user = userService.createUser(request);
    return ResponseEntity.ok(new UserResponse(user));
}

// GlobalExceptionHandler에서 검증 예외 처리
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
    Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
        .collect(Collectors.toMap(
            FieldError::getField,
            FieldError::getDefaultMessage,
            (existing, replacement) -> existing  // 중복 시 첫 번째 에러 사용
        ));
    ValidationErrorResponse response = new ValidationErrorResponse("VALIDATION_FAILED", errors);
    return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}

설명

이것이 하는 일: 클라이언트가 잘못된 데이터를 보내면 컨트롤러 메서드가 실행되기 전에 자동으로 검증이 수행되고, 실패 시 MethodArgumentNotValidException이 발생하여 GlobalExceptionHandler가 이를 처리합니다. 첫 번째로, CreateUserRequest 클래스의 각 필드에 검증 어노테이션이 선언되어 있습니다.

@NotBlank는 null, 빈 문자열, 공백만 있는 문자열을 거부하고, @Email은 이메일 형식을 검증하며, @Pattern은 정규식을 사용한 복잡한 검증을 수행합니다. 왜 이렇게 하는지는, 검증 규칙을 DTO에 명시하여 코드의 자기 문서화를 이루고, 같은 DTO를 사용하는 모든 곳에서 일관된 검증이 적용되게 하기 위함입니다.

그 다음으로, 컨트롤러 메서드의 파라미터에 @Valid가 붙어있으면 Spring이 요청 본문을 역직렬화한 후 자동으로 검증을 수행합니다. 모든 검증을 통과하면 메서드 본문이 실행되지만, 하나라도 실패하면 즉시 MethodArgumentNotValidException이 발생하여 메서드가 실행되지 않습니다.

이렇게 하면 비즈니스 로직에서는 이미 검증된 데이터만 다루게 되어 안전합니다. 세 번째로, GlobalExceptionHandler의 handleValidation 메서드가 호출되면, BindingResult에서 모든 필드 에러를 추출합니다.

getFieldErrors()는 List<FieldError>를 반환하는데, 이를 스트림으로 변환하여 필드명을 키로, 에러 메시지를 값으로 하는 Map으로 만듭니다. 중복 키가 있을 때는 첫 번째 에러만 사용하도록 처리합니다.

마지막으로, ValidationErrorResponse 객체에 에러 코드와 필드별 에러 정보를 담아 반환합니다. 최종적으로 클라이언트는 {"errorCode": "VALIDATION_FAILED", "errors": {"email": "올바른 이메일 형식이 아닙니다", "age": "18세 이상만 가입 가능합니다"}} 같은 상세한 정보를 받게 됩니다.

여러분이 이 코드를 사용하면 첫째, 컨트롤러와 서비스 로직이 검증 코드로 지저분해지지 않고, 둘째, 검증 규칙이 변경되어도 DTO만 수정하면 되며, 셋째, 클라이언트가 정확히 어떤 필드를 어떻게 고쳐야 하는지 알 수 있어 사용자 경험이 향상됩니다. 실무에서는 프론트엔드 개발자가 이 응답을 파싱하여 각 입력 필드 아래에 에러 메시지를 표시하는 방식으로 활용합니다.

실전 팁

💡 중첩된 객체도 검증하려면 필드에 @Valid를 추가하세요. 예를 들어 Address 필드가 있다면 @Valid private Address address;로 선언하면 Address 내부의 검증도 수행됩니다.

💡 같은 DTO를 생성과 수정에 사용하면서 검증 규칙을 다르게 하려면 Validation Groups를 사용하세요. @NotNull(groups = Create.class)처럼 그룹을 지정할 수 있습니다.

💡 커스텀 검증기를 만들려면 @Constraint 어노테이션과 ConstraintValidator 인터페이스를 구현하세요. 예를 들어 @UniqueEmail 같은 DB 조회를 포함한 검증도 가능합니다.

💡 @Validated를 클래스 레벨에 붙이면 메서드 파라미터에 대한 검증도 가능합니다. 예를 들어 @Min이 붙은 Long id를 메서드 파라미터로 받을 때 유용합니다.

💡 메시지를 외부 파일(ValidationMessages.properties)로 분리하면 다국어 지원이 쉬워집니다. message = "{user.email.invalid}" 형태로 키를 참조하세요.


5. 예외 응답 형식 표준화

시작하며

여러분이 여러 개발자와 협업할 때 이런 상황을 겪어본 적 있나요? A 개발자는 에러 응답을 {"error": "message"} 형태로 만들고, B 개발자는 {"message": "...", "code": "..."} 형태로 만들어서, 프론트엔드에서 에러를 처리하는 코드가 각 API마다 달라지는 상황 말이죠.

이런 문제는 실제 개발 현장에서 클라이언트 코드의 복잡도를 크게 증가시킵니다. 일관되지 않은 에러 응답 형식은 프론트엔드에서 각 API마다 다른 에러 처리 로직을 작성하게 만들고, API 문서화도 어렵게 만듭니다.

특히 모바일 앱처럼 업데이트가 자주 일어나지 않는 클라이언트는 예상하지 못한 응답 형식 때문에 크래시가 발생할 수도 있습니다. 바로 이럴 때 필요한 것이 표준화된 에러 응답 형식입니다.

모든 예외 상황에서 일관된 구조의 응답을 반환하면, 클라이언트는 단 하나의 에러 처리 로직으로 모든 API 에러를 다룰 수 있습니다.

개요

간단히 말해서, 에러 응답 형식 표준화는 모든 예외 상황에서 동일한 구조의 JSON 응답을 반환하도록 설계하는 것을 의미합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, API는 백엔드와 프론트엔드 간의 계약이므로 예측 가능하고 일관된 형식이 매우 중요하기 때문입니다.

예를 들어, 에러 응답이 항상 errorCode, message, timestamp 필드를 포함한다고 약속하면, 프론트엔드는 이를 신뢰하고 안전하게 파싱할 수 있습니다. 기존에는 개발자마다 제멋대로 에러 응답을 만들었다면, 이제는 표준 ErrorResponse 클래스를 사용하여 모든 에러가 동일한 형식으로 반환되도록 강제할 수 있습니다.

이 개념의 핵심 특징은 첫째, 에러 코드를 통해 프로그래밍적으로 에러를 처리할 수 있다는 점, 둘째, 메시지는 사람이 읽을 수 있는 설명을 제공한다는 점, 셋째, 추가 정보(path, timestamp, details 등)를 포함하여 디버깅을 돕는다는 점입니다. 이러한 특징들이 견고하고 사용자 친화적인 API를 만듭니다.

코드 예제

// 기본 에러 응답
@Getter
@AllArgsConstructor
public class ErrorResponse {
    private final String errorCode;      // 프로그래밍적 처리를 위한 코드
    private final String message;        // 사람이 읽을 수 있는 메시지
    private final LocalDateTime timestamp;
    private final String path;           // 에러가 발생한 요청 경로

    public ErrorResponse(String errorCode, String message) {
        this.errorCode = errorCode;
        this.message = message;
        this.timestamp = LocalDateTime.now();
        this.path = null;  // 생성자에서는 알 수 없음
    }
}

// 유효성 검증 실패 전용 응답
@Getter
public class ValidationErrorResponse extends ErrorResponse {
    private final Map<String, String> fieldErrors;  // 필드별 에러 정보

    public ValidationErrorResponse(String errorCode, Map<String, String> fieldErrors) {
        super(errorCode, "Validation failed");
        this.fieldErrors = fieldErrors;
    }
}

// GlobalExceptionHandler에서 path 정보 포함하여 응답 생성
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(
        BusinessException ex,
        HttpServletRequest request) {
    ErrorResponse error = new ErrorResponse(
        ex.getErrorCode(),
        ex.getMessage(),
        LocalDateTime.now(),
        request.getRequestURI()  // 요청 경로 포함
    );
    return new ResponseEntity<>(error, ex.getHttpStatus());
}

설명

이것이 하는 일: 어떤 예외가 발생하든 항상 동일한 구조의 JSON 응답을 반환하여, 클라이언트가 예측 가능한 방식으로 에러를 처리할 수 있게 합니다. 첫 번째로, ErrorResponse 클래스는 모든 에러 응답의 기본 형태를 정의합니다.

errorCode는 클라이언트가 프로그래밍적으로 에러를 구분할 수 있게 하고, message는 개발자나 사용자에게 보여줄 설명을 담습니다. timestamp는 에러 발생 시각을 기록하여 로그 추적에 도움을 주고, path는 어떤 엔드포인트에서 에러가 발생했는지 알려줍니다.

왜 이렇게 하는지는, 에러 디버깅 시 필요한 모든 정보를 한 번에 제공하기 위함입니다. 그 다음으로, ValidationErrorResponse는 ErrorResponse를 상속받아 유효성 검증 실패에 특화된 정보를 추가합니다.

fieldErrors 맵은 각 필드별로 어떤 검증이 실패했는지 상세하게 담고 있어, 프론트엔드가 각 입력 필드 옆에 정확한 에러 메시지를 표시할 수 있습니다. 이렇게 상속 구조를 사용하면 기본 필드는 유지하면서 특수한 경우에만 추가 정보를 제공할 수 있습니다.

세 번째로, GlobalExceptionHandler에서 에러 응답을 생성할 때 HttpServletRequest를 파라미터로 받아 요청 경로를 추출합니다. Spring이 자동으로 현재 요청 객체를 주입해주므로, request.getRequestURI()로 에러가 발생한 경로를 얻어 응답에 포함시킵니다.

이 정보는 클라이언트 로그나 에러 리포팅 시스템에 매우 유용합니다. 마지막으로, 생성된 ErrorResponse 객체가 ResponseEntity에 담겨 반환됩니다.

최종적으로 클라이언트는 {"errorCode": "USER_NOT_FOUND", "message": "User not found with id: 123", "timestamp": "2025-01-15T10:30:00", "path": "/api/users/123"} 같은 구조화된 응답을 받게 됩니다. 여러분이 이 코드를 사용하면 첫째, 프론트엔드에서 axios 인터셉터 하나로 모든 API 에러를 처리할 수 있고, 둘째, 에러 코드를 기준으로 특정 에러에 대한 커스텀 처리가 가능하며, 셋째, 에러 추적과 디버깅이 훨씬 쉬워집니다.

실무에서는 Sentry 같은 에러 모니터링 도구와 연동할 때도 표준화된 형식이 있으면 설정이 간편해집니다.

실전 팁

💡 개발 환경에서는 stackTrace 필드를 추가하여 상세한 에러 정보를 제공하고, 운영 환경에서는 보안을 위해 제외하세요. @Profile("dev")를 활용할 수 있습니다.

💡 에러 응답에 traceId를 포함하면 분산 시스템에서 요청을 추적하기 쉽습니다. MDC(Mapped Diagnostic Context)를 활용하여 로그와 응답에 동일한 ID를 포함시키세요.

💡 다국어를 지원해야 한다면 MessageSource를 주입받아 Locale에 따라 다른 메시지를 반환하세요. errorCode를 키로 사용하여 메시지를 조회하는 방식이 효과적입니다.

💡 성공 응답도 비슷한 구조(data, timestamp, path)로 표준화하면 클라이언트 코드가 더욱 일관되게 작성됩니다. ApiResponse<T> 같은 래퍼 클래스를 고려해보세요.

💡 HTTP 상태 코드와 errorCode를 명확히 구분하세요. 상태 코드는 HTTP 레벨의 분류(404, 500 등)이고, errorCode는 비즈니스 레벨의 세부 분류(USER_NOT_FOUND, INSUFFICIENT_STOCK 등)입니다.


6. ResponseEntityExceptionHandler 활용

시작하며

여러분이 GlobalExceptionHandler를 작성할 때 이런 상황을 겪어본 적 있나요? HttpMessageNotReadableException, HttpRequestMethodNotSupportedException 같은 Spring이 자동으로 던지는 수십 가지 예외를 일일이 처리하려니 코드가 너무 길어지고, 어떤 예외를 놓쳤는지도 확신할 수 없는 상황 말이죠.

이런 문제는 실제 개발 현장에서 예외 처리의 누락을 만들어냅니다. Spring MVC가 던지는 표준 예외들을 모르고 지나치면, 특정 상황에서 의도하지 않은 500 에러가 발생하거나 일관되지 않은 에러 응답이 반환될 수 있습니다.

특히 JSON 파싱 실패, 지원하지 않는 HTTP 메서드, 필수 파라미터 누락 같은 흔한 상황을 제대로 처리하지 못하면 사용자 경험이 크게 저하됩니다. 바로 이럴 때 필요한 것이 ResponseEntityExceptionHandler입니다.

이 클래스를 상속받으면 Spring MVC의 주요 예외들에 대한 기본 처리가 이미 구현되어 있어, 필요한 부분만 오버라이드하여 커스터마이징할 수 있습니다.

개요

간단히 말해서, ResponseEntityExceptionHandler는 Spring MVC가 던지는 표준 예외들을 처리하는 메서드를 미리 구현해둔 추상 클래스로, 이를 상속받으면 일관된 예외 처리를 쉽게 구현할 수 있습니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, Spring이 자동으로 던지는 예외는 매우 다양하고(약 15가지 이상) 각각을 개별적으로 처리하는 것은 비효율적이기 때문입니다.

예를 들어, JSON 파싱 실패 시 HttpMessageNotReadableException, 지원하지 않는 HTTP 메서드 사용 시 HttpRequestMethodNotSupportedException 등이 발생하는데, 이들을 일일이 @ExceptionHandler로 처리하는 대신 상속을 통해 한 번에 해결할 수 있습니다. 기존에는 각 예외마다 @ExceptionHandler 메서드를 작성해야 했다면, 이제는 ResponseEntityExceptionHandler를 상속받고 handleExceptionInternal 메서드 하나만 오버라이드하여 모든 예외의 응답 형식을 통일할 수 있습니다.

이 개념의 핵심 특징은 첫째, Spring MVC의 주요 예외들이 이미 적절한 HTTP 상태 코드와 함께 처리된다는 점, 둘째, handleExceptionInternal이라는 공통 진입점을 제공하여 응답 형식을 쉽게 커스터마이징할 수 있다는 점, 셋째, 특정 예외만 다르게 처리하고 싶으면 해당 메서드만 오버라이드하면 된다는 점입니다. 이러한 특징들이 완전하고 일관된 예외 처리 시스템을 구축하는 데 도움을 줍니다.

코드 예제

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    // 모든 Spring MVC 예외의 최종 응답을 만드는 곳
    @Override
    protected ResponseEntity<Object> handleExceptionInternal(
            Exception ex,
            Object body,
            HttpHeaders headers,
            HttpStatusCode statusCode,
            WebRequest request) {

        String message = ex.getMessage();
        String errorCode = determineErrorCode(ex);

        ErrorResponse errorResponse = new ErrorResponse(
            errorCode,
            message,
            LocalDateTime.now(),
            ((ServletWebRequest) request).getRequest().getRequestURI()
        );

        log.error("Spring MVC exception occurred: {}", errorCode, ex);
        return new ResponseEntity<>(errorResponse, headers, statusCode);
    }

    // JSON 파싱 실패 시 더 친절한 메시지 제공
    @Override
    protected ResponseEntity<Object> handleHttpMessageNotReadable(
            HttpMessageNotReadableException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {

        String message = "요청 본문을 읽을 수 없습니다. JSON 형식을 확인해주세요.";
        return handleExceptionInternal(ex, null, headers, status, request);
    }

    // 에러 코드 결정 로직
    private String determineErrorCode(Exception ex) {
        if (ex instanceof MethodArgumentNotValidException) return "VALIDATION_FAILED";
        if (ex instanceof HttpMessageNotReadableException) return "INVALID_REQUEST_BODY";
        if (ex instanceof HttpRequestMethodNotSupportedException) return "METHOD_NOT_ALLOWED";
        if (ex instanceof MissingServletRequestParameterException) return "MISSING_PARAMETER";
        return "BAD_REQUEST";
    }
}

설명

이것이 하는 일: Spring MVC가 던지는 다양한 표준 예외들을 자동으로 잡아서 일관된 형식의 응답으로 변환하고, 필요한 경우 특정 예외만 다르게 처리할 수 있게 합니다. 첫 번째로, GlobalExceptionHandler가 ResponseEntityExceptionHandler를 상속받으면, 부모 클래스에 정의된 약 15개의 protected 메서드들이 자동으로 활성화됩니다.

이 메서드들은 각각 handleHttpMessageNotReadable, handleMethodArgumentNotValid 같은 이름으로 특정 예외를 처리하며, 모두 최종적으로 handleExceptionInternal을 호출합니다. 왜 이렇게 하는지는, 각 예외의 세부 처리는 다르지만 최종 응답 생성 로직은 통일하기 위함입니다.

그 다음으로, handleExceptionInternal을 오버라이드하여 모든 Spring MVC 예외의 응답 형식을 우리가 정의한 ErrorResponse로 통일합니다. 파라미터로 받은 Exception, HttpStatusCode, WebRequest 정보를 조합하여 완전한 에러 응답을 만들어냅니다.

determineErrorCode 헬퍼 메서드는 예외 타입을 분석하여 적절한 에러 코드를 결정합니다. 세 번째로, handleHttpMessageNotReadable을 개별적으로 오버라이드한 예시를 보여줍니다.

JSON 파싱 실패는 흔히 발생하는 에러이므로, 사용자에게 더 친절한 메시지를 제공하기 위해 메시지를 커스터마이징합니다. 하지만 최종 응답 생성은 여전히 handleExceptionInternal에 위임하여 형식의 일관성을 유지합니다.

마지막으로, 이 구조 덕분에 새로운 Spring MVC 버전에서 예외가 추가되어도 자동으로 처리되고, 특정 예외만 다르게 처리하고 싶으면 해당 메서드만 오버라이드하면 됩니다. 최종적으로 클라이언트는 JSON 파싱 실패든, HTTP 메서드 오류든, 파라미터 누락이든 항상 동일한 구조의 ErrorResponse를 받게 됩니다.

여러분이 이 코드를 사용하면 첫째, Spring이 던지는 모든 표준 예외를 빠짐없이 처리할 수 있고, 둘째, 새로운 Spring 버전의 예외도 자동으로 처리되며, 셋째, 특정 예외만 커스터마이징하는 것이 매우 쉬워집니다. 실무에서는 보일러플레이트 코드가 대폭 줄어들어 핵심 비즈니스 예외 처리에 집중할 수 있습니다.

실전 팁

💡 ResponseEntityExceptionHandler의 메서드들은 protected이므로 외부에서 호출되지 않고, Spring이 자동으로 찾아서 호출합니다. public으로 바꾸지 마세요.

💡 handleExceptionInternal의 body 파라미터는 보통 null이지만, 특정 예외 핸들러가 커스텀 바디를 전달할 수 있으므로 null 체크 후 사용하는 것이 안전합니다.

💡 각 예외 핸들러 메서드의 시그니처를 정확히 지켜야 오버라이드가 정상 작동합니다. IDE의 "Override Methods" 기능을 활용하세요.

💡 ProblemDetail을 사용하는 최신 방식(Spring 6+)도 지원하므로, RFC 7807 표준을 따르고 싶다면 ProblemDetail을 반환하도록 수정할 수 있습니다.

💡 handleExceptionInternal에서 로깅을 한 번만 하면 되므로, 개별 핸들러에서 중복 로깅하지 않도록 주의하세요.


7. 비동기 예외 처리

시작하며

여러분이 @Async로 비동기 메서드를 실행할 때 이런 상황을 겪어본 적 있나요? 비동기 작업에서 예외가 발생했는데 GlobalExceptionHandler가 이를 잡지 못하고, 로그에만 스택 트레이스가 찍힌 채 조용히 실패하는 상황 말이죠.

이런 문제는 실제 개발 현장에서 디버깅을 매우 어렵게 만듭니다. 비동기 메서드는 다른 스레드에서 실행되므로, 일반적인 Spring MVC의 예외 처리 메커니즘이 작동하지 않습니다.

이메일 발송, 알림 전송, 데이터 동기화 같은 중요한 작업이 조용히 실패하면, 사용자는 작업이 완료되었다고 생각하지만 실제로는 아무 일도 일어나지 않은 상황이 발생합니다. 바로 이럴 때 필요한 것이 AsyncUncaughtExceptionHandler입니다.

이를 구현하면 비동기 메서드에서 발생한 예외를 전역적으로 처리하고, 적절한 로깅이나 알림을 수행할 수 있습니다.

개요

간단히 말해서, AsyncUncaughtExceptionHandler는 @Async 메서드에서 발생한 예외를 처리하는 전역 핸들러로, AsyncConfigurer를 구현하여 설정할 수 있습니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 비동기 작업은 호출자와 다른 스레드에서 실행되므로 일반적인 try-catch나 @ExceptionHandler로는 예외를 잡을 수 없기 때문입니다.

예를 들어, 주문 완료 후 이메일을 비동기로 발송하는데 SMTP 서버 연결이 실패하면, 이를 감지하고 관리자에게 알려야 재시도하거나 대응할 수 있습니다. 기존에는 각 @Async 메서드 내부에 try-catch를 작성해야 했다면, 이제는 AsyncUncaughtExceptionHandler를 등록하여 모든 비동기 메서드의 예외를 한 곳에서 처리할 수 있습니다.

이 개념의 핵심 특징은 첫째, void를 반환하는 비동기 메서드의 예외를 잡을 수 있다는 점(Future를 반환하는 경우는 get() 호출 시 예외가 전파됨), 둘째, 예외가 발생한 메서드 이름과 파라미터 정보를 제공한다는 점, 셋째, 전역 설정이므로 모든 비동기 메서드에 자동 적용된다는 점입니다. 이러한 특징들이 비동기 작업의 안정성과 모니터링을 강화합니다.

코드 예제

// AsyncConfigurer 구현하여 비동기 설정
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new CustomAsyncExceptionHandler();
    }
}

// 비동기 예외 핸들러 구현
@Slf4j
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        log.error("Async method '{}' threw exception. Params: {}",
                  method.getName(), Arrays.toString(params), ex);

        // 심각한 예외는 관리자에게 알림 (Slack, Email 등)
        if (isCritical(ex)) {
            notifyAdministrator(method, ex, params);
        }

        // 재시도 가능한 작업은 큐에 추가
        if (isRetryable(ex)) {
            addToRetryQueue(method, params);
        }
    }

    private boolean isCritical(Throwable ex) {
        return ex instanceof NullPointerException || ex instanceof OutOfMemoryError;
    }

    private boolean isRetryable(Throwable ex) {
        return ex instanceof MailException || ex instanceof HttpServerErrorException;
    }

    private void notifyAdministrator(Method method, Throwable ex, Object... params) {
        // Slack webhook 또는 이메일 발송
        log.error("CRITICAL: Notifying administrator about async failure");
    }

    private void addToRetryQueue(Method method, Object... params) {
        // Redis 큐 또는 DB에 재시도 작업 추가
        log.info("Adding failed async task to retry queue");
    }
}

설명

이것이 하는 일: @Async 메서드가 void를 반환하고 예외를 던지면, Spring이 자동으로 등록된 AsyncUncaughtExceptionHandler의 handleUncaughtException을 호출하여 예외를 처리합니다. 첫 번째로, AsyncConfig 클래스에서 AsyncConfigurer 인터페이스를 구현합니다.

getAsyncExecutor에서는 비동기 작업을 실행할 ThreadPoolTaskExecutor를 설정하고, getAsyncUncaughtExceptionHandler에서는 우리가 만든 CustomAsyncExceptionHandler를 반환합니다. 왜 이렇게 하는지는, 비동기 실행기와 예외 핸들러를 한 곳에서 중앙 집중식으로 관리하기 위함입니다.

그 다음으로, CustomAsyncExceptionHandler의 handleUncaughtException 메서드가 호출되면, 예외 객체(Throwable), 실패한 메서드 정보(Method), 그리고 메서드에 전달된 파라미터(Object...)를 받습니다. 이 정보를 조합하여 상세한 로그를 남기고, 예외의 심각도와 재시도 가능 여부를 판단합니다.

세 번째로, isCritical 메서드는 NullPointerException이나 OutOfMemoryError 같은 심각한 예외를 감지하여, 이런 경우 즉시 관리자에게 알림을 보냅니다. Slack webhook이나 이메일을 통해 실시간으로 장애를 알릴 수 있어, 빠른 대응이 가능합니다.

isRetryable 메서드는 일시적인 네트워크 오류나 외부 서비스 장애처럼 재시도하면 성공할 가능성이 있는 예외를 식별합니다. 마지막으로, 재시도 가능한 작업은 addToRetryQueue에서 Redis 큐나 데이터베이스에 저장하여, 나중에 배치 작업이나 별도의 워커가 재시도할 수 있게 합니다.

최종적으로 비동기 작업이 실패해도 조용히 넘어가지 않고, 로그 기록 → 알림 → 재시도 큐 추가라는 체계적인 프로세스를 거치게 됩니다. 여러분이 이 코드를 사용하면 첫째, 비동기 작업의 실패를 놓치지 않고 감지할 수 있고, 둘째, 심각도에 따라 차별화된 대응이 가능하며, 셋째, 재시도 메커니즘을 통해 일시적 장애에 대한 복원력이 높아집니다.

실무에서는 이메일 발송, 알림 전송, 외부 API 호출 같은 중요하지만 실패 가능성이 있는 작업을 비동기로 처리할 때 필수적입니다.

실전 팁

💡 Future나 CompletableFuture를 반환하는 비동기 메서드는 이 핸들러가 아니라 get() 호출 시 예외를 받으므로, 호출자가 적절히 처리해야 합니다.

💡 비동기 메서드에서 발생한 예외는 트랜잭션 롤백을 트리거하지 않으므로, 중요한 데이터 변경은 비동기로 처리하지 마세요.

💡 ThreadLocal을 사용하는 경우(MDC, SecurityContext 등) TaskDecorator를 설정하여 부모 스레드의 컨텍스트를 자식 스레드로 전파하세요.

💡 재시도 큐를 구현할 때는 최대 재시도 횟수와 백오프 전략을 설정하여 무한 루프를 방지하세요.

💡 비동기 작업의 성공/실패를 모니터링하려면 Micrometer로 메트릭을 수집하여 Prometheus + Grafana로 시각화하는 것을 권장합니다.


8. 트랜잭션 예외 처리

시작하며

여러분이 데이터베이스 트랜잭션을 사용할 때 이런 상황을 겪어본 적 있나요? 비즈니스 예외를 던졌는데 트랜잭션이 롤백되지 않아서 일부 데이터만 저장되거나, 반대로 의도하지 않은 예외에도 롤백이 되어 정상 데이터까지 날아가는 상황 말이죠.

이런 문제는 실제 개발 현장에서 데이터 정합성 문제를 일으킵니다. Spring의 기본 트랜잭션 설정은 RuntimeException과 Error만 롤백하고 체크 예외는 롤백하지 않는데, 이를 모르고 사용하면 예상하지 못한 동작이 발생합니다.

특히 비즈니스 로직에서 의도적으로 던진 예외가 롤백을 트리거하지 않으면, 절반만 완료된 작업이 데이터베이스에 남아 심각한 버그를 만듭니다. 바로 이럴 때 필요한 것이 @Transactional의 rollbackFor와 noRollbackFor 속성입니다.

이를 활용하면 어떤 예외가 롤백을 트리거할지 명확히 제어할 수 있고, 예외 처리와 트랜잭션 관리를 일관되게 설계할 수 있습니다.

개요

간단히 말해서, 트랜잭션 예외 처리는 @Transactional 설정을 통해 특정 예외 발생 시 트랜잭션을 롤백할지 커밋할지를 제어하는 것을 의미합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 비즈니스 로직의 의도에 따라 트랜잭션 동작을 다르게 해야 하는 경우가 많기 때문입니다.

예를 들어, 결제 실패 시 주문 데이터를 롤백해야 하지만, 단순 조회 실패는 롤백할 필요가 없습니다. 또한 체크 예외를 사용하는 레거시 코드와 통합할 때도 롤백 설정이 중요합니다.

기존에는 트랜잭션 롤백 여부가 예외 타입에 의해 암묵적으로 결정되었다면, 이제는 rollbackFor 속성으로 명시적으로 선언하여 코드의 의도를 분명히 할 수 있습니다. 이 개념의 핵심 특징은 첫째, rollbackFor로 특정 예외를 롤백 대상으로 지정할 수 있다는 점, 둘째, noRollbackFor로 특정 예외를 롤백에서 제외할 수 있다는 점, 셋째, 예외 계층 구조를 활용하여 여러 예외를 한 번에 처리할 수 있다는 점입니다.

이러한 특징들이 데이터 정합성과 비즈니스 로직의 정확성을 보장합니다.

코드 예제

@Service
@Transactional
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentClient paymentClient;
    private final InventoryService inventoryService;

    // 기본 설정: RuntimeException은 롤백, 체크 예외는 롤백 안 함
    public Order createOrder(CreateOrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        inventoryService.decreaseStock(request.getProductId(), request.getQuantity());
        return order;
    }

    // 특정 비즈니스 예외에 대해 롤백 지정
    @Transactional(rollbackFor = PaymentFailedException.class)
    public Order createOrderWithPayment(CreateOrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        inventoryService.decreaseStock(request.getProductId(), request.getQuantity());

        try {
            paymentClient.charge(order.getTotalAmount());
        } catch (IOException e) {
            // 네트워크 오류는 비즈니스 예외로 변환하여 롤백
            throw new PaymentFailedException("Payment service unavailable", e);
        }

        order.setStatus(OrderStatus.PAID);
        return order;
    }

    // 특정 예외는 롤백하지 않음
    @Transactional(noRollbackFor = StockNotificationException.class)
    public Order createOrderWithNotification(CreateOrderRequest request) {
        Order order = orderRepository.save(new Order(request));

        try {
            inventoryService.decreaseStock(request.getProductId(), request.getQuantity());
        } catch (InsufficientStockException e) {
            // 재고 부족은 롤백
            throw e;
        }

        try {
            notificationService.sendLowStockAlert(request.getProductId());
        } catch (StockNotificationException e) {
            // 알림 실패는 롤백하지 않음 (주문은 정상 처리)
            log.warn("Failed to send stock notification", e);
        }

        return order;
    }
}

// 체크 예외지만 롤백이 필요한 경우
public class PaymentFailedException extends Exception {
    public PaymentFailedException(String message, Throwable cause) {
        super(message, cause);
    }
}

설명

이것이 하는 일: 트랜잭션 메서드에서 예외가 발생하면, @Transactional 설정에 따라 자동으로 롤백 또는 커밋이 수행되어 데이터 정합성을 보장합니다. 첫 번째로, createOrder 메서드는 @Transactional만 붙어있어 기본 설정을 따릅니다.

RuntimeException(예: InsufficientStockException)이 발생하면 주문 저장과 재고 감소가 모두 롤백되지만, 체크 예외가 발생하면 롤백되지 않습니다. 왜 이렇게 하는지는, Spring의 기본 정책이 언체크 예외는 프로그래밍 오류로 간주하여 롤백하고, 체크 예외는 비즈니스 예외로 간주하여 롤백하지 않기 때문입니다.

그 다음으로, createOrderWithPayment 메서드는 rollbackFor = PaymentFailedException.class를 지정하여, 이 체크 예외가 발생해도 롤백하도록 명시합니다. 결제 클라이언트 호출 중 IOException이 발생하면 이를 PaymentFailedException으로 감싸서 던지므로, 주문 저장, 재고 감소, 모든 변경사항이 롤백됩니다.

내부적으로 Spring의 트랜잭션 매니저가 예외를 잡아 롤백을 수행합니다. 세 번째로, createOrderWithNotification 메서드는 noRollbackFor = StockNotificationException.class를 지정합니다.

주문 저장과 재고 감소는 정상적으로 처리되어야 하지만, 알림 전송은 부가 기능이므로 실패해도 주문 자체는 롤백하지 않습니다. catch 블록에서 로그만 남기고 예외를 다시 던지지 않거나, 던지더라도 noRollbackFor에 지정되어 있으면 롤백되지 않습니다.

마지막으로, 예외 계층 구조를 활용하면 더욱 세밀한 제어가 가능합니다. 예를 들어 BusinessException을 모두 롤백하고 싶다면 rollbackFor = BusinessException.class로 지정하면, 하위의 모든 예외(InsufficientStockException, PaymentFailedException 등)가 자동으로 롤백 대상이 됩니다.

최종적으로 비즈니스 의도에 맞는 정확한 트랜잭션 동작이 보장됩니다. 여러분이 이 코드를 사용하면 첫째, 예외 발생 시 데이터베이스 상태가 예측 가능하게 되고, 둘째, 비즈니스 규칙에 맞는 정확한 롤백 제어가 가능하며, 셋째, 코드를 읽는 사람이 트랜잭션 경계와 롤백 조건을 명확히 이해할 수 있습니다.

실무에서는 결제, 주문, 재고 관리 같은 중요한 비즈니스 로직에서 필수적으로 설정해야 합니다.

실전 팁

💡 클래스 레벨의 @Transactional과 메서드 레벨의 설정이 충돌하면 메서드 레벨이 우선 적용되므로, 세밀한 제어가 필요한 메서드만 개별 설정하세요.

💡 @Transactional 메서드가 같은 클래스의 다른 메서드를 직접 호출하면 프록시가 작동하지 않아 트랜잭션이 적용되지 않습니다. 별도의 서비스 클래스로 분리하거나 self-injection을 사용하세요.

💡 읽기 전용 트랜잭션(@Transactional(readOnly = true))은 성능 최적화와 실수 방지에 도움이 됩니다. 조회 메서드에는 항상 readOnly를 설정하세요.

💡 nested 트랜잭션이 필요하면 propagation = Propagation.REQUIRES_NEW를 사용하여 독립적인 트랜잭션을 만들 수 있지만, 데드락 위험이 있으므로 신중히 사용하세요.

💡 트랜잭션 경계와 예외 처리를 테스트하려면 @Transactional이 붙은 테스트 메서드에서 DataSource를 주입받아 커넥션 상태를 확인하거나, TransactionSynchronizationManager를 사용하세요.


9. 서비스 간 통신 예외 처리

시작하며

여러분이 마이크로서비스나 외부 API를 호출할 때 이런 상황을 겪어본 적 있나요? RestTemplate으로 다른 서비스를 호출했는데 타임아웃이 발생하거나, 500 에러를 받았거나, 네트워크가 끊겼을 때, 이를 클라이언트에게 어떻게 전달해야 할지 고민되는 상황 말이죠.

이런 문제는 실제 개발 현장에서 분산 시스템의 복잡도를 크게 증가시킵니다. 외부 서비스 호출은 다양한 방식으로 실패할 수 있는데(타임아웃, 연결 거부, 잘못된 응답, 인증 실패 등), 각각을 어떻게 처리하고 사용자에게 어떤 메시지를 보여줄지 결정하기 어렵습니다.

또한 외부 서비스의 장애가 우리 서비스 전체를 마비시키지 않도록 적절한 격리와 폴백 전략이 필요합니다. 바로 이럴 때 필요한 것이 RestTemplate의 ErrorHandler와 적절한 예외 변환 전략입니다.

외부 서비스의 에러를 우리 도메인의 비즈니스 예외로 변환하고, 재시도나 폴백 로직을 적용하여 서비스의 복원력을 높일 수 있습니다.

개요

간단히 말해서, 서비스 간 통신 예외 처리는 외부 HTTP 호출 시 발생하는 다양한 에러를 감지하고, 이를 우리 애플리케이션의 비즈니스 예외로 변환하여 일관되게 처리하는 것을 의미합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 외부 시스템은 우리가 제어할 수 없고 언제든 실패할 수 있으므로, 이에 대한 방어적 프로그래밍이 필수적이기 때문입니다.

예를 들어, 결제 게이트웨이가 일시적으로 다운되었을 때 사용자에게 "내부 서버 오류"가 아니라 "결제 서비스에 일시적 문제가 발생했습니다. 잠시 후 다시 시도해주세요"라는 명확한 안내를 제공해야 합니다.

기존에는 RestTemplate 호출 시마다 try-catch로 감싸고 각 HTTP 상태 코드를 일일이 확인했다면, 이제는 ResponseErrorHandler를 등록하여 모든 외부 호출의 에러를 일관되게 처리할 수 있습니다. 이 개념의 핵심 특징은 첫째, HTTP 상태 코드별로 다른 비즈니스 예외를 던질 수 있다는 점, 둘째, 타임아웃과 네트워크 오류를 구분하여 처리할 수 있다는 점, 셋째, Spring Cloud의 Circuit Breaker 같은 패턴과 결합하여 장애 전파를 막을 수 있다는 점입니다.

이러한 특징들이 안정적인 분산 시스템을 만드는 데 기여합니다.

코드 예제

// RestTemplate에 커스텀 ErrorHandler 등록
@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.setErrorHandler(new CustomResponseErrorHandler());

        // 타임아웃 설정
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
        factory.setConnectTimeout(3000);  // 연결 타임아웃 3초
        factory.setReadTimeout(5000);     // 읽기 타임아웃 5초
        restTemplate.setRequestFactory(factory);

        return restTemplate;
    }
}

// 커스텀 에러 핸들러
@Slf4j
public class CustomResponseErrorHandler implements ResponseErrorHandler {

    @Override
    public boolean hasError(ClientHttpResponse response) throws IOException {
        HttpStatusCode statusCode = response.getStatusCode();
        return statusCode.is4xxClientError() || statusCode.is5xxServerError();
    }

    @Override
    public void handleError(ClientHttpResponse response) throws IOException {
        HttpStatusCode statusCode = response.getStatusCode();
        String responseBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);

        log.error("External API error: status={}, body={}", statusCode, responseBody);

        if (statusCode.is5xxServerError()) {
            // 외부 서비스 서버 오류
            throw new ExternalServiceException(
                "External service is temporarily unavailable",
                statusCode.value()
            );
        } else if (statusCode.value() == 404) {
            // 리소스를 찾을 수 없음
            throw new ExternalResourceNotFoundException(
                "Requested resource not found in external service"
            );
        } else if (statusCode.value() == 401 || statusCode.value() == 403) {
            // 인증/권한 오류
            throw new ExternalAuthenticationException(
                "Authentication failed with external service"
            );
        } else {
            // 기타 클라이언트 오류
            throw new ExternalServiceException(
                "External service returned error: " + responseBody,
                statusCode.value()
            );
        }
    }
}

// 외부 서비스 호출 with 재시도
@Service
@RequiredArgsConstructor
public class PaymentService {

    private final RestTemplate restTemplate;

    @Retryable(
        value = ExternalServiceException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)
    )
    public PaymentResponse processPayment(PaymentRequest request) {
        try {
            return restTemplate.postForObject(
                "https://payment-api.example.com/charge",
                request,
                PaymentResponse.class
            );
        } catch (ResourceAccessException e) {
            // 타임아웃이나 네트워크 오류
            log.error("Network error while calling payment service", e);
            throw new ExternalServiceException("Payment service is unreachable", 503);
        }
    }

    @Recover
    public PaymentResponse recover(ExternalServiceException e, PaymentRequest request) {
        // 3번 재시도 후에도 실패하면 폴백
        log.error("All retry attempts failed for payment", e);
        throw new PaymentFailedException("Payment service is currently unavailable");
    }
}

설명

이것이 하는 일: RestTemplate이 외부 API를 호출하여 4xx, 5xx 응답을 받으면 ErrorHandler가 이를 감지하고, HTTP 상태 코드에 따라 적절한 비즈니스 예외를 던져 GlobalExceptionHandler가 일관되게 처리할 수 있게 합니다. 첫 번째로, RestTemplateConfig에서 RestTemplate 빈을 생성할 때 CustomResponseErrorHandler를 등록합니다.

또한 HttpComponentsClientHttpRequestFactory로 연결 타임아웃 3초, 읽기 타임아웃 5초를 설정하여 외부 서비스가 응답하지 않을 때 무한정 기다리지 않도록 합니다. 왜 이렇게 하는지는, 외부 서비스의 지연이 우리 서비스 전체를 멈추게 하지 않기 위함입니다.

그 다음으로, CustomResponseErrorHandler의 hasError 메서드가 먼저 호출되어 응답이 에러인지 확인합니다. 4xx나 5xx 상태 코드면 true를 반환하여 handleError가 호출되도록 합니다.

handleError에서는 상태 코드와 응답 본문을 분석하여, 5xx면 ExternalServiceException(일시적 오류), 404면 ExternalResourceNotFoundException, 401/403이면 ExternalAuthenticationException을 던집니다. 세 번째로, PaymentService의 processPayment 메서드는 @Retryable을 사용하여 ExternalServiceException 발생 시 최대 3번까지 1초 간격으로 재시도합니다.

Spring Retry 라이브러리가 자동으로 재시도 로직을 수행하므로, 일시적인 네트워크 오류나 서비스 재시작 시 성공 가능성이 높아집니다. ResourceAccessException(타임아웃, 연결 거부 등)도 ExternalServiceException으로 변환하여 재시도 대상에 포함시킵니다.

마지막으로, @Recover 메서드는 모든 재시도가 실패했을 때 호출되는 폴백입니다. 여기서는 최종적으로 PaymentFailedException을 던져 GlobalExceptionHandler가 사용자에게 적절한 에러 메시지를 전달하도록 합니다.

최종적으로 외부 서비스의 일시적 장애는 자동으로 복구되고, 지속적인 장애는 명확한 에러 메시지와 함께 사용자에게 안내됩니다. 여러분이 이 코드를 사용하면 첫째, 외부 서비스 호출마다 에러 처리 코드를 반복하지 않아도 되고, 둘째, 일시적 장애에 대한 자동 복구 능력이 생기며, 셋째, 외부 서비스의 에러가 우리 도메인의 예외로 깔끔하게 변환되어 일관된 처리가 가능합니다.

실무에서는 결제, 알림, 외부 데이터 조회 같은 모든 외부 연동에 이런 패턴을 적용해야 합니다.

실전 팁

💡 WebClient(Spring WebFlux)를 사용하면 더 세밀한 에러 처리와 논블로킹 호출이 가능합니다. onStatus() 메서드로 상태 코드별 처리를 체이닝할 수 있습니다.

💡 Circuit Breaker 패턴(Resilience4j)을 적용하면 외부 서비스가 계속 실패할 때 일정 시간 동안 호출을 차단하여 시스템을 보호할 수 있습니다.

💡 재시도 시 멱등성(idempotency)을 보장해야 합니다. POST 요청은 여러 번 호출되면 중복 생성될 수 있으므로, 멱등성 키를 사용하거나 GET/PUT/DELETE만 재시도하세요.

💡 외부 서비스별로 별도의 RestTemplate 빈을 만들어 타임아웃, 재시도 정책을 다르게 설정하면 더 세밀한 제어가 가능합니다.

💡 외부 API 호출 메트릭(성공률, 응답 시간, 에러율)을 수집하여 모니터링하면 장애를 조기에 발견하고 SLA를 관리할 수 있습니다.


10. 로깅과 모니터링 전략

시작하며

여러분이 운영 중인 서비스에서 에러가 발생했을 때 이런 상황을 겪어본 적 있나요? 사용자가 "오류가 발생했어요"라고 신고했는데, 로그를 확인해도 언제, 어떤 요청에서, 왜 발생했는지 추적이 안 되어서 재현조차 할 수 없는 상황 말이죠.

이런 문제는 실제 개발 현장에서 장애 대응 시간을 크게 증가시킵니다. 예외가 발생했을 때 충분한 컨텍스트 정보를 로깅하지 않으면, 사용자 ID, 요청 파라미터, 시스템 상태 등을 알 수 없어 문제를 재현하고 수정하기가 매우 어렵습니다.

또한 로그가 너무 많으면 중요한 에러가 묻히고, 너무 적으면 디버깅이 불가능한 딜레마에 빠집니다. 바로 이럴 때 필요한 것이 체계적인 로깅과 모니터링 전략입니다.

MDC로 요청별 추적 ID를 부여하고, 적절한 로그 레벨을 사용하며, 중요한 에러는 실시간 알림을 받아 빠르게 대응할 수 있습니다.

개요

간단히 말해서, 예외 처리에서의 로깅과 모니터링은 발생한 에러의 컨텍스트를 충분히 기록하고, 중요한 에러를 실시간으로 감지하여 빠른 대응을 가능하게 하는 전략입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 운영 환경에서는 디버거를 붙일 수 없으므로 로그가 유일한 디버깅 수단이기 때문입니다.

예를 들어, 특정 사용자가 특정 상품을 주문할 때만 간헐적으로 발생하는 에러는, 요청 ID, 사용자 ID, 상품 ID, 타임스탬프 같은 정보가 모두 로깅되어 있어야 재현하고 수정할 수 있습니다. 기존에는 예외 메시지만 로깅하거나 스택 트레이스만 찍었다면, 이제는 MDC로 요청을 추적하고, 구조화된 로그 형식을 사용하며, 로그 집계 도구(ELK, Splunk)와 연동하여 분석할 수 있습니다.

이 개념의 핵심 특징은 첫째, MDC(Mapped Diagnostic Context)로 요청별 고유 ID를 부여하여 분산 추적이 가능하다는 점, 둘째, 로그 레벨(ERROR, WARN, INFO)을 적절히 사용하여 중요도를 구분한다는 점, 셋째, 실시간 알림과 메트릭 수집으로 장애를 조기에 발견한다는 점입니다. 이러한 특징들이 안정적인 서비스 운영과 빠른 장애 대응을 가능하게 합니다.

코드 예제

// MDC 필터로 요청별 추적 ID 부여
@Component
@Order(Filters.LOGGING_FILTER_ORDER)
public class MdcLoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        String traceId = UUID.randomUUID().toString().substring(0, 8);
        MDC.put("traceId", traceId);
        MDC.put("userId", extractUserId(request));
        MDC.put("requestUri", request.getRequestURI());

        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.clear();  // 요청 처리 후 반드시 정리
        }
    }

    private String extractUserId(HttpServletRequest request) {
        // JWT 토큰이나 세션에서 사용자 ID 추출
        return Optional.ofNullable(request.getHeader("X-User-Id")).orElse("anonymous");
    }
}

// GlobalExceptionHandler에서 체계적인 로깅
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private final MetricService metricService;
    private final AlertService alertService;

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException ex, HttpServletRequest request) {
        // WARN 레벨: 예상된 비즈니스 예외
        log.warn("Business exception occurred. errorCode={}, message={}, uri={}",
                 ex.getErrorCode(), ex.getMessage(), request.getRequestURI());

        // 메트릭 수집
        metricService.incrementCounter("business_exception", "error_code", ex.getErrorCode());

        ErrorResponse error = new ErrorResponse(ex.getErrorCode(), ex.getMessage());
        return new ResponseEntity<>(error, ex.getHttpStatus());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpectedException(Exception ex, HttpServletRequest request) {
        // ERROR 레벨: 예상하지 못한 심각한 에러
        log.error("Unexpected exception occurred. uri={}, params={}",
                  request.getRequestURI(), request.getParameterMap(), ex);

        // 메트릭 수집
        metricService.incrementCounter("unexpected_exception", "type", ex.getClass().getSimpleName());

        // 심각한 에러는 즉시 알림
        if (isCritical(ex)) {
            alertService.sendAlert(
                "Critical Exception",
                String.format("Type: %s, Message: %s, TraceId: %s",
                             ex.getClass().getSimpleName(),
                             ex.getMessage(),
                             MDC.get("traceId"))
            );
        }

        ErrorResponse error = new ErrorResponse("INTERNAL_SERVER_ERROR", "An unexpected error occurred");
        return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
    }

    private boolean isCritical(Exception ex) {
        return ex instanceof NullPointerException
            || ex instanceof OutOfMemoryError
            || ex instanceof StackOverflowError;
    }
}

// logback-spring.xml 설정 (MDC 포함)
// <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [traceId=%X{traceId}] [userId=%X{userId}] %logger{36} - %msg%n</pattern>

설명

이것이 하는 일: 모든 HTTP 요청에 고유 ID를 부여하고, 예외 발생 시 요청 정보, 사용자 정보, 에러 상세를 함께 로깅하여 나중에 로그를 추적하고 분석할 수 있게 합니다. 첫 번째로, MdcLoggingFilter가 모든 요청의 가장 앞단에서 실행되어 traceId, userId, requestUri를 MDC에 저장합니다.

UUID로 생성된 8자리 traceId는 이 요청과 관련된 모든 로그에 자동으로 포함되어, 수천 개의 로그 중에서 특정 요청의 흐름만 필터링할 수 있습니다. 왜 이렇게 하는지는, 분산 시스템에서 하나의 사용자 요청이 여러 서비스와 스레드를 거치며 처리될 때 전체 흐름을 추적하기 위함입니다.

그 다음으로, GlobalExceptionHandler에서 예외를 처리할 때 로그 레벨을 적절히 구분합니다. BusinessException은 예상된 비즈니스 로직의 일부이므로 WARN 레벨로 기록하고, 예상하지 못한 Exception은 프로그래밍 오류일 가능성이 높으므로 ERROR 레벨로 기록합니다.

이렇게 구분하면 로그 모니터링 도구에서 ERROR 레벨만 필터링하여 정말 중요한 문제에 집중할 수 있습니다. 세 번째로, metricService를 통해 예외 발생 횟수를 메트릭으로 수집합니다.

Micrometer 같은 라이브러리를 사용하면 Prometheus나 Datadog으로 메트릭을 전송하여, "지난 1시간 동안 INSUFFICIENT_STOCK 에러가 100건 발생" 같은 대시보드를 만들 수 있습니다. 이는 로그보다 훨씬 빠르게 트렌드를 파악하고 이상 징후를 감지하는 데 유용합니다.

마지막으로, NullPointerException이나 OutOfMemoryError 같은 치명적인 에러는 alertService를 통해 Slack이나 PagerDuty로 즉시 알림을 보냅니다. 최종적으로 개발자는 장애가 발생한 즉시 알림을 받고, MDC에 저장된 traceId를 사용하여 해당 요청의 모든 로그를 추적하고, 메트릭 대시보드에서 에러 발생 패턴을 분석하여 빠르게 원인을 파악하고 수정할 수 있습니다.

여러분이 이 코드를 사용하면 첫째, 특정 사용자의 특정 요청을 로그에서 즉시 찾을 수 있고, 둘째, 에러의 심각도와 빈도를 실시간으로 모니터링할 수 있으며, 셋째, 치명적인 장애는 발생 즉시 알림을 받아 대응할 수 있습니다. 실무에서는 이런 체계적인 로깅과 모니터링이 서비스의 안정성과 팀의 대응 속도를 결정합니다.

실전 팁

💡 비동기 메서드에서 MDC를 사용하려면 TaskDecorator를 설정하여 부모 스레드의 MDC를 자식 스레드로 복사해야 합니다.

💡 민감한 정보(비밀번호, 카드 번호, 주민번호 등)는 절대 로그에 남기지 마세요. DTO에 @JsonIgnore나 커스텀 toString()을 구현하여 마스킹하세요.

💡 구조화된 로그 형식(JSON)을 사용하면 ELK 스택에서 파싱과 검색이 쉬워집니다. Logstash의 Logback encoder를 사용하세요.

💡 로그 보관 정책을 설정하여 오래된 로그는 압축하거나 삭제하세요. 디스크 용량 부족으로 서비스가 다운되는 것을 방지할 수 있습니다.

💡 에러율(error rate) 임계값을 설정하여, 1분 동안 에러가 100건 이상 발생하면 자동으로 알림을 보내는 규칙을 만드세요.


#Spring#ExceptionHandler#ControllerAdvice#ErrorHandling#RestAPI#Java