Yarn Classic에서 Pnpm으로 전환하기 with TurboRepo

Eunsonny
원티드랩 기술 블로그
15 min readFeb 7, 2024

안녕하세요 저는 원티드랩 HR솔루션사업팀에서 프론트엔드 개발을 하고 있는 신은선입니다.

HR솔루션사업부에서는 팀 디렉토리, 근태, 전자결재/계약부터 슬랙 연동까지 모든 HR데이터를 쉽고 편하게 관리할 수 있는 HR 솔루션 SaaS인 원티드 스페이스를 만들고 있습니다.

이번 글에서는 원티드 스페이스 프로젝트의 패키지 매니저를 Yarn Classic에서 Pnpm으로 전환한 과정에 대해서 공유해보려 합니다.

배경

Yarn Classic이 가진 문제

원티드 스페이스 프로젝트는 Turbo Repo를 사용한 모노레포 구조에 패키지 매니저로는 Yarn Classic을 사용하고 있었습니다. Yarn Classic이란 Yarn 1.x 버전을 지칭 하는데요. 성능면에서 다소 느릴 뿐만 아니라 의존성 중복 저장 문제를 호이스팅을 통해 해결하기 때문에 유령 의존성 현상을 야기 할 수 있다는 단점이 존재합니다.

특히 모노레포 구조에서는 하나의 레포지토리에서 여러 프로젝트의 의존성을 관리해야 하기 때문에 프로젝트가 서로 다른 프로젝트의 의존성에 의존하는 등 더 빈번하고 크리티컬한 유령 의존성 현상이 발생할 수 있습니다. 실제로 원스 프로젝트에서도 depcheck를 통해 확인한 결과 다양한 유령 의존성이 존재함을 알 수 있었습니다.

유령 의존성 현상이란

과연 이 유령 의존성 현상이란 무엇이며 왜 문제가 되는 것 일까요? 한 번 짚고 넘어가겠습니다.

https://classic.yarnpkg.com/blog/2018/02/15/nohoist/
https://classic.yarnpkg.com/blog/2018/02/15/nohoist/

왼쪽과 같은 의존성 트리를 가진 프로젝트가 있다고 가정해 보겠습니다. 왼쪽 트리에서 [A (1.0)]과 [B (1.0)]을 두 번 설치 하는 것은 디스크 공간 측면에서 비효율적입니다. 따라서 Npm(ver 3~), Yarn Classic은 호이스팅 & 병합을 통해 오른쪽 트리와 같이 평탄화(flat) 된 종속성 트리로 모양을 바꿉니다. (놀랍게도 Npm ver 2 까지는 모든 의존성을 중복 설치했다고 합니다.) 이를 통해 디스크 공간을 절약하고 트리 경로 깊이 내려가지 않아도 최상위에서 원하는 의존성을 탐색할 수 있게 되므로 보다 효율적입니다.

그러나 이로 인해 프로젝트에서 직접 의존하고 있지 않은 패키지(위의 이미지 에서는 [B (1.0)]에 해당)를 암묵적으로 참조하게 되는 경우가 발생합니다. 이것이 바로 유령 의존성 현상입니다. 이처럼 유령 의존성 현상이 발생하면 어떠한 의존성 파일을 지웠을 때 암묵적으로 참조 했던 패키지도 삭제될 수 있는 등 의존성 트리의 유효성을 보증 받기 어렵습니다.

해결책

Why Pnpm?

앞서 언급한 문제점을 해결할 수 있는 방법으로는 크게 두 가지가 있습니다. 바로 Yarn Berry와 Pnpm 입니다. Yarn Berry 에서는 Plug’n’Play 전략을 통해 종속성 중복 저장 문제를 해결하며 호이스팅을 사용하지 않는 nohoist가 기본 값입니다.

https://github.com/vercel/turbo/issues/693#issuecomment-1278886166

그러나 원스 프로젝트는 Turbo Repo로 구성 되어 있고 Turbo Repo에서는 Yarn Berry의 PnP를 지원하지 않기 때문에 실질적인 선택지는 Pnpm 하나 뿐 이였습니다. 하지만 Pnpm은 Npm 또는 Yarn Classic과 비교 했을 때 더 좋은 성능과 보안을 제공 하므로 객관적으로도 매력적인 옵션이라고 생각합니다.

