[Spring] JWT 04. 만료 임박 자동 갱신(TokenRenewalInterceptor) 적용

[Spring] JWT 04. 만료 임박 자동 갱신(TokenRenewalInterceptor) 적용 (응답 헤더로 새 토큰 내려주기)

JWT를 운영하다 보면 AccessToken 만료 때문에 사용자 경험이 끊기는 문제가 생깁니다.
이를 줄이기 위해 만료 임박(예: 10분 이하)이면 서버가 자동으로 새 AccessToken을 발급해서
응답 헤더로 내려주는 방식을 사용할 수 있습니다.


1. 자동 갱신 방식이란?

요청이 들어오면 서버가 AccessToken의 남은 시간을 확인하고,
만료가 임박하면 새 AccessToken을 만들어서 응답 헤더에 같이 담아줍니다.

  1. 클라이언트가 AccessToken으로 API 요청
  2. 서버가 토큰 남은 시간(remain)을 계산
  3. 남은 시간이 기준(renewal-limit)보다 작으면 새 AccessToken 발급
  4. 응답 헤더에 Access-Token으로 내려줌
  5. 프론트는 응답 헤더를 읽어 AccessToken을 교체

장점: 사용자는 로그인 끊김을 거의 못 느낍니다.
단점: 프론트가 응답 헤더를 읽어 토큰 교체하는 로직이 필요합니다.


2. 설정 값(yml/properties)에 필요한 항목

예시( yml 형태 ):

custom:
  jwt:
    key-str: ${JWT_SECRET}
    issuer: my-service
    expiration: 30
    renewal-limit: 10
  • expiration : AccessToken 만료(분)
  • renewal-limit : 남은 시간이 이 값(분) 이하이면 자동 갱신

3. TokenService - 남은 시간 계산(getRemain)

토큰의 exp(만료시간)을 꺼내서 현재 시간과 차이를 계산합니다.

import java.nio.charset.StandardCharsets;
import java.util.Date;

import javax.crypto.SecretKey;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;

@Service
public class TokenService {

    @Value("${custom.jwt.key-str}")
    private String keyStr;

    @Value("${custom.jwt.issuer}")
    private String issuer;

    private SecretKey getKey() {
        return Keys.hmacShaKeyFor(keyStr.getBytes(StandardCharsets.UTF_8));
    }

    public TokenVO parse(String authorization) {
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            throw new RuntimeException("NO_TOKEN");
        }
        String token = authorization.substring(7);

        Claims claims = (Claims) Jwts.parser()
                .verifyWith(getKey())
                .requireIssuer(issuer)
                .build()
                .parse(token)
                .getPayload();

        TokenVO vo = new TokenVO();
        vo.setLoginId((String) claims.get("loginId"));
        vo.setLoginLevel((String) claims.get("loginLevel"));
        return vo;
    }

    // 남은 시간(ms) 반환
    public long getRemain(String authorization) {
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            throw new RuntimeException("NO_TOKEN");
        }
        String token = authorization.substring(7);

        Claims claims = (Claims) Jwts.parser()
                .verifyWith(getKey())
                .requireIssuer(issuer)
                .build()
                .parse(token)
                .getPayload();

        Date exp = claims.getExpiration();
        Date now = new Date();
        return exp.getTime() - now.getTime();
    }

    // 새 AccessToken 발급 (예시)
    public String generateAccessToken(String loginId, String loginLevel) {
        Date now = new Date();
        Date exp = new Date(now.getTime() + (30L * 60 * 1000)); // 30분 예시

        return Jwts.builder()
                .signWith(getKey())
                .issuer(issuer)
                .issuedAt(now)
                .expiration(exp)
                .claim("loginId", loginId)
                .claim("loginLevel", loginLevel)
                .compact();
    }
}

4. TokenRenewalInterceptor 구현

이 인터셉터는 “인증 실패”를 막는 목적이 아니라,
토큰이 임박하면 새 토큰을 헤더에 내려주는 역할입니다.

