Deep Dive /Java Web /2

2. Connector → CoyoteAdapter.service()

Tomcat의 네트워크 레이어(Coyote)에서 만들어진 org.apache.coyote.Request/Response를, 서블릿 컨테이너 레이어(Catalina)가 사용하는 org.apache.catalina.connector.Request/Response로 변환하고 파이프라인에 넘기는 구간입니다.


왜 이 과정이 존재해야 하는가

두 세계가 분리된 이유

Tomcat 내부에는 Request 객체가 두 종류 존재합니다.

  org.apache.coyote.Request org.apache.catalina.connector.Request
역할 네트워크에서 읽은 날 것(raw)의 HTTP 데이터 Servlet 스펙(HttpServletRequest) 구현체
관심사 소켓, 바이트 스트림, 프로토콜(HTTP/1.1·HTTP/2·AJP) 세션, 쿠키, 파라미터, URL 라우팅, 웹 앱 컨텍스트
설계 원칙 소스 주석: “Most fields are GC-free, expensive operations are delayed” Jakarta Servlet 스펙을 그대로 구현

이 두 개를 분리한 이유는 관심사의 분리(Separation of Concerns) 입니다.

  • Coyote(네트워크 레이어) 는 프로토콜만 이해합니다. HTTP 패킷을 파싱해서 메서드, URI, 헤더를 뽑아낸 뒤 그걸 coyote.Request에 담습니다. 서블릿이 무엇인지, 세션이 무엇인지 전혀 모릅니다.
  • Catalina(서블릿 컨테이너 레이어) 는 프로토콜이 무엇인지 모릅니다. HttpServletRequest를 받아서 Filter를 거치고 Servlet을 호출합니다.

덕분에 프로토콜은 독립적으로 교체할 수 있습니다. HTTP/1.1을 HTTP/2로 바꿔도, AJP를 추가해도, 서블릿 코드는 한 줄도 바꿀 필요가 없습니다.

그렇다면 누가 두 세계를 연결하는가

두 레이어 사이에 번역기 역할을 하는 것이 바로 CoyoteAdapter입니다.

이것은 GoF의 어댑터 패턴(Adapter Pattern) 을 그대로 구현한 것입니다.

[ProtocolHandler (Coyote 세계)]
        │  service(coyote.Request, coyote.Response)
        ▼
[CoyoteAdapter]  ← 인터페이스는 org.apache.coyote.Adapter
        │  coyote.Request → catalina.Request 로 변환
        │  postParseRequest() 로 라우팅·세션·URI 정보 세팅
        ▼
[Pipeline → StandardEngineValve (Catalina 세계)]
        │  invoke(catalina.Request, catalina.Response)

org.apache.coyote.Adapter 인터페이스의 service() 메서드 하나가 두 세계의 계약 지점입니다. Coyote는 Adapter만 알고, 내부가 Catalina인지 다른 컨테이너인지 신경 쓰지 않습니다.


왜 이렇게 만들었는가 — 각 동작의 이유

getNote(ADAPTER_NOTES) 로 객체를 캐싱하는가

// coyote.Request 소스 주석 (Request.java:715~717)
// CoyoteAdapter: ADAPTER_NOTES = 1
// - stores the HttpServletRequest object (req/res)
private final Object[] notes = new Object[Constants.MAX_NOTES];

HTTP Keep-Alive 연결에서는 하나의 TCP 소켓으로 여러 HTTP 요청을 처리합니다. Coyote 입장에서는 같은 coyote.Request 인스턴스를 재사용해서 다음 요청 데이터를 덮어씁니다. catalina.Request를 매 요청마다 new로 생성하면 GC 압박이 발생합니다.

notes[] 배열은 인덱스 기반의 초경량 맵입니다. 해시 계산 없이 배열 인덱스로 바로 꺼내오므로, 첫 요청에 만든 Catalina 래퍼 객체를 같은 커넥션의 다음 요청에서 재사용할 수 있습니다.

