node_modules로부터 우리를 구원해 줄 Yarn Berry

2021. 5. 7

토스 프론트엔드 챕터에서는 지난해부터 의존성을 관리하기 위해 Yarn Berry(v2)를 도입했습니다. 처음에는 일부 레포지토리부터 시작하여, 현재는 대부분의 레포지토리에 Yarn Berry가 적용되어 있는데요. 토스팀이 새로운 패키지 관리 시스템을 도입하게 된 배경과 사용하면서 좋았던 점을 테크 블로그를 통해 공유합니다.

Yarn Berry란?

Yarn Berry는 Node.js를 위한 새로운 패키지 관리 시스템으로, Yarn v1의 주요 개발자인 Maël Nison 씨가 만들었습니다. 2020년 1월 25일부터 정식 버전(v2)가 출시되어, 현재는 Babel과 같은 큰 오픈소스 레포지토리에서도 채택하고 있습니다. Yarn Berry는 GitHub yarnpkg/berry 레포지토리에서 소스코드가 관리되고 있습니다.

Yarn Berry는 기존의 “깨져 있는” NPM 패키지 관리 시스템을 혁신적으로 개선합니다.

NPM의 문제점

NPM은 Node.js 설치 시에 기본으로 제공되어 범용적으로 사용되고 있으나, 비효율적이거나 깨져 있는 부분이 많습니다.

비효율적인 의존성 검색

NPM은 파일 시스템을 이용하여 의존성을 관리합니다. 익숙한 node_modules 폴더를 이용하는 것이 특징인데요. 이렇게 관리했을 때 의존성 검색은 비효율적으로 동작합니다.

예를 들어, /Users/toss/dev/toss-frontend-libraries 폴더에서 require() 문을 이용하여 react 패키지를 불러오는 상황을 가정합시다.

라이브러리를 찾기 위해 순회하는 디렉토리의 목록을 확인하려고 할 때, Node.js에서 제공하는 require.resolve.paths() 함수를 사용할 수 있습니다. 이 함수는 NPM이 검색하는 디렉토리의 목록을 반환합니다.

$ node Welcome to Node.js v12.16.3. Type ".help" for more information. > require.resolve.paths('react') [ '/Users/toss/dev/toss-frontend-libraries/repl/node_modules', '/Users/toss/dev/toss-frontend-libraries/node_modules', '/Users/toss/node_modules', '/Users/node_modules', '/node_modules', '/Users/toss/.node_modules', '/Users/toss/.node_libraries', '/Users/toss/.nvm/versions/node/v12.16.3/lib/node', '/Users/toss/.node_modules', '/Users/toss/.node_libraries', '/Users/toss/.nvm/versions/node/v12.16.3/lib/node' ]
Code language: Shell Session (shell)

목록에서 확인할 수 있는 것처럼, NPM은 패키지를 찾기 위해서 계속 상위 디렉토리의 node_modules 폴더를 탐색합니다. 따라서 패키지를 바로 찾지 못할수록 readdir, stat과 같은 느린 I/O 호출이 반복됩니다. 경우에 따라서는 I/O 호출이 중간에 실패하기도 합니다.

TypeScript 4.0까지는 node_modules를 이용한 패키지 탐색이 너무 비효율적인 나머지, 패키지를 처음으로 import 하기 전까지는 node_modules 내부의 타입 정보를 찾아보지 않기도 했습니다. (TS 4.0 Changelog)

환경에 따라 달라지는 동작

NPM은 패키지를 찾지 못하면 상위 디렉토리의 node_modules 폴더를 계속 검색합니다. 이 특성 때문에 어떤 의존성을 찾을 수 있는지는 해당 패키지의 상위 디렉토리 환경에 따라 달라집니다.

예를 들어, 상위 디렉토리가 어떤 node_modules를 포함하고 있는지에 따라 의존성을 불러올 수 있기도 하고, 없기도 합니다. 다른 버전의 의존성을 잘못 불러올 수 있는 여지도 존재합니다.

이렇게 환경에 따라 동작이 변하는 것은 나쁜 징조입니다. 해당 상황을 재현하기 까다로워지기 때문입니다.

