Spring SecurityJWTAuthentication

Spring Security와 JWT refresh token 재발급 전략

access token만 믿고 갔다가 금방 한계를 느꼈고, refresh token을 어떤 기준으로 재발급하고 폐기해야 운영이 편해졌는지 실무 기준으로 정리했습니다.

Srue2026년 3월 30일
Spring Security와 JWT refresh token 재발급 전략

JWT 인증을 처음 붙일 때는 access token 하나면 충분해 보였습니다.
로그인하면 토큰 하나 내려주고, 만료되면 다시 로그인시키면 된다고 생각했기 때문입니다.

하지만 실서비스에서는 금방 불편이 생깁니다.
access token 만료 시간을 짧게 잡으면 사용자는 자꾸 로그인을 다시 해야 하고, 길게 잡으면 탈취됐을 때 리스크가 커집니다.

결국 access token과 refresh token을 분리하는 방향으로 가게 되는데, 여기서부터는 단순히 토큰 두 개를 발급하는 것으로 끝나지 않습니다.
실무에서는 언제 재발급할지, 이전 토큰을 언제 폐기할지, 여러 기기를 어떻게 다룰지가 더 중요했습니다.

처음에는 왜 문제가 생겼나

초기 구현은 꽤 단순했습니다.

  • access token: 30분
  • refresh token: 14일
  • refresh token으로 access token만 재발급

처음에는 편해 보였지만 운영하면서 몇 가지 문제가 바로 드러났습니다.

  • refresh token이 오래 살아서 탈취 시 대응이 어렵다
  • 로그아웃해도 기존 refresh token이 남아 있으면 다시 access token을 만들 수 있다
  • 모바일과 웹을 같이 쓰는 사용자의 세션을 구분하기 어렵다

즉 "토큰을 발급한다"보다 "세션을 어떻게 관리할 것인가"에 가까운 문제가 됐습니다.

지금은 refresh token도 재발급하면서 돌린다

이후에는 refresh token 재발급 전략을 바꿨습니다.
현재는 재발급 요청이 들어오면 access token만 새로 주는 게 아니라, refresh token도 함께 교체합니다.

이 방식의 장점은 분명했습니다.

  • 오래된 refresh token이 계속 살아남지 않는다
  • 탈취 토큰 재사용 가능 시간을 줄일 수 있다
  • 세션 관리 흐름이 더 명확해진다

대신 주의할 점도 있습니다.
예전 refresh token을 그대로 유효하게 남겨두면 회전 전략을 쓰는 의미가 없어집니다.

서버에는 refresh token 상태를 남겨둔다

refresh token을 진짜 stateless하게만 처리하면 운영이 불편했습니다.
로그아웃, 강제 만료, 중복 재발급 방지를 하려면 결국 서버 쪽 상태가 어느 정도 필요했습니다.

그래서 지금은 refresh token 자체를 그대로 저장하기보다, 아래 기준으로 세션 상태를 남깁니다.

  • 사용자 id
  • 기기 또는 세션 식별자
  • refresh token 식별값
  • 만료 시각
  • 폐기 여부

저장소는 Redis를 쓰는 경우가 많았고, 만료 시간 관리도 수월했습니다.

재발급 요청은 이렇게 처리했다

핵심은 "유효한 refresh token인지"만 보는 게 아니라, 지금 이 토큰이 마지막으로 발급된 토큰인지를 같이 보는 것이었습니다.

public TokenPair reissue(String refreshToken) {
    RefreshTokenPayload payload = tokenProvider.parseRefreshToken(refreshToken);
 
    StoredSession session = sessionRepository.findBySessionId(payload.sessionId())
        .orElseThrow(() -> new AuthException(ErrorCode.INVALID_REFRESH_TOKEN));
 
    if (session.isRevoked()) {
        throw new AuthException(ErrorCode.REVOKED_REFRESH_TOKEN);
    }
 
    if (!session.matches(refreshToken)) {
        throw new AuthException(ErrorCode.INVALID_REFRESH_TOKEN);
    }
 
    String newAccessToken = tokenProvider.createAccessToken(session.userId());
    String newRefreshToken = tokenProvider.createRefreshToken(session.userId(), session.sessionId());
 
    session.rotate(newRefreshToken, LocalDateTime.now().plusDays(14));
    sessionRepository.save(session);
 
    return TokenPair.of(newAccessToken, newRefreshToken);
}

실무에서는 이 "rotate" 과정이 중요했습니다.
이전 refresh token이 남아 있으면 재사용 공격을 감지하기도 어려워지고, 로그아웃 처리도 애매해집니다.

refresh token은 어디에 둘까

웹에서는 refresh token을 브라우저 저장소에 두기보다 HttpOnly Cookie로 내려주는 편이 훨씬 안정적이었습니다.
XSS 관점에서도 그렇고, 프론트에서 토큰을 직접 만지는 코드를 줄일 수 있어서 협업도 편했습니다.

반면 access token은 API 요청 헤더에 넣는 구조가 더 관리하기 쉬웠습니다.

정리하면 웹 서비스 기준으로는:

  • access token: Authorization 헤더
  • refresh token: HttpOnly Cookie

이 조합이 가장 운영하기 좋았습니다.

다중 기기는 처음부터 고려하는 편이 낫다

실무에서는 한 사용자가 PC, 모바일 웹, 앱을 같이 쓰는 경우가 많았습니다.
이때 refresh token을 사용자 단위로 하나만 두면, 한 기기에서 재로그인할 때 다른 기기 세션이 의도치 않게 끊어질 수 있습니다.

그래서 세션은 사용자 단위보다 기기 또는 클라이언트 단위로 관리하는 쪽이 낫다고 봅니다.

이걸 뒤늦게 붙이려면 데이터 구조와 만료 전략을 다시 손봐야 해서, 처음 설계할 때부터 세션 식별자를 넣는 편이 훨씬 편했습니다.

마무리

JWT 인증은 토큰을 발급하는 순간보다, 만료와 재발급을 어떻게 운영할지가 더 어렵습니다.

특히 refresh token은 편의 기능처럼 보이지만, 실제로는 세션 전략 그 자체에 가깝습니다.
실무에서는 단순히 access token 수명을 늘리는 방식보다, 짧은 access token + 회전하는 refresh token + 서버 측 세션 관리 조합이 가장 균형이 좋았습니다.