개발 기술/사소하지만 놓치기 쉬운 개발 지식

dependencies, devDependencies, peerDependencies는 번들 결과를 결정하지 않는다

by GicoMomg 2026. 6. 9.

0. 들어가며…

package.json에는 프로젝트 이름, 버전, 실행 스크립트, 빌드 설정 등 다양한 정보가 담겨 있다.

그중 dependencies, devDependencies, peerDependencies는 프로젝트가 사용하는 의존성을 설명하는 항목이다.

처음에는 이 항목들을 단순히 “설치된 라이브러리를 관리하는 곳” 정도로 이해하기 쉽다.

하지만 실제로 패키지를 설치하거나, 애플리케이션이 아닌 라이브러리를 만들다 보면 이런 질문이 생긴다.

이 패키지는 dependencies에 넣어야 할까?
devDependencies에 넣어야 할까?
peerDependencies에 넣어야 할까?

특히 라이브러리를 만들 때는 단순히 “설치했으니까 dependencies에 넣는다”로 끝나지 않는다.
이 패키지가 실행 시 필요한지, 개발할 때만 필요한지, 아니면 사용하는 프로젝트가 직접 제공해야 하는지에 따라 선언 위치가 달라진다.

{
  "dependencies": {},
  "devDependencies": {},
  "peerDependencies": {}
}

그런데 여기서 한 가지 더 헷갈리는 지점이 있다.
의존성을 구분해서 선언했는데, 실제 빌드 결과를 보면 예상과 다르게 동작하는 경우가 있다.

dependencies에 넣었는데 번들에 포함되지 않네?

devDependencies에 있는데 번들에 포함되네?

React를 peerDependencies로 선언했지만, React가 번들에 포함되네?

dependencies, devDependencies, peerDependencies를 구분했는데도
왜 번들 결과는 예상과 다르게 나올까?

그 이유는 dependencies, devDependencies, peerDependencies가 번들 결과를 직접 결정하는 설정이 아니기 때문이다.

세 가지 의존성 분류는 “무엇을 번들에 넣을지”를 결정하는 옵션이라기보다, 패키지와 프로젝트의 관계를 표현하는 메타데이터에 가깝다.

결국 번들 결과는 의존성 분류 자체가 아니라, 실제 코드의 import 구조와 번들러 설정에 의해 결정된다.

package.json 의존성 분류 번들러
이 패키지가 어떤 관계로 필요한지 설명한다. 실제 코드가 무엇을 import하는지 분석해 산출물을 만든다.



이번 글에서는 다음 질문들을 중심으로 살펴보려 한다.

1. 각 의존성 분류는 무엇을 의미할까?

2. 왜 이 분류가 번들 결과를 직접 결정하지 않을까?

3. React UI 라이브러리에서는 왜 peerDependencies만으로 충분하지 않을까?

4. 실무에서는 어떤 기준으로 의존성을 나누면 좋을까?






1. 의존성 분류를 먼저 이해하자

앞에서 살펴본 것처럼 dependencies, devDependencies, peerDependencies는 번들 결과를 직접 결정하는 설정이 아니다.

이 세 분류는 “무엇을 번들에 포함할지”를 정하는 옵션이라기보다, 패키지가 프로젝트와 어떤 관계를 맺고 있는지 설명하는 정보에 가깝다.

따라서 번들 결과를 이야기하기 전에, 먼저 각 분류가 실제로 무엇을 의미하는지부터 정리해보자.


1-1. 세 분류는 무엇을 설명할까?

package.json의 의존성은 보통 세 가지로 나뉜다.

{
  "dependencies": {},
  "devDependencies": {},
  "peerDependencies": {}
}

각 분류의 역할은 다음과 같다.

분류 역할 예시
dependencies 애플리케이션 실행에 필요한 패키지 react, vue, axios, zustand
devDependencies 개발·빌드·테스트·린트에 필요한 패키지 typescript, vite, webpack, eslint, vitest
peerDependencies 라이브러리가 직접 포함하지 않고, 사용하는 프로젝트가 직접 제공해야 하는 패키지 react, react-dom, vue, styled-components

짧게 정리하면 다음과 같다.

dependencies
→ 실행 중 필요한 패키지

devDependencies
→ 개발 과정에서 필요한 패키지

