목록으로
코어/커스텀 분리기

코어/커스텀 분리기 (3) - 모노레포 패키지를 Nuxt 3에 연결하면서 겪은 것들

2026년 04월 03일20 min read
#Nuxt#Monorepo#Vite#Module Resolution#Vue

목차 보기
시리즈 목차 보기3 / 3

코어/커스텀 분리기

3 / 3

  1. 왜 분리해야 하는가
  2. 프레임워크 Agnostic 라이브러리 설계
  3. 모노레포 패키지를 Nuxt 3에 연결하면서 겪은 것들

이전 편 요약

1편에서는 B2B 솔루션에서 코어와 커스텀 코드를 왜 분리해야 하는지, 그 동기와 설계 원칙을 다뤘다. 2편에서는 실제로 라이브러리를 어떻게 설계하고 패키지 경계를 나눴는지 이야기했다. 거기까지는 비교적 깔끔했다. 패키지를 나누고, 인터페이스를 정의하고, 빌드 파이프라인을 구성하는 일은 설계 원칙만 잘 세우면 된다.

진짜 전쟁은 지금부터다. 잘 나눠놓은 패키지를 실제 Nuxt 3 프로젝트에 연결하는 순간, 모듈 해석이라는 거대한 늪에 빠진다. 이 글은 그 늪에서 건져 올린 삽질 기록이다.

의존성이 "어디서" 해석되는가

모노레포에서 패키지 간 참조 방식은 크게 두 가지다.

workspace protocol (workspace:^)은 같은 모노레포 안에서 로컬 패키지를 참조할 때 쓴다. pnpm이나 yarn이 이 프로토콜을 해석해서, 빌드 없이도 로컬 소스를 직접 가리키게 만들어 준다.

portal 링크 (portal:../some-other-monorepo/packages/lib)는 다른 모노레포의 패키지를 로컬에서 참조할 때 쓴다. npm에 배포하지 않고도 로컬 개발 환경에서 바로 연결할 수 있어서 편리하다.

문제는 portal 링크에 있다. portal로 가져온 패키지는 자기 의존성을 자기 모노레포의 node_modules에서 해석한다. 이게 핵심이다.

예를 들어, 코어 라이브러리 패키지 안에 이런 코드가 있다고 하자.

typescript
// 코어 라이브러리 내부
import { defineNuxtPlugin } from 'nuxt/app'

이 라이브러리를 소비자 프로젝트에서 portal로 가져오면, nuxt/app은 소비자 프로젝트의 nuxt가 아니라 라이브러리가 속한 모노레포의 nuxt로 해석된다. 버전이 같더라도 물리적으로 다른 경로의 파일이다. 버전이 다르면 상황은 더 심각해진다. 라이브러리 쪽 nuxt가 소비자 프로젝트의 .nuxt/ 디렉토리 안에 있는 가상 모듈을 참조하려 하는데, 당연히 그런 파일은 라이브러리 모노레포에 없다.

이것이 모든 문제의 근원이다. 같은 패키지 이름이라도, 해석 위치가 다르면 전혀 다른 모듈이 된다.

같은 패키지, 두 개의 인스턴스

위 원리를 이해하면 다음 문제도 자연스럽게 따라온다.

i18n 라이브러리를 마이그레이션하면서 i18next-vue를 사용하게 되었다. 이 패키지는 소비자 프로젝트의 node_modules에도 설치되어 있고, portal 링크를 통해 가져온 코어 라이브러리의 모노레포 node_modules에도 설치되어 있었다. 같은 버전이었다. 같은 코드였다.

그런데 파일 경로가 다르면 Node.js와 Vite는 이것을 다른 모듈로 취급한다.

Vue의 provide/inject는 Symbol을 키로 사용한다. i18next-vue 내부에서 Symbol('i18next')를 선언하면, 이 Symbol은 모듈이 처음 로드될 때 딱 한 번 생성된다. 모듈 인스턴스가 두 개면 Symbol도 두 개다.

결과는 이렇다:

  1. Nuxt 플러그인이 i18next-vue의 인스턴스 A를 사용해 provide(symbolA, i18nextInstance)를 실행한다.
  2. 컴포넌트의 composable이 i18next-vue의 인스턴스 B를 사용해 inject(symbolB)를 실행한다.
  3. symbolA !== symbolB이므로 inject는 undefined를 반환한다.

증상이 교묘했다. "번역이 잠깐 나왔다가 사라진다." i18next에는 global fallback이 있어서, inject에 실패해도 global 인스턴스에서 한 번은 번역값을 가져온다. 그런데 컴포넌트가 리렌더링되면 제대로 된 reactive 바인딩이 없으니 번역이 초기화된다.

