모노레포에서 심볼릭 링크가 번들링을 망치는 순간
도입
모노레포를 쓰다 보면 한 번쯤 마주치는 에러가 있다.
"Invalid hook call. Hooks can only be called inside the body of a function component."
코드를 아무리 봐도 Hook 사용법에 문제가 없다. 원인은 엉뚱한 곳에 있다 — 번들러가 심볼릭 링크를 **실제 경로(realpath)**로 풀어버리면서, React가 두 번 로드된 것이다.
이 글에서는 모노레포 환경에서 심볼릭 링크와 번들러가 충돌하는 구조적 원인을 짚고, preserveSymlinks 설정의 역할과 한계를 정리한다.
워크스페이스가 만드는 심볼릭 링크 구조
yarn/npm/pnpm 워크스페이스는 로컬 패키지를 node_modules에 심볼릭 링크로 연결한다.
monorepo/
├── node_modules/
│ └── @my/ui → ../../packages/ui ← 심볼릭 링크
├── packages/
│ └── ui/ ← 실제 코드
│ └── src/Button.tsx
└── apps/
└── web/
└── src/App.tsx ← import { Button } from '@my/ui'
개발자 입장에서는 @my/ui를 npm 패키지처럼 import하면서, 수정하면 바로 반영되는 편리한 구조다. 문제는 번들러가 이 심볼릭 링크를 어떻게 해석하느냐에서 시작된다.
번들러의 기본 동작: realpath 해석
Webpack과 Vite는 기본적으로 심볼릭 링크를 실제 경로로 풀어서 처리한다.
import { Button } from '@my/ui'
# 번들러가 해석한 경로
심볼릭 경로: node_modules/@my/ui/src/Button.tsx
↓ realpath 변환
실제 경로: packages/ui/src/Button.tsx ← 이걸 기준으로 동작
파일 내용은 같지만, 경로가 달라지면 모듈 해석의 시작점이 바뀐다. 여기서 세 가지 문제가 연쇄적으로 발생한다.
문제 1: 의존성 탐색 경로가 달라진다
Node.js 모듈 해석은 현재 파일 위치에서 상위로 node_modules를 찾아 올라간다.
# 심볼릭 경로 기준 (정상)
node_modules/@my/ui/ → 상위 → node_modules/react ✅
# realpath 기준 (문제)
packages/ui/ → packages/ → monorepo/
→ 운이 좋으면 monorepo/node_modules/react를 찾지만
→ packages/ui/node_modules/react가 있으면 그걸 먼저 찾음
같은 import 'react'인데, 어느 경로에서 탐색을 시작하느냐에 따라 다른 react를 로드할 수 있다.
문제 2: React 이중 인스턴스 — Hook 에러의 진짜 원인
위 문제의 직접적인 결과다. 앱과 패키지가 각각 다른 경로에서 React를 로드하면:
apps/web → node_modules/react (인스턴스 A)
packages/ui → packages/ui/node_modules/react (인스턴스 B)
React Hook은 동일한 React 인스턴스 내에서만 작동한다. 두 인스턴스가 로드되면 Hook 내부의 전역 상태가 공유되지 않아 "Invalid hook call" 에러가 발생한다.
특히 이 에러는 코드상으로는 문제가 전혀 보이지 않기 때문에 디버깅이 까다롭다.
문제 3: 번들러 룰이 적용되지 않는다
// webpack.config.js
{
test: /\.tsx?$/,
include: path.resolve(__dirname, 'src'), // apps/web/src
use: 'babel-loader'
}
@my/ui의 코드가 packages/ui/src/(realpath)로 풀리면, apps/web/src에 대한 include 룰에 매칭되지 않는다. TSX가 트랜스파일 없이 번들에 들어가면서 문법 에러가 터진다.
해결: preserveSymlinks
심볼릭 링크를 realpath로 풀지 않도록 지시하는 설정이다.
Webpack:
// webpack.config.js
resolve: {
symlinks: false
}
Vite:
// vite.config.ts
resolve: {
preserveSymlinks: true
}
이 설정을 켜면 번들러가 node_modules/@my/ui/를 그대로 유지하므로, 모듈 탐색이 node_modules/ 하위에서 시작되어 공유 의존성을 정상적으로 찾는다.
preserveSymlinks만으로 충분한가?
아니다. 몇 가지 부작용이 있다:
- 캐싱 중복: 같은 파일이 심볼릭 경로와 실제 경로 두 개로 캐싱될 수 있다
- 순환 참조 감지 실패: 같은 파일을 다른 모듈로 인식할 수 있다
- pnpm과의 궁합: pnpm은 자체적인
.pnpm구조로 이 문제를 우회하므로, 이 설정이 오히려 역효과를 낼 수 있다
실무에서는 preserveSymlinks 단독보다 다음을 함께 조정하는 경우가 많다:
resolve.alias로 공유 의존성 경로를 명시적으로 고정resolve.modules로 탐색 디렉토리를 직접 지정- 패키지 매니저의 hoisting 설정 조정 (
.npmrc의shamefully-hoist등)
마무리
모노레포의 심볼릭 링크는 편의를 위한 장치지만, 번들러의 realpath 해석과 만나면 의존성 탐색 경로가 의도와 달라지는 구조적 문제를 일으킨다. "코드에는 문제가 없는데 왜 안 되지?"라는 상황의 상당수가 이 지점에서 발생한다.
핵심은 단순하다. 번들러가 파일의 "위치"를 어떻게 판단하느냐가 모듈 해석의 모든 것을 결정한다는 것이다.