주제: 보안 예외의 발생·전파·응답 결정 — 필터 예외가 @RestControllerAdvice에 안 잡히는 이유, 401 vs 403이 갈리는 지점, 그리고 사정권 안에서 advice가 실제로 도는 구조(표준 예외 파이프라인) · 갱신: 2026-07-31 · 상태: 진행중 태그: #spring-security #필터 #예외처리 #ErrorResponse #spring-mvc 확인 필요:
ExceptionHandlingConfigurer가 명시된 EntryPoint를 우선하는 내부 구조와, ClientRegistration이 1개일 때 로그인 페이지를 건너뛰는OAuth2LoginConfigurer분기는 소스를 직접 열어 확인하지 않았다. 동작(302 → 401 전환) 자체는 curl로 확인됨.AsyncRequestTimeoutException(503)의 상태코드는 클래스를 직접 열어 확인하지 못했다. 나머지 상태코드와 20종 목록은 javap 로 실측했다.
핵심은 하나 — 범위의 문제가 아니라 위치의 문제다.
[예외 처리기들의 사정권]
[요청] → Filter1 → JwtFilter → ... → 31.ExceptionTranslationFilter → 32.AuthorizationFilter → DispatcherServlet → Controller
↑ │ │
여기서 예외 └ 자기보다 "뒤"에서 난 └ 여기서부터 advice 사정권
│ AuthenticationException /
│ AccessDeniedException 만 번역
│
둘 다의 사각지대
[못 잡힌 예외가 가는 곳]
필터에서 throw → 컨테이너 도달 → /error 로 ERROR 디스패치 → BasicErrorController → 기본 JSON
{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/..."}
→ 내 ErrorResponse 포맷도 아니고, 401이어야 할 게 500으로 나간다
비유: @ExceptionHandler 는 소방서에 걸어둔 대응 매뉴얼이다. "어떤 화재든 출동한다"고 아무리 넓게 써놔도 신고 전화가 안 오면 출동하지 않는다. 신고 전화 = DispatcherServlet이 예외를 받고 "처리할 핸들러 있나?" 뒤지는 동작.
사정권 안에서는 다시 누가 무엇을 판정하는가로 나뉜다. 거부를 판정하는 곳과 그 사유를 아는 곳이 서로 다르다.
[사정권 안의 역할 분담]
JwtFilter 사유는 안다(만료/위조). 막을 권한이 없다 → 통과시킴
AnonymousAuthenticationFilter 빈 SecurityContext에 익명 토큰 → 401/403 판정의 근거가 여기서 생김
ExceptionTranslationFilter try { 다음 필터 호출 }
AuthorizationFilter 막을 권한은 있다. 사유는 모른다 → AccessDeniedException "하나만"
↑ 예외가 거슬러 올라옴 (필터는 줄이 아니라 중첩된 함수 호출)
ExceptionTranslationFilter 의 catch → 익명? EntryPoint(401) : AccessDeniedHandler(403)
비유2: JwtFilter는 목격자, AuthorizationFilter는 경비원, ExceptionTranslationFilter는 둘을 부르는 관리자. 경비원은 "안 됩니다"만 알고, 왜 안 되는지는 목격자만 안다.
필터가 응답을 왜 직접 써야 하는지(체인 중단·response 공유)는 → [[ServletFilter]].
그럼 사정권 안으로 들어온 예외는 어떻게 되는가. 여기서 다시 어느 입구로 들어가는가로 갈린다.
[advice 사정권 안 — 예외가 들어가는 세 개의 입구]
예외 발생 (DispatcherServlet 안쪽)
│
├─(A) 내가 붙인 @ExceptionHandler CustomException / DataIntegrityViolationException
│ └→ 각자 ErrorResponse 직접 반환하고 끝. handleExceptionInternal 안 거침
│
├─(B) GlobalExceptionHandler 가 상속받은 부모 ResponseEntityExceptionHandler
│ 의 handleException @ExceptionHandler({20종}) 스프링 MVC 표준 예외
│ └→ 타입별 분기(handleMethodArgumentNotValid 등)
│ └→ handleExceptionInternal ← 공통 출구. 여기만 오버라이드하면 20종이 전부 통일
│
└─(C) @ExceptionHandler(Exception.class) A·B 어디에도 안 걸린 것 = 진짜 예상 못 한 예외 → 500
비유3: A는 전담 창구, B는 ResponseEntityExceptionHandler 가 미리 파둔 표준 창구, C는 그물 맨 아래층. B의 모양이 핵심이다 — 입구 하나 → 20갈래 → 출구 하나의 모래시계다. 안에서 아무리 갈라져도 결국 한 곳으로 다시 모이기 때문에, 출구 한 곳만 잡으면 20종의 응답이 전부 통일된다.
- 한줄답:
AuthenticationEntryPoint인터페이스에 딱 하나 있는 메서드. 이름 뜻은 "인증 절차를 개시(commence)한다". - 원리 — 시작 → 과정 → 결과:
- [시작] Spring Security가 원래 폼 로그인 기준으로 설계됐다. 인증 안 된 사용자가 보호된 페이지에 오면 "로그인 화면으로 보내 인증을 시작시켜라"가 이 메서드의 원래 역할
- [과정] 그래서 폼 로그인 기본 구현은
response.sendRedirect("/login")— 인증 시작 = 로그인 페이지로 - [과정] REST API에는 리다이렉트할 화면이 없다 → 의미가 바뀐다. 401 + JSON을 내려주고 "인증을 시작하라"는 신호는 응답 코드로 전달한다
- [결과] ==그래서 이름이 "인증 실패 처리"가 아니라 "인증 개시"다==
- 시그니처:
void commence(HttpServletRequest, HttpServletResponse, AuthenticationException) throws IOException, ServletException - 주의: 반환 타입이
void인 게 포인트 — ==값을 리턴하는 게 아니라 response 객체에 직접 써넣는다.== 세 번째 파라미터AuthenticationException이 실패 사유이고, 이 타입을 보고 만료인지 위조인지 구분할 수 있다. - 호출 주체: 보통
ExceptionTranslationFilter. 필터가 직접 부르는 방식도 있다. - 연결: → 아래 Q.401과 403은 어디서 갈리나 (
handle과의 대비), → [[ServletFilter]] Q.commence에서 response객체를 만들었잖아 (체인이 거기서 끝나는 이유)
- 한줄답: 맞다. ==범위가 아니라 위치의 문제==라 모든 예외의 부모인
Throwable로 잡아도 결과가 같다. - 원리 — 시작 → 과정 → 결과:
- [시작]
@ExceptionHandler가 정하는 건 "어떤 타입을 잡을까" 하나뿐이다. "언제 실행될까"는 DispatcherServlet이 정한다 - [과정] advice는 능동적으로 예외를 감시하는 게 아니라, ==DispatcherServlet이 예외를 받았을 때 "이걸 처리할 핸들러 있나?" 하고 뒤져서 호출해주는 수동적 구조==다
- [과정] 필터는 DispatcherServlet 앞단이라 예외가 거기까지 도달하지 못한다 → advice를 실행시켜 줄 주체가 없다
- [과정] 톰캣이 받아서
/error로 ERROR 디스패치 →BasicErrorController가 기본 JSON을 낸다 - [결과]
/error로 재진입할 때도 원래 예외는 다시 던져지는 게 아니라 ==jakarta.servlet.error.exception이라는 request attribute로만 전달==돼서, advice가 반응할 대상 자체가 없다
- [시작]
- 증명: 같은
IllegalArgumentException이라도 컨트롤러·서비스에서 던지면 advice가 정상적으로 잡는다. 타입은 동일한데 결과가 다르다는 건, 변수가 타입이 아니라 위치라는 뜻이다. - 연결: → [[ServletFilter]] (필터가 응답을 직접 써야 하는 이유), → [[JavaBasics]] (컨트롤러·서비스 예외가 advice까지 가는 흐름), → 아래 Q.handleExceptionInternal 이거는 뭐야 (사정권 안에서는 어떻게 도는가)
- 한줄답: 아니다. ==
AuthenticationException과AccessDeniedException두 타입만 처리한다.==IllegalArgumentException은 그냥 통과시켜 위로 올려보낸다. - 원리:
ExceptionTranslationFilter는chain.doFilter()를 try-catch로 감싼 필터일 뿐이다. try 블록 안 = 자기 뒤쪽 필터들이므로, 거기서 난 두 타입만 번역한다. 타입 제한이 1차 이유이고, 순서가 2차 조건이다 —addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)로 등록한 내 필터는 인증 필터들이 놓이는 구간에 들어가는데, 그 구간은 ==ExceptionTranslationFilter보다 앞==이다. 순서를 고쳐 뒤에 놓더라도 타입이 안 맞으면 여전히 못 잡는다. (순서가 어떻게 정해지는지는 → [[FilterChain]]) - 401 vs 403 판정:
AnonymousAuthenticationFilter가 비어 있는 SecurityContext에 익명 토큰을 채워둔다. 그보다 뒤의AuthorizationFilter가 막으면, 그 바로 앞의ExceptionTranslationFilter가 잡아서 — 익명이면AuthenticationEntryPoint(401), 인증은 됐는데 권한이 없으면AccessDeniedHandler(403). - 주의: 그래서 필터가
ExceptionTranslationFilter보다 앞에 있다면 ==응답을 스스로 마무리 짓는 것 말고 선택지가 없다== — "직접 처리하거나, 아예 예외를 안 내보내거나" 둘 중 하나다. - 연결: → 위 Q.commence 이게 뭔데, → [[FilterChain]] (필터 체인 순서)
- 한줄답: 방법은 있고, ==advice를 포기할 필요도 없다.== 직접 만드는 건 세 가지 중 하나일 뿐이다.
- 원리:
- ① 필터에서 직접 JSON 작성 —
ObjectMapper로 직렬화해response.getWriter()에 쓴다. 간단하지만 상태코드·인코딩·직렬화를 필터가 직접 하게 되어 응답 포맷 로직이GlobalExceptionHandler(이는 컴트롤러에서 발생하는 예외일 경우에 해당) 와 필터 두 군데로 갈라진다. 나중에 응답 스펙에 필드를 추가할 때 한 쪽만 고치는 사고가 난다. - ② HandlerExceptionResolver 위임 —
HandlerExceptionResolver는 DispatcherServlet이 예외 처리에 쓰는 바로 그 컴포넌트다. 필터에 직접 주입해resolver.resolveException(request, response, null, e)를 호출하면 ==DispatcherServlet을 안 거치고도 advice의 @ExceptionHandler를 태울 수 있다.== 우회로를 하나 뚫는 셈이다. - ③ 애초에 예외를 안 던지기 — 토큰이 이상하면 인증 안 된 상태로 통과시키고
AuthenticationEntryPoint가 401을 내게 둔다.
- ① 필터에서 직접 JSON 작성 —
- 주의(②의 함정):
HandlerExceptionResolver타입 빈이 여러 개라@Qualifier("handlerExceptionResolver")가 필수다. 그런데 ==Lombok의@RequiredArgsConstructor는 필드에 붙은@Qualifier를 생성자 파라미터로 복사하지 않는다== → qualifier가 유실돼NoUniqueBeanDefinitionException이 난다. 생성자를 직접 쓰거나lombok.config에lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier를 넣어야 한다. - 공통점: 어느 방식이든 응답 작성 코드는 EntryPoint(또는 advice) 안에만 있다 — 그게 책임 분리가 지켜지는 지점이다.
- 연결: → [[JavaBasics]] (HandlerExceptionResolver가 advice를 찾는 상세)
- 한줄답: 맞다. 다만 "그 다음 필터"가 아니라 ==체인 거의 끝의
AuthorizationFilter==이고, 그 바로 앞의ExceptionTranslationFilter가 잡아서 EntryPoint를 부른다. - 원리 — 시작 → 과정 → 결과:
- [시작]
JwtFilter가 SecurityContext를 안 채우고chain.doFilter()로 통과시킨다 - [과정] 뒤쪽의
AnonymousAuthenticationFilter가 비어 있는 컨텍스트에 익명 토큰을 채운다 → 그래서 그 뒤에서 보는 건 "인증 정보 없음"이 아니라 "익명 사용자"다 - [과정] 체인 맨 끝의
AuthorizationFilter가 인가 검사에서 막고AccessDeniedException을 던진다 - [과정] 그 바로 앞의
ExceptionTranslationFilter가 그걸 잡아 익명 여부를 판정 → 익명이므로AuthenticationEntryPoint호출 - [결과] 401이 나간다. 다만 ==EntryPoint를 등록 안 하면 기본값이 302 리다이렉트==다 (oauth2Login이 켜져 있으면 구글 로그인으로). 프론트가 401을 못 받으면 refresh 분기 자체가 안 돈다
- [시작]
- 연결: → [[TokenAuth]] (refresh 재발급 흐름), → [[OAuth2]]
- 한줄답: 순수한 "전부 통과"는 정석이 아니다. Spring Security 자체 필터는 ==없으면 통과, 있는데 틀리면 즉시 거절==하는 하이브리드다.
- 원리:
BearerTokenAuthenticationFilter(JWT용 공식 필터)와BasicAuthenticationFilter는 둘 다 같은 모양이다 — 토큰이 없으면chain.doFilter()로 조용히 통과시키고, 토큰이 있는데 인증에 실패하면 그 자리에서AuthenticationEntryPoint를 부르고 체인을 끊는다. 헤더가 없는 건permitAll경로(로그인·회원가입)에서 정상이므로 예외로 다룰 일이 아니고, 헤더를 보냈는데 틀린 건 클라이언트 오류의 명확한 신호라 즉시 알려주는 게 맞다. - 왜 이렇게: 전부 통과시키면 ==실패 사유(만료/서명불일치/타입불일치)가 EntryPoint까지 전달되지 않는다.== 그래서 request attribute에 사유를 심는 우회책이 필요해진다. 하이브리드는 실패 지점이 곧 필터 안이라 예외 객체를 그대로 손에 쥐고 있어 그 트릭이 불필요하다.
- 주의: 정리하면 세 방식의 성격 — 직접 작성(포맷 중복) / Resolver 위임(advice 재사용) / 안 던지기 + EntryPoint(Security 표준 흐름, 401 의미에 부합).
- 연결: → [[ServletFilter]] (체인 중단 메커니즘), → [[TroubleShooting]]
- 한줄답: ==302를 보낸 건 구글이 아니라 우리 서버==다.
oauth2Login()이 EntryPoint 슬롯에 몰래 심어둔 기본 구현이 한 일이다. - 원리 — 시작 → 과정 → 결과:
- [시작]
.exceptionHandling()을 설정하지 않으면 EntryPoint 슬롯이 비어 있고,oauth2Login()이 자기 기본값을 후보로 등록해둔다 - [과정] 미인증 요청이 거부되면 그 기본 구현(
LoginUrlAuthenticationEntryPoint)의commence()가sendRedirect("/oauth2/authorization/google")을 호출한다 → 302 ① - [과정] 브라우저가 그 경로를 재요청하면
OAuth2AuthorizationRequestRedirectFilter가 state를 만들고accounts.google.com으로 보낸다 → 302 ② - [결과] 302는 두 번 일어난다. ①이 문제이고 ②는 정상적인 로그인 시작 과정이다
- [시작]
- 왜 하필 구글이었나: 스프링이 "로그인 수단"으로 인지하는 건
.formLogin()과.oauth2Login()뿐이다. 우리 로컬 로그인은@PostMapping("/auth/login")평범한 컨트롤러라 ==시큐리티는 그 존재를 모른다.== 등록된 ClientRegistration이 google 하나뿐이라 "선택지가 없으니 직행"한 것이고, 둘 이상이면 선택 페이지를 생성한다. 제멋대로 고른 게 아니라 자기가 아는 유일한 답을 고른 셈이다. - 왜 REST API에서 치명적인가: 클라이언트가 주소창이 아니라
fetch다. ==fetch도 302를 자동으로 따라가는데 남의 도메인이라 CORS에 막히고, 프론트는 "Network Error"만 받는다.== 401이었다는 사실조차 모르니 재발급도 로그인 유도도 못 한다. - 어떻게 없앴나:
.exceptionHandling()에 EntryPoint를 명시하면 기본값을 덮어쓰는 게 아니라 기본값을 조립할 기회 자체가 사라진다 — 명시값이 있으면 그 조립 로직을 타지 않는다. 구글 로그인은 프론트가/oauth2/authorization/google(permitAll)로 직접 이동시켜 시작하므로 EntryPoint를 거치지 않아 멀쩡하다. - 연결: → [[OAuth2]] (state·콜백 흐름), → 아래 Q.어느 필터에서 거부되나
- 한줄답: ==체인 맨 마지막
AuthorizationFilter에서 일어난다.== 앞의 필터들은 아무도 막지 않는다. - 원리 — 시작 → 과정 → 결과 (토큰 없이
GET /problems):- [시작]
JwtFilter— Authorization 헤더가 없으니 아무것도 하지 않고 통과시킨다. 여기서 막지 않는 이유는permitAll경로도 이 필터를 지나기 때문이다 - [과정]
AnonymousAuthenticationFilter— 비어 있는 SecurityContext에 익명 토큰을 채운다 - [과정]
AuthorizationFilter—.anyRequest().authenticated()규칙을 검사한다. 익명은 인증된 게 아니므로 거부 - [결과]
AccessDeniedException을 던진다. ==401이든 403이든 이 필터는 항상 이 예외 하나만 던진다== — 사유를 모르기 때문이다
- [시작]
- 인증이 아니라 인가: 이름이
AuthorizationFilter인 게 핵심이다. "토큰이 없다"를 판정하는 게 아니라.authenticated()라는 자격 조건을 만족하는지 검사한다..hasRole("ADMIN")과 같은 종류의 검사이고, 인증 상태는 그중 한 조건일 뿐이다. - 예외가 거슬러 올라가는 이유: 필터는 줄 서 있는 게 아니라 중첩된 함수 호출이다.
ExceptionTranslationFilter가try { chain.doFilter() } catch { ... }로AuthorizationFilter를 감싸고 있어서, 거기서 던지면 자연히 catch로 올라온다. ==잡으러 가는 게 아니라 자기가 부른 놈이 던진 걸 받는 것==이고, 그래서ExceptionTranslationFilter는AuthorizationFilter바로 앞에 있어야만 한다. - 결과: 예외가
ExceptionTranslationFilter에서 응답으로 바뀌므로 DispatcherServlet도 컨트롤러도 실행되지 않는다 → 이 노트 첫 Q의 "위치의 문제"가 여기서 다시 확인된다. - 연결: → 위 Q.@ExceptionHandler(Throwable)로 넓혀도, → [[ServletFilter]] (체인이 중첩 호출이라는 것)
- 한줄답: ==
ExceptionTranslationFilter가 인증 객체가 익명인지 보고 가른다.== 우리가 만든 분기가 아니라 원래부터 있던 것이다. - 원리 — 시작 → 과정 → 결과:
- [시작] catch 블록에서
AuthenticationTrustResolver.isAnonymous(authentication)로 판정한다 - [과정] 익명이면 →
AuthenticationEntryPoint.commence()→ 401 - [과정] 익명이 아니면(=
JwtFilter가 컨텍스트를 채우는 데 성공한 사람) →AccessDeniedHandler.handle()→ 403 - [결과] 두 슬롯은 원래부터 뚫려 있었다. ==우리가 한 건 분기를 만든 게 아니라 그 안의 구현체를 갈아끼운 것뿐이다==
- [시작] catch 블록에서
- 이름이 다른 이유: 401은 "다시 인증해봐라"고 길을 열어주는 것이라
commence(개시)이고, 403은 같은 계정으로 다시 와도 결과가 같아 열어줄 길이 없으니handle(뒤처리)다. HTTP 스펙 그대로 — 401은 재시도 가능(그래서WWW-Authenticate헤더가 스펙상 필수), 403은 재시도 무의미. - 용어 주의: ==401 Unauthorized는 이름과 달리 "미인증"이고, "비인가"는 403이다.== HTTP 스펙의 유명한 작명 실수다.
- 함정:
AnonymousAuthenticationToken도isAuthenticated()가true를 반환한다. 시큐리티 내부도 그걸 안 보고 타입으로 판정한다. 컨트롤러에서if (auth.isAuthenticated())로 로그인 여부를 가리면 익명도 통과한다. 로그인 여부는@AuthenticationPrincipal이 null인지 보거나, 애초에 SecurityConfig 규칙으로 거르는 게 맞다. - 현재 상태:
.anyRequest().authenticated()뿐이라 403 경로는 아직 실행되지 않는다. 관리자 API에hasRole("ADMIN")을 걸면 그때부터 동작한다. - 실측: 토큰 없음 / 쓰레기 토큰 / 만료 토큰 / refresh를 access 자리에 — 네 경우 모두 401 JSON이 나가는 것을 curl로 확인했다.
- 연결: → [[FilterChain]] (필터 체인 구조), → [[TokenAuth]] (만료 시 재발급 분기)
- 한줄답:
ResponseEntityExceptionHandler가 처리하는 스프링 MVC 표준 예외 20종이 ==마지막에 공통으로 거쳐가는 단일 출구==다. 여기만 오버라이드하면 20종의 응답 본문이 전부 우리ErrorResponse형태로 통일된다. - 원리 — 시작 → 과정 → 결과:
- [시작] 스프링 MVC가 표준 예외를 던진다 (깨진 JSON →
HttpMessageNotReadableException) - [과정]
GlobalExceptionHandler가 상속받은 부모ResponseEntityExceptionHandler의handleException이 잡는다 (@ExceptionHandler에 20종이 선언돼 있음) - [과정] 타입별 분기 메서드로 넘어간다 (
handleHttpMessageNotReadable) - [과정] 그 분기 메서드가 마지막에
handleExceptionInternal을 호출한다 - [결과] 우리가
@Override한 버전이 실행되어 ==상태코드는ResponseEntityExceptionHandler가 정한 것을 그대로 쓰고 본문만 교체한다==
- [시작] 스프링 MVC가 표준 예외를 던진다 (깨진 JSON →
- 왜 상속 자체가 필요한가:
ResponseEntityExceptionHandler를 상속하지 않으면@ExceptionHandler(Exception.class)가 20종을 전부 삼켜서 URL 오타도 500, 깨진 JSON도 500이 된다. 프론트가 서버 장애와 구분 못 하고 500 알람이 오탐으로 울린다. - 왜 오버라이드하나:
ResponseEntityExceptionHandler의 기본 구현은 본문을 RFC 7807ProblemDetail({type,title,status,detail,instance})로 만든다. 우리CustomException경로는{status,name,message}다. 그대로 두면 프론트가 파서를 두 개 유지해야 한다. - 실측: 부팅 로그에
handleExceptionInternal: status=400, type=MethodArgumentNotValidException/status=404, type=NoResourceFoundException이 찍혔다. - 연결: → 아래 Q.왜 handleExceptionInternal 을 쓰며 어떻게 실행되나, → 위 Q.@ExceptionHandler(Throwable.class)로 넓혀도 (그건 사정권 밖, 이건 사정권 안의 이야기)
Q. DataIntegrityViolationException 을 잡는 핸들러가 따로 있잖아? 근데 거기서 갑자기 handleExceptionInternal 을 왜 쓰며 이게 어떻게 실행되는거야?
- 한줄답: ==둘은 아무 관계가 없다.== 우리가 직접 붙인
@ExceptionHandler는handleExceptionInternal을 거치지 않고 자기 메서드에서 응답을 만들고 끝낸다. - 원리: 클래스 안 메서드가 두 그룹으로 나뉜다. 같은 클래스 안에 있어서 한 덩어리처럼 보일 뿐, 실행 경로가 아예 다르다.
- ① 우리가 붙인
@ExceptionHandler(CustomException,DataIntegrityViolationException,AccessDeniedException,AuthenticationException,Exception) — 각자return ErrorResponse.toResponseEntity(...)로 끝난다.handleExceptionInternal을 거치지 않는다. - ② 상속받은 부모
ResponseEntityExceptionHandler가 붙인@ExceptionHandler(20종) — 이쪽만 타입별 분기를 거쳐handleExceptionInternal로 수렴한다.
- ① 우리가 붙인
- 스프링이 고르는 기준: 던져진 예외 타입에 ==가장 구체적으로 일치하는 핸들러를 고른다.==
DataIntegrityViolationException은ResponseEntityExceptionHandler의 20종에 없으므로 우리 핸들러가 이기고,NoResourceFoundException은 20종에 있고 우리가 선언하지 않았으므로ResponseEntityExceptionHandler쪽이 이긴다. - 증명(부팅 로그 실측): 409(닉네임 중복) 요청 구간에는
handleDataIntegrityViolation: constraint=uk_users_nickname_lower, errorCode=DUPLICATE_NICKNAME한 줄만 있고 ==handleExceptionInternal줄이 아예 없다.== 반대로 400(형식 위반) 요청에는handleExceptionInternal줄만 있다. - 단서:
handleExceptionInternal에@Override가 붙어 있다는 것 자체가 답이다 — 우리가 새로 만든 메서드가 아니라 부모ResponseEntityExceptionHandler의 것을 가로챈 것이다. - 연결: → 위 Q.handleExceptionInternal 이거는 뭐야, → [[Persistence]] (제약 위반이 트랜잭션을 abort 시켜 트랜잭션 안에서 복구 못 하는 이유)
- 한줄답: 순서는 맞는데 표현이 다르다. =="뒤에서 잡힌다"가 아니라 "뒤에서 호출된다"== 가 정확하다. 예외를 잡는 건
handleException한 곳뿐이다. - 원리 — 시작 → 과정 → 결과:
- [시작] 예외 발생 →
handleException진입 (@ExceptionHandler가 매칭되는 유일한 지점) - [과정] 내부
instanceof체인으로 타입별 분기 메서드를 호출한다 - [과정] 분기 메서드가 마지막에
handleExceptionInternal을 호출한다 - [결과] 최종
ResponseEntity반환
- [시작] 예외 발생 →
- 핵심 구분: ==
handleExceptionInternal에는@ExceptionHandler가 없다.== 예외를 직접 잡지 않고 처리 과정 마지막에 일반 메서드로 호출될 뿐이다.handleException은 입구,handleExceptionInternal은 출구이며, 둘은 경쟁 관계가 아니라 한 흐름의 앞뒤 단계다. - 왜 출구를 잡는가: 입구(
handleException)나 중간 분기를 건드리면 예외별로 따로 손봐야 하지만, ==모든 분기가 결국 지나는 출구를 잡으면 한 번에 끝난다.== - 연결: → 아래 Q.내가 이해한 흐름이 맞나 (
final이야기)
- 한줄답:
Exception은 모든 예외의 최상위라 ==가장 덜 구체적==이다. 스프링은 가장 구체적인 핸들러를 먼저 고르므로 20종은 부모 쪽에 먼저 걸린다. - 원리:
Exception.class핸들러는 ==그물의 맨 아래층==이다. 여기까지 내려오려면 A(우리 전담 핸들러)에도 B(ResponseEntityExceptionHandler의 20종)에도 안 걸리는 예외여야 한다 — NPE나 예상 못 한 런타임 예외 같은 것. - 그래서 역할이 갈린다:
handleExceptionInternal은 예상된 표준 예외를 받고,handleUnexpectedException(Exception)은 진짜 예상 못 한 것만 받아 error 로그 + 스택트레이스를 남긴다. - 검증 방법: 잘못된
@RequestBody를 보내면 로그에handleExceptionInternal: status=400...만 찍히고handleUnexpectedException은 안 찍힌다. 반대로 찍힌다면ResponseEntityExceptionHandler상속이 빠진 것이다. - 연결: → 위 Q.handleExceptionInternal 이거는 뭐야
Q. (검증) ResponseEntityExceptionHandler 안 handleException에 예외들이 미리 정의돼 있고, 가장 구체적인 핸들러가 먼저 잡고, 분기 메서드가 handleExceptionInternal을 호출해서 응답 형식을 통일한다 — 이 이해가 맞아?
- 한줄답: 흐름은 전부 맞다. 다만 세 가지를 바로잡아야 한다.
- 20종 목록 (javap 로
handleException내부instanceof체인에서 추출, 이 순서 그대로):
[!note]- 스프링 MVC 표준 예외 20종 (굵은 것이 5xx)
HttpRequestMethodNotSupportedException— 405HttpMediaTypeNotSupportedException— 415HttpMediaTypeNotAcceptableException— 406MissingPathVariableException— 500MissingServletRequestParameterException— 400MissingServletRequestPartException— 400ServletRequestBindingException— 400MethodArgumentNotValidException— 400HandlerMethodValidationException— 400 (@Validated+@RequestParam/@PathVariable)NoHandlerFoundException— 404NoResourceFoundException— 404 (URL 오타가 여기)AsyncRequestTimeoutException— 503ErrorResponseException— 가변 (예외가 들고 있는 값)MaxUploadSizeExceededException— 413ConversionNotSupportedException— 500TypeMismatchException— 400HttpMessageNotReadableException— 400 (깨진 JSON이 여기)HttpMessageNotWritableException— 500MethodValidationException— 500AsyncRequestNotUsableException— 응답 불가 (클라이언트가 이미 끊긴 상태)
- 바로잡을 것 ①:
handleException은public final이다. ==오버라이드 자체가 불가능하다.== "오버라이드하면 개별 분기를 안 타게 될 위험" 같은 얘기는 성립하지 않는다. 우리가 손댈 수 있는 건protected분기 메서드들과handleExceptionInternal뿐이고, 이게 출구 하나만 잡는 방식이 정석인 이유이기도 하다. - 바로잡을 것 ②: 상태코드는
handleException이 정하는 게 아니다. 리터럴로 박힌 건 5개뿐이다(ConversionNotSupported→500,TypeMismatch→400,HttpMessageNotReadable→400,HttpMessageNotWritable→500,MethodValidation→500). ==나머지 15종은 예외 객체가 자기 상태코드를 들고 있다== — Spring 6부터 이 예외들이ErrorResponse인터페이스를 구현하고,handleException은ex.getStatusCode()를 꺼내 넘길 뿐이다.ErrorResponseException이 "가변"인 것도 같은 이유다. - 바로잡을 것 ③: "표준 예외 = 전부 4xx"가 아니다. 5종이 5xx다(
MissingPathVariable500,ConversionNotSupported500,HttpMessageNotWritable500,MethodValidation500,AsyncRequestTimeout503). 그런데 현재 우리handleExceptionInternal은 상태코드를 안 보고 무조건log.warn이다 → ==@PathVariable이름을 잘못 적어 500이 나도 warn으로 조용히 지나가고 스택트레이스도 없다.==handleCustomException에서 고친 것과 같은statusCode.is5xxServerError()분기가 여기에도 필요하다. (아직 안 고침 — TODO) - 연결: → [[TroubleShooting]]
[!quote]- 용어 15개
- commence():
AuthenticationEntryPoint의 유일한 메서드. "인증을 개시하라". REST에선 401 JSON 작성 역할. → Q.commence 이게 뭔데- DispatcherServlet 경계: @ExceptionHandler가 개입 가능한 안쪽과 필터가 도는 바깥쪽을 가르는 선. → Q.Throwable로 넓혀도
jakarta.servlet.error.exception: ERROR 디스패치 때 원래 예외가 담기는 request attribute. 다시 던져지는 게 아니라 여기 실려서 전달된다. → Q.Throwable로 넓혀도- BasicErrorController: Spring Boot의
/error기본 핸들러. timestamp/status/error/path JSON을 낸다. → Q.Throwable로 넓혀도- HandlerExceptionResolver: DispatcherServlet이 예외 처리에 쓰는 컴포넌트. 필터에 주입해 부르면 advice로 가는 우회로가 된다. → Q.ErrorResponse로 전달할 방법
- AuthenticationEntryPoint: 인증 안 된 요청의 최종 응답을 만드는 지점. API 서버에선 401 JSON. → Q.정석이야
- AnonymousAuthenticationToken: 인증 안 된 상태를 null이 아닌 객체로 표현한 것. 401/403 판정의 근거. → Q.401과 403은 어디서 갈리나
- AuthenticationTrustResolver: 인증 객체가 익명인지 판정하는 컴포넌트.
isAuthenticated()대신 이걸 쓴다. → Q.401과 403은 어디서 갈리나- 인증 vs 인가: 인증=너 누구냐(JwtFilter) / 인가=그거 해도 되냐(AuthorizationFilter). → Q.어느 필터에서 거부되나
- ResponseEntityExceptionHandler: 스프링 MVC 표준 예외 20종을 이미 처리할 줄 아는 부모 클래스. 상속하면 그 처리를 물려받는다. → Q.handleExceptionInternal 이거는 뭐야
- handleException: 위 20종의
@ExceptionHandler가 붙은 유일한 진입점.public final이라 오버라이드 불가. → Q.내가 이해한 흐름이 맞나- handleExceptionInternal: 모든 타입별 분기가 마지막에 거쳐가는 공통 출구. 여기만 오버라이드하면 응답 본문이 통일된다. → Q.handleExceptionInternal 이거는 뭐야
- 핸들러 우선순위: 스프링은 던져진 예외 타입에 가장 구체적으로 일치하는
@ExceptionHandler를 고른다.Exception.class가 최후 폴백인 이유. → Q.왜 표준 예외는 Exception.class로 안 가- ProblemDetail: RFC 7807 형식(
type/title/status/detail/instance). 오버라이드하지 않으면 부모가 이 형태로 본문을 만든다. → Q.handleExceptionInternal 이거는 뭐야- ErrorResponse(인터페이스): Spring 6부터 표준 예외들이 구현. 예외가 자기 상태코드를 들고 다니게 만든 것. → Q.내가 이해한 흐름이 맞나
[!quote]- 오개념 17건
내가 생각했던 것 실제 @RestControllerAdvice가 애플리케이션의 모든 예외를 감시한다능동적 감시자가 아니다. DispatcherServlet이 불러줘야 돈다 @ExceptionHandler(Exception.class)로 넓히면 필터 예외도 잡힌다범위가 아니라 위치의 문제라 Throwable.class도 동일ExceptionTranslationFilter가 필터에서 난 예외를 처리해준다AuthenticationException/AccessDeniedException두 타입만. 게다가 자기보다 뒤에서 난 것만/error로 재진입하면 예외가 다시 던져진다request attribute로만 전달된다. advice가 반응할 대상이 없다 같은 IllegalArgumentException이면 어디서 던지든 결과가 같다컨트롤러·서비스에서 던지면 advice가 정상적으로 잡는다. 위치가 답을 바꾼다 필터 예외를 처리하려면 ErrorResponse를 직접 new 해야 한다 HandlerExceptionResolver로 위임하면 기존 advice를 그대로 재사용한다 인증 필터는 전부 통과시키는 게 정석이다 Spring Security 자체 필터는 "없으면 통과, 틀리면 즉시 거절" 하이브리드 구글이 302를 보냈다 우리 서버의 EntryPoint가 보냈다. 302는 두 번 일어난다 우리가 401/403을 두 갈래로 분리했다 스프링이 이미 두 슬롯을 뚫어뒀고 구현체만 갈아끼웠다 AuthorizationFilter가 401/403을 구분해 던진다항상 AccessDeniedException하나만. 구분은ExceptionTranslationFilter의 몫isAuthenticated()가 true면 로그인된 것익명 토큰도 true다. 타입으로 판정해야 한다 handleException을 오버라이드해서 동작을 바꿀 수 있다public final이라 오버라이드 자체가 불가능하다handleExceptionInternal이 예외를 "잡는다"잡지 않는다. @ExceptionHandler가 없다. 출구에서 호출될 뿐이고, 잡는 건handleException한 곳우리가 붙인 @ExceptionHandler도handleExceptionInternal을 거친다안 거친다. 자기 메서드에서 응답을 만들고 끝난다 (409 로그에 그 줄이 없는 것으로 확인) handleException이 상태코드를 정한다리터럴은 5개뿐. 나머지 15종은 예외 객체가 ErrorResponse구현으로 자기 상태코드를 들고 있다스프링 MVC 표준 예외는 전부 4xx다 5종이 5xx다(500 넷, 503 하나). 그래서 log.warn일괄 처리에 구멍이 생긴다@ExceptionHandler(Exception.class)가 표준 예외까지 삼킨다더 구체적인 핸들러가 먼저 이긴다. Exception은 최후 폴백