동일한 계정으로 중복으로 로그인하는 상황을 제어해야 하는 요구사항이 있어 적용해봤다.
일반적인 로그인 성공/실패 처리가 아니라,
이미 로그인된 계정이 다른 환경에서 다시 로그인할 경우
기존 로그인 상태를 어떻게 처리할 것인지를 결정해야 했다.
동시 로그인 제어 기능을 붙이면서 마주한 JWT의 한계
핵심은 단순 인증이 아니라 "현재 이 계정이 로그인 중인가?"를 서버가 판단할 수 있어야 했다.
JWT 기반 인증은 일반적으로 AccessToken으로 매 API요청을 인증하고, 토큰의 서명과 만료 시간을 검증하는 구조이다. 그니까 원래 토큰 인증이라고 하면 Stateless라고 해서 "서버가 세션 상태를 안 들고도 인증을 할 수 있다"는 것이 장점이자 전제인 인증 방법인 것이다.
근데 동시 로그인을 금지해야 한다면, 특정 아이디에 대해 지금 허용되는 세션은 딱 하나뿐이어야 한다는 것이니까 Stateless한 JWT 만으로는 "이 사용자가 이미 로그인 중인지"를 알 수 없음이 분명했다.
본질적으로 기존 로그인 상태를 비교해야 했기에, 계정의 현재 로그인 상태를 알고 있어야 한다고 생각했다.
서버가 jwt의 로그인 상태를 기억하게 만들기위한 첫번째 고민

