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.classhandler 빠뜨리기: 예상치 못한 에러(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가 보는 내용을 결정하기 때문입니다.