원문: https://www.developerway.com/posts/client-side-rendering-flame-graph
퍼포먼스 탭의 플레임(Flame) 그래프를 설명하는 글입니다. 리액트 애플리케이션의 클라이언트 사이드 렌더링이 어떻게 작동하는지 탐색하면서 성능 플레임 그래프에서 유용한 정보를 얻는 방법을 배워보세요.

“리액트 개발자를 위한 비리액트 지식” 시리즈의 새로운 글에 오신 것을 환영합니다 😅. 이전 글에서는 성능 측정을 시작하는 방법과 다양한 시나리오에서 초기 렌더링 및 LCP를 측정하는 방법을 알아보았습니다. 이제 이 정보를 바탕으로 다양한 렌더링 패턴과 그 목적에 대해 이야기해 보겠습니다.
리액트 세계에서 가장 오래되고 여전히 가장 인기 있는 패턴인 클라이언트 사이드 렌더링부터 시작하겠습니다. 이 패턴이 무엇을 하는지, 그리고 어떤 비용을 수반하는지 살펴볼 것입니다. 하지만 그전에 성능 플레임 그래프가 무엇인지, 간단한 시나리오에서 어떻게 읽는지, 그리고 실제 애플리케이션에서 유용한 정보를 어떻게 뽑아내는지 알아보아야 합니다.
이번에도 실습 프로젝트가 있으니, 클론하고 설치해주세요!
클라이언트 사이드 렌더링
정의부터 시작해보겠습니다. 클라이언트 사이드 렌더링이란 무엇일까요?
사실, 이건 우리가 이전 글에서 살펴본 내용입니다! 해당 아티클에서 우리는 리액트 프로젝트가 빌드될 때 빈 div, JS 파일, CSS 파일이 포함된 index.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>
이 프로젝트를 실행하면 볼 수 있는 아름다운 홈페이지는 온전히 자바스크립트 파일과 그 안의 리액트 코드로 생성된 것입니다.
리액트는 우리가 작성한 모든 내용을 DOM 노드로 변환한 후, root
라는 id를 가진 요소를 찾아 표준 자바스크립트 명령을 사용해 새로 생성된 DOM 요소를 여기에 추가합니다. 대략 다음과 같은 모습입니다.
// 리액트가 App 코드를 가져와 필요한 작업을 수행합니다.
const domElements = ReactDoYourThing(App);
// 일반 자바스크립트로 "root" 요소를 가져옵니다.
const rootDomElement = document.getElementById('root');
// 요소들을 root에 추가합니다.
rootDomElement.appendChild(domElements);
브라우저가 모든 자바스크립트 실행을 마치면, 사용자는 갑자기 전체 페이지가 나타나는 것을 보게 됩니다.
실습 프로젝트의 main.tsx
파일을 살펴보면 거의 동일한 코드를 볼 수 있습니다.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
이 코드가 모든 리액트 앱의 진입점입니다. 자바스크립트로 전체 DOM을 생성한 다음 빈 페이지에 주입하는 이 과정이 “클라이언트 사이드 렌더링”이라고 알려진 것입니다.
성능 관점에서 이것은 무엇을 의미할까요?
이를 정확히 이해하기 위해서는 다시 성능 패널이 필요합니다. 이번에는 “main” 섹션입니다. 실습 프로젝트를 빌드하고 시작해 봅시다.
npm run build
npm run start
크롬 DevTools를 열고 성능 패널을 연 후 성능을 기록하고, “main” 섹션을 확장해보면 다음과 같은 꽤 명확한 그림이 보일 것입니다.

