세션 관리 고도화 시리즈 (1/4)

다음 편: [Spring Security] 2. 권한 변경 시 세션 무효화


왜 동시 로그인을 방지해야 할까?

같은 계정으로 여러 곳에서 동시에 로그인하면 계정 탈취 여부를 감지하기 어렵고, 보안 위협이 된다. Spring Security는 sessionConcurrency 설정으로 이를 간단하게 제어할 수 있다.


SessionRegistry란?

SessionRegistry 는 현재 활성화된 세션과 사용자 정보를 메모리에서 관리하는 컴포넌트다.

Spring Security가 로그인/로그아웃 시 자동으로 세션을 등록/제거한다. 이를 통해:

  • 특정 사용자가 현재 로그인 중인지 확인
  • 해당 사용자의 세션을 강제로 무효화
  • 동시 로그인 세션 수 제한

등을 할 수 있다.


SessionRegistry 빈 등록

@Bean
public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}
  • SessionRegistryImpl : 인메모리 기반 SessionRegistry 구현체
  • HttpSessionEventPublisher : HttpSession이 만료될 때 이벤트를 발행해 SessionRegistry도 자동으로 동기화한다. 이 빈이 없으면 세션이 만료돼도 SessionRegistry에 남아 있어 동시 로그인 방지가 제대로 동작하지 않는다.

sessionConcurrency 설정

http
    .sessionManagement(management -> management
        .sessionConcurrency(concurrency -> concurrency
            .maximumSessions(1)
            .maxSessionsPreventsLogin(false)
            .sessionRegistry(sessionRegistry)
        )
    )
  • maximumSessions(1) : 동일 계정으로 최대 1개의 세션만 허용
  • maxSessionsPreventsLogin(false) : 새 로그인 시 기존 세션을 만료 (true면 새 로그인을 거부)
  • sessionRegistry(sessionRegistry) : 위에서 등록한 SessionRegistry 빈 연결
maxSessionsPreventsLogin

- false (기본값): 새로 로그인하면 기존 세션이 만료된다. "다른 곳에서 로그인됨" 처리

- true: 이미 로그인 중이면 새 로그인을 차단한다


DiscodeitUserDetails에 equals()와 hashCode() 오버라이딩

Spring Security의 SessionRegistry는 내부적으로 Map으로 사용자를 관리한다. 같은 사용자인지 판단할 때 equals()hashCode()를 사용하므로, 반드시 오버라이딩해야 한다.

오버라이딩하지 않으면 동일한 사용자가 다른 객체로 인식되어 동시 로그인 방지가 동작하지 않는다.

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof DiscodeitUserDetails)) return false;
    DiscodeitUserDetails that = (DiscodeitUserDetails) o;
    return Objects.equals(userDto.id(), that.userDto.id());
}

@Override
public int hashCode() {
    return Objects.hash(userDto.id());
}
  • userDto.id() : UUID로 동일 사용자 여부를 판단한다
  • username이나 다른 필드로 비교해도 되지만, 변경 가능한 값보다 불변 식별자(id)를 사용하는 게 안전하다

전체 흐름

sequenceDiagram participant Client1 as 클라이언트 A participant Client2 as 클라이언트 B participant Filter as Security Filter participant Registry as SessionRegistry Client1->>Filter: 로그인 (user1) Filter->>Registry: 세션 등록 (user1, session1) Filter-->>Client1: 200 OK + JSESSIONID=session1 Client2->>Filter: 로그인 (user1) Filter->>Registry: 기존 세션 확인 → session1 만료 Filter->>Registry: 세션 등록 (user1, session2) Filter-->>Client2: 200 OK + JSESSIONID=session2 Client1->>Filter: 요청 (JSESSIONID=session1) Filter-->>Client1: 세션 만료 → 401