배경: React 19 마이그레이션과 pnpm Catalogs
pnpm workspace 기반의 모노레포 환경을 구축하여 프론트엔드 애플리케이션을 운영하고 있습니다. 기존에는 모노레포 내 여러 워크스페이스(앱)의 라이브러리 파편화를 막기 위해, 모든 의존성을 최상단(root)에서 일괄 관리해 왔습니다.
이에 따라 모든 앱이 동일하게 React 18 버전을 사용하고 있었으나, 최근 React 19로의 마이그레이션을 계획하게 되었습니다. 하지만 규모가 큰 모노레포 특성상 모든 앱을 한 번에 업데이트하는 것은 리스크가 컸습니다.
먼저 단계적인 마이그레이션을 진행하고 각 앱별로 의존성을 안전하게 격리하기 위해, pnpm의 catalogs 기능을 새롭게 도입했습니다.
pnpm Catalogs 도입: 앱별 의존성 격리
기존에 의존성을 최상단에서 일괄 관리했던 핵심적인 이유는 ‘버전 관리’ 때문이었습니다. 각 앱에서 개별적으로 패키지를 설치하다 보면 버전이 파편화될 위험이 컸기 때문입니다.
이러한 구조적 한계를 해결하기 위해 도입한 것이 바로 pnpm catalogs입니다. pnpm catalogs는 모노레포 워크스페이스 내에서 공통으로 사용할 패키지 버전을 한 곳에 정의하고, 여러 package.json에서 이를 참조할 수 있게 하는 기능입니다.
catalogs를 적용하여 최상단에서 관리하던 의존성을 각 워크스페이스에서 관리하도록 변경했습니다. 이를 통해 각 워크스페이스별로 독립적으로 버전을 관리하면서도, 모노레포 내의 라이브러리 버전 파편화를 방지할 수 있는 환경을 마련했습니다.
# pnpm-workspace.yaml catalogs: v1: # 기존 React 18 앱용 react: 18.2.0 react-dom: 18.2.0 "@types/react": 18.2.21 "@types/react-dom": 18.2.7 ... v2: # 신규 React 19 앱용 react: 19.2.4 react-dom: 19.2.4 "@types/react": 19.0.8 "@types/react-dom": 19.0.8 ...위와 같이 설정을 마치고, 업데이트 대상 앱에만 catalog:v2를 적용해 보았습니다.
// 기존 React 18을 유지하는 앱 - v1 참조 // apps/A/package.json { "name": "A", "dependencies": { "react": "catalog:v1", "react-dom": "catalog:v1" }, "devDependencies": { "@types/react": "catalog:v1", "@types/react-dom": "catalog:v1" } } // React 19로 마이그레이션하는 앱 - v2 참조 // apps/B/package.json { "name": "B", "dependencies": { "react": "catalog:v2", "react-dom": "catalog:v2" }, "devDependencies": { "@types/react": "catalog:v2", "@types/react-dom": "catalog:v2" } }문제 발생 및 원인 파악: 얽혀버린 의존성과 pnpm의 동작 원리
문제 발생: 예상치 못한 타입 오염
본격적으로 작업을 진행하는 순간, 기존에 운영되던 React 18 앱들에서 React 19 타입을 참조하게 되면서 다수의 타입 에러가 발생하는 “타입 오염” 현상이 발생했습니다.
빌드를 돌려보니 터미널에는 다음과 같이 무려 98개의 타입 에러가 쏟아졌습니다.