물론 “꽤 명확하다”는 것은 농담입니다. 재미와 보상을 위해 매일 몇 년 동안 이러한 그래프를 읽어온 것이 아니라면, 이 그림은 아마 아무것도 알려주지 않을 것입니다. 그러므로 다시 리액트로 돌아가기 전에, 이 그래프가 무엇을 의미하고 우리가 어떤 정보를 얻을 수 있는지 살펴봅시다.
플레임 그래프 읽기
이 그림은 ‘플레임 그래프’ 또는 플레임 차트라고 알려져 있습니다. 이와 같은 그래프는 메모리나 밀리초 단위의 시간(우리의 경우)과 같이 조사하고자 하는 리소스에 상대적인 애플리케이션의 콜 스택을 나타냅니다.
개발자의 일상적인 출근길을 설명하는 함수를 상상해 보세요.
- 개발자가 집을 나서서 어떤 노래를 흥얼거리면서 팟캐스트를 듣고 걷기 시작합니다. (Walk to the office)
- 그런 다음 귀여운 강아지를 쓰다듬기 위해 멈춥니다. (Pet a dog)
- 조금 더 걸은 후, 커피를 마시기 위해 멈추는데, 이 과정은 다음 작업들을 포함합니다. (Grab a coffee)
- 커피를 주문하기 위해 1~2분 동안 줄 서서 기다리기 (Order coffee)
- 실제로 커피를 받기 위해 몇 분 더 기다리기
- 그 후, 커피를 마시면서 계속 걷습니다.

전체 흐름은 goToWork
함수입니다. 실행하는 동안, 이 함수는 어떤 특정 시점에 petADog
함수를 트리거하고 완료될 때까지 기다립니다. 그 후, getSomeCoffee
함수가 트리거됩니다. 이 함수는 다른 함수인 orderCoffee
를 트리거합니다. 실행 후, getSomeCoffee
함수는 커피를 기다리고 완료되면 반환합니다. 그 후, 핵심 함수인 goToWork
는 개발자가 목적지에 도착할 때까지 계속됩니다.
const goToWork = async () => {
await petADog();
const coffee = await getSomeCoffee();
await finishWalking(coffee);
};
이는 콜 스택이라고 알려진 것을 설명합니다. 이 개념을 플레임 그래프에서 시각적으로 표현하면 다음과 같습니다.

이 그래프에서 현재 가장 중요한 점은 어떤 작업이 어디에서 시작되었는지 명확하게 보여준다는 것입니다.
- ‘Pet a dog’ 작업은 ‘Walk to the office’에 의해 트리거되었고 커피 상황과는 완전히 독립적입니다.
- ‘Order coffee’ 작업은 ‘Grab a coffee’ 작업에서 시작되었으며, 이것도 ‘Walk to the office’에 의해 시작되었습니다.
- ‘Order coffee’ 작업은 ‘Pet a dog’ 작업의 계층 구조 내에 있지 않으며 아무 관련이 없습니다. 만약 ‘Grab a coffee’ 작업이 어느 날 사라진다면, ‘Order coffee’는 그것의 자식이기 때문에 함께 사라질 것이지만, ‘Pet a dog’ 작업은 그것을 전혀 알아차리지 못할 것입니다.
여기서 또 다른 중요한 점은 그래프가 모든 작업의 지속 시간을 보여준다는 것입니다.

예를 들어, ‘Grab a coffee’ 작업은 10분이 걸렸으며, 그 중 개발자는 주문을 하기 위해 줄에서 6분을 기다렸습니다. 이 10분은 ‘총 시간(Total Time)’이라고 합니다. 모든 자식의 실행 시간을 포함하여 함수를 실행하는 데 걸리는 시간입니다. ‘Walk to the office’의 총 시간은 30분인데, 그 중 ‘Pet a dog’에 4분, ‘Grab a coffee’에 10분이 걸렸고, 그 중 6분은 커피를 주문하는 줄에서 보냈습니다.
총 시간과 자식을 실행하는 데 걸린 시간의 차이는 ‘자체 시간(Self Time)’이라고 합니다. 이 예시에서 (30–4–10), ‘Walk to the office’ 함수의 경우 16분입니다. 이 16분은 개발자가 실제로 걷는 데 보낸 시간입니다. ‘Grab a coffee’의 경우 4분입니다.
해당 활동에 직접 사용한 시간, 총 사용 시간, 그리고 자식의 구조에 대한 지식을 통해 성능 최적화를 수행할 수 있습니다. 그리고 가장 중요한 최적화의 트레이드오프와 비용을 계산할 수 있습니다.
‘Walk to the office’ 시간을 20분으로 줄이고 싶다고 가정해 봅시다. 어떤 선택지가 있을까요?
‘coffee’ 작업을 완전히 제거하여 즉시 목표를 달성할 수 있습니다.

