pnpm 워크스페이스와 모노레포: Railway CI를 견디는 설정과 문서가 예상치 못한 문제

발행: (2026년 6월 19일 PM 09:03 GMT+9)
9 분 소요
원문: Dev.to

출처: Dev.to

pnpm 워크스페이스와 모노레포: CI에서 Railway에 살아남은 설정과 Docs가 예상하지 못한 문제들

TypeScript 모노레포의 install을 가속화하는 올바른 방법은 패키지 분해에 더 많은 제약을 추가하는 것입니다.

이것은 이상하게 들릴 수 있지만, 직관에 따라 ‘문제가 발생하면 설정을 완화하라’고 생각합니다. 하지만 pnpm 워크스페이스에서는 호스팅을 완화하는 것이 오히려 CI를 안정적인 상태에서 불안정한 상태로 만드는 원인이 됩니다.

제 tesis는 이다: pnpm 워크스페이스가 2026년 TypeScript 모노레포에 가장 좋은 선택이지만, Docs의 행복한 경로는 CI에서 실제 배포와 함께 나타나는 세 가지 함정을 숨기고 있다. 이는 드문 엣지 케이스가 아니라 로컬에서는 잘 작동하고 Railway 첫 번째 배포 시 README에 전혀 나타나지 않는 오류가 발생하는 상황을 정확히 설명한다.

이 포스트는 초기 설정 가이드가 아니다. 이미 pnpm-workspace.yaml을 가지고 로컬에서 모노레포가 정상적으로 작동하고, CI에서 문서에 바로 찾아볼 수 없는 방식으로 문제가 발생하는 사후 분석이다.

pnpm 워크스페이스의 실제 상태: Docs가 말하고 있는 것과 omission한 것

[pnpm 워크스페이스 공식 문서](https://pnpm. io/workspaces)는 기본 메커니즘을 잘 설명한다: 루트에 pnpm-workspace.yaml 파일을 두어 패키지를 정의하고, workspace:* 프로토콜로 내부 의존성을 지정하며, 루트에서 pnpm install을 실행하면 전체 그래프를 해결한다.

Docs는 CI 환경에서 pnpm 로컬 스토어 없이 그래프가 재구성되는 경우가 어떻게 되는지 명시적으로 설명하지 않는다. 개발 머신에서는 pnpm 의 컨텐츠 주소 지정 저장소가 전全局 캐시 역할을 하여 many resolution errors를 가려낸다. Railway에서는 각 빌드가 처음부터 시작하므로, 이러한 함정이 나타난다.

기본적으로 동작하는 최소 설정:

# pnpm-workspace.yaml — 루트 디렉터리
packages:
   -  'apps/*'       # Next.js, API, 서비스
   -  'packages/*'   # UI 컴포넌트, 유틸, 공유 설정

// 패키지 루트 - 조율 스크립트

{
   "private": true,
   "scripts":  {
     "build":  "pnpm  --filter='./apps/*' build",
     "dev":  "pnpm  --filter='./apps/*' dev  --parallel",
     "typecheck": "pnpm -r typecheck"
   },
   "engines": {
     "node": ">="20,
     "pnpm": ">="9
   }
}

Docs가 예상하지 못한 세 가지 함정

함정 1: CI에서의 Phantom 종속성

pnpm 워크스페이스의 가장 조용한 문제인 Phantom 종속성은 npm과 Yarn Classic에서는 node_modules가 평평하게 만들어 어떤 패키지도 트리 안에 설치된 다른 패키지를 import할 수 있게 허용한다. pnpm은 디자인상 이를 깨고 있다: 각 패키지는 명시적으로 선언한 것만 접근할 수 있다.

문제는 로컬에서는 직간접 의존성이 lodash를自身의 의존성으로 가지고 있다면 선언하지 않아도 동작할 수 있다는 점이다. CI에서 처음 시작하면 해결이 달라져 그 import가 실패한다.

CI에 발견되기 전에 이를 진단하는 방법은 다음과 같다:

# 루트 디렉터리에서 실행 — 선언되지 않은 사용된 의존성 목록
pnpm  --filter='./apps/dashboard' ls  --depth 0

# 로컬에서 엄격한 해상도를 강제하는 대안
# .npmrc 에서 node-linker=isolated 설정

함정 2: Railway에서 shamefully- hoist — 아무도 말하지 않는 트레이드오프

shamefully- hoist 문서(https://pnpm. io/npmrc#shamefully- hoist)는 정직하다: 이름은 의도적이고, pnpm이 필요한 악을 최소한으로 허용하는 호환성 concede라는 의미다.”

Railway는 배포한 서비스의 디렉터리에서 빌드를 실행하며, 모노레포 루트가 아니라 사용한다. .npmrc 루트에 shamefully- hoist=true를 설정하면 root 디렉터리에서 pnpm install 실행 시 해당 설정이 적용된다. 그러나 Railway의 service 설정에 따라 apps/ api 디렉터리에서 pnpm install을 실행할 경우 .npmrc 루트가 기대에 미치지 않을 수 있다.

shamefully- hoist가 아니라, 어떤 패키지가 호스팅을 필요로 하는지 파악하고 정확히 선언하는 것이 가장 견고한 해결책이다:”

# 루트 디렉터리 — CI에 더 세밀하고 예측 가능한 설정
# 전역 호스팅 대신 필요한 패키지만을 hoist 하라
hoist- pattern[]=*eslint*
hoist- pattern[]=*prettier*
hoist- pattern[]=*typescript*

특히 Railway에서는 가장 안정한 설정은 루트에서 배포하고 서비스의 빌드 명령을 필터링하도록 구성하는 것이다:

# Railway 서비스 apps/api용 빌드 명령어
pnpm  --filter=api build

Install command:

# Railway — 항상 루트에서 설치하라
pnpm install  --frozen- lockfile

함정 3: 스크립트 필터링이 예상치 못한 것을 필터링하지 않음

pnpm --filter는 강력하지만 워크스페이스 간 의존성 처리 방식이 특수하여 처음엔 거의 الجميع을 혼란스럽게 만든다.

# 모노레포에서 내부 의존성으로는 예상대로 동작하지 않는다
pnpm  --filter=dashboard build

# dashboard가 packages/ui에 의존한다면 이 명령은 실패할 수 있다
# packages/ui가 아직 빌드되지 않았기 때문이다

--filter 플래그는 패키지를 선택하지만, 내부 의존성 그래프의 빌드 순서를 자동으로 해결하지 않는다 — 올바른 플래그를 사용해야만 한다:

# ✅ 올바른 그래프 순서로 빌드한다
pnpm  --filter=dashboard... build
# ✅ 'dashboard'와 dashboard가 의존하는 모든 것을 의미한다

# ✅ 더 명확하게: 순서대로 재귀적으로 빌드한다
pnpm -r  --filter=dashboard... build

문서에는 언급하지만 --filter=dashboard--filter=dashboard...의 차이는 발음이 쉬운 각주에 적혀 있어 쉽게 놓치기 쉽다. 필터링과 또 다른 gotcha: --parallel와 순서형은 서로 배타적이다. --parallel을 사용하면 pnpm는 의존성 그래프를 무시하고 스크립트를 병렬로 실행한다. 이는 dev에 유용하다 (모든 워치러를 동시에 올릴 때).

0 조회
Back to Blog

관련 글

더 보기 »