React useMemo와 useCallback, 언제 쓰면 이득인가 | 렌더링 최적화 실전

React useMemo와 useCallback, 언제 쓰면 이득인가 | 렌더링 최적화 실전

이 글의 핵심

React useMemo useCallback 언제—참조 안정화·비싼 계산 캐시에 쓰고, Profiler로 증명하기 전엔 남발하지 않는 기준을 정리했습니다.

들어가며

React 18/19에서 함수 컴포넌트는 props·state·부모 리렌더에 따라 자주 다시 실행됩니다. useMemouseCallback이전 결과를 재사용해 불필요한 연산·참조 변경을 줄이는 도구입니다. React useMemo·useCallback을 언제 쓸지는 “비싼 계산인가, 참조 안정이 필요한가”로 먼저 갈라집니다. 다만 둘 자체도 비용이 있어 남발하면 오히려 느려질 수 있습니다.

이 글은 언제 메모이제이션이 이득인지를 판별하는 기준과, 프로파일러로 검증하는 순서를 담았습니다. 비동기 흐름은 async 가이드, 디버깅은 비동기 디버깅 사례와 연결됩니다.

useMemo·useCallback이 존재하나요?

함수 컴포넌트는 매 렌더마다 새로 실행되므로, 안에서 만든 객체·함수 참조도 기본적으로 새로 생깁니다. 그 참조가 React.memo 자식, useEffect 의존성, 외부 훅의 안정성에 영향을 주기 때문에, 필요할 때만 이전 참조를 재사용하려는 도구가 useMemouseCallback입니다.

프로덕션에서 주의할 점

  • 측정 없이 전 레이어에 적용하면 메모리·비교 비용만 늘고 체감 개선이 없을 수 있습니다. React DevTools Profiler로 먼저 병목을 확인하는 것을 권장합니다.
  • 의존성 배열을 잘못 맞추면 “메모했는데도 갱신이 이상하다” 또는 “안 바뀌어야 하는데 바뀐다”가 납니다. **ESLint exhaustive-deps**와 팀 규칙을 함께 씁니다.
  • 서버 컴포넌트로 데이터를 끌어올 수 있는 부분은 클라이언트 메모이제이션 부담 자체가 줄어듭니다. 경계 설계를 먼저 검토합니다.

목차

  1. 개념 설명
  2. 실전 구현 (단계별 코드)
  3. 고급 활용
  4. 성능·비교
  5. 실무 사례
  6. 트러블슈팅
  7. 마무리

개념 설명

리렌더링 기본 원리

  • 리렌더: 부모가 렌더되면 기본적으로 자식도 다시 렌더됩니다(조건 없다면).
  • 참조 동일성: 객체·함수·배열 리터럴은 렌더마다 새 참조가 됩니다. React.memo로 감싼 자식이나 useEffect의 의존성 배열에서 불필요한 변화로 이어질 수 있습니다.

useMemouseCallback

  • useMemo: 값(객체·배열·계산 결과)을 의존성이 바뀔 때만 다시 계산합니다.
  • useCallback: 함수 참조를 의존성이 바뀔 때만 유지합니다. 사실상 useMemo(() => fn, deps)의 문법 설탕입니다.

언제 사용하나?

useMemo 사용 시기:

  1. 비용이 큰 계산: 배열 필터링, 정렬, 복잡한 연산
  2. 참조 안정화: 객체나 배열을 props로 전달할 때
  3. 의존성 배열: useEffect의 의존성으로 사용될 때

useCallback 사용 시기:

  1. React.memo 자식: 메모이제이션된 자식에게 콜백 전달
  2. 의존성 배열: useEffect의 의존성으로 사용될 때
  3. 외부 훅: 참조 안정성이 필요한 외부 라이브러리

언제 사용하지 말아야 하나?

  • 가벼운 연산: 단순 계산은 메모이제이션 비용이 더 클 수 있음
  • 측정 없이: Profiler로 병목을 확인하기 전
  • 모든 함수: 과도한 메모이제이션은 코드 복잡도만 증가

실전 구현 (단계별 코드)

1) 비용 큰 리스트 필터링 — useMemo

문제: 매 렌더마다 필터링 재실행

import { useState } from "react";

type Item = { id: string; label: string; score: number };

