목차
분산 환경에서의 세션 관리 전략
현대 웹 애플리케이션을 분산된 서버 환경에서 운영할 때는 세션 관리 방법이 매우 중요합니다. 단일 서버에서는 세션을 메모리에 저장하면 간단하지만, 서버가 여러 대일 경우 사용자의 연속적인 요청이 다른 서버로 라우팅되면 세션 정보가 유실될 수 있습니다. 이를 해결하기 위한 몇 가지 대표적인 세션 관리 전략이 있습니다:
- 스티키 세션(Sticky Session): 로드 밸런서가 특정 사용자의 모든 요청을 최초 접속한 동일 서버로만 전달하도록 고정하는 방식입니다. 예를 들어 A 서버에서 세션이 시작되면 이후 그 사용자의 요청은 항상 A 서버로 보내는 것입니다. 설정이 간단하지만 단점도 있습니다. 특정 서버로 트래픽이 몰려 부하 불균형이 생길 수 있고, 해당 서버가 다운되면 해당 사용자의 세션도 모두 사라질 위험이 있습니다. 또한 장애 조치나 배포 시 세션이 안전하지 않다는 문제가 있습니다.
- 세션 복제(Session Clustering): 여러 서버(WAS)가 서로 세션 정보를 복제하여 모든 인스턴스가 동일한 세션 데이터를 갖도록 하는 방법입니다. 한 서버에서 세션이 생성되면 즉시 다른 서버들에도 복사되어, 다음 요청이 어느 서버로 가든 동일한 세션을 찾을 수 있습니다. 이 방식은 서버 장애 시에도 세션을 유지할 수 있지만, 매 요청마다 모든 서버에 세션을 동기화해야 하므로 메모리 오버헤드가 크고 구현이 복잡합니다. (예: Tomcat의 세션 클러스터링 기능에 all-to-all 복제 등이 있으나 부하가 큰 단점이 있습니다.)
- 공유 저장소 세션: 세션 데이터를 개별 서버가 아닌 외부 공용 저장소에 보관하는 방식입니다. 가장 흔한 예로 데이터베이스나 캐시(예: Redis)를 세션 스토어로 사용하는 것입니다. 전통적으로 관계형 DB에 세션을 저장하기도 하나, 세션 데이터는 주로 일시적이고 I/O가 잦아 빠른 인메모리 저장소가 선호됩니다. Redis와 같은 인메모리 데이터베이스를 세션 저장소로 쓰면, 모든 서버가 중앙의 Redis를 참조하여 세션을 공유하게 됩니다. 이 방식은 구현이 비교적 간단하고 확장성이 높으며, 어떤 서버로 요청이 가도 세션 일관성을 유지할 수 있다는 장점이 있습니다. 다만 매 요청마다 Redis와 통신해야 하므로 트래픽이 매우 많을 경우 Redis가 병목이나 **단일 장애점(SPOF)**이 될 수 있어, Redis 클러스터링 및 복제를 통해 가용성을 높이는 설계가 필요합니다.
- JWT 등 무상태 토큰: 세션 자체를 사용하지 않고 JSON Web Token (JWT)과 같이 토큰에 인증 정보를 담아 클라이언트가 보관하도록 하는 방식입니다. 서버는 각 요청에서 토큰의 유효성만 검증하고 별도 세션 저장소를 두지 않으므로 완전 무상태(stateless)에 가깝습니다. 그러나 JWT는 필요한 모든 정보를 토큰 자체에 포함하기 때문에 크기가 크고 매 요청마다 전송되어 네트워크 부하가 증가합니다. 또한 만료 전 토큰을 강제로 무효화(로그아웃)하기 어렵고, 토큰 탈취 시 보안 문제가 생길 수 있습니다. 결국 안전한 구현을 위해서는 리프레시 토큰 등을 도입하면서 서버에 저장소가 다시 필요해지는 등 복잡도가 올라가므로, 단순한 세션만큼의 이점이 없을 수도 있다는 지적이 있습니다.
위 보기의 의 전략 중 Redis 기반의 세션 공유는 구현 난이도와 성능의 균형 측면에서 많이 채택됩니다. 아래에서는 Spring Boot 애플리케이션에서 Spring Session과 Redis를 활용하여 세션 클러스터를 구축하고, AWS의 Blue/Green 배포 시나리오에서 세션 일관성을 유지하는 방법을 상세히 살펴보겠습니다.
※ 이전글
인플레이스 배포 지쳤나요? CodePipeline Blue Green으로 AWS 배포 자동화 및 Spring Boot EC2 무중단 배포!
목차여러분은 배포할 때마다 심장이 쫄깃해지는 경험을 하고 있나요? 밤늦은 시간 트래픽이 적을 때를 골라 서비스를 재시작하고, 배포 중 서비스가 다운될까 노심초사했던 기억이 있을 것입니
kberry.tistory.com
2025.07.31 - [IT 트렌드/AWS & 클라우드 쉬운 활용법] - AWS CodePipeline에서 GitHub 연동 – 실무 후기 및 CI/CD 구축 가이드
AWS CodePipeline에서 GitHub 연동 – 실무 후기 및 CI/CD 구축 가이드
목차GitHub에 있는 코드를 AWS CodePipeline으로 빌드하고 배포하는 CI/CD 파이프라인을 구축해보았습니다. 이번 포스트에서는 “AWS CodePipeline에서 GitHub 연동”하는 방법을 실무 관점에서 공유하고, 자
kberry.tistory.com
Spring Boot와 Redis를 통한 세션 클러스터 구현

