티스토리 뷰

반응형

Node.js와 그에 파생된 여러 프레임워크를 사용하다 보면, 패키지 설치와 의존성 관리를 담당하는 패키지 매니저를 자주 볼 수 있다. (package.json으로부터 비롯된 매니징 툴 npm, yarn, pnpm 등등...) 다만, 이러한 라이브러리 패키지 매니징과 node_modules 폴더에 대한 깊은 고찰을 한 적은 없어서 이번 글을 통해 해보려 한다. 이번 글에서는 npm, yarn의 역사와 의존성, 패키지 관리에 대하여 살펴보자.

 

NPM & Yarn에 대하여 알아보자.


1. import, require는 Node.js에서 어떤 원리로 동작하는가?

현대 JS에서 사용하고 있는 모듈 불러오기 문법은 두 가지가 있다. Import 문과 Require 문이 그 둘이다. 두 문법에는 여러 작은 차이가 있지만, 이번 글의 목적은 두 문구의 비교가 아니기에 자세한 내용은 적지 않겠다.

import fs from 'fs';

Import 또는 require를 통해 모듈을 불러올 경우, Node.j는 먼저 Node.js 자체 내장 모듈인지 검색한다. 예를 들어, Node.js가 기본으로 제공하는 fs, http 모듈을 불러올 경우 Node.js는 이들을 우선적으로 불러오게 된다. Node.js가 기본으로 제공하는 모듈들을 다음 링크에서 제공한다.

import App from "./App.js"

다음으로, Node.js는 파일 경로인 지 검색한다. App.js라는 파일이 동일 경로에 존재한다고 가정할 경우, Node.js는 파일 경로임을 인식하면 해당 파일을 불러오는 방법으로 동작한다.

import React from "React";

 다음으로, Node.js가 기본으로 제공하지 않는 string이 인식될 경우, Node.js는 node_modules 폴더 안의 경로를 모두 탐색하기 시작한다. Node.js 내부의 module resolver 함수가 해당 string을 받으면, module.paths에 등록되어 있는 모든 폴더를 탐색하기 시작한다.

$ node
> module.paths
[
	"/some/path/node_modules"
    "/some/node_modules"
    "/node_modules"
    ...
]

Node.js는 현재 모듈의 상위 디렉토리에 해당하는 node_modules부터 검색을 시작하며, 찾는 string이 발견될 때까지 반복한다.  따라서, 어디서 import 하냐에 따라 다른 결과가 나올 수 있고, import문은 상대적이라는 의미를 함축하고 있다. 달리 말해, 다른 폴더에서 node > module.paths를 실행한다면 다른 결과를 얻을 수 있다.

 

쉽게 말해, 가장 가까운 node_modules를 탐색한다고 생각할 수 있다.

 

이러한 방식의 장점은 각 패키지 마다 자신만의 의존성을 가질 수 있고, 다른 패키지에 의해 간섭받지 아니하며, 여러 파일을 import해도 그리 큰 문제가 되는 일은 없다. 다만, 여러 버전을 동시에 들고 있다는 것 자체가 최적화에 안좋은 영향을 미치며, 동일한 코드를 여러 번 다른 곳에 쓰면 낭비가 아닐 수 없다.

 

모던 웹사이트는 속도 및 최적화를 굉장히 중요시하기에, 파일 크기 및 번들링된 js 크기를 최대한 줄일려고 한다. 혹은 싱글턴 방식으로 동작하는 패키지 (ex. graphql) , 글로벌 패키지는 위 방식과 잘 맞지 않는다. 또한, 각 패키지마다의 독립적인 의존성을 가지니, 각 패키지 안에 어떤 일이 발생하는 지 아무도 모른다. 또한, 어떤 패키지에 작은 패치가 이루어져도 이에 의존하는 패키지들은 모두 빌드를 다시 해야하므로 공간의 낭비로 이루어진다. 또한, 이러한 패키지가 많아질수록 버전이 꼬일 것은 불 보듯 뻔하다.

 

이러한 배경 속에서 JS의 폭발적인 성장을 이끈 패키지 매니저가 나왔다.


2. Npm & Yarn의 등장

