이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 열한 번째 글입니다. 10편에서는 WAR 하나가 StandardContext가 되어 서비스되기까지를 봤습니다. 이번에는 그 웹앱 안에서 사용자를 기억하는 방법, 곧 세션을 봅니다. 서블릿에서 request.getSession()을 부르면 응답에 Set-Cookie: JSESSIONID=…가 붙고, 다음 요청부터는 같은 세션 객체가 돌아옵니다. 그 사이에 무슨 일이 있는지 따라갑니다.
답하려는 질문은 네 가지입니다.
- 요청이 들고 온 세션 ID는 어디서 읽고, 세션 객체는 언제 만들어지는가
JSESSIONID의 32글자는 어떻게 만들고, 쿠키의Path와HttpOnly는 누가 정하는가- 세션이 만료되는 것은 정확히 언제이고, 어느 스레드가 지우는가
- Tomcat을 껐다 켜면 세션은 남는가
| 항목 | 내용 |
|---|---|
| 대상 | Apache Tomcat |
| 버전 | 12.0.0-M1 (main) |
| 커밋 | 17aa1769b0 (2026-10-23) |
| 주로 읽는 클래스 | connector/Request, connector/CoyoteAdapter, session/ManagerBase, session/StandardManager, session/StandardSession, util/StandardSessionIdGenerator |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 안정판 11.0.x 브랜치와 비교해 보니 session 패키지의 ManagerBase·StandardManager·StandardSession, ID 생성기, 쿠키 설정 클래스는 파일이 똑같았고, Request·CoyoteAdapter·Response도 세션을 다루는 부분은 같았습니다. 이 글의 내용은 11.0.x에도 그대로 들어맞습니다.
session 패키지만 Java 파일 열두 개, 5,800줄이 넘습니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 클러스터 복제처럼 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
세션(session)은 HTTP 요청 여러 개를 "같은 사용자의 것"으로 묶어 주는 서버 쪽 저장소입니다. HTTP는 요청 하나하나가 따로 놀기 때문에, 서버는 응답에 세션 ID라는 표를 붙여 보내고 브라우저가 다음 요청에 그 표를 다시 들고 오게 합니다. 서버는 ID로 저장소를 찾아 "아까 그 사람"임을 압니다. 장바구니나 로그인 상태가 여기에 담깁니다.
JSESSIONID 는 서블릿 스펙이 정한 세션 쿠키의 기본 이름입니다. 쿠키를 쓸 수 없을 때는 URL 끝에 ;jsessionid=…를 붙여 ID를 실어 나르는데, 이것을 URL 재작성(URL rewriting)이라고 합니다. URL의 ;이름=값 부분을 경로 매개변수라고 하며, Tomcat이 URI를 디코딩하면서 이것을 떼어 두는 과정은 7편에서 봤습니다.
Manager(세션 관리자)는 웹앱 하나의 세션을 모두 맡는 Tomcat 부품입니다. server.xml이나 context.xml의 <Context> 안에 <Manager>로 적는 바로 그것입니다. 아무것도 적지 않으면 StandardManager가 쓰입니다. 웹앱(Context)마다 하나씩 있으므로 /app1의 세션과 /app2의 세션은 서로 모릅니다. Context가 무엇인지는 2편에서 봤습니다.
maxInactiveInterval 은 "마지막 요청 뒤 몇 초 동안 아무 요청이 없으면 세션을 버릴까"입니다. web.xml의 <session-timeout>(분 단위)이 이 값이 됩니다. Tomcat의 conf/web.xml에는 30분으로 적혀 있습니다(conf/web.xml 613–615행).
직렬화(serialization)는 자바 객체를 바이트로 바꿔 파일이나 네트워크로 보낼 수 있게 하는 것입니다. java.io.Serializable을 구현한 객체만 기본 방식으로 직렬화할 수 있습니다. 세션을 디스크에 저장할 때 쓰입니다.
백그라운드 처리(background process)는 Tomcat의 컨테이너들이 주기적으로 하는 뒷정리입니다. 클래스 변경 감지(4편의 리로드), 자동 배포(10편), 그리고 이 글의 세션 만료 검사가 모두 이 시간에 돕니다.
1. 큰 그림: 웹앱마다 Manager 하나, 그 안에 맵 하나
세션에 관여하는 클래스를 한 장에 모으면 이렇습니다.
노란 상자가 실제로 무언가를 담는 곳입니다. 세션 저장소의 본체는 메모리의 맵 하나입니다.
<Manager>를 따로 적지 않은 웹앱은 시작할 때 StandardContext.startInternal()이 StandardManager를 하나 만들어 붙입니다(core/StandardContext.java 4542–4567행, 4557행이 new StandardManager()). 클러스터가 있고 웹앱이 <distributable/>이면 클러스터용 Manager를 대신 만드는데, 이 글에서는 다루지 않습니다.
세션을 담는 곳은 ManagerBase의 필드 하나입니다.
protected Map<String,Session> sessions = new ConcurrentHashMap<>();
java/org/apache/catalina/session/ManagerBase.java 147행
세션 ID를 열쇠로, StandardSession 객체를 값으로 담는 맵입니다. 여러 요청 스레드가 동시에 세션을 만들고 찾으므로 ConcurrentHashMap입니다. 찾기는 이 맵에서 꺼내는 것이 전부입니다.
@Override
public Session findSession(String id) throws IOException {
if (id == null) {
return null;
}
return sessions.get(id);
}
java/org/apache/catalina/session/ManagerBase.java 752–758행
findSession()은 만료 여부를 보지 않고 맵에 있으면 돌려줍니다. 만료 검사는 부르는 쪽이 isValid()로 따로 합니다. 4절에서 이것이 중요해집니다.
서블릿이 받는 것은 StandardSession이 아닙니다
StandardSession은 서블릿 API의 HttpSession과 Tomcat 내부용 Session 인터페이스를 둘 다 구현합니다(StandardSession.java 68행). 내부용 쪽에는 setId(), expire(), setManager()처럼 웹앱이 만지면 안 되는 메서드가 있습니다. 그래서 서블릿에게는 HttpSession만 구현한 껍데기 StandardSessionFacade를 줍니다(StandardSession.java 467–473행, StandardSessionFacade.java 27행). 7편에서 본 RequestFacade와 같은 이유입니다. 예제 1의 출력에서 class : org.apache.catalina.session.StandardSessionFacade로 확인합니다.
2. 요청이 세션 ID를 들고 오는 길
CoyoteAdapter가 ID를 미리 꺼내 둔다
세션 ID 읽기는 서블릿보다 훨씬 앞, 7편에서 본 CoyoteAdapter.postParseRequest()에서 일어납니다. Mapper가 이 요청의 웹앱(Context)을 정한 직후입니다. 웹앱마다 쿠키 이름이나 추적 방식을 다르게 설정할 수 있으므로, 웹앱이 정해져야 ID를 읽을 수 있습니다.
// Now we have the context, we can parse the session ID from the URL
// (if any). Need to do this before we redirect in case we need to
// include the session id in the redirect
String sessionID;
if (request.getServletContext().getEffectiveSessionTrackingModes().contains(SessionTrackingMode.URL)) {
// Get the session ID if there was one
sessionID = request.getPathParameter(SessionConfig.getSessionUriParamName(request.getContext()));
if (sessionID != null) {
request.setRequestedSessionId(sessionID);
request.setRequestedSessionURL(true);
}
}
// Look for session ID in cookies and SSL session
try {
parseSessionCookiesId(request);
} catch (IllegalArgumentException e) {
// Too many cookies
if (!response.isError()) {
response.setError();
response.sendError(400, e.getMessage());
}
return true;
}
parseSessionSslId(request);
java/org/apache/catalina/connector/CoyoteAdapter.java 716–741행
순서가 중요합니다. 먼저 URL의 ;jsessionid=를 보고, 그다음 쿠키를 봅니다. parseSessionCookiesId() 안에는 "Override anything requested in the URL"이라는 주석과 함께 쿠키 값으로 덮어쓰는 코드가 있습니다(1027–1033행). 둘 다 있으면 쿠키가 이깁니다(웹앱에 쿠키 추적이 켜져 있을 때입니다. 꺼져 있으면 쿠키는 아예 읽지 않습니다, 1010–1013행). 같은 이름의 쿠키가 여럿이면 첫 번째를 받되, 그것이 유효하지 않으면 다음 것으로 바꿔 봅니다(1037–1043행).
이 단계에서 하는 일은 문자열을 requestedSessionId 필드에 적어 두는 것뿐입니다. 그 ID의 세션이 실제로 있는지는 아직 보지 않습니다. 쿠키·매개변수 이름은 SessionConfig가 정합니다. <Context sessionCookieName="…"> → web.xml의 <cookie-config><name> → 기본값 순서이고, 기본값은 이 두 상수입니다.
private static final String DEFAULT_SESSION_COOKIE_NAME = "JSESSIONID";
private static final String DEFAULT_SESSION_PARAMETER_NAME = "jsessionid";
java/org/apache/catalina/util/SessionConfig.java 28–29행
어떤 추적 방식이 켜져 있는지는 ApplicationContext가 정합니다. URL 재작성은 항상, 쿠키는 <Context cookies="true">(기본값)일 때 켜집니다(core/ApplicationContext.java 888–896행). web.xml의 <tracking-mode>로 이 집합을 바꿀 수 있습니다.
Request.doGetSession() — 찾거나, 만들거나
서블릿이 request.getSession()을 부르면 connector/Request의 getSession(true)를 거쳐 doGetSession(true)에 닿습니다(2136–2149행). 세션의 모든 길이 이 함수 하나로 모입니다.
노란 상자는 이미 손에 든 값입니다. getSession()이 맵을 뒤지는 것은 그 요청에서 처음 부를 때뿐입니다.
핵심 부분을 옮깁니다.
if (requestedSessionId != null) {
try {
session = manager.findSession(requestedSessionId);
} catch (IOException ioe) {
if (log.isDebugEnabled()) {
log.debug(sm.getString("request.session.failed", requestedSessionId, ioe.getMessage()), ioe);
} else {
log.info(sm.getString("request.session.failed", requestedSessionId, ioe.getMessage()));
}
session = null;
}
if ((session != null) && !session.isValid()) {
session = null;
}
if (session != null) {
session.access();
// The client has chosen to join the session
session.setNew(false);
return session;
}
}
// Create a new session if requested and the response is not committed
if (!create) {
return null;
}
// ... 응답이 이미 나갔으면 예외, 클라이언트가 보낸 ID 를 다시 쓸지 정하기 ...
session = manager.createSession(sessionId);
// Creating a new session cookie based on that session
if (session != null && trackModesIncludesCookie) {
Cookie cookie =
ApplicationSessionCookieConfig.createSessionCookie(context, session.getIdInternal(), isSecure());
response.addSessionCookieInternal(cookie);
}
java/org/apache/catalina/connector/Request.java 2692–2770행
- 이 요청에서 이미 세션을 꺼냈으면(
session필드) 그것을 돌려줍니다(2679–2685행). 한 요청에서getSession()을 여러 번 불러도 맵은 한 번만 뒤집니다. requestedSessionId가 있으면 맵에서 찾고,isValid()로 만료를 확인합니다. 살아 있으면access()로 "지금 쓰는 중"이라고 표시하고 돌려줍니다.- 없거나 만료됐는데
create가false면null입니다.getSession(false)는 여기서 끝납니다. create가true면manager.createSession()으로 새로 만들고, 쿠키 추적이 켜져 있으면 응답에Set-Cookie를 붙입니다.
세션은 누군가 getSession()을 불러야 생깁니다. JSP는 기본으로 세션을 만들지만(<%@ page session="true" %>가 기본값이라는 것은 JSP 스펙의 내용이고, Jasper 소스로는 13편에서 봅니다), 서블릿만 있는 웹앱은 부르지 않는 한 세션도 쿠키도 없습니다.
클라이언트가 보낸 ID를 그대로 쓰는 경우는 거의 없습니다
줄인 자리(2724–2761행)는 "새 세션을 만들 때 클라이언트가 보낸 ID를 그대로 쓸까"를 정합니다. 결론은 대부분 쓰지 않는다(sessionId = null → 새 ID)입니다. 다시 쓰는 경우는 둘뿐입니다.
- ID가 TLS 세션에서 왔을 때(
requestedSessionSSL) <Context sessionCookiePath="/">처럼Context에 쿠키 경로를/로 직접 적었고 ID가 쿠키에서 왔을 때. 같은 호스트의 여러 웹앱이 같은 ID를 나눠 쓰는 경우를 위한 것입니다. 이때도validateClientProvidedNewSessionId(기본값true,StandardContext.java785행)가 켜져 있으면 같은 호스트의 웹앱 가운데 어딘가에 그 ID의 세션이 실제로 있을 때만 씁니다.
이렇게 막는 이유는 세션 고정 공격(session fixation) 때문입니다. 공격자가 JSESSIONID=ABC123을 피해자의 브라우저에 심어 두고, 피해자가 그 ID로 로그인하게 만든 뒤 같은 ID로 들어오는 공격입니다. 서버가 아무 ID나 받아 주면 이것이 가능해집니다. 예제 1의 5번에서 확인합니다.
예제 1: 세션 ID와 쿠키
이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. 서블릿 하나가 요청이 들고 온 ID와 세션의 상태를 찍습니다(ex/u11-session-id).
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// ... package, import 생략 ...
@WebServlet("/s")
public class SessionServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain");
PrintWriter out = resp.getWriter();
// 요청이 들고 온 세션 ID — 쿠키나 URL 의 ;jsessionid= 에서 CoyoteAdapter 가 미리 뽑아 둔 값
out.println("requested id : " + req.getRequestedSessionId()
+ " (cookie=" + req.isRequestedSessionIdFromCookie()
+ ", url=" + req.isRequestedSessionIdFromURL()
+ ", valid=" + req.isRequestedSessionIdValid() + ")");
// ?create=0 이면 getSession(false) — 없으면 만들지 않는다
boolean create = !"0".equals(req.getParameter("create"));
HttpSession session = req.getSession(create);
if (session == null) {
out.println("session : null");
return;
}
// ... 세션의 count 속성을 하나 올리고, id·isNew·maxInactive·클래스 이름을 찍는다 ...
// URL 재작성 — 쿠키로 받은 ID 가 아니면 ;jsessionid= 를 붙여 준다
out.println("encodeURL : " + resp.encodeURL("/u11/s"));
}
}
[예제 ex/u11-session-id/src/ex/u11/SessionServlet.java에서 줄임]
curl로 다섯 번 요청했습니다. 상태 줄과 Set-Cookie 헤더, 본문만 남겼습니다.
===== 1. GET /u11/s?create=0 (getSession(false)) =====
HTTP/1.1 200
requested id : null (cookie=false, url=false, valid=false)
session : null
===== 2. GET /u11/s (getSession() - first time) =====
HTTP/1.1 200
Set-Cookie: JSESSIONID=E221CAF5F85FF1E04892CC5FFAC94FAF; Path=/u11; HttpOnly
requested id : null (cookie=false, url=false, valid=false)
session id : E221CAF5F85FF1E04892CC5FFAC94FAF (32 chars)
isNew : true
count : 1
maxInactive : 1800 s
class : org.apache.catalina.session.StandardSessionFacade
encodeURL : /u11/s;jsessionid=E221CAF5F85FF1E04892CC5FFAC94FAF
===== 3. GET /u11/s with the cookie =====
HTTP/1.1 200
requested id : E221CAF5F85FF1E04892CC5FFAC94FAF (cookie=true, url=false, valid=true)
session id : E221CAF5F85FF1E04892CC5FFAC94FAF (32 chars)
isNew : false
count : 2
maxInactive : 1800 s
class : org.apache.catalina.session.StandardSessionFacade
encodeURL : /u11/s
===== 4. GET /u11/s;jsessionid=<id> without a cookie =====
HTTP/1.1 200
requested id : E221CAF5F85FF1E04892CC5FFAC94FAF (cookie=false, url=true, valid=true)
session id : E221CAF5F85FF1E04892CC5FFAC94FAF (32 chars)
isNew : false
count : 3
maxInactive : 1800 s
class : org.apache.catalina.session.StandardSessionFacade
encodeURL : /u11/s;jsessionid=E221CAF5F85FF1E04892CC5FFAC94FAF
===== 5. GET /u11/s with a made-up cookie JSESSIONID=ABC123 =====
HTTP/1.1 200
Set-Cookie: JSESSIONID=C74FE9B92C22274296B4370082622AEA; Path=/u11; HttpOnly
requested id : ABC123 (cookie=true, url=false, valid=false)
session id : C74FE9B92C22274296B4370082622AEA (32 chars)
isNew : true
count : 1
maxInactive : 1800 s
class : org.apache.catalina.session.StandardSessionFacade
encodeURL : /u11/s
세션 ID 값은 실행마다 다릅니다.
getSession(false)만 부르면 세션도Set-Cookie도 생기지 않습니다.- 처음
getSession()을 부르자 32글자 ID와Set-Cookie가 생깁니다.Path=/u11은 웹앱의 경로,HttpOnly는 자바스크립트가 이 쿠키를 읽지 못하게 하는 표시입니다(3절).maxInactive는conf/web.xml의 30분 = 1800초입니다. - 쿠키를 돌려보내면 같은 세션에 합류하고
isNew가false가 됩니다.Set-Cookie는 다시 오지 않습니다. - 쿠키 없이 URL에
;jsessionid=만 붙여도 같은 세션입니다(url=true).count가 3으로 이어집니다. - 지어낸 ID
ABC123을 보내면valid=false이고, 그 ID를 쓰지 않고 새 ID로 세션을 만듭니다. 앞에서 본 세션 고정 방어입니다.
헷갈리기 쉬운 곳
- 2번의
encodeURL에;jsessionid=가 붙었습니다. 쿠키를 보냈는데도요.Response.isEncodeable()은 세션이 있으면 "요청이 들고 온 ID가 쿠키에서 왔나"를 보고, 이번 응답에Set-Cookie를 붙였는지는 보지 않습니다(connector/Response.java1378–1384행). 첫 응답 시점에는 브라우저가 쿠키를 받아 줄지 서버가 알 수 없으므로, 쿠키와 URL 양쪽에 ID를 실어 보내는 것입니다. 3번처럼 쿠키가 돌아온 뒤에는 붙이지 않습니다. 첫 페이지의 링크에만;jsessionid=가 보이는 현상이 이것입니다. - 5번에서
encodeURL이 그대로입니다. 보낸 ID가 틀렸어도 "쿠키에서 왔다"는 사실은 맞으므로 위와 같은 규칙으로 붙이지 않습니다. - 응답을 이미 보내기 시작한 뒤에는 세션을 만들 수 없습니다. 쿠키 추적이 켜져 있는데 응답이 커밋(헤더가 이미 나감)됐으면
IllegalStateException입니다(2718–2722행).Set-Cookie를 붙일 자리가 없기 때문입니다. 큰 응답을 쓰다가 중간에getSession()을 부르는 코드에서 납니다.
3. 세션 ID와 쿠키 만들기
createSession()
ManagerBase.createSession()은 짧습니다.
@Override
public Session createSession(String sessionId) {
if ((maxActiveSessions >= 0) && (getActiveSessions() >= maxActiveSessions)) {
rejectedSessions++;
throw new TooManyActiveSessionsException(sm.getString("managerBase.createSession.ise"), maxActiveSessions);
}
// Recycle or create a Session instance
Session session = createEmptySession();
// Initialize the properties of the new session and return it
session.setNew(true);
session.setValid(true);
session.setCreationTime(System.currentTimeMillis());
session.setMaxInactiveInterval(getContext().getSessionTimeout() * 60);
String id = sessionId;
if (id == null) {
id = generateSessionId();
}
session.setId(id);
SessionTiming timing = new SessionTiming(session.getCreationTime(), 0);
synchronized (sessionCreationTiming) {
sessionCreationTiming.add(timing);
sessionCreationTiming.poll();
}
return session;
}
java/org/apache/catalina/session/ManagerBase.java 715–743행
<Manager maxActiveSessions="…">를 넘으면 예외입니다. 기본값은-1(제한 없음, 159행)입니다.createEmptySession()은getNewSession()을 거쳐new StandardSession(this)입니다(747–749행, 886–888행). 주석은 "Recycle"이라고 하지만 객체를 재사용하지 않고 매번 새로 만듭니다.- 만료 시간은 웹앱의
sessionTimeout(분) × 60초입니다.StandardContext의 기본값도 30분이고(525행), web.xml의<session-timeout>이 있으면ContextConfig가 그 값으로 바꿉니다(startup/ContextConfig.java1600행). - 맵에 넣는 것은
setId()입니다. 따로add()를 부르는 줄이 보이지 않는데,StandardSession.setId()가 옛 ID로 맵에서 빼고, 새 ID로manager.add(this)를 부르고,tellNew()로HttpSessionListener.sessionCreated()를 부릅니다(StandardSession.java282–297행, 303–333행). 그래서sessionCreated()는Set-Cookie를 붙이기 전에, 요청 스레드에서 불립니다. 예제 2의created … on http-nio-8080-exec-…가 그것입니다.
32글자 ID — SecureRandom 16바이트
generateSessionId()는 ID 생성기에 맡깁니다(896–898행). 생성기를 설정하지 않았으면 Manager가 시작할 때 StandardSessionIdGenerator를 만듭니다(659–663행).
@Override
public String generateSessionId(String route) {
byte[] random = new byte[16];
int sessionIdLength = getSessionIdLength();
// Render the result as a String of hexadecimal digits
// Start with enough space for sessionIdLength and medium route size
StringBuilder buffer = new StringBuilder(2 * sessionIdLength + 20);
int resultLenBytes = 0;
while (resultLenBytes < sessionIdLength) {
getRandomBytes(random);
for (int j = 0; j < random.length && resultLenBytes < sessionIdLength; j++) {
byte b1 = (byte) ((random[j] & 0xf0) >> 4);
byte b2 = (byte) (random[j] & 0x0f);
if (b1 < 10) {
buffer.append((char) ('0' + b1));
} else {
buffer.append((char) ('A' + (b1 - 10)));
}
if (b2 < 10) {
buffer.append((char) ('0' + b2));
} else {
buffer.append((char) ('A' + (b2 - 10)));
}
resultLenBytes++;
}
}
if (route != null && !route.isEmpty()) {
buffer.append('.').append(route);
} else {
String jvmRoute = getJvmRoute();
if (jvmRoute != null && !jvmRoute.isEmpty()) {
buffer.append('.').append(jvmRoute);
}
}
return buffer.toString();
}
java/org/apache/catalina/util/StandardSessionIdGenerator.java 32–73행
난수 바이트를 sessionIdLength(기본 16, SessionIdGeneratorBase.java 92행)개 받아, 한 바이트를 4비트씩 나눠 0–9, A–F 두 글자로 씁니다. 16바이트 = 128비트 = 대문자 16진수 32글자입니다. 예제 1의 (32 chars)가 이것입니다.
ID와 쿠키는 예제 1의 실제 출력입니다. 바이트 값은 그 ID를 두 글자씩 끊어 바이트로 되돌린 것입니다. 쿠키의 속성은 이 절 끝의 표에서 봅니다.
끝의 .route는 클러스터용입니다. server.xml의 <Engine jvmRoute="node1">을 적으면 ID가 …AEA.node1이 됩니다(ManagerBase.java 925–928행에서 Engine의 값을 가져옵니다). 앞단의 로드 밸런서가 . 뒤를 읽고 같은 Tomcat으로 보내 줄 수 있게 하는 표시입니다. httpd의 mod_proxy_balancer가 바로 이 일을 합니다(글 끝에서 비교합니다).
난수는 SecureRandom에서 옵니다.
protected void getRandomBytes(byte[] bytes) {
SecureRandom random = randoms.poll();
if (random == null) {
random = createSecureRandom();
}
random.nextBytes(bytes);
randoms.add(random);
}
java/org/apache/catalina/util/SessionIdGeneratorBase.java 227–235행
SecureRandom 하나를 여러 스레드가 함께 쓰면 그 안의 잠금에서 줄을 서게 됩니다. 그래서 randoms라는 큐(ConcurrentLinkedQueue, 73행)를 풀로 씁니다. 하나 꺼내 쓰고 돌려놓고, 비어 있으면 새로 만듭니다. 동시에 세션을 만드는 스레드가 많아지면 SecureRandom도 그만큼 늘어납니다. 알고리즘은 JDK에 SHA1PRNG가 있으면 그것을 씁니다(56–64행). SecureRandom은 "예측할 수 없는" 난수를 만드는 JDK 클래스로, 세션 ID를 맞혀서 남의 세션에 들어가는 것을 막는 것이 이 128비트의 목적입니다.
Manager가 시작할 때 generateSessionId()를 한 번 미리 불러 둡니다(SessionIdGeneratorBase.startInternal(), 307–313행). SecureRandom의 첫 초기화는 운영체제의 엔트로피를 모으느라 오래 걸릴 수 있어서, 첫 사용자의 요청이 그 시간을 떠안지 않게 하려는 것입니다. 만드는 데 100ms를 넘으면 경고를 남깁니다(294–296행). 오래된 리눅스 서버에서 Tomcat 시작 로그에 Creation of SecureRandom instance for session ID generation using [SHA1PRNG] took [...] milliseconds.가 보이는 것이 이 경고입니다.
헷갈리기 쉬운 곳: 중복 검사는 없습니다
createSession()과 generateSessionId() 어디에도 "이 ID가 이미 맵에 있나"를 보는 반복문이 없습니다. 128비트 난수가 겹칠 확률을 무시할 만하다고 보는 설계입니다. 만에 하나 겹치면 setId()의 sessions.put()이 기존 세션을 덮어씁니다.
쿠키의 속성은 누가 정하나
Set-Cookie 한 줄은 ApplicationSessionCookieConfig.createSessionCookie()가 만듭니다.
Cookie cookie = new Cookie(SessionConfig.getSessionCookieName(context), sessionId);
// Just apply the defaults.
cookie.setMaxAge(scc.getMaxAge());
if (context.getSessionCookieDomain() == null) {
// Avoid possible NPE
if (scc.getDomain() != null) {
cookie.setDomain(scc.getDomain());
}
} else {
cookie.setDomain(context.getSessionCookieDomain());
}
// Always set secure if the request is secure
if (scc.isSecure() || secure) {
cookie.setSecure(true);
}
// Always set httpOnly if the context is configured for that
if (scc.isHttpOnly() || context.getUseHttpOnly()) {
cookie.setHttpOnly(true);
}
if (context.getUsePartitioned()) {
cookie.setAttribute(Constants.COOKIE_PARTITIONED_ATTR, "");
}
cookie.setPath(SessionConfig.getSessionCookiePath(context));
java/org/apache/catalina/core/ApplicationSessionCookieConfig.java 208–236행
설정 파일에서 보던 것과 이렇게 이어집니다.
| 쿠키 속성 | 정하는 곳 (먼저 적힌 것이 우선) | 기본 |
|---|---|---|
| 이름 | <Context sessionCookieName> → web.xml <cookie-config><name> |
JSESSIONID |
Max-Age |
web.xml <cookie-config><max-age> |
-1(브라우저를 닫으면 사라지는 쿠키, 그래서 예제 출력에 없음) |
Secure |
web.xml <secure>, 또는 요청이 HTTPS면 항상 |
HTTP에서는 없음 |
HttpOnly |
web.xml <http-only> 또는 <Context useHttpOnly> |
붙음 (useHttpOnly 기본값 true, StandardContext.java 649행) |
Path |
<Context sessionCookiePath> → web.xml <path> → 웹앱 경로 |
/u11 같은 웹앱 경로, ROOT는 / (SessionConfig.java 82–113행) |
HttpOnly가 "또는"이라는 데 주의합니다. web.xml에서 <http-only>false</http-only>로 꺼도 <Context useHttpOnly>가 기본값 true이면 붙습니다. 끄려면 Context 쪽도 꺼야 합니다. SameSite 속성은 여기가 아니라 <CookieProcessor sameSiteCookies="…">가 정하고, 기본값은 unset(붙이지 않음)입니다(tomcat/util/http/CookieProcessorBase.java 56행).
4. 만료 — 세션은 두 군데서 죽는다
isValid()가 만료를 "실행"한다
만료의 판정은 StandardSession.isValid()에 있습니다. 이름은 "유효한가 묻기"인데, 실제로는 시간이 지났으면 그 자리에서 지워 버립니다.
@Override
public boolean isValid() {
if (!this.isValid) {
return false;
}
if (this.expiring) {
return true;
}
if (activityCheck && accessCount.get() > 0) {
return true;
}
if (maxInactiveInterval > 0) {
int timeIdle = (int) (getIdleTimeInternal() / 1000L);
if (timeIdle >= maxInactiveInterval) {
expire(true);
}
}
return this.isValid;
}
java/org/apache/catalina/session/StandardSession.java 476–499행
- 쉰 시간(
getIdleTimeInternal())은 "지금 − 마지막 접근 시각"입니다(410–419행). 마지막 접근 시각은access()(요청이 세션을 꺼낼 때)와endAccess()(요청이 끝날 때)에서 갱신됩니다(522–553행).endAccess()는 요청 객체를 재활용하는Request.recycleSessionInfo()에서 불립니다(Request.java573–587행).isNew가false로 바뀌는 것도 여기입니다(536행). maxInactiveInterval이 0 이하면 시간으로는 만료되지 않습니다.session.setMaxInactiveInterval(-1)이 "영원히"인 이유입니다.activityCheck는 "지금 이 세션을 쓰는 요청이 있으면 만료시키지 않는다"입니다. 기본값은 JVM 옵션org.apache.catalina.STRICT_SERVLET_COMPLIANCE를 따르므로 보통은 꺼져 있고(ManagerBase.java208–210행),<Manager sessionActivityCheck="true">로 직접 켤 수도 있습니다.
그러면 누가 isValid()를 부를까요. 두 군데입니다.
회색 상자가 출발점입니다. 어느 길로 오든 마지막은 expire() 하나입니다.
① 요청이 왔을 때. 2절의 doGetSession()이 findSession() 다음에 isValid()를 부릅니다. 만료된 세션의 ID를 들고 온 요청이 그 세션을 지우는 셈입니다.
② 뒷정리 스레드. 아무도 다시 찾지 않는 세션은 ①로는 영영 지워지지 않습니다. 그래서 주기적으로 모든 세션을 훑습니다. 출발점은 ContainerBase의 백그라운드 작업입니다.
backgroundProcessorFuture = Container.getService(this).getServer().getUtilityExecutor()
.scheduleWithFixedDelay(new ContainerBackgroundProcessor(), backgroundProcessorDelay,
backgroundProcessorDelay, TimeUnit.SECONDS);
java/org/apache/catalina/core/ContainerBase.java 1090–1092행
backgroundProcessorDelay가 0보다 큰 컨테이너만 이 작업을 예약합니다(1079행). 기본값은 -1인데(142행) StandardEngine만 생성자에서 10으로 바꿉니다(StandardEngine.java 59–63행). 그래서 기본 설정에서는 Engine 하나가 10초마다 자식(Host → Context → Wrapper)을 차례로 내려가며 backgroundProcess()를 부릅니다(processChildren(), 1164–1185행). 작업은 Server의 유틸리티 스레드 풀(Catalina-utility-N)에서 돕니다. StandardContext.backgroundProcess()는 클래스로더, Manager, 리소스 순으로 뒷정리를 시키고(StandardContext.java 4961–5001행), Manager 쪽은 이렇습니다.
@Override
public void backgroundProcess() {
count = (count + 1) % processExpiresFrequency;
if (count == 0) {
processExpires();
}
}
java/org/apache/catalina/session/ManagerBase.java 597–603행
processExpiresFrequency의 기본값은 6입니다(181행). 10초 × 6 = 60초에 한 번 processExpires()가 돌고, 이 함수는 모든 세션에 isValid()를 부르는 것이 전부입니다(608–629행). 지우는 일은 isValid() 안의 expire()가 합니다.
③ invalidate(). 로그아웃처럼 웹앱이 직접 끝내는 경우입니다. StandardSession.invalidate()는 이미 무효면 IllegalStateException을 던지고, 아니면 expire()를 부릅니다(904–914행).
expire() — 지우는 순서
// Mark this session as "being expired"
expiring = true;
// ... HttpSessionListener 들의 sessionDestroyed() 를 등록의 역순으로 부른다 ...
// Remove this session from our manager's active sessions
manager.remove(this, true);
// ... SessionListener(Tomcat 내부용)에 알리고, 로그인한 사용자면 logout() ...
String[] keys;
synchronized (attributes) {
// We have completed expire of this session
setValid(false);
expiring = false;
// Snapshot the attributes before permitting any racing setAttribute() call to observe the invalid state
keys = keys();
}
java/org/apache/catalina/session/StandardSession.java 578–685행에서 600–601행, 646–647행, 663–671행
expiring = true로 "지우는 중" 표시를 합니다. 리스너 안에서isValid()나getAttribute()를 불러도 다시expire()로 들어가지 않게 하려는 것입니다.isValid()의if (this.expiring) return true;가 그 짝입니다.HttpSessionListener.sessionDestroyed()를 등록의 역순으로 부릅니다(614–636행의j = (listeners.length - 1) - i). 이때 세션은 아직 살아 있어 리스너가 속성을 읽을 수 있습니다. 웹앱의 클래스로더로 바꿔 놓고 부르는데(613행context.bind(null)), 뒷정리 스레드는 웹앱의 스레드가 아니기 때문입니다.- 맵에서 뺍니다(
manager.remove(this, true),true는 "만료로 지웠으니 통계에 넣으라"). isValid = false로 바꾸고, 속성을 하나씩 지웁니다(673–682행). 속성 값이HttpSessionBindingListener면 이때valueUnbound()가 불립니다.
예제 2: 세 가지 길을 스레드 이름으로 보기
HttpSessionListener를 하나 만들어, 세션이 생기고 없어질 때 그 일을 한 스레드의 이름과 Tomcat 쪽 호출 스택을 찍었습니다(ex/u11-expire).
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// ... package, import 생략 ...
@WebListener
public class SessionLog implements HttpSessionListener {
// ... sessionCreated() 는 ID 앞 8글자와 스레드 이름만 찍는다 ...
@Override
public void sessionDestroyed(HttpSessionEvent e) {
String thread = Thread.currentThread().getName();
System.out.println("[u11] " + app(e) + " destroyed " + e.getSession().getId().substring(0, 8)
+ " on " + thread);
// 누가 지웠는지 — Tomcat 쪽 호출 스택을 위에서부터 7줄까지만 찍는다
int shown = 0;
for (StackTraceElement f : new Throwable().getStackTrace()) {
if (f.getClassName().startsWith("org.apache.catalina") && shown++ < 7) {
System.out.println("[u11] at " + f.getClassName() + "." + f.getMethodName());
}
}
}
}
[예제 ex/u11-expire/src/ex/u11/SessionLog.java에서 줄임]
서블릿 /touch는 세션을 꺼내고, ?ttl=2면 setMaxInactiveInterval(2)로 2초짜리 세션을 만들고, ?invalidate면 invalidate()를 부릅니다. 같은 웹앱을 두 이름으로 배포했습니다.
/lazy— 기본 설정. 뒷정리는 60초에 한 번이라 예제가 도는 동안에는 한 번도 돌지 않습니다./bg—META-INF/context.xml로 뒷정리를 1초마다 돌게 했습니다.
<Context backgroundProcessorDelay="1">
<Manager processExpiresFrequency="1" />
</Context>
Context에 backgroundProcessorDelay를 주면 그 Context가 자기 백그라운드 작업을 따로 예약하고, Engine의 작업은 그 Context를 건너뜁니다(processChildren()의 1182행 child.getBackgroundProcessorDelay() <= 0 조건). 결과입니다.
===== /lazy: create a session with a 2 s timeout =====
session E7009DE4 isNew=true ttl=2s
/lazy created E7009DE4 on http-nio-8080-exec-10
===== /lazy: wait 4 s, then send the old cookie =====
session 97FDB75E isNew=true ttl=1800s
/lazy destroyed E7009DE4 on http-nio-8080-exec-2
at org.apache.catalina.session.StandardSession.expire
at org.apache.catalina.session.StandardSession.isValid
at org.apache.catalina.connector.Request.doGetSession
at org.apache.catalina.connector.Request.getSessionInternal
at org.apache.catalina.authenticator.AuthenticatorBase.invoke
at org.apache.catalina.core.StandardHostValve.invoke
at org.apache.catalina.valves.ErrorReportValve.invoke
/lazy created 97FDB75E on http-nio-8080-exec-2
===== /bg: create a session with a 2 s timeout, then send nothing for 4 s =====
session 28C1B8F5 isNew=true ttl=2s
/bg created 28C1B8F5 on http-nio-8080-exec-3
/bg destroyed 28C1B8F5 on Catalina-utility-1
at org.apache.catalina.session.StandardSession.expire
at org.apache.catalina.session.StandardSession.isValid
at org.apache.catalina.session.ManagerBase.processExpires
at org.apache.catalina.session.ManagerBase.backgroundProcess
at org.apache.catalina.core.StandardContext.backgroundProcess
at org.apache.catalina.core.ContainerBase$ContainerBackgroundProcessor.processChildren
at org.apache.catalina.core.ContainerBase$ContainerBackgroundProcessor.run
===== /lazy: invalidate() =====
/lazy created 4CF22D6A on http-nio-8080-exec-5
session 4CF22D6A isNew=false ttl=1800s
invalidated
/lazy destroyed 4CF22D6A on http-nio-8080-exec-7
at org.apache.catalina.session.StandardSession.expire
at org.apache.catalina.session.StandardSession.expire
at org.apache.catalina.session.StandardSession.invalidate
at org.apache.catalina.session.StandardSessionFacade.invalidate
at org.apache.catalina.core.ApplicationFilterChain.doFilter
at org.apache.catalina.core.ApplicationFilterChain.doFilter
at org.apache.catalina.core.StandardWrapperValve.invoke
세션 ID와 스레드 번호는 실행마다 다릅니다. 스택은 org.apache.catalina로 시작하는 줄만 남겼으므로 서블릿(ex.u11)이나 jakarta.servlet 쪽 줄은 빠져 있습니다.
- ①
/lazy: 2초짜리 세션을 만들고 4초 뒤 옛 쿠키로 요청했습니다. 요청 스레드(http-nio-8080-exec-2)가doGetSession()→isValid()→expire()로 지우고, 같은 스레드에서 바로 새 세션(97FDB75E)을 만들었습니다. 클라이언트는 아무 오류 없이isNew=true인 새 세션을 받습니다. - ②
/bg: 요청을 보내지 않았는데Catalina-utility-1스레드가 지웠습니다. 스택이 그림의 오른쪽 길과 같습니다.ContainerBackgroundProcessor.run()→StandardContext.backgroundProcess()→ManagerBase.backgroundProcess()→processExpires()→isValid()→expire(). - ③
invalidate(): 서블릿 호출 안에서 요청 스레드가 지웠습니다.expire가 두 번 보이는 것은expire()가expire(true)를 부르기 때문입니다.
헷갈리기 쉬운 곳
- ①의 스택에 서블릿이 없습니다. 세션을 처음 건드린 것은 서블릿의
getSession()이 아니라AuthenticatorBase.invoke()였습니다. 로그인 설정이 없는 웹앱에도ContextConfig가 "인증 없음"용NonLoginAuthenticator를 밸브로 끼우고(startup/ContextConfig.java339–403행), 이 밸브는 "세션에 로그인한 사용자가 저장돼 있나"를 보려고getSessionInternal(false)를 부릅니다(authenticator/AuthenticatorBase.java504–521행). 그래서 만료된 세션은 서블릿에 닿기 전, 밸브 단계에서 이미 지워집니다. 밸브는 8편, 인증기는 14편에서 봅니다. session-timeout이 지나도 세션이 바로 사라지지는 않습니다. 아무도 찾지 않는 세션은 다음processExpires()까지, 기본 설정이면 최대 60초쯤 더 메모리에 남습니다.sessionDestroyed()가 정확히 30분에 불리기를 기대하면 안 됩니다. 반대로 그 세션의 ID로 요청이 오면 ①에서 즉시 지워지므로, 만료된 세션이 다시 쓰이는 일은 없습니다.sessionDestroyed()는 요청 스레드가 아닐 수 있습니다. ②처럼 유틸리티 스레드에서 불리므로, 리스너 안에서ThreadLocal에 기대거나 요청 정보를 찾으면 안 됩니다.
5. 재시작과 세션 — StandardManager의 저장
기본으로는 저장하지 않습니다
StandardManager에는 Tomcat이 멈출 때 세션을 파일에 쓰고 시작할 때 읽어 오는 기능이 있습니다. 그런데 파일 이름을 정하는 pathname의 기본값이 null입니다.
protected String pathname = null;
java/org/apache/catalina/session/StandardManager.java 76행
pathname이 비어 있으면 file()이 null을 돌려주고(331–334행), load()와 unload()는 아무것도 하지 않고 돌아갑니다. Tomcat이 함께 주는 conf/context.xml에도 이 설정이 주석으로만 들어 있습니다.
<!-- Uncomment this to enable session persistence across Tomcat restarts -->
<!--
<Manager pathname="SESSIONS.ser" />
-->
conf/context.xml은 모든 웹앱에 적용되는 기본 <Context> 설정입니다(10편). 이 주석을 풀어야 재시작 뒤에도 세션이 남습니다. 상대 경로면 웹앱의 임시 작업 폴더(work/Catalina/<호스트>/<웹앱>/) 아래에 만들어집니다(335–343행).
빨간 길이 기본 설정입니다. 재시작하면 모든 사용자가 로그아웃됩니다.
unload()와 load()
멈출 때(stopInternal() → unload())의 핵심입니다.
oos.writeObject(Integer.valueOf(sessions.size()));
for (Session s : sessions.values()) {
StandardSession session = (StandardSession) s;
list.add(session);
session.passivate();
session.writeObjectData(oos);
}
}
}
// Expire all the sessions we just wrote
if (log.isDebugEnabled()) {
log.debug(sm.getString("standardManager.expiringSessions", Integer.toString(list.size())));
}
for (StandardSession session : list) {
try {
session.expire(false);
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
} finally {
session.recycle();
}
}
java/org/apache/catalina/session/StandardManager.java 225–247행
세션 수를 먼저 쓰고, 세션마다 passivate()(속성 중 HttpSessionActivationListener인 것에 sessionWillPassivate() 알림)를 한 뒤 직렬화합니다. 다 쓴 세션은 expire(false)로 정리하는데, false는 sessionDestroyed()를 부르지 않는다는 뜻입니다. 세션이 끝난 것이 아니라 잠시 디스크로 옮겨 가는 것이기 때문입니다. 저장하지 않는 기본 설정에서는 stopInternal()의 뒷부분이 남은 세션에 그냥 expire()를 불러(303–317행) sessionDestroyed()가 불립니다.
직렬화할 때 속성은 이렇게 거릅니다.
// Accumulate the names of serializable and non-serializable attributes
String[] keys = keys();
List<String> saveNames = new ArrayList<>();
List<Object> saveValues = new ArrayList<>();
for (String key : keys) {
Object value = attributes.get(key);
if (value == null) {
// Continue
} else if (isAttributeDistributable(key, value) && !exclude(key, value)) {
saveNames.add(key);
saveValues.add(value);
} else {
removeAttributeInternal(key, true);
}
}
java/org/apache/catalina/session/StandardSession.java 1341–1355행
isAttributeDistributable()은 value instanceof Serializable입니다(1134–1136행). Serializable이 아닌 속성은 오류 없이 조용히 지워집니다.
시작할 때(startInternal() → load())는 반대입니다. 세션 수를 읽고, 하나씩 readObjectData()로 되살려 맵에 넣고 activate()(sessionDidActivate() 알림)를 부른 뒤, 성공하든 실패하든 파일을 지웁니다(153–178행). 저장할 때 이미 무효였던 세션은 건너뜁니다(157–166행). Manager는 웹앱의 리스너가 시작된 다음에 시작되므로(StandardContext.java 4616–4636행), 되살아난 세션의 알림을 리스너가 받을 수 있습니다. 멈출 때는 반대로 리스너보다 먼저 멈춥니다(4834–4840행).
예제 3: 껐다 켜기
서블릿 /count는 세션에 세 가지를 넣습니다. 직렬화되는 숫자 count, Serializable이 아닌 객체 plain, 그리고 HttpSessionActivationListener를 구현한 witness입니다(ex/u11-restart).
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// ... package, import 생략. @WebServlet("/count") public class CountServlet extends HttpServlet { 안쪽 ...
/** Serializable 이 아니다 — 디스크에 저장될 수 없다 */
static class NotSerializable {
}
/** 세션이 디스크로 나가고(passivate) 다시 들어올 때(activate) 알림을 받는다 */
static class Witness implements HttpSessionActivationListener, Serializable {
@Override
public void sessionWillPassivate(HttpSessionEvent e) {
System.out.println("[u11] sessionWillPassivate " + e.getSession().getId().substring(0, 8));
}
@Override
public void sessionDidActivate(HttpSessionEvent e) {
System.out.println("[u11] sessionDidActivate " + e.getSession().getId().substring(0, 8));
}
}
// ... doGet(): 처음이면 plain 과 witness 를 넣고, count 를 하나 올려 찍는다 ...
[예제 ex/u11-restart/src/ex/u11/CountServlet.java에서 줄임]
기본 설정(default)과 conf/context.xml의 주석을 푼 설정(persist)에서 각각 요청 두 번 → catalina.sh stop → catalina.sh start → 같은 쿠키로 요청 한 번을 했습니다.
===== [default] two requests =====
session BF6AC4DB isNew=true count=1 attributes=[witness, plain, count]
session BF6AC4DB isNew=false count=2 attributes=[witness, plain, count]
===== [default] stop Tomcat =====
no SESSIONS.ser
===== [default] start again, same cookie =====
session C928A7E4 isNew=true count=1 attributes=[witness, plain, count]
no SESSIONS.ser
===== [persist] conf/context.xml =====
29: <Manager pathname="SESSIONS.ser" />
===== [persist] two requests =====
session 67837EDF isNew=true count=1 attributes=[witness, plain, count]
session 67837EDF isNew=false count=2 attributes=[witness, plain, count]
===== [persist] stop Tomcat =====
sessionWillPassivate 67837EDF
336 bytes /tmp/base/work/Catalina/localhost/u11/SESSIONS.ser
===== [persist] start again, same cookie =====
sessionDidActivate 67837EDF
session 67837EDF isNew=false count=3 attributes=[witness, count]
no SESSIONS.ser
세션 ID는 실행마다 다르고, 속성 이름의 순서는 맵의 순서라 실행 환경에 따라 다를 수 있습니다.
- default: 멈춰도 파일이 생기지 않고, 다시 켠 뒤 같은 쿠키를 보내도 새 세션(
count=1)입니다. - persist: 멈출 때
sessionWillPassivate가 불리고work/Catalina/localhost/u11/SESSIONS.ser(336바이트)가 생겼습니다. 다시 켜자sessionDidActivate가 불리고, 같은 ID의 세션이count=3으로 이어집니다. 읽은 뒤 파일은 지워졌습니다. plain이 사라졌습니다.Serializable이 아니라서 저장할 때 지워졌고, 로그에는 아무 경고도 없었습니다.
헷갈리기 쉬운 곳
kill -9나 정전에는 소용이 없습니다. 파일을 쓰는 것은stopInternal(), 곧 Tomcat이 정상적으로 멈출 때뿐입니다. 갑자기 죽으면 세션은 사라집니다. 실행 중에도 디스크에 백업해 두려면 다음 절의PersistentManager를 씁니다.- 세션에 넣는 객체는
Serializable로 만듭니다. 재시작 저장이나 클러스터 복제를 켜는 순간, 그렇지 않은 속성은 말없이 빠집니다. 웹앱이<distributable/>이면setAttribute()할 때 바로IllegalArgumentException으로 알려 줍니다(StandardSession.java1000–1002행). - 웹앱의 리로드에도 같은 일이 일어납니다. 리로드(4편)는
Context를 멈췄다 다시 시작하는 것이므로,pathname이 없으면 리로드할 때마다 세션이 사라집니다.
PersistentManager 맛보기
<Manager className="org.apache.catalina.session.PersistentManager">는 StandardManager보다 한 걸음 더 갑니다. 세션을 <Store>(파일인 FileStore, DB인 DataSourceStore)에 두고, 메모리에 없는 ID로 요청이 오면 저장소에서 꺼내 옵니다(swapIn()).
@Override
public Session findSession(String id) throws IOException {
Session session = super.findSession(id);
// ... 다른 스레드가 swapOut 하는 중일 수 있어 세션을 잠그고 맵에서 다시 찾는다 ...
if (session != null) {
return session;
}
// See if the Session is in the Store
session = swapIn(id);
return session;
}
java/org/apache/catalina/session/PersistentManagerBase.java 370–398행
뒷정리 때는 만료 검사 뒤에 processPersistenceChecks()가 이어져, 오래 쉰 세션을 저장소로 내보내거나(maxIdleSwap), 너무 많으면 내보내거나(maxActiveSessions), 백업만 해 둡니다(maxIdleBackup)(processExpires() 322–348행, processPersistenceChecks() 355–361행). maxIdleSwap이나 minIdleSwap을 켜면 4절에서 본 activityCheck가 강제로 켜지는데(292–294행), 요청이 쓰고 있는 세션을 디스크로 내보내지 않으려는 것입니다(793–796행). 이 글에서는 예제로 돌려 보지 않았습니다.
이 글에서 뺀 것
- 세션 ID 바꾸기(
changeSessionId(), 로그인 때 세션 고정을 막는rotateSessionId()):Request.java2341–2375행,ManagerBase.java801–828행. 인증기가 부르는 자리는 14편에서 봅니다. - 병렬 배포(
app##2)에서 세션 ID로 옛 버전 웹앱을 고르는 다시 매핑:CoyoteAdapter.java745–780행 - TLS 세션 ID를 세션 ID로 쓰는
SSL추적 방식:CoyoteAdapter.parseSessionSslId()985행부터 - 클러스터 세션 복제(
DeltaManager,BackupManager):java/org/apache/catalina/ha/session/ - 저장소 구현
FileStore,DataSourceStore와 공통 부분StoreBase:java/org/apache/catalina/session/ - 세션 속성 이름·클래스 필터(
sessionAttributeNameFilter,sessionAttributeValueClassNameFilter):ManagerBase.java841–875행 - 통계(
sessionCreationTiming, 만료 수, 평균 수명)와 JMX 노출:ManagerBase1020행 근처,session/mbeans-descriptors.xml. JMX는 16편에서 봅니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
StandardManager |
웹앱마다 하나. <Manager>를 안 적으면 StandardContext가 만든다 |
| 세션 저장소 | ManagerBase.sessions — ID를 열쇠로 하는 ConcurrentHashMap 하나 |
| ID 읽기 | CoyoteAdapter.postParseRequest()가 ;jsessionid= 다음 쿠키 순으로 읽어 requestedSessionId에 적는다. 쿠키가 이긴다 |
doGetSession() |
찾고(findSession + isValid) 없으면 만든다. 클라이언트가 보낸 ID는 거의 다시 쓰지 않는다 |
| 세션 ID | SecureRandom 풀에서 16바이트 → 대문자 16진수 32글자, jvmRoute가 있으면 .route. 중복 검사는 없다 |
| 쿠키 속성 | Path는 웹앱 경로, HttpOnly는 기본으로 붙고, HTTPS면 Secure |
| URL 재작성 | 요청 ID가 쿠키에서 오지 않았으면 encodeURL()이 ;jsessionid=를 붙인다 — 첫 응답에도 |
| 만료 판정 | isValid()가 쉰 시간을 보고 그 자리에서 expire()한다 |
| 만료 시점 | 그 ID로 요청이 올 때, 또는 뒷정리 스레드(기본 10초 × 6 = 60초마다) |
expire() |
리스너(역순) → 맵에서 제거 → isValid=false → 속성 제거 |
| 재시작 저장 | 기본은 꺼져 있다. <Manager pathname="SESSIONS.ser"/>로 켜며, 정상 종료 때만, Serializable 속성만 |
다음에 볼 것
세션까지 해서 요청 하나가 서블릿에서 처리되는 동안 Tomcat이 해 주는 일을 거의 다 봤습니다. 지금까지는 요청 하나를 스레드 하나가 처음부터 끝까지 붙들고 있었습니다. 서블릿이 오래 걸리는 일을 기다리는 동안 그 스레드를 놓아줄 수는 없을까요. 12편: 비동기 서블릿과 논블로킹 I/O — 스레드를 놓아주는 법에서 startAsync()와 AsyncStateMachine을 따라갑니다.
httpd에도 세션과 맞닿는 곳이 있습니다. httpd 14편의 끝에 이름만 남긴 세션 고정(sticky session — 2절의 세션 고정 공격과는 다른 말입니다)은 mod_proxy_balancer가 JSESSIONID 같은 쿠키 값에서 . 뒤를 route로 읽어 같은 백엔드로 보내는 기능입니다(find_session_route()). 3절에서 본 StandardSessionIdGenerator가 ID 끝에 .jvmRoute를 붙이는 것이 그 짝입니다. 앞에 httpd를 두고 Tomcat 여러 대를 돌리면, Tomcat이 만든 세션 ID의 꼬리를 httpd가 읽어 길을 고르는 셈입니다. 한편 httpd 13편의 Basic 인증은 요청마다 Authorization 헤더로 아이디와 비밀번호를 다시 받아 판정하므로 서버에 세션이 필요 없습니다. Tomcat의 FORM 로그인은 반대로 로그인한 사용자를 이 글의 세션에 담아 두고, 4절에서 본 AuthenticatorBase가 요청마다 세션에서 꺼내 씁니다. 그 자세한 흐름은 14편에서 봅니다.