export function LeaderboardBad({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");

  const q = query.trim().toLowerCase();
  const visible = !q
    ? items
    : items.filter((it) => it.label.toLowerCase().includes(q));

  return (
    <section>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {visible.map((it) => (
          <li key={it.id}>
            {it.label}{it.score}
          </li>
        ))}
      </ul>
    </section>
  );
}

문제점:

  • 부모가 리렌더될 때마다 필터링 재실행
  • items가 수천 건이면 성능 저하

해결: useMemo로 캐싱

import { useMemo, useState } from "react";

type Item = { id: string; label: string; score: number };

export function Leaderboard({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");

  const visible = useMemo(() => {
    console.log("필터링 실행");
    const q = query.trim().toLowerCase();
    if (!q) return items;
    return items.filter((it) => it.label.toLowerCase().includes(q));
  }, [items, query]);

  return (
    <section>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {visible.map((it) => (
          <li key={it.id}>
            {it.label}{it.score}
          </li>
        ))}
      </ul>
    </section>
  );
}

효과:

  • itemsquery가 변경될 때만 필터링 실행
  • 부모 리렌더 시 캐시된 결과 재사용

2) 자식 콜백 안정화 — useCallback + memo

문제: 매 렌더마다 새 함수 생성

import { memo, useState } from "react";

const Row = memo(function Row({
  id,
  onSelect,
}: {
  id: string;
  onSelect: (id: string) => void;
}) {
  console.log(`Row ${id} 렌더`);
  return <button onClick={() => onSelect(id)}>{id}</button>;
});

export function TableBad({ ids }: { ids: string[] }) {
  const [active, setActive] = useState<string | null>(null);

  const handleSelect = (id: string) => {
    setActive(id);
  };

  return (
    <div>
      <p>active: {active}</p>
      {ids.map((id) => (
        <Row key={id} id={id} onSelect={handleSelect} />
      ))}
    </div>
  );
}

문제점:

  • handleSelect가 매 렌더마다 새로 생성
  • Rowmemo가 무력화됨
  • 모든 Row가 매번 리렌더

해결: useCallback으로 안정화

import { memo, useCallback, useState } from "react";

const Row = memo(function Row({
  id,
  onSelect,
}: {
  id: string;
  onSelect: (id: string) => void;
}) {
  console.log(`Row ${id} 렌더`);
  return <button onClick={() => onSelect(id)}>{id}</button>;
});

export function Table({ ids }: { ids: string[] }) {
  const [active, setActive] = useState<string | null>(null);

  const handleSelect = useCallback((id: string) => {
    setActive(id);
  }, []);

  return (
    <div>
      <p>active: {active}</p>
      {ids.map((id) => (
        <Row key={id} id={id} onSelect={handleSelect} />
      ))}
    </div>
  );
}

효과:

  • handleSelect 참조가 안정적
  • Rowmemo가 제대로 작동
  • 클릭한 Row만 리렌더

3) 객체 props 안정화 — useMemo

문제: 매 렌더마다 새 객체 생성

import { memo } from "react";

const Chart = memo(function Chart({ config }: { config: { theme: string; width: number } }) {
  console.log("Chart 렌더");
  return <div style={{ width: config.width }}>차트 ({config.theme})</div>;
});

export function DashboardBad() {
  const [count, setCount] = useState(0);

  const config = { theme: "dark", width: 600 };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>카운트: {count}</button>
      <Chart config={config} />
    </div>
  );
}

문제점:

  • config 객체가 매 렌더마다 새로 생성
  • Chartmemo가 무력화됨

해결: useMemo로 안정화

import { memo, useMemo, useState } from "react";

const Chart = memo(function Chart({ config }: { config: { theme: string; width: number } }) {
  console.log("Chart 렌더");
  return <div style={{ width: config.width }}>차트 ({config.theme})</div>;
});

export function Dashboard() {
  const [count, setCount] = useState(0);

  const config = useMemo(() => ({ theme: "dark", width: 600 }), []);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>카운트: {count}</button>
      <Chart config={config} />
    </div>
  );
}

효과:

  • config 참조가 안정적
  • Chartcount 변경 시 리렌더되지 않음

4) Context 값 분리 — Provider 안에서 객체 쪼개기

문제: Context 값이 매 렌더마다 새로 생성

import { createContext, useContext, useState } from "react";

const ThemeContext = createContext<{ theme: string; setTheme: (t: string) => void } | null>(null);

export function AppBad() {
  const [theme, setTheme] = useState("light");

  const value = { theme, setTheme };

  return (
    <ThemeContext.Provider value={value}>
      <Header />
      <Content />
    </ThemeContext.Provider>
  );
}

문제점:

  • value 객체가 매 렌더마다 새로 생성
  • 모든 구독자가 리렌더

해결: useMemo로 안정화

import { createContext, useContext, useMemo, useState } from "react";

const ThemeContext = createContext<{ theme: string; setTheme: (t: string) => void } | null>(null);

export function App() {
  const [theme, setTheme] = useState("light");

  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      <Header />
      <Content />
    </ThemeContext.Provider>
  );
}

효과:

  • theme이 변경될 때만 새 value 생성
  • 불필요한 리렌더 방지

5) useEffect 의존성 안정화

문제: 함수가 의존성에 포함되어 무한 루프

