패키지 매니저의 과거, 토스의 선택, 그리고 미래

박서진 · 토스 Head of Frontend
2024년 5월 23일

토스 기술 조직의 각 챕터는 라이트닝 토크에서 다양한 주제에 대한 인사이트와 아이디어를 자유롭게 공유합니다. 기록을 통해 생생한 라이트닝 토크 현장을 함께 느껴보세요!

오늘 다룰 내용은, 많아요. 사실 이렇게 많을 줄 몰랐어요! 주제는 패키지 매니저 인데요. 어떤 기술을 왜 선택했는지 그 배경을 이해하려면 먼저 개념을 명확히 알아야 해요. 그래서 JavaScript 패키지 매니저가 무엇인지 먼저 다룬 뒤에, 패키지 매니저가 동작하는 세 가지 단계를 설명할게요. 그리고 npm, pnpm, 그리고 Yarn, 이 세 가지 패키지 매니저가 어떻게 다른지 서로 다른 패키지 매니저의 특징을 살펴보고, 마지막으로 토스는 왜 Yarn을 선택했고, 앞으로는 어떤 방향성이 될 것인지 소개하겠습니다.

패키지 매니저란?

패키지 매니저의 정의부터 시작해보죠. 잘 아시는 분들도 많을 텐데요. JavaScript나 TypeScript를 사용하면 이렇게 requireimport 구문을 사용해서 외부 의존성을 참조하잖아요. 그걸 올바르게 참조할 수 있도록 보장해주는 프로그램이라고 생각하시면 돼요.

import React from '/Users/raon0211/path/to/react/index.js';
import { sum } from '/Users/raon0211/path/to/@toss/utils/index.js'

JavaScript 표준인 ECMAScript에 따르면, 원래는 정확한 절대 경로나 상대 경로를 통해서만 import 할 수 있어요. 실제로 Deno나 브라우저의 JavaScript 표준 문법을 보면 다 정확한 절대 경로를 사용하고 있죠. 하지만 실제로 이렇게 쓰지 않잖아요. 우리는 보통 이렇게 쓰고 싶어 하죠.

import React from 'react';
import { sum } from '@toss/utils';

sum(1, 2, 3);
const _ = require('lodash');

하지만 이렇게 하면 문제가 생깁니다. 예를 들어, react가 정확히 어떤 버전인지, @toss/utils이 어떤 버전인지 모호하다는 문제인데요. 예를 들면 React는 18.0.1일 수도, 18.3.1일 수도 있고, 최근에 나온 19 베타일 수도 있잖아요. @toss/utils도 버전이 많고, lodash 라이브러리도 마찬가지죠. 그러면 정확한 정보는 어디서 제공할까요? 우리는 소스 코드보다 상위 디렉토리인 package.json 파일에 명시해요.

{
  "dependencies": {
    "react": "^18.2.0" // react는 ≥ 18.2.0, <19 사이의 어떤 버전이든지 쓸 수 있다고 명시
  }
}

이렇게 명시된 의존성 정보를 바탕으로, 모든 소스 코드 파일이 특정 버전의 라이브러리를 사용할 수 있도록 보장합니다. 일반적으로 package.json 파일에 디펜던시를 명시하고, npm install 혹은 yarn install을 하면, 해당 의존성의 명시된 버전을 설치하게 됩니다. 즉, 패키지 매니저가 앞서 이야기 한 모호한 버저닝 문제를 해결해주는 거예요.

패키지 매니저가 동작하는 세 단계

패키지 매니저의 정의를 이야기 해봤어요. 이렇게 보면 패키지 매니저가 생각보다 간단하게 느껴지는데요. 버저닝 문제를 해결하기 위해 실제 동작하는 방식을 살펴보면 꽤 복잡해요.

다음은 패키지 매니저 중 하나인 Yarn을 터미널에 실행했을 때 볼 수 있는 화면인데요.

