ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • pnpm + Corepack 조합, 왜 요즘 표준처럼 쓰일까? 🤔
    웹 개발 2026. 5. 31. 22:58

    Node.js 생태계는 꽤 오랫동안 npm 중심이었다. 프로젝트를 시작하면 자연스럽게 npm install을 입력했고, 패키지 매니저 버전은 크게 신경 쓰지 않았다.

    하지만 프로젝트 규모가 커지고, 모노레포와 협업 환경이 보편화되면서 몇 가지 문제가 반복적으로 드러나기 시작했다.

    • 프로젝트마다 비대해지는 node_modules
    • package.json에 선언하지 않은 패키지를 우연히 사용하다 환경이 바뀌면 터지는 문제
    • 팀원마다 다른 패키지 매니저 버전으로 인한 lockfile 충돌
    • "내 컴퓨터에서는 되는데요?" 문제
      그리고 어느 순간부터 많은 프로젝트들이 이런 조합으로 이동하기 시작했다.
    corepack enable
    pnpm install

    이 두 줄이 사실상 표준처럼 자리 잡은 이유를, 처음부터 차근차근 따라가 보자.


    npm 시대의 불편함

    npm이 나쁜 도구는 아니다. 오히려 Node.js 생태계를 여기까지 끌고 온 핵심 도구에 가깝다. 다만 현대 프론트엔드 개발 환경과는 조금씩 맞지 않는 부분들이 생겼다.

    1. 프로젝트마다 반복되는 node_modules

    npm은 패키지를 프로젝트마다 물리적으로 복사해 저장한다. 프로젝트가 많아질수록 디스크 사용량이 급격히 증가한다.

    project-a/node_modules/react  (50MB)
    project-b/node_modules/react  (50MB)
    project-c/node_modules/react  (50MB)

    같은 버전의 react가 세 군데 통째로 복사된다. 직관적으로도 낭비다.

    2. 유령 의존성(Phantom Dependency) 문제

    npm의 플랫(flat) 구조는 실제로 선언하지 않은 패키지에도 접근할 수 있게 만든다.

    예를 들어 express를 설치하면, express가 내부적으로 사용하는 qsbody-parser 같은 패키지도 node_modules 최상단에 함께 올라온다(호이스팅). 이를 내 코드에서 직접 require해도 동작한다.

    문제는 이게 우연히 동작하는 것이라는 점이다. expressqs 의존성을 제거하는 순간, 내 코드는 선언도 없이 사용하던 패키지를 잃어버리고 런타임에서 터진다.

    Error: Cannot find module 'qs'

    "내 컴퓨터에서는 되는데요?" 문제의 상당수가 이 구조에서 비롯된다.

    3. 팀별 패키지 매니저 버전 불일치

    이 문제는 사실 npm만의 문제가 아니다. 누군가는 pnpm 8, 누군가는 pnpm 9, CI는 또 다른 버전. 이렇게 되면 lockfile 충돌이 발생하거나 예상치 못한 설치 차이가 생긴다.


    그래서 등장한 pnpm

    pnpm은 이런 문제를 꽤 현실적으로 해결했다.

    하드링크 + 글로벌 스토어로 디스크 낭비 해결

    패키지를 글로벌 저장소(~/.pnpm-store)에 딱 한 번만 저장하고, 프로젝트에서는 하드링크로 연결한다. 동일한 버전의 react를 10개 프로젝트에서 쓴다면, 실제 파일은 딱 하나뿐이다.

    ~/.pnpm-store/react@18.2.0  (50MB, 딱 한 번만 저장)
    
    project-a/node_modules/react → (hardlink)
    project-b/node_modules/react → (hardlink)
    project-c/node_modules/react → (hardlink)

    엄격한 node_modules 구조로 유령 의존성 차단

    pnpm은 node_modules 최상단에 package.json에 직접 선언한 패키지만 노출시킨다. 다른 패키지의 의존성은 .pnpm 디렉터리 안에 격리된다.

    node_modules/
     ├── express/        → (symlink, 직접 선언한 것만)
     └── .pnpm/
          ├── express@4.x/
          │    └── node_modules/
          │         ├── qs/          (express의 의존성, 외부에서 접근 불가)
          │         └── body-parser/ (express의 의존성, 외부에서 접근 불가)
          └── ...

    package.json에 선언하지 않은 패키지를 require하면 즉시 에러가 난다. 유령 의존성이 구조적으로 불가능하다.

    덕분에 설치 속도가 빠르고, 디스크 사용량이 적고, 의존성 관리가 엄격하다. Turborepo, Nx 같은 모노레포 환경에서는 사실상 기본 선택지로 자리 잡은 경우도 많다.


    그런데 pnpm도 문제가 있었다

    흥미로운 건, pnpm 자체도 버전 관리 문제가 있었다는 점이다. 예전에는 보통 이렇게 설치했다.

    npm install -g pnpm
    # 또는
    brew install pnpm

    글로벌 설치 기반이라 사람마다 버전이 다르고, OS마다 동작이 다르고, CI 환경과 로컬이 달라질 수 있었다. 즉, 패키지 매니저 자체가 또 다른 환경 변수가 된 셈이다.

    npm의 유령 의존성 문제를 해결하려고 pnpm으로 갈아탔더니, 이번엔 pnpm 버전이 문제가 되는 아이러니한 상황이다.


    그래서 Corepack이 중요해졌다

    Node.js는 이 문제를 해결하기 위해 v16.9.0부터 Corepack을 포함시키기 시작했다. 한 줄로 요약하면 이렇다.

    Node.js가 직접 패키지 매니저 버전을 관리해주는 시스템

    package.json에 이렇게 작성하면:

    {
      "packageManager": "pnpm@10.2.0"
    }

    corepack enable을 한 번 실행해두는 것만으로, 이후 pnpm 명령을 입력할 때 Corepack이 지정된 버전을 자동으로 사용한다. 해당 버전이 로컬에 없으면 다운로드까지 알아서 해준다.


    Corepack이 정확히 어떻게 동작하는가

    Corepack의 핵심 원리는 shim(심)이다. corepack enable을 실행하면, Corepack은 Node.js 바이너리가 설치된 경로 옆에 pnpm, yarn 같은 이름의 shim 파일을 만들어둔다.

    이후 터미널에서 pnpm을 입력하면, 실제로는 이 shim이 먼저 실행된다. shim은 다음 과정을 거친다.

    사용자: pnpm install
      ↓
    ① Corepack shim 실행
      ↓
    ② 현재 디렉터리부터 상위로 올라가며 package.json 탐색
      ↓
    ③ "packageManager": "pnpm@10.2.0" 확인
      ↓
    ④ 해당 버전의 pnpm 바이너리가 캐시에 있으면 바로 실행,
       없으면 다운로드 후 실행

    덕분에 개발자는 글로벌 pnpm을 직접 설치하거나 버전을 관리할 필요가 없다. 프로젝트에 들어오는 순간 자동으로 맞는 버전이 실행된다. 신규 팀원 온보딩도 훨씬 간단해진다.


    글로벌 pnpm과 Corepack을 같이 쓰면 어떻게 되나 (사실 이 글을 쓰게된 이유이다.)

    여기서 한 가지 함정이 있다.

    이미 npm install -g pnpm이나 brew install pnpm으로 pnpm을 글로벌 설치한 상태에서 corepack enable까지 같이 쓰면 어떤 pnpm 바이너리가 실행되는지 꼬일 수 있다.

    Corepack shim도 pnpm이라는 이름으로 등록되기 때문에, PATH 우선순위에 따라 어떤 게 먼저 잡히느냐가 달라진다.

    # 어떤 pnpm이 실행되는지 확인
    which pnpm
    
    # 글로벌 npm으로 설치한 경우
    /Users/me/.nvm/versions/node/v20.x.x/bin/pnpm  ← npm이 설치한 바이너리
    
    # Homebrew로 설치한 경우
    /opt/homebrew/bin/pnpm  ← Homebrew 바이너리
    
    # Corepack shim이 제대로 잡힌 경우
    /Users/me/.nvm/versions/node/v20.x.x/bin/pnpm  ← Corepack shim (같은 경로처럼 보여도 다름)

    이 상태에서 대표적으로 생기는 문제들이 있다.

    버전이 의도와 다르게 나온다

    package.jsonpnpm@10.2.0을 지정했는데 터미널에서 pnpm -v를 입력하면:

    pnpm -v
    # 8.15.0

    글로벌로 설치해둔 구버전 pnpm이 실행되고 있는 것이다.

    lockfile이 PR마다 흔들린다

    pnpm 버전마다 lockfile 포맷이 미묘하게 다르다. 팀원마다 다른 버전을 쓰면 pnpm-lock.yaml이 매번 조금씩 바뀌면서 PR마다 불필요한 변경이 붙어 나온다.

    로컬에선 되는데 CI에서 안 된다

    로컬에서는 글로벌 pnpm 8이 실행되고, CI에서는 Corepack pnpm 10이 실행되면 의존성 resolve 결과가 달라질 수 있다. 원인을 찾기도 꽤 까다롭다.

    생각해보면 이건 처음에 제기했던 문제들과 정확히 같은 패턴이다. "팀원마다 버전이 다르고, CI 환경과 로컬이 달라서 재현이 안 된다." pnpm을 써도, 그 pnpm 자체가 버전이 들쭉날쭉하면 같은 문제가 반복된다.


    결론: 하나만 선택하자

    실무에서 권장하는 방식은 단순하다. 글로벌 pnpm을 제거하고, Corepack 하나만 사용한다.

    # 기존 글로벌 pnpm 제거
    npm uninstall -g pnpm
    brew uninstall pnpm  # Homebrew로 설치한 경우
    
    # Corepack 활성화 (한 번만)
    corepack enable

    그리고 package.json에 버전을 명시한다.

    {
      "packageManager": "pnpm@10.2.0"
    }

    이후 팀원 전원이 corepack enable 한 번씩만 실행하면, 이 프로젝트에서는 항상 pnpm@10.2.0이 실행된다. CI에서도 동일하다.

    이게 바로 처음에 말한 문제들의 해답이다.

    문제 해결 방법
    node_modules 비대 pnpm 글로벌 스토어 + 하드링크
    유령 의존성 pnpm의 엄격한 node_modules 격리
    팀원별 버전 불일치 Corepack packageManager 필드
    CI/로컬 환경 차이 Corepack이 버전 통일
    글로벌 설치 관리 부담 Corepack이 자동으로 다운로드·실행

    마무리

    예전 Node.js 생태계에서 중요한 질문은 "어떻게 설치할까?"였다면, 지금은 "어떻게 모두가 동일한 환경을 사용할까?"가 훨씬 중요해졌다.

    pnpm + Corepack 조합은 그 질문에 현재로서는 가장 깔끔하게 답한다. npm도 계속 발전하고 있고 선택지는 여전히 여러 개지만, 특히 협업 규모가 크거나 모노레포 환경이라면 이 조합의 이점이 두드러진다.

    새 프로젝트를 시작한다면, 이렇게 시작하는 것을 권장한다.

    # package.json에 버전 먼저 명시
    # "packageManager": "pnpm@10.2.0"
    
    corepack enable
    pnpm install

    글로벌 pnpm은 지우고, Corepack에 맡기자.


    참고 자료

Designed by Tistory.