프론트엔드 모노레포 boundary 전략: 패키지 경계와 의존성을 어떻게 지킬까?

Frontend

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

shared package와 domain package를 어느 정도 나눠놓고 나면, 결국 남는 질문은 이것입니다.

  • 어떤 package가 어떤 package를 참조해도 되는가
  • 앱에서 package 내부 구현을 바로 참조해도 되는가
  • uiauth를 알아도 되는가
  • 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/admin

package가 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/web

boundary가 무너지는 흔한 패턴

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 이름을 명확하게 짓기

  • ui
  • auth
  • api
  • config-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를 분리하는 것 자체가 아니라, 레포 안에서 어떤 코드가 누구를 알아도 되는지 팀이 설명할 수 있는 상태를 만드는 것입니다.

같이 보면 좋은 글