리액트 useCallback 제대로 쓰기: 참조 안정화로 자식 리렌더 줄이기

리액트 useCallback 제대로 쓰기: 참조 안정화로 자식 리렌더 줄이기

useCallback함수의 참조를 의존성 배열이 바뀔 때만 새로 만들어 전달하는 훅입니다. “계산 결과”를 캐시하는 useMemo와 달리, 콜백 함수레퍼런스를 안정화해 자식의 React.memo와 조합할 때 리렌더를 줄이는 데에 쓰입니다.

1) 언제 useCallback을 쓸까?

  • 메모된 자식(React.memo)에 onClick 등 콜백을 props로 전달할 때
  • 의존성이 드물게 변하고, 부모가 자주 렌더(다른 state 변경)되어도 자식 리렌더를 막고 싶을 때
  • 효과/메모 등 deps 배열에 콜백을 넣어야 하는데 참조가 매 렌더마다 바뀌면 곤란할 때

2) 쓰지 말아야 할 때

  • 콜백을 자식에 넘기지 않고 로컬에서만 쓰는 가벼운 핸들러(코드 복잡도만 증가)
  • 의존성이 자주 바뀌어 useCallback이 매번 새 함수를 만들 수밖에 없는 경우
  • 성능 문제가 관측되지 않았는데 습관적으로 일괄 적용(오버엔지니어링)

예제 1 — React.memo + useCallback으로 자식 리렌더 억제

부모에 counttext 두 상태가 있고, 자식 목록은 count를 증가시키는 버튼만 가집니다. text만 변경되어도 자식이 매번 리렌더되는 문제를 useCallback으로 해결합니다.

import React, { memo, useCallback, useState } from 'react';

// 메모된 자식: 동일 props면 리렌더 생략
const Row = memo(function Row({ item, onInc }) {
  console.log('Row render:', item.id); // 개발자도구에서 렌더 횟수 확인용
  return (
    <li style={{display:'flex', alignItems:'center', gap:8}}>
      {item.label}
      <button onClick={() => onInc(item.id)}>+1</button>
    </li>
  );
});

const seed = [
  { id: 1, label: '🍎 사과' },
  { id: 2, label: '🍌 바나나' },
  { id: 3, label: '🍒 체리' },
];

export default function MemoizedChildWithCallback() {
  const [text, setText] = useState('');
  const [counts, setCounts] = useState({ 1: 0, 2: 0, 3: 0 });

  // ❌ 나쁜 예: 매 렌더마다 새 함수 → 자식이 매번 리렌더
  // const onInc = (id) => setCounts((prev) => ({ ...prev, [id]: prev[id] + 1 }));

  // ✅ 좋은 예: 참조 안정화 (deps가 바뀔 때만 동일 참조 유지)
  const onInc = useCallback((id) => {
    setCounts((prev) => ({ ...prev, [id]: prev[id] + 1 }));
  }, []); // setState 업데이터는 안전하므로 deps 비움

  return (
    <section style={{border:'1px solid #ddd', padding:12, borderRadius:8, maxWidth:540}}>
      <h3>MemoizedChildWithCallback (useCallback + React.memo)</h3>

      <div style={{display:'flex', gap:8, marginBottom:8}}>
        <input
          placeholder="부모의 text (자식과 무관)"
          value={text}
          onChange={(e) => setText(e.target.value)}
          style={{flex:1}}
        />
        <span style={{color:'#64748b'}}>text 길이: {text.length}</span>
      </div>

      <ul>
        {seed.map((it) => (
          <Row key={it.id} item={it} onInc={onInc} />
        ))}
      </ul>

      <hr />
      <p>현재 카운트: 🍎 {counts[1]} / 🍌 {counts[2]} / 🍒 {counts[3]}</p>
      <p style={{color:'#64748b'}}>
        입력창에 타이핑해도 Row가 찍히지 않으면(콘솔 로그 확인) 최적화 성공!</p>
    </section>
  );
}

▼ 실행 화면 설명: 입력창에 타이핑해도 자식 Row들이 리렌더되지 않습니다(콘솔 로그로 확인). 각 행의 버튼을 눌렀을 때만 해당 행과 부모 상태가 갱신됩니다.

리액트 useCallback "React.memo + useCallback으로 자식 리렌더 억제" 예제 실행 결과 화면

예제 2 — Stale Closure(오래된 값) 재현과 해결

의존성을 빈 배열로 둔 콜백에서 오래된 상태를 캡처해 버그가 나는 경우를 재현합니다. 해결책은 함수형 업데이트를 쓰거나, 정확한 deps를 지정하는 것입니다.

import React, { useCallback, useEffect, useState } from 'react';

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

  // ❌ 나쁜 예: count를 참조하지만 deps가 [] → count=0만 본 채로 굳어버림
  // const tick = useCallback(() => setCount(count + 1), []);

  // ✅ 해결 1: 함수형 업데이트(이전 값을 인자로 수신)
  const tick = useCallback(() => {
    setCount((c) => c + 1);
  }, []);

  // ✅ 해결 2: 정확한 deps 지정 (대신 매번 새 콜백이 됨)
  // const tick = useCallback(() => setCount(count + 1), [count]);

  useEffect(() => {
    const id = setInterval(tick, 500);
    return () => clearInterval(id);
  }, [tick]);

  return (
    <section style={{border:'1px solid #ddd', padding:12, borderRadius:8, maxWidth:420}}>
      <h3>StaleClosureFix (useCallback)</h3>
      <p>0.5초마다 카운트 업: <strong>{count}</strong></p>
      <p style={{color:'#64748b'}}>함수형 업데이트를 쓰면 deps 없이도 최신 값을 안전하게 업데이트합니다.</p>
    </section>
  );
}

▼ 실행 화면 설명: 카운트가 매 0.5초마다 정상적으로 증가합니다. 잘못된 버전([] deps + 직접 count 사용)은 1 이상 증가하지 않는 문제가 발생합니다.

리액트 useCallback "Stale Closure(오래된 값) 재현과 해결" 예제 실행 결과 화면

3) 체크리스트 & 베스트 프랙티스

  • 자식 메모 + 콜백 전달 구조일 때 우선 검토(React.memo와 시너지)
  • 함수형 업데이트로 deps 최소화: setState(prev => ...)
  • 콜백에서 참조하는 값은 정확히 deps에 포함(누락 시 stale)
  • 불필요 남용 금지: 로컬 핸들러나 자주 바뀌는 deps엔 실익이 적음
  • useMemo와 구분: useMemo는 “값” 메모화, useCallback은 “함수 참조” 메모화

참고 링크

함께 보면 좋은 게시글

위로 스크롤