엔지니어 1명이 레포 170여 개를 pnpm 11로

필요한 명령은 pnpm에 있었고 반복은 검증 하네스를 붙인 AI가 맡았습니다

Wes • Gihyeon Lee, Web Desk Engineer Thanks.

  • Frontend

ChainDrop이 지나간 뒤

2026년 8월 4일 npm에 ChainDrop이라는 웜이 퍼졌습니다. keyv, flat-cache처럼 ESLint를 설치하면 함께 설치되는 패키지의 관리자 GitHub 계정이 탈취됐고 감염된 버전의 package.json에는 이런 줄이 들어 있었습니다.

JSON

패키지를 설치하면 이 스크립트가 실행되면서 npm·GitHub·클라우드 자격 증명을 훔쳤습니다. 훔친 npm 토큰으로 다른 패키지를 또 감염시켜 몇 시간 만에 수백 개 패키지가 오염됐습니다(Elastic Security Labs, StepSecurity).

당시 살펴본 저희 레포의 lockfile에는 감염된 버전이 없었습니다. 운이 좋았을 뿐입니다.

당시 제 로컬에 깔려 있던 npm 11, pnpm 9, yarn 1은 모두 의존성의 install script(설치할 때 자동으로 실행되는 preinstall, postinstall 같은 스크립트)를 기본으로 실행했습니다. pnpm 10과 npm 12부터는 이 스크립트를 기본으로 막고 허용 목록에 있는 것만 실행합니다.

그래서 채널팀의 JS·TS 레포를 모두(아카이브된 레포는 빼고) pnpm 11로 옮기기로 했습니다. 이미 pnpm을 쓰던 레포는 버전을 올리고 npm·yarn 레포는 pnpm으로 바꾸는 일이었습니다. 7주 동안 해 보고 얻은 결론은 이렇습니다.

npm·yarn에서 옮기든 옛 pnpm에서 올리든 필요한 명령은 pnpm에 이미 있습니다. 그 반복 작업을 검증 장치를 붙인 AI에게 맡기면 엔지니어 한 명이 레포 170개 넘게 pnpm 11로 옮기는 PR을 낼 수 있습니다(그중 npm·yarn에서 옮기는 레포가 90개 넘게).

AI가 한 일을 기계로 확인해서 틀린 결과가 다음 단계로 못 넘어가게 막는 장치를 이 글에서는 하네스라고 부르겠습니다. 앞부분은 pnpm 명령 이야기이고 뒷부분은 하네스 이야기입니다.

숫자로 보면

항목

값

기간

2026년 8월 26일 ~ 10월 14일, 7주 (다른 업무와 병행)

pnpm 11로 옮기는 PR을 낸 레포

171개

그중 npm·yarn에서 옮기는 PR을 낸 레포

94개 (36개는 머지됨, 10월 20일 기준)

연 PR

234개 (머지 130, 열림 89, 머지 없이 닫힘 15, 10월 20일 기준)

직접 작업한 사람

1명 (레포 오너들은 리뷰와 머지로 참여)

나머지 레포는 이미 pnpm을 쓰던 곳을 11로 올리거나 방어 설정을 넣은 곳입니다. PR 수와 머지 수는 10월 20일에 센 값입니다.

레포 작업은 AI가 실행했고 저는 원래 하던 업무를 하면서 정할 것이 생기거나 결과를 볼 때만 들어갔습니다.

pnpm 11에서 install script를 막는 설정

레포마다 넣은 설정은 이렇습니다.

YAML
JSON

pnpm 버전은 알려진 보안 취약점(CVE) 두 건(CVE-2025-69264, CVE-2025-69262)이 고쳐진 10.27.0 이상이어야 합니다. 저희는 먼저 시험해 본 레포에서 검증한 11.24.0으로 맞췄습니다.

strictDepBuilds는 pnpm 11에서 기본으로 켜져 있지만 직접 적어 두었습니다(pnpm 10에서는 꺼져 있어서 안내 한 줄만 남기고 넘어갑니다). 켜져 있으면 허용 목록에 없는 스크립트를 만났을 때 install이 ERR_PNPM_IGNORED_BUILDS로 멈춥니다. 목록에서 빠진 패키지를 기계가 대신 찾아 줍니다.

허용 목록은 패키지마다 따로 적용됩니다. ESLint를 쓴다고 그 밑의 keyv, flat-cache까지 허용되지는 않고 원래 스크립트가 없던 이 둘이 preinstall을 들고 오면 목록에 없으니 install이 멈춥니다.