2명의 유저가 요청하는 경우 — 시퀀스 다이어그램

sequenceDiagram participant A as 유저 A (PC1) participant B as 유저 B (PC2) participant P as Poller (Selector) participant CA as CoyoteAdapter participant CRA as coyote.Request(A) participant CRB as coyote.Request(B) participant XA as catalina.Request(X) participant XB as catalina.Request(Y) Note over A,P: 유저 A 첫 번째 요청 A->>P: TCP 연결 + GET /pageA P->>CA: service(coyoteReq A) CA->>CRA: getNote(1) → null CA->>XA: new catalina.Request(coyoteReq A) 생성 CA->>CRA: setNote(1, X) 저장 CA->>XA: /pageA 처리 CA->>XA: recycle() — 데이터 초기화, 객체는 유지 Note over B,P: 유저 B 첫 번째 요청 (동시) B->>P: TCP 연결 + GET /pageB P->>CA: service(coyoteReq B) CA->>CRB: getNote(1) → null CA->>XB: new catalina.Request(coyoteReq B) 생성 CA->>CRB: setNote(1, Y) 저장 CA->>XB: /pageB 처리 CA->>XB: recycle() Note over A,P: 유저 A 두 번째 요청 (Keep-Alive) A->>P: 같은 소켓 + GET /pageC P->>CA: service(coyoteReq A) ← 같은 인스턴스 CA->>CRA: getNote(1) → X (이미 있음!) Note over CA,XA: 생성 안 함, 꺼내서 재사용 CA->>XA: /pageC 처리 CA->>XA: recycle()
  coyote.Request catalina.Request
유저 A 전용 (A) 인스턴스 (X) 인스턴스
유저 B 전용 (B) 인스턴스 (Y) 인스턴스
첫 요청 notes[1] == null 새로 생성
두 번째 요청 이후 notes[1] != null 꺼내서 재사용
요청 완료 후 recycle()로 데이터만 초기화, 객체는 유지

A와 B는 완전히 독립된 인스턴스 쌍을 가집니다. A의 Keep-Alive 연결 안에서만 (A)↔(X) 쌍이 재사용됩니다.

request.recycle() 로 돌려보내는가

// 동기 요청 완료 후
if (!async) {
    request.recycle();
    response.recycle();
}

catalina.Request는 세션·쿠키·파라미터 등 무거운 상태를 담습니다. 요청이 끝날 때마다 GC에 던지면 고트래픽 서버는 GC 멈춤(Stop-the-World)에 시달립니다.

recycle()은 객체를 메모리 풀에 반납하는 것입니다. 내부 상태를 초기화해서 다음 요청에서 재사용할 수 있도록 준비합니다. 비동기 요청에서는 async == true 일 때 recycle을 건너뛰는데, 아직 응답이 완료되지 않았으므로 객체를 살려두어야 하기 때문입니다.

postParseRequest() 를 파이프라인 진입 전에 실행하는가

postParseSuccess = postParseRequest(req, request, res, response);
if (postParseSuccess) {
    connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
}

Catalina의 Valve들(StandardEngineValveStandardHostValve → …)은 이미 라우팅이 결정된 상태를 전제로 동작합니다. 어느 Host, 어느 Context(웹 앱), 어느 Servlet으로 가야 할지, 세션이 어디 있는지를 Valve에 진입하기 전에 확정해야 합니다.

postParseRequest() 내부에서 이 세 가지를 순서대로 처리합니다.

  1. scheme/secure 결정request.isSecure()가 Valve에서 정상 동작하려면 파이프라인 전에 설정되어야 합니다.
  2. URI 디코딩%xx 인코딩 해제 없이는 URL 패턴 매칭 자체가 불가능합니다.
  3. Mapper.map() — Host 헤더와 디코딩된 URI로 StandardEngine → Host → Context → Wrapper(Servlet) 라우팅을 결정하고, 결과를 MappingData에 저장합니다. 이후 모든 Valve가 이 정보를 참조합니다.
  4. 세션 ID 파싱 — URL 경로 파라미터 / 쿠키 / SSL 세션 세 경로를 탐색하고, 세션에 따라 Context 버전이 달라질 수 있으면 Mapper.map()을 재실행합니다.