비효율적인 설치

NPM에서 구성하는 node_modules 디렉토리 구조는 매우 큰 공간을 차지합니다. 일반적으로 간단한 CLI 프로젝트도 수백 메가바이트의 node_modules 폴더가 필요합니다. 용량만 많이 차지할 뿐 아니라, 큰 node_modules 디렉토리 구조를 만들기 위해서는 많은 I/O 작업이 필요합니다.

node_modules 폴더는 복잡하기 때문에 설치가 유효한지 검증하기 어렵습니다. 예를 들어, 수백 개의 패키지가 서로를 의존하는 복잡한 의존성 트리에서 node_modules 디렉토리 구조는 깊어집니다.

이렇게 깊은 트리 구조에서 의존성이 잘 설치되어 있는지 검증하려면 많은 수의 I/O 호출이 필요합니다. 일반적으로 디스크 I/O 호출은 메모리의 자료구조를 다루는 것보다 훨씬 느립니다. 이런 문제로 인해 Yarn v1이나 NPM은 기본적인 의존성 트리의 유효성까지만 검증하고, 각 패키지의 내용이 올바른지는 확인하지 않습니다.

유령 의존성 (Phantom Dependency)

NPM 및 Yarn v1에서는 중복해서 설치되는 node_modules를 아끼기 위해 끌어올리기(Hoisting) 기법을 사용합니다.

예를 들어, 의존성 트리가 왼쪽의 모습을 하고 있다고 가정합시다.

왼쪽 트리에서 [A (1.0)][B (1.0)] 패키지는 두 번 설치되므로 디스크 공간을 낭비합니다. NPM과 Yarn v1에서는 디스크 공간을 아끼기 위해 원래 트리의 모양을 오른쪽 트리처럼 바꿉니다.

오른쪽 트리로 의존성 트리가 바뀌면서 package-1 에서는 원래 require() 할 수 없었던 [B (1.0)] 라이브러리를 불러올 수 있게 되었습니다.

이렇게 끌어올리기에 따라 직접 의존하고 있지 않은 라이브러리를 require() 할 수 있는 현상을 유령 의존성(Phantom Dependency)이라고 부릅니다.

유령 의존성 현상이 발생할 때, package.json에 명시하지 않은 라이브러리를 조용히 사용할 수 있게 됩니다. 다른 의존성을 package.json 에서 제거했을 때 소리없이 같이 사라지기도 합니다. 이런 특성은 의존성 관리 시스템을 혼란스럽게 만듭니다.

Plug’n’Play (PnP)

Yarn Berry는 위에서 언급한 문제를 새로운 Plug’n’Play 전략을 이용하여 해결합니다.

Plug’n’Play의 배경

Yarn v1은 package.json 파일을 기반으로 의존성 트리를 생성하고, 디스크에 node_modules 디렉토리 구조를 만듭니다. 이미 패키지의 의존성 구조를 완전히 알고 있는 것입니다.

node_modules 파일 시스템을 이용한 의존성 관리는 깨지기 쉽습니다. 모든 패키지 매니저가 실수하기 쉬운 Node 내장 의존성 관리 시스템을 사용해야 할까요? 패키지 매니저들이 node_modules 디렉토리 구조를 만드는 것에 그치지 않고, 보다 근본적으로 안전하게 의존성을 관리하면 어떨까요?

Plug’n’Play는 이런 생각에서 출발했습니다.

Plug’n’Play 켜기

NPM에서 최신 버전의 Yarn을 내려받고, 버전을 Berry로 설정하면 Yarn Berry를 사용할 수 있습니다.

$ npm install -g yarn $ cd ../path/to/some-package $ yarn set version berry
Code language: Shell Session (shell)

Yarn Berry는 기존 Node.js 의존성 관리 시스템과 많이 다르기 때문에 하위호환을 위해 패키지 단위로만 도입할 수 있습니다.

Plug’n’Play의 동작 방법

Plug’n’Play 설치 모드에서 yarn install 로 의존성을 설치했을 때, 기존과 다른 모습을 볼 수 있습니다.

