인증 시리즈 2편: 토큰을 추적하고 중복 로그인 방지하기

민경찬·2025년 7월 31일
9

백엔드

목록 보기
29/29
post-thumbnail

인후 서비스의 인증 시리즈는 아래 순서대로 진행돼요.

  • 1편: Access Token과 Refresh Token 사용하기
  • 2편: (이번 편) 중복 로그인 방지, 기기 제한 기능 도입하기
  • 3편: E2E 테스트마다 독립적인 로그인 상태 유지하기

안녕하세요, 개발자 민경찬입니다.

이전 편에서는 Access Token과 Refresh Token을 왜 분리해서 사용하게 되었는지, 그리고 Refresh Token을 서버에 저장하는 방식으로 어떤 인증 정책을 구현할 수 있는지 이야기했어요.

이번 2편에서는, Refresh Token을 어떤 구조로 저장할지, 그리고 Redis를 선택한 이유와 실제 기기 제한 정책을 어떻게 구현했는지 공유해보려고 해요.


⭐️ 서비스에 필요한 인증 정책을 소개할게요.

서비스에서는 앱 로그인웹 로그인을 구분하고 있어요.
웹에서 로그인 했을 때 보다 앱에서 로그인 했을 때 더 많은 권한을 가지고 있기 때문이에요.

앱에서는 이런 것을 더 할 수 있어요.

  • 계정에 모든 로그인 활동을 볼 수 있어요.
  • 다른 기기 로그인을 비활성화 하여 로그아웃 시킬 수 있어요.
  • QR인증을 통해 웹 토큰을 새로 발급할 수 있어요.

제한 사항도 있어요.

  • 앱 로그인: 1대만 허용
  • 웹 로그인: 최대 3대까지 허용

🤔 왜 토큰 스토리지로 Redis를 선택했을까요?

Refresh Token을 서버에 저장해야 하는 이유는 전 편에서 말씀드린 것처럼,
강제 로그아웃, 로그인 기기 제한, 토큰 탈취 감지 같은 서버 주도 인증 정책을 구현하기 위함이에요.

구체적으로는 아래의 기능들이 있어요.

  • 강제 로그아웃
  • 로그인 기기 제한
  • 토큰 탈취 감지

이런 기능을 필요로 했죠.

  • 특정 토큰의 유효성을 빠르게 확인해야 해요.
  • 유저 단위로 모든 토큰을 조회할 수 있어야 해요.
  • 만료된 토큰은 자동으로 삭제되길 바라요.

RDB는 TTL을 직접 반영하려면 별도 로직이 필요하고,
이미 영구 데이터를 보관하는 책임이 있어 토큰 관리에는 다소 부담이 있었어요.

반면 Refresh Token은 일정 시간 후 만료되는 임시 세션성 데이터이기 때문에,
TTL 관리가 간단하고 읽기/쓰기에 최적화된 메모리 기반 Redis가 더 적합하다고 판단했어요.

이제부터는 Redis에 어떤 자료구조를 사용하여 저장하였는지 이야기해볼게요.

👀 1차 시도: String 자료구조 사용하기

처음에는 단순하게 String 구조로 저장하려 했어요.

KeyValueTTL
auth:{refresh-token}metadatan시간

구현이 매우 간단하고, 특정 토큰의 유효성 확인도 빠르게 가능하며 TTL도 적용할 수 있죠.

하지만 서비스에서는 이런 기능이 필요했어요

  • 로그인 활동 목록 보기
  • 웹에서 발급한 Refresh Token은 3개로 제한

이를 위해서는 반드시 특정 사용자의 로그인 토큰 목록을 봐야만 했죠.
현재 구조에서는 기능을 구현할 수 없죠.

👀 2차 시도: String + key 네임스페이스 사용하기

그래서 이렇게 바꿨어요.

KeyValueTTL
auth:{user-id}:{refresh-token}metadatan시간

이제 특정 유저와 관련된 토큰만 따로 구분할 수 있게 되었어요.
하지만 여전히 문제가 있었어요.

특정 유저의 Refresh Token 목록을 보려면 SCAN 명령어로 Redis의 모든 키를 탐색해야만 했죠.

