웹앱과 Chrome Extension을 따로 관리하면 타입 정의가 중복되고, API 계약이 어긋나기 쉽다. 프로젝트 초반에는 각각 독립 레포로 시작했지만, 공유 코드가 늘어나면서 복사-붙여넣기의 한계가 드러났다.
모노레포로 통합하면 해결될 문제였다. 선택지는 크게 세 가지였다.
패키지 매니저 선택
| 패키지 매니저 | Workspace 지원 | 디스크 효율 | 속도 | 특이사항 |
|---|---|---|---|---|
| npm | v7+ | 낮음 | 느림 | 기본 제공 |
| yarn (classic) | 있음 | 보통 | 보통 | 호이스팅 이슈 |
| yarn berry (PnP) | 있음 | 높음 | 빠름 | node_modules 없음, 호환성 문제 |
| pnpm | 있음 | 높음 | 빠름 | 심볼릭 링크 기반, 엄격한 의존성 |
yarn classic의 호이스팅 이슈는 의존성이 node_modules 루트로 끌어올려져서 명시하지 않은 패키지도 import할 수 있는 문제다. 나중에 의존성을 정리할 때 어떤 패키지가 실제로 필요한지 파악하기 어려워진다.
pnpm은 심볼릭 링크로 의존성을 관리한다. 각 패키지가 명시한 의존성만 접근할 수 있어서 유령 의존성(phantom dependency) 문제가 없다. yarn berry의 PnP 모드도 이 문제를 해결하지만, Next.js와 Prisma에서 호환성 이슈가 있었다.
pnpm을 선택했다.
빌드 시스템 선택
모노레포에서 태스크 관리도 고려해야 했다.
| 도구 | 특징 | 설정 복잡도 | 캐싱 |
|---|---|---|---|
| Turborepo | 태스크 러너, 증분 빌드 | 낮음 | 로컬/원격 |
| Nx | 풀 프레임워크, 코드 생성 | 높음 | 로컬/원격 |
| Lerna | 버전 관리 특화 | 중간 | 없음 |
Nx는 기능이 많지만 설정이 복잡하다. pixelDiff 규모에서는 과하다. Turborepo는 기존 pnpm 워크스페이스에 turbo.json 하나만 추가하면 된다. 태스크 의존성과 캐싱을 선언적으로 관리할 수 있다.
pnpm workspace가 패키지 구조를, Turborepo가 태스크 실행을 담당하는 구조다. 각자 역할이 명확하고 조합이 깔끔하다.
디렉토리 구조 설계
최종 구조는 이렇다.
pixelDiff/
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
├── apps/
│ └── web/ # Next.js 웹앱
├── packages/
│ ├── api-contracts/ # API 타입 정의 (Zod)
│ ├── core/ # 공유 로직
│ └── scripts/ # DB 스크립트
└── extension/ # Chrome Extension (Vite)
apps/에는 배포 가능한 애플리케이션, packages/에는 공유 라이브러리를 둔다. 일반적인 모노레포 컨벤션이다.
extension/은 apps/extension/이 아닌 루트에 두었다. Chrome Extension은 Vite 기반이고, 빌드 결과물을 웹스토어에 업로드해야 한다. apps 패턴과 맞지 않아서 별도로 뺐다.
워크스페이스 설정
pnpm-workspace.yaml은 단순하다.
packages:
- 'apps/*'
- 'packages/*'
- 'extension'
각 패키지의 package.json에서 name 필드가 중요하다. 다른 패키지에서 참조할 때 이 이름을 사용한다.
{
"name": "@pixeldiff/api-contracts",
"main": "./src/index.ts",
"types": "./src/index.ts"
}
빌드 없이 TypeScript 소스를 직접 참조하도록 main과 types를 ./src/index.ts로 지정했다. 개발 중에는 빌드 단계 없이 바로 타입 체크가 된다. 프로덕션 빌드 시에는 Next.js가 트랜스파일한다.
웹앱에서 내부 패키지를 참조할 때는 workspace:* 프로토콜을 쓴다.
{
"dependencies": {
"@pixeldiff/api-contracts": "workspace:*",
"@pixeldiff/core": "workspace:*"
}
}
workspace:*는 "워크스페이스 내 해당 패키지를 참조하라"는 의미다. 버전 번호 대신 쓰면 항상 로컬 코드를 참조한다.
Turborepo 설정
turbo.json에서 태스크 파이프라인을 정의한다.
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"type-check": {
"dependsOn": ["^type-check"]
},
"test": {
"outputs": ["coverage/**"],
"cache": false
}
}
}
dependsOn: ["^build"]에서 ^는 의존하는 패키지를 뜻한다. @pixeldiff/web이 @pixeldiff/core에 의존하면, core의 build가 먼저 실행된다.
dev 태스크는 persistent: true로 설정한다. 개발 서버는 계속 실행 중이어야 하기 때문이다. cache: false는 개발 서버 결과를 캐싱하지 않는다는 의미다.
루트 스크립트
루트 package.json에서 워크스페이스 전체를 제어하는 스크립트를 정의한다.
{
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
"type-check": "turbo run type-check"
}
}
pnpm dev를 실행하면 Turborepo가 모든 패키지의 dev 태스크를 병렬로 실행한다. 웹앱과 Extension 개발 서버가 동시에 뜬다.
특정 패키지만 실행하려면 --filter 플래그를 쓴다.
pnpm --filter @pixeldiff/web dev
pnpm --filter @pixeldiff/extension build
공유 패키지 활용
@pixeldiff/api-contracts에는 API 요청/응답 타입을 Zod 스키마로 정의한다.
import { z } from 'zod';
export const ProjectSchema = z.object({
id: z.string(),
name: z.string(),
createdAt: z.date(),
});
export type Project = z.infer<typeof ProjectSchema>;
웹앱과 Extension 모두 같은 타입을 사용한다. API 응답 형식이 바뀌면 한 곳만 수정하면 된다. 타입 불일치는 빌드 타임에 잡힌다.
Extension 분리의 이유
Chrome Extension을 apps/extension/이 아닌 루트 extension/에 둔 이유가 있다.
- 빌드 도구가 다르다. 웹앱은 Next.js, Extension은 Vite다.
@crxjs/vite-plugin으로 manifest.json을 처리한다. - 배포 플로우가 다르다. 웹앱은 서버 배포, Extension은 Chrome 웹스토어 업로드다. 빌드 결과물을 zip으로 패키징해야 한다.
- 의존성이 독립적이다. Extension은 현재 내부 패키지를 참조하지 않는다. 웹앱과 메시지를 주고받지만, 코드 레벨 의존성은 없다.
Extension이 @pixeldiff/api-contracts를 참조해야 한다면 apps/extension/으로 옮기는 것이 맞다. 현재는 그렇지 않아서 루트에 두었다.
요약하면
pnpm workspace + Turborepo 조합은 설정이 단순하면서도 효과적이다.
- 공유 코드는 packages/로 분리한다. 타입 정의, 유틸리티, 공통 로직.
- workspace:* 프로토콜로 내부 패키지를 참조한다. 버전 관리 부담이 없다.
- 빌드 도구나 배포 플로우가 다르면 별도 디렉토리로 뺀다. 무리하게 패턴에 맞추지 않는다.
소규모 프로젝트에서 시작해도 모노레포 구조를 잡아두면 나중에 코드 공유가 쉬워진다. 다만 패키지를 너무 잘게 쪼개면 오버헤드가 생긴다. 실제로 공유할 코드가 생겼을 때 분리해도 늦지 않다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'토이프로젝트' 카테고리의 다른 글
| postMessage로 다중 iframe 스크롤 동기화 구현하기 (0) | 2026.03.12 |
|---|---|
| 사이드 프로젝트 DB로 Supabase를 선택한 이유 (0) | 2026.03.05 |
| 서비스 웹앱과 Chrome Extension 간 버전 호환성 관리하기 (0) | 2026.02.28 |
| 서버와 DB 리전, 같이 두면 얼마나 빨라질까? (0) | 2026.02.14 |
| Figma 스타일 스냅 가이드 시스템 구현하기 (0) | 2026.01.29 |
