logo
Published on

[플랫폼] pnpm에서 peerDependencies와 peerDependenciesMeta를 사용한 멀티 어댑터 라이브러리 설계

Authors
  • avatar
    Name
    MJ
    Twitter

이 글에서는 peerDependencies를 어떻게 활용하면 인스턴스 충돌을 막고, 필요한 모듈만 설치할 수 있을지 고민하며 정리한 내용을 공유합니다.
가상의 시나리오인 Redis v5 ↔︎ v4 버전 충돌 사례로, peerDependenciesMeta.optional을 지정해 라이브러리 개발자가 사용자의 의존성을 존중하는 방식으로 구현해볼 수 있을 것 같아요.


이 글에 필요한 개념과 내용을 요약해봤어요

주제정리
1. peerDependencies란 무엇인가?라이브러리가 직접 의존성을 갖지 않고 호스트(사용자)가 설치한 모듈을 공유(단일 인스턴스) 하도록 위임하는 필드예요.
2. 인스턴스 충돌을 막기 위한 Hoisting패키지 매니저가 peerDeps를 루트로 끌어올려(hoist) 중복 설치를 방지하고, 라이브러리·호스트가 같은 객체를 사용하도록 강제해요.
3. optional peerDependencies를 써야 하는 이유가 있을까요?Redis가 필요 없는 환경에서는 설치를 건너뛰어 빌드 크기·설치 속도를 최적화할 수 있어요.
4. 구현 아이데이션을 해보자package.jsonpeerDependenciesMeta.optional 선언 → 런타임에서 조건부(동적) import 혹은 서브-엔트리로 안전하게 로딩

중복 인스턴스 시나리오를 상상해보자

my-app/
 ├─ node_modules/
 │  ├─ my-cache/
 │  │   └─ node_modules/
 │  │      └─ redis   (v5)  ← my-cache가 품고 있는 인스턴스
 │  └─ redis          (v4)  ← 앱이 원래 쓰던 인스턴스

my-app에서 my-cache라는 라이브러리를 사용하고 있어요. 이 라이브러리는 redis를 의존성으로 가지고 있어요. 이 때 라이브러리 내부에도 redis가 있고, 루트에도 redis가 있으면 두 인스턴스가 메모리에 올라가요.

  • 문제 1 — 연결 풀 이중화 두 클라이언트가 서로 다른 TCP 연결을 유지 → 같은 키를 써도 값이 분산될 수 있어요.

  • 문제 2 — API 버전 불일치 라이브러리는 client.connect()(v5) 호출, 앱 코드는 client.on('ready')(v4)를 기대 → 런타임 오류가 발생할 수 있어요.

peerDependencies + Hoisting을 적용하면 루트에 하나의 redis 인스턴스만 남아 위 상황을 방지할 수 있어요.


my-cache의 개발자가 되어 라이브러리를 만들어보자

라이브러리 개발자 입장에서, 사용자(호스트)의 Redis 버전을 그대로 쓰면서도 필요한 환경에서만 Redis를 설치하도록 설계해볼게요.

1) package.json 선언

{
  "peerDependencies": {
    "redis": "^4 || ^5", // v4·v5 모두 허용
  },
  "peerDependenciesMeta": {
    "redis": { "optional": true }, // Redis 없이도 설치 가능
  },
  "exports": {
    ".": "./dist/core.js",
    "./redis": "./dist/redis.js", // Redis 전용
  },
}

2) 라이브러리 코드 설계

src/core.ts

기본적으로 Map을 이용한 인메모리 캐시를 구현해요. 혹은 Memcached를 사용할 수도 있어요. 예시에서는 이해가 쉽도록 단순히 Map을 사용해 간단하게 구현했어요.

const store = new Map<string, string>()

export function set(key: string, value: string) {
  store.set(key, value)
}

export function get(key: string) {
  return store.get(key)
}

src/redis.ts

Redis 어댑터를 구현해요. 이 때 호스트의 redis 인스턴스를 사용해요.

// src/redis.ts
type V5 = {
  connect(): Promise<void>
  set(k: string, v: string): Promise<void>
  get(k: string): Promise<string | null>
  quit(): Promise<void>
}

type V4 = {
  set(k: string, v: string, cb: (e: Error | null) => void): void
  get(k: string, cb: (e: Error | null, v: string | null) => void): void
  once(ev: 'ready' | 'error', fn: (...a: unknown[]) => void): void
  end(): void
}

// 1) 호스트의 redis 한 번만 import
const r = (await import('redis')) as { createClient(): V5 & V4 }
const client = r.createClient()

// 2) 연결 분기
if ('connect' in client)
  await client.connect() // v5
else
  await new Promise<void>((ok, err) => {
    // v4
    client.once('ready', ok)
    client.once('error', err)
  })

// 3) get / set 분기
export function set(key: string, val: string): Promise<void> {
  return 'connect' in client // v5
    ? client.set(key, val)
    : new Promise((ok, err) => client.set(key, val, (e) => (e ? err(e) : ok())))
}

export function get(key: string): Promise<string | null> {
  return 'connect' in client // v5
    ? client.get(key)
    : new Promise((ok, err) => client.get(key, (e, v) => (e ? err(e) : ok(v))))
}

export async function close() {
  if ('quit' in client)
    await client.quit() // v5
  else client.end() // v4
}

라이브러리 사용자 입장에서의 설치 & 코드

시나리오설치import
Redis 사용 (v4 or v5)pnpm add redis my-cacheimport { set, get } from 'my-cache/redis'
Redis가 필요 없는 환경pnpm add my-cacheimport { set, get } from 'my-cache'

이로써 해결할 수 있는 문제

  • 연결 풀 이중화 → 루트에 하나의 redis 인스턴스만 존재해요.
  • API 버전 불일치 → 라이브러리가 호스트가 고른 버전을 그대로 씁니다.
  • 불필요한 설치·번들 → Redis 없는 빌드(테스트·서버리스)에서 설치·번들링 자체가 생략돼요.

마무리 체크리스트

  • peerDependencies 로 단일 인스턴스 보장
  • peerDependenciesMeta.optional 로 선택 설치 허용
  • 서브-엔트리 ("./redis") 로 드라이버 코드 분리

이렇게 설계하면 가볍고, 충돌 없는 라이브러리를 만들 수 있어요. 읽어주셔서 감사해요.