peerDependencies
→ 라이브러리 사용자가 직접 제공해야 하는 패키지

(1) dependencies

dependencies는 애플리케이션이 실행될 때 필요한 패키지다.

즉, 사용자가 서비스를 이용할 때 실제로 동작해야 하는 코드라면 dependencies에 둬야 한다.

예시
→ react, vue, axios, zustand, date-fns, lodash

예를 들어 앱 코드에서 axios로 API 요청을 보낸다면, axios는 런타임에 필요한 패키지다.

따라서 이런 패키지는 dependencies에 선언하는 것이 자연스럽다.


(2) devDependencies

devDependencies는 개발, 빌드, 테스트, 린트처럼 개발 과정에서 필요한 패키지다.

사용자가 서비스를 이용할 때 직접 실행되는 코드는 아니지만, 프로젝트를 만들고 검증하는 과정에서 필요한 도구들이 여기에 해당한다.

예시
→ typescript, vite, webpack, eslint, vitest, prettier

예를 들어 vite는 개발 서버를 실행하거나 빌드할 때 필요하다.

하지만 사용자가 브라우저에서 서비스를 이용하는 순간에 vite 자체가 실행되는 것은 아니다.

따라서 이런 도구성 패키지는 보통 devDependencies에 선언한다.


(3) peerDependencies

peerDependencies는 이 패키지를 사용하는 쪽에서 직접 설치해 제공해야 하는 패키지이다.

특히 애플리케이션보다 라이브러리를 만들 때 중요하다.

예시
→ react, react-dom, vue, styled-components

예를 들어 React UI 라이브러리를 만든다고 해보자.

이때 라이브러리 내부에 react를 포함해 배포하기보다, 사용자 프로젝트에 이미 설치된 React를 함께 사용하도록 만드는 경우가 많다.

그래서 React UI 라이브러리에서는 보통 react, react-dom을 peerDependencies로 선언한다.

{
  "peerDependencies": {
    "react": ">=18",
    "react-dom": ">=18"
  }
}

이 선언의 의미는 다음과 같다.

이 라이브러리는 react가 필요합니다.
하지만 react를 라이브러리 안에 직접 포함하지는 않습니다.
사용하는 프로젝트에서 react를 설치해 제공해주세요.

즉, peerDependencies는 라이브러리와 사용자 프로젝트가 동일한 패키지를 함께 사용해야 할 때 활용한다.

예를 들어 라이브러리마다 React를 각각 포함해 배포하면, 사용자 프로젝트 안에 React가 중복 설치되거나 서로 다른 React 인스턴스를 참조하는 문제가 발생할 수 있다.



1-2. peerDependencies와 external은 역할이 다르다

여기서 주의해야 할 점이 있다.

peerDependencies에 선언했다고 해서 해당 패키지가 자동으로 번들 결과물에서 제외되는 것은 아니다.

peerDependencies는 “이 패키지는 사용하는 프로젝트가 직접 설치해 제공해야 한다”는 의존성 관계를 표현하는 설정이다.

반면 external은 번들러에게 “이 패키지는 번들에 포함하지 말라”고 알려주는 설정이다.

peerDependencies
→ 사용자 프로젝트가 직접 제공해야 하는 패키지라고 선언한다.

external
→ 번들 결과물에서 제외할 패키지를 지정한다.

예를 들어 React UI 라이브러리에서 react와 react-dom을 peerDependencies로 선언했다고 해보자.

{
  "peerDependencies": {
    "react": ">=18",
    "react-dom": ">=18"
  }
}

이 설정은 “이 라이브러리를 사용하려면 프로젝트에 React가 필요하다”는 의미다.


하지만 이것만으로 번들러가 react와 react-dom을 자동으로 제외한다고 볼 수는 없다.
실제 번들 결과물에서 React를 제외하려면 번들러 설정에서 external도 함께 지정해야 한다.

// 예시: Rollup / Vite library mode

export default {
  build: {
    rollupOptions: {
      external: ["react", "react-dom"]
    }
  }
};

React UI 라이브러리를 만들 때는 보통 다음 두 가지를 함께 설정한다.

1. package.json의 peerDependencies에 react, react-dom 선언

2. 번들러 설정에서 react, react-dom을 external 처리

정리하면 peerDependencies와 external은 같은 역할을 하는 설정이 아니다.

