메모이제이션을 남발하면 안 되는 이유와 큰 컴포넌트를 최적화하는 방법

Frontend

코드 리뷰에서 이런 PR을 만난 적이 있습니다. 파일 하나에 useMemouseCallback이 서른 개 넘게 붙어 있었습니다. 그중 절반은 문자열 하나를 만드는 계산이었고, 몇 개는 React.memo로 감싸지도 않은 평범한 <div>에 넘기는 핸들러였습니다.

"이건 왜 감쌌어요?"라고 물었더니 돌아온 답이 인상적이었습니다.

  • "일단 감싸두면 손해는 아니잖아요?"
  • "리렌더링 줄이려고요."
  • "다른 파일도 다 이렇게 되어 있어서요."

세 답 모두 나쁜 의도는 아닙니다. 다만 공통점이 하나 있습니다. 무엇이 얼마나 빨라졌는지 아무도 모른다는 것입니다. 실제로 프로파일러를 켜보면, 이 서른 개가 만든 성능 개선은 측정 오차 수준인 경우가 많습니다. 정작 화면이 버벅이는 원인은 다른 곳, 보통은 컴포넌트 구조 쪽에 있습니다.

React.memo, useMemo, useCallback이 각각 무엇을 기억하고 어떻게 쓰는지는 React.memo, useMemo, useCallback은 언제 어떻게 써야 할까에서 이미 정리했습니다. 이번 글은 그 다음 질문입니다.

왜 남발하면 안 되는가, 그리고 메모이제이션으로 풀리지 않는 큰 컴포넌트는 무엇으로 푸는가.

한눈에 보면

먼저 짧게 정리하면 이렇습니다.

  • 메모이제이션은 공짜가 아닙니다. 메모리 보관, 의존성 얕은 비교, 클로저 생성이라는 비용을 항상 지불합니다.
  • 이득은 "props나 의존성이 실제로 안 바뀔 때"만 발생합니다. 조건이 깨지면 비용만 남습니다.
  • 현장에서 자주 보이는 메모이제이션의 상당수는 이 조건이 애초에 성립하지 않는 자리에 붙어 있습니다.
  • 의존성을 억지로 줄여 참조를 고정하면 성능 최적화가 stale closure 버그로 바뀝니다. 정확성이 성능보다 먼저입니다.
  • 큰 컴포넌트가 느린 원인은 대개 "렌더 비용"이 아니라 "렌더 범위"입니다. 범위 문제는 구조로 풉니다.
  • state colocation, children as props, 컴포넌트 분할, context 분리, ref, 가상화가 메모이제이션보다 먼저 검토할 카드입니다.
  • 그 순서를 다 거치고 남은 병목에만 메모이제이션을 씁니다.

즉, 메모이제이션을 잘 쓴다는 것은 많이 붙이는 것이 아니라, 붙이지 않아도 되는 구조를 먼저 만들고 남은 곳에만 붙이는 것입니다.

메모이제이션은 무엇을 지불하고 무엇을 얻을까?

메모이제이션을 "혹시 모르니까 걸어두는 보험"으로 생각하면 판단이 흐려집니다. 정확히는 선불 결제에 가깝습니다.

useMemo 한 줄이 지불하는 비용은 세 가지입니다.

const value = useMemo(() => computeSomething(a, b), [a, b]);

이 한 줄에서 렌더링마다 반드시 일어나는 일은 이렇습니다.

  1. 클로저 생성() => computeSomething(a, b) 화살표 함수 객체가 매 렌더 새로 만들어집니다. 캐시가 적중하든 말든 이 함수는 항상 생성됩니다.
  2. 의존성 배열 생성과 비교[a, b] 배열이 새로 만들어지고, 이전 배열과 원소별로 Object.is 비교를 수행합니다.
  3. 메모리 보관 — 이전 계산 결과와 이전 의존성 배열을 컴포넌트가 언마운트될 때까지 fiber에 붙들고 있습니다.

얻는 것은 하나뿐입니다. 의존성이 안 바뀌었을 때 computeSomething 호출을 건너뛰는 것.

그래서 손익 계산은 단순합니다.

항목 항상 지불 조건부 획득
useMemo 클로저 생성 + 배열 비교 + 보관 의존성 불변 시 계산 1회 절약
useCallback 클로저 생성 + 배열 비교 + 보관 참조 유지로 하위 memo/effect 절약
React.memo props 전체 얕은 비교 + 보관 props 불변 시 서브트리 렌더 절약

정리하면 분모(비용)는 무조건이고 분자(이득)는 조건부입니다. 이 조건이 성립하는지 확인하지 않고 붙이면, 기대값이 음수인 코드를 늘리는 셈입니다.

물론 개별 비용은 작습니다. 배열 두 개 비교하는 데 걸리는 시간은 무시할 만합니다. "그래도 손해는 아니잖아요"라는 말이 아주 틀린 것도 아닙니다. 정작 문제는 실행 시간이 아니라 그 아래에서 시작합니다.

효과가 0인 메모이제이션은 어떤 모습일까?

실무 코드에서 반복적으로 보이는 패턴을 여섯 가지로 모아봤습니다. 전부 "붙어 있지만 아무 일도 하지 않는" 사례입니다.

1. memo 자식에게 매번 새 객체를 넘긴다

가장 흔합니다.