줄어든 비용은 개발자의 삶의 질에 상당한 영향을 미치고, 나머지 하루의 생산성에도 영향을 줄 수 있습니다. 지금 당장 빠른 성과가 정말 필요한 경우가 아니라면 최선의 선택이 아닐 수 있습니다.
대신, 걸으면서 앱을 통해 커피를 미리 주문함으로써 ‘Order coffee’ 작업을 제거할 수 있습니다. 앱을 다운로드하고 계정을 만들기만 하면 되기 때문에 비용도 거의 들지 않습니다.
‘Grab a coffee’ 작업은 여전히 존재합니다. 개발자는 여전히 카운터로 걸어가서 커피를 찾고, 인파를 뚫고 나와야 합니다. 따라서 함수의 자체 시간은 유지됩니다.

이제 추가로 4분만 더 줄이면 목표를 달성할 수 있습니다. ‘Pet a dog’ 작업을 제거할 수 있지만, ‘coffee’ 작업과 유사하게, 이 비용은 개발자의 기분과 나머지 하루의 생산성에 영향을 미칠 것입니다.
여기서 또 다른 옵션은 ‘Walk to the office’ 작업 자체의 자체 시간을 줄이는 것입니다. 예를 들어, 개발자에게 스쿠터나 자전거를 사주어 속도를 높일 수 있습니다. 또는 직원들을 픽업하여 직장으로 데려다주는 일종의 교통수단을 마련할 수 있습니다. 혹은 정부와 협력하여 보행 속도를 늦추는 가로등이 없는 보도를 더 만들 수도 있습니다. 아니면 어떻게든 개발자가 걷는 대신 뛰도록 격려하는 방법도 있습니다.

어떤 경우든 성능 목표는 달성되었으며, 플레임 그래프가 그 과정에서 도움이 되었습니다.
DevTools에서 플레임 그래프 읽기
이제 실제 그래프로 돌아갈 시간입니다.
실습 프로젝트를 시작하고, ‘http://localhost:3000/go-to-work'로 이동한 다음 성능을 기록해보세요. 이 페이지의 그래프는 위의 그림과 똑같아 보입니다! 물론 완전 똑같지는 않겠지만요.

