프론트엔드 프로젝트를 시작하면서 모노레포 구조를 선택했다. 초기 설계가 완벽할 거라 생각했지만, 실제 개발이 진행되면서 구조는 계속 바뀌었다. 이 글은 약 8개월간 프로젝트 구조가 어떻게 바뀌었는지, 그리고 각 변화의 이유가 무엇이었는지를 기록한다.
배경
이 프로젝트는 크게 두 개의 시스템으로 구성돼 있었다.
- AI 추론 관리 시스템 — AI 모델의 추론 결과를 관리하는 시스템. 코드 관리, 로그 조회, 추론 이력 등 폼/테이블 중심의 CRUD 기능이 주를 이룬다.
- AI 학습 관리 시스템 — 모델 학습 작업을 관리하는 시스템. 추론 시스템과 독립적으로 배포되고, 대상 사용자도 달랐다.
여기까지는 명확했다. 문제는 세 번째 시스템이었다.
추론 관리 시스템 안에 GIS 지도 시각화 기능이 포함돼 있었다. OpenLayers 기반으로 위성 이미지 위에 변화 탐지 결과를 레이어로 올리는 기능이었는데, 기술 스택이 나머지 추론 관리 기능과 완전히 달랐다. 처음부터 이 GIS 기능을 추론 시스템 안에 합칠지, 아니면 별도 앱으로 분리할지 결정이 나지 않은 상태에서 개발이 시작됐다.
이 불확실성이 이후 구조 결정에 계속 영향을 줬다.
1단계: Vite + pnpm workspace
초기 구조는 심플했다.
apps/
├── inference/ # 추론 관리
├── labeling/ # 라벨링
├── viewer/ # 뷰어
└── training/ # 학습 관리
packages/
└── ui/Vite를 선택한 이유는 명확했다. 빠른 HMR, 간단한 설정, 가벼운 시작. pnpm workspace로 앱들을 묶고 공유 패키지를 packages/에 뒀다.
초기에 i18n(다국어)도 넣었다. 나중에 쓸 것 같아서. 결론적으로는 한 번도 안 썼고 나중에 제거했다. YAGNI(You Aren't Gonna Need It!)를 몸으로 배웠다.
2단계: Turborepo + Next.js 전환
약 한 달 후, 큰 결정을 했다. 기존 코드를 전부 삭제하고 새로 시작하는 것. (어짜피 기획이 확정된 상황이 아니라 이런 큰 결심을 할 수 있었다.)
refactor: remove all커밋 메시지가 전부다. 4개 앱 전체를 삭제하고 Turborepo 공식 템플릿(create-turbo)에서 다시 시작했다.
왜 Next.js로 갔나
SEO가 필요해서가 아니었다. 주된 이유는 세 가지였다.
next/image— 이미지 최적화가 필요했는데, Vite에서 직접 구현하는 것보다 Next.js가 훨씬 편했다.- 파일 기반 라우팅 — React Router로 직접 라우트를 관리하는 것보다 App Router의 폴더 구조가 코드 정리에 유리했다.
- 공유 패키지 수정이 즉시 반영 — 기존 Vite 기반 workspace에서는
packages/ui를 수정할 때마다 해당 패키지를 직접 빌드해줘야 앱에 반영됐다.transpilePackages옵션을 쓰면 Next.js가 패키지 소스를 직접 읽어서 트랜스파일하기 때문에, 공유 컴포넌트를 수정하면 앱에서 바로 확인할 수 있었다. 개발 중에 UI 패키지를 자주 손봐야 하는 상황에서 이 차이가 꽤 컸다.
왜 Turborepo였나
여러 앱과 패키지의 빌드 순서와 캐싱을 직접 관리하는 건 번거롭다. Turborepo는 의존성 그래프를 분석해서 빌드 순서를 자동으로 결정하고, 변경되지 않은 패키지는 캐시에서 꺼낸다. 모노레포 빌드 속도 문제를 설정 몇 줄로 해결했다.
공식 템플릿 활용
처음부터 설정을 직접 짜지 않고 create-turbo로 시작한 게 좋은 선택이었다. ESLint, Prettier, TypeScript 설정이 이미 모노레포에 맞게 세팅돼 있다. 이걸 베이스로 프로젝트에 맞게 수정했다.
네이밍 컨벤션도 이때 정했다.
apps/ → 네임스페이스 없이 (inference, training)
packages/ → @project/ 프리픽스 (@project/ui, @project/styles)3단계: Feature-driven Architecture
앱이 커지면서 구조 문제가 본격적으로 드러났다.
추론 관리 시스템 안에는 성격이 전혀 다른 두 도메인이 공존하고 있었다.
| 추론/코드/로그 관리 | 폼, 테이블, CRUD | 일반적인 관리 시스템 |
| GIS 지도 시각화 | OpenLayers, 좌표계 변환, 레이어 관리 | 지도 전용 기술 스택 |
이 두 도메인이 같은 components/, hooks/ 폴더 안에 뒤섞여 있었다. 파일을 찾으려면 여러 폴더를 오가야 했다.
GIS 분리 가능성을 고려한 구조 설계
더 중요한 문제가 있었다. GIS 기능이 언제 별도 앱으로 떨어져 나갈지 몰랐다. 처음부터 독립 앱으로 갈 수도 있고, 계속 추론 시스템 안에 남을 수도 있는 상황이었다.
이 불확실성 때문에 도메인 경계를 코드 레벨에서 명확하게 그어두는 것이 중요했다. 나중에 분리하더라도 최소한의 비용으로 할 수 있도록.
폴더 구조 변경
# Before
src/
├── components/
│ ├── DataTable/
│ ├── MapViewer/ # GIS 컴포넌트가 일반 컴포넌트와 섞임
│ └── SearchForm/
├── hooks/
└── stores/
# After
src/
└── features/
├── inference/ # 추론 관리 도메인
│ ├── accounts/
│ ├── logs/
│ ├── model/
│ ├── ...
│ └── shared/ # 도메인 내 공통
├── gis/ # GIS 지도 시각화 도메인
│ ├── changeDetection/
│ └── shared/
└── labeling/ # 라벨링 도메인각 feature 안에 관련된 api, components, hooks, stores, types를 전부 모았다. 도메인 안에서 공유하는 코드는 각 shared/에 넣었다. GIS가 분리되더라도 features/gis/ 폴더째로 옮기면 된다.
Next.js 라우트 그룹 활용
같은 이유로 라우트도 도메인별로 분리했다.
app/
├── (inference-accounts)/ # 계정 관리 (별도 레이아웃)
├── (inference)/ # 추론 관리 도메인
│ ├── code-management/
│ ├── log-management/
│ └── inference-management/
├── (gis)/ # GIS 지도 시각화 도메인
│ └── change-detection/
└── (labeling)/ # 라벨링 도메인괄호 폴더는 URL에 영향을 주지 않는다. 계정 관리처럼 헤더/사이드바 레이아웃이 완전히 다른 경우 별도 라우트 그룹으로 분리해서 layout.tsx를 독립적으로 가져갔다. GIS나 라벨링이 별도 앱으로 분리될 경우 라우트 그룹째로 이동하면 된다.
4단계: 멀티앱 확장과 패키지 빌드
추론 시스템과 학습 시스템의 독립 배포가 본격적으로 필요해지면서 두 앱이 명확하게 분리됐다.
apps/
├── inference/ # port 3001
└── training/ # port 3002코드와 컴포넌트는 packages/에서 공유하되, 빌드와 배포는 완전히 독립적으로 간다. 모노레포를 선택한 가장 핵심적인 이유가 여기서 실현됐다.
공유 패키지를 앱에서 쓰려면 무슨 일이 일어나는가
먼저 트랜스파일이 뭔지 짚고 넘어가야 한다. 브라우저는 TypeScript를 직접 실행하지 못한다. JavaScript만 이해한다. 그래서 .tsx → .js로 변환하는 과정이 필요한데, 이걸 트랜스파일이라고 한다.
Next.js는 src/ 안의 코드는 트랜스파일한다. 그런데 node_modules/ 안은 건너뛴다. 이유는 관례 때문이다. npm에 올라오는 패키지들은 원래 "이미 빌드된 JS"를 배포한다. react, lodash 같은 걸 열어보면 .ts 파일이 아니라 .js 파일이 들어있다. 그래서 Next.js는 "node_modules 안은 이미 JS겠지" 하고 그냥 통과시킨다.
문제는 모노레포에서 생긴다. pnpm workspace를 쓰면 packages/ui가 심볼릭 링크로 node_modules/@project/ui에 연결된다. Next.js 입장에서는 그냥 node_modules 안의 패키지처럼 보인다. 근데 실제 파일을 열어보면 .tsx 파일이다. 빌드된 적이 없으니까.
Next.js: "node_modules 안이네, 이미 JS겠지" → 그냥 통과
실제 파일: Button.tsx (TypeScript) → 브라우저가 못 읽음 → 에러이 문제를 해결하는 방법은 두 가지다.
방법 1: transpilePackages (임시 해결)
// next.config.ts
const nextConfig = {
transpilePackages: ['@project/ui', '@project/styles'],
}Next.js에게 "이 패키지는 내가 직접 트랜스파일할게"라고 알려주는 옵션이다. 패키지를 미리 빌드하지 않아도 되고, 소스를 수정하면 앱에 즉시 반영된다. 개발 중에는 편하다.
문제는 Turborepo의 캐싱과 충돌한다는 점이다.
Turborepo는 "이 패키지 소스가 바뀌지 않았으면 다시 빌드하지 않는다"는 방식으로 동작한다. 그런데 transpilePackages를 쓰면 패키지 빌드가 앱 빌드 안으로 녹아들어가기 때문에, Turborepo 입장에서는 패키지를 독립된 단위로 캐싱할 수가 없다. 앱을 빌드할 때마다 패키지 소스도 매번 다시 트랜스파일된다. 앱이 두 개가 되면 두 번, 세 개가 되면 세 번.
방법 2: tsup pre-build (근본 해결)
tsup은 TypeScript 패키지를 빌드하는 도구다. 패키지를 앱과 독립적으로 미리 빌드해두면, 앱은 소스 대신 빌드 결과물(dist/)을 가져다 쓴다.
// packages/ui/tsup.config.ts
export default defineConfig((options) => {
const isWatch = options.watch;
return {
entry: ['src/components/index.ts', '!src/**/*.test.{ts,tsx}'],
outDir: 'dist',
format: ['esm'],
dts: true,
splitting: false,
sourcemap: false,
clean: !isWatch,
};
});옵션 하나씩 뜯어보면:
entry: ['src/components/index.ts', '!src/*/*.test.{ts,tsx}']— 진입점은 컴포넌트 인덱스 파일 하나.!로 시작하는 패턴은 제외 규칙이라 테스트 파일은 번들에 포함하지 않는다.format: ['esm']— ES Module 형식으로 빌드. Next.js App Router가 ESM 기반이라 맞춰줬다.dts: true—.d.ts타입 정의 파일도 함께 생성. 이게 없으면 앱에서 타입을 못 읽는다.splitting: false— 출력을 단일 파일로 유지. 패키지를 여러 청크로 쪼개면 앱 번들러가 의존성을 추적하기 복잡해진다. tree-shaking은 앱 번들러(Next.js/webpack)가 알아서 처리한다.clean: !isWatch— 빌드 전에dist/를 비울지 여부. watch 모드일 때는false로 꺼둔다. watch 중에dist/가 잠깐 지워지는 순간, 앱 dev 서버가 모듈을 못 찾아서 에러가 터지기 때문이다.
이렇게 하면 Turborepo가 패키지 빌드를 독립적으로 캐싱할 수 있다. packages/ui 소스가 바뀌지 않았으면 앱을 빌드할 때 패키지 빌드를 건너뛴다.
tsup으로 전환하고 나서 신기한 점이 생겼다. packages/ui의 컴포넌트를 수정하면 앱에 바로 반영된다. 수동으로 빌드를 실행한 적이 없는데도.
이 흐름을 굴러가게 만드는 세팅
tsup.config.ts만 만든다고 빌드가 알아서 되는 건 아니다. 설정 파일은 레시피일 뿐이고, 실행 트리거를 연결해야 한다. 필요한 건 두 가지다.
1. 패키지에 스크립트 등록
// packages/ui/package.json
{
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
}
}
build는 한 번 빌드하고 끝나는 작업, dev는 소스 변경을 감시하며 계속 재빌드하는 작업이다. 이 구분이 중요한데, turbo.json에서 두 작업을 연결하는 방식이 다르기 때문이다.
2. turbo.json에 작업 관계 연결
// turbo.json
{
"tasks": {
"inference#dev": {
"with": ["@project/ui#dev"]
},
"training#dev": {
"with": ["@project/ui#dev"]
},
"inference#build": {
"dependsOn": ["@project/ui#build"]
},
"training#build": {
"dependsOn": ["@project/ui#build"]
}
}
}
dependsOn과 with는 역할이 다르다.
dependsOn은 "저 작업이 끝난 다음에 나를 시작하라"는 뜻이다. 앱을 빌드하려면 dist/가 먼저 존재해야 하니까, build처럼 끝이 있는 작업의 순서 보장에 쓴다. Turborepo 캐싱도 이 단위로 동작한다.
with는 "나를 실행할 때 저 작업도 같이 띄워라"는 뜻이다. tsup --watch는 영원히 끝나지 않는 persistent 작업이라, dependsOn으로 걸면 watch가 끝나기를 무한정 기다리다 앱 dev 서버가 영영 안 뜬다. 그래서 dev 계열은 전부 with로 연결한다.
dev → with (동시에 띄운다, watch는 끝나지 않으니까)
build → dependsOn (끝나길 기다린다, dist가 있어야 하니까)
이 세팅이 완성되면 turbo dev 하나로 앱 dev 서버와 tsup watch가 나란히 뜨고, turbo build를 돌리면 패키지 빌드 → 앱 빌드 순서가 보장되면서 소스가 안 바뀐 패키지는 캐시로 건너뛴다.
5단계: 상태관리와 폼 체계화
기능이 늘어나면서 상태관리와 폼 처리 방식을 통일할 필요가 생겼다.
선택한 조합
| Zustand | 전역 UI 상태 (모달, 사이드바, 헤더) |
| Zod | 스키마 정의 + 런타임 유효성 검사 |
| react-hook-form | 폼 상태 관리 |
| react-aria-components | 접근성 있는 headless UI |
Zod + react-hook-form 조합이 좋은 이유
const schema = z.object({
name: z.string().min(1, '필수 항목'),
email: z.string().email('이메일 형식'),
})
type FormValues = z.infer<typeof schema> // 타입 자동 추출
const { register, handleSubmit } = useForm<FormValues>({
resolver: zodResolver(schema),
})스키마 하나로 TypeScript 타입과 런타임 검증을 동시에 처리한다. API 요청 타입 검증에도 같은 Zod 스키마를 재사용할 수 있다.
Zustand 사용 범위를 좁게 유지
Zustand는 전역 UI 상태만 담당하도록 했다. 서버 데이터는 react-query로 분리. 이 경계를 지키니까 상태가 예측 가능한 범위 안에서 관리됐다.
되돌린 것들
진행하면서 추가했다가 제거한 것들도 있다.
| 도입 | 이유 | 제거 이유 |
|---|---|---|
| i18n | 나중에 쓸 것 같아서 | 실제로 쓰지 않음 (YAGNI) |
transpilePackages |
빌드 에러 임시 해결 | tsup으로 대체 |
| Tailwind 설정 패키지 | 앱 간 Tailwind 설정 공유 | Tailwind 4는 CSS 파일만으로 충분 |
| 별도 도메인 layout | 예상했던 필요성 | 실제로는 불필요 |
Tailwind 설정 패키지가 사라진 이유
Tailwind v3까지는 커스텀 색상, 폰트, 간격 등을 tailwind.config.js에 정의했다. 모노레포에서 여러 앱이 같은 디자인 토큰을 써야 했기 때문에 packages/tailwind-config 패키지를 만들고, 각 앱의 config에서 import해서 썼다.
// apps/inference/tailwind.config.js
const baseConfig = require('@project/tailwind-config')
module.exports = { ...baseConfig }Tailwind v4부터는 JS 설정 파일이 없어졌다. 커스터마이징을 CSS 파일 안의 @theme으로 한다.
/* packages/styles/css/base.css */
@import "tailwindcss";
@theme {
--color-primary: #3b82f6;
--font-family-sans: 'Pretendard', sans-serif;
}각 앱은 이 CSS 파일만 import하면 끝이다.
/* apps/inference/src/app/globals.css */
@import "@project/styles/css/base.css";JS 패키지를 만들어서 공유하는 구조가 통째로 필요 없어졌다. packages/tailwind-config를 삭제하고 packages/styles 안의 CSS 파일로 흡수했다.
필요 없어진 걸 삭제할 수 있다는 것이 좋은 코드베이스의 신호라고 생각한다.
마치며
돌아보면 이 프로젝트에서 가장 어려운 부분은 기술적인 문제가 아니었다. GIS 시스템을 합칠지 분리할지 결정이 안 난 상태에서 구조를 설계해야 하는 것, 즉 불확실성 속에서 확장 가능한 경계를 미리 그어두는 것이었다.
결국 선택한 방법은 결정을 미루되, 나중에 어느 방향으로든 바꾸기 쉬운 구조를 만드는 것이었다. Feature-driven 폴더 구조와 라우트 그룹이 그 역할을 했다. 실제로 GIS가 분리되더라도 feature 폴더와 라우트 그룹을 통째로 옮기면 된다.
반복된 패턴 몇 가지:
- 공식 템플릿에서 시작한다. 처음부터 직접 짜면 놓치는 게 많다.
- 결정이 안 난 것은 미루되, 쉽게 바꿀 수 있는 구조를 만든다. 경계를 코드 레벨에서 명확하게 그어두면 나중에 선택지가 생긴다.
- 임시 해결과 근본 해결을 구분한다. 임시 해결은 괜찮다. 단, 나중에 근본 해결로 교체한다.
- 삭제도 작업이다. 필요 없어진 코드를 지우는 PR이 기능 추가 PR만큼 중요하다.
모노레포는 처음 세팅이 어렵지, 한번 자리잡으면 오히려 편하다. 특히 공유 컴포넌트와 설정을 한 곳에서 관리할 수 있다는 점이 프로젝트가 커질수록 더 빛을 발했다.
'React, Next.js' 카테고리의 다른 글
| [Next.js] 배포 환경에서만 터지는 Next.js Server Actions 500 에러 — nginx $host vs $http_host (0) | 2026.06.17 |
|---|---|
| 브라우저에서 GeoTIFF 이미지 미리보기 구현하기 (Next.js API Route + geotiff + sharp) (0) | 2026.06.17 |
| [Next.js] Hydration Mismatch 에러 (0) | 2026.02.19 |
| Vite와 Next.js에서 ENV/MODE/NODE_ENV 정리 (0) | 2025.11.19 |
| React Query (Tanstack Query) + Suspense + Error Boundary (0) | 2025.11.09 |