allowBuilds는 객체로 쓰고 키에 버전을 붙입니다

allowBuilds를 배열(- esbuild)로 쓰면 아무것도 허용되지 않습니다. 배열 번호("0", "1")가 키로 읽히는데도 에러나 경고가 없습니다. 허용한 패키지가 계속 막혀서 원인을 찾다가 AI가 pnpm config list로 실제로 읽힌 값을 열어 보고서야 알았습니다.

키에 버전을 붙이면(esbuild@0.25.12) 그 패키지 버전이 바뀔 때 strictDepBuilds가 install을 멈추고 다시 승인하게 만듭니다. 새 버전이 몰래 스크립트를 실행하는 길이 막힙니다. 이 방식은 작업 도중에 코드 리뷰 봇의 지적을 받고 바꿨기 때문에 그 전에 낸 PR에는 적용하지 않았습니다.

packageManager 한 줄로는 버전이 안 맞습니다

이 필드를 읽고 버전을 바꿔 주는 도구(Node에 들어 있는 corepack이나 pnpm 10 이상)가 없으면 소용이 없습니다. pnpm 9를 직접 설치한 환경은 이 필드를 무시하고 스크립트를 그대로 실행했습니다(pnpm 10을 검토하던 때 잰 결과이고 11로는 다시 재지 않았습니다).

CI에서는 워크플로에 적힌 버전이, 사내 공용 setup 액션에서는 기본값인 pnpm 8이, Dockerfile에서는 npm install -g pnpm@9로 설치한 버전이 각각 실행되고 있었습니다. 그래서 완료 기준을 CI 로그에 찍힌 실제 pnpm 버전으로 잡았습니다.

이 설정이 막지 못하는 것

이 설정이 막는 건 의존성의 install script뿐입니다. 레포 자신의 package.json에 적힌 postinstall은 코드 리뷰와 브랜치 보호로 지켜야 합니다. 원래 스크립트가 있어 허용해 둔 패키지 자체가 오염되는 경우도 막지 못합니다.

키에 버전을 붙여 두면 새 버전은 다시 승인해야 하지만 이름만 적은 목록에서는 새 버전도 그대로 실행됩니다. 누군가 strictDepBuilds: false 한 줄을 넣으면 방어가 조용히 꺼지는 것도 막지 못합니다. 그래서 방어를 끄는 설정 여섯 가지(dangerouslyAllowAllBuilds: true 등)를 옮긴 레포 전체에서 찾는 스캔을 주기 점검에 넣었습니다.

npm·yarn에서 옮길 때

npm·yarn을 그대로 두고 방어 설정만 켜는 길도 있었습니다. npm 11.16 이상과 yarn Berry(yarn 2 이상) 4.15 이상에도 스크립트를 막는 설정이 있습니다.

그래도 모두 pnpm으로 옮겼습니다. 패키지 매니저가 하나면 지킬 곳이 줄어듭니다.

옮길 때 쓴 명령과 조심할 곳을 정리하면 이렇습니다.

하려는 일

pnpm에 있는 것

조심할 곳

옛 lockfile을 옮긴다

pnpm import

그대로 옮기지 않고 버전을 다시 해석해서 바뀔 수 있다

허용 목록을 만든다

install이 실패하면서 써 주는 allowBuilds 뼈대

뼈대 밑에 같은 키를 이어 붙이면 YAML이 깨진다

pnpm 버전을 맞춘다

packageManager를 읽고 그 버전으로 바꾸는 동작

다른 버전으로 불러도 필드에 적힌 버전으로 바뀐다

실제로 읽힌 설정을 본다

pnpm config list, pnpm config get

파일에 적었다고 읽힌 게 아니다

워크스페이스 멤버를 잇는다

workspace: 프로토콜

버전 범위로 참조하던 줄은 뜻이 바뀐다

버전 고정을 옮긴다

pnpm-workspace.yaml의 overrides

npm overrides, yarn resolutions는 읽지 않는다

순서는 이렇습니다.

Shell

pnpm import에서 만난 함정 네 가지

pnpm import는 package-lock.json이나 yarn.lock을 읽어서 pnpm-lock.yaml을 만들어 줍니다. 다만 기존 lockfile을 그대로 옮겨 주지는 않습니다. 실행하면 기존 pnpm-lock.yaml부터 지우니 커밋해 둔 상태에서 실행하세요.

