이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 열다섯 번째 글입니다. 14편에서는 요청이 컨테이너 안에서 인증 밸브를 지나는 길을 봤습니다. 지금까지 본 요청은 모두 HTTP/1.1이었습니다. 요청 하나가 오면 응답 하나를 보내고, 같은 연결로 다음 요청을 기다리는 방식입니다. 이번에는 같은 TCP 연결 위에서 말하는 방식 자체를 바꾸는 두 프로토콜, HTTP/2와 WebSocket을 봅니다.
답하려는 질문은 네 가지입니다.
- server.xml의
<UpgradeProtocol>한 줄은 소스의 어디로 들어가고, HTTP/2 연결은 어떤 문으로 들어오는가 - HTTP/1.1로 읽던 연결을 다른 프로토콜로 "갈아 끼우는" 일은 누가, 어느 시점에 하는가
- HTTP/2는 연결 하나에 요청 여럿을 어떻게 동시에 싣는가
- WebSocket 핸드셰이크는 왜 서블릿 필터에서 처리되고, 연결이 올라간 뒤의 메시지는 어디로 가는가
| 항목 | 내용 |
|---|---|
| 대상 | Apache Tomcat |
| 버전 | 12.0.0-M1 (main) |
| 커밋 | 17aa1769b0 (2026-10-23) |
| 주로 읽는 클래스 | coyote/UpgradeProtocol, coyote/UpgradeToken, coyote/http11/Http11Processor, coyote/AbstractProtocol, coyote/http2/Http2Protocol·Http2UpgradeHandler·Stream, tomcat/websocket/server/WsSci·WsServerContainer·WsFilter·UpgradeUtil·WsHttpUpgradeHandler |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
coyote/http2 패키지만 해도 자바 파일이 서른 개가 넘고, Http2UpgradeHandler.java 하나가 2,300줄입니다. HPACK 헤더 압축이나 우선순위 같은 HTTP/2의 세부는 그것만으로 글 몇 편이 됩니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기, 곧 "연결이 다른 프로토콜로 넘어가는 자리" 만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
HTTP 업그레이드는 HTTP/1.1에 있는 "이 연결에서 다른 프로토콜로 바꿔 말하자"는 약속입니다. 클라이언트가 Connection: Upgrade와 Upgrade: <프로토콜 이름> 헤더를 붙여 보내고, 서버가 받아들이면 101 Switching Protocols로 답합니다. 그 응답 뒤로는 같은 소켓에 HTTP/1.1이 아닌 바이트가 오갑니다.
HTTP/2는 HTTP의 뜻(메서드, 경로, 헤더, 상태 코드)은 그대로 두고 전송 방식만 바꾼 프로토콜입니다. 글자 대신 프레임(frame)이라는 이진 덩어리로 주고받고, 연결 하나 안에 번호 붙은 스트림(stream)을 여러 개 열어 요청·응답 쌍 하나를 스트림 하나에 싣습니다. 그래서 응답을 기다리는 동안에도 같은 연결로 다른 요청을 보낼 수 있습니다. 클라이언트가 여는 스트림 번호는 1, 3, 5처럼 홀수로 늘어납니다.
h2와 h2c는 HTTP/2로 들어가는 방법의 이름입니다. h2는 TLS(https) 위의 HTTP/2이고, TLS 핸드셰이크 때 ALPN(어떤 프로토콜로 말할지 고르는 TLS 확장)으로 정합니다. h2c는 TLS 없는(cleartext) HTTP/2로, 앞의 HTTP 업그레이드로 들어가거나 처음부터 HTTP/2로 말을 거는 prior knowledge 방식으로 들어갑니다. 주요 브라우저는 h2만 쓰고, h2c는 프록시 뒤의 서버끼리나 curl 같은 도구에서 주로 씁니다(이 문장은 소스로 확인한 내용은 아닙니다).
WebSocket은 연결을 열어 둔 채 양쪽이 아무 때나 메시지를 보낼 수 있게 하는 프로토콜입니다. HTTP 업그레이드(Upgrade: websocket)로 시작하고, 101 뒤로는 WebSocket 프레임이 오갑니다. 자바에는 서버 쪽 표준 API(Jakarta WebSocket, @ServerEndpoint)가 있고, Tomcat은 그것을 org.apache.tomcat.websocket 패키지로 구현합니다. Spring의 WebSocket 지원이 결국 이 구현을 부른다는 것은 Spring 25편에서 봤습니다.
Coyote와 Catalina는 2편에서 본 Tomcat의 두 덩어리입니다. Coyote는 소켓에서 바이트를 읽어 요청 객체를 만드는 커넥터이고, Catalina는 그 요청을 Engine → Host → Context → Wrapper(서블릿)로 보내는 서블릿 컨테이너입니다. 둘 사이를 CoyoteAdapter가 잇습니다(7편). 이 글의 큰 줄거리는 HTTP/2는 Coyote에서, WebSocket은 Catalina에서 결정된다는 것입니다.
프로세서(Processor)는 연결 하나의 바이트를 해석하는 Coyote 객체입니다. HTTP/1.1이면 Http11Processor입니다(6편). 업그레이드란 소스로 말하면 연결에 붙은 프로세서를 다른 것으로 바꾸는 일입니다.
1. 설정 한 줄: <UpgradeProtocol>과 UpgradeProtocol 인터페이스
Tomcat에 HTTP/2를 켜려면 커넥터 안에 한 줄을 넣습니다. 배포판의 conf/server.xml에도 주석으로 막아 둔 예가 있습니다.
<!--
<Connector port="8443" protocol="org.apache.coyote.http11.Http11NioProtocol"
maxThreads="150" SSLEnabled="true">
<UpgradeProtocol className="org.apache.coyote.http2.Http2Protocol" />
<SSLHostConfig>
<Certificate certificateKeystoreFile="conf/localhost-rsa.jks"
certificateKeystorePassword="changeit" type="RSA" />
</SSLHostConfig>
</Connector>
-->
<UpgradeProtocol> 요소는 3편에서 본 Digester 규칙 하나로 객체가 됩니다. className의 클래스를 만들고, 속성을 setter로 넣고, 부모인 Connector의 addUpgradeProtocol()에 넘깁니다(startup/Catalina.java 582–586행). Connector는 그것을 다시 프로토콜 핸들러(Http11NioProtocol)에게 넘기고, 핸들러는 목록에 쌓아 둡니다.
Http2Protocol이 구현하는 UpgradeProtocol 인터페이스는 메서드가 몇 개 안 됩니다. 핵심만 남기면 이렇습니다.
public interface UpgradeProtocol {
String getHttpUpgradeName(boolean isSSLEnabled);
byte[] getAlpnIdentifier();
String getAlpnName();
Processor getProcessor(SocketWrapperBase<?> socketWrapper, Adapter adapter);
InternalHttpUpgradeHandler getInternalUpgradeHandler(SocketWrapperBase<?> socketWrapper, Adapter adapter,
Request request);
boolean accept(Request request);
// ...
}
java/org/apache/coyote/UpgradeProtocol.java 26–111행 (주석을 빼고 줄임)
| 메서드 | 뜻 | Http2Protocol의 답 |
|---|---|---|
getHttpUpgradeName(isSSLEnabled) |
Upgrade: 헤더에 적힐 이름 |
TLS가 아니면 "h2c", TLS면 null |
getAlpnName() |
ALPN으로 고를 때의 이름 | "h2" |
getProcessor(...) |
업그레이드 요청 없이 바로 이 프로토콜로 시작할 프로세서 | UpgradeProcessorInternal |
getInternalUpgradeHandler(...) |
연결을 넘겨받아 처리할 객체 | Http2UpgradeHandler 또는 Http2AsyncUpgradeHandler |
accept(request) |
업그레이드 요청을 받아들일지 | HTTP2-Settings 헤더 검사 |
세 이름은 Http2Protocol 맨 위에 상수로 있습니다(81–83행). TLS면 업그레이드 이름을 null로 돌려주는 까닭은 인터페이스 주석에 있습니다. "HTTP/2 같은 프로토콜은 TLS가 아닌 연결에서만 HTTP 업그레이드를 지원한다"(UpgradeProtocol.java 31–32행). TLS 위에서는 ALPN으로 고릅니다.
@Override
public String getHttpUpgradeName(boolean isSSLEnabled) {
if (isSSLEnabled) {
return null;
} else {
return HTTP_UPGRADE_NAME;
}
}
java/org/apache/coyote/http2/Http2Protocol.java 141–148행
기동할 때 두 개의 표가 만들어진다
Http11NioProtocol의 부모 AbstractHttp11Protocol.init()은 엔드포인트를 초기화하기 전에 업그레이드 프로토콜들을 등록합니다. 주석이 이유를 말합니다. 엔드포인트가 TLS를 준비하면서 "ALPN으로 내세울 프로토콜 목록"을 써야 하기 때문입니다(85–105행). 등록하는 함수는 이름을 두 표에 나눠 넣습니다.
private void configureUpgradeProtocol(UpgradeProtocol upgradeProtocol) {
// HTTP Upgrade
String httpUpgradeName = upgradeProtocol.getHttpUpgradeName(getEndpoint().isSSLEnabled());
boolean httpUpgradeConfigured = false;
if (httpUpgradeName != null && !httpUpgradeName.isEmpty()) {
httpUpgradeProtocols.put(httpUpgradeName, upgradeProtocol);
httpUpgradeConfigured = true;
getLog().info(sm.getString("abstractHttp11Protocol.httpUpgradeConfigured", getName(), httpUpgradeName));
}
// ALPN
String alpnName = upgradeProtocol.getAlpnName();
if (alpnName != null && !alpnName.isEmpty()) {
// ALPN is only available with TLS
if (getEndpoint().isSSLEnabled()) {
negotiatedProtocols.put(alpnName, upgradeProtocol);
getEndpoint().addNegotiatedProtocol(alpnName);
getLog().info(sm.getString("abstractHttp11Protocol.alpnConfigured", getName(), alpnName));
} else {
// ...
}
}
}
java/org/apache/coyote/http11/AbstractHttp11Protocol.java 864–894행
httpUpgradeProtocols—Upgrade:헤더의 이름(h2c)으로 찾는 표.getUpgradeProtocol(name)이 이 표를 봅니다(902–904행).negotiatedProtocols— ALPN으로 고른 이름(h2)으로 찾는 표.getNegotiatedProtocol(name)이 이 표를 봅니다(897–899행).
TLS 없는 8080 커넥터에 Http2Protocol을 넣으면 앞의 표에만 h2c가 들어갑니다. 기동 로그에 그 사실이 한 줄 찍히는 것을 예제 1에서 봅니다.
왼쪽은 예제 1의 8080 커넥터가 가는 길, 오른쪽은 TLS 커넥터에서만 생기는 길입니다.
2. HTTP/2로 들어오는 세 개의 문
연결이 새로 오면 5편에서 본 작업 스레드가 AbstractProtocol.ConnectionHandler.process()를 부릅니다. HTTP/2로 가는 길은 이 함수와 Http11Processor 안에 세 갈래로 나 있습니다.
브라우저가 쓰는 길은 왼쪽(ALPN) 하나입니다. 가운데 두 길은 TLS 없는 h2c입니다.
문 1: ALPN (https)
프로세서를 만들기 전에 소켓에 "협상된 프로토콜"이 있는지부터 봅니다.
if (processor == null) {
String negotiatedProtocol = wrapper.getNegotiatedProtocol();
// OpenSSL typically returns null whereas JSSE typically
// returns "" when no protocol is negotiated
if (negotiatedProtocol != null && !negotiatedProtocol.isEmpty()) {
UpgradeProtocol upgradeProtocol = getProtocol().getNegotiatedProtocol(negotiatedProtocol);
if (upgradeProtocol != null) {
processor = upgradeProtocol.getProcessor(wrapper, getProtocol().getAdapter());
java/org/apache/coyote/AbstractProtocol.java 1264–1271행
TLS 핸드셰이크에서 클라이언트와 h2로 합의했으면 Http11Processor는 아예 만들어지지 않습니다. HTTP/1.1 바이트가 한 번도 오가지 않으니 "업그레이드"라기보다 처음부터 HTTP/2입니다. TLS와 ALPN 자체는 이 글에서 다루지 않습니다. httpd에서 같은 일을 하는 mod_ssl의 ALPN 콜백은 httpd 15편에서 잠깐 봤습니다.
문 2: prior knowledge — 첫 바이트가 PRI * HTTP/2.0
HTTP/2 클라이언트는 연결을 열자마자 정해진 24바이트, 연결 서두(connection preface)를 보냅니다. 일부러 HTTP/1.1 요청 줄처럼 보이게 만든 글자입니다. Http11InputBuffer는 요청 줄을 읽기 시작할 때 이것과 비교합니다.
if (!keptAlive && byteBuffer.position() == 0 && byteBuffer.limit() >= CLIENT_PREFACE_START.length) {
boolean prefaceMatch = true;
for (int i = 0; i < CLIENT_PREFACE_START.length && prefaceMatch; i++) {
if (CLIENT_PREFACE_START[i] != byteBuffer.get(i)) {
prefaceMatch = false;
}
}
if (prefaceMatch) {
// HTTP/2 preface matched
parsingRequestLinePhase = -1;
return false;
}
}
java/org/apache/coyote/http11/Http11InputBuffer.java 348–360행
CLIENT_PREFACE_START는 "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"입니다(54–55행). 일치하면 단계 번호를 -1로 두고 false를 돌려줍니다. Http11Processor.service()는 그 -1을 보고 곧바로 SocketState.UPGRADING을 돌려줍니다(273–277행). 업그레이드 토큰은 없습니다. 그 뒤는 3절에서 볼 ConnectionHandler의 몫인데, 토큰이 없으면 "HTTP/2로 바로 온 연결"로 보고 getUpgradeProtocol("h2c")를 찾습니다(1326–1333행).
!keptAlive 조건 때문에 이 비교는 연결의 첫 요청에서만 합니다. HTTP/1.1로 요청 몇 개를 주고받은 뒤에 갑자기 서두를 보내는 것은 HTTP/2로 받아 주지 않습니다.
문 3: Upgrade: h2c 헤더
세 번째는 이름 그대로의 HTTP 업그레이드입니다. 요청 줄과 헤더를 다 읽은 직후, 컨테이너로 넘기기 전에 Http11Processor가 직접 처리합니다.
// Has an upgrade been requested?
if (isConnectionToken(request.getMimeHeaders(), "upgrade")) {
// Check the protocol
String requestedProtocol = request.getHeader("Upgrade");
UpgradeProtocol upgradeProtocol = protocol.getUpgradeProtocol(requestedProtocol);
if (upgradeProtocol != null) {
if (upgradeProtocol.accept(request)) {
// Create clone of request for upgraded protocol
Request upgradeRequest = null;
try {
upgradeRequest = cloneRequest(request);
/*
* Make sure any remaining body is swallowed before we start processing the upgraded
* protocol.
*/
inputBuffer.endRequest();
} catch (ByteChunk.BufferOverflowException ioe) {
// ...
}
if (upgradeRequest != null && !getErrorState().isError()) {
// Complete the HTTP/1.1 upgrade process
response.setStatus(HttpServletResponse.SC_SWITCHING_PROTOCOLS);
response.setHeader("Connection", "Upgrade");
response.setHeader("Upgrade", requestedProtocol);
action(ActionCode.CLOSE, null);
getAdapter().log(request, response, 0);
// Continue processing using new protocol
InternalHttpUpgradeHandler upgradeHandler = upgradeProtocol
.getInternalUpgradeHandler(socketWrapper, getAdapter(), upgradeRequest);
UpgradeToken upgradeToken = new UpgradeToken(upgradeHandler, null, null, requestedProtocol);
action(ActionCode.UPGRADE, upgradeToken);
return SocketState.UPGRADING;
}
}
}
}
java/org/apache/coyote/http11/Http11Processor.java 333–375행
차례로 읽으면 이렇습니다.
Connection헤더에upgrade가 있으면Upgrade헤더의 이름으로 1절의 표(httpUpgradeProtocols)를 찾습니다. 표에 없는 이름이면 아무 일도 없이 아래로 내려가 보통의 요청으로 처리합니다. 이것이 WebSocket이 지나는 길입니다(5절).accept()가 받아들이는지 묻습니다.Http2Protocol.accept()는HTTP2-Settings헤더가 정확히 한 개이고Connection헤더에HTTP2-Settings가 들어 있는지만 봅니다(175–194행).- 요청을 복제해 두고, 남은 본문을 읽어 버립니다.
- 상태를 101로 정하고
Connection·Upgrade헤더를 붙여 응답을 닫습니다(ActionCode.CLOSE). 접근 로그에도 이 101을 한 줄 남깁니다(getAdapter().log(...)). - 넘겨받을 쪽(
Http2UpgradeHandler)을 만들어UpgradeToken에 담고,ActionCode.UPGRADE로 프로세서에 기록한 뒤UPGRADING을 돌려줍니다.
getAdapter().service()는 이 블록보다 아래(404행)에 있으므로, h2c 업그레이드 요청은 101로 답하는 순간까지 서블릿 컨테이너에 한 번도 들어가지 않습니다. 서블릿이 이 요청을 보는 것은 HTTP/2로 넘어간 뒤 "스트림 1"로서입니다(4절).
UpgradeToken은 이 커밋에서 레코드 하나입니다. "누가 넘겨받는가(httpUpgradeHandler), 웹앱 클래스로더를 어떻게 걸까(contextBind), 객체를 무엇으로 만들까(instanceManager), 프로토콜 이름(protocol)"을 담은 쪽지입니다.
public record UpgradeToken(HttpUpgradeHandler httpUpgradeHandler, ContextBind contextBind,
InstanceManager instanceManager, String protocol) {
java/org/apache/coyote/UpgradeToken.java 33–34행
h2c는 Coyote 안에서 끝나는 일이라 웹앱이 없으므로 가운데 둘이 null입니다(369행). WebSocket에서는 채워집니다(5절).
예제 1: h2c를 켜기 전과 후
server.xml의 8080 커넥터에 <UpgradeProtocol>을 넣기 전과 후에 같은 요청을 보내 봤습니다. 서블릿은 요청의 프로토콜, HTTP/2 스트림 번호, 연결 번호, 스레드 이름을 돌려주고, ?stack을 붙이면 자기를 부른 Coyote 클래스들을 아래에서 위로 찍습니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// 요청이 어떤 프로토콜로, 어느 연결의 몇 번 스트림으로, 어떤 스레드에서 왔는지 돌려준다.
@WebServlet("/info")
public class InfoServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
// ... ?sleep=ms 면 그만큼 기다린다 ...
resp.setContentType("text/plain");
PrintWriter out = resp.getWriter();
// HTTP/2 이면 스트림 번호. HTTP/1.1 에서는 이 커밋에서 null 이 온다 (API 문서는 빈 문자열이라고 한다)
String streamId = req.getProtocolRequestId();
out.println("protocol=" + req.getProtocol()
+ " stream=" + (streamId == null || streamId.isEmpty() ? "-" : streamId)
+ " connection=" + req.getServletConnection().getConnectionId()
+ " thread=" + Thread.currentThread().getName());
if (req.getParameter("stack") != null) {
// 이 서블릿을 부른 쪽 가운데 Coyote(커넥터) 클래스만 골라 아래에서 위로 찍는다
// ...
}
}
}
[예제 ex/u15-h2c/InfoServlet.java에서 줄임]
이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. 컨테이너의 curl은 8.18.0이고 nghttp2를 달고 있어 --http2(업그레이드를 시도)와 --http2-prior-knowledge(처음부터 HTTP/2)를 모두 씁니다. 먼저 배포판 그대로의 server.xml입니다.
===== [no UpgradeProtocol] curl -sv --http2 http://127.0.0.1:8080/u15/info =====
> GET /u15/info HTTP/1.1
> Upgrade: h2c
> HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA
> Connection: Upgrade, HTTP2-Settings
< HTTP/1.1 200
protocol=HTTP/1.1 stream=- connection=1 thread=http-nio-8080-exec-2
===== [no UpgradeProtocol] curl -sv --http2-prior-knowledge http://127.0.0.1:8080/u15/info =====
* Request completely sent off
* closing connection #0
(curl exit code: 16)
>는 curl이 보낸 줄, <는 받은 줄입니다. curl이 업그레이드를 청했지만 표에 h2c가 없으니 Tomcat은 못 들은 척 HTTP/1.1로 답했습니다. 업그레이드는 "해도 되겠느냐"는 제안이라 거절해도 오류가 아닙니다. 반면 처음부터 HTTP/2로 말을 건 쪽은 연결이 그냥 닫혔습니다. 서두는 알아봤지만 getUpgradeProtocol("h2c")가 null이라 SocketState.CLOSED로 끝나는 길입니다(AbstractProtocol.java 1334–1341행). curl의 종료 코드 16은 HTTP/2 계층 오류라는 뜻입니다.
이제 8080 커넥터 안에 한 줄을 넣고 다시 띄웠습니다.
===== server.xml (8080 connector) =====
70: <Connector port="8080" protocol="HTTP/1.1"
71- connectionTimeout="20000"
72- redirectPort="8443">
73- <UpgradeProtocol className="org.apache.coyote.http2.Http2Protocol" />
74- </Connector>
===== catalina.out: upgrade configured =====
The ["http-nio-8080"] connector has been configured to support HTTP upgrade to [h2c]
===== curl -sv --http1.1 http://127.0.0.1:8080/u15/info?stack =====
< HTTP/1.1 200
protocol=HTTP/1.1 stream=- connection=1 thread=http-nio-8080-exec-2
AbstractProtocol$ConnectionHandler.process
AbstractProcessorLight.process
Http11Processor.service
CoyoteAdapter.service
===== curl -sv --http2 http://127.0.0.1:8080/u15/info?stack (HTTP/1.1 Upgrade: h2c) =====
> GET /u15/info?stack HTTP/1.1
> Upgrade: h2c
> HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA
> Connection: Upgrade, HTTP2-Settings
< HTTP/1.1 101
< Connection: Upgrade
< Upgrade: h2c
< HTTP/2 200
protocol=HTTP/2.0 stream=1 connection=2 thread=http-nio-8080-exec-4
StreamRunnable.run
StreamProcessor.process
AbstractProcessorLight.process
StreamProcessor.service
CoyoteAdapter.service
스레드 번호는 실행마다 다릅니다. 읽을 것이 세 가지 있습니다.
- 기동 로그의 한 줄은 1절의
configureUpgradeProtocol()이 찍은 것입니다(TLS 없는 커넥터라 ALPN 줄은 없습니다). --http2는101을 받은 뒤 같은 연결에서HTTP/2 200을 받았습니다. 서블릿이 본 요청은HTTP/2.0의 스트림 1입니다.- 서블릿을 부른 길이 다릅니다. HTTP/1.1은
ConnectionHandler.process→Http11Processor.service→CoyoteAdapter.service이고, HTTP/2는StreamRunnable.run→StreamProcessor.service→CoyoteAdapter.service입니다.CoyoteAdapter부터 위는 같습니다. 밸브, 필터 체인, 서블릿은 HTTP/2인지 모릅니다. 4절에서 이StreamRunnable이 어디서 오는지 봅니다.
접근 로그(localhost_access_log)에는 업그레이드 요청이 두 줄로 남았습니다.
127.0.0.1 - - [time] "GET /u15/info?stack HTTP/1.1" 200 196
127.0.0.1 - - [time] "GET /u15/info?stack HTTP/1.1" 101 -
127.0.0.1 - - [time] "GET /u15/info?stack HTTP/2.0" 200 198
앞의 101은 Http11Processor가 업그레이드 블록에서 직접 남긴 것이고, 뒤의 200은 스트림 1이 컨테이너를 지나며 8편의 AccessLogValve에 남긴 것입니다. 시각은 [time]으로 바꿔 적었습니다.
헷갈리기 쉬운 곳: getProtocolRequestId()가 HTTP/1.1에서 null입니다
처음 예제를 돌렸을 때 HTTP/1.1 요청이 전부 500이 났습니다. 서블릿에서 req.getProtocolRequestId().isEmpty()를 불렀는데, 이 값이 null이었기 때문입니다. 서블릿 API 문서는 HTTP/1.x에서 빈 문자열을 돌려주라고 적어 두었습니다(jakarta/servlet/ServletRequest.java 531–551행). 그런데 Coyote의 기본 구현은 null을 돌려주고(AbstractProcessor.java 1124–1126행), 이것을 덮어쓰는 것은 HTTP/2의 StreamProcessor뿐입니다(스트림 번호, StreamProcessor.java 435–438행). Http11Processor에는 이 메서드가 없습니다. 이 커밋에서 HTTP/1.1이면 null이 온다고 보고 짜야 합니다. 예제의 서블릿은 그래서 null과 빈 문자열을 둘 다 검사합니다.
3. 프로세서를 갈아 끼우는 곳: ConnectionHandler의 UPGRADING 고리
2절의 세 길 가운데 둘(prior knowledge, Upgrade: h2c)은 Http11Processor가 SocketState.UPGRADING을 돌려주는 것으로 끝났습니다. 그 값을 받는 쪽이 AbstractProtocol.ConnectionHandler.process()의 do–while 고리입니다.
SocketState state;
do {
state = processor.process(wrapper, status);
if (state == SocketState.UPGRADING) {
// Get the HTTP upgrade handler
UpgradeToken upgradeToken = processor.getUpgradeToken();
// Restore leftover input to the wrapper so the upgrade
// processor can process it.
ByteBuffer leftOverInput = processor.getLeftoverInput();
wrapper.unRead(leftOverInput);
if (upgradeToken == null) {
// Assume direct HTTP/2 connection
UpgradeProtocol upgradeProtocol = getProtocol().getUpgradeProtocol("h2c");
if (upgradeProtocol != null) {
// Release the Http11 processor to be re-used
release(processor);
// Create the upgrade processor
processor = upgradeProtocol.getProcessor(wrapper, getProtocol().getAdapter());
} else {
// ...
// Exit loop and trigger appropriate clean-up
state = SocketState.CLOSED;
}
} else {
HttpUpgradeHandler httpUpgradeHandler = upgradeToken.httpUpgradeHandler();
// Release the Http11 processor to be re-used
release(processor);
// Create the upgrade processor
processor = getProtocol().createUpgradeProcessor(wrapper, upgradeToken);
// ...
if (upgradeToken.instanceManager() == null) {
httpUpgradeHandler.init((WebConnection) processor);
} else {
ClassLoader oldCL = upgradeToken.contextBind().bind(null);
try {
httpUpgradeHandler.init((WebConnection) processor);
} finally {
upgradeToken.contextBind().unbind(oldCL);
}
}
// ...
}
}
} while (state == SocketState.UPGRADING);
java/org/apache/coyote/AbstractProtocol.java 1315–1376행
HTTP/2(왼쪽)와 WebSocket(가운데)은 토큰을 만드는 자리가 다를 뿐, 그 뒤의 길은 같습니다.
고리가 하는 일을 정리하면 이렇습니다.
- 다 읽지 못한 바이트를 돌려놓습니다.
Http11Processor가 버퍼에 읽어 둔 나머지(getLeftoverInput())를 소켓 래퍼에unRead()로 되돌립니다. 101 바로 뒤에 클라이언트가 보낸 HTTP/2 서두나 WebSocket 프레임이 이미 버퍼에 들어와 있을 수 있기 때문입니다. 버리면 새 프로토콜이 첫 바이트를 잃습니다. Http11Processor를 반납합니다(release(processor)). 이 연결에서 HTTP/1.1 프로세서의 일은 끝났고, 다른 연결이 다시 쓸 수 있게 재활용 목록으로 돌아갑니다.- 새 프로세서를 붙입니다. 토큰이 없으면(prior knowledge)
Http2Protocol.getProcessor(), 있으면createUpgradeProcessor()입니다. 뒤쪽은 넘겨받는 객체가 Tomcat 내부용(InternalHttpUpgradeHandler)이면UpgradeProcessorInternal을, 웹앱이 직접 만든HttpUpgradeHandler면UpgradeProcessorExternal을 고릅니다(AbstractHttp11Protocol.java 1177–1185행). HTTP/2와 Tomcat의 WebSocket은 둘 다 내부용이라 앞쪽입니다. init()을 부릅니다. 새 프로토콜이 연결을 넘겨받아 첫 동작(설정 프레임 보내기,onOpen부르기)을 하는 순간입니다. 토큰에instanceManager가 있으면(웹앱이 만든 핸들러) 웹앱 클래스로더를 걸고 부릅니다.
토큰이 없는 prior knowledge 길에서는 init()을 부르지 않은 채 상태가 여전히 UPGRADING이라 고리를 한 번 더 돕니다. 이번에는 새로 붙은 UpgradeProcessorInternal.process()가 불리고, 그것이 Http2UpgradeHandler.upgradeDispatch()를 부르며, 그 첫 줄이 init(null)입니다(Http2UpgradeHandler.java 421–428행). null은 "앞선 HTTP/1.1 요청이 없다"는 뜻입니다.
이 뒤로 이 연결의 바이트는 전부 새 프로세서가 받습니다. UpgradeProcessorInternal.dispatch()는 받은 사건을 그대로 핸들러의 upgradeDispatch()에 넘기는 한 줄짜리입니다(UpgradeProcessorInternal.java 71–74행).
헷갈리기 쉬운 곳: "업그레이드"라는 이름이 두 가지를 가리킵니다
소스에서 upgrade라는 낱말은 두 층에서 쓰입니다.
- HTTP 업그레이드:
Upgrade:헤더와 101 응답이라는, 프로토콜 사이의 약속. - Coyote의 업그레이드: 연결에 붙은 프로세서를 바꾸는 일(
SocketState.UPGRADING,UpgradeProcessorInternal).
둘이 늘 같이 다니지는 않습니다. ALPN(문 1)과 prior knowledge(문 2)로 온 HTTP/2는 101도 Upgrade: 헤더도 없지만, 소스에서는 똑같이 Http2UpgradeHandler라는 "업그레이드 핸들러"가 처리합니다. HTTP/2의 연결 처리자를 업그레이드 장치 위에 얹어 만들었기 때문에 이름이 그렇게 붙었습니다. Http2UpgradeHandler라는 이름을 보고 "업그레이드로 들어온 연결만 처리한다"고 읽으면 안 됩니다.
4. 연결 하나, 스트림 여럿: Http2UpgradeHandler
스트림 1은 업그레이드 요청이다
Http2UpgradeHandler는 생성자에서 넘겨받은 HTTP/1.1 요청이 있으면 그것을 1번 스트림으로 등록합니다.
// Initial HTTP request becomes stream 1.
if (coyoteRequest != null) {
if (log.isTraceEnabled()) {
log.trace(sm.getString("upgradeHandler.upgrade", connectionId));
}
Integer key = Integer.valueOf(1);
Stream stream = new Stream(key, this, coyoteRequest);
streams.put(key, stream);
activeRemoteStreamCount.set(1);
maxProcessedStreamId = 1;
}
java/org/apache/coyote/http2/Http2UpgradeHandler.java 227–237행
예제 1에서 업그레이드한 요청이 stream=1이었던 까닭이 이것입니다. 업그레이드 요청의 HTTP2-Settings 헤더는 클라이언트의 첫 SETTINGS 프레임을 base64로 적은 것이라, init()이 그것을 풀어 클라이언트 설정으로 읽습니다(274–311행).
init()의 뒷부분이 연결을 여는 순서입니다.
// Send the initial settings frame
writeSettings();
// Make sure the client has sent a valid connection preface before we
// send the response to the original request over HTTP/2.
try {
parser.readConnectionPreface(webConnection, stream);
} catch (Http2Exception e) {
// ...
}
// ...
// Allow streams and connection to determine timeouts
socketWrapper.setReadTimeout(-1);
socketWrapper.setWriteTimeout(-1);
processConnection(webConnection, stream);
}
java/org/apache/coyote/http2/Http2UpgradeHandler.java 313–336행
서버 쪽 SETTINGS 프레임을 먼저 보내고, 클라이언트의 연결 서두를 읽어 확인한 뒤, processConnection()이 PING을 하나 보내고(왕복 시간을 재려고) 업그레이드였으면 스트림 1을 처리하러 보냅니다(344–355행). 서두를 확인하기 전에는 스트림 1의 응답을 보내지 않는다는 주석이 이 순서의 이유입니다.
프레임은 한 스레드가 읽고, 스트림은 여러 스레드가 처리한다
그다음부터 소켓에 데이터가 오면 upgradeDispatch(OPEN_READ)가 불리고, 그 안에서 parser.readFrame()을 읽을 것이 없을 때까지 되풀이합니다(448–452행). 프레임 종류는 FrameType 열거형에 있습니다. DATA(본문), HEADERS(헤더), SETTINGS, PING, GOAWAY(연결 끝내기), WINDOW_UPDATE(흐름 제어), RST(스트림 하나 취소) 같은 것입니다(FrameType.java 25–36행).
새 요청은 HEADERS 프레임으로 옵니다. 헤더를 다 받으면 headersEnd()가 그 스트림을 컨테이너로 보냅니다.
if (endOfStream) {
receivedEndOfStream(stream);
}
if (processStream) {
processStreamOnContainerThread(stream);
}
java/org/apache/coyote/http2/Http2UpgradeHandler.java 1846–1851행
void processStreamOnContainerThread(StreamProcessor streamProcessor, SocketEvent event) {
StreamRunnable streamRunnable = new StreamRunnable(streamProcessor, event);
if (streamConcurrency == null) {
socketWrapper.execute(streamRunnable);
} else {
if (getStreamConcurrency() < protocol.getMaxConcurrentStreamExecution()) {
increaseStreamConcurrency();
socketWrapper.execute(streamRunnable);
} else {
queuedRunnable.offer(streamRunnable);
}
}
}
java/org/apache/coyote/http2/Http2UpgradeHandler.java 393–405행
스트림마다 StreamProcessor를 만들어 StreamRunnable로 감싸고, 커넥터의 스레드 풀(5편의 http-nio-8080-exec-N)에 맡깁니다. 예제 1의 스택 맨 아래에 StreamRunnable.run이 있었던 것이 이 자리입니다. 프레임을 읽는 스레드는 서블릿을 기다리지 않고 다음 프레임을 읽으러 돌아가므로, 한 스트림의 서블릿이 오래 걸려도 같은 연결의 다른 스트림은 막히지 않습니다.
동시에 실행할 스트림 수에는 상한이 있습니다. 연결 하나가 받아들일 스트림은 기본 100개(maxConcurrentStreams), 그중 동시에 스레드를 쓰는 것은 기본 20개(maxConcurrentStreamExecution)이고(Http2Protocol.java 64–68행), 넘치는 것은 queuedRunnable에 줄을 섭니다. 연결 하나가 스레드 풀을 다 차지하지 못하게 하는 장치입니다.
노란 상자가 연결 안에 쌓이는 상태(스트림, 흐름 제어 창)입니다. 스레드 이름은 아래 예제 2의 한 번 실행에서 나온 것입니다.
예제 2: 연결 하나에 요청 셋
curl --http2-prior-knowledge --parallel로 요청 세 개를 한꺼번에 보냈습니다. 서블릿은 각 요청에서 0.5초씩 잡니다.
===== curl -s --http2-prior-knowledge --parallel (three requests) =====
protocol=HTTP/2.0 stream=1 connection=3 thread=http-nio-8080-exec-7
protocol=HTTP/2.0 stream=5 connection=3 thread=http-nio-8080-exec-9
protocol=HTTP/2.0 stream=3 connection=3 thread=http-nio-8080-exec-8
세 요청 모두 연결 번호가 3으로 같고, 스트림 번호는 1, 3, 5(클라이언트가 여는 홀수 번호), 스레드는 셋이 다릅니다. 줄의 순서는 실행마다 다릅니다. 같은 연결의 요청 셋이 서로를 기다리지 않고 서로 다른 작업 스레드에서 동시에 처리된 것입니다. HTTP/1.1에서는 한 연결의 응답이 요청 순서대로 하나씩 돌아와야 하므로, 여러 개를 동시에 받으려면 연결을 여러 개 열어야 합니다.
처음에는 curl에 --parallel-immediate도 붙였는데, 그러자 curl이 연결을 셋 열어 connection=3, 4, 5에 모두 stream=1이 찍혔습니다. 이 옵션은 "다중화를 기다리지 말고 바로 새 연결을 열라"는 뜻이라 이 예제에는 맞지 않았습니다.
흐름 제어 맛보기: 창 두 개
연결 하나를 여러 스트림이 나눠 쓰면, 큰 응답 하나가 상대의 버퍼를 다 채워 다른 스트림을 굶길 수 있습니다. HTTP/2는 이를 흐름 제어 창(window)으로 막습니다. 받는 쪽이 "이만큼은 더 받을 수 있다"고 알려 주고, 보내는 쪽은 그 양만큼만 DATA를 보낸 뒤 WINDOW_UPDATE로 창이 다시 열리기를 기다립니다. 창은 스트림마다 하나, 연결 전체에 하나 있고, 처음 크기는 65,535바이트입니다(ConnectionSettingsBase.java 44행).
서블릿이 쓴 응답 본문을 내보내는 Stream의 출력 버퍼에 두 창을 차례로 덜어 내는 모습이 그대로 있습니다.
int left = buffer.remaining();
while (left > 0) {
if (streamReservation == 0) {
streamReservation = reserveWindowSize(left, block);
if (streamReservation == 0) {
// Must be non-blocking.
// Note: Can't add to the writeBuffer here as the write
// may originate from the writeBuffer.
buffer.compact();
return true;
}
}
while (streamReservation > 0) {
int connectionReservation = handler.reserveWindowSize(Stream.this, streamReservation, block);
if (connectionReservation == 0) {
// ...
buffer.compact();
return true;
}
// Do the write
handler.writeBody(Stream.this, buffer, connectionReservation, !writeInProgress && closed &&
left == connectionReservation && coyoteResponse.getTrailerFields() == null);
streamReservation -= connectionReservation;
left -= connectionReservation;
}
}
java/org/apache/coyote/http2/Stream.java 1160–1187행
먼저 스트림 창에서 보낼 만큼을 예약하고(Stream.reserveWindowSize()), 그 예약분을 다시 연결 창에서 예약해(Http2UpgradeHandler.reserveWindowSize()) 받은 만큼만 씁니다. 스트림 창이 0이면 블로킹 쓰기에서는 streamWriteTimeout만큼 창이 열리기를 기다립니다(Stream.java 255–293행). 연결 창이 모자라면 그 스트림을 backLogStreams에 넣어 두었다가 창이 열릴 때 나눠 줍니다(Http2UpgradeHandler.java 1071행부터). 나눠 주는 규칙(우선순위 반영)은 이 글에서 다루지 않습니다.
서블릿 입장에서는 getOutputStream().write()가 잠시 멈춰 있을 뿐, 창이 있다는 것을 모릅니다. 상대가 느리게 읽으면 그 스트림을 처리하는 작업 스레드가 쓰기에서 기다린다는 점은 HTTP/1.1과 같습니다. 스레드를 놓아주려면 12편의 비동기 서블릿과 WriteListener를 씁니다.
헷갈리기 쉬운 곳: NIO 커넥터에서 실제로 도는 것은 Http2AsyncUpgradeHandler입니다
Http2Protocol.getInternalUpgradeHandler()는 소켓 래퍼가 비동기 I/O를 지원하면 하위 클래스 Http2AsyncUpgradeHandler를 만듭니다(Http2Protocol.java 167–172행). 그 판단(hasAsyncIO())은 소켓 래퍼에 세마포어가 있는지인데(SocketWrapperBase.java 1425–1428행), 세마포어는 엔드포인트의 useAsyncIO가 켜져 있으면 만들어지고(159–165행), useAsyncIO의 기본값은 true입니다(AbstractEndpoint.java 1730행). 그래서 설정을 따로 바꾸지 않은 NIO 커넥터에서는 Http2AsyncUpgradeHandler가 돕니다. 이 글에서 읽은 Http2UpgradeHandler의 메서드들은 대부분 그대로 물려받고, 프레임 읽기(Http2AsyncParser)와 프레임 쓰기, sendfile 쪽을 비동기 I/O로 바꿔 둔 클래스입니다(extends Http2UpgradeHandler, 46행). 앞에서 본 init()의 순서와 관련해 하나 다른 점은, 이 클래스의 processConnection()이 비어 있고 같은 일(PING, 스트림 1 처리)을 Http2AsyncParser가 클라이언트 서두를 비동기로 다 읽은 뒤 processConnectionCallback()에서 한다는 것입니다(Http2AsyncUpgradeHandler.java 113–120행, Http2AsyncParser.java 124행). 순서는 그대로입니다. 이 사실은 소스의 조건으로 확인한 것이고, 예제에서 클래스 이름을 찍어 본 것은 아닙니다.
5. WebSocket: 서블릿 필터에서 시작하는 업그레이드
이제 WebSocket입니다. 2절에서 봤듯이 Http11Processor는 Upgrade: websocket을 받아도 표에 websocket이라는 UpgradeProtocol이 없으니 아무것도 하지 않고 요청을 컨테이너로 넘깁니다. WebSocket은 서블릿 API의 HttpServletRequest.upgrade()로, 웹앱 안에서 업그레이드합니다. 왜 그렇게 만들었는지는 필요한 정보를 보면 알 수 있습니다. 어느 경로에 어떤 엔드포인트 클래스가 있는지, Origin을 허용할지, 로그인한 사용자가 누구인지는 모두 웹앱이 알고 있는 것입니다.
노란 상자는 기동할 때 만들어 두는 표입니다. 101부터 아래는 HTTP 요청이 아닙니다.
기동할 때: WsSci가 표와 필터를 만든다
tomcat-websocket.jar에는 ServletContainerInitializer인 WsSci가 들어 있습니다. 10편에서 본 대로 웹앱이 시작될 때 jar들에서 찾은 SCI가 불리고, 그때 @HandlesTypes에 적힌 형식의 클래스를 모아 넘겨받습니다.
@HandlesTypes({ ServerEndpoint.class, ServerApplicationConfig.class, Endpoint.class })
public class WsSci implements ServletContainerInitializer {
java/org/apache/tomcat/websocket/server/WsSci.java 38–39행
onStartup()은 먼저 WsServerContainer를 만들어 ServletContext 속성(jakarta.websocket.server.ServerContainer)에 걸어 두고(134–147행), 넘겨받은 클래스 가운데 @ServerEndpoint가 붙은 것을 sc.addEndpoint()로 등록합니다(48–124행). WsServerContainer는 생성자에서 필터 하나를 웹앱에 직접 붙입니다.
FilterRegistration.Dynamic fr = servletContext.addFilter("Tomcat WebSocket (JSR356) Filter", new WsFilter());
if (fr != null) {
fr.setAsyncSupported(true);
EnumSet<DispatcherType> types = EnumSet.of(DispatcherType.REQUEST, DispatcherType.FORWARD);
fr.addMappingForUrlPatterns(types, true, "/*");
}
java/org/apache/tomcat/websocket/server/WsServerContainer.java 98–105행
addMappingForUrlPatterns의 두 번째 인자 true는 "web.xml에 선언된 필터들 뒤에 걸라"는 뜻입니다. 모든 요청(/*)이 이 필터를 지나지만, 웹앱이 직접 건 필터(예: 인증 필터)가 먼저 돕니다. 필터 체인이 배열을 차례로 부르는 구조는 9편에서 봤습니다.
요청이 올 때: WsFilter → UpgradeUtil.doUpgrade()
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// This filter only needs to handle WebSocket upgrade requests
if (!sc.areEndpointsRegistered() || !UpgradeUtil.isWebSocketUpgradeRequest(request, response)) {
chain.doFilter(request, response);
return;
}
// HTTP request with an upgrade header for WebSocket present
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
// Check to see if this WebSocket implementation has a matching mapping
String path;
String pathInfo = req.getPathInfo();
if (pathInfo == null) {
path = req.getServletPath();
} else {
path = req.getServletPath() + pathInfo;
}
WsMappingResult mappingResult = sc.findMapping(path);
if (mappingResult == null) {
// No endpoint registered for the requested path. Let the
// application handle it (it might redirect or forward for example)
chain.doFilter(request, response);
return;
}
UpgradeUtil.doUpgrade(sc, req, resp, mappingResult.config(), mappingResult.pathParams());
}
java/org/apache/tomcat/websocket/server/WsFilter.java 53–85행
- 엔드포인트가 하나도 없거나,
GET이면서Upgrade헤더에websocket이 든 요청(isWebSocketUpgradeRequest(), UpgradeUtil.java 80–86행)이 아니면 그냥 다음으로 넘깁니다. 보통의 요청에서 이 필터가 하는 일은 이if한 번뿐입니다. - 경로로 엔드포인트를 찾고(
findMapping()— 먼저 정확히 같은 경로, 없으면{name}같은 템플릿을 마디 수가 같은 것끼리 비교, WsServerContainer.java 293–338행), 있으면doUpgrade()로 갑니다.chain.doFilter()를 부르지 않으므로 그 뒤의 필터와 서블릿은 불리지 않습니다. 업그레이드는 필터에서 끝납니다.
doUpgrade()는 헤더를 검사하고, 응답 헤더를 만들고, 마지막에 서블릿 API로 업그레이드합니다. 검사의 앞부분입니다.
if (!headerContainsToken(req, Constants.CONNECTION_HEADER_NAME, Constants.CONNECTION_HEADER_VALUE)) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
if (!headerContainsToken(req, Constants.WS_VERSION_HEADER_NAME, Constants.WS_VERSION_HEADER_VALUE)) {
resp.setStatus(HttpServletResponse.SC_UPGRADE_REQUIRED);
resp.setHeader(Constants.WS_VERSION_HEADER_NAME, Constants.WS_VERSION_HEADER_VALUE);
return;
}
key = req.getHeader(Constants.WS_KEY_HEADER_NAME);
if (!validateKey(key)) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
java/org/apache/tomcat/websocket/server/UpgradeUtil.java 107–120행
Connection: upgrade가 없으면 400, Sec-WebSocket-Version: 13이 없으면 426(Upgrade Required)과 함께 "13을 쓰라"는 헤더, 키가 올바른 base64 16바이트가 아니면 400입니다. 그 뒤로 Origin 검사(실패하면 403, 123–128행), 서브프로토콜과 확장 협상이 이어지고, 응답 헤더를 붙입니다(199–209행). Sec-WebSocket-Accept는 클라이언트가 보낸 키에 규격이 정한 고정 문자열(258EAFA5-..., 61–62행)을 붙여 SHA-1 하고 base64로 만든 값입니다(361–364행). 클라이언트는 이 값으로 "상대가 정말 WebSocket을 아는 서버인지" 확인합니다. 마지막 두 줄이 업그레이드입니다.
키와 Sec-WebSocket-Accept 값, 426 응답은 예제 3의 실제 입력·출력입니다.
WsHttpUpgradeHandler wsHandler = req.upgrade(WsHttpUpgradeHandler.class);
wsHandler.preInit(perSessionServerEndpointConfig, sc, wsRequest, negotiatedExtensionsPhase2, subProtocol,
transformation, pathParams, req.isSecure());
java/org/apache/tomcat/websocket/server/UpgradeUtil.java 252–254행
Spring의 StandardWebSocketUpgradeStrategy가 부르는 ServerContainer.upgradeHttpToWebSocket()도 Tomcat에서는 이 doUpgrade()를 그대로 부릅니다(WsServerContainer.java 274–283행). Spring 25편에서 "실제로 연결을 WebSocket으로 바꾸는 일은 서버가 한다"고 하고 넘어간 자리가 여기입니다. Spring을 쓰면 WsFilter 대신 DispatcherServlet 안에서 이 함수에 닿을 뿐, 그 뒤는 같습니다.
request.upgrade() — 토큰을 맡기고 101을 정한다
public <T extends HttpUpgradeHandler> T upgrade(Class<T> httpUpgradeHandlerClass)
throws IOException, ServletException {
T handler;
InstanceManager instanceManager = null;
try {
// Do not go through the instance manager for internal Tomcat classes since they don't
// need injection
if (InternalHttpUpgradeHandler.class.isAssignableFrom(httpUpgradeHandlerClass)) {
handler = httpUpgradeHandlerClass.getConstructor().newInstance();
} else {
instanceManager = getContext().getInstanceManager();
handler = (T) instanceManager.newInstance(httpUpgradeHandlerClass);
}
} catch (ReflectiveOperationException | NamingException | IllegalArgumentException | SecurityException e) {
throw new ServletException(e);
}
UpgradeToken upgradeToken = new UpgradeToken(handler, getContext(), instanceManager,
getUpgradeProtocolName(httpUpgradeHandlerClass));
coyoteRequest.action(ActionCode.UPGRADE, upgradeToken);
// Output required by RFC2616. Protocol specific headers should have
// already been set.
response.setStatus(HttpServletResponse.SC_SWITCHING_PROTOCOLS);
return handler;
}
java/org/apache/catalina/connector/Request.java 1913–1939행
Catalina의 Request가 하는 일은 핸들러 객체를 만들고, UpgradeToken을 만들어 ActionCode.UPGRADE로 Coyote 요청에 건네고, 상태를 101로 정하는 것뿐입니다. ActionCode.UPGRADE는 프로세서의 action()을 거쳐(AbstractProcessor.java 692–695행) Http11Processor.doHttpUpgrade()에 닿고, 토큰을 필드에 저장하고 HTTP 출력을 막습니다(Http11Processor.java 1353–1358행).
연결은 아직 바뀌지 않았습니다. 필터가 끝나고, 밸브들을 되짚어 나오고, CoyoteAdapter.service()가 돌아온 뒤에야 Http11Processor의 고리가 upgradeToken == null 조건(268행)에 걸려 멈추고, 함수 끝에서 isUpgrade()를 보고 UPGRADING을 돌려줍니다(478–479행). 그다음은 3절의 고리 그대로입니다. 토큰이 있으니 createUpgradeProcessor()가 UpgradeProcessorInternal을 만들고(WsHttpUpgradeHandler는 InternalHttpUpgradeHandler를 구현합니다, WsHttpUpgradeHandler.java 50행), init()을 부릅니다.
WsHttpUpgradeHandler.init()은 WsSession과 프레임 읽기 객체(WsFrameServer)를 만들고 엔드포인트의 onOpen을 부릅니다(117–167행). 연결이 열린 뒤 데이터가 오면 upgradeDispatch()가 받습니다.
@Override
public SocketState upgradeDispatch(SocketEvent status) {
switch (status) {
case OPEN_READ:
try {
return wsFrame.notifyDataAvailable();
} catch (WsIOException ws) {
close(ws.getCloseReason());
} catch (IOException ioe) {
onError(ioe);
CloseReason cr = new CloseReason(CloseCodes.CLOSED_ABNORMALLY, ioe.getMessage());
close(cr);
}
return SocketState.CLOSED;
java/org/apache/tomcat/websocket/server/WsHttpUpgradeHandler.java 176–189행
예제 3: 핸드셰이크를 손으로, 그리고 JDK 클라이언트로 왕복
받은 글자를 돌려보내는 엔드포인트 하나를 웹앱(/u15)에 두었습니다. 핸드셰이크 직전(modifyHandshake), 연결이 열릴 때(onOpen), 메시지를 받을 때(onMessage) 세 순간에 스레드 이름과 호출 스택을 catalina.out에 찍습니다. 스택에서는 Tomcat의 커넥터·컨테이너·WebSocket 클래스만 골라, 같은 메서드가 이어지면 한 번만 찍습니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// 받은 글자를 그대로 돌려보내는 WebSocket 엔드포인트.
// 핸드셰이크, 연결이 열릴 때, 메시지를 받을 때 — 세 순간에 누가 우리 코드를 부르는지 찍는다.
@ServerEndpoint(value = "/echo", configurator = EchoEndpoint.Printer.class)
public class EchoEndpoint {
// 핸드셰이크 응답을 만들기 직전에 불리는 자리 (아직 HTTP 요청 안이다)
public static class Printer extends ServerEndpointConfig.Configurator {
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
trace("modifyHandshake");
}
}
@OnOpen
public void onOpen(Session session) {
trace("onOpen (session " + session.getId() + ")");
}
@OnMessage
public String onMessage(String message) {
trace("onMessage \"" + message + "\"");
return "echo: " + message; // 돌려준 값이 그대로 상대에게 간다
}
// ... trace(): 스레드 이름과 호출 스택의 Tomcat 클래스만 찍는다 ...
}
[예제 ex/u15-websocket/EchoEndpoint.java에서 줄임]
web.xml은 없습니다. @ServerEndpoint만 붙여 두면 앞에서 본 WsSci가 찾아 등록합니다. 먼저 curl로 핸드셰이크를 손으로 보냈습니다. 키는 WebSocket 규격(RFC 6455) 본문에 예로 실린 값입니다.
===== curl: plain GET (no Upgrade header) =====
HTTP 404
===== curl: handshake without Sec-WebSocket-Version =====
HTTP/1.1 426
Sec-WebSocket-Version: 13
===== curl: full handshake (key from RFC 6455) =====
HTTP/1.1 101
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Upgrade헤더 없는 보통의GET /u15/echo는WsFilter가 그냥 넘겨, 매핑된 서블릿이 없으니 기본 서블릿의 404가 났습니다. WebSocket 경로는 서블릿 매핑(7편의 Mapper)에 들어 있지 않습니다.- 버전 헤더를 빼자 426과
Sec-WebSocket-Version: 13이 왔습니다. 위에서 본 111–115행 그대로입니다. - 다 갖추자 101이 왔고,
Sec-WebSocket-Accept가 RFC 6455에 적힌 기댓값s3pPLMBiTxaQ9kYGzzhZRbK+xOo=와 같습니다.Connection: upgrade가 소문자인 것은 Tomcat의 상수가"upgrade"이기 때문입니다(websocket/Constants.java 121행).
다음은 JDK에 들어 있는 WebSocket 클라이언트(java.net.http.WebSocket)로 붙어 메시지 두 개를 보냈습니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
WebSocket ws = HttpClient.newHttpClient().newWebSocketBuilder()
.buildAsync(URI.create(args[0]), listener)
.get(5, TimeUnit.SECONDS);
// 연결 하나 위에서 메시지 두 개를 차례로 보낸다
ws.sendText("hello", true).get();
ws.sendText("tomcat", true).get();
closed.get(5, TimeUnit.SECONDS);
[예제 ex/u15-websocket/WsClient.java에서 줄임 — listener는 받은 글자를 찍고, 두 번째 답을 받으면 sendClose로 닫는다]
===== java WsClient.java ws://127.0.0.1:8080/u15/echo =====
client: opened, subprotocol=""
client: received "echo: hello"
client: received "echo: tomcat"
client: closed 1000 "bye"
이때 서버의 catalina.out입니다(앞의 curl 핸드셰이크도 같은 줄들을 남겼는데, 클라이언트로 붙은 두 번째 연결의 것만 옮깁니다).
[u15] modifyHandshake thread=http-nio-8080-exec-6
[u15] AbstractProtocol$ConnectionHandler.process
[u15] AbstractProcessorLight.process
[u15] Http11Processor.service
[u15] CoyoteAdapter.service
[u15] StandardWrapperValve.invoke
[u15] ApplicationFilterChain.doFilter
[u15] WsFilter.doFilter
[u15] UpgradeUtil.doUpgrade
[u15] onOpen (session 1) thread=http-nio-8080-exec-6
[u15] AbstractProtocol$ConnectionHandler.process
[u15] WsHttpUpgradeHandler.init
[u15] PojoEndpointServer.onOpen
[u15] PojoEndpointBase.doOnOpen
[u15] onMessage "hello" thread=http-nio-8080-exec-7
[u15] AbstractProtocol$ConnectionHandler.process
[u15] AbstractProcessorLight.process
[u15] UpgradeProcessorInternal.dispatch
[u15] WsHttpUpgradeHandler.upgradeDispatch
[u15] WsFrameServer.notifyDataAvailable
[u15] WsFrameServer.doOnDataAvailable
[u15] WsFrameServer.onDataAvailable
[u15] WsFrameBase.processInputBuffer
[u15] WsFrameBase.processData
[u15] WsFrameBase.processDataText
[u15] WsFrameServer.sendMessageText
[u15] WsFrameBase.sendMessageText
[u15] PojoMessageHandlerWholeBase.onMessage
[u15] onMessage "tomcat" thread=http-nio-8080-exec-7
[u15] ... ("hello" 와 같은 스택) ...
스레드 번호는 실행마다 다릅니다. 세 덩어리를 비교하면 이 절의 내용이 그대로 보입니다.
modifyHandshake:Http11Processor.service→CoyoteAdapter.service→ 밸브(StandardWrapperValve) → 필터 체인 →WsFilter→UpgradeUtil.doUpgrade. 핸드셰이크는 보통의 HTTP 요청으로 컨테이너를 지나갑니다. 서블릿은 스택에 없습니다.onOpen: 같은 스레드(exec-6)인데Http11Processor도CoyoteAdapter도 없고ConnectionHandler.process바로 위가WsHttpUpgradeHandler.init입니다. 핸드셰이크 요청이 컨테이너를 완전히 빠져나온 뒤, 3절의 고리가init()을 부른 것입니다.onMessage: 메시지마다ConnectionHandler.process→UpgradeProcessorInternal.dispatch→WsHttpUpgradeHandler.upgradeDispatch→WsFrameServer. 밸브도 필터도 지나지 않습니다. 101 이후의 메시지는 HTTP 요청이 아니기 때문입니다.
위 출력의 세 덩어리를 그대로 옮겼습니다. 스레드 번호는 이 실행의 것입니다.
접근 로그에는 핸드셰이크가 "GET /u15/echo HTTP/1.1" 101 -로 한 줄씩 남고, 그 뒤 메시지는 남지 않았습니다.
헷갈리기 쉬운 곳
- WebSocket 메시지에는 서블릿 필터를 걸 수 없습니다. 위의
onMessage스택이 그 증거입니다. 인증 필터는 핸드셰이크 때 한 번만 돕니다. Spring의HandshakeInterceptor가 핸드셰이크에만 걸리는 것(Spring 25편)도 같은 이유입니다. onOpen이 불리는 곳에 웹앱 클래스로더가 걸려 있는 이유가 토큰이 아닙니다.request.upgrade()는 Tomcat 내부 클래스(InternalHttpUpgradeHandler)에는InstanceManager를 쓰지 않으므로(1920–1921행) 토큰의instanceManager가null이고, 3절의 고리는 클래스로더를 바꾸지 않은 채init()을 부릅니다(1358–1359행). 대신WsHttpUpgradeHandler.init()이 스스로 스레드의 컨텍스트 클래스로더를 웹앱 것으로 바꿨다가 되돌립니다(132–134행, 165행). 웹앱마다 클래스로더가 다른 이유는 4편에서 봤습니다.- HTTP/2 연결 위의 WebSocket(RFC 8441)은 이 커밋에서 지원하지 않습니다. 그 방식은 HTTP/2 설정
ENABLE_CONNECT_PROTOCOL을 켜고CONNECT메서드로 여는데, Tomcat은 그 설정을 받으면 "Not supported. Ignore it."이라는 주석과 함께 무시합니다(ConnectionSettingsBase.java 86–90행). HTTP/2 스트림을 처리하는StreamProcessor는doHttpUpgrade()를 덮어쓰지 않으므로, 스트림 안에서 업그레이드를 시도하면 기본 구현이UnsupportedOperationException을 던지게 되어 있습니다(AbstractProcessor.java 1075–1078행). 이 마지막 동작은 소스로만 확인했고 예제로 돌려 보지는 않았습니다. 그래서 h2c를 켠 커넥터에서도 WebSocket 연결은 HTTP/1.1로 열립니다.
6. 두 업그레이드를 나란히 놓으면
| HTTP/2 (h2c 업그레이드) | WebSocket | |
|---|---|---|
| 켜는 곳 | server.xml <Connector> 안의 <UpgradeProtocol> |
웹앱의 @ServerEndpoint (WsSci가 찾음) |
| 결정하는 층 | Coyote (Http11Processor, 컨테이너에 들어가기 전) |
Catalina (필터 체인 안의 WsFilter) |
| 101을 정하는 코드 | Http11Processor.service() 360행 |
Request.upgrade() 1936행 |
UpgradeToken의 contextBind |
null (웹앱과 무관) |
그 웹앱의 Context |
| 넘겨받는 객체 | Http2UpgradeHandler (Http2AsyncUpgradeHandler) |
WsHttpUpgradeHandler |
| 그 뒤의 요청 처리 | 스트림마다 CoyoteAdapter.service() — 밸브·필터·서블릿을 다시 지난다 |
메시지마다 엔드포인트 메서드 — 컨테이너를 지나지 않는다 |
| 업그레이드 없이 들어오는 길 | ALPN(h2), prior knowledge |
없음 |
마지막 두 줄이 두 프로토콜의 성격 차이입니다. HTTP/2는 HTTP의 다른 포장이라, 연결을 넘겨받은 뒤에도 요청 하나하나를 다시 서블릿 컨테이너로 보냅니다. 그래서 커넥터 층에서 처리하고, 웹앱은 바뀐 것을 모릅니다. WebSocket은 HTTP가 아닌 다른 대화라, 핸드셰이크만 HTTP로 하고 나면 웹앱의 엔드포인트가 연결을 직접 붙잡습니다. 그래서 웹앱이 아는 정보(경로, 엔드포인트, Origin)로 결정하도록 컨테이너 안에 두었습니다.
위 표의 줄들을 위에서 아래로 놓았습니다. 마지막 칸(초록)이 두 프로토콜이 갈라지는 곳입니다.
이 글에서 뺀 것
- HPACK 헤더 압축:
coyote/http2/HpackDecoder.java,HpackEncoder.java - 프레임 읽기의 세부:
coyote/http2/Http2Parser.java, 비동기 판Http2AsyncParser.java - 스트림의 상태 기계(idle → open → half-closed → closed):
coyote/http2/StreamStateMachine.java - 우선순위(
PRIORITY_UPDATE)와 연결 창 나눠 주기:Http2UpgradeHandler.releaseBackLog()1232행부터 - 이상한 클라이언트 막기(오버헤드 프레임 세기,
overheadCountFactor):Http2UpgradeHandler.java1552–1621행 - 연결 닫기(
GOAWAY,drainTimeout):Http2UpgradeHandler.java799행부터 - 웹앱이 직접 만든
HttpUpgradeHandler를 감싸는UpgradeProcessorExternal과UpgradeServletInputStream·UpgradeServletOutputStream:java/org/apache/coyote/http11/upgrade/ - WebSocket 프레임 읽기·쓰기와 확장(permessage-deflate):
java/org/apache/tomcat/websocket/WsFrameBase.java,PerMessageDeflate.java - WebSocket 클라이언트 쪽 구현(
WsWebSocketContainer)과 쓰기 타임아웃(WsWriteTimeout) - TLS와 ALPN 설정(
SSLHostConfig,tomcat/util/net/SSLUtil)
정리
| 개념 | 한 줄 요약 |
|---|---|
<UpgradeProtocol> |
Digester가 Connector.addUpgradeProtocol()로 넘긴다. 기동 때 h2c(업그레이드)·h2(ALPN) 표에 나뉘어 들어간다 |
| HTTP/2의 세 문 | ALPN h2, prior knowledge(PRI * HTTP/2.0), Upgrade: h2c. 앞의 둘은 101이 없다 |
Http11Processor의 업그레이드 블록 |
헤더를 읽은 직후, 컨테이너에 넘기기 전. 표에 있는 이름이면 Coyote가 직접 101 |
UpgradeToken |
넘겨받을 핸들러, 클래스로더 바인딩, InstanceManager, 프로토콜 이름을 담은 레코드 |
ConnectionHandler의 UPGRADING 고리 |
남은 바이트를 되돌리고, Http11Processor를 반납하고, 새 프로세서를 붙여 init() |
Http2UpgradeHandler |
업그레이드 요청은 스트림 1. 프레임은 한 곳에서 읽고, 스트림마다 StreamRunnable을 스레드 풀에 맡긴다 |
| 흐름 제어 | 스트림 창과 연결 창(처음 65,535바이트)에서 차례로 예약한 만큼만 DATA를 쓴다 |
WsSci·WsServerContainer |
기동 때 @ServerEndpoint를 모아 경로 표를 만들고 WsFilter를 /*에 건다 |
WsFilter·UpgradeUtil.doUpgrade() |
헤더 검사(400·426·403), Sec-WebSocket-Accept 계산, request.upgrade() |
WsHttpUpgradeHandler |
컨테이너를 빠져나온 뒤 init()에서 onOpen, 메시지마다 upgradeDispatch()에서 onMessage |
다음에 볼 것
이 글까지가 요청 하나가 Tomcat을 지나는 길에 대한 이야기였습니다. 마지막 글은 그 Tomcat을 자바 코드 안에서 직접 띄우는 내장 Tomcat(org.apache.catalina.startup.Tomcat), 실행 중인 Tomcat을 들여다보는 JMX, 웹앱마다 로그 설정을 따로 두는 JULI를 보고 연재를 마무리합니다. 16편: 내장 Tomcat, JMX, 로깅 — 그리고 연재를 마치며에서 이어집니다.
다른 연재와 비교하면 이렇습니다. httpd에서는 다른 프로토콜을 다루는 모듈이 process_connection 훅에 먼저 손을 들어 연결을 통째로 가져갑니다. mod_http2도 그렇게 끼어듭니다(httpd 9편). Tomcat은 같은 일을 "프로세서를 갈아 끼우는 고리"로 합니다. 연결 단위로 프로토콜을 바꾼다는 발상은 같고, 훅 대신 반환값(SocketState.UPGRADING)으로 신호를 보낸다는 점이 다릅니다. httpd가 WebSocket을 뒤쪽 서버로 넘기는 터널(ap_proxy_tunnel_run())은 httpd 14편에 이름만 나옵니다. 그 뒤쪽 서버가 Tomcat이면, 터널 끝에서 이 글의 WsFilter가 핸드셰이크를 받습니다. Spring 쪽에서는 Spring 25편이 이 글의 바로 위층입니다. 거기서 StandardWebSocketUpgradeStrategy가 부른 upgradeHttpToWebSocket()이 이 글의 UpgradeUtil.doUpgrade()로 이어집니다.