React 개발자를 위한 초기 로딩 성능: 심층 탐구

원문을 번역하고 사내 발표용으로 정리한 글입니다. 단계별 스크린샷과 전체 설명은 원문에 있고, 아래 내용은 직접 따라 해봐야 몸에 남습니다. 실습 저장소를 받아 같이 돌려보시길 권합니다. 원문: Initial load performance for React devs: a deep dive (developerway.com) · 실습 저장소: developerway/initial-load-performance

이 글에는 연습 프로젝트 말고는 React가 거의 나오지 않습니다. “빠르다”가 무엇이고 어떻게 재는지, 그리고 그 숫자를 무엇이 흔드는지에 관한 기본기입니다.

초기 로딩에서 무엇을 재는가

주소를 입력하면 브라우저가 GET 요청을 보내고 HTML을 받습니다. 여기까지 걸린 시간이 TTFB(Time To First Byte) 입니다.

HTML을 받은 브라우저는 크리티컬 패스를 먼저 그립니다. 사용자에게 보여줄 수 있는 최소한의 가장 중요한 콘텐츠인데, 이걸 구성하려면 세 가지가 필요합니다.

  1. 서버가 보낸 초기 HTML — DOM 요소를 만들기 위해
  2. 초기 요소의 스타일을 잡는 CSS — 없으면 스타일 없는 콘텐츠가 번쩍인다
  3. 레이아웃을 동기적으로 수정하는 JavaScript

브라우저는 이것들 없이는 초기 렌더링을 끝낼 수 없어서 렌더링 차단 리소스(render-blocking resources) 라고 부릅니다. 전부가 차단되는 건 아니고 보통 이렇습니다.

  • 인라인이든 <link> 태그든 대부분의 CSS
  • <head> 안에 있으면서 asyncdefer도 아닌 JavaScript

여기서 지표가 갈립니다.

  • First Paint(FP) — 화면에 무언가 처음 찍히는 순간
  • First Contentful Paint(FCP) — 텍스트나 이미지 같은 의미 있는 콘텐츠가 처음 보이는 순간. 체감 초기 로딩을 대표하는 지표이고, Google 기준 1.8초 이하가 좋은 값이다
  • Largest Contentful Paint(LCP) — 뷰포트에서 가장 큰 텍스트·이미지·비디오가 그려지는 순간. 주요 콘텐츠 영역이 찼다는 뜻이고, 2.5초 이하가 권장값이다. Core Web Vitals 중 로딩을 담당한다

FCP만 보면 안 되는 이유가 있습니다. 스피너로 시작하는 사이트라면 FCP는 그 스피너를 잡습니다. 사용자가 보러 온 건 로딩 화면이 아니니까요.

도구: Lighthouse와 Performance 패널

Lighthouse는 진입점으로 훌륭합니다. 점수와 FCP·LCP 값, 개선 제안 목록을 한 번에 줍니다. 노드 모듈로 빌드에 끼워 넣어 배포 전 성능 저하를 잡을 수도 있습니다. 다만 느린 네트워크나 낮은 CPU 같은 시나리오를 시뮬레이션하지 못합니다.

시나리오를 흔들어보려면 Performance 패널이 필요합니다. 기록을 뜨면 네 덩어리가 보입니다.

  • 타임라인 개요 — 스크린샷과 구간 선택
  • Network — 어떤 리소스가 언제 내려오는지. 파란색은 HTML, 보라색 CSS(차단), 노란색 JS
  • Frames / Timing — FP·FCP·LCP가 언제 찍혔는지
  • Main — 메인 스레드에서 Parse HTML, Layout, JavaScript 실행이 각각 얼마나 걸렸는지

로컬에서 재면 대체로 완벽하게 나옵니다. “내 컴퓨터에서는 잘 되는데요”가 늘 그렇듯이요. 그래서 조건을 바꿔봐야 합니다.

조건을 흔들면 병목이 옮겨다닌다

느린 서버

연습 프로젝트에서 서버에 sleep(500)을 넣으면 파란 막대만 길어지고 나머지는 그대로입니다. LCP는 약 650ms인데 그중 560ms가 초기 HTML을 기다리는 시간이고 React 부분은 50ms 남짓입니다.

여기서 얻는 교훈이 이 글에서 제일 중요합니다. 최적화 전에 전체 그림을 보고 병목을 먼저 찾아야 합니다. 이 상황에서 번들을 줄이는 건 의미가 없습니다.

좁은 대역폭

50 Mbps / 지연 40ms 프로필로 낮추면 LCP는 640ms에서 700ms로 조금 오릅니다. 서버 구간은 그대로인데(HTML이 작으니 당연합니다) 리소스와 메인 스레드의 관계가 바뀝니다.

