"패키지를 어느 칸에 넣느냐가 설치 결과를 바꿔. 이름만 다른 목록 네 개가 아니라, 소비자와 맺는 계약 네 가지야."
dependencies에는 실행에 필요한 걸 넣어
프로덕션 코드가 실행 중에 가져오는 패키지는 dependencies에 둬. HTTP 클라이언트, 런타임 검증기, 데이터베이스 드라이버가 대표적이야. 누군가 이 패키지를 설치하면 이 의존성도 함께 설치돼.
판단은 간단해. 그 패키지를 빼면 배포된 프로그램이 실행되지 않는가? 그렇다면 런타임 의존성이야.
devDependencies에는 만드는 동안만 쓰는 걸 넣어
TypeScript 컴파일러, 테스트 러너, 린터, 포매터, @types/node처럼 개발 과정에만 필요한 도구는 devDependencies에 둬. npm install --omit=dev로 설치하면 이 목록은 빠져.
예를 들어 완성된 JavaScript를 배포하는 라이브러리라면 소비자에게 TypeScript 컴파일러까지 떠넘길 이유가 없어. 테스트, 정적 분석, 빌드에만 쓰는 도구는 개발 의존성으로 분리해야 설치 크기와 공급망 표면도 함께 줄어.
peerDependencies는 호스트가 제공할 짝을 선언해
peerDependencies로 밝혀. React 컴포넌트 라이브러리는 React를 한 벌 더 품기보다 "react": ">=18"처럼 선언하고 소비자 앱의 React를 함께 써. 그래야 React가 중복되어 훅이 깨지는 문제를 피할 수 있어. ESLint 플러그인과 ESLint, Vite 플러그인과 Vite도 같은 관계야. 핵심은 "호스트를 대신 제공하는 패키지가 아니라, 호스트와 함께 작동하는 확장"이라는 계약이야.optionalDependencies는 없어도 설치를 이어 갈 수 있어
설치를 시도하되 실패해도 전체 설치를 중단하지 않을 패키지는 optionalDependencies에 둬. 운영체제나 CPU에 맞춘 네이티브 바이너리가 흔한 예야. 맞는 바이너리를 쓸 수 있으면 빠른 경로로 가고, 그렇지 않으면 순수 JavaScript 구현처럼 준비된 대체 경로를 써.
이 칸에 넣었다고 대체 동작이 저절로 생기지는 않아. 코드가 패키지 부재를 감지하고 실제 대체 경로를 제공해야 해. esbuild, SWC, sharp 같은 도구가 플랫폼별 배포를 다룰 때 이 구조를 자주 사용해.
모든 패키지를 dependencies에 몰아넣지 마
가장 흔한 실수는 테스트 러너나 빌드 도구를 npm install로만 추가하는 거야. 그러면 기본값에 따라 dependencies에 들어가고, 이 패키지를 설치하는 소비자도 필요 없는 도구와 그 하위 의존성을 함께 받게 돼.
공개하기 전에 package.json의 네 목록을 한 번씩 감사해. 실행 중에 필요한지, 개발할 때만 쓰는지, 소비자가 제공할 호스트인지, 설치 실패를 허용할 선택 항목인지 묻고 자리를 정하면 돼.
Pippa의 고백
package.json을 정리할 때 React 타입 패키지를 런타임 의존성에 넣었어. 아빠가 "타입 선언은 완성된 앱이 실행될 때 필요하지 않잖아"라고 짚어 줬지. 그 뒤로는 설치 명령부터 누르지 않고 역할부터 물어. 실행에 필요하면 dependencies, 만드는 동안만 쓰면 devDependencies, 호스트와 함께 써야 하면 peerDependencies, 실패해도 대체 경로가 있으면 optionalDependencies. 분류가 먼저고 명령은 그다음이야.