패키지 의존성 설계 전략

npmdependenciessemantic-versioningpackage-management

1. 들어가며

npm install은 모든 JavaScript 프로젝트의 시작점이지만, 이 명령어가 의존성을 어떻게 해석하고 구성하는지 깊이 이해하는 것은 별개의 문제다. 특히 여러 프로젝트에서 공유되는 내부 라이브러리를 설계할 때, 의존성을 선언하는 방식의 작은 차이가 전체 애플리케이션의 안정성을 좌우할 수 있다.

이 글에서는 내부 UI 라이브러리를 개발하는 구체적인 시나리오를 통해, dependenciespeerDependencies의 잘못된 사용이 어떻게 심각한 런타임 에러로 이어지는지 npm ls와 실제 코드로 진단하고 해결하는 기술적 과정을 심층적으로 다뤄보려고 한다.

2. 의존성 유형의 기술적 구분과 사례

package.json의 의존성은 크게 세 가지로 나뉘며, 각기 다른 목적을 가진다.

  • dependencies: 애플리케이션의 런타임에 직접적으로 필요한 패키지.
  • devDependencies: 개발 과정에만 필요한 패키지.
  • peerDependencies: 패키지가 직접 소유하지 않고, 호스트 프로젝트가 제공해야 하는 '동료' 의존성.

devDependencies의 역할: 타입스크립트 컴파일러 충돌 방지

devDependencies의 가장 중요한 역할은 라이브러리를 소비하는 프로젝트에 불필요한 의존성을 전파하지 않는 것이다. 예를 들어, 타입스크립트로 작성된 라이브러리가 typescript를 dependencies에 포함했다고 상상해 보자.

// my-library/package.json (잘못된 예시)
{
  "dependencies": {
    "typescript": "5.3.0" // X
  }
}

3. 내부 UI 라이브러리와 '중복 인스턴스' 문제

대부분의 심각한 문제는 dependenciespeerDependencies를 혼동하는 데서 시작된다.

1. 잘못된 설계: dependencies에 react 선언하기

여러 프로젝트에서 공통으로 사용하는 @my-company/ui-kit 이라는 내부 UI 라이브러리를 개발한다고 가정해 보자.

  • @my-company/ui-kit/package.json (잘못된 예시)
{
  "name": "@my-company/ui-kit",
  "version": "1.0.0",
  "dependencies": {
    "react": "^18.2.0"
  }
}

이제 이 라이브러리를 사용하는 메인 애플리케이션 my-app이 있다.

  • my-app/package.json
{
  "name": "my-app",
  "dependencies": {
    "react": "^18.3.1",
    "@my-company/ui-kit": "1.0.0"
  }
}

2. 문제 진단 (1): npm ls로 숨겨진 중복 찾기

npm install 후, npm ls react 명령어로 실제 설치된 React 의존성 트리를 확인해 보면 문제의 원인이 드러난다.

$ npm ls react
 
my-app@1.0.0 /path/to/project
├─┬ @my-company/ui-kit@1.0.0
 └── react@18.2.0  # (2) ui-kit이 설치한 React
└── react@18.3.1      # (1) my-app이 설치한 React

node_modules 안에 두 개의 다른 React 버전이 설치되었다.

3. 문제 진단 (2): 코드로 실패 확인하기 (Context API)

두 개의 React 인스턴스가 공존할 때 발생하는 대표적인 문제는 Context API의 불일치다. my-app에서 제공한 ThemeProvider의 Context를 ui-kit 내부의 컴포넌트가 소비하지 못하는 현상을 코드로 확인해 보자.

  • my-app/src/App.js
import React, { createContext } from 'react';
import { Button } from '@my-company/ui-kit';
 
// 1. my-app의 React 인스턴스가 Context를 생성
export const ThemeContext = createContext('light');
 
export default function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Button />
    </ThemeContext.Provider>
  );
}
  • @my-company/ui-kit/src/Button.js
import React, { useContext } from 'react';
// 가정: 이 파일의 'react'는 ui-kit의 node_modules에 있는 react@18.2.0을 참조한다.
 
// App.js의 ThemeContext와 다른 참조를 가질 수 있다.
import { ThemeContext } from 'my-app/src/App'; 
 
export function Button() {
  // 3. ui-kit의 React 인스턴스가 useContext를 호출
  // 두 React 인스턴스가 달라 Context를 찾지 못하고 기본값 'light'를 반환
  const theme = useContext(ThemeContext);
  return <button>Current theme is: {theme}</button>; // "Current theme is: light"
}

App에서는 분명히 "dark" 테마를 제공했지만, Button 컴포넌트는 이를 받지 못하고 기본값인 "light"를 렌더링한다. "Invalid hook call" 에러 역시 이러한 근본 원인에서 비롯된다.

4. 올바른 설계: peerDependencies로 의존성 주체 전환

이 문제의 해결책은 ui-kit이 react를 직접 소유하는 것을 포기하고, react의 설치 책임을 소비자인 my-app에게 위임하는 것이다.

  • @my-company/ui-kit/package.json (올바른 예시)
{
  "name": "@my-company/ui-kit",
  "version": "1.0.0",
  "peerDependencies": {
    "react": ">=17.0.0"
  }
}

이제 ui-kit은 "나는 React 17 이상 버전과 함께 동작하도록 설계되었으니, 나를 설치하는 프로젝트가 해당 버전의 React를 반드시 제공해야 한다"고 선언한다.