postParseSuccessfalse이면 파이프라인을 아예 건너뜁니다. 잘못된 요청이 Valve 체인을 통과하는 것을 막는 사전 검문소 역할을 합니다.

왜 동기와 비동기를 여기서 분기하는가

if (request.isAsync()) {
    async = true;
    // ...비동기 완료는 다른 스레드가 처리
} else {
    request.finishRequest();
    response.finishResponse();
}

Servlet 3.0 이후 AsyncContext.start()를 호출하면 응답은 다른 스레드에서 나중에 완료됩니다. 이 컨테이너 스레드가 invoke() 에서 돌아왔다고 응답이 끝난 것이 아닙니다.

async == true이면 recycle()을 하지 않고, finishResponse()도 호출하지 않습니다. 응답 소켓을 닫거나 객체를 풀에 반납하는 작업은 비동기 작업이 실제로 완료될 때까지 미뤄집니다.


시퀀스 다이어그램

sequenceDiagram participant ProtocolHandler participant Connector participant CoyoteAdapter participant CoyoteReq as coyote.Request participant CoyoteRes as coyote.Response participant CatReq as catalina.Request participant CatRes as catalina.Response participant Mapper participant Pipeline as StandardEngine Pipeline Note over Connector,CoyoteAdapter: 서버 시작 시 — Connector.initInternal() Connector->>CoyoteAdapter: new CoyoteAdapter(this) Connector->>ProtocolHandler: protocolHandler.setAdapter(adapter) Note over ProtocolHandler,Pipeline: HTTP 요청마다 — CoyoteAdapter.service(req, res) ProtocolHandler->>CoyoteAdapter: service(req, res) CoyoteAdapter->>CoyoteReq: req.getNote(ADAPTER_NOTES) CoyoteAdapter->>CoyoteRes: res.getNote(ADAPTER_NOTES) alt request == null (Catalina 래퍼 없음) CoyoteAdapter->>Connector: connector.createRequest(req) Connector-->>CoyoteAdapter: CatReq CoyoteAdapter->>Connector: connector.createResponse(res) Connector-->>CoyoteAdapter: CatRes CoyoteAdapter->>CatReq: request.setResponse(response) CoyoteAdapter->>CatRes: response.setRequest(request) CoyoteAdapter->>CoyoteReq: req.setNote(ADAPTER_NOTES, request) CoyoteAdapter->>CoyoteRes: res.setNote(ADAPTER_NOTES, response) CoyoteAdapter->>CoyoteReq: req.getParameters().setQueryStringCharset(connector.getURICharset()) end opt connector.getXpoweredBy() == true CoyoteAdapter->>CatRes: response.addHeader("X-Powered-By", POWERED_BY) end CoyoteAdapter->>CoyoteReq: req.setRequestThread() CoyoteAdapter->>CoyoteAdapter: postParseRequest(req, request, res, response) Note over CoyoteAdapter,Mapper: postParseRequest() 내부 CoyoteAdapter->>CoyoteReq: req.scheme().isNull() 확인 alt scheme 미설정 CoyoteAdapter->>CoyoteReq: req.scheme().setString(connector.getScheme()) CoyoteAdapter->>CatReq: request.setSecure(connector.getSecure()) else scheme 설정됨 CoyoteAdapter->>CatReq: request.setSecure(req.scheme().equals("https")) end CoyoteAdapter->>CoyoteReq: req.getURLDecoder().convert(decodedURI, ...) CoyoteAdapter->>Mapper: connector.getService().getMapper().map(serverName, decodedURI, version, mappingData) Mapper-->>CoyoteAdapter: MappingData (Host/Context/Wrapper 결정) CoyoteAdapter->>CatReq: request.getPathParameter(sessionUriParamName) — URL 세션 파싱 CoyoteAdapter->>CoyoteAdapter: parseSessionCookiesId(request) CoyoteAdapter->>CoyoteAdapter: parseSessionSslId(request) alt postParseSuccess == true CoyoteAdapter->>Pipeline: connector.getService().getContainer().getPipeline().isAsyncSupported() Pipeline-->>CoyoteAdapter: boolean CoyoteAdapter->>CatReq: request.setAsyncSupported(...) CoyoteAdapter->>Pipeline: getPipeline().getFirst().invoke(request, response) end alt request.isAsync() == true CoyoteAdapter->>CoyoteAdapter: async = true opt readListener != null && request.isFinished() CoyoteAdapter->>CoyoteReq: req.getReadListener().onAllDataRead() end opt !request.isAsyncCompleting() && throwable != null CoyoteAdapter->>CatReq: request.getAsyncContextInternal().setErrorState(throwable, true) end else 동기 요청 CoyoteAdapter->>CatReq: request.finishRequest() CoyoteAdapter->>CatRes: response.finishResponse() end Note over CoyoteAdapter: finally 블록 CoyoteAdapter->>CoyoteRes: res.action(ActionCode.IS_ERROR, error) opt request.isAsyncCompleting() && error CoyoteAdapter->>CoyoteRes: res.action(ActionCode.ASYNC_POST_PROCESS, null) end opt !async && postParseSuccess CoyoteAdapter->>CatReq: context.logAccess(request, response, time, false) end CoyoteAdapter->>CoyoteReq: req.getRequestProcessor().setWorkerThreadName(null) CoyoteAdapter->>CoyoteReq: req.clearRequestThread() opt !async CoyoteAdapter->>CoyoteAdapter: updateWrapperErrorCount(request, response) CoyoteAdapter->>CatReq: request.recycle() CoyoteAdapter->>CatRes: response.recycle() end

