401은 왜 @RestControllerAdvice가 아니라 EntryPoint가 처리하나

최정윤·2026년 8월 24일

Spring

목록 보기
35/39

예외는 다 @RestControllerAdvice(GlobalExceptionHandler)가 잡는 줄 알았는데,
인증 실패(401)만은 그게 안 잡고 다른 곳에서 응답이 만들어졌다.

이번 과정의 핵심은 "예외 처리기를 하나 만드는 것"이 아니라,
요청이 컨트롤러에 도달하기 전과 후의 예외 처리 경로가 다르다는 걸 이해하는 것이었다.


1. 핵심 원리

예외를 처리하는 자리가 두 군데로 나뉜다. 경계는 "컨트롤러에 도달했느냐"다.

요청
  │
[필터 체인]  ← 인증/인가가 여기서 일어남
  │  · 인증 실패(토큰 없음/틀림) → AuthenticationEntryPoint (401)
  │  · 인가 실패(권한 없음)      → AccessDeniedHandler (403)
  ▼
[DispatcherServlet → Controller]
  │  · 컨트롤러/서비스에서 던진 예외 → @RestControllerAdvice (GlobalExceptionHandler)
  ▼
응답

@RestControllerAdvice컨트롤러 안에서 던진 예외만 잡는다. 그런데 인증 실패는 컨트롤러에 도달하기 전, 필터 단계에서 막히기 때문에 Advice가 손댈 수 없다. 그래서 그 401 응답은 AuthenticationEntryPoint가 만든다.


2. 코드로 보는 연결

SecurityConfig — 인증 실패 시 우리 EntryPoint를 쓰도록 등록한다.

.exceptionHandling(ex ->
        ex.authenticationEntryPoint(jwtAuthenticationEntryPoint))

JwtAuthenticationEntryPoint — 실제로 401 JSON을 써서 내려보낸다.

@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
                     AuthenticationException authException) throws IOException {

    ErrorCode errorCode = ErrorCode.UNAUTHORIZED;
    ErrorResponse body = ErrorResponse.from(errorCode);

    response.setStatus(errorCode.getStatus().value());          // 401
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.setCharacterEncoding("UTF-8");
    response.getWriter().write(jsonMapper.writeValueAsString(body));
}

commence()는 "인증 안 된 요청이 보호된 자원에 접근했을 때" 호출되는 진입점이다.


3. 기술적 의사결정 — 왜 EntryPoint를 커스텀했나

  • 스프링 시큐리티 기본 EntryPoint는 빈 401이나 기본 로그인 페이지/HTML을 돌려준다. 우리 서비스는 REST API라 그게 안 맞았다.
  • 우리 팀은 모든 오류 응답을 {code, message} 형태(ErrorResponse)로 통일하기로 했다. 컨트롤러 예외는 GlobalExceptionHandler가 그 포맷으로 만드는데, 필터 단 401도 같은 포맷이어야 클라이언트가 한 방식으로 처리할 수 있다.
  • 그래서 EntryPoint를 커스텀해 같은 ErrorResponse(UNAUTHORIZED) 를 직접 JSON으로 써서, 어디서 막히든 응답 모양이 일관되게 했다.

4. 트러블슈팅 — "없는 카테고리"인데 401이 뜬 사건

문제원인해결
GET /api//products?category=... 가 401URL의 //(슬래시 중복)가 permitAll/api/products/** 패턴에 매칭 안 됨 → anyRequest().authenticated()로 떨어짐 → 토큰 없는 요청이 차단됨URL을 /api/products(슬래시 하나)로 수정

이때 "이 401 응답을 대체 누가 만들었나" 가 헷갈렸다. 컨트롤러는 실행되지도 않았고(경로가 안 잡힘), GlobalExceptionHandler도 안 탔다. 답은 EntryPoint였다 — 필터 단에서 "인증 필요"로 막히면서 commence()가 호출돼 401 JSON을 만든 것이다.

200(빈 목록)을 기대했는데 401이 나온 건 API 버그가 아니라, 인가 규칙이 그 경로를 보호 대상으로 판단 → EntryPoint가 401 생성이라는 흐름이 그대로 작동한 결과였다.


5. 실무 활용

  • 에러가 안 잡힐 땐 "어느 단계에서 막혔나"부터 본다. 컨트롤러 전(필터)이면 Advice로는 못 잡는다.
  • 세 가지 처리기를 구분해두면 응답이 일관된다: 컨트롤러 예외 → @RestControllerAdvice, 인증 실패 → AuthenticationEntryPoint(401), 인가 실패 → AccessDeniedHandler(403).
  • REST API에서 시큐리티를 쓰면 EntryPoint(그리고 필요시 AccessDeniedHandler)를 커스텀해 에러 포맷을 통일하는 게 거의 필수다.

6. 마무리

@RestControllerAdvice는 컨트롤러 안에서 던진 예외만 처리한다.
인증 실패처럼 컨트롤러에 도달하기 전 필터 단계에서 막힌 요청AuthenticationEntryPoint가 401 응답을 만든다.
우리는 이걸 커스텀해 컨트롤러 예외와 동일한 {code, message} 포맷으로 통일했고,
오늘 겪은 // 오타 401 사건이 이 흐름을 정확히 보여준 사례였다.

한 줄 요약

컨트롤러 전에 막힌 401은 Advice가 아니라 AuthenticationEntryPoint가 만든다.
그래서 EntryPoint를 커스텀해 에러 응답 포맷을 전 구간 통일했다.

profile
콩떡

0개의 댓글