내 리스트가 왜 이렇게 느릴까
대시보드에 100개 정도의 항목을 보여주는 리스트를 만들었다. 로컬에서는 괜찮았는데 데이터가 늘어나니까 체감상 확실히 느려졌다. 스크롤할 때마다 버벅거리고, 검색 필터를 바꾸면 1-2초 정도 멈춘 것처럼 보였다.
문제는 "어디가 문제인지" 알 수가 없었다는 거다. 코드를 봐도 특별히 이상한 부분이 없었다. console.log를 여기저기 찍어봤지만 컴포넌트가 언제, 왜 다시 렌더링되는지 감이 안 왔다. "이 컴포넌트가 매번 렌더링되나?" 싶어서 로그를 찍으면 정말 매번 찍히는데, 그게 정상인지 문제인지 판단이 안 섰다.
그러다가 동료가 던진 한마디: "DevTools Profiler 켜봤어?"
아, 그게 있었지. Chrome DevTools만 쓰다가 React DevTools는 설치만 해두고 제대로 써본 적이 없었다. 열어보니 Components 탭이랑 Profiler 탭 두 개가 있었다. 이게 뭐가 다른 건지도 모르고 일단 Profiler부터 켜봤다.
범인을 눈으로 보다
Profiler 탭에서 파란색 녹화 버튼을 눌렀다. 그리고 내 앱에서 검색 필터를 한번 바꿔봤다. 그 순간, DevTools에 형형색색의 불꽃 그래프(flame graph)가 나타났다. 각 컴포넌트가 얼마나 렌더링 시간을 썼는지 막대 길이로 보여주는 거였다.
// 문제가 있던 코드
function Dashboard() {
const [searchTerm, setSearchTerm] = useState('');
const [items, setItems] = useState([]);
// 이 객체가 매번 새로 만들어진다
const filterConfig = {
caseSensitive: false,
includeArchived: true
};
return (
<div>
<SearchBar value={searchTerm} onChange={setSearchTerm} />
<ItemList items={items} filter={filterConfig} searchTerm={searchTerm} />
</div>
);
}
function ItemList({ items, filter, searchTerm }) {
console.log('ItemList rendered'); // 이게 계속 찍힌다
return (
<div>
{items.map(item => (
<ItemCard key={item.id} item={item} filter={filter} />
))}
</div>
);
}
Profiler의 flame graph를 보니까 ItemList가 노란색(중간 정도 느림)으로 떴고, 그 아래 100개의 ItemCard가 전부 회색(렌더링됨)으로 표시됐다. 검색어를 하나 바꿨을 뿐인데 100개 카드가 전부 다시 렌더링됐다는 뜻이다.
그런데 진짜 놀라운 건 Profiler의 "Why did this render?" 기능이었다. ItemList를 클릭하니까 이렇게 나왔다:
Why did this render?
- Props changed: filter
- Props changed: searchTerm
searchTerm이 바뀌는 건 당연한데, filter는 왜 바뀐 거지? 코드를 다시 보니 filterConfig 객체를 매번 새로 만들고 있었다. JavaScript에서는 {} !== {}이니까 React가 보기엔 props가 바뀐 거였다. 이게 내가 찾던 범인이었다.
Components 탭 - 컴포넌트 해부하기
Profiler로 범인을 찾았으니 이제 고칠 차례다. 근데 고치기 전에 Components 탭을 먼저 열어봤다. 이건 내 앱의 컴포넌트 트리를 보여주는 거였는데, 마치 HTML 구조를 보는 것처럼 컴포넌트 계층이 쫙 펼쳐져 있었다.
ItemList를 클릭하니까 오른쪽에 props, state, hooks가 전부 보였다. filter prop을 클릭해보니까 실제 객체 내용도 볼 수 있었다. 그리고 제일 유용했던 건 "rendered by" 정보였다. 이 컴포넌트가 어느 부모 컴포넌트 때문에 렌더링됐는지 추적할 수 있었다.
DevTools 설정에서 "Highlight updates when components render"를 켜니까 진짜 눈으로 보였다. 검색어를 바꿀 때마다 화면에 파란색 테두리가 깜빡이는데, 어느 부분이 리렌더링되는지 실시간으로 보이는 거였다. 100개 카드가 전부 깜빡이는 걸 보니까 확신이 들었다. "이거 고쳐야 해."
최적화 워크플로우
DevTools로 문제를 정확히 파악했으니 이제 고치는 건 간단했다. 세 단계로 접근했다:
1단계: 불필요한 객체 생성 제거
function Dashboard() {
const [searchTerm, setSearchTerm] = useState('');
const [items, setItems] = useState([]);
// useMemo로 객체를 메모이제이션
const filterConfig = useMemo(() => ({
caseSensitive: false,
includeArchived: true
}), []); // 의존성 없으니 한번만 생성됨
return (
<div>
<SearchBar value={searchTerm} onChange={setSearchTerm} />
<ItemList items={items} filter={filterConfig} searchTerm={searchTerm} />
</div>
);
}
2단계: 불필요한 리렌더링 방지
// React.memo로 props가 실제로 바뀔 때만 리렌더링
const ItemCard = React.memo(({ item, filter }) => {
return (
<div className="card">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
);
});
3단계: Profiler로 검증
다시 Profiler를 켜고 똑같은 동작을 해봤다. 이번엔 ItemCard 100개가 회색 대신 "Did not render"라고 나왔다. 검색어가 바뀌어도 필터 설정이 안 바뀌었으니 카드들은 리렌더링되지 않은 거다. Flame graph의 막대도 훨씬 짧아졌다.
체감 속도 차이가 확실했다. 검색 필터를 바꿨을 때 1-2초 걸리던 게 즉각 반응하게 됐다. 스크롤도 훨씬 부드러웠다.
결국 데이터가 답이었다
이 경험으로 깨달은 건, 성능 최적화는 "감"이 아니라 "데이터"로 해야 한다는 거다.
코드만 보면 "이 부분이 느릴 것 같은데?"라는 추측만 할 뿐이다. React DevTools는 추측을 확신으로 바꿔줬다. Profiler는 "어느 컴포넌트가 느린지", Components 탭은 "왜 렌더링됐는지", Highlight updates는 "실제로 어떻게 동작하는지" 눈으로 보여줬다.
처음엔 모든 리스트에 React.memo를 다 붙이려고 했다. 전형적인 "과잉 최적화"였다. 근데 Profiler로 측정해보니까 실제로 문제가 되는 컴포넌트는 2-3개뿐이었다. 나머지는 리렌더링되어도 충분히 빨랐다. DevTools 없었으면 쓸데없는 메모이제이션 코드만 잔뜩 추가했을 거다.
React DevTools는 마치 의사가 쓰는 CT 스캐너 같았다. 겉으로 보면 "어디가 아픈 것 같긴 한데" 수준이지만, 스캔을 해보면 정확히 어디가 문제인지 보인다. 이제는 성능 문제가 생기면 코드를 뒤지기 전에 DevTools부터 켠다. 5분 프로파일링이 5시간 삽질을 줄여준다.