대표적으로 아래와 같이 React 19 버전의 @types/react가 섞여 들어오면서, children이나 ReactNode 타입 추론이 어긋나 발생하는 타입 충돌 에러(TS2322)들이었습니다.
TS2322: Type 'Element' is not assignable to type 'ReactNode'. Property 'children' is missing in type 'Element' but required in type 'ReactPortal'. // @tanstack/react-query의 QueryClientProvider에서 발생한 에러 QueryClientProvider.d.ts(13, 5): The expected type comes from property 'children' which is declared here on type 'IntrinsicAttributes & QueryClientProviderProps' // react-router-dom의 BrowserRouter에서 발생한 에러 index.d.ts(29, 5): The expected type comes from property 'children' which is declared here on type 'IntrinsicAttributes & BrowserRouterProps'분명 앱별로 버전을 분리했음에도 왜 다른 버전의 타입을 참조하게 되었을까요?
그 해답은 바로 pnpm만의 독특한 node_modules 구조에 있었습니다.
이 글에서는 타입 충돌 에러의 원인을 분석하고 해결한 과정을 다루어 보겠습니다.
pnpm의 기본 의존성 관리 개념
pnpm은 기존 패키지 매니저(npm, yarn v1)가 가진 비효율성과 구조적 한계를 극복하기 위해 독창적인 의존성 관리 방식을 사용합니다. 본격적인 node_modules 구조를 살펴보기 전에, pnpm 의존성 관리의 핵심이 되는 두 가지 동작 원리를 먼저 알아보겠습니다.
- Global Store(글로벌 스토어)와 Hard Link(하드 링크): pnpm은 패키지 파일을 시스템의 글로벌 스토어(Content-addressable store)에 단 한 번만 저장합니다. 프로젝트에 패키지를 설치할 때는 파일을 일일이 복사하는 대신 이 원본 파일에 대한 하드 링크를 생성하여 참조합니다. 이를 통해 디스크 공간을 획기적으로 절약하고 설치 속도를 높입니다.
- Symbolic Link(심볼릭 링크) 기반의 엄격한 격리: 기존 npm은 호환성을 위해 node_modules를 평탄화(Flat) 하여 모든 의존성을 최상단으로 끌어올렸고, 이로 인해 package.json에 명시하지 않은 패키지도 코드에서 불러올 수 있는 ‘유령 의존성(Phantom Dependencies)’ 문제가 발생했습니다. 반면 pnpm은 모든 패키지를 .pnpm 가상 스토어에 flat하게 저장한 뒤, 프로젝트가 직접 의존하는 패키지만 심볼릭 링크를 사용해 원래의 폴더 위치로 연결함으로써 유령 의존성이 원천 차단된 격리 환경을 만듭니다.

pnpm node_modules의 3 Layers
pnpm 동작 방식을 바탕으로 실제 구성된 pnpm의 node_modules 구조를 살펴보면, 크게 3개의 계층(Layer)으로 구분할 수 있습니다.

