개발일지/CommerceBackend

[Design]리프레시 토큰: 로테이션과 재사용 탐지를 선택한 이유

기억지기 개발자 2026. 9. 23. 19:57

상황

지금까지는 액세스 토큰 하나만으로 로그인을 구현했다.

로그인에 성공하면 액세스 토큰을 발급하고, 이후 요청마다 그 토큰을 검증하는 단순한 구조였다.

여기서 한 단계 업그레이드하고 싶어서 리프레시 토큰 도입을 알아보기 시작했다.

그런데 찾아보다 보니, 리프레시 토큰을 붙이는 것 자체도 완전한 해법은 아니라는 이야기가 계속 나왔다. 리프레시 토큰이 탈취되면 액세스 토큰보다 훨씬 오래 살아있는 만큼 피해가 더 크고, 단순히 리프레시 토큰만 추가한다고 보안이 저절로 좋아지는 게 아니었다. 그래서 어떤 방식들이 있는지, 그리고 그 중 실무에서 실제로 쓰이는 조합이 뭔지 정리해보기로 했다.


어떤 대안들이 있나?

먼저 토큰 기반 인증에서 무엇을 절충해야 하는지 정리했다.

JWT는 서버가 별도 저장소를 조회하지 않고도 서명과 만료 시간을 검증할 수 있다.

하지만 서명과 만료만 검증하는 구조에서는 특정 토큰을 만료 전에 개별적으로 차단하기 어렵다.

🔺 서버 세션 + opaque 토큰

토큰을 의미 없는 랜덤 문자열로 만들고, 서버가 세션 저장소(Redis 등)에 상태를 들고 있는 방식이다. 즉시 무효화가 가능하고 토큰이 털려도 정보가 새지 않는다. 대신 매 요청마다 저장소 조회가 필요해서, JWT가 애초에 얻으려던 "조회 없는 검증"이라는 이점을 포기하는 셈이다.

🔺 Reference Token + Introspection

액세스 토큰 자체를 참조값으로 만들고, 리소스 서버가 매번 인증 서버의 introspection 엔드포인트에 물어봐서 유효성을 확인하는 OAuth2 방식이다. 즉시 무효화는 되지만 요청마다 네트워크 호출이 추가된다.

🔺 JWT 하이브리드 (짧은 수명 + 블랙리스트)

JWT의 stateless 장점은 유지하되, 액세스 토큰 수명을 아주 짧게 잡아서 탈취 위험 구간 자체를 줄이고, 로그아웃 같은 예외 상황만 블랙리스트로 걸러내는 절충안이다.

🔺 리프레시 토큰 로테이션 + 재사용 탐지

리프레시 토큰을 쓸 때마다 새 토큰으로 교체하고, 이미 폐기된 토큰이 다시 들어오면 탈취로 간주해 전체 체인을 무효화하는 방식이다. Auth0, Okta 등이 실제로 채택한 표준적인 방어책이다.

🔺 DPoP (RFC 9449)

가장 최근 방향이다. 토큰을 특정 클라이언트의 키 쌍에 묶어서, 탈취되더라도 개인키가 없으면 재사용할 수 없게 만든다. "무효화"가 아니라 "탈취돼도 못 쓰게 만드는" 접근이라 앞의 방식들과 결이 다르다.

 

이번 구현에서는 다음 조합을 선택했다.

짧은 수명의 액세스 토큰 + 리프레시 토큰 로테이션 + 재사용 탐지

리프레시 토큰의 로테이션과 재사용 탐지는 OAuth 보안 권고에서도 다루는 방어 방식이다.


설계의 기준: 토큰 패밀리

토큰 패밀리(family)는 하나의 로그인 세션에서 파생된 리프레시 토큰들을 전부 하나로 묶는 그룹이다.

왜 필요한가?

리프레시 토큰 로테이션을 쓰면, 로그인 한 번 했다고 리프레시 토큰이 하나로 끝나는 게 아니라 계속 갱신되면서 새 토큰으로 바뀌어간다.