const UserCard = React.memo(function UserCard({ user, style, onSelect }: Props) {
  return (
    <div style={style} onClick={() => onSelect(user.id)}>
      {user.name}
    </div>
  );
});
 
function UserList({ users }: { users: User[] }) {
  const [keyword, setKeyword] = useState('');
 
  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      {users.map((user) => (
        <UserCard
          key={user.id}
          user={user}
          style={{ padding: 12 }} // 매 렌더 새 객체
          onSelect={(id) => console.log(id)} // 매 렌더 새 함수
        />
      ))}
    </>
  );
}

UserCardReact.memo로 감싸져 있습니다. 하지만 styleonSelect가 매 렌더 새 참조로 만들어지므로 얕은 비교는 항상 실패합니다. 결과적으로 원래도 렌더링됐을 UserCard가 그대로 다시 렌더링되고, 거기에 props 비교 비용이 항목 수만큼 얹힙니다.

즉, 이 React.memo는 성능을 개선한 게 아니라 순수하게 비용만 늘렸습니다. memo는 자식에 붙이는 순간 끝나는 것이 아니라, 부모가 props를 어떻게 만들어 내려보내는지까지 같이 봐야 성립합니다.

2. memo 컴포넌트가 children을 받는다

이건 잘 안 알려져 있는데 자주 나옵니다.

const Panel = React.memo(function Panel({ children }: { children: ReactNode }) {
  return <section className="panel">{children}</section>;
});
 
function Page() {
  const [count, setCount] = useState(0);
 
  return (
    <>
      <button onClick={() => setCount((prev) => prev + 1)}>{count}</button>
      <Panel>
        <Heavy />
      </Panel>
    </>
  );
}

Panel에 넘기는 props는 children 하나뿐이고, 겉보기에는 아무것도 안 바뀌었습니다. 그런데 Panel은 매번 다시 렌더링됩니다.

이유는 JSX가 무엇으로 변환되는지 생각하면 명확합니다. <Heavy />Page가 렌더링될 때마다 새로 만들어지는 React element 객체입니다. 객체이므로 참조가 매번 다릅니다. 따라서 children prop은 매 렌더 새 값이고, 얕은 비교는 통과할 수 없습니다.

재미있는 건 이 구조에서 React.memo떼어내도 성능이 거의 같다는 점입니다. 오히려 뒤에서 볼 것처럼, children으로 받는 이 구조 자체가 memo 없이도 Heavy의 리렌더를 막아줍니다. memo는 그 위에 얹힌 장식일 뿐입니다.

3. 즉시 계산되는 값을 useMemo로 감싼다

// 이 세 줄은 전부 손해입니다
const total = useMemo(() => price + tax, [price, tax]);
const label = useMemo(() => `${user.firstName} ${user.lastName}`, [user]);
const isValid = useMemo(() => value.length > 0, [value]);

덧셈 한 번, 문자열 연결 한 번, 길이 비교 한 번의 비용보다 의존성 배열을 만들고 비교하고 결과를 보관하는 비용이 더 큽니다.

// 그냥 이렇게 쓰는 편이 빠르고 읽기도 쉽습니다
const total = price + tax;
const label = `${user.firstName} ${user.lastName}`;
const isValid = value.length > 0;

기준을 하나 잡자면 이렇습니다. 계산 결과가 원시값이고 계산이 상수 시간이면 useMemo는 거의 의미가 없습니다. 원시값은 값 비교가 되므로 참조 안정성도 필요 없습니다.

의미가 생기는 경계는 두 가지입니다. 결과가 객체나 배열이라 참조 안정성이 필요하거나, 계산이 배열 전체를 도는 등 실제로 무거울 때입니다.

4. context value만 useMemo로 감싼다

이 패턴은 "했으니까 됐다"는 착각을 만들기 쉽습니다.

function AppProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<Theme>('light');
  const [notifications, setNotifications] = useState<Notification[]>([]);
 
  const value = useMemo(
    () => ({ user, setUser, theme, setTheme, notifications, setNotifications }),
    [user, theme, notifications]
  );
  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

useMemo를 제대로 붙였습니다. 그런데 notifications가 5초마다 폴링으로 갱신된다면 어떻게 될까요.

notifications가 바뀔 때마다 value 객체가 새로 만들어지고, AppContext를 구독하는 모든 컴포넌트가 리렌더링됩니다. 테마만 쓰는 컴포넌트도, 유저 이름만 읽는 헤더도 예외 없이 함께 렌더링됩니다.

여기서 중요한 사실이 있습니다. context 소비자는 React.memo로도 막을 수 없습니다. useContext로 구독한 값이 바뀌면 props가 그대로여도 리렌더링됩니다. 이 문제는 memo로 풀리지 않고, context를 쪼개는 것으로만 풀립니다. 뒤에서 다시 다루겠습니다.

5. 의존성에 매번 바뀌는 값이 들어간다

function Dashboard({ filters }: { filters: Filters }) {
  // filters가 부모에서 매 렌더 새로 만들어지는 객체라면?
  const rows = useMemo(() => aggregate(rawData, filters), [rawData, filters]);
 
  return <Table rows={rows} />;
}

useMemo는 정확히 걸려 있습니다. 문제는 filters가 부모에서 이렇게 내려온다는 것입니다.

<Dashboard filters={{ from, to, status }} />