이걸 디버깅하는 데 꽤 오래 걸렸다. provide/inject가 실패하는 경우는 대개 "플러그인 등록 순서가 잘못됐다"거나 "컴포넌트 트리 밖에서 inject를 호출했다" 정도를 의심하는데, 모듈 인스턴스 이중화는 쉽게 떠올리기 어려운 원인이다.

Vite dedupe와 alias로 강제 단일화

Vite에는 이 문제를 해결하기 위한 두 가지 도구가 있다.

vite.resolve.dedupe 는 "이 패키지 이름으로 여러 경로가 해석되면, 하나로 통일하라"는 지시다. Vite가 의존성 트리를 순회하면서 같은 이름의 패키지를 발견하면, 가장 가까운 하나만 사용한다.

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  vite: {
    resolve: {
      dedupe: ['vue', 'nuxt', 'i18next', 'i18next-vue'],
    },
  },
})

그런데 portal 링크 환경에서는 dedupe만으로 부족한 경우가 있었다. Vite의 dedupe가 패키지를 찾는 로직이 항상 소비자 프로젝트의 node_modules를 우선하지는 않기 때문이다.

이때 vite.resolve.alias 로 절대 경로를 강제한다. "이 패키지 이름이 나오면 무조건 이 경로의 파일을 사용하라."

typescript
import { resolve } from 'path'

// nuxt.config.ts
export default defineNuxtConfig({
  vite: {
    resolve: {
      dedupe: ['vue', 'nuxt', 'i18next', 'i18next-vue'],
      alias: {
        'i18next-vue': resolve(__dirname, 'node_modules/i18next-vue'),
        'i18next': resolve(__dirname, 'node_modules/i18next'),
      },
    },
  },
})

이 설정을 추가한 뒤에야 provide/inject가 정상 동작했다. 번역이 사라지지 않았다.

교훈: portal 링크로 외부 모노레포 패키지를 가져올 때는 dedupe + alias를 반드시 함께 설정한다. provide/inject를 사용하는 라이브러리라면 특히 그렇다.

defineNuxtPlugin을 라이브러리가 직접 import하면

코어 라이브러리에서 Nuxt 플러그인을 제공하고 싶었다. 자연스럽게 라이브러리 안에서 defineNuxtPlugin을 import하고, 빌드 시에는 nuxt/app을 external로 처리했다.

typescript
// 코어 라이브러리의 플러그인 팩토리
import { defineNuxtPlugin } from 'nuxt/app'

export function createI18nPlugin(options) {
  return defineNuxtPlugin((nuxtApp) => {
    // i18n 초기화 로직
  })
}

빌드 결과물에는 import { defineNuxtPlugin } from 'nuxt/app'이 그대로 남아 있다. 소비자 프로젝트에서 이 라이브러리를 로드하면, nuxt/app라이브러리의 물리적 위치를 기준으로 해석된다. portal 링크라면 라이브러리 모노레포의 nuxt를 가리킨다.

그 nuxt는 소비자 프로젝트의 .nuxt/ 디렉토리를 모른다. 에러 메시지는 이랬다:

Could not load .nuxt/route-rules.mjs
Failed to resolve #app-manifest

전혀 관련 없어 보이는 에러가 i18n 플러그인 때문에 터지는 상황이었다. nuxt/app 내부에서 연쇄적으로 가상 모듈들을 참조하기 때문이다.

해결 방법은 두 가지였다.

첫 번째, 라이브러리에서 defineNuxtPlugin을 아예 쓰지 않는 것이다. defineNuxtPlugin의 실체를 들여다보면, 사실상 identity function에 가깝다. 플러그인 함수를 받아서 약간의 메타데이터를 붙이고 그대로 반환한다. 라이브러리는 plain function만 export하고, 소비자 프로젝트의 플러그인 파일에서 defineNuxtPlugin으로 감싸면 된다.

typescript
// 코어 라이브러리 — framework-agnostic한 팩토리
export function createI18nSetup(options) {
  return (app) => {
    // i18n 초기화 로직. Vue app 인스턴스만 받는다.
  }
}
typescript
// 소비자 프로젝트의 plugins/i18n.ts
import { createI18nSetup } from '@myorg/core-i18n'

export default defineNuxtPlugin((nuxtApp) => {
  const setup = createI18nSetup({ /* options */ })
  setup(nuxtApp.vueApp)
})

