Skip to content

0-1. 주소창에서 픽셀까지: 웹의 동작 모델 ​

한 줄 요약: 주소창 입력부터 첫 픽셀이 그려지기까지의 전체 파이프라인을 단계별로 설명하고, 각 단계를 DevTools로 관찰·계측할 수 있다.

학습 목표 ​

  • 내비게이션 요청이 픽셀이 되기까지의 파이프라인을 단계별로 설명할 수 있다.
  • 브라우저가 네트워크 계층 위에 얹은 고유한 동작(연결 풀, 프리로드, 리소스 힌트)을 근거로 로딩 순서를 판단할 수 있다.
  • HTML 파싱이 스트리밍이라는 사실이 렌더링 전략에 미치는 영향을 설명할 수 있다.
  • 브라우저의 멀티 프로세스 아키텍처가 무엇을 얻기 위해 무엇을 포기했는지 설명할 수 있다.
  • DevTools Network/Performance 패널에서 각 단계를 찾아 병목을 진단할 수 있다.

배경: 왜 이것이 존재하는가 ​

백엔드 개발자에게 HTTP 요청의 생애는 대체로 "요청이 도착한다 → 처리한다 → 응답을 보낸다"에서 끝난다. 응답을 보낸 뒤에 무슨 일이 일어나는지는 클라이언트의 사정이다. 프론트엔드는 정확히 그 반대편에서 시작한다. 응답 바이트가 도착한 순간부터 픽셀이 그려질 때까지가 전부 우리 관할이고, 심지어 요청이 나가기 전 단계(캐시, 연결 재사용, 리소스 힌트)도 상당 부분 문서 작성자가 제어한다.

이 파이프라인의 큰 그림이 없으면 프론트엔드의 거의 모든 논의가 공중에 뜬다. "왜 <script>를 defer로 로드하는가", "왜 CSS는 <head>에 두는가", "LCP가 왜 이 시점에 찍히는가" — 전부 이 파이프라인 위의 위치 문제다. 이 문서는 각 단계를 얕고 넓게 한 번 관통한다. 파싱의 세부는 1-1, 렌더링 파이프라인의 세부는 Phase 7-1, HTTP 심화는 Phase 2에서 다룬다.

전제: DNS, TCP, TLS가 각각 무엇인지는 이미 안다고 가정한다. 여기서 다루는 것은 그 위에서 브라우저가 다르게 행동하는 지점이다.

핵심 개념 ​

요청이 나가기 전: 브라우저는 네트워크를 마지막 수단으로 쓴다 ​

주소창에 URL을 입력하고 엔터를 치면, 브라우저가 가장 먼저 하는 일은 네트워크 요청이 아니다. 요청을 회피할 수 있는지부터 확인한다.

  1. HSTS 목록 확인 — http://로 입력했어도 해당 도메인이 HSTS(HTTP Strict Transport Security) 목록에 있으면 네트워크에 나가기 전에 https://로 바꾼다. 서버가 리다이렉트할 기회조차 주지 않는 이유는, 최초의 평문 요청 자체가 중간자 공격의 표적이기 때문이다.
  2. HTTP 캐시 확인 — 이전 응답이 유효 기간(Cache-Control: max-age) 안에 있으면 요청 없이 캐시에서 꺼낸다. 만료됐지만 ETag가 있으면 조건부 요청으로 바꾼다. 캐시 협상 모델은 2-2에서 다룬다.
  3. Service Worker 확인 — 해당 스코프에 서비스 워커가 등록되어 있으면 요청이 네트워크에 닿기 전에 워커의 fetch 핸들러가 가로챈다. 브라우저 안에 사용자가 짠 프록시가 있는 셈이다.

이 순서를 알아야 하는 실무적 이유: DevTools Network 패널에서 (disk cache), (memory cache), 톱니바퀴 아이콘(서비스 워커 경유)이 붙은 요청은 서버에 도달하지 않았다. "배포했는데 반영이 안 된다"류 문제의 절반은 이 세 계층 중 하나에서 멈춘 것이다.

DNS와 연결: 브라우저가 얹은 최적화 계층 ​

