구독 플랜별로 동시 접속 기기 수를 제한하는 기능이다. 무료는 1대, 상위 플랜은 그 이상.
Redis 에 사용자별 세션 목록을 두고, 로그인할 때 개수를 세어 넘으면 막는다.
만들고 나서 테스트했더니 제한이 걸리는데도 다른 기기가 계속 쓰였다.
로그인 화면에서는 “기존 기기를 종료하시겠습니까”가 뜨고 확인을 눌렀는데,
옆 노트북의 화면은 멀쩡히 살아 있었다.
처음 만든 구조
사용자 한 명당 Redis Hash 하나를 쓴다. 필드가 세션 하나다.
user:sessions:{userId}
├─ {sessionId-A} → {"browser":"Chrome","os":"macOS","ip":"...","loginTime":...}
├─ {sessionId-B} → {"browser":"Safari","os":"iOS", ...}
└─ ...
Set 이 아니라 Hash 를 쓴 이유는 “어떤 기기인지”를 같이 보여줘야 했기 때문이다.
세션 목록 화면에 브라우저·OS·IP·로그인 시각이 뜨고, 사용자가 끊을 기기를 고른다.
Set 이면 세션 ID 만 남아서 어느 것이 내 폰인지 알 수 없다.
개수 세기는 HLEN 한 번이면 된다.
Long currentSessions = redisTemplate.opsForHash().size(key);
if (currentSessions >= maxSessions) {
return false; // 제한 초과
}
강제 로그인은 이 Hash 를 지우면 된다고 생각했다.
redisTemplate.delete(key); // user:sessions:{userId} 삭제
그런데 여전히 다른 기기가 살아 있었다
지운 것은 내가 만든 장부였다. 실제 인증을 들고 있는 것은 Spring Session 이 따로 관리하는
다른 키였다.
icbm:session:sessions:{sessionId} ← 진짜 세션. 여기가 인증을 들고 있다
user:sessions:{userId} ← 내가 만든 목록. 세션 ID 를 적어둔 장부일 뿐
장부를 찢어도 세션은 그대로다. 다른 기기는 자기 쿠키로 요청을 보내고, Spring Session 은
그 세션이 살아 있으니 통과시킨다. 내 코드는 그 흐름 어디에도 끼어 있지 않았다.
세션 수 세기가 장부만 봐도 됐던 것과 달리, 끊는 것은 장부로 안 된다.
읽기는 내 데이터로 되지만 쓰기는 원본을 건드려야 한다는 걸 놓쳤다.
고친 뒤
장부에서 세션 ID 를 꺼내 Spring Session 키를 직접 지운다.
Set<Object> activeSessionKeys = redisTemplate.opsForHash().keys(key);
for (String sessionId : activeSessions) {
// 진짜 세션 삭제
String springSessionKey = sessionNamespace + ":sessions:" + sessionId;
redisTemplate.delete(springSessionKey);
// Spring Session 이 만든 역인덱스도 함께
Set<String> indexKeys = redisTemplate.keys(sessionNamespace + ":index:*:" + sessionId);
if (indexKeys != null && !indexKeys.isEmpty()) {
redisTemplate.delete(indexKeys);
}
}
redisTemplate.delete(key); // 그다음에 장부
역인덱스(:index:*:)까지 지우는 건 Spring Session 이 “이 사용자의 세션 목록” 을
따로 들고 있기 때문이다. 세션 본체만 지우면 그 목록에 죽은 ID 가 남는다.
좀비 세션
이 구조에는 반대 방향의 어긋남도 있다. Spring Session 이 먼저 만료되면 장부에 죽은 ID 가
남는다. 그 상태로 개수를 세면 실제보다 많아서, 자리가 비었는데도 로그인이 막힌다.
서버를 재시작하면 특히 확실하게 생긴다. Spring Session 은 무효화되는데 장부는 Redis 에 그대로다.
두 군데서 막았다.
서버 시작 시 일괄 정리 — 어차피 전부 무효이므로 통째로 지운다.
@EventListener(ContextRefreshedEvent.class)
public void cleanupAllSessionsOnStartup() {
Set<String> sessionKeys = redisTemplate.keys(SESSION_KEY_PREFIX + "*");
redisTemplate.delete(sessionKeys);
}
ApplicationReadyEvent 가 아니라 ContextRefreshedEvent 를 쓴 이유는 로그인 요청을 받기 전에
정리를 끝내야 하기 때문이다.
런타임 중 개별 확인 — 장부의 세션 ID 마다 진짜 세션이 있는지 물어보고, 없으면 지운다.
Boolean exists = redisTemplate.hasKey(sessionNamespace + ":sessions:" + sessionId);
if (Boolean.FALSE.equals(exists)) {
zombieSessions.add(sessionId); // 좀비
}
알림을 먼저 보내고 200ms 기다린다
끊긴 기기에 “다른 곳에서 로그인했습니다” 를 띄우려고 SSE 를 쓴다. 그런데 세션을 먼저 지우면
SSE 연결도 같이 끊겨서 알림이 도착하지 못한다.
그래서 순서를 뒤집고 사이에 대기를 넣었다.
publishSessionTerminationNotification(userId, sessionIds); // 1. 알림 먼저
Thread.sleep(200); // 2. 전달될 시간
// 3. 그다음 세션 삭제
알림은 Redis Pub/Sub 로 보낸다. 채널은 session-terminated:{userId} 다.
서버가 여러 대여도 끊긴 기기가 붙어 있는 쪽으로 전달된다.
redisTemplate.convertAndSend(channel, String.join(",", terminatedSessions));
Thread.sleep(200) 은 보기 좋은 코드가 아니다. 정확히는 SSE 전송 완료를 확인하고 넘어가야 하는데,
그러려면 클라이언트 ack 을 받는 경로를 새로 만들어야 한다. 강제 로그인은 자주 일어나지 않고
그 200ms 는 로그인하는 본인만 기다리므로, 지금은 이 비용을 받아들였다.
실패했을 때 어느 쪽으로 기울일 것인가
세션 체크 중 Redis 가 죽거나 예외가 나면 어떻게 할지 정해야 한다.
} catch (Exception e) {
log.error("세션 생성 체크 중 오류 발생: userId={}", userId, e);
return true; // 허용
}
막는 대신 허용을 골랐다. 반대로 하면 Redis 장애가 곧 전체 로그인 장애가 된다.
이쪽을 고르면 장애 중에 제한이 잠깐 새는데, 동시 접속 제한은 과금 정책이지 보안 경계가 아니라서
그 편이 낫다고 봤다.
같은 이유로 알림 발행 실패도 로그만 남기고 로그인은 진행한다.
안 한 것
-
분산 락을 걸지 않았다. 두 기기가 동시에 로그인하면 둘 다
HLEN을 같은 값으로 읽어
제한을 한 칸 넘길 수 있다. 잠깐 한 개 초과하는 것보다 모든 로그인에 락을 거는 비용이 크다고 봤다. - 좀비 정리를 스케줄러로 돌리지 않았다. 서버 시작 시 일괄 정리 + 조회 시점 확인으로 덮인다.
-
장부와 Spring Session 의 TTL 을 맞추지 않았다. 장부는 30분 고정인데 세션 타임아웃은
프로파일마다 다르다. 어긋나도 좀비 정리가 받아내는 구조라 두었다.
남는 교훈
읽기가 되는 데이터라고 쓰기도 되는 건 아니다. 세션 수를 세는 것은 내가 만든 장부로 충분했지만
세션을 끊는 것은 원본을 건드려야 했다. 처음 설계할 때 이 둘을 같은 것으로 봤다.
다음에 남의 상태를 요약해 들고 있는 구조를 만들면 두 가지를 먼저 정하겠다.
- 원본을 바꿔야 하는 동작이 무엇인지 — 그건 요약본으로 못 한다
- 원본과 요약본이 어긋나는 방향이 몇 가지인지 — 여기서는 양방향이었고, 각각 다른 처리가 필요했다