로그인 → RT1 발급
갱신 1회차 → RT1 폐기, RT2 발급
갱신 2회차 → RT2 폐기, RT3 발급
갱신 3회차 → RT3 폐기, RT4 발급

여기서 RT1, RT2, RT3, RT4는 별개의 토큰이 아니라, 같은 로그인에서 출발한 하나의 흐름이다.

이 흐름 전체를 하나로 묶어서 부르는 이름이 "패밀리"이고, 코드에서는 보통 familyId 같은 값으로 표현한다.

왜 굳이 묶어야 하는지

재사용 탐지가 실제로 하는 일을 떠올려보면 이유가 명확해진다.

만약 RT2가 탈취당했다고 하면, 공격자가 RT2로 갱신을 시도할 수 있다. 이때 서버가 해야 할 일은 "RT2 하나만" 막는 게 아니다.

RT2에서 이어질 수 있는 RT3, RT4, RT5... 전부를 미리 차단해야 한다. 공격자가 RT2를 갖고 있다는 건, 그걸로 만들어질 후속 토큰들도 잠재적으로 위험하기 때문이다.

만약 familyId로 묶여있지 않다면, 서버는 RT2가 어느 로그인에서 나왔는지, RT3·RT4가 RT2와 이어진 토큰인지 전혀 알 수가 없다.

그냥 "토큰 하나하나"만 있을 뿐, "이 토큰들이 하나의 체인이다"라는 정보 자체가 없는 것이다... 그러면 재사용이 감지돼도 딱 그 토큰 하나만 막을 수 있을 뿐, 같은 체인에서 이미 나가있는 다른 토큰들은 손 못 대고 그대로 살아있게 된다.

정리하면

  • 토큰: 갱신될 때마다 계속 바뀌는 개별 값
  • 패밀리: 그 바뀌는 토큰들이 원래 같은 로그인에서 시작된 하나의 흐름이라는 걸 나타내는 식별자

재사용 탐지에서 "체인 전체를 무효화한다"고 할 때, 그 "체인"이 바로 이 패밀리 단위다. familyId가 없으면 애초에 "체인"이라는 개념 자체가 성립하지 않아서, 탈취를 감지해도 반쪽짜리 대응인 셈이다.


1. DB에는 원본 토큰 대신 해시 저장하기

리프레시 토큰 엔티티는 다음 정보를 관리한다.

필드  역할
tokenHash 전달받은 토큰을 조회하기 위한 해시
familyId 같은 로그인에서 이어진 토큰의 식별자
userId 토큰 소유 사용자
expiresAt 토큰 만료 시각
revoked 폐기 여부

entity 필드의 정의

예제에서는 getter와 import를 생략했다. 만료 시각과 현재 시각이 같아지는 순간도 만료로 판단한다.

왜 원본 토큰을 저장하지 않을까?

DB가 유출됐을 때, 저장된 값을 그대로 인증에 사용할 수 없게 하기 위해서다.

클라이언트에는 원본 토큰을 전달하지만, DB에는 해시만 저장한다. 재발급 요청이 들어오면 전달받은 원본을 다시 해싱해서 조회한다.

비밀번호처럼 BCrypt를 써야 할까?

이번 구현에서는 SHA-256을 사용한다.

비밀번호는 사람이 만드는 값이므로 추측 가능한 경우가 많다. 반면 리프레시 토큰은 암호학적으로 안전한 난수 생성기로 만든 충분히 긴 랜덤 값이어야 한다.

이 전제에서 빠른 해시로 조회용 값을 만들 수 있다. SHA-256 결과를 16진수 문자열로 저장하면 길이는 64자가 된다.

해시 알고리즘만큼 중요한 것은 원본 토큰의 예측 불가능성이다.

generateRandomToken()은 SecureRandom 같은 안전한 난수 생성기로 최소 32바이트의 난수를 만들도록 구현한다.


로그인할 때 새로운 패밀리 생성하기

1. 이메일은 비교하기 전에 정규화한다

