본문 바로가기
JavaScript,TypeScript/JavaScript

패키지 매니저

by 그냥하는거지뭐~ 2025. 5. 20.

1. 패키지 매니저가 해결하는 것 

우리는 평소 아무 생각 없이 "npm install"이나 "yarn install" 같은 명령어를 실행한다. 하지만 이 명령어가 실행되는 동안, 뒤에서는 어떤 일이 일어나고 있었을까?

 

1.1. 혼란스러웠던 초창기 자바스크립트의 모듈 시스템 

초창기에 우리 조상님(?)들은 외부 라이브러리를 쓰기 위해 jquery.min.js 파일을 직접 다운로드해서 프로젝트에 복사하곤 했다. 그리고 아래와 같이 <script> 태그를 써서 순서를 조심스럽게 맞춰가며 수동으로 로딩해야 했다.

<script src="./lib/jquery.js"></script>
<script src="./lib/jquery-plugin.js"></script>
<script src="./my-code.js"></script>

 

이뿐만이 아니라, 같은 이름의 파일이 여러 버전으로 존재하고, 스크립트 로딩 순서가 바뀌면 실행되지 않으며, 전역 스코프 충돌로 디버깅은 일상이었다. 팀 프로젝트를 진행하다 보면 어떤 버전의 코드가 어디서 왔는지 추적조차 힘들었다. 

 

이런 혼란을 해결하기 위해 모듈 시스템이 등장했다. CommonJS, AMD, ES Modules 등 다양한 방식이 제안되었고, import, require와 같은 문법을 통해 외부 코드를 구조적으로 분리할 수 있게 되었다.

 

Module System

목차1. JavaScript 모듈이 왜 등장했을까2. 모듈 시스템의 발전 흐름 (CommonJS > AMD -> UMD -> ESM)3. 마치면서.. (다음 글 예고) 1. JavaScript 모듈이 왜 등장했을까 모듈이 왜 등장했는지부터 알아보자. 그러

hwanheejung.tistory.com

 

1.2. 외부 패키지를 안전하고 일관되게 사용하는 법

하지만 모듈 시스템만으로는 충분하지 않았다. 프로그래밍 생태계가 커지면서 새로운 문제가 등장했기 때문이다.

  • 라이브러리가 점점 많아졌고
  • 프로젝트는 수많은 외부 의존성에 기대게 되었으며
  • 각 라이브러리는 업데이트되고 버전이 나뉘었다

그러자 개발자들은 다시 이런 고민을 하게 된다:

  • "내가 지금 쓰고 있는 이 lodash는 몇 버전이지?"
  • "팀원이랑 내가 같은 버전을 쓰고 있는 걸까?"
  • 이걸 어떻게 다른 사람도 똑같이 설치할 수 있을까?"

그 해결책으로 개발자들은 한 가지 합의를 하게 된다. "내가 어떤 패키지를 어떤 버전으로 쓰고 있는지 명시할게. 이걸 그대로 받아서 써!"

그렇게 만들어진 것이 바로 package.json이다. 그리고 이 파일을 기반으로 패키지를 자동으로 설치하는 명령어 "npm install"이 등장한다.

하지만 이 간단한 명령어 하나에도 사실은 수많은 복잡한 문제가 얽혀 있다.

 

1.3. “그럼 이걸 어떻게 설치하지?”

모듈 시스템은 코드를 구조화해줬고, package.json은 어떤 라이브러리를 쓸지 정의해줬다. 그런데 그 라이브러리를 실제로 설치하고 연결하는 건 누가 어떻게 해야 할까?

수많은 프로젝트와 라이브러리가 얽힌 생태계에서, 의존성이 꼬이지 않도록 하려면 구조적으로 깔끔하게 설치하는 로직이 필요하다.
그래서 패키지 매니저는 다음의 세 단계를 거치게 된다.

  1. Resolution: 어떤 패키지의 어떤 버전을 설치할 것인가?
  2. Fetch: 그 패키지를 어디에서 받아올 것인가?
  3. Link: 받아온 패키지를 어떻게 연결할 것인가?

