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명의 유저가 요청하는 경우 — 시퀀스 다이어그램
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들(StandardEngineValve → StandardHostValve → …)은 이미 라우팅이 결정된 상태를 전제로 동작합니다.
어느 Host, 어느 Context(웹 앱), 어느 Servlet으로 가야 할지, 세션이 어디 있는지를 Valve에 진입하기 전에 확정해야 합니다.
postParseRequest() 내부에서 이 세 가지를 순서대로 처리합니다.
- scheme/secure 결정 —
request.isSecure()가 Valve에서 정상 동작하려면 파이프라인 전에 설정되어야 합니다. - URI 디코딩 —
%xx인코딩 해제 없이는 URL 패턴 매칭 자체가 불가능합니다. Mapper.map()— Host 헤더와 디코딩된 URI로StandardEngine → Host → Context → Wrapper(Servlet)라우팅을 결정하고, 결과를MappingData에 저장합니다. 이후 모든 Valve가 이 정보를 참조합니다.- 세션 ID 파싱 — URL 경로 파라미터 / 쿠키 / SSL 세션 세 경로를 탐색하고, 세션에 따라 Context 버전이 달라질 수 있으면
Mapper.map()을 재실행합니다.
postParseSuccess가 false이면 파이프라인을 아예 건너뜁니다. 잘못된 요청이 Valve 체인을 통과하는 것을 막는 사전 검문소 역할을 합니다.
왜 동기와 비동기를 여기서 분기하는가
if (request.isAsync()) {
async = true;
// ...비동기 완료는 다른 스레드가 처리
} else {
request.finishRequest();
response.finishResponse();
}
Servlet 3.0 이후 AsyncContext.start()를 호출하면 응답은 다른 스레드에서 나중에 완료됩니다.
이 컨테이너 스레드가 invoke() 에서 돌아왔다고 응답이 끝난 것이 아닙니다.
async == true이면 recycle()을 하지 않고, finishResponse()도 호출하지 않습니다.
응답 소켓을 닫거나 객체를 풀에 반납하는 작업은 비동기 작업이 실제로 완료될 때까지 미뤄집니다.
시퀀스 다이어그램
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.xml의 xpoweredBy 속성이 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의 Mapper가 serverName(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()가 실행됩니다.