하나의 계정으로 PC와 모바일에서 동시에 로그인하면 어떻게 처리해야 할까요? 이전 세션을 끊을까요, 새 로그인을 거부할까요?

동시 로그인 제한, 세션 고정 공격 방어, 세션 타임아웃은 웹 애플리케이션 보안의 핵심입니다. Spring Security는 이 세 가지를 선언적 설정 으로 제공합니다.

개념 정의

세션 관리 는 사용자의 인증 상태를 HTTP 세션을 통해 유지하면서, 동시 접속 제어와 세션 탈취 방어를 처리하는 것입니다.

세션 고정 공격 방어

공격 시나리오

PLAINTEXT
1. 공격자가 사이트에 접속하여 세션 ID를 얻음 (JSESSIONID=abc123)
2. 피해자에게 이 세션 ID를 포함한 링크를 보냄
3. 피해자가 해당 링크로 로그인
4. 공격자가 abc123 세션으로 접근 → 피해자의 인증된 세션 탈취

이 공격이 성립하는 이유는, 로그인 전후로 세션 ID가 바뀌지 않으면 공격자가 미리 알고 있던 세션 ID로 인증된 세션에 접근할 수 있기 때문입니다.

방어 설정

Spring Security는 인증 성공 시 세션 ID를 변경하여 이 공격을 방어합니다.

JAVA
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .sessionManagement(session -> session
            .sessionFixation(fixation -> fixation
                .changeSessionId()  // 기본값
            )
        )
        .build();
}
전략동작
changeSessionId()세션 ID만 변경 (기본, Servlet 3.1+)
migrateSession()새 세션 생성, 기존 속성 복사
newSession()새 세션 생성, 기존 속성 삭제
none()보호 없음 (비권장)

changeSessionId()가 기본인 이유는, 세션 데이터를 유지하면서 ID만 바꾸기 때문에 사용자 경험에 영향을 주지 않기 때문입니다.

동시 로그인 제한

기본 설정 — 이전 세션 만료

JAVA
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .sessionManagement(session -> session
            .maximumSessions(1)
        )
        .build();
}

기본 동작은 ** 새 로그인을 허용하고 이전 세션을 만료 **시킵니다.

PLAINTEXT
사용자 A: PC에서 로그인 → 세션 1 생성
사용자 A: 모바일에서 로그인 → 세션 2 생성, 세션 1 만료
사용자 A: PC에서 요청 → "세션이 만료되었습니다"

새 로그인 거부

JAVA
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .sessionManagement(session -> session
            .maximumSessions(1)
            .maxSessionsPreventsLogin(true)
        )
        .build();
}
PLAINTEXT
사용자 A: PC에서 로그인 → 세션 1 생성
사용자 A: 모바일에서 로그인 시도 → "이미 로그인 중입니다" 거부

SessionRegistry

동시 세션 제어는 SessionRegistry 가 활성 세션을 추적해야 동작합니다.

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

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .sessionManagement(session -> session
            .maximumSessions(1)
            .sessionRegistry(sessionRegistry())
        )
        .build();
}

SessionRegistry를 활용하면 관리자 기능으로 활성 세션을 조회하거나 강제 만료시킬 수 있습니다.

JAVA
@GetMapping("/admin/sessions")
public List<SessionDto> getActiveSessions() {
    return sessionRegistry.getAllPrincipals().stream()
        .flatMap(principal ->
            sessionRegistry.getAllSessions(principal, false).stream())
        .map(SessionDto::from)
        .toList();
}
JAVA
@DeleteMapping("/admin/sessions/{sessionId}")
public void expireSession(@PathVariable String sessionId) {
    SessionInformation session =
        sessionRegistry.getSessionInformation(sessionId);
    if (session != null) {
        session.expireNow();  // 다음 요청 시 만료 처리
    }
}

세션 생성 정책

Spring Security가 세션을 생성하는 시점과 방식을 제어합니다.

JAVA
http.sessionManagement(session -> session
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
정책동작
ALWAYS세션이 없으면 항상 생성
IF_REQUIRED필요할 때만 생성 (기본)
NEVERSpring Security가 생성하지 않지만, 이미 있으면 사용
STATELESS세션을 생성하지도, 사용하지도 않음 (JWT용)

세션 타임아웃

YAML
# application.yml
server:
  servlet:
    session:
      timeout: 30m  # 기본 30분

세션 만료 시 리다이렉트 URL을 구분하여 설정할 수 있습니다.

JAVA
http.sessionManagement(session -> session
    .invalidSessionUrl("/login?expired=true")      // 유효하지 않은 세션
    .maximumSessions(1)
    .expiredUrl("/login?maxSession=true")           // 다른 곳에서 로그인으로 만료
);

invalidSessionUrl은 유효하지 않은 세션 ID로 접근할 때, expiredUrl은 다른 곳에서 로그인하여 세션이 만료되었을 때 리다이렉트됩니다.


주의할 점

UserDetails의 equals/hashCode를 구현하지 않으면 동시 세션 제한이 무력화된다

SessionRegistryprincipal 객체를 키로 사용하여 세션을 추적합니다. 커스텀 UserDetails에서 equals()hashCode()를 재정의하지 않으면, 같은 사용자의 로그인을 다른 사용자로 인식합니다. 결과적으로 maximumSessions(1)을 설정해도 동일 계정으로 무한히 로그인할 수 있게 됩니다.

STATELESS와 maximumSessions()을 함께 설정하는 실수

SessionCreationPolicy.STATELESS로 설정하면 세션 자체를 사용하지 않습니다. 이 상태에서 maximumSessions(1)을 설정해도 아무 효과가 없습니다. JWT 기반 인증에서 동시 로그인을 제한하려면 Redis 같은 외부 저장소에서 토큰을 관리하는 별도 구현이 필요합니다.

invalidSessionUrl과 expiredUrl의 혼동

두 설정을 혼동하면 사용자에게 잘못된 안내 메시지가 표시됩니다. invalidSessionUrl은 "세션이 유효하지 않습니다"(쿠키의 세션 ID가 서버에 존재하지 않을 때), expiredUrl은 "다른 기기에서 로그인하여 세션이 만료되었습니다"(동시 세션 제한으로 만료될 때) 용도입니다.


정리

항목설명
세션 고정 방어인증 성공 시 changeSessionId()로 세션 ID 변경 (기본)
동시 로그인 제한maximumSessions()으로 설정, 기본은 이전 세션 만료
새 로그인 거부maxSessionsPreventsLogin(true)로 변경
SessionRegistry활성 세션 추적, 강제 만료 (equals/hashCode 구현 필수)
STATELESS세션 미사용 — JWT 기반 인증용, maximumSessions 무효
타임아웃server.servlet.session.timeout으로 설정 (기본 30분)
댓글 로딩 중...