Yarn을 포함한 패키지 매니저는 이렇게 ‘Resolution’, ‘Fetch’, ‘Link’ 세 단계로 동작해요. 각 단계를 알아볼게요.

Resolution 단계

첫 번째는 Resolution 단계예요. 영어 뜻을 생각해보면 ‘문제를 해결하다’라고 이해할 수 있죠. 어떤 문제를 해결하는 지가 중요할 텐데요. 첫 번째 문제는 라이브러리를 정확한 버전으로 고정하는 문제예요. 패키지 매니저는 Resolution 단계에서 package.json 파일에 명시된 버전 범위에 따라 정확한 버전을 결정합니다. 예를 들어, “react: ^18.2.0” 이라고 명시되어 있으면, ^이 나타내는 규칙에 따라 ≥ 18.2.0, <19 사이의 어떤 버전이든 사용할 수 있어요. 패키지 매니저는 저 범위를 만족하는 선에서 가능한 최신 버전을 사용하려고 해요. 즉, 최신 버전인 18.3.1을 선택할 수 있습니다.

다음 문제는 설치한 라이브러리가 사용하는 다른 라이브러리, 즉 의존성의 의존성 문제인데요. JavaScript에서는 패키지끼리 의존성을 갖는 상황이 흔하잖아요. 예를 들어서, @toss/use-overlayreact 를 사용해요. 그런데 react도 의존성을 가지고 있죠. 그래서 의존성이 또 어떤 의존성을 가지는지 확인하는 작업이 필요해요.

마지막으로 그 의존성의 버전도 고정해야 한다는 건데요. JavaScript에서는 위와 같이 의존성의 버전을 범위로 명시하고, 패키지 간에도 의존성을 가지기 때문에, 똑같은 package.json에 대해서도 사용하는 의존성 버전이 완전히 달라질 수 있어요.

예를 들어서, 어떤 기기에서는 Next.js 13.1 버전과 React 18.1.0 버전을 사용하고, 다른 기기에서는 Next.js 13.2, React 18.2.0 을 사용할 수 있는 거죠. 저희 입장에서 제일 좋은 건 당연히 버전마다 동작이 똑같은 건데요. 슬프게도 당연히 동작이 다를 수 밖에 없고, 버그가 생길 수도 있어요. ‘제 PC에서는 잘 돼요’라고 하는 상황이 이럴 때 생기죠. 이런 의존성 고정 문제를 전부 해결하는 게 Resolution 단계입니다.

다시 정리하자면, Resolution 단계는 모든 기기에서 고정된 버전을 사용할 수 있도록 합니다. 의존성 버전을 전부 고정시키고, 의존성의 의존성을 다 찾아서 그 버전도 고정시키며, 결과물을 yarn.lock이나 package-lock.json에 저장해요.

Fetch 단계

두 번째는 Fetch 단계인데요. 이건 조금 쉬워요. Resolution의 결과로 결정된 버전을 실제로 다운로드하는 과정이에요 yarn.lock에 명시된 패키지가 있잖아요. 패키지 매니저는 이 과정에서 네트워크를 통해 필요한 파일들을 가져오는데, 그 과정이 Fetch 단계입니다. 일반적으로, 99%는 npm 레지스트리에서 다 받아옵니다.

Link 단계

세 번째는 가장 까다로운 Link 단계인데요. npm, pnpm, PnP(Plug'n'Play) 사례를 각각 살펴보려고 해요.

[1] npm Linker

첫 번째로 가장 익숙한 node_modules 기반의 Linker를 살펴볼게요. package.json에서 명시하는 모든 의존성을 그냥 node_modules 디렉토리 밑에다가 하나하나씩 쓰는 게 npm Linker의 역할인데요.

예를 들어 소스 코드에서 React와 TDS 모바일 라이브러리를 사용한다면, my-servicenode_modules하위에 React랑 TDS 모바일 패키지를 추가해요. TDS 모바일 패키지에도 node_modules가 있다면 @radix-ui/dialog를 또 그 밑에 깔아주는 게 npm Linker가 하는 일이죠.

