pnpm 모노레포 FSD 아키텍처 — 레이어를 패키지 경계로 강제하는 단계별 가이드
"이 컴포넌트 어디에 두면 되죠?" 신규 입사자가 꼭 묻는 질문이죠. 저도 여러 팀을 거치면서 이 질문에 대한 답이 늘 달랐습니다. components/에 넣으면 되는지, modules/에 넣어야 하는지, common/은 어떤 기준으로 쓰는지. 코드베이스가 커질수록 이 불확실성은 조용히 기술 부채가 됩니다.
이 글은 React 기반 모노레포를 운영 중이거나 도입을 고민 중인 프론트엔드 개발자를 위한 글입니다. Feature-Sliced Design(FSD)의 레이어 원칙을 pnpm 패키지 경계로 물리적으로 강제하는 구조를 단계별로 다룹니다. 저희 팀에서 이 구조를 도입하고 나서 온보딩 첫 주에 구조 파악이 끝났고, PR 충돌 빈도도 눈에 띄게 줄었습니다. 아직 완벽한 해법은 아니지만, 처음 도입할 때 알았더라면 좋았을 내용들을 솔직하게 담았습니다.
FSD의 핵심 개념부터 pnpm 모노레포에서의 실전 구조 설계, 그리고 Steiger 린터로 규칙을 자동화하는 방법까지 차례로 살펴봅니다.
핵심 개념
FSD의 3계층 구조 — 레이어 · 슬라이스 · 세그먼트
FSD는 코드를 세 단계로 분류합니다. 가장 큰 단위가 레이어(Layer), 그 안에서 비즈니스 기능별로 나뉘는 것이 슬라이스(Slice), 슬라이스 내부의 기술적 역할별 폴더가 **세그먼트(Segment)**입니다.
레이어는 총 6개이고(v2 기준 processes deprecated), 의존 방향은 위에서 아래로만 흐릅니다.
| 레이어 | 역할 | 슬라이스 존재 |
|---|---|---|
| app | 라우팅, 전역 스타일, 프로바이더 | ✗ |
| pages | 전체 페이지, 네스티드 라우팅 단위 | ✓ |
| widgets | 독립적인 대형 UI/기능 블록 | ✓ |
| features | 비즈니스 가치를 제공하는 사용자 시나리오 | ✓ |
| entities | 비즈니스 도메인 객체 (user, product 등) | ✓ |
| shared | 비즈니스 비종속적 재사용 모듈 | ✗ |
핵심 규칙 — 상위 레이어는 하위 레이어에만 의존할 수 있고, 같은 레이어 내 슬라이스 간 직접 import는 금지됩니다. 이 규칙 하나가 순환 의존을 구조적으로 막아줍니다.
저도 처음엔 features와 entities의 경계가 가장 헷갈렸습니다. 간단히 기억할 수 있는 기준은 이렇습니다. "사용자가 직접 수행하는 행동"이라면 features, "도메인 데이터와 그 표현"이라면 entities입니다. 장바구니에 상품을 추가하는 버튼은 features/cart-add-item, 그 버튼이 보여주는 상품 카드는 entities/product가 됩니다.
widgets 레이어도 처음엔 감이 잘 안 옵니다. 저희 팀에서 처음 도입했을 때 widgets 레이어 때문에 가장 많이 논쟁했습니다. 결국 도달한 결론은 이겁니다. "entities와 features를 조합해서 독립적으로 작동하는 대형 UI 블록"이 widgets입니다. 예를 들어 헤더 컴포넌트는 entities/user에서 사용자 아바타를 가져오고, features/auth-by-email에서 로그아웃 버튼을 가져와 조합합니다.
// apps/web/src/widgets/header/index.tsx
import { UserAvatar, useUserStore } from '@myapp/entities/user'
import { LogoutButton } from '@myapp/features/auth-by-email'
import { Button } from '@myapp/shared/ui'
export function Header() {
const user = useUserStore(state => state.user)
return (
<header>
<UserAvatar user={user} />
<LogoutButton />
</header>
)
}widgets는 "레고 블록을 조합하는 레이어"라고 생각하면 이해하기 쉽습니다.
세그먼트 네이밍 컨벤션
슬라이스 내부 폴더는 기술적 역할로 나뉩니다.
| 세그먼트 | 담당 내용 |
|---|---|
ui/ |
컴포넌트, 스타일 |
api/ |
서버 요청 함수, 타입, 매퍼 |
model/ |
스키마, 인터페이스, 스토어, 비즈니스 로직 |
lib/ |
해당 슬라이스 내부에서만 쓰는 유틸리티, 포매터 |
config/ |
환경 설정값, 상수 |
포매터는 ui/가 아닌 lib/에 두는 것이 FSD 공식 기준입니다. ui/는 컴포넌트와 스타일 파일에 집중되어야 합니다.
각 슬라이스에는 반드시 index.ts가 있어야 합니다. 외부에서는 이 파일을 통해서만 import할 수 있습니다. 이것이 Public API 패턴이고, 내부 구현을 자유롭게 바꾸면서도 외부 계약을 유지하게 해주는 핵심 장치입니다.
// packages/entities/src/user/index.ts ← Public API 파일
export { UserAvatar } from './ui/UserAvatar'
export { useUserStore } from './model/user.store'
export type { User } from './model/user.types'
// 외부에서는 이렇게만 import
import { UserAvatar, useUserStore } from '@myapp/entities/user'
// 이렇게 하면 안 됩니다 — Public API 우회
import { UserAvatar } from '@myapp/entities/src/user/ui/UserAvatar'pnpm 모노레포와 FSD의 자연스러운 매핑
단일 앱 안에서 폴더로 FSD를 구성할 수도 있지만, 여러 앱이 공유하는 모노레포라면 레이어를 패키지로 분리하는 전략이 훨씬 강력합니다. 패키지 경계가 곧 레이어 경계가 되어, package.json의 dependencies 선언만으로 의존 방향이 강제됩니다.
my-monorepo/
├── pnpm-workspace.yaml
├── turbo.json
├── apps/
│ ├── web/ # Next.js 앱 — app, pages, widgets 레이어 포함
│ └── admin/ # 관리자 앱
└── packages/
├── shared/ # @myapp/shared
├── entities/ # @myapp/entities
└── features/ # @myapp/featuresentities는 shared에만 의존하고, features는 entities와 shared에만 의존합니다. 이 관계를 package.json에 명시하면 런타임이 아니라 패키지 설치 단계에서 위반을 잡아낼 수 있습니다.
슬라이스 간 공유가 필요할 때
실무에서 처음 FSD를 적용하면 반드시 막히는 케이스가 하나 있습니다. 두 entities 슬라이스가 같은 공통 타입을 필요로 하는 상황입니다. 예를 들어 user와 order 슬라이스가 모두 Currency 타입을 쓴다고 할 때, 한 슬라이스에서 다른 슬라이스로 import하는 건 규칙 위반입니다.
해법은 단순합니다. 공유되는 것은 아래로 내립니다. Currency 타입이 비즈니스 도메인과 무관하게 범용으로 쓰인다면 @myapp/shared/lib로, 특정 도메인에 종속된다면 entities 내부 별도 슬라이스의 model/로 분리합니다. 처음엔 "이걸 어디에 두는 게 맞지?"라는 고민이 많이 생기지만, 의존 방향을 다시 생각하는 습관이 생기면 점점 자연스러워집니다.
장단점 분석
실전 예시로 들어가기 전에, 이 구조가 내 팀 상황에 맞는지 먼저 판단해보는 것이 좋을 것 같습니다.
장점
| 항목 | 내용 |
|---|---|
| 단방향 의존성 | 순환 의존이 구조적으로 방지되어 예측 가능한 코드 흐름이 유지됩니다 |
| 팀 병렬 개발 | 슬라이스 경계가 팀 소유권 경계와 일치해 PR 충돌이 줄어듭니다 |
| 온보딩 속도 | "이 파일은 어디에?" 질문에 명확한 답이 생겨 신규 합류자 학습 비용이 낮아집니다 |
| 리팩터링 안전성 | Public API 경계 덕분에 내부 구현 변경 시 영향 범위를 예측할 수 있습니다 |
| 모노레포 확장성 | 패키지 경계와 FSD 레이어 경계가 자연스럽게 대응되어 앱 추가가 수월합니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 높은 초기 학습 비용 | 레이어/슬라이스/세그먼트 개념을 팀 전체가 이해해야 합니다 | 팀 워크샵 + 공식 문서 읽기 세션으로 온보딩 |
| 소규모 프로젝트 오버헤드 | MVP나 단기 프로젝트에서는 구조 설계 비용이 이득을 초과할 수 있습니다 | 앱이 하나이고 팀이 작다면 폴더 FSD만 적용하는 것을 권장합니다 |
| 해석 불일치 위험 | "이 코드가 entities인가 features인가" 논쟁이 발생하기 쉽습니다 | 팀 ADR 문서로 명확한 기준을 미리 문서화 |
| 같은 레이어 간 공유의 어려움 | features 간 공유 로직은 entities나 shared로 내려야 합니다 | 처음엔 불편하지만 의존 방향을 다시 생각하는 계기가 됩니다 |
| 코드리뷰 문화 필요 | 도구만으로는 규칙 강제에 한계가 있습니다 | Steiger + ESLint를 CI에 필수로 연결하는 것을 권장합니다 |
제가 직접 목격한 실수들
-
엔티티 하나당 패키지 분리 —
@myapp/entity-user,@myapp/entity-product처럼 쪼개면 패키지 관리 비용이 폭발적으로 늘어납니다.@myapp/entities하나 안에 슬라이스로 구분하는 것이 훨씬 현실적입니다. -
단일 앱에서 패키지 분리 서두르기 — 앱이 하나뿐이라면 먼저 앱 내부 폴더 구조로 FSD를 경험해보시는 것이 좋습니다. 두 번째 앱이 생기는 시점에 패키지 분리를 고려해도 충분히 늦지 않습니다.
-
shared에 비즈니스 로직 넣기 —shared는 어떤 도메인에도 종속되지 않아야 합니다. "user 개념이 없어도 이 코드가 의미 있나?"라고 물어봤을 때 "아니오"라면shared에 들어가면 안 됩니다.
실전 적용
장단점을 확인했다면, 이제 실제로 어떻게 구성하는지 살펴봅니다. 예시마다 앞의 예시 위에 쌓이는 구조여서, 순서대로 따라가시면 전체 그림이 자연스럽게 완성됩니다.
예시 1: pnpm 모노레포 초기화 & workspace 설정
프로젝트를 처음 만들 때부터 workspace 설정을 잡아두면 나중에 패키지를 추가하는 것이 훨씬 수월합니다.
mkdir my-fsd-monorepo && cd my-fsd-monorepo
pnpm init# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'// turbo.json — Turborepo v2 이상 기준 (v1에서는 "pipeline" 키를 사용합니다)
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"lint": {
"dependsOn": ["^build"]
}
}
}Turborepo의
^build— "내가 의존하는 모든 패키지의build가 먼저 끝나야 한다"는 의미입니다.entities가shared를 의존한다면,shared build→entities build순서가 자동으로 보장됩니다.
예시 2: @myapp/shared 패키지 구성
shared는 비즈니스 로직과 무관한 공통 모듈의 집합입니다. 버튼 같은 기본 UI 컴포넌트, axios 인스턴스, 날짜 포매터 같은 유틸리티가 여기 들어옵니다.
packages/shared/
├── package.json
├── tsconfig.json
└── src/
├── ui/
│ ├── Button/
│ │ ├── Button.tsx
│ │ └── index.ts
│ └── index.ts
├── api/
│ ├── client.ts
│ └── index.ts
├── lib/
│ ├── formatDate.ts ← 포매터는 여기에
│ └── index.ts
└── index.ts이 설정 없이 배포했다가 TypeScript 타입이 날아가는 경험을 하고 나서 types 조건을 반드시 넣게 됐습니다. exports 필드를 작성할 때 types 조건이 없으면 타입 자동완성이 동작하지 않습니다.
// packages/shared/package.json
{
"name": "@myapp/shared",
"version": "0.0.0",
"exports": {
"./ui": {
"types": "./src/ui/index.ts",
"default": "./src/ui/index.ts"
},
"./api": {
"types": "./src/api/index.ts",
"default": "./src/api/index.ts"
},
"./lib": {
"types": "./src/lib/index.ts",
"default": "./src/lib/index.ts"
}
}
}exports 필드 덕분에 import { Button } from '@myapp/shared/ui'는 허용되지만 import { Button } from '@myapp/shared/src/ui/Button/Button'은 Node.js 수준에서 차단됩니다. Public API를 소프트웨어가 강제해주는 구조입니다.
workspace:*프로토콜 — pnpm에서 내부 패키지를 참조할 때 사용하는 특별한 버전 표기입니다."@myapp/shared": "workspace:*"로 선언하면 로컬 파일 시스템의 패키지를 참조하고, 배포 시 실제 버전으로 자동 치환됩니다.
예시 3: @myapp/entities & @myapp/features 패키지 구성
shared를 만들었으니, 이제 그것에 의존하는 entities를 추가해봅니다. 실제 컴포넌트가 레이어 경계를 어떻게 표현하는지 코드로 보면 훨씬 직관적입니다.
packages/entities/
├── package.json
└── src/
├── user/
│ ├── ui/
│ │ └── UserAvatar.tsx
│ ├── model/
│ │ ├── user.store.ts # Zustand 스토어
│ │ └── user.types.ts
│ └── index.ts # Public API
└── product/
├── model/
└── index.ts// packages/entities/package.json
{
"name": "@myapp/entities",
"version": "0.0.0",
"dependencies": {
"@myapp/shared": "workspace:*"
},
"exports": {
"./user": {
"types": "./src/user/index.ts",
"default": "./src/user/index.ts"
},
"./product": {
"types": "./src/product/index.ts",
"default": "./src/product/index.ts"
}
}
}entities를 만들었으니, 이제 그것과 shared에 의존하는 features를 추가합니다. features의 컴포넌트가 entities를 어떻게 참조하는지 실제 코드로 확인해봅니다.
// packages/features/src/cart-add-item/ui/CartButton.tsx
import type { Product } from '@myapp/entities/product'
import { Button } from '@myapp/shared/ui'
interface Props {
product: Product
onAdd: () => void
}
export function CartButton({ product, onAdd }: Props) {
return (
<Button onClick={onAdd}>
{product.name} 담기
</Button>
)
}// packages/features/package.json
{
"name": "@myapp/features",
"version": "0.0.0",
"dependencies": {
"@myapp/shared": "workspace:*",
"@myapp/entities": "workspace:*"
}
}features의 package.json에 @myapp/entities는 있지만 @myapp/features는 없습니다. features 패키지가 자기 자신에 의존할 수 없으니, 같은 레이어 내 슬라이스 간 import는 선언 자체가 불가능해집니다. 이것이 패키지 분리 전략의 진짜 강점입니다.
예시 4: 앱 내부 FSD 레이어 & TSConfig paths 연결
app, pages, widgets는 앱 고유 레이어이므로 앱 내부 폴더로 유지합니다. 여기서 한 가지 혼란이 생기기 쉬운 부분이 있습니다. 앱 내부에서는 @/로 시작하는 경로 alias를 쓰고, 패키지를 참조할 때는 @myapp/으로 시작하는 패키지명을 씁니다. 역할이 전혀 다릅니다.
@/pages/home— 같은 앱 내부 파일을 참조하는 경로 단축어 (tsconfig paths로 정의)@myapp/entities/user— 별도 패키지를 참조하는 패키지 import (pnpm workspace로 연결)
apps/web/src/
├── app/
│ ├── providers.tsx
│ └── styles/
│ └── global.css
├── pages/
│ ├── home/
│ │ └── index.tsx
│ └── product-detail/
│ └── index.tsx
└── widgets/
├── header/
│ └── index.tsx
└── sidebar/
└── index.tsx// apps/web/tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/app/*": ["./src/app/*"],
"@/pages/*": ["./src/pages/*"],
"@/widgets/*": ["./src/widgets/*"]
}
}
}// apps/web/package.json — workspace 패키지 참조
{
"name": "@myapp/web",
"dependencies": {
"@myapp/shared": "workspace:*",
"@myapp/entities": "workspace:*",
"@myapp/features": "workspace:*"
}
}예시 5: Steiger로 FSD 규칙 자동 검증
아무리 구조를 잘 잡아도 코드리뷰만으로 모든 위반을 잡기는 어렵습니다. 바쁜 시즌에 슬쩍 들어온 cross-import 한 줄이 나중에 큰 얽힘의 씨앗이 됩니다. Steiger는 FSD 팀이 공식으로 만든 린터로, 이 문제를 정적 분석으로 잡아줍니다.
pnpm add -D steiger @feature-sliced/steiger-plugin -w// steiger.config.ts (모노레포 루트)
import { defineConfig } from 'steiger'
import fsd from '@feature-sliced/steiger-plugin'
export default defineConfig([
...fsd.configs.recommended,
{
files: ['apps/web/src/**'],
},
])모노레포 루트에서 실행할 때 어느 경로를 스캔할지 지정해줘야 합니다. files 옵션에 FSD 구조가 적용된 앱 경로를 명시해두면 의도한 범위만 검사합니다. 이 옵션 없이 실행하면 packages/ 하위까지 스캔하려다 경고가 쏟아질 수 있습니다.
Steiger가 검사하는 주요 규칙은 두 가지입니다.
| 규칙 | 내용 |
|---|---|
fsd/forbidden-imports |
상위 레이어에서 하위 레이어로의 import, 동일 레이어 슬라이스 간 교차 import 차단 |
fsd/no-public-api-sidestep |
index.ts를 우회해 내부 파일 직접 import 차단 |
마치며
FSD의 레이어 의존 방향을 pnpm 패키지 경계로 물리적으로 강제하면, 구조 규칙이 문서가 아닌 툴링이 되어 팀 전체가 자연스럽게 따르게 됩니다. 처음엔 설정할 게 많아 보이지만, 한 번 잡아두면 팀이 커져도 코드베이스가 일관성을 유지합니다.
지금 바로 시작해볼 수 있는 3단계:
-
기존 프로젝트에
shared레이어부터 분리해보시면 좋습니다 — 비즈니스 로직과 무관한 UI 컴포넌트, API 클라이언트, 유틸 함수를packages/shared/로 옮기고@myapp/shared로 참조하도록 변경해볼 수 있습니다. 가장 충격이 적은 첫 번째 시도입니다. -
entities패키지를 생성하고 도메인 모델을 옮겨보시는 것을 권장합니다 —user,product같은 도메인 객체의 타입, 스토어, 기본 UI 컴포넌트를 슬라이스 단위로 정리하고index.tsPublic API를 작성해볼 수 있습니다. -
Steiger를 CI 파이프라인에 연결해두시면 효과적입니다 —
pnpm add -D steiger @feature-sliced/steiger-plugin -w한 줄로 설치하고, GitHub Actions에pnpm steiger단계를 추가해두면 앞으로 들어오는 모든 PR에서 FSD 위반을 자동으로 잡을 수 있습니다.
이 구조 위에서 팀이 어떻게 일하는지 경험해보면, 아키텍처가 논쟁이 아니라 코드가 스스로 알려주는 언어가 됩니다.
다음 글: Turborepo 원격 캐시와 태스크 그래프 최적화로 모노레포 빌드 시간을 줄이는 실전 설정 가이드
참고 자료
- Feature-Sliced Design 공식 문서 | feature-sliced.design
- FSD 모노레포 아키텍처 완전 가이드 2025 | feature-sliced.design
- FSD 프론트엔드 폴더 구조 가이드 | feature-sliced.design
- FSD 네이밍 컨벤션 | feature-sliced.design
- FSD 레이어 레퍼런스 | feature-sliced.design
- FSD 모노레포 예제 | feature-sliced.design
- Steiger 공식 린터 | GitHub
- Frontend Monorepo Architecture with pnpm & Turborepo | DEV.to
- FSD import 규칙 강제 (eslint-plugin-import) | DEV.to
- pnpm Workspaces 공식 문서 | pnpm.io
- Turborepo 레포지터리 구조 설계 | turborepo.dev