카테고리 없음

RefreshToken은 왜 Redis를 사용해 관리할까? (with. RTR 방식)

최동훈1 2024. 7. 10. 21:32

1. “왜?” 🤔

우리는 MySQL과 같은 일반 DB를 다룰 줄 아는데 사람들은
 굳이 리프레시 토큰을 Redis에 저장하여 사용하는지 이유가 궁금했으며,

 다들 Redis를 사용해서 리프레시 토큰을 저장하는 지 궁금했습니다.

 

 

 


2. Redis가 뭔데?

레디스는 디스크가 아닌 메모리에 데이터를 저장하는 In-Memory 방식의 데이터베이스입니다.

 

 

2-1. In-Memory?

In-Memory 데이터베이스는 MySQL과 같은 다른 일반 DB들처럼 SSD, HDD와 같은 보조기억장치가 아닌, 프로세서가 직접 액세스할 수 있는 컴퓨터의 주 메모리인 RAM에 데이터를 저장합니다.

 

 

2-2. 디스크에 저장하는 것과 무슨차이가 있는데?

특정 프로그램을 실행하면, 아래와 같이 컴퓨터는 보조기억장치에 저장된 데이터를 RAM으로 불러와 CPU가 해당 데이터를 처리하는 과정으로 실행됩니다.

Disk-RAM-CPU 플로우

 

여기서 장점이 확연히 보이지 않나요?

RAM에 데이터를 저장하여 사용하게 되면 보조기억장치에서 데이터를 Load하는 비용이 절약됩니다.

때문에 인메모리 데이터베이스의 읽기 및 쓰기 연산은 기존 디스크 기반 데이터베이스보다 훨씬 빠릅니다.

 

 

하지만 보조기억장치와는 다르게 RAM은 전원이 끊어지면 데이터가 전부 지워지는 휘발성 메모리(Volatile Memory)라는 특징이 있다!

 


3. Redis의 특징

  • Key-Value의 형태의 데이터베이스이기 때문에 적은 메모리로도 데이터를 저장할 수 있으며, 작성 속도가 빠름
  • Key-Value 형태를 가지고 있기 때문에 키를 알고 있다면 조회 성능이 O(1)까지 나온다는 장점을 가짐
  • 인메모리 DB 방식으로 빠르게 접근이 가능
  • 휘발성인 In-Memory DB는 영구적으로 저장될 필요가 없는 Refresh token을 관리하기에 충분
  • 캐시처럼 데이터 만료일을 정할 수 있음

 

 

3-1. Redis의 특징이 Refresh Token 보관에 이점이 되는 이유

이러한 Redis의 특징을 토대로 Refresh Token을 보관하는 것이 적합한 이유를 설명해보자면 아래와 같습니다.

  1. 우선 RDB와는 다르게 데이터의 만료일을 지정할 수 있습니다. 이를 TTL(Time-To-Live)이라고 하는데, 이를 토큰의 만료일과 똑같이 맞춰두어 관리하면 토큰이 만료되면 Redis에서도 토큰이 삭제되도록 하여 데이터를 효율적으로 관리할 수 있습니다.

  2. 대체로 30분~2시간 단위로 갱신하는 JWT Access Token은 새롭게 갱신하기 위해 Refresh Token이 필요합니다.
    이렇게 호출의 빈도가 대체적으로 높은 Refresh Token은 RDB에 저장하는 것보다 In-Memory DB에 저장해두고 사용하는 것이 훨씬 속도가 빠르고, 토큰 ReIssue 로직의 병목현상을 방지할 수 있다습니다.

  3. 모든 기술 사용에는 트레이드 오프가 존재합니다. RDB vs In-Memory DB의 트레이드 오프는 속도 안정성이라고 생각합니다.
    In-Memory 방식은 굉장히 뛰어난 조회 속도를 얻을 수 있어 호출의 빈도가 높은 토큰 ReIssue 로직의 병목현상을 방지할 수 있습니다. 하지만 In-Memory DB는 휘발성 메모리이기 때문에 전원이 끊어지면 데이터가 전부 지워집니다.
    그런데 단지 Refresh Token만을 관리하기 위해 Redis를 사용한다면 이 트레이드 오프는 손실보다 이득이 더 클 수도 있습니다. Refresh Token이 모두 삭제되었을 때 발생하는 가장 큰 사건은 모든 유저가 재로그인을 해야한다는 점인데, 웬만해선 전원이 끊어질 일이 자주 발생하지 않고 때문에 유저 경험에 그리 크리티컬하게 작용하지 않는다고 생각합니다.

 

 