동작 조건:

  • Authorization이 없으면 비회원이므로 그냥 통과
  • Authorization이 있으면 남은 시간 확인
  • remain이 renewal-limit(분)보다 작으면 새 토큰 발급
  • 응답 헤더로 Access-Token 내려줌
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class TokenRenewalInterceptor implements HandlerInterceptor {

    private final TokenService tokenService;

    @Value("${custom.jwt.renewal-limit}")
    private int renewalLimit; // 분

    public TokenRenewalInterceptor(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {

        String authorization = request.getHeader("Authorization");

        // 토큰이 없으면 비회원이므로 갱신 대상 아님 (그냥 통과)
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            return true;
        }

        try {
            long remainMs = tokenService.getRemain(authorization);
            long limitMs = renewalLimit * 60L * 1000;

            // 아직 충분히 남았으면 통과
            if (remainMs > limitMs) {
                return true;
            }

            // 만료 임박이면 새 AccessToken 발급
            TokenVO tokenVO = tokenService.parse(authorization);
            String newAccessToken = tokenService.generateAccessToken(
                    tokenVO.getLoginId(),
                    tokenVO.getLoginLevel()
            );

            // 프론트가 읽을 수 있도록 노출 허용
            response.setHeader("Access-Control-Expose-Headers", "Access-Token");
            response.setHeader("Access-Token", newAccessToken);

            return true;
        }
        catch (Exception e) {
            // 토큰이 만료/위조 등으로 문제가 있어도
            // 여기서는 "갱신만" 담당이므로 그냥 통과시키거나(권장X),
            // 인증 인터셉터(MemberInterceptor)가 막도록 역할을 분리합니다.
            return true;
        }
    }
}

5. Interceptor 등록 순서

보통은 다음처럼 구성합니다.

  • MemberInterceptor: 로그인 필수 API 보호(없으면 401)
  • TokenRenewalInterceptor: 토큰 임박 시 자동 갱신(헤더로 새 토큰 내려줌)

등록 예시:

@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {

    private final MemberInterceptor memberInterceptor;
    private final TokenRenewalInterceptor tokenRenewalInterceptor;

    public InterceptorConfiguration(MemberInterceptor memberInterceptor,
                                    TokenRenewalInterceptor tokenRenewalInterceptor) {
        this.memberInterceptor = memberInterceptor;
        this.tokenRenewalInterceptor = tokenRenewalInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        // 1) 갱신 인터셉터: 거의 모든 요청에 적용(로그인/회원가입/refresh 제외)
        registry.addInterceptor(tokenRenewalInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns(
                        "/api/account/login",
                        "/api/account/join",
                        "/api/account/refresh"
                );

        // 2) 인증 인터셉터: 로그인 필요 API만 보호
        registry.addInterceptor(memberInterceptor)
                .addPathPatterns(
                        "/api/mypage/**",
                        "/api/point/**",
                        "/api/admin/**"
                )
                .excludePathPatterns(
                        "/api/account/login",
                        "/api/account/join",
                        "/api/account/refresh"
                );
    }
}

※ 갱신 인터셉터는 “비회원도 지나갈 수 있어야” 하므로 토큰 없으면 통과하게 구현하는 것이 포인트입니다.


6. 프론트(React/JS)에서 새 토큰 자동 교체

서버가 응답 헤더로 내려준 Access-Token을 읽어서 저장하면 됩니다.
axios interceptor로 처리하면 가장 편합니다.

import axios from "axios";

const api = axios.create({
  baseURL: "/",
});

// 요청 시 accessToken 자동 첨부
api.interceptors.request.use((config) => {
  const accessToken = localStorage.getItem("accessToken");
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

// 응답에서 Access-Token 헤더가 있으면 교체
api.interceptors.response.use((response) => {
  const newAccess = response.headers["access-token"];
  if (newAccess) {
    localStorage.setItem("accessToken", newAccess);
  }
  return response;
});

export default api;

7. 실행 결과 (Output)

7-1) 토큰이 충분히 남았을 때

응답 헤더:
(Access-Token 없음)

HTTP/1.1 200 OK

7-2) 만료 임박일 때 (renewal-limit 이하)

응답 헤더:
Access-Control-Expose-Headers: Access-Token
Access-Token: eyJhbGciOiJIUzI1NiJ9....(새 토큰)

HTTP/1.1 200 OK

프론트는 위 헤더를 읽어서 accessToken을 새 값으로 교체합니다.


8. 주의사항(실전 포인트)

  • 응답 헤더를 프론트에서 읽으려면 Access-Control-Expose-Headers 설정이 필요함
  • 갱신 인터셉터는 “막는 역할”이 아니라 “갱신” 역할만 담당하는 것이 깔끔함
  • 인증 실패(만료/위조)는 MemberInterceptor(또는 Security)에서 401 처리
  • AccessToken을 너무 길게 잡으면 갱신 의미가 줄어듦

마무리

TokenRenewalInterceptor 방식은 “사용자 경험”을 정말 부드럽게 만들어 줍니다.
다음 글(5편)에서는 RefreshToken을 DB에 저장/검증하는 방식(보안 강화)과
재발급 흐름을 더 안전하게 만드는 방법을 정리하겠습니다.