기본 콘텐츠로 건너뛰기

스프링에서 `@ControllerAdvice`를 언제 쓰고 어떻게 구성해야 할까

스프링에서 @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 메서드를 찾습니다.

동작 순서를 단순화하면 다음과 같습니다.

  1. 현재 컨트롤러 안에 해당 예외를 처리할 @ExceptionHandler가 있는지 확인합니다.
  2. 없다면 @ControllerAdvice 또는 @RestControllerAdvice에 등록된 핸들러를 찾습니다.
  3. 가장 구체적으로 매칭되는 예외 핸들러가 선택됩니다.
  4. 반환 타입에 따라 뷰를 렌더링하거나 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

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...