BlogFlow | 블로그

프론트엔드 개발과 IT 기술을 중심으로 실무 경험과 학습을 기록합니다.

React

React Compiler으로 useMemo/useCallback는 어디까지 줄어들까?

2026.04.16·수정 2026.04.17·약 16분

이 글에서 정리하는 내용

React Compiler 1.0을 적용한 뒤 useMemo와 useCallback을 어디까지 줄일 수 있는지, 새 코드와 기존 코드에서 판단 기준이 어떻게 달라지는지 실무 흐름에 맞춰 정리합니다.

React Compiler 1.0이 바꾸는 기준

ChatGPT Image 2026년 4월 16일 오후 05 37 33

처음 React Compiler 1.0을 도입해보면 가장 먼저 달라지는 것은 최적화 코드를 쓰는 순서입니다. 예전에는 계산값이 다시 만들어질 것 같으면 useMemo를, 함수 참조가 바뀔 것 같으면 useCallback을 먼저 붙이기 쉬웠습니다. 하지만 이제는 먼저 평범한 코드로 작성한 뒤 compiler가 자동으로 memoization할 수 있게 두고, 정말 제어가 필요한 지점만 수동으로 남기는 방식이 더 자연스럽습니다. 핵심은 useMemo와 useCallback을 전부 지운다는 뜻이 아니라, 습관적 사용을 줄이고 판단 기준을 바꾼다는 데 있습니다. 특히 새 코드와 기존 코드는 같은 기준으로 보면 안 됩니다. 새 코드는 compiler 우선으로 설계해도 되지만, 기존 코드는 이미 React.memo, custom hook, Effect dependency와 얽혀 있는 경우가 많아서 테스트 없이 한꺼번에 지우면 오히려 동작이나 성능 판단이 더 어려워질 수 있습니다.

먼저 평문으로 작성하고 필요할 때만 남기기

type Todo = {
  id: string;
  title: string;
  done: boolean;
};

