namalzi

← 글 목록

Tomcat 소스 파헤치기 11: 세션 — JSESSIONID는 어떻게 만들고 지우나

Tomcat · 게시

request.getSession() 한 줄 뒤에서 Tomcat이 세션 ID를 어디서 읽고, 어떻게 만들고, 언제 지우는지 소스로 따라갑니다. 쿠키와 ;jsessionid, SecureRandom으로 만드는 32글자 ID, 요청 때와 뒷정리 스레드에서 일어나는 두 가지 만료, 재시작 때 세션을 파일에 저장하는 StandardManager까지 예제 셋으로 직접 확인합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 열한 번째 글입니다. 10편에서는 WAR 하나가 StandardContext가 되어 서비스되기까지를 봤습니다. 이번에는 그 웹앱 안에서 사용자를 기억하는 방법, 곧 세션을 봅니다. 서블릿에서 request.getSession()을 부르면 응답에 Set-Cookie: JSESSIONID=…가 붙고, 다음 요청부터는 같은 세션 객체가 돌아옵니다. 그 사이에 무슨 일이 있는지 따라갑니다.

답하려는 질문은 네 가지입니다.

항목 내용
대상 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 하나, 그 안에 맵 하나

세션에 관여하는 클래스를 한 장에 모으면 이렇습니다.

세션 관련 클래스. StandardContext 하나에 StandardManager 하나가 붙는다. StandardManager는 ManagerBase를 상속하고, ManagerBase는 세션 ID를 열쇠로 하는 ConcurrentHashMap인 sessions와 StandardSessionIdGenerator를 가진다. ID 생성기는 SecureRandom 풀에서 16바이트를 꺼내 16진수 32글자로 만든다. 맵의 값은 StandardSession이고, 서블릿이 받는 HttpSession은 그것을 감싼 StandardSessionFacade다. pathname이 설정되어 있을 때만 StandardManager가 work 폴더의 SESSIONS.ser 파일에 세션을 저장한다. 노란 상자가 실제로 무언가를 담는 곳입니다. 세션 저장소의 본체는 메모리의 맵 하나입니다.

<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의 흐름. CoyoteAdapter.postParseRequest가 URL의 jsessionid 다음에 쿠키 JSESSIONID를 보고 requestedSessionId에 적어 둔다. 서블릿이 getSession을 부르면 Request.doGetSession이 먼저 이 요청에서 이미 꺼낸 세션이 살아 있으면 그것을 돌려준다. 없으면 manager.findSession으로 맵에서 찾고 isValid로 확인한다. 찾았으면 access를 부르고 setNew false로 있던 세션에 합류한다. 없거나 만료됐으면 create 인자를 본다. false면 null, true면 manager.createSession이 새 ID를 만들어 맵에 넣고, 쿠키 추적이 켜져 있으면 Set-Cookie JSESSIONID를 응답에 붙인다. 노란 상자는 이미 손에 든 값입니다. 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행

  1. 이 요청에서 이미 세션을 꺼냈으면(session 필드) 그것을 돌려줍니다(2679–2685행). 한 요청에서 getSession()을 여러 번 불러도 맵은 한 번만 뒤집니다.
  2. requestedSessionId가 있으면 맵에서 찾고, isValid()로 만료를 확인합니다. 살아 있으면 access()로 "지금 쓰는 중"이라고 표시하고 돌려줍니다.
  3. 없거나 만료됐는데 create가 false면 null입니다. getSession(false)는 여기서 끝납니다.
  4. create가 true면 manager.createSession()으로 새로 만들고, 쿠키 추적이 켜져 있으면 응답에 Set-Cookie를 붙입니다.

세션은 누군가 getSession()을 불러야 생깁니다. JSP는 기본으로 세션을 만들지만(<%@ page session="true" %>가 기본값이라는 것은 JSP 스펙의 내용이고, Jasper 소스로는 13편에서 봅니다), 서블릿만 있는 웹앱은 부르지 않는 한 세션도 쿠키도 없습니다.

클라이언트가 보낸 ID를 그대로 쓰는 경우는 거의 없습니다

줄인 자리(2724–2761행)는 "새 세션을 만들 때 클라이언트가 보낸 ID를 그대로 쓸까"를 정합니다. 결론은 대부분 쓰지 않는다(sessionId = null → 새 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 값은 실행마다 다릅니다.

  1. getSession(false)만 부르면 세션도 Set-Cookie도 생기지 않습니다.
  2. 처음 getSession()을 부르자 32글자 ID와 Set-Cookie가 생깁니다. Path=/u11은 웹앱의 경로, HttpOnly는 자바스크립트가 이 쿠키를 읽지 못하게 하는 표시입니다(3절). maxInactive는 conf/web.xml의 30분 = 1800초입니다.
  3. 쿠키를 돌려보내면 같은 세션에 합류하고 isNew가 false가 됩니다. Set-Cookie는 다시 오지 않습니다.
  4. 쿠키 없이 URL에 ;jsessionid=만 붙여도 같은 세션입니다(url=true). count가 3으로 이어집니다.
  5. 지어낸 ID ABC123을 보내면 valid=false이고, 그 ID를 쓰지 않고 새 ID로 세션을 만듭니다. 앞에서 본 세션 고정 방어입니다.

헷갈리기 쉬운 곳

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행

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)가 이것입니다.