DNS 해석과 TCP/TLS 핸드셰이크 자체는 OS와 네트워크 스택의 일이지만, 브라우저는 그 위에 자체 계층을 얹는다.

  • 자체 DNS 캐시 — OS 리졸버 캐시와 별개로 브라우저가 짧은 TTL의 캐시를 유지한다. chrome://net-internals/#dns에서 확인할 수 있다.
  • 연결 풀(connection pool) — 한 번 맺은 TCP+TLS 연결은 keep-alive로 재사용한다. HTTP/1.1에서는 오리진당 최대 6개의 동시 연결을 허용하는데, 이 제한이 과거 도메인 샤딩(정적 리소스를 여러 서브도메인에 분산)이라는 기법을 낳았다. HTTP/2의 멀티플렉싱은 연결 1개로 이 제한을 없앴고, 도메인 샤딩은 오히려 핸드셰이크 비용만 늘리는 안티패턴이 되었다. 어떤 기법이 "왜 대체되었는가"를 보여주는 전형적 사례다.
  • 선점적 연결(preconnect) — 브라우저는 과거 방문 기록을 근거로 자주 가는 오리진에 미리 연결을 열어 두기도 하고, 문서 작성자가 명시적으로 지시할 수도 있다:
html
<!-- DNS 해석만 미리 (비용 낮음, 효과 작음) -->
<link rel="dns-prefetch" href="https://api.example.com">

<!-- DNS + TCP + TLS까지 미리 (비용 높음 — 확실히 쓸 오리진에만) -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

preconnect는 공짜가 아니다. 연결을 열어 두는 것은 양쪽 모두의 소켓 자원을 점유하며, 10초 안에 쓰이지 않으면 브라우저가 닫아 버려 낭비가 된다. "혹시 몰라서 전부 preconnect"는 오히려 초기 경쟁 대역폭을 낭비한다.

응답은 스트림이다: 첫 바이트부터 파싱이 시작된다 ​

여기가 백엔드 감각과 가장 크게 갈라지는 지점이다. 서버 코드에서 HTTP 응답 body는 보통 "다 받아서 역직렬화하는" 단위지만, 브라우저는 HTML 응답을 다 받을 때까지 기다리지 않는다. 첫 청크가 도착하는 순간 파싱을 시작하고, DOM을 만들면서 발견한 리소스(<link>, <img>, <script>)의 다운로드를 곧바로 병렬로 시작한다.

이 스트리밍 모델에서 파이프라인의 큰 그림이 나온다:

HTML 바이트 ──파싱──▶ DOM ─┐
                           ├─▶ 렌더 트리 ─▶ 레이아웃 ─▶ 페인트 ─▶ 합성 ─▶ 픽셀
CSS 바이트 ──파싱──▶ CSSOM ─┘

각 화살표에는 서로 다른 차단 규칙이 있다.

  • CSS는 렌더 차단(render-blocking)이다. CSSOM이 완성되기 전에는 렌더 트리를 만들지 않는다. 스타일 없는 문서를 먼저 보여줬다가 스타일이 도착하면 전체가 번쩍이며 바뀌는 것(FOUC)보다, 잠깐 흰 화면이 낫다는 판단이다. 단, CSS는 파싱을 막지는 않는다 — DOM 구성은 계속된다.
  • <script>는 파서 차단(parser-blocking)이다. 스크립트는 document.write로 파서의 입력 자체를 바꿀 수 있으므로, 실행이 끝나기 전에는 다음 토큰을 확정할 수 없다. 게다가 스크립트는 앞선 스타일시트의 CSSOM이 완성될 때까지 실행을 대기한다(getComputedStyle을 호출할 수 있으므로). 즉 느린 CSS가 스크립트를 막고, 그 스크립트가 파싱을 막는 연쇄가 성립한다. 이 병목을 푸는 defer/async와 프리로드 스캐너는 1-1에서 다룬다.

파싱이 스트리밍이라는 사실의 실무적 귀결 하나: 서버가 <head>를 먼저 플러시(flush)하면, body 생성이 느려도 브라우저는 그동안 CSS·폰트 다운로드를 시작할 수 있다. 스트리밍 SSR(Phase 7-5)이 성립하는 토대가 바로 이것이다.

픽셀까지의 마지막 구간: 레이아웃, 페인트, 합성 ​

