필요한 패키지와 종속성(dependencies)을 자동으로 설치해주며, 버전 관리를 통해 최신 상테로 유지하거나 특정 버전을 유지하도록 도와준다.
require
, import
구문을 사용해서 외부 의존성을 참조하는 데, 그걸 올바르게 참조할 수 있도록 보장해주는 프로그램이라고 생각하면 된다!
JavaScript의 표준인 ECMAScript에 따르면 정확한 절대 경로나 상대 경로를 통해서만 import
할 수 있다.
실제로 Deno나 브라우저의 JavaScript 표준 문법을 보면 다 정확한 절대 경로를 사용하고 있다.
하지만 실제로 코드를 작성할 때 보면 절대 경로와 상대 경로 없이 모듈 이름만 적어서 가져오고 있다.
// ESModule
import React from 'react';
import { sum } from '@toss/utils';
// CommonJS
const _ = require('lodash')
이러한 방식이 가능한 이유는 빌드 도구와 패키지 매니저들이 이를 지원하도록 설정되어 있기 때문이다!
하지만 이런 방식의 문제점은 react
가 어떤 버전인지, @toss/utils
가 어떤 버전인지 명확하지 않다는 문제가 있다.
이러한 버전 정보들을 소스 코드보다 상위 디렉토리인 package.json
파일에 명시하여 모든 소스 코드 파일들이 특정 버전의 라이브러리를 사용할 수 있도록 보장해준다.
Yarn을 포함한 패키지 매니저들은 Resolution
, Fetch
, Link
세 단계로 동작한다.
Resolution는 영어 뜻 그대로 문제를 해결하는 단계이다.
패키지 매니저는 Resolution단계에서 package.json
에 명시되어 있는 버전 범위에 따라 정확한 버전을 결정한다.
예를 들어 react-dom
은 react
를 사용한다, 그런데 react
자체도 loose-envify
와 같은 다른 의존성을 가지고 있기 때문에 의존성이 또 어떤 의존성을 가지는지 확인하는 작업이 필요하다.
위와 같이 의존성의 버전을 범위로 명시하고 패키지 간에도 의존성을 가지기 때문에, 똑같은 package.json
에 대해서도 사용하는 의존성 버전이 완전히 달라질 수 있다.
Resolution 단계는 모든 기기에서 고정된 버전을 사용할 수 있도록 해주고, 의존성 버전을 전부 고정 시키고, 의존성의 의존성을 다 찾아서 그 버전도 고정시키며, 결과물을 yarn.lock
혹은 package.json
에 저장한다.
Resolution의 과정으로 결과된 버전을 실제로 다운로드하는 과정이다.
yarn.lock
이나 package.json
에 명시된 패키지를 네트워크를 통해 필요한 파일들을 가져온다.
일반적으로 99%는 npm 레지스트리에서 다 받아온다.
Resolution / Fetch된 라이브러리를 소스 코드에서 사용할 수 있는 환경을 제공하는 과정이다.
npm
, pnpm
, PnP
의 사례를 각각 살펴본다.
package.json
에 명시하는 모든 의존성을 node_modules
디렉토리 밑에다가 하나씩 쓰는게 npm Linker의 역할이다.
예를 들어 소스 코드에서 React와 TDS 모바일 라이브러리를 사용한다면, my-service
의node_modules
하위에 React와 TDS 모바일 패키지를 추가한다.
TDS 모바일 패키지에도 node_modules
이 있다면 @radix/dialog
를 그 밑에 깔아주는게 npm Linker가 하는 일이다.
my-service/
└─ node_modules/
| ├─ react/
| |
| └─ @tossteam/tds-mobile/
| └─ node_modules/
| └─ @radix-ui/react-dialog
|
└─ src
└─ index.ts
이 방식의 단점은 패키지를 찾으려고 하면 node_modules
를 계속 타고 올라가면서 파일을 여러 번 읽어야 한다.
실제로 파일 시스템에 디렉토리와 파일을 하나하나 만들고 쓰기 때문에 import
or require
하는 속도가 느려지고 디렉토리 크기가 너무 커진다는 단점이 있다.
만약 100개의 프로젝트에서 React 18.2.0 버전을 쓴다고 하면 정말로 100번 React 18.2.0이 추가된다.
이러한 문제점을 개선하기 위해 호이스팅 기법을 사용하지만, 최적화가 완전히 되는 것도 아니고 불안정하기도 해서 좋은 방법은 아니다.
이러한 단점때문에 pnpm
이 만들어졌다. 퍼포먼스가 향상된 npm
이라고 보면 된다.
pnpm Linker는 Hard link를 사용해 기존의 node_modules
디렉토리를 그대로 사용하는 대신 빠르고 용량을 최적하는 방식을 사용한다.
- Hard link란?
파일 시스템에서 한 파일에 여러 개의 이름을 부여하는 방법이다.
동일한 파일에 대한 여러 경로를 가지게 되므로, 각 경로는 동일한 데이터에 접근한다.
npm
처럼 단순 복붙하는 게 아니라 alias(새로운 이름)가 생기면 거기로 바로 접근하는 방식으로 의존성이 디스크에 하나만 설치가 된다.
node_modules
를 쓸 때도 파일을 하나하나 쓸 필요가 없어지고, 크기도 작으며 속도도 훨씬 빠르다. 이게 pnpm
이 접근하는 방식이다.
node_modules
디렉토리 계속 돌면서 alisa를 하나씩 걸기 때문에 약간 느리지만 npm
처럼 파일을 하나씩 쓰는 건 아니기 때문에 호환성도 좋고 훨씬 빠르다.
다만 node_modules
디렉토리는 그대로 유지하기 때문에, import
, require
할 때 파일 읽기가 많이 발생해서 멈추기도 하는 단점이 있다.
PnP Linker는node_modules
없이 의존성을 처리하는 방법이다.
PnP는 "패키지를 import
할 때 중요한 것은 두 가지" 라는 관점에서 접근한다.
먼저 어떤 파일에서 import
하는가, 그리고 무엇을 import
하는가 이 두 가지이다.
즉, 앞의 npm
과 pnpm
방식 처럼 node_modules
을 순회하는게 중요하지 않다고 생각하여 JavaScript 객체로 똑똑하게 처리한다.
PnP 동작을 좀 더 자세히 살펴보면 yarn install
을 하면 .pnp.cjs
라는 파일이 생긴다.
아래 파일을 해석해보면 my-service
라는 디렉토리에서 React를 import
할 수 있고, 18.2.0 버전을 사용하면 된다는 뜻이다.
["my-service", /* ... */ [{
// ./my-service에서...
"packageLocation": "./my-service/",
"packageDependencies": [
// React를 import 하면 18.2.0 버전을 제공하라.
["react", "npm:18.2.0"]
]
]
다음 예시도 마찬가지이다. React를 사용할 때도 npm 18.2.0이 있는 위치를 알려주고, 그 아래에 있는 패키지를 import
하면 명시된 버전의 패키지를 반환하면 된다는 뜻이다.
/* react 패키지 중에서 */
["react", [
/* npm:18.2.0 버전은 */
["npm:18.2.0", {
/* 이 위치에 있고 */
"packageLocation": "./.yarn/cache/react-npm-18.2.0-98658812fc-a76d86ec97.zip/node_modules/react/",
/* 이 의존성들을 참조한다. */
"packageDependencies": [
["loose-envify", "npm:1.4.0"]
],
}]
]],
이렇게 PnP는 의존성을 찾는 방법을 JavaScript Map으로 관리한다.
Yarn을 실행하는 순간 Node.js 프로세스가 이 PnP Map을 메모리에 전부 로드하고 import
와 require
문에서 이 Map을 참조한다.
Node.js의 --require
옵션과 --require
을 사용해서 Map을 로딩시키는데 import
와 require
의 동작을 바꾸는 Node.js의 API를 사용해 동작을 바꿔서 참고해 사용하도록 한 것 이다.
이 방식을 사용하면 yarn.lock
기반으로 .pnp.cjs
파일만 만들어서 쓰면 끝나기 때문에 설치 속도가 빠르고, 메모리에 파일이 로드되고 나면 Map 연산만 하기 때문에 node_modules
디렉토리도 순회하지 않아 import
나 require
하는 속도도 빠르다.
이러한 측면은 PnP는 굉장히 속도에 집중한 접근이라고 할 수 있다.
단점은 추가적인 초기화 과정과 패키지 위치 해석 작업이 필요하기 때문에 Node.js 프로세스가 뜨는 속도가 느리고,
PnP의 모듈 해석 방식이 전통적인 방식과 달라node_modules
디렉토리와 호환성이 낮다.
Link는 Resolution / Fetch 한 것을 기반으로 실제로 소스 코드에 필요한 라이브러를 사용할 수 있게 환경을 제공해주는 단계이다.
npm
1. 각 패키지가 자신의 node_modules
폴더에 의존성을 설치
2. 중복 설치로 인한 디스크 공간 낭비, 속도 저하 등의 이슈 발생
3. 호이스팅 기법으로 공통 의존성을 최상위 node_modules
로 끌어올림
4. 디스크 공간 절약 및 성능 개선
💡 호이스팅 기법을 사용해 이전 방식을 개선하였지만 유령 의존성과 같은 문제가 있다.
pnpm
1. 글로벌 저장소(store)에 패키지를 설치한다.
2. 심볼릭 링크와 하드 링크를 사용하여 node_modules
구조를 구성한다.
3. 프로젝트의 node_modules
에는 글로벌 저장소의 패키지에 대한 링크만 존재한다.
💡 이 방식으로 디스크 공간을 절약하고 설치 속도를 개선한다.
Yarn(Yarn Berry)
1. JavaScript Map 객체를 사용하여 의존성 트리를 메모리에 저장
2. .pnp.cjs
파일에 의존성 정보를 저장하고, 이를 통해 의존성을 찾는다.
3. node_modules
폴더를 생성하지 않고 .yarn/cache
에 패키지를 저장한다.
💡 파일 시스템 검색 대신 메모리 내 맵을 사용하여 성능을 향상 시킨다.
PnP: node_modules
없이 JavaScript Map 객체를 활용해 의존성을 엄격하고 빠르게 관리하는 방식
Zero-install: PnP의 JavaScript Map 객체와 Fetch된 의존성들까지 모두 Git에 넣어 버전을 관리하는 방식
PnP(Plug and Play) | Zero-install | |
---|---|---|
주요 개념 | JavaScript Map 객체를 활용해 의존성을 관리함 | 패키지 관리와 패치된 결과물을 모두 버전 관리 시스템(Git)에 포함시켜 설치 과정 생략 |
동작 방식 | pnp.cjs 파일을 사용해 의존성 위치를 관리 | 프로젝트의 의존성과 결과물을 저장소에 포함하여 설치 과정 없이 바로 사용 |
사용 조합 | npm 또는 Yarn PnP 모드에서 사용 가능 | npm 방식, PnP 방식 모두 사용 가능 (PnP 방식이 경제적(디스크 사용량, 설치 속도)이다.) |
장 점 | 하나의 의존성만 설치되어 효율적, 설치 속도 향상, 디스크 사용량 감소 | 모든 의존성과 환경을 버전 관리 하므로 일관성 있는 개발 환경 제공, 초기 설정 없이 빠른 시작 가능 |
단 점 | 기존 툴링과의 호환성 문제, 설정 복잡성 증가 | 의존성 업데이트 시 추가적이 관리 필요, 저장소 용량 증가 가능 |
npm
을 사용하면서 Zero-install을 할 수도 있고, PnP를 사용하면서 Zero-install을 할 수도 있다.
하지만 npm
방식으로 설치하면 중복된 의존성들이 너무 많아져서 용량이 커지는 반면 PnP는 효율적으로 하나의 의존성만 설치되므로 버전 관리가 용이하다.
과거 토스 프로트엔드 팀이 작을 때, 브랜치를 바꿀 때 마다 yan install
을 돌리고, 잘 안될 때는 node_modules
를 지우고 다시 깔아야 했는데, 이런 문제를 해결할 때 모든 의존성을 버전 관리하는 Zero-install이 유용했다.
하지만 이 방식의 단점은 레포지토리 사이즈가 커지고, Git 관리가 어려워진다는 점이다.
기존에는 zero-install의 동작이 어떻게 이루어지는 모르고 node_modules 디렉토리가 없고 설치가 빠르다.
npm이 느리고 yarn이 더 빠르다.
와 같은 정보만 얕게 알고 있었는데, 이번에 좋은 글들을 읽고 실제로 어떻게 동작해서 더 빨라지는지, 어떤 단점이 있는지 알게 되어서 나중에 패키지 매니저을 선정할 때도 명확한 기준으로 선정할 수 있어 도움이 될 것 같다.
⭐️⭐️⭐️ 패키지 매니저의 과거, 토스의 선택, 그리고 미래 ⭐️⭐️⭐️
node_modules로부터 우리를 구원해 줄 Yarn Berry