이거 누가설치했어..?
JS로 개발을 할 때, 여러 패키지들을 사용해서 개발하게 됩니다. 이때 패키지들을 다운로드하고, 버전을 관리하고, 수많은 패키지들의 복잡한 의존성을 편리하게 관리하기 위해 패키지 매니저를 사용하게 됩니다.
기능 개발에 초점을 맞추어 구성하고만 넘어갔던 패키지 매니징에 대해 알아보게 되는 계기가 생겼습니다.
잘 사용하던 라이브러리가 알고보니 package.json에 없었고, 설치한적이 없는 라이브러리였던 것이었습니다.
이 이슈는 사용중이던 Storybook
을 v8
로 업데이트하면서 yarn v1
에서 최신 버전으로 업데이트를 권장했기 때문에, yarn classic(v1)
에서 yarn berry
로 업그레이드하면서 발생하였습니다.
yarn
의 문제점 중 하나이던 유령 의존성에 의해 설치되었던 라이브러리를 사용하고 있었기 때문에 yarn 버전을 올리면서 유령 의존성이 해결되었고,
따라서 사용중이던 라이브러리가 없어져 직접 추가 설치했던 경험이 있었습니다.
이 때 처음으로 패키지 매니저의 시스템과 유령 의존성에 대해 학습해야할 필요성을 알게되었습니다.
그리고 개인적으로 모노레포를 테스트해보던 중에, turborepo와 pnpm을 사용해보게 되어 또 새로운 세계(...)를 맞게 되었는데요.
가장 널리 사용되는 npm를 시작으로, yarn, yarn berry, pnpm에 대해 혼동되는 내용 위주로 정리해보겠습니다.
npm
npm은 node.js 환경의 기본 패키지 매니저(node package manager) 입니다.
yarn을 사용하다가 npm을 사용하면 가장 먼저 체감되는 단점이 느리다는 것입니다. 왜 느릴까요?
비효율적인 의존성 검색과 설치
npm은 파일 시스템을 이용하여 의존성을 관리합니다.
상위 디렉토리의 node_modules
폴더를 순차적으로 탐색하여 원하는 패키지를 찾는 방식으로 동작하게 됩니다. 원하는 패키지가 없다면, 계속해서 상위 디렉토리로 이동하며 node_modules
를 탐색하게 됩니다.
탐색에 파일 I/O를 사용하여 무거운 작업이 수행되고, 무거운 node_modules
를 재귀적으로 반복 탐색하니 설치와 실행에 상대적으로 느립니다.
환경에 따른 비일관성
또 이렇게 상위 디렉토리 환경에 따라, 의존성을 찾는 결과가 달라지게 됩니다.
개발 환경에 따라 의존성을 불러올 수 없게 되기도, 설치 순서가 변경되기도 합니다.
따라서, 기존 프로젝트를 진행하며 하나씩 패키지를 추가 설치한 사람과 신규 개발자로 npm install
로 처음부터 설치하는 사람의 설치 순서가 다를 수 있습니다.
고정되지 않는 패키지 버전
기본적으로 node.js 라이브러리는 x.y.z
형식의 Semantic Versioning을 따라 모듈의 버전을 나타냅니다.
x.y.z
버전의 자리는 차례로 MAJOR.MINOR.PATCH
를 나타냅니다.
MAJOR version when you make incompatible API changes,
MINOR version when you add functionality in a backwards-compatible manner, and
PATCH version when you make backwards-compatible bug fixes.
개발해본 라이브러리를 토대로 각 버저닝을 업데이트했던 예시입니다.
- MAJOR: 이 전 API를 그대로 사용하면 내부 동작이 변경되어서 기대값이 달라지거나, API가 변경된 경우.
사내 디자인 시스템을 만들면서 마이그레이션으로 인해 대대적인 코드 수정이 이루어졌었다.
의존하는 패키지부터 동작 방식, 디자인 토큰을 제공하는 방식과 Provider 미지원 등이 변경되었고, 전반적인 라이브러리가 안정화되었으므로,v0.y.z
버전에서 첫 메이저 버전v1.0.0
을 제공하며 major 업데이트를 진행하였다. - MINOR: 이 전 버전과 호환을 유지하면서 새로운 기능 추가. 주로 신규 컴포넌트나 기능, 디자인 토큰의 추가가 이루어 진다.
- PATCH: 이 전 버전과 호환을 유지하면서 버그 수정. 리포팅된 버그를 수정하는 작은 업데이트. 다음에 설명할
^
캐럿을 사용하여 버전관리를 하면 패치 버전이 변경될 수 있게되므로, 사이드이펙트 없이 버그만 작고 정확하게 수정한다.호환 보장해야해서 뭐 깨질까봐 조심조심 업데이트했던 기억이 나네요...
package.json
에는 버전을 표시하는 방식 중 ^(carrot)
, ~(tilde)
기호를 확인할 수 있습니다.
캐럿과 틸드는 node.js 모듈이 시멘틱 버저닝의 규약을 따른다는 것을 신뢰한다는 가정하에,
캐럿(^)은 x.y.z
중 x 이하 하위호환성이 보장되는 범위 내에서 버전 업데이트,
틸드(~)는 x.y.z
중 z 범위 내에서 버전 업데이트 허용의 의미입니다.
예를 들어, 캐럿기호는 react: ^17.0.2
와 같이 사용되는데, 이 의미는 17.0.2 이상 18.0 미만
을 의미합니다.
이 사이의 버전을 허용하게 되므로, npm install
을 실행하게 되면 해당 버전 범위 중 최신 버전을 다운로드하게 됩니다.
신규 개발자가 있다면, 그 사이 최신 버전이 업데이트되어 설치 버전이 달라지게 되는 이슈가 발생하게 됩니다.
yarn
yarn.lock
자동 모듈 버전 고정
이렇게 패키지 버전 고정이 안되는 이슈를 해결하기 위해 yarn
에서는 패키지 잠금을 지원하게 되었습니다.
(이에 따라, npm도 지원하게 되어, 현재는 npm도 package-lock.json으로 패키지 잠금을 지원하게 되었습니다.)
yarn
은 yarn.lock
파일을 자동으로 생성하여 관리하고 모듈의 버전을 고정할 수 있습니다.
개발자들은 git과 같은 형상관리 툴에 올려 원격 저장소에서 공유하게 됩니다.
이렇게 공유된 yarn.lock
파일을 기준으로 현재 프로젝트에 적용된 패키지의 버전을 관리할 수 있습니다.
이 lock 파일에 정의된 버전으로 모듈이 설치되어 개발자들간의 버전을 같게 유지할 수 있습니다.
yarn install
을 통해 설치하면, yarn.lock
파일이 존재하면서 package.json
에 리스트된 모든 의존성을 충족한다면, 정확히 yarn.lock
에 기록된 버전을 설치하게 되고 yarn.lock
파일은 변경되지 않습니다.
앞서 이슈가 되었던 최신 버전에 대한 체크도 진행되지 않습니다.
이 동작을 토대로, 대게 CI/CD 프로세스를 구성할 때
yarn.lock
파일이 변경되지 않음을 보장하기 위해서--frozen-lockfile
옵션을 사용합니다.
무결성 확인 Checksum
그리고 yarn.lock
의 resolved
를 확인해보면, d0b5...
와 같은 해쉬값이 추가되어 있습니다.
이 해쉬값은 패키지 파일의 무결성을 확인하는 안전 장치 역할의 checksum
입니다.
이 값을 통해 다운로드한 데이터가 손상되지 않았음을 보장하는데,
데이터가 변경되었다면 체크섬이 불일치되어 경고 혹은 다운로드를 실패시키는 방식으로 패키지의 내용으로부터 고유한 체크섬을 생성하여 관리합니다.
패키지 캐쉬와 병렬 다운로드
yarn은 한 번 다운로드한 패키지를 캐쉬합니다.
이에 따라 메모리를 차지하지만, 다음 다운로드부터 빠른 속도로 설치가 가능합니다.
또한 순차 설치가 아닌 병렬 설치를 지원하므로 모듈이 많아질 수록 설치 속도에 있어 강점을 나타내게 됩니다.
npm과 yarn(v1, classic)의 문제점
유령 의존성
다른 패키지에서 같은 패키지를 의존하는 경우가 있습니다.
아래와 같이 package1에 포함된 패키지 A(1.0)는 B (1.0)를 의존하고, 다른 패키지인 C(1.0)에서도 A(1.0)가 필요하여 B(1.0)에 의존하게 될 수 있습니다.
이런 경우 npm과 yarn(classic)에서는 중복 설치를 피하기 위해 호이스팅(hoisting)을 사용합니다. 호이스팅을 통해 패키지를 끌어올려, 패키지 트리를 평탄화하게 됩니다.
호이스팅을 사용하면 중복된 보라색 A(1.0), 연두색 B(1.0)을 제거하면서, D의 의존성인 B(2.0)을 보존할 수 있습니다.그리고 동일한 루트인 package-1/node_modules 을 유지할 수 있어서, 대부분의 모듈 번들러들은 프로젝트 루트에서 node_modules 트리를 따라 내려가며 모듈을 탐색할 수 있습니다.
이렇게 되면 개발자가 호이스팅에 의해서 직접 의존하지 않는 (package.json에 명시하지 않은) 라이브러리를 사용할 수 있게되는 유령 의존성현상이 발생하게 됩니다.
스르륵 나타난 라이브러리를 사용하다가, B에 의존하던 라이브러리를 삭제하면 스르륵 사라지게 되는 유령 의존성 패키지인것이죠.
실제로 yarn v1을 사용하다가, yarn 최신 버전으로 업데이트하고나서 사용하던 라이브러리가 설치가 안되었다는 이슈를 발견하고 직접 추가 설치를 해주었던 기억이 있습니다. 이 이슈가 유령 의존성때문에 생성된 패키지를 사용하고 있었것 같습니다.
Yarn Berry
PnP(Plug n Play)
yarn은 Yarn Berry라는 기존의 yarn과 다른 새로운 설계를 도입한 기존 버전과 구분될 수 있도록 yarn v2 이상을 부르는 명칭입니다.
(이전 v1은 yarn classic)
기존의 유령 의존성과 무거운 node_modules을 해결하기 위해 PnP 라는 전략으로 빠르게 패키지를 순회할 수 있게 하였습니다.
필요한 패키지들을 압축하고, .yarn/cache
폴더에 의존성을 저장힙니다.
그리고 필요한 패키지를 설치하고, 가져올 때 node_modules 를 순회하지않을 수 있도록 .pnp.cjs
파일에 의존성을 기록하는 방식으로 느린 디스크 I/O를 대신할 수 있습니다.
Zero-install
PnP에서 의존성은 압축되어 관리되므로 의존성의 용량이 줄어들게 됩니다.
이렇게 줄어들었기 때문에, 의존성 압축 파일을 Git에 올려 원격 저장소에서 관리할 수 있게 됩니다.
따라서, 버전 관리에 포함시켜 코드와 함께 관리한다면 git clone
으로 받아온 후에 별도 설치 프로세스가 불필요하기 때문에 zero-install 이라고 부르게 됩니다.
당연한 과정으로 생각되던 clone 후 의존성을 설치하는 프로세스를 원격 저장소에 실제 의존성 파일을 올려버려 설치를 건너뛴 획기적인 전략입니다. CI/CD 과정에서도 의존성 설치 과정의 시간을 절약할 수 있어 곧바로 빌드, 배포가 가능합니다.
하지만, GitHub에서는 파일당 최대 용량을 제한하고, 저장소 크기를 1GB 미만으로 제한하고 있습니다. 소규모 프로젝트에서는 문제되지 않지만, 대규모 모노레포로 관리되는 프로젝트의 경우에는 이슈가 될 수 있습니다.
pnpm
pnpm에은 기존 npm에서 더 빠르고 디스크 용량을 효율적으로 관리하는 패키지 매니저입니다.
node_modules
를 직접 설치하는 대신, 전역 저장소에서 패키지를 공유하는 구조를 가집니다.
npm을 사용할 때, 하나의 의존성을 100개 프로젝트에서 사용한다면 100개의 의존성 복사본이 생성되게 됩니다.
pnpm을 사용한다면, content-addressable store 에 저장되게 됩니다.
pnpm으로 패키지를 설치할 때, package.json에 명시된 패키지들을 node_modules에 Symbolic Link를 생성하여 전역 저장소에 있는 패키지를 참조하게 됩니다.
따라서, 여러 패키지가 있더라도 전역 저장소에서 패키지를 딱 한 번만 다운로드할 수 있어 중복 설치가 되지않아 저장 공간을 효율적이게 사용하게 됩니다. 이를 통해, 프로젝트 간에 동일한 버전의 패키지를 공유할 수 있습니다.
만약, 다른 버전의 의존성을 사용할 경우에 변경된 파일만 추가 저장하여 공간을 절약할 수 있습니다. 예를 들어, 100개 파일을 가지고 있고, 새로운 버전은 이 파일 중 딱 하나의 변경만 가진다면 `pnpm update`는 전체 의존성을 복사하지 않고 하나의 새 파일만 스토어에 저장할 것입니다.
그런 다음 하드링크과 심볼릭 링크를 사용하여 프로젝트의 종속성을 관리하게 됩니다.
1. <home-dir>/.pnpm-store 라는 전역 스토어에 필요한 모든 종속성 버전을 물리적으로 한 번만 저장합니다.
2. 각 프로젝트에서 필요한 패키지는 프로젝트의 node_modules/.pnpm 에 전역 스토어에 설치된 패키지와 하드 링크됩니다. pnpm의 하드링크 설명에 의하면 다음과 같습니다.
하드링크는 디스크에서 원본 파일이 있는 동일한 위치를 가리킵니다.
예를 들어, 프로젝트에서 foo라는 의존성을 가지고 있고, 이 의존성이 1MB의 공간을 차지한다고 해봅시다.
이 경우, node_modules 폴더에서도 이 의존성이 1MB를 차지하는 것처럼 보이고, 글로벌 저장소에서도 동일한 크기로 보입니다.
하지만 이 1MB는 디스크의 같은 공간을 두 개의 다른 위치에서 가리키는 것뿐입니다.
따라서, 실제로 foo는 총 1MB의 공간만 차지하며, 2MB를 차지하지 않습니다.
3. 이 후, node_modules/ 하위에 위치한 의존성들은 node_modules/.pnpm에 위치한 의존성들과 symbolic link로 연결되어 관리되게 됩니다. 예를 들어, node_modules/react는 node_moduels/.pnpm 하위에 위치한 react와 심볼릭 링크로 연결되게 됩니다
이렇게 모든 종속성이 전역 스토어에 저장되게 되어 Single Source Of Origin 구조를 갖게 되어, 효율적이고 깔끔한 패키지 관리를 구성할 수 있습니다. node_modules/.pnpm 하위에 평탄화하여 유령 의존성 문제도 해결할 수 있게 되었습니다.
이렇게 npm부터 시작해서 제가 이슈를 겪었던 yarn classic, yarn berry, 이제 곧 프로젝트로 도입하려고 공부한 pnpm까지 한 흐름으로 정리해보았습니다. 하다가 너무 광범위해서... 하하..... 힘드네요
좋은 레퍼런스가 많아서... 너무 감사합니다.
- https://medium.com/wantedjobs/yarn-classic%EC%97%90%EC%84%9C-pnpm%EC%9C%BC%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0-with-turborepo-7c0c37cb3f9e
- https://pnpm.io/motivation
- https://engineering.ab180.co/stories/yarn-to-pnpm
- https://toss.tech/article/27772
- https://toss.tech/article/node-modules-and-yarn-berry
- https://usage.tistory.com/147
- https://enjoydev.life/blog/frontend/7-yarn-npm
'Web Development' 카테고리의 다른 글
2024 회고 - 경력 면접과 커피챗, 번아웃과 취미🎹, 체력관리, 재태크 (9) | 2024.12.22 |
---|---|
🚧 Nestjs 에러 로깅 (0) | 2024.11.26 |
Vanilla-extract 라이브러리 번들사이즈 60% 최적화하기(feat. 내가 Anti-pattern이라니..) (3) | 2024.11.10 |
Next.js v12.0 점진적으로 개편하기(Feat. 도커이미지 95% 경량화) (0) | 2024.10.27 |
퇴사 부검 ⚰️ (4) | 2024.09.30 |