Spring Boot에서 @ControllerAdvice를 이용한 집중식 예외 처리

1. 개요

REST API를 작성할 때, 각 controller가 자체적으로 try-catch를 하고 자기 방식대로 에러 response를 반환한다면, 하나의 시스템 내에 수십 개의 다른 JSON 에러 포맷을 갖게 될 것입니다. @ControllerAdvice는 예외 처리 로직을 한 곳에서만 정의할 수 있게 해주고, 전체(또는 일부) controller에 적용함으로써 이 문제를 해결합니다.

이 글을 읽은 후 여러분은 표준적인 GlobalExceptionHandler를 작성하는 방법, exception을 적절한 HTTP status로 매핑하는 방법, 일관된 error body를 반환하는 방법, 그리고 그것이 올바르게 작동하는지 확인하는 테스트를 작성하는 방법을 배우게 될 것입니다.

2. 집중식 처리 없을 때의 문제점

Spring Boot를 처음 배웠을 때 자주 보게 되는 “흔한” controller를 살펴보겠습니다:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<?> getOrder(@PathVariable Long id) {
        try {
            Order order = orderService.findById(id);
            return ResponseEntity.ok(order);
        } catch (OrderNotFoundException ex) {
            Map<String, String> body = new HashMap<>();
            body.put("error", ex.getMessage());
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
        }
    }
}

문제점:

  • Error body를 만드는 로직(Map<String, String>)이 모든 method, 모든 controller에서 반복됩니다.
  • 새로운 method에서 try-catch를 빠뜨리기 쉽고, 이는 500 에러와 함께 stack trace가 노출되는 결과로 이어집니다.
  • MethodArgumentNotValidException(validation @Valid 실패)처럼 “공통” exception을 처리할 곳이 없습니다.

주의: 모든 exception을 controller의 try-catch로 잡아야 하는 것은 아닙니다. Controller는 service를 호출하기만 하고, “exception을 response로 변환하는” 작업은 별도의 exception 처리 계층의 책임입니다.

3. @ControllerAdvice란 무엇인가

@ControllerAdvice는 class 레벨 annotation으로, 여러 controller를 동시에 지원(advise)하는 메서드들을 포함한 class를 표시합니다 — 가장 일반적인 용도는 @ExceptionHandler를 통한 exception 처리입니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
        ErrorResponse body = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                Instant.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }
}

설명:

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody이며, REST API에서는 반환 body가 항상 JSON으로 serialize되므로 사용합니다.
  • @ExceptionHandler(OrderNotFoundException.class)는 이 메서드를 OrderNotFoundException 타입을 처리하도록 등록합니다 — 이제 OrderController에서 try-catch가 필요 없습니다.
  • ErrorResponse는 간단한 record로, 시스템 전체에서 사용할 표준화된 에러 포맷을 정의합니다.

3.1. ErrorResponse와 커스텀 exception 정의

public record ErrorResponse(int status, String message, Instant timestamp) {
}
public class OrderNotFoundException extends RuntimeException {

    public OrderNotFoundException(Long orderId) {
        super("id = " + orderId + "인 order를 찾을 수 없습니다");
    }
}

OrderNotFoundException은 RuntimeException을 상속하므로 모든 곳에서 throws를 선언할 필요가 없습니다. OrderService 내에서는 찾을 수 없을 때만 throw하면 됩니다:

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Order findById(Long id) {
        return orderRepository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }
}

이제 controller는 훨씬 깔끔해집니다:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderService.findById(id);
    }
}

try-catch도 없고, 수동으로 ResponseEntity를 생성할 필요도 없습니다 — GlobalExceptionHandler가 exception을 response로 변환하는 모든 작업을 처리합니다.

4. 여러 종류의 exception 처리

실제로는 다양한 종류의 에러가 존재합니다: not found (404), request 검증 실패 (400), 데이터 충돌 (409), 시스템 에러 (500). @ExceptionHandler는 여러 메서드 선언을 지원하며, 각 메서드는 하나 이상의 exception을 처리합니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) {
        return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex) {
        return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleConflict(DataIntegrityViolationException ex) {
        return buildResponse(HttpStatus.CONFLICT, "데이터 충돌이 발생했습니다. 다시 시도해주세요");
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
        return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "시스템 오류가 발생했습니다. 지원팀에 연락해주세요");
    }

    private ResponseEntity<ErrorResponse> buildResponse(HttpStatus status, String message) {
        ErrorResponse body = new ErrorResponse(status.value(), message, Instant.now());
        return ResponseEntity.status(status).body(body);
    }
}

