모든 글

useMemo, 이제 지워도 될까? — Rust로 다시 태어난 React Compiler 직접 써보기

Oxc가 Rust로 새로 짠 React Compiler를 실제로 돌려보고, 코드가 어떻게 바뀌는지 직접 확인해봤습니다.

퍼블리셔로 일할 때 저는 useMemo라는 걸 몰랐어요.

대신 이런 규칙을 몸에 새기고 있었죠.

리플로우가 일어나는 속성은 최대한 늦게 건드린다, 클래스 토글로 될 걸 굳이 인라인 스타일로 안 쓴다, 애니메이션은 transform과 opacity로만.

브라우저가 다시 그림을 그리는 비용을 아끼려고 몸으로 익힌 습관이었어요.

프론트엔드로 넘어와 처음 useMemo, useCallback을 마주했을 때 묘하게 낯이 익었어요.

결국 똑같은 문제였거든요.

뭔가를 다시 계산하는 비용을 아끼려고, 개발자가 직접 “이건 다시 안 해도 돼”라고 표시해두는 일.

다른 layer, 같은 문제.

그런데 최근 이 표시를 아예 사람이 안 해도 되게 만드는 도구가 한 단계 더 실전에 가까워졌어요.

React Compiler를 Rust로 다시 짠 oxc 프로젝트의 포트가 @vitejs/plugin-react 6.1에 실험적으로 들어온 거예요.

마침 사이드 프로젝트에도 붙여볼 겸, 실제로 무슨 일이 일어나는지 코드 레벨까지 뜯어봤어요.

React Compiler는 2024년 React Conf에서 처음 공개된 이후 계속 다듬어져 온 프로젝트예요.

하는 일은 한 줄로 요약돼요.

컴포넌트 안에서 다시 계산할 필요 없는 값을 자동으로 찾아서, 사람이 useMemo/useCallback으로 하던 일을 컴파일 타임에 대신 해준다.

문제는 이 컴파일러 자체가 원래 Babel 플러그인, 즉 JS로 작성돼 있었다는 점이에요.

큰 프로젝트에선 이 변환 단계가 빌드 시간에 꽤 부담이 됐고, “최적화를 위해 켰더니 빌드가 느려진다”는 아이러니한 불평이 나오곤 했죠.

Oxc 프로젝트는 이 컴파일러를 Rust로 이식해 oxc-transform-react라는 패키지로 내놨어요.

숫자를 그냥 인용하고 싶지 않아서, oxc가 공개한 벤치마크 저장소(oxc-project/bench-transformer)의 원본 수치를 직접 확인했어요.

Apple M3 Max 기준으로 UserSettings.tsx가 13.39ms에서 0.87ms(약 15.4배), table.tsx가 98.68ms에서 8.02ms(약 12.3배)였어요.

10배 남짓이라는 수치가 과장이 아니었어요.

다만 이건 컴파일 자체의 속도지, 프로젝트 전체 빌드 시간이 그만큼 준다는 뜻은 아니에요.

번들링, 타입 체크 등 다른 단계는 그대로니까요.

@vitejs/plugin-react의 changelog를 보면 6.1.0(2026-08-19)에서 실험적 네이티브 React Compiler 지원이 추가됐다고 나와요.

여기서 짚을 게 있는데, @vitejs/plugin-react 6.1은 플러그인 패키지 자체의 버전이고 요구하는 Vite 코어는 8.x예요.

패키지 버전과 코어 버전이 다르게 매겨져 있어서 처음엔 저도 헷갈렸어요.

릴리스 노트를 읽을 때 “6.1”이 뭘 가리키는지부터 확인하는 습관, 브라우저 벤더 프리픽스 시절부터 몸에 밴 그 습관이 여기서도 통하더군요.

켜는 법 자체는 간단해요.

oxc-transform-react는 옵션 peer dependency라, 따로 설치하고 플래그 하나만 켜면 돼요.

npm install -D oxc-transform-react
// vite.config.ts
import react from '@vitejs/plugin-react'

export default {
  plugins: [react({ compiler: true })],
}

여기까지는 문서 그대로예요.

저는 실제 코드가 어떻게 바뀌는지 보고 싶어서, oxc-transform-react의 transformSync API로 컴포넌트 하나를 직접 컴파일해봤어요.

먼저 수동으로 메모이제이션한 코드.