사실 약간 비슷해 보이긴 합니다. 그 이유는 스크립트가 브라우저에서 실행되며, 프로파일러가 발생하는 모든 작업을 기록하기 때문입니다. 가장 단순한 스크립트라 하더라도 여전히 많은 작업이 이루어지고 있습니다. 하지만 적어도 이제 그래프를 읽을 수 있으며, 다음과 같은 내용을 확인할 수 있습니다.
먼저, ‘Parse HTML’의 파란색 막대가 있습니다. 우리는 이미 이것을 알고 있습니다. 이것은 HTML이 서버로부터 수신될 때 발생합니다. 어떤 이유로 자바스크립트를 트리거했지만, ‘walk to the office’ 작업과는 관련이 없으므로 신경 쓰지 않습니다. 해당 항목 위에서 우클릭하고 ‘Hide children’을 선택하면 눈에 방해되는 요소를 제거할 수도 있습니다.
그런 다음, 자바스크립트의 아주, 아주 긴 노란색 줄이 있습니다. 이것은 ‘go to work’ 기능을 구현하는 스크립트입니다. 이 스크립트는 우리의 모든 함수를 트리거하고, 그 순서는 위의 그림과 상당히 비슷합니다.
긴 노란색 자바스크립트 줄이 완료된 후에만, FCP/LCP 지표가 측정됩니다. 이는 Parse HTML과 자바스크립트 작업이 모두 완료된 후에만 지표를 측정할 수 있다는 것을 알려줍니다.
마지막 가정을 확인하기 위해, 실제 스크립트와 HTML 페이지를 살펴보겠습니다. 실습 프로젝트의 backend/assets
에서 찾을 수 있습니다.
HTML은 다음과 같습니다.
<html>
<head>
<script async src="./main.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
매우 전형적인 클라이언트 사이드 렌더링 HTML입니다. 그리고 스크립트의 맨 끝에는 다음과 같은 내용이 있습니다.
const div = document.createElement('div');
div.innerText = 'Arrived!';
document.getElementById('root').appendChild(div);
위의 동기 함수가 완료된 후, 자바스크립트는 DOM 요소를 생성하고 페이지의 기존 <div>
에 추가합니다. 이는 플레임 차트에서 나타나는 동작과 정확히 일치합니다. 자바스크립트가 작업을 마치기 전까지 페이지에 아무런 콘텐츠도 표시되지 않았기 때문에, FCP/LCP 지표가 측정되지 않았던 것입니다.
실제 앱의 플레임 차트로 돌아가기 전에 또 어떤 흥미로운 점을 찾아볼 수 있을까요?
우선, 노란색 자바스크립트 줄과 같은 라인에 작은 보라색 블록이 있습니다. 위에 마우스를 올리면 그것이 ‘Layout’ 작업임을 알 수 있습니다.

이 작업은 꽤 중요합니다. 브라우저가 요소들의 화면 내 위치와 정확한 크기를 계산하는 과정이 여기서 이루어집니다. 이번 경우에는 단순히 하나의 <div>
만 추가했기 때문에 영향이 미미하지만, 대형 애플리케이션에서는 이 과정이 눈에 띄게 나타날 수 있습니다.
또 하나 중요한 점은, 이 모든 선이 상호작용할 수 있다는 것입니다. 각각을 클릭하면 화면 하단의 탭에서 자세한 정보가 업데이트됩니다. 예를 들어 ‘goToWork’ 막대 중 하나를 클릭해 보세요. 그러면 “Summary” 탭에는 다음과 같은 내용이 있을 것입니다.

여기에서 “총 시간”과 “자체 시간”을 찾았습니다. 그리고 더 많은 정보도요!
“Function” 부분도 있습니다. 이는 특정 함수 실행이 어떻게 트리거되었는지를 보여줍니다. 클릭해보면 해당 함수 실행이 시작된 정확한 코드 위치가 열립니다.
추가 연습
- 'petADog' 함수를 생성한 'goToWork' 블록을 클릭하세요. 총 시간과 자체 시간을 확인하세요.
- 'petADog' 블록을 클릭하세요. 해당 블록의 총 시간은 그것을 생성한 블록의 총 시간과 자체 시간의 차이와 대략 일치해야 합니다.
- 이제 'Function' 블록의 링크를 클릭하세요. 이 함수가 호출된 정확한 파일과 정확한 줄로 이동해야 합니다.
- 함수를 생성한 노란색 자바스크립트 줄을 클릭하세요. 'Function' 대신, 그 줄을 생성한 스크립트에 대한 링크가 있는 'Script'가 있어야 합니다.
각 블록마다 “Summary” 탭에 표시되는 정보가 다를 수 있습니다.
이제 이 그래프의 또 다른 작은 미스터리를 해결할 수 있습니다. 이 추가적인 노란색 자바스크립트 블록들은 어디에서 온 걸까요? 특히 Parse HTML 블록 아래와 우리의 주요 “goToWork” 작업 이후에 나타나는 것들 말입니다.
이 블록들을 하나씩 클릭해 보면 “Summary” 탭의 “Script” 부분에서 출처를 확인할 수 있습니다. 놀랍게도, 모두 다양한 크롬 플러그인에서 발생한 작업들입니다! 이 플러그인들이 성능 그래프(그리고 전체 페이지 성능)에 얼마나 영향을 미치는지 확인하려면, 크롬 시크릿 모드에서 “Go to work” 연습 페이지를 열고 다시 성능을 기록해 보세요.
제 경우에는 이렇게 보였습니다.

