모노레포에서 React 인스턴스가 두 개일 때 생기는 일

웹뷰를 모노레포로 이전한 직후, 단독 레포에서는 한 번도 본 적 없는 버그가 두 개 나타났습니다. 증상은 전혀 달랐지만 원인은 하나였습니다.


배경: 웹뷰를 모노레포로 이전

디자인 시스템을 웹뷰에도 적용하고 싶었습니다. 방법은 두 가지였습니다.

  • npm 패키지로 배포: 웹뷰 레포는 그대로 두되, 내부 시스템을 public으로 공개해야 합니다.
  • 웹뷰를 모노레포로 이전: 의존성을 맞추는 작업이 번거롭지만, 모노레포의 api 패키지까지 함께 활용할 수 있습니다.

internal 시스템을 외부에 공개하는 부담 때문에 이전 방식을 선택했습니다. 소스를 복사한 뒤 tsconfig, vite.config 등 설정 파일을 모노레포 구조에 맞게 정리했습니다.

이전 직후부터 이상한 버그가 두 개 생겼습니다.


버그 1: 퍼널 진행 중 이벤트가 막히는 현상

퍼널 진행 중 스크롤이나 버튼 클릭이 동작하지 않는 경우가 생겼습니다.

  • 단독 레포에서는 발생하지 않고, 모노레포 환경에서만 발생
  • 새로고침하면 정상 동작

표면적 원인

웹뷰에는 useLayer 훅이 있습니다. 모달, 바텀시트처럼 화면 위에 컴포넌트를 쌓아 렌더링하는 역할이고, 라우트가 변경되면 clear()를 호출해 레이어를 제거합니다.

motion/reactAnimatePresence는 exit 애니메이션이 완료될 때까지 컴포넌트를 DOM에 유지합니다. 레이어 컨테이너는 position: fixed이므로, exit 애니메이션이 진행되는 동안 화면 전체를 덮은 채 포인터 이벤트를 가로막고 있었습니다.

pointer-events: none을 exit 상태에 적용하면 이벤트는 통과합니다. 하지만 왜 단독 레포에서는 이 문제가 없었는지를 먼저 설명해야 합니다.

진짜 원인: automatic batching이 깨진다

퍼널의 다음 단계로 이동할 때 pop()next()가 같은 이벤트 핸들러 안에서 연달아 호출됩니다.

const handleNext = () => {
  pop();   // 레이어 제거
  next();  // 라우터 이동
};

React 18의 automatic batching은 하나의 이벤트 핸들러 안에서 발생한 상태 업데이트를 하나의 커밋으로 묶습니다. 단독 레포에서는 pop()next()가 같은 커밋 안에서 처리되기 때문에, 다음 퍼널이 그려질 때 레이어 배열은 이미 비어 있습니다.

모노레포에서는 웹뷰의 React 18과 디자인 시스템의 React 19가 각자의 인스턴스로 설치되어 있었습니다. React의 batching은 단일 인스턴스 안에서만 동작합니다. 인스턴스가 두 개이면 각자의 scheduler가 독립적으로 커밋을 결정하기 때문에, pop()이 속한 인스턴스가 먼저 커밋하고 next()가 속한 인스턴스가 나중에 커밋하는 상황이 생깁니다.

라우트가 먼저 변경되어 다음 퍼널이 그려지는 시점에, 레이어 배열은 아직 이전 레이어를 갖고 있습니다. useLayeruseEffect에서 clear()가 뒤늦게 호출되면 AnimatePresence가 exit 애니메이션을 시작하고 — 이것이 이벤트를 차단했습니다.


버그 2: ReactCurrentDispatcher 오류

React 19로 버전을 올린 뒤 개발 서버에서 오류가 떴습니다.

Uncaught TypeError: Cannot read properties of undefined (reading 'ReactCurrentDispatcher')

원인

React는 ReactCurrentDispatcher, ReactCurrentOwner 등 내부 전역 상태를 인스턴스마다 따로 관리합니다. 훅을 호출하면 현재 렌더 중인 컴포넌트의 fiber를 dispatcher를 통해 추적합니다.

모노레포의 다른 패키지가 peer dependency로 React 18을 유지하고 있었습니다. 해당 패키지에서 import한 코드가 React 18의 dispatcher를 참조하려 했지만, 실제 렌더 트리는 React 19 인스턴스로 구동되고 있었습니다. React 18 인스턴스는 이 트리에서 한 번도 마운트된 적이 없어 dispatcher가 초기화되지 않은 상태였고, 그 시점에 위 오류가 발생합니다.

임시 해결: Vite dedupe

해당 패키지를 당장 React 19로 올리기 어려운 상황이었습니다. Vite의 dedupe 설정으로 React 관련 패키지를 어디서 import하든 동일한 하나의 인스턴스로 해석하도록 지시했습니다.

// vite.config.ts
resolve: {
  dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
},

이 설정은 패키지 버전이 섞여 있는 과도기에 유효한 처방이지, 궁극적인 해결은 아닙니다.


근본 해결: React 버전 통일

두 버그 모두 인스턴스가 두 개라는 사실에서 비롯됐습니다. 버전을 통일하면 batching 불일치, dispatcher 참조 오류, context 전파 실패 등 아직 드러나지 않은 버그들도 함께 예방할 수 있습니다.

모노레포에 서비스를 통합할 때 React처럼 내부 전역 상태를 사용하는 라이브러리는 버전 통일이 선행되어야 합니다. 버전이 달리면 인스턴스가 분리되고, 그 영향은 예상치 못한 곳에서 한참 뒤에 드러납니다.