노트

내부 가변성과 메모이제이션

Interior Mutability

프런트엔드#react#performance · 연결된 개념 5개

쉽게 말하면

내부 가변성은 겉 상자는 그대로인데 안에 든 물건만 몰래 바뀌는 거예요. React는 상자가 바뀌었는지만 보고 다시 계산할지 정해서, 안이 바뀐 걸 놓치고 옛 값을 계속 보여 줄 수 있어요.

비유가 깨지는 곳 React Compiler는 react-hook-form의 watch처럼 알려진 상자는 감지해서 그 컴포넌트를 컴파일하지 않고 건너뛰어요. 확실한 해법은 바뀔 때 다시 렌더해 주는 useWatch로 구독하는 거예요.

내부 가변성(Interior Mutability)은 객체나 함수의 참조는 그대로인데 안에 숨은 상태가 바뀌는 성질이다. React의 메모이제이션은 "입력이 다른 참조인가"만 보기 때문에, 참조가 같으면 내용이 바뀌어도 바뀌지 않았다고 판단한다. React 공식 문서는 이를 "겉모습은 같은데 몰래 내용물을 바꾸는 상자"에 비유한다.

function Form() {
  const { watch } = useForm()
  // watch는 늘 같은 함수다 → deps가 안 바뀌어 처음 값에 얼어붙는다
  const name = useMemo(() => watch('name'), [watch])
  return <div>{name}</div>
}

React Compiler와 만날 때

React Compiler는 입력 참조가 같으면 이전 결과를 재사용한다. 그래서 내부 가변성이 있는 API의 반환값을 메모하면 위 useMemo와 같은 일이 자동으로 생길 수 있다. 이를 막으려고 컴파일러는 알려진 비호환 API(react-hook-form의 watch 등)를 쓰는 컴포넌트를 컴파일하지 않고 건너뛴다. 앱은 깨지지 않지만 그 컴포넌트는 자동 메모 혜택을 못 받는다. eslint-plugin-react-hooks의 incompatible-library 규칙이 이런 곳을 경고로 알려 준다.

두 경우를 구분한다.

  • 손으로 쓴 useMemo(..., [watch]): deps가 영영 안 바뀌어 값이 얼어붙는 버그
  • 컴파일러가 감지한 watch(): 컴포넌트가 최적화 대상에서 빠질 뿐 동작은 맞다. 다만 감지에 기대는 것이라 감지가 빗나가는 코드 모양이면 얼어붙을 위험이 남는다

watch와 useWatch

watch()useWatch({ control, name })
구독 위치useForm을 부른 루트이 훅을 부른 컴포넌트
다시 렌더되는 범위폼 전체호출한 컴포넌트만
컴파일러와의 관계비호환 API로 건너뜀바뀔 때 새 값으로 다시 렌더되어 호환
  • 렌더에 쓰는 값은 useWatch로 구독한다. 공식 문서도 리렌더 범위를 훅 단위로 좁혀 성능이 낫다고 설명하고, React 문서의 incompatible-library 해결책도 useWatch다
  • 이벤트 핸들러나 Effect에서 한 번 읽는 값은 구독이 필요 없으니 getValues()
  • 폼 상태(dirty, errors) 표시는 useFormState
  • react-hook-form 쪽에서 컴파일러와의 올바른 동작을 다루는 추적 이슈는 아직 열려 있다(2026-10 기준). 라이브러리 업데이트를 기다리기보다 useWatch로 바꾸는 편이 확실하다

탈출구

당장 바꾸기 어려우면 그 컴포넌트 함수 첫 줄에 'use no memo'를 두어 컴파일러 대상에서 뺀다. 임시 조치로 쓰고, 원인 API를 구독형으로 바꾸면 지운다.

같은 함정은 렌더 중에 변경 가능한 객체를 직접 고치는 코드에서도 생긴다. 새 참조를 만드는 불변 업데이트가 메모이제이션의 전제다(React의 규칙과 렌더 순수성, 얕은 복사와 깊은 복사, memo·useMemo·useCallback). 외부 가변 저장소를 안전하게 구독하는 표준 방법은 useSyncExternalStore다.

출처: React - incompatible-library · React Hook Form - useWatch · react-hook-form #12298 - Correct behaviour for apps using react-compiler

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • Effect가 필요 없는 경우

    Effect는 외부 시스템과 동기화할 때 쓰는 탈출구(Escape Hatch)다. 렌더 중에 계산할 수 있는 값이나 사용자 이벤트 처리에 Effect를 쓰면 렌더가 한 번 더 일어나고 흐름이 꼬인다. React 공식 문서가 이런 경우를 따로 정리해 두었다.

  • 변수·컬렉션 캡슐화

    넓은 범위에서 쓰이는 데이터에 직접 접근하지 못하게 하고, 읽고 쓰는 함수를 통해서만 다루게 하는 리팩터링. 데이터를 옮기거나 바꿀 때 고쳐야 할 곳이 접근 함수 한 곳으로 좁아진다.

  • 상태 내리기와 컴포지션으로 리렌더 줄이기

    상태 내리기(State Colocation)는 state를 실제로 쓰는 컴포넌트 가까이로 옮겨, state가 바뀔 때 다시 렌더되는 범위를 좁히는 기법이다. 메모이제이션(Memoization)보다 먼저 시도할 구조적 최적화다.

  • Activity와 숨겨진 라우트 보존

    Activity는 React 19.2의 컴포넌트로, UI를 언마운트하지 않고 display: none으로 숨기면서 state와 DOM(Document Object Model)을 보존한다. 숨기는 동안 Effect는 정리(cleanup)된다. 예전 이름은 Offscreen이다.

  • 재조정(Reconciliation)

    재조정(Reconciliation)은 React가 이전 렌더의 엘리먼트 트리와 새 트리를 비교(Diffing)해, 실제 DOM(Document Object Model)에 필요한 최소한의 변경만 반영하는 과정이다. 두 트리를 완벽하게 비교하면 O(n³)이라, React는 두 가지 가정을 두어 O(n)으로 줄인다.

보기 옵션