이제부터 위의 세 단계를 중심으로, npm, yarn, pnpm이 어떤 문제를 해결하려 했는지, 그리고 어떤 방식으로 접근했는지를 하나씩 살펴보려 한다.


2. Resolution: "무엇을 설치할 것인가?"

패키지 매니저가 설치를 수행할 때 가장 먼저 하는 일은, package.json을 읽고 “어떤 패키지를, 어떤 버전으로 설치할 것인지”를 결정하는 것이다. 이 과정을 Resolution 단계라 부른다.

2.1. package.json에 어떤 식으로 작성할 것인가?

package.json에는 보통 다음과 같이 의존성 패키지를 명시한다:

{
  "dependencies": {
    "axios": "^1.5.0",
    "lodash": "~4.17.0",
    "uuid": "9.0.0"
  }
}

 

이때 사용되는 버전 형식은 세 가지다:

형식 의미 예시 범위
^ (caret) 마이너, 패치 업데이트 허용 ^1.5.0 ≥1.5.0 <2.0.0
~ (tilde) 패치 업데이트만 허용 ~4.17.0 ≥4.17.0 <4.18.0
고정 버전 정확히 그 버전만 허용 9.0.0 =9.0.0

 

대부분의 개발자와 라이브러리는 semver range(^, ~)을 사용한다.
그 이유는 다음과 같다:

  • 자동으로 버그 수정이나 보안 패치가 반영된다.
  • 라이브러리 개발자가 여러 프로젝트에 대해 호환성을 선언하기 쉬워진다.
  • 같은 범위 내 버전을 공유하면 중복 설치가 줄어들고 빌드가 빨라진다.

즉, 유지보수성, 확장성, 효율성을 위해 semver range(범위 버전)이 기본 선택지가 된 것이다.

 

2.2. 하지만 package.json만으로는 불충분하다

범위 버전은 편리하지만, 한 가지 큰 문제가 있다. 바로 해석 시점에 따라 실제 설치되는 버전이 달라질 수 있다는 점이다 .

예를 들어 "lodash": "^4.17.0"은 오늘 설치하면 4.17.21, 내일 설치하면 4.17.22가 될 수도 있다.

 

게다가 package.json은 직접 명시한 루트 의존성만 포함할 뿐, axios가 내부적으로 사용하는 follow-redirects, form-data 같은 하위 의존성(sub-dependency)은 전혀 알 수 없다.

 

이로 인해 발생하는 문제는 다음과 같다:

  • 팀원마다 설치 결과가 다름
  • 로컬에서는 잘 되지만 CI 환경에서는 깨짐
  • “그 버그 언제 생겼는지 모르겠음” → 디버깅 불가능

즉, 같은 package.json으로도 결과가 달라지는 상황이 실제로 흔하게 발생한다.

 

2.3. 그래서 등장한 것이 Lock File

이 문제를 해결하기 위해 도입된 것이 바로 Lock File이다. Lock File은 다음을 수행한다:

  • 한 번 해석된 정확한 버전(Resolution 결과)을 고정
  • 모든 하위 의존성 포함 전체 트리를 기록
  • 설치 대상의 URL, 무결성 해시(integrity)까지 포함
패키지 매니저 Lock File
npm package-lock.json
yarn yarn.lock
pnpm pnpm-lock.yaml

 

예시 (package-lock.json 일부)

"lodash": {
  "version": "4.17.21",
  "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
  "integrity": "sha512-..."
}

 

이 정보를 통해:

  • 어떤 환경에서도 항상 같은 버전이 설치되고
  • 어떤 의존성 트리였는지 정확히 재현할 수 있으며
  • 버그가 생겼을 때 언제 어떤 버전이 들어왔는지 추적이 가능해진다

즉, 범위 버전의 유연함은 그대로 유지하면서, Lock File로 결과는 고정하는 방식이 패키지 매니저의 기본 전략이다.

 


3. Fetch: "어디서 받아올 것인가?"

