리액트 성능 최적화 가이드

Ricki
20 min read4 days ago

최근 회사에서 성능 이슈가 많이 발생하는 페이지를 개선하면서 적용했던 성능 최적화 방법들을 공유해보고자 합니다. 어떤 분들께는 너무 당연한 내용일 수 있지만, 어떤 분들에겐 당연하지 않은 내용일 수도 있다고 생각해서 한번에 모아볼 수 있게 정리해봅니다.

1. 성능 문제 진단하기

성능 문제 개선 작업을 시작하기 전엔, 먼저 어느 부분에서 병목이 발생했고 왜 느려졌는지 파악하는 게 효율적입니다. 성능 문제를 찾아내는 방법은 여러 개가 있는데, 그 중 가장 대중적인 건 크롬 devtools와 react devtools입니다.

1–1. Chrome DevTools Performance 탭 활용하기

Performance 탭은 애플리케이션의 전반적인 성능 병목을 파악하는 데 유용합니다. 특히 다음 영역을 잘 봐야합니다.

  • JavaScript 실행 시간: 스크립트 실행에 많은 시간이 소요된다면, 무거운 연산을 최적화하거나 Web Worker로 분리해야 할 수 있습니다.
    자바스크립트는 단일 스레드로 동작하기 때문에, 하나의 긴 작업이 실행되면 다른 모든 작업이 대기해야 합니다. 이건 이벤트 루프와 연관이 있는데, 이벤트 루프는 콜 스택이 비어있을 때 태스크 큐나 마이크로태스크 큐에서 다음 실행할 작업을 가져옵니다.
    따라서 하나의 무거운 동기 작업이 콜 스택을 점유하고 있으면, 사용자 입력이나 애니메이션 같은 다른 중요한 작업들이 지연됩니다.
    그래서 무거운 계산이 필요하다면 Web Worker를 활용하여 메인 스레드를 차단하지 않도록 하거나, 작업을 더 작은 단위로 나누어 이벤트 루프가 주기적으로 다른 작업을 처리할 수 있게 해야 합니다.

시각적으로 위 작업을 확인하고 싶다면 다음 글을 추천드립니다.

추천 글의 스크린샷
  • Recalculate Style & Layout: 이 작업이 너무 자주 발생한다면, DOM 조작과 스타일 변경을 최소화하고 will-change 속성 등을 고려해볼 수 있습니다.
  • Forced Reflow: JavaScript에서 DOM을 조작하면서 레이아웃이 강제로 재계산되는 경우를 주의해야 합니다.
https://web.dev/articles/long-tasks-devtools

Long Task 는 위와 같이 우측 상단에 빨간 삼각형처럼 표시되는데, 이 작업들을 중점적으로 확인하시면 좋습니다.

Performance 탭의 flame chart를 보는 법에 대한 내용도 함께 번역해두었으니 참고해보세요.

1-2. React DevTools 활용하기

React DevTools는 React 애플리케이션의 성능 문제를 진단하는 데 필수적인 도구입니다.

Rendering Highlight 기능

“Highlight updates when components render” 옵션을 활성화하면 컴포넌트가 리렌더링될 때마다 화면에 하이라이트가 표시됩니다. 이를 통해 불필요한 리렌더링이 발생하는 컴포넌트를 쉽게 파악할 수 있습니다.

다만 위 기능을 켜놓고 개발자도구를 열어둔 후 Performance를 측정하면 (당연하게도) 해당 highlight 작업의 렌더링도 포함되기 때문에 조금 더 느려집니다.

개발자 도구에 대해 추가로 말하자면, 프로덕션에선 (당연히) 콘솔에 찍히는 로그들을 모두 제거해야 합니다. 콘솔에 찍히는 로그들이 흔하게 디버깅에 사용되는 경우가 많지만, 해당 로그들은 사실 내부적으로는 io 스트림의 일부이기 때문에 성능에 영향을 준다고 합니다.

Profiler 탭 활용하기

React Profiler를 사용하면 어떤 컴포넌트가 리렌더링되고, 얼마나 느린지를 파악할 수 있습니다. 이와 관련된 다음 글을 추천합니다.

Profiler 탭에서 Anonymous로 잡히는 경우, 컴포넌트에 DisplayName이 없어서 그런 것이니 각 컴포넌트에 DisplayName을 잘 쓰도록 합시다.

2. 불필요한 리렌더링 방지하기

