Spring Security JWT 전환기: 세션 폐기 실전

2편 톰캣 세션 클러스터링 도입과 한계“에서 다룬 세션 누락 이슈는 결국 인증 패러다임 자체를 바꾸지 않으면 풀리지 않는 문제였습니다. 본 글은 10년차 Spring Boot 개발자가 운영 중인 시스템을 세션 기반 인증에서 JWT 기반 무상태 인증으로 전환한 실전 마이그레이션 과정을 다룹니다. Spring Security 필터 구현, Access·Refresh Token 패턴, 그리고 운영 서비스를 멈추지 않고 점진적으로 전환한 전략까지 모두 공개합니다.

이 글은 본 시리즈의 마지막 편이자, 무중단 배포 → 세션 공유 → 무상태 인증으로 이어지는 진화의 종착점입니다.


왜 결국 JWT였는가

1편 무중단 배포 Nginx HAProxy 둘 다 써본 후기“에서 정착한 Nginx 단독 소프트 로드밸런싱은 톰캣 인스턴스를 자유롭게 추가·제거할 수 있게 만들어 주었지만, 인증 정보가 JVM 메모리에 묶여 있다는 사실은 그대로였습니다. 세션 클러스터링으로 메모리를 동기화했지만, 본질적 종속성은 사라지지 않았습니다.

JWT(JSON Web Token) 는 이 종속성을 끊습니다. 인증 정보를 서명된 토큰 형태로 클라이언트에 위임하기 때문에, 서버는 세션 저장소 자체가 필요 없어집니다. 다음 비교표가 의사결정의 근거가 되었습니다.

항목세션 기반 인증JWT 기반 인증
서버 상태Stateful
(메모리 보관)
Stateless
수평 확장성클러스터 트래픽 증가선형 확장
노드 추가클러스터 재구성무영향
무중단 배포세션 복제 필요불필요
토큰 무효화즉시 가능별도 전략 필요
(블랙리스트)
토큰 크기Cookie ID만 전송Header에 페이로드 포함

JWT의 단점인 토큰 무효화의 어려움은 Refresh Token 패턴과 짧은 만료시간(Access Token 15~30분)으로 충분히 보완할 수 있다고 판단했습니다.


JWT 구조와 서명 알고리즘 선택

JWT는 Header.Payload.Signature 세 부분이 점(.)으로 연결된 문자열입니다. 각 부분은 Base64Url로 인코딩되어 있습니다.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwicm9sZSI6IkFETUlOIn0.signature_part

서명 알고리즘은 두 가지 선택지가 있습니다.

HS256(HMAC-SHA256) 은 대칭키 방식입니다. 발급과 검증에 같은 비밀키를 사용하므로 모든 인증 서버가 같은 키를 알아야 합니다. 단일 서비스 내에서는 충분히 안전합니다.

RS256(RSA-SHA256) 은 비대칭키 방식입니다. 개인키로 서명하고 공개키로 검증합니다. MSA 환경처럼 발급 주체와 검증 주체가 분리될 때 유리합니다.

저희는 모놀리식 Spring Boot 환경이라 운영 단순성을 우선해 HS256을 선택했습니다. 비밀키는 환경변수와 Vault에서 주입했고, 256비트 이상 강한 키를 사용했습니다. 짧은 키는 알고리즘 등급보다 더 큰 보안 취약점이 됩니다.


Spring Security 필터 체인 설정

