본문으로 건너뛰기 프론트엔드 성능 내부 구조 가이드 — CRP·리소스 우선순위·JS·스래싱·프로덕션

프론트엔드 성능 내부 구조 가이드 — CRP·리소스 우선순위·JS·스래싱·프로덕션

프론트엔드 성능 내부 구조 가이드 — CRP·리소스 우선순위·JS·스래싱·프로덕션

이 글의 핵심

브라우저가 HTML·CSS·JS를 어떻게 합쳐 화면에 피사체를 그리는지(크리티컬 렌더링 경로), 네트워크 우선순위와 스크립트 실행 비용, 레이아웃·페인트 스래싱, 배포 환경에서의 검증 패턴까지 한 흐름으로 연결합니다.

최적화 전에 측정부터

몇 년 전 랜딩을 손볼 때가 생각나요. 팀에서는 “LCP가 안 나온다”고 해서 저는 당연히 이미지 포맷·압축부터 갈아엎었습니다. WebP, srcset, fetchpriority까지 붙였는데 숫자는 거의 안 움직였습니다. 그때 처음으로 Performance 탭을 진짜로 녹화해 봤습니다. Flame chart를 보면서 정말 어이없었던 게, 병목은 이미지가 아니라 메인 스레드에 붙어 있던 긴 작업(Long Task)서드파티 스크립트였다는 거예요. 그날 이후로 저는 말을 이렇게 고쳤습니다. 추측하지 말고 측정하세요. 체감으로 “느린 것 같다”고 판단하는 순간 이미 한 방 먹은 겁니다. 최적화 전에 측정부터 — 이건 겸손한 조언이 아니라, 시간을 아끼는 제일 싸고 확실한 방법입니다.


들어가며

프론트엔드 성능은 “번들 줄였다”, “이미지 WebP로 바꿨다” 같은 보이는 조치만으로 끝나지 않아요. 사용자 기기에서는 파싱·스타일·레이아웃·페인트·컴포지트가 한 프레임 안에서 서로 밀고 당기며, JS는 대부분 메인 스레드에서 그 예산을 같이 씁니다. 아래는 Core Web Vitals랑도 이어지게, 브라우저가 일하는 순서실무에서 자주 쓰는 레버를 정리한 거예요. 다만 원칙은 하나입니다. 아래 어떤 팁을 읽기 전에, DevTools로 한 번만 찍어보세요. 제 말 믿지 말고, 프로파일이 말하게 하세요.

이 글을 읽으면

  • 크리티컬 렌더링 경로(CRP)에서 병목이 자주 나는 지점을 짚을 수 있습니다.
  • 리소스 우선순위preload·fetchpriority·스크립트 속성이랑 같이 설계하는 감이 생겨요.
  • JS 실행 비용을 Long Task·스케줄링·Worker 쪽에서 줄일 방향을 잡을 수 있습니다.
  • 레이아웃·페인트 스래싱은 왜 생기는지, 어떻게 찍어서 잡는지까지 연결돼요.
  • 프로덕션에서 회귀 막는 관측·배포 패턴을 체크리스트처럼 가져갈 수 있습니다.

1. 크리티컬 렌더링 경로 (Critical Rendering Path)

크리티컬 렌더링 경로는, HTML로 DOM, CSS로 CSSOM 만들고 합쳐 렌더 트리 쓰며, 그다음 레이아웃(레플로우)페인트컴포지트(합성)까지 가서 처음으로 의미 있는 픽셀이 뜨기까지 흐름입니다. “크리티컬”은 사용자가 제일 먼저 보는 화면에 필요한 최소 경로에 자원이 붙느냐 쪽 느낌입니다. 여기서 병목 보려면 Rendering 쪽만 쳐다보지 말고, Performance 한 번 박아보세요.