my-service/
└─ node_modules/
|  ├─ react/
|  |  
|  └─ @tossteam/tds-mobile/
|     └─ node_modules/
|         └─ @radix-ui/react-dialog
|
└─ src
    └─ index.ts

사용해 보신 분들은 잘 아실텐데, 이 방식에는 단점이 꽤 많아요. 일단 패키지를 찾으려고 하면 node_modules를 계속 타고 올라가면서 파일을 여러 번 읽어야 해요. 그래서 importrequire 하는 속도가 느려지죠. 또, 디렉토리 크기가 너무 커져요. 실제로 파일 시스템에 디렉토리와 파일을 하나하나 만들고 쓰기 때문이에요.

예를 들어 만약 100개 프로젝트에서 React 18.2.0 버전을 쓴다고 하면 정말로 100번씩 React 18.2.0이 추가되는 거예요. 그래서 ‘호이스팅(Hoisting)’이라는 특이한 방법을 사용하기도 하는데, 최적화가 완전히 되는 것도 아니고 불안정하기도 해서 좋은 방법은 아니에요.

[2] pnpm Linker

이런 단점 때문에 pnpm이 만들어졌어요. pnpm문서에 보면 ‘fast, disk space efficient’한 패키지 매니저라고 써있어요. 즉, 퍼포먼스가 향상된(performant) npm 이라고 이해하시면 돼요.

npm에서 문제였던 node_modules를 하나씩 쓰는 것 때문에 느리고 용량도 많이 차지하는 걸 개선했다는 건데요. 중요한 건 ‘어떻게’ 개선했냐는 거겠죠.

pnpm Linker는 기존의 node_modules 디렉토리를 그대로 사용해요. 대신 보다 빠르고 용량을 최적화하는 방식을 사용해요. Hard link 방식 덕분인데요. 보통 OS나 시스템 프로그래밍에서 파일 시스템을 관리할 때 쓰는 개념으로 알고 계실 거예요. Hard link는 쉽게 말해 alias를 거는 거예요. npm처럼 단순 복붙하는 게 아니라 alias가 생기면 거기로 바로 접근하는 거에요. 그래서 의존성이 디스크에 하나만 설치가 돼요. node_modules를 쓸 때도 파일을 하나하나 쓸 필요가 없어지고, 속도도 훨씬 빠르죠. 이게 pnpm이 접근하는 방식입니다.

node_modules 디렉토리 크기도 무척 작아요. 그치만 조금 뒤 소개해드릴 PnP 방식 보다는 느릴 수 밖에 없어요. 왜냐면 node_modules 디렉토리를 계속 돌면서 alias를 하나씩 걸거든요. 그래서 약간 느리지만, npm처럼 파일 하나씩 쓰는 건 아니기 때문에 훨씬 빠르긴 해요. 게다가 호환성도 좋죠. 그래서 우리가 npm 쓰다가 pnpm으로 바꾸면 갑자기 ‘오, 빨라지네. 그리고 잘 되네, 뭐야 좋네!’ 이렇게 생각하게 되는 거죠.

다만 node_modules 디렉토리는 그대로 유지하기 때문에, require, import 시 파일 읽기가 많이 발생해서 중간중간 멈추기도 해요.

[3] PnP Linker

node_modules 디렉토리에서 벗어나고 싶다는 생각으로 래디컬하게 접근한 게 PnP인데요. 결국 node_modules 없이 의존성을 처리하는 방법을 찾아냈어요.

PnP는 ‘패키지를 import 할 때 중요한 것은 단 두 가지’라는 관점에서 접근해요. 먼저, ‘어떤 파일’에서 import 하는가, 그리고 ‘무엇’을 import 하는가예요. 즉, 앞의 npm과 pnpm처럼 node_modules를 순회하는 게 중요하지 않다고 생각한 거죠. 그래서, 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 옵션--loader 옵션을 사용해서 Map을 로딩시키는데요. importrequire의 동작을 바꾸는 Node.js의 API를 사용해서 동작을 바꿔서 참고해 사용하도록 한 거죠.

