브라우저는 HTML을 어떻게 화면으로 바꿀까: 렌더링 파이프라인과 성능 포인트
빠른 답
- 브라우저는 DOM -> CSSOM -> 렌더 트리 -> 레이아웃 -> 페인트 -> 컴포지팅 순서로 화면을 만든다.
- 크기와 위치를 바꾸면 레이아웃 비용이 커지고, 색상·그림자·배경 변경은 주로 페인트 비용을 만든다.
- transform과 opacity 중심 애니메이션은 컴포지팅 단계에서 처리돼 더 부드럽게 동작하는 경우가 많다.
- 성능 문제는 감으로 추측하지 말고 Chrome DevTools의 Performance, Layers, Paint flashing으로 먼저 확인한다.
목차
데이터 흐름과 상태 소유권부터 봐야 하는 이유
브라우저 렌더링 파이프라인은 브라우저가 화면을 그리는 방법을 설명합니다. 하지만 실무에서 성능 문제가 시작되는 지점은 대개 그보다 앞입니다. 어떤 상태가 바뀌었고, 그 상태 변화가 어떤 컴포넌트 재계산과 DOM 변경으로 이어졌는지가 먼저입니다. 브라우저는 갑자기 화면을 느리게 만드는 주체라기보다, 애플리케이션이 만든 결과를 계산하고 그리는 쪽에 가깝습니다.
그래서 프런트엔드 성능은 브라우저 지식만으로는 잘 풀리지 않습니다. 상태를 누가 소유하는지, 변경이 어떤 방향으로 흐르는지, 작은 상호작용이 왜 큰 트리의 리렌더링으로 번지는지를 같이 봐야 합니다. 상태는 한 곳이 소유하고, 아래로 전달하고, 자식은 이벤트만 올리는 구조가 가장 추적하기 쉽습니다.
아래 예시는 검색어와 선택된 항목을 부모가 소유하고, 자식은 표현과 이벤트 전달만 담당하는 형태입니다. 이 구조에서는 어떤 입력이 어떤 목록 필터링과 선택 상태 변경을 만들었는지 경로가 분명합니다.
import React, { useState } from 'react';
function ProductPage({ products }) {
const [keyword, setKeyword] = useState('');
const [selectedId, setSelectedId] = useState(null);
const filtered = products.filter((product) =>
product.name.toLowerCase().includes(keyword.toLowerCase())
);
return React.createElement(
React.Fragment,
null,
React.createElement(FilterBar, {
keyword,
onKeywordChange: setKeyword,
}),
React.createElement(ProductList, {
items: filtered,
selectedId,
onSelect: setSelectedId,
})
);
}
function FilterBar({ keyword, onKeywordChange }) {
return React.createElement('input', {
value: keyword,
placeholder: '상품 검색',
onChange: (event) => onKeywordChange(event.target.value),
});
}
function ProductList({ items, selectedId, onSelect }) {
return React.createElement(
'section',
null,
items.map((item) =>
React.createElement(ProductRow, {
key: item.id,
item,
selected: item.id === selectedId,
onSelect,
})
)
);
}
function ProductRow({ item, selected, onSelect }) {
return React.createElement(
'button',
{
onClick: () => onSelect(item.id),
style: { fontWeight: selected ? 'bold' : 'normal' },
},
item.name
);
}
이 예시의 핵심은 “상태가 한 번만 존재한다”는 점입니다. 검색어는 FilterBar가 아니라 부모가 갖고, 선택 상태도 각 행이 아니라 부모가 갖습니다. 이 원칙이 지켜져야 특정 입력 한 번이 어떤 리렌더링과 DOM 변경을 만들었는지 쉽게 좁혀갈 수 있습니다.
브라우저 렌더링 파이프라인을 왜 알아야 할까: 화면은 느린데 API는 빠른 이유
실무에서는 “API는 100ms 안에 끝났는데 화면은 왜 버벅이지?”라는 상황을 자주 만납니다. 이때 네트워크만 보고 있으면 원인을 놓칩니다. 사용자가 체감하는 지연은 응답 시간만이 아니라, 상호작용 뒤 다음 프레임이 제때 그려졌는지에 달려 있기 때문입니다.
60Hz 화면에서는 한 프레임 예산이 약 16.7ms입니다. 이 안에서 자바스크립트 실행, 스타일 계산, 레이아웃, 페인트가 끝나야 부드럽게 보입니다. API가 빨라도, 응답을 받은 뒤 2,000개 항목을 한 번에 만들고, 각 항목의 크기를 읽고, 다시 스타일을 바꾸고, 그림자와 블러가 많은 UI를 그리면 메인 스레드가 금방 막힙니다.
초기 로딩에서도 비슷한 일이 일어납니다. HTML 파서는 문서를 읽으며 DOM을 만들고, CSS는 CSSOM을 만듭니다. 이때 CSS는 첫 렌더링에 필요한 정보이기 때문에 화면 표시를 지연시킬 수 있고, 동기 스크립트는 파싱을 멈추게 할 수 있습니다. 결국 “페이지가 늦게 보인다”는 문제도 DOM, CSSOM, 자바스크립트 실행 타이밍이 서로 얽힌 결과입니다.
HTML이 픽셀이 되기까지: DOM, CSSOM, 렌더 트리, 레이아웃, 페인트, 컴포지팅
브라우저 동작은 엔진마다 더 복잡하지만, 성능을 이해할 때는 아래 여섯 단계로 정리해도 충분히 유용합니다.
DOM: HTML을 파싱해 문서 구조를 트리로 만듭니다. 어떤 요소가 부모이고 자식인지, 텍스트가 어디에 속하는지가 여기서 정해집니다.CSSOM: CSS를 파싱해 스타일 규칙을 구조화합니다. 어떤 선택자가 어떤 선언을 가지는지 정리된 상태라고 보면 됩니다.렌더 트리: DOM과 CSSOM을 합쳐 실제로 화면에 그릴 대상을 추립니다.display: none상태의 요소는 여기서 빠집니다.레이아웃: 각 요소의 크기와 위치를 계산합니다. 줄바꿈, 박스 크기, 주변 요소와의 배치가 이 단계에서 결정됩니다.페인트: 텍스트, 배경, 테두리, 그림자, 이미지 같은 시각 요소를 실제 픽셀로 그립니다.컴포지팅: 여러 레이어를 합성해 최종 화면을 만듭니다. 브라우저가 가능한 경우 이 단계에서 GPU 도움을 받습니다.
중요한 점은 모든 변경이 이 여섯 단계를 처음부터 끝까지 다시 거치는 것은 아니라는 사실입니다. 브라우저는 영향 범위만 다시 계산하려고 합니다. 하지만 어떤 변경은 주변까지 쉽게 전파됩니다. 너비 변경은 자기 자신만이 아니라 형제·부모·자식 배치까지 흔들 수 있고, 그 결과 더 넓은 레이아웃 재계산을 부릅니다.
또 하나 자주 헷갈리는 차이가 있습니다. display: none은 렌더 트리에서 빠지지만, visibility: hidden은 공간을 유지한 채 보이지만 않게 합니다. opacity: 0은 보이지 않더라도 여전히 레이아웃과 이벤트 처리 측면에서 살아 있을 수 있습니다. 같은 “숨김”이라도 의미와 비용이 다릅니다.
어떤 변경이 어디까지 전파될까: 스타일 계산, 레이아웃, 페인트, 컴포지팅
실무에서 많이 쓰는 “리플로우”와 “리페인트”는 대체로 레이아웃과 페인트 비용을 가리킵니다. 모든 속성이 같은 비용을 만드는 것은 아닙니다.
- 레이아웃까지 전파되기 쉬운 변경:
width,height,margin,padding,font-size,top,left - 주로 페인트 비용을 만드는 변경:
color,background-color,border-color,box-shadow - 컴포지팅 단계에서 처리될 가능성이 높은 변경:
transform,opacity
이 분류를 외우는 것보다 중요한 것은 전파 범위를 생각하는 습관입니다. 예를 들어 앱 루트에 테마 클래스를 토글하면 넓은 서브트리 전체의 스타일 계산이 다시 일어날 수 있습니다. 꼭 필요한 변경이라면 해야 하지만, “작은 토글 하나”라고 가볍게 보면 실제 비용을 놓치기 쉽습니다.
또 하나 자주 놓치는 지점이 읽기와 쓰기의 순서입니다. 스타일을 바꾼 직후 레이아웃 값을 읽으면 브라우저는 미뤄두었던 계산을 즉시 수행해야 할 수 있습니다. 이런 패턴이 반복되면 강제 동기 레이아웃이 생기고, 흔히 말하는 레이아웃 스래싱으로 이어집니다.
const box = document.querySelector('.card');
let x = 0;
let rafId = 0;
function moveBad() {
x += 12;
box.style.left = `${x}px`;
console.log(box.offsetLeft);
}
function moveGood(delta) {
x += delta;
if (rafId) return;
rafId = requestAnimationFrame(() => {
box.style.transform = `translateX(${x}px)`;
rafId = 0;
});
}
moveBad는 위치를 바꾼 뒤 곧바로 offsetLeft를 읽습니다. 브라우저는 “나중에 한 번에 계산”할 기회를 잃고 중간에 레이아웃을 확정해야 할 수 있습니다. 반면 moveGood은 빠르게 들어오는 입력을 다음 프레임 단위로 모으고, 배치 계산에 덜 민감한 transform을 사용합니다. 이것이 항상 공짜라는 뜻은 아니지만, 레이아웃을 흔드는 방식보다 대체로 안전합니다.
프런트엔드에서 자주 만드는 안티패턴
렌더링 비용을 키우는 안티패턴은 브라우저 API보다 상태 관리 쪽에서 먼저 나타나는 경우가 많습니다. 대표적인 예가 부모의 값을 자식 상태로 다시 복사하는 패턴입니다. 이 구조는 같은 정보를 두 군데서 관리하게 만들고, 입력 지연·값 불일치·불필요한 리렌더링을 부르기 쉽습니다.
import React, { useEffect, useState } from 'react';
function SearchBoxBad({ keyword, onCommit }) {
const [localKeyword, setLocalKeyword] = useState(keyword);
useEffect(() => {
setLocalKeyword(keyword);
}, [keyword]);
return React.createElement('input', {
value: localKeyword,
onChange: (event) => setLocalKeyword(event.target.value),
onBlur: () => onCommit(localKeyword),
});
}
function SearchBoxGood({ keyword, onChange }) {
return React.createElement('input', {
value: keyword,
onChange: (event) => onChange(event.target.value),
});
}
SearchBoxBad는 부모가 이미 가진 keyword를 다시 자식 상태로 복사합니다. 이렇게 되면 입력 중인 값, 부모가 가진 값, 동기화 시점이 따로 놀 수 있습니다. 입력창처럼 타이밍이 민감한 UI에서는 특히 문제가 잘 드러납니다.
자주 나오는 다른 안티패턴도 비슷한 결을 가집니다.
- 사소한 UI 상태까지 앱 루트가 모두 소유해 큰 트리를 함께 리렌더링하는 패턴
- 항목 수가 많은 리스트에서 개별 노드에 인라인 스타일을 반복적으로 주입하는 패턴
- 스크롤·마우스 이동 같은 고빈도 이벤트에서 즉시 DOM을 읽고 쓰는 패턴
- 숨기기만 하면 되는 요소를 무조건
display: none으로 바꿔 주변 레이아웃까지 흔드는 패턴
성능에 유리한 렌더링 구성 예시: CSS 속성 선택과 숨김 전략
성능은 한 가지 마법 속성으로 좋아지지 않습니다. 상태 소유권, DOM 변경 범위, CSS 속성 선택이 같이 맞아야 합니다. 그래도 실무에서 바로 도움이 되는 기준은 분명합니다. 이동과 투명도 애니메이션은 transform, opacity 위주로 설계하고, 숨김 방식은 목적에 맞게 고릅니다.
아래 스타일 예시는 실무에서 자주 쓰는 선택 기준을 한 번에 보여줍니다.
.card {
transition: transform 180ms ease, opacity 180ms ease;
}
.card.is-entering {
transform: translateY(8px);
opacity: 0;
}
.card.is-visible {
transform: translateY(0);
opacity: 1;
}
.panel.is-removed {
display: none;
}
.panel.is-hidden {
visibility: hidden;
opacity: 0;
pointer-events: none;
}
.feed-section {
content-visibility: auto;
contain-intrinsic-size: 800px;
}
.dragging-item {
will-change: transform;
}
이 설정을 읽을 때는 아래 기준으로 보면 됩니다.
- 문서 흐름에서 완전히 빼야 하면
display: none - 자리는 유지하되 보이지만 않게 하려면
visibility: hidden - 페이드 효과가 필요하면
opacity: 0을 쓰되pointer-events: none까지 같이 고려 - 긴 문서나 피드처럼 화면 아래쪽 콘텐츠가 많으면
content-visibility: auto검토 will-change는 드래그나 짧은 전환처럼 정말 필요한 순간에만 제한적으로 사용
특히 will-change는 자주 오해됩니다. 브라우저가 미리 최적화를 준비하게 도와줄 수 있지만, 레이어와 메모리 사용량을 늘릴 수 있습니다. 모든 카드, 모든 버튼에 습관처럼 넣는 속성은 아닙니다.
렌더링 타이밍: React 리렌더링과 브라우저 페인트는 다르다
프런트엔드에서 자주 섞어 말하는 두 개념이 있습니다. React의 리렌더링과 브라우저의 레이아웃·페인트입니다. 이 둘은 같은 말이 아닙니다.
React 관점에서는 대략 이렇게 생각하면 됩니다.
- 리렌더링: 컴포넌트 함수를 다시 실행해 다음 UI 결과를 계산
- 커밋: 계산된 변경을 실제 DOM에 반영
- 브라우저 단계: 그 다음에 스타일 계산, 레이아웃, 페인트, 컴포지팅 수행
즉 React가 한 번 더 계산했다고 해서 곧바로 브라우저 레이아웃 비용이 큰 것은 아닙니다. 반대로 DOM 변경이 많지 않아 보여도 특정 속성 하나가 큰 레이아웃 비용을 만들 수 있습니다. 성능 문제를 볼 때 “리렌더링이 많다”와 “브라우저가 비싸게 그린다”를 구분해야 하는 이유입니다.
렌더링 타이밍도 여기서 중요해집니다. DOM 크기를 재거나 스크롤 위치를 즉시 맞춰야 하는 작업은 useLayoutEffect처럼 페인트 직전 타이밍이 필요할 수 있습니다. 반대로 네트워크 호출, 로깅, 구독 등록처럼 화면을 그리는 일과 직접 상관없는 작업은 useEffect로 넘겨야 첫 페인트를 막지 않습니다. useLayoutEffect를 남용하면 눈에 안 보이는 동기 작업으로 프레임 예산을 깎아먹게 됩니다.
정리하면 이렇습니다. 상태 변경은 최소 범위에서 일어나야 하고, DOM 변경은 필요한 만큼만 커밋되어야 하며, 브라우저가 처리할 속성은 레이아웃을 흔들지 않는 방향이 유리합니다. 이 세 가지가 함께 맞아야 “리렌더링은 있는데 프레임 드랍은 없는 상태”를 만들 수 있습니다.
Chrome DevTools로 병목 확인하기
성능 문제를 감으로 해결하려고 하면 오래 갑니다. 먼저 실제로 어느 단계가 느린지 봐야 합니다.
Performance에서 녹화를 시작하고, 실제로 느린 상호작용을 한 번 재현합니다.- 메인 스레드에서 긴
Layout,Paint,Composite Layers, 긴 자바스크립트 작업이 어디서 나오는지 봅니다. Rendering > Paint flashing을 켜서 어떤 상호작용이 화면 전체를 다시 칠하게 만드는지 확인합니다.Layers에서 레이어가 지나치게 많아졌는지, 기대한 요소가 실제로 분리되어 있는지 확인합니다.- CPU throttling을 걸어 저사양 기기에서 병목이 더 심하게 드러나는지 확인합니다.
이렇게 보면 “리스트 필터링이 느리다” 같은 막연한 문제도 구체적으로 바뀝니다. 자바스크립트 계산이 무거운지, 스타일 계산이 넓게 퍼지는지, 레이아웃이 자주 강제되는지, 페인트가 비싼지 분리해서 볼 수 있기 때문입니다.
자주 하는 오해와 실무 체크리스트
transform,opacity가 항상 공짜는 아니다. 요소 수가 많거나 레이어가 과하면 컴포지팅과 메모리 비용이 커질 수 있다.- DOM 수를 줄이는 것만으로는 부족하다. 노드 수보다 더 중요한 것은 변경 패턴과 전파 범위다.
- React 리렌더링과 브라우저 리플로우를 같은 뜻으로 쓰면 디버깅이 틀어지기 쉽다.
- 숨김 방식은 “어떤 비용이 더 적은가”보다 “이 요소를 문서 흐름에서 빼야 하는가”를 먼저 따져야 한다.
- 성능 문제를 볼 때는
상태 변경 -> 컴포넌트 리렌더링 -> DOM 커밋 -> 스타일 계산 -> 레이아웃/페인트/컴포지팅순서로 끊어 보는 습관이 가장 유용하다.
브라우저 렌더링 파이프라인을 이해한다는 것은 단계 이름을 외우는 일이 아닙니다. 어떤 상태 변화가 어떤 DOM 변경을 만들고, 그 변경이 레이아웃인지 페인트인지 컴포지팅인지 구분해낼 수 있는 상태를 뜻합니다. 프런트엔드 성능은 결국 브라우저와 프레임워크 사이의 경계를 정확히 이해하는 데서 시작합니다.
원문 참고
https://www.maeil-mail.kr/question/19
댓글
댓글 쓰기