가입할 때 대문자가 섞인 이메일로 입력했든, 로그인할 때 전부 소문자로 입력했든 같은 계정으로 인식돼야 한다. 대소문자 차이나 앞뒤 공백 때문에 로그인이 실패하는 걸 막기 위해, 인증을 시도하기 전에 이메일을 미리 소문자로 통일해둔다.

2. 비밀번호는 직접 비교하지 않는다

비밀번호가 맞는지 서비스 코드에서 직접 비교할 수도 있지만, 이 메서드는 그 판단을 Spring Security의 인증 매니저에 넘긴다.

이렇게 위임하면 단순히 비밀번호 일치 여부만 확인하는 게 아니라, 계정 잠금이나 비활성화 같은 검증까지 프레임워크가 이미 짜둔 흐름을 그대로 타게 된다. 인증에 실패하면 프레임워크가 던지는 예외를 그대로 노출하지 않고, 서비스에서 통일해서 쓰는 예외 형태로 바꿔서 던진다.

3. 인증에 성공한 뒤에야 회원 정보를 가져온다

인증 과정은 "이 비밀번호를 가진 사람이 실제로 존재하는가"만 확인해줄 뿐, 이후 로직에서 쓸 회원 객체까지 바로 손에 쥐여주지는 않는다. 그래서 인증이 끝난 다음 별도로 회원 정보를 조회한다. 토큰을 발급할 때 회원의 식별자와 정보가 필요하기 때문이다.

4. 리프레시 토큰을 만들어서 저장한다

예측할 수 없는 랜덤 문자열을 하나 만든 뒤, 이 원본 값을 그대로 저장하지 않고 해시로 변환한 값만 저장한다.

저장소가 유출되더라도 실제 토큰 값을 복원할 수 없게 하려는 목적이다.

이때 이 로그인을 대표하는 새로운 식별자도 하나 함께 만들어 저장한다. 로그인할 때마다 이 식별자가 새로 생기고, 이후 이 리프레시 토큰이 갱신될 때마다 같은 식별자를 계속 이어받는다. 이게 앞서 다뤘던 "토큰 패밀리" 개념이 실제로 만들어지는 지점이다.

5. 클라이언트에는 원본 토큰만 내려준다

저장소에는 해시값이 남아있지만, 클라이언트에게는 해시하기 전의 원본 리프레시 토큰을 내려준다. 다음번에 클라이언트가 이 원본 토큰을 다시 보내오면, 서버는 그걸 똑같은 방식으로 해시해서 저장된 값과 비교하는 식으로 검증한다.

 

  LocalDateTime Instant
담고 있는 정보 날짜 + 시각 (타임존 없음) UTC 기준 절대 시점
서버 간 비교 타임존 설정에 따라 어긋날 수 있음 항상 동일하게 비교 가능
어울리는 용도 사람이 인지하는 달력/시계 개념 토큰 만료, 로그 시각, 이벤트 순서 등 절대적 타임스탬프

정상 갱신: 현재 토큰을 폐기하고 다음 토큰 발급하기

💡포인트 : "쓴 토큰은 바로 버리고, 같은 가족(familyId)으로 새 토큰을 하나 더 낳는다"가 전부이다.

 토큰은 바뀌어도 familyId는 유지된다는 점이다.

1. 빈 값인지부터 확인

전달받은 리프레시 토큰이 null이거나 빈 문자열이면, 더 볼 것도 없이 바로 에러를 던진다.

2. 받은 토큰을 해시해서 DB에서 찾는다

클라이언트가 보낸 원본 토큰을 그대로 조회하지 않고, 똑같이 해시로 변환한 다음 그 해시값으로 DB를 조회한다. 저장할 때 해시해서 넣었으니, 찾을 때도 해시해서 찾아야 같은 값이 맞는지 비교가 된다. 못 찾으면 애초에 존재하지 않는 토큰이라는 뜻이므로 에러.

3. 이미 폐기된 토큰인지 확인 — 재사용 탐지