4. 그럼 RefreshToken은 왜 DB에 저장해서 사용하는 건데?

우선 리프레시 토큰과 엑세스 토큰에 대해 알아보겠습니다.

  • 엑세스 토큰: 서버에 API를 직접 요청(Access)할 때 사용
  • 리프레시 토큰 엑세스 토큰이 만료되었을 때, 엑세스 토큰을 재발급(Refresh)할 때 사용

이 둘이 분리된 이유는 보안입니다.

토큰을 탈취당하면 다른 사람이 내 계정의 권한을 사용하여 서비스를 마음대로 사용할 위험이 있기 때문에 절대 노출되면 안됩니다.

JWT 토큰은 Stateless라는 HTTP의 특징에 의해 한번 발급되면 그 뒤로 토큰의 상태에 대해 관리할 수 없으며,
그렇다보니 회원이 토큰을 통해 요청을 보낸 건지, 해커가 토큰을 탈취해서 요청을 보낸 건지 알 수 없습니다.

 

 

때문에 유저 인증 역할을 가진 엑세스 토큰은 대체로 만료시간을 짧게 잡습니다. (보통 30분 ~ 2시간 정도로 생각하는데, 서비스에 따라 다릅니다)

이러한 엑세스 토큰이 만료되면 리프레시 토큰을 통해 새로운 엑세스 토큰을 발급받게 됩니다.

때문에 리프레시 토큰은 엑세스 토큰보다 훨씬 긴 만료시간을 가집니다. (저는 보통 1주 ~ 1개월 정도로 생각하는데, 서비스에 따라 다를 수 있습니다)

 

 

4-1. 그럼 토큰이 탈취당하면 어떻게 될까요?

엑세스 토큰만 탈취 당한다면 30분 ~ 2시간 정도만 유저의 권한을 마음대로 이용하고 그 뒤엔 사용할 수 없지만,

엑세스 토큰과 리프레쉬 토큰이 모두 탈취 당하면 최악의 경우 해커는 계속해서 엑세스 토큰을 재발급하며 유저의 권한을 악용할 수 있습니다.

 

 

4-2. 이를 방지하기 위해 우리는 Refresh Token을 DB에 저장해놓고 사용합니다.

우선 위의 말 처럼 데이터베이스(Redis) 유저 정보 Refresh Token을 저장하여 사용합니다.

  • Key: 유저 고유정보 (ex. member Entity ID)
  • Value: RefreshToken

그리고 이와 함께 Refresh Token Rotation(RTR) 방식으로 리프레쉬 토큰을 관리하도록 설정합니다.

 

RTR 방식은 RefreshToken을 DB에서 관리할 때 적용할 수 있는 방식입니다.

플로우를 살펴볼까요?

 

 

(좌) 토큰 저장 및 갱신 플로우 / (우) RefreshToken 만료 시 플로우

 

 

 

Access Token이 만료되어 토큰을 갱신시키는 시점에

클라이언트는 토큰 갱신을 위해 토큰 갱신 API 에 AccessToken, RefreshToken을 담아 전송합니다.

전달받은 토큰의 유효성 검사가 완료되면, AccessToken을 통해 유저 정보(member PK)를 얻고,

DB에서 유저 정보에 해당하는 Refresh Token 을 조회해옵니다.

 

이때 'DB에서 조회해온 Refresh Token'과 클라이언트에서 '요청에 담아 보낸 Refresh Token' 이 동일하다면 토큰을 재발급합니다.

새로 발급된 Access Token, RefreshToken은 클라이언트 측에 전송되며, 새로운 RefreshToken은 DB에 갱신됩니다.

 

이렇게 관리하는 경우 Refresh Token을 1회용으로 사용할 수 있습니다.

 

 

 

그렇다면 어떻게 RTR 방식으로 어떻게 탈취로 인한 피해를 방지할 수 있는걸까요?

 

탈취 플로우를 통해 살펴보겠습니다.

 

