1. 프로젝트 구조
모놀리식 프로젝트에서 DDD + 클린 아키텍처 구조이다. Spring Security를 사용하고, 추후에 MSA 프로젝트로 확장성을 고려한 설계이다. 구체적으로, 몇 개월 이내 MSA 서비스 분리와 함께 소셜 로그인(OAuth 2.0), KeyClock, OpenID Connect 등으로 확장될 계획이라는 가정이다.
1.1 인증 구조
먼저, 전체적인 흐름에 대해서 간단하게 소개한다.
현재 프로젝트는 다음 구조를 사용한다.

Access Token, Refresh Token 분리
Access Token(AT)
- 역할: 인증
- 특징: 짧은 수명, 서버 저장 안 함
Refresh Token
- 역할: 재발급
- 특징: 긴 수명, Redis 저장, Rotation 실행
Stateless + Redis
JWT는 Stateless 하지만, 로그아웃과 재사용 공격 방어를 위해 Redis를 보조 저장소로 사용한다.
- JWT = 인증
- Redis = 제어
1.2 JWT 패키지 구성
수많은 클래스에 당황하지 말고, 천천히 보자. 현재 JWT 관련 코드는 `global.security.jwt `다음 패키지에 위치한다.

상당히 복잡하다. 기존에 사이드 프로젝트나 가벼운 서비스에서 JWT를 사용할 때는 보통 JwtProvider(생성, 검증)와 JwtAuthenticationFilter(필터) 2~3개 클래스만으로 끝냈을 것이다. 결론부터 말하자면, 이 구조는 "절대 과한 게 아니라, 제대로 만든 프로덕션(실무) 레벨의 Spring Security + JWT 구조"라는 점을 참고하자. 설계 목표인 Spring Security와 JWT의 한계점(필터단 예외 처리, 하드코딩 문제 등)을 정확히 이해하고 극복하려고 노력한 흔적인 것이다.
JWT 관련 로직을 하나의 패키지로 모은 이유는 인증(Authentication), 인가(Authorization), 토큰 생성, 토큰 검증, 예외 응답을 하나의 도메인으로 보기 때문이다.
예외 처리(Error Handling)
가볍게 쓸 때는 토큰 검증에 실패하면 그냥 401 Unauthorized를 대충 던지지만,
실무에서는 왜 실패했는지(만료, 위조, 양식 오류 등)를 클라이언트에게 정확히 알려줘야만 한다.
- `JwtAuthenticationEntryPoint`: 인증되지 않은 사용자(토큰이 없거나 잘못된 사람)가 보호된 API에 접근했을 때 처리 (401 에러)
- `JwtAccessDeniedHandler`: 인증은 되었으나(예: 일반 회원), 권한이 없는 API(예: 관리자 페이지)에 접근했을 때 처리 (403 에러)
- `SecurityExceptionHandlingFilter & SecurityErrorResponder`: Spring Security의 필터 체인 안에서 발생하는 예외는 `@RestControllerAdvice` 같은 일반적인 스프링 예외 핸들러가 잡아내지 못한다. 필터 단에서 터진 예외를 예쁘게 JSON 형태로 내려주기 위해 필터 제일 앞단에서 에러를 걈싸고 응답을 만들어주는 역할을 수행한다.
유지보수와 확장을 위한 데이터 분리 (DTO & 상수)
토큰 내부에 들어갈 데이터(Claim)나 환경 변수들을 하드코딩하지 않고 객체화한 것이다.
이렇게 해야 토큰 스펙이 바뀔 때 다른 코드를 안 건드리고 이 클래스들만 수정하면 됩니다.
- `JwtProperties`: application.yml에 적어둔 Secret Key, 만료 시간(TTL) 등을 바인딩하는 프로퍼티 클래스
- `JwtClaimNames / JwtClaims / JwtPayload`: 토큰 payload에 담길 유저 ID, 권한, 이메일 등의 Key 값과 Value를 타입 안정성 있게 관리하는 DTO 및 상수 클래스
- `TokenType / AccessTokenBlacklistReason`: 토큰 종류(AT, RT)나 블랙리스트 등록 사유(로그아웃, 토큰 탈취 등)를 관리하는 Enum(열거형) 클래스
보안성 강화를 위한 유틸리티
- `BearerTokenResolver`: HTTP Request Header에서 Authorization: Bearer <토큰> 형태를 파싱해서 순수 토큰 값만 쏙 뽑아내 주는 Spring Security 표준 인터페이스의 구현체이다. 코드의 책임을 분리하기 아주 좋다.
- `TokenHashUtil`: Redis에 Refresh Token이나 블랙리스트 토큰을 저장할 때, 토큰 원본을 그대로 저장하면 Redis 데이터베이스가 털렸을 때 위험하다. 이를 한 번 더 해싱(Sha-256 등)해서 저장하기 위한 보안 유틸리티이다.
한눈에 보는 전체 흐름

