패키지 매니저 톺아보기 🔎

우혁·2024년 6월 26일
8

FE

목록 보기
4/11

📦 패키지 매니저(Package Manager)

필요한 패키지와 종속성(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 세 단계로 동작한다.

1. Resolution 단계

Resolution는 영어 뜻 그대로 문제를 해결하는 단계이다.

  • 첫 번째 문제는 라이브러리를 정확한 버전으로 고정하는 문제이다.

패키지 매니저는 Resolution단계에서 package.json에 명시되어 있는 버전 범위에 따라 정확한 버전을 결정한다.

  • 두 번째 문제는 설치한 라이브러리가 사용하는 다른 라이브러리, 즉 의존성의 의존성 문제이다.

예를 들어 react-domreact를 사용한다, 그런데 react 자체도 loose-envify 와 같은 다른 의존성을 가지고 있기 때문에 의존성이 또 어떤 의존성을 가지는지 확인하는 작업이 필요하다.

  • 세 번째 문제는 그 의존성의 버전도 고정해야 한다는 것이다.

위와 같이 의존성의 버전을 범위로 명시하고 패키지 간에도 의존성을 가지기 때문에, 똑같은 package.json에 대해서도 사용하는 의존성 버전이 완전히 달라질 수 있다.

📝 Resolution단계 정리하기

Resolution 단계는 모든 기기에서 고정된 버전을 사용할 수 있도록 해주고, 의존성 버전을 전부 고정 시키고, 의존성의 의존성을 다 찾아서 그 버전도 고정시키며, 결과물을 yarn.lock 혹은 package.json에 저장한다.

2. Fetch 단계

Resolution의 과정으로 결과된 버전을 실제로 다운로드하는 과정이다.

yarn.lock 이나 package.json에 명시된 패키지를 네트워크를 통해 필요한 파일들을 가져온다.

일반적으로 99%는 npm 레지스트리에서 다 받아온다.

Resolution / Fetch된 라이브러리를 소스 코드에서 사용할 수 있는 환경을 제공하는 과정이다.

npm, pnpm, PnP의 사례를 각각 살펴본다.

  • npm Linker

package.json에 명시하는 모든 의존성을 node_modules 디렉토리 밑에다가 하나씩 쓰는게 npm Linker의 역할이다.

예를 들어 소스 코드에서 React와 TDS 모바일 라이브러리를 사용한다면, my-servicenode_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 Linker

이러한 단점때문에 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

PnP Linker는node_modules 없이 의존성을 처리하는 방법이다.

PnP는 "패키지를 import할 때 중요한 것은 두 가지" 라는 관점에서 접근한다.

먼저 어떤 파일에서 import 하는가, 그리고 무엇을 import 하는가 이 두 가지이다.

즉, 앞의 npmpnpm 방식 처럼 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을 메모리에 전부 로드하고 importrequire문에서 이 Map을 참조한다.

Node.js의 --require옵션과 --require을 사용해서 Map을 로딩시키는데 importrequire의 동작을 바꾸는 Node.js의 API를 사용해 동작을 바꿔서 참고해 사용하도록 한 것 이다.

이 방식을 사용하면 yarn.lock기반으로 .pnp.cjs 파일만 만들어서 쓰면 끝나기 때문에 설치 속도가 빠르고, 메모리에 파일이 로드되고 나면 Map 연산만 하기 때문에 node_modules 디렉토리도 순회하지 않아 importrequire하는 속도도 빠르다.

이러한 측면은 PnP는 굉장히 속도에 집중한 접근이라고 할 수 있다.

단점은 추가적인 초기화 과정과 패키지 위치 해석 작업이 필요하기 때문에 Node.js 프로세스가 뜨는 속도가 느리고,
PnP의 모듈 해석 방식이 전통적인 방식과 달라node_modules 디렉토리와 호환성이 낮다.

📝 Link단계 정리하기

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 vs Zero-install

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

profile
🏁

0개의 댓글