찾은 토큰이 이미 revoked 상태라면, 이건 "정상적으로는 절대 다시 올 수 없는 토큰이 다시 왔다"는 뜻이다. 누군가 훔쳐서 쓰고 있다는 신호로 보고, 이 토큰이 속한 패밀리 전체를 통째로 무효화한 뒤 에러를 던진다. 정상 사용자든 공격자든 이 패밀리로는 더 이상 아무 것도 못 하게 된다.

4. 만료 여부 확인

재사용은 아니지만, 유효기간이 지났으면 여기서 걸러진다.

5. 회원 정보 조회 및 기존 토큰 폐기

여기까지 통과했으면 정상적인 갱신 요청이다. 이 토큰의 주인(memberId)으로 회원을 다시 조회하고, 지금 사용한 토큰은 revoke()로 폐기 처리한다. 한 번 쓴 토큰은 여기서 생을 마감한다.

6. 새 리프레시 토큰 발급 — 같은 패밀리를 물려받음

완전히 새로운 랜덤 토큰을 만들지만, familyId만큼은 새로 만들지 않고 방금 폐기한 토큰(saved)의 familyId를 그대로 가져다 쓴다.

즉 토큰 자체는 바뀌어도 "어느 로그인에서 시작된 흐름인지"는 계속 이어진다.

7. 새 액세스 토큰과 새 리프레시 토큰을 반환

갱신된 두 토큰을 클라이언트에게 내려주며 끝난다.

@Transactional(noRollbackFor = BusinessException.class)
원래는 "에러 나면 다 취소"가 기본값인데, noRollbackFor = BusinessException.class는
" 이 에러만큼은 예외로 치고, 방금 한 작업은 취소하지 마라"고 콕 집어 말해주는 설정이다.

재사용 탐지: 이전 토큰이 다시 들어왔다면?

예를 들어 RT1을 사용해 RT2를 발급받았다고 하자. 이때 RT1은 이미 폐기된 상태다.

그런데 RT1이 다시 들어왔다면 어떤 상황일까?

  • 공격자가 탈취한 RT1을 사용했을 수 있다.
  • 정상 클라이언트가 이전 토큰을 다시 보냈을 수 있다.
  • 응답 유실이나 중복 요청으로 같은 토큰이 재전송됐을 수 있다.

서버는 토큰만 보고 공격자와 정상 사용자를 확실하게 구분하기 어렵다. 따라서 이번 구현에서는 재사용을 위험 신호로 보고 해당 패밀리를 폐기한다.

if (saved.isRevoked()) {
    refreshTokenRepository.revokeAllByFamilyId(saved.getFamilyId());
    throw new BusinessException(ErrorCode.REFRESH_TOKEN_REUSE_DETECTED);
}
@Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("update RefreshToken r set r.revoked = true where r.familyId = :familyId")
    int revokeAllByFamilyId(@Param("familyId") String familyId);

폐기한 토큰을 바로 삭제하면 안 되는 이유

이번 구조는 이전 토큰을 조회해야 어떤 패밀리에 속했는지 알아낼 수 있다.

폐기된 토큰을 곧바로 삭제하면, 재사용 요청이 들어와도 단순히 “존재하지 않는 토큰”으로만 판단하게 된다.

따라서 후속 토큰을 보호하는 데 필요한 기간 동안 이전 토큰의 이력을 보존하는 정책이 필요하다.

패밀리 폐기가 즉시 로그아웃을 뜻할까?

정확하게는 해당 패밀리의 리프레시 토큰으로 더 이상 재발급할 수 없다는 뜻이다.

이미 발급한 액세스 토큰을 서명과 만료만으로 검증한다면, 그 토큰은 남은 유효 시간 동안 사용할 수 있다.

액세스 토큰까지 즉시 차단하려면 별도의 폐기 확인 구조가 필요하다.


동시성 문제

동시성 문제는 두 가지로 나눠 봐야 한다.

① 두 요청이 모두 정상 토큰으로 읽는 경우

초기 코드에는 잠금이나 조건부 갱신이 없다. 따라서 같은 토큰으로 요청 두 개가 동시에 들어오면 다음 상황이 가능하다.