1.3 JwtProvider
JWT 시스템의 핵심 클래스이다. 역할을 크게 세 가지이다.
- Access Token 생성
- Refresh Token 생성
- JWT 검증 및 Payload 파싱
실제로 모든 JWT 관련 작업은 결국 JwtProvider를 통한다.





Service가 아니라 Provider로 네이밍을 지은 이유는 비즈니스 로직이 아니라 JWT 제공, 생성, 검증 역할을 수행하기 때문이다. 흔히 Spring Security 생태계에서도 AuthenticationProvider, UserDetailsService, PasswordEncoder 처럼 Provider 패턴을 자주 사용한다.
1.4 JwtProperties
JWT 설정을 관리한다. 설정 파일과 코드의 연결 지점이다.

`@ConfigurationProperties`를 통해 application.yml의 값들을 자바 객체로 묶어서(바인딩) 사용할 수 있다. Properteis 사용 목적은 민감한 정보를 감추고 여러 곳에서 반복 사용되는 구조에서 유리하기 때문이다. 보통은 아래 방식으로 간단하게 구현하는 코드도 많이 봤을 것이다.
@Value("${jwt.secret}")
@Value("${jwt.access-token-expiration-millis}")
한 곳에서만 사용한다면 큰 문제가 없다. 하지만 여러 곳에서 반복 사용된다면 다음과 같은 문제에 직면하게 된다.
- 설정 변경 추적 어려움
- 테스트 어려움
- 유지보수 어려움
대표적인 세 가지 문제에 부딪히게 된다.
1.5 JwtClaims
토큰 생성용 객체이다. 토큰 생성 시 필요한 최소 정보만 전달한다.

여기에서 중요한 포인트는 바로 "왜 Member를 직접 넘기지 않았는가?" 이다.
createAccessToken(Member member)
위의 코드처럼 구현할 수도 있다. 하지만 그러면 JWT 생성기가 Member 도메인을 알게 되면서 결합도가 증가한다. 그래서 JWTClaims라는 DTO를 생성한 것이다.
1.6 JwtPayload
토큰 검증 결과 객체이다. JWTPayload는 파싱 결과 DTO이다.

{
"sub": "1",
"role": "CUSTOMER",
"type": "ACCESS"
}
JwtPayload(
memberId=1,
role="CUSTOMER",
tokenType=ACCESS
)
핵심 포인트는 "JwtClaims와 왜 분리했는가?"이다. 하나로 합칠 수도 있지만 역할이 다르기 때문에 다음과 같이 분리했다.
- 생성 시 필요한 정보: JwtClaims
- 파싱 후 얻는 정보: JwtPayload
1.7 TokenType
현재 프로젝트는 두 종류 AT, RT 토큰을 사용한다. 흔히 상수 문자열로 간단하게 관리하지만, 나는 enum으로 관리했다.

괜히 복잡하게 enum을 선택한 이유는 반복을 줄이고 가장 안정적인 유지보수가 가능하기 때문이다.
나쁜 사례를 살펴보자.
claim("type", "access")
만약 다른 곳에서 "ACCESS"라고 사용하다면 런타임 시점에 문제점을 파악하지 못하고 컴파일 에러가 발생할 것이다.
TokenType.ACCESS
TokenType.REFRESH
대신 enum을 사용한다면 이 문제점을 해결할 수 있다.
1.8 JwtClaimNames
Claim 이름 상수 모음이다.