“Parse HTML” 작업이 거의 사라졌고, 긴 자바스크립트 작업도 없어졌습니다. 그래프가 훨씬 깔끔해졌습니다.
따라서 여기서 얻을 수 있는 중요한 교훈은 다음과 같습니다. 플레임 그래프를 분석하기 전에, 예를 들어 “Parse HTML” 작업이 너무 길다고 당황하기 전에, 외부 요인(예: 서드파티 플러그인)이 제거되었는지 먼저 확인하세요.
성능 패널에서 실제 애플리케이션의 클라이언트 사이드 렌더링 분석
이제 플레임 차트를 읽는 방법을 익혔으니, 우리가 반쯤 실제로 만든 홈페이지의 그래프를 다시 살펴보겠습니다. 이번에는 좀 더 현명하게 접근해 봅시다. 시크릿 모드(Incognito mode)에서 열어 불필요한 시각적 요소를 제거하세요. 아주 빠른 컴퓨터를 사용 중이라면, “CPU 감속(CPU slowdown)”을 활성화하는 것도 유용합니다. 이렇게 하면 그래프의 막대가 길어져서 더 읽기 쉬워집니다. 해당 기능은 우리가 네트워크 속도 제한(Network throttling)을 설정했던 곳과 같은 위치에 있으므로 쉽게 찾을 수 있을 것입니다.
이제 여기서 무슨 일이 일어나고 있는지 읽을 수 있나요?

먼저, “Parse HTML” 단계가 다시 등장합니다. 맨 처음에 있는 작은 파란색 막대입니다. 그 다음에는 두 개의 긴 노란색 자바스크립트 막대가 이어집니다. 클릭해 보면, 모든 리액트 코드와 제가 작성한 앱이 포함된 index.js
파일입니다.
그리고 나서야 FCP와 LCP같은 성능 지표가 표시됩니다. 이는 당연한 결과입니다. 이 앱은 클라이언트 사이드 렌더링을 사용하고 있기 때문에 자바스크립트가 실행되기 전까지는 페이지에 표시될 콘텐츠가 없기 때문입니다.
이번에는 “Layout” 블록이 상당히 커졌으며, 두 번째 노란색 자바스크립트 막대의 거의 3분의 1을 차지합니다. 이제 전체 페이지 레이아웃이 적용되어서 이전 실험에서는 작은 div 하나만 있었지만, 이제는 전체 페이지를 렌더링해야 하기 때문입니다. 또 하나 흥미로운 점은 Layout이 자바스크립트 막대 안에 포함되어 있다는 것입니다. 리액트가 이를 동기적으로 실행했기 때문입니다.
이제 재미있는 부분이 나옵니다. 이 그래프를 기존의 전통적인 방식으로 렌더링되는 웹사이트와 비교해 보면 완전히 반대되는 패턴이 나타납니다. 예를 들어, MDN 페이지에서 같은 실험을 실행해 보면 다음과 같은 차이를 볼 수 있습니다. 거의 역순이고 극적으로 다릅니다.