Spring Boot 3 + Spring Security 6 기준의 SecurityConfig입니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtAuthenticationEntryPoint entryPoint;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s ->
                s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**", "/actuator/health").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .exceptionHandling(e -> e.authenticationEntryPoint(entryPoint))
            .addFilterBefore(jwtAuthenticationFilter,
                             UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

가장 중요한 한 줄은 SessionCreationPolicy.STATELESS입니다. 이 설정으로 Spring Security는 HttpSession을 일절 생성하지 않습니다. JSESSIONID 쿠키도 더 이상 발급되지 않고, 모든 요청은 헤더의 토큰만으로 인증됩니다.


JwtAuthenticationFilter 실전 구현

OncePerRequestFilter를 상속해 모든 요청에 정확히 한 번씩 인증 검증을 수행하도록 만듭니다.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String HEADER = "Authorization";
    private static final String PREFIX = "Bearer ";

    private final JwtTokenProvider tokenProvider;
    private final CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        String token = resolveToken(request);

        if (token != null && tokenProvider.validate(token)) {
            String username = tokenProvider.getSubject(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities());
            authentication.setDetails(
                new WebAuthenticationDetailsSource().buildDetails(request));

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader(HEADER);
        if (StringUtils.hasText(bearer) && bearer.startsWith(PREFIX)) {
            return bearer.substring(PREFIX.length());
        }
        return null;
    }
}

운영 중 발견한 가장 흔한 실수는 토큰 검증 실패 시 예외를 던지지 않고 그냥 통과시키는 것입니다. 검증 실패는 SecurityContextHolder에 인증을 세팅하지 않은 채로 체인을 진행시키고, 그러면 후속 인가 단계에서 AuthenticationEntryPoint가 401을 반환합니다. 이 흐름이 자연스럽고 깔끔합니다.


JwtTokenProvider 발급과 검증

토큰 생성·검증 로직은 별도 컴포넌트로 분리합니다. jjwt 라이브러리(io.jsonwebtoken) 기준입니다.

@Component
public class JwtTokenProvider {

    private final Key key;
    private final long accessExpMs;
    private final long refreshExpMs;

    public JwtTokenProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.access-exp-min}") long accessExpMin,
            @Value("${jwt.refresh-exp-day}") long refreshExpDay) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.accessExpMs  = accessExpMin * 60 * 1000;
        this.refreshExpMs = refreshExpDay * 24 * 60 * 60 * 1000;
    }

    public String createAccessToken(Long userId, String role) {
        Instant now = Instant.now();
        return Jwts.builder()
            .setSubject(String.valueOf(userId))
            .claim("role", role)
            .claim("type", "access")
            .setIssuedAt(Date.from(now))
            .setExpiration(Date.from(now.plusMillis(accessExpMs)))
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();
    }

    public String createRefreshToken(Long userId) {
        Instant now = Instant.now();
        return Jwts.builder()
            .setSubject(String.valueOf(userId))
            .claim("type", "refresh")
            .setIssuedAt(Date.from(now))
            .setExpiration(Date.from(now.plusMillis(refreshExpMs)))
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();
    }

    public boolean validate(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException e) {
            log.debug("Expired JWT");
        } catch (JwtException | IllegalArgumentException e) {
            log.warn("Invalid JWT: {}", e.getMessage());
        }
        return false;
    }

    public String getSubject(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build()
                   .parseClaimsJws(token).getBody().getSubject();
    }
}

application.yml에는 다음 설정을 둡니다.

jwt:
  secret: ${JWT_SECRET:replace-with-strong-256-bit-secret-value}
  access-exp-min: 30
  refresh-exp-day: 14

비밀키는 절대 코드나 깃 저장소에 두지 마시기 바랍니다. 환경변수, AWS Secrets Manager, HashiCorp Vault 등 외부 비밀 저장소를 통해 주입해야 합니다.


Access Token과 Refresh Token 전략

Access Token만 사용하면 만료될 때마다 사용자가 재로그인해야 하므로 UX가 나쁩니다. Refresh Token을 함께 발급해 다음과 같이 운용합니다.

토큰만료시간보관 위치용도
Access15~30분메모리·HTTPOnly 쿠키API 호출 인증
Refresh7~30일HTTPOnly Secure 쿠키Access Token 재발급

Refresh Token은 서버 측 저장소(DB 또는 Redis)에도 함께 보관합니다. 사용자가 로그아웃하거나 비밀번호를 변경하면 해당 Refresh Token을 무효화해야 하기 때문입니다. 토큰 자체는 무상태이지만, 무효화 가능한 토큰이 되려면 작은 상태 저장소가 필요합니다.