1. Layer 1: 앱의 node_modules(Strict Interface Layer)
이 계층은 우리가 작업하는 애플리케이션과 가장 가까운, 즉 직접 마주하는 인터페이스 역할을 합니다.
- 위치: apps/*/node_modules/
- 제어 주체: 각 앱의 package.json (dependencies, devDependencies)
- 핵심 특징:
- 엄격한 접근 제어: package.json에 명시적으로 선언한 의존성만 존재합니다. 선언하지 않은 패키지는 절대 불러올 수 없으며, 이로 인해 유령 의존성이 원천적으로 차단됩니다.
- 심볼릭 링크(Symbolic link) 활용: 이곳에 있는 패키지들은 실제로 설치된 파일이 아닙니다. 모두 virtual store를 가리키는 심볼릭 링크로 연결되어 있습니다.
- 독립성 보장: 심볼릭 링크를 사용하기 때문에, 모노레포 내의 각 앱은 서로 간섭 없이 독립적인 버전의 패키지(예: 앱 A는 React 18, 앱 B는 React 19)를 가질 수 있습니다.
2. Layer 2: .pnpm 가상 저장소(Virtual Store & Dependency Context)
Layer 1의 심볼릭 링크들이 가리키는 실제 목적지이자, pnpm이 관리하는 의존성 그래프의 실제 구현체입니다.
- 위치: 모노레포 최상단의 node_modules/.pnpm/@/
- 제어 주체: 패키지 자체의 package.json (dependencies, peerDependencies)
- 핵심 특징:
- Flat한 구조: 모든 직접/간접 의존성이 고유한 디렉터리 이름으로 평탄화(Flat) 되어 격리 저장됩니다.
- 하드 링크(Hard Link): 패키지의 실제 본체는 전역 스토어(Global Store)에 단 한 번만 저장되며, 이곳에는 하드 링크로 연결되어 디스크 용량을 획기적으로 절약합니다.
- peerDependency에 따른 영리한 격리: 동일한 패키지와 버전이더라도, 함께 사용되는 피어 의존성(peerDependencies) 조합이 다르면 서로 다른 디렉터리로 분리하여 저장합니다.
예시(@sentry/react와 react의 관계)
.pnpm/@sentry+react@9.39.0_react@18.2.0/ ➔ React 18과 결합된 Sentry
.pnpm/@sentry+react@9.39.0_react@19.2.4/ ➔ React 19와 결합된 Sentry
이처럼 피어 조합마다 별도의 컨텍스트(Entry)를 생성하여 의존성 충돌을 완벽하게 방지합니다.
3. Layer 3: 내부 호이스팅 레이어(Internal Hoisting / The Fallback Layer)
Node.js가 Layer 1과 2에서 모듈을 찾지 못했을 때 마지막으로 탐색하는 최후의 보루(Fallback)이자, pnpm의 호이스팅 결과가 저장되는 숨겨진 공간입니다.
- 위치: node_modules/.pnpm/node_modules/
- 제어 주체: .npmrc의 hoist-pattern 설정 (기본값: ['*'] 즉, 모두 호이스팅)
- 핵심 특징 및 주의점:
- 존재 이유: pnpm의 엄격한 구조를 미처 고려하지 못하고, 유령 의존성에 기대어 잘못 작성된 외부 패키지들이 동작할 수 있도록 돕는 안전망 역할입니다.
- 치명적 제약:
이곳에는 동일한 패키지의 여러 버전 중 오직 하나만 올라갈 수 있습니다.
예를 들어, 모노레포 내에 @types/react v18과 v19가 혼재한다면 내부 알고리즘에 의해 단 하나만 이 디렉터리에 자리 잡게 됩니다. - 비결정적(Non-deterministic) 결과:
어떤 버전이 호이스팅될지는 pnpm 내부 알고리즘에 따르며, lockfile을 생성하거나 재생성할 때마다 결과가 달라질 수 있어 예기치 않은 버그의 원인이 될 수 있습니다.
TypeScript의 모듈 해석 순서가 가져온 문제
문제는 TypeScript가 모듈을 해석하는 방식에 있었습니다. TypeScript는 해당 파일의 물리적 실제 경로를 기준으로 상위 폴더를 탐색합니다.
서드파티 라이브러리 내부 코드(Layer 2에 위치)에서 import 'react'를 호출하면 다음과 같은 순서로 탐색을 시작합니다.
Layer 2(자기 자신의 폴더) ⇒ Layer 3(호이스팅 레이어)
문제의 원인은 바로 Layer 3(호이스팅 레이어)에 있었습니다.
해결 과정: 호이스팅 차단부터 의존성 주입까지
원인 분석: 호이스팅 레이어의 참조 오류
Layer 3에는 동일한 패키지의 여러 버전 중 단 하나의 버전만 호이스팅된다는 특징이 있습니다.
만약 pnpm이 @types/react@19.0.8을 Layer 3로 끌어올리게 되면 어떤 문제가 발생할까요?
React 18 앱에서 사용 중인 서드파티 패키지들이 자신의 스코프(Layer 2) 내에서 @types/react를 찾지 못할 경우, 상위 경로인 Layer 3까지 올라가서 React 19의 타입을 참조해버리는 문제가 발생합니다. 여기서 앞서 겪었던 타입 충돌 에러가 발생한 것입니다.
1차 해결 시도: 호이스팅 대상에서 제외하기
문제는 hoist되는 @types를 우리가 임의로 지정할 수 없다는 점입니다.
따라서 문제가 되는 @types/react와 @types/react-dom이 아예 호이스팅되지 못하도록 .npmrc 설정을 변경하여 원천 차단했습니다.
호이스팅 레이어에서 @types/react를 제거해 의도치 않게 다른 버전의 @types/react를 참조하지 못하도록 해준 것입니다.
# .npmrc # React 18/19 공존: @types/react 내부 hoisting 방지 hoist-pattern[]=* hoist-pattern[]=!@types/react hoist-pattern[]=!@types/react-dom호이스팅을 막은 뒤, 드디어 버전 충돌 이슈가 전부 해결되었을까요? 아쉽게도 그렇지 않았습니다.
기존의 ‘타입 충돌’ 에러는 사라졌지만, 이번에는 새로운 형태의 타입 에러가 발생했습니다.
새로운 문제 직면: 호이스팅 차단이 불러온 나비효과(누락된 의존성)
앞서 잘못된 타입 참조를 막기 위해 Layer 3에서 호이스팅을 원천 차단했습니다.
그런데 바로 이 조치 직후, 이번엔 서드파티 라이브러리인 @sentry/react에서 React 타입을 찾지 못해 any로 추론해버리는 타입에러가 발생했습니다.
// @sentry/react의 ErrorBoundary에서 발생한 에러 (JSX 컴포넌트 타입 추론 실패) TS2786: 'ErrorBoundary' cannot be used as a JSX component. Its instance type 'ErrorBoundary' is not a valid JSX element. Type 'ErrorBoundary' is missing the following properties from type 'ElementClass': context, setState, forceUpdate, props, refs원인을 분석해 보니 상황은 이랬습니다.
- 정상적인 패키지: peerDependencies에 @types/react를 명시해 두었습니다. 덕분에 Layer 2 스코프 내에 심볼릭 링크로 @types/react을 잘 가지고 있었습니다.
- 문제가 된 패키지(예: @sentry/react): peerDependencies에 @types/react를 명시하지 않았습니다. devDependencies에만 @types/react를 명시하고 있기 때문에 pnpm은 virtual store의 @sentry/react 디렉터리 하위에 @types/react를 따로 설치하지 않게 됩니다.
즉, 이전에는 @sentry/react 폴더(Layer 2)에 타입이 없어도 상위 폴더(Layer 3)에 ‘우연히 호이스팅된’ 타입을 몰래 가져다 쓰고 있었던 것입니다.
하지만 앞선 조치로 호이스팅을 원천 차단했기 때문에 TypeScript가 @sentry/react 내부에서 React 타입을 찾지 못해 any로 추론해버린 것입니다.
최종 해결: packageExtensions로 의존성 주입
라이브러리가 명시적으로 선언하지 않아 누락된 의존성을 해결하기 위해, pnpm의 packageExtensions 기능을 도입했습니다.
packageExtensions는 서드파티 패키지의 package.json을 가상으로 패치하는 기능입니다. 패키지 자체를 수정하지 않고, pnpm의 의존성 해석 단계에서 추가 의존성을 주입합니다.
// package.json { "pnpm": { "packageExtensions": { "@sentry/react": { "peerDependencies": { "@types/react": "*" } } } } }@sentry/react의 package.json에 peerDependencies: { "@types/react": "*" }가 선언된 것처럼 취급하여 @sentry/react는 peerDependencies 별로 virtual store에 각각 저장됩니다.
버전을 "*"로 설정하면, pnpm은 @sentry/react가 사용되는 각 앱(Layer 1)에 설치된 버전을 따르도록 합니다.
[적용 전후 디렉터리 구조 변화]
이제 Layer 2의 @sentry/react 디렉터리에 우리가 주입한 @types/react가 정상적으로 심볼릭 링크로 들어간 것을 확인할 수 있습니다.
// ❌ 적용 전: 타입 의존성 누락 .pnpm/@sentry+react@9.39.0_react@18.2.0/node_modules/ ├── @sentry/react/ ├── react/ └── (no @types/) // ✅ 적용 후: @types/react 배치 완료! .pnpm/@sentry+react@9.39.0_@types+react@18.2.21_react@18.2.0/node_modules/ ├── @sentry/react/ ├── @types/react/ ← @types/react@18.2.21 주입 성공 ├── react/ └── hoist-non-react-statics/packageExtensions의 한계점
이 방식으로 타입 오염을 성공적으로 해결했지만, 누락된 패키지를 발견할 때마다 수동으로 주입해야 한다는 유지보수 한계가 존재합니다.
pnpm 공식 문서(Working with TypeScript)에서는 이에 대한 대안으로 @pnpm/plugin-types-fixer 플러그인을 소개하고 있습니다. 아쉽게도 현재 해당 플러그인과 관련된 이슈(#10698)가 v11에만 수정될 예정이라 당장 실무에 완벽하게 적용하기에는 무리가 있어 보입니다. 향후 릴리즈될 pnpm v11 버전부터는 원활하게 사용할 수 있을 것으로 예상되니, 추후 마이그레이션 시 참고하시면 좋을 것 같습니다.
🎉 마침내 모든 타입 에러가 사라졌습니다!
packageExtensions 설정까지 마친 후 다시 빌드를 실행해 보니, 더 이상 엉뚱한 타입을 참조하지 않고 정상적으로 타입 이슈 없이 빌드까지 완료되었습니다.
잠깐, @types/react를 누락한 서드파티 라이브러리가 @sentry/react뿐이었을까요?
문제는 성공적으로 해결했지만, 한 가지 의문이 남았습니다. peerDependencies 혹은 dependencies에 @types/react를 선언하지 않은 라이브러리가 과연 @sentry/react뿐이었을까요?
아닙니다. 또 다른 라이브러리인 @tanstack/react-query 역시 @types/react가 선언되어 있지 않았습니다.
같은 조건인데 왜 라이브러리마다 결과가 달랐을까요?
@sentry/react의 타입 에러는 아래 코드에서 발생하고 있었습니다.
import { ErrorBoundary } from '@sentry/react'; <ErrorBoundary fallback={<GlobalErrorState />}> ... </ErrorBoundary>
해답은 각 라이브러리가 React 타입을 코드 내부에서 어떻게 활용하고 있는지에 있습니다.
[에러 발생] @sentry/react
@sentry/react는 ErrorBoundary처럼 JSX로 직접 사용하는 클래스 컴포넌트를 export합니다.
// @sentry/react/.../errorboundary.d.ts import * as React from 'react'; export declare class ErrorBoundary extends React.Component<...> { ... }React 타입이 any로 떨어지면 React.Component도 any가 되어, ErrorBoundary 인스턴스 타입이 React 컴포넌트 인스턴스처럼 해석되지 않습니다.
그 결과 앱에서 <ErrorBoundary>를 사용할 때 TypeScript가 JSX 컴포넌트 타입 체크를 수행하며 타입 에러(TS2786)가 발생하게 됩니다. “props, setState 같은 property가 없기 때문에 너는 JSX가 아니야”라고 TypeScript가 말하고 있는 것이죠.
[에러 없음] @tanstack/react-query
반면 @tanstack/react-query는 React 타입을 주로 인터페이스나 훅 타입 같은 타입 참조에 사용하며, JSX로 직접 쓰는 클래스 컴포넌트를 export하지 않는 경우가 많습니다.
import type * as React from 'react'; export interface ContextOptions { context?: React.Context<...>; }패키지 내부의 .d.ts에서 React 타입을 찾지 못해 발생하는 자체 에러는 tsconfig.json의 skipLibCheck: true 옵션에 의해 무시됩니다. 그 결과 앱 코드에서 이를 가져다 쓸 때 React 타입이 any로 떨어지더라도 context?: any처럼 타입 검사만 약해질 뿐, 타입 에러가 촉발되지 않아 문제가 "조용히" 지나갈 수 있었던 것입니다.
다른 대안은 없을까?
앞서 적용한 packageExtensions 방식이 현재 모노레포의 장점을 해치지 않으면서 가장 안정적으로 운영할 수 있는 해결책이었습니다.
트러블슈팅 과정에서 다른 대안들도 충분히 검토해 보았으나, 다음과 같은 이유로 최종 도입을 반려했습니다.
1. tsconfig.json의 paths 설정으로 우회하기
// tsconfig.json "paths": { "react": ["./node_modules/@types/react"] }반려 이유
- 모듈 해석 첫 단계에서 강제로 경로를 변경하는 방식입니다. 하지만 Layer 2에서 정상적으로 해석된 타입과 경로가 달라지면 TypeScript가 이를 서로 다른 모듈로 인식하여 호환성 에러를 발생시킬 수 있습니다. 또한 모든 앱의 tsconfig를 일일이 수정해야 하는 유지보수 비용이 발생합니다.
- 또한 팀 내에서 사용하고 있는 tsconfigPaths 플러그인과도 충돌이 발생해 정상적으로 해결할 수 없었습니다.
2. .npmrc의 shared-workspace-lockfile=false 설정
shared-workspace-lockfile=false 옵션은 각 워크스페이스 패키지가 독립적인 lockfile과 가상 스토어(virtual store)를 가지도록 하여, 의존성을 완전히 격리하는 방식입니다.
반려 이유
- 각 앱이 완전히 독립적인 의존성을 갖게 되므로 당장의 에러는 피할 수 있습니다. 하지만 중복 설치로 인한 용량 증가, 설치 속도 저하, 다수의 lockfile 관리로 인한 병합 충돌 등 모노레포를 쓰는 의미와 장점을 통째로 포기하는 것이라 도입할 수 없었습니다.
- pnpm workspace 내 모든 프로젝트를 빠른 시일 내에 마이그레이션할 수 있다면 고려해 볼 수 있지만, 그렇지 못한 환경에서는 리스크가 있다고 판단되었습니다.
마치며
지금까지 pnpm workspace를 사용한 모노레포 환경에서 React 19 마이그레이션을 진행하며 겪었던 타입 충돌 이슈와 그 해결 과정을 살펴보았습니다.
단순히 눈앞의 에러를 해결하기보다는 pnpm의 node_modules 의존성 구조를 파악해 정확한 원인을 분석해보았습니다. 호이스팅 설정을 통해 유령 의존성을 차단하고, packageExtensions를 통해 서드파티 라이브러리의 누락된 의존성을 명시적으로 주입했습니다. 이를 통해 다중 버전이 혼재된 복잡한 모노레포 환경에서도 패키지 간의 격리를 더 안전하게 제어할 수 있게 되었습니다.
pnpm 모노레포에서 멀티 리액트 앱을 운영하거나 알 수 없는 타입 에러를 마주쳤을 때 이 글이 조금이나마 도움이 되기를 바랍니다.
배달의민족 주문 도메인에서 웹 프론트엔드 개발을 담당하고 있습니다.








English (US) ·