1.1 DOM·CSSOM·렌더 트리

  • DOM: HTML 토큰을 파싱해 노드 트리로 만듭니다. 파싱 중 <script>(동기·블로킹 성격이 있는 경우)를 만나면 파싱을 멈추고 스크립트를 가져와 실행할 수 있습니다. (defer·async·type="module" 등은 이 동작을 바꿉니다.)
  • CSSOM: 스타일시트는 렌더링을 막는(blocking) 리소스로 취급되는 경우가 많습니다. 사용자에게 보이는 형태를 결정하므로, 브라우저는 스타일 규칙을 모두 알기 전에는 안정적인 레이아웃을 확정하기 어렵습니다.
  • 렌더 트리: DOM의 가시 노드와 각 노드에 적용된 계산된 스타일(computed style)이 합쳐집니다. display: none처럼 레이아웃에 참여하지 않는 노드는 이 트리에서 빠질 수 있습니다.

이 세 가지가 준비되어야 “어디에, 어떤 크기로 박스를 둘 것인가”가 정의됩니다.

1.2 레이아웃·페인트·컴포지트

  • Layout(Reflow): 박스의 기하학(위치·크기)을 계산합니다. 뷰포트 크기·폰트 로딩·동적 콘텐츠 삽입 등이 바뀌면 다시 일어날 수 있습니다.
  • Paint: 레이아웃 결과를 픽셀 래스터로 그립니다. 텍스트·테두리·그림자 등 레이어 단위로 나뉘기도 합니다.
  • Composite: 여러 레이어를 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]

1.3 CRP를 짧게 만드는 실무 포인트

  • 초기 HTML에 LCP 후보를 포함: 히어로 이미지·제목 등 가장 큰 콘텐츠가 JS 이후에야 발견되면 LCP가 늦어집니다. SSR/SSG·정적 HTML·이미지 fetchpriority="high" 등은 모두 발견·요청 시점을 앞당기는 전략입니다.
  • 스타일 최소화·중복 제거: 불필요한 선택자·과도한 @import첫 페인트 전 비용을 키웁니다. 임계 경로에 필요한 CSS만 먼저 도착하게 쪼개는 패턴(크리티컬 CSS 인라인 등)은 여전히 유효하지만, 유지보수 비용과 트레이드오프를 봐야 합니다.
  • 동기 스크립트 남용 자제: 파서 블로킹은 CRP를 직접 늘립니다. 번들 하단 배치, defer, 모듈, 코드 분할이 기본 레버입니다.

2. 리소스 로딩 우선순위

브라우저가 모든 파일을 동시에 쏴 주진 않아요. 우선순위 큐·의존성 그래프 때문에 HTML 먼저 풀리며, 그다음 언제 발견됐는지·뭐냐·어디냐·힌트에 따라 대역폭이 갈려요. 따라서 “이거 preload 넣으면 좋다”는 말이 나오기도 하며, 잘못 넣으면 아래에서 말하듯 역효과도 납니다.

2.1 브라우저 휴리스틱과 Priority Hints

기본적으로 문서 안에서 위쪽·뷰포트 근처·렌더링에 직접 쓰이는 리소스가 더 높은 우선순위를 받는 경향이 있습니다. 그런데 SPA·지연 로딩이 많아지면 휴리스틱만으로는 부족합니다. 그때 쓰는 게 fetchpriority(이미지·일부 리소스)랑 리소스 힌트예요.

  • fetchpriority="high" — LCP 이미지처럼 정말 중요한 하위 리소스에 우선순위를 올릴 때. 대신 남용하면 다른 필수 자원이랑 대역폭 싸움납니다. “빨리”가 아니라 상대적으로 먼저 받는 거라는 점을 잊지 마세요.
  • fetchpriority="low" — 당장 안 써도 되는 이미지·스크립트를 덜 급한 줄로 보낼 때. “느리게 내려받는다”가 아니라 상대 우선순위만 조정하는 거예요.
  • preload파싱만으로는 늦게 발견되는 자원의 요청 시점을 앞당길 때. 잘못 쓰면 중요한 리소스 자리를 불필요하게 선점해서 오히려 LCP를 망칩니다. Network + Performance로 “정말 여기가 병목인지” 찍고 쓰세요. 추측 금지.

