프론트엔드 모노레포에서 도메인 패키지는 어떻게 설계할까?

Frontend

이 글은 프론트엔드 모노레포 아키텍처 시리즈 2편입니다.

앞 글에서 shared package를 어떤 기준으로 나누면 좋은지 정리했다면, 다음으로는 더 어려운 질문이 남습니다.

  • auth 같은 건 shared package일까, domain package일까
  • api는 범용 패키지일까, 도메인 패키지일까
  • billing, notification, catalog 같은 도메인 로직은 어디까지 package로 분리해야 할까

프론트엔드 모노레포에서 도메인 패키지 설계는 단순히 폴더를 예쁘게 나누는 문제가 아닙니다. 실제로는 아래를 같이 결정하는 문제에 가깝습니다.

  • 비즈니스 규칙을 어디에 둘 것인가
  • 앱 간 중복을 어디까지 허용할 것인가
  • 어떤 변경이 어떤 앱에 영향을 줄 것인가

즉, 도메인 패키지 설계는 재사용성보다 비즈니스 경계와 변경 반경을 어떻게 잡을지에 더 가깝습니다.

한눈에 보면

먼저 짧게 정리하면 이렇습니다.

  • 도메인 패키지는 "범용 코드"보다 비즈니스 책임이 분명한 코드를 묶을 때 의미가 있습니다.
  • auth, billing, catalog처럼 이름만 보고 책임이 드러나는 패키지가 더 안정적입니다.
  • 반대로 services, core, common-domain처럼 추상적인 패키지는 시간이 갈수록 경계가 흐려지기 쉽습니다.

조금 더 자세히 보면 아래처럼 볼 수 있습니다.

기준 안정적인 방향 불안정한 방향
패키지 이름 auth, billing, catalog core, services, domain-common
분리 기준 비즈니스 책임 단순 코드 양
영향 범위 어떤 앱이 이 도메인을 쓰는지 설명 가능 여러 앱이 두루 참조하지만 책임은 모호
변경 관리 도메인 단위로 추적 가능 fan-out만 넓어짐

빠르게 정리하면, 도메인 패키지는 기술 기준보다 비즈니스 기준으로 나누는 편이 더 오래 갑니다.

도메인 패키지가 왜 필요한가?

프론트엔드 앱이 커지면 UI만 공통화해서는 부족해집니다. 예를 들어 아래 같은 코드가 여러 앱에서 반복되기 시작합니다.

  • 로그인 상태 확인
  • 권한 판단
  • 결제 상태 계산
  • 상품 옵션 조합
  • 알림 읽음 처리

이런 코드는 단순 util도 아니고, 공통 UI도 아닙니다. 그렇다고 앱 안에만 두면 같은 비즈니스 규칙이 여러 곳에 흩어질 수 있습니다.

이럴 때 도메인 패키지가 의미를 가집니다.

어떤 코드는 domain package에 가깝고, 어떤 코드는 아닌가?

예를 들어 아래 코드를 보겠습니다.

export function isAdminRole(role: string) {
  return role === 'admin';
}

겉보기에는 util처럼 보이지만, 실제로는 auth/role 도메인 규칙에 가깝습니다.

반면 아래 코드는 더 범용 util에 가깝습니다.

export function chunk<T>(items: T[], size: number) {
  const result: T[][] = [];
  for (let i = 0; i < items.length; i += size) {
    result.push(items.slice(i, i + size));
  }
  return result;
}

즉, package를 나눌 때 중요한 것은 코드 모양보다 그 코드가 어떤 비즈니스 의미를 갖는가입니다.

실무에서는 어떤 도메인 패키지가 자주 보일까?

보통 아래는 비교적 자연스럽게 나오는 편입니다.

packages/
  auth/
  billing/
  catalog/
  notification/
  ui/
  api/

예를 들어 auth 패키지는 아래 책임을 가질 수 있습니다.

packages/auth/
  src/
    model/
      session.ts
      role.ts
    lib/
      is-admin-role.ts
      is-logged-in.ts
    api/
      refresh-token.ts
    index.ts

이 구조가 의미 있는 이유는 package 이름만 보고도 책임이 설명되기 때문입니다.

api는 domain package일까?

이 질문은 실무에서 자주 헷갈립니다.

보통 api package는 두 가지로 나뉩니다.

1. 범용 API client

export async function request<T>(url: string, init?: RequestInit): Promise<T> {
  const response = await fetch(url, init);
  if (!response.ok) throw new Error('Request failed');
  return response.json();
}

이건 domain package보다 infra 성격에 가깝습니다.

2. 도메인 API

export async function fetchCurrentUser() {
  return request<User>('/api/me');
}

이건 auth 혹은 user 도메인에 더 가깝습니다.

즉, api라는 이름 하나로 모든 것을 묶기보다:

  • 범용 request client는 api 혹은 infra package
  • 사용자, 결제, 상품 API는 각 도메인 package

로 나누는 편이 경계가 더 분명합니다.

package를 나누는 기준은 무엇이 좋을까?

실무에서는 보통 아래 순서로 보는 편이 좋습니다.