@Service
@RequiredArgsConstructor
public class AuthService {

    private final JwtTokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;

    public TokenPair issueTokens(Long userId, String role) {
        String access  = tokenProvider.createAccessToken(userId, role);
        String refresh = tokenProvider.createRefreshToken(userId);

        refreshTokenRepository.save(
            new RefreshToken(userId, refresh, Instant.now().plus(14, ChronoUnit.DAYS)));

        return new TokenPair(access, refresh);
    }

    public String reissueAccessToken(String refreshToken) {
        if (!tokenProvider.validate(refreshToken))
            throw new InvalidTokenException("Invalid refresh token");

        Long userId = Long.valueOf(tokenProvider.getSubject(refreshToken));

        RefreshToken stored = refreshTokenRepository.findByToken(refreshToken)
            .orElseThrow(() -> new InvalidTokenException("Revoked refresh token"));

        return tokenProvider.createAccessToken(userId, stored.getRole());
    }
}

핵심은 refreshTokenRepository.findByToken 검증입니다. 서명만 유효한 Refresh Token이라도 저장소에서 삭제되었으면 거부해야 진정한 의미의 무효화가 동작합니다.


운영 중인 시스템의 점진적 마이그레이션 전략

신규 서비스라면 처음부터 JWT로 설계하면 그만이지만, 이미 세션 인증으로 운영 중인 서비스의 전환은 훨씬 까다롭습니다. 저희가 사용한 전략은 이중 인증 필터 체인 방식이었습니다.

1단계 병행 발급 구간(2주): 로그인 시 JSESSIONID 쿠키와 JWT 토큰을 동시에 발급합니다. 인증 필터는 JWT가 있으면 JWT를, 없으면 세션을 검증하도록 분기합니다. 기존 사용자는 영향 없이 그대로 사용하고, 신규 로그인부터 점진적으로 JWT로 전환됩니다.

2단계 JWT 우선, 세션 폴백 구간(2주): 새 클라이언트 빌드를 배포해 모든 신규 요청이 JWT 헤더를 동반하도록 변경합니다. 서버는 여전히 세션 인증도 받아주지만, JWT 사용 비율을 모니터링합니다. 95%를 넘기는 시점이 전환의 시그널입니다.

3단계 세션 인증 폐기: SessionCreationPolicy.STATELESS로 설정을 바꾸고 세션 인증 필터를 제거합니다. 톰캣 클러스터 설정도 함께 제거해 운영 부담을 덜어냅니다.

이 방식의 장점은 각 단계마다 즉시 롤백이 가능하다는 점입니다. JWT에 문제가 생기면 1·2단계에서 세션 인증으로 즉시 복귀할 수 있고, 사용자는 단 한 명도 로그아웃되지 않습니다.


전환 후 운영 지표 변화

전환 완료 후 3개월간의 운영 지표 변화입니다.

지표세션 클러스터링JWT 전환 후
평균 인증 응답 시간38ms21ms
클러스터 복제 트래픽일 12GB0
인증 관련 장애 건수(월)2~3건0건
톰캣 노드 증설 소요2~3시간(설정 변경)10분(스케일아웃)
무중단 배포 시간평균 12분평균 4분

특히 마지막 줄, 무중단 배포 시간이 1/3로 단축된 것은 클러스터 동기화 대기와 세션 누락 검증이 사라졌기 때문입니다. 결과적으로 1편 “Nginx 무중단 배포 후기“에서 시작한 야근 줄이기 여정이 이 단계에서 완성된 셈입니다.


자주 묻는 질문 (FAQ)

Q1. JWT를 어디에 저장하는 것이 가장 안전한가요?

HTTPOnly + Secure + SameSite=Strict 쿠키가 가장 안전합니다. localStorage는 XSS 공격에 그대로 노출되므로 권장하지 않습니다. Access Token은 메모리(자바스크립트 변수)에 두고 Refresh Token만 HTTPOnly 쿠키에 두는 방식도 보안성이 높습니다.