두 번째, Vite alias로 nuxt/app의 해석 경로를 소비자 프로젝트로 강제하는 것이다. 하지만 이 방법은 nuxt 내부 모듈 전체의 해석 경로를 건드리는 것이라 부작용이 예측하기 어렵다. 첫 번째 방법을 권한다.

Nuxt virtual module과 외부 패키지

위 문제의 근본 원인을 좀 더 파고들어 보자.

Nuxt는 빌드 시 .nuxt/ 디렉토리 안에 여러 가상 모듈을 생성한다. #imports, #app-manifest, #app, #build/plugins 같은 것들이다. 이 파일들은 nuxi preparenuxi dev를 실행할 때 동적으로 생성되며, Nuxt의 Vite 플러그인이 런타임에 이 경로들을 해석해 준다.

핵심은 이 가상 모듈들은 Nuxt Vite 플러그인의 컨텍스트 안에서만 올바르게 해석된다는 것이다. 외부 패키지가 nuxt/app을 import하면, nuxt/app 내부에서 #app-manifest 같은 가상 모듈을 참조하는 코드가 연쇄적으로 로드된다. 이때 해당 패키지가 소비자 프로젝트의 Vite 플러그인 컨텍스트 밖에서 해석되면, 가상 모듈 경로를 찾지 못한다.

여기에 한 가지 함정이 더 있다. .nuxt/ 디렉토리에 이전 빌드의 캐시가 남아 있으면, production build 시에 생성된 가상 모듈과 dev mode의 가상 모듈이 충돌한다. 이전에는 잘 되던 빌드가 갑자기 깨지는 원인이 되기도 한다.

의존성 구조를 변경한 뒤에는 반드시 다음 순서를 따른다:

bash
# .nuxt 캐시 삭제 후 재생성
rm -rf .nuxt
npx nuxi prepare

이걸 습관화하지 않으면 "어제는 됐는데 오늘은 안 된다"는 유령 버그에 시달리게 된다.

Nuxt 정적 분석의 함정

Nuxt는 플러그인 파일을 등록할 때 소스 코드를 정적으로 텍스트 스캔한다. 구체적으로, 플러그인 파일의 소스 코드에서 defineNuxtPlugin이라는 문자열이 있는지 확인한다. AST를 파싱하는 게 아니라 단순 텍스트 매칭이다.

코어 라이브러리에서 createI18nPlugin() 함수가 내부적으로 defineNuxtPlugin을 호출해서 반환하더라도, 소비자 프로젝트의 플러그인 파일에 defineNuxtPlugin이라는 텍스트가 직접 없으면 경고가 뜬다.

[warn] Plugin 'plugins/i18n' is not wrapped in defineNuxtPlugin

동작에는 영향이 없다. 하지만 이 경고가 콘솔에 남아 있으면 다른 경고를 놓치기 쉽다. 무시하기 싫다면 이중 래핑을 하면 된다.

typescript
// 소비자 프로젝트의 plugins/i18n.ts
import { createI18nSetup } from '@myorg/core-i18n'

// defineNuxtPlugin이 텍스트로 존재하므로 경고 없음
export default defineNuxtPlugin((nuxtApp) => {
  const setup = createI18nSetup({ /* options */ })
  setup(nuxtApp.vueApp)
})

사실 이 구조가 정석이기도 하다. 라이브러리는 framework-agnostic한 plain function을 반환하고, 소비자가 framework의 wrapper로 감싸는 것. 이 원칙을 따르면 정적 분석 경고도 자연스럽게 사라진다.

vue-i18n에서 i18next로 — reactive의 차이

코어/커스텀 분리와 직접 관련은 없지만, 이 과정에서 i18n 라이브러리를 vue-i18n에서 i18next로 마이그레이션했다. 여기서 reactive 계약의 차이가 문제를 일으켰다.

vue-i18n 에서는 이렇게 쓴다:

typescript
const { locale } = useI18n()
// locale은 Vue Ref<string>
// computed에서 locale.value를 참조하면 자동 추적

locale이 Vue의 Ref이기 때문에, computed나 watch에서 참조하면 자동으로 의존성에 등록된다. 언어가 바뀌면 그 computed가 재계산되고, 관련 UI가 업데이트된다.

i18next-vue 에서는 사정이 다르다:

typescript
const { i18next } = useTranslation()
// i18next.language는 plain string
// computed에서 참조해도 추적되지 않음

i18next.language는 그냥 문자열이다. Vue의 반응형 시스템이 이것을 추적하지 않는다. 언어를 변경해도 computed의 반환값은 바뀌지 않는다.