Resolution 단계를 통해 설치할 정확한 패키지 버전이 결정되었다면, 그다음은 이 패키지를 어디에서, 어떻게 받아올 것인가를 결정하는 단계다. 이 과정을 Fetch 단계라 부른다.

 

단순히 패키지를 “다운로드”하는 것처럼 보일 수 있지만, 실제로는 다음과 같은 속도, 안정성, 재현성과 관련된 중요한 문제들이 얽혀 있다:

  • 매번 설치할 때마다 패키지를 다시 받아와야 할까?
  • 네트워크가 끊기면 개발이 멈추는 건 아닐까?
  • CI/CD에서 설치 속도를 어떻게 개선할 수 있을까?

각 패키지 매니저는 이 문제들을 해결하기 위해 저장 방식과 캐시 전략을 고안해 왔다.

 

3.1. 기본 동작

모든 패키지 매니저는 기본적으로 다음의 순서를 따른다:

  1. 설치할 버전과 URL은 Lock File에 이미 기록되어 있다.
  2. 해당 .tgz(tarball) 파일을 NPM registry에서 다운로드한다.
  3. 받아온 패키지를 로컬 디스크에 저장하거나 압축 해제한다.

여기까지는 공통된 동작이지만, 캐시 관리 방법에서 전략이 달라진다. 

 

3.2. npm – 기본적인 디스크 캐시

  • npm은 $HOME/.npm/_cacache/ 디렉토리에 패키지를 캐시한다.
  • 다음에 같은 버전을 설치하면, 캐시에서 불러와 설치 속도를 개선할 수 있다.
  • 하지만 이 캐시는 로컬에만 저장되며, Git이나 CI에 포함되지 않는다.

📌 특징 요약:

  • 오프라인 설치 가능 (단, 같은 환경에서만)
  • 캐시 불일치나 삭제 시 다시 다운로드
  • CI/CD에서는 매번 설치가 일어나기 때문에 속도 개선 효과가 크지 않다

3.3. yarn – zip 캐시 + Zero-install 

yarn은 패키지 캐시를 .zip 파일로 저장하고, 이 .yarn/cache/ 디렉토리를 Git에 커밋함으로써 설치 과정 자체를 생략할 수 있는 전략, 즉 Zero-install을 도입했다.

my-app/
├── .yarn/cache/
│   └── axios-npm-1.6.0-xxxxx.zip
├── .yarnrc.yml
├── yarn.lock
  • 이 캐시는 registry가 필요 없는 완전한 오프라인 설치를 가능하게 한다.
  • Git에 커밋되므로 CI에서도 yarn install 없이 바로 실행 가능

📌 특징 요약:

  • 캐시를 Git에 포함하여 설치 자체를 생략
  • CI에서 빠른 빌드, 네트워크 장애에 강함
  • 디스크 공간은 조금 더 쓰지만, reproducibility는 매우 높음

3.4. pnpm – 전역 스토어 + 하드링크

pnpm은 전역 저장소(global store)에 압축을 풀고, 각 프로젝트에는 하드링크만 생성한다.

# 저장 경로 예시
~/.pnpm-store/v3/files/...
  • 동일한 패키지를 여러 프로젝트에서 사용해도 디스크에 한 번만 저장됨
  • 실제 프로젝트에서는 .pnpm/ 디렉토리에 압축 해제된 패키지를 모아두고,
    node_modules에는 symbolic link 또는 hard link로 연결

📌 특징 요약:

  • 디스크 사용량이 가장 적음 (중복 제거)
  • 설치 속도가 빠르며, 하드링크이기 때문에 성능 저하 없음
  • 단점: .pnpm-store는 로컬 전용이므로, CI에서 캐시 설정을 따로 해줘야 함

4. Link: "어떻게 연결할 것인가?"

Fetch 단계에서 패키지를 받아왔다면, 이제 이 패키지를 프로젝트 내부에서 require()나 import로 사용할 수 있도록 연결해야 한다.
이 연결을 담당하는 것이 바로 Link 단계다.

 

