오늘은 프론트엔드 개발자라면 누구나 매일 사용하는 패키지 매니저에 대해 이야기해보려 한다.
npm으로 시작해서 yarn, 그리고 최근 인기가 높아지고 있는 pnpm까지, 각 패키지 매니저의 특징과 폴더 구조를 비교해보면서 왜 개발자들이 다른 패키지 매니저로 이동하는지 알아보자.
npm: 모든 시작점
Node.js를 설치하면 기본으로 따라오는 npm은 2010년부터 자바스크립트 생태계의 기반을 다져왔다. 별도 설치가 필요 없어서 접근성이 좋다는 장점이 있다.
npm의 폴더 구조
npm의 폴더 구조는 단순해 보이지만, 실제로는 몇 가지 문제점을 갖고 있다. 특히 의존성 트리를 평탄화(hoisting)하는 과정에서 생기는 '유령 의존성' 문제는 많은 개발자들을 혼란스럽게 했다.

예를 들어, A 패키지가 B 패키지를 의존하고, C가 다시 A를 의존한다고 해보자.
npm의 hoisting 알고리즘 때문에 B 패키지가 루트 node_modules로 올라오면, B를 직접 사용할 수 있게 된다. 하지만 package.json에는 명시되지 않았다. 이런 상황에서 혼란이 생긴다.
yarn classic: 속도와 안정성의 향상
2016년에 Facebook에서 만든 yarn은 npm의 문제들을 해결하기 위해 등장했다. 병렬 설치, 더 나은 캐싱, 그리고 yarn.lock 파일로 버전 관리가 훨씬 편해졌다.
yarn classic의 폴더 구조
yarn classic은 폴더 구조는 npm과 거의 비슷하다. 하지만 설치 과정이나 캐싱 전략에서 훨씬 효율적이고, yarn.lock 파일 덕분에 "내 컴퓨터에선 잘 돌아가는데?"라는 문제가 많이 줄었다.
대규모 프로젝트에서
npm install
이 몇 분 걸리던 것이 yarn install
은 몇십 초면 끝나는 경우가 많았다. 이는 병렬 설치와 효율적인 캐싱 전략 덕분이다.yarn classic의 한계: 성능 저하 문제
yarn classic은 npm보다 많은 개선점을 가져왔지만, 여전히 node_modules 구조를 사용하기 때문에 몇 가지 한계가 있다.
개인적으로 특히 눈에 띄는 문제 중 하나는 IDE 성능 저하 현상이다.
내 블로그 프로젝트는 현재 yarn classic를 사용하고 있고, 사이드 프로젝트나 회사에서는 yarn berry, pnpm 등을 사용하고 있다.
내 블로그 프로젝트가 규모가 훨씬 작음에도 불구하고 유독 이 프로젝트를 킬때만 IDE에서 렉이 걸린다.
작은 프로젝트에서도 IDE가 느려지는 주요 원인은 node_modules 구조에 있다. node_modules는 수천 개의 작은 파일로 구성되어 있고, 이러한 파일들을 IDE가 인덱싱하는 과정에서 많은 디스크 I/O 작업이 발생한다.
특히 node_modules 폴더는 복잡한 중첩 구조를 가지고 있어, IDE가 파일을 탐색하고 인덱싱하는 데 상당한 리소스를 소모하게 된다.
예를 들어,
require()
호출이 발생할 때마다 Node.js는 다음과 같은 복잡한 탐색 과정을 거친다:- 현재 디렉토리의 node_modules 폴더 확인
- 없으면 상위 디렉토리의 node_modules 폴더 확인
- 루트 디렉토리까지 이 과정 반복
이러한 탐색 과정은 매 호출마다 수많은 폴더와 파일을 실제로 열고 닫으면서 검색하게 되며, 이는 IDE에도 동일하게 부담을 준다. 결과적으로 프로젝트 규모가 작더라도 node_modules의 구조적 특성 때문에 IDE가 느려질 수 있다.
이런 문제는 yarn berry의 PnP 시스템에서는 크게 개선되었다.
PnP는 파일 시스템에서 패키지를 찾는 대신 메모리 상의 자료구조를 사용하므로 디스크 I/O 작업을 크게 줄일 수 있다. 이는 IDE 성능에 직접적인 영향을 미쳐, 같은 코드베이스라도 yarn berry를 사용할 때 IDE가 훨씬 빠르게 반응한다.
yarn berry: PnP 시스템의 혁신
yarn 2.0(berry)부터는 완전히 다른 접근 방식을 취했다. 가장 큰 변화는 node_modules 폴더를 없애고 Plug'n'Play(PnP) 시스템을 도입한 것이다.
yarn berry의 폴더 구조
yarn berry는 패키지를 ZIP 아카이브로 .yarn/cache에 저장하고, .pnp.cjs 파일로 의존성 위치를 관리한다.
이 방식은 디스크 I/O를 크게 줄이고, require() 호출 시간도 단축시켜준다.
또 하나 좋은 점은 Zero-Install이 가능하다는 것이다.
.yarn/cache
와 .pnp.cjs
파일을 Git에 포함시키면, 새로운 팀원이 합류하거나 CI 환경에서 yarn install
없이 바로 개발을 시작할 수 있다.PnP의 기술적 작동 원리
PnP 시스템은 Node.js의 기본 모듈 해석 알고리즘을 대체한다. 기존에는 모듈을 찾기 위해 파일 시스템을 재귀적으로 탐색했지만, PnP는 미리 계산된 의존성 맵을 사용한다. 이 과정은 다음과 같이 작동한다:
- 패키지 설치 시, yarn은 모든 의존성의 정확한 위치와 관계를 분석하여
.pnp.cjs
파일에 저장한다.
.pnp.cjs
파일은 모든 패키지와 해당 패키지의 의존성 간의 관계를 하나의 맵으로 구성한다.
- 애플리케이션이 실행될 때, Node.js는 이
.pnp.cjs
파일을 로드하여 모듈 해석 과정을 가로챈다.
- 코드에서
require()
또는import
문이 실행되면, PnP 시스템은 파일 시스템을 검색하는 대신 맵을 참조하여 필요한 모듈의 정확한 위치를 즉시 찾아낸다.
이러한 방식은 파일 시스템 I/O 작업을 거의 제거하여 성능을 크게 향상시킨다. 기존 방식에서는
require()
호출마다 여러 번의 파일 시스템 액세스가 필요했지만, PnP에서는 단 한 번의 메모리 검색으로 해결된다.PnP 모드에서 라이브러리 호환성 문제
PnP는 Node.js의 기본 모듈 해석 메커니즘을 완전히 교체한다. 기존에는 node_modules 디렉토리를 검색하여 패키지를 찾았지만, PnP는
.pnp.cjs
파일에 모든 위치 정보를 담아 메모리에서 관리한다.
많은 라이브러리들이 node_modules 구조를 전제로 작성되었기 때문에, 파일 시스템을 직접 참조하거나 내부적으로 require.resolve()
같은 함수를 사용하는 라이브러리는 PnP 환경에서 문제가 발생할 수 있다.예를 들어, webpack과 같은 일부 빌드 도구는 기본적으로 PnP를 완벽하게 지원하지 않는다. 또한 prettier 플러그인처럼 다른 모듈을 동적으로 로드하는 라이브러리들도 문제가 될 수 있다.
해결 방법
이러한 호환성 문제를 해결하기 위한 방법으로는 다음과 같은 옵션이 있다:
yarn unplug <package>
: 특정 패키지를 PnP 시스템에서 제외하고 일반 파일로 추출한다.
require.resolve()
사용: 설정 파일을 JavaScript로 작성하고require.resolve()
를 사용하여 패키지 경로를 명시적으로 지정한다.
.yarnrc.yml
설정 조정:nodeLinker: node-modules
로 설정하여 특정 패키지에 대해 기존 node_modules 방식을 사용할 수 있다.
yarn dlx @yarnpkg/sdks
: VSCode와 같은 IDE에서 타입스크립트 지원을 위한 SDK를 설치한다.
packageExtensions
사용:.yarnrc.yml
파일에packageExtensions
설정을 추가하여 패키지 간 의존성 정보를 수동으로 보완할 수 있다.
하지만 이러한 해결책들이 모든 경우에 완벽하게 작동하지는 않기 때문에, 일부 개발자들은 결국 pnpm과 같은 다른 패키지 매니저로 전환하게 되었다.
pnpm: 효율적인 디스크 사용과 엄격한 의존성 관리
최근 인기가 높아지고 있는 pnpm은 디스크 공간 활용과 의존성 관리를 모두 효율적으로 해결하는 접근 방식을 취한다.
pnpm의 폴더 구조
pnpm은 심볼릭 링크와 하드 링크를 활용한 독특한 구조를 가지고 있다.
모든 패키지는 글로벌 스토어에 설치되고, 프로젝트에서는 이를 하드 링크로 참조한다.
각 패키지는 자신의 의존성만 볼 수 있게 심볼릭 링크가 구성되어 있어서 유령 의존성 문제를 원천적으로 방지한다.
심볼릭 링크와 하드 링크 이해하기
pnpm의 핵심 원리를 이해하려면, 심볼릭 링크와 하드 링크의 차이점을 알아야 한다. 이 두 링크 유형은 파일 시스템에서 데이터를 참조하는 방식의 차이를 가진다.
하드 링크
하드 링크는 파일 데이터의 실제 위치를 가리키는 추가적인 참조점이다. 파일 시스템에서 모든 파일은 inode라는 데이터 구조로 관리되는데, 하드 링크는 동일한 inode를 가리키는 또 다른 파일 이름이라고 볼 수 있다.
하드링크 사용 예시
하드 링크의 특징:
- 원본 파일과 동일한 inode를 공유
- 원본이 삭제되어도 데이터에 접근 가능
심볼릭 링크
심볼릭 링크는 윈도우의 바로가기와 유사하게, 특정 파일이나 디렉토리의 경로를 가리키는 특수한 파일이다. 원본 파일의 경로 정보만 저장하며, 별도의 inode를 가진다.
심볼릭 링크 사용 예시
심볼릭 링크의 특징
- 자신만의 inode를 가짐
- 데이터 블록에는 원본 파일의 경로만 저장
- 원본 삭제 시 참조할 경로가 없어져서 링크가 깨짐
pnpm에서의 링크 활용 방식
pnpm은 이 두 링크 유형을 효과적으로 조합하여 다음과 같은 구조를 구현한다:
- 글로벌 스토어(Content-addressable Store): 모든 패키지는 먼저
~/.pnpm-store
와 같은 전역 위치에 저장된다.
- 하드 링크를 통한 프로젝트 참조: 프로젝트의
.pnpm
디렉토리에는 글로벌 스토어의 패키지에 대한 하드 링크가 생성된다. 이를 통해 실제 패키지 파일은 한 번만 저장되지만, 여러 프로젝트에서 마치 별도의 복사본처럼 사용할 수 있다.
- 심볼릭 링크를 통한 의존성 구조: 프로젝트의
node_modules
디렉토리에는.pnpm
디렉토리의 패키지를 가리키는 심볼릭 링크가 생성된다. 이 구조를 통해 각 패키지는 자신의 직접적인 의존성만 접근할 수 있게 된다.
이 방식의 핵심은 다음과 같다:
- 디스크 공간 절약: 동일한 패키지는 모든 프로젝트에서 하드 링크를 통해 공유되므로, 실제로는 한 번만 저장된다.
- 의존성 고립: 각 패키지는 자신의
node_modules
디렉토리에 자신이 직접 의존하는 패키지만 심볼릭 링크로 연결되어 있어, 유령 의존성 문제가 발생하지 않는다.
- 설치 속도 향상: 이미 글로벌 스토어에 있는 패키지는 하드 링크를 생성하는 것만으로 설치가 완료되므로, 중복 다운로드가 발생하지 않는다.
pnpm의 이러한 구조는 npm과 yarn classic의 단점을 해결하면서도, yarn berry의 PnP 시스템보다 기존 Node.js 생태계와 호환성이 높다는 장점이 있다. 이 구조 덕분에 pnpm은 다음과 같은 장점을 제공한다:
- 디스크 공간 절약: 같은 패키지가 여러 프로젝트에서 공유된다
- 엄격한 의존성 경계:
package.json
에 명시된 패키지만 접근 가능하다
- 빠른 설치 속도: 이미 설치된 패키지는 하드 링크로 빠르게 연결된다
어떤 패키지 매니저를 선택해야 할까?
패키지 매니저 선택은 프로젝트의 특성과 팀의 선호도에 따라 달라질 수 있다. 이 글에서 살펴본 세 가지 패키지 매니저는 각각 고유한 장점과 한계를 가지고 있다:
- npm: 접근성이 좋고 생태계가 안정적이지만, 성능과 의존성 관리에 한계가 있다.
- yarn: classic은 속도와 안정성을 개선했으며, berry는 혁신적인 PnP 시스템으로 IDE 성능과 의존성 관리를 크게 향상시켰지만 호환성 문제가 존재한다.
- pnpm: 디스크 공간 절약, 엄격한 의존성 관리, 빠른 설치 속도라는 세 마리 토끼를 모두 잡은 균형 잡힌 솔루션이다.
개인적인 경험을 바탕으로, 현재 시점에서는 pnpm이 가장 균형 잡힌 선택이라고 생각한다. 특히 대규모 프로젝트나 모노레포 환경에서 pnpm의 장점이 두드러진다. 물론 모든 기술 선택에는 트레이드오프가 존재하므로, 각 패키지 매니저의 특성을 충분히 이해하고 프로젝트 요구사항에 맞는 선택을 하는 것이 중요하다.