Context
프로젝트를 진행할 때 package.json이라는 파일을 자주 마주한다. 당연히 존재해야하는 파일임을 알지만 그 내부에 작성 되어있는 것들은 정확히 무슨 역할을 하는지 이해하지 못한다. 대부분의 경우 npm init이나 create-*와 같은 도구가 자동으로 생성해 준 package.json을 그대로 사용하고, 필요할 때마다 의존성을 추가하거나 스크립트를 복사해 붙이는 정도에 그친다. 이 글에서는 package.json을 의도적으로 의식하고 잘 관리하기 위해 package.json을 구성하고 있는 각 요소들과 그 것들의 역할이 무엇인지 알아본다.
Agenda
package.json의 요소를 알아보고 각각의 요소들이 무슨 역할을 하는지 항목별로 설명한다.
Content
먼저 nvm을 활용하여 Node를 설치한다. Node 설치는 이 글을 통해 설치한다. Node를 설치하면 기본 패키지 매니저인 npm(Node Package Manager)이 설치 된다. npm은 패키지 매니저 중 하나인데, 나는 pnpm을 사할 것이기 때문에 pnpm을 따로 설치 해주어야 한다.
pnpm은 npm의 단점을 개선한 package manager인데, npm과 pnpm의 차이점은 있어도 pnpm이 npm보다 무조건 좋다고는 할 수 없다. 하지만, npm의 라이브러리 설치 방식으로 인해 프로젝트 용량이 많이 늘어나는 것을 경험하고 pnpm을 사용하기로 결정 했다.
로컬에 Node를 설치하지 않고 nvm을 활용해 Node의 버전을 관리하는 것과 같이 package manager도 버전을 관리해주는 도구가 있다. 바로 Corepack이라는 도구인데, Node에 내장되어 있다. 이 것을 활용해 pnpm을 로컬에 설치하지 않고 corepack을 활용하여 설치한다.
•
여기서, Corepack은 package manager가 설치가 되는 장소의 개념이 아닌 버전을 관리해주는 도구의 개념이다.
chaminjae@macbookpro ~ % pnpm --version
zsh: command not found: pnpm
Shell
복사
pnpm이 설치 되었는지 버전을 확인해 본 결과 pnpm이 현재 설치되지 않은 것을 알 수 있다.
chaminjae@macbookpro ~ % corepack enable
chaminjae@macbookpro ~ % corepack prepare pnpm@latest --activate
Preparing pnpm@latest for immediate activation...
chaminjae@macbookpro ~ % pnpm --version
10.27.0
Shell
복사
먼저, corepack을 활성화하고 corepack prepare 명령어를 활용해 사용하고자 하는 pnpm의 버전을 --activate 플래그로 즉시 활성화 한다. 그 뒤, pnpm의 버전을 다시 확인해보면 정상적으로 pnpm이 환경에 제공 된다.
chaminjae@macbookpro ~ % which pnpm
/Users/chaminjae/.nvm/versions/node/v24.12.0/bin/pnpm
Shell
복사
pnpm이 연결 된 경로를 보면, nvm에서 설치한 node 안에 존재하는 것을 알 수 있다.
corepack prepare로 준비한 pnpm은 ~/.cache/node/corepack/v1/pnpm 경로에서 확인할 수 있다. nvm 내에 있는 Node의 corepack 을 사용하여 pnpm을 준비했기 때문에 nvm 폴더 내의 cache에 있을 줄 알았는데 로컬 홈 디렉토리 캐시 폴더 내에 있어서 예상과 달랐다.
chaminjae@macbookpro ~ % corepack disable
chaminjae@macbookpro ~ % pnpm --version
zsh: command not found: pnpm
chaminjae@macbookpro ~ % which pnpm
pnpm not found
Shell
복사
실제로 pnpm이 로컬이 아닌 corepack이 관리하고 있는지 확인하기 위해 corepack disable 명령어를 통해 비활성화 하고 다시 버전을 확인 해보면 pnpm이 존재하지 않고, 경로 또한 존재하지 않는 것을 알 수 있다. 따라서 corepack이 pnpm을 관리하고 로컬과의 의존성이 없다.
다시 corepack을 corepack enable 명령어로 활성화를 하고 pnpm@latest를 활성화 시킨다. 아마 이미 pnpm@latest 버전이 활성화 되어있을 것이다.
package.json 파일은 Node 기반의 프로젝트의 설정 파일로, 패키지 의존성을 관리하고 실행 규칙 등을 선언하는 파일이다. 현재 pnpm을 사용하고 있기에 pnpm 문서에서 제공하는 옵션을 살펴본다.
init 명령어와 옵션중 --bare 옵션을 통해 required 항목들만 포함한 package.json 을 생성한다.
chaminjae@macbookpro bp % pnpm init --bare
Wrote to <현재 경로>/package.json
{
"packageManager": "pnpm@10.27.0"
}
Shell
복사
생성 된 package.json의 내용을 보면 아래와 같다.
chaminjae@macbookpro bp % cat package.json
{
"packageManager": "pnpm@10.27.0"
}
Shell
복사
package.json 파일은 json 형태의 파일로, key: value 형태를 띄고 있다. 이하부터는 package.json에 들어가는 항목과 그 항목에 대한 설명을 나타낸다.
packageManager
packageManager는 이 프로젝트에서 사용할 패키지 매니저와 그 것의 정확한 버전을 명시한다. 이 값은 corepack이 읽어, 항상 동일한 패키지 매니저 버전이 사용되도록 강제한다.
현재 설치 되어있는 pnpm 버전: 10.0.0
프로젝트 A에서 요구하는 pnpm 버전: 10.27.0
Plain Text
복사
위와 같이 작업에 필요한 pnpm 버전이 다르다고 가정해보겠다. 나는 범용적으로 10.0.0 버전의 pnpm을 사용하는데, 프로젝트 A 에서는 10.27.0 버전의 pnpm을 요구한다.
일단, corepack prepare pnpm@10.0.0 --activate 명령어를 사용해 10.0.0 버전을 설치한 뒤 버전을 확인 해보면 다음과 같다.
chaminjae@macbookpro ground % corepack prepare pnpm@10.0.0 --activate
Preparing pnpm@10.0.0 for immediate activation...
chaminjae@macbookpro ground % pnpm --version
10.0.0
Shell
복사
이 과정까지 거치면 지금 현재 10.0.0 버전과 10.27.0 버전 두 개의 pnpm이 설치가 되어있다.
이후에 프로젝트 A의 경로로 이동하면 package.json의 packageManager가 pnpm@10.27.0으로 설정 되어있기 때문에 corepack이 이를 읽고 패키지 매니저 버전을 자동으로 전환한다.
chaminjae@macbookpro ground % cd bp
chaminjae@macbookpro bp % pnpm --version
10.27.0
Shell
복사
따로 corepack prepare의 명령어를 입력하지 않아도 된다.
만약, 설치되어 있지 않은 pnpm의 버전이라면 pnpm을 사용하고자 할 때 해당 버전을 다운로드 할 것이냐는 안내 문구가 나온다.
{
"packageManager": "pnpm@10.26.0"
}
JSON
복사
10.27.0에서 10.26.0으로 package.json 파일의 packageManager 항목 수정
chaminjae@macbookpro bp % pnpm --version
! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-10.26.0.tgz
? Do you want to continue? [Y/n]
Shell
복사
하지만, corepack pnpm 커맨드를 통해 pnpm을 사용하면 해당 버전을 강제로 다운로드 한다.
chaminjae@macbookpro bp % corepack pnpm --version
10.26.0
Shell
복사
이후 다시 프로젝트 A의 경로에서 탈출하면 pnpm의 버전은 내가 현재 범용으로 사용하고 있는 10.0.0 버전으로 자동으로 전환 된다.
chaminjae@macbookpro bp % pnpm --version
10.26.0
chaminjae@macbookpro bp % cd ..
chaminjae@macbookpro ground % pnpm --version
10.0.0
Shell
복사
여기서, corepack pnpm --version을 통해 자동으로 pnpm이 설치 되었다면, 현재 글로벌하게 사용 중인 pnpm 버전 또한 해당 버전으로 변경된다. 따라서 갑자기 글로벌 pnpm 버전이 달라질 수 있으니 주의. 버그인가?
pnpm --version을 통해 Y/n 선택 후 다운로드 → 글로벌 pnpm 버전 변경 안 됨
corepack pnpm --version을 통해 다운로드 → 글로벌 pnpm 버전 변경
name
name은 이름에서 알 수 있듯이 말 그대로 이 패키지가 불리는 이름이다. npm Docs에 따르면, 패키지를 배포할 계획이 있으면 package.json에서 가장 중요한 필드는 name과 version으로 이 두 필드는 필수로 작성 되어야 한다. name과 version의 조합은 패키지를 식별하는 고유한 ID 역할을 하며 패키지에 변경이 생기면 반드시 version도 함께 변경 되어야 한다.
하지만 패키지를 배포하지 않을 계획이면 name과 version 항목은 선택 사항이다.
name에는 입력할 수 있는 값의 규칙이 있는데 아래와 같다.
•
최대 214자로 scoped package인 경우 scope를 포함한 길이이다.
◦
Scoped package는 @scope/name과 같이 소속(네임스페이스)를 붙인 패키지이다.
•
Scoped package에서는 이름이 . 또는 _로 시작할 수 있으며, scope가 없으면 불가하다.
◦
@scope/util - 가능
◦
@scope/.util - 가능
◦
@scope/_util - 가능
◦
.util 또는 _util - 불가능
•
신규 패키지 이름에는 대문자 사용 불가능
•
패키지 이름은 URL, 명령줄 인수, 폴더 이름 등의 일부로 사용되기 때문에 URL에서 안전하지 않은 문자는 사용 불가
•
name을 작성하는 팁은 아래와 같이 안내되어 있다.
•
fs, path, http와 같은 Node의 코어 모듈 이름 사용 금지
•
이름에 js 또는 node를 넣지 말 것
◦
package.json을 쓰는 순간 이미 JavaScript 패키지이고 Node 버전을 명시해야 하는 경우 engines 필드로 지정할 수 있음
•
require() 인자로 쓰인다는 점을 고려하고 짧고, 설명적인 이름으로 작성할 것
require('@scope/util');
JavaScript
복사
추가로, npm registry 중복 확인을 통해 현재 사용 중인지 확인하고 scope 또한 적용할 수 있다.
version
name과 version은 패키지를 배포할 때 package.json에서 가장 중요한 항목이다. 이 두 필드는 필수이며, 함께 사용되어 완전히 고유한 식별자를 이룬다. 패키지에 변경 사항이 생기면, 그 버전은 반드시 version의 변경과 함께 이루어져야한다. 만약, 패키지의 배포 계획이 없다면 name과 version 필드는 선택 사항이다.
private
package.json에서 private가 true이면 npm은 해당 패키지의 배포를 거부한다. 이는 비공개 저장소가 실수로 배포되는 것을 방지하기 위한 안전장치이다. 만약 특정 패키지가 오직 하나의 특정 레지스트리에만 배포되도록 보장하고싶다면, 배포 시점에 사용되는 레지스트리 설정을 덮어쓰기 위해 publishConfig 객체를 사용해야 한다.
만약 사내용으로 패키지를 구현하였다. 이를 실수로 npm에 publish한 경우 되돌리기가 어렵고 내부용 패키지가 외부에 공개되는 결과를 낳는다. 코드 구조나 내부 로직이 노출되는 사고가 발생하기 때문에 monorepo 루트나 내부 앱에서는 사실상 필수로 true로 지정한다.
scripts
scripts 속성은 패키지의 라이프사이클 중 특정 시점에 실행될 스크립트 명령어들을 담는 객체이다. 각 키는 라이프사이클 이벤트를 의미하고, 각 값은 해당 시점에 실행할 명령어를 나타낸다. npm이 기본으로 제공하는 여러 내장 스크립트와 그에 대응하는 라이프사이클 이벤트를 지원하고, 사용자가 임의로 정의한 스크립트 또한 함께 사용할 수 있다. 그리고 이 스크립트들은 모두 다음 명령으로 실행할 수 있다.
$ npm run <stage>
Shell
복사
script의 key 값을 stage라고 칭하는 것 같다.
Node 기반의 구현물을 실행하거나 빌드와 같은 작업을 수행할 때 명령어를 입력한다. 그 명령어는 package.json의 scripts 항목에 명시 되어있고 특정 키워드가 키로, 해당 키의 값이 실제 명령어를 대신한다. 따라서 키는 명령어의 별칭이라고 생각하면 이해가 쉽다.
npm Docs의 scripts 항목을 디테일하게 설명해주는 docs를 살펴보면 알지 못했던 내용이 많아 새로운 내용을 보게 되면 신기하다. 먼저 pre / post 라이프사이클에 대한 설명이다. 아래와 같은 명령어가 있다고 가정 해보겠다.
{
"scripts": {
"hello": "echo Hello, World!"
}
}
JSON
복사
scripts 항목 안에 hello라는 stage가 있고 해당 stage는 Hello, World!라는 문자열을 출력한다.
chaminjae@macbookpro bp % pnpm run hello
> boilerplate@0.0.1 hello <현재 경로>/bp
> echo Hello, World!
Hello, World!
Shell
복사
그리고 hello stage 앞 뒤로 pre와 post를 붙인 stage를 추가한다. 단순히 stage 명 앞에 pre나 post를 붙이면 된다.
{
"scripts": {
"prehello": "echo Preparing to say hello...",
"hello": "echo Hello, World!",
"posthello": "echo Finished saying hello."
}
}
JSON
복사
다시 한 번 hello stage를 실행한다.
chaminjae@macbookpro bp % pnpm hello
> boilerplate@0.0.1 prehello <현재 경로>/bp
> echo Preparing to say hello...
Preparing to say hello...
> boilerplate@0.0.1 hello <현재 경로>/bp
> echo Hello, World!
Hello, World!
> boilerplate@0.0.1 posthello <현재 경로>/bp
> echo Finished saying hello.
Finished saying hello.
Shell
복사
결과를 보면 hello stage만 실행했음에도 불구하고 pre와 post가 붙은 stage 또한 앞/뒤로 자동으로 실행하는 것을 알 수 있다. Build 전에 lint / test, Deploy 전에 build와 같이 실행하고자 하는 스크립트 앞/뒤에 항상 실행해야 하는 작업을 강제로 묶어 자동으로 실행한다. 실행 순서는 다음과 같다.
pre<stage> - <stage> - post<stage>
Plain Text
복사
하지만 아래와 같이 한 명령어에 명시적으로 여러 명령을 묶어 사용하는 경우도 많다.
{
"scripts": {
"hello": "echo Preparing to say hello... && echo Hello, World! && echo Finished saying hello."
}
}
JSON
복사
engines
공식 문서에 따르면 engines 항목은 현재 이 패키지가 어느 버전의 node에서 동작하는지를 지정할 수 있게 한다.
{
"engines": {
"node": ">=15.0.0 <=24"
}
}
JSON
복사
위는 package.json에 engines 항목을 지정한 것이다. node를 15 이상, 24 이하의 버전만 사용할 수 있게 권장한다. 물론, >=24.0.0 <15와 같은 모순적인 범위도 설정할 수 있다. 만약 버전을 지정하지 않거나 *로 지정하는 경우 모든 버전의 Node가 작동한다.
Node 버전 뿐만 아니라 engines 필드를 활용하여 패키지를 적절히 설치할 수 있는 패키지 매니저 버전도 지정할 수 있다.
{
"engines": {
"node": ">=18.0.0 <=24",
"pnpm": ">=10.0.0"
}
}
JSON
복사
정확히 적용 됐는지 확인하기 위해 현재 Node 버전은 24.12.0이고 pnpm 버전은 10.27.0일 때 위의 설정을 다음과 같이 변경한다.
{
"engines": {
"node": ">=18.0.0 <24",
"pnpm": ">=10.0.0"
}
}
JSON
복사
Node를 18버전 이상, 24버전 미만 범위의 버전을 사용하게 설정하고 scripts에 있는 stage중 하나를 동작하면 Warning이 나타난다.
chaminjae@macbookpro bp % pnpm hello
WARN Unsupported engine: wanted: {"node":">=18.0.0 <24"} (current: {"node":"v24.12.0","pnpm":"10.27.0"})
> boilerplate@0.0.1 hello <현재 경로>/bp
> echo Preparing to say hello... && echo Hello, World! && echo Finished saying hello.
Preparing to say hello...
Hello, World!
Finished saying hello.
Shell
복사
Node 버전이 engines에 작성해 둔 범위와 맞지 않더라도 정상적으로 실행되는 것을 알 수 있다.
관리자나 사용자 입장에서는 engines에 설정해 둔 버전의 범위와 맞지 않으면 실행이 되지 않게끔 강제하고 싶을 것이다. 그럴 때 engine-strict 옵션을 사용한다.
package manager마다 그리고 그 버전마다 engine-strict 옵션을 설정하는 방법이 상이하다. pnpm 10버전 이상은 pnpm-workspace.yaml을 사용하여 engineStrict를 설정한다 관련 문서를 참고.
engineStrict: true
YAML
복사
pnpm-workspace.yaml을 생성하고 engineStrict를 true로 변경하면 engines 항목에 선언한 버전과 맞지 않을 때 Warning이 아닌 Error를 출력한다.
chaminjae@macbookpro bp % pnpm hello
ERR_PNPM_UNSUPPORTED_ENGINE Unsupported environment (bad pnpm and/or Node.js version)
Your Node version is incompatible with "<현재 경로>/bp".
Expected version: >=18.0.0 <24
Got: v24.12.0
This is happening because the package's manifest has an engines.node field specified.
To fix this issue, install the required Node version.
Shell
복사
위는 Node의 버전이 맞지 않을 때의 예시인데, 패키지매니저인 pnpm의 버전이 맞지 않을 때의 경우에도 적용이 되는지 확인 해보았다.
{
"engines": {
"node": ">=18.0.0 <=24",
"pnpm": "<10.0.0"
}
}
JSON
복사
Node의 버전 범위는 정상으로 돌리고 pnpm을 10버전 미만의 것만 사용할 수 있게 범위를 변경했다. 그리고 pnpm-workspace.yaml을 삭제해서 engineStrict를 제거했다.
chaminjae@macbookpro bp % pnpm hello
ERR_PNPM_UNSUPPORTED_ENGINE Unsupported environment (bad pnpm and/or Node.js version)
Your pnpm version is incompatible with "<현재 경로>/bp".
Expected version: <10.0.0
Got: 10.27.0
This is happening because the package's manifest has an engines.pnpm field specified.
To fix this issue, install the required pnpm version globally.
To install the latest version of pnpm, run "pnpm i -g pnpm".
To check your pnpm version, run "pnpm -v".
Shell
복사
pnpm의 경우에는 강제성이 없어도 에러를 뿜는 것을 확인할 수 있다. 그렇다면 engineStrict를 false로 하면 Warning이 나타나는지도 알아보고싶어서 실험 해보았다.
engineStrict: false
YAML
복사
pnpm-workspace.yaml의 engineStrict를 false로 설정
chaminjae@macbookpro bp % pnpm hello
ERR_PNPM_UNSUPPORTED_ENGINE Unsupported environment (bad pnpm and/or Node.js version)
Your pnpm version is incompatible with "<현재 경로>/bp".
Expected version: <10.0.0
Got: 10.27.0
This is happening because the package's manifest has an engines.pnpm field specified.
To fix this issue, install the required pnpm version globally.
To install the latest version of pnpm, run "pnpm i -g pnpm".
To check your pnpm version, run "pnpm -v".
YAML
복사
그래도 Warning이 아닌 Error가 나타나는 것을 알 수 있다.
흠, 공식 문서를 보면 pnpm은 pnpm-workspace.yaml 뿐만 아니라 .npmrc 파일에서도 설정을 가져온다고 나와있으니, .npmrc를 통해 engine-strict 옵션을 false로 지정한 뒤 다시 테스트 했다.
engine-strict=false
Plain Text
복사
그래도 같은 결과가 나오는 것을 확인할 수 있다.
chaminjae@macbookpro bp % pnpm hello
ERR_PNPM_UNSUPPORTED_ENGINE Unsupported environment (bad pnpm and/or Node.js version)
Your pnpm version is incompatible with "/Users/chaminjae/Desktop/camp/ground/bp".
Expected version: <10.0.0
Got: 10.27.0
This is happening because the package's manifest has an engines.pnpm field specified.
To fix this issue, install the required pnpm version globally.
To install the latest version of pnpm, run "pnpm i -g pnpm".
To check your pnpm version, run "pnpm -v".
Shell
복사
이 외에도 현재 10버전 미만의 pnpm이 다운로드 되어있지 않아 corepack prepare로 다운로드 한 뒤 packageManager 항목을 지우고 테스트를 해보아도 계속 에러가 나타나는 것을 확인할 수 있었다.
type
type 항목은 Node가 패키지 내의 .js 파일을 어떻게 해석해야 하는지를 정의한다. Node는 현재 위치에서 가장 가까운 package.json을 부모로 갖고 .js를 어떤 모듈 형식으로 해석할지를 정의한다.
여기서 가장 가까운 package.json은 하위 폴더로 탐색하지 않으며, 상위 폴더로 이동하면서 처음으로 찾은 package.json이다. node_modules 폴더나 디스크 루트에 도달하면 탐색을 종료한다.
type의 값으로는 module과 commonjs 두 종류가 있으며, type을 부여하지 않으면 commonjs가 기본값으로 설정 된다.
type이 module일 경우 ES Module로 처리
{
"type": "module"
}
JSON
복사
type이 commonjs일 경우 CommonJS로 처리
{
"type": "commonjs"
}
JSON
복사
예외도 존재한다. 파일의 확장자에 따라서 type이 강제로 결정 되는 경우 또는 package.json이 존재하지 않는 경우이다. package.json의 type 속성 값과 상관 없이 .mjs 또는 .cjs 확장자를 갖고있는 파일의 경우 type이 강제된다.
•
.mjs일 경우에는 항상 ES Module로 처리
•
.cjs일 경우에는 항상 CommonJS로 처리
•
.js일 경우 type 값에 따라 결정
•
볼륨 루트에 도달했지만 package.json이 존재하지 않는 경우 CommonJS로 처리
type이 module로 되어있는 상태에서 CommonJS 문법을 사용했을 때의 에러
// index.js
const fs = require("fs");
JavaScript
복사
chaminjae@macbookpro bp % node index.js
file://<현재 경로>/index.js:1
const fs = require("fs");
^
ReferenceError: require is not defined in ES module scope, you can use import instead
This file is being treated as an ES module because it has a '.js' file extension and '<현재 경로>/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
at file://<현재 경로>/index.js:1:12
at ModuleJob.run (node:internal/modules/esm/module_job:413:25)
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:660:26)
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v24.12.0
Shell
복사
type이 commonjs로 되어있는 상태에서 ES Module 문법을 사용했을 때의 에러
// index.js
import fs from 'fs';
JavaScript
복사
chaminjae@macbookpro bp % node index.js
(node:28308) Warning: Failed to load the ES module: <현재 경로>/index.js. Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
<현재 경로>/index.js:1
import fs from 'fs';
^^^^^^
SyntaxError: Cannot use import statement outside a module
at wrapSafe (node:internal/modules/cjs/loader:1692:18)
at Module._compile (node:internal/modules/cjs/loader:1735:20)
at Object..js (node:internal/modules/cjs/loader:1893:10)
at Module.load (node:internal/modules/cjs/loader:1481:32)
at Module._load (node:internal/modules/cjs/loader:1300:12)
at TracingChannel.traceSync (node:diagnostics_channel:328:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:245:24)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47
Node.js v24.12.0
Shell
복사
아래 경우에서는 에러가 나타나지 않는다.
•
type이 module일 때 .cjs 확장자 파일을 실행
•
type이 commonjs일 때 .mjs 확장자 파일을 실행