1. 서론: 세션 기반 인증의 한계와 JWT의 대두
전통적인 모놀리식 웹 애플리케이션에서는 서버의 메모리에 사용자 로그인 상태를 저장하는 세션(Session) 기반 인증을 주로 사용했습니다. 클라이언트가 로그인하면 서버는 JSESSIONID를 발급하고, 브라우저는 쿠키를 통해 이를 전달받아 매 요청마다 세션 저장소에서 사용자를 조회합니다.
하지만 현대의 마이크로서비스 아키텍처(MSA), 모바일 앱 클라이언트, 그리고 서버리스 환경에서는 세션 방식이 심각한 확장성 병목을 유발합니다:
- 서버 메모리 부담: 수십만 명의 동시 접속자가 로그인할 경우 세션 메모리가 급증합니다.
- 수평 확장(Scale-out)의 어려움: 서버를 여러 대로 늘리면 로드 밸런서 뒤에서 세션 불일치(Session Disconnect)가 발생하여 Sticky Session이나 별도의 분산 세션 스토리지를 강제해야 합니다.
- CORS 및 모바일 앱 제약: 쿠키 기반 세션은 도메인이 다른 다중 클라이언트나 네이티브 모바일 앱 환경에서 관리가 까다롭습니다.
이를 해결하기 위해 등장한 표준 솔루션이 바로 서버에 상태를 일절 저장하지 않는 **무상태(Stateless) 토큰 기반 인증, JWT(JSON Web Token)**입니다. 이번 포스팅에서는 Spring Boot 3 / Spring Security 6 최신 표준을 기준으로, 견고한 JWT 인증 필터 체인 구축, Refresh Token Rotation(RTR) 전략, 그리고 보안 하드닝까지 상세히 다룹니다.
2. JWT의 3단계 구조와 동작 원리
JWT는 점(.)으로 구분된 세 부분의 Base64URL 인코딩 문자열로 구성됩니다.
Token --> H["1. Header (헤더)"]
Token --> P["2. Payload (페이로드)"]
Token --> S["3. Signature (서명)"]
H -.-> H_Desc["토큰 타입(JWT) & 해시 알고리즘(HS256 / RS256)"]
P -.-> P_Desc["클레임(Claims): 사용자 식별자, 권한(Roles), 만료시간(exp)"]
S -.-> S_Desc["Secret Key로 암호화된 위변조 검증 서명값"]
[!WARNING] Payload는 암호화(Encryption)된 것이 아니라 인코딩(Base64URL)된 것입니다. 누구나 디코딩하여 페이로드 내용을 들여다볼 수 있으므로, 비밀번호나 주민번호 같은 민감 정보는 절대 Payload에 담아서는 안 됩니다. 위변조 여부는 오직 서버의 Secret Key로 생성된
Signature를 통해서만 검증됩니다.
3. JWT 인증 라이프사이클 (Access Token + Refresh Token)
Access Token의 탈취 위험을 최소화하기 위해 유효 기간을 15~30분으로 짧게 잡고, 유효 기간이 긴 Refresh Token을 Redis에 보관하여 재발급을 관리하는 듀얼 토큰 아키텍처 시퀀스입니다.
User->>Controller: POST /api/auth/login (아이디/비밀번호)
Controller->>Controller: 비밀번호 검증 & 인증 성공
Controller->>Redis: Refresh Token 저장 (TTL: 7일)
Controller-->>User: Access Token (30분) + Refresh Token 반환
Note over User,SecFilter: 이후 보호된 리소스 요청
User->>SecFilter: GET /api/orders (Header: Authorization: Bearer <Access_Token>)
SecFilter->>SecFilter: 서명 및 만료시간 자체 검증 (DB 조회 없음!)
SecFilter-->>Controller: SecurityContextHolder에 인증 객체 등록
Controller-->>User: 200 OK (데이터 응답)
Note over User,Controller: Access Token 만료 시
User->>Controller: POST /api/auth/refresh (Refresh Token 전달)
Controller->>Redis: Refresh Token 유효성 검증
Controller->>Redis: 이전 토큰 폐기 & 새 Refresh Token 갱신 (RTR)
Controller-->>User: 새 Access Token + 새 Refresh Token 발급
4. Spring Boot 3 / Security 6 필터 체인 구현
Spring Security 6에서는 기존의 WebSecurityConfigurerAdapter 및 메서드 체이닝 방식이 전면 폐기되고, **Lambda DSL 기반의 SecurityFilterChain**이 표준으로 자리 잡았습니다.
1) SecurityConfig.java (Spring Security 6 최신 표준)
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationEntryPoint unauthorizedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 1. CSRF 비활성화 (Stateless REST API 환경에서는 불필요)
.csrf(AbstractHttpConfigurer::disable)
// 2. CORS 기본 설정 활성화
.cors(Customizer.withDefaults())
// 3. 세션을 사용하지 않고 STATELESS로 설정
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 4. 인증 예외 처리기 등록
.exceptionHandling(exception ->
exception.authenticationEntryPoint(unauthorizedHandler)
)
// 5. URL별 인가(Authorization) 규칙 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/h2-console/**", "/error").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
// 6. UsernamePasswordAuthenticationFilter 앞단에 JWT 검증 필터 배치
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
5. 최신 JJWT (0.12.x) 기반 JwtTokenProvider 구현
JJWT 최신 라이브러리(0.12.x)의 안전한 SecretKey 생성과 Fluent API 문법을 준수하여 작성한 토큰 유틸리티 클래스입니다.
@Component
@Slf4j
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long accessTokenValidityInMs = 1000L * 60 * 30; // 30분
private final long refreshTokenValidityInMs = 1000L * 60 * 60 * 24 * 7; // 7일
public JwtTokenProvider(@Value("${jwt.secret}") String secret) {
// 최소 256비트(32바이트) 이상의 강력한 키 생성
this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
// Access Token 생성
public String createAccessToken(String username, List<String> roles) {
Date now = new Date();
Date expiry = new Date(now.getTime() + accessTokenValidityInMs);
return Jwts.builder()
.subject(username)
.claim("roles", roles)
.issuedAt(now)
.expiration(expiry)
.signWith(secretKey)
.compact();
}
// 토큰 유효성 및 만료 검증
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.error("잘못된 JWT 서명입니다: {}", e.getMessage());
} catch (ExpiredJwtException e) {
log.warn("만료된 JWT 토큰입니다: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.error("지원되지 않는 JWT 토큰입니다: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.error("JWT 토큰이 비어있습니다: {}", e.getMessage());
}
return false;
}
// 토큰에서 사용자 인증 정보 추출
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
List<String> roles = claims.get("roles", List.class);
Collection<? extends GrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.toList();
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
}
6. HTTP 요청 헤더 검증: JwtAuthenticationFilter
모든 HTTP 요청마다 한 번씩 실행되는 OncePerRequestFilter를 상속하여, Authorization: Bearer <token> 헤더를 파싱하고 Spring Security Context에 인증 정보를 주입합니다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// 현재 스레드의 SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
7. 프로덕션 보안 체크리스트: 탈취와 무효화 대응
- 클라이언트 저장소 보안 (XSS 방어):
- Access Token은 메모리(JS 변수/상태)에 보관하고, Refresh Token은 자바스크립트 접근이 원천 차단되는
HttpOnly; Secure; SameSite=Strict쿠키에 담아 전달하는 것이 XSS(크로스 사이트 스크립팅) 공격 방어의 정석입니다.
- Access Token은 메모리(JS 변수/상태)에 보관하고, Refresh Token은 자바스크립트 접근이 원천 차단되는
- Refresh Token Rotation (RTR):
- Refresh Token을 사용하여 새 Access Token을 발급받을 때, 사용된 Refresh Token도 즉시 무효화하고 새로운 Refresh Token을 재발급합니다. 이미 사용된 토큰이 다시 인입되면 토큰 탈취로 간주하고 해당 사용자의 모든 세션을 강제 만료시킵니다.
- 블랙리스트(Blacklist) 처리:
- 사용자가 로그아웃했을 때 아직 만료 시간이 남은 Access Token을 무력화하기 위해, 토큰의 남은 만료 시간(TTL)만큼 Redis에 키(
blacklist:token)로 등록하여 필터에서 차단합니다.
- 사용자가 로그아웃했을 때 아직 만료 시간이 남은 Access Token을 무력화하기 위해, 토큰의 남은 만료 시간(TTL)만큼 Redis에 키(
8. 결론
Spring Security 6와 최신 JJWT 라이브러리를 활용한 무상태 JWT 인증 시스템은 서버의 수평 확장을 무한히 가능하게 해주는 현대 클라우드 백엔드의 핵심 인프라입니다.
단순히 인증 통과에만 만족하지 말고, 짧은 만료 시간 설정, Refresh Token의 Redis 기반 RTR 전략, 그리고 보안 헤더 설정을 결합하여 안전하고 확장성 높은 엔터프라이즈 인증 파이프라인을 완성해 보시기 바랍니다.