이 단계에서는 단순히 폴더를 만드는 것이 아니라, Node.js가 require('lodash') 또는 import _ from 'lodash'를 호출했을 때 정확히 어떤 경로를 탐색할 수 있도록 환경을 구성하는지가 핵심이다.

 

패키지 매니저마다 이 동작을 처리하는 방식은 다르며, 이 방식이 결국 다음과 같은 것들에 영향을 미친다:

  • 디스크 사용량
  • 모듈 탐색 속도
  • 디버깅과 문제 추적 가능성
  • 모노레포 등 대규모 프로젝트의 구조적 안정성

4.1. Link 가 뭐지?

Node.js의 모듈 시스템(CommonJS / ESM)은 다음과 같은 구조를 가정한다:

import _ from 'lodash';
// or
const _ = require('lodash');

이때 Node.js는 다음 경로를 탐색한다:

/project/node_modules/lodash
/project/node_modules/lodash/index.js

 

즉, 패키지 매니저는 설치된 패키지를 이런 경로에 존재하도록 배치하거나,
혹은 다른 방식으로 Node.js의 모듈 로딩 로직과 호환되도록 가상 경로를 연결해야 한다.

 

4.2. npm – 깊고 중첩된 node_modules

npm은 다음 원칙을 따른다:

  • 가능한 모든 의존성을 루트 node_modules/에 hoist한다.
  • 하지만 버전 충돌이 생기면, 해당 패키지 하위에 또다시 node_modules/를 만든다.
my-app/
├── node_modules/
│   ├── a/
│   │   └── node_modules/
│   │       └── lodash@4.17.21
│   ├── b/
│   │   └── node_modules/
│   │       └── lodash@3.10.1
│   └── lodash (없음)

⚠️ 문제점

  • lodash는 2번 설치되어 디스크 낭비 발생
  • 중첩이 깊어지면 Windows 등에서 ENOENT: path too long 오류 발생 가능
  • require()가 깊은 경로로 탐색되면 어느 파일이 어떤 버전을 참조하는지 추적이 어려움
// 이 require는 어떤 lodash를 사용하는가?
const _ = require('lodash');

npm은 실수로 루트에 없는 모듈을 require해도 조용히 통과시키기 때문에,
의존성 오염이 발생해도 디버깅이 어렵다

 

 

4.3. yarn PnP – node_modules 없이 .pnp.cjs로 경로 매핑

yarn은 2.x부터 기존의 node_modules 구조를 아예 제거하고,
.pnp.cjs 파일을 기반으로 가상의 경로 매핑을 사용한다. 이를 Plug’n’Play(PnP)라고 부른다.

my-app/
├── .pnp.cjs         # 모든 모듈의 경로 매핑 정보
├── .yarn/cache/     # zip 형태로 저장된 패키지
├── node_modules/    # (존재하지 않음)

 

Node.js에서 모듈을 불러올 때, require("lodash") 호출이 내부적으로 .pnp.cjs를 참조하여 "lodash"가 어떤 zip 파일에 들어 있는지 알아낸다.

// .pnp.cjs 
module.exports = {
  // 패키지별 경로 매핑
  "lodash": [
    [
      "1.0.0",
      "/Users/yourname/my-app/.yarn/cache/lodash-npm-4.17.21-xxxx.zip/node_modules/lodash"
    ]
  ],
  ...
}
  • 모듈을 불러오면 yarn이 .pnp.cjs에서 해당 경로를 찾아 직접 연결해준다
  • Node.js의 기본 모듈 해석 로직을 인터셉트해서 가로채는 방식

✅ 장점:

  • node_modules가 아예 없음 → 디스크 공간 절약, 설치 시간 단축
  • yarn install 자체가 거의 생략되며 Zero-install과 시너지가 좋음

⚠️ 단점:

  • Webpack, ESLint, Jest 등 많은 도구가 node_modules 전제를 깬 환경에 대해 지원이 부족하거나 불안정
  • 일부 패키지는 내부적으로 require.resolve() 등으로 경로를 직접 다루기 때문에 PnP 호환성 문제가 생김
  • → 해결을 위해 PnP 플러그인 추가 또는 fallback 모드 필요

