코어/커스텀 분리기 (2) - 프레임워크 Agnostic 라이브러리 설계
목차 보기
시리즈 목차 보기2 / 3
이전 편 요약
1편에서는 B2B 솔루션에서 코어와 커스텀 코드가 왜 분리되어야 하는지를 다뤘다. 핵심은 단순하다. 고객사 A의 요구사항이 고객사 B의 시스템을 망가뜨려서는 안 된다. 커스텀이 코어에 직접 손을 대면 업그레이드할 때마다 지옥문이 열린다. 분리의 필요성은 충분히 논의했으니, 이번 편에서는 실제로 어떻게 설계하는지 이야기한다.
한 가지 전제를 먼저 밝힌다. 여기서 다루는 패턴은 사내 공통 라이브러리를 설계할 때 적용한 것이다. npm에 올리는 오픈소스든, 모노레포 안의 내부 패키지든 근본적인 설계 원칙은 같다.
3계층 구조
라이브러리를 설계할 때 가장 먼저 결정해야 하는 건 계층 구조다. 나는 세 계층으로 나눈다.
첫 번째 계층은 core다. 프레임워크 의존이 전혀 없는 순수 TypeScript 코드다. i18next 같은 런타임 라이브러리에만 의존할 수 있다. Vue도 React도 모른다. 이 계층의 코드는 어디서든 돌아간다. Node.js CLI 도구에서 쓸 수도 있고, Deno에서 가져다 쓸 수도 있다.
두 번째 계층은 프레임워크 어댑터다. @mylib/vue나 @mylib/react 같은 패키지가 여기에 해당한다. core를 감싸서 각 프레임워크의 플러그인 시스템에 연결하는 역할을 한다. Vue라면 app.use()로 등록할 수 있는 플러그인을 제공하고, React라면 Context Provider를 제공한다.
세 번째 계층은 메타프레임워크 어댑터다. @mylib/nuxt나 @mylib/next 같은 패키지다. SSR 처리, 라우터 통합, 자동 설정 같은 메타프레임워크 전용 기능을 담당한다. Nuxt라면 모듈 시스템으로, Next라면 미들웨어로 통합한다.
의존 방향은 항상 위에서 아래로 흐른다.
@mylib/core ← 프레임워크 의존 없음
↑
@mylib/vue ← peerDeps: vue
@mylib/react ← peerDeps: react
↑
@mylib/nuxt ← peerDeps: nuxt / deps: @mylib/vue
@mylib/next ← peerDeps: next / deps: @mylib/react
이 구조의 장점은 교체 가능성이다. 회사가 Vue에서 React로 전환하더라도 core는 그대로 쓸 수 있다. 어댑터만 새로 만들면 된다. 물론 현실에서 프레임워크를 바꾸는 일이 흔하진 않다. 하지만 메타프레임워크 전환은 실제로 일어난다. Vue 생태계 안에서 Nuxt 2에서 Nuxt 3으로 넘어가는 것만 해도 엄청난 변경이다. 이때 core와 vue 어댑터는 건드릴 필요 없이 nuxt 어댑터만 교체하면 된다.
고정 옵션과 확장 옵션
라이브러리가 존재하는 이유를 한 문장으로 요약하면 이렇다. 규격을 코드로 강제하는 것이다.
사내 공통 라이브러리는 특히 그렇다. 각 프로젝트가 자유롭게 설정할 수 있는 부분과, 절대로 바꿔서는 안 되는 부분이 있다. 이 경계를 명확하게 나누는 것이 설계의 핵심이다.
판단 기준은 간단하다.
- 달라지면 사고 → 고정한다
- 달라야 정상 → 확장 옵션으로 열어둔다
예를 들어 i18n 라이브러리를 래핑한다고 하자. 번역 리소스를 서버에서 불러오는 백엔드 설정, 미번역 키를 자동 수집하는 기능, 보간(interpolation) 문법 같은 것들은 모든 프로젝트에서 동일해야 한다. 한 프로젝트가 보간 문법을 {key}에서 {{key}}로 바꿔버리면 공유 번역 리소스가 깨진다. 이런 건 고정이다.
반면 기본 언어, 디버그 모드, 네임스페이스 목록 같은 건 프로젝트마다 다를 수 있다. 이런 건 확장 옵션으로 열어둔다.
TypeScript에서 이 개념을 가장 깔끔하게 표현하는 방법은 Omit 유틸리티 타입이다.
// 고정할 키 목록
type FixedKeys = 'backend' | 'saveMissing' | 'interpolation'
// 네이티브 라이브러리의 옵션에서 고정 키를 제거하고, 우리만의 필수 필드를 추가
interface LibOptions extends Omit<NativeOptions, FixedKeys> {
apiBaseUrl: string // API 서버 주소 — 프로젝트마다 다르므로 필수 주입
request: RequestFn // HTTP 클라이언트 — 프로젝트의 인증 로직에 맞게 주입
ssr?: {
detectLanguage?: boolean // SSR 환경에서 언어 자동 감지 여부
}
}이렇게 하면 IDE 자동완성에서 backend, saveMissing, interpolation이 아예 뜨지 않는다. 사용자가 이 옵션들의 존재 자체를 모르게 된다. 모르면 못 건드린다. 이것이 타입 수준의 첫 번째 방어선이다.
이 방식의 미묘한 이점이 하나 더 있다. 네이티브 라이브러리가 버전 업그레이드되면서 새 옵션을 추가해도, Omit을 쓰면 고정 키를 제외한 나머지가 자동으로 노출된다. 옵션 하나 추가될 때마다 래퍼 인터페이스를 수동으로 업데이트할 필요가 없다.
스프레드 순서 — 이중 안전장치
타입 수준 방어만으로는 충분하지 않다. TypeScript를 쓰다 보면 누구나 한 번쯤은 as any를 쓴다. 급할 때, 타입 정의가 복잡할 때, "일단 돌아가게 만들자"고 할 때. 타입 시스템을 우회하는 순간 고정 옵션이 뚫린다.
그래서 런타임에서도 방어해야 한다. 방법은 의외로 단순하다. 객체 스프레드 순서를 이용한다.
function createLibrary(userOptions: LibOptions) {
const instance = createNativeInstance()
instance.init({
// 1단계: 기본값 (사용자가 오버라이드할 수 있음)
fallbackLng: 'en',
debug: false,
// 2단계: 사용자 옵션
...userOptions,
// 3단계: 고정값 (사용자가 뭘 넣든 무조건 이 값으로 덮어씌움)
backend: {
loadPath: '/api/locales/{lng}/{ns}',
request: userOptions.request,
},
saveMissing: true,
interpolation: { prefix: '{', suffix: '}' },
})
return instance
}JavaScript 객체 스프레드는 나중에 선언된 속성이 이전 속성을 덮어쓴다. 이 특성을 이용하면 세 단계 방어가 완성된다.
1단계 기본값은 사용자가 같은 키를 넘기면 자연스럽게 대체된다. fallbackLng: 'ko'를 넘기면 'en' 대신 'ko'가 적용된다. 이게 정상적인 확장이다.
2단계에서 사용자 옵션이 펼쳐진다.
3단계 고정값은 사용자 옵션 뒤에 오므로, 사용자가 backend나 interpolation을 넘겼더라도 무조건 고정값으로 대체된다. as any로 타입을 뚫고 { interpolation: { prefix: '{{', suffix: '}}' } }를 넣어도 소용없다.
타입으로 차단하고, 런타임으로 덮어쓴다. 이 두 겹의 방어가 라이브러리의 규격을 보장한다.
같은 함수명, 다른 패키지
각 어댑터 패키지에서 외부로 내보내는 주요 함수가 있다. 예를 들어 createI18n이라는 함수를 @mylib/vue, @mylib/react, @mylib/nuxt, @mylib/next에서 모두 제공한다고 하자.
초기에는 createVueI18n, createNuxtI18n 같은 식으로 프레임워크 이름을 함수명에 넣는 게 직관적으로 보였다. 하지만 곧 문제를 깨달았다. 사용자는 이미 import 문에서 패키지를 선택하고 있다. 패키지명이 곧 네임스페이스다.
// 패키지명이 네임스페이스 역할을 한다
import { createI18n } from '@mylib/vue'
import { createI18n } from '@mylib/nuxt'함수명에 프레임워크를 넣으면 이름이 길어지기만 하고 정보가 중복된다. @mylib/vue에서 가져오는 건 당연히 Vue용이다. 굳이 createVueI18n이라고 쓸 이유가 없다.
이 패턴의 또 다른 장점은 프레임워크 전환 시 변경량이 줄어든다는 것이다. Nuxt 프로젝트에서 React로 마이그레이션한다고 상상해보자. import 경로만 @mylib/vue에서 @mylib/react로 바꾸면 된다. 함수명이 같으니 호출부는 수정할 필요가 없다. 물론 완벽하게 동일할 수는 없다. Vue의 app.use()와 React의 Context Provider는 연결 방식이 다르니까. 하지만 초기화 인터페이스를 최대한 통일하면 마이그레이션 비용이 확실히 줄어든다.
SSR 옵션의 위치
SSR 관련 옵션을 어디에 정의할지는 의외로 고민이 된다. ssr.detectLanguage 같은 옵션은 실제로 nuxt 어댑터에서만 사용된다. 그러면 nuxt 어댑터의 인터페이스에만 넣으면 되지 않을까?
결론부터 말하면, 타입 정의는 core에, 구현은 어댑터에 두는 게 맞다.
이유는 타입 일관성이다. createI18n의 옵션 인터페이스가 패키지마다 달라지기 시작하면 혼란이 생긴다. core에서 LibOptions를 정의하고, 모든 어댑터가 이 인터페이스를 공유하면 사용자는 어떤 패키지를 쓰든 같은 옵션 구조를 보게 된다. SSR이 지원되지 않는 환경(브라우저 전용 SPA 등)에서 ssr 옵션을 넘기면? 단순히 무시된다. 에러를 던질 필요도 없다.
실제 SSR 로직은 당연히 메타프레임워크 어댑터에서만 구현한다. Accept-Language 헤더 파싱, 쿠키 기반 언어 감지, 서버 미들웨어 등록 같은 코드는 nuxt나 next 어댑터의 몫이다.
여기서 한 가지 절대 규칙이 있다. core에 번들러 의존 코드를 넣지 않는다. import.meta.server, process.env, typeof window 같은 환경 감지 코드는 번들러마다 처리 방식이 다르다. Vite의 import.meta.server는 Webpack에서는 해석되지 않는다. core가 특정 번들러에 종속되면 React Native 같은 전혀 다른 빌드 환경에서 사용할 수 없게 된다.
core는 깨끗해야 한다. 순수 TypeScript, 순수 런타임 로직. 그래야 어디서든 동작한다.
빌드 시스템과 externals
라이브러리를 설계하고 코드를 작성하는 것까지는 좋은데, 빌드 설정에서 실수하면 모든 노력이 허사가 된다. 특히 externals 설정은 한 번 잘못하면 런타임에 미묘한 버그가 생기는데, 원인을 찾기 극도로 어렵다.
나는 unbuild을 사용한다. ESM, CJS, 타입 선언 파일(.d.ts)을 동시에 빌드할 수 있고, 설정이 간결하다. 하지만 빌드 도구가 뭐든 핵심 원칙은 같다.
externals 결정 기준: "소비자의 node_modules에 이미 있을 패키지"는 external로 지정한다.
// build.config.ts 예시
export default defineBuildConfig({
entries: ['src/index'],
clean: true,
declaration: true,
rollup: {
emitCJS: true,
},
externals: [
'vue', // 프레임워크 — 소비자가 이미 설치함
'react',
'nuxt',
'next',
'@mylib/core', // 자기 워크스페이스 패키지
'@mylib/vue',
'i18next', // 런타임 의존 — peerDeps로 선언
],
})프레임워크는 당연히 external이다. Vue 프로젝트에는 이미 vue가 설치되어 있다. 라이브러리가 vue를 번들에 포함시키면 vue 인스턴스가 두 개 존재하게 되고, 반응성 시스템이 완전히 깨진다. ref()로 만든 값이 컴포넌트에서 반응하지 않는 기괴한 버그를 만나게 된다.
자기 워크스페이스 패키지도 external이다. @mylib/nuxt가 @mylib/vue를 번들에 포함시키면, @mylib/vue의 코드가 두 번 로드된다. 사용자의 프로젝트에 설치된 것 한 번, @mylib/nuxt 번들 안에 있는 것 한 번. 동일한 모듈이 두 번 실행되면 싱글턴 패턴이 깨진다. 상태 공유가 안 되고, Symbol 비교가 실패하고, instanceof 체크가 엉뚱한 결과를 낸다.
이 문제의 무서운 점은 개발 환경에서는 잘 동작하는 경우가 많다는 것이다. 모노레포에서 심링크로 연결되어 있으면 같은 파일을 참조하니까 문제가 안 생긴다. 실제 npm에 배포하고 소비자가 설치하면 그때서야 터진다. 배포 후에 발견하는 버그는 원인 추적이 힘들고, 신뢰를 크게 깎아먹는다.
역으로 생각하면 이것도 간단하다. 번들에 포함해야 하는 것은 라이브러리 내부에서만 쓰는 유틸리티 함수뿐이다. 나머지는 전부 external로 빼는 게 안전하다.
설계 결정의 연쇄 효과
지금까지 다룬 내용을 정리하면 이렇다.
- 3계층 구조로 관심사를 분리한다
Omit으로 고정 옵션을 타입에서 제거한다- 스프레드 순서로 런타임에서도 고정 옵션을 보호한다
- 패키지명을 네임스페이스로 활용해 함수명을 통일한다
- SSR 옵션의 타입은 core에, 구현은 어댑터에 둔다
- externals를 정확히 설정해 중복 로딩을 방지한다
이 결정들은 독립적이지 않다. 3계층 구조를 채택했기 때문에 externals 설정이 중요해졌고, externals가 정확해야 싱글턴 패턴이 유지되고, 싱글턴이 유지되어야 상태 공유가 동작한다. 하나를 빼먹으면 연쇄적으로 무너진다.
설계는 이렇게 서로 맞물리는 결정들의 그물이다. 한 편의 글에 정리해두면 나중에 "왜 이렇게 했더라?"라는 질문에 바로 답할 수 있다. 이 글이 바로 그 목적이다.
다음 편 예고
설계가 끝나면 연결이 시작이다. 그리고 연결은 설계보다 훨씬 더 고통스럽다.
3편에서는 이 패키지를 실제 Nuxt 3 프로젝트에 연결하면서 겪은 온갖 함정들을 다룬다. 모노레포의 portal 링크가 패키지 해석을 꼬이게 만드는 문제, Nuxt virtual module과의 충돌, Vue의 inject/provide에서 Symbol이 불일치하는 버그까지. 설계 문서에는 없는, 실전에서만 만나는 이야기들이다.