https://pnpm.io/benchmarks

또한 Pnpm은 Npm과 사용법이 비슷하며 마이그레이션 과정이 단순하다는 점도 장점이라 생각되었습니다.

결론적으로 Pnpm을 선택한 이유를 정리해보자면 다음과 같습니다.

  • 유령 의존성 문제를 해결하면서 현재 사용하고 있는 Turbo Repo와의 궁합이 가장 좋은 조합
  • 기존에 사용하던 Yarn Classic과 비교해 설치와 실행이 빠르고 더 나은 보안성을 제공함
  • Npm과 사용법이 비슷하며 (Yarn Berry와 비교하여) 마이그레이션 과정이 단순함

Pnpm이 종속성 중복 저장 문제를 해결하는 방법

그렇다면 Pnpm은 어떤 방법으로 종속성 중복 저장 문제를 해결하고 유령 의존성을 없애는 것 일까요? Pnpm은 호이스팅 대신 Content-addressable Store 방식을 통해 중복 의존성 저장 문제를 해결합니다. 전역 스토어에 종속성을 설치한 다음 심볼릭 링크와 하드링크를 사용하여 디렉터리 구조를 구성하는 방식인데요 좀 더 자세히 살펴보자면 다음과 같습니다.

처음 Pnpm은 <home dir>/.pnpm-store에 종속성을 설치합니다. 이곳이 전역 스토어 이며 모든 종속성 버전은 해당 폴더에 물리적으로 한번만 저장 됩니다.

그런 다음 프로젝트에서 필요한 패키지를 프로젝트의 최상위 node_modules/.pnpm 경로에 하드링크합니다. 여기서 말하는 필요한 패키지란 package.json에 나열되어 프로젝트에서 직접적으로 의존하는 의존성 뿐만 아니라 의존성의 의존성까지 포함합니다. 이렇게 필요한 모든 종속성은 node_modules/.pnpm디렉터리 아래에 평탄화 되어 위치합니다.

이후 node_modules의 최상위 종속성(프로젝트에서 직접적으로 의존하는 패키지를 의미합니다.)과 의존성의 의존성들을 평탄화 되지 않은 형태로 중첩되어(nested) 존재하며node_modules/.pnpm에 위치한 것들과 Symbolic Link로 연결됩니다.

예시로 demo-foo@ 1.0.1 과 demo-baz@ 1.0.0에 의존성을 가지는 node_modules의 구조를 그려본다면 다음과 같습니다.

아직도 감이 안오신다면 다음은 프로젝트 구조에서 심볼릭 링크와 하드 링크가 어떻게 구성되는지 더 잘 이해할 수 있도록 도와주는 이미지입니다.

결과적으로 모든 종속성은 전역 스토어에 물리적으로 한번만 저장되어 단일한 진실 공급원(Single Source Of Truth)를 구성하고 이를 통해 디스크 공간을 크게 절약합니다. 또한 node_modules/.pnpm디렉터리 아래에 하드링크된 의존성들을 평탄화 하여 위치 시킴으로써 유령 의존성 문제와 Npm 도플갱어 문제를 해결합니다.

Pnpm 도입 과정

자 그럼 실질적인 Pnpm 도입 과정에 대해서 설명하겠습니다. 원티드 스페이스 프로젝트는 Turbo Repo로 구성한 모노레포에 Pnpm을 도입 하였으므로 이를 참고해주세요!

  1. brew install -g pnpm 을 통해 Pnpm을 전역으로 설치 합니다.
  2. Pnpm의 workspace 기능을 사용하면 모노레포를 지원할 수 있습니다. 모노레포 프로젝트의 루트에 pnpm-workspace.yaml 파일을 생성하고 workspace를 선언합니다.
packages:
- "docs"
- "apps/*"
- "packages/*"

3. App에서 workspace package들을 별칭으로 참조할 수 있도록 apps 내부 프로젝트의 package.json 파일을 수정합니다.

// root/apps/dashboard/package.json

