할 일 관리 앱을 만들었는데, 항목이 50개만 넘어가도 검색할 때마다 버벅거리기 시작하더라고요. 처음엔 “내 컴퓨터가 느린가?” 싶었는데, 알고 보니 제가 React를 제대로 이해하지 못하고 있었던 거죠.

저만 그랬을까요? React로 개발하다 보면 누구나 한 번쯤은 겪는 문제입니다. 분명 간단한 기능인데 왜 이렇게 느린지, 콘솔에 로그 찍어보면 함수가 미친 듯이 돌아가고 있고… 그때 알았어요. “최적화”라는 게 단순히 코드를 줄이는 게 아니라는 걸요.

React 성능 최적화를 상징하는 3D 아이콘 - React 로고와 상승하는 성능 그래프, 빠른 속도를 나타내는 속도계가 함께 배치된 이미지

React 최적화로 앱 성능을 극대화하세요

성능 문제의 진짜 원인: 불필요한 연산 낭비

React는 기본적으로 엄청 빠릅니다. 근데 우리가 실수하면 그 장점이 다 무너져요. 가장 흔한 실수가 뭘까요?

똑같은 계산을 계속 반복하는 거예요.

예를 들어볼게요. 카페 알바하던 시절 생각이 나는데요, 손님이 메뉴판 보면서 “아메리카노 가격이 얼마였죠?”라고 물어보면, 저는 매번 메뉴판을 펼쳐서 확인했어요. 하루에 100번도 넘게요. 완전 비효율적이죠?

근데 어느 날 선배가 그러더라고요. “자주 묻는 건 그냥 외워. 훨씬 빨라.” 그 순간 깨달았죠. 이게 바로 메모이제이션(Memoization)이라는 거였어요.

graph LR
    A[손님 질문] --> B{메뉴 외웠나?}
    B -->|아니오| C[메뉴판 확인]
    B -->|예| D[바로 답변]
    C --> E[시간 소요]
    D --> F[빠른 응대]
    
    style D fill:#90EE90
    style F fill:#90EE90
    style C fill:#FFB6C6
    style E fill:#FFB6C6

프로그래밍에서도 똑같아요. 이미 계산한 결과를 “외워두면” 다시 계산할 필요가 없잖아요?

useMemo: 계산 결과를 기억하는 마법

자, 이제 본격적으로 들어가볼게요. 제가 만든 할 일 앱에 통계 기능을 추가했어요. 전체 할 일 개수, 완료된 개수, 남은 개수를 보여주는 거죠.

코드는 이렇게 생겼어요:

function TodoList() {
  const [todo, setTodo] = useState([]);
  const [search, setSearch] = useState("");
  
  const analyzeTodo = () => {
    console.log("analyzeTodo 함수 호출!");
    const totalCount = todo.length;
    const doneCount = todo.filter(item => item.isDone).length;
    const notDoneCount = totalCount - doneCount;
    return { totalCount, doneCount, notDoneCount };
  };
  
  const stats = analyzeTodo();
  // ... 화면에 통계 표시
}

딱 봐도 문제없어 보이죠? 저도 그렇게 생각했어요. 근데 검색창에 글자 하나 입력할 때마다 콘솔에 “analyzeTodo 함수 호출!” 로그가 미친 듯이 찍히는 거예요.

메모이제이션 개념을 표현한 3D 아이콘 - 메모장과 반복되는 계산을 나타내는 순환 화살표가 그려진 이미지

같은 계산은 한 번만! 메모이제이션의 핵심 원리

왜 이런 일이 벌어질까요?

React의 동작 원리를 이해해야 해요. 검색어(search)를 바꾸면 컴포넌트가 리렌더링됩니다. 리렌더링되면 컴포넌트 함수가 처음부터 다시 실행되는 거죠. 그러면 analyzeTodo 함수도 당연히 다시 호출돼요.

근데 생각해보세요. 검색어만 바꿨는데 할 일 목록(todo)은 바뀐 게 없잖아요? 통계를 다시 계산할 필요가 전혀 없는데 말이죠!

이게 바로 불필요한 연산 낭비입니다.

useMemo로 해결하기

useMemo는 이런 문제를 해결해줍니다. 사용법은 간단해요:

const stats = useMemo(() => {
  console.log("analyzeTodo 함수 호출!");
  const totalCount = todo.length;
  const doneCount = todo.filter(item => item.isDone).length;
  const notDoneCount = totalCount - doneCount;
  return { totalCount, doneCount, notDoneCount };
}, [todo]); // 의존성 배열: todo가 바뀔 때만 다시 계산