2.2 preload·prefetch·preconnect·dns-prefetch

  • rel="preload": 현재 내비게이션에서 곧 필요한 리소스를 미리 가져옵니다. 폰트·CSS의 중요 청크·JS 청크 등 발견 지연을 줄일 때 유효합니다. as 속성으로 유형을 정확히 주는 것이 중요합니다.
  • rel="prefetch": 다음 내비게이션에 쓰일 가능성이 높은 리소스를 여유 있을 때 가져옵니다. 초기 경로와 경쟁시키면 역효과입니다.
  • rel="preconnect": DNS·TCP·TLS 핸드셰이크를 미리 열어 첫 바이트까지의 지연을 줄입니다. 서드파티 API·CDN·폰트 출처에 자주 씁니다.
  • rel="dns-prefetch": DNS만 미리 조회합니다. preconnect보다 가볍지만 연결 수립까지는 포함하지 않습니다.

다음은 위 네 가지 힌트를 한 페이지의 <head>에 같이 배치한 예시입니다.

<head>
  <!-- LCP 후보 이미지: 발견 즉시 최우선으로 받아옵니다 -->
  <link
    rel="preload"
    as="image"
    href="/hero-1600.avif"
    imagesrcset="/hero-800.avif 800w, /hero-1600.avif 1600w"
    fetchpriority="high"
  />

  <!-- 본문에서 곧 쓰는 웹폰트: 발견 지연을 줄여 FOIT/FOUT을 줄입니다 -->
  <link
    rel="preload"
    as="font"
    href="/fonts/pretendard-subset.woff2"
    type="font/woff2"
    crossorigin
  />

  <!-- 서드파티 API 도메인: DNS·TCP·TLS를 미리 열어둡니다 -->
  <link rel="preconnect" href="https://api.example.com" crossorigin />

  <!-- 다음 페이지에서 쓸 가능성이 높은 리소스: 여유 있을 때만 -->
  <link rel="prefetch" href="/next-page-chunk.js" as="script" />
</head>

이 코드에서 핵심은 우선순위의 상대성입니다. preload한 히어로 이미지와 웹폰트는 브라우저의 요청 큐에서 앞자리를 차지하지만, 그만큼 다른 리소스는 뒤로 밀립니다. 그래서 preload는 “정말 초기 렌더에 쓰이는 것”에만 걸어야 하고, prefetch처럼 나중에 필요한 리소스와 섞어 쓰면 안 됩니다. imagesrcset<link rel="preload" as="image">에서 반응형 이미지의 후보군을 지정할 때 쓰는 속성으로, <img srcset>과 동일한 문법을 따릅니다. 실무에서는 Lighthouse의 “Preload key requests” 경고가 뜬 리소스부터 이 패턴을 적용하고, 반드시 Network 패널에서 실제로 요청 시점이 앞당겨졌는지 확인하는 순서로 작업합니다.

2.3 스크립트: async·defer·module

  • 동기 스크립트(기본): 파싱을 막을 수 있어 초기 CRP에 직접 영향.
  • defer: HTML 파싱은 계속하며, 스크립트는 문서 순서대로 DOMContentLoaded 전에 실행. 전통적인 “하단 스크립트”와 잘 맞습니다.
  • async: 다운로드는 비동기지만 실행 순서가 보장되지 않음. 독립 위젯·광고처럼 순서 무관할 때만.
  • type="module": 기본적으로 defer와 유사한 로딩 특성(브라우저·모드에 따라 세부는 문서 확인)을 가지며, 정적 import 그래프가 명확합니다. 번들러와 함께 쓸 때는 청크 분할 전략이 곧 네트워크 우선순위 전략이 됩니다.