순서  요청 A 요청 B
1 RT1 조회: 사용 가능 RT1 조회: 사용 가능
2 RT1 폐기 처리 RT1 폐기 처리
3 RT2 발급 RT3 발급

이 경우 하나의 토큰에서 두 개의 후속 토큰이 만들어진다.

이를 막으려면 유효성 확인·기존 토큰 소비·후속 토큰 발급이 경쟁 상황에서도 한 번만 성공하도록 제어해야 한다.

비관적 락, 낙관적 락, 조건부 갱신 등을 검토할 수 있다. 또한 같은 패밀리의 다른 토큰 갱신과 패밀리 전체 폐기가 겹치는 상황까지 고려해야 한다.

② 정상적인 재시도가 재사용으로 판단되는 경우

중복 발급을 막더라도 다음 문제는 남는다.

  1. 클라이언트가 RT1으로 갱신을 요청한다.
  2. 서버가 RT1을 폐기하고 RT2를 발급한다.
  3. 네트워크 문제로 클라이언트가 응답을 받지 못한다.
  4. 클라이언트가 RT1으로 다시 요청한다.
  5. 서버가 재사용으로 판단한다.

여러 탭이 같은 리프레시 토큰을 공유하는 경우에도 비슷한 상황이 생길 수 있다. 반면 기기마다 별도로 로그인하고 서로 다른 패밀리를 사용한다면, 다른 기기라는 이유만으로 이 문제가 발생하지는 않는다.

유예 시간은 별도의 정책이다

일부 구현은 짧은 유예 시간으로 응답 유실과 재시도 문제를 완화한다. 

다만 유예 시간을 두는 것만으로 동시성이 해결되지는 않는다. 다음 사항을 함께 결정해야 한다.

  • 클라이언트의 중복 갱신 요청을 어떻게 묶을 것인가?
  • 유예 시간 안에서 어떤 요청을 재시도로 인정할 것인가?
  • 재시도에 동일한 결과를 반환할 것인가?
  • 그 결과를 어디에, 얼마나 오래 보관할 것인가?

특히 DB에 토큰 해시만 저장했다면 원본 토큰을 복원할 수 없다. 이전에 발급한 토큰을 그대로 다시 내려주려면, 안전한 단기 결과 보관 등 추가 설계가 필요하다.


클라이언트측 흐름

1. 로그인 → 액세스 토큰은 JS 변수에 저장, 리프레시 토큰은 쿠키에 자동 저장됨
2. API 요청 시 Authorization: Bearer {액세스 토큰} 헤더로 보냄
3. 401 응답을 받으면 → /api/auth/refresh 호출 (쿠키는 브라우저가 자동으로 같이 보냄)
4. 새 액세스 토큰을 받아서 실패했던 요청을 다시 시도

마무리 

처음에는 로그인 유지 기능을 추가하려고 시작했다. 하지만 리프레시 토큰을 도입하면서 고민해야 할 범위는 더 넓어졌다.

토큰을 언제 교체할 것인지, 이전 토큰이 다시 들어오면 어떻게 판단할 것인지, 오류가 발생해도 폐기 상태를 남길 수 있는지, 동시에 요청이 들어와도 한 번만 발급되는지까지 확인해야 했다.

이번 설계에서 각 장치의 역할은 분명하다.

  • 짧은 액세스 토큰 수명은 탈취된 액세스 토큰의 사용 가능 시간을 제한한다.
  • 로테이션은 갱신할 때마다 리프레시 토큰을 교체한다.
  • 재사용 탐지는 이전 토큰이 다시 사용됐을 때 해당 로그인 체인을 차단한다.

로테이션과 재사용 탐지는 탈취 자체를 막거나 모든 탈취를 즉시 발견하는 장치는 아니다. 하지만 이전 토큰의 재사용이라는 신호를 포착하고, 후속 재발급을 끊을 수 있는 구조를 만든다.

리프레시 토큰 도입에서 가장 크게 배운 점은 이것이다.

인증은 토큰을 발급하는 순간 끝나지 않는다.
발급한 토큰이 교체되고, 만료되고, 폐기되는 과정까지 설계해야 한다.