| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
- 합정맛집
- 무이네 사막투어
- 연어포케
- 한빛미디어
- 호치민여행
- 기술부채
- 호치민 무이네
- 자료구조
- 호치민 여행
- 니코호텔
- 회고
- 베트남여행
- 혼공컴운
- 합정 맛집
- 호치민
- 양꼬치
- 밤리단길 맛집
- cs
- 합정포케
- Redis
- 포케맛집
- OS
- 혼공학습단
- 호치민 맛집
- 혼공네트
- 무이네투어
- 핑크성당
- 혼공학습단12기
- Docker
- 혼공컴운11기
- Today
- Total
경험은 나의 것
[Spring] Redis의 세션 Store 휩쓸기 본문
1. yml 파일의 설정 한 줄로 바뀌는 세션 위치
spring:
session:
store-type: redis # 이 한 줄이 핵심!
redis:
namespace: spring:session
data:
redis:
host: localhost
port: 6379
현재 진행 중인 프로젝트에서 무중단 배포(Blue/Green) 환경 구축과 세션 정합성 문제를 해결하기 위해 Redis를 도입하게 되었다.
build.gradle에 Spring Session Data Redis 의존성을 추가하고, application.yml에 간단한 설정 몇 줄을 추가했다. 기존 비즈니스 로직 코드를 단 한 줄도 수정하지 않았는데, 세션 저장소가 Tomcat 메모리에서 Redis로 변경된 것이다.
Controller에 있는 httpServletRequest.getSession() 코드는 그대로인데, 어떻게 저장소만 바뀐 걸까?
2. 의문: 도대체 언제 가로챈 걸까?
"스프링은 도대체 언제, 어떻게 내 요청을 가로채서 Redis를 연결한 걸까??"
기존에는 Tomcat(WAS)이 자체적으로 메모리에서 세션을 관리했다. (ConcurrentHashMap) 그렇다면 스프링이 Filter나 Interceptor 단계에서 createSession 같은 동작을 중간에 낚아채는 것일까?
3. 분석: 범인은 Filter와 Decorator 패턴
결론부터 말하자면, 이 마법은 서블릿 컨테이너의 문지기인 Filter, 객체를 포장하는 Decorator(Wrapper) 패턴, 그리고 Spring의 추상화(PSA)가 합작해 낸 결과물이었다.
전체 흐름도
HTTP Request
↓
[SessionRepositoryFilter] ← 요청 가로채기
↓
wrappedRequest = new SessionRepositoryRequestWrapper(request)
↓
filterChain.doFilter(wrappedRequest, ...)
↓
[Controller에서 request.getSession() 호출]
↓
SessionRepositoryRequestWrapper. getSession() ← 메서드 오버라이딩
↓
sessionRepository.findById(sessionId) ← SessionRepository 호출
↓
[RedisIndexedSessionRepository. findById()] ← Redis에서 조회
↓
HttpSessionWrapper로 감싸서 반환 ← HttpSession 인터페이스 구현
↓
[응답 완료 시]
↓
commitSession() → sessionRepository.save() → Redis 저장 ← 저장
3-1. 문지기: SessionRepositoryFilter
모든 비밀의 시작은 SessionRepositoryFilter다. 이 필터는 Order가 매우 낮게 설정되어 있어 필터 체인의 가장 앞단에서 동작한다.
@Order(SessionRepositoryFilter.DEFAULT_ORDER) // Integer.MIN_VALUE + 50
public class SessionRepositoryFilter<S extends Session> extends OncePerRequestFilter {
public static final int DEFAULT_ORDER = Integer.MIN_VALUE + 50;
private final SessionRepository<S> sessionRepository;
public SessionRepositoryFilter(SessionRepository<S> sessionRepository) {
if (sessionRepository == null) {
throw new IllegalArgumentException("sessionRepository cannot be null");
}
this.sessionRepository = sessionRepository;
}
}
다른 Filter나 Interceptor가 request.getSession()을 호출하기 전에 request를 Wrapping 해야 한다. 그렇지 않다면 모든 코드가 Redis 기반 세션을 사용하지 않을 수 있다.
3-1-2. Request를 Wrapper로 감싼다
이 필터의 핵심 역할은 '포장(Wrapping)'이다. doFilterInternal 메서드를 보면 Decorator 패턴이 보인다.
Decorator 패턴이란? 객체에 대한 기능 확장이나 변경이 필요할 때, 상속 대신 객체를 감싸서 기능을 추가하는 유연한 구조 패턴이다. 선물 포장지를 생각하면 된다.
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
// 1. 원본 request를 Wrapper로 감싼다 (포장)
SessionRepositoryRequestWrapper wrappedRequest =
new SessionRepositoryRequestWrapper(request, response);
// 2. 원본 response도 Wrapper로 감싼다
SessionRepositoryResponseWrapper wrappedResponse =
new SessionRepositoryResponseWrapper(wrappedRequest, response);
try {
// 3. 원본이 아닌 '포장된 객체'를 다음 체인으로 전달!
filterChain.doFilter(wrappedRequest, wrappedResponse);
}
finally {
// 4. 요청 처리 완료 후 세션을 Redis에 저장 (Commit)
wrappedRequest.commitSession();
}
}
핵심은 3번이다. 이후 실행되는 모든 로직(Controller 등)은 톰캣의 원본 HttpServletRequest가 아니라, 스프링이 바꿔치기한 wrappedRequest를 사용하게 된다.
AS-IS vs TO-BE
| 구분 | AS-IS (기존) | TO-BE (Spring Session 적용 후) |
|---|---|---|
| Controller가 받는 객체 | HttpServletRequest |
SessionRepositoryRequestWrapper |
getSession() 호출 시 |
Tomcat 메모리 세션 반환 | Redis 세션 반환 |
3-2. 바꿔치기: SessionRepositoryRequestWrapper
그렇다면 포장된 wrappedRequest는 무슨 일을 할까? 이 클래스는 HttpServletRequestWrapper를 상속받아 구현되어 있으며, 가장 중요한 getSession() 메서드를 오버라이딩(Override) 하고 있다.
private final class SessionRepositoryRequestWrapper extends HttpServletRequestWrapper {
@Override
public HttpSessionWrapper getSession(boolean create) {
// 1. 이미 현재 요청에서 세션을 가져온 적 있으면 캐시된 것 반환
HttpSessionWrapper currentSession = getCurrentSession();
if (currentSession != null) {
return currentSession;
}
// 2. SessionRepository에서 세션 조회 (Redis에서 찾는다!)
S requestedSession = getRequestedSession();
if (requestedSession != null) {
if (getAttribute(INVALID_SESSION_ID_ATTR) == null) {
requestedSession. setLastAccessedTime(Instant.now());
this.requestedSessionIdValid = true;
currentSession = new HttpSessionWrapper(requestedSession, getServletContext());
currentSession.markNotNew();
setCurrentSession(currentSession);
return currentSession;
}
}
// 3. 세션이 없고, create=false면 null 반환
if (!create) {
return null;
}
// 4. 새로운 세션 생성도 SessionRepository를 통해 (Redis에 생성!)
S session = SessionRepositoryFilter.this.sessionRepository. createSession();
session.setLastAccessedTime(Instant.now());
currentSession = new HttpSessionWrapper(session, getServletContext());
setCurrentSession(currentSession);
return currentSession;
}
@Override
public HttpSessionWrapper getSession() {
return getSession(true);
}
}
즉, 우리가 컨트롤러에서 무심코 호출했던 request.getSession()은 사실 Redis와 통신하는 래퍼 객체의 메서드였던 것이다.
3-3. 저장소: RedisIndexedSessionRepository
실제로 Redis와 대화하는 구현체는 RedisIndexedSessionRepository다. 세션 데이터를 MapSession 객체로 변환하고, 이를 Redis Hash 자료구조로 저장한다.
@Override
public RedisSession findById(String id) {
return getSession(id, false);
}
private RedisSession getSession(String id, boolean allowExpired) {
// 1. Redis Hash에서 세션 데이터 조회
Map<String, Object> entries = getSessionBoundHashOperations(id).entries();
if ((entries == null) || entries.isEmpty()) {
return null;
}
// 2. Map → MapSession 객체로 변환
MapSession loaded = this.redisSessionMapper.apply(id, entries);
if (loaded == null || (! allowExpired && loaded.isExpired())) {
return null;
}
// 3. RedisSession으로 감싸서 반환
RedisSession result = new RedisSession(loaded, false);
result.originalLastAccessTime = loaded.getLastAccessedTime();
return result;
}
Redis에 저장되는 데이터 구조
HGETALL spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe
┌────────────────────────────┬─────────────────────────────┐
│ Field │ Value │
├────────────────────────────┼─────────────────────────────┤
│ creationTime │ 1404360000000 │
│ maxInactiveInterval │ 1800 │
│ lastAccessedTime │ 1404360000000 │
│ sessionAttr: username │ "Sang Hyeok" │
│ sessionAttr:loginTime │ "2025-12-26T10:30:00" │
└────────────────────────────┴─────────────────────────────┘
3-4. 응답 완료 시 세션 저장 (Commit Session)
요청 처리가 끝나면 finally 블록에서 commitSession()이 호출되어 세션을 Redis에 저장한다.
private void commitSession() {
HttpSessionWrapper wrappedSession = getCurrentSession();
if (wrappedSession == null) {
if (isInvalidateClientSession()) {
SessionRepositoryFilter.this.httpSessionIdResolver
.expireSession(this, this.response);
}
}
else {
S session = wrappedSession.getSession();
// SessionRepository. save() 호출 → Redis에 저장!
SessionRepositoryFilter.this.sessionRepository.save(session);
String sessionId = session.getId();
if (! isRequestedSessionIdValid() || !sessionId.equals(requestedSessionId)) {
SessionRepositoryFilter.this.httpSessionIdResolver
.setSessionId(this, this.response, sessionId);
}
}
}
3-5. 최적화: Delta 저장 방식
RedisSession은 변경된 속성만 추적하여 저장한다. 매번 모든 세션 데이터를 덮어쓰면 네트워크 낭비가 심하기 때문이다.
public final class RedisSession implements Session {
private Map<String, Object> delta = new HashMap<>();
@Override
public void setAttribute(String attributeName, Object attributeValue) {
this.cached.setAttribute(attributeName, attributeValue);
// 변경사항을 delta에 기록
this.delta.put(getSessionAttrNameKey(attributeName), attributeValue);
}
private void saveDelta() {
if (this.delta.isEmpty()) {
return; // 변경된 게 없으면 저장 안 함 (효율성)
}
// HashOperations.putAll → Redis HSET (partial hash update)
getSessionBoundHashOperations(sessionId).putAll(this.delta);
// delta 초기화
this. delta = new HashMap<>(this.delta. size());
}
}
Tip: 왜 만료 시간이 복잡하게 설정될까?
Redis를 조회해보면 세션 키 외에도
expires,shadow key등 복잡한 키들이 보인다. 이는 Redis의 Key Expiration 이벤트가 정확한 시점에 발생하지 않을 수 있다는 한계를 극복하고, 세션 만료 시점에SessionDestroyedEvent를 확실하게 발행하기 위한 Spring Session만의 트릭이다. (실제 데이터는 만료 시간보다 5분 더 오래 살아있다.)
핵심 정리
| 단계 | 역할 |
|---|---|
| 1단계 | SessionRepositoryFilter가 요청을 가로채고 Request Wrapping |
| 2단계 | SessionRepositoryRequestWrapper가 getSession() 오버라이딩 |
| 3단계 | SessionRepository에서 세션 저장소 추상화 |
| 4단계 | RedisIndexedSessionRepository에서 Redis 구현체 동작 |
| 5단계 | HttpSessionAdapter에서 Session을 HttpSession으로 변환 |
| 6단계 | RedisSession에서 Delta 추적 후 Redis 저장 |
4. 결론: PSA (Portable Service Abstraction)
우리가 흔히 쓰는 HttpSession은 사실 클래스가 아니라 인터페이스다.
Spring은 이 점을 이용해 완벽한 PSA를 구현했다:
- Filter로 요청을 가로채고,
- Wrapper로 구현체를 몰래 바꿔치기하여,
- 개발자가 모르는 사이에 Session Store를 교체했다.
HttpSession 인터페이스는 그대로, 구현체만 Tomcat에서 Redis로 교체.
아무것도 수정하지 않아도 된다.
덕분에 비즈니스 로직을 단 한 줄도 수정하지 않고, 인프라를 로컬 메모리에서 Redis, JDBC, MongoDB 등으로 자유롭게 변경할 수 있다.
"설정은 별거 없어보이지만, 까보면 생각보다 복잡하게 돌아가고 있었다."
참고 자료
'Dev' 카테고리의 다른 글
| 2025년 회고: 정면으로 마주하기 (0) | 2025.12.30 |
|---|---|
| [Spring] Filter의 요청 가로채기 (0) | 2025.12.28 |
| 토스 러너스 하이 2기 (1) | 2025.12.15 |
| 뒤엉킨 레거시 청산기: 빠른 개발과 유지보수, 그 사이 (0) | 2025.11.25 |
| 윈도우와 리눅스에서 중복 폴더 일괄 제거 방법 (0) | 2024.07.15 |