Menu

package.json 완벽 정리: scripts, 의존성, 버전 규칙

package.json 안에는 뭐가 들어 있을까? 꼭 알아야 할 필드부터 scripts 동작 방식, ^와 ~가 결정하는 npm 설치 버전까지 한 번에 정리했습니다.

Node 프로젝트의 매니페스트 파일

모든 Node.js 프로젝트의 루트에는 package.json 파일이 있습니다. 평범한 JSON 파일이지만, 프로젝트의 이름과 버전, 어떤 패키지에 의존하는지, 어떤 명령어를 제공하는지 같은 핵심 정보를 담고 있죠. npm은 무슨 작업을 하든 항상 이 파일을 먼저 읽습니다. 이 파일을 지워버리면 npm은 이 프로젝트가 뭔지조차 알 수 없게 됩니다.

가장 빠르게 만드는 방법은 npm init 명령어입니다:

npm init -y

-y 플래그를 붙이면 질문을 건너뛰고 기본값으로 바로 생성됩니다. 그러면 대략 이런 파일이 만들어집니다:

{
  "name": "my-app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

기본 골격은 이 정도입니다. 지금 보이는 필드들은 그 자체로는 별로 하는 일이 없고, 의존성과 스크립트를 추가하기 시작하면 비로소 제 역할을 하기 시작합니다.

dependencies와 devDependencies 차이

실제로 일하는 필드는 사실상 두 개, dependenciesdevDependencies입니다. 둘 다 패키지 이름과 버전 범위를 매핑해 둔 객체 형태입니다.

index.js
Output
Click Run to see the output here.

이렇게 두 가지로 나누는 데는 분명한 이유가 있습니다. dependencies는 코드가 실행될 때 꼭 필요한 패키지고, devDependencies개발할 때만 필요한 패키지입니다. 테스트 러너, 린터, 빌드 도구, 타입 체커 같은 것들이죠. 다른 사람이 여러분의 패키지를 자기 프로젝트의 의존성으로 설치하면, npm은 dependencies만 내려받고 devDependencies는 건너뜁니다.

이 필드들은 npm이 알아서 관리해 줍니다. npm install express를 실행하면 dependencies에 한 줄이 추가되고, npm install --save-dev vitest를 실행하면 devDependencies에 추가됩니다. 직접 손으로 고칠 일은 거의 없습니다.

semver 버전 규칙: ^, ~, 그리고 고정 버전의 차이

^4.19.0 같은 버전 문자열은 정확한 버전이 아니라 버전 범위를 뜻합니다. npm은 semver 규칙을 따르는데, 버전을 MAJOR.MINOR.PATCH 세 자리로 나눕니다.

  • MAJOR가 올라가면 하위 호환성이 깨집니다.
  • MINOR가 올라가면 기능이 추가되지만 기존 동작은 그대로입니다.
  • PATCH가 올라가면 버그가 수정됩니다.

실제로 자주 마주치게 되는 연산자는 두 가지입니다.

"express": "^4.19.0"   // >= 4.19.0 이고 < 5.0.0  (4.19.0 이상의 모든 4.x.x)
"express": "~4.19.0"   // >= 4.19.0 이고 < 4.20.0 (4.19.0 이상의 모든 4.19.x)
"express": "4.19.0"    // 정확히 4.19.0

^는 npm이 패키지를 설치할 때 기본으로 붙여주는 기호예요. 마이너와 패치 업데이트까지는 호환된다고 믿고 허용하겠다는 뜻이죠. ~는 조금 더 보수적이라 패치 업데이트만 허용하고, 버전 숫자만 딱 적으면 그 버전에 완전히 고정됩니다.

함정은 여기 있어요. "내가 방금 설치한 버전"과 "범위가 허용하는 버전"은 별개라는 점이죠. 오늘 express@4.19.0을 설치했는데 한 달 뒤 팀원이 프로젝트를 설치하면, ^4.19.04.19.5로 해석될 수도 있거든요. 바로 이 지점에서 package-lock.json이 등장합니다. 실제로 어떤 버전이 설치됐는지 정확하게 기록해두기 때문에, 누가 설치하든 동일한 의존성 트리를 갖게 돼요. 꼭 커밋해 두세요.

scripts: 프로젝트의 명령어 모음

scripts 필드는 자주 쓰는 명령어에 단축키를 달아두는 공간이에요. 여기 등록해 두면 npm run <이름> 형태로 바로 실행할 수 있습니다:

index.js
Output
Click Run to see the output here.

npm scripts에 관해 알아둘 만한 포인트 몇 가지:

  • npm start, npm test를 비롯한 몇몇 이름은 run 없이도 바로 실행됩니다. 나머지는 전부 npm run <이름> 형태로 써야 해요.
  • 스크립트는 node_modules/.binPATH에 잡힌 셸에서 돌아갑니다. 그래서 설치된 패키지의 바이너리를 바로 호출할 수 있어요. vitest를 전역으로 깔지 않아도 "test": "vitest"가 잘 동작하는 이유죠.
  • 스크립트끼리 이어붙일 수도 있습니다. "build": "npm run lint && npm run compile"처럼요. &&는 "순서대로 실행하되 하나라도 실패하면 멈춰라"라는 뜻입니다.
  • pre<name>, post<name> 스크립트는 알아서 실행됩니다. 예를 들어 prebuild가 정의돼 있으면 build 직전에 자동으로 돌아가요. 별도로 연결해줄 필요가 없습니다.

스크립트는 말하자면 그 프로젝트의 명령어 진입점입니다. package.json만 잘 갖춰놓으면 새로 합류한 팀원이 저장소를 클론해서 npm install을 돌리고, 곧바로 npm run devnpm test를 실행해볼 수 있죠. 위키 문서를 뒤질 필요 없이요.

엔트리 포인트: main, exports, type

이 필드들은 Node(그리고 번들러)가 패키지를 어떻게 불러올지 알려주는 역할을 합니다.

index.js
Output
Click Run to see the output here.
  • type: .js 파일을 어떻게 해석할지 결정합니다. "module"로 두면 ESM(import / export) 방식이고, 생략하거나 "commonjs"로 지정하면 CommonJS(require) 방식으로 동작합니다. 자세한 내용은 CommonJS vs ESM 문서를 참고하세요.
  • main: 예전부터 쓰이던 엔트리 포인트입니다. require("my-lib")가 이 경로를 찾아갑니다. 구버전 도구들은 여전히 이 필드를 참고합니다.
  • exports: main을 대체하는 최신 방식으로, 훨씬 엄격하게 동작합니다. 소비자가 어떤 파일을 어떤 서브패스로 import할 수 있는지 명확히 정의하죠. 여기에 명시되지 않은 파일은 아예 import가 막히는데, 이건 버그가 아니라 의도된 동작입니다. 패키지의 공개 API를 직접 통제할 수 있다는 뜻이니까요.

패키지를 배포하는 게 아니라 그냥 앱을 만드는 중이라면, 이 중에서 신경 쓸 만한 건 사실상 type 하나뿐입니다.

실전 package.json 예시

지금까지 살펴본 내용을 모아보면, 작은 Node 앱의 package.json은 대체로 이런 모습입니다:

index.js
Output
Click Run to see the output here.

engines.node 필드도 한 번 눈여겨봐 두자. 강제성은 없고 권고 수준이지만, 사용자의 Node 버전이 맞지 않으면 npm이 경고를 띄우거나 (engine-strict 옵션을 켜둔 경우) 에러를 낸다. 공개할 패키지라면 꼭 챙겨두는 게 좋은 습관이다.

알아두면 좋은 필드들

실무에서 자주 마주치는 필드 몇 가지를 더 정리해 봤다.

  • private: true — npm에 실수로 패키지를 퍼블리시하는 사고를 막아준다. 공개할 목적이 아닌 프로젝트라면 무조건 켜두자.
  • license"MIT""ISC" 같은 SPDX 식별자를 쓴다. 공개 프로젝트라면 꼭 명시해야 한다.
  • repository, bugs, homepage — npm 레지스트리 페이지에 그대로 노출되는 정보들이다.
  • bin — CLI를 함께 배포하는 패키지라면, 명령어 이름과 실행 스크립트 파일을 여기서 매핑한다. 설치되고 나면 해당 명령어로 바로 실행할 수 있다.
  • workspaces — 모노레포 구성용. 하위 디렉터리를 연결된 패키지로 인식하도록 npm에 알려준다.

모든 필드를 다 쓸 필요는 없다. 지금 하는 일에 필요한 것만 골라 쓰면 된다.

자주 하는 실수들

사람들이 흔히 걸려 넘어지는 포인트 몇 가지.

  • node_modules를 커밋하는 것. 하지 말자. .gitignore에 넣어두면 된다. package.jsonpackage-lock.json만 있으면 누구든 npm install로 똑같이 복원할 수 있다.
  • package-lock.json을 커밋하지 않는 것. 이건 꼭 커밋해야 한다. 락파일이 없으면 semver 범위가 시간이 지남에 따라 다른 버전으로 풀릴 수 있어서, "내 컴퓨터에서는 되는데요" 상황이 현실이 된다.
  • 런타임 의존성을 devDependencies에 넣는 것. 로컬에서는 dev 의존성도 설치되니까 잘 돌아가다가, 프로덕션에서는 건너뛰니 갑자기 터진다. 배포되는 코드가 사용하는 모듈이라면 dependencies에 있어야 한다.
  • package.json에서 버전만 바꾸고 재설치하지 않는 것. 버전을 수정했다면 반드시 npm install을 다시 실행하자. 안 그러면 node_modules와 락파일이 점점 어긋난다.

다음: Node 런타임

package.json이 Node에게 이 프로젝트가 무엇인지 알려준다면, Node 런타임은 그걸 어떻게 실행할지를 결정한다. 모듈 해석 방식, 내장 모듈, 전역 객체, 그리고 내부에서 돌아가는 이벤트 루프까지. 이어지는 페이지에서 바로 그 얘기를 다룬다.

자주 묻는 질문

package.json은 어떤 역할을 하나요?

Node.js 프로젝트의 매니페스트 파일입니다. 프로젝트 이름과 버전, 어떤 패키지에 의존하는지, npm run으로 실행할 수 있는 스크립트, 엔트리 포인트나 모듈 타입 같은 메타데이터가 모두 여기에 기록돼요. npm install은 이 파일을 읽고 무엇을 다운로드할지 판단합니다.

dependencies와 devDependencies는 뭐가 다른가요?

dependencies는 런타임에 실제로 필요한 패키지예요. expressreact 같은 것들이죠. 반면 devDependencies는 개발하거나 빌드할 때만 쓰는 것들 — 테스트 러너, 번들러, 린터 같은 도구입니다. 다른 사람이 내 패키지를 의존성으로 설치하면 npm은 내 devDependencies는 건너뜁니다.

package.json 버전에서 ^와 ~는 무슨 뜻인가요?

semver 범위 연산자예요. ^1.2.31.2.3 이상의 모든 1.x.x 버전을 허용합니다(메이저 고정). ~1.2.3은 더 엄격해서 1.2.3 이상의 1.2.x만 허용하고요(마이너까지 고정). 아무 기호 없이 1.2.3만 쓰면 그 버전에 완전히 고정됩니다. 실제로 설치된 버전은 package-lock.json에 기록돼서 설치 결과가 매번 동일하게 재현됩니다.

package.json은 어떻게 만드나요?

빈 디렉터리에서 npm init을 실행하고 질문에 답하면 됩니다. 귀찮으면 npm init -y로 기본값을 받아 바로 생성할 수도 있어요. 어차피 그냥 JSON 파일이라 직접 손으로 작성해도 됩니다. 필수 필드는 nameversion 두 개뿐이에요.

Coddy로 코딩 배우기

시작하기