본문 바로가기
TYLER SONGBlog
← Blog index
CS학습7 min read

Browser 동작 원리: URL 입력부터 픽셀 출력까지의 렌더링 파이프라인

브라우저는 URL을 받아 네트워크 요청을 보내고, 응답받은 HTML·CSS·JavaScript를 해석해 화면의 픽셀로 바꾸는 프로그램이다. 이 과정은 DNS 조회, 연결 수립, 파싱, 스타일 계산, 레이아웃, 페인트, 합성 단계로 나뉜다. 각 단계의 비용을 이해하면 리플로우(reflow)와 렌더링 차단 같은 성능 문제를 원인부터 짚을 수 있다.

By 송민성

1. 개념

브라우저(browser)는 URL로 지정된 자원을 네트워크로 가져와 HTML, CSS, JavaScript를 해석하고 화면에 그리는 소프트웨어다. 핵심 구성 요소는 다음과 같다.

  • 네트워크 계층: DNS 조회, TCP/TLS(또는 QUIC) 연결, HTTP 요청·응답을 처리한다.
  • 렌더링 엔진(rendering engine): HTML과 CSS를 해석해 화면을 구성한다. Chrome·Edge의 Blink, Safari의 WebKit, Firefox의 Gecko가 여기에 해당한다.
  • JavaScript 엔진: 스크립트를 실행한다. V8, JavaScriptCore, SpiderMonkey가 대표적이다.
  • 브라우저 프로세스 구조: Chrome 계열은 브라우저 프로세스, 렌더러 프로세스, GPU 프로세스, 네트워크 서비스 등으로 역할을 나누는 멀티 프로세스 구조를 쓴다.

2. 왜 사용하는가

웹 개발자가 브라우저 내부를 알아야 하는 이유는 성능과 동작 예측에 있다.

  • 화면이 느린 원인이 네트워크인지, 자바스크립트 실행인지, 레이아웃 계산인지 구분할 수 있다.
  • <script>의 위치와 defer, async 속성이 왜 첫 화면 표시 시점을 바꾸는지 설명할 수 있다.
  • 애니메이션에서 left 대신 transform을 권장하는 이유를 이해할 수 있다.
  • 비동기 코드의 실행 순서(마이크로태스크, 매크로태스크)를 정확히 예측할 수 있다.

3. 동작 원리

3.1 탐색과 네트워크

  1. 주소창 입력을 URL로 해석한다. 검색어라면 검색 엔진 URL로 바꾼다.
  2. 캐시(cache)와 서비스 워커(Service Worker)를 확인해 응답을 재사용할 수 있는지 본다.
  3. DNS로 도메인을 IP 주소로 변환한다.
  4. TCP 연결을 맺고 HTTPS라면 TLS 핸드셰이크를 수행한다. HTTP/3는 UDP 기반의 QUIC 위에서 동작한다.
  5. HTTP 요청을 보내고 응답을 스트리밍으로 받는다.

3.2 크리티컬 렌더링 패스(Critical Rendering Path)

  1. HTML 파싱 → DOM: 바이트를 문자로, 토큰으로, 노드로 바꿔 DOM(Document Object Model) 트리를 만든다. 파싱은 응답이 도착하는 대로 점진적으로 진행된다.
  2. CSS 파싱 → CSSOM: 스타일 시트를 CSSOM(CSS Object Model)으로 만든다.
  3. 스타일 계산(style): DOM과 CSSOM을 결합해 각 요소의 최종 계산 스타일을 구한다. 이 결과로 렌더 트리(render tree)에 해당하는 구조가 만들어지며, display: none 요소는 화면에 그려지는 대상에서 제외된다.
  4. 레이아웃(layout, reflow): 각 요소의 크기와 위치를 계산한다.
  5. 페인트(paint): 요소를 어떤 순서로 그릴지 정하고 그리기 명령을 만든다.
  6. 합성(composite): 여러 레이어를 GPU에서 합쳐 최종 화면을 만든다.

3.3 스크립트와 파싱의 관계

  • 일반 <script src>는 HTML 파서를 멈추고, 스크립트를 내려받아 실행한 뒤 파싱을 재개한다(파서 차단, parser-blocking).
  • 스크립트가 getComputedStyle 같은 스타일 정보를 읽으려 하면 앞선 CSS가 로드될 때까지 기다린다. 따라서 CSS는 렌더링을 차단하는 자원이다.
  • defer: 병렬로 내려받고 HTML 파싱이 끝난 뒤, 문서 순서대로 실행한다.
  • async: 병렬로 내려받고 준비되는 즉시 실행한다. 실행 순서는 보장되지 않는다.

3.4 이벤트 루프(event loop)

렌더러의 메인 스레드는 자바스크립트 실행, 스타일·레이아웃·페인트 계산, 사용자 입력 처리를 함께 담당한다. 이벤트 루프는 태스크 큐에서 작업을 하나 꺼내 실행하고, 끝나면 마이크로태스크 큐를 모두 비운 뒤, 필요하면 렌더링 갱신을 수행한다. 한 태스크가 오래 걸리면 그동안 화면 갱신과 입력 처리가 모두 밀린다.

4. 코드 예제

4.1 이벤트 루프 실행 순서