peerDependencies는 패키지 사용 조건을 설명하는 설정이고, external은 실제 번들링 결과를 제어하는 설정이다.

만약 라이브러리를 만들 때는 “사용자 프로젝트가 제공해야 하는 패키지인지”와 “번들 결과물에서 제외해야 하는 패키지인지”를 나누어 생각해야 한다.

(참고로 최근에는 tsup, microbundle 같은 라이브러리 빌드 도구나 rollup-plugin-peer-deps-external 플러그인이 package.json의 peerDependencies를 읽어 자동으로 external 처리해주기도 함)






2. 그런데 번들 결과는 왜 예상과 다를까?

이제 처음에 봤던 의문으로 돌아가보자.

dependencies에 넣었는데 번들에 포함되지 않는다.

devDependencies에 있는데 번들에 포함된다.

peerDependencies로 선언했지만 React가 번들에 포함된다.

이런 상황이 발생하는 이유는 하나다.

dependencies, devDependencies, peerDependencies는 패키지의 역할과 관계를 설명하는 정보이지, 번들 결과를 직접 결정하는 설정이 아니기 때문이다.

번들러는 package.json의 의존성 분류보다 실제 코드의 import 구조와 번들러 설정을 기준으로 산출물을 만든다.

의존성 분류
→ 이 패키지가 프로젝트와 어떤 관계인지 설명한다.

번들러
→ 실제 코드가 무엇을 import하는지 분석해 산출물을 만든다.

2-1. dependencies에 있어도 import하지 않으면 번들에 들어가지 않는다

dependencies는 앱이 실행될 때 필요한 패키지를 의미한다.

예를 들어 앱 코드에서 axios로 API 요청을 보낸다면, axios는 런타임 의존성이다.

{
  "dependencies": {
    "axios": "^1.7.0"
  }
}
import axios from "axios";

export async function fetchUser() {
  const response = await axios.get("/api/user");
  return response.data;
}

하지만 dependencies에 선언되어 있어도 실제 코드에서 import하지 않는다면 번들러는 해당 패키지를 따라갈 이유가 없다.

console.log("hello");

즉, 설치되어 있다는 사실만으로 번들에 포함되는 것은 아니다.

dependencies에 있음
≠ 번들 포함 보장

2-2. devDependencies에 있어도 import하면 번들에 들어갈 수 있다

반대로 devDependencies에 있는 패키지도 런타임 코드에서 import하면 번들에 포함될 수 있다.

{
  "devDependencies": {
    "lodash": "^4.17.21"
  }
}
import debounce from "lodash/debounce";

이 경우 번들러는 lodash/debounce를 실제 사용되는 모듈로 보고 의존성 그래프에 포함할 수 있다.

devDependencies에 있음
≠ 번들 제외 보장

다만 이 경우는 의존성 분류가 잘못된 것이다.

런타임 코드에서 사용하는 패키지는 dependencies에 두는 것이 맞다.


여기서 한 가지 구분해야 할 점이 있다.

Vite나 webpack으로 빌드하는 순수 프론트엔드 SPA라면, 배포 서버에 node_modules가 그대로 올라가지 않는다.
보통 로컬 또는 CI 환경에서 의존성을 설치한 뒤 빌드하고, 운영 환경에는 dist 같은 정적 결과물만 배포한다.

그래서 프론트엔드 SPA에서는 런타임 코드가 실수로 devDependencies의 패키지를 import하더라도,
빌드가 성공했다면 운영 서버에서 곧바로 Cannot find module 오류가 발생하지 않을 수 있다.

하지만 Node.js 서버 애플리케이션, SSR 환경, 또는 라이브러리 배포에서는 이야기가 달라진다.
예를 들어 서버 애플리케이션(ex. Express)이나 SSR 환경에서 운영 배포 시 다음과 같이 개발 의존성을 제외하고 설치할 수 있다.

npm install --omit=dev   // dev 유형을 제외하고 설치하는 명령어

이때 런타임에 필요한 패키지가 devDependencies에만 있다면,
로컬에서는 동작하더라도 운영 환경에서는 모듈을 찾지 못하는 오류가 발생할 수 있다.

Cannot find module 'axios'