이렇게 하면 일단 설치 속도가 빨라요. 왜냐면 yarn.lock 기반으로 .pnp.cjs 파일만 만들어서 쓰면 끝나거든요. 파일 하나만 쓰면 되니 설치 속도가 빨라지는 거죠. 그리고 importrequire 하는 속도도 빨라요. 메모리에 파일이 로드되고 나면, 그냥 Map 연산만 하기 때문이에요. node_modules 디렉토리를 순회할 필요가 없죠. 이런 측면에서 PnP는 굉장히 속도에 집중한 접근이라고 할 수 있어요. 물론 단점도 있어요. 일단 Node.js 프로세스가 뜨는 속도가 느리고, node_modules 디렉토리와 호환성이 낮아요.

Link 파트를 요약해 보면, Link는 Resolution/Fetch한 것을 기반으로 실제로 소스 코드에서 필요한 라이브러리를 사용할 수 있게 환경을 제공해 주는 단계에요. 우리가 실제로 importrequire 구문을 사용하는 환경 말이죠. npm은 node_modules 디렉토리에 파일을 하나하나 쓰는 방식으로, pnpm은 Hard link를 사용해서 alias를 거는 방식으로 속도를 좀 더 빠르게 한 거고요. Yarn은 여전히 부족하다며 더 래디컬하게 node_modules 없는 방식을 도입했습니다.

PnP vs. Zero-install

다음으로 PnP랑 Zero-install 관련해서 짚고 넘어가고 싶은 내용이 있는데요. 이 두 가지를 헷갈리시는 분들이 많아서 비교해 보는 시간을 마련했어요. 한 마디로 정리하면, PnP와 Zero-install은 다릅니다.

먼저 PnP는 node_modules 없이 JavaScript Map 객체를 활용해 의존성을 엄격하고 빠르게 관리하는 접근 방식입니다. 그리고 Zero-install은 PnP의 JavaScript Map 객체와 Fetch된 의존성들까지 모두 Git에 넣어 버전을 관리하자는 방식입니다. 쉽게 말해, npm을 사용하면서 node_modules 디렉토리를 버전 관리하는 것이 Zero-install이죠.

npm을 사용하면서 Zero-install을 할 수도 있고, PnP를 사용하면서 Zero-install을 할 수도 있습니다. 하지만 npm 방식으로 설치하면 중복된 의존성들이 너무 많아져서 용량이 커져요. 반면, PnP는 효율적으로 하나의 의존성만 설치되므로 버전 관리가 용이해요. 그래서 Zero-install을 도입하게 된 것입니다. 특히 엔터프라이즈 레벨에서는 편리한 점이 많습니다.

과거 토스 프론트엔드 팀이 작을 때, 브랜치를 바꿀 때마다 yarn install을 돌리고, 잘 안 될 때는 node_modules를 지우고 다시 깔아야 했는데요. 이런 문제를 해결할 때 Zero-install이 유용했습니다. 모든 의존성을 버전 관리하면 이런 문제가 해소돼요. 그러나 이 방식에도 단점이 있습니다. 레포지토리 사이즈가 커지고, Git 관리가 어려워 진다는 점인데요. 지금은 효용 대비 부담이 커져서 Zero-install 을 기본으로 끄는 방향으로 가고 있습니다.

토스가 Yarn을 선택한 이유와 앞으로의 방향성

이제 드디어 토스가 패키지 매니저를 관리하는 방향성에 대해 이야기 해 볼게요. 토스는 지금까지 Yarn을 사용하고 있어요. 크게 보면 네 가지 이유가 있는데요.

첫 번째는 Yarn의 아키텍처가 매우 잘 설계되어 있기 때문이에요. 패키지 매니저의 각 구성 요소가 모듈화되어 있고, 인터페이스가 잘 분리되어 있습니다. 예를 들어 Resolution 단계의 코드와 Fetch 단계, Link 단계의 코드가 섞여있지 않고 모듈화 되어 있는 건 PnP 밖에 없어요. 코드 작성 방식과 컨벤션도 일관적이죠.

