프로젝트내에서 불필요한 리렌더링일 발생한다는 의혹이 있었습니다. 증거는 분명했지만, 범인을 찾기는 쉽지 않았습니다.
const TestComponent = () => {
console.log("리렌더링") // 분명 누군가가 이 컴포넌트를 리렌더링 시키고 있다!
return (
<div>
<A/>
<B/>
</div>
)
}
이런 단순한 로그만으로는 "누가","왜" 리렌더링을 일으키는지 알 수 없었습니다. 마치 범인은 있지만 동기와 수법을 알 수 없는 미스터리와 같았죠.
React 세계의 기존 수사 도구들을 검토해보았습니다. 저는 기존 수사도구의 많은 불편함을 느꼈습니다. 마치 최첨단 관제실에 앉아있지만, 정작 필요한 CCTV 화면은 흐릿하고 핵심 지점은 사각지대에 있는 것 같았습니다. React 프로파일링은 렌더링 시간과 발생 시점은 보여주지만, 정작 그 안에서 무슨 일이 벌어지는지, 왜 그런 현상이 발생하는지에 대한 실마리는 찾기 어려웠습니다. 진짜 범인을 찾기 위한 결정적 단서가 부족했던 것이죠.
"Record Why each component rendered while profiling"을 체크하면 렌더링 원인을 파악할 수 있지만, 실제로는 "Hooks 3 and 8 changed"와 같이 모호한 증언만 제공합니다. 마치 추리 소설에서 목격자가 "범행 시간에 그 장소에 누군가 있었어요"라고만 말하고, 정작 범인의 인상착의나 특징은 기억하지 못하는 상황과 같습니다.

