인후 서비스의 인증 시리즈는 아래 순서대로 진행돼요.
- 1편: Access Token과 Refresh Token 사용하기
- 2편: (이번 편) 중복 로그인 방지, 기기 제한 기능 도입하기
- 3편: E2E 테스트마다 독립적인 로그인 상태 유지하기
안녕하세요, 개발자 민경찬입니다.
이전 편에서는 Access Token과 Refresh Token을 왜 분리해서 사용하게 되었는지, 그리고 Refresh Token을 서버에 저장하는 방식으로 어떤 인증 정책을 구현할 수 있는지 이야기했어요.
이번 2편에서는, Refresh Token을 어떤 구조로 저장할지, 그리고 Redis를 선택한 이유와 실제 기기 제한 정책을 어떻게 구현했는지 공유해보려고 해요.
서비스에서는 앱 로그인과 웹 로그인을 구분하고 있어요.
웹에서 로그인 했을 때 보다 앱에서 로그인 했을 때 더 많은 권한을 가지고 있기 때문이에요.
Refresh Token을 서버에 저장해야 하는 이유는 전 편에서 말씀드린 것처럼,
강제 로그아웃, 로그인 기기 제한, 토큰 탈취 감지 같은 서버 주도 인증 정책을 구현하기 위함이에요.
구체적으로는 아래의 기능들이 있어요.
이런 기능을 필요로 했죠.
RDB는 TTL을 직접 반영하려면 별도 로직이 필요하고,
이미 영구 데이터를 보관하는 책임이 있어 토큰 관리에는 다소 부담이 있었어요.
반면 Refresh Token은 일정 시간 후 만료되는 임시 세션성 데이터이기 때문에,
TTL 관리가 간단하고 읽기/쓰기에 최적화된 메모리 기반 Redis가 더 적합하다고 판단했어요.
이제부터는 Redis에 어떤 자료구조를 사용하여 저장하였는지 이야기해볼게요.
처음에는 단순하게 String 구조로 저장하려 했어요.
Key | Value | TTL |
---|---|---|
auth:{refresh-token} | metadata | n시간 |
구현이 매우 간단하고, 특정 토큰의 유효성 확인도 빠르게 가능하며 TTL도 적용할 수 있죠.
하지만 서비스에서는 이런 기능이 필요했어요
이를 위해서는 반드시 특정 사용자의 로그인 토큰 목록을 봐야만 했죠.
현재 구조에서는 기능을 구현할 수 없죠.
그래서 이렇게 바꿨어요.
Key | Value | TTL |
---|---|---|
auth:{user-id}:{refresh-token} | metadata | n시간 |
이제 특정 유저와 관련된 토큰만 따로 구분할 수 있게 되었어요.
하지만 여전히 문제가 있었어요.
특정 유저의 Refresh Token 목록을 보려면 SCAN 명령어로 Redis의 모든 키를 탐색해야만 했죠.
SCAN은 KEYS보다는 안전하지만, 서비스 규모가 커지면 병목의 원인이 될 수 있어요.
특히 로그인 활동을 보여주기 위해 매 요청마다 SCAN을 쓰는 것은 부담이었어요.
String 자료구조를 포기해야했죠.
그래서 고민 끝에 Redis의 Hash 자료구조를 쓰기로 했어요.
Key | Field | Value | TTL |
---|---|---|---|
auth:{user-id} | n시간 | ||
app-{refresh-token} | metadata | ❌ | |
web-{refresh-token} | metadata | ❌ | |
web-{refresh-token} | metadata | ❌ |
Hash 자료구조를 사용하여 사용자 식별자를 키로 하여 각 토큰을 필드로 저장할 수 있게 되었어요.
이제 이런 것들을 할 수 있게 되었어요.
HLEN
으로 사용자의 로그인 기기 개수를 O(1)로 알 수 있게 되었어요.HGETALL
로 현재 로그인 활동 목록을 O(1)로 가져올 수 있게 되었어요.하지만 이 방식도 결정적인 한계가 있었어요.
바로, Hash의 Field에는 TTL을 걸 수 없다는 점이에요.
그 결과, 로그아웃하지 않은 토큰은 Redis에 영구히 남게 되는 문제가 생겼어요.
3차 시도까지 적용해보며, 우리가 원하는 기능은 대부분 충족되었지만
결정적인 한 가지 문제, 즉 각 필드에 TTL을 설정할 수 없다는 점이 여전히 남아 있었어요.
이 구조에서는 사용자가 로그아웃을 하지 않는 이상,
모든 Refresh Token이 Redis에 영구히 남는 문제가 있었죠.
이 문제를 해결하려면 선택지는 두 가지였어요.
하지만 이 둘 다 큰 비용이 드는 선택이었기 때문에
고민 끝에 Redis 기반 구조를 유지하면서도 TTL 문제를 해결할 수 있는 방법을 찾고 있었어요.
그러던 중 반가운 소식을 접했어요.
Redis 7.4부터는 Hash의 각 필드에도 TTL을 설정할 수 있게 되었어요!
Key | Field | Value | TTL |
---|---|---|---|
auth:{user-id} | |||
app-{refresh-token} | metadata | n시간 | |
web-{refresh-token} | metadata | n시간 | |
web-{refresh-token} | metadata | n시간 |
이제는 auth:{user-id}
키 아래의 각 토큰 필드에 TTL을 개별로 걸 수 있어요.
운이 좋았어요.
Redis를 선택하기 전, 원하는 기능이 Redis로 구현 가능한 것인지 확인했어야했죠.
로그인 활동을 조회하고, 특정 토큰을 무효화하는 기능을 구현하던 중 중대한 보안 문제를 발견했어요.
강제 로그아웃을 하려면, 클라이언트가 무효화할 Refresh Token을 지정해서 보내야 해요.
그러려면 로그인 활동 목록을 조회할 때, 모든 Refresh Token을 클라이언트에게 그대로 노출해야 하죠.
하지만 Refresh Token은 만료 시간이 길고 탈취 시 피해가 크기 때문에,
이처럼 네트워크를 자주 타게 되면 위험이 크게 증가해요.
게다가 클라이언트 입장에서도,
Refresh Token 자체를 직접 다루고 보관해야 한다는 건 불필요하고 부담스러운 책임이에요.
그래서 Refresh Token 마다 고유한 토큰 ID(JTI)를 만들어 저장하기로 했어요.
JTI는 Refresh Token의 Payload로 포함되어있기도 해요.
Key | Field | Value | TTL |
---|---|---|---|
auth:{user-id} | |||
app-{refresh-token-id} | metadata | n시간 | |
web-{refresh-token-id} | metadata | n시간 | |
web-{refresh-token-id} | metadata | n시간 |
이렇게 바뀌었어요!
이 구조 덕분에 다음과 같은 기능을 안전하게 구현할 수 있었어요.
Redis의 Hash 자료구조로 유저별 토큰 개수 확인이 O(1) 이고,
web-{refresh-token-id}
, app-{refresh-token-id}
로 구분되어 있어 정책 적용이 쉬웠어요.
Redis Hash 구조를 다시볼게요.
Key | Field | Value | TTL |
---|---|---|---|
auth:{user-id} | |||
app-{refresh-token-id} | metadata | n시간 | |
web-{refresh-token-id} | metadata | n시간 | |
web-{refresh-token-id} | metadata | n시간 |
Value인 metadata
에는 토큰이 발급될 당시의 기기 정보, OS, IP, User-Agent 등의 환경 정보가 담겨 있어요.
서버는 Refresh Token을 사용할 때마다, 현재 요청의 환경 정보와 metadata를 비교해요.
이 값들이 다르다면, 탈취 가능성이 높다고 판단할 수 있죠.
이후엔 서버 정책에 따라 의심 알림을 보내거나, 재인증을 요구하는 방식으로 대응할 수 있어요.
user-1
계정으로 이미 두 기기에서 웹 로그인되었다고 가정해볼게요.
이처럼 인증 정책의 핵심인 기기 수 제한 로직이 무력화되는 상황이 생길 수 있어요.
싱글 스레드인 Redis 자체는 Race Condition을 피할 수 있지만,
서버에서 조회 → 검증 → 삽입까지의 사이클 중 동시성을 제어하지 않으면 이 문제는 항상 발생할 수 있어요.
이 문제를 해결하려면 Redis에서 원자적으로 기기 개수를 확인하고 토큰을 삽입하는 구조(예: Lua 스크립트 기반 트랜잭션 등)를 고민할 수 있어요.
팀에서는 분산락 방법을 진지하게 검토하고 있어요.
metadata
를 비교해서 토큰 탈취 여부를 감지하는 구조는 토큰 자체만 탈취된 경우에는 꽤 유효해요.
공격자가 Refresh Token만 가지고 있다면, 정상적인 User-Agent나 IP 등의 metadata를 재현하기 어렵기 때문이에요.
하지만 만약 통신 전체를 감청하는 스니핑이나 중간자 공격(MITM) 상황이라면 이야기가 달라져요.
이 경우 공격자는 Refresh Token뿐 아니라, 함께 전송되는 User-Agent, IP 같은 metadata까지 모두 확보할 수 있어요.
그렇다면 이후의 요청에서도 정상 사용자의 환경을 그대로 복제해 사용할 수 있기 때문에,
서버 입장에서는 탈취된 요청인지 아닌지를 구분하기 어려워지는 거죠.
이 문제는 완벽하게 감지하기 어려운 영역이지만, Refresh Token Rotation이나 TLS 강제 적용 같은 추가적인 방어 전략을 함께 검토하고 있어요.
다음 편에서는 서비스에서 어떤 테스트를 고집하고 있는지, 테스트 환경에서 독립적인 Redis 환경은 어떻게 이루고 있는지 소개해볼게요.
읽어주셔서 감사합니다.