설명:

  • 각 @ExceptionHandler는 한 종류의 에러만 처리하고, Spring은 throw된 exception의 클래스 계층 구조를 기반으로 가장 일치하는 메서드를 자동으로 선택합니다.
  • handleUnknown은 Exception.class를 잡아서 “안전망” 역할을 합니다 — 예상치 못한 에러에서 원본 stack trace가 client에 노출되는 것을 방지합니다. 이는 거의 모든 GlobalExceptionHandler에 포함되어야 합니다.
  • 메서드 선언 순서는 우선순위를 결정하지 않습니다; Spring은 throw된 exception과 가장 구체적으로 일치하는 exception type의 handler를 선택합니다.

주의: 같은 @ControllerAdvice 내에서 두 개의 @ExceptionHandler가 같은 exception type을 공유하면, Spring은 앱 시작 시 ambiguous mapping 에러를 발생시킵니다.

5. @Valid로 request 검증 에러 처리

매우 흔한 상황: Bean Validation으로 request body를 검증합니다. 검증이 실패하면 Spring이 자동으로 MethodArgumentNotValidException을 throw하고, 여러분은 이를 잡아서 읽기 좋은 형태로 포맷하기만 하면 됩니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CreateOrderRequest(
        @NotBlank(message = "customerName은 공백일 수 없습니다") String customerName,
        @Positive(message = "amount는 0보다 커야 합니다") BigDecimal amount
) {
}
@PostMapping
public Order createOrder(@Valid @RequestBody CreateOrderRequest request) {
    return orderService.create(request);
}

MethodArgumentNotValidException handler를 추가하세요:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
    Map<String, String> fieldErrors = new LinkedHashMap<>();
    for (FieldError error : ex.getBindingResult().getFieldErrors()) {
        fieldErrors.put(error.getField(), error.getDefaultMessage());
    }

    Map<String, Object> body = new LinkedHashMap<>();
    body.put("status", HttpStatus.BAD_REQUEST.value());
    body.put("errors", fieldErrors);
    body.put("timestamp", Instant.now());

    return ResponseEntity.badRequest().body(body);
}

설명:

  • ex.getBindingResult().getFieldErrors()는 어떤 field가 잘못되었는지와 해당 메시지 목록을 반환합니다 — 이것이 바로 @NotBlank, @Positive에서 선언한 메시지입니다.
  • LinkedHashMap을 사용하면 field 순서가 유지되어 디버깅할 때 읽기 쉽습니다.

customerName이 비어있고 amount가 음수인 request를 보내면 다음과 같이 반환됩니다:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "status": 400,
  "errors": {
    "customerName": "customerName은 공백일 수 없습니다",
    "amount": "amount는 0보다 커야 합니다"
  },
  "timestamp": "2026-10-05T10:15:30.123Z"
}

6. basePackages로 적용 범위 좁히기

기본적으로 @ControllerAdvice는 application context 내의 모든 controller에 적용됩니다. 여러 module(admin과 public-api 같은)이 다른 방식의 에러 처리를 필요로 할 때, 범위를 제한할 수 있습니다:

@RestControllerAdvice(basePackages = "com.nonladev.shop.api.admin")
public class AdminExceptionHandler {

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
        ErrorResponse body = new ErrorResponse(
                HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다", Instant.now());
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
    }
}

basePackages는 이 advice를 com.nonladev.shop.api.admin package 내에 있는 controller에만 적용합니다. basePackages 외에도 annotations(특정 annotation이 있는 controller에만 적용)이나 assignableTypes(특정 controller 클래스들에만 적용)를 사용할 수 있습니다.

7. ProblemDetail 사용 (RFC 7807 표준) — 현대적인 방식