Spring Session은 Spring 생태계에서 세션을 중앙 관리하기 위한 모듈로, 간단한 설정만으로 Redis, JDBC 등 다양한 스토어에 세션을 저장하도록 지원합니다. 이번 구현에서는 Redis를 세션 저장소로 사용하고, AWS ElastiCache Redis(예: Valkey 라는 이름의 Redis 클러스터)와 TLS 암호화를 적용한 환경을 가정합니다. 코드 예시는 Spring Boot 3.x 기반입니다.
프로젝트 설정 및 Redis 연동
먼저 Spring Boot 프로젝트에 Spring Session Redis 관련 의존성을 추가해야 합니다. spring-session-data-redis와 Redis 클라이언트(Lettuce)를 pom.xml에 포함하면 Spring Boot 자동 설정으로 RedisConnectionFactory가 구성됩니다.
그 후 Redis 세션 사용 설정을 위해 간단한 설정 클래스를 추가합니다:
@Configuration
@EnableRedisHttpSession // Redis 기반 세션 저장소 활성화
public class RedisSessionConfig {
// 추가 설정이 필요 없다 (ConnectionFactory는 자동 구성됨)
}
@EnableRedisHttpSession 어노테이션만 붙이면 해당 애플리케이션의 세션 관리가 Redis로 위임됩니다. 이제 application.properties에서 Redis 접속 정보를 설정해보겠습니다. 보안을 위해 우리는 환경변수로 호스트와 포트를 주입받습니다 (예: REDIS_HOST, REDIS_PORT). 또한 암호화된 연결을 사용하기 위해 SSL 옵션을 켭니다:
spring.data.redis.host=${REDIS_HOST}
spring.data.redis.port=${REDIS_PORT}
spring.session.store-type=redis
spring.data.redis.fail-fast=false # Redis 미접속 시 애플리케이션 기동 실패하지 않도록
spring.data.redis.ssl.enabled=true # 전송 계층 암호화 (TLS) 활성화
spring.session.redis.rename-session-attribute-enabled=false
위 설정으로 애플리케이션은 지정된 Redis 호스트(master.elasticache-valkey-...)의 6379 포트에 TLS로 접속하여 세션 데이터를 저장합니다. 여기서 spring.session.store-type=redis는 Spring Session 사용 시 세션 저장소로 Redis를 쓰겠다는 명시적 설정이고, fail-fast=false는 Redis 연결 실패 시 애플리케이션이 바로 죽지 않도록 해주는 옵션입니다. 중요한 점은, AWS ElastiCache와 같은 Redis 클러스터 모드를 사용할 경우 rename-session-attribute-enabled=false 설정을 해야 한다는 것입니다. 이 설정에 대해서는 뒤에서 자세히 다루겠지만, 간단히 말해 Spring Session이 세션 ID 변경 시 수행하는 Redis RENAME 명령을 비활성화하여 Redis Cluster 환경에 맞게 동작하도록 하는 것입니다.
참고: 이번 예에서는 Redis에 별도 비밀번호 인증이 없고, 인증 트래픽 암호화(Encryption in transit)가 활성화된 상태이므로 클라이언트에서 SSL을 적용했습니다. Spring Boot에서는 spring.data.redis.ssl.enabled=true 설정만으로 Lettuce 클라이언트가 TLS로 연결합니다.
프로젝트 설정(pom.xml)
pom.xml 파일에 필요한 의존성을 추가합니다.
Spring Session Redis와 Lettuce 드라이버, 그리고 웹 의존성이 필요합니다.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>modern-spring-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>modern-spring-demo</name>
<description>Demo project for Spring Session with Redis</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- redis dependency -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
프로젝트 소스 코드
1) Spring Security 설정 (config/SecurityConfig.java)
package com.example.modern_spring_demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
// 기존 퍼블릭 경로들
.requestMatchers("/", "/session/**", "/login", "/css/**", "/js/**").permitAll()
.requestMatchers("/actuator/health").permitAll() // <-- 이 줄을 추가합니다.
.anyRequest().authenticated() // 나머지 모든 요청은 인증 필요
)
.formLogin(formLogin -> formLogin
.loginPage("/login")
.defaultSuccessUrl("/welcome", true)
.failureUrl("/login?error")
.permitAll())
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.permitAll())
// .csrf(csrf -> csrf.disable()) // Postman 등으로 테스트 중 CSRF 문제가 발생하면 잠시 주석을
// 해제하세요. 운영 환경에서는 다시 활성화해야 합니다.
;
return http.build();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
UserDetails user = User.withUsername("user")
.password(passwordEncoder.encode("password"))
.roles("USER")
.build();
UserDetails admin = User.withUsername("admin")
.password(passwordEncoder.encode("admin"))
.roles("ADMIN", "USER")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
2) Redis Setting 설정 (config/RedisSessionConfig.java)
- 기존과 동일하게 EnableRedisHttpSession 어노테이션을 사용하여 Redis 세션을 활성화
package com.example.modern_spring_demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
@Configuration
@EnableRedisHttpSession
public class RedisSessionConfig {
// spring-boot-starter-data-redis가 RedisConnectionFactory를 자동으로 구성합니다.
3) 로그인/로그아웃 컨트롤러 (controller/AuthController.java)
package com.example.modern_spring_demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@Controller
@RequestMapping("/") // 이 컨트롤러의 기본 경로를 "/"로 설정하여 루트 경로와 관련 기능을 담당합니다.
public class AuthController {
// 로그인 페이지를 렌더링
// 이 메서드는 "/login" 경로를 처리합니다.
@GetMapping("/login")
public String login() {
return "login"; // src/main/resources/templates/login.html 렌더링
}
// 환영 페이지를 렌더링하고 세션 정보를 모델에 추가
// 이 메서드는 "/welcome" 경로를 처리합니다.
@GetMapping("/welcome")
public String welcome(HttpServletRequest request, Model model) {
HttpSession session = request.getSession(true);
String sessionId = session.getId();
model.addAttribute("sessionId", sessionId);
System.out.println("Current Session ID from AuthController: " + sessionId);
return "welcome"; // src/main/resources/templates/welcome.html 렌더링
}
// 로그아웃 페이지를 처리하는 매핑 (선택사항, Spring Security의 /logout을 사용하지 않을 경우)
// @GetMapping("/logout")
// public String logout(HttpServletRequest request) {
// HttpSession session = request.getSession(false);
// if (session != null) {
// session.invalidate();
// }
// return "redirect:/login"; // 로그아웃 후 로그인 페이지로 리다이렉트
// }
}
4) 테스트용 웹 컨트롤러 개발 (controller/SessionController.java)
- 세션에 데이터를 저장하고 조회하며, 세션 ID를 확인하여 세션 클러스터링이 작동하는지 테스트할 컨트롤러를 만듭니다.
package com.example.modern_spring_demo.controller; // 실제 프로젝트의 패키지명에 맞게 수정
import java.time.LocalDateTime;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@Controller
@RequestMapping("/session") // 이 컨트롤러는 "/session" 하위 경로를 처리합니다.
public class SessionController {
@GetMapping("/") // 이 경로는 "/session/"이 됩니다.
public String index(HttpSession session, Model model) {
String sessionId = session.getId();
LocalDateTime lastAccessedTime = LocalDateTime.now();
session.setAttribute("lastAccessedTime", lastAccessedTime.toString());
Map<String, String> sessionAttributes = new HashMap<>();
Enumeration<String> attributeNames = session.getAttributeNames();
while (attributeNames.hasMoreElements()) {
String name = attributeNames.nextElement();
sessionAttributes.put(name, session.getAttribute(name).toString());
}
model.addAttribute("sessionId", sessionId);
model.addAttribute("sessionAttributes", sessionAttributes);
model.addAttribute("currentTime", LocalDateTime.now().toString());
return "index"; // 아마도 templates/index.html (혹은 session_info.html 등)을 렌더링할 것으로 보입니다.
}
@GetMapping("/add") // 이 경로는 "/session/add"가 됩니다.
public String addSessionAttribute(@RequestParam String key, @RequestParam String value, HttpSession session) {
session.setAttribute(key, value);
return "redirect:/session/";
}
@GetMapping("/invalidate") // 이 경로는 "/session/invalidate"가 됩니다.
public String invalidateSession(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/session/";
}
}
5_1) HTML 템플릿 (src/main/resources/templates/index.html)
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Spring Session Redis Demo</title>
</head>
<body>
<h1>Spring Session Redis Demo</h1>
<p><strong>Current Session ID:</strong> <span th:text="${sessionId}"></span></p>
<p><strong>Current Time:</strong> <span th:text="${currentTime}"></span></p>
<h2>Session Attributes:</h2>
<div th:if="${sessionAttributes.isEmpty()}">
<p>No attributes in session.</p>
</div>
<ul th:unless="${sessionAttributes.isEmpty()}">
<li th:each="entry : ${sessionAttributes}">
<span th:text="${entry.key}"></span>: <span th:text="${entry.value}"></span>
</li>
</ul>
<h2>Add Session Attribute:</h2>
<form action="/session/add" method="get"> Key: <input type="text" name="key" required/> <br/>
Value: <input type="text" name="value" required/> <br/>
<button type="submit">Add Attribute</button>
</form>
<h2>Actions:</h2>
<p>
<a href="/session/">Refresh Page</a><br/> <a href="/session/invalidate">Invalidate Session</a> </p>
<hr/>
<p>
<strong>테스트 방법:</strong>
<ol>
<li>이 애플리케이션의 인스턴스를 두 개 이상 실행합니다 (예: 두 개의 다른 EC2 인스턴스에 배포하거나, 로컬에서 다른 포트로 두 개 실행).</li>
<li>브라우저에서 한 인스턴스(예: Instance A)의 URL로 접속합니다.</li>
<li>'Add Session Attribute' 폼을 사용하여 세션에 데이터를 추가합니다 (예: Key='name', Value='John Doe').</li>
<li>같은 브라우저에서 다른 인스턴스(예: Instance B)의 URL로 접속합니다.</li>
<li>세션 ID는 동일하고, 추가했던 세션 속성(name: John Doe)이 Instance B에서도 동일하게 조회되는지 확인합니다. 이는 세션이 Redis에 저장되어 공유되고 있음을 의미합니다.</li>
<li>어느 인스턴스에서든 'Invalidate Session'을 클릭하면, 다른 인스턴스에서도 세션이 무효화되는 것을 확인할 수 있습니다.</li>
</ol>
</p>
</body>
</html>
6_2) HTML 템플릿 (src/main/resources/templates/login.html)
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>Login</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f4f4f4;
}
.login-container {
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
width: 300px;
text-align: center;
}
.login-container h2 {
margin-bottom: 20px;
color: #333;
}
.login-container input[type="text"],
.login-container input[type="password"] {
width: calc(100% - 20px);
padding: 10px;
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
.login-container button {
width: 100%;
padding: 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.login-container button:hover {
background-color: #0056b3;
}
.error-message {
color: red;
margin-bottom: 10px;
}
.logout-message {
color: green;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="login-container">
<h2>Login</h2>
<div th:if="${param.error}" class="error-message">
Invalid username or password.
</div>
<div th:if="${param.logout}" class="logout-message">
You have been logged out.
</div>
<form th:action="@{/login}" method="post">
<div>
<input
type="text"
name="username"
placeholder="Username"
required
autofocus
/>
</div>
<div>
<input
type="password"
name="password"
placeholder="Password"
required
/>
</div>
<button type="submit">Log in</button>
</form>
<p style="margin-top: 15px; font-size: 0.9em; color: #555">
Test Credentials: user / password
</p>
</div>
</body>
</html>
6_3) HTML 템플릿 (src/main/resources/templates/login.html)
<!DOCTYPE html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
>
<head>
<meta charset="UTF-8" />
<title>Welcome</title>
</head>
<body>
<h1>Welcome, <span sec:authentication="name"></span>!</h1>
<p>You have successfully logged in.</p>
<p>Your roles: <span sec:authentication="authorities"></span></p>
<h2>Session Info (from Redis):</h2>
<p>
<strong>Session ID:</strong>
<span th:text="${sessionId}"></span>
</p>
<p>
<a href="/session/">Go to Session Info Page</a>
</p>
<p>
<a th:href="@{/logout}">Logout</a>
</p>
<hr />
<p>
<a href="/">Go back to Home Page</a>
</p>
</body>
</html>
Spring Security 설정과 세션 동작
이제 애플리케이션에 로그인 기능을 도입하여 Redis 세션 클러스터링이 실제로 어떻게 동작하는지 확인해보겠습니다. Spring Security를 활용하여 폼 로그인과 로그아웃을 구현하고, 인증된 세션이 Redis에 저장되도록 합니다.
스프링 시큐리티 설정 (SecurityConfig.java)에서 주요 내용은 다음과 같습니다:
- 특정 경로는 인증 없이 접근 가능하도록 열어주고 (permitAll), 그 외 경로는 인증을 요구하도록 설정합니다. 로그인 페이지 경로(/login), 세션 테스트 경로(/session/**), 정적 자원(/css/**, /js/**), 그리고 헬스 체크 용도(/actuator/health) 등을 열어 두었습니다.
- .formLogin()을 통해 커스텀 로그인 페이지(/login)와 로그인 성공 후 이동할 페이지(/welcome), 실패 시 처리 등을 지정했습니다.
- .logout() 설정으로 로그아웃 URL(/logout)과 로그아웃 성공 시 리디렉션 경로, 그리고 invalidateHttpSession(true)와 deleteCookies("JSESSIONID")를 지정하여 세션 무효화와 쿠키 삭제도 확실히 수행하도록 했습니다. 이는 사용자가 /logout 요청만 보내도 Spring Security가 현재 세션을 제거하고 JSESSIONID 쿠키를 만료시켜주는 역할을 합니다.
또한 SecurityConfig에서는 실습을 위해 간단히 인메모리 사용자를 하나 등록했습니다. InMemoryUserDetailsManager로 사용자명을 user/비밀번호 pass 등으로 생성하고 BCryptPasswordEncoder로 암호화하여 비밀번호를 설정했습니다 (코드 전문은 생략). 이러한 설정으로 애플리케이션 구동 시 기본 계정이 만들어지며, 별도 DB 없이도 로그인을 테스트할 수 있습니다.
이제 컨트롤러들을 살펴보겠습니다. 인증 관련 컨트롤러 AuthController는 /login 경로의 로그인 페이지 렌더링과, /welcome 경로의 환영 페이지 처리를 담당합니다. /welcome 핸들러에서는 HttpServletRequest로부터 HttpSession 객체를 받아 현재 세션 ID를 추출하여 뷰 템플릿으로 전달하고, 콘솔에 출력도 합니다. 이를 통해 실제 동작 시 어떤 세션 ID가 활용되는지 로그로 확인할 수 있습니다.
세션 상태를 직접 확인하기 위한 SessionController도 구현되어 있습니다. 이 컨트롤러는 /session 하위 경로들을 처리하며, 주요 기능은 다음과 같습니다:
- 세션 정보 조회 (GET /session/): 현재 세션의 ID와 모든 세션 속성들을 보여줍니다. HttpSession.getId()를 통해 세션 ID를 가져오고, getAttributeNames()로 모든 키를 열람하여 값들을 맵으로 수집한 뒤 화면에 출력합니다. 이때 요청 시 세션이 아직 없으면 request.getSession(true)로 새로 생성하며, 매 페이지 접근마다 lastAccessedTime 속성을 현재 시각으로 갱신하여 세션이 유지되고 있음을 표시합니다.
- 세션 속성 추가 (GET /session/add): 쿼리 파라미터로 전달된 key, value를 현재 세션에 저장합니다. 코드에서는 session.setAttribute(key, value) 호출 후 /session/ 페이지로 리디렉트하여, 추가된 내용이 목록에 나타나도록 구현되었습니다.
- 세션 무효화 (GET /session/invalidate): 현재 세션을 강제로 만료시킵니다. request.getSession(false)로 기존 세션을 얻어와 invalidate()를 호출한 후, 다시 /session/으로 리디렉트합니다. 이 엔드포인트는 Spring Security의 /logout과 별개로 세션을 직접 없애는 용도로 마련되었는데, 둘 모두 결국 Redis에 저장된 세션 데이터가 삭제되어 다른 인스턴스에도 즉시 반영된다는 점은 동일합니다.
이러한 컨트롤러와 뷰 템플릿 (index.html, login.html, welcome.html)을 통해, 멀티 인스턴스 환경에서 세션이 제대로 공유되는지 직관적으로 시험해볼 수 있습니다. 예를 들어 index.html 템플릿에서는 현재 세션 ID와 시간, 그리고 세션에 저장된 속성들을 출력해주며, 폼을 통해 새로운 속성을 추가하거나 세션을 무효화하는 기능을 제공합니다.
Blue/Green 배포 시 세션 일관성 보장
그림 1: Blue/Green 배포 환경에서 Redis 기반 세션 클러스터링 동작 흐름.

AWS CodeDeploy의 Blue/Green 배포에서는 기존(Blue) 인스턴스와 신규(Green) 인스턴스가 한동안 동시에 실행됩니다. 사용자의 요청은 애플리케이션 로드 밸런서(ALB)를 통해 Blue 또는 Green 중 어느 인스턴스로든 전달되지만, 세션 상태는 공용 Redis 저장소에 저장되어 있기 때문에 어느 인스턴스가 처리하더라도 동일한 세션 데이터를 읽을 수 있습니다. 결과적으로 배포 진행 중에도 세션의 연속성이 유지되어, Blue 환경에서 로그인한 사용자는 트래픽이 Green으로 전환된 후에도 다시 로그인할 필요 없이 이용을 지속할 수 있습니다.
Blue/Green 배포는 새로운 버전(Green)을 배포한 뒤 트래픽 스위칭을 통해 구 버전(Blue)을 교체하는 방식입니다. AWS CodeDeploy에서는 기존 인스턴스와 신규 인스턴스를 각각 다른 대상 그룹(Target Group)으로 등록하고, 로드 밸런서(ALB)가 일정 시점에 트래픽을 Green 쪽으로 전환합니다. 이 과정에서 세션 유지가 잘 되지 않으면 배포 직후 사용자들이 로그인 상태를 잃거나, 진행 중이던 작업이 초기화되는 문제가 발생할 수 있습니다. Redis 세션 클러스터링을 적용하면 세션이 인스턴스 메모리에 종속되지 않으므로, 배포로 백엔드 인스턴스가 바뀌어도 사용자는 모르는 사이에 새 버전으로 스위치되면서도 세션은 그대로 유지됩니다.
추가로 로드 밸런서의 세션 정책도 고려해봐야 합니다. 일반적인 AWS ALB에서는 기본적으로 스티키 세션이 비활성화되어 있어 요청 단위로 대상 인스턴스를 고를 수 있습니다. 이때 Redis 세션 공유를 하고 있다면 굳이 스티키 세션을 활성화할 필요가 없습니다. 만약 스티키 세션을 켜 두었다면 동일 사용자는 배포 전후 한쪽 환경에만 묶여있게 되는데, Green 배포 완료 후 Blue 인스턴스가 내려가면 세션 이동이 발생할 수밖에 없습니다. 따라서 Redis를 쓴다면 세션Affinity는 끄고, 모든 요청을 자유롭게 분산하도록 하여 Blue -> Green 전환을 자연스럽게 만드는 편이 좋습니다. (참고로, 본 예제의 SecurityConfig에서 /actuator/health를 열어둔 것은 ALB의 헬스 체크 엔드포인트 호출에 대해 인증을 요구하지 않게 하기 위함입니다.)

Redis 클러스터 환경 이슈 및 설정 팁
Redis를 세션 스토어로 사용할 때 단일 노드 Redis든 Redis Cluster든 기본적으로 Spring Session은 잘 동작합니다. 다만 Redis 클러스터 모드(샤딩)에서는 유의해야 할 점이 하나 있는데, 바로 세션 ID 변경 시 발생하는 키 이동 문제입니다. Spring Security 등에서 로그인 후 세션 ID가 갱신되는 경우(세션 fixation 보호 등), Spring Session은 내부적으로 Redis의 RENAME 명령을 사용해 기존 키를 새로운 키로 변경합니다. 이때 Redis Cluster에서는 기존 세션 키와 새로운 세션 키의 해시 슬롯(hash slot)이 다르면 RENAME 명령이 허용되지 않아 오류가 발생합니다. 실제로 AWS ElastiCache Cluster 모드에서 이 현상을 겪으면 CROSSSLOT Keys in request don't hash to the same slot라는 에러가 발생하며 세션 저장에 실패하게 됩니다.
이 문제를 해결하려면 Spring Session의 Redis 작동 방식 설정을 조금 바꾸면 됩니다. 앞서 spring.session.redis.rename-session-attribute-enabled=false 옵션을 추가한 것이 바로 그 해결책입니다. 이 설정을 false로 주면 Spring Session이 더 이상 Redis RENAME을 쓰지 않고, 대신 COPY 후 DELETE 방식으로 세션 ID 변경을 처리하게 됩니다. COPY+DEL은 두 개의 명령으로 분리되지만 서로 다른 슬롯에 대해 수행될 수 있기 때문에 클러스터에서 문제없이 동작합니다. 즉, 이 옵션을 끄면 Redis Cluster 환경에서도 세션 ID 변경 로직이 오류 없이 처리되어 세션 클러스터링이 정상 유지됩니다. (Spring Session 2.2 이상에서는 이 옵션이 제공되며 기본값은 true이므로, 클러스터 사용 시 꼭 false로 바꿔주세요.)
Tip: 만약 Redis Cluster를 사용하지 않고 레디스 리플리카 또는 샤딩 없는 단일 노드 구조라면, 위 옵션은 굳이 꺼주지 않아도 무방합니다. 그러나 환경이 변경될 수 있으니 설정을 넣어두는 것이 안전하며, 추가로 Redis에 세션 데이터를 저장할 키 접두사(prefix)를 지정하거나 TTL을 관리하는 등 세부 설정도 Spring Session에서 제공합니다.
타 프레임워크의 세션 관리 비교
Redis를 이용한 세션 공유 패턴은 스프링 뿐만 아니라 여러 플랫폼에서 활용되고 있습니다. 다음은 다른 언어/프레임워크에서 세션을 처리하는 몇 가지 사례와의 간략한 비교입니다:
- Node.js (Express) – 기본적으로 Express 서버는 메모리 세션을 사용하지만, 다중 서버 환경에서는 express-session 미들웨어와 Redis 스토어(connect-redis)를 함께 사용해 세션을 공유합니다. 실제 구현 시 app.use(session({ store: new RedisStore({...}) })) 형태로 Redis를 세션 저장소로 등록하며, Redis 서버 정보만 주면 작동 원리는 Spring Session과 유사합니다. 이러한 구조 덕분에 Node.js도 로드 밸런서 밑에 여러 인스턴스를 둘 때 중앙 세션 스토어로 Redis를 사용하는 것이 일반적입니다. (물론 JWT 등의 토큰 방식을 사용하기도 하지만, 서버단에서 상태를 관리해야 할 경우 Redis 세션이 단순하고 효율적입니다.)
- Python (Django) – Django 프레임워크는 세션을 기본적으로 DB에 저장하거나 파일 등에 저장하지만, 캐시 기반 세션 엔진도 제공합니다. 설정에서 SESSION_ENGINE = "django.contrib.sessions.backends.cache"와 캐시로 Redis를 지정하면, 모든 웹 서버 프로세스가 같은 Redis에 세션을 읽고 쓰게 되어 세션이 일관되게 유지됩니다. Flask 등 경량 프레임워크도 Flask-Session 등을 통해 Redis 세션 저장소를 사용하는 패턴이 보편적입니다.
- 기타 – PHP의 Laravel은 SESSION_DRIVER로 redis를 설정하면 비슷하게 동작하며, .NET 코어 역시 AddDistributedRedisCache를 통해 세션 상태를 Redis에 저장하도록 구성할 수 있습니다. 한편 과거 Java 진영의 Tomcat, JBoss 등은 세션 복제 기능을 내장하기도 했는데, 앞서 언급한 바와 같이 확장성과 메모리 부담 문제로 오늘날에는 외부 캐시를 쓰는 방법이 더 선호됩니다. 로드 밸런서의 스티키 세션은 간단하지만 일시적인 방편일 뿐, 근본적인 솔루션은 아니므로 대규모 서비스에서는 잘 쓰지 않는 추세입니다. 실제로 Microsoft의 권고에서도 “sticky 세션 대신 분산 세션 스토리지 사용을 고려하라”는 언급이 있을 정도입니다.
세션 클러스터 구현 동작 검증 방법
마지막으로, 이렇게 구축한 세션 클러스터링 환경이 제대로 동작하는지 확인하는 방법을 알아보겠습니다. 우선 애플리케이션을 두 개 이상의 인스턴스로 실행합니다. 로컬 테스트 시 포트를 다르게 두어 하나의 PC에서 여러 서버를 띄울 수도 있고, 실제 AWS 환경에서는 CodeDeploy를 통해 Blue/Green 두 군데에 애플리케이션을 배포하게 됩니다.
이후 다음 과정을 따라 세션 공유를 검증할 수 있습니다:

- 세션 생성 및 데이터 추가: 웹 브라우저에서 Blue 환경의 애플리케이션 URL에 접속합니다 (예: 배포 완료 전 현재 서비스 중인 Blue 대상). 제공된 로그인 페이지에서 로그인하여 세션을 생성합니다. 로그인 후 /session/ 페이지로 이동하여 폼을 통해 세션에 임의의 속성을 추가해봅니다 (예: name = Alice). 이때 화면에 표시된 세션 ID를 기록해둡니다.
- 다른 인스턴스로 접근: 동일한 웹 브라우저에서 Green 환경의 애플리케이션으로 접속합니다. (Blue/Green 모두 같은 ALB 도메인을 공유한다면, CodeDeploy의 테스트 단계에서 Green 환경을 미리보기 위한 별도 URL이나 임시 포트를 활용할 수 있습니다.) 혹은 Blue/Green 전환 후 새 버전이 활성화되었다면, 그냥 페이지를 새로고침하여 다른 인스턴스에 요청이 보내지도록 합니다. 이때 여전히 로그인 상태가 유지되고, /session/ 페이지에서 앞서 추가한 세션 속성(name = Alice)이 보이는지 확인합니다. 세션 ID도 이전에 기록한 값과 동일한지 비교합니다. Redis 세션 공유가 제대로 되었다면 Green 인스턴스에서도 똑같은 세션 ID와 데이터를 확인할 수 있습니다.
- 세션 무효화 확인: 한 인스턴스 (예: Green)의 화면에서 로그아웃을 수행하거나 /session/invalidate를 호출하여 세션을 제거합니다. 그런 다음 다른 인스턴스(예: Blue)에서 새로운 요청을 보내 보면 세션이 유효하지 않아 로그인 페이지로 리다이렉트되거나, /session/ 페이지라면 세션 없음 상태가 나타나는 것을 확인할 수 있습니다. 즉 한쪽에서 세션을 지우면 다른 쪽에서도 즉시 효력이 발생하는 것입니다.
- Redis에서 직접 검증: 가능하다면 Redis CLI를 통해 세션 키가 제대로 생성/삭제되는지 살펴보는 것도 좋습니다. 예를 들어 AWS ElastiCache Redis의 경우 별도 클러스터 엔드포인트와 redis-cli --tls 옵션 등을 이용해 접속할 수 있습니다. 앞서 설정한 세션 키 접두사가 spring:session:이므로, Redis에 접속하여 KEYS spring:session:sessions* 명령으로 현재 세션 저장 키들을 나열해봅니다. 로그인 후라면 spring:session:sessions:<세션ID> 형태의 해시 키가 보일 것이고, HGETALL로 그 해시의 모든 필드를 조회하면 세션에 저장된 속성들이 나옵니다. 그런 다음 애플리케이션에서 로그아웃하거나 세션 무효화 후 Redis에서 동일 키를 EXISTS나 GET 해보면 삭제된 것을 확인할 수 있습니다 (남아있다면 TTL이 지나 자동 삭제되기 전일 수 있으니 DEL로 제거 가능). 이처럼 Redis를 직접 모니터링하면 세션 클러스터링이 내부에서 어떻게 동작하는지도 파악할 수 있습니다.

이상으로 Spring Boot와 AWS 환경에서 Redis 기반 세션 클러스터링을 구현하고 Blue/Green 배포 시 세션 일관성을 유지하는 방법을 살펴보았습니다. 이 접근법을 통해 배포나 스케일 아웃시에 사용자 경험을 해치지 않고 세션을 안정적으로 관리할 수 있습니다. DevOps 엔지니어는 해당 구조를 구축할 때 Redis의 가용성(예: 다중 AZ 구성, 백업)과 세션 데이터 관리 정책(만료 시간, 용량 모니터링)도 함께 고려해야 할 것입니다. 적절한 구성 하에 Redis 세션 클러스터링은 확장성과 안정성을 모두 잡는 세션 관리 솔루션으로서, Spring Boot 애플리케이션의 배포 전략(Blue/Green 등)에 유연하게 대응해줄 것입니다.
'IT 트렌드 > AWS & 클라우드 쉬운 활용법' 카테고리의 다른 글
| 인플레이스 배포 지쳤나요? CodePipeline Blue Green으로 AWS 배포 자동화 및 Spring Boot EC2 무중단 배포! 😃 (5) | 2025.08.05 |
|---|---|
| AWS Elastic Beanstalk를 활용한 Spring Boot 자동 배포: CodePipeline 연동 가이드 (3) | 2025.08.01 |
| AWS CodePipeline에서 GitHub 연동 – 실무 후기 및 CI/CD 구축 가이드 (3) | 2025.07.31 |
| Git 없이 S3로 구축하는 AWS DevOps 배포 파이프라인 (2) | 2025.07.29 |