[Spring] JWT 04. 만료 임박 자동 갱신(TokenRenewalInterceptor) 적용 (응답 헤더로 새 토큰 내려주기)
JWT를 운영하다 보면 AccessToken 만료 때문에 사용자 경험이 끊기는 문제가 생깁니다.
이를 줄이기 위해 만료 임박(예: 10분 이하)이면 서버가 자동으로 새 AccessToken을 발급해서
응답 헤더로 내려주는 방식을 사용할 수 있습니다.
1. 자동 갱신 방식이란?
요청이 들어오면 서버가 AccessToken의 남은 시간을 확인하고,
만료가 임박하면 새 AccessToken을 만들어서 응답 헤더에 같이 담아줍니다.
- 클라이언트가 AccessToken으로 API 요청
- 서버가 토큰 남은 시간(remain)을 계산
- 남은 시간이 기준(renewal-limit)보다 작으면 새 AccessToken 발급
- 응답 헤더에 Access-Token으로 내려줌
- 프론트는 응답 헤더를 읽어 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에 저장/검증하는 방식(보안 강화)과
재발급 흐름을 더 안전하게 만드는 방법을 정리하겠습니다.
'Tech Stack > Spring Boot' 카테고리의 다른 글
| [Spring MVC] DispatcherServlet(디스패처 서블릿)이란 무엇인가? (0) | 2026.03.28 |
|---|---|
| [Spring] JWT 05. RefreshToken DB 저장 + 1회성 폐기(로테이션) + 재발급 흐름 완성 (0) | 2026.02.19 |
| [Spring] JWT 03. Interceptor로 인증 적용하기 (로그인 필요 API 보호) (0) | 2026.02.19 |
| [Spring] JWT 02. AccessToken / RefreshToken 구조 + 재발급 흐름 (0) | 2026.02.18 |
| [Spring] JWT 01. Json Web Token 개념정리 (0) | 2026.02.18 |