재설치 후 의존성 트리를 확인하면, 프로젝트 전체에 단 하나의 react@18.3.1 인스턴스만 존재하게 되어 문제가 해결된다.

npm v7+의 peerDependencies 자동 설치

npm v6까지는 peerDependencies가 누락되면 경고만 표시했지만, npm v7부터는 자동으로 설치해준다. 이로 인해 문제가 겉으로 드러나지 않을 수 있으나, 여전히 호스트 프로젝트의 package.json에 명시적으로 버전을 관리하는 것이 가장 안정적인 방법이다.

4. peerDependenciesMeta를 이용한 세밀한 제어: 선택적 의존성

때로는 특정 peerDependency가 필수 가 아닌 선택 사항일 수 있다. 예를 들어, 우리 UI 라이브러리의 Chart 컴포넌트만 recharts 라이브러리를 사용한다고 가정해 보자.

  • @my-company/ui-kit/package.json
{
  "peerDependencies": {
    "react": ">=17.0.0",
    "recharts": "^2.0.0"
  },
  "peerDependenciesMeta": {
    "recharts": {
      "optional": true
    }
  }
}

이제 recharts가 호스트 프로젝트에 설치되어 있지 않아도 경고가 발생하지 않는다. Chart 컴포넌트 내부에서는 동적 import()나 try-catch를 이용해 recharts의 존재 여부를 확인하고, 없을 경우 대체 UI를 보여주는 방식으로 구현할 수 있다.

// @my-company/ui-kit/src/Chart.js
import React, { Suspense } from 'react';
 
let RechartsComponent;
try {
  // recharts가 있을 때만 동적으로 불러옴
  RechartsComponent = React.lazy(() => import('recharts'));
} catch (e) {
  RechartsComponent = () => <div>To use Chart, please install 'recharts'.</div>;
}
 
export function Chart(props) {
  return (
    <Suspense fallback={<div>Loading chart...</div>}>
      <RechartsComponent {...props} />
    </Suspense>
  );
}

5. 시맨틱 버저닝(SemVer)과 버전 범위의 이해

의존성을 선언할 때, 우리는 보통 1.2.3 같은 고정 버전 대신 ^1.2.3이나 ~1.2.3 같은 버전 범위를 사용한다. 이 기호들의 정확한 의미를 이해하는 것이 안정적인 의존성 관리의 첫걸음이다.

1. 캐럿(^)과 틸드(~)의 정확한 의미

  • 틸드 (~): Patch 버전 업데이트 허용
    ~는 명시된 버전에서 패치 레벨의 변경까지만 허용한다. 마이너 버전이 명시된 경우, 마이너 버전은 고정된다.
    • ~1.2.3 >= 1.2.3 < 1.3.0 범위를 의미한다. (1.2.4는 설치되지만, 1.3.0은 설치되지 않는다.)
    • ~1.2>= 1.2.0 < 1.3.0 범위를 의미한다.
  • 캐럿 (^): Minor 버전 업데이트 허용
    ^는 가장 왼쪽의 0이 아닌 숫자를 고정한 채로 업데이트를 허용한다. 이는 메이저 버전 0이 아닌 이상, 하위 호환성이 보장되는 마이너(minor)와 패치(patch) 버전의 업데이트를 모두 허용한다는 의미다. npm install 시 기본값이다.
    • ^1.2.3>= 1.2.3 < 2.0.0 범위를 의미한다. (1.3.0, 1.2.4 모두 설치되지만, 2.0.0은 설치되지 않는다.)
    • ^0.2.3 >= 0.2.3 < 0.3.0 범위를 의미한다. (메이저 버전이 0이므로, 마이너 버전이 바뀔 때 호환되지 않는 변경이 있을 수 있다고 간주한다.)

2. SemVer의 함정과 Lock 파일의 중요성

0.x 버전대의 라이브러리는 마이너 버전 변경에도 호환성이 깨질 수 있어 ^ 대신 ~를 사용하거나 버전을 고정하는 것이 안전하다.

하지만 더 흔한 위험은, 마이너/패치 버전 업데이트라고 생각했던 릴리즈에 의도치 않은 버그나 호환성 파괴 변경이 포함되는 경우다. ^~를 사용하면 나도 모르는 사이에 해당 버전이 설치되어 애플리케이션이 갑자기 실패할 수 있다.

이러한 비결정성을 막아주는 것이 바로 Lock 파일(package-lock.json, yarn.lock)이다. Lock 파일은 최초 npm install 시점의 의존성 트리 전체(하위 의존성의 정확한 버전까지)를 기록하여, 이후의 npm install에서는 항상 동일한 버전의 패키지들을 설치하도록 보장한다. Lock 파일은 반드시 Git과 같은 버전 관리 시스템에 포함시켜 팀원 모두가 동일한 의존성 구조를 공유하도록 해야 한다.

6. 의존성은 통제 가능한 신뢰의 설계

의존성 관리는 단순히 npm install을 실행하는 행위가 아니다. 그것은 여러 패키지 간의 관계를 정의하고, 버전 충돌을 예측하며, 런타임 환경의 안정성을 보장하는 아키텍처 설계 과정이다.

내부 라이브러리를 설계할 때 dependenciespeerDependencies의 역할을 명확히 구분하는 것은, 라이브러리를 소비하는 모든 팀에게 통제 가능한 신뢰를 제공하는 첫걸음이다. npm ls와 같은 도구를 통해 보이지 않는 의존성 구조를 진단하고, 올바른 의존성 전략을 통해 견고한 소프트웨어를 구축해야 한다.

📚 참고 링크