홈으로
2026년 4월 7일
frontendmonorepobundlersymlink

모노레포에서 심볼릭 링크가 번들링을 망치는 순간

모노레포 워크스페이스의 심볼릭 링크가 번들러의 realpath 해석과 충돌하면서 생기는 문제들과 해결법을 정리한다.

모노레포에서 심볼릭 링크가 번들링을 망치는 순간

도입

모노레포를 쓰다 보면 한 번쯤 마주치는 에러가 있다.

"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가 트랜스파일 없이 번들에 들어가면서 문법 에러가 터진다.

심볼릭 링크를 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 설정 조정 (.npmrcshamefully-hoist 등)

마무리

모노레포의 심볼릭 링크는 편의를 위한 장치지만, 번들러의 realpath 해석과 만나면 의존성 탐색 경로가 의도와 달라지는 구조적 문제를 일으킨다. "코드에는 문제가 없는데 왜 안 되지?"라는 상황의 상당수가 이 지점에서 발생한다.

핵심은 단순하다. 번들러가 파일의 "위치"를 어떻게 판단하느냐가 모듈 해석의 모든 것을 결정한다는 것이다.

참고 자료