패키지 매니저를 어떤 걸 선택할까 고민하는 시간이 있었습니다.
그래서 오늘은 패키지 매니저의 특성들을 간단하게 정리하고, 제가 선택한 pnpm의 구조에 대해 좀 더 이야기해보는 시간을 갖고자 합니다.
참고로, 패키지 매니저가 존재하기에 간단하게 import 하는 형식이 가능했습니다.
특정 라이브러리를 import 해오기 위해서는 반드시 절대 경로가 필요한데, 그동안 import React from 'react'; 와 같은 방식이 가능했던건 패키지 매니저가 버전과 위치를 명확히 가르쳐주고 있기 때문입니다. package.json으로 패키지의 버전에 대한 범위를 지정하고, package-lock.json 혹은 yarn.lock 파일로 하나의 버전으로 고정해버립니다.
npm vs yarn classic vs yarn berry vs pnpm
크게 4가지의 패키지매니저로 나눌 수 있었습니다. yarn은 하나인 것 아닌가 하는 질문이 생길 수 있는데, yarn 버전 1이 업그레이드 되면서 yarn 버전 2 이상은 yarn berry로, 버전 1은 yarn classic으로 부르기로 했습니다. 그만큼 yarn classic과 yarn berry는 엄청난 변화가 있었습니다.
npm : 패키지 매니저의 선구자
패키지 매니저의 선구자인 npm은, node에 기본적으로 내장되어 있는 패키지 매니저이기도 합니다.
npm이 존재하기 전에는 일일히 패키지들을 다운로드 받고, 의존성을 직접 다운로드 받아야하는 매우 불편한 과정을 거쳐야 했습니다.
이를 npm이 해결해준 거죠.
yarn classic : 패키지 매니저의 혁명
페이스북과 구글 개발자가 합심하여 만든 패키지매니저로, 기존 npm에 존재하던 보안성, 일관성, 성능을 개선하기 위해 탄생했습니다.
npm 대비 yarn classic의 아주 큰 장점은, 설치 프로세스가 병렬로 진행된다는 점입니다. 그래서 설치 속도(성능)이 매우 좋아졌어요.
또한, lock 개념을 도입하여 checksum으로 일관성을 보장하고, 협업을 할 때 하나의 버전으로 작업을 할 수 있도록 보장합니다.
(따라서 yarn.lock과 같은 파일은 반드시 .gitignore에 포함되어서는 안됩니다.)
npm과 yarn classic은 패키지를 flat하게 저장하기 위해 패키지 호이스팅이라는 기법을 도입했습니다.
패키지 호이스팅으로 인해, 동일한, 동일 버전의 패키지의 중복 설치는 방지할 수 있었지만 이로 인해 유령 의존성(phantom dependency) 가 발생하였고 직접 의존하고 있지 않은 패키지도 import(require) 해올 수 있는 큰 단점이 발생했습니다. 의존성 관리 시스템을 혼란시키는 일이죠. 이를 yarn berry와 pnpm 은 각기 다른 방식으로 해결했습니다.
yarn berry : yarn classic의 업그레이드 버전! -> zip 으로 패키지 관리
yarn classic의 업그레이드 버전이지만, 새로운 코드베이스 및 새로운 원칙을 도입하였기 때문에 명칭을 yarn berry로 나누었습니다.
yarn berry의 가장 큰 특징 중 하나는 plug n play 의 지원입니다. 이로 인해 엄청난 변화를 얻게 됩니다.
node_modules를 생성하는 대신, .pnp.cjs라 불리는 의존성 lookup 파일이 생성되는데, 이는 중첩된 폴더 구조 대신 단일 파일에 참조해야할 패키지의 경로를 저장해두기 때문에 더욱 빠르게 패키지의 의존성과 패키지(라이브러리)의 위치를 알 수 있습니다 (IO의 발생 횟수 적어짐) 또한 모든 패키지는 .yarn/cache 폴더 내부에 zip 파일로 저장되므로, node_modules 폴더보다 더 디스크 공간을 적게 차지합니다.
다만 yarn berry를 사용하기 위해서는 vscode 상에서 ZipFS 를 설치해야하고, typescript를 정상적으로 지원하기 위한 sdks 설정을 진행해주어야 합니다. 또한 yarn berry의 경우 opt-in(합의)없이 강행했기 때문에, 몇몇 라이브러리는 yarn berry의 pnp 환경에서 제대로 돌아가지 않습니다. (이를 위해선 해당 라이브러리를 폐기하거나, yarnrc.yml에서 nodeLinker: node-modules 와 같은 세팅을 해주어야 합니다.)
node초기 세팅이 약간은 존재한다는게 그나마 단점이라면 단점인데, 사실 장점이 막강해서 충분히 사용을 고려해볼만한 패키지매니저입니다. 다른 패키지매니저에 비해 차지하는 용량도, 설치하는 속도도 매우 빠르고, yarn berry는 확장 가능성도 높기 때문에 정말 많이 고민한 후보 중 하나입니다.
(만약 pnp 방식을 도입한다면 zero intall도 고민했지만, 깃에 file changed에 뜨는게 마음에 안들어서 도입하지 않기로 했습니다. )
pnpm : 향상된 npm! -> 링크로 패키지 관리
npm과 yarn classic 과 마찬가지로, node_modules 폴더를 통해 패키지를 관리합니다.
다만 다른 점은, dependencies를 해결하기 위한 전략으로 content-addressable storage를 사용했다는 점입니다.
즉, 글로벌 저장소에 패키지를 딱 한번만 저장해두고, 이를 hard link 와 symbolic link를 통해 접근하여 사용하는 방식입니다.
(물론, 같은 패키지라고 해도, 버전이 다르면 다른 패키지처럼 다운로드 되는 건 아시죠?!)
좀 더 자세히 설명해볼게요. (https://pnpm.io/next/symlinked-node-modules-structure)
node_modules
└── .pnpm
├── bar@1.0.0
│ └── node_modules
│ └── bar -> <store>/bar
│ ├── index.js
│ └── package.json
└── foo@1.0.0
└── node_modules
└── foo -> <store>/foo
├── index.js
└── package.json
우선 pnpm은 node_modules안에 .pnpm 디렉토리를 생성하여 패키지들을 관리합니다.
위의 구조에서 -> 로 되어 있는건 symbolic link고, index.js나 package.json과 같은 파일들은 hard link 되어 있는 파일입니다.
여기서 하드 링크랑 심볼릭 링크는 OS에서의 개념을 떠올리시면 됩니다. (hard link와 symbolic link는 i-node를 새롭게 만드는지 아닌지에 따른 차이등등이 있는데, 여기서는 간단하게 link = 참조로 뭉뚱그려서 생각하셔도 됩니다.)
아주 간단하게 생각하면, 디렉토리는 심볼릭 링크로, 파일은 하드 링크로 연결되어 있다고 보시면 됩니다.
예시 하나만 더 보죠.
node_modules
├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
├── bar@1.0.0
│ └── node_modules
│ ├── bar -> <store>/bar
│ └── qar -> ../../qar@2.0.0/node_modules/qar
├── foo@1.0.0
│ └── node_modules
│ ├── foo -> <store>/foo
│ ├── bar -> ../../bar@1.0.0/node_modules/bar
│ └── qar -> ../../qar@2.0.0/node_modules/qar
└── qar@2.0.0
└── node_modules
└── qar -> <store>/qar
이 예시가 실제로 보게 될 구조입니다.
이 구조에서는, direct dependency 는 foo 입니다. (실제로 프로젝트에서 필요로 하는 패키지 = direct dependency)
참고 - indirect dependency (직접 사용하진 않지만, 사용하려는 라이브러리가 간접적으로 필요로 하는 패키지들)
위의 예시를 살펴보면, 상대 경로 및 심볼릭 링크를 통해 foo의 실제 위치를 가리키고 있습니다. (결국 최종 도착지는 <store>/foo)
그리고 foo는 bar를 필요로 하기 때문에, bar가 또한 .pnpm 디렉토리에서 관리되고 있습니다.
만약 qar@2.0.0이 foo와 bar의 의존성으로 추가될 때, qar가 추가되더라도 디렉토리 깊이는 늘어나지 않습니다.
모든 의존성은 node_modules/.pnpm에만 저장되고, 필요한 위치에 심볼릭 링크로 연결되기 때문입니다.
링크를 통해 연결하기 때문에 위처럼 depth가 더욱 깊어지지도, 순환 참조 문제도 발생하지 않습니다. 장점이 명확하네요!
pnpm store path
이 명령어를 pnpm에 설정된 프로젝트에서 실행하면, (링크가 아니라, 참조되어 사용되는)실제로 저장되어 있는 파일의 위치를 알 수 있습니다. 저는 /Users/유저명/Library/pnpm/store/v3 와 같은 path가 만들어졌는데, pnpm 폴더의 경우 기본적으로 가려져있으니 맥북의 f파인더에서 찾고 싶다면 shift + command + . 등을 통해 숨김 파일 보기를 통해 확인해보길 바랍니다.
왜 PNPM을 선택했는가?
패키지매니저의 특성들을 알아볼수록, 저는 yarn berry 와 pnpm 중 무엇을 도입할지 고민이 깊어졌습니다.
둘 다 플러그인 기반으로 구현되어 있어 확장 가능성이 높고, 쓸 데 없이 중복해서 사용할 이유도 없어보였습니다.
특히 yarn berry는 zip으로 패키지를 관리하기 때문에 디스크 사용량과 설치 속도가 pnpm 보다 우세했습니다.
그러나 저는 딱 한가지가 크게 걸렸습니다.
"yarn berry는 radical하게 버전 업그레이드하면서, yarn berry를 지원하지 않는 라이브러리들이 존재한다"
물론 메인테이너들이 울며 겨자먹기로 yarn berry의 pnp 환경에서도 돌아가는 패키지들을 많이들 내놓았지만, 이를 따르지 않는 라이브러리들도 있습니다. 예를 들어, yarn berry의 pnp 방식에서는 어떤 수를 써도 stylelint가 적용되지 않는 문제점이 있었습니다. (혹시 해결하신 분들은 댓글 써주시면 감사하겠습니다.) 그래서 결국 node-linker: node-modules 방식을 적용했고 pnp 방식을 제대로 활용하지 못했습니다.
yarn berry에서는 매우 엄격하고 정확한 의존성 설치를 제공하기 때문에 만약 yarn berry에서 작동하는 건 npm과 pnpm에서도 동작 작동합니다. 이게 큰 장점이기도 하지만, 종종 특별한 case에서 사용하고만 싶은 라이브러리가 있을 때 못 사용한다는 건 아쉬웠습니다.
그래서 yarn berry는 조금은 더 지켜보기로 했습니다. 시간이 더 지나면 대부분의 패키지들이 yarn berry의 pnp 환경에서도 잘 돌아가겠죠. 지금은 충분히 성능이 좋고 기본적으로 패키지를 node_modules에 직접 저장하여 안정성이 높은 pnpm을 사용하기로 했습니다.
(여담으로, 하드링크과 심볼릭링크를 적절히 활용하여 npm의 문제를 해결해낸 것이 인상적이기도 했습니다.)
+) 그리고 부가적으로, 나중에 필요하다면 모노 레포도 도입해보고 싶은데 모노레포 도입을 위해 사용할 수 있는 방법 중 turborepo가 pnpm을 강추하고 있기도 해서, pnpm을 선택하는데 조금 더 긍정적으로 고민해봤습니다.
https://yceffort.kr/2022/05/npm-vs-yarn-vs-pnpm#yarn
https://toss.tech/article/node-modules-and-yarn-berry
https://toss.tech/article/27772
https://umanking.github.io/2022/05/05/yarn-lock/
'Develop > Frontend' 카테고리의 다른 글
zod 유효성 검사에서 겪은 트러블 슈팅 (0) | 2025.02.04 |
---|---|
useCallback은 도대체 언제 사용하는게 좋을까? (0) | 2025.01.09 |
Web Socket을 도입하며 겪었던 트러블 슈팅들 (0) | 2024.12.31 |
HTTP 그리고 Socket (Web Socket, 웹소켓) (2) | 2024.12.27 |
퀴즈! 렌더링이 되지 않는 이유는? (Uncaught TypeError) (0) | 2024.11.19 |