npm -> Yarn Berry

블로그를 리팩토링하면서 패키지 매니저를 npm에서 Yarn Berry로 변경하였습니다. 이번 시간에는 Yarn Berry에 대해 알아보면서 Yarn Berry를 사용하면서 느낀 장단점을 공유하고자 합니다.
  • 해당 글은 Yarn Berry의 사용방법을 정리한 글이 아닌 Yarn Berry를 사용하면서 느낀 장단점을 공유하는 글입니다.
  • 만약 Yarn Berry를 사용하실 계획이라면 한 번쯤 읽어 보시는 것을 추천드립니다.

Yarn Berry란?

Yarn Berry란 2020년 1월에 출시된 Yarn Classic의 업그레이드 버전입니다. yarn 팀에서는 본질먹인 새로운 코드 베이스와 새로운 원칙을 가진 완전리 새로운 패키지 매니저라는 것을 분명하게 하기 위해 Yarn Berry라고 이름을 붙였습니다.
사실 Yarn Berryyarn classic, npm, pnpm 등 모든 패키지 매니저의 역할은 동일합니다. 매키지 매니저의 역할은 다음과 같습니다.
  • 패키지의 Dependency 관리(일괄(Batch) 설치 or 업데이트)
  • 패키지 신뢰성 & 손상되지 않음을 보장 (보안 감사(audit) 수행)
  • 기능에 따른 여러 패키지의 그룹화
  • 패키지 압축 해제
  • SW repository로부터 패키지 검색, 다운로드, 설치, 업데이트 수행
  • 메타데이터 처리 및 쓰기
  • 스크립트 실행
  • 패키지 배포(publish)

npm의 문제점

그렇다면 패키지 매니저의 기능은 모두 동일한데 왜 Yarn Berry를 사용했을까요?
먼저 기존에 사용중이었던 npm의 문제점을 알아보겠습니다.

비효율적인 의존성 탐색

미처버린 node_modules...

미처버린 node_modules...

다음 이미지처럼 npm이 관리하는 node_modules는 비효율적이고 무겁습니다.
이는, npm의 비효율적인 의존성 탐색에 있습니다.
npm은 의존성을 탐색할 때, node_modules를 탐색하고, 없으면 상위 디렉토리로 올라가서 다시 node_modules를 탐색합니다.
이러한 과정을 반복하다 보면, node_modules를 찾지 못하고 프로젝트의 루트 디렉토리까지 올라가게 됩니다.
이는 심각한 문제로 이어지는데 어떤 의존성을 찾을 수 있는지는 해당 패키지 상위 디렉토리 환경에 따라 달라지기 때문입니다.
이런 탐색은 느린 I/O 호출의 반복이며, 경우에 따라 I/O 호출의 실패로 이어집니다.

유령 의존성

npm은 중복해서 설치되는 node_modules를 아끼기 위해 호이스팅 기법을 사용합니다.
중복 모듈 A(1.0)과 B(1.0)을 root node_modules에 호출

중복 모듈 A(1.0)과 B(1.0)을 root node_modules에 호출

다음과 같이 의존성 트리를 수정할 경우 우리 프로젝트에서 install하지 않은 B(1.0)이 root node_modules에 설치됩니다.
프로젝트에서 추가하지 않은 B(1.0)은 require 할 수 없어야 하지만 의존성 탐색을 통해 설치되지 않은 B(1.0)을 root node_modules에서 찾아서 사용할 수 있습니다.
이렇게 호이스팅에 따라 직접 의존(install)하지 않은 라이브러리를 require 할 수 있는 현상을 유령 의존성이라고 합니다.
만약 기존 라이브러리를 삭제하지 않는다면 문제가 되지 않습니다. 하지만 특정 의존성을 제거했을 경우 소리없이 다른 의존성이 제거되기 때문에 의존성 관리 시스템을 혼란스럽게 만듭니다.
저또한 lodash 라이브러리를 설치하지 않고 사용한 경험이 있습니다.
이는 `npm`의 호이스팅 기법으로 인해 발생한 문제였습니다.

