이번 글에서는 Frontend 파트 내에서 진행한 패키지 매니저 마이그레이션 경험을 공유하고자 합니다. npm에서 Pnpm으로의 전환을 결정하게 된 배경, 실제 진행한 PoC 과정와 결과에 대해 다루어보겠습니다.

배경

기존 프로젝트들에 npm을 사용하던 중 패키지 설치 시간이 늘어나고 의존성 관리에 어려움을 겪으면서, 이를 해결할 수 있는 새로운 패키지 매니저의 필요성을 느꼈습니다.

npm이 가진 문제점 : 유령 의존성

출처: https://classic.yarnpkg.com/blog/2018/02/15/nohoist/

npm의 문제점 중 하나는 유령 의존성(Phantom Dependencies)입니다. npm은 의존성 중복 저장 문제를 해결하기 위해 호이스팅을 통해 평탄화된 의존성 트리를 만듭니다. 호이스팅은 의존성 설치 과정에서 이 평탄화 구조를 통해 직접 선언하지 않은 의존성도 마치 존재하는 것처럼 접근할 수 있게 되는 현상을 말합니다.

위 그림을 예시로 설명을 드리겠습니다. 왼쪽의 Dependency Tree는 실제 패키지 간의 의존성 관계를 보여줍니다. npm은 이러한 중첩된 의존성 구조를 오른쪽 Dependency Tree처럼 호이스팅을 통해 평탄화합니다. 이 과정에서 B(1.0)이 최상위 node_modules로 호이스팅되어 모든 패키지에서 접근 가능하게 됩니다.

여기서 문제가 발생합니다. package-1은 A, C, D만을 직접적인 의존성으로 선언했음에도 불구하고, B(1.0)이 최상위 node_modules에 호이스팅되었기 때문에 package-1의 코드에서 B(1.0)을 직접 import하여 사용할 수 있게 됩니다. 이것이 바로 '유령 의존성'입니다.

타 패키지 매니저 고려

위 문제점을 해결할 수 있는 Yarn Berry와 Pnpm를 검토했습니다. Yarn Berry는 Plug'n'Play(PnP)를 통해 패키지의 위치 정보를 .pnp.cjs 파일에 명시적으로 저장하여 유령 의존성 문제를 해결하고, Zero Install을 통해 의존성 설치 과정을 제거할 수 있다는 장점이 있었습니다.

하지만 Zero Install을 적용할 경우 모든 의존성을 .yarn/cache에 저장하고 이를 Git으로 관리해야 하는데, 이는 Git 저장소의 크기를 크게 증가시키는 문제가 있었습니다. 또한 PnP 시스템과 호환되지 않는 패키지들은 unplugged 폴더에 별도로 설치가 되는데, 이는 결과적으로 node_modules와 유사한 구조를 만들어내어 Yarn Berry가 제공하는 이점을 반감시키는 요인이 되었습니다.

Pnpm 소개

Pnpm은 기존 패키지 매니저의 디스크 공간 낭비, 복잡한 의존성 관리, 느린 설치 속도 문제를 개선하기 위해 설계된 Node.js 패키지 매니저입니다.

하드링크와 심볼릭 링크의 조화

출처: https://Pnpm.io/motivation

Pnpm은 디스크 공간 낭비 문제를 해결하기 위해 효율적인 스토리지 관리 방식을 사용했습니다. 콘텐츠 주소 지정 저장소(Content-addressable storage)를 사용하여 모든 패키지 버전을 전역 저장소에 단 한 번만 저장합니다. 콘텐츠 주소 지정 저장소에 대한 자세한 정의는 링크를 참조하시면 좋을 것 같습니다.

이후 패키지 관리를 위해 하드링크심볼릭링크를 함께 활용합니다. 하드링크는 글로벌 저장소에 있는 실제 패키지 파일들을 가리키는 포인터로, 동일한 패키지를 여러 프로젝트에서 사용하더라도 디스크 공간을 중복해서 차지하지 않게 해줍니다. 반면 심볼릭링크는 프로젝트의 node_modules 디렉토리 내에서 패키지 간의 의존성 구조를 표현하는데 사용되며, 실제 파일이 아닌 다른 위치의 파일이나 디렉토리를 참조하는 특별한 파일입니다.

이 두 링크 시스템의 조합을 통해 Pnpm은 디스크 공간을 절약하고, 엄격한 의존성을 관리할 수 있습니다. 자세한 내용은 공식문서를 확인하시면 좋을 것 같습니다.