DOM과 CSSOM이 결합된 렌더 트리에서 실제 픽셀까지는 세 단계가 더 있다. 여기서는 각 단계의 책임만 세운다(세부와 비용 모델은 Phase 7-1).

  1. 레이아웃(layout) — 각 박스의 기하(위치·크기)를 계산한다. 한 요소의 크기가 바뀌면 조상·형제의 기하가 연쇄적으로 다시 계산될 수 있는, 파이프라인에서 가장 비싼 축에 드는 단계다.
  2. 페인트(paint) — 계산된 기하에 색·텍스트·이미지를 채워 레이어별 비트맵 명령 목록을 만든다.
  3. 합성(composite) — 레이어들을 GPU에서 합쳐 최종 프레임을 만든다. transform과 opacity 변경은 이 단계만으로 처리될 수 있어서 애니메이션 속성 선택의 기준이 된다(1-6에서 다뤘다).

관찰 방법: DevTools Performance 패널로 기록하면 메인 스레드 트랙에 Parse HTML, Recalculate Style, Layout, Paint, 별도 트랙에 컴포지터 활동이 단계별로 찍힌다. "파이프라인을 안다"는 것은 이 타임라인을 읽을 수 있다는 뜻이다.

멀티 프로세스 아키텍처: 격리를 사고, 메모리로 지불한다 ​

브라우저는 단일 프로그램처럼 보이지만 내부는 프로세스 여러 개의 연합이다. Chromium 기준으로:

프로세스책임비고
브라우저 프로세스UI(주소창, 탭), 프로세스 조율, 권한 관리1개
렌더러 프로세스HTML/CSS/JS 실행 (Blink + V8)사이트 단위로 분리, 샌드박스 안에서 실행
GPU 프로세스합성, 래스터화1개
네트워크·스토리지 등 유틸리티네트워크 요청, 캐시렌더러 밖에 있음

핵심 설계 판단은 렌더러를 신뢰하지 않는 것이다. 렌더러는 인터넷에서 받은 임의의 코드(JS)를 실행하는 프로세스이므로, 파일 시스템·네트워크에 직접 접근할 수 없는 샌드박스에 가두고, 필요한 작업은 브라우저 프로세스에 IPC로 요청하게 한다. 백엔드로 치면 신뢰할 수 없는 워크로드를 컨테이너에 격리하고 API 게이트웨이를 통해서만 바깥과 통신하게 하는 구조와 같다.

"사이트 단위 분리(Site Isolation)"는 2018년 Spectre 공격 공개 이후 전면 적용됐다. Spectre는 같은 주소 공간의 메모리를 추측 실행 부채널로 읽어내는 공격이라, 프로세스 경계가 사실상 유일한 방어선이었다. 그 대가는 메모리다 — 탭·iframe의 사이트 수만큼 프로세스가 늘어난다. 그래서 메모리가 부족한 환경(저사양 Android)에서는 브라우저가 프로세스를 통합하는 등 격리 수준을 조정한다. 보안 격리와 메모리 사용량의 트레이드오프이며, 어느 쪽을 얼마나 취할지는 플랫폼별로 다르게 결정된다.

이 구조에서 나오는 실무적 사실 두 가지:

  • 탭 하나가 무한 루프에 빠져도 브라우저 전체는 죽지 않는다(렌더러만 죽는다). 크래시 격리는 멀티 프로세스의 원래 동기였다.
  • "브라우저의 메인 스레드"라는 말은 정확히는 해당 렌더러 프로세스의 메인 스레드다. 파싱, 스타일 계산, 레이아웃, JS 실행이 전부 이 한 스레드를 공유한다는 사실이 Phase 3-5(이벤트 루프)와 Phase 7-1(렌더링 성능)의 전제가 된다.

실무 관점 ​

Network 패널의 타이밍 분해를 읽는 법 ​

요청 하나를 클릭해 Timing 탭을 열면 이 문서의 전반부가 그대로 나열된다.

구간의미여기가 길면
Queueing / Stalled연결 풀 대기 (HTTP/1.1 오리진당 6개 제한 등)HTTP/2 미적용, 요청 과다
DNS LookupDNS 해석dns-prefetch 검토
Initial connection / SSLTCP + TLS 핸드셰이크preconnect, 연결 재사용 실패 의심
Waiting for server response (TTFB)서버 처리 시간프론트엔드가 아니라 서버/인프라 문제
Content Downloadbody 수신응답 크기, 네트워크 대역폭