SCAN은 KEYS보다는 안전하지만, 서비스 규모가 커지면 병목의 원인이 될 수 있어요.
특히 로그인 활동을 보여주기 위해 매 요청마다 SCAN을 쓰는 것은 부담이었어요.

String 자료구조를 포기해야했죠.

👀 3차 시도: Hash 자료구조 사용하기

그래서 고민 끝에 Redis의 Hash 자료구조를 쓰기로 했어요.

KeyFieldValueTTL
auth:{user-id}n시간
app-{refresh-token}metadata
web-{refresh-token}metadata
web-{refresh-token}metadata

Hash 자료구조를 사용하여 사용자 식별자를 키로 하여 각 토큰을 필드로 저장할 수 있게 되었어요.

이제 이런 것들을 할 수 있게 되었어요.

  • HLEN 으로 사용자의 로그인 기기 개수를 O(1)로 알 수 있게 되었어요.
  • HGETALL 로 현재 로그인 활동 목록을 O(1)로 가져올 수 있게 되었어요.
  • 각 Field-key에 web/app prefix를 넣어 플랫폼 구분도 가능해졌어요.

하지만 이 방식도 결정적인 한계가 있었어요.
바로, Hash의 Field에는 TTL을 걸 수 없다는 점이에요.

그 결과, 로그아웃하지 않은 토큰은 Redis에 영구히 남게 되는 문제가 생겼어요.

👀 4차 시도: Redis 7.4 부터는 Hash 필드에 TTL을 걸 수 있다!

3차 시도까지 적용해보며, 우리가 원하는 기능은 대부분 충족되었지만
결정적인 한 가지 문제, 즉 각 필드에 TTL을 설정할 수 없다는 점이 여전히 남아 있었어요.

이 구조에서는 사용자가 로그아웃을 하지 않는 이상,
모든 Refresh Token이 Redis에 영구히 남는 문제가 있었죠.

이 문제를 해결하려면 선택지는 두 가지였어요.

  • Redis를 포기하고 별도 토큰 스토리지를 직접 구현하기
  • 아니면, TTL 없이 영구 데이터를 관리하는 구조를 감수하기

하지만 이 둘 다 큰 비용이 드는 선택이었기 때문에
고민 끝에 Redis 기반 구조를 유지하면서도 TTL 문제를 해결할 수 있는 방법을 찾고 있었어요.

그러던 중 반가운 소식을 접했어요.

Redis 7.4부터는 Hash의 각 필드에도 TTL을 설정할 수 있게 되었어요!

KeyFieldValueTTL
auth:{user-id}
app-{refresh-token}metadatan시간
web-{refresh-token}metadatan시간
web-{refresh-token}metadatan시간

이제는 auth:{user-id} 키 아래의 각 토큰 필드에 TTL을 개별로 걸 수 있어요.

운이 좋았어요.
Redis를 선택하기 전, 원하는 기능이 Redis로 구현 가능한 것인지 확인했어야했죠.

🧨 하지만 보안 문제가 남아 있었어요.

로그인 활동을 조회하고, 특정 토큰을 무효화하는 기능을 구현하던 중 중대한 보안 문제를 발견했어요.

강제 로그아웃을 하려면, 클라이언트가 무효화할 Refresh Token을 지정해서 보내야 해요.
그러려면 로그인 활동 목록을 조회할 때, 모든 Refresh Token을 클라이언트에게 그대로 노출해야 하죠.

하지만 Refresh Token은 만료 시간이 길고 탈취 시 피해가 크기 때문에,
이처럼 네트워크를 자주 타게 되면 위험이 크게 증가해요.

게다가 클라이언트 입장에서도,
Refresh Token 자체를 직접 다루고 보관해야 한다는 건 불필요하고 부담스러운 책임이에요.

👀 Refresh Token에 ID 부여하기!

그래서 Refresh Token 마다 고유한 토큰 ID(JTI)를 만들어 저장하기로 했어요.

JTI는 Refresh Token의 Payload로 포함되어있기도 해요.

KeyFieldValueTTL
auth:{user-id}
app-{refresh-token-id}metadatan시간
web-{refresh-token-id}metadatan시간
web-{refresh-token-id}metadatan시간