두 번째 이유는 정확성 때문이에요. PnP는 가장 엄격하고 정확한 설치 방식이기 때문에 에러 발생 가능성이 적습니다. dependencies/peerDependencies에 명시하지 않은 의존성은 사용할 수 없기도 하고요. 덕분에 PnP에서 잘 작동한다는 것은 곧 npm, pnpm에서도 잘 작동한다는 것을 뜻하죠.

세 번째 이유는 성능입니다. PnP는 파일 I/O의 수가 적고, 설치 과정이 간단해서 속도가 빠릅니다. 실제로 벤치마크 결과를 보면, npm은 30초가 넘게 걸리지만, pnpm은 8초 정도 걸리고, Yarn은 그보다 더 빠릅니다. CLI 품질도 제일 좋고, 읽기 편해요.

마지막 이유는 확장 가능성입니다. Yarn은 코어 부분을 제외하면 전부 플러그인화가 되어 있어서(Pluggable), 다양한 기능을 쉽게 추가할 수 있습니다. 저희도 자체적으로 여러 플러그인을 만들어서 사용하고 있죠. pnpm도 좋은 패키지 매니저이지만, Yarn의 플러그인 API가 더 확장성이 높다고 생각합니다.

그래서 토스는 현재 Yarn을 계속 사용하고 있습니다. 다만, Zero-install을 끄고, 레포지토리 사이즈를 줄이는 방향으로 가고 있습니다. 브랜치를 변경할 때 yarn install을 매번 하는 불편함은 있을 것으로 예상해요. 하지만 레포지토리 사이즈가 줄어들고, Git 속도 증가할 것으로 예상되고요. .pnp.cjs 파일의 충돌도 더 줄어들 거예요.

번외: JavaScript 표준으로 패키지를 관리하는 방법

관련해서 간단하게 재미있는 점을 하나 소개해드리려고 합니다. package.json이 없는 브라우저나 Deno와 같은 환경에서는 어떻게 패키지 매니저를 관리할까요? 놀랍게도 브라우저에서도 패키지 매니저와 비슷한 방식이 있고, Deno 같은 경우에는 이미 이런 방식을 사용하고 있어요. 이 방식은 JavaScript 표준이라고도 할 수 있는데, ‘Import Map’이라고 부릅니다.

예를 들어, Import Map에서는 React를 다음과 같이 명시해요:


<script type="importmap">
{
  "imports": {
    "react": "https://cdn.jsdelivr.net/npm/react@17/umd/react.development.js"
  }
}
</script>

이후, 스크립트 타입을 모듈로 지정하고, 다음과 같이 React를 가져오면 됩니다:

<script type="module">
  import React from 'react';
</script>

이 방식에서는 Import Map이 'react'를 지정된 URL로 매핑하여, 해당 URL에서 React를 불러와요.

놀랍게도, Deno와 브라우저는 이미 이 방식을 채택하고 있습니다. 제가 알기로는 JavaScript 표준의 방향성이 이렇고, Node.js도 Import Map을 지원하고 있어요. 그래서 먼 미래에는 패키지 매니저와 관련한 갑론을박이 별로 중요하지 않을 수도 있다고 예상해 봅니다. 패키지 매니저 없이 모든 의존성을 가져오는 행복한 방식으로 전환될 수도 있다는 의미예요.

하지만 현재로서는 Node.js에서는 패키지 매니저가 필수적이에요. Deno 같은 경우에는 package.json 없이 importmap을 사용하고 있다는 점만 참고해 주세요.

오늘 패키지 매니저에 대한 라이트닝 토크를 마무리하도록 하겠습니다. 감사합니다!

Talk 박서진 Edit 한주연 Graphic 이나눔

댓글 0댓글 관련 문의: toss-tech@toss.im
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.