Connector.initInternal() — 서버 시작 시 1회

Connector.java:1063

// Initialize adapter
adapter = new CoyoteAdapter(this);
protocolHandler.setAdapter(adapter);

Connector가 Tomcat 서버 시작 시 initInternal()에서 CoyoteAdapter를 생성하고 ProtocolHandler에 등록합니다. 이후 HTTP 요청이 들어올 때마다 ProtocolHandler는 이 어댑터의 service()를 호출합니다.

CoyoteAdapter.service() — 요청마다 실행

1. Catalina 래퍼 재사용 확인

CoyoteAdapter.java:304~305

Request request = (Request) req.getNote(ADAPTER_NOTES);
Response response = (Response) res.getNote(ADAPTER_NOTES);

getNote(ADAPTER_NOTES)로 이미 래핑된 Catalina 객체가 있는지 확인합니다. Keep-Alive 등 같은 커넥션에서 재사용될 수 있으므로 매번 새로 만들지 않습니다.

2. 없으면 생성 및 상호 연결

CoyoteAdapter.java:308~323

if (request == null) {
    // Create objects
    request = connector.createRequest(req);
    response = connector.createResponse(res);

    // Link objects
    request.setResponse(response);
    response.setRequest(request);

    // Set as notes
    req.setNote(ADAPTER_NOTES, request);
    res.setNote(ADAPTER_NOTES, response);

    // Set query string encoding
    req.getParameters().setQueryStringCharset(connector.getURICharset());
}

createRequest(req) / createResponse(res)는 Coyote 객체를 내부에 보유한 Catalina 객체를 만듭니다. setNote()로 캐싱해두어 이후 동일 요청에서 재생성하지 않습니다.

3. X-Powered-By 헤더

CoyoteAdapter.java:325~327

if (connector.getXpoweredBy()) {
    response.addHeader("X-Powered-By", POWERED_BY);
}

connector.xmlxpoweredBy 속성이 true일 때만 응답 헤더에 추가합니다. 기본값은 false입니다.