React 애플리케이션의 성능 문제 중 가장 흔한 것은 불필요한 리렌더링입니다. 이를 방지하기 위한 방법을 살펴보겠습니다.

2-1. 컴포넌트 책임 분리하기

하나의 컴포넌트가 너무 많은 상태를 관리하면 한 상태가 변경될 때 불필요한 리렌더링이 발생할 수 있습니다.

// 좋지 않은 예시: 모든 상태를 부모 컴포넌트에서 관리
function Dashboard() {
const [userData, setUserData] = useState({});
const [posts, setPosts] = useState([]);

// userData가 변경되면 PostList도 불필요하게 리렌더링됨
return (
<div>
<UserProfile data={userData} updateUser={setUserData} />
<PostList posts={posts} setPosts={setPosts} />
</div>

);
}
// 개선된 예시: 각 컴포넌트가 자신의 책임에 필요한 상태만 관리
function Dashboard() {
return (
<div>
<UserProfileContainer />
<PostListContainer />
</div>

);
}

function UserProfileContainer() {
const [userData, setUserData] = useState({});
return <UserProfile data={userData} updateUser={setUserData} />;
}

function PostListContainer() {
const [posts, setPosts] = useState([]);
return <PostList posts={posts} setPosts={setPosts} />;
}

위처럼, 현재 렌더링에 필요한 내용만 렌더링 될 수 있도록 잘 분리하는 것이 좋습니다.

Profiler로 잘 확인해가면서, 현재 의도된 렌더링만 일어나고 있는지 파악해야 합니다.

컴포넌트뿐만 아니라, hook도 마찬가지입니다. toss의 frontend-fundamental에서 이 부분을 잘 다루고 있습니다. Hook이 담당하는 책임을 잘 분리해서 특정한 상태 값이 업데이트되었을 때 최소한의 부분이 리렌더링되도록 설계하는 것이 좋습니다.

2–2. React.memo 올바르게 사용하기

React.memo는 컴포넌트의 props가 변경되지 않으면 리렌더링을 방지하는 고차 컴포넌트입니다. 하지만 모든 컴포넌트에 무분별하게 적용하는 것은 오히려 성능 저하가 일어날 수 있습니다.

React.memo를 사용해야 하는 경우:

  • 렌더링 비용이 높은 복잡한 컴포넌트
  • 같은 props로 자주 리렌더링되는 컴포넌트
  • 부모 컴포넌트의 상태 변화와 무관한 컴포넌트 (특히 이 때 유용합니다.)

사용을 피해야 하는 경우:

  • 단순한 UI 컴포넌트(버튼, 입력 필드 등)
  • 내부 상태가 자주 변경되는 컴포넌트

무조건적으로 memo를 쓰는 건 당연히 좋지 않지만, 잘 모를 경우 대체로 써주는 것이 좋았습니다(..)

2-3. useMemo와 useCallback 이해하기

useMemouseCallback은 최적화 도구이지만, 언제 어떻게 사용해야 하는지 정확히 이해하는 것이 중요합니다. (아마 1–2년 뒤에는 이해하지 않아도 될 것 같지만요..)

React.memo vs useMemo 차이점

미디엄은 언제쯤 표를 지원할까요?

2-4. useMemo 올바르게 사용하기

useMemo는 두 가지 주요 용도가 있습니다.

  1. 계산 비용이 많이 드는 연산을 메모화하여 성능 최적화
  2. 객체와 배열에 대한 안정적인 참조를 유지하여 불필요한 리렌더링 방지
// 좋은 사용 예시: 비용이 많이 드는 계산
const filteredData = useMemo(() => {
return data.filter(item => item.isActive);
}, [data]);
// 좋은 사용 예시: 참조 안정성 유지
const styleObject = useMemo(() => ({
color: theme === 'dark' ? 'white' : 'black',
padding: '10px'
}), [theme]);

“복잡한(비싼) 연산”의 기준은 1ms or more 정도라고 하네요. 이 글에서 useMemo에 대해 잘 나와있으니 참고하면 도움이 될 듯 합니다. 특히 useMemo를 꼭 성능 목적으로만 써야 한다! 는 점은 중요합니다. 종종 useEffect처럼 쓰이는 경우도 있어서..

2-5. useMemo의 나쁜 사례

스칼라 값(문자열, 숫자, 불리언)은 일반적으로 useMemo를 사용할 필요가 없습니다.

