React 성능, 측정 없이 건드리지 마세요

React 성능, 측정 없이 건드리지 마세요

DevTools Profiler로 병목을 먼저 찾고, 불필요한 useEffect를 걷어내는 것—그 순서가 전부입니다.

React 성능 최적화 React DevTools Profiler useMemo useEffect 제거 코드 스플리팅 리스트 가상화 TanStack Query Core Web Vitals
광고

6초. 2026년에 대시보드가 6초 만에 뜬다면, 저라면 그날 밤을 통째로 날릴 각오를 하겠습니다. dev.to에 올라온 한 시니어 개발자의 사례가 딱 그 상황입니다. 버튼 클릭마다 UI가 0.5초씩 얼어붙고, 바운스율은 치솟고. 그런데 흥미로운 건 그가 코드를 고치기 전에 한 일입니다. 아무것도 건드리지 않고 React DevTools Profiler를 먼저 켰다는 것.

이게 사실 대부분의 개발자가 실수하는 지점입니다. 느리다는 느낌이 오면 반사적으로 React.memouseMemo를 여기저기 뿌리기 시작하죠. 저도 솔직히 그런 적 있습니다. Profiler의 Flame Chart를 열어보면 50ms 이상 걸리는 컴포넌트가 바로 보이는데, 그걸 확인하기도 전에 최적화부터 시작하면 틀린 곳을 고치는 겁니다. 심지어 불필요한 메모이제이션은 오히려 성능을 악화시킬 수 있어요. 메모리 오버헤드와 얕은 비교 비용이 생기니까요.

프로파일링으로 찾아낸 문제는 크게 세 가지였습니다. 불필요한 리렌더링, 비대한 초기 번들, 매 렌더마다 돌아가는 고비용 연산. 첫 번째는 고전적인 패턴입니다. 부모 컴포넌트의 count 상태가 바뀔 때마다 HeavyChart, UserTable, ActivityFeed가 전부 따라 렌더링되는 구조. 이 컴포넌트들은 count를 props로 받지도 않는데 말이죠. React.memo로 감싸는 것만으로 리렌더링이 70% 줄었다고 합니다. React 19+에서 React Compiler(구 React Forget)를 쓰면 이 메모이제이션을 자동으로 처리해주지만, 아직 도입 전이라면 React.memo는 여전히 유효한 카드입니다.

두 번째 문제가 개인적으로 더 와닿습니다. 초기 번들이 95KB였는데, Webpack Bundle Analyzer를 돌려보니 차트 라이브러리 하나가 38KB를 잡아먹고 있었습니다. 그것도 해당 페이지를 방문하지 않는 모든 유저에게 전달되면서요. React.lazySuspense로 라우트 단위 코드 스플리팅을 적용했더니 번들이 31KB로 줄었고, 로딩 타임은 6초에서 2.8초로 뚝 떨어졌습니다. Figma에서 시안 볼 때는 멀쩡해 보이는데 실제 배포하면 Lighthouse 점수가 바닥인 이유 중 하나가 바로 이겁니다. 코드 스플리팅 없이 컴포넌트만 예쁘게 만들면 소용없어요.

세 번째는 2,000개 행 테이블의 필터+정렬 연산이 매 렌더마다 실행되던 문제입니다. useMemo[users, searchQuery] 의존성을 걸어줬더니 인터랙션 응답이 340ms에서 12ms로 줄었습니다. Core Web Vitals 기준으로 INP(Interaction to Next Paint)가 200ms 이하면 Good 등급인데, 340ms는 명백한 Poor 구간입니다. 여기에 보너스로 react-window를 써서 리스트 가상화까지 적용하면, 2,000개 DOM 노드 대신 화면에 보이는 약 20개만 실제로 렌더링합니다. 스크롤 버터리함의 차이가 체감으로 확 옵니다.

그런데 이 최적화 이야기와 맞물리는 또 다른 주제가 있습니다. 같은 저자의 두 번째 글에서 나온 이야기인데, useEffect의 80%는 처음부터 쓰지 말았어야 했다는 겁니다. useEffect 안에서 setState를 호출해 파생 상태를 만들고 있다면, 렌더 사이클이 하나 더 추가되는 겁니다. 렌더 → effect 실행 → setState → 다시 렌더. useMemo로 렌더 중에 바로 계산하면 이 사이클 자체가 사라집니다.

더 나아가, 데이터 페칭을 위한 useEffect도 대부분 더 나은 대안이 있습니다. React 19의 use() 훅은 Promise를 직접 소비하면서 Suspense와 함께 로딩/에러 상태를 선언적으로 처리합니다. 15줄짜리 useEffect 페칭 코드가 5줄로 줄어드는 거죠. 프로덕션 레벨에서는 TanStack Query가 여전히 강력합니다. 캐싱, 백그라운드 리페칭, 레이스 컨디션 방지까지 한 번에 들어오니까요. React Router v6.4+를 쓰고 있다면 loader + useLoaderData 패턴으로 컴포넌트가 마운트되기 전에 데이터를 미리 가져올 수도 있습니다. 로딩 플리커가 사라지는 걸 보면 사용자 입장에서는 세상이 달라집니다.

useEffect가 진짜로 필요한 경우는 명확합니다. DOM API, WebSocket, 서드파티 라이브러리 같이 React 바깥의 외부 시스템과 동기화할 때입니다. 그 외의 상황에서 useEffect를 쓰고 있다면 한 번 의심해봐야 합니다. "이걸 useMemo로 대체할 수 있지 않을까?

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요