의존성 하나가 매 렌더 새 참조이므로 캐시는 매번 무효화됩니다. aggregate는 항상 다시 실행되고, 거기에 비교 비용이 얹힙니다.

메모이제이션 체인은 가장 약한 고리에서 끊깁니다. 아래쪽에 useMemo를 아무리 정확하게 걸어도, 위쪽에서 참조가 매번 새로 만들어지면 전부 무효입니다. 그래서 메모이제이션은 한 줄짜리 국소 최적화가 아니라 트리 전체의 참조 흐름을 지키겠다는 약속에 가깝습니다. 이 약속을 유지하는 비용이 생각보다 큽니다.

6. memo가 아닌 자식에게 useCallback을 넘긴다

function SearchBar() {
  const [keyword, setKeyword] = useState('');
 
  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setKeyword(e.target.value);
  }, []);
 
  return <input value={keyword} onChange={handleChange} />;
}

handleChange를 받는 것은 호스트 DOM 요소인 <input>입니다. React는 DOM 요소의 이벤트 핸들러 참조가 바뀌어도 렌더링을 건너뛰지 않습니다. 어차피 이 컴포넌트는 렌더링되는 중이고, 리스너 교체는 사실상 무료입니다.

useCallback이 의미를 갖는 자리는 셋 중 하나입니다.

  • React.memo로 감싼 자식에게 넘길 때
  • useEffect나 다른 훅의 의존성으로 들어갈 때
  • context value 안에 담겨 내려갈 때

이 셋 중 어디에도 해당하지 않으면 useCallback은 비용만 남습니다.

위험한 쪽은 성능이 아니라 정확성입니다

여기까지는 "손해지만 눈에 안 띄는" 문제였습니다. 하지만 메모이제이션 남발이 실제로 사고를 내는 지점은 따로 있습니다. 의존성 배열을 손으로 줄이는 순간입니다.

function ChatRoom({ roomId }: { roomId: string }) {
  const [draft, setDraft] = useState('');
 
  // 참조를 고정하고 싶어서 의존성을 비웠습니다
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const handleSend = useCallback(() => {
    sendMessage(roomId, draft);
    setDraft('');
  }, []);
 
  return (
    <>
      <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
      <SendButton onSend={handleSend} />
    </>
  );
}

이 코드는 첫 렌더의 draft, 즉 빈 문자열을 영원히 붙들고 있습니다. 사용자가 무엇을 입력하든 빈 메시지가 전송됩니다. roomId도 마찬가지라, 방을 이동해도 처음 방으로 메시지가 갑니다.

무서운 건 이런 버그가 조용하다는 점입니다. 타입 에러도 없고, 보통은 화면도 정상으로 보입니다. 특정 순서로 조작했을 때만 값이 틀어집니다.

고치는 방법은 의존성을 되돌리거나, 애초에 클로저에 값을 가두지 않는 것입니다.

// 필요한 값을 정직하게 의존성에 넣습니다
const handleSend = useCallback(() => {
  sendMessage(roomId, draft);
  setDraft('');
}, [roomId, draft]);

이렇게 하면 draft가 바뀔 때마다 handleSend 참조도 바뀝니다. 그러면 SendButton의 memo 효과는 사라집니다. 하지만 그게 정답입니다. 입력값이 바뀌었는데 핸들러가 옛날 값을 들고 있으면 안 되기 때문입니다.

교훈은 명확합니다. exhaustive-deps 경고를 끄고 싶어지는 순간은 최적화 신호가 아니라 설계를 다시 봐야 한다는 신호입니다. 참조를 정말 고정해야 한다면 함수형 업데이트를 쓰거나, 값을 ref에 담거나, 아예 상태 위치를 바꾸는 쪽이 맞습니다.

가독성 비용은 왜 더 비싼가?

메모이제이션이 늘어나면 코드에서 신호와 잡음이 섞입니다.

// 이 파일에서 진짜 무거운 계산은 어느 것일까요?
const columns = useMemo(() => buildColumns(schema), [schema]);
const title = useMemo(() => `${name} 대시보드`, [name]);
const rows = useMemo(() => aggregate(raw, range), [raw, range]);
const isEmpty = useMemo(() => rows.length === 0, [rows]);
const handleExport = useCallback(() => exportCsv(rows), [rows]);
const handleClose = useCallback(() => setOpen(false), []);

전부 useMemo로 감싸져 있으면 어느 것이 실제 병목인지 코드만 봐서는 알 수 없습니다. 반대로 꼭 필요한 곳에만 붙어 있으면 그 자체가 문서가 됩니다. "여기가 무거우니 조심하라"는 표시로 읽힙니다.

여기에 더해 실무에서 체감되는 비용이 하나 더 있습니다. 메모이제이션은 유지보수 계약을 만듭니다. 누군가 나중에 props를 하나 추가하면서 인라인 객체를 넘기는 순간, 아래쪽 memo 체인은 조용히 무너집니다. 아무도 알아채지 못하고, 테스트도 통과합니다. 최적화가 있었다는 사실만 남고 효과는 사라집니다.

React Compiler는 이 논쟁을 끝낼까?

React 팀은 컴파일러가 메모이제이션을 자동으로 삽입하는 방향을 오래 준비해왔습니다. React 19 시기에 React Compiler가 공개되면서, 손으로 useMemouseCallback을 바르는 작업을 컴파일 단계로 옮기려는 흐름이 뚜렷해졌습니다.