PnP (plug n play)

Yarn Berry는 위에 언급한 문제를 해결하기 위해 PnP라는 새로운 패러다임을 도입했습니다.
PnPnode_modules를 사용하지 않고, 패키지의 종속성을 프로젝트 단일 디렉토리 트리(.yarn/cache)에 직접 설치합니다.
이때 각 라이브러리에 대한 의존성 정보는 .pnp.cjs 파일에 저장되며 이를 통해 디스크 I/O 없이 어떤 패키지가 어떤 라이브러리에 의존하는지, 각 라이브러리는 어디에 위치하는지 바로 알 수 있습니다.
만약 react@18.2.0을 설치한다면 .pnp.cjs 파일에 다음과 같이 저장됩니다.
1  // react 패키지 중에서
2  ["react", [
3    // npm:18.2.0 버전을 설치하고
4    ["npm:18.2.0", {
5      // 해당 위치에 존재하며
6      "packageLocation": "./.yarn/cache/react-npm-18.2.0-1eae08fee2-88e38092da.zip/node_modules/react/",
7      // 해당 패키지의 의존성은 다음과 같다.
8      "packageDependencies": [
9        ["react", "npm:18.2.0"],
10        ["loose-envify", "npm:1.4.0"]
11      ],
12      "linkType": "HARD"
13    }]
14  ]],

ZipFS (Zip File System)

PnP 시스템에서 각 의존성은 Zip 아카이브로 관리됩니다.
post_image
예를 들어 React@18.2.0을 설치하면 다음과 같은 압축파일로 관리됩니다.
1.yarn/cache/react-npm-18.2.0-1eae08fee2-88e38092da.zip
이후 .pnp.cjs 파일이 지정하는 바에 따라 동적으로 Zip 아카이브의 내용이 참조됩니다.
이러한 Zip 아카이브를 통해 PnP는 다음과 같은 장점을 가집니다.
  1. 디스크 I/O 없이 의존성을 탐색할 수 있습니다.
  2. node_modules를 사용하지 않기 때문에 설치가 빠르게 완료됩니다.
  3. 각 패키지는 버전마다 하나의 Zip 아카이브로 관리되기 때문에 중복된 패키지를 설치하지 않습니다.
  4. 압축되어 있기 때문에 스토리지 공간을 크게 절약할 수 있습니다.

성능차이는 얼마나 날까?

해당 단락에선 Yarn Berry를 도입하면서 얼마나 성능이 향상되었는지 npm과 비교해보겠습니다.

node_modules vs .yarn

이전 npm을 사용할 때는 node_modules를 사용하기 때문에 자연스럽게 프로젝트의 용량이 커졌습니다.
현재 이전 프로젝트에 설치된 node_modules의 용량은 다음과 같습니다.
1# client
2$ du -sh node_modules
3614M    node_modules
4
5# server
6$ du -sh node_modules
7465M    node_modules
대략 1GB에 가까운 용량을 차지하고 있습니다. 다음으론 Yarn Berry를 사용하면서 생성된 .yarn 디렉토리의 용량을 확인해 보겠습니다.
1# client
2$ du -sh node_modules
3324M    .yarn
4
5# server
6$ du -sh node_modules
768M    .yarn
이를 표로 정리하면 다음과 같습니다.
분류npmYarn Berry증감(%)
클라이언트614MB324MB약 48%
서버465MB68MB약 86%

Docker Image

이번엔 기존 npm을 사용할 때와 Yarn Berry를 사용할 때의 docker image 빌드 시간/용량을 비교해 보겠습니다.

build