4.4. pnpm – .pnpm/ 내부 정규화 + 하드링크 alias

pnpm은 정규화된 패키지 저장소(.pnpm)를 만들고, node_modules에는 해당 경로를 하드링크로 연결하는 구조를 채택했다.

my-app/
├── .pnpm/
│   ├── lodash@4.17.21/
│   ├── lodash@3.10.1/
│   └── a@1.0.0/
├── node_modules/
│   ├── lodash → 없음
│   ├── a → .pnpm/a@1.0.0/node_modules/a
│   └── b → .pnpm/b@1.0.0/node_modules/b

 

  • 패키지는 .pnpm/ 내부에서 압축 해제된 형태로 저장됨
  • node_modules/는 실제 파일 없이 링크(alias)만 보유
  • 하나의 파일을 여러 패키지가 공유 → 중복 설치 없음, 디스크 효율 극대화

pnpm은 특히 strict isolation이라는 강력한 특징이 있는데, 

  • 루트에서 명시하지 않은 의존성을 require()하면 즉시 에러 발생
  • 의존성 오염, 우회 참조, 의도치 않은 작동을 사전에 차단
// 루트 package.json에 lodash가 없지만,
const _ = require('lodash'); // → pnpm에서는 에러 발생

이 방식은 실수를 차단하고, 어떤 패키지가 어떤 의존성을 사용하는지 명확히 분리해준다.

 

✅ 장점:

  • 중복 설치 없음 → 디스크 사용량 감소
  • 링크이므로 실제 파일은 1개 → 빠르고 공간 효율적
  • 의존성 오염 원천 차단
  • .pnpm 디렉토리 구조 = Lock File 구조 = 추적 편리
  • 모노레포나 대규모 프로젝트에서 명확한 의존성 추적 가능

⚠️ 단점:

  • .pnpm/ 구조는 일반적인 node_modules 트리와 달라 일부 도구나 플러그인에서 설정이 필요할 수 있음
  • hoisting이 제한되기 때문에, 일부 의존성 우회 호출은 깨질 수 있음

 

항목 npm yarn pnpm
핵심 가치 관용성과 호환성 결정론적 설치와 생산성 구조적 명확성과 일관성
철학 요약 "웬만하면 설치는 되게 해줄게" "같은 결과를 항상 만들자" "실수조차도 막아야 한다"
Resolution 유연하지만 느슨함 (충돌 허용) 병합 시도 + 정렬 중심 strict isolation (의존성 오염 차단)
Lock File 구조 트리 형태 (package-lock.json) 평탄하고 읽기 쉬움 (yarn.lock) 경로 기반 해시 구조 (pnpm-lock.yaml)
Fetch 전략 단순 캐시 Git 커밋 가능한 zip 캐시 + Zero-install 전역 store + 하드링크 공유
Link 전략 중첩된 node_modules PnP (node_modules 제거, 경로 매핑) .pnpm 내부 정규화 + 링크 구조
디스크 효율성 ❌ 중복 설치 많음 ⭕ 보통 ✅ 하드링크 기반, 중복 없음
실수 방지력 ❌ 낮음 (묵인) 중간 ✅ 엄격한 차단 (require도 제한) 
호환성 최고 좋음 ⚠ 일부 도구 설정 필요
적합한 대상 개인, 취미 프로젝트, 전통 프로젝트 대부분의 현대 프로젝트, 모노레포 대규모 프로젝트, 장기적 유지보수, 구조 중요 시

 

  • npm은 유연하고 호환성 높지만, 구조적 추적과 최적화 측면에서 취약하다.
  • yarn PnP는 기존 구조를 없애고 경량화와 속도 중심으로 재설계했으나, 호환성 문제를 동반한다.
  • pnpm은 디스크 효율과 명확성을 최우선으로 삼고, 잘못된 연결을 원천 차단한다.

 

'JavaScript,TypeScript > JavaScript' 카테고리의 다른 글

Module System  (6) 2024.08.20
JavaScript가 비동기를 다루는 방법  (4) 2024.04.05