스프링에서 @ControllerAdvice를 언제 쓰고 어떻게 구성해야 할까
목차
빠른 답
- 여러 컨트롤러에서 반복되는 예외 처리 로직은 @ControllerAdvice로 모으는 것이 기본입니다.
- JSON 에러 응답이 목적이라면 보통 @RestControllerAdvice를 먼저 검토하면 됩니다.
- 적용 범위는 패키지나 애너테이션 기준으로 좁힐 수 있어 전역 남용을 막을 수 있습니다.
- 핸들러가 기대대로 동작하지 않으면 예외 타입, 우선순위, 응답 본문 생성 방식을 먼저 확인해야 합니다.
빠른 답
- 여러 컨트롤러에서 반복되는 예외 처리, 바인딩, 공통 모델 설정은
@ControllerAdvice로 모으는 것이 기본입니다. - REST API에서 JSON 에러 응답을 일관되게 내려야 한다면 보통
@RestControllerAdvice를 먼저 선택합니다. - 적용 범위는
basePackages,assignableTypes,annotations로 좁혀야 전역 남용과 예상치 못한 충돌을 줄일 수 있습니다. - 동작이 기대와 다르면 예외가 발생한 위치, 예외 타입 매칭, 여러 어드바이스의 우선순위와 응답 직렬화 방식을 먼저 확인해야 합니다.
컨트롤러 코드가 늘어나기 시작하면 비슷한 로직이 반복됩니다. 입력 검증 실패를 400으로 바꾸는 코드, 특정 비즈니스 예외를 404나 409로 바꾸는 코드, 날짜 문자열을 공통 형식으로 파싱하는 코드가 대표적입니다. 처음에는 각 컨트롤러에서 직접 처리해도 큰 문제가 없어 보이지만, API 수가 늘어나면 응답 포맷이 조금씩 달라지고 수정 범위도 넓어집니다.
이 지점에서 @ControllerAdvice는 단순한 편의 기능이 아니라 컨트롤러 계층의 공통 정책을 모으는 장치가 됩니다. 예외 처리 규칙을 중앙화하고, 바인딩 규칙을 통일하고, 화면용 MVC에서는 공통 모델 값까지 한 곳에서 제공할 수 있습니다. 특히 REST API 프로젝트에서는 @RestControllerAdvice와 함께 쓰는 패턴이 사실상 표준에 가깝습니다.
왜 @ControllerAdvice가 필요한가
@ControllerAdvice가 필요한 이유는 코드 중복을 줄이기 위해서만이 아닙니다. 더 중요한 목적은 API 계약을 안정적으로 유지하는 것입니다.
예를 들어 사용자 조회 API에서는 UserNotFoundException을 404로 내려주는데, 주문 조회 API에서는 같은 성격의 예외를 500으로 내려준다면 클라이언트 입장에서는 일관된 처리 규칙을 만들기 어렵습니다. 검증 실패 응답도 어떤 API는 message만 내려주고, 어떤 API는 errors 배열을 내려준다면 프론트엔드와의 약속이 계속 흔들리게 됩니다.
컨트롤러마다 try-catch를 두는 구조는 처음엔 단순하지만 시간이 갈수록 다음 문제가 생깁니다.
- 상태 코드 규칙이 컨트롤러별로 갈라집니다.
- 응답 본문 구조가 제각각이 됩니다.
- 에러 정책을 바꿀 때 수정 범위가 커집니다.
- 예외 처리 코드가 비즈니스 로직을 가립니다.
반대로 공통 정책을 @ControllerAdvice로 모아두면, 컨트롤러는 요청과 응답의 핵심 흐름에 집중하고 예외 응답 형식은 한 곳에서 통제할 수 있습니다. 팀 단위 개발에서는 이 차이가 더 크게 드러납니다.
@ControllerAdvice와 @RestControllerAdvice의 차이
두 애너테이션은 비슷해 보이지만 용도가 완전히 같지는 않습니다.
@ControllerAdvice는 여러 컨트롤러에 공통으로 적용되는 보조 기능을 등록하는 용도입니다. 대표적으로 다음 세 가지를 묶을 수 있습니다.
@ExceptionHandler@InitBinder@ModelAttribute
반면 @RestControllerAdvice는 여기에 REST 응답 친화적인 성격이 더해진 형태입니다. 실무에서는 예외를 잡아서 JSON 본문으로 바로 반환하는 경우가 많기 때문에, REST API 중심 프로젝트라면 @RestControllerAdvice가 더 자연스럽습니다.
선택 기준은 어렵지 않습니다.
- 서버 사이드 템플릿을 렌더링하는 MVC 화면 중심이라면
@ControllerAdvice - JSON 응답이 기본인 API 서버라면
@RestControllerAdvice
중요한 점은 @RestControllerAdvice가 새로운 개념이라기보다, 예외 응답을 본문으로 바로 직렬화하기 편한 형태라는 것입니다. 그래서 API 서버에서는 별다른 이유가 없다면 @RestControllerAdvice를 우선 검토하면 됩니다.
스프링 MVC에서 전역 처리 로직은 어떻게 적용될까
요청이 들어오면 스프링 MVC는 컨트롤러 메서드를 찾고, 파라미터 바인딩과 검증을 수행한 뒤 핸들러를 실행합니다. 이 과정에서 예외가 발생하면 스프링은 @ExceptionHandler 메서드를 찾습니다.
동작 순서를 단순화하면 다음과 같습니다.
- 현재 컨트롤러 안에 해당 예외를 처리할
@ExceptionHandler가 있는지 확인합니다. - 없다면
@ControllerAdvice또는@RestControllerAdvice에 등록된 핸들러를 찾습니다. - 가장 구체적으로 매칭되는 예외 핸들러가 선택됩니다.
- 반환 타입에 따라 뷰를 렌더링하거나 JSON 본문을 응답합니다.
@InitBinder는 이보다 앞선 바인딩 단계에서 동작합니다. 문자열 트리밍, 날짜 포맷 통일, 커스텀 에디터 등록 같은 작업을 공통화할 때 유용합니다. @ModelAttribute는 주로 MVC 화면 렌더링에서 공통 모델 값을 넣는 데 사용됩니다.
여기서 한 가지는 분명히 구분해야 합니다. @ControllerAdvice는 컨트롤러 계층에 걸친 공통 처리 장치이지, 애플리케이션 전체의 모든 예외를 무조건 잡는 장치는 아닙니다. 필터, 서블릿 컨테이너, 시큐리티 체인에서 먼저 발생한 예외는 기대와 다르게 흘러갈 수 있습니다.
적용 범위는 반드시 좁혀서 시작하는 것이 좋다
작은 프로젝트에서는 전역 하나로 시작해도 괜찮지만, 서비스가 커질수록 무제한 전역 어드바이스는 오히려 관리 비용을 키웁니다. 공개 API와 관리자 API의 에러 정책이 다를 수 있고, 웹 페이지 컨트롤러와 REST 컨트롤러를 구분해야 할 수도 있기 때문입니다.
이럴 때는 적용 대상을 제한해야 합니다. 스프링은 basePackages, assignableTypes, annotations 속성으로 범위를 제어할 수 있습니다. 아래 예시는 API 패키지에만 적용되는 REST 예외 처리기입니다.
package com.example.demo.global;
import org.springframework.core.annotation.Order;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Order(1)
@RestControllerAdvice(
basePackages = "com.example.demo.api",
annotations = {org.springframework.web.bind.annotation.RestController.class}
)
public class ApiExceptionAdvice {
}
이 설정은 두 가지 면에서 실용적입니다. 첫째, 템플릿 기반 웹 컨트롤러까지 같은 정책이 흘러가는 일을 막을 수 있습니다. 둘째, 나중에 어드바이스가 여러 개로 나뉘어도 책임 범위를 설명하기 쉬워집니다.
특정 컨트롤러만 묶고 싶다면 assignableTypes가 유용하고, 공개 API 여부 같은 정책 단위로 묶고 싶다면 커스텀 애너테이션과 annotations 조합이 더 유지보수에 좋습니다. 핵심은 "전역"이라는 이름에 끌려 전체 적용부터 시작하지 않는 것입니다.
실전 예시: 공통 에러 응답과 전역 예외 처리기
실무에서 가장 자주 쓰는 패턴은 에러 응답 DTO를 먼저 정하고, 예외를 상태 코드와 코드값으로 매핑하는 방식입니다. 이 구조를 잡아두면 클라이언트는 문자열 메시지뿐 아니라 시스템용 코드도 기준으로 분기할 수 있습니다.
먼저 응답 형태를 고정합니다.
package com.example.demo.global;
import java.time.LocalDateTime;
import java.util.List;
public record ErrorResponse(
String code,
String message,
LocalDateTime timestamp,
List<FieldErrorDetail> errors
) {
public static ErrorResponse of(String code, String message) {
return new ErrorResponse(code, message, LocalDateTime.now(), List.of());
}
public static ErrorResponse of(String code, String message, List<FieldErrorDetail> errors) {
return new ErrorResponse(code, message, LocalDateTime.now(), errors);
}
public record FieldErrorDetail(String field, String reason) {
}
}
이제 전역 예외 처리기에서 예외별 정책을 연결합니다. 검증 오류, 비즈니스 오류, 예상하지 못한 예외를 분리하면 운영 중 로그 해석도 쉬워집니다.
package com.example.demo.global;
import com.example.demo.user.UserNotFoundException;
import jakarta.validation.ConstraintViolationException;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(basePackages = "com.example.demo.api")
public class GlobalApiExceptionAdvice {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of("USER_NOT_FOUND", e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
List<ErrorResponse.FieldErrorDetail> errors = e.getBindingResult()
.getFieldErrors()
.stream()
.map(this::toFieldErrorDetail)
.toList();
return ResponseEntity.badRequest()
.body(ErrorResponse.of("VALIDATION_ERROR", "요청 값 검증에 실패했습니다.", errors));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException e) {
return ResponseEntity.badRequest()
.body(ErrorResponse.of("INVALID_PARAMETER", e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of("INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다."));
}
private ErrorResponse.FieldErrorDetail toFieldErrorDetail(FieldError error) {
return new ErrorResponse.FieldErrorDetail(
error.getField(),
error.getDefaultMessage()
);
}
}
이 방식의 장점은 명확합니다. 컨트롤러마다 try-catch를 넣지 않아도 되고, 어떤 예외가 어떤 HTTP 상태 코드로 번역되는지가 한눈에 드러납니다. 또한 내부 예외 메시지를 그대로 बाह부에 노출하지 않고, 공개용 메시지와 시스템용 코드 체계를 분리하기도 쉽습니다.
@InitBinder와 @ModelAttribute는 언제 쓸까
@ControllerAdvice가 예외 처리에만 쓰이는 것은 아닙니다. 화면 렌더링이 있는 프로젝트에서는 @InitBinder와 @ModelAttribute도 꽤 유용합니다.
@InitBinder는 입력 전처리를 공통화할 때 적합합니다. 예를 들어 공백 문자열을 null로 정리하거나, 날짜 문자열을 특정 형식으로 파싱하는 규칙을 한 곳에 두고 싶을 때 쓸 수 있습니다. @ModelAttribute는 모든 뷰에 서비스 이름, 현재 사용자 정보, 공통 메뉴 같은 값을 넣고 싶을 때 사용합니다.
package com.example.demo.web;
import java.beans.PropertyEditorSupport;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
@ControllerAdvice(basePackages = "com.example.demo.web")
public class WebControllerAdvice {
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(String.class, new PropertyEditorSupport() {
@Override
public void setAsText(String text) {
setValue(text == null || text.trim().isEmpty() ? null : text.trim());
}
});
binder.registerCustomEditor(LocalDate.class, new PropertyEditorSupport() {
@Override
public void setAsText(String text) {
setValue(LocalDate.parse(text, DateTimeFormatter.ISO_DATE));
}
});
}
@ModelAttribute("serviceName")
public String serviceName() {
return "Demo Admin";
}
}
REST API만 다루는 프로젝트라면 @ModelAttribute는 자주 등장하지 않을 수 있습니다. 하지만 관리자 화면, 백오피스, 서버 렌더링 UI가 함께 있는 프로젝트에서는 여전히 쓸모가 있습니다. 중요한 것은 API용 어드바이스와 웹 화면용 어드바이스를 같은 클래스에 섞지 않는 것입니다.
에러 응답 설계에서 자주 놓치는 기준
전역 예외 처리기를 도입했다고 해서 자동으로 좋은 API가 되는 것은 아닙니다. 응답 형식과 상태 코드 정책이 부실하면, @ControllerAdvice는 단지 예외를 모아두는 창고가 될 뿐입니다.
설계할 때는 최소한 아래 기준을 먼저 정하는 편이 좋습니다.
- 클라이언트 잘못은
400계열, 서버 문제는500계열로 구분합니다. - 사람이 읽는
message와 시스템 분기용code를 분리합니다. - 검증 오류라면 어떤 필드가 왜 실패했는지
errors목록을 제공할지 결정합니다. - 내부 예외 메시지와 스택 트레이스를 외부에 그대로 노출하지 않습니다.
- 도메인 예외를 HTTP 의미와 연결하는 매핑 규칙을 문서화합니다.
예를 들어 DuplicateEmailException은 409, UserNotFoundException은 404, 인증 실패는 401, 권한 부족은 403처럼 기준을 세워두면 컨트롤러와 서비스 계층이 같은 방향으로 움직이기 쉬워집니다.
응답이 실제로 일관되게 내려오는지는 간단한 요청으로도 확인할 수 있습니다. 아래처럼 잘못된 입력을 보내면 검증 실패 응답 구조를 바로 점검할 수 있습니다.
curl -i -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"email":"","name":" "}'
이런 요청에 대해 400 상태 코드와 함께 code, message, errors가 같은 구조로 내려온다면 기본적인 전역 정책이 제대로 잡힌 것입니다. 반대로 어떤 API는 JSON, 어떤 API는 기본 에러 페이지를 반환한다면 어드바이스 범위나 반환 방식부터 다시 확인해야 합니다.
기대대로 동작하지 않을 때 확인할 것
@ControllerAdvice를 붙였는데 핸들러가 실행되지 않는 경우는 생각보다 흔합니다. 대부분은 프레임워크 문제라기보다 적용 범위와 예외 흐름을 잘못 이해해서 생깁니다.
가장 먼저 확인할 것은 실제 예외 타입입니다. 코드에서는 IllegalArgumentException을 기대했는데 실제로는 MethodArgumentNotValidException이나 HttpMessageNotReadableException이 발생하고 있을 수 있습니다. 로그를 보고 정확한 타입부터 확인해야 합니다.
그다음으로는 다음 항목을 점검하면 됩니다.
@ExceptionHandler메서드의 파라미터 타입이 실제 발생 예외와 맞는지 확인합니다.- 여러 어드바이스가 있다면
@Order와 적용 범위를 확인합니다. @ControllerAdvice를 써놓고 JSON 응답을 기대하고 있지는 않은지 봅니다.- 예외가 컨트롤러 바깥, 예를 들어 필터나 시큐리티 체인에서 먼저 발생한 것은 아닌지 구분합니다.
- 기본 에러 응답이 섞여 나온다면 커스텀 핸들러보다 먼저 처리된 경로가 있는지 확인합니다.
또 하나의 실전 팁은 "모든 예외를 한 클래스에서 다 처리하려고 하지 말라"는 것입니다. 검증 오류, 도메인 오류, 인증/인가 오류, 외부 연동 실패는 성격이 다릅니다. 정책도 다르고 로그 레벨도 다를 수 있으므로, 범위와 책임을 나누는 편이 결국 더 단단한 구조가 됩니다.
운영 가능한 구조로 정리하는 방법
실무에서는 거대한 GlobalExceptionAdvice 하나보다, 역할이 분리된 어드바이스 여러 개가 더 낫습니다. 예를 들어 아래처럼 나누면 책임이 훨씬 분명해집니다.
- 공개 API용
@RestControllerAdvice - 관리자 웹용
@ControllerAdvice - 검증 오류 전담 핸들러
- 도메인 예외 전담 핸들러
여기에 더해 예외 클래스와 에러 코드 체계를 같이 관리하면 유지보수성이 올라갑니다. 중요한 것은 "전역 처리"를 넓게 적용하는 것이 아니라 "공통 정책"을 정확한 범위에 적용하는 것입니다.
정리하면 @ControllerAdvice는 컨트롤러 공통 로직을 모으는 출발점이고, 실제 품질 차이는 그 위에 어떤 응답 규칙과 적용 범위를 설계하느냐에서 갈립니다. REST API 프로젝트라면 @RestControllerAdvice를 기본값으로 두고, DTO 구조와 상태 코드 매핑을 먼저 정하는 방식이 가장 안정적입니다.
원문 참고
https://www.maeil-mail.kr/question/13
댓글
댓글 쓰기