function TodoList({ todos, tab }: { todos: Todo[]; tab: 'all' | 'done' }) {
  // 이전에는 이런 파생 값을 보자마자 useMemo를 붙이기 쉬웠습니다.
  // React Compiler 1.0 환경에서는 먼저 평문으로 작성해도 됩니다.
  const visibleTodos = todos.filter((todo) => {
    return tab === 'all' ? true : todo.done;
  });

  return (
    <ul>
      {visibleTodos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

이 예시는 React Compiler 이후의 기본 사고방식을 보여줍니다. 렌더 과정에서 만들어지는 파생값이라고 해서 바로 useMemo로 감싸지 않고, 우선 읽기 쉬운 코드로 둡니다. 이 흐름은 코드량을 줄이고 의존성 배열을 관리하는 부담도 줄여줍니다. 다만 계산 비용이 매우 크거나, 이 값이 다른 Hook의 의존성으로 직접 쓰이거나, 기존 코드에서 이미 세밀한 최적화 체인이 만들어져 있다면 그때는 다시 수동 memoization을 검토해야 합니다.

컴파일이 실제로 적용되는지 먼저 확인하기

// annotation mode에서는 "use memo"가 있는 함수만 최적화 대상이 됩니다.
function ProductCard({ product }: { product: { name: string } }) {
  "use memo";

  return <div>{product.name}</div>;
}

// infer mode에서는 PascalCase 컴포넌트와 use로 시작하는 Hook을 기본적으로 추론합니다.

여기서 한 가지 빠뜨리기 쉬운 전제가 있습니다. React Compiler 1.0은 build 단계에서 동작하므로, 실제로 해당 컴포넌트나 Hook이 컴파일 대상이 되어야 useMemo와 useCallback 감소 효과도 기대할 수 있습니다. 기본 compilationMode인 infer에서는 PascalCase 컴포넌트와 use로 시작하는 Hook을 기준으로 추론하고, annotation 모드에서는 "use memo" 지시어가 있는 함수만 최적화합니다. 그래서 도입 후 체감이 없다면 무조건 Hook을 더 지울지 고민하기보다, 먼저 이 함수가 정말 React Compiler 1.0의 적용 대상인지부터 확인하는 편이 정확합니다.

useMemo는 어디까지 줄일 수 있을까

실무에서 가장 많이 줄어드는 것은 렌더 중 파생값을 만들기 위해 습관처럼 붙여둔 useMemo입니다. 필터링, 정렬, map 결과, 옵션 객체 생성처럼 비교적 흔한 패턴은 이제 먼저 평문으로 두고 시작해볼 수 있습니다. 중요한 점은 useMemo의 원래 역할이 계산 결과 캐시라는 데 있습니다. 따라서 정말로 줄이려면 “이 계산이 비싼가”보다 먼저 “이 계산을 꼭 내가 손으로 캐시해야 하는가”를 다시 물어봐야 합니다. React Compiler가 들어오면 이 질문의 답이 예전보다 훨씬 자주 “아니오” 쪽으로 바뀝니다.

계산값 캐시를 위해 습관적으로 넣은 useMemo

type Product = {
  id: string;
  name: string;
  price: number;
};

function ProductList({ products, keyword }: { products: Product[]; keyword: string }) {
  // before
  // const visibleProducts = useMemo(() => {
  //   return products.filter((product) => product.name.includes(keyword));
  // }, [products, keyword]);

  // after
  // 새 코드에서는 먼저 단순한 파생값으로 둡니다.
  const visibleProducts = products.filter((product) => {
    return product.name.includes(keyword);
  });

  return (
    <ul>
      {visibleProducts.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

이런 종류의 useMemo는 삭제 후보로 보기 좋습니다. 이유는 코드의 목적이 계산 자체보다 캐시에 더 끌려가 있었기 때문입니다. React Compiler 1.0 환경에서는 먼저 이렇게 단순하게 써보고, 실제로 병목이 관찰되는 경우에만 다시 수동 useMemo를 고려하는 편이 유지보수에 유리합니다. 특히 단순 파생값을 위해 의존성 배열을 계속 관리하는 구조는 시간이 지날수록 읽기 비용이 커지기 쉽습니다.

패턴 판단
필터링, 정렬, map 결과, 단순 옵션 객체 새 코드에서는 우선 삭제 후보로 보기 좋습니다.
Effect 의존성, 기존 복잡한 hotspot 계산 바로 지우지 말고 유지한 뒤 테스트로 판단하는 편이 안전합니다.

useCallback은 어디까지 줄일 수 있을까

useCallback은 보통 자식 컴포넌트에 넘기는 함수 참조를 고정하려고 많이 사용했습니다. 그래서 React.memo와 세트처럼 등장하는 경우가 많았습니다. React Compiler 1.0 이후에는 이 패턴도 상당수 줄일 수 있습니다. 이벤트 핸들러를 넘긴다는 이유만으로 무조건 useCallback을 붙이는 습관은 먼저 내려놓아도 됩니다. 특히 새 코드에서는 함수 참조를 안정화하려는 의도보다, 함수가 실제로 어떤 역할을 하는지 읽히는 편이 더 중요해졌습니다.

이벤트 핸들러를 넘긴다는 이유만으로 쓰던 useCallback

function Row({ id, onSelect }: { id: string; onSelect: (id: string) => void }) {
  return <button onClick={() => onSelect(id)}>선택</button>;
}

function ListPage() {
  const [selectedId, setSelectedId] = useState<string | null>(null);

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

  // after
  // 새 코드에서는 먼저 일반 함수로 둡니다.
  function handleSelect(id: string) {
    setSelectedId(id);
  }

  return (
    <div>
      <Row id="a1" onSelect={handleSelect} />
      <p>selected: {selectedId}</p>
    </div>
  );
}

이 예시처럼 단순 상태 업데이트를 자식에게 넘기는 정도라면 useCallback을 먼저 제거 후보로 볼 수 있습니다. 예전에는 함수 참조가 바뀌면 자식 렌더링에 영향을 줄 수 있다는 이유로 방어적으로 useCallback을 넣는 경우가 많았지만, React Compiler 환경에서는 이런 패턴을 손으로 계속 관리할 필요가 줄어듭니다. 또한 자식 컴포넌트를 React.memo로 감싼 이유가 함수 참조 안정화 하나뿐이었다면, React Compiler 1.0 도입 후에는 useCallback과 React.memo를 함께 재검토할 여지도 생깁니다. 결과적으로 코드가 더 짧아지고, 의존성 배열 때문에 발생하던 실수도 함께 줄어들 수 있습니다.

여전히 남겨야 하는 경우

ChatGPT Image 2026년 4월 16일 오후 05 41 07

여기서부터가 실무 판단의 핵심입니다. React Compiler 1.0이 자동 memoization을 해준다고 해서 모든 useMemo와 useCallback이 사라지지는 않습니다. 가장 대표적인 예외는 Effect dependency를 안정적으로 유지해야 하는 경우입니다. 값이 의미 있게 바뀌지 않았는데도 객체나 함수 참조가 계속 달라져 Effect가 반복 실행되는 상황이라면, 수동 memoization이 여전히 명확한 해결책이 될 수 있습니다. 또한 기존 코드베이스에서는 이미 잘 동작하는 memoization을 바로 걷어내기보다, 실제 화면 반응과 렌더 횟수, 병목 구간을 확인하면서 조금씩 줄이는 접근이 더 안전합니다.

function ChatRoom({ roomId }: { roomId: string }) {
  // Effect 의존성으로 쓰는 객체는 명시적으로 고정할 이유가 있습니다.
  const options = useMemo(() => {
    return {
      roomId,
      serverUrl: 'https://api.example.com',
    };
  }, [roomId]);

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();

    return () => {
      connection.disconnect();
    };
  }, [options]);

  return <div>connected</div>;
}

이 경우에는 단순히 “코드 수를 줄이자”보다 “Effect가 언제 다시 실행되어야 하는가”가 더 중요합니다. 그래서 React Compiler를 쓰더라도 Effect dependency 제어 목적의 useMemo와 useCallback은 남겨둘 가치가 충분합니다. 즉, 자동 최적화가 들어와도 제어가 필요한 경계면은 여전히 존재합니다.

기존 코드베이스는 삭제보다 검증이 먼저

function Dashboard({ reports, tab }: { reports: string[]; tab: string }) {
  // 기존 hotspot은 바로 지우지 말고 현재 동작과 렌더 흐름부터 확인합니다.
  const visibleReports = useMemo(() => {
    return reports.filter((report) => report.includes(tab));
  }, [reports, tab]);

  return <section>{visibleReports.length}</section>;
}

이미 운영 중인 코드에서 useMemo와 useCallback을 한 번에 걷어내면, 기대와 다르게 성능이 아니라 판단 가능성이 떨어질 수 있습니다. 무엇이 실제로 병목이었는지, 어떤 조합이 의미 있던 최적화였는지 흐려지기 때문입니다. 그래서 기존 코드에서는 삭제보다 분류가 먼저입니다. 단순 파생값, 단순 이벤트 핸들러, Effect dependency, custom hook API, 무거운 계산 구간을 나눠서 보고, 가장 안전한 곳부터 줄여가는 편이 실무적으로 맞습니다.

문제가 생기면 일시적으로 범위를 좁혀서 확인하기

function LegacyChart({ data }: { data: number[] }) {
  "use no memo";

  // 디버깅 중이거나 특정 함수를 잠시 컴파일 대상에서 제외할 때 사용합니다.
  return <Chart data={data} />;
}

React Compiler 1.0 도입 후 특정 화면에서만 동작이 어색하다면, 원인을 막연히 추측하기보다 범위를 좁혀서 확인하는 편이 좋습니다. React 공식 문서에는 특정 함수를 일시적으로 최적화 대상에서 제외하는 "use no memo" 지시어가 안내되어 있습니다. 이 방식은 영구 해결책이라기보다 디버깅용 escape hatch에 가깝지만, 문제가 React Compiler 1.0 자체인지 원래 코드 구조인지 분리해서 보는 데는 도움이 됩니다.

정리

React Compiler 1.0을 도입한 뒤 useMemo와 useCallback을 얼마나 줄일 수 있느냐는 숫자 하나로 답하기 어렵습니다. 대신 기준은 분명해졌습니다. 새 코드에서는 먼저 평문으로 작성하고 compiler에 맡기며, 수동 memoization은 정밀 제어가 필요할 때만 씁니다. 반대로 기존 코드에서는 이미 들어가 있는 useMemo와 useCallback을 무조건 지우지 말고, 삭제 후보와 유지 후보를 나눈 뒤 테스트로 확인해야 합니다. 정리하면 가장 많이 줄어드는 것은 습관적으로 넣어둔 파생값 캐시와 단순 이벤트 핸들러 memoization이고, 마지막까지 남는 것은 Effect dependency 제어와 기존 복잡한 최적화 구간입니다.

많이 받는 질문

Q. React Compiler를 켜면 useMemo와 useCallback을 전부 삭제해도 되나요?
그렇게 접근하면 위험합니다. 새 코드는 compiler 우선으로 단순하게 작성해도 되지만, 기존 코드는 이미 최적화 구조가 얽혀 있을 수 있어서 삭제 전에 테스트가 필요합니다.

Q. useEffect와 함께 쓰던 useMemo도 대부분 지워도 되나요?
항상 그렇지는 않습니다. Effect dependency의 실행 시점을 안정적으로 제어하려는 목적이라면 useMemo나 useCallback을 남겨두는 편이 더 명확할 수 있습니다.

Q. React Compiler 1.0을 켰는데도 useMemo와 useCallback이 별로 줄지 않는 것 같아요.
그럴 수 있습니다. 먼저 build 설정이 제대로 되었는지, 현재 compilationMode가 무엇인지, 컴포넌트와 Hook 이름이 React Compiler가 추론할 수 있는 형태인지 확인하는 편이 좋습니다. 컴파일이 실제로 적용되지 않았다면, 수동 memoization을 줄여도 기대한 효과가 잘 보이지 않을 수 있습니다.

Q. 이 변화의 핵심을 한 문장으로 정리하면 무엇인가요?
이제는 먼저 useMemo와 useCallback을 쓰는 것이 아니라, 먼저 읽기 쉬운 코드로 작성한 뒤 정말 필요한 부분만 수동으로 남기는 방향으로 사고방식을 바꾸는 것입니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기