Flame Graph로 보는 클라이언트 사이드 렌더링

원문을 번역하고 사내 발표용으로 정리한 글입니다. 실습용 저장소와 단계별 스크린샷, 전체 설명은 원문에 있으니 함께 따라 해보시길 권합니다. 원문: Client-side rendering flame graph (developerway.com) · 실습 저장소: developerway/reading-flame-graph

클라이언트 사이드 렌더링이란 무엇인가

빌드 결과물을 열어보면 답이 나옵니다. React 프로젝트를 빌드하면 index.html에는 빈 div와 JS·CSS 링크만 남습니다.

html
<html lang="en">
  <head>
    <script type="module" crossorigin src="/assets/index-Cx2U5bbX.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-BjPt9w-2.css" />
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

우리가 보는 화면 전체는 저 JavaScript 안에서 만들어집니다. React가 작성한 것을 DOM 노드로 바꾸고, root 요소를 찾아 거기에 붙입니다. 모든 React 앱의 진입점이 하는 일이 정확히 이것입니다.

javascript
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

JavaScript로 DOM 전체를 만들어 빈 페이지에 주입하는 것, 이것이 클라이언트 사이드 렌더링입니다. 그럼 성능 관점에서 이게 무슨 뜻일까요. 그걸 보려면 Performance 패널의 그래프를 읽을 줄 알아야 합니다.

Flame Graph 읽는 법

Flame Graph는 조사하려는 리소스(여기서는 밀리초)에 대한 호출 스택을 그린 것입니다. 원문은 개발자의 출근길 비유로 설명합니다.

goToWork가 실행되는 동안 petADog을 부르고 기다렸다가, getSomeCoffee를 부릅니다. getSomeCoffee는 다시 orderCoffee를 부릅니다.

javascript
const goToWork = async () => {
  await petADog();
  const coffee = await getSomeCoffee();
  await finishWalking(coffee);
};

그래프에서 읽어낼 수 있는 건 두 가지입니다.

첫째, 무엇이 무엇을 불렀는가. 위아래 계층 관계가 곧 호출 관계입니다. “커피 주문”은 “커피 사오기”의 자식이라 부모가 사라지면 함께 사라지지만, 옆 계층의 “강아지 쓰다듬기”와는 아무 상관이 없습니다.

둘째, 각 작업이 얼마나 걸렸는가. 여기서 두 개념이 갈립니다.

  • 총 시간(Total Time) — 자식들의 실행 시간까지 포함한 전체 시간. “커피 사오기”가 10분이면 그 안엔 줄 서기 6분이 포함돼 있다.
  • 자체 시간(Self Time) — 총 시간에서 자식들을 뺀 시간. “사무실로 걷기”가 30분이고 자식이 4분 + 10분이면 자체 시간은 16분, 실제로 걷는 데 쓴 시간이다.

이 구분이 중요한 이유는 최적화의 선택지와 비용을 계산할 수 있게 해주기 때문입니다. 30분을 20분으로 줄이고 싶다면, 커피 작업을 통째로 없앨 수도 있고(효과는 즉각적이지만 삶의 질을 해칩니다), 앱으로 미리 주문해 “커피 주문”이라는 자식만 없앨 수도 있고(비용이 거의 없습니다), 자체 시간 자체를 줄일 수도 있습니다(자전거를 사주거나 신호등을 줄이거나). 그래프는 어느 쪽이 얼마나 남는 장사인지를 보여줍니다.

DevTools에서 실제로 읽어보면

실습 페이지에서 성능을 기록하면 대략 이런 순서가 보입니다.

  • 맨 앞의 파란 Parse HTML 막대 — 서버에서 HTML을 받았을 때
  • 긴 노란 JavaScript 막대 — 우리 함수들이 여기서 실행된다
  • 그 노란 막대가 끝난 뒤에야 FCP/LCP가 찍힌다

마지막이 핵심입니다. HTML엔 빈 div뿐이니 JS가 끝나기 전엔 화면에 그릴 게 없고, 따라서 메트릭이 트리거될 수 없습니다.

노란 막대와 같은 줄에 있는 작은 보라색 블록은 Layout입니다. 브라우저가 요소의 배치와 치수를 계산하는 구간인데, div 하나면 작지만 큰 앱에서는 눈에 띄게 커집니다.

막대를 클릭하면 하단 Summary 탭에서 총 시간·자체 시간과 함께 Function 링크가 나옵니다. 클릭하면 실제 코드 위치로 이동합니다. 스크립트 막대를 클릭하면 Script 링크가 나옵니다.

여기서 실무적으로 제일 쓸모 있는 교훈 하나. 그래프에 정체불명의 노란 막대가 잔뜩 붙어 있다면 대부분 Chrome 확장 프로그램입니다. 시크릿 모드로 다시 기록하면 Parse HTML 뒤에 붙어 있던 긴 꼬리들이 통째로 사라집니다. 그래프를 보고 뭔가 판단하기 전에 외부 영향부터 걷어내야 합니다.

CSR의 비용이 그래프에 드러나는 방식

실제 앱을 시크릿 모드 + CPU 스로틀링으로 기록하면 이렇게 보입니다. 짧은 Parse HTML → 긴 JavaScript 두 덩어리 → 그 뒤에 FCP/LCP. Layout 블록은 이제 두 번째 JS 막대의 3분의 1가량을 차지할 만큼 커지고, React가 동기적으로 트리거했기 때문에 JS 막대 안에 들어가 있습니다.

같은 측정을 MDN 같은 전통적인 문서 페이지에서 하면 순서가 거의 뒤집힙니다. Parse HTML 다음에 바로 Layout과 Painting이 오고, 그 시점에 이미 페이지가 보이므로 FCP/LCP가 찍힙니다. 노란 JavaScript 막대는 그 뒤에 나오고, DOMContentLoaded는 LCP보다 한참 늦게 발생합니다.

이 대비가 CSR의 비용을 그대로 보여줍니다. 스크립트가 이미 캐시돼 있더라도, 화면에 무언가 보이려면 그 실행이 끝나기를 기다려야 합니다. JavaScript가 없으면 사이트 자체가 없고, 사용자는 빈 페이지를 봅니다.

그런데 왜 여전히 CSR을 쓰는가

원문의 답이 설득력 있습니다.

쉽고 싸기 때문입니다. 정적 파일 몇 개면 끝이라 어디든 배포할 수 있고, 호스팅 비용이 0인 채로 매일 수천 명에게 서비스할 수도 있습니다. 확장이나 메모리 누수 같은 “백엔드적인” 걱정도 없습니다.

그리고 초기 로드가 전부는 아니기 때문입니다. 앱이 1초에 뜨는지 2초에 뜨는지 아무도 신경 쓰지 않는 경우가 있습니다. 프로젝트 관리 도구 같은 것을 떠올려 보면, 사람들은 그 화면을 감상하러 오는 게 아니라 상호작용하러 옵니다. 이럴 땐 부드럽고 빠른 상호작용과 화면 전환이 초기 로드보다 우선이고, 그래서 INP 같은 메트릭과 SPA가 의미를 갖습니다.

결국 판단의 근거는 취향이 아니라 그래프입니다. 무엇이 무엇을 부르고 각각 얼마나 걸리는지 읽을 수 있으면, 어떤 렌더링 패턴이 이 앱에 맞는지도 근거를 갖고 말할 수 있게 됩니다.