다만 이 부분은 버전과 시점에 민감합니다. 안정화 단계, 지원 범위, 기본 활성화 여부는 계속 바뀌고 있으므로, 도입을 검토한다면 팀이 쓰는 React 버전 기준으로 공식 문서를 직접 확인하는 편이 안전합니다. 이 글에서 단정할 수 있는 부분은 아닙니다.

방향성 수준에서 정리하면 이렇습니다.

  • 손으로 붙이는 메모이제이션의 상대적 가치는 줄어드는 쪽으로 가고 있습니다.
  • 컴파일러가 잘 동작하려면 컴포넌트가 React의 규칙을 지켜야 합니다. 렌더 중 변경, 조건부 훅, 미묘한 사이드 이펙트가 있으면 컴파일러는 최적화를 포기합니다.
  • 컴파일러는 참조 안정성을 대신 관리해줄 수 있지만, 잘못된 컴포넌트 구조를 고쳐주지는 않습니다. 500줄짜리 컴포넌트는 컴파일러가 붙어도 여전히 500줄짜리 컴포넌트입니다.

컴파일러 이후에도 이 글의 2부, 구조적 처방은 그대로 유효합니다. 오히려 손 메모이제이션이 자동화될수록 구조 설계가 개발자가 하는 일의 대부분으로 남습니다.

붙이기 전에 무엇을 측정해야 할까?

측정하지 않은 메모이제이션은 추측에 가깝습니다. React DevTools만으로도 충분히 확인할 수 있습니다.

  • Profiler 탭 기록 — 어떤 상호작용이 어떤 컴포넌트를 얼마나 오래 렌더링시켰는지 커밋 단위로 봅니다.
  • Why did this render — Profiler 설정에서 "Record why each component rendered"를 켜면, 각 컴포넌트가 props 때문인지 state 때문인지 부모 때문인지 표시됩니다. 이 한 줄이 웬만한 추측을 없애줍니다.
  • Highlight updates when components render — 화면에서 어느 영역이 깜빡이는지 눈으로 봅니다. 입력 한 글자에 페이지 전체가 번쩍이면 그게 답입니다.

판단 기준은 하나면 충분합니다. 이 메모이제이션을 지웠을 때 Profiler 수치가 얼마나 나빠지는지 말할 수 없다면, 그건 아직 최적화가 아닙니다.

프로파일러를 켜면 흔히 이런 결론이 나옵니다. 병목은 흩어진 작은 계산이 아니라, 한 번에 너무 넓은 범위가 다시 그려지는 구조입니다. 여기서부터가 2부입니다.

큰 컴포넌트의 진짜 문제는 무엇일까?

"큰 컴포넌트가 느리다"는 말에는 서로 다른 두 문제가 섞여 있습니다.

구분 렌더 비용 문제 렌더 범위 문제
증상 한 번 그리는 데 오래 걸림 조금만 건드려도 전부 다시 그려짐
원인 무거운 계산, 수천 개 노드 state 위치, props 흐름, context 설계
처방 계산 이동, 가상화, 지연 구조 분리, colocation, composition
memo 효과 제한적 임시방편은 되지만 근본 해결은 아님

실무에서 만나는 대부분은 오른쪽, 범위 문제입니다. 범위 문제에 메모이제이션을 바르는 것은 새는 파이프를 테이프로 감는 것과 비슷합니다. 당장은 멎지만 다음 변경에서 다시 샙니다.

처방의 순서를 그림으로 정리하면 이렇습니다.

flowchart TD
  START[화면이 느리다] --> M[프로파일러로 측정]
  M --> Q{원인이 무엇인가}
 
  Q -->|넓은 범위가 자주 렌더| SCOPE[범위 문제]
  Q -->|한 번 렌더가 무겁다| COST[비용 문제]
 
  SCOPE --> S1[1 state를 쓰는 곳으로 내리기]
  S1 --> S2[2 children as props로 전파 끊기]
  S2 --> S3[3 자주 바뀌는 부분 분리]
  S3 --> S4[4 context 쪼개기]
  S4 --> S5[5 state 대신 ref나 비제어]
 
  COST --> C1[6 긴 리스트는 가상화]
  C1 --> C2[7 무거운 계산은 렌더 밖으로]
 
  S5 --> LAST[8 남은 병목에만 메모이제이션]
  C2 --> LAST

이제 하나씩 보겠습니다.

처방 1. state를 실제로 쓰는 곳으로 내려보낸다

가장 효과가 크고 가장 자주 놓치는 처방입니다.

// Before: 500줄짜리 폼. 입력 한 글자에 전부 다시 그려집니다
function OrderForm() {
  const [memo, setMemo] = useState('');
  const [items, setItems] = useState<Item[]>(initialItems);
 
  return (
    <form>
      <CustomerSection />
      <ItemTable items={items} onChange={setItems} />
      <ShippingSection />
      <PriceSummary items={items} />
      <textarea value={memo} onChange={(e) => setMemo(e.target.value)} />
      <SubmitBar />
    </form>
  );
}

memo는 맨 아래 <textarea> 하나에서만 쓰입니다. 그런데 이 state가 최상단에 있으니, 사용자가 배송 메모를 한 글자 칠 때마다 ItemTablePriceSummary도 전부 다시 렌더링됩니다.