Yarn Berry는 node_modules를 생성하지 않습니다. 대신 .yarn/cache 폴더에 의존성의 정보가 저장되고, .pnp.cjs 파일에 의존성을 찾을 수 있는 정보가 기록됩니다. .pnp.cjs를 이용하면 디스크 I/O 없이 어떤 패키지가 어떤 라이브러리에 의존하는지, 각 라이브러리는 어디에 위치하는지를 바로 알 수 있습니다.

예를 들어, react 패키지는 .pnp.cjs 파일에서 다음과 같이 나타납니다.

/* react 패키지 중에서 */ ["react", [ /* npm:17.0.1 버전은 */ ["npm:17.0.1", { /* 이 위치에 있고 */ "packageLocation": "./.yarn/cache/react-npm-17.0.1-98658812fc-a76d86ec97.zip/node_modules/react/", /* 이 의존성들을 참조한다. */     "packageDependencies": [ ["loose-envify", "npm:1.4.0"], ["object-assign", "npm:4.1.1"] ], }] ]],
Code language: JavaScript (javascript)

react 17.0.1 버전 패키지의 위치와 의존성의 목록을 완전하게 기술하고 있는 것을 확인할 수 있습니다. 이로부터 특정 패키지와 의존성에 대한 정보가 필요할 때 바로 알 수 있습니다.

Yarn은 Node.js가 제공하는 require() 문의 동작을 덮어씀으로써 효율적으로 패키지를 찾을 수 있도록 합니다. 이 때문에 PnP API를 이용하여 의존성 관리를 하고 있을 때에는 node 명령어 대신 yarn node 명령어를 사용해야 합니다.

$ yarn node
Code language: Shell Session (shell)

일반적으로 Node.js 앱을 실행할 때에는 package.json의 scripts 에 실행 스크립트를 등록하여 사용하게 됩니다. 이때 Yarn v1에서 사용하던 것처럼 Yarn으로 스크립트를 실행하기만 하면 자동으로 PnP로 의존성을 불러옵니다.

$ yarn dev
Code language: Shell Session (shell)

ZipFS (Zip Filesystem)

zip으로 묶인 라이브러리가 저장된 .yarn/cache 폴더

Yarn PnP 시스템에서 각 의존성은 Zip 아카이브로 관리됩니다. 예를 들어, Recoil 0.1.2 버전은 recoil-npm-0.1.2-9a0edbd2b9-c69105dd7d.zip과 같은 압축 파일로 관리됩니다.

이후 .pnp.cjs 파일이 지정하는 바에 따라 동적으로 Zip 아카이브의 내용이 참조됩니다.

Zip 아카이브로 의존성을 관리하면 다음과 같은 장점이 생깁니다.

  1. 더 이상 node_modules 디렉토리 구조를 생성할 필요가 없기 때문에 설치가 신속히 완료됩니다.
  2. 각 패키지는 버전마다 하나의 Zip 아카이브만을 가지기 때문에 중복해서 설치되지 않습니다. 각 Zip 아카이브가 압축되어 있음을 고려할 때, 스토리지 용량을 크게 아낄 수 있습니다.
    • 실제로 토스팀에서 의존성이 차지하는 크기를 대폭 감축할 수 있었습니다.
    • 한 서비스의 경우 NPM을 이용했을 때 node_modules 디렉토리가 약 400MB를 차지했지만, Yarn PnP를 사용했을 때 의존성 디렉토리의 크기는 120MB에 불과했습니다.
  3. 의존성을 구성하는 파일의 수가 많지 않으므로, 변경 사항을 감지하거나 전체 의존성을 삭제하는 작업이 빠릅니다.
    • 없는 의존성이나 더 이상 필요 없는 의존성을 쉽게 찾을 수 있습니다.
    • Zip 파일의 내용이 변경되었을 때에는 체크섬과 비교하여 쉽게 변경 여부를 감지할 수 있습니다.

Plug’n’Play 도입 결과

토스 프론트엔드 챕터가 Plug’n’Play를 도입한 결과, 다양한 장점을 느낄 수 있었습니다.