즉, 의존성 분류는 번들 결과를 직접 결정하지는 않지만, 설치 환경과 배포 안정성에는 영향을 준다.


2-3. peerDependencies에 있어도 external 설정이 없으면 번들에 들어갈 수 있다

peerDependencies는 라이브러리를 사용하는 프로젝트가 직접 제공해야 하는 패키지를 선언한다.

예를 들어 React UI 라이브러리라면 보통 react, react-dom을 peerDependencies로 선언한다.

{
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  }
}

이 선언의 의미는 다음과 같다.

이 라이브러리는 React가 필요하다.
하지만 React를 라이브러리 안에 직접 포함하지는 않는다.
React는 이 라이브러리를 사용하는 앱이 제공해야 한다.

하지만 peerDependencies 선언만으로 번들러가 React를 자동으로 제외하는 것은 아니다.

React를 실제 번들 결과물에서 제외하려면 번들러 설정에서 external 처리를 해야 한다.

export default {
  external: ["react", "react-dom"]
};

정리하면 다음과 같다.

peerDependencies
→ 사용자가 직접 설치해야 한다고 선언한다.

external
→ 번들러가 실제 번들에서 제외하도록 설정한다.

따라서 React UI 라이브러리를 만들 때는 peerDependencies 선언과 external 설정을 함께 확인해야 한다.






3. 실제 번들 결과는 무엇이 결정할까?

앞에서 본 사례들은 모두 같은 이유로 발생한다.

번들 결과는 dependencies, devDependencies, peerDependencies 중 어디에 선언했는지가 아니라,
코드가 무엇을 import하고 번들러가 그것을 어떻게 처리하는지에 의해 결정된다.

번들러가 주로 보는 기준은 다음과 같다.

실제 번들 결과를 결정하는 것
├─ import graph
│  └─ 코드에서 실제로 import 되었는가?
│
├─ external
│  └─ 번들에서 제외하도록 설정했는가?
│
├─ tree-shaking
│  └─ 사용하지 않는 코드를 제거할 수 있는가?
│
└─ code splitting
   └─ 하나의 번들이 아니라 별도 청크로 분리되는가?

예를 들어 다음 코드가 있다고 해보자.

import React from "react";
import axios from "axios";
import { Button } from "@my/ui";

번들러는 entry 파일에서 시작해 import 관계를 따라간다.

main.ts
├─ react
├─ axios
└─ @my/ui

이 연결 구조를 보통 import graph, 또는 의존성 그래프라고 볼 수 있다.

여기서 중요한 질문은 “이 패키지가 package.json의 어느 칸에 있는가?”가 아니다.

번들러 입장에서는 다음 질문이 더 중요하다.

이 모듈이 실제 코드에서 import 되었는가?
external로 제외되어 있는가?
사용하지 않는 코드는 제거 가능한가?
별도 청크로 분리되는가?

따라서 번들 결과를 확인할 때는 관점을 나누어 봐야 한다.

보고 싶은 것 확인할 기준
이 패키지가 왜 필요한가? dependencies, devDependencies, peerDependencies
이 패키지가 번들에 들어가는가? import graph, external, 번들러 설정
코드 일부가 제거될 수 있는가? tree-shaking, sideEffects
어느 파일에 포함되는가? code splitting, chunk 설정






4. 앱 프로젝트와 라이브러리 프로젝트는 기준이 다르다

의존성 분류는 앱 프로젝트와 라이브러리 프로젝트에서 조금 다르게 판단해야 한다.

앱 프로젝트에서는 주로 다음 질문이 중요하다.

이 패키지가 앱 실행 중 필요한가?

반면 라이브러리 프로젝트에서는 질문이 하나 더 늘어난다.

이 패키지를 내 라이브러리 번들에 포함해야 할까?
아니면 사용하는 프로젝트가 제공하게 해야 할까?

즉, 앱 프로젝트는 실행 환경을 기준으로 판단하고,
라이브러리 프로젝트는 소비자 프로젝트와의 관계까지 함께 고려해야 한다.


4-1. 앱 프로젝트의 기준

앱 프로젝트라면 기준은 비교적 단순하다.

실제 앱 실행 중 필요한가?
→ dependencies

개발, 빌드, 테스트, 린트에만 필요한가?
→ devDependencies