병렬 설치 제공

또한 Pnpm은 느린 설치 속도를 해결하기 위해 병렬 설치 방식을 사용해 npm보다 최대 2배 빠른 설치 속도를 제공하며, 캐시를 효율적으로 활용하여 반복 설치 시 더욱 빠른 성능을 보여줍니다. 메모리 사용량도 최적화되어 있어 시스템 리소스를 효율적으로 사용할 수 있습니다.

Pnpm 선택 이유

따라서, Pnpm은 전역 저장소와 하드 링크 시스템을 통해 중복 설치를 방지하고, 여러 프로젝트에서 동일한 패키지를 공유해 저장 공간을 절약할 수 있기 때문입니다. 또한, 엄격한 의존성 관리를 통해 유령 의존성 문제를 해결하며, 병렬 설치로 빠른 설치 속도를 제공해주기에 선택을 했습니다.

PoC 진행

PoC를 통해 두 가지 주요 목표인 패키지 설치의 효율성과 기존 프로젝트와의 호환성 검증하고자 했습니다.

마이그레이션 진행

기존 npm 프로젝트를 Pnpm으로 마이그레이션하기 위해, 먼저 기존 패키지 매니저로 설치된 파일과 설정들을 삭제해주었습니다. Pnpm을 전역에 설치한 뒤, 기존의 node_modules 폴더와 package-lock.json 파일을 삭제해 중복이나 충돌을 방지해줍니다. 이후, 프로젝트의 의존성 설정을 Pnpm 형식에 맞게 변환하고, pnpm install을 통해 필요한 패키지들을 새롭게 설치해주었습니다.

Pnpm 빌드 시 발생한 에러

기존 npm으로 정상 동작하던 프로젝트를 Pnpm으로 전환한 후 빌드 시 lodash/isEqualimport 해오는 위치에서 에러가 발생했습니다.

Error: Cannot find module 'lodash/isEqual'
  at Function.Module._resolveFilename (node:internal/modules/cjs/loader:933:15)
  at Function.Module._load (node:internal/modules/cjs/loader:778:27)
  ...

에러 발생 원인은 Pnpm의 의존성 관리 방식에 있었습니다. npm은 모든 의존성을 node_modules 최상위에 호이스팅하여 자유로운 접근을 허용하는 반면, Pnpm은 package.json에 명시된 의존성만 접근 가능하도록 엄격하게 제한합니다.

이 문제는 package.json에 필요한 패키지를 명시적으로 추가하는 방법으로 해결했습니다. 다른 방법인 .npmrcshamefully-hoist 속성을 사용하면 Pnpm이 기본적으로 유지하는 구조를 무시하고, 일부 의존성만 선택적으로 호이스팅해 Pnpm의 공간 절약 이점을 잃게 됩니다. 추가적인 옵션인 shamefully-hoist에 대한 설명은 공식문서에서 참고할 수 있습니다.

벤치마크 결과

macOS Sonoma 14.5 환경에서 Node.js v20.17.0으로 테스트한 결과는 다음과 같습니다. 각 시나리오는 5회 테스트 후 평균값을 측정했습니다.

이 결과를 통해 Pnpm이 다양한 설치 시나리오에서 npm보다 뛰어난 성능을 보이며, 전반적으로 성능이 크게 향상된 것을 확인할 수 있었습니다.

결론

Frontend 파트에서는 이번 PoC를 통해 npm에서 Pnpm으로의 전환이 우리 프로젝트에 가져올 수 있는 이점들을 직접 검증할 수 있었습니다. 이후 프로젝트에 Pnpm을 도입하기로 결정했습니다. 이번 프로젝트에서 선택한 Pnpm 이외에도 Yarn이나 npm과 같은 다른 패키지 매니저들도 각자의 장점을 가지고 있습니다. 프로젝트 특성에 따라 알맞는 패키지를 선택하는 것이 중요할 것 같습니다. 저희 파트의 이번 경험이 패키지 매니저 선택을 고민하는 다른 개발자분들께 도움이 되길 바랍니다.

EVI Studio Dev그룹 EVD팀 FrontEnd 개발자 유은지


참고:

  1. https://pnpm.io/ko/blog/2020/05/27/flat-node-modules-is-not-the-only-way
  2. https://rushjs.io/pages/advanced/phantom_deps/#phantom-dependencies