의존성을 검색할 때

의존성을 검색할 때, 더 이상 node_modules 폴더를 순회할 필요가 없습니다. .pnp.cjs 파일이 제공하는 자료구조를 이용하여 바로 의존성의 위치를 찾기 때문입니다. 이로써 require()에 걸리는 시간이 크게 단축되었습니다.

재현 가능성

패키지의 모든 의존성은 .pnp.cjs 파일을 이용하여 관리되기 때문에 더 이상 외부 환경에 영향받지 않습니다. 이로써 다양한 기기 및 CI 환경에서 require() 또는 import 문의 동작이 동일할 것임을 보장할 수 있게 되었습니다.

의존성을 설치할 때

더 이상 설치를 위해 깊은 node_modules 디렉토리를 생성하지 않아도 됩니다. 또 NPM이 설치하는 것처럼 같은 버전의 패키지가 여러 번 복사되어 설치 설치 시간을 극단적으로 단축할 수 있습니다. 이에 더해 Zero-install을 사용하면 대부분 라이브러리를 설치 없이 사용할 수 있습니다.

이를 이용하면 CI와 같이 반복적으로 의존성 설치 작업이 이루어지는 곳에서 시간을 크게 절약할 수 있습니다. 토스팀에서는 원래 CI에서 60초씩 걸리던 설치 작업을 Yarn PnP를 도입함으로써 수 초 이내로 단축했습니다.

엄격한 의존성 관리

Yarn PnP는 node_modules에서와 같이 의존성을 끌어올리지 않습니다. 이로써 각 패키지들은 자신이 package.json에 기술하는 의존성에만 접근할 수 있습니다. 기존에 환경에 따라 우연히 작동할 수 있었던 코드들이 보다 엄격히 관리되는 것입니다. 이로써 예기치 못한 버그를 쉽게 일으키던 유령 의존성 현상을 근본적으로 막을 수 있었습니다.

의존성 검증

node_modules를 사용하여 의존성을 관리했을 때에는 올바르게 의존성이 설치되지 못해서 의존성 폴더 전체를 지우고 다시 설치해야 하는 경우가 발생하고는 했습니다. node_modules 폴더를 검증하기 어려웠기 때문입니다. 전체 재설치를 수행할 때 node_modules 디렉토리 구조를 다시 만드느라 1분 이상의 시간이 허비되기도 했습니다.

Yarn PnP에서는 Zip 파일을 이용하여 패키지를 관리하기 때문에 빠진 의존성을 찾거나 의존성 파일이 변경되었음을 찾기 쉽습니다. 이로써 의존성이 잘못되었을 때 쉽게 바로잡을 수 있습니다. 이로써 올바르게 의존성이 설치되는 것을 100%에 가깝게 보장할 수 있습니다.

Zero-Install

위에서 Yarn Berry의 PnP를 도입함으로써 얻을 수 있는 다양한 장점들을 살펴보았습니다. 여기에서 한 발 더 나아간 생각을 해 볼 수 있습니다. 바로 의존성도 Git 등을 이용하여 버전 관리를 하면 어떨까? 라고 하는 생각인데요.

Yarn PnP은 의존성을 압축 파일로 관리하기 때문에 의존성의 용량이 작습니다. 또한 각 의존성은 하나의 Zip 파일로만 표현되기 때문에 의존성을 구성하는 파일의 숫자가 NPM만큼 많지 않습니다. 예를 들어, 일반적인 node_modules 는 1.2GB 크기이고 13만 5천개의 파일로 구성되어 있는 반면, Yarn PnP의 의존성은 139MB 크기의 2천개의 압축 파일로 구성됩니다.

이처럼 용량과 파일의 숫자가 적기 때문에 Yarn Berry를 사용하면 의존성을 Git으로 관리할 수 있습니다. 그리고 이렇게 의존성의 버전을 관리할 때 더욱 큰 장점들을 발견할 수 있습니다.

이렇게 Yarn Berry에서 의존성을 버전 관리에 포함하는 것을 Zero-Install이라고 합니다.