예를 들어 앱 코드에서 axios를 사용한다면 axios는 dependencies에 두는 것이 자연스럽다.

반대로 vite, typescript, eslint, vitest처럼 개발 과정에서만 필요한 도구는 devDependencies에 둔다. 그리고 React나 Vue처럼 앱 자체를 실행하는 핵심 런타임도 앱 프로젝트에서는 dependencies에 두어야 한다.

{
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

앱 자체가 React 에서 실행되기 때문이다.

다만 앞에서 본 것처럼 dependencies에 둔다고 해서 자동으로 번들에 포함되는 것은 아니다.

번들 포함 여부는 실제 코드의 import 구조와 번들러 설정에 의해 결정된다.


4-2. 라이브러리 프로젝트의 기준

라이브러리 프로젝트에서는 한 가지 질문을 더 해야 한다.

이 패키지를 내 라이브러리 내부에 포함할 것인가?
아니면 사용자 프로젝트가 제공하게 할 것인가?

예를 들어 React UI 라이브러리를 만든다면 React는 보통 사용자 앱과 공유해야 하는 핵심 런타임이다

React를 라이브러리 번들에 함께 포함하면 사용자 앱의 React와 라이브러리 내부 React가 나뉠 수 있다.

사용자 앱
├─ react@19
└─ @my/ui
   └─ react@19

이 예시는 같은 버전의 React를 사용하고 있지만, 번들 안에서 서로 다른 React 인스턴스로 포함되면 문제가 될 수 있다.

React의 Hook 규칙 오류인 Invalid hook call은 단순히 React 버전이 다를 때만 발생하는 문제가 아니다.

같은 버전이라도 앱과 라이브러리가 동일한 React 인스턴스를 공유하지 않으면 문제가 발생할 수 있다.


물론 버전까지 다르다면 문제 가능성은 더 커진다.

사용자 앱
├─ react@18
└─ @my/ui
   └─ react@19

즉, 핵심은 “버전이 같은가?”만이 아니다.
앱과 라이브러리가 같은 React 인스턴스를 공유하고 있는지가 중요하다.


그래서 React UI 라이브러리에서는 보통 react, react-dom을 peerDependencies로 선언한다.
추가로, 로컬 개발과 테스트를 위해 같은 패키지를 devDependencies에도 둘 수 있다.

{
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  },
  "devDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

마지막으로 실제 번들에서 React를 제외하려면 external 설정이 필요하다.

export default {
  external: ["react", "react-dom"]
};

라이브러리 프로젝트에서는 다음 기준으로 나눌 수 있다.

구분 역할
dependencies 라이브러리 내부 구현에 실제로 필요한 런타임 유틸
devDependencies 개발, 빌드, 테스트에 필요한 도구
peerDependencies 사용자 앱과 공유해야 하는 핵심 런타임
external 번들 결과물에서 제외해야 하는 패키지






5. 마치며

dependencies, devDependencies, peerDependencies는 이름 때문에 번들 결과와 직접 연결해서 생각하기 쉽다.

하지만 이 세 분류는 무엇을 번들에 포함할지 결정하는 설정이 아니다.

이들은 패키지가 프로젝트와 어떤 관계로 필요한지를 설명한다.

dependencies
→ 애플리케이션 실행에 필요한 패키지

devDependencies
→ 개발, 빌드, 테스트, 린트에 필요한 패키지

peerDependencies
→ 라이브러리 사용자가 직접 제공해야 하는 패키지

반면 실제 번들 결과는 의존성 분류가 아니라, 코드의 import 구조와 번들러 설정에 의해 결정된다.

번들 포함 여부를 결정하는 것
├─ import graph
├─ external 설정
├─ tree-shaking
├─ code splitting
└─ bundler config

결국 핵심은 다음과 같다.

dependencies, devDependencies, peerDependencies는
**번들 포함 여부를 결정하는 설정이 아니라, 패키지가 어떤 관계로 필요한지를 설명하는 선언이다.

특히 React UI 라이브러리나 디자인 시스템을 만든다면 이 차이를 명확히 이해해야 한다.

react를 peerDependencies에 넣었다고 해서 자동으로 번들에서 제외되는 것은 아니다.

React 중복 번들을 막으려면 peerDependencies 선언과 함께 번들러의 external 설정까지 확인해야 한다.


반응형

댓글