홈페이지가 표시되기 위해 어떤 파일들이 어떤 순서로 전송되나요?
- 예상 시간
- 8분
사용자가 URL을 입력하면 DNS lookup, TCP 연결, TLS handshake 이후 HTTP 요청을 보내고, 보통 HTML 문서를 먼저 받습니다. 브라우저는 HTML을 먼저 받고, 파싱 중 발견한 CSS, JavaScript, image, font 같은 하위 resource를 추가 요청하는 흐름으로 화면을 준비합니다.
CSS는 render blocking이 될 수 있고, JavaScript는 속성에 따라 HTML parsing을 막을 수 있습니다. 이미지는 보통 화면에 필요한 우선순위에 따라 요청됩니다. 실제 파일 개수와 총 용량은 사이트 구조, bundling, lazy loading, cache 상태에 따라 크게 달라집니다.
URL을 입력하면 브라우저가 다음 순서로 움직입니다.
- DNS lookup: 도메인 이름을 IP 주소로 변환합니다. 캐시가 있으면 생략됩니다.
- TCP 연결: 서버와 3-way handshake로 연결을 맺습니다. TCP는 처음에 약 14KB만 보내고 ACK를 받으면서 전송량을 점진적으로 늘립니다(TCP Slow Start). 첫 HTML 응답이 14KB 안에 끝나야 추가 왕복 없이 한 번에 받을 수 있어서, 중요한 CSS를 inline으로 넣거나 14KB 내에 포함시키는 최적화가 여기서 나옵니다.
- TLS handshake: HTTPS라면 암호화 협상을 합니다. 여기까지만 해도 왕복이 1-2번 추가됩니다.
- HTTP 요청: HTML 문서를 요청합니다.
- HTML 파싱: 받은 HTML을 위에서부터 파싱하면서 CSS, JS, 이미지, font 등을 발견하면 추가 요청을 보냅니다. 브라우저는 메인 파서와 별도로 preload scanner를 병렬 실행합니다. JS 때문에 메인 파서가 멈춰있어도 preload scanner가 HTML을 미리 훑으며 CSS, JS, 이미지, font를 선제적으로 요청해 전체 로딩 시간을 줄입니다.
HTTP/1.1에서는 하나의 TCP 연결에서 요청을 순차적으로 처리해서 하위 리소스가 많으면 느렸습니다. HTTP/2부터는 하나의 연결에서 여러 요청을 동시에 처리(multiplexing)할 수 있어 병렬 다운로드가 가능해졌습니다.
꼬리질문
조금 더 깊게 물어본다면
답변 뒤에 이어질 수 있는 질문들을 하나씩 열어볼 수 있어요.
CSS가 render blocking인 이유는?
브라우저가 CSSOM을 알아야 정확한 layout과 style을 계산할 수 있기 때문입니다.
브라우저는 DOM과 CSSOM을 합쳐 render tree를 만들고, 이걸로 화면을 그립니다. CSS가 로드되기 전에 그리면 스타일 없이 날 것의 HTML이 잠깐 보였다가 스타일이 적용되면서 화면이 바뀌는 FOUC(Flash of Unstyled Content) 현상이 생깁니다. 이를 막기 위해 브라우저는 CSS가 로드될 때까지 렌더링을 기다립니다.
그래서 CSS는 항상 <head>에 넣어야 합니다. <body> 안에 넣으면 HTML이 부분적으로 렌더링된 뒤 CSS가 도착해 다시 그려야 합니다. <link media="print"> 처럼 조건부 미디어 쿼리를 쓰면 해당 조건에서만 blocking이 됩니다. 현재 화면과 관련 없는 CSS는 다운로드는 하지만 렌더링을 막지 않습니다.
JavaScript가 render blocking이 될 때는?
<script> 태그에 async나 defer가 없으면 HTML 파싱이 멈추고 script를 다운로드, 실행한 뒤 파싱을 재개합니다(blocking). async와 defer는 둘 다 non-blocking으로 HTML 파싱과 병렬로 다운로드하며, 차이는 실행 시점입니다. async는 다운로드 완료 즉시 실행하고, defer는 HTML 파싱이 완전히 끝난 후 실행합니다.
JS가 HTML 파싱을 막는 이유는 JS가 DOM을 수정할 수 있기 때문입니다. 브라우저 입장에서는 JS가 실행되기 전까지 다음 HTML이 어떻게 바뀔지 모르므로 파싱을 멈추고 기다립니다.
async와 defer의 차이는 실행 시점입니다.
async: 다운로드가 완료되는 즉시 실행합니다. 실행 시점에 HTML 파싱이 중단됩니다. 여러 async script 간 실행 순서가 보장되지 않습니다. 다른 script에 의존하지 않는 독립적인 script(광고, 분석 등)에 씁니다.defer: HTML 파싱이 완료된 후 DOMContentLoaded 이벤트 직전에 실행됩니다. 선언 순서대로 실행됩니다. 대부분의 앱 script에 적합합니다.
<body> 끝에 script를 두는 관행은 defer가 없던 시절의 해결책이었습니다. HTML을 다 파싱한 뒤에야 script를 만나므로 blocking이 늦게 일어납니다. 지금은 defer를 쓰는 게 더 명확합니다.
Critical rendering path란?
HTML을 받아 화면에 첫 픽셀을 그리기까지의 단계를 말합니다. HTML 파싱으로 DOM을 만들고, CSS 파싱으로 CSSOM을 만들고, 두 트리를 결합해 render tree를 구성한 뒤 layout과 paint를 거칩니다. 이 경로를 짧게 만들수록 첫 화면이 빨리 표시됩니다.
각 단계를 구체적으로 보면:
- DOM 생성: HTML을 파싱해 노드 트리를 만듭니다.
display: none인 요소도 포함됩니다. - CSSOM 생성: CSS를 파싱해 스타일 규칙 트리를 만듭니다. 모든 CSS가 로드되어야 완성됩니다.
- Render tree: DOM과 CSSOM을 합쳐 실제로 화면에 그릴 요소만 모은 트리입니다.
display: none은 제외됩니다. - Layout: 각 요소의 크기와 위치를 계산합니다. 이 단계가 비용이 큽니다.
- Paint: 픽셀을 채웁니다.
- Composite: 레이어를 합쳐 최종 화면을 만듭니다.
JS가 DOM을 수정하면 이 경로가 다시 실행됩니다. layout이 다시 계산되면 reflow, paint가 다시 실행되면 repaint라고 합니다. transform이나 opacity 변경은 composite 단계만 트리거해서 빠릅니다.
파일 총 용량은 어떻게 줄일 수 있나요?
압축, code splitting, image optimization, caching, lazy loading, CDN 사용 등으로 줄일 수 있습니다.
각 기법이 어느 지점을 줄이는지 보면:
- gzip / Brotli 압축: 서버가 응답을 압축해서 보내면 브라우저가 받아서 풉니다. 텍스트 기반 파일(HTML, CSS, JS)은 70-80%까지 줄어듭니다. 서버 설정만으로 가능합니다.
- code splitting: 앱 전체를 하나의 JS 파일로 묶지 않고, 페이지나 기능 단위로 나눠 필요할 때만 불러옵니다. 초기 로딩에 필요한 코드만 먼저 받습니다.
- tree shaking: 빌드 시 실제로 사용하지 않는 코드를 번들에서 제거합니다.
- image optimization: WebP, AVIF 포맷은 JPEG보다 같은 품질에서 파일 크기가 작습니다.
srcset으로 화면 크기에 맞는 이미지만 보냅니다. - 캐싱: 변경되지 않은 리소스를 브라우저 캐시에서 바로 씁니다. 네트워크 요청 자체가 없어집니다.
Lazy loading은 어떤 resource에 유리한가요?
초기 화면에 필요하지 않은 이미지나 script를 나중에 불러오게 해 초기 로딩 비용을 줄일 때 유리합니다.
이미지에는 loading="lazy" 속성 하나로 적용됩니다. 뷰포트 밖에 있는 이미지는 스크롤로 가까워질 때까지 요청을 미룹니다. 단, LCP 대상이 되는 첫 화면의 핵심 이미지에는 쓰지 않습니다. 오히려 늦게 로드되어 LCP 점수가 나빠집니다.
JS는 dynamic import로 lazy loading합니다. 버튼을 클릭했을 때만 필요한 무거운 라이브러리(예: 차트, 에디터)를 클릭 시점에 불러오면 초기 번들 크기를 줄일 수 있습니다.
trade-off는 첫 접근 시 약간의 지연이 생긴다는 점입니다. 캐시가 없는 첫 방문에서 스크롤하다 이미지가 뒤늦게 나타나는 경험이 생길 수 있습니다.
Preload와 prefetch의 차이는?
Preload는 현재 페이지에서 곧 필요한 resource를 우선 요청할 때 사용합니다. Prefetch는 다음 페이지 이동에 필요할 resource를 미리 받아두는 힌트입니다. Preload는 높은 우선순위로 즉시 요청하고, prefetch는 유휴 시간에 낮은 우선순위로 처리됩니다.
preload의 대표적인 사용처는 LCP 이미지와 font입니다. 브라우저가 HTML을 파싱하다 CSS를 다 읽어야 font를 발견하는 경우, font가 늦게 로드되어 텍스트가 늦게 표시됩니다. <link rel="preload" as="font">로 미리 선언하면 CSS보다 먼저 font 요청을 보낼 수 있습니다.
<link rel="preload" as="image" href="/hero.webp" />
<link rel="prefetch" as="script" href="/next-page.js" />preload를 남용하면 오히려 중요한 리소스와 대역폭을 경쟁해 성능이 나빠질 수 있습니다. 실제로 필요한 리소스에만 씁니다.
Waterfall chart에서 무엇을 볼 수 있나요?
각 resource의 요청 시작 시점, DNS/TCP/TLS/TTFB/download 시간, 병렬성과 blocking 관계를 볼 수 있습니다.
waterfall에서 주로 확인하는 것들:
- TTFB(Time to First Byte): 요청을 보내고 첫 바이트를 받기까지의 시간입니다. 서버 응답 속도를 나타냅니다. 높으면 서버 처리나 네트워크가 느린 것입니다.
- blocking 리소스: 다른 요청들이 이 리소스 때문에 시작을 못 하고 기다리는 구간이 보입니다. render blocking CSS나 JS가 원인인 경우가 많습니다.
- 병렬 요청 수: HTTP/1.1에서는 도메인당 6개 정도 동시 연결 제한이 있어서 요청이 줄을 서는 모습이 보입니다. HTTP/2에서는 하나의 연결에서 병렬 처리해 줄이 줄어듭니다.
Core Web Vitals와 로딩 최적화는 어떻게 연결되나요?
LCP(Largest Contentful Paint)는 주요 콘텐츠가 화면에 그려지는 시점으로 이미지와 blocking resource 최적화에 직결됩니다. FID(First Input Delay)나 INP는 main thread가 얼마나 빠르게 응답하는지를 나타내며 JavaScript 실행 비용과 연관됩니다. CLS(Cumulative Layout Shift)는 레이아웃이 얼마나 안정적인지를 측정합니다.
각 지표의 기준값과 원인:
- LCP ≤ 2.5s: 첫 화면에서 가장 큰 이미지나 텍스트 블록이 그려지는 시점입니다. 느린 서버 응답, render blocking 리소스, 느린 이미지 로드가 원인입니다. LCP 대상 이미지에 preload를 쓰거나, 이미지 포맷을 WebP로 바꾸는 것이 직접적인 개선책입니다.
- INP ≤ 200ms: 사용자의 모든 클릭/키 입력에 브라우저가 반응하는 시간입니다. 무거운 JS가 main thread를 오래 점유하면 입력이 느려집니다. long task를 쪼개거나 Web Worker로 오프로드하면 개선됩니다.
- CLS ≤ 0.1: 이미지나 광고의 크기를 미리 지정하지 않으면 로드되면서 레이아웃이 밀립니다.
width와height속성을 명시하거나aspect-ratio를 쓰면 공간을 미리 확보해 CLS를 줄입니다.
부가 설명
화면 표시는 HTML 하나만으로 끝나지 않습니다. 여러 resource와 네트워크 round trip이 함께 관여합니다.
개발자 도구 Network 탭에서 waterfall을 보면 어떤 resource가 언제 요청되고 얼마나 걸렸는지 확인할 수 있습니다.
한 줄 정리
브라우저는 HTML을 먼저 받고, 파싱 중 발견한 CSS, JS, 이미지, font 같은 resource를 요청하며 dependency와 우선순위에 따라 화면을 그립니다.