import { useEffect, useState } from "react";

export function DataFetcherBad() {
  const [data, setData] = useState(null);

  const fetchData = async () => {
    const res = await fetch("/api/data");
    const json = await res.json();
    setData(json);
  };

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return <div>{JSON.stringify(data)}</div>;
}

문제점:

  • fetchData가 매 렌더마다 새로 생성
  • useEffect가 매번 실행 (무한 루프 가능)

해결: useCallback으로 안정화

import { useCallback, useEffect, useState } from "react";

export function DataFetcher() {
  const [data, setData] = useState(null);

  const fetchData = useCallback(async () => {
    const res = await fetch("/api/data");
    const json = await res.json();
    setData(json);
  }, []);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return <div>{JSON.stringify(data)}</div>;
}

효과:

  • fetchData 참조가 안정적
  • useEffect가 마운트 시 한 번만 실행

고급 활용: 자식 메모·커스텀 비교

memo의 커스텀 비교 함수

기본 memo (얕은 비교)

import { memo } from "react";

const User = memo(function User({ user }: { user: { id: string; name: string } }) {
  console.log("User 렌더");
  return <div>{user.name}</div>;
});

커스텀 비교 함수

import { memo } from "react";

const User = memo(
  function User({ user }: { user: { id: string; name: string; updatedAt: number } }) {
    console.log("User 렌더");
    return <div>{user.name}</div>;
  },
  (prevProps, nextProps) => {
    return prevProps.user.id === nextProps.user.id;
  }
);

주의사항:

  • 커스텀 비교는 복잡도 증가
  • 디버깅이 어려워질 수 있음
  • 측정 후 필요할 때만 사용

React Compiler (실험적)

자동 메모이제이션

// React Compiler가 활성화되면 자동으로 최적화
export function AutoOptimized({ items }: { items: Item[] }) {
  const filtered = items.filter((it) => it.score > 50);

  return (
    <ul>
      {filtered.map((it) => (
        <li key={it.id}>{it.label}</li>
      ))}
    </ul>
  );
}

현재 상태:

  • React 19에서 실험적 기능
  • 프로덕션에서는 수동 메모이제이션 권장

Server Components와의 조합

서버 컴포넌트에서 데이터 가져오기

// app/page.tsx (Server Component)
async function getData() {
  const res = await fetch("https://api.example.com/data");
  return res.json();
}

export default async function Page() {
  const data = await getData();
  return <ClientComponent data={data} />;
}

클라이언트 컴포넌트에서 메모이제이션

// components/ClientComponent.tsx
"use client";

import { useMemo } from "react";

export function ClientComponent({ data }: { data: Item[] }) {
  const sorted = useMemo(() => {
    return data.sort((a, b) => b.score - a.score);
  }, [data]);

  return (
    <ul>
      {sorted.map((it) => (
        <li key={it.id}>{it.label}</li>
      ))}
    </ul>
  );
}

효과:

  • 서버에서 데이터 가져오기 (클라이언트 부담 감소)
  • 클라이언트에서 필요한 부분만 메모이제이션

성능·비교: 이득이 나는 조건

비교표

상황useMemouseCallback
무거운 순수 계산후보해당 없음
안정 참조가 필요한 객체/배열 props후보객체를 만들 때 함수도 함께 고정하려면 둘 다
memo 자식에 넘기는 핸들러보통 직접 이득 적음자식 메모와 세트일 때 의미
이미 가벼운 연산오버헤드만 증가 가능동일

성능 벤치마크

테스트 환경: 10,000개 아이템, 검색 쿼리 변경

useMemo 없이

const visible = items.filter((it) => it.label.includes(query));
  • 렌더 시간: ~50ms
  • 매 렌더마다 필터링 실행

useMemo 사용

const visible = useMemo(
  () => items.filter((it) => it.label.includes(query)),
  [items, query]
);
  • 렌더 시간: ~5ms (캐시 히트 시)
  • query 변경 시에만 필터링 실행

측정 방법: React DevTools Profiler

  1. Profiler 탭 열기
  2. 녹화 시작
  3. 상호작용 (입력, 클릭 등)
  4. 녹화 중지
  5. 커밋 시간 비교

메모이제이션 비용

useMemo 오버헤드:

  • 의존성 배열 비교: ~0.1ms
  • 메모리 사용: 이전 결과 저장

언제 손해인가?:

  • 가벼운 연산 (< 1ms)
  • 의존성이 매번 변경
  • 메모리 제약이 큰 환경

실무 사례

사례 1: 차트 라이브러리 최적화

문제: 차트 데이터 가공이 무거움

import { useMemo } from "react";
import { LineChart } from "recharts";

export function SalesChart({ sales }: { sales: Sale[] }) {
  const chartData = useMemo(() => {
    return sales.map((sale) => ({
      date: sale.date,
      revenue: sale.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    }));
  }, [sales]);

  return <LineChart data={chartData} />;
}

