예외는 다
@RestControllerAdvice(GlobalExceptionHandler)가 잡는 줄 알았는데,
인증 실패(401)만은 그게 안 잡고 다른 곳에서 응답이 만들어졌다.이번 과정의 핵심은 "예외 처리기를 하나 만드는 것"이 아니라,
요청이 컨트롤러에 도달하기 전과 후의 예외 처리 경로가 다르다는 걸 이해하는 것이었다.
예외를 처리하는 자리가 두 군데로 나뉜다. 경계는 "컨트롤러에 도달했느냐"다.
요청
│
[필터 체인] ← 인증/인가가 여기서 일어남
│ · 인증 실패(토큰 없음/틀림) → AuthenticationEntryPoint (401)
│ · 인가 실패(권한 없음) → AccessDeniedHandler (403)
▼
[DispatcherServlet → Controller]
│ · 컨트롤러/서비스에서 던진 예외 → @RestControllerAdvice (GlobalExceptionHandler)
▼
응답
@RestControllerAdvice는 컨트롤러 안에서 던진 예외만 잡는다. 그런데 인증 실패는 컨트롤러에 도달하기 전, 필터 단계에서 막히기 때문에 Advice가 손댈 수 없다. 그래서 그 401 응답은 AuthenticationEntryPoint가 만든다.
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()는 "인증 안 된 요청이 보호된 자원에 접근했을 때" 호출되는 진입점이다.
{code, message} 형태(ErrorResponse)로 통일하기로 했다. 컨트롤러 예외는 GlobalExceptionHandler가 그 포맷으로 만드는데, 필터 단 401도 같은 포맷이어야 클라이언트가 한 방식으로 처리할 수 있다.ErrorResponse(UNAUTHORIZED) 를 직접 JSON으로 써서, 어디서 막히든 응답 모양이 일관되게 했다.| 문제 | 원인 | 해결 |
|---|---|---|
GET /api//products?category=... 가 401 | URL의 //(슬래시 중복)가 permitAll의 /api/products/** 패턴에 매칭 안 됨 → anyRequest().authenticated()로 떨어짐 → 토큰 없는 요청이 차단됨 | URL을 /api/products(슬래시 하나)로 수정 |
이때 "이 401 응답을 대체 누가 만들었나" 가 헷갈렸다. 컨트롤러는 실행되지도 않았고(경로가 안 잡힘), GlobalExceptionHandler도 안 탔다. 답은 EntryPoint였다 — 필터 단에서 "인증 필요"로 막히면서 commence()가 호출돼 401 JSON을 만든 것이다.
200(빈 목록)을 기대했는데 401이 나온 건 API 버그가 아니라, 인가 규칙이 그 경로를 보호 대상으로 판단 → EntryPoint가 401 생성이라는 흐름이 그대로 작동한 결과였다.
@RestControllerAdvice, 인증 실패 → AuthenticationEntryPoint(401), 인가 실패 → AccessDeniedHandler(403).@RestControllerAdvice는 컨트롤러 안에서 던진 예외만 처리한다.
인증 실패처럼 컨트롤러에 도달하기 전 필터 단계에서 막힌 요청은 AuthenticationEntryPoint가 401 응답을 만든다.
우리는 이걸 커스텀해 컨트롤러 예외와 동일한 {code, message} 포맷으로 통일했고,
오늘 겪은 // 오타 401 사건이 이 흐름을 정확히 보여준 사례였다.
컨트롤러 전에 막힌 401은 Advice가 아니라
AuthenticationEntryPoint가 만든다.
그래서 EntryPoint를 커스텀해 에러 응답 포맷을 전 구간 통일했다.