URL을 입력하면
브라우저 주소창에 https://example.com을 입력하고 엔터를 누릅니다. 화면에 페이지가 나타나기까지 불과 수백 밀리초. 하지만 그 짧은 시간 동안 브라우저는 놀라울 정도로 많은 일을 처리합니다.
이 글에서 다루는 것은 네트워크 요청 파이프라인입니다. HTML 바이트가 도착한 이후 DOM을 구축하고 화면을 그리는 과정은 브라우저 렌더링 파이프라인 시리즈의 HTML/CSS 파싱 편에서 이어집니다. 여기서는 그 이전 단계 , 즉 서버에서 바이트를 받아오기까지의 과정을 살펴봅니다.
전체 흐름을 요약하면 이렇습니다.
URL 입력 → DNS 조회 → TCP 연결 → TLS 핸드셰이크 → HTTP 요청 → HTTP 응답 → 렌더링각 단계는 이전 단계가 완료되어야 시작할 수 있습니다. 직렬 파이프라인이기 때문에, 어느 한 단계가 느리면 전체가 느려집니다.
요청 흐름 시각화
아래 시각화에서 각 단계를 하나씩 넘겨보세요. 현재 단계가 파이프라인 위에 강조됩니다.
example.com → 93.184.216.34 캐시 순서: 브라우저 → OS → 라우터 → ISP → 루트 DNS
DNS 조회
브라우저가 example.com에 접속하려면 먼저 이 도메인의 IP 주소 를 알아야 합니다. 사람은 도메인 이름으로 사이트를 기억하지만, 네트워크는 IP 주소로 통신합니다.
DNS (Domain Name System)는 이 변환을 담당합니다. 전화번호부와 비슷합니다.
조회는 캐시를 먼저 확인합니다.
1. 브라우저 DNS 캐시
2. OS DNS 캐시 (/etc/hosts 포함)
3. 라우터 캐시
4. ISP의 DNS 리졸버 캐시
5. 루트 DNS → TLD DNS → 권한 DNS (재귀 조회)캐시에 있으면 네트워크 요청 없이 즉시 IP를 얻습니다. 없으면 최대 수십~수백 밀리초가 걸리는 재귀 조회가 시작됩니다.
DNS 프리페치
브라우저는 이 비용을 줄이기 위해 DNS 프리페치 를 지원합니다. HTML의 <link> 태그로 미리 DNS를 조회해둘 수 있습니다.
<link rel="dns-prefetch" href="//cdn.example.com">
<link rel="dns-prefetch" href="//api.example.com">이렇게 하면 실제로 해당 도메인에 요청을 보내기 전에 DNS 조회가 완료되어 있습니다. 특히 서드파티 리소스(CDN, 분석 서비스, 폰트 서버)에 효과적입니다.
TCP 연결
IP 주소를 알았으니 이제 서버와 연결을 만들어야 합니다. HTTP는 TCP 위에서 동작하고, TCP 연결은 3-way handshake 로 수립됩니다.
클라이언트 → SYN → 서버 "연결하고 싶습니다"
클라이언트 ← SYN+ACK ← 서버 "좋습니다, 저도 준비됐습니다"
클라이언트 → ACK → 서버 "확인, 연결합시다"이 과정에 1 RTT (Round Trip Time)가 소요됩니다. 서울에서 미국 서부 서버까지의 RTT가 약 150ms라면, TCP 연결만으로 150ms가 사라집니다.
keep-alive
HTTP/1.1부터는 keep-alive 가 기본입니다. 한 번 수립된 TCP 연결을 재사용해서, 같은 서버에 여러 요청을 보낼 때마다 handshake를 반복하지 않습니다.
Connection: keep-aliveHTTP/2 멀티플렉싱
HTTP/1.1에서는 하나의 TCP 연결에서 한 번에 하나의 요청-응답만 처리합니다. 병렬로 리소스를 받으려면 여러 TCP 연결을 열어야 했습니다(브라우저는 보통 도메인당 6개로 제한).
HTTP/2는 하나의 TCP 연결 위에 여러 스트림을 만들어 동시에 요청과 응답을 주고받습니다. 이것을 멀티플렉싱 이라 합니다.
TLS 핸드셰이크
주소가 https://로 시작하면 TCP 연결 위에 TLS (Transport Layer Security) 핸드셰이크가 추가됩니다. 이 과정에서 클라이언트와 서버가 암호화 방식을 협상하고, 서버의 인증서를 검증합니다.
ClientHello → 지원하는 암호 스위트, 랜덤 값
ServerHello ← 선택한 암호 스위트, 인증서
키 교환 ↔ 대칭키 생성
Finished ↔ 암호화 채널 수립TLS 1.2는 2 RTT , TLS 1.3은 1 RTT가 소요됩니다. TLS 1.3에서는 이전에 연결한 적 있는 서버에 대해 0-RTT 재개 도 가능합니다.
인증서 검증은 단순하지 않습니다. 브라우저는 서버가 보낸 인증서의 체인 을 따라가며, 신뢰할 수 있는 루트 CA(Certificate Authority)까지 도달하는지 확인합니다. 만료되었거나 도메인이 일치하지 않으면 경고를 표시합니다.
연결 비용 정리
HTTP (포트 80): DNS + TCP = 2 RTT
HTTPS (포트 443): DNS + TCP + TLS 1.3 = 3 RTT
HTTPS (포트 443): DNS + TCP + TLS 1.2 = 4 RTTDNS 조회는 캐시 여부에 따라 0 RTT(캐시 히트)에서 수십 ms(재귀 조회)까지 달라집니다. 위 계산은 캐시되지 않은 단일 조회를 1 RTT로 가정한 단순화입니다.
이 비용이 바로 CDN이 중요한 이유입니다. 서버가 물리적으로 가까울수록 RTT가 줄어들고, 전체 파이프라인이 빨라집니다.
HTTP 요청과 응답
암호화된 연결이 수립되면, 드디어 HTTP 요청 을 보냅니다.
요청 구조
GET /index.html HTTP/2
Host: example.com
Accept: text/html,application/xhtml+xml
Accept-Language: ko-KR,ko;q=0.9
Cookie: session=abc123
User-Agent: Mozilla/5.0 ...요청은 메서드 , 경로 , 헤더 , 그리고 선택적으로 바디로 구성됩니다. GET은 바디가 없고, POST나 PUT은 바디를 포함합니다.
응답 구조
HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 4521
Cache-Control: max-age=3600
ETag: "abc123"응답의 상태 코드 는 요청 결과를 알려줍니다.
| 코드 | 의미 | 설명 |
|---|---|---|
| 200 | OK | 정상 응답 |
| 301 | Moved Permanently | 영구 리다이렉트. 브라우저가 새 URL로 재요청 |
| 304 | Not Modified | 캐시된 버전 사용 가능. 바디 없음 |
| 404 | Not Found | 리소스 없음 |
| 500 | Internal Server Error | 서버 오류 |
304 Not Modified 는 성능에 중요합니다. 브라우저가 If-None-Match: "abc123" 헤더를 보내면, 서버는 리소스가 변경되지 않았을 때 바디 없이 304만 반환합니다. 네트워크 대역폭을 절약하는 핵심 메커니즘입니다.
HTTP/1.1 vs HTTP/2 vs HTTP/3
세 버전의 핵심 차이를 정리합니다.
HTTP/1.1은 텍스트 기반 프로토콜입니다. 하나의 연결에서 한 번에 하나의 요청만 처리합니다. 여러 리소스를 병렬로 받으려면 여러 TCP 연결이 필요하고, 이것은 HOL(Head-of-Line) blocking 문제를 만듭니다. 앞선 요청이 느리면 뒤의 요청이 대기합니다.
HTTP/2는 바이너리 프레이밍 기반입니다. 하나의 TCP 연결 위에 여러 스트림을 멀티플렉싱합니다. HOL blocking이 HTTP 계층에서는 해결되지만, TCP 계층 에서는 여전히 존재합니다. 하나의 패킷이 유실되면 같은 연결의 모든 스트림이 대기합니다.
HTTP/3는 TCP 대신 QUIC(UDP 기반)을 사용합니다. 각 스트림이 독립적이므로 TCP 계층의 HOL blocking도 해결됩니다. 연결 수립도 빠릅니다. QUIC은 전송 계층 핸드셰이크와 TLS 핸드셰이크를 동시에 처리해 1 RTT 에 연결을 완료합니다.
HTTP/1.1: TCP + TLS 별도 | HOL blocking 있음 | 텍스트 프로토콜
HTTP/2: TCP + TLS 별도 | TCP HOL 있음 | 바이너리 프레이밍
HTTP/3: QUIC (UDP + TLS 통합) | HOL blocking 없음 | 바이너리 프레이밍다음 단계
이 글에서 살펴본 파이프라인은 브라우저가 자동으로 처리하는 흐름입니다. 하지만 JavaScript에서는 fetch API를 통해 이 파이프라인을 직접 제어할 수 있습니다.
const response = await fetch('https://api.example.com/data', {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer token123',
},
});
const data = await response.json();fetch를 호출하면 위에서 살펴본 DNS, TCP, TLS, HTTP 요청/응답이 모두 일어납니다. 다음 글에서는 fetch API의 구조와 옵션, 그리고 요청을 효율적으로 관리하는 패턴을 다룹니다.