Welcome to the tech blog of Junior Backend Developer Seulbin Kang!
Backend
(BE 개발자 3개월차) 현업에서 고민한 RefreshToken 동시성 관리
-
👀 들어가기 전에
이전 포스팅에서 JWT를 다룬 김에 당시 고민하게 되었던 Refresh Token의 동시성 관리에 대해 더 자세히 남겨보려 한다.
우선 요구사항은 다음과 같다. 동시 로그인은 불가해야하며, 계정 하나당 하나의 기기에서 로그인하게 되며, 나중에 로그인한 기기가 있다면 현재 로그인 중인 기기가 로그아웃 되는 방식이어야한다는 것이다. 단순히 토큰 발급하고 끝나면 될 문제가 아니라 동시성 관점에서 안전한 인증 구조를 어떻게 설계할 것인가에 대해 많은 고민을 했고 이번 글에서 고민했던 Refresh Token 동시성 관리 방식과, 실제로 어떤 설계를 선택했는지 정리해보려 한다.
👀 본론
처음 요구사항을 듣고 정리부터 해보았다.
당시 정리한 요구사항
즉, 위 요구사항을 자세히 살펴보면 계정 단위 단일 세션 정책을 반영한 것을 확인할 수 있다.
1. 어떻게 만족시킬 것인가?
처음에는 단순하게 이렇게 생각했다.
Refresh Token을 DB에서 삭제 시키고, 계속해서 새로운 토큰을 발급하면 되지 않을까?
즉,
- 로그인 시 refreshToken 저장하고
- 새로운 로그인 시 기존 refreshToken을 삭제하고
- 다시 새 refreshToken을 저장하는 방식이다.
그러나, 좀만 생각해보면 이 방식이 얼마나 문제 있는 방식인지를 알 수 있다.
Refresh Token을 삭제한다고 해서 이미 발급된 Access Token이 즉시 무효화되는 것은 아니다.
Access Token은 기본적으로 stateless하게 작동한다. 그래서 이미 기존 기기가 accessToken을 가지고 있다면, refreshToken이 삭제되어도 accessToken 만료 전까지는 일정 시간 동안 로그아웃이 안된 것 처럼 작동 가능하다.
즉, 나중에 로그인한 기기가 우선이 될 수가 없는 것이다. 기존 기기가 즉시 로그아웃이 되어야 하는데, 여전히 로그인된 상태로 남아있게 되는 것이다.
그렇다면 문제되는 상황은 이것 하나뿐일까?
아니다....😭
Refresh Token 재발급 흐름 자체도 동시성 관점에서 취약한 지점을 가지고 있다.
아래와 같은 상황을 가정해보자
Access Token이 만료된 상태
동일한 Refresh Token을 사용해
거의 동시에 두 개의 재발급 요청이 들어오는 경우
이때, 별도의 동시성 방어가 없다면 아래와 같은 문제가 발생한다.
두 요청 모두 정상적인 Refresh Token으로 판단되어 각각 새로운 Access Token을 발급받는다.
따라서 탈취된 Refresh Token이 재사용 되더라도 이를 탐지하는 것이 불가하다...
아래와 같은 상황이 발생하는 것이다.
해커가 탈취해버린다면?
즉, Refresh Token을 삭제하고 재발급 시키더라도 동시 요청과 재사용 공격까지 고려해야하는 것이다.
(별첨) 왜 블랙리스트 방식을 안썼나요?
Access Token을 블랙리스트로 관리하는 방식도 한때 고려헀다. 로그아웃 시 Access Token을 아예 블랙리스트로 등록시켜서 차단 시켜버리고 매 요청마다 해당 토큰이 차단 대상인지 확인을 하면, 앞에서 말한 동시 로그인 불가는 어떻게 해결되더라도 아래 Refresh Token을 동시에 재발급하는 상황은 어쨋든 발생하게 되며, 만료된 Access Token을 무한정으로 DB에 쌓을 수도 없을 뿐더러, 매 요청마다 상태 기반으로 검증하는 것은 불필요한 Disk IO 비용의 증가를 가져온다고 생각하였기 때문에 선택하지 않았다.
(Redis를 써서 Disk I/O를 줄인다고 하더라도 그게 정말 근본적인 해결방법은 아니라는 생각이 들었다.)
2. 그럼 어떻게 풀어나갔나
1) 토큰마다 버전을 둔다면?
아이디어는 낙관락으로 부터 나왔다. '낙관락에서 버전을 두고 관리하는 것과 동일하게 토큰에도 버전을 둔다면?' 이라는 생각에서 출발하여 Token마다 version을 두었다. 기존 세션을 무효화 시키는 이벤트들(로그인, 로그아웃, 비밀번호 변경)마다 이 값을 증가 시켜 버린다.
Access Token과 Refresh Token을 발급할 때는 이 tokenVersion값을 JWT Claim에 포함시킨다.
버전이 일치할 경우 로직버전 불일치 로직
서버는 매 요청마다 토큰에 포함된 버전을 DB 내부 버전과 비교한다.
(물론 이때도 Disk I/O가 발생하지만, 전체 AccessToken 리스트들을 다 메모리로 불러와서 비교를 하는 것과 버전만 딸랑 가져오는 것은 비용 적인 차이가 분명히 생긴다라고 생각하였다.)
이 방식으로 진행하게 되면, 기존 Access Token이 만료되기 전이라도 즉시 무효화가 가능하고, 나중에 로그인한 기기만 유효한 구조를 만들 수 있었다.
2) 동일한 Refresh Token으로 발생하는 Access Token 재발급 동시성 문제
앞서 살펴본 문제의 핵심은 이것이었다.
동일한 Refresh Token을 사용한 Access Token 재발급 요청이 동시에 여러번 성공할 수 있다.
이를 해결하기 위해서는 Refresh Token을 한 번만 성공할 수 있는 멱등성이 성립하는 자원이어야만 했다.
여기서 시도하게 된 방법이 바로 Refresh Token Rotation이다.
Refresh Token Rotation이란?
말 그대로 재발급 요청이 들어오면, 새로운 Access Token과 함께 새로운 Refresh Token을 발급하고 기존 Refresh Token은 즉시 폐기하는 방식이다.
즉, 하나의 Refresh Token은 단 한 번만 유효해야 한다는 전제를 둔다.
여기까지 왔을 때 또 다른 질문이 생겼다.
'거의 동시에 들어온 두 개의 재발급 요청은 어떻게 구분할 것인가?'
에서 나온 해결책이 아래의 방법이다.
Compare And Set 방식
DB에 저장된 Refresh Token값이 요청으로 들어온 Refresh Token과 정확히 일치할 때만 새로운 Refresh Token으로 교체한다.
즉, 동시에 두 개의 요청이 들어오더라도 DB레벨에서 단 하나의 요청만 성공할 수 있도록 만든 것이다.
결국 이 설계로 만들어낸 구조는 다음과 같다.
내가 구현한 로그인 로직
즉, 공격자가 토큰을 탈취하더라도 사용자가 비밀번호를 변경하는 순간, 해당 토큰은 더 이상 유효한 인증 수단으로 사용될 수 없다.
비밀번호 변경과 같은 보안에 직접적인 영향을 주는 행위는 사내 시스템 내에서만 수행 가능하며, 서버가 직접 관리하는 권한 경계 안에서 처리된다. 따라서 공격자는 이미 탈취한 토큰만으로는 이후의 인증 흐름을 다시 복구할 수 없게 된다.
👀 결론
이 구조를 설계하면서 한 가지 계속해서 드는 생각이 있었다.
이 방식은 전통적인 의미의 Stateless JWT와는 거리가 있다.
Access Token과 Refresh Token 모두에 대해 서버가 일정 수준의 상태를 관리하고 있으며, 이는 완전한 무상태 인증이라는 JWT의 이상적인 모델과는 다르다.
결국 완전한 무상태를 유지하는 것은 현실적으로 불가능하거나, 오히려 더 큰 복잡도를 유발할 수 있다고 판단했다.
결국 Access Token 전체를 추적하거나 블랙리스트를 사용하는 방식 대신,
계정 단위의 tokenVersion 하나
Refresh Token 1회성 사용 여부
라는 최소한의 상태만 서버가 관리하도록 선택했다.
그 결과, 매 요청마다 토큰 전체를 비교하는 것이 아니라 DB에서 숫자 하나(version)만 조회하여 간단한 값 비교로 유효성을 판단할 수 있게 되었다.