{
"dependencies": {
"@wantedspace-inc/api": "workspace:*",
"@wantedspace-inc/constants": "workspace:*",
"@wantedspace-inc/css": "workspace:*",
"@wantedspace-inc/hooks": "workspace:*",
"@wantedspace-inc/icons": "workspace:*",
...
}
}

4. 프로젝트의 루트에 .npmrc 파일을 생성하여 추가적인 설정을 해줄 수 있습니다. .npmrc에서 node-linker=hoisted 옵션을 사용하면 Yarn Classic과 같은 방식으로, node-linker=pnp로 사용하면 Plug’n’Play 방식으로 사용할 수 있습니다. 다만 저는 디폴드 옵션을 사용하고 추가적인 세팅이 필요하지 않아 .npmrc 파일을 생성하지 않았습니다.

6. 모든 node_modules와 캐시를 삭제하고 pnpm install 명령어를 통해 의존성들을 재설치합니다. pnpm-lock.yaml 파일이 생성되며 기존에 package-lock.json 파일이 존재했다면 무시되니 삭제하셔도 좋습니다. 이후 로컬에서 App이 잘 실행되는지 확인합니다. 기존에 유령 의존성 문제가 존재하지 않았다면 프로젝트가 로컬에서 잘 실행이 되었겠지만 저의 상황은 그렇지 못했습니다. 유령 의존성으로 인한 각종 에러메세지와 만나게 되었습니다.

7. 프로젝트 내에 존재하는 유령 의존성을 좀 더 쉽고 빠르게 찾기 위해 depcheck를 이용했습니다. depcheck는 프로젝트의 종속성들을 분석하여 불필요한 종속성, 유령 의존성 등을 찾아주는 툴 입니다.

depcheck 수행 결과

의존성 분석 결과를 참고하여 유령 의존성은 설치하거나 적절한 의존성을 사용할 수 있도록 수정해주고 불필요한 의존성은 삭제합니다.

8. Resolution 등 의존성과 관련해 설정한 것들이 있다면 이 역시 Pnpm 환경에서 적용될 수 있도록 잘 이관해줍니다.

9. 원스 프로젝트는 Amplify Preview를 스테이징 환경으로 사용하는데요. 여기서 약간의 문제가 있었습니다. Pnpm은 심볼릭 링크를 지원하지 않는 환경에서는 동작하지 않습니다.

따라서 lambda와 같은 서버리스 호스팅 환경이나 Electron App에서는 사용할 수 없습니다. 관련한 이슈를 여기에서 확인할 수 있으며 Pnpm 공식문서에서는 다음과 같은 경우 node-linker=hoisted 옵션을 사용해 해결할 것을 권장합니다. AWS에서도 Turborepo 및 pnpm 모노레포 앱 구성과 관하여 같은 해결책을 제안합니다.

그러나 node-linker=hoisted 옵션을 사용한다는 것은 호이스팅을 이용해 중복 종속성을 해결한다는 뜻이고 이는 Pnpm으로 전환한 의미가 퇴색된다고 생각했습니다. 뿐만 아니라 실제 프로덕션 빌드 시에는 Github Action를 사용하고 있었기 때문에 node-linker=hoisted를 사용하지 않고도 정상적으로 배포가 가능한 상황이였습니다.

따라서 프로젝트 루트에 .npmrc를 생성해 호이스팅 설정을 하는 대신 Amplify 빌드 설정에 echo “node-linker=hoisted” > .npmrc 명령어를 추가했습니다. Amplify 빌드 설정을 담고 있는amplify.yml의 전체 내용은 다음과 같습니다.

version: 1
applications:
- frontend:
phases:
preBuild:
commands:
- npm install -g pnpm
- cd ../../
- echo "$PWD"
- echo "node-linker=hoisted" > .npmrc
- pnpm install --no-frozen-lockfile
build:
commands:
- echo "$PWD"
- if [ $NODE_ENV_VARIABLES = ".env.development" ]; then cat "./$AMPLIFY_MONOREPO_APP_ROOT/$NODE_ENV_VARIABLES" > "./$AMPLIFY_MONOREPO_APP_ROOT/.env.production"; fi
- pnpm run "build:$AMPLIFY_MONOREPO_APP"
artifacts:
baseDirectory: build
files:
- '**/*'
cache:
paths:
- node_modules/**/*
appRoot: apps/dashboard

