Tech Stack/Spring Boot
[Spring] JWT 05. RefreshToken DB 저장 + 1회성 폐기(로테이션) + 재발급 흐름 완성
GWDEVEL
2026. 2. 19. 15:24
[Spring] JWT 05. RefreshToken DB 저장 + 1회성 폐기(로테이션) + 재발급 흐름 완성
AccessToken은 짧게, RefreshToken은 길게 사용합니다.
하지만 RefreshToken을 그냥 클라이언트에만 두면 탈취 위험이 있습니다.
그래서 실무에서는 RefreshToken을 DB에 저장하고 1회성으로 폐기(로테이션)하는 방식을 많이 사용합니다.
1. 왜 RefreshToken을 DB에 저장해야 할까?
- 탈취된 RefreshToken 무효화 가능
- 중복 로그인 제어 가능 (1인 1토큰)
- 로그아웃 시 강제 만료 가능
즉, AccessToken은 Stateless,
RefreshToken은 Stateful하게 관리하는 전략입니다.
2. DB 테이블 설계 예시
CREATE TABLE member_token (
member_token_no NUMBER PRIMARY KEY,
member_token_target VARCHAR2(50) NOT NULL, -- loginId
member_token_value VARCHAR2(500) NOT NULL, -- refreshToken
member_token_created DATE DEFAULT SYSDATE
);
※ loginId 기준으로 기존 토큰 삭제 후 새로 저장하면 “1인 1토큰” 구조가 됩니다.
3. 로그인 시 RefreshToken 저장 로직
로그인 성공 → AccessToken + RefreshToken 발급 → DB 저장
public String generateRefreshToken(MemberDto member) {
Date now = new Date();
Date exp = new Date(now.getTime() + (7L * 24 * 60 * 60 * 1000)); // 7일 예시
String refreshToken = Jwts.builder()
.signWith(getKey())
.issuer(issuer)
.issuedAt(now)
.expiration(exp)
.claim("loginId", member.getMemberId())
.compact();
// 기존 refresh 삭제 (1인 1토큰 정책)
memberTokenDao.deleteByTarget(member.getMemberId());
// 새 refresh 저장
memberTokenDao.insert(MemberTokenDto.builder()
.memberTokenTarget(member.getMemberId())
.memberTokenValue(refreshToken)
.build());
return refreshToken;
}
4. RefreshToken 재발급 API 흐름
재발급 시 반드시 아래 3단계를 거쳐야 합니다.
- JWT 서명/만료/issuer 검증
- DB에 저장된 값과 일치하는지 확인
- 확인 후 기존 refresh 삭제 (1회성 폐기)
@PostMapping("/api/account/refresh")
public ResponseEntity<Map<String, String>> refresh(
@RequestBody Map<String, String> body) {
String refreshToken = body.get("refreshToken");
// 1) JWT 검증
TokenVO tokenVO = tokenService.parse("Bearer " + refreshToken);
// 2) DB 확인
boolean ok = tokenService.checkRefreshToken(tokenVO, "Bearer " + refreshToken);
if(!ok) {
return ResponseEntity.status(401).build();
}
// 3) 새 토큰 발급
MemberDto member = memberDao.selectOne(tokenVO.getLoginId());
String newAccess = tokenService.generateAccessToken(member);
String newRefresh = tokenService.generateRefreshToken(member); // 로테이션
Map<String, String> result = new HashMap<>();
result.put("accessToken", newAccess);
result.put("refreshToken", newRefresh);
return ResponseEntity.ok(result);
}
5. 1회성 폐기(로테이션) 핵심
checkRefreshToken 내부에서 기존 refresh를 삭제합니다.
public boolean checkRefreshToken(TokenVO tokenVO, String refreshToken) {
MemberTokenDto dto = memberTokenDao.selectOne(
tokenVO.getLoginId(),
refreshToken.substring(7)
);
if(dto == null) {
return false;
}
// 사용한 refresh는 즉시 삭제 (재사용 방지)
memberTokenDao.deleteByTarget(tokenVO.getLoginId());
return true;
}
이 방식은 RefreshToken 탈취 후 재사용 공격을 막는 데 매우 효과적입니다.
6. 실행 결과 (Output)
6-1) 정상 재발급
{
"accessToken": "새로운AccessToken값",
"refreshToken": "새로운RefreshToken값"
}
6-2) 탈취된 RefreshToken 재사용 시
HTTP/1.1 401 Unauthorized
이미 사용되어 DB에서 삭제된 RefreshToken은 재사용할 수 없습니다.
7. 로그아웃 처리
로그아웃 시 DB에 저장된 RefreshToken을 삭제하면 됩니다.
@PostMapping("/api/account/logout")
public ResponseEntity<Void> logout(HttpServletRequest request) {
String loginId = (String) request.getAttribute("loginId");
memberTokenDao.deleteByTarget(loginId);
return ResponseEntity.ok().build();
}
AccessToken은 짧기 때문에 자연 만료되며,
RefreshToken을 삭제하면 재발급도 불가능해집니다.
8. 실무 보안 체크리스트
- RefreshToken은 DB 저장
- 1회 사용 후 폐기(로테이션)
- 로그아웃 시 DB 삭제
- AccessToken은 짧게 유지
- key-str는 환경변수로 관리
시리즈 정리
- 1편 – JWT 기본 개념
- 2편 – Access / Refresh 구조
- 3편 – Interceptor 인증 적용
- 4편 – 자동 갱신(TokenRenewalInterceptor)
- 5편 – RefreshToken DB 관리 + 로테이션
이 구조까지 구현하면 실무에서도 충분히 사용하는 JWT 인증 시스템이 완성됩니다.