// 불필요한 사용 예시
const number = useMemo(() => 10, []);
const text = useMemo(() => "Hello", []);

스칼라 값은 자바스크립트에서 참조가 아닌 실제 값으로 전달되고 비교됩니다. 그래서 스칼라 값을 메모화하면 오히려 메모이제이션 오버헤드만 추가되어 성능이 저하될 수 있습니다.

2-6. 내장 컴포넌트에서의 함수 프롭 처리

React의 내장 컴포넌트(<button>, <input> 등)는 함수 프롭(onClick 등)을 사용자 정의 컴포넌트와는 다르게 처리합니다.

이벤트 위임 방식

  • React는 모든 이벤트 핸들러를 최상위 루트에서 이벤트 위임 방식으로 관리합니다.예를 들어, onClick 핸들러를 <button>에 설정하면, React는 실제 DOM 버튼에 직접 이벤트 리스너를 추가하지 않습니다.
  • 대신 루트 레벨에 하나의 이벤트 리스너를 추가하고, 이벤트 버블링을 통해 이벤트를 처리합니다. 이 방식을 통해 메모리 사용량을 줄이고 초기 설정 시간을 단축합니다.

렌더링 동작

  • 내장 컴포넌트는 함수 프롭이 변경되더라도 그 자체로는 리렌더링되지 않습니다. 새로운 함수가 전달되더라도 React는 단순히 기존 함수를 대체합니다. 이 과정은 빠르게 일어나서 보통 최적화할 필요가 없습니다. 최적화 비용이 더 많이 드는 경우가 이런 걸 의미합니다.

가상 DOM 비교

  • 내장 컴포넌트에 대해 React는 함수 프롭의 깊은 비교를 수행하지 않습니다.
  • 새 함수는 단순히 DOM 엘리먼트에 설정된 기존 함수를 대체합니다.

따라서…

  • 내장 컴포넌트에 전달하는 이벤트 핸들러는 useCallback으로 메모화할 필요가 없습니다.
// 내장 컴포넌트: useCallback 불필요
function Counter() {
const [count, setCount] = useState(0);

// useCallback으로 감쌀 필요 없음
const handleClick = () => {
setCount(count + 1);
};

return <button onClick={handleClick}>증가</button>;
}
  • 사용자 정의 컴포넌트에 함수를 전달할 때는 useCallback을 사용하면 좋습니다.
// 사용자 정의 컴포넌트: useCallback 필요할 수 있음
function Parent() {
const [count, setCount] = useState(0);

// CustomButton이 React.memo로 감싸져 있다면 useCallback 사용이 좋음
const handleClick = useCallback(() => {
setCount(count + 1);
}, [count]);

return <CustomButton onClick={handleClick}>증가</CustomButton>;
}

2-7. useCallback 올바르게 사용하기

위 예시와 더불어, useCallback을 올바르게 사용하는 법도 알아봅시다.

useCallback은 함수를 메모이제이션하여 리렌더링 시 함수의 참조 안정성을 유지합니다.

// 좋은 사용 예시
const handleSubmit = useCallback(() => {
// 복잡한 로직
}, [dependencies]);

그러나 모든 함수에 useCallback을 사용하는 것은 불필요한 오버헤드를 발생시킬 수 있으므로, 다음과 같은 경우에만 사용하는 것이 좋습니다:

  1. 메모화된 사용자 정의 컴포넌트에 함수를 props로 전달할 때
  2. 함수가 의존성 배열에 포함되어 있을 때(예: useEffect 내부)
  3. 함수가 복잡하고 생성 비용이 높을 때

3. Context API 최적화하기

Context API를 사용할 때 주의해야 할 점이 있습니다. Provider의 value가 변경되면 해당 Context를 사용하는 모든 컴포넌트가 리렌더링됩니다.

Provider에 대해 다룬 글에 좋은 예시가 나와있어 인용해봅니다.

// 좋지 않은 예시
const NotificationContext = React.createContext();

const NotificationProvider = ({ children }) => {
const [notifications, setNotifications] = useState([]);

// 빈번한 리렌더링
const addNotification = (notification) => {
setNotifications(prev => [...prev, notification]);
};

return (
<NotificationContext.Provider value={{ notifications, addNotification }}>
{children}
</NotificationContext.Provider>

);
};

위 코드에서는 리렌더링될 때마다 새로운 객체가 생성되어 모든 Consumer 컴포넌트가 불필요하게 리렌더링됩니다.

