한 줄로 모두 보내는 HTTP/2
여섯 개에서 멈추는 요청부터, 매일 짜는 코드 한 줄 뒤를 봐요.
Network 탭을 열어두고 페이지를 새로고침하면 가로로 길게 늘어선 막대들이 줄을 서요. 처음 여섯 개는 동시에 출발하는 것 같은데, 일곱 번째 막대부터는 한 박자 늦게 시작하거든요. "왜 하필 6 개부터 줄을 서지?" 처음엔 그게 의문이었어요. 알고 보면 그 6 이라는 숫자는 MDN 이 정리한 브라우저 동작 이 직접 알려주는 한도예요. 그리고 이 한도가 사라진 자리에서 우리가 매일 짜는 번들과 sprite 전략까지 줄줄이 바뀌었거든요.
한 번에 하나씩 처리하던 HTTP/1.1
HTTP/1.1 시절 브라우저는 한 origin 당 TCP 연결을 최대 6개까지만 열었어요. 한 연결 위에서는 요청 하나가 응답을 다 받기 전에 다음 요청을 보낼 수 없었거든요. 그러니까 이미지 30장을 받으려면 6개 슬롯을 차례로 5번씩 돌려쓰는 식이었어요. 일곱 번째 막대가 늦게 시작하는 진짜 이유가 그거예요.
이 한계를 우회하려고 파이프라이닝(pipelining) 이 들어왔어요. 한 연결 위에 응답을 기다리지 않고 요청 여러 개를 줄세워 보내는 방식인데, 결과는 좋지 않았죠.
"Pipelining is subject to the head-of-line blocking." - MDN
요청 순서는 자유였지만 응답은 보낸 순서대로 돌려받아야 했거든요. 앞 응답이 느리면 뒷 응답들도 다 같이 멈춰버리는 거예요. 결국 파이프라이닝은 대부분 브라우저에서 기본 비활성으로 묻혔어요.
그래서 등장한 게 도메인 샤딩(domain sharding) 이에요. 같은 서버를 static1.example.com, static2.example.com 같은 다른 호스트로 노출시켜서, 브라우저가 호스트별로 6개씩 연결을 새로 열게 만드는 트릭이었어요. 30장 이미지를 두 도메인에 나누면 동시 12개까지 처리할 수 있었던 거죠. 약간 야매스러웠지만 한동안 표준 프론트엔드 최적화로 통했어요.
HTTP/2가 한 줄에 여러 칸을 만든 법
HTTP/2 의 변화는 한 문장으로 단일 TCP 연결 위에서 동시에 여러 요청을 처리 라고 정리돼요. 어떻게 가능했을까요? 메시지 자체의 형태가 바뀌었거든요.
HTTP/1.1 까지는 메시지가 사람이 읽을 수 있는 텍스트였어요. HTTP/2 는 메시지를 frame 이라는 작은 바이너리 조각으로 쪼개고, 각 frame 에 어느 stream 에 속하는지를 표시해서 한 연결 위에 마구 섞어 보내요. 받는 쪽은 stream 번호로 frame 을 다시 모아서 원래 메시지로 조립하고요.
"The ability to break down an HTTP message into independent frames, interleave them, and then reassemble them on the other end is the single most important enhancement of HTTP/2." - web.dev
이 한 줄이 HTTP/2 의 핵심이에요. frame 으로 쪼개고, 끼워 넣고, 재조립한다. 그 결과가 바로 멀티플렉싱이고요.
쪼개기
각 HTTP 메시지를 작은 바이너리 frame으로 분해해요. 헤더 frame과 데이터 frame이 분리됩니다.
끼워 넣기
여러 stream의 frame을 한 TCP 연결 위에 섞어 보내요. 큰 응답이 작은 응답을 막지 않습니다.
재조립
받는 쪽은 stream 번호로 frame을 모아 원래 메시지로 합쳐요. 애플리케이션 코드는 변화를 모릅니다.
여기에 헤더 압축까지 더해졌어요. HTTP 요청은 매번 비슷한 헤더를 들고 오거든요. User-Agent, Cookie, Accept-Encoding 같은 게 100개 요청에 100번 반복되는 식이에요. HTTP/2 는 HPACK 이라는 알고리즘으로 한 번 보낸 헤더를 색인해 두고, 다음에는 색인 번호만 보내요. 헤더가 TCP 첫 번째 패킷을 가득 채워서 본문이 한 RTT 늦어지는 함정도 같이 줄었죠.
중요한 사실 하나 짚고 갈게요. HTTP/2 는 시맨틱은 그대로 유지해요. 메서드, 상태 코드, URI, 헤더 필드 모두 HTTP/1.1 과 동일합니다. 바뀐 건 "이 시맨틱을 wire 위에 어떻게 실어 보낼 것인가" 뿐이에요. 그래서 우리가 쓰는 fetch, axios, 서버 핸들러 코드는 손대지 않고도 HTTP/2 의 효과를 그대로 받을 수 있어요.
프론트엔드 작업 방식이 바뀐 자리
여기서부터 재밌어져요. 멀티플렉싱이 들어오면서 그동안 "정답" 이라고 알려져 있던 최적화 패턴이 줄줄이 옛 얘기가 됐거든요.
가장 먼저 사라진 게 도메인 샤딩이에요. HTTP/2 에서는 한 origin 에 연결이 하나만 있어도 동시 요청 수에 사실상 제한이 없어요. 오히려 도메인을 쪼개면 각 도메인마다 새 TCP 연결, 새 TLS 핸드셰이크, 새 HPACK 색인 테이블을 만들어야 해서 손해예요. MDN 도 도메인 샤딩을 anti-pattern 으로 분류해 두었어요.
CSS sprite 도 비슷한 운명이에요. 작은 아이콘 수십 개를 한 장의 큰 이미지로 합치고 background-position 으로 잘라 쓰던 그 패턴, HTTP/1.1 시절 "요청 수 줄이기" 가 거의 종교 수준이었던 시대의 산물이거든요. 멀티플렉싱이 들어온 뒤로는 SVG sprite 같은 변형은 남아있지만, "요청 수가 무서워서 합친다" 는 동기는 많이 약해졌어요.
번들 전략도 결이 달라졌어요. 예전엔 "JS 파일 하나가 정답" 에 가까웠다면, 지금은 라우트별, 컴포넌트별로 잘게 쪼개도 부담이 적어요. 다만 여기서 오해하기 쉬운 게 있는데, 무조건 작은 파일이 좋아진 건 아니에요. 각 파일마다 압축 효율은 떨어지고, 파싱 오버헤드도 누적되거든요. "쪼개도 괜찮은 환경" 이지 "쪼개야만 하는 환경" 은 아니라는 게 정확해요.
마지막으로 알아둘 게 하나 있어요. HTTP/2 가 처음 나왔을 때 화제였던 Server Push 는 사실상 사라졌어요. 대부분 브라우저 엔진에서 구현이 빠졌고, Chrome 도 2022 년에 제거했어요. 같은 용도는 지금 <link rel="preload"> 와 103 Early Hints 가 맡고 있어요. 이미지와 폰트 최적화 글 에서 preload 가 어느 자리에서 효과를 내는지 확인할 수 있어요.
HTTP/2도 못 푼 한 가지
여기까지만 보면 HTTP/2 가 만능 같지만, 한 가지 함정이 남아있어요. 이번엔 HTTP 자체가 아니라 그 아래 TCP 의 문제예요.
HTTP/2 가 응용 계층의 head-of-line blocking 은 풀었지만, TCP 자체는 연결을 단일 byte stream 으로 다뤄요. 그래서 패킷 하나가 손실되면 TCP 가 그 패킷의 재전송을 기다리는 동안 같은 연결 위의 모든 stream 이 같이 멈춰요.
"a lost or reordered packet causes all active transactions to experience a stall regardless of whether that transaction was directly impacted by the lost packet." - RFC 9114
손실된 패킷과 무관한 stream 까지 같이 멈춰버려요. 응용 계층에서는 멀티플렉싱돼 있어도, 운반 수단이 한 줄이라서 어딘가 끊기면 전부 영향을 받는 거죠.
이 문제를 풀려고 QUIC 위에서 도는 HTTP/3 가 나왔어요. QUIC 은 stream 별로 독립적인 신뢰성을 갖도록 설계돼서, 한 stream 의 패킷 손실이 다른 stream 을 막지 않아요. Network 탭의 protocol 컬럼에서 h3 가 보이기 시작한 게 그 결과고요.
브라우저가 화면을 그리는 첫 단계 가 네트워크에서 바이트를 받아오는 거잖아요. 그 바이트가 어떤 줄에 어떻게 실려 오는지 알면, 6 이라는 숫자가 보이고, 멀티플렉싱이 보이고, 우리가 짜는 코드의 트레이드오프도 같이 보이기 시작해요. 다음 편은 그 바이트가 도착한 다음, JS 한 줄이 어떻게 실행되는지 이야기예요. Promise 가 setTimeout 보다 먼저 실행되는 자리부터 시작합니다.
참고 자료
- MDN - Evolution of HTTP
HTTP/1.x 부터 HTTP/2 까지의 변화, 멀티플렉싱과 HPACK 정리
- MDN - Connection management in HTTP/1.x
도메인당 6개 연결, 파이프라이닝의 head-of-line blocking, 도메인 샤딩 anti-pattern
- MDN - Glossary: HTTP/2
HTTP/2 정의, 시맨틱 동일성, Server Push deprecated 상태
- RFC 9113 - HTTP/2
HTTP/2 최신 명세, stream 과 frame, HPACK 정의
- RFC 9114 - HTTP/3
TCP head-of-line blocking 의 한계와 QUIC 의 stream 독립성 근거
- web.dev - Introduction to HTTP/2
frame 분할, interleave, 재조립이 HTTP/2 의 핵심임을 정리