- 동시 세션 제어의 개념, 필요성
- Spring Security에서 제공하는 동시 세션 제어 기능
- maximumSessions() 와 maxSessionsPreventsLogin() 설정의 차이
- 세션 만료 전략(이전 세션 만료 vs 새 로그인 차단)을 비교하기
- 직접 동시 세션 제어 설정을 적용하고 테스트하기
동시 세션 제어
웹 애플리케이션은 기본적으로 하나의 계정으로 여러 기기나 브라우저에 동시에 접속할 수 있다
하지만, 보안상 또는 비즈니스 정책상 이를 제한할 필요가 있다
- 은행, 금융 서비스 - > 하나의 계정이 여러곳에서 동시에 로그인하면 위험함
- 온라인 시험 시스템 - > 하나의 계정이 여러 기기에서 동시 시험 응시 불가
따라서, 동일 사용자의 동시 세션수를 제어하는 기능이 필요하다
1. 동시 세션 제어의 필요성
- 보안 강화 : 계정 공유, 세션 탈취 등 방지
- 정책 준수 : 특정 서비스의 이용약관(1인 1계정 원칙 등등)
- 리소스 절약 : 불필요한 세션 사용을 줄여서 서버 부하를 완화하기
Spring Security에서의 동시 세션 제어
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain meFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/me/**")
.csrf(csrf -> csrf.disable())
// 이 부분
.sessionManagement(session -> session
.maximumSessions(1) // 최대 1개의 세션만 허용하기
// 새로운 로그인에 대해 차단 여부 설정, 기본 false
.maxSessionsPreventsLogin(true)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/me/**").authenticated()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
1. maximumSessions()
동일 사용자가 동시에 가질 수 있는 세션의 최대 갯수를 지정한다
- maximumSessions(1) : 동일 사용자에 대한 세션 최대 갯수 1개
- maximumSessions(2) : 2개
2. maxSessionsPreventsLogin()
최대 세션 수를 초과했을 때 동작 방식을 정의함
- true : 새로운 로그인 시도를 차단한다 (기존 세션 유지)
- false : 새로운 로그인을 허용하고, 기존 세션 중 하나를 만료시킨다
3. 동작 흐름 다이어그램
동시 세션 제어 적용해보기
https://github.com/B1uffer/multiSessionTest
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.14'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.b1uffer'
version = '0.0.1-SNAPSHOT'
description = 'multiSessionTest'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testAnnotationProcessor 'org.projectlombok:lombok'
}
tasks.named('test') {
useJUnitPlatform()
}
* Thymeleaf : 만료 리다이렉션 페이지와 커스텀 로그인 페이지를 간단히 만들기 위해 사용함
(뷰 엔진은 다른 것으로 대체해도 된다)
사용자 계정 구성(InMemory)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class InMemoryUsers {
@Bean
public InMemoryUserDetailsManager userDetailsService() {
UserDetails user = User.withUsername("user")
.password("{noop}password")
.roles("USER")
.build();
UserDetails user2 = User.withUsername("user2")
.password("{noop}password")
.roles("USER")
.build();
UserDetails admin = User.withUsername("admin")
.password("{noop}password")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user, user2, admin);
}
}
Security 설정 (동시 세션 제어 핵심)
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// 동시 세션 제어 핵심
@Bean
@Order(1)
public SecurityFilterChain sessionFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login","/session-expired", "/css/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/", true)
.permitAll()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredUrl("/session-expired")
.sessionRegistry(sessionRegistry())
);
return http.build();
}
@Bean
@Order(0)
public SecurityFilterChain h2FilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher(PathRequest.toH2Console())
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toH2Console()).permitAll()
.anyRequest().authenticated()
)
.csrf(csrf -> csrf.disable())
.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin())
);
return http.build();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
}
* 주의 : 분산 환경 / 외부 무효화와의 정확한 동기화를 위해서는
추가 구성(예 : 이벤트 퍼블리셔, 세션 스토어 연동)이 필요할 수 있음. 해당 문서는 동시 세션 제어 핵심 로직에 집중한다
컨트롤러 / 뷰
import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
@GetMapping("/")
public String home(Model model, HttpSession session, Authentication auth) {
// session 에서 id 가져옴
model.addAttribute("sessionId", session.getId());
// authentication 에서 name 가져옴
model.addAttribute("username", auth.getName());
// session 생성일자
model.addAttribute("creationTime", session.getCreationTime());
// session 최근접속시간
model.addAttribute("lastAccessedTime", session.getLastAccessedTime());
return "home";
}
@GetMapping("/login")
public String loginPage() {
return "login";
}
@GetMapping("/session-expired")
public String sessionExpired(Model model) {
model.addAttribute("msg","세션이 만료되었습니다. 다시 로그인 해주세요.");
return "session-expired";
}
}
home.html
<!DOCTYPE html>
<html lang="ko" xmlns:th="<http://www.thymeleaf.org>">
<head>
<meta charset="UTF-8" />
<title>홈</title>
</head>
<body>
<h2>동시 세션 제어 실습 홈</h2>
<ul>
<li>사용자: <span th:text="${username}"></span></li>
<li>세션 ID: <code th:text="${sessionId}"></code></li>
<li>생성 시각(ms): <span th:text="${creationTime}"></span></li>
<li>마지막 접근 시각(ms): <span th:text="${lastAccessedTime}"></span></li>
</ul>
<a href="/logout">로그아웃</a>
</body>
</html>
login.html
<!DOCTYPE html>
<html lang="ko" xmlns:th="<http://www.thymeleaf.org>">
<head>
<meta charset="UTF-8" />
<title>로그인</title>
</head>
<body>
<h2>로그인</h2>
<form th:action="@{/login}" method="post">
<div>
<label>아이디</label>
<input type="text" name="username" value="user" />
</div>
<div>
<label>비밀번호</label>
<input type="password" name="password" value="password" />
</div>
<button type="submit">로그인</button>
</form>
</body>
</html>
session-expired.html
<!DOCTYPE html>
<html lang="ko" xmlns:th="<http://www.thymeleaf.org>">
<head>
<meta charset="UTF-8" />
<title>세션 만료</title>
</head>
<body>
<h3 th:text="${msg}">세션이 만료되었습니다.</h3>
<a href="/login">다시 로그인</a>
</body>
</html>
- 브라우저 A(일반 창) - > http://localhost:8080 접속 - > 로그인(user/password) - > 홈 화면에서 세션ID 확인
- 브라우저 B(시크릿 / 사생활 보호창) - > 동일 계정(user) 로그인
설정상 maxSessionPreventsLogin(false) 이므로 B 로그인은 성공함, 동시에 A 세션은 만료된다 - 브라우저 A에서 페이지 새로고침 - > /session-expired 로 이동되어 만료 안내 확인
- 동작 변경 실험 : maxSessionPreventsLogin(true) 로 변경 후 재시도
B에서 새 로그인 시도가 거부되고(login?error), A의 세션은 유지된다(로그인 유지)
트러블 슈팅 체크리스트
| 증상 | 원인 | 해결 |
| A가 만료되지 않음 | 같은 브라우저 프로파일 사용(쿠키 공유) | 반드시 시크릿창 또는 다른 브라우저 사용 |
| 만료 후에도 홈 접근가능 | 브라우저 캐시 화면 | 강력 새로고침 / 네트워크 탭 확인 |
| 로그인 자체가 안됨 | 사용자 정보 미설정 | InMemory 사용자 / 비밀번호 재확인 |
| 404 템플릿 | 템플릿 경로 / 확장자 오타 | resources/templates/*.html 확인 |
| session-expired 로 리다이렉트가 안됨 | authenticated()에 걸림 | requestMatchers("/session-expired") .permitAll() 추가하기 |
| sessionFilterChain과 h2FilterChain 충돌 | Security FilterChain 순서 문제 | 구체적인 FilterChain(h2FilterChain)은 앞에 두고(@Order(0)), 추상적인 FilterChain, 가령 sessionFilterChain 와 같은 securityMatcher() 를 사용하지 않는 전역 FilterChain은 뒤에 두기(@Order(99)) |
팁
- 보안 민감 서비스는 mexSessionPreventsLogin(true) 로 계정 공유 방지하기
- 분산 / 클러스터 환경에서는 세션 저장소 공유(예 : Redis)와 세션 이벤트 동기화를 함께 고려해야한다
세션 만료 전략
Spring Security 에서는 세션이 초과될 경우, 두가지 전략 중 하나를 선택할 수 있다
- 이전 세션 만료(기본값)
- 새 로그인 차단하기
1. 이전 세션 만료(기본값)
새로운 로그인은 허용하고, 기존 세션 중 하나를 강제로 만료시킴
.sessionManagement(session -> session
.maximumSessions(1) // 동일 user 최대 세션 1개
.maxSessionsPreventsLogin(false) // 로그인은 허용, 기본값, 기존세션 만료
2. 새 로그인 차단하기
기존 세션은 유지하고, 새로운 로그인은 거부함
.sessionManagement(session -> session
.maximumSessions(1) // 동일 user 최대 세션 1개
.maxSessionsPreventsLogin(true) // 세션 유지중 로그인 거부, 기존 세션 유지
팁
- 보안 민감 서비스(금융, 공공기관) - > maxSessionPreventsLogin(true) 권장, 로그인 거부하기
- 일반 서비스(커뮤니티, 쇼핑몰) - > maxSessionsPreventsLogin(false) 권장, 로그인은 허용하고 세션 만료
- 테스트 방법 : 서로 다른 브라우저나 시크릿 모드에서 동일 계정 로그인 시도하기
| 항목 | 설명 | 기본값 |
| maximumSessions(n) | 허용 가능한 최대 동시 세션 수 | 제한없음 |
| maxSessionsPreventsLogin(true) | 초과시 새 로그인 거부 | false |
| maxSessionsPreventsLogin(false) | 초과시 기존 세션 만료 후 새 로그인 허용 |
'Spring Boot > Security 쿠키,세션 기반 인증,인가' 카테고리의 다른 글
| Remember-me : 인증 개념과 필요성 (0) | 2026.05.11 |
|---|---|
| 동시 세션 제어와 세선 고정 보호 : 세션 고정 보호 (0) | 2026.05.10 |
| 세션 관리 설정과 커스터마이징 : 세션 만료 후 처리 전략 (0) | 2026.05.08 |
| 세션 관리 설정과 커스터마이징 : 세션 타임아웃 설정 (0) | 2026.05.08 |
| 세션 관리 설정, 커스터마이징 : 세션 생성 정책 설정 (0) | 2026.05.07 |