함정 ①에서 pnpm import가 하는 일은 옛 영수증을 보고 장을 새로 보는 것과 비슷합니다. 영수증에 있는 물건이 가게에 있으면 똑같이 집지만 어느 요리에 쓸 재료인지는 영수증에 없습니다.

이 동작은 pnpm 11.24.0에서 본 결과입니다. 관련 이슈 pnpm#6249와 pnpm#4385가 2026년 9월에 일부 정리돼서 최신 버전에서는 다를 수 있습니다.

함정 ②에서 pnpm은 packageManager에 적힌 버전으로 스스로 바뀝니다. 이 전환을 끄려고 설정과 환경변수 네 가지를 시도해 봤지만 방법을 아직 찾지 못해서, import에 쓸 버전을 이 필드에 먼저 적어 두었습니다.

허용 목록 뼈대는 pnpm 11이 써 줍니다

허용 목록도 손으로 처음부터 쓸 필요가 없었습니다. pnpm 11.24.0은 목록에 없는 스크립트가 있으면 install을 실패시키면서 pnpm-workspace.yaml에 뼈대를 직접 써 넣습니다.

YAML

저희는 install을 일부러 한 번 실패시켜 뼈대를 받고 값을 채운 뒤 키에 버전을 붙였습니다. 값은 옮기기 전에 실제로 실행되던 스크립트 목록을 그대로 따랐습니다. 처음에 네이티브 모듈이면 허용하는 식으로 짐작해서 채웠다가 install이 깨졌기 때문입니다.

이 목록은 Linux 컨테이너에서 재야 합니다. 플랫폼마다 설치되는 선택 의존성이 달라서 같은 레포를 macOS에서 재면 0개, Linux에서 재면 3개였습니다.

뼈대 밑에 allowBuilds: 블록을 이어 붙이면 같은 키가 두 번 생겨 YAML이 깨지니 파일을 통째로 다시 쓰는 편이 안전합니다.

lockfile 밖에 남은 npm·yarn의 흔적

lockfile을 옮겨도 옛 패키지 매니저를 전제로 한 곳이 레포 곳곳에 남습니다.

  • 워크스페이스 멤버끼리 버전 범위("^1.2.0")로 참조하던 줄은 pnpm에서는 레지스트리에서 받는다는 뜻으로 바뀝니다. workspace:로 바꾸거나 linkWorkspacePackages: true를 켰습니다. 그 package.json을 npm이 읽는 곳(Dockerfile 안의 npm install 등)이 남아 있으면 EUNSUPPORTEDPROTOCOL로 깨지니 함께 봐야 합니다.

  • yarn·npm은 의존성을 위로 끌어올려 평평하게 설치하지만 pnpm은 선언한 패키지만 보여 줍니다. SCSS import 경로, tsconfig의 typeRoots, 선언 없이 require하던 패키지가 깨졌습니다. 꼭 평평해야 하는 곳(React Native의 Metro 번들러 등)에만 nodeLinker: hoisted를 썼습니다.

  • pnpm은 npm의 overrides를 읽지 않고 pnpm 11은 yarn의 resolutions도 적용하지 않습니다. 보안 패치 때문에 고정해 둔 버전이 고정에서 빠져서 한 레포에서는 옛 취약 버전이 lockfile에 되살아났습니다. pnpm-workspace.yaml의 overrides로 옮겨야 합니다.

옛 pnpm에서 11로 올릴 때

이미 pnpm을 쓰던 레포는 처음에 packageManager 한 줄이면 될 줄 알았습니다. 막상 해 보니 pnpm 8·9·10과 11 사이에 동작이 바뀐 곳을 하나씩 옮겨야 했습니다.

pnpm 11.24.0은 Node 22.13 이상을 요구해서 Node가 낮은 레포는 Node부터 올렸습니다.

하려는 일

pnpm에 있는 것

조심할 곳

옛 lockfile(v6)을 v9로 바꾼다

pnpm@9 install --lockfile-only

pnpm 10은 v6을 읽지 못하고 버전을 처음부터 다시 해석한다

허용 목록을 옮긴다

onlyBuiltDependencies → allowBuilds

옛 버전마다 원래 실행되던 스크립트가 다르다

.npmrc 설정을 옮긴다

pnpm-workspace.yaml, pnpm config get

pnpm 11은 프로젝트 .npmrc에서 인증·레지스트리 줄만 읽는다

스크립트에 인자를 넘긴다

pnpm <스크립트> <인자> (-- 없이)

pnpm 10부터 --까지 스크립트로 넘어간다