4. 요청 스레드 등록

CoyoteAdapter.java:332

req.setRequestThread();

현재 처리 스레드를 Coyote Request에 등록합니다. 이후 비동기 처리나 에러 핸들링에서 스레드 식별에 사용됩니다.

5. postParseRequest()

CoyoteAdapter.java:336

postParseSuccess = postParseRequest(req, request, res, response);

파이프라인 진입 전 Catalina 전용 요청 파라미터를 설정하는 메서드입니다. 내부 동작은 아래에 별도 정리합니다.

6. 파이프라인 진입

CoyoteAdapter.java:338~342

if (postParseSuccess) {
    // check valves if we support async
    request.setAsyncSupported(
        connector.getService().getContainer().getPipeline().isAsyncSupported()
    );
    // Calling the container
    connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
}

getContainer()StandardEngine을 반환합니다. getPipeline().getFirst()는 파이프라인의 첫 번째 Valve인 StandardEngineValve를 반환합니다. 이 invoke() 한 줄이 이후 Catalina Valve 체인 전체를 시작합니다.

7. 동기/비동기 분기

CoyoteAdapter.java:343~372

if (request.isAsync()) {
    async = true;
    ReadListener readListener = req.getReadListener();
    if (readListener != null && request.isFinished()) {
        // ...
        if (req.sendAllDataReadEvent()) {
            req.getReadListener().onAllDataRead();
        }
    }
    Throwable throwable =
        (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
    if (!request.isAsyncCompleting() && throwable != null) {
        request.getAsyncContextInternal().setErrorState(throwable, true);
    }
} else {
    request.finishRequest();
    response.finishResponse();
}

파이프라인 처리가 끝난 뒤 request.isAsync()로 비동기 여부를 확인합니다. 동기 요청이면 finishRequest() / finishResponse()를 호출해 응답을 완료합니다.

8. finally — 액세스 로그 및 recycle

CoyoteAdapter.java:374~420

} finally {
    AtomicBoolean error = new AtomicBoolean(false);
    res.action(ActionCode.IS_ERROR, error);

    if (request.isAsyncCompleting() && error.get()) {
        res.action(ActionCode.ASYNC_POST_PROCESS, null);
        async = false;
    }

    // Access log
    if (!async && postParseSuccess) {
        long time = System.nanoTime() - req.getStartTimeNanos();
        if (context != null) {
            context.logAccess(request, response, time, false);
        } else if (response.isError()) {
            if (host != null) {
                host.logAccess(request, response, time, false);
            } else {
                connector.getService().getContainer().logAccess(request, response, time, false);
            }
        }
    }

    req.getRequestProcessor().setWorkerThreadName(null);
    req.clearRequestThread();

    // Recycle the wrapper request and response
    if (!async) {
        updateWrapperErrorCount(request, response);
        request.recycle();
        response.recycle();
    }
}

동기 요청 완료 후 request.recycle() / response.recycle()을 호출해 Catalina 객체를 풀에 반환합니다. 비동기 요청은 응답이 나중에 완료되므로 async == true이면 recycle하지 않습니다.


postParseRequest() 내부

CoyoteAdapter.java:555 — 파이프라인 진입 전 Catalina 전용 처리를 담당합니다.

5-1. scheme / secure 결정

CoyoteAdapter.java:563~574

if (req.scheme().isNull()) {
    req.scheme().setString(connector.getScheme());
    request.setSecure(connector.getSecure());
} else {
    request.setSecure(req.scheme().equals("https"));
}

AJP, HTTP/2는 프로토콜 핸들러가 scheme을 미리 설정합니다. HTTP/1.x는 SSL 활성화 시에만 설정합니다. scheme이 없으면 Connector 설정값(기본 "http", false)을 사용합니다.

5-2. URI 디코딩

CoyoteAdapter.java:638~650