항상 그렇듯이, 시작 부분에 파란색 ‘Parse HTML’이 있습니다. 하지만 그 다음에는 바로 보라색 Layout 블록이 이어지고, 곧바로 초록색 Painting(브라우저가 계산된 레이아웃의 모든 픽셀을 실제로 그릴 때) 블록이 나타납니다. 이 시점에서 브라우저는 이미 전체 페이지의 레이아웃을 계산하고, 화면에 내용을 표시할 준비를 마쳤습니다. 따라서 FCP와 LCP가 바로 측정됩니다.
그리고 나서야 자바스크립트 실행을 나타내는 노란색 바가 나타납니다. LCP가 발생한 후 한참 뒤에야 DOMContentLoaded(DCL) 이벤트가 발생하는 것도 확인할 수 있습니다. 즉, 브라우저가 HTML을 모두 파싱한 후, 자바스크립트 로딩과 실행이 진행되는 구조입니다.
그렇다면 이러한 차이를 분석하는 이유는 무엇일까요?
이 그래프를 통해 클라이언트 사이드 렌더링의 비용이 얼마나 큰지를 명확하게 이해할 수 있습니다.
- 스크립트가 이미 다운로드되고 브라우저에 의해 캐시된 경우에도 초기 로드 및 LCP 지표가 저하될 수 있습니다. 페이지에서 무언가를 보려면 실행을 기다려야 합니다.
- 자바스크립트 없이는 웹사이트에 아무 것도 표시되지 않고, 사용자는 빈 페이지만 보게 될 것입니다. MDN 페이지와 실습 프로젝트에서 자바스크립트를 비활성화하여 차이점을 확인해 보세요.
와, 정말 끔찍합니다! 그렇다면 왜 여전히 많은 개발자들이 이런 단점이 있음에도 CSR을 선택할까요?
우선, 쉽고 저렴하기 때문입니다. 더 정확히 말하자면, 믿을 수 없을 정도로 쉽고 저렴합니다. 복잡하고 잘 동작하는 애플리케이션을 구축하여 매일 수천 명의 사용자에게 제공하면서도 호스팅 비용을 한 푼도 들이지 않는 것이 가능합니다. 게다가 시스템 확장, 장애, 메모리 누수 등 모든 “백엔드 관련” 문제를 걱정할 필요도 없습니다. 애플리케이션 전체가 몇 개의 정적 파일로 이루어져 있어 특별한 관리 없이 거의 어디에나 배포할 수 있기 때문입니다. 또한, “자바스크립트가 없는” 환경을 고려할 필요가 없다면, 클라이언트 사이드 렌더링은 훌륭한 선택이 될 수 있습니다.
그리고 두 번째 이유는, 때로는 더 중요한 이유이기도 한데, LCP나 초기 로딩 속도만이 사용자 경험과 인식 성능에 영향을 미치는 요소가 아니라는 점입니다. 경우에 따라, 앱이 1초 만에 로딩되든, 1.2초가 걸리든, 심지어 5초가 걸리든 아무도 신경 쓰지 않을 수도 있습니다. 특히 로딩 중에 귀여운 애니메이션이 표시된다면 더욱 그렇습니다. 어쩌면 사용자는 앱을 보기 위해 온 것이 아니라 앱과 상호작용하기 위해 방문한 것이고, 초기 로딩 시간이 조금 걸리더라도 이후 뛰어난 성능을 경험할 수 있다면 기꺼이 기다릴 수도 있습니다. 게다가, 클라이언트 사이드 렌더링을 사용하는 앱에서도 초기 로딩 시간을 최소화하는 방법은 존재합니다.
예를 들어, 프로젝트 관리 소프트웨어를 생각해 보세요. 이 앱을 사용하는 사람들은 단순히 화면을 감상하거나 시를 읽기 위해 방문하지 않습니다(그럴 일은 없길 바랍니다). 사람들은 중요한 일상 업무를 수행하기 위해 이 앱을 실행하고 이러한 업무는 거의 항상 앱과의 많은 상호작용을 포함합니다.
이 경우, 페이지 간 전환이 부드럽고 매우 빠르게 이루어지는 것이 초기 로딩 속도보다 더 중요합니다. 이때 INP(Interaction to Next Paint)와 같은 성능 지표가 부각되며, 클라이언트 사이드 렌더링의 자연스러운 확장 형태인 SPA(Single-Page Applications)의 진정한 강점이 드러나기 시작합니다.
하지만 이 부분에 대한 더 깊은 이야기는 다음 글에서 다루겠습니다.