프론트엔드 모노레포 boundary 전략: 패키지 경계와 의존성을 어떻게 지킬까?
이 글은 프론트엔드 모노레포 아키텍처 시리즈 3편입니다.
shared package와 domain package를 어느 정도 나눠놓고 나면, 결국 남는 질문은 이것입니다.
- 어떤 package가 어떤 package를 참조해도 되는가
- 앱에서 package 내부 구현을 바로 참조해도 되는가
ui가auth를 알아도 되는가web앱 전용 코드가 shared package로 새어나가도 되는가
즉, 모노레포 boundary 전략은 폴더 구조보다 의존성 방향을 어떻게 통제할 것인가의 문제에 더 가깝습니다.
실무에서 모노레포가 무너지는 시점도 대체로 여기입니다.
- package 수는 늘었는데 의존성 방향은 제어되지 않고
- 앱 전용 요구사항이 shared package로 새고
- 공통 package가 내부 구현까지 노출하면서
- 결국 "분리된 것처럼 보이지만 사실은 다 엮여 있는" 상태가 됩니다
이 글에서는 프론트엔드 모노레포에서 boundary를 어떻게 잡고, 어떤 원칙으로 지키면 좋은지 정리해보겠습니다.
한눈에 보면
먼저 짧게 정리하면 이렇습니다.
- 모노레포 boundary의 핵심은 package 개수보다 의존성 방향입니다.
- 앱은 package를 참조해도 되지만, package는 앱을 참조하면 안 됩니다.
- 낮은 레벨 package는 높은 레벨 package를 참조하지 않는 편이 안정적입니다.
- boundary는 문서로만 두기보다 package entry와 lint rule로 같이 지키는 편이 좋습니다.
조금 더 자세히 보면 아래처럼 볼 수 있습니다.
| 원칙 | 의미 |
|---|---|
| app -> package | 가능 |
| package -> app | 지양 |
| ui -> auth | 상황에 따라 위험 |
| auth -> ui | 보통 더 위험 |
| 내부 파일 직접 import | 지양 |
| public entry만 import | 권장 |
빠르게 정리하면, 모노레포 boundary는 "누가 누구를 알아도 되는가"를 분명하게 정하는 것에 가깝습니다.
왜 boundary가 중요할까?
처음에는 아래처럼 단순하게 보일 수 있습니다.
apps/
web/
admin/
packages/
ui/
auth/
api/하지만 시간이 지나면 이런 일이 생깁니다.
apps/web -> packages/ui
packages/ui -> packages/auth
packages/auth -> packages/api
packages/api -> apps/web이 순간부터 경계는 사실상 무너집니다.
ui는 더 이상 순수 UI가 아니고api는 특정 앱 구현을 알게 되며web앱 변경이 shared package 설계까지 흔들 수 있습니다
즉, boundary 전략은 코드 위치를 예쁘게 만드는 문제가 아니라, 변경 반경을 통제하는 장치입니다.
가장 먼저 지켜야 할 원칙: app은 package를 참조하지만, package는 app을 참조하지 않는다
이 원칙은 단순하지만 강력합니다.
좋은 예
apps/web -> packages/ui
apps/web -> packages/auth
apps/admin -> packages/ui
apps/admin -> packages/auth좋지 않은 예
packages/ui -> apps/web
packages/auth -> apps/adminpackage가 app을 참조하기 시작하면 shared package가 아니라 앱 내부 구현에 기대는 package가 됩니다.
예를 들어:
// packages/ui/src/button.tsx
import { webTheme } from '../../../apps/web/theme';이런 구조는 package를 공유 가능한 단위로 보기 어렵게 만듭니다.
즉, shared package는 app의 구현 상세를 모르는 상태여야 합니다.
두 번째 원칙: package는 public entry를 통해서만 참조한다
모노레포에서 흔한 문제 중 하나는 내부 파일 직접 import입니다.
좋지 않은 예
import { Button } from '@repo/ui/src/button';
import { canAccessDashboard } from '@repo/auth/src/lib/can-access-dashboard';이 방식은 빠르게는 편해 보이지만, 실제로는 package 내부 구조를 외부에 노출합니다.
더 나은 예
import { Button } from '@repo/ui';
import { canAccessDashboard } from '@repo/auth';그리고 package entry에서 공개 범위를 명시합니다.
// packages/auth/src/index.ts
export * from './lib/can-access-dashboard';
export * from './model/session';즉, boundary는 "어디에 둘까"뿐 아니라, 무엇을 외부에 공개할까도 같이 포함합니다.
세 번째 원칙: UI package와 domain package를 섞지 않는다
실무에서 자주 생기는 일은 이겁니다.
packages/ui/
login-button.tsx
admin-guard.tsx겉으로 보면 재사용 가능한 UI처럼 보이지만, 실제로는 auth 도메인과 role 정책이 섞여 있습니다.
예를 들어:
import { useSession } from '@repo/auth';
export function AdminGuard({ children }: { children: React.ReactNode }) {
const session = useSession();
if (session?.role !== 'admin') return null;
return <>{children}</>;
}이 코드는 UI라기보다 auth 도메인 규칙과 더 가깝습니다.
즉:
ui는 표현 계층auth는 비즈니스 규칙
처럼 boundary를 나누는 편이 더 안정적입니다.
package 사이 의존성 방향은 어떻게 보면 좋을까?
프론트엔드 모노레포에서는 보통 아래 정도 방향이 비교적 안정적입니다.
apps/*
-> domain packages (auth, billing, catalog)
-> ui
-> api
-> config packages
domain packages
-> api
-> utils (있다면 아주 제한적으로)
ui
-> lower-level util 정도만반대로 아래는 조심할 필요가 있습니다.
ui -> auth
ui -> billing
api -> app
config -> app-specific code이 방향이 왜 위험한지 한 줄로 정리하면, 낮은 레벨 package가 높은 레벨 문맥을 알기 시작하기 때문입니다.
디렉토리 구조 예시로 보면
아래처럼 시작하는 경우를 생각해보겠습니다.
repo/
apps/
web/
admin/
packages/
ui/
auth/
api/
config-eslint/
config-typescript/이 구조에서 비교적 자연스러운 흐름은 이렇습니다.
apps/web
-> @repo/ui
-> @repo/auth
-> @repo/api
apps/admin
-> @repo/ui
-> @repo/auth
-> @repo/api그리고 @repo/auth 안에서는 이런 정도가 자연스럽습니다.
@repo/auth
-> @repo/api하지만 아래는 경계가 흔들리는 신호일 수 있습니다.
@repo/ui
-> @repo/auth
@repo/api
-> apps/webboundary가 무너지는 흔한 패턴
1. 앱 전용 요구사항이 shared package로 올라간다
예를 들어 web 전용 버튼 variant가 shared ui package에 계속 들어가기 시작하면, ui는 공통 UI보다 web 전용 UI 모음에 가까워질 수 있습니다.
2. domain package가 서로 너무 많이 참조한다
auth -> billing
billing -> auth
catalog -> auth
notification -> billing이런 구조는 시간이 지나면 graph가 지나치게 촘촘해집니다.
3. config package가 앱별 조건을 알기 시작한다
예를 들어:
// packages/config-eslint/base.js
module.exports = {
rules: {
// apps/web 전용 예외 규칙
},
};이런 식으로 특정 앱 문맥이 config package에 들어가면 공통 설정의 의미가 흐려집니다.
boundary를 어떻게 지킬까?
문서만으로는 한계가 있습니다. 보통 아래 세 가지를 같이 보는 편이 좋습니다.
1. package 이름을 명확하게 짓기
uiauthapiconfig-typescript
처럼 이름만 봐도 책임이 드러나는 편이 좋습니다.
2. public entry를 강제하기
// packages/ui/src/index.ts
export * from './button';
export * from './input';외부에서는 package root만 import하게 두는 편이 boundary를 지키기 쉽습니다.
3. lint rule이나 리뷰 기준으로 의존성 방향을 본다
예를 들어 boundary를 문서로만 두지 말고, 리뷰에서 아래를 반복적으로 확인할 수 있습니다.
- package가 app을 참조하고 있지 않은가
- 내부 파일 직접 import가 생기지 않았는가
ui가 도메인 규칙을 알기 시작하지 않았는가- 공통 설정 package가 앱 전용 예외를 담고 있지 않은가
필요하면 lint rule로도 잡을 수 있습니다.
{
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": ["@repo/*/src/*"]
}
]
}
}즉, boundary는 의식만으로 지키기보다 import 경로와 공개 범위를 통해 계속 확인되는 상태가 더 안정적입니다.
실무에서 현실적으로 타협해야 하는 지점
물론 모든 boundary를 완벽하게 지키기는 어렵습니다.
예를 들어:
- 작은 팀에서는 package 수를 너무 세밀하게 나누기 어렵고
- 빠른 개발이 중요할 때는 shared package에 앱 요구사항이 조금 섞일 수 있으며
- 초기에는 review rule이 lint rule보다 더 현실적일 수 있습니다
중요한 것은 완벽함보다 어느 지점에서 경계가 깨지고 있는지 팀이 알고 있는가입니다.
정리하면
프론트엔드 모노레포 boundary 전략의 핵심은 package 개수보다 의존성 방향입니다.
실무 기준으로 정리하면:
- app은 package를 참조하지만, package는 app을 참조하지 않는 편이 좋고
- public entry만 통해 package를 참조하게 두는 편이 안정적이며
- UI와 도메인 규칙을 같은 package에 섞지 않고
- boundary는 문서가 아니라 import 규칙과 공개 범위로 같이 지키는 편이 더 현실적입니다
중요한 것은 package를 분리하는 것 자체가 아니라, 레포 안에서 어떤 코드가 누구를 알아도 되는지 팀이 설명할 수 있는 상태를 만드는 것입니다.