흔한 대응은 ItemTablePriceSummaryReact.memo를 붙이는 것입니다. 동작은 합니다. 대신 props 참조 관리라는 숙제가 영구적으로 생깁니다.

더 나은 방법은 state를 아래로 내리는 것입니다.

// After: 메모 상태를 자기 컴포넌트 안으로
function MemoField({ onCommit }: { onCommit: (value: string) => void }) {
  const [value, setValue] = useState('');
  return (
    <textarea
      value={value}
      onChange={(e) => setValue(e.target.value)}
      onBlur={() => onCommit(value)}
    />
  );
}
 
function OrderForm() {
  const [items, setItems] = useState<Item[]>(initialItems);
  const memoRef = useRef('');
 
  return (
    <form>
      <CustomerSection />
      <ItemTable items={items} onChange={setItems} />
      <PriceSummary items={items} />
      <MemoField onCommit={(value) => (memoRef.current = value)} />
    </form>
  );
}

이제 타이핑은 MemoField 안에서만 리렌더를 일으킵니다. memo도, useCallback도 필요 없습니다. 리렌더 범위가 상태의 위치로 결정된다는 사실을 이용한 것뿐입니다.

state를 배치할 때 던져볼 질문은 하나입니다. 이 값을 실제로 읽는 컴포넌트가 어디까지인가. 그보다 위에 있다면 내릴 여지가 있습니다.

처방 2. children as props로 리렌더 전파를 끊는다

메모이제이션 없이 리렌더를 끊는 기법 중 효과가 가장 크지만, 그만큼 덜 알려져 있습니다.

먼저 문제 상황입니다.

// Before: state가 바뀔 때마다 Heavy도 함께 렌더링됩니다
function Layout() {
  const [isOpen, setIsOpen] = useState(false);
 
  return (
    <div className={isOpen ? 'expanded' : 'collapsed'}>
      <button onClick={() => setIsOpen((prev) => !prev)}>토글</button>
      <Heavy />
    </div>
  );
}

isOpen이 바뀌면 Layout이 다시 실행되고, 그 안에서 <Heavy /> element가 새로 만들어지므로 Heavy도 다시 렌더링됩니다.

이제 구조만 바꿔보겠습니다.

// After: 자식을 props로 받습니다
function Layout({ children }: { children: ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
 
  return (
    <div className={isOpen ? 'expanded' : 'collapsed'}>
      <button onClick={() => setIsOpen((prev) => !prev)}>토글</button>
      {children}
    </div>
  );
}
 
function Page() {
  return (
    <Layout>
      <Heavy />
    </Layout>
  );
}

이 코드에서 isOpen을 토글하면 Layout은 다시 렌더링되지만, Heavy는 다시 렌더링되지 않습니다.

왜 그럴까요. 핵심은 <Heavy /> element를 누가 만드는가입니다.

  • Before에서는 Layout이 만들었습니다. Layout이 다시 실행되면 element도 새로 만들어집니다.
  • After에서는 Page가 만들었습니다. Layout의 state가 바뀌어도 Page는 다시 실행되지 않습니다. 따라서 children으로 전달된 element 객체는 이전 렌더와 같은 참조입니다.

React는 같은 위치에 같은 참조의 element가 오면 해당 서브트리 렌더링을 건너뜁니다. React.memo가 하는 일과 결과는 비슷하지만, 비교조차 필요 없습니다.

flowchart LR
  subgraph BEFORE[Before]
    L1[Layout state 변경] --> H1[Heavy element 새로 생성]
    H1 --> R1[Heavy 리렌더]
  end
 
  subgraph AFTER[After]
    P[Page 렌더 안 됨] --> E[children element 참조 그대로]
    L2[Layout state 변경] --> E
    E --> R2[Heavy 렌더 건너뜀]
  end

이 기법이 좋은 이유는 세 가지입니다. 훅이 하나도 늘지 않고, 나중에 누가 props를 추가해도 조용히 무너지지 않으며, 컴포넌트 재사용성이 같이 올라갑니다. 같은 아이디어를 sidebar, content처럼 element를 이름 붙은 prop으로 받는 형태로도 쓸 수 있습니다.

이 발상은 Compound Component 패턴과도 자연스럽게 이어집니다. 합성으로 조립하는 구조가 성능 측면에서도 유리한 이유가 여기에 있습니다.

처방 3. 자주 바뀌는 부분을 경계로 잘라낸다

컴포넌트 분할을 "파일이 길어져서" 하는 경우가 많지만, 성능 관점에서는 변경 주기가 다른 코드를 분리한다는 기준이 더 유용합니다.

// Before: 1초마다 바뀌는 타이머가 무거운 차트와 한 컴포넌트에 있습니다
function ReportPage({ data }: { data: Row[] }) {
  const [elapsed, setElapsed] = useState(0);
 
  useEffect(() => {
    const id = setInterval(() => setElapsed((prev) => prev + 1), 1000);
    return () => clearInterval(id);
  }, []);
 
  return (
    <div>
      <p>{elapsed}초 경과</p>
      <ExpensiveChart data={data} />
      <BigTable rows={data} />
    </div>
  );
}

1초마다 차트와 테이블이 함께 다시 렌더링됩니다. 여기에 React.memo를 바르는 대신, 자주 바뀌는 부분을 잘라냅니다.

// After: 초 단위로 바뀌는 것은 자기 컴포넌트 안에 가둡니다
function ElapsedTimer() {
  const [elapsed, setElapsed] = useState(0);
 
  useEffect(() => {
    const id = setInterval(() => setElapsed((prev) => prev + 1), 1000);
    return () => clearInterval(id);
  }, []);
 
  return <p>{elapsed}초 경과</p>;
}
 
function ReportPage({ data }: { data: Row[] }) {
  return (
    <div>
      <ElapsedTimer />
      <ExpensiveChart data={data} />
    </div>
  );
}

ReportPage는 이제 data가 바뀔 때만 렌더링됩니다. 컴포넌트 경계가 곧 리렌더 경계라는 점을 이용한 분할입니다.

분할 기준을 하나 정리하면 이렇습니다. 한 컴포넌트 안에 초 단위로 바뀌는 값과 분 단위로 바뀌는 값이 같이 있으면, 그 자리가 자를 곳입니다.

처방 4. context를 쪼갠다

앞에서 본 "value만 useMemo로 감싼 거대한 context" 문제로 돌아오겠습니다. 이건 memo로 못 막으므로 구조를 바꿔야 합니다.

첫 번째는 값과 dispatch 분리입니다.

const TodoStateContext = createContext<Todo[]>([]);
const TodoDispatchContext = createContext<Dispatch<Action>>(() => {});
 
function TodoProvider({ children }: { children: ReactNode }) {
  const [todos, dispatch] = useReducer(todoReducer, initialTodos);
 
  return (
    <TodoStateContext.Provider value={todos}>
      {/* dispatch는 리렌더와 무관하게 참조가 고정됩니다 */}
      <TodoDispatchContext.Provider value={dispatch}>{children}</TodoDispatchContext.Provider>
    </TodoStateContext.Provider>
  );
}

useReducerdispatch는 React가 참조를 유지해주므로 useCallback이 필요 없습니다. 그래서 "값은 안 읽고 동작만 필요한" 컴포넌트, 예를 들어 추가 버튼 같은 것은 todos가 아무리 바뀌어도 리렌더링되지 않습니다.

두 번째는 도메인별 분리입니다. 하나의 AppContext에 유저, 테마, 알림을 다 넣지 않고 나눕니다.

// Before
<AppContext.Provider value={{ user, theme, notifications }}>
 
// After
<UserContext.Provider value={user}>
  <ThemeContext.Provider value={theme}>
    <NotificationContext.Provider value={notifications}>

이렇게 하면 알림 폴링이 테마 소비자를 건드리지 않습니다.

세 번째는 selector가 필요할 때의 선택입니다. "이 context의 특정 필드만 구독하고 싶다"는 요구가 나오면 context의 한계에 도달한 것입니다. context는 부분 구독을 지원하지 않으므로, 이때는 외부 store로 옮기는 편이 정직합니다.

import { useSyncExternalStore } from 'react';
 
function useStoreSelector<T>(store: Store, selector: (state: State) => T): T {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),
    () => selector(store.getInitialState())
  );
}
 
