tech blog 글 읽고 정리하기
#
제목은 Spring Session 도입기로 하겠습니다. 근데 이제 Redis를 곁들인 #
개요 #
줌인터넷의 회원 서비스는 분산 환경에서 운영되고있다. 분산 환경에서 세션 동기화 문제를 해결하기 위해 사용하고 있는 세션 저장소를 Redis로 교체하게 된 이유와 기존 아키텍처를 유지하면서 안정적으로 Spring Session으로 도입할 수 있는 방법을 소개한다.
도입 배경 #
기존 Aerospike -> Redis로의 전환을 결정하게 되었다. 전환을 통해 바라는 점
-
Spring Session의 도입 가능 Spring Session은 Redis 이외에도 다양한 세션 저장소를 지원하여 세션 정보를 유연하게 관리할 수 있다. 다음에 세션 저장소를 변경해야할 경우에도 유연성을 제공한다.
-
유지보수 용이성 향상 팀 내에 이미 사용중이며, 신규 인프라 구축 비용이 발생하지 않는다. Aerospike 관련 레퍼런스가 부족하여 러닝 커브가 큰 점을 고려할때 Redis로 전환하게되면 학습에서 발생하는 리소스 비용을 줄일 수 있다.
위와 같은 이유로 세션 저장소를 Aerospike에서 Redis로 전환하면서 Spring Session을 도입하게 되었다.
Spring Session #
스프링 기반 애플리케이션에서 세션 관리를 효과적으로 처리하기 위한 기술이다. 기본적으로 스프링 세션은 세션 데이터를 서버의 메모리에 저장하는 대신 외부 스토리지에 저장하고 관리한다. Spring Session은 세션 데이터를 외부 스토리지에 저장함으로써 여러 서버 간에 세션 데이터를 공유하고 로드밸런싱과 확장성을 지원한다. 이는 분산 환경에서 여러 서버가 같은 세션 데이터를 접근하고 처리할 수 있도록 해주는 장점을 제공한다.
Spring Session은 사용자의 세션 정보를 관리하기 위해 API 및 구현체를 제공한다.
- springSessionRepositoryFilter (Filter 인터페이스를 구현한 빈)
서블릿 컨테이너는 모든 요청에 대해
springSessionRepositoryFilter
를 사용하도록 설정해야 하지만, 이 과정은 Spring Boot가 자동으로 처리해준다.
Spring Session 적용 예시 #
build.gradle
dependencies {
// Spring Boot에서 Redis를 사용하기 위한 의존성입니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Spring Session을 Redis에 저장하기 위한 의존성입니다.
implementation 'org.springframework.session:spring-session-data-redis'
}
application.yml
server:
servlet:
session:
cookie:
path: / # 적용될 URL 경로를 나타냅니다. 예를 들어, path를 "/"로 설정하면 해당 도메인의 모든 경로에서 쿠키가 사용될 수 있습니다.
name: JSESSIONID # 이름을 지정합니다.
domain: zum.com # 유효 도메인을 지정합니다. 예를 들어, domain을 "zum.com"으로 설정하면 해당 도메인과 그 서브도메인에서 쿠키가 유효합니다.
http-only: true # 브라우저에서 해당 쿠키에 대한 JavaScript 접근을 제한합니다. 이를 통해 XSS 공격을 방지할 수 있습니다.
secure: true # 쿠키가 HTTPS(SSL/TLS) 연결을 통해서만 전송되어야 함을 나타냅니다. 즉, HTTPS로 암호화된 연결에서만 쿠키가 전송되어야 합니다.
timeout: 3600 # 세션의 유효 시간을 지정합니다. 단위는 초입니다.
spring:
redis:
host: 127.0.0.1
port: 6379
password:
session:
store-type: redis # 세션 저장소를 지정합니다.
redis:
namespace: zum:session # 세션을 저장하는 데 사용되는 키의 네임스페이스를 지정합니다.
server.servlet.session.cookie를 통해 쿠키의 속성을 지정할 수 있다. spring.session.store-type을 지정해주면 별도의 설정 없이 Spring Boot의 AutoConfiguration으로 인해 @EnableRedisHttpSession 을 추가한 것과 같다. 또한 Spring Boot는 spring.session.store-type 속성을 기반으로 실제 사용할 구현체를 결정한다. 기본적으로 인메모리 저장소인 MapSessionRepository 클래스가 적용된다. 예를 들어, Redis를 사용하려는 경우 Redis 세션 저장소 구현체인 RedisIndexedSessionRepository 클래스가 Bean으로 등록된다.
아키텍처 #
AS-IS
회원 서비스는 위와 같이 각 모듈을 독립적으로 서버에 배포하여 운영하고 있다. 기존 아키텍처에서는 모듈마다 같은 ID Generator를 사용하여 세션 아이디를 생성하고 있다. 생성된 세션 아이디를 통해 각 모듈은 세션 서버에 요청하여 세션 저장소에 접근한다. 이렇게 각 모듈마다 ID Generator가 존재하는 경우 만약 세션 아이디 생성 전략이 변경되었다면, 변경된 전략을 적용하기 위해 각 모듈을 전부 재배포해야한다. 또한 새로운 모듈이 추가된다면 매번 ID Generator를 추가해야한다. 매우 낮은 확률이지만 중복된 세션 아이디가 생성될 수 있다는 가능성도 고려했다.
TO-BE
세션 아이디 생성 전략을 담당하는 ID Generator를 하나의 모듈에서 관리하도록 변경했다.
- 사용자가 각 모듈에 접근합니다.
- 각 모듈은 세션 서버에 세션 아이디를 요청합니다.
- 세션 서버는 세션 아이디를 발행하고 세션 아이디와 함께 응답합니다.
- 각 모듈은 발급받은 세션 아이디를 클라이언트에 전송합니다.
와 같이 ID Generator를 한 곳에서 관리하면 모듈 간 응집도가 높아지며, 필요한 변경이 있을 때도 해당 모듈만 수정하여 유지보수와 서비스의 확장을 유연하게 할수있다. ID Generator를 하나의 모듈에서 관리하고, Redis를 통해 세션 정보를 관리하도록 변경하여, 아키텍처를 개선하고, 중복 코드를 제거할 수 있다.
하지만 ID Generator를 한 곳에서 관리되기 때문에 SPOF(Single Point of Failure)
가 발생할 수 있다.
SPOF란 시스템에서 단일 실패 지점으로, 해당 지점에 장애가 발생하면 전체 시스템이 영향을 받는 상황이다.
이러한 문제를 해결하기 위해 세션 서버의 인스턴스를 분산하여 구성하였다.
코드로 살펴보는 개선 과정 #
프로젝트 환경
- Java 8
- Spring Boot 2.x
- Spring Security 5.x
AS-IS 기존 아키텍처에서 사용자가 요청 시 세션 아이디가 발행되는 과정
- SessionIdFilter 해당 필터에서는 사용자의 요청마다 세션 쿠키가 존재하는지 확인하고, 만약 존재하지 않을 때에는 ID Generator를 통해 세션 아이디를 생성하고 쿠키에 저장한다.
public class SessionIdFilter extends OncePerRequestFilter {
private static final ThreadLocal<String> sessionIdHolder = new ThreadLocal<>();
private static final String SESSION_KEY = "JSESSIONID";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String sessionId = CookieUtils.getCookieValue(request, SESSION_KEY); // 쿠키에서 세션 아이디를 조회합니다.
if (Strings.isBlank(sessionId)) { // 세션 아이디가 존재하지 않는 경우 새로운 세션 아이디를 생성합니다.
sessionIdHolder.set(SessionIdGenerator.generate());
CookeUtils.addCookie(response, SESSION_KEY);
}
sessionIdHolder.set(sessionId); // 세션 아이디를 ThreadLocal에 저장합니다.
filterChain.doFilter(request, response); // 다음 필터로 요청을 전달합니다.
sessionIdHolder.remove(); // ThreadLocal에 저장된 세션 아이디를 제거합니다.
}
}
- CustomSecurityContextRepository Spring Security에서 기본적으로는 HttpSession을 사용하여 SecurityContext를 저장하고 로드한다. 다른 세션 저장소를 사용하기 위해 SecurityContextRepository를 구현한 클래스이다. SessionAdapter를 통해 세션 모듈에서 세션 정보 가져오고, SecurityContext를 생성하여 반환한다.
public class CustomSecurityContextRepository implements SecurityContextRepository {
private final SessionAdapter sessionAdapter; // 세션 서버와 통신하기 위한 SessionAdapter
public ZumContextRepository(SessionAdapter sessionAdapter) {
this.sessionAdapter = SessionAdapter;
}
@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) { // 세션 아이디를 통해 세션 정보를 조회합니다.
return SessionAdapter.getSession()
.map(this::createSecurityContext)
.orElseGet(this::emptyContext);
}
private SecurityContext createSecurityContext(final User user) { // 세션 정보를 통해 SecurityContext를 생성합니다.
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(user, user.getAuthorities());
return new SecurityContextImpl(usernamePasswordAuthenticationToken);
}
private SecurityContext emptyContext() { // 세션 정보가 존재하지 않는 경우 빈 SecurityContext를 생성합니다.
return SecurityContextHolder.createEmptyContext();
}
}
- SpringSecurityConfiguration Spring Security 설정 클래스다. SecurityContextPersistenceFilter는 SecurityContextRepository를 사용하여 SecurityContext의 저장 및 로드를 처리한다. SecurityContextPersistenceFilter 이전에 실행되며, CustomSecurityContextRepository를 SecurityContextRepository로 설정한다.
@EnableWebSecurity
public class SpringSecurityConfiguration extends WebSecurityConfigurerAdapter {
private final SessionAdapter sessionAdapter;
public SecurityConfig(SessionAdapter sessionAdapter) {
this.sessionAdapter = SessionAdapter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.and()
.securityContext() // SecurityContext를 설정합니다.
.securityContextRepository(new CustomSecurityContextRepository(SessionAdapter)) // SecurityContextRepository를 설정합니다.
.and()
.addFilterBefore(new SessionIdFilter(), SecurityContextPersistenceFilter.class); // SessionIdFilter를 SecurityContextPersistenceFilter 이전에 실행합니다.
}
}
TO-BE
- SessionAdapter
public class SessionAdapter {
@Cacheable(value = "session", key = "#sessionId")
public ZumSessionDto getSession(String sessionId) {
ResponseEntity<User> response = restTemplate.exchange(
"http://localhost:8080/api/v2/session",
HttpMethod.GET,
createRequestEntityWithHttpHeader(sessionId),
User.class); // 세션 서버에서 세션 정보를 조회합니다.
String responseSessionId = getSessionIdFromResponseHeaders(response.getHeaders()); // 세션 서버에서 응답받은 세션 ID를 가져옵니다.
return ZumSessionDto.of(response.getBody(), responseSessionId); // 세션 정보를 Dto 변환합니다.
}
private String getSessionIdFromResponseHeaders(HttpHeaders headers) {
String cookie = headers.getFirst("X-SESSION-ID");
if (Strings.isNotBlank(cookie)) {
return cookie;
}
return null;
}
private HttpEntity<Object> createRequestEntityWithHttpHeader(String sessionId) {
HttpHeaders requestHeaders = new HttpHeaders();
requestHeaders.add("X-Session-ID", sessionId);
return new HttpEntity<>(null, requestHeaders);
}
}
SessionAdapter는 각 모듈에서 세션 서버에 요청하기 위해 존재하는 Adapter이다. 기존 아키텍처는 세션 정보를 조회하기 위해 매번 네트워크 I/O 비용이 발생하는 문제가 있었다. 여러 가지 방법을 고민해보았지만, 세션 서버 요청의 90% 이상이 GET 요청으로 이루어지고 있어, 캐싱 시스템을 도입하여 세션 정보를 조회하는 비용을 줄이기로 했다.
Look-aside 전략은 캐시 시스템에서 사용되는 하나의 전략으로, 데이터를 캐시에 저장하고 검색할 때 데이터베이스 또는 백엔드 시스템에 대한 추가 작업을 최소화하는 것을 목표로 한다.
Look-aside 전략 #
- 데이터를 읽거나 검색하기 전에, 먼저 캐시에서 데이터를 찾는다.
- 데이터가 캐시에 존재하면, 해당 데이터를 반환하고 추가적인 백엔드 작업을 수행하지 않는다.
- 데이터가 캐시에 존재하지 않으면, 데이터를 백엔드 시스템에서 검색한 후, 해당 데이터를 캐시에 저장하고 반환한다.
- 이후 같은 데이터에 대한 요청이 들어올 때는 캐시에서 데이터를 반환한다.
이러한 방식으로 Look-aside 전략은 세션 서버에 대한 요청을 최소화하고, 데이터를 빠르게 반환하여 성능을 향상시킬 수 있다. 만약 데이터의 업데이트가 발생하면, @CacheEvict를 통해 캐시를 업데이트 하도록 구현했다. 세션 정보가 필요할 때 마다 네트워크 I/O 비용이 발생하는 문제는 캐싱 시스템을 도입함으로써 해결하였으며 캐시 정합성 문제는 @CachePut를 통해 해결했다.
코드를 보면 특이하게도 세션 서버에 요청할 때 세션 아이디를 쿠키가 아닌 헤더에 담아 요청하고 응답을 받고있다. 아래 사진처럼 Spring Session은 세션 아이디를 기본적으로 쿠키를 통해 발행하도록 구현되어 있지만 세션 아이디를 헤더에 담아서 요청과 응답을 하는 것이 현재 아키텍처에서 더욱 적합하다고 생각했다.
이유는 아래와 같다.
- Set-Cookie 헤더는 브라우저가 응답받고 쿠키를 저장하는 과정을 거치기 때문에 브라우저가 없는 서버 간 요청에서는 쿠키를 저장할 수 없습니다.
- 쿠키는 클라이언트 보안 정책에 따라 다르지만, 기본적으로 같은 도메인 간에만 공유된다. 하지만 헤더는 타사 도메인 간 요청에서 커스텀 헤더를 통해 데이터를 전달할 수 있다. 이는 추후 줌인터넷에서 제공하는 서비스가 만약 다른 도메인을 가질 때 유연하게 대응할 수 있다고 판단했다.
따라서 세션 아이디를 헤더에 발행하는 방법으로 Spring Session을 커스터마이징하여 구현하였다.
- CustomSessionIdResolver
import org.springframework.session.web.http.HeaderHttpSessionIdResolver;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
public class CustomSessionIdResolver extends HeaderHttpSessionIdResolver {
public CustomSessionIdResolver(String headerName) {
super(headerName);
}
@Override
public List<String> resolveSessionIds(HttpServletRequest request) {
List<String> sessionIds = super.resolveSessionIds(request);
if (sessionIds.isEmpty()) {
String sessionId = request.getHeader("X-Session-ID"); // 헤더에서 세션 아이디를 읽어옵니다.
if (sessionId != null) {
sessionIds.add(sessionId);
}
}
return sessionIds;
}
@Override
public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) {
super.setSessionId(request, response, sessionId);
response.setHeader(HEADER_SESSION_ID, sessionId); // 헤더에 세션 아이디를 추가합니다.
}
}
HeaderHttpSessionIdResolver 클래스는 Spring Session에서 제공하는 HttpSessionIdResolver 인터페이스의 구현체다. 이 클래스를 사용하면 세션 아이디를 HTTP 헤더에 포함하여 전달할 수 있다. 해당 클래스의 생성자는 세션 아이디를 담을 헤더의 이름을 인자로 받을 수 있다.
- HttpSessionIdResolverConfiguration
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.session.web.http.HttpSessionIdResolver;
@Configuration
public class HttpSessionIdResolverConfiguration {
@Bean
public HttpSessionIdResolver httpSessionIdResolver() {
return new CustomSessionIdResolver("X-Session-ID"); // 세션 아이디를 담을 헤더 이름을 지정합니다.
}
}
위의 코드를 통해 CustomSessionIdResolver 클래스가 Bean으로 등록된다.
- SessionController
@RestController
@RequestMapping("/api/v2/session")
public class SessionController {
private final ObjectMapper objectMapper;
public SessionController(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@GetMapping
User session(HttpSession httpSession) {
return objectMapper.convertValue(httpSession.getAttribute("user"), User.class); // 세션에서 user attribute를 꺼내서 응답합니다.
}
}
세션 서버의 역할은 간단하다. SessionController 에서는 각 모듈에서 요청한 attribute를 세션에서 꺼내서 응답한다. Spring Session을 적용하였기 때문에 기존의 HttpSession 객체는 Spring Session이 제공하는 세션 객체로 대체되며, 데이터는 Redis에 저장되고 관리된다.
마무리 - 확인 #
세션 정보를 조회하는 별도의 모듈이 존재하고 각 서비스는 특정 모듈을 통해 세션 정보를 조회하도록 API를 제공하고 있지만, 세션 서버를 직접 호출하는 서비스가 존재할 수 있어서 이를 확인해야 했다. 아니나 다를까 우려했던 것처럼 세션 서버를 직접 호출하는 서비스가 존재했다. 회원 서비스는 전사 서비스이기 때문에 다른 서비스에 장애 전파가 발생하지 않도록 특히 주의해야한다. 기존 서비스의 수정 없이 세션 서버에서 대응할 수 있는 방법을 찾아야 했고, Nginx를 통해 세션 서버로의 요청을 가로챈 후 헤더에 세션 아이디를 담아서 요청을 보내는 방법을 선택했다.
nginx.conf
http {
server {
listen 80;
server_name example.com;
location /api/v1/session { # 특정 URL로 요청이 들어오면
set $session_id ""; # 세션 아이디를 담을 변수를 선언합니다.
if ($args ~* "(?:^|&)JSESSIONID=([^&]+)") { # URL에 세션 아이디가 담겨있다면
set $session_id $1; # 세션 아이디를 변수에 담습니다.
}
proxy_set_header X-Session-ID $session_id; # 세션 아이디를 헤더에 담습니다.
proxy_pass http://backend;
# 나머지 프록시 설정
}
# 나머지 서버 설정
}
결과적으로, 서비스에 장애가 전파되지 않도록 기존에 사용하던 /api/v1/session 엔드포인트를 유지하면서 세션 저장소를 성공적으로 교체할 수 있었다.