범행 사실은 알려주지만 범인의 정체는 감춰줌. "누군가 이곳에 있었다"는 발자국만 남길 뿐, 그 주인이 누구인지는 알 수 없었습니다.
복잡한 절차와 Next.js와의 호환성 문제. 고급 수사 장비지만 들고다니기 무겁고, 세팅도 번거로워서 현장에서 바로 사용하기 어려웠습니다.
이런 상황에서 저는 직접 수사 도구를 만들기로 결심했습니다.
수사를 위해 React의 리렌더링이 발생하는 근본적인 원인 4가지를 정리했습니다:
이 모든 원인은 결국 "상태의 변화"로 귀결됩니다. props도 결국 상위 컴포넌트의 상태가 전달된 것이니까요.
이전에 바닐라 자바스크립트로 React의 가상 DOM과 reconciler를 구현해본 경험을 살려, 리렌더링 수사에 특화된 커스텀 훅을 만들었습니다.
🧩 수사 도구의 설계 원칙
1. 최초 렌더링 감지 : 컴포넌트의 첫 렌더링인지 확인합니다.
2. 현장 증거 수집 : 컴포넌트의 최초 상태(props와 state)를 저장합니다.
3. 상태 비교 : 이전 상태와 현재 상태 대조
3. 결과 출력 : 수사 결과를 명확하게 보고
4. 부모 컴포넌트 유추 : 다른 변화가 없다면 부모로부터의 영향 추정
상태 기록은 useRef를 활용합니다. 렌더링 사이클 간에 값을 유지하면서도 값이 변경되어도 컴포넌트의 리렌더링을 유발하지 않기 때문에 useWhyDidyouRender가 겹치는 상황에서도 원인을 추적하는데 방해를 받지 않을 수 있습니다.
if (isFirstRender.current) {
isFirstRender.current = false;
prevStateRef.current = { ...state };
prevActionRef.current = { ...action };
return;
}
해당 코드에서는 컴포넌트의 첫 렌더링을 감지하고, 현재의 state와 action 값의 스냅샷을 생성합니다. 첫 렌더링 에서는 이전 상태가 없기 때문에 비교할 필요가 없으므로 함수를 빠르게 종료합니다.
const changedState: Record<string, any> = {};
let hasStateChanged = false;
if (state && typeof state === 'object') {
Object.keys(state).forEach((key) => {
if (state[key] !== prevStateRef.current[key]) {
changedState[key] = {
이전: prevStateRef.current[key],
현재: state[key],
};
hasStateChanged = true;
}
});
}
리렌더링 시점 부터는 이전의 상태와 현재의 상태를 비교합니다. 리액트는 상태를 비교할때 객체를 대상으로 깊은 비교를하지 않기 때문에 이를 반영하여 간단한 얕은 비교를 통해 이전의 상태와 현재의 상태를 비교합니다.
변화를 쉽게 추적할 수 있게 ChangedState의 이전의 상태와 현재의 상태를 기록합니다.
if (hasStateChanged || hasActionChanged) {
console.group(`[렌더] ${componentName}`);
if (hasStateChanged) {
console.log('변경된 state:', changedState);
}
if (hasActionChanged) {
console.log('변경된 action:', changedAction);
}
console.groupEnd();
} else {
console.log(
`[렌더] ${componentName} - state/action 변경 없음 (부모 리렌더링)`,
);
}
리렌더링이 발생하면 콘솔에 깔끔하게 구조화된 정보를 출력합니다. console.group을 활용하여 컴포넌트 별로 변화를 그룹화함으로써, 복잡한 컴포넌트 트리에서도 원인을 쉽게 찾아낼 수 있습니다.
state나 action의 변화가 감지되지 않았는데도 리렌더링이 발생했다면, 이는 React의 기본 동작에 따라 부모 컴포넌트의 리렌더링으로 인한 전파임을 알려줍니다. 이 정보는 React.memo나 useMemo, useCallback를 통한 최적화가 필요한 컴포넌트를 식별하는 데 매우 유용합니다.
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@/src/shared/hooks",
"importNames": ["useWhyDidYouRender"],
"message": "이 훅은 개발 환경에서만 사용해야 합니다. 프로덕션 빌드 전에 제거하세요."
}
]
}
]
이 훅이 개발 과정에서만 사용되고 실제 배포 환경에 포함되지 않도록 이중 안전장치를 마련했습니다. 첫째, 훅 내부에서 process.env.NODE_ENV === 'development' 조건을 통해 개발 모드에서만 로직이 실행되도록 했습니다. 둘째, ESLint 규칙을 추가하여 빌드 단계에서 해당 훅의 사용을 감지하고 경고함으로써 프로덕션 코드에 실수로 포함되는 것을 방지합니다.
/* eslint-disable no-console */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef } from 'react';
/**
* 컴포넌트 리렌더링 원인 감지 훅
*
* @param componentName - 컴포넌트 이름
* @param state - 추적할 state (선택적)
* @param action - 추적할 action (선택적)
*/
const useWhyDidYouRender = (
componentName: string,
state: Record<string, unknown> = {},
action: Record<string, unknown> = {},
) => {
const isDevelopment = process.env.NODE_ENV === 'development';
const isFirstRender = useRef(true);
const prevStateRef = useRef<Record<string, any>>({});
const prevActionRef = useRef<Record<string, any>>({});
useEffect(() => {
if (!isDevelopment) {
return;
}
if (isFirstRender.current) {
isFirstRender.current = false;
prevStateRef.current = { ...state };
prevActionRef.current = { ...action };
return;
}
// 변경된 state 찾기
const changedState: Record<string, any> = {};
let hasStateChanged = false;
if (state && typeof state === 'object') {
Object.keys(state).forEach((key) => {
if (state[key] !== prevStateRef.current[key]) {
changedState[key] = {
이전: prevStateRef.current[key],
현재: state[key],
};
hasStateChanged = true;
}
});
}
// 변경된 action 찾기
const changedAction: Record<string, any> = {};
let hasActionChanged = false;
if (action && typeof action === 'object') {
Object.keys(action).forEach((key) => {
if (action[key] !== prevActionRef.current[key]) {
changedAction[key] = {
이전: prevActionRef.current[key],
현재: action[key],
};
hasActionChanged = true;
}
});
}
// 변경사항 출력
if (hasStateChanged || hasActionChanged) {
console.group(`[렌더] ${componentName}`);
if (hasStateChanged) {
console.log('변경된 state:', changedState);
}
if (hasActionChanged) {
console.log('변경된 action:', changedAction);
}
console.groupEnd();
} else {
console.log(
`[렌더] ${componentName} - state/action 변경 없음 (부모 리렌더링)`,
);
}
prevStateRef.current = state ? { ...state } : {};
prevActionRef.current = action ? { ...action } : {};
});
};
export default useWhyDidYouRender;
해당 훅의 동작을 1차적으로 테스트 코드를 통해 검증하였고, 실제 프로젝트에 적용하여 사용자 경험을 분석했습니다.
인자강 프로젝트의 인터뷰 페이지는 퍼널 구조로 구현되어 있습니다. 메인 퍼널 컴포넌트는 5개의 스텝 컴포넌트를 관리하며, 각 컴포넌트의 내부 상태 조건 달성 시 다음 스텝 버튼이 활성화되는 구조입니다. 이러한 복잡한 상태 관리 환경에서 리렌더링 원인을 정확히 파악할 도구가 필요했습니다.