// 개선된 예시
const NotificationProvider = ({ children }) => {
const [notifications, setNotifications] = useState([]);

const addNotification = useCallback((notification) => {
setNotifications(prev => [...prev, notification]);
}, []);

const value = useMemo(() => ({
notifications,
addNotification
}), [notifications, addNotification]);

return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>

);
};

useMemo를 사용하여 value 객체의 참조를 안정적으로 유지하면 불필요한 리렌더링을 방지할 수 있습니다.

또, 리액트 커뮤니티에 나온 얘기에 따르면 Context를 전역에 감싸지 않고 필요한 부분에 사용하는 것이 좋다고 얘기합니다. 아무래도 리렌더링 이슈가 많으니까요. 저는 그래서 사실 zustand, jotai 등 조금 더 마이크로한 상태관리 라이브러리를 쓰는 걸 선호합니다.

Provider 사용 시 좋은 패턴

추가로, 가독성을 위해 아래와 같이 Provider를 합쳐서 쓰는 방법도 있어서 참고하면 좋을 것 같습니다.

const AppProviders = combineComponents(
AuthProvider,
ThemeProvider,
NotificationProvider
);

const App = () => (
<AppProviders>
{/* Your app components */}
</AppProviders>

);

전역 상태관리 라이브러리를 사용할 때 주의할 점

사실 모든 최적화 방법들을 다 적용한 것보다 가장 효율적이었던 방법은, 전역 상태관리 라이브러리에 일어났던 빈번한 업데이트를 제거한 것이었습니다. 전역 상태관리에 업데이트가 일어나면 리렌더링이 발생하는데, 이것 때문에 큰 성능 저하가 발생했었습니다.

전역 상태관리 라이브러리가 편하긴 하지만.. 꼭 로컬 상태와 전역 상태를 잘 구분해서, 정말 필요한 경우에만 전역 상태로 관리될 수 있도록 주의해야 합니다.

4. 지연 로딩으로 초기 로딩 최적화하기

React의 lazySuspense를 활용하면 초기 로딩 시 필요한 코드만 다운로드하고, 나머지는 필요할 때 로드할 수 있습니다.

const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {
return (
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>

);
}

lazy

읽어들인 모듈의 프라미스를 반환하는 함수를 lazy 함수에 전달합니다.
lazy는 우리가 import하는 모듈이 default export라고 전제하기 때문에, named export를 할 경우에는 다른 방식으로 import해야 합니다.

lazy는 써드 파티를 import할 때나, 라우팅을 할 때 특히 유용합니다. (물론 제가 CSR을 쓰고 있어서…….)

Suspense

  • 트리에서 대상이 되는 컴포넌트를 감싸서 사용합니다.
  • 전체 컴포넌트를 Suspense로 감싸고 모든 비동기 자식의 promise가 해결될 때까지 폴백을 렌더링합니다.
  • Suspense가 가능한 데이터만이 Suspense 컴포넌트를 활성화합니다. 아래와 같은 것들이 해당합니다.
    - RelayNext.js 같이 Suspense가 가능한 프레임워크를 사용한 데이터 가져오기
    - lazy를 활용한 지연 로딩 컴포넌트
    - use를 사용해서 캐시된 Promise 값 읽기
  • Suspense는 Effect 또는 이벤트 핸들러 내부에서 가져오는 데이터를 감지하지 않습니다.

5. React Compiler

React 컴파일러는 객체 동일성 비교를 최적화하여 useMemouseCallback의 필요성이 앞으로는 많이 없어질 것으로 보입니다. (사실 뷰는 원래 제공해주었던 부분인데요, 리액트에도 들어온다 하니 기쁘네요…)

리액트 컴파일러에 대한 FE article 팀의 좋은 번역이 많으니 추천드립니다.

리액트 컴파일러 이해하기

오늘 리액트 컴파일러를 사용해 봤는데, 어땠을 것 같나요? 😉

이외에도 많습니다.

6. 자동화된 성능 최적화 도구 활용하기

최근에는 React 애플리케이션의 성능 최적화를 자동화하고 도와주는 도구들이 등장했는데, 개인적으로는 정말 큰 도움이 되었어서 소개합니다.

6–1. React-Scan

React-Scan은 React 애플리케이션에서 성능 문제를 정적으로 분석하는 도구입니다. 코드를 스캔하여 다음과 같은 문제를 감지합니다. AI로 문제가 되는 부분도 수정해주니 유용합니다.