옛 lockfile은 pnpm 9를 거쳐 바꿉니다

pnpm 8 시절 형식(v6)의 lockfile은 pnpm 10이 읽지 못합니다. --fix-lockfile을 줘도 고치지 못하고 버전을 처음부터 다시 해석합니다. 한 레포에서는 이렇게 99건이 바뀌었습니다.

pnpm 9는 v6을 읽고 v9로 쓸 수 있으니 먼저 형식만 바꿨습니다.

Shell

이렇게 바꾼 18개 레포에서 버전이 바뀐 패키지는 하나도 없었습니다. 한 레포에서 어느 package.json에도 선언되지 않은 채 남아 있던 항목이 정리돼 빠졌을 뿐입니다.

다만 lockfile이 v9가 되면 CI의 pnpm과 packageManager도 9 이상이어야 합니다. pnpm 8로 고정된 레포는 형식 변환만 먼저 머지할 수 없었습니다.

허용 목록은 옛 버전을 보고 옮깁니다

pnpm 11에서는 onlyBuiltDependencies가 없어져서 허용 목록을 allowBuilds로 옮겨야 합니다. 원래 실행되던 스크립트만 허용하려면 옮기기 전 pnpm 버전부터 봐야 했습니다.

pnpm 10은 원래 기본 차단이라 기존 onlyBuiltDependencies에 있던 것만 허용하고 새로 드러난 스크립트는 막아 두면 됩니다. pnpm 9 이하는 목록이 없으면 전부 실행되고 있었으니 실행되던 것을 다 허용합니다.

함정은 pnpm 9였습니다. package.json에 onlyBuiltDependencies가 있으면 pnpm 9도 그 밖의 스크립트를 조용히 막고 있었습니다.

한 레포에서는 목록 밖의 esbuild와 protobufjs가 원래 막혀 있었는데 버전 규칙대로 목록을 만드는 저희 변환 스크립트는 둘 다 허용으로 냈습니다. 이후로는 버전 규칙은 참고만 하고 앞의 npm·yarn 레포처럼 옮기기 전에 실제로 실행된 스크립트 목록을 정답으로 삼았습니다.

.npmrc 설정은 키마다 갈 곳을 정합니다

pnpm 11은 프로젝트 .npmrc에서 인증·레지스트리 줄만 읽습니다. shamefully-hoist 같은 나머지 설정은 파일에 남아 있어도 꺼진 것과 같습니다. 이걸 그대로 둔 레포도 회귀 검사(바꾸기 전과 후의 결과 비교)는 통과했습니다.

그런데 최상위 node_modules가 441개에서 29개로 줄어 있었습니다. AI는 이 숫자를 재서 PR 설명에 적었지만 원인을 잘못 짚었고 원인은 PR 리뷰어가 잡아 줬습니다.

그래서 키마다 셋 중 하나로 정했습니다. pnpm-workspace.yaml로 옮기거나, pnpm 11 기본값과 같으면 지우거나, 일부러 안 옮기는 키는 바로 윗줄에 이유를 주석으로 남겼습니다. 이유 없이 남은 키가 있으면 회귀 검사가 실패하게 했고 옮긴 키는 pnpm config get <키>로 실제로 읽혔는지 봤습니다.

인증 줄도 바뀝니다. pnpm 11은 레포 설정으로 토큰이 새는 걸 막으려고 프로젝트 .npmrc의 ${ENV} 인증 줄을 확장하지 않습니다.

사내 private 패키지를 쓰는 레포는 개발자의 ~/.npmrc에 같은 줄을 두고 CI는 NPM_CONFIG_USERCONFIG로 임시 사용자 설정 파일을 쓰게 바꿨습니다. @scope:registry 매핑은 그대로 읽습니다.

AI에게 맡기면서 붙인 하네스

옮기는 일에서 어려운 건 위의 함정을 레포마다 빠짐없이 확인하는 일이었고 그 반복을 AI에게 맡겼습니다. 처음부터 잘 되지는 않았습니다.

처음 계획은 두 번 무너졌습니다

저희는 작업마다 시작 전에 계약(무엇을 하고 언제 끝났다고 볼지 적은 문서)을 쓰고 끝나면 채점자 AI 둘이 결과를 판정하게 했습니다. 첫 조사도 그렇게 끝났습니다. 그런데도 계획은 두 번 무너졌습니다.