이렇게 바꾸니까 어떻게 됐을까요?

  1. 처음 렌더링: 통계를 계산하고 결과를 기억해둡니다
  2. 검색어 입력 (리렌더링): “어? todo는 안 바뀌었네?” → 기억해둔 결과 그대로 반환
  3. 할 일 추가 (리렌더링): “오! todo가 바뀌었네!” → 다시 계산하고 새 결과 저장
graph TD
    A[컴포넌트 렌더링] --> B{useMemo 만남}
    B --> C{의존성 배열 체크}
    C -->|todo 변경됨| D[함수 실행]
    C -->|todo 변경 안됨| E[저장된 값 반환]
    D --> F[결과 저장]
    F --> G[값 반환]
    E --> G
    
    style D fill:#FFE4B5
    style E fill:#90EE90
    style G fill:#87CEEB

진짜 신기하게도, 검색할 때는 더 이상 “analyzeTodo 함수 호출!” 로그가 안 찍혀요. 완벽하죠?

핵심 포인트는 두 번째 인수인 의존성 배열이에요. 여기에 todo를 넣어줬기 때문에 “todo가 바뀔 때만 다시 계산해”라고 React에게 알려주는 거죠.

React.memo: 컴포넌트 자체를 기억하기

useMemo로 계산 낭비는 막았어요. 근데 아직 문제가 하나 더 있어요. 바로 불필요한 리렌더링이에요.

제 앱 구조는 이랬어요:

  • App (최상위)
    • Header (날짜만 표시)
    • TodoList (할 일 목록)
      • TodoItem (개별 할 일)

여기서 할 일을 하나 추가하면 어떻게 될까요? 당연히 App이 리렌더링되겠죠. 그럼 자식인 Header랑 모든 TodoItem도 다 리렌더링돼요.

useMemo의 동작 원리를 나타내는 3D 아이콘 - 계산된 값을 저장하는 캐시 박스 이미지

useMemo는 계산 결과를 안전하게 보관합니다

근데 말이 안 되잖아요?

Header는 날짜만 보여주는데, 할 일 추가랑 무슨 상관이에요? 그리고 할 일을 하나 추가했는데 기존의 할 일들까지 전부 다시 그려진다는 게 말이 돼요?

할 일이 100개면 1개 추가할 때마다 100개 전부 리렌더링… 생각만 해도 끔찍하죠.

React.memo로 막아보자

React.memo컴포넌트를 메모이제이션합니다. 쉽게 말해서 “Props가 안 바뀌었으면 리렌더링하지 마”라고 말해주는 거예요.

// Header.js
function Header() {
  console.log("Header 렌더링!");
  return <div>{new Date().toDateString()}</div>;
}

export default React.memo(Header);

이렇게 React.memo로 감싸주니까, App이 리렌더링되어도 Header는 꿈쩍도 안 해요. Props를 아예 안 받으니까요!

TodoItem도 똑같이 해봤어요:
// TodoItem.js
function TodoItem({ id, content, isDone, onUpdate, onDelete }) {
  console.log(`${id}번 아이템 렌더링!`);
  // ... 렌더링 로직
}

export default React.memo(TodoItem);

근데 이상한 일이 벌어졌어요. React.memo를 적용했는데도 여전히 모든 아이템이 리렌더링되는 거예요!

뭐가 문제였을까요?

디버깅을 해보니까 범인은 바로 함수(Props)였어요. TodoItemonUpdate, onDelete 같은 함수들을 Props로 받아요.

JavaScript에서 함수는 객체예요. 그리고 App이 리렌더링될 때마다 이 함수들이 새로 생성돼요. 똑같은 로직이어도 메모리 주소가 달라지는 거죠.

// App이 리렌더링될 때마다...
const onUpdate = (id) => { ... }  // 새로운 함수 (새 주소값)
const onDelete = (id) => { ... }  // 새로운 함수 (새 주소값)

React.memo는 Props를 얕은 비교(Shallow Comparison)로 체크해요. 객체나 함수는 참조값(주소)으로 비교하는데, 매번 주소가 바뀌니까 “어? Props 바뀌었네!”라고 판단하는 거죠.

React.memo의 최적화 기능을 표현한 3D 아이콘 - 체크마크가 있는 보호된 컴포넌트 박스 이미지

React.memo가 불필요한 리렌더링을 차단합니다

완전 억울하죠? 내용은 똑같은데 주소만 다르다고 다시 렌더링하다니…

useCallback: 함수를 기억하는 마지막 퍼즐

바로 이 문제를 해결하는 게 useCallback입니다. useMemo을 기억했다면, useCallback함수를 기억해요.