이게 문제가 된 곳은 API 쿼리 키였다. 그리드나 데이터 조회 컴포넌트에서 locale을 쿼리 키의 일부로 사용하고 있었다. 언어가 바뀌면 새 언어로 데이터를 다시 가져와야 하니까. vue-i18n 시절에는 locale이 Ref여서 자동으로 refetch가 트리거됐는데, i18next로 바꾸니 refetch가 되지 않았다.

해결은 reactive wrapper composable을 만드는 것이었다:

typescript
// composables/useReactiveLocale.ts
export function useReactiveLocale() {
  const locale = ref(i18next.language)

  // i18next의 언어 변경 이벤트를 구독
  const handler = (lng: string) => {
    locale.value = lng
  }

  onMounted(() => {
    i18next.on('languageChanged', handler)
  })

  onUnmounted(() => {
    i18next.off('languageChanged', handler)
  })

  return { locale }
}

이 composable이 반환하는 locale은 Vue Ref이므로 기존 코드에서 그대로 사용할 수 있다. 마이그레이션 범위가 크게 줄었다.

교훈: 라이브러리를 교체할 때는 API 시그니처뿐 아니라 reactive 계약도 반드시 비교한다. 반환값이 Ref인지 plain value인지, 이벤트 기반인지 반응형 기반인지. 이 차이가 전체 애플리케이션의 데이터 흐름에 영향을 준다.

경험에서 얻은 체크리스트

모노레포 패키지를 Nuxt 3에 연결하면서 얻은 교훈을 체크리스트로 정리한다:

  1. portal 링크 사용 시 Vite dedupe/alias 설정 확인 — provide/inject, Symbol 기반 라이브러리가 있다면 필수다.
  2. 라이브러리에서 nuxt/app 직접 import 금지 — 소비자 프로젝트에서 wrapper로 감싸거나, 불가피하면 alias로 해석 경로를 강제한다.
  3. provide/inject를 쓰는 라이브러리는 모듈 인스턴스 단일화 필수 — dedupe + alias로 물리적 경로를 하나로 고정한다.
  4. .nuxt/ 캐시 삭제 후 nuxi prepare를 습관화 — 의존성 변경 후에는 반드시 실행한다.
  5. framework adapter에서 번들러 의존 코드 사용 금지import.meta.server, import.meta.client 같은 코드를 라이브러리에 넣으면 번들러 환경에 종속된다.
  6. reactivity 계약이 다른 라이브러리 마이그레이션 시 computed/watch 의존성 전수 점검 — Ref를 반환하던 것이 plain value로 바뀌면 모든 의존 코드를 확인한다.
  7. 빌드 성공이 런타임 정상을 보장하지 않는다 — 모듈 해석 문제는 대부분 런타임에서 터진다. dev server에서 반드시 검증한다.

시리즈를 마치며

세 편에 걸쳐 코어/커스텀 분리의 여정을 기록했다.

돌이켜보면, 분리 자체보다 "연결"이 더 어려웠다. 코드를 나누는 건 설계 원칙과 의지의 문제다. 나눈 코드를 실제 프로젝트에 연결해서 하나의 애플리케이션으로 동작하게 만드는 건 모듈 시스템, 번들러, 프레임워크의 내부 동작에 대한 이해가 필요하다.

핵심 교훈 하나를 꼽으라면 이것이다: 모듈 해석은 "어디서 해석되느냐"가 전부다. 같은 패키지 이름, 같은 버전, 같은 코드라도 물리적 경로가 다르면 다른 모듈이다. 이 원리를 이해하면 portal 링크의 함정도, provide/inject 실패도, 가상 모듈 해석 오류도 모두 같은 뿌리에서 나온 문제임을 알 수 있다.

B2B 솔루션에서 코어/커스텀 분리의 방어선은 세 겹이다:

  • 타입으로 강제한다 — 인터페이스와 타입 시스템으로 코어와 커스텀의 계약을 명시한다.
  • 패키지로 격리한다 — 물리적 패키지 경계로 의존성 방향을 통제한다.
  • 빌드로 검증한다 — 타입 체크와 빌드 파이프라인으로 계약 위반을 자동 감지한다.

그리고 이 세 겹 위에 한 가지를 더 얹는다면, 런타임에서 확인한다. 빌드가 성공했다고 끝이 아니다. dev server를 띄우고, 실제로 동작하는지 눈으로 확인한다. 모듈 해석 문제는 빌드 타임에 잡히지 않는 경우가 많다.

이 삽질 기록이 비슷한 여정을 시작하려는 누군가에게 도움이 되길 바란다.

검색...

검색...