세션 관리 고도화 시리즈 (1/4)
왜 동시 로그인을 방지해야 할까?
같은 계정으로 여러 곳에서 동시에 로그인하면 계정 탈취 여부를 감지하기 어렵고, 보안 위협이 된다. 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