1. 이 책임은 앱을 넘어 반복되는가?

한 앱에서만 쓰는 도메인 로직이라면 package보다 앱 내부에 두는 편이 더 단순할 수 있습니다.

2. 이 책임은 비즈니스 이름으로 설명되는가?

auth, billing, catalog처럼 이름이 곧 책임이면 package로 나누기 쉽습니다.

3. 바뀌었을 때 영향 받는 앱을 설명할 수 있는가?

예를 들어:

packages/auth
  ├─> apps/web
  └─> apps/admin

이 정도는 비교적 설명이 쉽습니다.

반면:

packages/core
  ├─> apps/web
  ├─> apps/admin
  ├─> apps/docs
  ├─> packages/ui
  └─> packages/api

이 구조는 바뀌었을 때 영향 범위는 큰데, 책임은 설명하기 어렵습니다.

좋지 않은 설계 패턴은 무엇일까?

1. core

packages/core/

처음에는 편해 보여도 시간이 지나면 무엇이든 들어가기 쉽습니다.

2. services

서비스라는 이름도 너무 넓습니다.

  • API client
  • 도메인 로직
  • 상태 관리
  • 유틸

가 섞이기 쉽습니다.

3. domain-common

도메인 공통이라는 말은 좋아 보이지만, 실제로는 여러 도메인이 섞이는 출발점이 되기 쉽습니다.

즉, 추상적인 이름은 package를 정리하는 것이 아니라 경계를 숨기는 역할을 하게 됩니다.

그럼 package는 어디까지 쪼개야 할까?

이건 너무 빨리 쪼개도, 너무 늦게 쪼개도 문제가 됩니다.

너무 빠를 때

packages/
  auth-session/
  auth-role/
  auth-api/
  auth-storage/

이 구조는 세밀해 보이지만, 초기에는 오히려 과할 수 있습니다.

너무 늦을 때

packages/
  shared/
  common/

이 구조는 단순해 보이지만, 결국 모든 비즈니스 규칙이 섞일 수 있습니다.

실무에서는 보통 아래 정도가 더 현실적입니다.

packages/
  auth/
  billing/
  catalog/
  ui/
  api/

즉, 도메인 패키지는 너무 미세하게 쪼개기보다 하나의 비즈니스 이름으로 설명 가능한 단위가 더 안정적입니다.

예시: auth package를 어떻게 설계할까?

아래처럼 역할을 나눌 수 있습니다.

packages/auth/
  src/
    model/
      session.ts
      role.ts
    lib/
      is-admin-role.ts
      can-access-dashboard.ts
    api/
      fetch-session.ts
      logout.ts
    index.ts

예를 들어:

// packages/auth/src/lib/can-access-dashboard.ts
import type { Session } from '../model/session';
 
export function canAccessDashboard(session: Session | null) {
  return session?.role === 'admin' || session?.role === 'operator';
}

이 코드는 범용 util보다 auth 도메인 규칙으로 보는 편이 자연스럽습니다.

도메인 패키지를 만들 때 같이 봐야 하는 것

1. shared UI와 섞이지 않게 하기

auth package 안에 로그인 버튼 컴포넌트까지 다 넣을 수도 있습니다. 하지만 이 경우 UI와 도메인 경계가 섞일 수 있습니다.

보통은:

  • 도메인 규칙은 auth
  • 공통 UI는 ui

처럼 두고, 앱에서 조합하는 편이 더 단순합니다.

2. 앱 전용 요구사항을 package에 너무 빨리 올리지 않기

web에서만 필요한 auth 예외 규칙을 package에 바로 올리면 admin도 그 영향을 같이 받게 됩니다.

3. fan-out을 계속 보기

도메인 package도 결국 여러 앱이 붙기 시작하면 변경 반경이 넓어질 수 있습니다.

즉, domain package는 "도메인이라서 안전한" 것이 아니라, 책임이 명확해서 다루기 쉬운 것에 가깝습니다.

정리하면

프론트엔드 모노레포에서 domain package는 기술 기준보다 비즈니스 기준으로 나누는 편이 더 안정적입니다.

실무 기준으로 정리하면:

  • auth, billing, catalog처럼 이름만 보고 책임이 드러나는 패키지가 더 좋고
  • core, services, domain-common처럼 추상적인 패키지는 가능한 늦게 만들며
  • 범용 util과 도메인 규칙을 섞지 않고
  • 공통화보다 영향 반경을 같이 보는 편이 더 현실적입니다

중요한 것은 package를 많이 만드는 것이 아니라, 이 비즈니스 규칙이 왜 이 package 안에 있어야 하는지 팀이 설명할 수 있는 상태를 만드는 것입니다.

다음 글에서 이어서 보기

여기까지는 도메인 패키지를 어떤 기준으로 설계하면 좋은지 정리했습니다. 다음 글에서는 마지막으로, 이런 package들이 실제로 서로를 어떻게 참조해야 하는지, 그리고 경계를 어떻게 지켜야 하는지를 boundary 전략 관점에서 정리합니다.

같이 보면 좋은 글