// 이 컴포넌트는 cartCount가 바뀔 때만 렌더링됩니다
function CartBadge() {
  const count = useStoreSelector(store, (state) => state.cart.items.length);
  return <span>{count}</span>;
}

zustand 같은 라이브러리는 이 패턴을 감싸둔 것에 가깝습니다. context로 selector를 흉내 내려고 memo를 겹겹이 쌓는 것보다, 도구를 바꾸는 쪽이 결과적으로 단순합니다.

상황 적절한 도구
거의 안 바뀌는 값 전역 공유 context 하나로 충분
값과 동작을 함께 내려보냄 state/dispatch context 분리
서로 다른 주기로 바뀌는 값들 도메인별 context 분리
큰 상태에서 일부 필드만 구독 외부 store + useSyncExternalStore

처방 5. 굳이 state일 필요 없는 값은 빼낸다

모든 값이 state여야 하는 것은 아닙니다. 화면에 즉시 반영될 필요가 없는 값은 state에서 빼면 리렌더 자체가 사라집니다.

// Before: 키 입력마다 폼 전체가 리렌더링됩니다
function SignupForm() {
  const [email, setEmail] = useState('');
  const [nickname, setNickname] = useState('');
 
  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    signup({ email, nickname });
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <input value={nickname} onChange={(e) => setNickname(e.target.value)} />
      <HeavyTermsSection />
      <button type="submit">가입</button>
    </form>
  );
}

입력이 전부 제어 컴포넌트라서, 한 글자마다 HeavyTermsSection까지 다시 렌더링됩니다.

제출 시점에만 값이 필요하다면 비제어로 바꿀 수 있습니다.

// After: 입력 중에는 리렌더가 0번입니다
function SignupForm() {
  const formRef = useRef<HTMLFormElement>(null);
 
  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    const formData = new FormData(formRef.current!);
    signup({
      email: String(formData.get('email')),
      nickname: String(formData.get('nickname')),
    });
  };
 
  return (
    <form ref={formRef} onSubmit={handleSubmit}>
      <input name="email" />
      <input name="nickname" />
      <HeavyTermsSection />
      <button type="submit">가입</button>
    </form>
  );
}

react-hook-form 계열 라이브러리가 빠른 이유가 정확히 이것입니다. 입력값을 ref로 관리하고 필요한 순간에만 리렌더를 일으킵니다.

