401과 403이 뜨는 진짜 이유 — 인증 버그 디버깅
스프링 시큐리티를 다루면서 가장 많은 시간을 잡아먹는 순간은 새 기능을 만들 때가 아니라, 분명히 맞게 설정한 것 같은데 401이나 403이 계속 나올 때입니다. 이 장은 그 순간에 실제로 도움이 되는 것만 모았습니다.
필터 체인이 뭘 하고 있는지 로그로 직접 보기
가장 먼저 할 일은 추측을 멈추고 로그를 보는 것입니다. application.yml에 한 줄만 추가하면 요청이 필터 체인을 어떻게 통과하는지 전부 찍힙니다.
logging:
level:
org.springframework.security: DEBUG이렇게 하면 요청마다 어떤 필터를 거쳤는지, 어느 지점에서 AccessDeniedException이나 AuthenticationException이 던져졌는지가 로그에 그대로 나옵니다. 저는 새 프로젝트에서 시큐리티 설정을 만질 때는 항상 이 로그를 켜놓고 시작합니다. "어디서 막혔는지"를 눈으로 보고 나면 원인의 8할은 이미 좁혀집니다. 운영 환경에서는 로그가 너무 많아지므로 로컬·개발 환경에서만 켜두시길 권합니다.
401과 403을 구분해서 접근하기
2장에서 인증과 인가가 다른 필터의 일이라고 했던 걸 여기서 실전에 씁니다. 401이 났다면 문제는 "누구인지 증명하는" 단계에 있고, 403이 났다면 "증명된 신원으로 이 리소스에 접근할 자격이 있는지" 판단하는 단계에 있습니다. 이 둘을 구분하지 않고 무작정 설정을 이것저것 바꿔보는 게 디버깅이 오래 걸리는 가장 흔한 이유입니다.
401이 나는데 토큰은 분명 유효한 것 같다면, 십중팔구 필터가 아예 실행되지 않았거나 SecurityContext에 인증 정보를 못 채운 경우입니다. 자주 놓치는 지점들입니다.
Authorization헤더 이름이나Bearer접두사(공백 포함)의 오타- JWT 필터를
addFilterBefore로 끼워넣을 때 기준 필터를 잘못 지정해서AuthorizationFilter보다 뒤에서 실행됨(2장) - 토큰 만료 시각과 서버 시간대(timezone) 불일치 — 특히 서버가 UTC, 로컬 테스트가 KST일 때
exp비교가 어긋남 - CORS 프리플라이트(
OPTIONS) 요청까지 인증을 요구하도록 설정해서, 브라우저가 보내는 사전 요청 자체가 401로 막힘
마지막 항목은 실제로 자주 놓칩니다. OPTIONS 요청은 인증 헤더 없이 오는 게 정상이므로, authorizeHttpRequests에 .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()을 명시적으로 열어둬야 합니다.
403이 나는데 로그인은 분명 됐다면, 필터 체인은 통과했지만 권한 판단에서 걸린 경우입니다.
- JWT의
roleclaim과SimpleGrantedAuthority에 넣은 문자열이 어긋남 — 스프링 시큐리티의hasRole("ADMIN")은 내부적으로"ROLE_ADMIN"이라는 문자열을 찾습니다. 토큰에"ADMIN"만 있고 필터에서"ROLE_"접두사를 안 붙였다면 항상 403이 납니다(5장 예제에서 이 접두사를 직접 붙였던 이유가 이것입니다) - 7장에서 다룬
@PreAuthorize의 SpEL 표현식 오타 — 표현식은 컴파일 타임에 검증되지 않으므로 오타가 나면 항상 거부로 평가되거나 런타임 예외가 남 authorizeHttpRequests의 규칙 순서 문제(2장) — 좁은 패턴이 넓은 패턴보다 아래에 있으면 절대 도달하지 못함
에러 응답을 원하는 형태로 바꾸기
기본 401/403 응답은 프런트엔드가 다루기 애매한 형태로 옵니다. AuthenticationEntryPoint와 AccessDeniedHandler를 직접 등록하면 우리 API의 에러 응답 규격에 맞출 수 있습니다.
http.exceptionHandling(handler -> handler
.authenticationEntryPoint((request, response, ex) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":\"AUTH_REQUIRED\",\"message\":\"로그인이 필요합니다\"}");
})
.accessDeniedHandler((request, response, ex) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":\"ACCESS_DENIED\",\"message\":\"권한이 없습니다\"}");
})
);이 두 핸들러를 등록하지 않으면 프런트엔드는 HTML 에러 페이지나 스프링 기본 에러 형식을 받게 되는데, 실제 서비스에서는 거의 항상 이 커스터마이징이 필요합니다. 초기 설계 단계에서 미리 넣어두면 나중에 프런트엔드 팀과 에러 응답 형식을 두고 왔다갔다할 일이 줄어듭니다.
여기까지가 이 책에서 다룬 내용입니다. 필터 체인이 어떻게 동작하는지 알고 나면, 스프링 시큐리티의 나머지는 대부분 그 위에 얹힌 설정입니다. 문서를 읽다가 낯선 애너테이션이나 설정을 만나면 "이게 필터 체인의 어느 지점에서, 인증과 인가 중 어느 쪽에 관여하는가"부터 물어보시길 권합니다. 그 질문 하나로 대부분의 새 기능은 이미 아는 개념의 변형이라는 게 보일 겁니다.