개요
React 디버깅을 돕는 Chrome 확장 프로그램 React Developer Tools는 분명히 편리한 도구이다. 이 확장 프로그램을 사용하면 어떤 부분에서 re-rendering이 발생하는지, 어떤 컴포넌트의 렌더링에 얼마나 시간이 걸리는지를 쉽게 파악할 수 있다. 하지만, React Developer Tools는 정확한 성능 측정에는 오히려 문제가 될 수 있다는 사실을 발견하였다.
최근, 매우 큰 테이블을 다루는 작업 중 최악의 성능 환경에서 성능 최적화를 진행한 적이 있다. DOM 크기가 매우 커진 상황에서 element.focus() 요청 시, 예상보다 훨씬 속도가 느려지는 현상을 발견했다.
memoization 적용, 렌더링 관리, 상태 분리, 알고리즘 최적화 등 성능 향상을 위한 다양한 작업을 마친 상태였고, 이론적으로나 경험적으로 이 정도의 상황에서 발생하는 퍼포먼스 문제는 말이 되지 않았다. 따라서 더 깊게 파고들 필요가 있었다.
Chrome의 Performance 탭을 사용해 여러 분석을 시도했으며, 순수하게 속도가 느려지는 현상만을 확인할 수 있었다. 아래는 그 성능 측정 결과이다.
[포커스 O]
[포커스 X]
원인 분석
같은 동작에 대해 element.focus()가 있는 경우와 없는 경우 처리 속도가 약 2배 정도 차이가 나는 것을 확인했다. 물론 focus 이벤트에 의한 동작 차이가 원인일 가능성도 있었으나, 이는 이미 무의미한 차이임을 검증한 상태였다.
이후 Chrome의 Performance 탭을 좀 더 자세히 분석한 결과, 성능이 저하되는 경우에만 특정 차이를 발견할 수 있었다. 바로 성능이 떨어지는 경우에는 아래 이미지와 같이 mark와 관련된 task가 로깅되어 있다는 점이다.
clearMarks, markAndClear, markComponentRenderStarted 등 다양한 task들이 로깅되어 있었으며, 이러한 task들이 어떤 작업을 수행하기에 속도를 이렇게 느리게 만드는지 조사해보았다.
// bundle-v5-react.development
function markCommitStarted(lanes) {
{
if (injectedProfilingHooks !== null && typeof injectedProfilingHooks.markCommitStarted === 'function') {
injectedProfilingHooks.markCommitStarted(lanes);
}
}
}
function markCommitStopped() {
{
if (injectedProfilingHooks !== null && typeof injectedProfilingHooks.markCommitStopped === 'function') {
injectedProfilingHooks.markCommitStopped();
}
}
}
function markComponentRenderStarted(fiber) {
{
if (injectedProfilingHooks !== null && typeof injectedProfilingHooks.markComponentRenderStarted === 'function') {
injectedProfilingHooks.markComponentRenderStarted(fiber);
}
}
}
function markComponentRenderStopped() {
{
if (injectedProfilingHooks !== null && typeof injectedProfilingHooks.markComponentRenderStopped === 'function') {
injectedProfilingHooks.markComponentRenderStopped();
}
}
}
function markComponentPassiveEffectMountStarted(fiber) {
{
if (injectedProfilingHooks !== null && typeof injectedProfilingHooks.markComponentPassiveEffectMountStarted === 'function') {
injectedProfilingHooks.markComponentPassiveEffectMountStarted(fiber);
}
}
}
function markComponentPassiveEffectMountStopped() {
{
if (injectedProfilingHooks !== null && typeof injectedProfilingHooks.markComponentPassiveEffectMountStopped === 'function') {
injectedProfilingHooks.markComponentPassiveEffectMountStopped();
}
}
}
...
코드를 확인해보니, 성능 저하를 일으킨 부분은 번들링된 react.development 파일에서 선언된 것으로 확인되었다. 이후 production 모드의 React 코드를 살펴본 결과, 해당 함수들이 존재하지 않았으며, 이는 개발 환경에서만 발생하는 문제일 것이라 예측할 수 있었다. 실제로 production 환경에서 동일한 상황으로 성능 테스트를 진행한 결과, focus()를 호출하더라도 성능 저하가 발생하지 않았다.
문제를 더 깊이 파고들어 React의 번들 코드에서 해당 함수를 찾아보니, 이는 React 라이브러리의 일부로, react-devtools-shared 파일에 선언되어 있었다. 이로 인해, React Developer Tools 확장 프로그램이 성능 저하의 주요 원인임을 의심하게 되었다.
이를 확인하기 위해 React Developer Tools 확장 프로그램을 비활성화한 상태에서 동일한 환경에서의 성능 테스트를 진행했다. 테스트 결과, 성능이 크게 개선된 것을 확인할 수 있었다. 실제로 React Developer Tools 는 성능에 상당한 영향을 미치고 있었으며, 심지어 focus 동작을 사용하지 않은 상황과 비교해도 속도가 40%나 더 빨라진 것을 확인할 수 있었다. 즉 이는 개발 환경의 모든 범위에서 React 애플리케이션의 전반적인 실행 속도를 늦추고 있었던 것이다.
React Developer Tools의 mark 함수는 이름 그대로 각 컴포넌트를 디버깅을 위한 마킹 작업을 수행하는 것으로 예상된다. 그렇다면 이 작업이 왜 속도를 느리게 만드는지에 대해 조금 더 자세히 분석해보았다.
확대하여 살펴보면, 동작 흐름은 다음과 같다:
- 마킹 시작
- 컴포넌트 렌더
- 마킹 종료
이 과정은 memoization이 적용된 컴포넌트도 예외 없이 포함되며, 트리 구조가 깊어질수록 모든 부분에서 마킹이 이루어진다.
문제는 모든 부분을 다 마킹하는 과정에서 발생한다. 마커 자체의 실행 시간은 약 1ms 정도로 짧지만, React의 컴포넌트 구조가 복잡해지고 깊어질수록 이러한 마킹 시간이 점점 누적된다. 즉, 테이블과 같은 여러 개의 row와 column이 있는 경우, 각 컴포넌트마다 마킹 시간이 추가되어 마치 React Developer Tools에서 시간이 소모되고 있음에도, React 애플리케이션의 성능에 문제가 있는 것처럼 착각할 수 있는 문제가 생기게된다.
하지만 여기서 드는 의문이 있다. 바로 이러한 동작이 너무 비효율적이고 눈에 띈다는 것이다. 혹시 현재 나의 환경 문제인가 싶어 조금 더 조사해보기로 결정하였다.
function markComponentRenderStarted(fiber) {
{
if (injectedProfilingHooks !== null && typeof injectedProfilingHooks.markComponentRenderStarted === 'function') {
injectedProfilingHooks.markComponentRenderStarted(fiber);
}
}
}
function markComponentRenderStopped() {
{
if (injectedProfilingHooks !== null && typeof injectedProfilingHooks.markComponentRenderStopped === 'function') {
injectedProfilingHooks.markComponentRenderStopped();
}
}
}
해당 코드는 실제로 렌더링 전후에 마킹을 해주는 프로파일링 코드이다. React 팀은 개발자가 DevTools에서 직접 프로파일링을 실행 중일 때만 마킹을 진행하도록 injectedProfilingHooks가 null이 아닐 때만 실행하도록 작성했을 것으로 예상했으며, 만약 이 예상대로 코드가 동작한다면, 프로파일링이 실행중이 아닐 때에는 해당 함수에 들어온 즉시 결과가 반환되므로 markComponentRenderStopped의 실행에 0.6ms가 소모되는 것은 말이 안되는 동작이라고 말할 수 있다.
그러나 디버깅 결과, 프로파일링을 실행하지 않은 상황에서도 아래 이미지와 같이 injectedProfilingHooks 객체에 함수들이 들어가 있어 해당 코드가 실행되고 있는 모습을 확인할 수 있었다.
let rendererID = null;
let injectedHook = null;
let injectedProfilingHooks: DevToolsProfilingHooks | null = null;
let hasLoggedError = false;
ReactFiberDevToolsHook 파일을 살펴보면 injectedProfilingHooks 값을 기본적으로 null로 설정하고 있다는 점이 분명하다. 이후 같은 파일 내에서 injectProfilingHooks 함수가 실행될 때만 해당 객체에 값이 채워지며, 다른 곳에서 이 값을 설정하는 경우는 발견되지 않았다. 어딘가에서 injectProfilingHooks 객체에 값을 넣어주기에 위의 문제가 발생하는 것이며 그 원인을 찾기 위해 injectProfilingHooks 함수에 브레이크 포인트를 추가하여 디버깅을 진행하였다.
// Profiler API hooks
export function injectProfilingHooks(
profilingHooks: DevToolsProfilingHooks,
): void {
injectedProfilingHooks = profilingHooks;
}
새로 고침 후 확인 결과 해당 함수에 브레이크 포인트가 걸린 모습을 확인할 수 있었으며 이를 시작점으로 하여 따라 올라가 이제 어떤 부분에서 이 함수를 실행시키는지 명확하게 디버깅을 진행해보자
var foundDevTools = injectIntoDevTools({
findFiberByHostInstance: getClosestInstanceFromNode,
bundleType: 1 ,
version: ReactVersion,
rendererPackageName: 'react-dom'
});
코드를 타고 올라간 결과 React 초기화 시, 위 코드를 실행하여 DevTools 사용 유무를 확인하는 것을 확인할 수 있었다. 만약 사용자가 React Developer Tools 을 사용하고 있다면 foundDevTools 값이 true로 설정될 것이고, 그렇지 않으면 false가 반환되는 모습도 확인할 수 있었다.
[extension이 없다면 REACT_DEVTOOLS_GLOBAL_HOOK 값이 ‘undefined’이기에 false를 반환한다.]
이후 해당 함수 내에서 아래 코드가 실행되는데, 이 부분에서 React Developer Tools를 통해 React 내부의 injectedProfilingHooks 함수를 실행시켜 프로파일링 훅 변수에 값을 할당해준다. 내부 동작을 좀 더 파헤쳐보자.
rendererID = hook.inject(internals); // We have successfully injected, so now it is safe to set up hooks.
여기부터는 브라우저의 디버깅으로 따라갈 수 없으므로 Call Stack을 참고하여 Clone한 React 라이브러리에서 직접 해당 함수를 분석해보았다. 아래는 hook.js 파일 안에 있는 inject 함수 코드이다.
const isProfiling = shouldStartProfilingNow;
let uidCounter = 0;
function inject(renderer: ReactRenderer): number {
const id = ++uidCounter;
renderers.set(id, renderer);
const reactBuildType = hasDetectedBadDCE
? 'deadcode'
: detectReactBuildType(renderer);
hook.emit('renderer', {
id,
renderer,
reactBuildType,
});
const rendererInterface = attachRenderer(
hook,
id,
renderer,
target,
isProfiling,
profilingSettings,
);
if (rendererInterface != null) {
hook.rendererInterfaces.set(id, rendererInterface);
hook.emit('renderer-attached', {id, rendererInterface});
} else {
hook.hasUnsupportedRendererAttached = true;
hook.emit('unsupported-renderer-version');
}
return id;
}
코드상으로는 React의 renderer 버전, UID, 빌드 타입 등을 검증하고, 이후 이 정보를 기반으로 renderer를 등록해주는 역할을 하는 것처럼 보인다. attachRenderer 이후, 마지막 진입점인 attach 함수까지 접근하게 되면 아래와 같은 코드를 발견할 수 있다.
if (typeof injectProfilingHooks === 'function') {
const response = createProfilingHooks({
getDisplayNameForFiber,
getIsProfiling: () => isProfiling,
getLaneLabelMap,
currentDispatcherRef: getDispatcherRef(renderer),
workTagMap: ReactTypeOfWork,
reactVersion: version,
});
// Pass the Profiling hooks to the reconciler for it to call during render.
injectProfilingHooks(response.profilingHooks);
// Hang onto this toggle so we can notify the external methods of profiling status changes.
getTimelineData = response.getTimelineData;
toggleProfilingStatus = response.toggleProfilingStatus;
}
여기서는 injectProfilingHooks 함수가 createProfilingHooks를 통해 프로파일링 훅을 생성하고, 이어서 injectProfilingHooks 함수를 실행하여 생성된 프로파일링 훅을 주입해준다. 즉, 개발자가 Chrome DevTools를 통해 프로파일링을 시작하지 않더라도 무조건 프로파일링 훅이 주입되어 위에서 언급한 마킹 관련 코드가 실행된다는 점을 알 수 있었다.
하지만 아직까지는 괜찮다. 마킹 관련 함수가 실행되더라도 내부적으로 처리가 잘 되어 있다면 성능상 문제는 없을 것이다. 이제 마킹 관련 함수 중 하나인 markComponentRenderStarted 함수를 자세히 분석해보자. 이 또한 크롬에서는 디버깅이 불가하기 때문에 React 라이브러리를 직접 확인하였다.
function markComponentRenderStarted(fiber) {
{
if (injectedProfilingHooks !== null && typeof injectedProfilingHooks.markComponentRenderStarted === 'function') {
injectedProfilingHooks.markComponentRenderStarted(fiber);
}
}
}
코드는 profilingHooks.js 파일 내부에 선언되어 있다. 아래는 해당 코드이다.
function markComponentRenderStarted(fiber: Fiber): void {
if (isProfiling || supportsUserTimingV3) {
const componentName = getDisplayNameForFiber(fiber) || 'Unknown';
if (isProfiling) {
// TODO (timeline) Record and cache component stack
if (isProfiling) {
currentReactComponentMeasure = {
componentName,
duration: 0,
timestamp: getRelativeTime(),
type: 'render',
warning: null,
};
}
}
if (supportsUserTimingV3) {
markAndClear(`--component-render-start-${componentName}`);
}
}
}
일단 isProfiling 변수를 통해 현재 상태를 검증하는 모습을 확인할 수 있었다. 실제로 코드를 확인해 본 결과, 해당 변수는 startProfiling 함수, toggleProfilingStatus 함수 등과 연계되어 Chrome DevTools의 프로파일링 상태를 따르도록 코딩되어 있다는 것을 확인할 수 있었다. 즉, 개발자가 직접 조작하지 않는 이상 이 변수는 기본적으로 false로 설정되어 있다.
그렇다면 남은 변수는 supportsUserTimingV3이다. 실제로 Performance 탭에서 markAndClear 함수가 실행되는 것을 확인했으므로, supportsUserTimingV3 변수의 값은 true일 것이라고 예측할 수 있다. 이 부분에 대해 조사해보자.
User Timing API는 브라우저가 User Timing API V3를 지원하는지 여부를 나타내며, 최신 브라우저의 경우 대부분 true일 것이다. 즉, markAndClear 함수를 포함하여 supportsUserTimingV3 변수와 연관된 마킹 함수들은 항상 실행된다는 것을 알 수 있다.
결론
결과적으로 React Developer Tools 사용 시 성능 마킹 작업이 항상 실행되며, 이로 인해 개발자의 예상과 다른 결과를 초래할 수 있다는 사실을 알 수 있었다. 각 마킹 작업은 0.1ms에서 1ms까지 소모되며, 개발자의 PC 성능에 따라 차이가 날 수 있다. 이 작은 시간이 누적되면 컴포넌트의 개수가 많을 경우 마킹 시간이 n배로 소모되어 눈에 띄는 성능 차이를 보이게 된다.
따라서 성능 관련 개발을 할 때, React Profiler를 사용하지 않거나 리렌더링이 일어나는 부분을 확인할 필요가 없는 경우, 정밀한 측정이 필요한 경우라면 React Developer Tools를 비활성화하고 개발하는 것을 추천하며 필요할 때만 활성화하여 사용하는 것을 권장한다.
[React Developer Tool 비활성화 시 보이지 않는 마킹 관련 함수들]
[React Developer Tool 활성화 시 각 컴포넌트 사이에 껴서 실행 시간을 늦추는 모습]
[React Developer Tool 의 마킹 관련 코드를 주석처리 하고 override하고 난 뒤 빨라진 성능]
다른 프로젝트에서 테스트
실제로 페이지를 하나 만들어 테스트를 해보아도 동일한 결과를 확인할 수 있었다.
[코드]
"use client";
import React, { useState } from "react";
const Cell = ({ row, cell, height, onFocus }) => {
return (
<div
id={`cell-${row}-${cell}`}
tabIndex="0"
onFocus={() => onFocus(row, cell)}
style={{
backgroundColor: "lightgray",
border: "1px solid black",
padding: "20px",
boxSizing: "border-box",
height: height || "auto", // height 상태에서 가져옴
transition: "height 0.2s ease, background-color 0.2s ease",
}}
>
{`Row ${row}, Cell ${cell}`}
</div>
);
};
const Row = ({ rowIndex, cellHeights, handleFocus }) => {
const cells = [];
for (let j = 1; j <= 5; j++) {
const key = `${rowIndex}-${j}`;
cells.push(
<Cell
key={key}
row={rowIndex}
cell={j}
height={cellHeights[key]} // 해당 셀의 height 전달
onFocus={handleFocus}
/>
);
}
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 200px)", gap: "10px" }}>
{cells}
</div>
);
};
const GridFocusTest = () => {
const [cellHeights, setCellHeights] = useState({});
const handleFocus = (row, cell) => {
console.time(`focus on Row ${row}, Cell ${cell}`);
// 새로운 height를 설정 (랜덤 값)
const newHeight = Math.random() * 100 + 50 + "px";
setCellHeights((prevHeights) => ({
...prevHeights,
[`${row}-${cell}`]: newHeight,
}));
console.timeEnd(`focus on Row ${row}, Cell ${cell}`);
};
const renderGrid = () => {
const rows = [];
for (let i = 1; i <= 1000; i++) {
rows.push(
<Row
key={`row-${i}`}
rowIndex={i}
cellHeights={cellHeights}
handleFocus={handleFocus}
/>
);
}
return rows;
};
return (
<div style={{ overflowY: "scroll", height: "500px" }}>
<h1>React Grid Focus Reflow Test (1000 Rows)</h1>
<div>{renderGrid()}</div>
</div>
);
};
export default GridFocusTest;
[전체]
[확대]
'frontend' 카테고리의 다른 글
Cypress의 단점을 극복하기 위한 puppeteer 기반의 e2e 테스트 프레임워크 개발(2) (0) | 2024.12.20 |
---|---|
Cypress의 단점을 극복하기 위한 puppeteer 기반의 e2e 테스트 프레임워크 개발(1) (0) | 2024.12.16 |
immer, Redux Toolkit 성능 문제 (0) | 2024.10.11 |
프론트엔드 전체 테스트 환경 구축해보기(3.웹서버) (0) | 2024.09.22 |
프론트엔드 전체 테스트 환경 구축해보기(2.CLI) (0) | 2024.08.30 |