React Devtools와의 비교

저자의 말을 인용해보자면..

React Devtools는 React를 위한 범용적인 도구를 목표로 하지만, 저는 매일 React의 성능 문제를 다루고 있으며 React Devtools는 제 문제를 잘 해결해주지 못합니다. 불필요한 렌더와 필요한 렌더를 명확하게 구분하기 어렵고, 프로그래밍 방식으로 접근할 수 있는 API도 없습니다. 만약 여러분도 같은 문제를 겪고 있다면, React Scan이 더 나은 선택이 될 수 있습니다.

또한, 개인적으로 React Devtools의 하이라이트 기능에 대해 불만이 있습니다.

  • React Devtools는 렌더링을 “배치 처리(batch)”하므로, 컴포넌트가 너무 빠르게 렌더링되면 1초에 한 번씩만 표시되어 지연됩니다.
  • 스크롤하거나 창 크기를 조절하면 박스의 위치가 업데이트되지 않습니다.
  • 몇 번 렌더링이 발생했는지 카운트할 수 없습니다.
  • 어떤 렌더링이 불필요하거나 느린지 직접 확인해보기 전에는 알 수 없습니다.
  • 메뉴가 숨겨져 있어서 켜고 끄는 것이 번거롭습니다. 성능 디버깅을 위해 UX가 최적화되어야 하는데, 프로파일러나 컴포넌트 트리 뒤에 숨겨져 있습니다.
  • 프로그래밍 방식으로 접근할 수 있는 API가 없습니다.
  • 크롬 확장 프로그램에 종속되어 있어서, 저는 웹 어디서나 실행할 수 있기를 원합니다.
  • 주관적인 의견이지만, UI가 보기 안 좋고(선이 흐릿하게 보이고), 전체적으로 느리게 느껴집니다.

라고 합니다.

React-Scan 사용 예시

# React-Scan 설치
pnpm add react-scan
or
yarn add react-scan

# 또는 CDN
<!-- import this BEFORE any scripts -->
<script src="https://unpkg.com/react-scan/dist/auto.global.js"></script>

6–2. Million.js

Million.js는 React 애플리케이션을 최적화하기 위한 컴파일러 도구입니다. React의 가상 DOM 위에서 작동하며, 리렌더링 최적화를 자동으로 처리합니다.

Million.js Lint

Million.js는 Lint 기능을 제공하여 코드에서 불필요한 리렌더링 패턴을 자동으로 감지하고 최적화 방법을 제안합니다.

# Million.js Lint 설치
pnpx million@latest
// Vite의 경우
import million from "million/compiler";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
plugins: [million.vite({ auto: true }), react()],
});



// 또는..

import million from "million/compiler";

/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};

const millionConfig = {
auto: true,// if you're using RSC: auto: { rsc: true },
};

export default million.next(nextConfig, millionConfig);

개발할 때도 위와 같이 extension을 통해 어떤 점이 문제가 되는지 확인할 수 있습니다.

7. 결론

  1. Performance 탭과 React DevTools를 활용하여 성능 병목 파악하기
  2. 컴포넌트 구조와 책임을 적절히 분리하기
  3. React.memo, useMemo, useCallback을 필요한 곳에만 적용하기
  4. Context API 사용 시 Provider의 value 안정화하기, 전역 상태 라이브러리 사용 시 리렌더링 주의하기
  5. 지연 로딩으로 초기 로딩 시간 단축하기
  6. Million.js와 React-Scan 같은 도구로 자동화된 성능 최적화 활용하기

먼저 어떤 부분에서 병목이 있는지 파악한 후, 그 지점을 중심으로 최적화하는 게 효율적입니다. 무조건적으로 최적화 기법들(ex. useMemo 등)을 적용하지 않고, 필요한지 아닌지 잘 판단해서 적용해야 합니다. 🤓

사실 더 효율적인 방법들이 시장에는 많은데요. 예를 들어 스트리밍 SSR 등이 있을 것 같습니다. 하지만 이런 방법들을 모든 팀, 모든 상황에서 도입할 수 있는 것은 아니여서 최대한 현재 상황에서 최선으로 해낼 수 있는 방법들을 잘 모색하면서 살고 있는 것 같습니다. 🥸

Sign up to discover human stories that deepen your understanding of the world.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Ricki
Ricki

Written by Ricki

Life is tons of discipline.

No responses yet

Write a response