Monorepo에서 프론트엔드와 백엔드 간에 TypeScript 코드를 공유하는 방법
I’m happy to translate the article for you, but I’ll need the text you’d like translated. Could you please paste the full content (or the portion you want translated) here? I’ll keep the source line exactly as you provided and preserve all formatting, markdown, and code blocks.
Background
In my monorepo project pawHaven, the frontend and backend are not completely isolated.
They naturally share a portion of code, including:
- Constants
- Configuration schemas
- Enums and dictionaries
- Pure utility functions
Extracting these common pieces into a shared package and using it across both frontend and backend felt natural. With TypeScript, pnpm workspaces, and a monorepo already in place, everything seemed aligned.
문제가 시작될 때
- Node가
Unexpected token 'export'를 보고했습니다 - 프론트엔드 빌드는 성공했지만 런타임에서 실패했습니다
- 일부 모듈이 누락된 것으로 보고되었습니다
- CommonJS가 ESM 구문을 처리할 수 없었습니다
오류가 무작위로 보였지만, 근본적인 문제는 명확했습니다: 프론트엔드와 백엔드가 완전히 다른 모듈 시스템을 기대하고 있었습니다.
My Initial Wrong Assumption
I initially assumed it would be possible to produce a single build output compatible with both CommonJS and ESM. I spent days experimenting with:
module: ESNextmodule: CommonJS"type": "module"- Different
moduleResolutionstrategies - Various
tsconfigcombinations
After nearly three days of trial and error, it became clear that a single build output cannot satisfy both CommonJS and ESM. These two targets are fundamentally incompatible.
핵심 사고 전환
돌파구는 간단한 질문에서 시작되었습니다: 공유 패키지는 왜 하나의 출력만을 생성해야 할까요?
프론트엔드와 백엔드 환경은 본질적으로 다릅니다:
| 환경 | 모듈 기대치 |
|---|---|
| Frontend (Vite/Webpack) | ESM |
| Node backend (Nest/require) | CommonJS |
따라서 올바른 접근 방식은 단일 아티팩트에 타협하기보다 각 환경에 별도 빌드를 생성하는 것입니다.
1. 단일 진실의 원천
공유 패키지는 ESM 문법을 사용한 TypeScript로 작성된 하나의 소스 코드 베이스를 유지합니다. 모든 코드는 하나의 src 디렉터리에 위치하며 표준 export 문을 사용합니다.
2. 두 개의 TypeScript 설정, 두 개의 타깃
패키지는 두 개의 별도 TypeScript 설정을 사용합니다:
packages/shared/
├─ tsconfig.esm.json
├─ tsconfig.cjs.json
- 프론트엔드와 번들러를 위한 ESM 출력용 설정.
- Node.js 백엔드를 위한 CommonJS 출력용 설정.
tsconfig.cjs.json
{
"extends": "@pawhaven/tsconfig/base",
"compilerOptions": {
"outDir": "dist/cjs",
"module": "CommonJS",
"moduleResolution": "node"
},
"exclude": ["node_modules", "dist"]
}
tsconfig.esm.json
{
"extends": "@pawhaven/tsconfig/base",
"compilerOptions": {
"outDir": "dist/esm",
"module": "ESNext",
"moduleResolution": "bundler"
},
"exclude": ["node_modules", "dist"]
}
이 설정으로 다음이 생성됩니다:
- 프론트엔드와 번들러용 ESM 빌드.
- Node.js 백엔드용 CommonJS 빌드.
3. package.json을 통한 정확한 엔트리 해석
{
"name": "@pawhaven/shared",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
}
package.json은 서로 다른 소비자에게 어떤 빌드가 로드되는지를 정의합니다:
main→ Node.js용 CommonJS 빌드 (require).module→ 번들러용 ESM 빌드 (import).types→ 타입 검사를 위한 TypeScript 선언 파일.
exports 필드는 정확한 해석을 보장합니다:
| 사용법 | 필드 | 출력 |
|---|---|---|
import | exports.import | ESM 빌드 |
require | exports.require | CommonJS 빌드 |
| TypeScript | exports.types | 선언 파일 |
이를 통해 프론트엔드와 백엔드 모두 런타임 체크나 환경 변수 없이 올바른 구현을 받을 수 있습니다.
Understanding main, module, and types
main: Node.js가 CommonJS 모드에서 사용하며,require()호출 시 로드됩니다.module: 번들러가 ESM 진입점을 표시하고 트리‑쉐이킹을 활성화하기 위해 사용하며, Node.js 런타임에서는 무시됩니다.types: TypeScript가 컴파일 시 사용하며, 프론트엔드와 백엔드 모두에 대한 타입 선언을 제공하고 런타임과는 독립적입니다.
왜 이 접근 방식이 안정적인가
모듈 선택은 런타임이 아니라 모듈 해석 시점에 이루어집니다.
장점
- 애플리케이션 코드에 조건부 로직이 없습니다.
- 환경에 의존하는 해킹이 없습니다.
- 로컬 및 CI 환경 전반에 걸쳐 완전히 결정론적인 빌드가 가능합니다.
최종 생각
이 3일간의 시행착오를 통해 중요한 교훈을 얻었습니다: 모노레포에서 공유 패키지의 과제는 코드 재사용이 아니라 명확한 모듈 경계를 정의하는 것입니다.
견고한 공유 패키지는 다음을 제공해야 합니다:
- 단일 진실의 원천.
- 여러 명시적인 빌드 출력.
exports를 통해 엄격히 제어된 사용.
모든 환경을 하나의 아티팩트에 맞추려 하면 충돌이 발생합니다. 듀얼‑빌드 전략은 대규모 모노레포에서 공유 모듈을 위한 가장 신뢰할 수 있고 유지 보수하기 쉬운 패턴 중 하나입니다.
실제로 확인하고 싶다면, 길고양이 구조를 위한 실제 모노레포 프로젝트를 확인해 보세요: pawhaven‑shared