// App.js
function App() {
  const [todo, setTodo] = useState([]);
  
  // 기존 방식 (매번 새 함수 생성)
  const onUpdate = (targetId) => {
    setTodo(todo.map(item => 
      item.id === targetId ? {...item, isDone: !item.isDone} : item
    ));
  };
  
  // useCallback 적용 (함수 재사용)
  const onUpdate = useCallback((targetId) => {
    setTodo(todo.map(item => 
      item.id === targetId ? {...item, isDone: !item.isDone} : item
    ));
  }, [todo]); // todo가 바뀔 때만 함수 재생성
}

근데 여기서 함정이 하나 있어요. 의존성 배열에 todo를 넣으면, 결국 할 일이 바뀔 때마다 함수가 새로 만들어지잖아요? 그럼 최적화가 무슨 의미가 있겠어요?

함수형 업데이트를 쓰면 해결돼요:

const onUpdate = useCallback((targetId) => {
  setTodo(prevTodo => prevTodo.map(item => 
    item.id === targetId ? {...item, isDone: !item.isDone} : item
  ));
}, []); // 의존성 배열 비움!

prevTodo => ... 이렇게 쓰면 항상 최신 state를 받아올 수 있어요. 그래서 의존성 배열을 비워도 안전하죠.

graph TB
    A[App 리렌더링] --> B[useCallback 확인]
    B --> C{의존성 배열 체크}
    C -->|비어있음| D[기존 함수 재사용]
    C -->|변경됨| E[새 함수 생성]
    D --> F[TodoItem에 전달]
    E --> F
    F --> G{React.memo 체크}
    G -->|함수 주소 동일| H[리렌더링 건너뛰기]
    G -->|함수 주소 다름| I[리렌더링]
    
    style D fill:#90EE90
    style H fill:#90EE90
    style I fill:#FFB6C6

이제 진짜 완벽해요:

  1. useCallback이 함수를 재사용 → 주소값 유지
  2. React.memo가 “Props 안 바뀌었네!” 확인
  3. 불필요한 리렌더링 차단 ✅

useCallback의 함수 재사용을 나타내는 3D 아이콘 - 재활용 화살표로 둘러싸인 함수 기호 이미지

useCallback으로 함수를 효율적으로 재사용하세요

할 일을 하나 추가해도 그 아이템만 렌더링되고, 나머지는 가만히 있어요. 개발자 도구 콘솔 보면 진짜 기분 좋아요.

실전 최적화 꿀팁

제가 삽질하면서 깨달은 것들 공유할게요.

1. 최적화는 맨 마지막에 하세요

처음부터 useMemo, React.memo 남발하면 나중에 코드 고치기 지옥이에요. 기능 다 만들고 나서 “어? 여기 느린데?” 싶은 곳만 최적화하세요.

2. 진짜 문제가 있을 때만 쓰세요

버튼 하나 있는 컴포넌트까지 React.memo 씌우면… 그냥 코드만 복잡해져요. 리스트 아이템처럼 반복되는 컴포넌트무거운 연산이 있을 때만 쓰는 게 좋아요.

3. React DevTools Profiler 활용하기

추측은 금물이에요. React DevTools의 Profiler로 실제로 어디가 느린지 측정하세요. 의외로 엉뚱한 곳이 문제일 때가 많아요.

4. 컴포넌트 구조부터 점검하세요

하나의 컴포넌트에 state가 10개씩 있다면? 최적화로 때우지 말고 컴포넌트를 쪼개세요. 구조가 엉망이면 아무리 최적화해도 한계가 있어요.

마무리하며

처음 React 배울 때는 “일단 돌아가게만 만들자”였는데, 이제는 “어떻게 하면 효율적으로 만들까?”를 고민하게 되더라고요.

최적화라는 게 처음엔 어렵게 느껴지지만, 결국 핵심은 간단해요:

  • useMemo: 계산 결과 기억하기
  • React.memo: 컴포넌트 리렌더링 막기
  • useCallback: 함수 재생성 막기

이 세 가지만 제대로 이해하면 웬만한 성능 문제는 다 해결돼요.

여러분도 한번 자신의 프로젝트에서 콘솔 로그 찍어가며 테스트해보세요. “와, 이렇게 많이 렌더링되고 있었구나!” 깨닫는 순간이 올 거예요. 그때가 진짜 실력이 늘어나는 타이밍이에요.

다음 프로젝트 때는 처음부터 성능 생각하면서 만들어보세요. 분명 달라진 자신을 발견할 수 있을 거예요! 🚀



참고 자료: 이 글은 ‘한입 크기로 잘라 먹는 리액트’를 바탕으로 작성되었습니다.