1. 로그인 시에 유저는 Access Token과 Refresh Token을 응답받습니다.
(이때 Redis에 Refresh Token이 MemberId(Key):RefreshToken(Value) 형태로 저장됩니다.


2. 이때 유저에게 AccessToken, Refresh Token을 전달하는 과정에서 해커가 토큰을 탈취합니다.

 

 

3. 해커는 탈취한 AccessToken을 통해 유저의 권한을 사용하던 중 토큰이 만료되었습니다.
(이때 당연히 같은 토큰을 사용하는 유저도 만료가 됩니다.)

이때 유저가 먼저 토큰을 갱신하는 경우(4-1) 해커가 먼저 토큰을 갱신하는 경우(4-2) 로 두 가지 상황이 있을 수 있습니다.

 

4-1. 이때 유저가 먼저 토큰을 갱신하는 경우, DB의 RefreshToken도 갱신되며
이후에 해커가  토큰 갱신을 요청했을 때 DB에 저장된 RefreshToken과 해커가 갱신을 위해 요청에 담아보낸 RefreshToken이 달라서 토큰 갱신에 실패합니다.

 

4-2. 해커가 먼저 토큰을 갱신하는 경우, DB의 RefreshToken도 갱신되며
이후에 유저가 토큰 갱신을 요청했을 때 DB에 저장된 RefreshToken과 유저가 갱신을 위해 요청에 담아보낸 RefreshToken이 달라서 토큰 갱신에 실패합니다.
그럼 유저는 다시 로그인해야 하며, 재로그인 시에 DB의 RefreshToken도 갱신되어 공격자가 가진 RefreshToken과 DB의 RefreshToken의 상태가 달라집니다.
떄문에 해커는 다음 토큰 갱신에서 실패하게 되며 유저의 권한을 악용할 수 없게됩니다.

 

 

이렇듯 RTR 방식으로 RefreshToken을 관리하게 되면

해커가 Refresh Token을 탈취해도, 실제 회원의 Access Token이 만료되어 토큰을 갱신하는 순간 Redis의 Refresh Token이 변경되어 해커가 탈취한 이전의 Refresh Token은 만료되어 해커는 더이상 토큰을 재발급 받을 수 없습니다.

 

물론 실제 회원이 토큰 탈취 이후, 한동안 로그인을 하지 않게 되면 그 동안은 해커가 계속해서 회원의 권한을 악용할 수 있습니다.때문에 HTTPS 설정이나 httpOnly, Secure 설정을 한 쿠키로 JWT 토큰을 발급하는 등의 방법으로
통신 과정의 안전성을 고려해야 하며, 토큰 탈취 가능성을 낮추기 위해 노력해야합니다.


5. Redis도 @Transactional로 트랜잭션 설정이 가능한가요?

Spring Data Redis의 공식 문서를 살펴본 결과

Redis는 트랜잭션을 RedisConfig의 추가적인 설정을 통해 @Transactional로 JPA와 하나의 트랜잭션으로 묶어 사용이 가능하다는 점을 알게 되었습니다.

 

template.opsForValue().set("thing1", "thing2");

template.opsForValue().get("thing1");

 

하지만 JPA와 같이 영속성 컨텍스트의 1차 캐시와 같은 기능을 지원하지 않기 때문에 문제가 발생할 수 있습니다.

예를 들어 위와 같은 상황에서는 thing1이 set() 메서드로 Redis에 Insert된 후 트랜잭션이 EXEC(commit) 되지 않았기 때문에

template.opsForValue().get("thing1"); 의 결과가 null이 됩니다.

JPA는 DB에 flush 되지 않아도 1차 캐시에 정보가 담겨서,
조회 시에 1차 캐시의 정보를 가져올 수 있지만 Redis는 1차 캐시라는 것이 존재하지 않아서
DB에 커밋되기 전에는 하나의 트랜잭션 내에서 발생한 변경을 조회해올 수 없습니다.

 

자세한 내용은 아래 공식문서에서 참고하실 수 있습니다.

https://docs.spring.io/spring-data/redis/reference/redis/transactions.html

 


글을 마치며

개발을 하다보며 느낀 것은 항상 100% 좋은 기술이나 방법은 없다는 점입니다. 제가 말씀드린 부분 이외에도 여러분들이 아쉽다고 느끼신 부분들이 있다면 꼭 추가해서 더욱 개선된 프로그램을 만들어가는 개발자로 성장하실 수 있으면 좋겠습니다.

긴 글 읽어주셔서 감사합니다!

 

 

 

 

reference

https://puleugo.tistory.com/154

https://kth990303.tistory.com/431