물론 트레이드오프가 있습니다. 실시간 유효성 검사나 입력에 따라 다른 필드를 바꾸는 UI에는 제어 컴포넌트가 더 맞습니다. 선택 기준은 controlled vs uncontrolled component 글에서 더 자세히 다뤘습니다.

같은 발상은 스크롤 위치, 드래그 중인 좌표, 마지막 클릭 시각처럼 렌더에 쓰이지 않는 값에도 그대로 적용됩니다. 이런 값을 useState에 넣으면 초당 수십 번의 리렌더를 스스로 만드는 셈입니다.

처방 6. 긴 리스트는 메모가 아니라 가상화로 푼다

1000행짜리 테이블을 React.memo로 최적화하려는 시도를 종종 봅니다. 그런데 memo는 이미 있는 1000개를 다시 그리지 않게 해줄 뿐, 1000개를 처음 그리는 비용은 그대로입니다. 초기 마운트, 스크롤, 필터 변경처럼 전체가 새로 만들어지는 순간은 그대로 느립니다.

DOM 노드 수 자체가 문제라면 답은 windowing입니다.

import { useVirtualizer } from '@tanstack/react-virtual';
 
function BigTable({ rows }: { rows: Row[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
 
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,
    overscan: 8,
  });
 
  return (
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      {/* 전체 높이만 차지하는 껍데기. 실제 DOM 노드는 보이는 만큼만 만듭니다 */}
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((item) => (
          <div key={item.key} style={rowStyle(item)}>
            <RowView row={rows[item.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

화면에 보이는 20~30개만 실제 DOM에 존재하므로, 행이 1000개든 10만 개든 렌더 비용이 거의 일정합니다. 메모이제이션으로는 만들 수 없는 차이입니다.

여기서 하나 덧붙이면, 스크롤 성능은 React만의 문제가 아닙니다. 행마다 그림자와 필터가 잔뜩 걸려 있으면 브라우저의 Paint 비용에서 막힙니다. 이 부분은 Reflow와 Repaint 비용 줄이기에서 다룬 관점이 함께 필요합니다.

처방 7. 무거운 계산은 렌더 밖으로 옮긴다

useMemo는 계산을 더 적게 실행하게 해줄 뿐, 계산 자체를 더 빠르게 만들어주지 않습니다. 한 번 실행에 200ms가 걸리는 계산은 캐시가 무효화되는 그 한 번에 여전히 200ms 동안 메인 스레드를 막습니다.

진짜 무거운 계산은 위치를 옮기는 편이 낫습니다. 옮길 곳은 세 군데입니다.

서버로 옮기기. 정렬, 집계, 필터링은 대부분 서버가 더 잘합니다. 클라이언트가 10만 건을 받아 정렬하는 구조라면 API 설계를 먼저 의심하는 편이 좋습니다.

우선순위를 낮추기. 입력 반응성과 무거운 목록 갱신이 경쟁한다면 useDeferredValue로 분리할 수 있습니다.

function SearchPage({ items }: { items: Item[] }) {
  const [keyword, setKeyword] = useState('');
  const deferredKeyword = useDeferredValue(keyword);
 
  // 입력은 즉시 반영되고, 무거운 목록은 한 박자 늦게 따라옵니다
  const results = useMemo(
    () => items.filter((item) => item.name.includes(deferredKeyword)),
    [items, deferredKeyword]
  );
 
  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <ResultList results={results} />
    </>
  );
}

useMemo는 정당합니다. 실제로 무거운 계산이고, 의존성이 안정적이며, 무엇을 줄이는지 설명할 수 있기 때문입니다. 이 API들의 배경은 React 18의 렌더링 방식에서 정리했습니다.

스레드를 옮기기. 이미지 처리, 대용량 파싱, 암호화처럼 수백 ms 단위 작업은 Web Worker로 보내는 것이 가장 확실합니다. 메인 스레드에서 하는 한, 어떤 React API도 프레임 드랍을 막아주지 못합니다.

처방 8. 그리고 남은 곳에만 메모이제이션을 쓴다

여기까지 왔는데도 프로파일러에 남아 있는 병목이 있다면, 그때가 메모이제이션의 자리입니다. 이 시점의 메모이제이션은 처음과 성격이 다릅니다. 대상이 "이 컴포넌트, 이 계산"으로 명확하고, Profiler 수치라는 근거가 있고, 범위가 파일 전체가 아니라 한두 곳입니다.

이럴 때는 주석으로 이유를 남겨두면 다음 사람이 판단하기 쉽습니다.

// 행 3000개 기준 정렬에 약 40ms. Profiler로 확인 후 메모이제이션.
// rows는 서버 응답 그대로라 참조가 안정적입니다.
const sortedRows = useMemo(() => sortRows(rows, sortKey), [rows, sortKey]);

이렇게 남겨두면 나중에 데이터가 300개로 줄었을 때 "이제 떼도 되겠다"는 판단이 가능해집니다. 근거 없는 메모이제이션은 영원히 지워지지 않습니다. 아무도 지워도 되는지 모르기 때문입니다.

증상별로 어디를 봐야 할까?

현장에서 자주 듣는 표현을 기준으로 정리하면 이렇습니다.

증상 흔한 원인 우선 처방
타이핑할 때 화면 전체가 버벅인다 입력 state가 상위에 있음 state 내리기, 비제어 입력
모달 하나 열면 페이지가 멈춘다 모달 open state가 페이지 최상단에 있음 state 내리기, children as props
리스트 스크롤이 끊긴다 DOM 노드 수 과다, 행마다 무거운 스타일 가상화, Paint 비용 점검
탭만 바꿨는데 전부 다시 그려진다 탭 state가 무거운 콘텐츠와 같은 컴포넌트 컴포넌트 분할, children as props
알림 하나 오면 앱 전체가 렌더링된다 거대한 단일 context context 도메인 분리, state/dispatch
특정 필드만 구독하고 싶다 context의 부분 구독 한계 외부 store + selector
검색 결과 갱신이 입력을 막는다 무거운 필터링이 같은 우선순위 useDeferredValue, 서버 필터링
memo를 붙였는데 아무 변화가 없다 props 참조가 매번 새로 만들어짐 부모의 props 생성 지점 확인

실무 체크리스트

메모이제이션을 추가하려는 순간, 아래를 먼저 확인해보시길 권합니다.

  • 프로파일러로 이 컴포넌트가 병목이라는 것을 측정했는가
  • 이 컴포넌트가 다시 렌더링되는 이유를 "Why did this render"로 확인했는가
  • state를 더 아래로 내릴 수 있는가
  • 무거운 자식을 children으로 받도록 바꿀 수 있는가
  • 자주 바뀌는 부분을 별도 컴포넌트로 자를 수 있는가
  • 이 값이 정말 state여야 하는가, ref로 충분하지 않은가
  • context가 너무 크지 않은가
  • 리스트 길이가 문제라면 가상화가 먼저 아닌가
  • 그래도 필요하다면, React.memo 자식에게 넘기는 모든 props의 참조가 안정적인가
  • 의존성 배열을 정직하게 채웠는가, 경고를 끄고 있지는 않은가
  • 제거했을 때 얼마나 느려지는지 숫자로 말할 수 있는가

자주 하는 오해

1. 메모이제이션은 붙여서 손해 볼 일은 없다

아닙니다. 비용은 항상 지불되고 이득은 조건부입니다. 조건이 안 맞으면 순손실이고, 의존성을 억지로 줄이면 버그까지 생깁니다.

2. React.memo를 붙이면 부모 렌더링과 무관해진다

아닙니다. props 참조가 매번 바뀌면 비교만 하고 그대로 렌더링됩니다. memo의 성패는 자식이 아니라 부모의 props 생성 방식에서 갈립니다.

3. children을 받는 컴포넌트에 memo를 붙이면 효과가 좋다

아닙니다. JSX children은 매 렌더 새 element 객체라 얕은 비교를 통과하지 못합니다. 다만 children으로 받는 구조 자체는 memo 없이도 리렌더 전파를 끊어줍니다. 효과는 memo가 아니라 구조에서 나옵니다.

4. useMemo는 계산을 빠르게 만든다

아닙니다. 실행 횟수를 줄일 뿐입니다. 한 번의 실행이 200ms라면 그 한 번은 여전히 200ms 동안 메인 스레드를 막습니다. 계산 자체가 무겁다면 서버나 worker로 옮기는 것이 답입니다.

5. context value를 useMemo로 감싸면 context 성능 문제는 해결된다

아닙니다. value가 바뀌는 빈도 자체를 줄이지 못하면 소비자 전체가 계속 렌더링됩니다. 그 리렌더는 React.memo로 막을 수 없습니다. context를 쪼개거나 store로 옮겨야 합니다.

6. React Compiler가 나오면 최적화를 신경 쓸 필요가 없다

성급한 결론입니다. 컴파일러는 참조 관리를 자동화하는 방향이지, 잘못된 컴포넌트 경계나 과도한 DOM 노드를 고쳐주지는 않습니다. 실제 동작과 지원 범위는 사용하는 React 버전 기준으로 공식 문서에서 확인하는 편이 안전합니다.

정리하면

  • 메모이제이션은 보험이 아니라 선불 결제입니다. 클로저 생성, 의존성 비교, 메모리 보관은 항상 지불되고, 이득은 참조가 안정적일 때만 발생합니다.
  • 현장에서 보이는 메모이제이션의 상당수는 그 조건이 성립하지 않는 자리에 붙어 있습니다. 매번 새로 만드는 객체 props, children, 원시값 계산, 거대한 context가 대표적입니다.
  • 의존성을 손으로 줄여 참조를 고정하는 순간, 최적화는 stale closure 버그가 됩니다. 정확성이 성능보다 먼저입니다.
  • 큰 컴포넌트의 문제는 대개 렌더 비용이 아니라 렌더 범위입니다. 범위는 state 위치, children as props, 컴포넌트 분할, context 분리, ref, 가상화로 풉니다.
  • 이 순서를 다 거치고 남은 병목에만 메모이제이션을 쓰면, 그때부터는 근거를 설명할 수 있는 최적화가 됩니다.

내일 코드에서 바로 쓸 판단 기준은 하나면 충분합니다. useMemo를 지웠을 때 무엇이 얼마나 느려지는지 말할 수 없다면, 그건 최적화가 아니라 습관입니다. 그런 자리에서는 훅을 하나 더 붙이는 대신, 상태를 한 칸 아래로 내릴 수 있는지부터 보시길 권합니다.

같이 보면 좋은 글