2010년 1월, 패키지 레지스트리 및 의존성 관리 관리 툴 npm이 등장하였다. Npm이 등장한 이유는 글로벌 패키징이 아닌, 로컬 패키지를 사용하도록 하고, 패키지 버젼을 관리하고, 중복된 패키지를 관리하기 위함이다. 사실 npm은 Node Package Manager의 약자가 아니며, bash 유틸 중 하나인 pm(pkgmakeinst)를 node에 맞게 구현한 것이라 생각하면 된다.

 

기존처럼 하나의 글로벌 패키징을 할 경우 보통 한 가지 버젼만 사용할 수 있도록 하였으며, 이러한 방식은 여러 부작용을 촉발하였다. 예를 들어, 여러 패키지가 의존하고 있는 하나의 모듈에 에러를 발생시키거나, 그 패키지의 API가 크게 바뀌거나, 그 패키지에 누군가가 악의적인 코드를 심었다면? 그야말로 난리가 날 것이다.

 

예를 들어, 2020년에 유명 개발자 Marak Squires가 패키지 colors.js에 일부러 악의적인 무한루프 코드를 심어 이에 의존하는 패키지를 모두 골로 보낸 사건이 있다. 개발자 말로는 대기업들이 자기의 오픈소스를 쓰면서, 아무런 보상이 없다는 것에 잔뜩 화가 난 모습인데, 이에 대한 진실은 오리무중이다. 아무튼 이러한 패키지 사보타주가 있을 경우, 이에 영향을 받는 패키지 (jest, netlify와 같은 유명 패키지까지) 모두 폭~삭~ 망~ 해~ 버리는 상태가 될 수 있다는 것이다.

 

따라서, 각 패키지마다의 일정 테스트를 통과하게 하거나, 기존 레거시 API를 계속 지원하거나, SHA-512 알고리즘 도입으로 패키지 무결성을 검사하는 방식을 활용하였다. (package-lock.json에서 보이는 외계어들이 이런 맥락을 포함하고 있다.)

 

Npm은 여러 영리한 방법으로, 각 버젼의 패키지를 싹 다 설치하는 방향을 채택하였다. 각 패키지마다 고유한 해쉬값을 부여햐여, 여러가지 버젼, 변형을 각각 설치하도록 하였다. 이러한 경우 디스크 공간을 많이 잡아먹는다는 단점이 존재하지만, 다른 방법으로 최대한 디스크 공간 차지를 줄이려 한다.

 

한 가지 방법으로 package.json에서 버젼을 살펴보면 틸다(~)와 캐럿(^) 표시, 부등호 표시들을 적극 활용하여 최대한 현재 키지를 재활용하려 한다. 이러한 방식을 시맨틱 버전 관리(Semantic Versioning)이라고 한다.

패키지 버젼 읽는 방법

버전은 점으로 구분된 세 가지 숫자와 추가적인 정보로 이루어져 있다.  첫 번째 숫자는 Major 버전으로, 이 숫자가 바뀔 경우 정말 큰 변화가 발생했다고 할 수 있다. 아예 새로운 패키지라고 생각해도 좋을 정도로 많은 변화 및 내용 확장이 발생할 때 이 숫자가 바뀐다고 생각해도 된다. 두 번째 숫자는 Minor 버전으로, Major보다 적지만 기능이 추가되거나, 내부에 커다린 리팩토링을 진행하였거나, 오래된 함수를 관리할 때 바뀌는 숫자이다. 이 숫자가 바뀌었을 경우 기존의 API는 사용이 가능하지만, 가급적 새로운 기능을 이용을 권장한다. 세 번째 숫자는 Patch 버전으로, 미미한 변화가 생기거나 버그 수정과 같이 사소한 변화가 생겼을 때 바뀌는 숫자이다. 그 뒤에는 추가적으로 베타 버전, 사전 등록 버전이란 뜻을 붙일 때 사용한다.