req.getURLDecoder().convert(
    decodedURI.getByteChunk(),
    connector.getEncodedSolidusHandlingInternal(),
    connector.getEncodedReverseSolidusHandlingInternal()
);

%xx 퍼센트 인코딩된 URI를 실제 문자로 디코딩합니다. 이 결과가 req.decodedURI()에 저장됩니다.

5-3. Mapper.map() — Host / Context / Servlet 결정

CoyoteAdapter.java:673

connector.getService().getMapper().map(serverName, decodedURI, version, request.getMappingData());

Tomcat의 MapperserverName(Host 헤더)과 decodedURI를 기준으로 어느 Host → Context(웹앱) → Wrapper(Servlet)로 라우팅할지 결정합니다. 결과는 MappingData에 저장되어 이후 Valve들이 이 정보를 참조합니다.

5-4. 세션 ID 파싱 (3가지 경로)

CoyoteAdapter.java:716~733

// 1) URL 경로 파라미터에서
sessionID = request.getPathParameter(SessionConfig.getSessionUriParamName(request.getContext()));

// 2) 쿠키에서
parseSessionCookiesId(request);

// 3) SSL 세션에서
parseSessionSslId(request);

URL, 쿠키, SSL 세션 3가지 경로로 세션 ID를 탐색합니다. 세션 ID가 있고 해당 세션이 다른 Context 버전에 속하면 Mapper.map()을 다시 실행해 정확한 Context를 찾습니다(mapRequired = true 루프).


코드 기준 단계 정리

순서 소스 위치 코드 설명
1 Connector.java:1063 adapter = new CoyoteAdapter(this) 서버 시작 시 어댑터 생성
2 Connector.java:1064 protocolHandler.setAdapter(adapter) ProtocolHandler에 어댑터 등록
3 CoyoteAdapter.java:304 req.getNote(ADAPTER_NOTES) 기존 Catalina 래퍼 재사용 확인
4 CoyoteAdapter.java:308 connector.createRequest(req) Coyote → Catalina Request 래핑
5 CoyoteAdapter.java:314 request.setResponse(response) 요청/응답 객체 상호 연결
6 CoyoteAdapter.java:317 req.setNote(ADAPTER_NOTES, request) Catalina 래퍼 캐싱
7 CoyoteAdapter.java:321 req.getParameters().setQueryStringCharset(...) 쿼리스트링 디코딩 charset 설정
8 CoyoteAdapter.java:325 response.addHeader("X-Powered-By", ...) xpoweredBy 설정 시 헤더 추가
9 CoyoteAdapter.java:332 req.setRequestThread() 현재 처리 스레드 등록
10 CoyoteAdapter.java:563 req.scheme().setString(...) scheme / secure 결정
11 CoyoteAdapter.java:638 req.getURLDecoder().convert(...) URI 퍼센트 디코딩
12 CoyoteAdapter.java:673 mapper.map(serverName, decodedURI, ...) Host/Context/Servlet 라우팅 결정
13 CoyoteAdapter.java:716 parseSessionCookiesId(request) URL·Cookie·SSL 세션 ID 파싱
14 CoyoteAdapter.java:339 request.setAsyncSupported(...) 파이프라인 async 지원 여부 반영
15 CoyoteAdapter.java:341 getPipeline().getFirst().invoke(request, response) StandardEngineValve로 위임
16 CoyoteAdapter.java:344 request.isAsync() 분기 비동기/동기 완료 처리 분기
17 CoyoteAdapter.java:370 request.finishRequest() / response.finishResponse() 동기 요청 응답 완료
18 CoyoteAdapter.java:415 request.recycle() / response.recycle() 동기 완료 후 객체 풀 반환

다음으로 이어지는 단계

getPipeline().getFirst().invoke(request, response)에서 StandardEngineValve가 호출되고, StandardEngineValve → StandardHostValve → StandardContextValve → StandardWrapperValve 순서로 Valve 체인이 이어집니다. 그 과정에서 ApplicationFilterChain.doFilter()가 실행됩니다.