Cheondi
개발 · 웹

React Compiler와 상태 소유권

React Compiler의 자동 메모이제이션을 기존 useMemo 코드와 비교하며 성능 최적화와 상태·Effect 설계의 경계를 정리했습니다.

  • #react
  • #compiler
  • #memoization
  • #state
  • #performance

React 화면의 렌더링이 느려 보이면 useMemo, useCallback, memo부터 붙인 적이 있다. 어떤 값이 자주 바뀌는지 측정하기 전에 참조를 고정하면 성능이 좋아질 것 같았다. 코드는 길어졌고 dependency 배열이 맞는지 검토할 일도 늘었다.

React Compiler 1.0 발표를 읽으면서 가장 눈에 들어온 부분은 자동 메모이제이션이었다. 컴파일러가 컴포넌트와 훅의 데이터 흐름을 분석해 불필요한 재계산을 줄여 준다. 그렇다면 수동 메모이제이션을 모두 지우고 상태 구조는 신경 쓰지 않아도 될까? 작은 예제로 비교해 보니 컴파일러가 줄여 주는 코드와 개발자가 결정해야 하는 경계가 분명했다.

손으로 고정하던 참조

계좌 목록에서 화면에 표시할 행을 만드는 코드를 생각해 봤다.

function AccountTable({ accounts, filter, onSelect }) {
  const visibleAccounts = useMemo(
    () => accounts.filter(account => account.state === filter),
    [accounts, filter]
  );

  const selectAccount = useCallback(
    accountId => onSelect(accountId),
    [onSelect]
  );

  return visibleAccounts.map(account => (
    <AccountRow
      key={account.id}
      account={account}
      onSelect={selectAccount}
    />
  ));
}

이 코드가 틀린 것은 아니다. 하지만 값과 함수마다 캐시 여부를 사람이 선택하고 의존성을 맞춰야 한다. 계산 비용이 작거나 자식 컴포넌트가 어차피 다시 렌더링된다면 복잡도만 늘 수 있다.

Compiler를 적용할 수 있는 코드에서는 먼저 평범하게 의도를 쓸 수 있다.

function AccountTable({ accounts, filter, onSelect }) {
  const visibleAccounts = accounts.filter(
    account => account.state === filter
  );

  return visibleAccounts.map(account => (
    <AccountRow
      key={account.id}
      account={account}
      onSelect={() => onSelect(account.id)}
    />
  ));
}

컴파일러는 React 규칙을 만족하는 코드에서 값과 함수 재사용을 판단한다. 여기서 “자동”은 아무 코드나 같은 의미로 바꿔 준다는 뜻이 아니다. render가 순수하고 Props와 State를 직접 변경하지 않으며 Hooks 규칙을 지켜야 분석이 가능하다.

메모이제이션이 소유권을 고치지는 않는다

화면의 selected account를 props에서 받은 값과 별도 state에 복사하면 두 원본이 생긴다.

function AccountPanel({ selectedAccountId }) {
  const [localAccountId, setLocalAccountId] = useState(selectedAccountId);

  useEffect(() => {
    setLocalAccountId(selectedAccountId);
  }, [selectedAccountId]);

  // ...
}

Compiler가 이 코드를 빠르게 실행해도 state를 복사한 이유와 동기화 순서는 그대로 남는다. props 변경 뒤 한 번 이전 값으로 렌더링될 수 있고, 사용자가 로컬 값을 바꾸는 기능이 있다면 어느 쪽이 우선인지 애매하다.

가능하면 원본을 하나로 둔다.

function AccountPanel({ selectedAccountId, onAccountChange }) {
  const selectedAccount = useAccount(selectedAccountId);

  return (
    <AccountSelector
      value={selectedAccount?.id ?? ''}
      onChange={onAccountChange}
    />
  );
}

로컬 편집 초안이 정말 필요하다면 draftAccountId처럼 목적을 이름에 드러내고 저장·취소 시점을 정한다. 복사 state를 없애는 일은 메모이제이션보다 상태 머신을 단순하게 만든다.

문제 Compiler가 도울 수 있는 부분 개발자가 정할 부분
반복 계산 안전한 값 재사용 계산 자체의 필요성
함수 참조 변화 재사용 최적화 이벤트 책임과 API
중복 state 해결하지 않음 단일 원본과 소유 컴포넌트
잘못된 Effect 해결하지 않음 외부 동기화 여부
느린 네트워크 해결하지 않음 캐시·요청 취소·UX

적용 전후에 측정할 것

Compiler를 켜기 전에 현재 성능을 기록해야 변화가 실제 이득인지 알 수 있다.

1. React Profiler로 느린 상호작용 기록
2. 자주 렌더링되는 컴포넌트와 원인 확인
3. Compiler를 제한된 범위에 적용
4. 같은 입력과 장치에서 다시 측정
5. 번들, 빌드 시간, 런타임 오류도 비교

기존 수동 메모이제이션을 한 번에 모두 제거하기보다 compiler 진단과 테스트를 보면서 줄여야 한다. 서드파티 라이브러리나 규칙을 지키기 어려운 코드 경계에서는 opt-out이 필요할 수 있다.

남겨 둔 기준

  • 성능 문제는 Profiler 결과로 위치를 먼저 확인한다.
  • server state, URL state, local draft를 같은 state로 복사하지 않는다.
  • Effect는 외부 시스템과 동기화할 때 사용하고 계산 결과 복사에는 피한다.
  • Compiler가 적용된 범위와 제외된 범위를 빌드에서 확인한다.
  • 수동 memo를 제거할 때 동작 테스트와 성능 측정을 함께 한다.

React Compiler는 반복적인 최적화 코드를 줄여 주는 좋은 도구였다. 다만 화면 상태의 주인이 누구인지, API 응답을 언제 확정으로 볼지, Effect가 왜 필요한지는 대신 결정하지 않는다. 자동화가 늘어날수록 내가 작성해야 할 코드는 줄지만, 남은 설계 결정은 더 선명하게 적어야 했다.

참고: React Compiler 1.0 공식 발표