~10 min · caching-perf, http2, multiplexing, client-design
Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"오랫동안 이어진 ‘HTTP 요청 수를 줄여라’는 조언은 HTTP/1.1의 연결 비용과 동시 요청 제한을 우회하려는 해법이었어. HTTP/2 다중화는 그 계산을 바꿨지만, 요청 비용 자체를 없앤 것은 아니야. 옛 최적화는 버릴 게 아니라 새로운 병목을 기준으로 다시 평가해야 해."
프런트엔드 구조를 바꾼 HTTP/1.1의 제약
브라우저는 HTTP/1.1에서 보통 한 출처에 열 수 있는 동시 TCP 연결 수를 제한해 왔어. 흔히 약 6개라고 설명하지만 정확한 수는 브라우저와 환경에 따라 달라. 그보다 많은 요청은 연결이 비기를 기다려야 했고, 자산이 많은 페이지는 도메인 샤딩, 인라인화, 큰 번들 같은 우회책을 사용했어.
CSS 스프라이트 시트, 이미지 인라인화, 대규모 JS 번들링, 도메인 샤딩은 요청 수와 연결 설정 비용을 줄이려는 HTTP/1.1 시대의 대표적인 최적화야. 당시에는 합리적이었지만, HTTP/2에서는 캐시 무효화와 연결 분산 비용까지 다시 따져야 해.
HTTP/2가 바꾼 것
HTTP/2는 보통 한 출처의 TCP 연결 하나에서 여러 스트림을 동시에 운반해. 연결과 헤더에 드는 요청별 부담은 크게 줄지만 사라지지는 않고, 서버가 허용한 동시 스트림 수와 대역폭, 혼잡 제어도 한계로 남아. HPACK은 같은 연결에서 반복되는 헤더를 압축해 중복 전송을 줄여.
클라이언트 설계에는 이런 변화가 생겨.
자산을 적절한 단위로 나눌 수 있어. 모든 코드를 하나의 거대한 번들로 묶으면 작은 변경에도 전체 캐시가 무효화돼. 변경 주기와 의존성이 다른 코드를 분리하면 독립적으로 캐시할 수 있지만, 지나치게 잘게 쪼개면 요청 처리와 압축 효율이 나빠질 수 있어.
도메인 샤딩의 이점이 줄어. 여러 서브도메인으로 자산을 나누면 연결 통합 조건이 맞지 않는 한 각 출처에 TCP와 TLS 연결이 더 필요해. HTTP/1.1의 연결 제한을 피하던 기법이 HTTP/2에서는 오히려 비용이 될 수 있어.
인라인화의 기준이 달라져. CSS나 이미지를 HTML에 넣으면 요청 하나를 줄일 수 있지만 별도로 캐시할 수 없고, HTML이 바뀔 때마다 함께 전송돼. 아주 작은 핵심 자산에는 여전히 유효할 수 있으니 렌더링 경로와 재사용성을 함께 봐야 해.
이미지 스프라이트의 필요성이 줄어. 개별 이미지가 독립적으로 캐시되고 갱신될 수 있다는 장점이 커졌어. 다만 아이콘 수와 전송 형식에 따라 SVG 스프라이트 같은 묶음이 여전히 적합할 수도 있어.
HTTP/2에서도 여전히 중요한 것
압축 — 다중화와 별개의 최적화야. 본문 바이트가 줄어드는 이점은 그대로 남아.
캐시 — 가장 빠른 요청은 네트워크로 보내지 않는 요청이야. 프로토콜 버전이 바뀌어도 캐시의 가치는 줄지 않아.
자산 크기 — 요청을 동시에 보낼 수 있어도 전체 바이트와 파싱·실행 비용은 남아. 큰 자산은 여전히 큰 자산이야.
핵심 렌더링 경로 — 첫 화면에 필요한 요청과 의존성은 최소화해야 해. 다중화가 의존 순서를 없애 주지는 않아.
스트림 우선순위 — HTTP/2에는 우선순위 신호가 있지만 브라우저, 서버, CDN의 해석과 지원은 일정하지 않아. 핵심 CSS가 항상 원하는 순서로 전송될 거라고 가정하지 말고 실제 워터폴을 확인해.
최적화는 당시의 제약을 우회한 결과야. 제약이 바뀌면 예전 해법의 비용과 이익도 달라져. HTTP/2는 무조건 빠른 마법이 아니라 연결과 요청의 비용 모델을 바꾼 프로토콜이야. 오래된 빌드 파이프라인은 요청 수, 캐시 적중률, 변경 범위, 압축 효율을 함께 측정해 다시 판단해야 해.
HTTP/2 실용 체크리스트
실제 HTTP/2 협상을 확인해. 서버나 CDN이 지원한다고 가정하지 말고 curl --http2 -v와 브라우저 DevTools의 프로토콜 열로 최종 연결을 확인해.
거대 번들을 자동으로 만들지 마. Vite, esbuild, Rollup의 코드 분할 결과를 그대로 믿지도 말고, 변경 주기와 로딩 경로에 맞는 청크인지 번들 분석으로 확인해.
도메인 샤딩을 재검토해. 출처를 나눈 이유가 HTTP/1.1 연결 제한뿐이었다면 통합하는 편이 나을 수 있어. 쿠키, 보안 경계, CDN 구성처럼 다른 이유가 있다면 별도로 판단해.
ETag와 Cache-Control을 자산별로 설계해. 변경 주기가 다른 파일을 분리하면 필요한 항목만 무효화할 수 있어. 다만 파일 수를 늘리는 것 자체가 목표는 아니야.
핵심 CSS 인라인화는 측정으로 결정해. 고정된 8KB 같은 숫자를 규칙으로 삼지 말고 첫 렌더링 지연, HTML 크기, 재방문 캐시 효과를 비교해.
HOL 블로킹의 남은 경계
HTTP/2는 HTTP 스트림을 다중화하지만 그 아래에는 하나의 순서 있는 TCP 바이트 스트림이 있어. 패킷이 손실되면 TCP가 빠진 바이트를 복구할 때까지 같은 연결의 뒤쪽 데이터가 애플리케이션에 전달되지 못해 여러 HTTP/2 스트림이 함께 지연될 수 있어. HTTP/3는 QUIC 스트림별로 손실 복구 경계를 나누어 한 스트림의 손실이 다른 스트림의 전달까지 막는 문제를 줄여. 다만 혼잡한 네트워크의 대역폭과 혼잡 제어 한계까지 사라지는 것은 아니야.
cwkPippa의 HTTP/2 현실
cwkPippa 백엔드를 Uvicorn이 직접 제공하는 경로는 HTTP/1.1이야. 로컬이나 Tailscale 환경에서는 RTT와 동시 요청 수를 먼저 측정해 HTTP/2 프록시의 이득이 실제로 있는지 판단해야 해. Vite 개발 서버의 모듈 그래프와 프로덕션 빌드 청크도 같은 형태가 아니므로 “작은 파일이 많다”는 이유만으로 빠르다고 단정할 수 없어. cwk-site처럼 Vercel 엣지를 거치는 공개 경로는 클라이언트 쪽에서 HTTP/2나 HTTP/3를 협상할 수 있지만, 원본까지의 내부 연결은 별도 계층이야. 공개 배포 시에는 nginx, Caddy, CDN 같은 역방향 프록시에서 TLS와 HTTP/2를 종료하고 실제 응답 프로토콜과 성능을 검증해야 해.
Code
curl로 HTTP/2 협상 확인하기·bash
# Server 가 HTTP/2 말하는지 검증
curl -v --http2 -o /dev/null https://creativeworksofknowledge.com/ 2>&1 | grep -E '(HTTP|h2)' | head
# 봐:
# * Connected to ... via HTTP/2
# < HTTP/2 200
# 비교 위해 HTTP/1.1 강제
curl -v --http1.1 -o /dev/null https://creativeworksofknowledge.com/ 2>&1 | grep HTTP | head
# < HTTP/1.1 200 OK
# 차이 측정: parallel asset fetch
ASSETS=$(for i in {1..6}; do echo https://creativeworksofknowledge.com/asset$i.png; done)
time curl -s --http1.1 -o /dev/null $ASSETS # 별개 TCP 연결 6 (순차 혹은 cap parallel)
time curl -s --http2 -o /dev/null $ASSETS # 한 연결 위 multiplexed
순차 HTTP/1.1과 동시 HTTP/2의 차이를 보여 주는 예시·python
# Python httpx — HTTP/2 opt-in + 연결 재사용
import httpx
import time
# 60 parallel asset request — 완벽한 HTTP/2 multiplexing 데모
URLS = [f'https://creativeworksofknowledge.com/asset_{i}.png' for i in range(60)]
# 순차 HTTP/1.1 — per-connection RTT 에 제한
start = time.perf_counter()
with httpx.Client() as c: # 기본 HTTP/1.1
for url in URLS:
c.get(url)
print(f'순차 HTTP/1.1: {(time.perf_counter() - start):.1f}s')
# 동시 HTTP/2 — 한 연결 위 multiplexed
import asyncio
async def fetch_all():
async with httpx.AsyncClient(http2=True) as c:
return await asyncio.gather(*[c.get(url) for url in URLS])
start = time.perf_counter()
asyncio.run(fetch_all())
print(f'동시 HTTP/2: {(time.perf_counter() - start):.1f}s')
Vite에서 변경 주기별 청크를 나누는 구성 예시·javascript
// 현대 Vite config — 작은 chunk, 큰 bundle NOT (HTTP/2-friendly)
// vite.config.js
export default {
build: {
target: 'es2022',
rollupOptions: {
output: {
// 주요 라이브러리 당 vendor chunk 나누기 — 영원 캐시, 앱 코드 변경이 무효화 안 함
manualChunks: {
'react-vendor': ['react', 'react-dom'],
'router': ['react-router'],
'state': ['zustand', 'jotai'],
// ... 작고 집중된 chunk
},
},
},
},
};
// HTTP/2 가 이 전략 이김: 각 chunk 가 별개 파일, 독립 캐시.
// 앱 코드 버그 fix 가 200KB react-vendor chunk 안 무효화.
// HTTP/2 없으면 request 최소화 위해 큰 bundle 하나 원함; 있으면 세밀 승리.
HTTP/2를 지원하는 테스트 사이트를 골라 홈페이지와 자산 URL 목록을 준비해. 먼저 --http1.1, 다음에는 --http2로 같은 URL을 요청해 선택된 프로토콜과 연결 설정 시간을 기록해. 자산 여러 개를 비교할 때는 curl의 --parallel과 --parallel-max를 사용해 두 프로토콜 모두 같은 동시성으로 요청하고, 전체 시간과 열린 연결 수를 비교해. 대상 서버에 부담을 주지 않도록 자산 수와 반복 횟수를 제한해. 보너스로 Chrome DevTools의 네트워크 패널에서 프로토콜 열과 워터폴을 함께 확인해.
Hint
curl -v의 'Trying X.X.X.X:443' 줄은 새 연결 시도를 찾는 단서지만, DNS 주소 전환이나 재시도도 섞일 수 있어. HTTP/1.1도 지속 연결을 재사용하고 여러 연결을 병렬로 열 수 있으며, HTTP/2도 서버의 동시 스트림 한도와 연결 통합 조건에 영향을 받아. 그래서 순차 HTTP/1.1과 동시 HTTP/2를 비교하면 프로토콜이 아니라 동시성까지 함께 바뀌는 불공정한 실험이 돼. 같은 URL, 같은 동시성, 비슷한 캐시 상태로 여러 번 측정해 중앙값을 비교해.
Progress
Progress is local-only — sign in to sync across devices.