npm에서는 시맨틱 버전 관리를 할 시 위 세 숫자를 기준으로 패키지를 관리한다. 틸다(~)의 경우 같은 Major, Minor 버전까지는 사용하겠다는 의미로, ~1.2.3이라 적혀 있는 경우 < 1.3.0 버전까지는 동일한 릴리즈를 사용하겠다는 의미를 가진다. 캐럿(^)의 경우 같은 Major 버전까지는 사용하겠다는 의미로, ^1.2.3이라 적혀있는 경우 < 2.0.0 비전까지는 동일한 릴리즈를 사용하겠다는 의미를 가진다. 다만 버전이 1.0.0 미만일 경우, 다시 말해 해당 패키지가 pre-release의 경우 API 변경 및 리팩토링이 자주 발생하므로 캐럿이 틸다처럼 동작한다.


npmv3의 종속성 트리 호이스팅

또 다른 방법으로, 서로 다른 버전으로 종속성을 가질 경우, 해당 패키지에 대하여 여러가지 버전을 가지도록 한다. npm3부터는 각 패키지들을 호이스팅하여 종속성 트리를 최대한 평평하게 만들어 중복 패키지를 제거하려고 한다.

 

Yarn(Yet Another Resource Negotiator)는 구글, 페이스북과 같은 대기업 빨(?)을 받아 더 발전된 패키지 매니징을 지원한다. 특히, Yarn은 npm 보다 더 나은 속도, 보안, 신뢰성을 제공한다. 그 비결 중 하나로 다운로드 받은 패키지를 캐시에 저장하고 이후 추가된 패키지가 이에 대한 종속성이 있을 경우 이를 복사하여 제공하고, 여러 패키지를 병렬적으로 다운로드 받을 수 있도록 하여 속도를 더욱 증가시켰다. 또한, 더 엄격한 의존성 관리로 각 패키지을 확인하여 신뢰성 있고 등록된 패키지만 의존성으로 설정할 수 있도록 하였다.

 

특히, 정확한 버전이 명시되어 있는 의존성 파일 yarn.lock을 제공함으로써 일관된 개발 환경을 만들 수 있고, npm도 package.lock 파일을 npm5부터 제공하기 시작했다.


다만, 이러한 시스템은 여전히 문제를 일으켰다.

 

Npm과 yarn이 제공하는 패키지 매니지 시스템은, 토스의 표현을 빌리자면 "깨져 있다." 1번에서 알아보았듯 node_modules를 탐새가는 것은 자원과 시간이 많이 드는 I/O 호출을 반복하는 행위이고, 현재 경로에 따라 상이한 결과가 나올 수 있다는 점에서 문제를 일으킬 확률이 컸다.

 

또한, 패키지 호이스팅을 통해 종속성을 관리하다 보니 해당 어플리케이션이 직접 의존하지 않는 라이브러리를 import할 수 있고, 이를 우린 유령 의존성(phantom dependency)라 한다. 다른 말로, package.json에 명시되지 않는 import할 수 있고, 만약 명시되지 않는 의존성을 import할 경우 코드에 혼란이 올 수 있으며, 해당 패키지를 사용하는 패키지를 package.json에서 제거하면 같이 사라지는 현상을 볼 수 있다. 따라서, 효과적인 의존성 시스템에 대한 요구가 지속되었고, 다른 방법을 활용한 여러 패키지 매니저가 등장하기 시작했다.


3. pnpm & yarn berry의 등장 및 동작

Pnpm(Performant NPM)은 NPM의 다양한 기능들을 향상시킨 패키지 매니저 툴이며, npm의 호이스팅 방식을 활용하여 의존성을 평탄화 하는 대신, 심볼릭 링크 및 파일 시스템을 영리하게 활용하였다.

 

pnpm의 경우 심볼릭 링크, 하드 링크를 활용하여 각 패키지에 접근한다. 

심볼릭 링크와 하드 링크

심볼릭 링크란 FS 상의 특정 파일을 가리키는 것을 의미하며, 쉽게 말해 '바로 가기'에 해당하는 개념이라 생각하면 된다. 하드 링크는 반대로 실제 물리적인 하드 디스크에 저장되어 있는 특정 파일의 위치를 가리키는 것으로, 원본 파일을 지워도 원본 파일의 inode에 접근하기에 데이터에 접근이 가능하다.

 

pnpm의 구현 방법

pnpm의 경우 위와 같은 방법으로 패키지에 접근하며, 위의 그림에서 살펴보면 하나의 .pnpm store라는 공간에 실제 패키지에 접근하는 것을 알 수 있다. 실제로 pnpm을 사용할 경우 ~/Home 디렉토리에 하나의 node_modules를 사용하며, 글로벌 패키징과 독립된 패키징의 장점을 합친 결과라고 할 수 있다.

 