Redis를 통해 상태(state) 를 기억하기로 했다. 근데 뭘 어떻게 저장해두면 좋을까?
처음엔 jwt만들때 넣었던 jti값을 sessionId로 두고 Redis에 저장해야겠다고 생각했었다.
※ jti: Jwt의 표준 클레임. 토큰을 식별하는 고유ID
- API요청 시, jwt내 jti값과 Redis에 저장한 jti를 비교하여 동일하면 → 현재 유효한 사용자로 판단
- Redis와 값이 다르면 "다른 곳에서 재로그인 됐네?" → 동시 로그인 차단
근데 좀 생각해보니 이 토큰 단위 설계는 AccessToken이 갱신(재발급)되는 경우에 잠재적 문제가 있었다.
매번 새로운 토큰과 내부 값을 세션에 셋팅하기 때문에, 가장 마지막에 발급된 토큰만 유효하고 이전에 발급된 Access/Refresh Token 은 전부 죽는 구조라는 점에서였다.
단일 브라우저/탭 사용자에 한해서는 문제가 없을 수도 있겠지만, A탭과 B탭 등 여러 탭으로 동시에 작업하는 사용자 입장에서 같은 브라우저에서 탭 두 개만 띄웠을 뿐인데, 갑자기 한쪽에서 로그인이 튕겨 나가거나 갱신요청으로 인한 탭 간 토큰 핑퐁 현상이 있을 수도 있었다. (AccessToken은 body로 응답하기에 변경 건에 대해서 탭간 공유가 안된다. 다음 글에서 얘기해보겠다)
- A 탭에서 로그인 → Token1
- B 탭에서 같은 브라우저 세션으로 페이지 열림 (Token1 공유하거나, 다시 로그인해서 Token2 발급)
- 그 사이에 어느 탭에서 토큰 재발급을 했는지에 따라, 다른 탭이 “이전 토큰을 들고 API 요청”을 날리면 401/SESSION_CONFLICT으로 강제 로그아웃
다른 건 몰라도, 토큰을 갱신(refresh) 할 때마다 Redis에 값을 무조건 덮어쓰는 구조로 만들면 성능적으로 안 좋을 게 뻔했다. 로그인 상태 비교를 위해 읽으면 읽었지(read) 재로그인이 아닌 단순 갱신 상황에서도 redis에 매번 write 하는 구조를 피하고 싶었다.
재발급 상황 및 다중 탭 사용자를 위한 설계 변경
그래서 Jwt의 jti와는 별도로 계정의 로그인 상태 자체를 대표할 수 있는 식별자가 필요하다고 판단했다.
jti는 "토큰 고유ID"라는 원래 의미 그대로 두고, 한 번의 로그인으로 형성된 상태를 대표하는 값으로 "sessionId" 를 새로 정의했다. 이를 로그인 시점에 한 번만 생성해서, 해당 로그인 세션이 유지되는 동안 발급되는 모든 토큰은 같은 "sessionId"를 공유하도록 설계했다.
즉, 구조는 다음과 같다.
- AccessToken/RefreshToken 둘 다에 같은 "sessionId"를 가진다.
- sessionId는 로그인 시점에 1회 생성된다.
- 토큰 재발급 시에도 sessionId는 변경되지 않는다.
public String createAccessToken(AdnpUserPrincipalInfo principalInfo, String sessionId) {
Instant now = Instant.now();
Instant accessTokenExpiration = now.plus(jwtProperties.getAccessTokenValidity());
String jti = UUID.randomUUID().toString();
return Jwts.builder()
.subject(principalInfo.getUserUnqNo())
.claim(SESSION_ID_KEY, sessionId)
.claim(JTI_KEY, jti)
....
.expiration(Date.from(accessTokenExpiration))
.signWith(key)
.compact();
}
포인트는 sessionId는 로그인할 때에만 값이 생성된다는 점이다.
이렇게 하면 하나의 로그인 세션 안에서 AccessToken은 여러 번 재발급될 수 있지만, 그 모든 토큰은 같은 세션(sessionId)에 속하게 된다. 재발급 과정에서 구토큰/신토큰이 잠시 혼재하는 상황이 발생하더라도, 동일한 로그인 세션이기 때문에 문제로 취급하지 않는다.
다음은 토큰 발급/응답 과정에서의 코드 일부이다.
// 세션ID 생성
String sessionId = UUID.randomUUID().toString();
// 토큰 생성 (둘 다 동일 sessionId)
long refreshTtlSec = TimeUnit.MILLISECONDS.toSeconds(jwtProvider.getRefreshTokenTtlMillis());
String accessToken = jwtProvider.createAccessToken(userInfo, sessionId);
String refreshToken = jwtProvider.createRefreshToken(userInfo, sessionId);
// Redis 저장
redisService.saveSessionId(userInfo.getUserId(), sessionId, refreshTtlSec);
redisService.saveRefreshToken(userInfo.getUserId(), refreshToken, refreshTtlSec);
①토큰 갱신(재발급)을 위한 인증수단인 RefreshToken과 ②현재 로그인 세션의 비교를 위한 SessionId를 Redis에 저장한다. 재발급 시에는 redis에서 userId에 매핑된 sessionId를 조회한 뒤, 해당 값을 새로 발급한 토큰의 claim으로 설정하면 된다.
API 요청시 Redis에 로그인 유효성 체크하기
private void verifyCurrentSessionId(Authentication auth) {
..
....
String currentSid = redisService.getSessionId(userUnqNo);
currentSid = redisService.getSessionId(userUnqNo);
if (!currentSid.equals(tokenSid)) {
// 다른 환경에서 로그인되어 기존 세션 무효
throw new SessionInvalidatedException(...);
}
}
}
실제 요청 처리 시에는, JWT에 담긴 sessionId와 Redis에 저장된 현재 sessionId를 비교해 로그인 상태의 유효성을 판단한다. 이 검증 로직은 인증 필터 단계에서 수행하도록 했다. 이로써, "다른 환경에서 로그인했다"는 판단을 서버가 일관되게 내릴 수 있게 됐다. 즉, 토큰이 유효하더라도 현재 로그인 세션에 속하지 않으면 요청은 거부된다.
'Backend' 카테고리의 다른 글
| 세션, 토큰, 쿠키, 캐시 비교 정리 (1) | 2025.06.25 |
|---|---|
| [Eclipse] 프로젝트를 원격 Git 저장소에 연동하고 Commit/push하기 (0) | 2019.08.22 |