특히 TTFB 구간은 프론트엔드 최적화로 줄일 수 없다. 성능 문제를 배정하기 전에 이 분해를 먼저 보는 것이 원인 소재를 가르는 가장 빠른 방법이다.

흔한 오해들 ​

  • "DOMContentLoaded = 화면에 보이는 시점" — 아니다. DOMContentLoaded는 파싱 완료(+ defer 스크립트 실행 완료) 시점이고, 화면 표시는 렌더 파이프라인이 첫 프레임을 내보낸 시점(First Paint / FCP)이다. 둘은 순서조차 고정이 아니다. 사용자 체감을 재려면 이벤트가 아니라 페인트 계열 지표(Phase 7-3)를 본다.
  • "리소스 힌트는 많을수록 좋다" — preload·preconnect는 대역폭·소켓의 우선순위 재배치이지 추가 확보가 아니다. 덜 중요한 것을 미는 만큼 더 중요한 것이 밀린다. Chrome은 preload 후 몇 초 내 미사용 리소스에 콘솔 경고를 띄우는데, 이 경고가 낭비의 신호다.
  • "캐시를 지웠는데도 옛 버전이 보인다" — HTTP 캐시 말고도 서비스 워커, (뒤로 가기라면) BFCache까지 문서를 되살리는 계층이 있다. Network 패널의 Disable cache는 HTTP 캐시만 끈다. Application 패널에서 서비스 워커를 확인하는 것까지가 진단 절차다.

더 깊이 ​

내비게이션의 소유권: 커밋(commit) 이전과 이후 ​

멀티 프로세스 구조에서 "페이지 이동"은 생각보다 복잡한 소유권 이전이다. 주소창 입력 후의 네트워크 요청은 렌더러가 아니라 브라우저 프로세스(의 네트워크 서비스)가 수행한다. 응답 헤더가 도착해 "이건 HTML이고, 이 사이트다"가 확정되어야 어느 렌더러 프로세스가 맡을지 결정할 수 있기 때문이다(사이트가 다르면 새 프로세스). 렌더러가 정해지면 브라우저 프로세스가 응답 스트림을 넘기며 내비게이션을 커밋하고, 그제서야 주소창의 URL이 바뀌고 이전 문서가 언로드된다.

주소창 URL이 응답 도착 전까지 바뀌지 않는 이유, beforeunload 핸들러가 요청 시작 전에 실행되는 이유가 이 순서에 있다.

BFCache: 언로드가 아니라 동결 ​

뒤로/앞으로 이동 시 브라우저는 문서를 다시 로드하는 대신, 이전 문서를 메모리에 통째로 동결해 둔 스냅숏(back/forward cache)에서 복원하려 시도한다. 파싱·스크립트 실행을 전부 건너뛰므로 체감상 즉시 복원된다. 대신 조건이 있다 — unload 핸들러 등록, 열린 WebSocket 등이 있으면 동결 부적격이 된다. 페이지가 BFCache에 들어갔는지는 DevTools Application 패널 → Back/forward cache에서 직접 테스트할 수 있고, 복원 시에는 load가 아니라 pageshow 이벤트(event.persisted === true)가 발생한다. "뒤로 가기에서 초기화 코드가 안 돈다"는 버그의 정체가 대개 이것이다.

렌더러 프로세스 안의 스레드 구성 ​

렌더러 프로세스 하나도 단일 스레드가 아니다. 메인 스레드(파싱·스타일·레이아웃·JS), 컴포지터 스레드(스크롤·합성), 래스터 스레드 풀, 워커 스레드(Web Worker)로 나뉜다. 컴포지터 스레드가 메인 스레드와 분리되어 있기 때문에, 메인 스레드가 JS로 바빠도 스크롤은 (합성만으로 처리 가능한 한) 부드럽게 동작한다. 반대로 wheel/touchmove에 non-passive 리스너를 달면 컴포지터가 메인 스레드의 preventDefault 여부를 기다려야 해서 스크롤이 JS에 종속된다 — 격리가 무너지는 경계 조건이다.