2.4 이미지·폰트·서드파티

  • 이미지: width/height 또는 aspect-ratio레이아웃 공간을 예약하면 CLS와 추가 레이아웃을 줄입니다. 포맷(AVIF/WebP) 전환은 디코딩 비용과도 연관됩니다.
<!-- 뷰포트 밖 이미지: 브라우저 네이티브 지연 로딩 + 치수 예약 -->
<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"를 붙이는 것입니다. loading="lazy"는 브라우저가 이미지를 뷰포트 근처에 올 때까지 요청 자체를 미루는 속성이라, 화면에 처음부터 보이는 LCP 후보 이미지에 걸면 오히려 발견 시점이 늦어져 LCP가 나빠집니다. 뷰포트 밖 이미지에만 lazy를 쓰고, 뷰포트 안에 바로 보이는 이미지는 fetchpriority="high"와 함께 즉시 로드되도록 두는 것이 기본 원칙입니다. width/height는 실제 픽셀 값을 몰라도 이미지의 가로세로 비율만 브라우저에 알려주는 역할을 하므로, 이미지가 도착하기 전에도 레이아웃 공간이 미리 확보되어 CLS를 줄입니다.

  • 폰트: font-display 전략과 서브셋은 FOUT/FOIT와 체감 속도에 영향을 줍니다. preload실제로 쓰는 페이스에 맞춰야 합니다.
  • 서드파티 스크립트: 태그 매니저·분석·채팅 위젯은 메인 스레드와 네트워크 큐를 잠식합니다. 동의·지연 로딩·필요 페이지만 삽입이 기본입니다.

3. JavaScript 실행 최적화

파일을 빨리 받아도 파싱·컴파일·실행은 또 다른 비용입니다. 메인 스레드에서 오래 씹으면 INP랑 스크롤·입력이 같이 망가집니다. 이 구간은 제가 앞서 말한 “이미지 바꿨는데 LCP 그대로” 케이스에서 제일 자주 정말 범인이었습니다. Network 탭이 아니라 Performance를 보세요.

3.1 메인 스레드와 이벤트 루프

브라우저는 한 탭에서 대부분의 DOM·스타일·레이아웃·많은 JS를 메인 스레드에서 처리합니다. 이벤트 루프는 태스크 큐에서 작업을 꺼내 실행하며, 한 번에 너무 길게 잡아먹으면 다음 프레임·입력 처리가 밀립니다. DevTools Performance에서 Long Task(대략 50ms 이상)로 보이는 구간이 바로 그 흔적입니다.

3.2 실행 비용 줄이기

  • 번들 크기·중복 제거: 동일 라이브러리의 여러 복사, 거대한 폴리필, 사용하지 않는 코드는 파싱·컴파일 시간을 늘립니다.
  • 코드 분할·동적 import(): 라우트·기능 단위로 나누어 초기에 필요한 JS만 실행합니다.
// 나쁜 예: 무거운 차트 라이브러리를 상단에서 즉시 import
// import Chart from "heavy-chart-lib";

// 좋은 예: 버튼을 눌러야만 필요한 코드이므로 동적으로 분할
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 등)가 해당 모듈을 별도의 청크 파일로 쪼개도록 지시하는 신호입니다. 위 코드에서 heavy-chart-lib을 포함한 chart.js는 페이지 로드 시점에는 전혀 다운로드되지 않고, 사용자가 실제로 버튼을 눌렀을 때만 네트워크 요청이 나갑니다. 초기 번들에서 빠지는 만큼 파싱·컴파일 비용도 그만큼 뒤로 미뤄지므로, 첫 화면과 무관한 기능(모달, 차트, 리치 에디터 등)은 이 패턴을 우선적으로 적용할 대상입니다. 라우터 단위로도 같은 원리가 적용되어, React의 lazy()/Suspense, Vue Router의 컴포넌트 lazy import, Next.js의 next/dynamic 등이 모두 이 동적 import() 위에 얹힌 API입니다.

  • 데이터 구조와 알고리즘: 대규모 리스트는 가상 스크롤·윈도잉, 불필요한 리렌더를 줄이는 메모이제이션(프레임워크별)을 검토합니다. “리액트 최적화”는 별도 가이드와 함께 보는 것이 좋습니다.