먼저 기존 npm을 사용할 때의 docker image 빌드 시간은 다음과 같습니다.
1# client
2$ docker build --no-cache -t prev_front .
3[+] Building 191.8s (12/12) FINISHED
4
5# server
6$ docker build --no-cache -t prev_back .
7[+] Building 88.8s (12/12) FINISHED
이번엔 Yarn Berry를 사용할 때의 docker image 빌드 시간을 확인해 보겠습니다.
1# client
2$ docker build --no-cache -t front .
3[+] Building 132.1s (22/22) FINISHED
4
5# server
6$ docker build --no-cache -t back .
7[+] Building 32.2s (26/26) FINISHED

size

먼저 기존 npm을 사용할 때의 docker image 용량은 다음과 같습니다.
1# client
2$ docker images -f "reference=prev_front"
3REPOSITORY       TAG       IMAGE ID       CREATED              SIZE
4prev_front       latest    40b0156a361d   About a minute ago   742MB
5
6# server
7$ docker images -f "reference=prev_back"
8REPOSITORY       TAG       IMAGE ID       CREATED              SIZE
9prev_back        latest    48dae3e2081c   About a minute ago   453MB
어마무시한 이미지 용량을 보여주고 있습니다. (참고로 초기에는 1GB 이상의 용량을 차지했습니다ㅎㅎ)
이번엔 Yarn Berry를 사용할 때의 docker image 용량을 확인해 보겠습니다.
1# client
2$ docker images -f "reference=front"
3REPOSITORY   TAG       IMAGE ID       CREATED              SIZE
4front        latest    43ef86c4c320   About a minute ago   591MB
5
6# server
7$ docker images -f "reference=back"
8REPOSITORY   TAG       IMAGE ID       CREATED              SIZE
9back         latest    cff45f3475a6   About a minute ago   188MB
이전 npm을 사용할 때의 docker image 용량에 비해 상당히 줄어든 것을 확인할 수 있습니다.

정리

이를 표로 정리하면 다음과 같습니다.
분류npmYarn Berry증감(%)
클라이언트742MB (191.8s)591MB (132.1s)약 20%
서버453MB (88.8s)188MB (32.2s)약 58%

Github Action

이번엔 github action을 통해 CI/CD를 진행할 때의 빌드 시간을 비교해 보겠습니다.
먼저 기존 npm을 사용할 때의 빌드 시간은 다음과 같습니다.
가장 최근 실행된 action을 기준으로 작성했으며, 이전 action과 변경된 action은 Dockerfile을 제외하고 동일하기 때문에 jobsbuild step만 비교하겠습니다.
deploy.yml 파일이 궁금하신 분들은 다음 링크를 참고해주세요.

Client

Client간 비교는 다음과 같습니다.
stepsnpmYarn Berry증감(%)
Install the project dependencies43s55s약 127%
Docker build & push to push6m 40s3m 26s약 48%
클라이언트에서는 의존성을 설치하는 과정에서 Yarn Berry가 약 27% 더 느렸습니다.
왜 그런것일까요? 이는 하단 단점에서 설명하겠습니다.

Server

Server간 비교는 다음과 같습니다.
stepsnpmYarn Berry증감(%)
Install the project dependencies19s6s약 68%
Docker build & push to push1m 47s1m 11s약 33%

정리

클라이언트와 서버의 CI/CD 시간을 비교하면 다음과 같습니다.
분류npmYarn Berry증감(%)
클라이언트8m 19s5m 44s약 31%
서버3m 4s2m 4s약 32%
클라이언트, 서버 포함 30% 이상의 성능 향상을 보여주고 있습니다. Docker image build시 Install the project dependencies을 통해 의존성 설치를 진행했기 때문에 Docker image build시간이 줄어든 것을 확인할 수 있습니다.

그런데... (사용하면서 느낀 단점)

위에서 정리한 내용과 같이 꽤나 유의미적인 성능 차이를 보여주고 있습니다.
그렇다면 Yarn Berry는 무조건 좋을까요? 글쎄요... 다음으론 제가 사용하면서 느낀 단점을 정리해보겠습니다.

초기 장벽 (typescript sdk, eslint, prettier)

