pnpm 모노레포에서 React 19로 단계적 마이그레이션하기 — 타입 오염 문제와 해결

7 min read
pnpmReact 19모노레포TypeScript마이그레이션
pnpm 모노레포에서 React 19로 단계적 마이그레이션하기 — 타입 오염 문제와 해결

"타입 에러 98개"

pnpm catalogs로 React 18과 React 19를 동시에 관리하겠다는 아이디어는 우아했다. 그런데 설정을 마치자마자 React 18 앱에서 타입 에러가 98개 터졌다. 에러 메시지는 이렇다.

Type 'Element' is not assignable to type 'ReactNode'

React 19의 타입이 React 18 앱으로 흘러들어온 것이다. 이 문제를 우아한형제들 프론트엔드 팀이 해결한 과정이 최근 기술 블로그에 공개됐다. pnpm의 내부 구조를 이해하지 않으면 원인조차 파악하기 어려운 케이스다.


pnpm catalogs로 버전 격리 시도

단계적 마이그레이션의 전제는 간단하다. 앱마다 React 버전을 다르게 가져가면서 하나씩 올리는 것.

pnpm-workspace.yaml
catalogs:
  v1:
    react: "18.2.0"
    "@types/react": "18.3.18"
  v2:
    react: "19.2.4"
    "@types/react": "19.1.2"

각 앱의 package.json에서 catalog:v1 또는 catalog:v2를 참조하면 된다. 이론적으로는 완벽한 격리다.

[💡 잠깐! 이 용어는?] pnpm catalogs: pnpm v9에서 추가된 기능. 모노레포 전체에서 특정 패키지 버전을 카탈로그로 관리하고, 각 패키지는 "react": "catalog:v1" 형태로 참조한다. 버전 불일치를 중앙에서 통제할 수 있다.


왜 격리가 실패했나 — pnpm 3 Layer 구조

원인을 이해하려면 pnpm이 node_modules를 구성하는 방식을 알아야 한다. pnpm은 3개 계층으로 나뉜다.

레이어위치역할
Layer 1packages/app-a/node_modules/앱이 선언한 의존성만 심볼릭 링크
Layer 2.pnpm/ 가상 저장소패키지 실제 구현체. peerDeps에 따라 컨텍스트 분리
Layer 3node_modules/.pnpm/node_modules/호이스팅 레이어. 동일 패키지는 한 버전만

[💡 잠깐! 이 용어는?] 호이스팅(Hoisting): npm/yarn이 node_modules 루트에 패키지를 끌어올리는 동작. pnpm도 일부 패키지를 Layer 3로 호이스팅한다. 중복 설치를 줄이지만, 같은 패키지의 두 버전이 있을 때 하나만 올라갈 수 있다.

TypeScript가 서드파티 라이브러리에서 타입을 찾을 때 흐름은 이렇다.

  1. Layer 2에서 해당 라이브러리의 @types/react 탐색
  2. 없으면 Layer 3(호이스팅 레이어)로 올라간다
  3. Layer 3에는 단 하나의 @types/react 버전만 존재

React 18 앱 옆에 React 19 앱이 있으면, Layer 3에 @types/react@19가 올라가 있을 수 있다. React 18 앱의 서드파티 라이브러리들이 Layer 3의 React 19 타입을 참조하게 되는 것이다.


해결 1단계 — 호이스팅 차단

.npmrc
hoist-pattern[]=*
hoist-pattern[]=!@types/react
hoist-pattern[]=!@types/react-dom

@types/react@types/react-dom이 Layer 3로 올라가지 않도록 막았다. 이 설정으로 타입 오염의 경로를 차단한다.

그런데 새로운 문제가 생겼다. @sentry/react의 타입이 any로 추론되기 시작했다.


새로운 문제 — Sentry의 타입이 사라졌다

원인은 @sentry/react의 패키지 선언에 있었다.

@sentry/react/package.json (간략)
{
  "peerDependencies": {
    "react": "15.x || 16.x || 17.x || 18.x || 19.x"
  }
}

react는 peerDependency로 선언되어 있지만 @types/react는 없다. Layer 3에서 @types/react를 차단했으니, @sentry/react는 더 이상 타입 정의를 찾을 수 없게 됐다.


해결 2단계 — packageExtensions로 의존성 주입