Spring Framework 6부터는 RFC 7807을 구현한 ProblemDetail이 기본으로 포함되어 있으므로 — 더 이상 ErrorResponse를 직접 정의할 필요가 없습니다:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleOrderNotFound(OrderNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Order Not Found");
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

Response는 다음과 같은 형태를 가집니다:

{
  "type": "about:blank",
  "title": "Order Not Found",
  "status": 404,
  "detail": "id = 99인 order를 찾을 수 없습니다",
  "instance": null,
  "timestamp": "2026-10-05T10:20:00Z"
}

ProblemDetail은 팀이 공통 표준을 따르고 싶거나, RFC 7807을 이해하는 client/tooling과 통합하려면(예: API gateway, OpenAPI tooling의 일부) 좋은 선택지입니다.

8. GlobalExceptionHandler 테스트 작성

테스트는 올바른 status code와 body를 보장해야 합니다 — handler가 실수로 수정되는 것을 방지하려면 필수입니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(GlobalExceptionHandler.class)
class OrderControllerExceptionTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        given(orderService.findById(99L))
                .willThrow(new OrderNotFoundException(99L));

        mockMvc.perform(get("/api/orders/99"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.message").value("id = 99인 order를 찾을 수 없습니다"));
    }
}

또는 handler를 직접 단위 테스트하고 Spring context를 로드하지 않을 수도 있습니다:

class GlobalExceptionHandlerTest {

    private final GlobalExceptionHandler handler = new GlobalExceptionHandler();

    @Test
    void shouldMapOrderNotFoundTo404() {
        ResponseEntity<ErrorResponse> response =
                handler.handleNotFound(new OrderNotFoundException(42L));

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
        assertThat(response.getBody().message())
                .isEqualTo("id = 42인 order를 찾을 수 없습니다");
    }
}

두 번째 방식이 MockMvc를 로드하지 않아서 더 빠르고, exception → response 매핑 로직만 테스트하려는 경우(routing과는 무관)에 적합합니다.

9. 흔한 실수들

  • Exception.class handler 빠뜨리기: 예상치 못한 에러(NullPointerException, DB 연결 오류 등)가 발생하면 client는 Spring Boot의 기본 500 response와 함께 stack trace를 받게 됩니다 — 내부 정보가 노출됩니다. 항상 마지막에 “catch-all” handler를 만드세요.
  • Exception message에 민감한 정보 포함: JPA에서 나오는 exception은 테이블명, 컬럼명 등을 포함할 수 있습니다. DataIntegrityViolationException의 ex.getMessage()를 client에 그대로 반환하지 마세요. 대신 일반적인 메시지를 사용하세요.
  • 여러 @ControllerAdvice에서 같은 exception 처리: 어느 것이 실행되는지 불명확해서 혼동하기 쉽습니다. 여러 advice를 사용한다면 @Order로 순서를 명시하거나, 가장 좋은 방법은 공통 로직을 하나의 class로 통합하는 것입니다.
  • @ControllerAdvice에서 business logic 처리: advice는 exception을 response로 “변환”하는 작업만 해야 합니다. Business 로직을 포함하지 마세요(예: 에러 메일 보내기) — 이런 작업은 service나 별도의 event listener에 있어야 합니다.

주의: @ControllerAdvice는 controller method(또는 그로부터 호출되는 계층) 내에서 throw된 exception만 잡습니다. 다른 thread(예: @Async)에서 throw되거나 DispatcherServlet 이전의 filter에서 throw된 exception은 잡아지지 않습니다.

10. 결론

@ControllerAdvice를 @ExceptionHandler와 함께 사용하면 에러 처리 로직을 완전히 controller에서 분리할 수 있고, 모든 에러 response가 같은 포맷을 유지하도록 하며, 민감한 정보가 client에 노출되는 것을 방지할 수 있습니다. 항상 Exception.class를 잡는 handler를 마지막 안전망으로 두세요. Request 검증은 @Valid와 MethodArgumentNotValidException를 사용하세요. 그리고 RFC 7807 표준을 따르고 싶다면 ProblemDetail 사용을 고려하세요. Handler에 대한 테스트도 service 테스트만큼 중요합니다 — 왜냐하면 이것이 에러 발생 시 client가 보는 내용을 결정하기 때문입니다.

spring-boot예외-처리controller-advicejavarest-api