다만, 이러한 방법은 OS, 파일 시스템마다 다르게 구현되어야 하며, 다른 툴과의 호환성 문제, 네트워크 파일 시스템 접근 관련해서 여러 자잘한 문제를 일으키기에 pnpm은 생각보다 큰 반향을 일으키진 못했다. 


Yarn의 경우 Yarn v2, yarn berry로 버젼을 업그레이드하며 몇 가지 추가적인 기능들을 도입하였다. Yarn에서 사용하는 node_modules 파일 시스템 대신, Plug'n'Play 방식과 Zip Filesystem을 도입하였다. 

Yarn Berry의 의존성 정보

Plug'n'Play는 node_modules를 생성하는 대신, .yarn/cache 폴더에 의존성 정보를 저장하고, .pnp.cjs에 의존성 관련 lookup table을 만든다는 방식을 취하였다. 따라서, 파일 시스템을 통해 경로를 일일히 찾아볼 필요 없이 룩업 테이블 한 방이면 해당 패키지로 바로 접근할 수 있다는 의미이다. 이는 yarn이 Node.js의 require문을 덮어 씀으로써 가능해졌다.

Yarn의 Zip FileSystem

또 다른 기능으로 ZipFS(Zip FileSystem)을 활용하며, 의존성은 ZIP 형태로 저장된다. 룩업 테이블로 찾아본 파일 경로에는 ZIP 파일이 존재하며, 압축된 패키지를 가지고 있으므로 용량이 대폭 감소하며, 의존성 관련 구성 파일 수가 감소하므로 의존성 변경이 매우 용이해진다.

 

따라서, 줄어든 용량을 토대로 의존성을 git으로 버젼 관리를 할 수 있게 되었으며, 이처럼 의존성을 따로 설치하지 않고 모든 설치파일을 버전 관리에 포함시키는 것을 Zero-install 전략이라고 한다.

 

다만, 이 yarn berry도 호환성 문제로 PnP API를 통해 의존성 관리를 할 경우 node 명령어 대신 yarn node 라는 명령어를 사용해야 하며, 패키지가 yarn berry를 지원하지 않는 경우 동작하지 않는 문제가 존재한다. 이외에도 ESLint와의 호환성, VSCode / IntelliJ와의 호환성 등 여러 과제가 존재한다. yarn berry가 require문을 덮어쓰다보니 require 문을 쓰는 다른 툴에서 문제를 일으키는 것으로 보인다


4. 그래서 뭐써요?

현재 npm이든, yarn이든, pnpm이든, yarn berry이든, 사실 기능 상에는 큰 문제가 없다. 다만, 디스크 효율성을 생각하면 더 이후에 나온 pnpm, yarn berry를 활용하는 것이 좋아보이고, 아직 패키지 매니징에 익숙하지 않으면 npm과 yarn을 써도 무방하다. 작은 프로젝트에는 눈에 가시적인 성능 차이를 보이지 않지만, 여러 라이브러리들을 활용하면서 패키지 의존성이 복잡해진다면 최신 패키징 방법을 도입해 보는 것을 고려해보는 것을 추천한다.

 

5. Reference (감사합니다.)

https://soshace.com/setting-up-automated-semantic-versioning-for-your-nodejs-project/

 

https://javascript.plainenglish.io/an-abbreviated-history-of-javascript-package-managers-f9797be7cf0e

 

https://yceffort.kr/2022/01/npm-colors-fakerjs

 

https://toss.tech/article/node-modules-and-yarn-berry

 

https://npm.github.io/how-npm-works-docs/npm3/how-npm3-works.html

 

https://simpleisit.tistory.com/72

 

https://github.com/npm/cli#is-npm-an-acronym-for-node-package-manager

 

https://pnpm.io/motivation

 

https://github.com/yarnpkg/yarn/issues/1761

 

https://github.com/yarnpkg/berry/issues?page=16&q=is%3Aissue+is%3Aopen

반응형
댓글
Total
Today
Yesterday
공지사항
최근에 올라온 글
최근에 달린 댓글
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함