Turborepo Boundaries로 모노레포 패키지 의존성 규칙을 강제하는 법
패키지 간 아키텍처 경계(package boundaries)를 코드로 선언하고, CI에서 자동으로 검사하기
모노레포를 운영하다 보면 어느 순간 이런 PR 리뷰를 마주치게 됩니다. "잠깐, 이 프론트엔드 패키지에서 왜 백엔드 내부 모듈을 직접 import하고 있죠?" 그리고 그 대답은 대부분 이렇습니다. "그냥 옆에 있어서요..."
저도 처음 팀 모노레포를 세팅할 때 이 문제를 겪었습니다. Notion에 아키텍처 다이어그램을 그려두고, 컨트리뷰션 가이드에 "이 패키지는 저 패키지를 참조하면 안 된다"고 명시해뒀죠. 그래도 PR 리뷰에서 한두 개씩 놓치고, 새로 합류한 팀원이 규칙을 모른 채 ../../packages/internal-auth/src/utils를 그냥 import하는 일이 반복됐습니다. 최근에는 여기에 변수가 하나 더 생겼는데요. AI 코드 에이전트가 모노레포에서 맥락 없이 cross-package import를 만들어내는 문제가 팀마다 화두가 되고 있습니다. 사람보다 훨씬 빠르게 코드를 생성하는 에이전트가 dependency rules는 아무렇게나 넘어버리는 상황이 점점 잦아지고 있거든요.
Turborepo 2.4에서 실험적으로 출시된 Boundaries 기능은 이 문제를 "리뷰어의 눈"이 아닌 "코드와 CI"가 잡아내도록 만들어줍니다. 이 글을 다 읽고 나면, turbo.json에 선언적으로 dependency rules를 정의하고 monorepo 전체에 package boundaries를 자동으로 검사하는 CI 파이프라인을 구성할 수 있게 됩니다.
핵심 개념
모노레포에서 경계가 무너지는 이유
모노레포의 매력은 패키지들이 같은 파일 시스템 위에 공존한다는 점입니다. 그런데 이게 동시에 함정이기도 합니다. package.json에 의존성을 선언하지 않아도, 상대 경로로 ../../packages/internal-auth/src/secret.ts 같은 파일을 직접 import하는 게 기술적으로 아무 문제 없이 돌아가거든요. TypeScript 컴파일러는 경로만 맞으면 타입도 잘 잡아주니, 개발자 입장에서는 "그냥 되는데?" 싶은 상황이 됩니다.
pnpm workspace 기반 모노레포라면 pnpm-workspace.yaml에 패키지 경로가 등록되어 있고, 각 패키지의 package.json에는 다른 패키지를 dependencies로 선언해야 합니다. Turborepo Boundaries의 기본 검사는 바로 이 package.json의 dependencies 선언을 기준으로 동작합니다. 즉, package.json에 선언하지 않은 채로 import하거나, 패키지 디렉터리 외부의 파일을 직접 참조하면 위반으로 탐지합니다.
| 위반 유형 | 설명 |
|---|---|
| Package Manager Workspace 위반 | package.json의 dependencies에 선언되지 않은 패키지를 import하는 경우 |
| 패키지 디렉터리 이탈 | 패키지 경계 밖의 파일을 상대 경로로 직접 참조하는 경우 |
이 두 가지만으로도 "그냥 옆에 있어서" 가져다 쓰는 패턴의 상당수를 잡아낼 수 있습니다.
태그(Tag) 시스템으로 커스텀 아키텍처 규칙 선언하기
기본 위반 탐지에서 한 발 더 나아가, 태그 시스템을 활용하면 팀의 아키텍처 철학을 코드로 표현할 수 있습니다. 각 패키지에 turbo.json으로 태그를 붙이고, 루트 turbo.json에서 "어떤 태그가 어떤 태그를 의존할 수 있는지"를 선언하는 구조입니다.
// 루트 turbo.json
{
"$schema": "https://turbo.build/schema.json",
"boundaries": {
"tags": {
"public": {
"dependencies": {
"deny": ["internal"]
}
},
"frontend": {
"dependencies": {
"allow": ["ui", "utils"]
}
}
}
}
}// packages/ui/turbo.json
{
"extends": ["//"],
"tags": ["internal", "ui"]
}
allowvsdeny차이:allow는 화이트리스트 방식으로 나열된 태그 외 의존성을 모두 차단합니다.deny는 블랙리스트 방식으로 특정 태그만 선택적으로 금지합니다. 엄격한 레이어 설계에는allow가, 특정 패키지만 격리할 때는deny가 자연스럽습니다.
전이적(Transitive) 의존성 검사
솔직히 이 부분이 가장 인상적이었습니다. 단순히 직접 import만 검사하는 게 아니라, 의존성 체인 전체를 추적합니다. A가 B를 import하고, B가 denied 태그를 가진 C를 import하면, A에서도 위반이 감지됩니다.
A (public) → B → C (internal) ← A에서도 위반 감지!이 덕분에 "나는 직접 import하지 않았으니 괜찮겠지"라는 우회로가 구조적으로 막힙니다.
turbo boundaries를 실행하면 위반 사항이 이런 형태로 출력됩니다.
$ turbo boundaries
✗ packages/design-system
Cannot depend on "packages/internal-helpers" (tag: "internal")
Rule: tag "public" cannot depend on tag "internal"
Found 1 boundary violation.어떤 패키지가, 어떤 규칙을 위반했는지 명확하게 짚어주기 때문에 원인을 파악하는 데 시간이 거의 걸리지 않습니다.
실전 적용
예시 1: public/internal 패키지 계층 분리
디자인 시스템처럼 외부에 공개할 패키지와 내부 구현 전용 패키지를 구분하고 싶을 때 바로 쓸 수 있는 패턴입니다. 내부 헬퍼 함수가 외부 노출 패키지에 슬그머니 import되어 리팩토링 때 발목을 잡는 일, 모노레포를 운영해보셨다면 한 번쯤은 겪어봤을 겁니다.
// packages/design-system/turbo.json
{
"extends": ["//"],
"tags": ["public"]
}// packages/internal-helpers/turbo.json
{
"extends": ["//"],
"tags": ["internal"]
}// 루트 turbo.json
{
"$schema": "https://turbo.build/schema.json",
"boundaries": {
"tags": {
"public": {
"dependencies": {
"deny": ["internal"]
}
}
}
}
}| 설정 요소 | 역할 |
|---|---|
design-system → tags: ["public"] |
외부 노출 패키지임을 선언 |
internal-helpers → tags: ["internal"] |
내부 전용 패키지임을 선언 |
deny: ["internal"] |
public 패키지가 internal 패키지를 의존하면 위반 |
예시 2: frontend / backend 레이어 강제
Node.js 전용 모듈이 브라우저 번들에 섞이면 런타임 에러로 직결됩니다. 이 규칙을 설정해두면 빌드 전에 차단할 수 있습니다.
// apps/web/turbo.json
{
"extends": ["//"],
"tags": ["frontend"]
}// packages/api-server/turbo.json
{
"extends": ["//"],
"tags": ["backend"]
}// 루트 turbo.json
{
"$schema": "https://turbo.build/schema.json",
"boundaries": {
"tags": {
"backend": {
"dependents": {
"deny": ["frontend"]
}
}
}
}
}
dependenciesvsdependents차이:dependencies는 "이 태그가 무엇을 의존할 수 있는지" 규칙이고,dependents는 반대로 "이 태그를 누가 의존할 수 있는지" 규칙입니다. 위 예시에서backend에dependents.deny: ["frontend"]를 설정하면, 프론트엔드에서 백엔드를 참조하는 방향을 차단합니다.
apps/web에서 실수로 packages/api-server를 import하면 이런 메시지가 출력됩니다.
$ turbo boundaries
✗ apps/web
Cannot depend on "packages/api-server" (tag: "backend")
Rule: tag "backend" dependents cannot include tag "frontend"
Found 1 boundary violation."어, 이런 import가 있었구나"를 PR 머지 전에 미리 알 수 있는 것이죠.
예시 3: 레이어드 아키텍처 강제
패키지를 역할별로 레이어로 나누고, 하위 레이어가 상위 레이어를 참조하지 못하도록 막는 패턴입니다. Feature-Sliced Design(FSD, 레이어·슬라이스 원칙 기반의 프론트엔드 아키텍처 방법론)의 레이어 원칙을 코드 레벨에서 강제할 때도 같은 방식을 적용할 수 있습니다.
각 레이어가 담당하는 역할은 이렇습니다.
- core: 외부 의존성 없는 순수 유틸리티, 공통 타입 정의
- shared: 여러 기능에서 재사용하는 공통 컴포넌트·훅
- feature: 특정 도메인 기능 단위 패키지
- app: 실제 배포 가능한 애플리케이션
// 루트 turbo.json — 레이어드 아키텍처 규칙
{
"$schema": "https://turbo.build/schema.json",
"boundaries": {
"tags": {
"core": {
"dependencies": {
"allow": []
}
},
"shared": {
"dependencies": {
"allow": ["core"]
}
},
"feature": {
"dependencies": {
"allow": ["core", "shared"]
}
},
"app": {
"dependencies": {
"allow": ["core", "shared", "feature"]
}
}
}
}
}// packages/core-utils/turbo.json
{
"extends": ["//"],
"tags": ["core"]
}// apps/my-app/turbo.json
{
"extends": ["//"],
"tags": ["app"]
}allow 화이트리스트 방식이라 새 레이어가 추가되어도 규칙을 명시적으로 선언해야만 의존이 가능합니다. 역방향 의존성과 순환 의존성을 구조적으로 차단하는 효과가 있습니다.
예시 4: CI 파이프라인 통합
팀에서 경계 검사를 실제로 강제하려면 CI에 통합하는 게 핵심입니다. 개발자 로컬에서만 돌아가는 검사는 결국 잊혀지거든요. 저희 팀에서도 처음 두 달간 로컬에서만 돌리다가, 바쁜 스프린트 기간에 아무도 실행을 안 한다는 걸 뒤늦게 알았습니다. CI에 넣고 나서야 비로소 "이 검사가 있었구나"가 팀 전체에 정착됐습니다.
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
check-boundaries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install dependencies
run: pnpm install
- name: Check boundaries
run: pnpm turbo boundaries로컬 개발 환경에서는 루트 turbo.json에 "boundaries": true를 추가해 Boundaries 기능을 활성화할 수 있습니다. 이 설정은 기능 활성화 플래그로, turbo boundaries 명령을 직접 실행하거나 CI 스텝에서 호출하는 방식으로 경계 검사를 수행합니다.
// 루트 turbo.json — Boundaries 기능 활성화
{
"$schema": "https://turbo.build/schema.json",
"boundaries": true
}장단점 분석
직접 팀에 도입하면서 가장 체감된 장점은 "아키텍처 규칙이 코드에 남는다"는 점이었습니다. 슬랙 스레드나 Notion 문서가 아니라 turbo.json 파일에 남거든요. 새로 합류한 팀원이 레포지토리를 클론하면 어떤 패키지가 무슨 역할인지, 어떤 방향으로 의존이 허용되는지 바로 파악할 수 있게 됐습니다. 반면 가장 신경 쓰인 것은 아직 Experimental이라는 점이었는데, 마이너 업그레이드마다 릴리스 노트를 확인하는 루틴이 생긴 건 사실입니다.
장점
| 항목 | 내용 |
|---|---|
| 선언적 규칙 | turbo.json으로 아키텍처 규칙이 코드에 내재되어, 별도 문서 없이도 규칙이 자명해집니다 |
| 전이적 검사 | 직접 import뿐 아니라 간접 의존성 체인까지 추적해 우회로를 막습니다 |
| 빌트인 통합 | ESLint 플러그인 없이 Turborepo 자체에서 검사할 수 있어 설정 복잡도가 낮습니다 |
| 점진적 도입 | "boundaries": true 한 줄로 시작하고, 필요할 때 태그 규칙을 하나씩 추가할 수 있습니다 |
| CI 자동화 | turbo boundaries를 CI에 추가하면 위반 PR 머지 자체를 차단할 수 있습니다 |
| AI 코드 생성 방어 | AI 코딩 에이전트가 임의로 cross-package import를 만들어내는 패턴도 정적으로 탐지합니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Experimental 상태 | 2026년 4월 기준 여전히 실험 기능으로 API 변경 가능성이 있습니다 | 마이너 버전 업그레이드 시 릴리스 노트 확인을 권장합니다. RFC #9435 스레드를 팔로우하면 변경 예고를 미리 파악할 수 있습니다 |
| Nx 대비 성숙도 | Nx의 enforce-module-boundaries는 ESLint 레벨의 성숙한 에코시스템을 갖추고 있습니다 |
세밀한 파일 레벨 규칙이 필요하다면 두 도구를 병행하는 방법도 있습니다 |
| 파일 레벨 경계 미지원 | 현재는 패키지 단위 경계만 지원합니다 | 패키지 내부 파일 레벨 규칙은 ESLint import/no-restricted-paths를 병용하면 보완됩니다 |
| 태그 관리 비용 | 패키지가 많아질수록 각 turbo.json의 태그를 일일이 유지해야 합니다 |
태그를 최소한으로 유지하고 네이밍 컨벤션을 문서화해두면 관리가 훨씬 수월합니다 |
| JS/TS 전용 | 현재 JavaScript/TypeScript 생태계 중심입니다 | 폴리글랏 환경에서는 Dependency Cruiser(의존성 그래프를 시각화하고 커스텀 규칙으로 위반을 탐지하는 독립 도구)를 함께 활용할 수 있습니다 |
Experimental 기능 사용 시 주의:
turbo boundaries는 아직 안정화 전이므로, 마이너 버전 업그레이드 시에도 릴리스 노트를 꼭 확인하는 것을 권장합니다. Turborepo 공식 GitHub의 RFC #9435 스레드를 팔로우하면 변경 예고를 미리 파악할 수 있습니다.
실무에서 가장 흔한 실수
저희 팀이 처음 도입할 때 실제로 두 번째 실수에 빠진 경험이 있습니다. 태그 규칙은 정성스럽게 짰는데 막상 검사 결과가 기대와 달랐을 때, package.json 선언이 빠진 거라는 걸 한참 후에야 파악했거든요.
-
태그를 처음부터 너무 세분화하는 것: 레이어를 처음부터 10개 이상 나누면 관리 부담이 급격히 늘어납니다.
public/internal,frontend/backend수준의 단순한 구조에서 시작하고, 실제 필요가 생길 때 확장하는 방식이 훨씬 지속 가능합니다. 처음 설계에 힘을 너무 빼면 정작 팀이 규칙을 유지하지 못하는 상황이 생깁니다. -
package.json선언 없이 태그만 믿는 것: Boundaries의 기본 검사(Package Manager Workspace 위반)는package.json의dependencies선언을 기준으로 합니다. 태그 규칙과 별개로, 실제로 사용하는 패키지는dependencies에도 명시되어 있어야 검사가 정확하게 동작합니다. 태그만 추가했는데 기본 검사가 원하는 대로 안 잡힌다면package.json을 먼저 확인해보는 것이 좋습니다. -
CI 통합 없이 로컬에서만 검사하는 것:
turbo boundaries가 로컬에서 잘 돌아가도, CI에 없으면 바쁜 스프린트에서 금세 잊혀집니다. 처음 도입할 때 GitHub Actions에 한 스텝 추가하는 것까지 함께 진행하면 팀 전체에 자연스럽게 습관이 됩니다. 검사를 로컬 선택사항으로 두는 순간, 그 검사는 "있어도 없는" 규칙이 됩니다.
마치며
도입한 지 몇 달이 지난 지금, 가장 달라진 건 PR 리뷰 대화였습니다. "이 패키지 여기서 쓰면 안 되지 않나요?"라는 코멘트가 사라지고, 그 자리를 CI 실패 로그가 채웠습니다. 리뷰어가 잡아야 할 것들을 도구가 잡아주니, 리뷰에서 더 중요한 것들에 집중할 수 있게 됐습니다.
Turborepo Boundaries는 모노레포의 아키텍처 규칙을 팀의 암묵지에서 꺼내어 turbo.json이라는 코드로 만들어주는 도구입니다. 아직 Experimental이지만, 기본 위반 탐지만으로도 당장 쓸 수 있는 가치가 충분합니다.
지금 바로 시작해볼 수 있는 3단계:
-
기본 검사 활성화: 루트
turbo.json에"boundaries": true를 추가하고turbo boundaries를 실행해서 현재 모노레포에 숨어있는 위반 사항을 먼저 확인해보시면 좋습니다. 숫자가 예상보다 많을 수도 있습니다. -
태그 규칙 설계: 가장 명확하게 분리하고 싶은 경계 하나(
publicvsinternal, 또는frontendvsbackend)를 골라 루트turbo.json과 해당 패키지의turbo.json에 태그와 규칙을 추가해보시면 됩니다. 처음엔 딱 한 쌍이면 충분합니다. -
CI 통합: GitHub Actions에
turbo boundaries실행 스텝을 추가해 위반이 있는 PR이 머지되지 않도록 파이프라인을 완성해보시면, 팀 전체에 규칙이 자연스럽게 정착됩니다. 로컬에서만 돌리는 검사는 결국 잊혀집니다.
다음 글: Feature-Sliced Design(FSD)의 레이어·슬라이스 원칙을 실제 pnpm 모노레포에서 패키지 구조와 네이밍 컨벤션으로 구현하는 단계별 가이드