claim(JwtClaimNames.ROLE, ...)
1.9 BearerTokenResolver
Authorization 헤더에서 토큰을 추출한다.
# 일부 헤더
Authorization: Bearer eyJhb...
# 토큰 추출 결과
eyJhb...
1.10 TokenHashUtil
Refresh Token과 Access Token Hash 계산기이다. 이것은 패스워드 저장 전략과 동일한 철학이다.

필요한 이유는 Redis에 원문 JWT를 저장하지 않기 위해서다. 원문을 저장하면 Redis 접근 권한을 획득한 공격자가 즉시 인증을 수행할 수 있다. 그래서 SHA-256 해시만 저장한다.
1.11 SecurityErrorResponder
Spring Security 예외 응답 생성기이다. 크게 세 개의 역할을 수행한다.
- 401 응답 생성
- 403 응답 생성
- Problem Details 응답 생성
{
"status":401,
"title":"UNAUTHORIZED",
"detail":"인증이 필요합니다."
}
이전에는 `AuthenticationEntryPoint`와 `AccessDeniedHandler`에서 각각 JSON 응답을 만들 수 있다.
하지만 그러면 다음과 같은 문제점에 부딪힌다.
- 중복 코드
- 중복 직렬화
- 중복 응답 생성
그래서 SecurityErrorResponder로 응답 생성을 통합했다.
초기에는 필터 단에서 예외가 발생했을 때 응답 메시지를 만들기 위해 `new JsonMapper()`를 내부에서 직접 생성해서 썼다. 하지만 개발을 하다 보니 이렇게 객체를 직접 생성해서 사용하는 방식에는 치명적인 문제가 있었다. 바로 스프링 부트가 전역적으로 관리하는 "Spring Jackson 설정"을 완전히 우회해 버린다는 점이다. 게다가 Java 8 날짜 관련 모듈 같은 필수 설정들이 누락되면서, 특정 상황에서 JSON 직렬화 오류가 발생할 수 있는 리스크도 존재했다. 이렇게 되면 다른 일반 API 응답과 에러 응답의 포맷이 따로 놀게 되니까 시스템 일관성도 깨지게 되고 말이다. 그래서 최종적으로는 스프링 컨테이너가 주입해 주는 Spring Bean ObjectMapper를 사용하도록 구조를 바꿨다. 그리고 이 ObjectMapper를 활용해서 예외 응답을 안전하고 일관되게 생성할 수 있도록 SecurityErrorResponder라는 컴포넌트로 응답 생성 로직을 깔끔하게 통합했다.
참고로 스프링 부트는 켜질 때 application.yml 설정이나 내부 자동 설정을 바탕으로 엄청나게 많은 커스텀 세팅을 마친 ObjectMapper를 딱 하나 만들어서 스프링 빈(Bean)으로 등록한다. 그리고 모든 API 응답을 만들 때 이 녀석을 사용하는데 그것을 내가 한땀한땀 전부 JSON 스타일 표준에 맞춰서 생성하지 않으면 다른 요청, 응답 시 바로 에러가 발생하게 된다.
2. Spring Security 예외 응답 흐름
JWT 인증 과정에서 예외 응답을 생성하는 클래스는 크게 세 가지로 나눌 수 있다.
- 인증 실패: `JwtAuthenticationEntryPoint`
- 권한 부족: `JwtAccessDeniedHandler`
- JSON 응답 생성 : `SecurityErrorResponder`
이 세 클래스는 모두 Spring Security에서 발생하는 인증, 인가 실패 상황을 애플리케이션의 공통 에러 응답 포맷으로 변환하기 위해 사용된다.
2.1 JwtAuthenticationEntryPoint
JwtAuthenticationEntryPoint는 인증되지 않은 사용자가 보호된 API에 접근했을 때 실행된다.
예를 들어 다음과 같은 상황이다.
- Authorization 헤더가 없음
- Access Token이 없음
- 인증되지 않은 사용자가 /members/me 같은 보호 API 접근
코드는 다음과 같다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final SecurityErrorResponder securityErrorResponder;
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException {
securityErrorResponder.unauthorized(request, response);
}
}
Spring Security는 인증이 필요한 요청인데 인증 객체가 없거나 인증에 실패한 경우 AuthenticationEntryPoint를 호출한다.
여기서 직접 JSON 응답을 만들지 않고 SecurityErrorResponder에게 위임한다.
securityErrorResponder.unauthorized(request, response);
즉, 이 클래스의 역할은 단순하다.
Spring Security 인증 실패 감지
↓
401 Unauthorized 응답 생성 위임
↓
SecurityErrorResponder
이렇게 역할을 분리한 이유는 JwtAuthenticationEntryPoint가 HTTP 응답 포맷을 직접 알 필요가 없기 때문이다.
JwtAuthenticationEntryPoint는 "인증 실패가 발생했다"는 사실만 처리하고, 실제 응답 본문 생성은 SecurityErrorResponder가 담당한다.
2.2 JwtAccessDeniedHandler
JwtAccessDeniedHandler는 인증은 되었지만 권한이 부족한 경우 실행된다.
예를 들어 다음과 같은 상황이다.
- 로그인은 되어 있음
- Access Token도 유효함
- 하지만 ADMIN 권한이 필요한 API에 CUSTOMER가 접근
이 경우는 인증 실패가 아니라 인가 실패다.코드는 다음과 같다.
@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final SecurityErrorResponder securityErrorResponder;
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException
) throws IOException {
securityErrorResponder.accessDenied(request, response);
}
}
AccessDeniedHandler는 Spring Security에서 인가 실패를 처리하는 인터페이스다.
여기서도 직접 JSON을 만들지 않는다.
대신 다음과 같이 위임한다.
securityErrorResponder.accessDenied(request, response);
흐름은 다음과 같다.
권한 부족 발생
↓
AccessDeniedHandler 호출
↓
403 Forbidden 응답 생성 위임
↓
SecurityErrorResponder
정리하면 JwtAccessDeniedHandler는 403 응답을 담당한다.
2.3 401과 403의 차이
이 두 클래스는 비슷해 보이지만 처리하는 상황이 다르다.
| 클래스 | 상황 | HTTP Status |
| JwtAuthenticationEntryPoint | 인증되지 않음 | 401 Unauthorized |
| JwtAccessDeniedHandler | 인증은 되었지만 권한 부족 | 403 Forbidden |
그래서 현재 프로젝트에서는 각각 다음 에러코드로 응답한다.
AuthErrorCode.UNAUTHORIZED
AuthErrorCode.ACCESS_DENIED
2.4 SecurityErrorResponder
SecurityErrorResponder는 실제 JSON 응답을 생성하는 컴포넌트다.
@Component
@RequiredArgsConstructor
public class SecurityErrorResponder {
private static final String PROBLEM_BASE_URI = "about:blank/";
private final ObjectMapper objectMapper;
public void unauthorized(HttpServletRequest request, HttpServletResponse response)
throws IOException {
write(request, response, AuthErrorCode.UNAUTHORIZED);
}
public void accessDenied(HttpServletRequest request, HttpServletResponse response)
throws IOException {
write(request, response, AuthErrorCode.ACCESS_DENIED);
}
public void write(
HttpServletRequest request,
HttpServletResponse response,
ErrorCode code
) throws IOException {
ErrorResponse body = ErrorResponse.problem(
problemType(code.code()),
code.code(),
code.status(),
code.message(),
request.getRequestURI(),
code.code(),
null,
null
);
response.setStatus(code.status().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(), body);
}
private String problemType(String title) {
return PROBLEM_BASE_URI + title.toLowerCase().replace('_', '-');
}
}
이 클래스의 핵심 역할은 다음과 같다.
- ErrorCode를 ErrorResponse로 변환
- HTTP Status 설정
- Content-Type 설정
- UTF-8 인코딩 설정
- ObjectMapper로 JSON 직렬화
2.5 unauthorized()
unauthorized()는 인증 실패 응답을 만든다.
public void unauthorized(HttpServletRequest request, HttpServletResponse response)
throws IOException {
write(request, response, AuthErrorCode.UNAUTHORIZED);
}
결과적으로 다음과 같은 응답을 생성한다.
{
"status": 401,
"title": "UNAUTHORIZED",
"detail": "인증: 인증이 필요합니다."
}
이 메서드는 JwtAuthenticationEntryPoint에서 호출된다.
2.6 accessDenied()
accessDenied()는 권한 부족 응답을 만든다.
public void accessDenied(HttpServletRequest request, HttpServletResponse response)
throws IOException {
write(request, response, AuthErrorCode.ACCESS_DENIED);
}
결과적으로 다음과 같은 응답을 생성한다.
{
"status": 403,
"title": "ACCESS_DENIED",
"detail": "인증: 접근 권한이 없습니다."
}
이 메서드는 JwtAccessDeniedHandler에서 호출된다.
2.7 write()
실제 응답 생성은 write() 메서드에서 이루어진다.
public void write(
HttpServletRequest request,
HttpServletResponse response,
ErrorCode code
) throws IOException {
ErrorResponse body = ErrorResponse.problem(
problemType(code.code()),
code.code(),
code.status(),
code.message(),
request.getRequestURI(),
code.code(),
null,
null
);
response.setStatus(code.status().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(), body);
}
먼저 ErrorCode를 기반으로 ErrorResponse를 만든다.
ErrorResponse body = ErrorResponse.problem(...)
그다음 HTTP 응답 상태를 설정한다.
response.setStatus(code.status().value());
응답 타입은 JSON으로 지정한다.
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
인코딩은 UTF-8로 고정한다.
response.setCharacterEncoding("UTF-8");
마지막으로 Spring이 관리하는 ObjectMapper를 사용해서 응답 객체를 JSON으로 직렬화한다.
objectMapper.writeValue(response.getWriter(), body);
2.8 problemType()
problemType()은 에러 타입 URI를 만든다.
private String problemType(String title) {
return PROBLEM_BASE_URI + title.toLowerCase().replace('_', '-');
}
예를 들어 에러코드가 다음과 같다면,
UNAUTHORIZED
결과는 다음과 같다.
about:blank/unauthorized
또 다른 예시로,
BLACKLISTED_ACCESS_TOKEN
은 다음처럼 변환된다.
about:blank/blacklisted-access-token
이 방식은 Problem Details 스타일의 응답 구조와 잘 맞는다.
2.9 왜 ObjectMapper를 주입받는가?
초기에는 필터 단에서 예외가 발생했을 때 응답 메시지를 만들기 위해 new JsonMapper()를 직접 생성해서 사용했다.
하지만 이 방식에는 문제가 있다.
new JsonMapper()
이렇게 직접 생성하면 Spring Boot가 전역적으로 관리하는 Jackson 설정을 우회하게 된다.
Spring Boot는 애플리케이션이 시작될 때 여러 설정을 반영해서 ObjectMapper를 빈으로 등록한다.
여기에는 다음과 같은 설정이 포함될 수 있다.
- 날짜/시간 직렬화 설정
- null 처리 정책
- property naming 전략
- 커스텀 serializer/deserializer
- Jackson module
- 전역 직렬화 옵션
그런데 new JsonMapper()를 직접 생성하면 이런 설정이 반영되지 않는다.
그 결과 일반 Controller 응답과 Security 예외 응답의 JSON 직렬화 방식이 달라질 수 있다.
예를 들어 일반 API 응답은 정상적으로 직렬화되는데, Security 필터에서 발생한 예외 응답만 직렬화 실패가 발생할 수 있다.
이런 문제는 시스템 전체 응답 일관성을 깨뜨린다.
그래서 최종적으로는 Spring Container가 관리하는 ObjectMapper를 주입받도록 변경했다.
private final ObjectMapper objectMapper;
이렇게 하면 일반 API 응답과 Security 예외 응답이 동일한 Jackson 설정을 공유하게 된다.
2.10 전체 예외 응답 흐름
인증 실패 흐름은 다음과 같다.
보호 API 요청
↓
인증 정보 없음 또는 인증 실패
↓
Spring Security
↓
JwtAuthenticationEntryPoint
↓
SecurityErrorResponder.unauthorized()
↓
ErrorResponse 생성
↓
ObjectMapper로 JSON 응답 작성
↓
401 Unauthorized
권한 부족 흐름은 다음과 같다.
보호 API 요청
↓
인증은 성공
↓
권한 부족
↓
Spring Security
↓
JwtAccessDeniedHandler
↓
SecurityErrorResponder.accessDenied()
↓
ErrorResponse 생성
↓
ObjectMapper로 JSON 응답 작성
↓
403 Forbidden
2.11 최종 설계 결정
이 구조를 선택한 이유는 다음과 같다.
- Spring Security 예외 응답을 공통 포맷으로 통일하기 위해
- 401과 403 응답 생성을 분리하되 JSON 생성 로직은 중복하지 않기 위해
- ObjectMapper를 직접 생성하지 않고 Spring 설정을 재사용하기 위해
- 인증/인가 실패 응답도 일반 API 에러 응답과 같은 형식으로 내려주기 위해
결론적으로 JwtAuthenticationEntryPoint와 JwtAccessDeniedHandler는 Spring Security의 예외 진입점 역할만 수행하고, 실제 응답 생성은 SecurityErrorResponder가 담당한다. 이렇게 역할을 나누면 Spring Security와 애플리케이션 공통 응답 포맷 사이의 연결 지점이 명확해진다.
2.12 권한 없음과 권한 부족은 어디서 검사되는가?
Spring Security에서 인증/인가 실패는 Controller에서 검사하지 않는다. 즉, AuthController, MemberController 같은 Spring MVC Controller에 요청이 도달하기 전에 이미 Spring Security Filter Chain에서 먼저 검사된다.
흐름을 단순화하면 다음과 같다.
Client
↓
Servlet Container
↓
Spring Security Filter Chain
↓
JwtAuthenticationFilter
↓
AuthorizationFilter
↓
Spring MVC DispatcherServlet
↓
Controller
즉, 인증/인가 검사는 Spring MVC Controller보다 앞에서 수행된다.
정확히는 Servlet Filter 영역에서 처리된다.
SecurityConfig가 인증/인가 규칙을 선언한다
현재 보안 설정은 SecurityConfig에서 정의한다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final SecurityExceptionHandlingFilter securityExceptionHandlingFilter;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.exceptionHandling(exception -> exception
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/v1/auth/signup",
"/api/v1/auth/login",
"/api/v1/auth/reissue"
).permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/business/**").hasAnyRole("BUSINESS", "ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(
securityExceptionHandlingFilter,
UsernamePasswordAuthenticationFilter.class
)
.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class
)
.build();
}
}
여기서 핵심은 두 부분이다.
.exceptionHandling(exception -> exception
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
그리고
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/v1/auth/signup",
"/api/v1/auth/login",
"/api/v1/auth/reissue"
).permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/business/**").hasAnyRole("BUSINESS", "ADMIN")
.anyRequest().authenticated()
)
첫 번째는 인증/인가 실패가 발생했을 때 어떤 핸들러로 응답할지 정한다.
두 번째는 어떤 URL에 어떤 권한이 필요한지 정한다.
권한 없음과 권한 부족의 차이
현재 프로젝트에서 구분해야 하는 상황은 두 가지였다.
- 권한 없음 = 인증되지 않음
- 권한 부족 = 인증은 되었지만 필요한 role이 없음
좀 더 정확히 정리하면 다음과 같다.
| 상황 | 의미 | 예시 | 처리 |
| 인증 없음 | 로그인하지 않았거나 Access Token이 없음 | Authorization 헤더 없음 | 401 |
| 인증 실패 | Access Token이 잘못됨 | 위조/만료/잘못된 토큰 | 401 |
| 권한 부족 | 인증은 됐지만 role 부족 | CUSTOMER가 ADMIN API 접근 | 403 |
- 401 Unauthorized = 인증 문제
- 403 Forbidden = 인가 문제
인증 없음은 어디서 확인하는가?
인증 없음은 주로 두 군데에서 드러난다. 첫 번째는 JwtAuthenticationFilter다.
요청에 Access Token이 있다면 이 필터가 토큰을 검증하고 인증 객체를 만든다.
Authorization 헤더
↓
BearerTokenResolver
↓
JwtProvider
↓
Authentication 생성
↓
SecurityContext 저장
정상 토큰이면 SecurityContext에 인증 정보가 들어간다.
SecurityContextHolder
└── Authentication
반대로 토큰이 없거나 잘못되면 인증 객체를 만들 수 없다. 그 결과 요청은 인증되지 않은 상태로 남는다.
두 번째는 Spring Security의 인가 필터다. `.anyRequest().authenticated()` 같은 규칙이 있는 요청에서 SecurityContext에 인증 객체가 없으면 Spring Security는 인증 실패로 판단한다. 그때 호출되는 것이 다음 클래스다.
JwtAuthenticationEntryPoint
결과적으로 401 응답이 내려간다.
securityErrorResponder.unauthorized(request, response);
흐름은 다음과 같다.
보호 API 요청
↓
SecurityContext에 Authentication 없음
↓
authenticated() 조건 불만족
↓
AuthenticationEntryPoint 호출
↓
JwtAuthenticationEntryPoint
↓
SecurityErrorResponder.unauthorized()
↓
401 Unauthorized
권한 부족은 어디서 확인하는가?
권한 부족은 authorizeHttpRequests()에서 선언한 권한 규칙을 기준으로 검사된다. 예를 들어 다음 설정이 있다.
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
이 설정은 `/api/v1/admin/**` 경로에 접근하려면 ROLE_ADMIN 권한이 필요하다는 뜻이다.
Spring Security의 hasRole("ADMIN")은 내부적으로 ROLE_ADMIN 권한을 찾는다.
즉, 다음과 같은 사용자는 접근 가능하다.
Authentication authorities = ROLE_ADMIN
하지만 다음 사용자는 접근할 수 없다.
Authentication authorities = ROLE_CUSTOMER
이 경우 사용자는 인증은 되어 있다. 하지만 필요한 권한이 없다.
그래서 Spring Security는 AccessDeniedException을 발생시키고, 이 예외는 다음 핸들러로 전달된다.
JwtAccessDeniedHandler
결과적으로 403 응답이 내려간다.
securityErrorResponder.accessDenied(request, response);
흐름은 다음과 같다.
permitAll, authenticated, hasRole의 의미
현재 설정은 URL별로 세 가지 접근 정책을 사용한다.
.requestMatchers(
"/api/v1/auth/signup",
"/api/v1/auth/login",
"/api/v1/auth/reissue"
).permitAll()
permitAll()은 인증 없이 접근 가능한 경로다.
회원가입, 로그인, 토큰 재발급은 로그인 전에도 호출할 수 있어야 하므로 인증을 요구하지 않는다.
.anyRequest().authenticated()
authenticated()는 인증만 되어 있으면 접근 가능한 경로다. 즉, 유효한 Access Token이 필요하다.
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
hasRole("ADMIN")은 인증뿐만 아니라 특정 역할까지 요구한다.
주의할 점은 hasRole("ADMIN")은 내부적으로 ROLE_ADMIN을 검사한다는 것이다.
따라서 JWT에서 꺼낸 role이 ADMIN이라면 실제 Authentication에는 보통 다음 형태로 권한을 넣어야 한다.
ROLE_ADMIN
.requestMatchers("/api/v1/business/**").hasAnyRole("BUSINESS", "ADMIN")
이 검사는 Spring MVC 이전에 일어난다
중요한 점은 이 모든 과정이 Controller 진입 전에 끝난다는 것이다. Spring MVC의 중심은 DispatcherServlet이다.
하지만 Spring Security는 DispatcherServlet 앞단의 Servlet Filter Chain에서 먼저 동작한다.
흐름은 다음과 같다.
HTTP 요청
↓
Servlet Filter Chain
↓
Spring Security Filter Chain
↓
JwtAuthenticationFilter
↓
AuthorizationFilter
↓
DispatcherServlet
↓
Controller
따라서 인증/인가에 실패한 요청은 Controller까지 도달하지 않는다. 예를 들어 /api/v1/admin/test 요청에서 권한이 부족하면 AdminController가 있다 하더라도 해당 메서드는 실행되지 않는다. Spring Security 단계에서 403으로 응답이 종료된다.
JwtAuthenticationFilter의 역할
JwtAuthenticationFilter는 직접 권한 부족을 판단하지 않는다. 이 필터의 핵심 책임은 다음이다.
- Access Token 추출
- Access Token 검증
- JwtPayload 파싱
- Authentication 객체 생성
- SecurityContext에 저장
JwtAuthenticationFilter는 "이 사용자가 누구인지"를 확인한다. 권한이 충분한지는 그 다음 단계에서 Spring Security가 판단한다.
정리하면 다음과 같다.
JwtAuthenticationFilter = 인증 객체 생성
authorizeHttpRequests = 접근 권한 판단
JwtAuthenticationEntryPoint = 인증 실패 응답
JwtAccessDeniedHandler = 권한 부족 응답
SecurityExceptionHandlingFilter의 역할
현재 설정에는 다음 필터도 등록되어 있다.
.addFilterBefore(
securityExceptionHandlingFilter,
UsernamePasswordAuthenticationFilter.class
)
이 필터는 JWT 필터나 그 앞뒤에서 발생한 AuthException 같은 커스텀 예외를 잡아 공통 응답으로 변환하기 위해 둔 필터다. 즉, Spring Security의 표준 예외 흐름으로 가지 않는 커스텀 인증 예외를 응답으로 바꿔주는 안전망 역할을 한다. 흐름은 다음과 같다.
JwtAuthenticationFilter에서 AuthException 발생
↓
SecurityExceptionHandlingFilter가 catch
↓
SecurityErrorResponder.write()
↓
공통 ErrorResponse JSON 응답
이 필터가 없으면 필터 체인 내부에서 발생한 커스텀 예외가 일반적인 ControllerAdvice까지 도달하지 못하거나, 원하는 응답 포맷으로 변환되지 않을 수 있다.
전체 흐름 정리
인증이 없는 경우다.
/api/v1/members/me 요청
↓
Access Token 없음
↓
SecurityContext 비어 있음
↓
authenticated() 조건 실패
↓
JwtAuthenticationEntryPoint
↓
401 Unauthorized
잘못된 토큰인 경우다.
/api/v1/members/me 요청
↓
Authorization: Bearer invalid-token
↓
JwtAuthenticationFilter
↓
JwtProvider 검증 실패
↓
AuthException 발생
↓
SecurityExceptionHandlingFilter
↓
401 Unauthorized 또는 세부 AuthErrorCode 응답
권한이 부족한 경우다.
/api/v1/admin/test 요청
↓
JWT 검증 성공
↓
Authentication 생성
↓
ROLE_CUSTOMER 보유
↓
hasRole("ADMIN") 조건 실패
↓
JwtAccessDeniedHandler
↓
403 Forbidden
정리하면 권한 없음과 권한 부족은 Controller가 아니라 Spring Security Filter Chain에서 검사한다. 그리고 그 검사 기준은 SecurityConfig의 authorizeHttpRequests()에 선언되어 있다.
'🍃SpringBoot' 카테고리의 다른 글
| Spring for Redis[1/3]: Spring Data Redis 그리고 Redisson의 필요성(Spring Data Redis) (0) | 2026.06.08 |
|---|---|
| JWT[3/3]: JWT 실전 사용(Redis Lua Script, JWT BlackList, Rotation) (0) | 2026.06.07 |
| JWT[1/3]: JWT 이해(이론) (0) | 2026.06.06 |
| ObjectMapper와 HttpMessageConverter: 스프링 직렬화 이해와 Security Filter에서 처리 (0) | 2026.06.06 |
| JUnit 5: JUnit 이론 (1) | 2026.01.25 |