위 화면에서 퍼널 컴포넌트의 두 번째 스텝은 면접 질문을 설정하는 컴포넌트입니다. 오른쪽은 관리자가 제공하는 예상 질문 리스트이고, PlayList는 최종적으로 확정된 리스트입니다. 흥미로운 점은 PlayList에서 항목을 삭제하면 예상 질문 리스트에도 리렌더링이 발생한다는 것이었습니다.
원인을 분석하기 위해 useWhyDidYouRender 훅을 각 컴포넌트와 상위 퍼널 컴포넌트에 적용했더니 다음과 같은 결과가 나타났습니다:
useWhyDidYouRender.ts:80 [렌더] ExpectedQuestionSelector - state/action 변경 없음 (부모 리렌더링)
useWhyDidYouRender.ts:68 [렌더] UserQuestionPlayList
useWhyDidYouRender.ts:71 변경된 state:
{userPlayList: {…}}
useWhyDidYouRender.ts:68 [렌더] IntvPage
useWhyDidYouRender.ts:71 변경된 state:
{userPlayList: {…}, interviewSteps: {…}}
로그 분석 결과, ExpectedQuestionSelector 내부에서는 상태 변화가 없었고 부모 컴포넌트의 리렌더링이 원인이었습니다. 퍼널 구조의 최상위인 IntvPage 컴포넌트를 살펴보니 userPlayList와 interviewSteps의 변화가 확인되었습니다.
해당 컴포넌트의 코드를 추적해보니:
const interviewSteps: IntvSteps[] = useMemo(
() => [
{
render: <InterviewLobby />,
subTitle: 'NextStage => (면접 설정)',
title: '면접 대기',
rule: null,
id: 'Step_01',
},
{
render: <ExpectedQuestionLayout />,
subTitle: 'Next Step...',
title: '면접 질문 선택',
rule: null,
id: 'Step_02',
},
{
render: <InterViewRandomSetting />,
subTitle: userPlayList.length ? 'Next Step...' : '질문 설정은 필수...',
title: '랜덤 질문 선택',
rule: !!userPlayList.length,
id: 'Step_03',
},
],
[userPlayList.length, videoDevice, audioDevice, currentStep],
);
퍼널 구조에서 다음 스텝 버튼의 활성화 조건을 관리하는 로직이 userPlayList.length를 의존성으로 포함하고 있어, PlayList가 변경될 때마다 interviewSteps가 재생성되면서 리렌더링이 발생했던 것입니다.
문제를 해결하기 위해 컴포넌트를 메모이제이션 처리했습니다
const MemoizedExpectedQuestionLayout = memo(ExpectedQuestionLayout);
{
render: <MemoizedExpectedQuestionLayout />,
subTitle: 'Next Step...',
title: '면접 질문 선택',
rule: null,
id: 'Step_02',
},
흠 그런데 React.memo는 신중하게 사용하라는 말이 있는 것처럼 과연 해당 메모이제이션이 어떤 이점이 있을지 비교해보았습니다.
useWhyDidYouRender 훅을 통해 불필요한 리렌더링을 발견한 후, React.memo를 적용하여 최적화를 진행했습니다. 이 과정에서 성능 개선 효과를 객관적으로 확인하기 위해 Chrome DevTools의 Performance 패널을 활용해 간단한 프로파일링을 실시했습니다.
실험 조건 : 동일한 환경에서 PlayList 항목 3개를 삭제
기록 방법 : 가장 큰 폭의 그래프
메모이제이션을 적용 전 : 렌더링 시간 : 최대 5.2ms

메모이제이션을 적용한 후 : 렌더링 시간 : 최대 1.1ms

결과를 보면 확인할 수 있듯이 Render의 시간이 현저히 줄어든 것을 확인할 수 있습니다.
하지만, 메모이제이션인 만큼 오히려 메모리의 큰 부담을 주지 않을까라는 우려가 있을 수 있기 때문에 비교 검토해볼 필요가 있었습니다.
메모이제이션 미적용 시점: 가장 큰 그래프의 경우 56.4KB 할당, 평균 50KB

메모이제이션 적용 시점 : 가장 큰 그래프의 경우 44.2KB 할당, 평균 35KB

useWhyDidYouRender 훅은 복잡한 React 애플리케이션에서 리렌더링의 미스터리를 해결하는 강력한 수사 도구임이 입증되었습니다.
하지만 모든 컴포넌트에 React.memo를 적용하는 것은 오히려 역효과를 낼 수 있습니다. 다른 스텝의 컴포넌트들을 테스트해보니 경우에 따라 렌더링 시간 감소가 미미하거나 오히려 메모리 사용량이 증가하는 사례도 있었습니다.
사건은 해결되었지만, 수사는 계속됩니다. 최적화의 여정에는 끝이 없으니까요!