3.3 긴 작업 분할과 스케줄링

  • requestIdleCallback: 브라우저가 한가할 때 실행. 입력 반응보다 낮은 우선순위 작업에 적합하며, 중요한 작업은 여기에 두면 안 됩니다.
  • scheduler.postTask(지원 브라우저): 우선순위를 명시해 사용자 차단 작업백그라운드 작업을 분리하기 쉽습니다.
  • MessageChannel·setTimeout(0): 큐를 나누어 Long Task를 쪼개는 고전적 패턴입니다. 프레임워크의 Concurrent 모드·Transition도 같은 문제의 현대적 해법 축에 있습니다.
// 10,000개 항목을 한 번에 처리하면 Long Task가 발생합니다.
// 청크 단위로 쪼개고, 다음 청크는 브라우저가 한가할 때 실행합니다.
function processInChunks(items, chunkSize = 200) {
  let index = 0;

  function runChunk(deadline) {
    while (index < items.length && (deadline.timeRemaining() > 0 || deadline.didTimeout)) {
      processItem(items[index]);
      index++;
    }

    if (index < items.length) {
      // 아직 남았으면 다음 유휴 시간에 이어서 처리합니다.
      requestIdleCallback(runChunk, { timeout: 1000 });
    }
  }

  requestIdleCallback(runChunk, { timeout: 1000 });
}

function processItem(item) {
  // 실제 연산 (DOM 갱신, 데이터 가공 등)
}

requestIdleCallback은 콜백에 deadline 객체를 넘겨주는데, deadline.timeRemaining()으로 “지금 남은 유휴 시간이 몇 ms인지”를 확인할 수 있습니다. 이 값이 소진되면 루프를 멈추고 다음 유휴 프레임으로 작업을 미루는 방식으로 하나의 거대한 Long Task를 여러 개의 짧은 작업으로 쪼갭니다. 다만 requestIdleCallback은 사용자 입력에 즉시 반응해야 하는 작업(타이핑에 대한 실시간 반응 등)에는 적합하지 않으며, 어디까지나 “당장 안 급한” 백그라운드 연산에만 써야 합니다. Safari처럼 requestIdleCallback 지원이 제한적인 환경에서는 setTimeout으로 폴백을 구성하거나, 최신 크로미움 기반 브라우저라면 우선순위를 명시할 수 있는 scheduler.postTask를 검토합니다.

3.4 Web Worker·OffscreenCanvas

Worker는 DOM에 접근할 수 없지만, 파싱·압축·대규모 계산·일부 그래픽을 메인에서 떼어낼 수 있습니다. 메인 스레드는 결과만 받아 반영하게 설계하면 INP가 안정됩니다.


4. 페인트·레이아웃 스래싱 (Thrashing)

스래싱은 짧은 시간에 레이아웃·페인트가 굳이 반복돼서 CPU만 태우는 상황입니다. 레이아웃 스래싱은 특히 “읽고→쓰고→또 읽고”가 도미노처럼 이어지면서 강제 동기 레이아웃 부르는 패턴이랑 잘 엮여요. 이건 체감으로 “뭔가 끊긴다”로 잡기 어렵으며, Layout/Recalculate Style이 촘촘한지 봐야 합니다. 역시 측정.

4.1 강제 동기 레이아웃이 생기는 이유

자바스크립트가 DOM을 변경한 뒤, 곧바로 offsetWidth·getBoundingClientRect()기하 정보를 읽으면, 브라우저는 최신 레이아웃을 확정해야 합니다. 이어서 또 스타일을 바꾸고 읽기를 반복하면 레이아웃이 연속으로 강제됩니다.