pnpm의 packageExtensions는 특정 패키지의 의존성을 가상으로 패치하는 기능이다. @sentry/react@types/react를 peerDependency로 선언한 것처럼 동작하게 만든다.

package.json (루트)
{
  "pnpm": {
    "packageExtensions": {
      "@sentry/react": {
        "peerDependencies": {
          "@types/react": "*"
        }
      }
    }
  }
}

이 설정으로 @sentry/react는 각 앱의 React 버전에 맞는 @types/react를 참조하게 된다. React 18 앱에선 v18 타입, React 19 앱에선 v19 타입.


검토했지만 쓰지 않은 방법들

tsconfig paths

tsconfig.jsonpaths@types/react를 특정 경로로 강제 매핑하는 방법이다.

tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@types/react": ["./node_modules/@types/react"]
    }
  }
}

문제는 Layer 2의 경로와 맞지 않아 호환성 이슈가 생겼다는 것이다.

shared-workspace-lockfile=false

각 패키지가 별도 lockfile을 가지게 하는 방법이다. 완벽한 격리가 가능하지만 중복 설치와 관리 복잡도가 늘어난다. 모노레포의 핵심 이점을 포기하는 것이나 다름없어서 반려됐다.


마무리

pnpm catalogs는 모노레포에서 의존성 버전을 통제하는 강력한 도구다. 하지만 TypeScript의 타입 해석 경로와 pnpm의 3 Layer 구조가 맞물리면 예상치 못한 타입 오염이 생길 수 있다.

해결의 핵심은 두 가지였다.

  • hoist-pattern으로 @types/react의 Layer 3 호이스팅 차단
  • packageExtensions로 누락된 peerDependencies 가상 패치

React 19로 점진적 마이그레이션을 계획 중이라면, 이 두 설정을 미리 알고 있는 것과 모르는 것의 차이가 꽤 크다.


참고:

관심 있을 만한 포스트

달리는 차의 엔진을 바꾸다 — Nx 모노레포에서 Bun 도입까지의 여정

Nx 18에서 21까지 버전 업그레이드와 Bun 패키지 매니저 도입을 동시에 진행한 컬리의 마이그레이션 전략과 실전 이슈를 정리한다.

NxBun

TypeScript 6.0 Beta — TS 7 가기 전에 tsconfig부터 정리하자

TypeScript 6.0 Beta의 주요 변경 사항과 깨지는 기본값들을 정리하고, TS 7(Go 네이티브) 전환을 대비하는 마이그레이션 전략을 다룬다.

TypeScripttsconfig

Naver FE News 2026년 4월 — 49MB 웹 페이지부터 Temporal Stage 4까지

Naver FE News 2026년 4월호에서 프론트엔드 개발자가 주목할 6가지 소식을 선별해 정리한다.

Naver FE NewsTemporal

Tsonic — TypeScript를 네이티브 바이너리로 컴파일하는 실험

TypeScript → C# → NativeAOT 파이프라인으로 네이티브 실행 파일을 만드는 Tsonic. 어떻게 동작하고, 어떤 한계가 있는지 살펴봤다.

TypeScriptNativeAOT

Babel 7.29.0 — 10년 역사의 마지막 마이너, 그리고 8 RC1

2026년 1월 31일, Babel 7의 마지막 마이너 릴리스가 공개됐다. 이 버전이 갖는 역사적 의미와 Babel 8 RC1의 핵심 변화를 정리한다.

BabelJavaScript

jQuery 4.0 — 10년 만의 메이저 릴리스, 무엇이 바뀌었나

jQuery가 20주년을 맞아 10년 만에 메이저 버전을 출시했다. IE 지원 축소, ES 모듈 전환, Trusted Types 등 핵심 변경 사항을 정리한다.

jQueryJavaScript

Babel 8 Beta — CJS를 버리고 ESM 전용으로 간다

2년간의 알파를 거쳐 베타에 진입한 Babel 8의 핵심 변경사항과 마이그레이션 전략을 정리한다.

BabelESM

Bun vs Node.js vs Deno — 뭐가 다른지, 그래서 뭘 쓰면 좋은지 (2026 기준)

런타임 3대장 비교: 호환성(Node), 속도/번들(Bun), 올인원/보안(Deno). 팀/프로덕트 상황별 선택 기준과 체크리스트까지 정리.

BunNode.js