namalzi

← 글 목록

Tomcat 소스 파헤치기 15: HTTP/2와 WebSocket — 업그레이드로 갈아타는 프로토콜

Tomcat · 게시

HTTP/1.1로 시작한 연결이 어떻게 HTTP/2나 WebSocket으로 바뀌는지 Tomcat 소스로 따라갑니다. UpgradeProtocol과 UpgradeToken, Http11Processor의 업그레이드 처리, 연결 하나에 스트림 여럿을 싣는 Http2UpgradeHandler, 필터 하나로 들어오는 WebSocket의 WsFilter와 WsHttpUpgradeHandler를 h2c 요청과 JDK WebSocket 클라이언트로 직접 확인합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 열다섯 번째 글입니다. 14편에서는 요청이 컨테이너 안에서 인증 밸브를 지나는 길을 봤습니다. 지금까지 본 요청은 모두 HTTP/1.1이었습니다. 요청 하나가 오면 응답 하나를 보내고, 같은 연결로 다음 요청을 기다리는 방식입니다. 이번에는 같은 TCP 연결 위에서 말하는 방식 자체를 바꾸는 두 프로토콜, 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>
    -->

conf/server.xml 87–96행

<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행

TLS 없는 8080 커넥터에 Http2Protocol을 넣으면 앞의 표에만 h2c가 들어갑니다. 기동 로그에 그 사실이 한 줄 찍히는 것을 예제 1에서 봅니다.

기동할 때 업그레이드 프로토콜이 두 표에 들어가는 길. server.xml의 UpgradeProtocol 요소(className이 Http2Protocol)는 Digester가 Connector.addUpgradeProtocol()로 넘겨 프로토콜 핸들러의 목록에 쌓인다. AbstractHttp11Protocol.init()은 엔드포인트를 초기화하기 전에 configureUpgradeProtocol(Http2Protocol)을 부른다. getHttpUpgradeName(isSSLEnabled)는 TLS가 아니면 h2c, TLS면 null을 돌려주므로, 8080처럼 TLS 없는 커넥터에서는 httpUpgradeProtocols 표에 h2c → Http2Protocol이 들어가고 Upgrade: 헤더 이름으로 getUpgradeProtocol(name)이 찾는다. getAlpnName()은 h2이고 TLS일 때만 쓰므로, 8443처럼 TLS 커넥터에서는 negotiatedProtocols 표에 h2 → Http2Protocol이 들어가고 endpoint.addNegotiatedProtocol(h2)도 불리며, ALPN으로 고른 이름으로 getNegotiatedProtocol(name)이 찾는다. 왼쪽은 예제 1의 8080 커넥터가 가는 길, 오른쪽은 TLS 커넥터에서만 생기는 길입니다.

2. HTTP/2로 들어오는 세 개의 문

연결이 새로 오면 5편에서 본 작업 스레드가 AbstractProtocol.ConnectionHandler.process()를 부릅니다. HTTP/2로 가는 길은 이 함수와 Http11Processor 안에 세 갈래로 나 있습니다.