4.2 완화 패턴

  • 읽기와 쓰기 배치: 먼저 관련 DOM 읽기를 모으며, 그다음 쓰기를 모읍니다. 루프 안에서 교차로 하지 않습니다.
  • requestAnimationFrame: 스크롤·애니메이션에 맞춰 한 프레임에 한 번 레이아웃 읽기/쓰기를 묶습니다.
// 나쁜 예: 루프 안에서 "읽기 → 쓰기"를 반복해 매번 강제 레이아웃이 발생합니다.
const boxes = document.querySelectorAll(".box");
boxes.forEach((box) => {
  const width = box.offsetWidth; // 읽기 → 레이아웃 확정을 강제
  box.style.width = width + 10 + "px"; // 쓰기 → 다음 읽기에서 또 강제
});

// 좋은 예: 읽기를 모두 끝낸 뒤 쓰기를 한 번에 몰아서 실행합니다.
const widths = Array.from(boxes).map((box) => box.offsetWidth); // 읽기 단계
requestAnimationFrame(() => {
  boxes.forEach((box, i) => {
    box.style.width = widths[i] + 10 + "px"; // 쓰기 단계
  });
});

첫 번째 코드는 forEach 루프를 도는 동안 읽기(offsetWidth)와 쓰기(style.width)가 번갈아 일어나 브라우저가 매 반복마다 레이아웃을 다시 계산하도록 강제합니다(강제 동기 레이아웃). 요소가 10개면 10번, 1,000개면 1,000번의 레이아웃 재계산이 연쇄적으로 발생할 수 있습니다. 두 번째 코드는 모든 요소의 offsetWidth를 먼저 배열로 모아 읽기를 한 번에 끝낸 뒤, requestAnimationFrame 콜백 안에서 쓰기만 몰아서 실행합니다. 이렇게 하면 레이아웃 계산이 프레임당 한 번으로 수렴해, 같은 작업이라도 체감 성능이 크게 달라집니다. 실무에서는 리스트 렌더링, 드래그 앤 드롭, 가상 스크롤처럼 DOM 크기·위치를 자주 조회하는 코드에서 이 패턴을 우선 점검합니다.

  • CSS contain: 특정 서브트리의 레이아웃·페인트 범위를 제한해 전역 레이아웃 비용을 줄입니다(속성 값에 따라 효과·호환 범위가 다름).
  • content-visibility: auto: 뷰포트 밖 콘텐츠의 렌더링 비용을 줄이는 힌트입니다. 긴 문서·피드에서 유용할 수 있습니다.
  • will-change: 특정 속성이 곧 바뀔 것을 미리 알려 합성 레이어 준비를 돕지만, 남용하면 메모리·합성 비용이 커집니다. 진짜로 자주 바뀌는 요소에만 제한적으로.

4.3 측정

Chrome DevTools Performance 녹화에서 Layout, Recalculate Style 이벤트가 짧은 간격으로 반복되는지 확인합니다. Performance monitor·Rendering 패널의 레이아웃 스래싱 경고도 참고할 수 있습니다.


5. 프로덕션 성능 패턴

내 맥에서 빠른 건 캐시·CPU·망이 붙어 있어서일 수 있습니다. 프로덕션에선 관측·캐시·점진 배포 없으면 회귀가 쌓입니다. “스테이징에선 괜찮았는데” 하고 다시 측정 안 하면, 그냥 운 좋게 넘어간 겁니다.

5.1 관측: RUM과 랩

  • RUM(실사용자 모니터링): Core Web Vitals는 필드 데이터가 핵심입니다. 페이지·국가·기기·캐시 상태별로 p75를 보는 습관이 필요합니다.
  • 랩(Lighthouse·CI): 회귀 방지·원인 추적에 좋습니다. 단, 합성 환경과 실사용자 조건의 괴리를 항상 염두에 둡니다.

5.2 캐시 계층과 CDN

정적 자산은 max-age + 파일명 해시, HTML은 짧은 TTL 또는 무효화 전략을 함께 설계합니다. CDN은 엣지 캐시·HTTP/2·HTTP/3·TLS까지 포함한 전달 경로 전체의 문제입니다.