이렇게 바뀌었어요!

  • 서버는 더 이상 Refresh Token 원문을 저장하지 않아요.
  • 클라이언트도 토큰 문자열이 아닌 토큰 ID만 받고 서버에 전달해요.

✅ 실제 구현한 정책

이 구조 덕분에 다음과 같은 기능을 안전하게 구현할 수 있었어요.

  • 앱은 1대만 로그인 허용
  • 웹은 최대 3대까지 로그인 허용

Redis의 Hash 자료구조로 유저별 토큰 개수 확인이 O(1) 이고,

web-{refresh-token-id}, app-{refresh-token-id}로 구분되어 있어 정책 적용이 쉬웠어요.

💡 토큰 탈취는 어떻게 감지할까요?

Redis Hash 구조를 다시볼게요.

KeyFieldValueTTL
auth:{user-id}
app-{refresh-token-id}metadatan시간
web-{refresh-token-id}metadatan시간
web-{refresh-token-id}metadatan시간

Value인 metadata에는 토큰이 발급될 당시의 기기 정보, OS, IP, User-Agent 등의 환경 정보가 담겨 있어요.

서버는 Refresh Token을 사용할 때마다, 현재 요청의 환경 정보와 metadata를 비교해요.
이 값들이 다르다면, 탈취 가능성이 높다고 판단할 수 있죠.

이후엔 서버 정책에 따라 의심 알림을 보내거나, 재인증을 요구하는 방식으로 대응할 수 있어요.

‼️아직 해결하지 못한 것들

1. 중복 로그인 검증 도중, Race condition이 발생할 수 있어요.

user-1 계정으로 이미 두 기기에서 웹 로그인되었다고 가정해볼게요.

  • 1번 요청이 들어오고, 서버는 Redis를 조회하여 현재 2개의 로그인 기록이 있음을 확인해요.
  • 2번 요청도 거의 동시에 들어오고, 역시 Redis에서 같은 결과(2개)를 확인해요.
  • 두 요청 모두 "로그인 가능"하다고 판단해 새로운 토큰을 각각 생성하고 저장해요.
  • 그 결과, 최대 허용 기기 수(3대)를 넘어서 4개의 로그인 토큰이 저장되는 문제가 발생해요.

이처럼 인증 정책의 핵심인 기기 수 제한 로직이 무력화되는 상황이 생길 수 있어요.

싱글 스레드인 Redis 자체는 Race Condition을 피할 수 있지만,
서버에서 조회 → 검증 → 삽입까지의 사이클 중 동시성을 제어하지 않으면 이 문제는 항상 발생할 수 있어요.

이 문제를 해결하려면 Redis에서 원자적으로 기기 개수를 확인하고 토큰을 삽입하는 구조(예: Lua 스크립트 기반 트랜잭션 등)를 고민할 수 있어요.
팀에서는 분산락 방법을 진지하게 검토하고 있어요.

2. 스니핑이나 중간자 공격 탈취 시나리오라면, 토큰 탈취를 감지할 수 없어요.

metadata를 비교해서 토큰 탈취 여부를 감지하는 구조는 토큰 자체만 탈취된 경우에는 꽤 유효해요.
공격자가 Refresh Token만 가지고 있다면, 정상적인 User-Agent나 IP 등의 metadata를 재현하기 어렵기 때문이에요.

하지만 만약 통신 전체를 감청하는 스니핑이나 중간자 공격(MITM) 상황이라면 이야기가 달라져요.
이 경우 공격자는 Refresh Token뿐 아니라, 함께 전송되는 User-Agent, IP 같은 metadata까지 모두 확보할 수 있어요.

그렇다면 이후의 요청에서도 정상 사용자의 환경을 그대로 복제해 사용할 수 있기 때문에,
서버 입장에서는 탈취된 요청인지 아닌지를 구분하기 어려워지는 거죠.

이 문제는 완벽하게 감지하기 어려운 영역이지만, Refresh Token Rotation이나 TLS 강제 적용 같은 추가적인 방어 전략을 함께 검토하고 있어요.


👋 마무리…

다음 편에서는 서비스에서 어떤 테스트를 고집하고 있는지, 테스트 환경에서 독립적인 Redis 환경은 어떻게 이루고 있는지 소개해볼게요.

읽어주셔서 감사합니다.

0개의 댓글