Yarn Berry를 사용한다면 초기 설정을 진행해야 합니다. 이때 초기에 많이 해매는 부분이 있었는데 바로 typescript sdk, eslint, prettier 설정이었습니다.
저는 Yarn Berry를 우아한테크캠프를 진행하면서 알게 되었습니다. 일주일이라는 짧은 시간안에 해당 기술을 녹여내야했기 때문에 초기설정에 많은 시간을 할애할 수 없었습니다.
하지만 Yarn Berry는 초기 설정해야되는 부분이 많았고 vscode를 사용한다면 typescript sdk 설정을 진행해야하는 점, sdk 설정이전 eslintprettier를 install하고 sdk를 설정해야했으며 이를 위해 각 워크스페이스의 typescript, eslint, prettier 버전을 맞춰줘야 했습니다.
부끄러운 기억이지만 Yarn Berry를 처음 사용한다면 겪을 수 있는 문제라고 생갹하여 단점으로 정리했습니다. 만약 Yarn Berry를 사용하신다면 사용 이전에 충분히 설치 방법, 동작 등을 숙지하시고 진행하는 것을 추천드립니다.

.git이 무거워요...

Yarn Berry의 단점을 설명하는데 뜬금없이 .git에 대한 얘기가 나왔습니다. 해당 부분은 아래 이미지를 확인하시면 이해가 되실 것 같습니다.
왼쪽: 이전 프로젝트 / 오른쪽: 현재 프로젝트

왼쪽: 이전 프로젝트 / 오른쪽: 현재 프로젝트

다음과 같이 이전 프로젝트의 .git 디렉토리보다 현재 프로젝트의 .git 디렉토리가 무거워진 것을 확인할 수 있습니다. (심지어 왼쪽은 client, server의 커밋 내역까지 포함되어 있습니다.)
이는 Zero Install을 사용하기 위해 의존성 정보를 담고있는 .yarn/cache 디렉토리에 대한 변경사항이 커밋내역으로 존재하기 때문에 발생하는 문제입니다.
현재 프로젝트에서 Yarn Berry를 사용하는 궁극적인 이유는 성능적인 이점을 가져가기 위함이여서 이러한 단점을 크게 신경쓰지 않았지만 의존성 버전 업데이트 등 Dependency Tree에 변경사항이 생길때마다 change 파일의 개수가 급격하게 증가했습니다.
이로 인해 vscode 메모리 사용량이 크게 증가하며 추가적인 file change가 발생했을 경우 변경 사항을 git log 상에 반영하는데 시간이 걸리거나(물론 짧은 시간이긴 합니다), eslint, prettier가 적용되는 과정에서 추가적인 시간이 소요되는 등의 문제가 발생하기도 했습니다.
.yarn/cache 삭제시 1583개의 file change가 발생합니다

.yarn/cache 삭제시 1583개의 file change가 발생합니다

이런 부분들이 하나하나 쌓이다보니 개발 피로도가 꽤나 쌓이는 것을 느꼈습니다..

Zero Install

앞서 설명했듯이 Yarn Berry.yarn/cache 디렉토리에 의존성 정보를 담고 있습니다. github repository를 clone할 경우 이미 .yarn/cache 디렉토리에 의존성 정보가 존재하기 때문에 yarn install을 진행하지 않아도 프로젝트를 실행할 수 있다고 설명했습니다.
하지만 이는 틀렸습니다. 추가적인 install을 진행해야 프로젝트를 실행할 수 있습니다. 안타깝게도 모든 Dependency가 zip 만으로 관리될 수 있는 것이 아닙니다.
Pnp를 지원하지 않거나 현재 환경에 대한 바이너리 정보를 가지고 동작해야하는 Dependency들(파일 쓰기, 스크립트 실행 등)은 install시 .yarn/cache 디렉토리에 zip파일 형태로 저장되는 것이 아닌 .yarn/unplugged 디렉토리에 별도의 바이너리 파일로 관리됩니다.
물론 해당 부분을 우회해서 해결하는 방법이 있습니다. .yarnrc.yml 파일에 enableScripts: false로 설정해주는 방법입니다. (물론 PnP 자체를 지원하지 않는 라이브러리들은 .yarn/unplugged에 생성됩니다.)
하지만 이 또한 올바른 방법은 아니며 결국 install을 통해 각 환경에 맞는 바이너리 파일을 생성해야합니다.
앞서 github action에서의 성능 비교 부분에서 client 측 install time이 이전 프로젝트보다 현재 프로젝트가 약 27% 더 느렸습니다. 이는 github 상에 존재하지 않는 .yarn/unplugged 패키지 들을 install 하기 위함이었습니다.
겨우 3개의 파일을 설치하는데 46초라는 시간이 걸렸네요..

