가장 자주 받는 질문
sgcWebSockets에 새 HTTP/2 기능 — 서버 푸시, HPACK 동적 테이블 튜닝, SETTINGS 프레임 협상 — 을 게시할 때마다 누군가가 포럼에서 같은 질문을 정중하게 해요: “왜 Indy의 TIdHTTP를 확장하지 않았나요? 이미 TCP, TLS 및 HTTP/1.1을 처리하고, HTTP/2는 그 위의 또 다른 프레이밍 형식일 뿐이잖아요.”
합리적인 질문이에요. 짧은 답변은 HTTP/2가 HTTP/1.1 클라이언트에 접목할 수 있는 계층이 아니라는 것이에요 — Indy가 가정하는 거의 모든 것을 깨는 근본적으로 다른 전송이에요. 이 게시물은 HTTP/2 구현을 처음부터 빌드한 구체적인 이유, 그것이 무엇을 가져왔는지, 그리고 그것이 sgcWebSockets의 나머지에 어떻게 영향을 미쳤는지 살펴봐요.
HTTP/1.1은 텍스트, HTTP/2는 이진 프레임
Indy의 HTTP 구현은 모든 클래식 HTTP/1.1 클라이언트와 마찬가지로 CRLF 종료 요청 라인을 읽은 다음 헤더를 읽고, 그 다음 content-length 본문 또는 청크 본문을 읽어요. 라인 지향 텍스트예요. 전송은 하나의 요청, 하나의 응답, 그 다음 닫기 또는 파이프라인(그리고 실제로 아무도 파이프라인하지 않아요)이에요.
HTTP/2는 이진 프레이밍 프로토콜이에요. 연결 프리앰블 뒤의 모든 바이트는 프레임에 속해요: DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION. 프레임은 요청과 정렬되지 않아요 — 단일 요청의 헤더는 여러 HEADERS / CONTINUATION 프레임에 걸쳐 있을 수 있고, 본문은 많은 인터리브된 DATA 프레임으로 도착할 수 있으며, 다른 요청의 프레임은 동일한 소켓에서 혼합돼요. 리더는 라인 파서가 아니라 고정 길이 프레임 헤더와 가변 길이 페이로드에 대한 상태 머신이에요.
TIdHTTP.ReadHeaderFromStream 위에 이를 개조하는 것은 읽기 루프를 완전히 다시 작성하는 것을 의미해요. 그 시점에서 Indy를 확장하는 것이 아니에요 — 소켓 클래스를 공유하는 병렬 구현을 작성하는 것이에요.
다중화는 연결당 하나의 요청 가정을 깹니다
Indy의 HTTP 클라이언트 모델은 단일 비행 중 요청을 중심으로 구축되어 있어요. Get을 호출하면 메서드가 차단되고, 응답이 도착하면 처리해요. TCP 소켓은 호출 기간 동안 그 호출에 속해요. HTTP/2는 이것을 뒤집어요: 단일 연결이 각각 스트림 ID로 식별되는 많은 동시 스트림을 다중화해요. 일반적인 Apple Push Notification 연결은 응답이 순서 없이 도착하면서 동일한 TCP 소켓에서 수천 개의 독립적인 POST 요청을 보내요.
Indy의 블로킹 모델로 이를 지원하려면 (a) HTTP/2 스트림당 하나의 Indy 클라이언트 — HTTP/2의 전체 요점을 무효화 — 또는 (b) 소켓을 소유하고, 프레임을 읽고, 스트림별 큐로 디스패치하고, 호출자에게 futures/promises API를 노출하는 별도의 디스패처 스레드가 필요해요. 옵션 (b)는 본질적으로 새 HTTP/2 클라이언트를 작성하고 Indy를 얇은 소켓 래퍼로 그 아래에 두는 것이에요. 우리는 대신 플랫폼에 가장 적합한 전송으로 명시적으로 그것을 하기로 선택했어요.
HPACK은 문자열이 아닌 상태 머신이에요
HTTP/1.1 헤더는 이름-값 텍스트예요. HTTP/2 헤더는 HPACK(RFC 7541)으로 압축되며, 이는 일반적인 헤더의 정적 테이블, 연결이 진행됨에 따라 자라는 동적 테이블 및 Huffman 코딩된 리터럴을 결합해요. 헤더 세트를 인코딩하는 것은 WriteLn(Stream, 'Content-Type: application/json')이 아니에요 — 두 테이블에 대한 조회, 인덱스/리터럴-인덱싱 포함/리터럴-인덱싱 미포함/리터럴-인덱싱 안 함 표현 간의 선택, 선택적 Huffman 압축, 그리고 양쪽 끝점이 동기화 상태로 유지해야 하는 동적 테이블에 대한 업데이트예요.
테이블 크기를 잘못 가져오면 연결이 COMPRESSION_ERROR로 죽어요. 최대 헤더 테이블 크기에 대한 SETTINGS 업데이트를 적용하는 것을 잊으면 이전에 작동하던 서버가 요청을 거부하기 시작해요. 지름길은 없어요 — HPACK을 완전히 구현하거나 HTTP/2가 없는 것이에요. 우리는 정적 테이블, eviction이 있는 동적 테이블, Huffman 인코딩/디코딩 및 네 가지 표현 형식을 약 1,200줄의 Pascal로 구현했어요. 그 코드는 Indy 어디에도 존재하지 않으며 거기에 자연스러운 집이 없었을 거예요.
흐름 제어는 스트림별 및 연결별
HTTP/2에는 두 개의 계층화된 윈도우가 있어요: 연결 수준 윈도우와 스트림당 하나. 둘 다 65,535바이트에서 시작하며 피어가 WINDOW_UPDATE 프레임을 보낼 때만 자라요. 윈도우를 확인하지 않고 DATA를 쓰는 순진한 구현은 첫 64 KB 후에 연결을 멈춰요. 올바른 구현에는 윈도우가 소진되면 작성자를 일시 중지하고, 큰 본문을 윈도우 크기 청크로 분할하며, 업데이트가 도착하면 재개하는 이벤트 루프가 필요해요.
이 중 어느 것도 HTTP/1.1에 존재하지 않으므로 Indy에도 존재하지 않아요. 어딘가에 살아야 하는 실제 작업이에요 — 우리는 프레임 리더 및 HPACK 코덱과 같은 곳에 두었어요.
우리의 작성자 루프 모습
다음은 가독성을 위해 단순화된 프레임 작성자의 작은 조각이에요. 명시적인 흐름 제어 검사와 페이로드를 윈도우 제한 청크로 분할하는 루프에 주목하세요:
procedure TsgcHTTP2Stream.WriteData(const aData: TBytes; aEndStream: Boolean);
var
vOffset, vChunk, vMax: Integer;
vFlags: Byte;
begin
vOffset := 0;
while vOffset < Length(aData) do
begin
// Respect both connection and stream flow-control windows.
vMax := Min(FConnection.SendWindow, Self.SendWindow);
vMax := Min(vMax, FConnection.PeerSettings.MaxFrameSize);
if vMax <= 0 then
begin
// Block until a WINDOW_UPDATE wakes us.
FWindowEvent.WaitFor(INFINITE);
Continue;
end;
vChunk := Min(Length(aData) - vOffset, vMax);
vFlags := 0;
if aEndStream and ((vOffset + vChunk) = Length(aData)) then
vFlags := FLAG_END_STREAM;
FConnection.SendFrame(FRAME_DATA, vFlags, Self.StreamId,
Copy(aData, vOffset, vChunk));
Dec(FConnection.SendWindow, vChunk);
Dec(Self.SendWindow, vChunk);
Inc(vOffset, vChunk);
end;
end;
이것은 수십 줄이지만 HTTP/2가 얇은 애드온이 될 수 없는 이유의 핵심을 포착해요. 모든 HTTP/1.1 클라이언트에 동등한 코드 경로가 없는데, 그 개념이 단순히 존재하지 않기 때문이에요. TIdHTTP.Get 위에 볼트로 부착하면 즉시 전체 호출 규칙 — 요청당 하나의 차단 호출 — 이 잘못된 모양임을 발견해요.
ALPN, 설정 협상 및 연결 라이프사이클
TLS를 통한 HTTP/2는 ALPN(Application-Layer Protocol Negotiation)을 통해 선택되며, 이는 핸드셰이크 전에 TLS 컨텍스트에 구성되어야 해요. Indy의 OpenSSL IO 핸들러는 수년간 ALPN을 노출하지 않았어요 — 결국 추가되었어요 — 그러나 ALPN은 첫 번째 단계일 뿐이에요. TLS 핸드셰이크 후 클라이언트는 24바이트 연결 프리앰블을 보낸 다음 SETTINGS 프레임을 보내고, 서버의 SETTINGS를 기다린 후 승인해야 해요. 그래야만 연결이 요청 준비가 돼요. Windows의 SChannel은 OpenSSL과 다른 ALPN 설정 경로가 필요해요. msquic는 또 다른 것을 가지고 있어요. 이를 깔끔하게 추상화하는 것이 Indy의 기존 TLS 핸드셰이크 코드를 통해 스레드하는 것보다 깨끗한 모듈에서 더 쉬웠어요.
성능: 숫자
이 모든 작업의 보상은 측정 가능해요. 대표적인 워크로드 — Delphi 클라이언트가 최신 Windows 서버에서 APNs(HTTP/2 모드의 apns2)에 백만 개의 Apple Push Notifications를 보냄 — 에서:
TIdHTTP가 있는 HTTP/1.1, 요청당 하나의 TCP 연결: TLS 핸드셰이크 비용으로 제한된 ~110 요청/초.TIdHTTP가 있는 HTTP/1.1, 연결 재사용: 연결당 비행 중 하나 규칙으로 제한된 ~850 요청/초.- sgcWebSockets가 있는 HTTP/2, 단일 연결, 다중화: 클라이언트가 아닌 APNs 서버 측 스로틀링으로 제한된 ~9,500 요청/초.
애플리케이션 로직을 변경하지 않고 동일한 하드웨어와 동일한 네트워크에서 대략 10배 개선. 그것이 병렬 HTTP/1.1 연결로 가짜로 만드는 대신 스택을 네이티브로 빌드할 때 HTTP/2 다중화가 제공하는 가치예요.
Indy에서 재사용한 것
모든 것이 다시 작성된 것은 아니에요. 우리는 Indy의 OpenSSL TIdSSLIOHandlerSocketOpenSSL을 기본 TLS 제공자 중 하나로 사용하고(자체 ALPN 패치 포함), 기본 백엔드의 원시 소켓 계층에 Indy의 TIdTCPClient를 사용하며, Indy의 TIdHTTPServer는 HTTP/1.1 폴백을 위해 그림에 남아 있어요. HTTP/2 계층은 그 위에 위치하며 읽기 루프, 프레임 파서, HPACK, 흐름 제어 및 스트림 라이프사이클을 소유해요. 분할을 통해 TLS 배관을 공유하면서 HTTP/2 의미를 Indy의 차단 호출 모델과 독립적으로 유지할 수 있어요.
다른 프로토콜에 대한 교훈
동일한 패턴이 sgcWebSockets 전체에서 반복돼요. WebSocket은 HTTP 업그레이드에 볼트로 부착된 이진 프레이밍 프로토콜이에요 — 프레이머를 처음부터 작성했어요. MQTT 5에는 자체 가변 길이 정수 인코딩, 속성 테이블 및 흐름 제어가 있어요 — 처음부터 작성. AMQP 1.0에는 전체 타입 시스템과 링크 크레딧 흐름이 있어요 — 처음부터 작성. HTTP/3는 TCP가 아닌 QUIC 위에 있어요 — msquic / ngtcp2를 래핑해요. 공통 스레드는 모든 최신 프로토콜에 자체 상태 머신이 있으며 “HTTP/1.1의 또 다른 모드일 뿐”인 척하는 것이 깨지기 쉽고 느린 코드로 이어진다는 것이에요.
맺음말
Indy를 확장하는 것은 명백한 첫 본능이에요 — 모든 Delphi 개발자가 이미 신뢰하는 라이브러리예요. 그러나 HTTP/2는 HTTP/1.1의 확장이 아니라 재설계예요. 프레임 다중화, HPACK, 흐름 제어 및 순서 없는 응답은 각각 차단 HTTP/1.1 클라이언트에 단순히 존재하지 않는 인프라가 필요해요. 네이티브로 빌드하는 것은 실제 엔지니어링 노력이 필요했지만 HTTP/2가 약속한 자릿수 수준의 성능을 제공하는 유일한 방법이에요 — 그리고 그 이후 sgcWebSockets가 출시한 모든 다른 최신 프로토콜에 대한 템플릿을 설정했어요.