프로젝트를 생성할 때, 일부 작업을 분산시키기 위해 종종 npm을 통해 서드 파티 패키지를 설치하곤 합니다. 이와 관련하여 의존성에는 크게 두 가지 유형, 즉 dependencies
(prod)와 devDependencies
(dev)가 있습니다. package.json
은 일반적으로 다음과 같을 것입니다.
{
"name": "my-cool-vue-components",
"dependencies": {
"vue": "^3.5.15"
},
"devDependencies": {
"eslint": "^9.15.0"
}
}
둘의 가장 큰 차이점은 devDependencies
는 빌드나 개발 단계에서만 필요하지만, dependencies
는 프로젝트 실행을 위해 필수적이라는 점입니다. 예를 들어, 위 예제의 eslint
는 소스 코드를 린트하는 역할만 하기 때문에 프로젝트를 게시하거나 프로덕션 환경에 배포할 때는 필요하지 않습니다.
dependencies
및 devDependencies
라는 개념은 원래 Node.js 라이브러리(npm에 게시된 라이브러리)를 작성하기 위해 도입되었습니다. vite
같은 패키지를 설치할 때, npm은 자동으로 vite의 dependencies
를 설치하지만 devDependencies
는 설치하지 않습니다. vite
를 의존성으로만 활용하고 vite
를 개발하는 데 사용된 도구는 필요하지 않기 때문입니다. 만약에 vite
를 개발하는 과정에서 prettier
가 사용되었다고 해도, 프로젝트에 vite
만 필요한 경우에는 굳이 prettier
를 설치하지 않아도 됩니다.
생태계가 발전함에 따라 그 어느 때보다 복잡한 프로젝트를 구축할 수 있게 되었습니다. 풀 스택 웹사이트 구축을 위한 메타 프레임워크가 마련되어 있고, 코드와 의존성을 트랜스파일링, 번들링하기 위한 번들러도 있습니다. Node.js는 단순히 서버 사이드에서 자바스크립트 코드와 패키지를 실행하는 것 이상의 의미를 갖게 되었습니다.
저는 프로젝트를 크게 세 가지 유형으로 분류합니다.
package.json
은 주로 의존성 정보를 추적하며, 앱 자체는 npm에 게시되지 않습니다.기본적으로 dependencies
와 devDependencies
를 구분하는 것은 npm에 게시하기 위한 라이브러리(Libraries)에서만 의미가 있습니다. 그러나 다양한 시나리오와 사용 패턴으로 인해 그 의미가 원래의 목적을 넘어 훨씬 더 확장되었습니다.
여러 도구들은 상황에 따라 dependencies
와 devDependencies
를 원래 의미보다 더 넓게 해석해서 사용합니다. 덕분에 사용자는 복잡한 설정 없이도 합리적인 기본값을 사용할 수 있고, 개발자 입장에서도 더 편리한 작업환경이 만들어집니다.
예를 들어, Vite
는 dependencies
를 "클라이언트 측 패키지"로 간주하고 자동으로 사전 최적화를 실행합니다. tsup
, unbuild
그리고 tsdown
과 같은 빌드 도구는 dependencies
를 외부화하여 번들에 제외하고, dependencies
에 나열되지 않은 것들은 모두 자동으로 번들에 포함시킵니다.
이러한 규칙들은 대체적으로 작업을 단순화하는데 도움을 주지만, dependencies
와 devDependencies
에 여러 의미를 덧씌우게 되어, 각 패키지의 실제 용도를 불분명하게 만듭니다.
devDependencies
에 vue
가 있다면 이는 다음과 같은 의미들을 가집니다.
vue
를 인라이닝/번들링합니다.vue
의 타입만 참조합니다.vue
를 테스트를 위해서만 사용합니다.vue
를 IDE IntelliSense를 활성화하는 데 사용합니다.외부 문서 없이 패키지를 단순히 dependencies
또는 devDependencies
로 분류하는 것만으로는 용도를 완전히 파악할 수 없습니다(게다가 package.json
은 주석을 지원하지 않습니다).
잠시 dependencies
와 devDependencies
를 제쳐두고, 의존성을 어떻게 분류할 수 있을지 생각해 봅시다. 저의 대략적인 아이디어는 다음과 같습니다.
test
: 테스트에 사용되는 패키지들 (ex. vitest
, playwright
, msw
)lint
: 린트/포맷팅에 사용되는 패키지들 (ex. eslint
, knip
)build
: 프로젝트 빌드에 사용되는 패키지들 (ex. vite
, rolldown
)script
: 스크립트 작업에 사용되는 패키지들 (ex. tsx
, tinyglobby
, cpx
)frontend
: 프런트엔드 개발에 사용되는 패키지들 (ex. vue
, pinia
)backend
: 백엔드 서버에 사용되는 패키지들types
: 타입 체크와 정의에 사용되는 패키지들inlined
: 최종 번들에 직접적으로 포함되는 패키지들prod
: 프로덕션 런타임 의존성분류 방법은 프로젝트마다 조금씩 다를 것입니다. 그러나 중요한 점은 dependencies
와 devDependencies
는 이 정도 수준의 세부 내용을 파악할 수 있는 유연성이 부족하다는 것입니다.
즉각적으로 해결할 필요가 있는 중대한 문제는 아니지만, 이 문제는 한동안 저를 괴롭혔습니다. 그러던 와중에 pnpm이 카탈로그(catalogs)를 도입하면서 이전에는 없던 의존성 분류의 새로운 가능성이 열렸습니다.
PNPM 카탈로그는 중앙화된 관리 위치를 통해 모노레포 워크스페이스에서 서로 다른 패키지 간에 의존성 버전을 공유할 수 있도록 도와주는 기능입니다.
기본적으로, pnpm-workspace.yaml
파일에 catalog
또는 catalogs
를 추가하고 package.json
에서 catalog:<name>
를 통해 이를 참조합니다.
# pnpm-workspace.yaml
catalog:
vue: ^3.5.15
pinia: ^2.2.6
cac: ^6.7.14
// package.json
{
"dependencies": {
"vue": "catalog:",
"pinia": "catalog:",
"cac": "catalog:"
}
}
또는 아래와 같은 식으로 Named 카탈로그를 활용할 수도 있습니다.
# pnpm-workspace.yaml
catalogs:
frontend:
vue: ^3.5.15
# 모종의 이유로 버전을 고정함
pinia: 2.2.6
prod:
cac: ^6.7.14
// package.json
{
"dependencies": {
"vue": "catalog:frontend",
"pinia": "catalog:frontend",
"cac": "catalog:prod"
}
}
설치 및 게시하는 동안, pnpm은 카탈로그에 명시된 버전에 대한 의존성을 자동으로 해결합니다. Named 카탈로그는 원래 모노레포 내에서 버전 일관성을 관리하기 위해 고안되었으나, 의존성을 분류하는 데도 잘 활용할 수 있다는 것을 발견했습니다. 위 예제에서 언급했듯이 vue
와 cac
은 모두 dependencies
내에 있지만 서로 다른 카탈로그로 분류할 수 있습니다. 이를 통해 버전 업그레이드를 쉽게 진행할 수 있고 의존성 변경도 쉽게 파악할 수 있습니다.
팁: 팀원들과 추가 맥락을 공유하기 위해
pnpm-workspace.yaml
에 주석을 사용할 수 있습니다.
카탈로그가 비교적 새로운 기능이라는 걸 고려했을 때, 앞으로 도구 지원을 확대할 필요가 있습니다. 이와 관련하여 제가 겪은 가장 큰 어려움은 catalog:<name>
을 사용할 때 package.json
에서 의존성을 한눈에 볼 수 없다는 점이었습니다.
이를 해결하기 위해, 저는 package.json
내에 버전을 인라인으로 표시하는 PNPM Catalog Lens라는 VSCode 확장 앱을 개발했습니다.
또한, named category를 쉽게 식별할 수 있도록 서로 다른 색깔로 표시하는 기능을 추가했습니다. 이를 통해 DX를 해치지 않으면서 의존성 분류 및 중앙화된 버전 관리를 수행할 수 있습니다.
버전이 pnpm-workspace.yaml
로 옮겨지므로, 이를 지원하기 위해 CLI 도구도 몇 가지 통합을 수행해야 합니다. 지금까지는 다음과 같은 도구를 적용했습니다.
taze
: 의존성 버전을 확인하고 올리며, 현재는 카탈로그에서 버전을 읽고 업데이트하는 기능도 지원합니다.eslint-plugin-pnpm
: auto-fix 기능을 통해 package.json
내의 모든 의존성이 카탈로그를 사용할 수 있도록 강제합니다.@anfu/eslint-config
를 사용하신다면, pnpm: true
로 설정하여 이 기능을 활성화할 수 있습니다.pnpm-workspace-yaml
: 주석과 포맷팅을 보존하면서 pnpm-workspace.yaml
을 읽고 쓸 수 있는 유틸리티 라이브러리입니다.node-modules-inspector
: node_modules
를 시각화하여, catalog 이름으로 의존성에 레이블을 지정하여 출처를 더 잘 파악할 수 있습니다. nip
: 카탈로그에 패키지를 설치할 수 있는 대화형 CLI입니다.현재로서는 의존성을 분류하는 주된 가치는 팀 간 소통을 원활하게 하고, 버전 업그레이드 검토를 더 쉽게 만드는 데 있다고 생각합니다. 이 방식이 더 널리 쓰이고 도구들의 지원도 향상되면, 이러한 정보를 도구와 더 깊이 통합해 활용할 수도 있을 것입니다.
예를 들어, Vite에서 의존성 최적화에 대한 통제권을 좀 더 명시적으로 확보하여 이를 dependencies
와 devDependencies
필드로부터 분리할 수 있습니다.
// vite.config.ts
import { readWorkspaceYaml } from 'pnpm-workspace-yaml'
import { defineConfig } from 'vite'
const yaml = await readWorkspaceYaml('pnpm-workspace.yaml') // pseudo-API
export default defineConfig({
optimizeDeps: {
include: Object.keys(yaml.catalogs.frontend)
}
})
유사하게, unbuild
의 경우 여러 군데에서 수동으로 리스트를 관리할 필요 없이 외부화와 인라이닝을 제어할 수 있습니다.
// build.config.ts
import { readWorkspaceYaml } from 'pnpm-workspace-yaml'
import { defineBuildConfig } from 'unbuild'
const yaml = await readWorkspaceYaml('pnpm-workspace.yaml')
export default defineBuildConfig({
externals: Object.keys(yaml.catalogs.prod),
rollup: {
inlineDependencies: Object.keys(yaml.catalogs.inlined)
}
})
린팅 또는 번들링의 경우, 백엔드 패키지를 프런트엔드 코드로 가져오려고 할 때 에러를 던지는 등, 카탈로그에 기반한 규칙을 강제하여 번들링 실수를 방지할 수 있습니다.
또한, 카테고리 분류는 취약점 보고에 유용한 컨텍스트도 제공할 수 있습니다. 빌드 도구 내의 취약성은 프로덕션에서 발생하는 취약성보다 비교적 덜 심각할 것입니다.
이 외에도 여러가지가 있습니다.
이미 제가 진행한 많은 프로젝트(ex. node-modules-inspector
)에서 named 카탈로그를 사용하도록 마이그레이션 했습니다. 모노레포가 아니더라도 의존성 분류 기능은 pnpm 카탈로그를 채택해야 하는 강력한 이유입니다. 다만, 아직은 모범 사례를 탐색하고 도구 지원을 개선하는 단계라고 생각합니다.
이것이 이 글을 작성한 이유입니다. 여러분들께 이 접근 방식을 고려하고 시도해보길 권장하기 위해서죠. 여러분의 생각과 활용 방법에 대해 듣고 싶습니다. 이와 같은 패턴이 더 많이 등장하여 더 나은 DX를 통해 유지보수하기 쉬운 프로젝트를 구축하는 데 도움이 되기를 기대합니다. 읽어주셔서 감사합니다!
프로젝트 생성 시 npm을 통해 서드 파티 패키지를 설치하는데, 이에는 dependencies (프로덕션 필수)와 devDependencies (개발/빌드용) 두 가지 유형이 있습니다. 이 구분은 원래 npm에 게시되는 라이브러리를 위한 것이었지만, 오늘날에는 웹사이트(Apps)나 모노레포 내부 패키지(Internal) 등 다양한 프로젝트 유형에서 그 의미가 확장되어 사용되고 있습니다. cat language translator app