Parse HTML은 이미 끝났는데 브라우저가 놀면서 CSS를 기다립니다. 차단 리소스니까요. CSS가 와도 그릴 게 없습니다. HTML에 빈 div만 있으니 JS가 내려와 실행될 때까지 또 기다립니다. 이 60ms짜리 빈 구간이 곧 LCP 증가분입니다.

10 Mbps로 더 낮추면 JS 다운로드만 300ms 가까이 걸리고, 상대적으로 Parse HTML과 JS 실행의 비중은 오히려 줄어듭니다.

대역폭보다 무서운 건 지연 시간

리소스 막대에 마우스를 올리면 “Request sent and waiting” 구간이 보입니다. 이게 지연 시간입니다. 지연을 300ms(3G 수준)로 올리면 모든 리소스가 그만큼씩 밀려 LCP가 1.2초까지 갑니다.

그리고 진짜 흥미로운 실험. 대역폭을 1 Gbps로 올리고 지연만 300ms로 두면 LCP가 약 960ms입니다. 앞서 시험한 가장 느린 인터넷보다 더 나쁩니다. 서버는 노르웨이에, 사용자는 호주에 있는 상황이 딱 이렇습니다. 이 시나리오에서는 번들을 절반으로 줄여도 거의 소용없습니다.

그래서 CDN이 0단계다

코드 분할이나 서버 컴포넌트를 고민하기 전에 정적 리소스를 CDN으로 내보내는 것이 먼저입니다. CDN은 사용자와 가까운 지점에 복사본을 두어 지연을 줄이고, 원본 서버의 부하도 덜어줍니다. 위 시뮬레이션에서 LCP는 960ms에서 640ms로 되돌아옵니다.

재방문 사용자와 캐시

여기까지는 전부 첫 방문자 이야기였습니다. 캐시를 켜고 다시 재보면 놀랍게도 CSS와 JS가 여전히 300ms씩 기다리고 있습니다.

Network 패널에서 CSS 파일을 보면 상태가 200이 아니라 304이고 크기도 파일 전체보다 훨씬 작습니다. 304는 조건부 요청에 대한 정상적인 응답입니다. 서버가 “안 바뀌었다”고 빈 본문으로 답한 것이고, 그래서 다운로드 시간은 0.33ms인데 대기 시간은 300ms 그대로입니다.

왜 요청이 아예 나가버렸을까요. 응답 헤더의 Cache-Control 때문입니다.

  • max-age — 이 응답을 몇 초 동안 캐시에 둘지
  • must-revalidate — 오래된 응답이면 반드시 서버에 새 버전을 물어보라

연습 프로젝트는 max-age=0, must-revalidate였습니다. 캐시에 둬도 되지만 유지 가능한 시간이 0초라는 뜻이니, 브라우저는 매번 서버에 확인합니다. max-age를 늘리면 Status가 회색으로 바뀌고 Size에 (memory cache)가 뜹니다. 그 상태로 다시 재면 대기 구간이 거의 0으로 줄고 LCP가 650ms로 복귀합니다.

그렇다고 다 캐시하면 되는 건 아니다

“이게 모든 경우에 맞는 캐시 전략 다섯 가지다”라고 말할 수 있는 사람은 없습니다. 지시문 조합과 서버 구현에 따라 결과가 달라지고, 잘못 걸면 사용자가 낡은 화면을 붙들게 됩니다.

다만 명확한 모범 사례가 하나 있습니다. Vite·Rollup·Webpack 같은 최신 번들러는 파일 내용에 따라 달라지는 해시를 파일명에 넣습니다. 내용이 바뀌면 이름이 바뀌므로, 배포되는 순간 브라우저는 캐시 설정과 무관하게 새 파일을 받습니다. 캐시가 자연스럽게 무효화되는 겁니다.

직접 확인해보면 명확합니다. 빌드를 여러 번 돌려도 내용이 그대로면 파일명이 같고, src/App.tsxconsole.log 한 줄만 넣으면 JS 파일명만 바뀌고 CSS 파일명은 그대로입니다.

빌드 시스템이 이렇게 되어 있다면 생성된 자산에 max-age를 최대치(31536000, 1년)로 걸어도 안전합니다. 이미지도 같은 방식으로 버전 관리한다면 함께 넣으면 됩니다. 사이트와 사용자 패턴에 따라 다르지만, 사실상 공짜로 얻는 초기 로딩 개선입니다.

정리

  • 먼저 재고, 그 다음에 고친다. 병목이 서버인지 대역폭인지 지연인지에 따라 해야 할 일이 완전히 달라진다
  • 지연 시간이 대역폭보다 자주 문제다. 그래서 CDN이 0단계다
  • 캐시는 은탄이 아니지만, 해시 파일명을 쓰는 정적 자산에는 길게 거는 것이 명확한 정답이다