[Spring] JWT 05. RefreshToken DB 저장 + 1회성 폐기(로테이션) + 재발급 흐름 완성

[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단계를 거쳐야 합니다.

  1. JWT 서명/만료/issuer 검증
  2. DB에 저장된 값과 일치하는지 확인
  3. 확인 후 기존 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 인증 시스템이 완성됩니다.