RedisSession 실제 구현
페이지 정보
- 조회 7
- 작성일 2026.03.06 09:54
1. 서론: 왜 Filter에서 세션을 직접 다뤄야 할까요?
웹 서비스를 운영하다 보면 트래픽이 늘어나면서 자연스럽게 서버(Tomcat 등)를 여러 대로 증설하게 됩니다.
이때 가장 먼저 마주하는 난관이 바로 '세션 불일치' 문제입니다.
예를 들어, 사용자가 1번 서버에서 로그인을 성공했는데 로드밸런서가 다음 요청을 2번 서버로 보내버리면 어떻게 될까요?
2번 서버에는 해당 사용자의 세션 정보가 없기 때문에 순식간에 로그아웃 처리가 되어버립니다.
실제로 쇼핑몰 결제 직전에 서버가 바뀌면서 장바구니가 모두 날아가는 등 치명적인 사용자 경험 저하로 이어질 수 있죠.
이러한 문제를 해결하기 위해 Redis 세션 클러스터링(Session Clustering)을 도입하게 됩니다.
모든 서버가 하나의 Redis 저장소를 바라보게 하여 세션을 공유하는 방식입니다.
최근에는 Spring Session 같은 훌륭한 라이브러리가 이 과정을 대신해주지만, 레거시 환경이나 아키텍처의 핵심 원리를 제대로 이해하기 위해서는
서블릿 필터(Filter) 단에서 세션 객체를 직접 제어해보는 경험이 무척 중요합니다.
이 글에서는 요청을 가로채어 우리가 만든 커스텀 세션(RedisSession)을 적용하는 전체 과정을 4개의 핵심 클래스 구현과 함께
차근차근 논리적으로 살펴보겠습니다.
2. 본론: RedisSession 핵심 클래스 4가지 구현하기
1) RedisSession: 세션 데이터의 실체
가장 먼저 만들 클래스는 실제 세션 데이터를 담아둘 RedisSession입니다.
이 객체는 기존 톰캣이 제공하는 세션과 완벽하게 동일한 역할을 수행해야 하므로,
반드시 HttpSession 인터페이스를 implements 하여 모든 메서드를 구현해야 합니다.
여기서 가장 중요한 것은 세션의 상태를 나타내는 3가지 플래그(Flag) 값입니다.
이 원리를 이해하셔야 불필요한 네트워크 I/O를 줄일 수 있습니다.
-
changed(또는dirty): 세션 내의 데이터가 하나라도 변경되었는지를 나타냅니다.
이 값이true일 때만 Redis에 데이터를 덮어써서 불필요한 저장을 막습니다. -
valid: 세션이 현재 유효한지(로그아웃되거나 만료되지 않았는지)를 판단합니다. -
isNew: 이번 요청에서 새롭게 생성된 세션인지를 확인합니다.
package mvc.controller.redis;
import java.io.Serializable;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpSession;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class RedisSession implements HttpSession, Serializable {
private static final long serialVersionUID = -7024556060254149497L;
@JsonIgnore
@Getter
@Setter
private volatile boolean changed = false;
@JsonIgnore
@Getter
@Setter
private volatile boolean valid = true;
@JsonIgnore
private volatile boolean newSession;
// 직렬화 대상
@Setter(AccessLevel.NONE)
private String id;
@Setter(AccessLevel.NONE)
private long creationTime;
@Setter
private long lastAccessedTime;
private int maxInactiveInterval = 1800;
@Getter
@Setter
private volatile Map<String, Object> attributes = new ConcurrentHashMap<>();
@JsonIgnore
private transient ServletContext servletContext;
public RedisSession(final String id) {
if (id == null || id.isEmpty()) {
throw new IllegalArgumentException("세션 ID가 비어있습니다.");
}
this.id = id;
this.newSession = true;
this.creationTime = System.currentTimeMillis();
this.lastAccessedTime = this.creationTime;
this.changed = true;
}
private void checkValid() {
if (!this.valid) {
throw new IllegalStateException("세션이 이미 무효화되었습니다.");
}
}
/* ---------- HttpSession ---------- */
@Override
public String getId() {
checkValid();
return this.id;
}
@Override
public long getCreationTime() {
checkValid();
return this.creationTime;
}
@Override
public long getLastAccessedTime() {
checkValid();
return this.lastAccessedTime;
}
@Override
public ServletContext getServletContext() {
checkValid();
return this.servletContext;
}
@Override
public void setMaxInactiveInterval(final int interval) {
checkValid();
this.maxInactiveInterval = interval;
this.changed = true;
}
@Override
public int getMaxInactiveInterval() {
checkValid();
return this.maxInactiveInterval;
}
@Override
public Object getAttribute(String name) {
checkValid();
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("속성명이 비어있습니다.");
}
return this.attributes.get(name);
}
@Override
public void setAttribute(String name, Object value) {
checkValid();
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("속성명이 비어있습니다.");
}
if (value == null) {
removeAttribute(name);
return;
}
this.attributes.put(name, value);
this.changed = true;
}
@JsonIgnore
@Override
public Enumeration<String> getAttributeNames() {
checkValid();
return Collections.enumeration(this.attributes.keySet());
}
@Override
public void removeAttribute(final String name) {
checkValid();
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("속성명이 비어있습니다.");
}
if (this.attributes.remove(name) != null) {
this.changed = true;
}
}
@Override
public void invalidate() {
checkValid();
this.valid = false;
this.attributes.clear();
this.changed = true;
}
@Override
public boolean isNew() {
checkValid();
return this.newSession;
}
public void refreshLastAccessedTime() {
checkValid();
this.lastAccessedTime = System.currentTimeMillis();
this.changed = true;
}
public void setNew(final boolean newSession) {
this.newSession = newSession;
}
public void setServletContext(final ServletContext servletContext) {
this.servletContext = servletContext;
}
}
2) RedisSessionManager: 세션의 생애주기 관리자
세션 객체를 만들었다면, 이 객체를 실제 Redis 서버에 넣고 빼는 역할을 할 '본체'가 필요합니다.
RedisSessionManager는 세션의 생성, 저장, 로드, 삭제를 전담합니다.
매번 객체를 생성하여 자원을 낭비하지 않도록 싱글톤(Singleton) 패턴으로 구현하는 것이 일반적입니다.
package mvc.controller.redis;
import common.util.xml.ConfigXml;
import common.util.xml.XmlTreeNode;
import lombok.extern.log4j.Log4j2;
import mvc.model.DataMapper;
import redis.clients.jedis.DefaultJedisClientConfig;
import redis.clients.jedis.RedisClient;
@Log4j2
public class RedisSessionManager {
private volatile RedisClient client;
private static final String KEY_PREFIX = "sess:";
private static final int EXPIRE_SECONDS = 1800;
private static volatile RedisSessionManager instance;
private RedisSessionManager() {
XmlTreeNode node = ConfigXml.getNode("redis");
if(node==null || node.isEmpty()) {
this.client = null;
return;
}
String url = ConfigXml.get("redis.url");
Integer port = ConfigXml.getInt("redis.port");
String username = ConfigXml.get("redis.username");
String password = ConfigXml.get("redis.password");
try {
DefaultJedisClientConfig config = DefaultJedisClientConfig.builder()
.connectionTimeoutMillis(1000)
.socketTimeoutMillis(2000)
.user(username)
.password(password)
.build();
RedisClient client = RedisClient.builder().hostAndPort(url, port).clientConfig(config).build();
client.ping();
this.client = client;
log.info("RedisSessionManager initialized successfully.");
} catch (Exception e) {
throw e;
}
}
public static synchronized RedisSessionManager getInstance() throws Exception {
if (instance == null) {
try {
instance = new RedisSessionManager();
} catch (Exception e) {
throw e;
}
}
return instance;
}
public void saveSession(RedisSession session) {
try {
String key = KEY_PREFIX + session.getId();
String jsonValue = DataMapper.MAPPER.writeValueAsString(session);
client.setex(key, EXPIRE_SECONDS, jsonValue);
session.setChanged(false);
} catch (Exception e) {
log.error("[RedisSession] Save saveSession Error: {}", e.getMessage(), e);
throw e;
}
}
public RedisSession loadSession(String sessionId) {
try {
String key = KEY_PREFIX + sessionId;
RedisSession session = null;
String json = client.get(key);
if(json!=null) {
session = DataMapper.MAPPER.readValue(json, RedisSession.class);
}
return session;
} catch (Exception e) {
log.error("[RedisSession] Save loadSession Error: {}", e.getMessage(), e);
throw e;
}
}
public void deleteSession(String sessionId) {
String key = KEY_PREFIX + sessionId;
client.del(key);
}
public void updateTTL(String sessionId) {
String key = KEY_PREFIX + sessionId;
client.expire(key, EXPIRE_SECONDS);
}
public void close() {
if (client != null) {
client.close();
}
}
}3) RedisSessionRequest: 원본 Request를 감싸는 래퍼(Wrapper)
이제 기존 로직(컨트롤러나 JSP)에서 request.getSession()을 호출했을 때, 톰캣의 기본 세션이 아닌
우리가 만든 RedisSession을 반환하도록 속이는 작업이 필요합니다.
이를 위해 HttpServletRequestWrapper를 상속받아 getSession() 메서드를 재정의(Override)합니다.
package mvc.controller.redis;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpSession;
import lombok.Getter;
public class RedisSessionRequest extends HttpServletRequestWrapper {
@Getter
private final HttpSession session;
private final boolean hasSessionIdCookie;
public RedisSessionRequestWrapper(HttpServletRequest request, HttpSession session, final boolean hasSessionIdCookie) {
super(request);
this.session = session;
this.hasSessionIdCookie = hasSessionIdCookie;
}
@Override
public HttpSession getSession(boolean create) {
if (!create && !this.hasSessionIdCookie) {
return null;
}
return this.session;
}
}4) RedisSessionFilter: 모든 요청을 오케스트레이션하다
드디어 대망의 필터입니다. 이곳은 앞서 만든 3개의 클래스를 조립하여 클라이언트의 요청이 들어올 때 세션을 준비하고,
응답이 나갈 때 세션을 처리하는 이벤트 구조의 중심입니다.
package mvc.controller.redis;
import java.io.IOException;
import java.util.UUID;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;
@Log4j2
public class RedisSessionFilter implements Filter {
private volatile RedisSessionManager sessionManager = null;
private static final String COOKIE_NAME = "RSESSIONID";
@Override
public void init(FilterConfig filterConfig) {
try {
sessionManager = RedisSessionManager.getInstance();
log.info("RedisSessionFilter initialized: REDIS_SESSION");
} catch (Exception e) {
sessionManager = null;
}
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
if (this.sessionManager == null) {
synchronized (this) {
if (this.sessionManager == null) {
chain.doFilter(request, response);
return;
}
}
}
if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
chain.doFilter(request, response);
return;
}
final HttpServletRequest httpRequest = (HttpServletRequest) request;
final HttpServletResponse httpResponse = (HttpServletResponse) response;
String sessionId = null;
final Cookie[] cookies = httpRequest.getCookies();
if (cookies != null) {
for (final Cookie c : cookies) {
if (COOKIE_NAME.equals(c.getName())) {
sessionId = c.getValue();
break;
}
}
}
boolean issueCookie = false;
String cookieHeader = null;
if (sessionId == null || sessionId.isEmpty()) {
sessionId = UUID.randomUUID().toString();
issueCookie = true;
boolean secure = httpRequest.isSecure();
if (!secure) {
final String xfProto = httpRequest.getHeader("X-Forwarded-Proto");
if (xfProto != null && xfProto.equalsIgnoreCase("https")) {
secure = true;
}
}
final StringBuilder sb = new StringBuilder(160);
sb.append(COOKIE_NAME).append("=").append(sessionId);
sb.append("; Path=/");
sb.append("; HttpOnly");
if (secure) {
sb.append("; Secure");
}
sb.append("; SameSite=Lax");
cookieHeader = sb.toString();
httpResponse.addHeader("Set-Cookie", cookieHeader);
}
RedisSession redisSession = null;
final RedisSessionManager manager = sessionManager;
try {
redisSession = manager.loadSession(sessionId);
} catch (Exception e) {
this.sessionManager = null;
chain.doFilter(request, response);
return;
}
if (redisSession == null) {
redisSession = new RedisSession(sessionId);
} else {
redisSession.setNew(false);
}
redisSession.setServletContext(request.getServletContext());
boolean isRedis = false;
HttpServletRequestWrapper wrapRequest = null;
try {
wrapRequest = new RedisSessionRequest(httpRequest, redisSession, !issueCookie);
isRedis = true;
} catch(Exception e) {
wrapRequest = new HttpServletRequestWrapper(httpRequest);
}
try {
chain.doFilter(wrapRequest, response);
} finally {
if (isRedis) {
try {
if (redisSession != null && redisSession.isValid()) {
if (redisSession.isNew() || redisSession.isChanged()) {
redisSession.refreshLastAccessedTime();
manager.saveSession(redisSession);
} else {
manager.updateTTL(sessionId);
}
} else {
if (sessionId != null && !sessionId.isEmpty()) {
manager.deleteSession(sessionId);
}
}
} catch (Exception ignore) {
this.sessionManager = null;
}
}
}
}
@Override
public void destroy() {
}
}3. 결론
이렇게 4개의 클래스를 직접 구현해보면 웹 애플리케이션이 클라이언트의 상태를 어떻게 유지하는지,
밑바닥 원리를 완벽하게 통제하고 이해할 수 있게 됩니다.
특히 필터의 try-finally 블록을 통해 예외 발생 시에도 세션 유실을 방어하고, Redis 장애 시 chain.doFilter()로 우회하여
서비스 중단을 막는 구조는 실무에서 고가용성(High Availability) 아키텍처를 설계할 때 매우 중요한 통찰을 제공합니다.
단순히 라이브러리를 가져다 쓰는 것을 넘어, 이러한 동작 원리를 이해하고 있다면 어떠한 프레임워크 환경에서도
유연하게 대처할 수 있는 탄탄한 기본기가 될 것입니다.
- 다음글자바 람다(Lambda) 기초 26.03.04
댓글목록
등록된 댓글이 없습니다.