typescript
console.log("1. 동기 코드 시작"); setTimeout(() => console.log("4. 매크로태스크: setTimeout"), 0); Promise.resolve().then(() => console.log("3. 마이크로태스크: Promise")); requestAnimationFrame(() => console.log("5. 렌더링 직전: rAF")); console.log("2. 동기 코드 끝");

동기 코드가 먼저 끝나고, 마이크로태스크가 실행된다. setTimeout과 requestAnimationFrame의 상대 순서는 브라우저와 타이밍에 따라 달라질 수 있다. 확실한 것은 동기 코드 → 마이크로태스크 순서다.

4.2 레이아웃 스래싱(layout thrashing)

typescript
function badUpdate(items: HTMLElement[]): void { for (const el of items) { const width = el.offsetWidth; // 읽기: 강제 레이아웃 유발 가능 el.style.width = `${width + 10}px`; // 쓰기: 레이아웃 무효화 } } function goodUpdate(items: HTMLElement[]): void { const widths = items.map((el) => el.offsetWidth); // 읽기를 먼저 모두 수행 items.forEach((el, i) => { el.style.width = `${widths[i] + 10}px`; // 쓰기를 나중에 모두 수행 }); }

badUpdate는 읽기와 쓰기를 번갈아 수행해 반복마다 레이아웃을 다시 계산하게 만들 수 있다. goodUpdate는 읽기와 쓰기를 분리해 이를 피한다.

4.3 긴 작업 쪼개기

typescript
async function processLargeArray(data: number[], chunkSize = 1000): Promise<number> { let sum = 0; for (let i = 0; i < data.length; i += chunkSize) { const end = Math.min(i + chunkSize, data.length); for (let j = i; j < end; j++) { sum += data[j]; } // 메인 스레드를 양보해 렌더링과 입력 처리가 끼어들 수 있게 한다 await new Promise<void>((resolve) => setTimeout(resolve, 0)); } return sum; }

4.4 스크립트 로딩 전략

shell
# 크리티컬 렌더링 패스 관련 응답 헤더와 크기를 확인한다 curl -sI https://example.com | head -n 10

5. 시간 복잡도 또는 성능 특성

브라우저 렌더링은 알고리즘의 점근 복잡도보다 어느 단계를 다시 실행하느냐가 비용을 좌우한다.

변경 대상다시 실행되는 단계상대적 비용
width, top, margin 등레이아웃 → 페인트 → 합성높음
color, background-color 등페인트 → 합성중간
transform, opacity합성(조건이 맞으면)낮음
  • 60Hz 디스플레이에서 한 프레임의 시간 예산은 약 16.7ms(1000ms ÷ 60)다. 스크립트, 스타일, 레이아웃, 페인트가 이 안에 끝나야 끊김이 없다.
  • 50ms를 넘는 메인 스레드 작업은 롱 태스크(long task)로 분류된다.
  • 합성 단계는 메인 스레드와 별도의 컴포지터 스레드와 GPU에서 처리되므로, 메인 스레드가 바쁠 때도 transform 애니메이션이 부드럽게 유지되는 경우가 많다.

6. 실무 사용 사례

  • 첫 화면 최적화: 핵심 CSS를 먼저 로드하고, 스크립트에는 defer를 사용해 파서 차단을 줄인다.
  • 자원 우선순위 힌트: <link rel="preload">, <link rel="preconnect">로 필요한 자원과 연결을 미리 준비한다.
  • 이미지 지연 로딩: loading="lazy"로 화면 밖 이미지를 늦게 불러온다.
  • 애니메이션: 위치 이동에는 left 대신 transform: translate()를 쓴다.
  • 측정 도구: Chrome DevTools의 Performance 패널과 Lighthouse로 롱 태스크, 레이아웃 비용, 렌더링 차단 자원을 확인한다. 핵심 지표로 LCP, INP, CLS를 본다.

7. 주의할 점

  • offsetWidth, getBoundingClientRect() 같은 읽기 API는 레이아웃이 무효화된 상태에서 호출하면 동기적 레이아웃을 강제한다. 읽기와 쓰기를 섞지 않는다.
  • async 스크립트는 실행 순서가 보장되지 않으므로 서로 의존하는 스크립트에는 쓰지 않는다.
  • will-change를 남발하면 레이어가 늘어 메모리를 더 쓴다. 필요한 요소에만 제한적으로 적용한다.
  • 마이크로태스크를 끝없이 생성하면 마이크로태스크 큐가 비지 않아 렌더링이 진행되지 못한다.
  • 렌더링 방식은 브라우저 구현마다 세부가 다르다. 이 글의 파이프라인은 일반적인 모델이므로, 정확한 동작은 대상 브라우저의 문서와 DevTools로 검증한다.

8. 핵심 정리

  • 브라우저는 네트워크 → 파싱(DOM/CSSOM) → 스타일 → 레이아웃 → 페인트 → 합성 순서로 화면을 만든다.
  • CSS는 렌더링을 차단하고, 일반 스크립트는 HTML 파서를 차단한다. defer와 async로 차단을 줄인다.
  • 메인 스레드는 자바스크립트와 렌더링 계산을 함께 처리하므로, 긴 작업은 쪼개거나 다른 스레드로 옮긴다.
  • 레이아웃을 유발하는 속성 변경은 비싸고, transform과 opacity는 상대적으로 싸다.
  • 이벤트 루프의 실행 순서는 동기 코드 → 마이크로태스크 → 매크로태스크(및 렌더링 갱신)로 이해한다.