세션 하나가 만들어져 쿠키가 되기까지. ManagerBase.createSession()이 maxActiveSessions(기본 -1, 제한 없음)를 검사하고 new StandardSession을 만들어 maxInactiveInterval을 sessionTimeout × 60 = 1800초로 둔다. generateSessionId()는 StandardSessionIdGenerator에 맡기고, 생성기는 randoms 큐에서 SecureRandom(JDK에 있으면 SHA1PRNG)을 하나 꺼내 쓰고 돌려놓는다. 난수 16바이트(sessionIdLength 기본 16, 128비트), 예를 들어 0xE2 0x21 0xCA 0xF5 … 0xAF를 받아, 바이트마다 4비트씩 둘로 나눠 0–9와 A–F 두 글자로 쓴다. 0xE2는 E2, 0x21은 21이 되어 E221CAF5F85FF1E04892CC5FFAC94FAF 32글자가 되고, jvmRoute가 있으면 뒤에 .node1 같은 꼬리가 붙는다. session.setId(id)가 manager.add(this)로 sessions 맵에 넣고 tellNew()로 HttpSessionListener.sessionCreated()를 부른다. 마지막으로 createSessionCookie()가 Set-Cookie: JSESSIONID=E221CAF5F85FF1E04892CC5FFAC94FAF; Path=/u11; HttpOnly를 만든다. 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행

그러면 누가 isValid()를 부를까요. 두 군데입니다.

세션이 없어지는 세 길. 첫째, 요청 스레드가 옛 ID로 doGetSession을 부르면 StandardSession.isValid가 쉰 시간이 maxInactiveInterval 이상인지 본다. 둘째, Catalina-utility 스레드가 Engine에서는 10초마다 StandardContext.backgroundProcess를 부르고, ManagerBase.backgroundProcess가 6번에 한 번 processExpires를 돌려 모든 세션의 isValid를 부른다. 셋째, 서블릿이 invalidate를 부른다. 세 길 모두 StandardSession.expire에 닿아, HttpSessionListener.sessionDestroyed를 등록의 역순으로 부르고, manager.remove로 맵에서 빼고, isValid를 false로 바꾼 뒤 속성을 하나씩 지운다. 회색 상자가 출발점입니다. 어느 길로 오든 마지막은 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행

  1. expiring = true로 "지우는 중" 표시를 합니다. 리스너 안에서 isValid()나 getAttribute()를 불러도 다시 expire()로 들어가지 않게 하려는 것입니다. isValid()의 if (this.expiring) return true;가 그 짝입니다.
  2. HttpSessionListener.sessionDestroyed()를 등록의 역순으로 부릅니다(614–636행의 j = (listeners.length - 1) - i). 이때 세션은 아직 살아 있어 리스너가 속성을 읽을 수 있습니다. 웹앱의 클래스로더로 바꿔 놓고 부르는데(613행 context.bind(null)), 뒷정리 스레드는 웹앱의 스레드가 아니기 때문입니다.
  3. 맵에서 뺍니다(manager.remove(this, true), true는 "만료로 지웠으니 통계에 넣으라").
  4. 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()를 부릅니다. 같은 웹앱을 두 이름으로 배포했습니다.

<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 쪽 줄은 빠져 있습니다.

헷갈리기 쉬운 곳

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 27–30행

conf/context.xml은 모든 웹앱에 적용되는 기본 <Context> 설정입니다(10편). 이 주석을 풀어야 재시작 뒤에도 세션이 남습니다. 상대 경로면 웹앱의 임시 작업 폴더(work/Catalina/<호스트>/<웹앱>/) 아래에 만들어집니다(335–343행).

재시작 때 세션의 길. catalina.sh stop으로 StandardContext.stopInternal이 StandardManager.unload를 부른다. pathname이 없으면(기본값) 아무것도 쓰지 않고, 남은 세션을 모두 expire하며 sessionDestroyed 알림이 간다. pathname이 있으면 세션마다 passivate해 sessionWillPassivate 알림을 보내고, writeObjectData로 쓰면서 Serializable이 아닌 속성은 지운다. 결과는 work 폴더의 SESSIONS.ser 파일이고, 쓴 세션은 sessionDestroyed 알림 없이 expire false로 정리한다. 시작할 때는 StandardContext.startInternal이 리스너 다음에 Manager를 시작하고, StandardManager.load가 파일을 읽어 readObjectData 뒤 activate해 sessionDidActivate 알림을 보내고 파일을 지운다. 빨간 길이 기본 설정입니다. 재시작하면 모든 사용자가 로그아웃됩니다.

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는 실행마다 다르고, 속성 이름의 순서는 맵의 순서라 실행 환경에 따라 다를 수 있습니다.

헷갈리기 쉬운 곳

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행). 이 글에서는 예제로 돌려 보지 않았습니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
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편에서 봅니다.

다른 글