이를 통해 Amplify Preview 에서 정상적으로 잘 빌드 되는 것을 확인할 수 있었습니다.

10. 이후 프로덕션 빌드를 수행하는 Github Action에서 Pnpm install cache를 사용할 수 있도록 workflow를 변경하였습니다. pnpm/action-setup 플러그인 레포지토리에 사용방법이 친절하게 나와 있으며 코드는 다음과 같습니다.

- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false

- name: Get pnpm store directory
id: pnpm-store-dir-path
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

- name: Setup pnpm cache
uses: actions/cache@v3
id: pnpm-cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-

- name: Install dependencies
run: pnpm install --no-frozen-lockfile

도입 후 결과

이 후 얼마간의 테스트 기간을 거쳐 프로덕션 배포를 성공적으로 진행하였습니다.

Pnpm 마이그레이션 이전과 이후의 node_modules 크기를 비교해 보았을 때 다소 늘어난 것을 확인할 수 있었는데요. 유령 의존성을 직접 의존할 수 있도록 설치한 점, 모노레포 내 프로젝트에서 같은 라이브러리의 서로 다른 버전 사용하는 점 등이 영향을 미쳤을 것으로 예상되어 관련한 최적화 작업을 진행할 계획입니다.

모노레포 내의 A 프로젝트에서는 React 17을 B 프로젝트에서는 React 18을 사용한다고 가정할 경우, node_modules/.pnpm에는 React 17과 18이 모두 나열되고 공간을 차지하게 되어 디스크 공간 측면에서 비효율적입니다. 따라서 가능하다면 프로젝트에서 공통으로 사용하는 의존성들은 같은 버전으로 맞춰주는 것이 이상적인데요. 이 경우 syncpack을 사용하면 각 프로젝트에서 일치 하지 않는 의존성 버전을 분석해주고 autofix 기능도 제공해주니 사용을 고려해볼 수 있겠습니다.

또한 속도 면에서는 Amplify Preview에서는 의존성 설치 시간이 Done in 74.52s → Done in 25.1s 정도로 약 3배 정도 단축 되었으며, Github action에서는 평균적으로 45~50초 정도 걸리던 설치 시간이 25–30초 정도로 줄어드는 등 개선이 이뤄진 것을 확인할 수 있었습니다.

추가적으로 Pnpm과 직접적 연관이 있는 것은 아니나 Turbo Repo를 사용하면서 빌드 툴로 Github Action을 사용하고 있다면 vercel의 Remote Cache를 사용하지 않아도 github actions/cache를 사용하여 CI 단계별 캐싱을 적용해 빌드 속도를 개선할 수 있는 여지도 있습니다. 참고 사항으로 언급해두겠습니다.

마지막으로 저는 과거 다른 프로젝트에서 Yarn Classic → Yarn Berry(PnP)으로 전환한 경험이 있는데요. 당시 node_module을 사용하지 않고 의존성을 .zip 포맷으로 저장하는 Yarn Berry PnP의 특성상 IDE에서 zip 파일로 된 종속성을 읽어올 수 있도록 추가적인 세팅이나 extension을 설치하는 과정이 다소 번거롭게 느껴졌었습니다. 이에 반해 Pnpm으로 변환하는 과정은 보다 단순하여 편리하다는 생각이 들었습니다.

또한 Pnpm을 통해 모노레포 환경에서 엄격한 의존성 관리가 가능하게 되어, 특히 Turbo Repo를 사용하고 있다면 적극 추천하는 선택지이며 Turbo Repo를 사용하지 않더라도 한 번 쯤 도입을 고려해볼만한 매력적인 기술인 것 같습니다.

참고자료

nohoist in Workspaces

JavaScript package managers compared: npm, Yarn, or pnpm? — LogRocket Blog

Inside the pain of monorepos and hoisting

How to Easily Manage Dependencies in a JS Monorepo

How does pnpm work

Configure pnpm for the best possible developer experience | Adam Coster

A story of how we migrated to pnpm — ‹div›RIOTS

--

--