Q2. JWT 토큰이 탈취되면 어떻게 대응해야 하나요?

짧은 Access Token 만료시간(15~30분)서버 저장 Refresh Token 무효화가 핵심 방어선입니다. 즉시 무효화가 필요하면 사용자별 토큰 발급 시각을 DB에 두고, 그 시각 이전에 발급된 토큰은 모두 거부하는 방식도 효과적입니다. 사용자 비밀번호 변경 시 모든 Refresh Token을 강제 만료시키는 것도 필수입니다.

Q3. 마이크로서비스 환경에서는 JWT 검증을 어떻게 하나요?

API Gateway에서 검증하는 패턴이 가장 일반적입니다. Gateway가 JWT를 검증하고 사용자 정보를 헤더로 변환해 내부 서비스로 전달하면, 내부 서비스는 검증 로직 없이 사용자 정보를 신뢰할 수 있습니다. 또는 RS256 비대칭키로 각 서비스가 공개키로 직접 검증하는 방식도 가능합니다.

Q4. JWT 페이로드에 어떤 정보까지 담아도 되나요?

민감 정보(비밀번호, 주민번호, 결제 정보)는 절대 담지 않습니다. JWT는 서명되어 있지만 암호화되어 있지는 않으므로 누구나 페이로드를 디코딩해 읽을 수 있습니다. userId, role, 권한 코드 같은 식별·인가 정보만 담는 것이 원칙입니다.

Q5. 세션과 JWT를 영구히 병행해도 되지 않나요?

이론적으로 가능하지만 권장하지 않습니다. 두 인증 방식이 공존하면 보안 정책 일관성이 깨지고, 운영자가 어떤 인증 상태인지 추적하기 어렵습니다. 마이그레이션은 명확한 종료 시점을 정해 단일 방식으로 수렴시키는 것이 운영상 훨씬 건강합니다.


마무리 시리즈를 마치며

본 시리즈는 무중단 배포라는 작은 목표에서 출발해, 결국 인증 패러다임 자체를 바꾸는 여정으로 이어졌습니다. 처음에는 단순히 “야근 좀 줄이자”였던 것이, 로드밸런서 구축 → 세션 클러스터링 도입 → JWT 전환이라는 세 단계의 학습 곡선을 그리며 시스템 아키텍처 전반을 바꿔놓았습니다.

10년차 개발자로서 가장 강조하고 싶은 점은 각 단계를 건너뛰지 말라는 것입니다. 만약 처음부터 JWT를 도입했다면 세션 인증의 한계를 체득하지 못한 채 토큰 무효화·만료시간·저장 위치 같은 결정을 직관 없이 내렸을 것입니다. 세션 클러스터링을 직접 운영하며 마주친 복제 트래픽, 멀티캐스트 차단, 비동기 누락 같은 문제들이 결국 JWT의 모든 설계 결정을 합리적으로 만들어 주었습니다.

지금 비슷한 단계에 있는 팀이라면, 이 시리즈가 시행착오의 순서를 한 단계씩 줄여주는 지도가 되었으면 합니다.
무중단 배포 도입 단계라면 “1편 Nginx HAProxy 둘 다 써본 후기“, 세션 동기화로 고민 중이라면 “2편 톰캣 세션 클러스터링 한계“를 함께 읽어 보시기 바랍니다.


핵심 요약

  • 세션 클러스터의 한계를 본질적으로 해결하는 길은 무상태(stateless) JWT 인증 전환
  • Spring Security 6에서는 SessionCreationPolicy.STATELESS 한 줄이 전환의 출발점
  • Access(15~30분) + Refresh(7~30일) 이중 토큰과 서버 측 Refresh 저장소 병행이 표준
  • 운영 중 시스템은 병행 발급 → JWT 우선 → 세션 폐기 3단계 점진 마이그레이션이 안전
  • 결과: 인증 응답 시간 45% 단축, 무중단 배포 시간 약 1/3로 감소, 인증 장애 0건

“Spring Security JWT 전환기: 세션 폐기 실전”에 대한 3개의 생각

댓글 남기기