겨우 3개의 파일을 설치하는데 46초라는 시간이 걸렸네요..

상황에 따라서 .yarn/unplugged가 필요 없어지는 경우도 있고 현재 저의 블로그 또한 .yarn/unplugged가 없더라도 정상적으로 동작합니다. 하지만 sharp 라이브러리를 통해 이미지를 최적화하기 위해서 .yarn/unplugged를 사용하였습니다.
1$ du -hs unplugged
2241.7M  unplugged
현재 프로젝트의 .yarn/unplugged 디렉토리 용량입니다. 이는 현재 프로젝트의 .yarn/cache 디렉토리 용량의 약 50%에 해당합니다. 프로젝트가 리눅스 환경에서 동작하보니 .yarn/unplugged 디렉토리의 용량이 굉장히 크게 증가한 모습입니다. (local에서 생성된 디렉토리보다 약 50% 정도 증가했습니다.)

Docker Image Size

이전 npm을 사용할 때는 클라이언트, 서버를 빌드할 경우 해당 빌드파일을 그대로 사용 가능했습니다.
클라이언트에 경우 Nextjs의 standalone option을 통해 완전히 독립된 빌드 디렉토리를 실행하기만 하면 되었고, 서버의 경우 tsc를 통해 빌드된 js 파일을 실행하기만 하면 되었습니다.
하지만 Yarn Berry를 사용하면서 클라이언트, 서버를 빌드할 경우 .pnp.cjs에 저장된 의존성 트리를 통해 라이브러리를 참조하기 때문에 .yarn/cache 디렉토리 등 추가적인 파일들을 COPY 함으로써 이미지 용량이 증가하였습니다.
만약 지금 npm을 사용하여 docker image를 재생성한다면 image size를 절반 이하로 줄일 자신이 있기 때문에 Yarn Berry를 사용한 image size와 관련해선 크게 이점을 가졌다고 생각하지 않습니다. (서버 측은 제외입니다.)

마치며

Yarn Berry를 도입하면서 성능적인 이점을 굉장히 많이 봤다고 생각합니다. Zero Install을 통해 가장 중점적으로 생각했던 build time을 크게 줄일 수 있었었고 새로운 기술을 도입하면서 개발 능력 또한 향상시킬 수 있었습니다. (서버 측이 굉장히 큰 성능적 이점을 가질 수 있었습니다.)
Yarn BerryZero Install을 제외하고도 굉장히 많은 기능이 존재하며 효율적으로 프로젝트를 관리할 수 있습니다. Yarn에서 제공하는 플러그인, workspace 등의 수많은 기능들을 통해 앞으로 개발에 있어 밑바탕이 될 것이라고 생각합니다.
하지만 기술을 도입하기 전 내가 목표로 둔 것이 무엇인지, 그리고 기술을 도입함으로써 얻을 수 있는 이점이 무엇인지 충분히 고민해보고 도입하는 것이 중요하다는 생각이 들었습니다.
쓰다보니 글의 볼륨이 꽤나 커졌네요..ㅎㅎ Yarn Berry에 대한 사용후기는 여기까지 하겠습니다. 감사합니다.
오타 오류 지적은 언제나 환영입니다.

참고