효과:

  • sales가 변경될 때만 데이터 가공
  • 차트 리렌더 성능 개선

사례 2: 가상 스크롤 최적화

문제: 스크롤 시 모든 아이템 리렌더

import { memo, useCallback, useMemo, useState } from "react";

const VirtualRow = memo(function VirtualRow({
  index,
  item,
  onClick,
}: {
  index: number;
  item: Item;
  onClick: (id: string) => void;
}) {
  return (
    <div onClick={() => onClick(item.id)}>
      {index}: {item.label}
    </div>
  );
});

export function VirtualList({ items }: { items: Item[] }) {
  const [selected, setSelected] = useState<string | null>(null);
  const [scrollTop, setScrollTop] = useState(0);

  const visibleItems = useMemo(() => {
    const startIndex = Math.floor(scrollTop / 50);
    return items.slice(startIndex, startIndex + 20);
  }, [items, scrollTop]);

  const handleClick = useCallback((id: string) => {
    setSelected(id);
  }, []);

  return (
    <div onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
      {visibleItems.map((item, index) => (
        <VirtualRow key={item.id} index={index} item={item} onClick={handleClick} />
      ))}
    </div>
  );
}

효과:

  • 보이는 아이템만 렌더링
  • 스크롤 성능 대폭 개선

사례 3: 폼 검증 최적화

문제: 입력마다 전체 폼 검증 실행

import { useMemo, useState } from "react";

export function SignupForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const errors = useMemo(() => {
    const errs: string[] = [];
    if (email && !email.includes("@")) {
      errs.push("유효한 이메일을 입력하세요");
    }
    if (password && password.length < 8) {
      errs.push("비밀번호는 8자 이상이어야 합니다");
    }
    return errs;
  }, [email, password]);

  return (
    <form>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      {errors.map((err, i) => (
        <p key={i}>{err}</p>
      ))}
    </form>
  );
}

효과:

  • email이나 password가 변경될 때만 검증
  • 불필요한 검증 방지

트러블슈팅

흔한 실수와 해결

실수결과해결
Profiler 없이 useCallback만 증설의존성 비교 비용만 증가병목 구간만 좁혀 적용
Context에 매 렌더 새 객체구독 컴포넌트 전부 리렌더값 분리·useMemo로 value 안정화
useMemo 안에서 부수효과Concurrent 등에서 예측 어려움부수효과는 **useEffect**로
memo만 쓰고 props 참조는 불안정메모 무력화콜백·객체를 의도적으로 안정화

증상별 해결 방법

증상: 메모했는데도 자식이 매번 렌더된다

// 문제
const Parent = () => {
  const [count, setCount] = useState(0);
  const config = { theme: "dark" };
  return <Child config={config} />;
};

// 해결
const Parent = () => {
  const [count, setCount] = useState(0);
  const config = useMemo(() => ({ theme: "dark" }), []);
  return <Child config={config} />;
};

증상: useMemo가 갱신이 안 된다

// 문제: 의존성 배열 누락
const filtered = useMemo(() => items.filter((it) => it.score > threshold), [items]);

// 해결: threshold 추가
const filtered = useMemo(() => items.filter((it) => it.score > threshold), [items, threshold]);

증상: 코드만 복잡해지고 체감 없음

// 문제: 가벼운 연산에 메모이제이션
const doubled = useMemo(() => count * 2, [count]);

// 해결: 메모이제이션 제거
const doubled = count * 2;

증상: Concurrent 렌더에서 이상하다

// 문제: useMemo 안에서 부수효과
const data = useMemo(() => {
  logAnalytics("data computed");
  return items.filter((it) => it.active);
}, [items]);

// 해결: useEffect로 분리
const data = useMemo(() => items.filter((it) => it.active), [items]);
useEffect(() => {
  logAnalytics("data computed");
}, [data]);

ESLint 규칙 설정

exhaustive-deps 활성화

{
  "rules": {
    "react-hooks/exhaustive-deps": "error"
  }
}

의존성 배열 자동 수정

npm run lint -- --fix

마무리

useMemouseCallback“렌더 비용 줄이기”와 “참조 안정화” 두 축으로 이해하면 선택이 쉬워집니다.

핵심 원칙:

  1. Profiler로 병목 확인 후 적용
  2. 가벼운 연산은 메모이제이션하지 않기
  3. 의존성 배열 정확히 관리 (ESLint 활용)
  4. React.memo와 세트로 사용 (콜백 안정화)
  5. 과도한 메모이제이션 경계 (코드 복잡도 증가)

패턴 전반은 JavaScript 패턴과 함께 보면 컴포넌트 설계까지 연결하기 좋습니다.