Yarn Berry Git 레포지토리에서 사용하는 Zero-install

의존성을 버전 관리에 포함하면 많은 장점들이 생깁니다.

  1. 새로 저장소를 복제하거나 브랜치를 바꾸었다고 해서 yarn install을 실행하지 않아도 됩니다. 일반적으로 다른 의존성을 사용하는 곳으로 브랜치를 변경했을 때, 잊지 않고 의존성을 설치해주어야 했습니다. 경우에 따라서는 잘못된 의존성 버전이 사용됨으로써 웹 서비스가 알 수 없는 이유로 오동작하기도 했습니다. Zero-Install을 사용했을 때 이런 문제는 완전히 해결됩니다. 더해서 네트워크가 끊어진 곳에서는 오프라인 캐시 기능을 해주기도 합니다.
  2. CI에서 의존성 설치하는 시간을 크게 절약할 수 있습니다. 토스에서는 일반적으로 캐시가 존재하지 않을 때 의존성을 설치하기 위해서 60초~90초의 시간이 필요했습니다. Zero-Install을 사용하면 Git Clone으로 저장소를 복제했을 때 의존성들이 바로 사용 가능한 상태가 되어, 의존성을 설치할 필요가 없습니다. 이로써 CI 시간을 크게 절약할 수 있었습니다.

토스 프론트엔드 챕터에서는 Zero-install 기능을 적극적으로 레포지토리에 도입함으로써 빌드와 배포 시간을 크게 단축할 수 있었습니다.

그 외 Yarn Berry에서 좋았던 점

이 외에 Yarn Berry는 다양한 개발자 친화적인 기능을 제공합니다.

  1. 플러그인 시스템: Yarn Berry는 핵심 기능도 플러그인을 이용하여 개발되어 있을 만큼 플러그인 친화적인 환경을 자랑합니다. 필요한 만큼 Yarn의 기능을 확장하여 손쉽게 CLI로 사용할 수 있습니다.

    토스 프론트엔드 챕터에서는 이현섭님께서 변경된 워크스페이스를 계산하는 플러그인을 며칠만에 만들어주시기도 하셨습니다. 이처럼 Yarn Berry의 기능이 부족하다면 손쉽게 플러그인을 만들 수 있습니다.
  2. 워크스페이스: Yarn Berry는 Yarn v1와 비교할 수 없을 정도로 높은 완성도의 워크스페이스 기능을 제공합니다. Yarn Berry의 Git 레포지토리에서 대표적으로 사용하는 모습을 확인할 수 있습니다. TypeScript를 사용함에도 한 패키지의 소스 코드의 변경사항이 즉시 다른 패키지에 반영되는 모습이 인상적입니다.

    토스 프론트엔드 챕터에서도 적극적으로 워크스페이스 기능을 사용하고 있습니다.
  3. 패치 명령어 기본 지원: 경우에 따라서 NPM에 배포된 라이브러리의 일부분만 수정해서 사용하고 싶은 니즈가 있습니다. Yarn Berry는 yarn patch 명령어를 제공함으로써 쉽게 라이브러리의 일부분을 수정해서 사용할 수 있도록 합니다. 이렇게 만든 패치 파일은 patch: 프로토콜을 이용해서 쉽게 의존성 설치에 사용할 수 있습니다.

토스팀은 이렇게 Yarn Berry를 도입함으로써 JavaScript 의존성을 효율적이고 안전하게 다룰 수 있었습니다. 오래 걸리던 CI 속도를 60초 이상 단축하기도 했습니다.

다음 Yarn Berry 아티클에서는 실제로 거대한 서비스 모노레포를 Yarn Berry로 이전한 경험을 소개드리면서 실사용에서 주의할 점에 대해 보다 자세히 소개드리겠습니다.

박서진

Frontend Developer

토스코어 Client Platform에서 개발자 경험과 웹 성능을 개선하고 있습니다.

토스팀이 만드는 수많은 혁신의 순간들

당신과 함께 만들고 싶습니다.
지금, 토스팀에 합류하세요.
채용 중인 공고 보기