다시 세울 때 붙인 규칙은 다섯 가지입니다.

  • 모르는 것은 근거로 답하거나 범위에서 빼기 전에는 시작하지 않는다

  • 로컬, CI, Docker, 시간 흐름, 레포의 기존 상태에서 각각 한 번 이상 직접 실행해 본다

  • 가설을 확인하기 전에 쓸 수 있는 설정과 선택지를 먼저 다 늘어놓는다

  • 끝까지 적용해 본 사양을 바탕으로만 레포를 나눈다

  • 주장마다 근거가 문서인지 실측인지 추정인지 적는다

레포 하나를 옮기는 사이클

다시 세운 뒤로는 레포 하나를 옮기는 과정을 단계로 나누고 단계마다 기계가 확인할 것을 붙였습니다. 레포는 웨이브(한 번에 묶어 처리하는 레포 묶음) 단위로 처리했습니다. 웨이브가 끝날 때마다 배운 함정을 번호 목록으로 정리해 다음 웨이브에 넘겼습니다(인계).

npm·yarn 레포는 이렇게 진행합니다.

그림의 확인 항목은 되도록 명령 한 줄로 판정되게 계약에 적었습니다(행 수, grep 결과 0건, 파일 해시 같은 것).

5단계 회귀 대조에서는 바꾸기 전 커밋과 바꾼 뒤 커밋을 같은 Linux 컨테이너에서 새로 설치·빌드해 층별로 비교합니다.

층

비교하는 것

왜

1

직접 선언한 의존성의 실제 설치 버전

lockfile을 다시 만들며 버전이 바뀌었나

2

실제로 실행된 install script 목록

막을 건 막히고 실행되던 건 그대로 실행되나

3

빌드 산출물의 내용 해시

배포되는 결과물이 같은가

4

레포가 원래 쓰던 lint·typecheck·test 결과

레포 자신의 기준으로도 같은가 (npm·yarn 쪽에서 더함)

검사 도구도 그대로 믿지 않았습니다. 버전 하나를 바꾸거나 산출물을 1바이트 바꾼 가짜 변경을 넣고 해당 층에서만 불일치가 나는지부터 확인했습니다.

실행하는 AI와 채점하는 AI를 나눕니다

실행한 AI가 자기 결과를 채점하면 놓친 것을 또 놓칩니다. 그래서 실행 앞뒤에 실행한 AI가 아닌 쪽의 확인을 두었습니다.

사람이 한 일

AI가 실행하는 동안 저는 방향과 기준을 정했습니다. 채널팀의 모든 레포를 pnpm 11로 바로 옮기고 npm·yarn 레포도 남기지 않기로 했고 버전 고정과 허용 목록 원칙 같은 결정 8개를 하나씩 내렸습니다. 리뷰어 지정과 머지 소통처럼 사람 사이의 일도 제 몫이었습니다.

AI가 결정 4개를 한꺼번에 내밀었을 때는 "결정 전에 맥락을 줘야지"라고 돌려보냈습니다. 그 뒤로는 용어 설명부터 듣고 하나씩 정했습니다. 인계가 쌓이면서 레포 10개짜리 웨이브 하나의 작업 타이머 기록(작업 세션이 열려 있던 시간, 쉬는 동안은 뺌)이 5시간 45분에서 1시간 53분으로 줄었습니다. 그 무렵부터는 "결과는 내일 아침에 볼게, 나머지는 잘 판단해서 진행해줘"라고 맡기고 퇴근하기도 했습니다.

돌아보면 가장 효과가 컸던 건 짧게 되묻는 일이었습니다. pnpm 9로 올리면 보안이 정말 나아지는지, 실제로 쓰는 Docker인지 같은 질문에 AI가 실측으로 답하다가 계획의 전제가 여러 번 바뀌었습니다.

마치며

npm·yarn에서 옮길 때도, 옛 pnpm에서 올릴 때도 명령을 새로 만들 필요는 없었습니다. 시간은 pnpm에 이미 있는 명령들이 정말 생각대로 동작했는지 확인하는 데 들었고 저희는 그 확인을 레포마다 빠짐없이 하는 하네스를 만들었습니다.

설정을 넣었다고 켜지지는 않았고 exit 0과 CI 통과 표시도 아무것도 보장하지 않았습니다. 켜졌는지는 매번 직접 실행해서 확인했습니다.

AI가 할 수 있는 일과 사람만 할 수 있는 일을 나눠 한 사람이 감당하는 범위를 넓혀 가고 있습니다. 이 일을 함께할 동료를 찾고 있습니다.

We Make a Future Classic Product