[JS] 브라우저 렌더링과 DOM을 꿰뚫는 인터뷰 완전 정복
목차
DOM
BOM & DOM & Node
BOM(Browser Object Model)과 DOM(Document Object Model)은 브라우저 환경을 다루는 두 축이다.
| 구분 | 대상 | 대표 객체 |
|---|---|---|
| BOM | 브라우저 창·환경 | window, screen, location, navigator |
| DOM | HTML 문서 구조 | document, element, text, comment 노드 |
Window 객체
- Global Context(전역공간)이자, 브라우저 창을 나타내는 객체
- 전역변수나 전역 함수의 경우
window프로퍼티처럼 작동하게 됨 - 주요 프로퍼티:
innerWidth,innerHeight,screenX,screenY,scrollBy(),scrollTo()
screen 객체
- 사용자 환경의 디스플레이(모니터) 정보를 가지는 객체
- 주요 프로퍼티:
availHeight,availWidth,width,height,orientation
location 객체
- 사용자가 보고 있는 페이지의 URL을 다루는 객체
- 주요 프로퍼티:
href,reload,replace
navigator 객체
- 웹브라우저 및 브라우저 환경 정보를 가지는 객체
- 주요 프로퍼티:
userAgent
DOM(Document Object Model)
자바스크립트의 계층화된 트리. 노드 종류는 document·element·text·comment 등이 있다.
document 노드
- 웹 페이지마다 존재하는 객체. 웹 페이지 안의 모든 컨텐츠를 다루는 시작점
- 주요 프로퍼티:
title,url,doctype,documentElement,head,body,getElementById,createElement,querySelector,readyState
readyState 단계별 변화
loading → 문서를 로딩(파싱)하는 중
interactive → 파싱 완료, DOM 사용 가능 (이미지 등 하위 리소스는 로딩 중)
complete → 문서와 모든 하위 리소스 로딩 완료
element 노드
- 웹 페이지 안의 각 HTML 태그 요소를 의미
- 주요 프로퍼티:
querySelector,classList,dataset,id,innerHTML,parentNode,nextSibling,previousSibling
브라우저의 렌더링
웹 브라우저의 기본 구조
┌─────────────────────────────────────────────┐
│ 사용자 인터페이스 (주소창, 뒤로/앞으로 버튼 등) │
├─────────────────────────────────────────────┤
│ 브라우저 엔진 (UI ↔ 렌더링 엔진 제어) │
├──────────────────────┬──────────────────────┤
│ 렌더링 엔진 │ 자바스크립트 엔진 │
│ (HTML/CSS 파싱·표시) │ (JS 해석·실행) │
├──────────────────────┴──────────────────────┤
│ 네트워킹 / UI 백엔드 / 데이터 저장소 │
└─────────────────────────────────────────────┘
단계 1. 파싱 — HTML → DOM
HTML 문자열 DOM 트리
<html> document
<head>...</head> → └── html
<body> ├── head
<div>...</div> └── body
</body> └── div
</html>
- 오타·잘못된 문법은 예외처리로 보완
link,img태그를 만나면 리소스를 다운로드script태그를 만나면 DOM 파싱을 중지하고 자바스크립트를 먼저 해석
단계 2. 스타일 계산 — CSS → CSSOM
CSS 규칙 CSSOM 트리
body { font-size: 16px; } body
p { color: red; } → └── p (color: red, font-size: 16px 상속)
- CSSOM 정보를 통해 DOM 노드에 대한 스타일을 결정
- 결정된 스타일은 크롬 개발자 도구 Computed 항목에서 확인 가능
단계 3. 레이아웃 — 렌더 트리 생성
DOM 트리 + CSSOM 트리 → 레이아웃(렌더) 트리
└── 화면에 그려질 노드 + 크기/좌표 정보
(display:none 노드는 제외)
- DOM과 계산된 스타일을 따라가며 요소의 크기·좌표 정보를 담은 레이아웃 트리 생성
display:none요소는 제외되며, 가상 요소(::before등)는 포함됨
단계 4. 페인트 & 컴포지팅
레이아웃 트리 → 페인트 기록(그리기 순서) → 텍스트·색·이미지·보더·그림자 등을 그림
- 페인트 기록에는 요소를 렌더링하는 순서가 저장됨
- 한 페이지를 여러 레이어로 나눈 뒤 각 레이어에 시각적 요소를 그림
레이어 1 ┐
레이어 2 ├─ 래스터화(픽셀화) ─→ 합성(컴포지팅) ─→ 최종 화면
레이어 3 ┘
각 레이어를 픽셀로 변환(래스터화)한 뒤 합성하는 과정을 컴포지팅이라 한다.
렌더링 파이프라인 요약
| 단계 | 입력 | 출력 |
|---|---|---|
| 파싱 | HTML | DOM 트리 |
| 스타일 계산 | DOM + CSS | CSSOM·확정 스타일 |
| 레이아웃 | DOM + CSSOM | 렌더 트리(크기·좌표) |
| 페인트 | 렌더 트리 | 레이어별 페인트 기록 |
| 컴포지팅 | 각 레이어 | 최종 화면 픽셀 |
리페인트 & 리플로우
Reflow란
생성된 DOM 노드의 레이아웃이 변경될 때 영향받는 모든 노드(부모·자식)의 수치를 다시 계산하여 레이아웃 트리(렌더 트리)를 재생성하는 작업.
Reflow를 유발하는 속성
| 분류 | 예시 속성 |
|---|---|
| 레이아웃 속성 | width, height, padding, margin, float, position |
| 시각적 속성 | color, border-radius, background, box-shadow |
Repaint란
Reflow 과정이 끝나고 재생성된 레이아웃 트리를 다시 레이어에 그리는 작업.
리플로우와 리페인트는 렌더링 파이프라인의 레이아웃·페인트 단계를 다시(Re) 거치는 과정이다.
왜 성능에 치명적인가
렌더링 과정은 순차적이므로, 앞 단계에 변화가 생기면 그 뒤 단계는 모두 다시 수행된다.
파싱 → 스타일 계산 → 레이아웃 → 페인트 → 컴포지팅
└ 앞 단계가 바뀌면 그 뒤 단계는 모두 다시 수행 ┘
60fps 기준으로 1프레임당 약 16.6ms 안에 리플로우 + 리페인트를 완료해야 부드러운 애니메이션을 얻을 수 있다.
60fps → 1프레임당 약 16.6ms 안에 리플로우 + 리페인트를 끝내야 함
[프레임1][프레임2][프레임3] ... 매 프레임 제때 그리면 부드럽게 보임
예산을 초과하면 프레임이 누락되어 버벅임이 발생한다.
프레임 예산 초과 시:
[프레임1][----지연----][프레임3] → 프레임 누락(드롭) → 애니메이션 버벅임
해결 방법
1. CSS transform 속성 사용
transform으로 만드는 애니메이션은 CPU 대신 GPU를 사용하여 렌더링을 처리하므로 레이아웃 단계를 건너뜀
2. requestAnimationFrame 함수 사용
- 자바스크립트를 통한 애니메이션 정보를 매 프레임 시작 시점에 브라우저에 미리 알려줌
각 프레임 시작 시점에 콜백 실행:
[프레임 시작 → rAF 콜백 실행 → 리플로우/리페인트] [다음 프레임 시작 → rAF ...]
정리
- Reflow: DOM 레이아웃 변경 시 렌더 트리를 재생성
- Repaint: Reflow 후 레이어를 다시 그리는 작업
- Reflow·Repaint가 많을수록 렌더링 성능 저하
- 개선책:
CSS transform+requestAnimationFrame
이벤트 흐름
브라우저가 사용자 입력을 받으면
- 이벤트 발생 — 마우스 클릭, 휠, 포인터 이동, 화면 터치 등 모든 사용자 제스처
- 이벤트 대상 탐색 — 페인트 기록을 통해 이벤트 발생 좌표에 위치한 요소 확인
- 캡처링 단계 —
window에서 타겟 요소까지 하향 전파하며 캡처링 리스너 실행
캡처링 단계 (위 → 아래로 전파)
window → document → html → body → ... → 타겟 요소
- 버블링 단계 — 타겟에서 다시
window까지 상향 전파하며 버블링 리스너 실행
버블링 단계 (아래 → 위로 전파)
타겟 요소 → ... → body → html → document → window
캡처링·버블링 전체 흐름
┌── 캡처링 단계 ──┐ ┌── 버블링 단계 ──┐
window ───┤ (위에서 아래로) ├─ 타겟 ─┤ (아래에서 위로) ├─── window
└─────────────────┘ └─────────────────┘
이벤트 위임
개념
요소에 이벤트를 등록하는 일반적인 방법은 addEventListener()다. 100개 요소에 각각 리스너를 붙이는 대신, 이벤트 버블링을 활용하면 단 1개의 리스너로 처리할 수 있다.
<div> ← 이벤트 리스너 등록
└── <button> ← 실제 클릭(이벤트 발생 지점)
클릭 → button에서 발생 → 버블링으로 부모 div의 리스너 실행
event.target vs event.currentTarget
event.currentTarget → 리스너가 등록된 요소 (= 리스너 안의 this)
event.target → 이벤트가 최초로 발생한 요소
이벤트 위임 예제
<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body>
<div class="parent">
<!-- "generate item" 버튼을 클릭하면 새로운 아이템을 생성하는 버튼 -->
<button type="button">generate item</button>
<ul>
<!-- 초기 아이템으로 화면에 표시될 리스트 아이템 -->
<li>initial item</li>
</ul>
</div>
<script>
// .parent 클래스를 가진 요소를 찾아 변수 parent에 할당
var parent = document.querySelector(".parent");
// .parent 요소에 클릭 이벤트 리스너 추가
parent.addEventListener('click', function (event) {
// 클릭된 요소의 태그 이름이 "button"인 경우
if (event.target.tagName.toLowerCase() === "button") {
// 새로운 리스트 아이템 생성하고 텍스트를 "hello world~"로 설정
const item = document.createElement("li");
item.innerText = "hello world~";
// 부모 요소(.parent)의 하위 ul 요소에 새로운 아이템 추가
parent.querySelector("ul").appendChild(item);
}
// 클릭된 요소의 태그 이름이 "li"인 경우
if (event.target.tagName.toLowerCase() === "li") {
// 콘솔에 "hit!!" 메시지 출력
console.log('hit!!');
};
});
</script>
</body>
</html>
이벤트를 받을 요소를 리스너가 붙은 부모의 자식으로 배치하면 개수에 상관없이 이벤트를 처리할 수 있다. 동적으로 추가되는 요소도 동일하게 동작한다.
정리
- 이벤트 위임이란, 이벤트 버블링을 활용해 단일 리스너로 여러 자식 요소의 이벤트를 처리하는 패턴
event.target으로 실제 발생 지점을 식별하여 분기 처리
DocumentFragment — DOM의 경량 조각
기본 패턴과 문제점
처음 자바스크립트로 DOM을 생성할 때 document.createElement()로 만들고 appendChild()로 바로 DOM에 등록하면, 반복할 때마다 Reflow가 발생한다.
<script>
for (let i = 0; i < 10; i++) {
let divEl = document.createElement("div");
divEl.innerText = "hello~ this is " + i;
document.body.appendChild(divEl);
}
</script>
DocumentFragment를 사용하면 메모리 상에만 존재하는 경량 DOM을 생성하여 한 번에 등록할 수 있다.
const docFrag = document.createDocumentFragment();
for (let i = 0; i < 10; i++) {
var divEl = document.createElement("div");
divEl.innerText = "hello~ this is " + i;
docFrag.appendChild(divEl);
}
document.body.appendChild(docFrag);
div래퍼 요소와의 차이점:div는 DOM에 추가될 때 자신도 함께 삽입되지만,DocumentFragment는 자식 노드만 삽입되고 자신은 사라진다.
//const docFrag = document.createDocumentFragment();
const test = document.createElement('div');
for (let i = 0; i < 10; i++) {
var divEl = document.createElement("div");
divEl.innerText = "hello~ this is " + i;
//docFrag.appendChild(divEl);
test.appendChild(divEl);
}
//document.body.appendChild(docFrag);
document.body.appendChild(test);
DocumentFragment의 특징
// 비어 있는 DocumentFragment를 생성하고 docFrag 변수에 할당합니다.
const docFrag = document.createDocumentFragment();
// 0부터 9까지 총 10번 반복하는 루프를 시작합니다.
for (let i = 0; i < 10; i++) {
// 새로운 div 요소를 생성하고 divEl 변수에 할당합니다.
var divEl = document.createElement("div");
// div 요소의 텍스트 내용을 설정합니다.
divEl.innerText = "hello~ this is " + i;
// 생성한 div 요소를 DocumentFragment에 추가합니다.
docFrag.appendChild(divEl);
// DocumentFragment에 현재까지 추가된 자식 노드들을 출력합니다.
console.log(docFrag.childNodes);
}
// DocumentFragment에 추가된 모든 div 요소들을 문서의 body에 추가합니다.
document.body.appendChild(docFrag);
// DocumentFragment가 비어 있음을 확인하기 위해 다시 자식 노드들을 출력합니다.
console.log(docFrag.childNodes);
- DocumentFragment를 DOM 노드에 추가해도 Fragment 노드 자체는 등록되지 않고 자식 노드들만 추가된다.
- DOM에 추가된 후 Fragment의 자식 노드들은 더 이상 메모리에 존재하지 않는다.
이 특징 덕분에 여러 부모 요소에 동일 구조를 삽입할 때 cloneNode(true)를 활용하면 이중 반복문 없이 깔끔하게 구성할 수 있다.
<script>
// 비어 있는 DocumentFragment를 생성하고 frag 변수에 할당합니다.
const frag = document.createDocumentFragment();
// 빈 배열 elements를 선언합니다.
let elements = [];
// 100번 반복하는 루프를 시작합니다.
for (let i = 0; i < 100; i++) {
// div 요소를 생성하고 el 변수에 할당합니다.
const el = document.createElement("div");
// 생성된 div 요소를 배열 elements에 추가합니다.
elements.push(el);
}
// 클래스 이름이 "container"인 모든 요소를 선택하여 cont 변수에 할당합니다.
const cont = document.querySelectorAll(".container");
// "container" 클래스를 가진 각 요소에 배열 elements의 복제된 요소들을 추가합니다.
for (let i = 0; i < cont.length; i++) {
// 배열 elements의 모든 요소를 복제하여 현재 "container" 요소에 추가합니다.
for(let j = 0; j < elements.length; j++){
cont[i].appendChild(elements[j].cloneNode(true));
}
}
</script>
cloneNode(true)를 활용하면 아래처럼 이중 반복문 없이 동일하게 구성할 수 있다.
<body>
<!-- 3개의 동일한 클래스 "container"를 가진 div 요소가 있습니다. -->
<div class="container">
</div>
<div class="container">
</div>
<div class="container">
</div>
<script>
// 비어 있는 DocumentFragment를 생성하고 frag 변수에 할당합니다.
const frag = document.createDocumentFragment();
// 100번 반복하는 루프를 시작합니다.
for (let i = 0; i < 100; i++) {
// div 요소를 생성하고 el 변수에 할당합니다.
const el = document.createElement("div");
// 이미지 요소를 생성하고 img 변수에 할당합니다.
const img = document.createElement("img");
// 이미지의 소스(src)를 'koreanFlag.png'로 설정합니다.
img.src = 'koreanFlag.png';
// div 요소에 이미지 요소를 자식으로 추가합니다.
el.appendChild(img);
// DocumentFragment에 현재 생성된 div 요소를 추가합니다.
frag.appendChild(el);
}
// 클래스 이름이 "container"인 모든 요소를 선택하여 cont 변수에 할당합니다.
const cont = document.querySelectorAll(".container");
// "container" 클래스를 가진 각 요소에 복제된 DocumentFragment를 추가합니다.
for (let i = 0; i < cont.length; i++) {
// 복제된 DocumentFragment를 현재 "container" 요소에 추가합니다.
cont[i].appendChild(frag.cloneNode(true));
}
</script>
</body>
정리
- DocumentFragment는 오직 메모리상에만 존재하는 경량화 DOM
- DOM에 추가 시 Fragment 자체는 등록되지 않고 자식 노드만 삽입됨
- 삽입 후 자식 노드는 Fragment에서 사라짐 — 재사용이 필요하면
cloneNode(true)로 복제