안녕하세요. 호시 입니다. 이번 UMC 프로젝트에서 인증 시스템을 구현하며 겪었던 깊은 고민과 이를 해결한 과정을 공유해보고자 합니다.
대부분의 서비스에서 사용하는 JWT 인증 방식은 상태 비저장(Stateless)라는 강력한 장점을 가지고 있지만, 이러한 특징으로 인해서 ‘중복 로그인 방지’과 같은 보안 기능을 구현하기가 매우 까다롭습니다. 오늘은 이문제를 어떻게 해결했는지, 그 여정을 단계별로 풀어보겠습니다.
왜 JWT는 중복 로그인을 막기 힘든가?
먼저 세션 방식과 JWT 방식의 차이점을 간단히 짚어보겠습니다.
- 세션(Session) 방식 : 사용자가 로그인하면 서버는 세션 ID를 발급하고, 이 ID를 서버 측 저장소(DB, 메모리 등)에 보관합니다. 요청이 들어올때 마다 저장된 세션 ID와 비교하므로, 새로운 로그인이 발생하면 이 전 세션 ID를 삭제함으로써 기존 세션을 만료시킬 수 있고 이를 통해 중복 로그인을 막을 수 있습니다. 그러나 사용자가 많아질수록 서버의 저장소 부하가 커진다는 단점이 있습니다.
- JWT(Json Web Token) 방식 : 로그인 시 서버는 암호화된 토큰을 발급할 뿐, 별도로 저장하지 않습니다. 서버는 요청에 담긴 토큰의 서명과 유효기간만 검증하면 되므로 확장성이 좋고 서버에 가해지는 부하가 적습니다. 그러나 서버에 로그인 상태를 기록하지 않기 떄문에, 특정 사용자의 토큰을 원격에서 무효화 (강제 로그아웃)시키는 것이 거의 불가능 합니다.
제가 개발하는 서비스에는 “새로운 기기에서 로그인 하면, 기존에 로그인된 기기는 자동으로 로그아웃” 되어야 하는 요구사항이 있었습니다. 이미 회원 기능을 JWT로 구현했기에 더 큰 어려움을 느꼈습니다.
1단계 : 기본 보안 체계 구축하기
본격적인 문제 해결에 앞서, JWT를 안전하게 사용하기 위한 기본적인 보안 장치들을 먼저 구축했습니다.
JWT Refresh Token & Rotation (RTR)
인증에 사용되는 Access Token과 이를 재발급받기 위한 Refresh Token으로 역할을 분리했습니다.
- Access Token: 유효기간을 10분~1시간 정도로 매우 짧게 설정하여, 탈취되더라도 위험을 최소화합니다.
- Refresh Token: Access Token보다 긴 유효기간(예 : 7일)을 가지며, Access Token이 만료되었을때 새로운 Access Token을 발급받는 용도로만 사용됩니다.
위 기술을 적용함으로써 Refresh Token을 사용해 새로운 Access Token을 발급받을 때, 기존 Refresh Token도 같이 무효화하고 새로운 Refresh Token을 발급하도록 구현하였습니다. 이를 통해 Refresh Token마저 탈취당했을때 발생할 수 있는 보안 위협을 크게 줄일 수 있습니다.
HttpOnly 쿠키
토큰을 어디에 저장할지도 중요한 문제입니다. localStorage는 JS로 접근이 가능해 XSS(Cross-Site Scripting) 공격에 매우 취약합니다. 이를 막기 위해 HttpOnly 속성을 설정한 쿠키에 Access Token 과 RefreshToken을 모두 저장했습니다.
HttpOnly 쿠키는 자바스크립트(document.cookie)로 접근할 수 없으며, 오직 브라우저의 HTTP 요청에 만 자동으로 담겨 서버로 전송됩니다. 덕분에 XSS 공격으로 토큰이 탈취 될 가능성을 원천적으로 차단할 수 있습니다.
Q. 프론트 엔드는 토큰정보를 어떻게 사용하나요? A. HttpOnly 쿠키는 스크립트를 통해 읽을 수 없으므로 이로 인해 프론트엔드에서 토큰의 페이로드에 포함된 사용자 닉네임이나 프로필 사진과 같은 정보를 읽을 수 없게 됩니다. 저는 이에 대한 대안으로 로그인이나 토큰 재발급 API 응답시 페이로드 정보를 JSON 데이터로 별도 전달해주었습니다. 프론트엔드는 이정보를 받아 별도의 상태관리 라이브러리에 저장하여 사용하도록 요청했습니다.
2단계(핵심 로직) : Access Token에 남긴 표식
이제 이 글의 핵심인 ‘중복 로그인 감지’의 핵심 로직을 설명할 차례 입니다. JWT의 장점을 유지하면서 상태를 추적하기 위해, 저는 DB와 토큰 페이로드를 함께 사용하는 하이브리드 방식을 고안했습니다.
먼저 Refresh Token은 DB에 저장되어 관리 하는 것을 전제로 합니다.
중복 로그인 감지 프로세스
- 로그인 요청 : 사용자가 로그인을 시도합니다.
- 서버는 새로운 Refresh Token을 발급하고, 이 토큰의 정보를 DB에 저장합니다. 이 테이블의 기본키(id컬럼)이 이번 검증 로직의 핵심입니다.
- 토큰 발급: 서버는 Access Token을 발급합니다.
- 이때, Access Token의 페이로드(payload)에
{ "userId": 123, "refreshTokenId": 5 }
와 같이 방금 DB에 저장한 Refresh Token의 기본 키 값을 함께 넣어줍니다.
- 이때, Access Token의 페이로드(payload)에
- API 인증 요청 : 사용자가 인증이 필요한 API를 호출하면, Access Token이 쿠키에 담겨 서버로 전송됩니다.
- 로그인 표식 검증 : 서버는 Access Token의 유효성을 검증한 후 , 다음을 실행합니다.
- Access Token 페이로드에서 userId를 꺼내, DB의 Refersh Token 테이블에서 해당 유저의 가장 최신 Refresh Token정보를 조회합니다.
- 조회된 Refresh Token의 id값과, Access Token 페이로드에 담겨있던 refreshTokenId 값을 비교합니다.
- 서버의 판단 및 응답
- 두 ID가 일치한다면? ⇒ 정상적인 사용자로 판단 후 인증을 승인합니다.
- 두 ID가 일치하지 않는다면? ⇒ 이 유저는 다른기기에서 새로 로그인하여 새로운 Refresh Token이 발급된 상태입니다. 즉, 현재 요청에 사용된 Access Token(refreshTokenId)는 구버전 로그인 표식을 가진 셈입니다. 그러므로 이를 유효하지 않은 토큰으로 판단하고
401 Unauthorized
에러를 반환합니다.
프론트 엔드의 후속 처리
- 프론트 엔드는 API요청에 대해 401 에러를 받으면, 우선 Access Token이 단순 만료된것으로 간주하고 토큰 재발급 (Refresh) API를 호출합니다.
- 하지만 해당 API 로직에서도 DB에 저장된 Refresh Token을 검증하는 과정에서 해당 토큰이 유효하지 않음을 확인하게 되고 결국 재발급 요청 역시 실패하면 다시 401 에러를 반환하게 됩니다.
- 최종적으로 재발급까지 실패한 프론트엔드는 마지막으로 토큰을 받은 시간값을 가지고 리프레시 토큰이 만료되었는지 중복로그인인지 판단후 사용자에게 에러메시지를 출력한 후 로그아웃 후 로그인 페이지로 리다이렉트 시킵니다.
스스로에게 던지는 질문
설계를 마치고 나니 두 가지 의문이 들었습니다.
Q1. 그냥 매번 Refresh Token을 DB에서 검증하면 안 되나요?
인증마다 Access Token 검증 + Refresh Token의 유효성 DB 검증을 하면 되지 굳이 페이로드에 ID를 넣는 이유가 뭔가요?
저는 ‘단일 책임 원칙(Single Responsibility Principle)을 지키고 싶었습니다. Refresh Token은 이름 그대로 토큰을 리프레시 할때만 사용되어야합니다. 매 인증 과정에 개입하는것은 책임이 과도해진다고 판단했습니다. 제방식은 Access Token의 유효성만으로 인증을 처리하되, 별도의 표식을 남겨 최소한의 정보만으로 중복로그인을 확인하도록 하였습니다.
Q2. DB를 매번 조회하면 JWT의 성능 이점이 사라지지 않나요?
DB를 매번 조회하면 세션방식과의 차이점이 모호해 지는데 이렇게 구현한 이유가 있나요?
맞습니다. 이 설계는 명백한 트레이드 오프라고 볼 수 있습니다. 인증 마다 DB 조회가 발생하므로, 순수한 JWT 방식보다는 성능이 저하될 수 밖에 없습니다.
하지만 JWT의 무상태성만으로는 이번 프로젝트의 보안 요구사항을 충족할 수 없었습니다. 결국 보안을 강화하기 위해 최소한의 상태를 DB에 기록하는 하이브리드 방식을 선택했습니다. 실제로 보안이 중요한 금융권 등에서 이런 접근 방식을 흔히 사용합니다.
그럼에도 순수JWT만큼은 아니지만 성능을 개선할 수 있는 방법이 있습니다. 바로 Redis를 활용하는 것입니다.
최적화를 향하여
Refresh Token을 저장하는 DB를 기존 관계형 데이터베이스(RDB)가 아닌, 인메모리 데이터 스토어인 Redis로 변경한다면 성능을 크게 향상시킬 수 있습니다.
Redis는 모든 데이터를 메모리에 저장하기 때문에, 디스크 기반의 RDB보다 읽기/쓰기 속도가 압도적으로 빠릅니다. Refresh Token처럼 빈번하지만 단순한 조회작업에 매우 적합합니다.
프로젝트 시연 이후 Redis를 적용하여 이 시스템의 성능을 최적화하는 작업을 진행해 볼 예정입니다.
마치며
이번 프로젝트는 JWT의 한계를 마주하고 이를 해결하기 위해 깊에 고민해볼 수 있던 소중한 경험이었습니다. 세상에 완벽한 기술은 없으며, 주어진 요구사항과 환경 속에서 최적의 트레이드오프를 찾고 저울질하는 것이 개발자의 중요한 역량임을 다시 한번 깨달았습니다.