HTTP/2로 들어오는 세 개의 문. 새 연결이 오면 ConnectionHandler.process가 먼저 TLS 핸드셰이크에서 ALPN으로 h2를 골랐는지 본다. 골랐으면 getNegotiatedProtocol('h2')로 바로 Http2Protocol에 간다. 아니면 Http11Processor.service가 HTTP/1.1로 읽기 시작하는데, 첫 바이트가 PRI * HTTP/2.0이면 업그레이드 토큰 없이 UPGRADING을 돌려주어 getUpgradeProtocol('h2c')로 가고, Upgrade: h2c 헤더가 있으면 101로 답한 뒤 그 요청을 스트림 1로 삼는다. 그 밖이면 보통의 HTTP/1.1 요청으로 CoyoteAdapter.service에 간다. 세 길 모두 Http2Protocol을 거쳐 Http2UpgradeHandler에 닿고, NIO 기본값이면 Http2AsyncUpgradeHandler다. 브라우저가 쓰는 길은 왼쪽(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행

차례로 읽으면 이렇습니다.

  1. Connection 헤더에 upgrade가 있으면 Upgrade 헤더의 이름으로 1절의 표(httpUpgradeProtocols)를 찾습니다. 표에 없는 이름이면 아무 일도 없이 아래로 내려가 보통의 요청으로 처리합니다. 이것이 WebSocket이 지나는 길입니다(5절).
  2. accept()가 받아들이는지 묻습니다. Http2Protocol.accept()는 HTTP2-Settings 헤더가 정확히 한 개이고 Connection 헤더에 HTTP2-Settings가 들어 있는지만 봅니다(175–194행).
  3. 요청을 복제해 두고, 남은 본문을 읽어 버립니다.
  4. 상태를 101로 정하고 Connection·Upgrade 헤더를 붙여 응답을 닫습니다(ActionCode.CLOSE). 접근 로그에도 이 101을 한 줄 남깁니다(getAdapter().log(...)).
  5. 넘겨받을 쪽(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

스레드 번호는 실행마다 다릅니다. 읽을 것이 세 가지 있습니다.

접근 로그(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행

프로세서를 갈아 끼우는 고리. Http11Processor.service가 요청 줄과 헤더를 읽은 뒤 Connection 헤더에 upgrade가 있는지 본다. 있고 Upgrade 이름이 설정된 UpgradeProtocol이면 Coyote가 직접 101로 답하고 UpgradeToken을 만든다(h2c). 없거나 모르는 이름이면 CoyoteAdapter.service로 밸브, 필터, 서블릿을 지나고, 서블릿이 request.upgrade를 부르면 UpgradeToken을 만든다(WebSocket). 보통의 응답이면 keep-alive로 다음 요청을 기다린다. 두 업그레이드 길 모두 SocketState.UPGRADING을 돌려주고, ConnectionHandler.process가 프로세서를 UpgradeProcessorInternal로 갈아 끼우고 handler.init을 부른다. 다음 바이트부터는 upgradeDispatch가 받는다. HTTP/2(왼쪽)와 WebSocket(가운데)은 토큰을 만드는 자리가 다를 뿐, 그 뒤의 길은 같습니다.

고리가 하는 일을 정리하면 이렇습니다.

  1. 다 읽지 못한 바이트를 돌려놓습니다. Http11Processor가 버퍼에 읽어 둔 나머지(getLeftoverInput())를 소켓 래퍼에 unRead()로 되돌립니다. 101 바로 뒤에 클라이언트가 보낸 HTTP/2 서두나 WebSocket 프레임이 이미 버퍼에 들어와 있을 수 있기 때문입니다. 버리면 새 프로토콜이 첫 바이트를 잃습니다.
  2. Http11Processor를 반납합니다(release(processor)). 이 연결에서 HTTP/1.1 프로세서의 일은 끝났고, 다른 연결이 다시 쓸 수 있게 재활용 목록으로 돌아갑니다.
  3. 새 프로세서를 붙입니다. 토큰이 없으면(prior knowledge) Http2Protocol.getProcessor(), 있으면 createUpgradeProcessor()입니다. 뒤쪽은 넘겨받는 객체가 Tomcat 내부용(InternalHttpUpgradeHandler)이면 UpgradeProcessorInternal을, 웹앱이 직접 만든 HttpUpgradeHandler면 UpgradeProcessorExternal을 고릅니다(AbstractHttp11Protocol.java 1177–1185행). HTTP/2와 Tomcat의 WebSocket은 둘 다 내부용이라 앞쪽입니다.
  4. 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라는 낱말은 두 층에서 쓰입니다.

둘이 늘 같이 다니지는 않습니다. 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에 줄을 섭니다. 연결 하나가 스레드 풀을 다 차지하지 못하게 하는 장치입니다.

연결 하나에 스트림 여럿. TCP 연결 하나(connection=3)에서 Http2Parser.readFrame이 프레임을 하나씩 읽는다. 스트림 1, 3, 5의 HEADERS 프레임이 각각 Stream 1, 3, 5가 되고, 스트림마다 StreamProcessor가 exec-7, exec-8, exec-9 스레드에서 돈다. 셋 다 HTTP/1.1과 같은 CoyoteAdapter.service를 거쳐 컨테이너로 들어간다. 응답 DATA를 쓸 때는 스트림 창과 연결 창에서 차례로 덜어 낸다. 노란 상자가 연결 안에 쌓이는 상태(스트림, 흐름 제어 창)입니다. 스레드 이름은 아래 예제 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을 허용할지, 로그인한 사용자가 누구인지는 모두 웹앱이 알고 있는 것입니다.

WebSocket 업그레이드의 흐름. 기동할 때 WsSci.onStartup이 @ServerEndpoint 클래스를 모아 WsServerContainer라는 경로별 엔드포인트 표에 넣고, WsFilter를 /*에 웹앱 필터들 뒤로 등록한다. GET /u15/echo 요청이 Upgrade: websocket 헤더와 함께 오면 밸브들과 ApplicationFilterChain을 지나 WsFilter.doFilter에 닿고, findMapping으로 엔드포인트를 찾는다. UpgradeUtil.doUpgrade가 Connection, 버전 13, 키, Origin을 검사해 버전이 없으면 426으로 답한다. 통과하면 request.upgrade로 WsHttpUpgradeHandler를 만들고 101을 정한다. Http11Processor가 끝난 뒤 WsHttpUpgradeHandler.init이 onOpen을 부르고, 그 뒤로 프레임이 오면 upgradeDispatch가 WsFrameServer를 거쳐 onMessage를 부른다. 노란 상자는 기동할 때 만들어 두는 표입니다. 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행

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을 아는 서버인지" 확인합니다. 마지막 두 줄이 업그레이드입니다.

doUpgrade()가 핸드셰이크를 검사하는 순서. 요청은 GET /u15/echo에 Upgrade: websocket, 그리고 RFC 6455의 예인 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==다. Connection 헤더에 upgrade가 없으면 400, Sec-WebSocket-Version: 13이 없으면 426과 Sec-WebSocket-Version: 13 헤더(예제 3의 두 번째 curl), 키가 올바른 base64 16바이트가 아니면 400, Origin 검사(checkOrigin)를 통과하지 못하면 403이다. 모두 통과하면 서브프로토콜과 확장을 협상해 응답 헤더를 만들고, Sec-WebSocket-Accept를 키에 258EAFA5-E914-47DA-95CA-C5AB0DC85B11을 붙여 SHA-1한 값의 base64, 곧 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=로 정한다. 마지막으로 req.upgrade(WsHttpUpgradeHandler.class)가 101과 Upgrade: websocket, Connection: upgrade를 보낸다. 키와 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=

다음은 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" 와 같은 스택) ...

스레드 번호는 실행마다 다릅니다. 세 덩어리를 비교하면 이 절의 내용이 그대로 보입니다.

예제 3의 catalina.out에서 본 세 순간. modifyHandshake는 thread=http-nio-8080-exec-6에서 ConnectionHandler.process, AbstractProcessorLight.process, Http11Processor.service, CoyoteAdapter.service, StandardWrapperValve.invoke, ApplicationFilterChain.doFilter, WsFilter.doFilter, UpgradeUtil.doUpgrade 순으로 불렸다. 핸드셰이크는 보통의 HTTP 요청으로 컨테이너를 지나고 서블릿은 없다. 101이 나가고 접근 로그에 한 줄이 남은 뒤, onOpen(session 1)은 같은 exec-6에서 ConnectionHandler.process, WsHttpUpgradeHandler.init, PojoEndpointServer.onOpen, PojoEndpointBase.doOnOpen 순으로 불렸다. Http11Processor와 CoyoteAdapter가 없으니 요청이 컨테이너를 빠져나온 뒤다. 프레임이 올 때마다 onMessage(hello와 tomcat)는 exec-7에서 ConnectionHandler.process, AbstractProcessorLight.process, UpgradeProcessorInternal.dispatch, WsHttpUpgradeHandler.upgradeDispatch, WsFrameServer와 WsFrameBase의 메서드들, PojoMessageHandlerWholeBase.onMessage 순으로 불렸고, 밸브도 필터도 지나지 않았다. 위 출력의 세 덩어리를 그대로 옮겼습니다. 스레드 번호는 이 실행의 것입니다.

접근 로그에는 핸드셰이크가 "GET /u15/echo HTTP/1.1" 101 -로 한 줄씩 남고, 그 뒤 메시지는 남지 않았습니다.

헷갈리기 쉬운 곳

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)로 결정하도록 컨테이너 안에 두었습니다.

두 업그레이드를 나란히 놓은 그림. 왼쪽 HTTP/2(h2c 업그레이드)는 HTTP의 다른 포장이다. server.xml의 Connector 안 UpgradeProtocol로 켜고, 컨테이너에 들어가기 전 Coyote의 Http11Processor가 360행에서 101을 정하며, UpgradeToken의 contextBind는 null이고 Http2UpgradeHandler(NIO에서는 Http2AsyncUpgradeHandler)가 연결을 넘겨받는다. 그 뒤에는 스트림마다 CoyoteAdapter.service()로 밸브, 필터, 서블릿을 다시 지난다. 오른쪽 WebSocket은 HTTP가 아닌 다른 대화다. 웹앱의 @ServerEndpoint를 WsSci가 찾아 켜고, Catalina의 필터 체인 안 WsFilter가 결정해 Request.upgrade() 1936행에서 101을 정하며, UpgradeToken의 contextBind는 그 웹앱의 Context이고 WsHttpUpgradeHandler가 넘겨받는다. 그 뒤에는 메시지마다 엔드포인트 메서드가 불리고 컨테이너를 지나지 않는다. 위 표의 줄들을 위에서 아래로 놓았습니다. 마지막 칸(초록)이 두 프로토콜이 갈라지는 곳입니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
<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()로 이어집니다.

다른 글