패키지 매니저를 바꾸고 1년 동안 사용해보며 든 생각들
Chanhee Lee — 이찬희
@hiddenest
당시에는 pnpm을 쓰는 곳에 관한 정보가 적었지만, 지난 1년간 Vite나 Turborepo, Next.js, Vue 등 다양한 프레임워크와 라이브러리가 pnpm을 도입했습니다. 다운로드 수도 급격하게 늘어, 작년 한 해 동안의 pnpm 다운로드 수는 2021년 다운로드 수의 5배를 넘긴 것을 확인할 수 있습니다.
이번 글에서는 Yarn에서 pnpm으로 옮기기로 한 이유, pnpm의 도입을 통해 얻은 장점 등을 간략하게 이야기해드리려고 합니다.
1/ Yarn PnP가 Git에 지속적으로 주는 부하
Airbridge는 Yarn Berry의 PnP(Plug n' Play) 모드를 사용하여 패키지를 관리했습니다. node_modules 대신 .yarn/cache에 ZipFS 방식으로 패키지를 압축하여 저장하기에 패키지 설치 시간을 줄일 수 있기 때문입니다.
하지만 문득 PnP가 Git에 지속적으로 영향을 줄 수 있겠다는 생각이 들었습니다. 작업한 뒤 커밋을 하면 .git/objects 내에 변경된 파일이 zlib으로 압축되어 영구히 기록으로 남습니다.
고민은 Airbridge의 커밋 수는 10,000건이 넘는다는 점이었습니다. Airbridge에서 사용하는 패키지 중에서는 바이너리가 포함된 무거운 패키지들이 있습니다. 커밋의 수가 많을 수록, 한 커밋에 포함되는 파일의 용량과 수가 많을 수록, 이후의 작업에서 Git의 체크아웃, 브랜칭 속도에 영향을 줄 수 있겠다는 생각이 들었습니다.
이를 풀기 위해 Git 대용량 파일 저장소(LFS) 내지는 Git 최대 커밋 크기 조정, 주기적으로 Git의 history를 살리면서 파일만 지우는 작업을 하는 것은 정말 오버 엔지니어링이라고 생각했습니다.
마침 PnP가 다른 패키지와 호환되지 않아 빌드가 깨지는 등 여러 문제가 있던 상황. PnP 대신 nodeLinker 모드로 변경했지만, nodeLinker를 쓴다면 Yarn Berry를 쓸 필요가 없다는 생각이 강하게 들기 시작했습니다.
2/ Ghost Dependency와의 끈질긴 싸움
가끔 이런 문제가 있었습니다. react-router-dom을 import해야 하는데 react-router를 import하거나, 설치하지도 않은 prop-types를 쓰거나. 작성된 코드는 에러를 내지 않은채 정상적으로 돌아갔습니다. 하지만 가끔씩 잘못된 의존성을 참조하며 프로덕션에 알 수 없는 장애를 일으켰고, 해결을 위해 꽤 많은 디버깅 시간을 들여야 했습니다.
하나의 패키지는 TypeScript나 lodash 등 여러 패키지를 가져와 만들게 됩니다(dependency). 하나의 패키지를 설치한다고 해도 실제로는 꼬리에 꼬리를 무는 dependency 패키지까지 모두 설치하는 셈입니다.
이를 모두 node_modules에 저장하면 복잡하고 무겁기에, npm과 Yarn은 복잡하게 얽힌 dependency들을 단일 루트 하에 평평하게 위치시킵니다(Hoisting). 하지만 이때문에 중복된 패키지가 저장되거나, 내가 설치한 적 없는 패키지를 쓸 수 있게 되어 잠재적인 버그 혹은 보안 사고의 여지가 있습니다.
pnpm은 이 문제를 해결하기 위해 node_modules를 직접 설치하는 대신, 전역 저장소(Virtual Store)에서 패키지를 공유하는 구조를 사용합니다. pnpm이 패키지를 설치할 때, package.json에 명시된 패키지를 읽은 후 node_modules에 Symbolic Link(symlinks)를 생성하여 전역 저장소의 해당 패키지를 참조합니다. 이 방식을 통해 명시한 패키지만 사용할 수 있게 됩니다.
부가적으로 얻는 장점도 컸습니다. 동시에 중복된 패키지를 설치하지 않아 저장 공간과 네트워크를 절약할 수 있었으며, Hard link와 Symbolic Link(symlinks)를 이용하여 파일 복사를 최소화하여 더 빠르게 패키지를 설치할 수 있게 되었습니다.
3/ Yarn Workspace의 자잘한 버그들
pnpm 도입 이전에는 Yarn을 v3.2 전후로 쓰고 있었습니다. Airbridge에 간단한 모노레포 구조를 도입하기 위해, 당시 개인적으로 작업하던 다른 사내 프로젝트에 Yarn Workspace의 도입을 시도했던 적이 있습니다.
하지만 당시 Yarn Berry의 플러그인 시스템을 비롯해 전체적으로 버그와 함께 불편한 점이 있었습니다.
예를 들어, 현재 실행중인 디렉토리를 항상 잘못 참조하여 --cwd 옵션으로 process.cwd()를 항시 지정해야하는 버그가 있었습니다. 이를 해결하기 위해 Yarn 커맨드를 wrapping하는 별도의 스크립트를 만들어 썼던 기억이 납니다.
그리고, 모노레포 내의 패키지들에 얽힌 dependency를 별도로 설치, 관리해야했습니다.
이로 인해 저장 공간을 중복으로 사용하거나 설치 시간이 오래 걸리는 문제가 있었습니다. 더 찾아봤다면 해결 방법이 있었겠지만... 그렇게까지 하기에는 Yarn의 버그로 인해 너무 지쳐있었습니다.
pnpm의 Workspace는 보다 간단하게 모노레포 설정을 할 수 있습니다. pnpm-workspace.yml에 모노레포를 적용할 폴더를 명시한 후, 패키지로 만들 폴더에 package.json 파일을 두면 끝납니다.
만들어진 패키지들은 자동으로 링크되기에 별도로 설치나 연결을 해줄 필요도 없습니다. 덕분에 Yarn에 비해 보다 간단하게 모노레포를 도입하고 운용할 수 있었습니다.
그외에도...
사실 그외에도 Yarn Berry를 쓰며 불편했던 점은 있습니다. Yarn Classic(v1)이 아닌 Berry(v2+)를 기본 지원하는 머신은 적거나 없었기에 Yarn의 set-version policy를 적용해 Yarn 자체를 다운로드받아 적용했습니다.
하지만 Airbridge에서 쓰는 패키지들 중에는 Post-Install 스크립트를 실행해야하는 것들이 있어, PnP를 쓴다고 해도 install 커맨드를 실행해야 했습니다. 문제는 --immutable 등의 옵션을 주어도, install 커맨드 실행 중 패키지의 resolution 과정에서 패키지를 재확인하는 시간이 좀 걸렸습니다. 어떨 때에는 아예 패키지가 새로 설치되기도 했습니다.
이런 부분들이 모이고 모여, 결국 Yarn에서 pnpm으로 넘어가게 되었습니다.
만족하며 사용하고 있나요?
pnpm을 도입하고 1년째 만족하며 잘 사용하고 있습니다.
우선, 배포 과정 중에서 패키지 설치 시간이 Yarn 대비 30초 ~ 1분 정도 줄었습니다.
기존에도 GitHub Actions에 캐싱을 했기에 큰 차이가 있을까 싶었는데, 눈에 띈 속도 향상을 볼 수 있어서 놀랐습니다.
다음으로, 사내 Node 버전을 관리하는 용도로 pnpm을 사용하고 있습니다.
물론 nvm처럼 프로젝트 별로 Node 버전을 따로 설정할 수는 없습니다. 하지만 Airbridge에서는 사내 프로젝트를 최신 Node 버전으로 사용할 수 있도록 관리하고 있기에 관련 니즈는 적은 편입니다.
pnpm env use --global lts를 주기적으로 실행, Node LTS 버전을 항상 쓰도록 하고 있습니다.
그외에도 Yarn Berry에 내장된 patch-package나 dedupe의 기능들을 그대로 사용할 수 있다는 점도 좋았습니다. 개인적으로는 Yarn Berry에서 없어진 outdated(새로운 패키지 릴리즈 여부 및 버전 확인) 커맨드가 남아있던 것, Yarn v1의 직관적인 커맨드들을 그대로 쓸 수 있다는 점이 좋았던 것 같습니다.
끝으로
pnpm의 방향성은 과연 옳을까요?
Yarn PnP도 좋은 방향이라고 생각했던 적이 있었기에 고민을 했습니다. 그러다 최근 npm v9.4부터 pnpm 방식의 Isolated node_modules를 지원하는 기능이 추가된 것을 보았습니다. 물론 시간이 흐르면 옳고 그른 것도 바뀌겠지요. 하지만 최소한 지금의 방향이 잘못되지는 않은 것 같습니다.
이 글을 읽으신 후에 ‘지금 쓰는 패키지 매니저는 나쁘다’거나 ‘패키지 매니저를 옮깁시다’고 생각하거나 움직이지는 않으셨으면 좋겠습니다. 하지만 여유가 되실 때 pnpm이라는 선택지도 있다는 것을 떠올려주신다면 좋을 것 같습니다.
ᴡʀɪᴛᴇʀ
Chanhee Lee @hiddenest
Frontend Software Engineer