정리 ​

  • 브라우저는 네트워크 요청 전에 HSTS → HTTP 캐시 → 서비스 워커 순으로 요청 자체를 회피하려 한다. "반영이 안 된다"는 문제는 대부분 이 계층에서 멈춘 것이다.
  • HTML 응답은 스트리밍으로 파싱된다. 첫 청크부터 DOM 구성과 하위 리소스 다운로드가 시작되며, 이것이 <head> 조기 플러시와 스트리밍 SSR의 토대다.
  • CSS는 렌더를 막고(FOUC 방지), 스크립트는 파서를 막는다(document.write 가능성). 느린 CSS → 스크립트 대기 → 파싱 중단의 연쇄가 성립한다.
  • 멀티 프로세스 아키텍처는 크래시 격리와 보안 격리(샌드박스, Site Isolation)를 얻고 메모리를 지불한 설계다. 파싱·스타일·레이아웃·JS는 렌더러의 메인 스레드 하나를 공유한다.
  • 각 단계는 DevTools에서 관찰 가능하다: 네트워크 구간은 Network 패널 Timing 탭, 렌더 파이프라인은 Performance 패널의 메인 스레드 트랙.

확인 문제 ​

  1. HTML 문서의 <head>에 느린 외부 CSS 한 개와 그 아래 인라인 <script>가 있다. 이 스크립트는 DOM을 전혀 만지지 않는데도 페이지의 첫 렌더가 CSS 다운로드 시간만큼 늦어졌다. 왜인가?
정답과 해설

스크립트는 앞선 스타일시트의 CSSOM이 완성될 때까지 실행을 대기한다. getComputedStyle 등으로 스타일에 접근할 수 있으므로, 미완성 CSSOM 위에서 실행하면 잘못된 값을 읽을 수 있기 때문이다. 인라인 스크립트도 예외가 아니다. 그리고 그 스크립트는 파서 차단이므로, 실행이 밀리는 동안 이후의 파싱도 함께 멈춘다. 결과적으로 "느린 CSS → 스크립트 실행 대기 → 파싱 중단 → 렌더 지연"의 연쇄가 만들어진다. 스크립트가 스타일과 무관하다면 defer를 붙여 이 연쇄에서 빼내는 것이 해법이다.

  1. 성능 리포트에서 특정 API 요청이 1.8초 걸린다고 나왔다. DevTools Timing 탭을 보니 Waiting for server response가 1.6초, 나머지 구간의 합이 0.2초다. 프론트엔드에서 할 수 있는 조치와 없는 조치를 구분하라.
정답과 해설

TTFB 1.6초는 서버가 첫 바이트를 내놓기까지의 시간으로, 프론트엔드 코드 최적화(번들 축소, 렌더 최적화)로는 줄일 수 없다 — 서버 처리·DB·인프라의 몫이다. 프론트엔드에서 가능한 것은 요청의 재배치다: 요청을 더 일찍 시작하거나(preconnect, 페이지 로드와 병렬화), 응답을 캐시하거나(HTTP 캐시 헤더 협의, 클라이언트 캐싱), 그 시간 동안 대기 UI로 체감을 관리하는 것이다. 타이밍 분해를 근거로 문제의 소유권을 먼저 가르는 것이 핵심이다.

  1. 사용자가 목록 페이지 → 상세 페이지 → 뒤로 가기로 목록에 돌아왔는데, 페이지 진입 시마다 실행되어야 할 분석 코드가 돌지 않았다. 새로고침하면 정상 동작한다. 무슨 일이 일어난 것이고, 어떻게 수정하는가?
정답과 해설

뒤로 가기가 BFCache에서 복원됐다. 문서가 언로드되지 않고 동결됐다가 그대로 깨어났으므로, 파싱도 스크립트 재실행도 없고 load/DOMContentLoaded도 다시 발생하지 않는다. 복원 시점을 잡으려면 pageshow 이벤트를 쓰고 event.persisted로 BFCache 복원 여부를 구분한다. unload 핸들러를 등록해 BFCache를 깨는 방법은 모든 뒤로 가기를 느리게 만드는 대가를 치르므로 우회로가 아니라 안티패턴이다.

참고 자료 ​