export function ProductList({ items, query, onSelect }) {
  const filtered = useMemo(
    () => items.filter((item) => item.name.includes(query)),
    [items, query]
  );

  const handleClick = useCallback(
    (id) => { onSelect(id); },
    [onSelect]
  );

  return (
    <ul>
      {filtered.map((item) => (
        <li key={item.id} onClick={() => handleClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
}

그리고 useMemo/useCallback을 다 지운 코드.

export function ProductList({ items, query, onSelect }) {
  const filtered = items.filter((item) => item.name.includes(query));
  const handleClick = (id) => { onSelect(id); };

  return (
    <ul>
      {filtered.map((item) => (
        <li key={item.id} onClick={() => handleClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
}

두 버전을 각각 컴파일해보면 결과가 완전히 같지는 않아요.

수동 메모이제이션이 있던 버전은 filtered를 [items, query]에만 의존하는 캐시 슬롯으로 따로 떼어내요.

반면 지운 버전은 컴파일러가 “어디까지가 하나의 단위인지” 알려주는 힌트가 없다 보니, filtered 계산과 handleClick 생성, 리스트 렌더링까지를 items·query·onSelect 세 값 전부에 의존하는 더 큰 덩어리 하나로 묶어버려요.

즉 onSelect만 바뀌어도 filtered 재계산 블록 전체가 다시 도는 구조가 돼요.

수동 버전이었다면 일어나지 않았을 일이죠.

과감하게 다 지워도 컴파일러가 알아서 캐싱은 해줘요.

다만 경계를 어디로 잡을지에 대한 힌트까지 대신 넣어주진 않는다는 게 이번에 직접 코드를 뜯어보고서야 실감한 부분이에요.

성능에 민감한 구간이라면 useMemo가 여전히 “여기까지만 다시 계산해”라는 의도 표현의 역할을 해요.

패키지 README엔 이런 문구가 있어요.

이 패키지와 Oxc의 React Compiler 포트는 실험적입니다. 프로덕션에 쓰기 전에 생성된 결과물을 검토하세요.

작성 시점(oxc-transform-react 0.149.0) 기준으로도 여전히 이 경고가 붙어 있어요.

그래서 제 결론은 이래요.

새로 짜는 컴포넌트라면 굳이 먼저 useMemo/useCallback부터 넣을 이유가 줄었어요 — 컴파일러가 대신 해주니까요.

하지만 이미 있는 수동 메모이제이션은 급하게 지우지 않는 게 나아요.

위에서 본 것처럼 경계 힌트로서 여전히 의미가 있고, 컴파일러도 기존 보장을 가능하면 유지하도록 설계돼 있지 무시해도 된다는 뜻은 아니거든요.

그리고 아직 실험적 기능이라는 꼬리표를 가볍게 보면 안 돼요.

peer dependency로 옵트인해야 하는 것도, 아직 기본값으로 켜지 않는 이유가 있다는 신호니까요.

10년 넘게 HTML/CSS를 짜면서 배운 게 하나 있다면 선언형 언어의 힘이에요.

<div>를 쓸 때 어떻게 그릴지는 브라우저의 몫이고, 저는 이게 무엇인지만 말하면 됐어요.

float으로 레이아웃을 짜던 시절에서 flex·grid로 넘어오며 실감했던 변화도 같은 방향이었죠.

어떻게를 내가 관리하는 코드에서, 무엇을만 선언하면 나머지는 엔진이 알아서 하는 코드로.

React Compiler가 하는 일도 결국 같은 이동이에요.

”이 값은 언제 다시 계산해야 하는가”라는 어떻게의 영역을 이제 컴파일러에게 넘길 수 있게 된 거니까요.

다만 이번에 코드를 직접 뜯어보고 나니 이 이동이 아직 완전하지 않다는 것도 같이 보였어요.

마크업에서 브라우저 엔진을 100% 믿기까지 오래 걸렸던 것처럼, JS 최적화에서 컴파일러를 100% 믿기까지도 시간이 좀 더 필요해 보여요.

당장 제 프로젝트의 useMemo를 다 지우진 않을 거예요.

대신 새 컴포넌트부터 하나씩, 컴파일러가 뭘 대신 해주고 뭘 못 해주는지 관찰하는 걸 습관으로 삼아보려고요.

N
NACRE
Writing on less · Seoul
다른 글 보기

댓글

  1. 불러오는 중…