Database

장부만 지우고 세션은 살려뒀다

구독 플랜별로 동시 접속 기기 수를 제한하는 기능이다. 무료는 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분 고정인데 세션 타임아웃은
    프로파일마다 다르다. 어긋나도 좀비 정리가 받아내는 구조라 두었다.

남는 교훈

읽기가 되는 데이터라고 쓰기도 되는 건 아니다. 세션 수를 세는 것은 내가 만든 장부로 충분했지만
세션을 끊는 것은 원본을 건드려야 했다. 처음 설계할 때 이 둘을 같은 것으로 봤다.

다음에 남의 상태를 요약해 들고 있는 구조를 만들면 두 가지를 먼저 정하겠다.

  1. 원본을 바꿔야 하는 동작이 무엇인지 — 그건 요약본으로 못 한다
  2. 원본과 요약본이 어긋나는 방향이 몇 가지인지 — 여기서는 양방향이었고, 각각 다른 처리가 필요했다