- [트러블 슈팅] JWT 유효기간보다 빨리 만료되는 문제 해결2026년 01월 11일 17시 21분 37초에 업로드 된 글입니다.작성자: do_hyuk728x90반응형
1️⃣ JWT 기본 개념 간단 정리
- JWT란?
- JWT(JSON Web Token)는 인증을 위한 토큰 방식
- 서버가 로그인/회원가입 성공 시 토큰을 발급하고, 이후 요청에서 토큰으로 사용자를 식별
- 구조는 Header / Payload / Signature 로 구성됨
단계 내용
회원가입 요청 사용자 이메일, 닉네임, 비밀번호 등 회원 정보 입력 서버에서 JWT 생성 - Header: { "alg": "HS256", "typ": "JWT" }
- Payload: { "email": "user@example.com", "nickname": "dohyuk", "exp": 1715505600 }
- Signature: HMACSHA256(Header + Payload + Secret)클라이언트로 전달 Authorization: Bearer <JWT>eyJhbGciOiJIUzI1NiJ9... 특징 / 포인트 - Payload는 암호화가 아닌 Base64 인코딩
- 이메일, 닉네임 등 민감도가 낮은 정보만 포함
- exp 값으로 토큰 만료 시간 관리
- Signature를 통해 토큰 위변조 여부 검증하나의 토큰만 사용하면 탈취 시 위험하므로, 실무에서는 보통 Access Token과 Refresh Token을 분리해서 사용
- Access Token: 인증용 토큰, 만료 시간을 짧게 설정
- Refresh Token: Access Token 만료 시 재발급 용도로만 사용, 비교적 만료 시간이 김
[클라이언트 요청] | v [AccessToken 만료?] ──No──> [요청 정상 처리 완료] | Yes | v [RefreshToken 유효?] ──No──> [로그아웃 처리] | Yes | v [AccessToken 재발급] | v [재발급된 AccessToken으로 요청 재시도]
2️⃣ 문제 상황
- 토큰 만료 시간 설정
- Access Token: 24시간
- Refresh Token: 7일
- 설정한 유효기간보다 더 빨리 로그아웃되는 현상 발생
- 문제의 중요성
- 의도치 않은 로그아웃으로 인한 사용자 경험 저하
- 원인을 알 수 없는 버그를 방치하는 것은 서비스 신뢰성 측면에서 치명적
3️⃣ 문제 원인 분석
1. 서버 로그를 확인한 결과
- accessToken을 재발급 받는 과정에서 401 에러 던짐 ⇒ Cookie가 비어있는 것을 확인
- 백단에서 쿠키를 만드는 과정에서 쿠키 자체의 만료기간을 설정했는지 확인했지만 문제 없음
public static Cookie createCookie(String name, String value) { Cookie cookie = new Cookie(name, value); cookie.setPath("/"); return cookie; } cookie.setMaxAge(60*60*24) 옵션을 사용하지 않으면 세션쿠키로 생성 세션 쿠키: 브라우저를 닫으면 사라지는 쿠키- 프론트에게 물어본 결과 재발급 API 호출할 때 계속 에러가 발생
if (status === HttpStatusCode.Unauthorized) { if (config?.url !== API.Auth.Refresh) { return useSigninApi .refresh() .then(() => instance(error.config as InternalAxiosRequestConfig)) .catch(() => logout()) } }- 백엔드 코드에서 refresh API 응답을 확인해봄
@PostMapping("/refresh") public void refresh(HttpServletRequest request, HttpServletResponse response) { String accessToken = service.refresh(request); Cookie accessTokenCookie = CookieUtil.createCookie(CommonResource.authorization, accessToken); response.addCookie(accessTokenCookie); }2. ResponseBodyControllerAdvice와 returnType 간의 관계
- ResponseBodyControllerAdvice : 모든 @RestController 의 응답을 가로채서 공통 포맷으로 감싸주도록 설정해둠controller 응답 가로챔 → supports()가 true면? → beforeBodyWrite() 호출
@Slf4j @RestControllerAdvice public class ResponseBodyControllerAdvice implements ResponseBodyAdvice { public static final String DEFAULT_RESPONSE_MSG = "Success"; public static final String DEFAULT_RESPONSE_CODE = "0000"; @Override public boolean supports(MethodParameter returnType, Class converterType) { return true; } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { String path = request.getURI().getPath(); Objects.requireNonNull(path); return (!(body instanceof CommonResponse) && !(body instanceof ErrorResponse)) ? CommonResponse.builder().code(DEFAULT_RESPONSE_CODE).message(DEFAULT_RESPONSE_MSG).data(body).build() : body; } public ResponseBodyControllerAdvice() { } }- 반환 타입이 void일 경우 기대한 응답 포맷은 { “code”: “0000”, “message” : “Success” } 인데 빈 응답값으로 반환됨 그렇기 때문에 프론트에서는 정상적인 응답 포맷이 아니기 때문에 error라 판단하고 logout이 된 것
3. 응답 포맷이 빈 이유 파악하기
- 반환 타입이 void일 경우 MessageConverter가 ResponseBodyAdvice 호출 로직을 거치기 전에 빈 값을 반환해버림
- AbstractMessageConverterMethodProcessor.class ⇒ writeWithMessageConverters() 171번째 줄에서 return;
4️⃣ 해결 방법
- void 응답 포맷을 공통 포맷으로 감싸서 반환하기
5️⃣실제 적용
@PostMapping("/refresh") public ResponseEntity<Void> refresh(HttpServletRequest request, HttpServletResponse response) { ... return ResponseEntity.ok().build(); }
6️⃣ PS…
- 웹에서 이미 로그인 기능이 구현된 상태에서 발생한 이슈
- 웹 로그인 시
- Refresh Token은 Cookie와 DB에 저장
- Access Token은 Cookie에 저장
- 이 상태에서Postman과 같은 API Client로 로그인 API를 다시 호출하면 문제가 발생
- Access Token이 만료되기 전에,
- 원인 흐름
- 웹 브라우저의 Cookie에는 과거 Access Token이 남아 있음
- 하지만 로그인 API 재호출로 인해
- DB에 저장된 Refresh Token은 최신 값으로 갱신
- 이 상태에서 Access Token 재발급 시도 시
- Cookie Refresh Token ↔ DB Refresh Token 불일치 발생
- 서버에서는 이를 비정상 상태로 판단
- 최종적으로 로그아웃 처리 발생
728x90반응형'Spring' 카테고리의 다른 글
[트러블 슈팅] JPA UPDATE 벌크 연산 후 조회 안됨 (0) 2026.02.01 [트러블 슈팅] 재배포 없이 스케줄링 날짜 변경하기 (0) 2026.01.31 Chained Transaction Manager deprecated (0) 2026.01.04 JPA Fetch Join과 페이징을 함께 사용할 때 주의점 (0) 2025.04.28 JPA 에서의 연관관계 (0) 2025.03.07 댓글 - JWT란?