5.3 코드·데이터 로딩 전략

  • 라우트·기능 단위 스플리팅: 초기 번들을 줄이며, 필요 시점에만 로드합니다.
  • 서버 컴포넌트·스트리밍(해당 스택인 경우): HTML을 조각 내어 보내면 발견·렌더 순서를 제어하기 쉬워집니다. App Router 렌더링 전략과 연결해 읽을 수 있습니다.
  • API 응답 슬리밍·페이지네이션: 한 번에 거대한 JSON을 그리지 않도록 설계합니다.

5.4 배포와 회귀 방지

  • 스테이징에서 프로파일: 프로덕션 빌드(production 모드)로 측정합니다.
  • 성능 예산: 번들 크기·LCP·TBT 등 팀이 합의한 상한을 CI에서 검사합니다.
  • 점진적 롤아웃: 카나리·기능 플래그로 성능 회귀를 빠르게 되돌릴 수 있게 합니다.

트러블슈팅 체크리스트

증상별로 먼저 열어볼 것만 짧게 적을게요. 표 대신 이렇게 읽는 편이, “나한텐 뭐가 해당하지?” 고를 때 덜 피곤해요.

  • LCP만 유독 느림 → LCP 요소가 HTML/SSR에 바로 있는지, fetchpriority, 이미지·폰트 지연, preload 과다인지. Lighthouse 한 줄 요약만 믿지 말고 Network 타임라인이랑 맞춰 보세요.
  • 스크롤·입력이 끊김 → Performance의 Long Task, 메인 스레드 점유 스크립트, 서드파티. 여기서도 추측하지 말고 flame chart가 가리키는 스택을 보세요.
  • 레이아웃이 들쭉날쭉 → 강제 동기 레이아웃, 이미지/광고 삽입으로 인한 CLS, 폰트 스왑.
  • 새 배포 후만 느림 → 캐시 무효화, 번들 크기 증가, 환경 변수로 켜진 디버그 코드.

마무리

프론트엔드 성능은 한 방 트릭이 아니라 경로 전체예요. CRP에서 뭐가 언제 발견·실행·그려지는지, 네트워크에선 우선순위·힌트, 런타임에선 메인 스레드 예산, 화면 갱신에선 스래싱 회피 — 이게 맞물려야 Core Web Vitals랑 체감이 같이 움직여요. 제 경험으로는, 여기서 제일 비싼 실수는 최적화는 했는데 측정은 안 한 것입니다. 마지막으로 한 번만: 추측하지 말고 측정하세요. 지표 위주로만 보고 싶으면 Core Web Vitals 개선 체크리스트랑 짝지어 읽으면 돼요.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. CRP, 리소스 우선순위(preload·fetchpriority), 메인 스레드·Long Task, 스래싱, 프로덕션 패턴까지 브라우저것에서 한번에 훑는 용도예요. 말로만 “최적화했습니다” 하기 전에, 위에 나온 도구로 한 번씩은 찍어보라는 뉘앙스로 읽으면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 아래 같이 보면 좋은 글이랑 Core Web Vitals 정도랑 엮으면, 지표↔내부가 연결돼요. C++ 시리즈랑은 이번 주제랑 안 맞으니 굳이 안 가도 됩니다(예전에 박아 둔 문장이 남아 있었어요).

Q. 더 깊이 공부하려면?

A. web.dev나 브라우저 문서(MDN) 쪽 “Performance / Rendering / Resource hints”를 따라가는 게 프론트 성능엔 맞아요. 엔진 소스까지 가도 되고요—그 전에 프로파일 한 장이 더 싸게 먹힐 때가 많습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

프론트엔드 성능, Critical Rendering Path, 웹 성능, 레이아웃 스래싱, JavaScript 최적화, 리소스 힌트 등으로 검색하시면 이 글이 도움이 됩니다.