프론트엔드 성능 내부 구조 가이드 — CRP·리소스 우선순위·JS·스래싱·프로덕션
이 글의 핵심
번들을 줄이고 이미지를 WebP로 바꿨는데 지표가 거의 움직이지 않는다면, 병목은 대개 스크립트 실행이나 레이아웃 계산 같은 다른 곳에 있습니다. 이 글은 Performance 탭 녹화로 실제 원인을 찾는 과정에서 출발해 브라우저가 화면을 그리는 순서, preload를 잘못 넣으면 생기는 역효과, 긴 작업을 쪼개는 방법, 회귀 방지 패턴을 설명합니다.
최적화 전에 측정부터
LCP가 느리다는 이야기가 나오면 가장 먼저 이미지 포맷과 압축을 손보는 경우가 많습니다. WebP나 AVIF로 바꾸고 srcset과 fetchpriority까지 붙였는데 지표가 거의 움직이지 않는 일도 흔합니다. 이럴 때 DevTools의 Performance 탭으로 페이지 로드를 녹화해 보면, 병목이 이미지 전송이 아니라 메인 스레드를 오래 점유하는 긴 작업(Long Task)이나 서드파티 스크립트에 있는 것으로 드러나곤 합니다. LCP는 이미지가 도착한 시점이 아니라 화면에 그려진 시점이므로, 이미지가 일찍 도착해도 메인 스레드가 바쁘면 렌더링이 늦어지기 때문입니다.
그래서 이 글의 모든 내용에는 전제가 하나 있습니다. 어떤 기법을 적용하기 전에 먼저 녹화하고, 프로파일 결과를 근거로 손댈 곳을 고르는 것입니다. 사용자 기기에서는 파싱, 스타일 계산, 레이아웃, 페인트, 합성이 한 프레임 안에서 시간을 나눠 쓰고, JavaScript도 대부분 같은 메인 스레드에서 실행됩니다. 아래에서는 Core Web Vitals 지표와 이어지도록 브라우저가 일하는 순서와 각 단계에서 쓸 수 있는 수단을 정리합니다.
크리티컬 렌더링 경로 (Critical Rendering Path)
크리티컬 렌더링 경로는 HTML로 DOM을, CSS로 CSSOM을 만들고, 이를 합쳐 렌더 트리를 구성한 뒤 레이아웃, 페인트, 합성(composite)을 거쳐 첫 픽셀이 화면에 나타나기까지의 흐름입니다. “크리티컬”은 사용자가 처음 보는 화면을 그리는 데 반드시 필요한 자원이 무엇인지를 가리킵니다.
DOM·CSSOM·렌더 트리
DOM은 HTML 토큰을 파싱해 만든 노드 트리입니다. 파서가 async·defer·type="module"이 없는 일반 <script>를 만나면 파싱을 멈추고 스크립트를 내려받아 실행한 뒤에야 다음 HTML로 넘어갑니다.
CSSOM은 스타일시트로 만든 규칙 트리입니다. 스타일시트는 렌더링을 막는(render-blocking) 리소스입니다. 화면에 보이는 형태를 결정하므로, 브라우저는 스타일 규칙을 다 알기 전에는 페인트를 시작하지 않습니다. 또 스크립트가 스타일 정보를 읽을 수 있으므로, 앞선 스타일시트가 로드되기 전에는 동기 스크립트의 실행도 미뤄집니다.
렌더 트리는 DOM에서 화면에 보이는 노드와 각 노드의 계산된 스타일(computed style)을 합친 것입니다. display: none인 노드는 레이아웃에 참여하지 않으므로 렌더 트리에서 빠집니다.
레이아웃·페인트·합성
레이아웃(리플로우)은 각 박스의 위치와 크기를 계산하는 단계로, 뷰포트 크기 변경, 웹폰트 로딩, 동적 콘텐츠 삽입이 있으면 다시 일어납니다. 페인트는 레이아웃 결과를 텍스트, 테두리, 그림자 같은 그리기 명령으로 바꾸고 래스터화하는 단계입니다. 합성은 여러 레이어를 합쳐 최종 화면을 만드는 단계로, 보통 별도의 컴포지터 스레드와 GPU가 처리합니다. transform과 opacity의 변경은 레이아웃과 페인트를 다시 하지 않고 합성 단계만으로 처리될 수 있어, 애니메이션에서 이 두 속성을 권장하는 이유가 됩니다.
flowchart LR HTML[HTML 파싱] --> DOM[DOM] CSS[CSS 로드·파싱] --> CSSOM[CSSOM] DOM --> RT[렌더 트리] CSSOM --> RT RT --> L[Layout] L --> P[Paint] P --> C[Composite]
엔진마다 세부 단계의 이름과 구성은 다를 수 있으며, 위 그림은 개념적인 흐름입니다.
CRP를 짧게 만드는 방법
LCP 후보가 되는 히어로 이미지나 제목이 JavaScript가 실행된 뒤에야 DOM에 들어가면, 브라우저는 그 리소스를 늦게 발견하고 LCP도 늦어집니다. SSR이나 정적 HTML로 LCP 요소를 초기 HTML에 포함하는 것이 가장 효과적인 수단입니다.
CSS는 첫 페인트 전에 모두 받아야 하므로, 쓰지 않는 규칙과 연쇄적인 @import(앞 파일을 받아야 다음 파일을 발견함)는 첫 페인트를 직접 늦춥니다. 첫 화면에 필요한 CSS만 인라인하는 크리티컬 CSS 기법은 여전히 유효하지만, 빌드 단계가 복잡해지는 유지보수 비용과 함께 판단해야 합니다.
파서를 막는 동기 스크립트는 CRP를 직접 늘립니다. defer, 모듈 스크립트, 코드 분할로 초기 렌더링에 필요 없는 스크립트를 경로에서 빼는 것이 기본입니다.
리소스 로딩 우선순위
브라우저는 모든 파일을 똑같이 다루지 않습니다. 리소스를 언제 발견했는지, 어떤 종류인지, 문서의 어디에 있는지, 어떤 힌트가 붙었는지에 따라 요청 우선순위를 정하고 대역폭을 나눕니다. “preload를 넣으면 빨라진다”는 말이 항상 맞지 않는 이유가 여기에 있습니다.
브라우저 휴리스틱과 fetchpriority
기본적으로 CSS와 동기 스크립트, 문서 앞쪽에 있는 리소스, 뷰포트 안의 이미지가 높은 우선순위를 받습니다. 하지만 이미지가 뷰포트 안에 있는지는 레이아웃을 해 봐야 알 수 있어, 브라우저는 처음에 이미지를 낮은 우선순위로 요청했다가 나중에 올리기도 합니다. 이런 경우 fetchpriority로 의도를 직접 알려 줄 수 있습니다.
fetchpriority="high"는 LCP 이미지처럼 정말 중요한 리소스의 우선순위를 올립니다. 다만 우선순위는 상대적이어서, 남용하면 다른 필수 자원과 경쟁하게 됩니다. fetchpriority="low"는 당장 쓰지 않는 이미지나 스크립트를 뒤로 보냅니다. 전송 속도를 늦추는 것이 아니라 순서만 조정합니다.
preload는 HTML 파싱만으로는 늦게 발견되는 자원(CSS 안에서 참조하는 폰트, JavaScript가 삽입하는 이미지 등)의 요청 시점을 앞당깁니다. 이미 HTML에 <img>로 들어 있어 바로 발견되는 자원에 preload를 걸면 얻는 것은 없고 다른 요청의 자리만 차지합니다. Network 패널의 워터폴에서 해당 리소스가 실제로 늦게 발견되는지 확인한 뒤에 씁니다.
preload·prefetch·preconnect·dns-prefetch
rel="preload": 현재 페이지에서 곧 필요한 리소스를 미리 가져옵니다.as속성으로 유형을 정확히 지정해야 하며, 틀리면 같은 리소스를 두 번 받게 됩니다.rel="prefetch": 다음 내비게이션에서 쓸 가능성이 높은 리소스를 낮은 우선순위로 가져옵니다.rel="preconnect": 해당 출처에 대한 DNS 조회, TCP 연결, TLS 핸드셰이크를 미리 수행합니다. 서드파티 API, CDN, 폰트 출처에 자주 씁니다. 연결은 일정 시간 쓰이지 않으면 닫히므로 곧 실제 요청이 나갈 출처에만 겁니다.rel="dns-prefetch": DNS 조회만 미리 합니다. preconnect보다 가볍습니다.
<head>
<!-- LCP 후보 이미지: 반응형 후보와 sizes를 img와 동일하게 지정 -->
<link
rel="preload"
as="image"
href="/hero-1600.avif"
imagesrcset="/hero-800.avif 800w, /hero-1600.avif 1600w"
imagesizes="100vw"
fetchpriority="high"
/>
<!-- CSS 안에서 참조되어 늦게 발견되는 웹폰트 -->
<link
rel="preload"
as="font"
href="/fonts/pretendard-subset.woff2"
type="font/woff2"
crossorigin
/>
<!-- 서드파티 API 출처: 연결을 미리 열어 둠 -->
<link rel="preconnect" href="https://api.example.com" crossorigin />
<!-- 다음 페이지에서 쓸 가능성이 높은 청크 -->
<link rel="prefetch" href="/next-page-chunk.js" as="script" />
</head>
imagesrcset과 imagesizes는 <img>의 srcset, sizes와 같은 문법이며, 두 값이 실제 <img>와 다르면 브라우저가 preload한 후보와 다른 이미지를 다시 요청할 수 있습니다. 폰트 preload에는 같은 출처라도 crossorigin이 필요합니다. 폰트는 CORS 모드로 요청되므로, 이 속성이 없으면 preload한 응답을 재사용하지 못하고 두 번 받습니다. 적용 후에는 Network 패널에서 요청 시작 시점이 실제로 앞당겨졌는지, 중복 요청이 없는지 확인합니다.
스크립트: async·defer·module
속성이 없는 일반 스크립트는 다운로드와 실행 동안 파서를 막습니다. defer 스크립트는 HTML 파싱과 병렬로 내려받고, 파싱이 끝난 뒤 문서 순서대로 DOMContentLoaded 직전에 실행됩니다. async 스크립트는 병렬로 내려받되 도착하는 즉시 실행되므로 실행 순서가 보장되지 않고, 실행되는 동안에는 파서를 멈춥니다. 다른 스크립트와 순서 의존성이 없는 분석 스크립트나 위젯에 맞습니다. type="module" 스크립트는 기본적으로 defer처럼 동작하며, async를 붙이면 async처럼 동작합니다.
이미지·폰트·서드파티
이미지에 width/height 속성이나 CSS aspect-ratio를 지정하면 브라우저가 이미지 도착 전에 공간을 예약하므로 레이아웃 이동(CLS)이 줄어듭니다. 두 속성은 실제 렌더링 크기가 아니라 가로세로 비율을 알려 주는 용도로도 충분합니다.
<!-- 뷰포트 밖 이미지: 네이티브 지연 로딩 + 공간 예약 -->
<img
src="/thumbnail-800.webp"
srcset="/thumbnail-400.webp 400w, /thumbnail-800.webp 800w"
sizes="(max-width: 600px) 100vw, 800px"
width="800"
height="450"
loading="lazy"
decoding="async"
alt="상품 썸네일"
/>
<!-- LCP 이미지: 지연 로딩을 걸지 않음 -->
<img
src="/hero-1600.avif"
width="1600"
height="900"
fetchpriority="high"
alt="히어로 배너"
/>
흔한 실수는 모든 이미지에 loading="lazy"를 붙이는 것입니다. 지연 로딩은 이미지가 뷰포트 근처에 올 때까지 요청을 미루므로, 처음부터 화면에 보이는 LCP 이미지에 걸면 레이아웃이 끝날 때까지 요청이 늦어져 LCP가 나빠집니다. 지연 로딩은 뷰포트 밖 이미지에만 씁니다.
웹폰트는 font-display 값에 따라 폰트가 도착하기 전에 글자를 숨길지(FOIT), 대체 폰트로 먼저 보여 줄지(FOUT)가 정해집니다. 쓰는 글자만 남긴 서브셋 폰트는 파일 크기를 크게 줄입니다. 태그 매니저, 분석, 채팅 위젯 같은 서드파티 스크립트는 네트워크와 메인 스레드를 함께 소비하므로, 필요한 페이지에만 넣고 사용자 동의 이후나 상호작용 이후로 로드를 미루는 것이 기본입니다.
JavaScript 실행 최적화
파일을 빨리 받아도 파싱, 컴파일, 실행에는 별도의 시간이 듭니다. 메인 스레드를 오래 점유하면 스크롤과 입력 반응이 함께 나빠지고 INP가 올라갑니다. 앞에서 말한 “이미지를 바꿨는데 LCP가 그대로인” 상황의 원인이 이 구간에 있는 경우가 많으므로, Network 탭보다 Performance 탭을 먼저 봅니다.
메인 스레드와 이벤트 루프
브라우저는 한 탭의 DOM 조작, 스타일 계산, 레이아웃과 대부분의 JavaScript를 메인 스레드에서 처리합니다. 이벤트 루프는 태스크 큐에서 작업을 하나씩 꺼내 실행하며, 한 작업이 길어지면 그동안 다음 프레임 렌더링과 입력 처리가 모두 밀립니다. DevTools Performance에서 50ms를 넘는 작업이 Long Task로 표시되는데, 이 기준은 100ms 안에 입력에 반응하려면 작업 하나가 50ms 안에 끝나야 한다는 RAIL 모델에서 왔습니다.
실행 비용 줄이기
같은 라이브러리가 여러 버전으로 중복 포함되거나, 필요 없는 폴리필과 사용하지 않는 코드가 번들에 남으면 그만큼 파싱과 컴파일 시간이 늘어납니다. 번들 분석 도구로 큰 의존성부터 확인합니다.
첫 화면에 필요 없는 기능은 동적 import()로 분리합니다.
// 버튼을 눌렀을 때만 필요한 차트 코드를 별도 청크로 분리
const button = document.querySelector("#show-chart");
button.addEventListener("click", async () => {
const { renderChart } = await import("./chart.js");
renderChart(document.querySelector("#chart-container"));
});
동적 import()는 번들러(Vite, webpack, Rollup 등)가 해당 모듈을 별도의 청크 파일로 쪼개는 지점이 됩니다. chart.js와 그것이 의존하는 차트 라이브러리는 페이지 로드 시점에는 받지 않고, 사용자가 버튼을 눌렀을 때만 요청합니다. 초기 번들에서 빠진 만큼 파싱과 컴파일 비용도 뒤로 미뤄지므로, 모달, 차트, 리치 에디터처럼 첫 화면과 무관한 기능이 우선 대상입니다. React의 lazy(), Vue Router의 lazy 컴포넌트, Next.js의 next/dynamic도 모두 이 동적 import() 위에 만들어진 API입니다. 다만 클릭 후 청크를 받는 동안 지연이 생기므로, 마우스를 올렸을 때 미리 받아 두는 식의 보완을 함께 고려합니다.
큰 목록은 보이는 부분만 렌더링하는 가상 스크롤로, 불필요한 리렌더링은 프레임워크의 메모이제이션으로 줄입니다. React의 경우는 React useMemo와 useCallback, 언제 쓰면 이득인가에서 자세히 다룹니다.
긴 작업 분할과 스케줄링
긴 작업을 여러 개의 짧은 작업으로 나누면, 그 사이에 브라우저가 입력을 처리하고 화면을 그릴 수 있습니다. 쓸 수 있는 수단은 우선순위에 따라 다릅니다.
setTimeout(fn, 0)이나 MessageChannel로 다음 작업을 새 태스크로 넘기는 것이 가장 오래된 방법입니다. scheduler.yield()와 scheduler.postTask()(현재 Chromium 계열 등 일부 브라우저에서 지원)는 양보 후 이어지는 작업의 우선순위를 명시할 수 있습니다. requestIdleCallback은 브라우저가 한가할 때만 실행되므로 분석 데이터 전송처럼 급하지 않은 작업에 맞고, 사용자 입력에 대한 반응을 여기에 두면 안 됩니다. Safari는 requestIdleCallback을 지원하지 않으므로 setTimeout 폴백이 필요합니다.
// 큰 배열을 유휴 시간에 나눠 처리
function processInIdle(items, processItem) {
let index = 0;
function runChunk(deadline) {
// 남은 유휴 시간이 있는 동안 처리. 타임아웃으로 호출된 경우에도 한 묶음만 처리하고 양보
const limit = deadline.didTimeout ? Math.min(index + 50, items.length) : items.length;
while (index < limit && (deadline.didTimeout || deadline.timeRemaining() > 0)) {
processItem(items[index]);
index++;
}
if (index < items.length) {
requestIdleCallback(runChunk, { timeout: 1000 });
}
}
requestIdleCallback(runChunk, { timeout: 1000 });
}
requestIdleCallback은 콜백에 deadline 객체를 넘기고, deadline.timeRemaining()은 이번 유휴 구간에 남은 시간(최대 50ms)을 알려 줍니다. 이 값이 0이 되면 루프를 멈추고 다음 유휴 구간으로 넘깁니다. timeout을 지정하면 그 시간 안에 유휴 구간이 오지 않을 때 didTimeout이 true인 채로 강제 호출되는데, 이때 남은 항목을 한꺼번에 처리하면 다시 긴 작업이 생기므로 위 코드처럼 한 묶음만 처리하고 양보합니다.
Web Worker
Worker는 DOM에 접근할 수 없지만, 파싱, 압축, 대규모 계산을 메인 스레드 밖으로 옮길 수 있습니다. 메인 스레드는 결과만 받아 화면에 반영하도록 설계하면 입력 반응이 안정됩니다. Worker와 주고받는 데이터는 구조화 복제로 복사되므로, 큰 버퍼는 postMessage의 transfer 목록으로 소유권을 넘겨 복사 비용을 피합니다. 캔버스 렌더링이라면 OffscreenCanvas를 Worker로 넘겨 그리기까지 옮길 수 있습니다.
레이아웃 스래싱 (Layout Thrashing)
스래싱은 짧은 시간에 레이아웃이 불필요하게 반복 계산되는 상황입니다. 대부분 스타일을 쓰고 곧바로 기하 정보를 읽는 동작이 반복되면서 강제 동기 레이아웃(forced synchronous layout)이 연쇄적으로 일어나는 패턴에서 생깁니다.
강제 동기 레이아웃이 생기는 이유
브라우저는 스타일 변경을 바로 반영하지 않고 모아 두었다가 다음 프레임에서 한꺼번에 레이아웃을 계산합니다. 그런데 JavaScript가 스타일을 바꾼 직후 offsetWidth나 getBoundingClientRect() 같은 기하 정보를 읽으면, 정확한 값을 돌려주기 위해 그 자리에서 레이아웃을 계산해야 합니다. 쓰기와 읽기를 번갈아 반복하면 이 계산이 매번 일어납니다.
// 나쁜 예: 반복마다 쓰기 직후 읽기 → 매번 강제 레이아웃
const boxes = document.querySelectorAll(".box");
boxes.forEach((box) => {
const width = box.offsetWidth; // 읽기: 직전 반복의 쓰기 때문에 레이아웃 강제
box.style.width = width + 10 + "px"; // 쓰기: 레이아웃을 다시 무효화
});
// 좋은 예: 읽기를 모두 끝낸 뒤 쓰기를 몰아서 실행
const widths = Array.from(boxes, (box) => box.offsetWidth); // 읽기 단계
requestAnimationFrame(() => {
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + "px"; // 쓰기 단계
});
});
첫 번째 코드는 요소가 N개면 레이아웃 계산이 최대 N번 일어납니다. 두 번째 코드는 첫 읽기에서 한 번 계산한 결과를 나머지 읽기가 그대로 쓰고, 쓰기는 다음 프레임의 레이아웃 한 번에 반영되므로 계산 횟수가 요소 수와 무관해집니다. 리스트 렌더링, 드래그 앤 드롭, 가상 스크롤처럼 DOM의 크기와 위치를 자주 조회하는 코드에서 이 패턴을 먼저 점검합니다.
레이아웃 범위를 줄이는 CSS
CSS contain 속성은 특정 서브트리의 레이아웃과 페인트가 바깥에 영향을 주지 않는다고 브라우저에 알려, 변경이 생겼을 때 다시 계산할 범위를 줄입니다. content-visibility: auto는 뷰포트 밖 콘텐츠의 렌더링을 건너뛰게 해 긴 문서나 피드의 초기 렌더링 비용을 줄입니다. 이때 contain-intrinsic-size로 예상 크기를 지정하지 않으면 스크롤바 길이가 흔들릴 수 있습니다. will-change는 곧 바뀔 속성을 미리 알려 합성 레이어를 준비시키지만, 레이어마다 메모리를 쓰므로 실제로 자주 바뀌는 요소에만 제한적으로 씁니다.
측정
Chrome DevTools Performance 녹화에서 Layout과 Recalculate Style 이벤트가 짧은 간격으로 반복되는지 확인합니다. 강제 레이아웃이 일어난 Layout 이벤트에는 경고 표시와 함께 “Forced reflow”가 표시되고, 이를 유발한 스크립트 위치를 따라갈 수 있습니다.
프로덕션 성능 패턴
개발자 노트북에서 빠른 것은 캐시가 차 있고 CPU와 네트워크가 좋기 때문일 수 있습니다. 실제 사용자 환경을 관측하지 않으면 배포마다 조금씩 회귀가 쌓여도 알아채기 어렵습니다.
RUM과 랩 측정
RUM(실사용자 모니터링)은 실제 사용자 기기에서 수집한 필드 데이터입니다. Core Web Vitals 평가는 필드 데이터의 75백분위수(p75)를 기준으로 하므로, 페이지, 국가, 기기, 네트워크별로 p75를 나눠 보는 것이 좋습니다. Lighthouse나 CI에서 돌리는 랩 측정은 같은 조건에서 반복할 수 있어 회귀를 잡고 원인을 추적하는 데 유리하지만, 합성 환경이 실제 사용자 조건과 다를 수 있다는 점을 염두에 둡니다. 특히 INP는 실제 상호작용이 있어야 측정되므로 랩에서는 TBT(Total Blocking Time)를 대리 지표로 봅니다.
캐시와 CDN
파일명에 콘텐츠 해시를 넣은 정적 자산은 긴 max-age와 immutable로 캐시하고, HTML은 짧은 TTL이나 재검증으로 새 배포가 바로 반영되게 합니다. CDN의 엣지 캐시, HTTP/2·HTTP/3 지원, TLS 설정은 모두 첫 바이트까지의 시간(TTFB)에 영향을 주며, TTFB가 늦으면 이후 모든 단계가 함께 늦어집니다.
코드와 데이터 로딩
라우트와 기능 단위로 코드를 나눠 초기 번들을 줄입니다. 서버 컴포넌트나 HTML 스트리밍을 쓰는 스택이라면 HTML을 조각으로 보내 중요한 부분을 먼저 렌더링하게 할 수 있으며, App Router 렌더링 전략에서 이를 다룹니다. API 응답은 화면에 필요한 필드와 개수만 보내도록 페이지네이션합니다.
배포와 회귀 방지
성능 측정은 개발 모드가 아닌 프로덕션 빌드로 합니다. 개발 모드는 추가 검사와 경고 코드 때문에 훨씬 느립니다. 번들 크기, LCP, TBT 같은 항목에 팀이 합의한 성능 예산을 정하고 CI에서 넘으면 실패하게 하면 회귀가 머지 단계에서 드러납니다. 카나리 배포나 기능 플래그를 쓰면 성능 회귀를 발견했을 때 빠르게 되돌릴 수 있습니다.
증상별로 먼저 볼 곳
LCP만 유독 느리다면 LCP 요소가 초기 HTML에 들어 있는지, 이미지에 loading="lazy"가 걸려 있지 않은지, fetchpriority와 preload가 과하거나 부족하지 않은지를 Network 워터폴과 함께 확인합니다. 리소스는 일찍 도착했는데 LCP가 늦다면 렌더링을 막는 CSS나 메인 스레드의 긴 작업을 봅니다.
스크롤이나 입력이 끊긴다면 Performance 녹화에서 Long Task와 그 작업을 만든 스크립트를 찾습니다. 서드파티 스크립트가 원인인 경우가 많습니다.
화면이 들쭉날쭉 움직인다면 크기가 예약되지 않은 이미지나 광고, 늦게 삽입되는 배너, 웹폰트 교체로 인한 레이아웃 이동을 확인합니다.
새 배포 이후에만 느려졌다면 캐시 무효화 설정, 번들 크기 증가, 실수로 켜진 디버그 코드를 확인합니다.
더 깊이 공부하려면 web.dev와 MDN의 Performance, Rendering, Resource hints 문서를 따라가는 것이 좋습니다.