패키지 배치와 참조 규칙
본 문서는 새로 작성하는 코드를 어느 계층·슬라이스·세그먼트에 둘 것인지, 그리고 각 위치에서 무엇을 참조할 수 있는지를 정의합니다.
본 문서의 규칙은 FSD v2.1을 따르며, FSD가 정의하지 않는 Angular 관련 사항은 7절에서 본 표준이 직접 정의합니다. 배치 판정은 3절의 판정 트리가 유일한 기준입니다.
1. 디렉터리 구조
src/
├── app/
│ ├── app.ts
│ ├── app.config.ts
│ ├── app.routes.ts
│ ├── app.config.server.ts
│ ├── app.routes.server.ts
│ └── styles.css
├── pages/
├── shared/
├── features/
├── entities/
├── main.ts
├── server.ts
└── index.html계층별 역할과 생성 조건은 2절이 원본입니다. app 계층의 파일은 다음과 같습니다.
| 파일 | 역할 |
|---|---|
app.ts |
루트 컴포넌트 |
app.config.ts |
프로바이더 |
app.routes.ts |
라우트 설정 |
app.config.server.ts |
SSR 프로바이더 |
app.routes.server.ts |
경로별 렌더링 모드 |
styles.css |
전역 스타일 |
main.ts·server.ts·index.html은 프레임워크 진입점이며 어느 계층에도 속하지 않습니다. features와 entities는 빈 폴더로 미리 만들지 않습니다. 첫 슬라이스를 생성하는 시점에 함께 만듭니다.
2. 계층과 생성 조건
| 계층 | 담는 것 | 생성 조건 |
|---|---|---|
| app | 프로바이더 등록, 라우트 설정, 전역 스타일, 애플리케이션 초기화 | 필수 |
| pages | 라우트 단위 화면과 그 화면 전용 로직 전부 | 필수 |
| features | 여러 화면에서 재사용되는 사용자 동작과 그 동작에 필요한 UI | 2곳 이상 사용이 확인된 뒤 |
| entities | 여러 화면에서 재사용되는 도메인 모델과 규칙 | 2곳 이상 사용이 확인된 뒤 |
| shared | UI 킷, 유틸리티, API 클라이언트, 인증 토큰, 설정 | 필수 |
widgets 계층의 생성은 금지합니다. 근거는 아키텍처 4절-1에 있습니다.
processes 계층은 FSD v2.1에서 폐기되었으므로 사용을 금지합니다.
슬라이스와 세그먼트
app과 shared는 슬라이스를 갖지 않고 세그먼트로 직접 구성합니다. pages·features·entities는 슬라이스를 먼저 두고 그 내부에 세그먼트를 둡니다.
| 세그먼트 | 담는 것 |
|---|---|
ui/ |
컴포넌트, 템플릿, 스타일 |
model/ |
상태, 검증, 도메인 로직, 타입 |
api/ |
서버 요청 함수와 조회 정의 |
lib/ |
이 슬라이스 내부 전용 유틸리티 |
config/ |
설정값, 상수 |
pages/risk-assessment-list/
├── ui/
│ ├── risk-assessment-list.ts
│ └── risk-assessment-filter.ts
├── model/
│ └── risk-assessment-list.ts
├── api/
│ └── fetch-risk-assessments.ts
└── index.tsui/risk-assessment-list.ts가 라우트 진입 컴포넌트이고, model/risk-assessment-list.ts가 화면 상태와 필터 로직을 담으며, index.ts가 공개 API입니다.
3. 배치 판정 트리
새 코드를 작성할 때 위에서부터 순서대로 판정합니다. 먼저 해당하는 항목에서 멈춥니다.
1. 애플리케이션 전역 설정인가?
(프로바이더, 라우트 정의, 전역 스타일, 초기화)
→ app/
2. 비즈니스 로직이 없는 인프라인가?
(UI 킷, 범용 유틸, HTTP 클라이언트, 인증 토큰, CRUD 요청)
→ shared/<세그먼트>/
3. 한 화면에서만 사용하는가?
→ pages/<해당 화면>/
4. 여러 화면에서 이미 사용 중인 사용자 동작인가?
(동사구로 이름 붙는 것: 승인한다, 담는다, 내보낸다)
→ features/<동작명>/
5. 여러 화면에서 이미 사용 중인 도메인 모델인가?
(명사로 이름 붙는 것: 평가표, 근로자, 공사)
→ entities/<도메인명>/
6. 위 어디에도 확신이 없는 경우
→ pages/<해당 화면>/중요
6번이 기본값입니다
판정이 갈리면 항상 pages에 둡니다. 화면 간 코드 중복은 허용되며, 중복 자체는 추출의 근거가 되지 않습니다. 가설적 재사용을 근거로 하위 계층에 두는 것을 금지합니다. 잘못 둔 코드를 pages로 되돌리는 비용보다, pages에 있던 코드를 승격하는 비용이 낮습니다.
4번과 5번의 "이미 사용 중"은 현재 두 곳 이상에서 실제로 호출되고 있음을 뜻합니다. 앞으로 그럴 것 같다는 판단은 조건을 만족하지 않습니다.
4. 배치 시나리오
판정 트리를 자주 나오는 상황에 적용한 결과입니다.
| 대상 | 단일 화면 사용 | 복수 화면 사용 확인 |
|---|---|---|
| 화면 진입 컴포넌트 | pages/<화면>/ui/ |
해당 없음 |
| 화면 내부 UI 블록 | pages/<화면>/ui/ |
features/<동작>/ui/ |
| 화면 전용 조회 함수 | pages/<화면>/api/ |
entities/<도메인>/api/ |
| 화면 전용 상태 | pages/<화면>/model/ |
entities/<도메인>/model/ |
| 도메인 타입과 계산 규칙 | pages/<화면>/model/ |
entities/<도메인>/model/ |
| 버튼 · 입력 · 다이얼로그 | shared/ui/ (항상) |
shared/ui/ (항상) |
| HTTP 클라이언트와 인터셉터 | shared/api/ (항상) |
shared/api/ (항상) |
| 생성된 서버 타입 | shared/api/generated/ (항상) |
shared/api/generated/ (항상) |
| CRUD 요청 헬퍼 | shared/api/ (항상) |
shared/api/ (항상) |
| 인증 토큰과 세션 | shared/auth/ (항상) |
shared/auth/ (항상) |
| 날짜 · 숫자 포맷 유틸 | shared/lib/ (항상) |
shared/lib/ (항상) |
| 기기 판정 신호 | shared/lib/adaptive/ (항상) |
shared/lib/adaptive/ (항상) |
| 다이얼로그 내용 | pages/<화면>/ui/ |
features/<동작>/ui/ |
| 전역 프로바이더와 인터셉터 등록 | app/ (항상) |
app/ (항상) |
shared로 고정된 항목은 판정 트리 2번에 해당하므로 사용처 개수를 세지 않습니다.
주의
인증 데이터로 user 엔티티를 만들지 않습니다
토큰, 로그인 응답, 세션 정보는 인증 맥락 전용이며 다른 화면에서 도메인 모델로 재사용되지 않습니다. entities/user를 만들면 거의 모든 계층이 임포트할 수 있는 위치에 인증 세부가 놓여 변경 파급이 넓어집니다. shared/auth/에 둡니다.
5. 참조 규칙
5.1 임포트 방향
임포트는 자신보다 하위 계층으로만 향합니다.
app → pages → features → entities → shared| 구분 | 준수 지침 (Do) | 금지 지침 (Don't) |
|---|---|---|
| 방향 | pages가 entities를 임포트합니다 |
entities가 features를 임포트합니다 |
| 동일 계층 | 두 슬라이스가 필요하면 상위 계층이 각각 임포트해 조합합니다 | features/a가 features/b를 임포트합니다 |
| 자기 자신 | 슬라이스 내부에서는 세그먼트 간 자유롭게 참조합니다 |
app과 shared는 슬라이스가 없으므로 계층 내부 세그먼트 간 참조가 허용됩니다.
5.2 공개 API
슬라이스 외부에서는 index.ts를 통해서만 접근합니다. 내부 파일 직접 임포트는 금지합니다.
// 허용
import { RiskAssessmentList } from '@/pages/risk-assessment-list';
// 금지 — 공개 API 우회
import { RiskAssessmentList } from '@/pages/risk-assessment-list/ui/risk-assessment-list';shared는 슬라이스가 없으므로 최상위 index.ts 하나를 두지 않고 세그먼트별로 공개 API를 정의합니다. shared/ui/index.ts, shared/api/index.ts 형태입니다. 임포트가 의도별로 구분되어 어느 성격의 코드를 쓰는지 임포트문에서 드러납니다.
index.ts에 무엇을 내보낼지는 최소 원칙을 따릅니다. 슬라이스 외부가 실제로 사용하는 것만 내보내고, 내부 구현은 내보내지 않습니다. 표현 컴포넌트나 내부 유틸을 공개하면 캡슐화가 무의미해집니다.
5.3 크로스임포트 해소
동일 계층의 두 슬라이스가 같은 코드를 필요로 할 때, 아래 순서로 해소합니다.
| 순서 | 전략 | 적용 상황 |
|---|---|---|
| 1 | 슬라이스 병합 | 두 슬라이스가 항상 함께 변경되는 경우. 경계 설정 자체가 잘못된 것입니다 |
| 2 | 하위 계층으로 이동 | 공유 대상이 도메인 로직이면 entities로, 인프라면 shared로 옮깁니다 |
| 3 | 상위 계층에서 조합 | 부모(pages 또는 app)가 두 슬라이스를 각각 임포트해 연결합니다 |
| 4 | 공개 API 경유 참조 | 위 셋이 모두 불가능한 경우에 한해, 상대 슬라이스의 index.ts만 참조합니다 |
4번을 사용하면 코드 주석 또는 ADR로 앞의 세 전략이 왜 적용되지 않는지를 남겨야 합니다. 사유 없는 크로스임포트는 금지합니다.
entities 계층의 @x 표기는 경계 병합이 진정으로 불가능한 경우의 최후 수단이며, 권장 패턴이 아닙니다. 사용 시 사유 기록이 필수입니다.
6. 슬라이스 승격과 강등
6.1 승격 (pages → features / entities)
같은 코드가 두 번째 화면에서 실제로 필요해진 시점에 수행합니다.
- 품사 판정: 사용자 동작이면
features, 도메인 모델이면entities로 정합니다. - 이동: 코드를 새 슬라이스로 옮기고 세그먼트를 재배치합니다.
- 공개 API 작성:
index.ts에 외부가 사용할 것만 내보냅니다. - 호출부 수정: 기존 화면의 임포트를 공개 API 경로로 변경합니다.
- 검증: Steiger를 실행해 계층 위반이 없는지 확인합니다.
이 절차의 비용이 승격을 늦추는 근거가 됩니다. 확신 없는 승격보다 중복 유지가 저렴합니다.
6.2 강등 (features / entities → pages)
Steiger의 insignificant-slice 규칙이 지적하면 강등합니다. 한 화면에서만 사용되는 슬라이스는 그 화면 안으로 되돌립니다.
강등을 규칙 위반으로 취급하지 않습니다. 재사용 예측이 빗나가는 것은 정상이며, 되돌리는 것이 방치보다 낫습니다.
7. Angular 관련 규칙
FSD 공식 통합 가이드에 Angular가 없으므로 본 절이 원본입니다.
7.1 app 계층
src/app을 FSD app 계층으로 그대로 사용합니다. Angular는 라우팅이 코드 기반이라 폴더명을 선점하지 않으므로 접두사가 필요 없습니다.
Angular CLI가 생성한 평면 파일 구조(app.config.ts, app.routes.ts)를 유지합니다. FSD는 app 계층의 세그먼트 이름을 표준화하지 않으며 소규모에서 단일 파일로 접는 것을 허용합니다.
같은 성격의 파일이 셋 이상이 되면 세그먼트 폴더로 승격합니다. 인터셉터가 세 개가 되면 app/interceptors/를 만드는 식입니다.
app/ui/ 폴더를 만드는 것은 금지합니다. Steiger의 no-ui-in-app 규칙이 app 계층의 ui 세그먼트를 차단하여 빌드가 실패합니다. 루트 컴포넌트 app.ts처럼 개별 파일은 대상이 아니며, 전역 레이아웃 컴포넌트가 필요하면 app/layout/ 등 목적을 드러내는 이름을 사용합니다.
7.2 계층에 속하지 않는 파일
src/main.ts, src/main.server.ts, src/server.ts, src/index.html은 프레임워크 진입점이며 어느 계층에도 속하지 않습니다. src/ 직하에 유지하고 steiger.config.ts의 ignores에 등재합니다.
실측 결과 등재 전에도 Steiger가 이들을 미분류 파일로 경고하지 않았으나, 의도를 설정에 남겨 두면 향후 규칙이 추가될 때 동작이 바뀌지 않습니다.
전역 스타일시트는 src/app/styles.css에 둡니다. FSD가 전역 스타일을 app 계층에 두도록 정하고 있으므로, CLI 기본 위치인 src/styles.css에서 이동하고 angular.json의 styles 항목을 함께 수정합니다.
7.3 경로 별칭
tsconfig.json에 다음을 정의하고, 계층 간 임포트는 항상 별칭을 사용합니다.
{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }baseUrl을 지정하는 것은 금지합니다. TypeScript 6에서 폐기 예정으로 표시되어 지정 시 TS5101 오류로 빌드가 실패합니다. paths만 정의하고 값을 "./src/*" 형태의 상대 경로로 씁니다. paths는 baseUrl 없이도 tsconfig.json 위치를 기준으로 해석됩니다.
상대 경로 임포트는 같은 슬라이스 내부에서만 허용합니다. 계층을 넘는 상대 경로(../../shared/ui)는 금지하며 ESLint의 no-restricted-imports가 차단합니다.
실측 결과 Steiger는 상대 경로로 작성된 계층 위반도 정확히 검출합니다. 따라서 별칭 규칙의 근거는 검출 가능성이 아니라 파일 이동 내성과 가독성입니다. 슬라이스를 다른 계층으로 옮길 때 상대 경로는 전부 깨지지만 별칭은 그대로 유지됩니다.
7.4 라우트 배선
app/app.routes.ts가 loadComponent로 pages 슬라이스의 공개 API를 참조합니다.
{
path: 'risk-assessments',
loadComponent: () =>
import('@/pages/risk-assessment-list').then((m) => m.RiskAssessmentList),
}pages 슬라이스의 index.ts는 외부가 실제로 사용하는 것만 내보냅니다. 라우트 진입 컴포넌트와, 그 화면의 리졸버가 대상입니다. 근거는 캡슐화이며 번들 크기가 아닙니다.
번들 간섭은 실측으로 확인한 결과 발생하지 않습니다. 배럴이 재수출한 모듈 중 사용되지 않는 것은 트리셰이킹되어 어느 번들에도 포함되지 않았고, 배럴에서 함께 내보낸 리졸버와 컴포넌트는 각각 초기 청크와 지연 청크로 정확히 분리되었습니다. 번들러가 배럴이 아니라 모듈 단위로 분할하기 때문입니다.
다만 최상위에 부수 효과가 있는 모듈은 트리셰이킹되지 않으므로, index.ts가 그런 모듈을 재수출하지 않도록 합니다.
주의
@defer 는 loadComponent 와 다르게 동작합니다
위 실측은 라우트의 loadComponent 에 대한 것입니다. @defer 는 배럴을 통과하지 못합니다. 같은 배럴에서 즉시 사용하는 심볼과 @defer 안에서만 쓰는 심볼을 함께 가져오면, 배럴 모듈이 정적 의존으로 확정되어 지연 청크가 만들어지지 않습니다. @defer 대상은 그 컴포넌트의 진입점을 직접 임포트해야 합니다. 실측 수치는 개발 환경 7절에 있습니다.
7.5 의존성 주입
providedIn: 'root' 서비스는 shared와 app 계층에서만 선언합니다. 다른 계층에서는 금지합니다.
Angular의 의존성 주입 자체는 계층 규칙을 우회하지 않습니다. inject(SomeService)를 쓰려면 그 파일이 서비스 클래스를 임포트해야 하므로 Steiger의 검사 대상입니다. 이 규칙의 목적은 다른 데 있습니다. pages 슬라이스가 전역 싱글턴을 만들면 서비스 수명이 화면 수명과 어긋나 화면을 떠나도 상태가 남습니다.
화면 범위의 서비스가 필요하면 라우트 정의의 providers 배열에 등록합니다.
shared에 인젝션 토큰을 정의하고 상위 계층이 구현을 제공하는 형태는 의존성 역전이므로 허용합니다.
문자열이나 동적 토큰을 Injector.get에 전달하는 방식은 임포트 그래프에 나타나지 않으므로 금지합니다. 다만 이 호출 자체를 구문 규칙으로 정확히 판별하기는 어렵습니다. Map.get 같은 정상 호출과 구분되지 않기 때문이며, 대신 @angular/core의 Injector 임포트를 제한해 우회 경로의 입구를 막습니다. 동적 컴포넌트 생성처럼 불가피한 경우 지역 예외 주석으로 사유를 남기면 코드 리뷰에 드러납니다.
7.6 스키매틱
ng generate의 기본 출력 경로는 슬라이스 구조와 맞지 않습니다. 생성 시 경로를 명시합니다.
ng g component pages/risk-assessment-list/ui/risk-assessment-list --flat8. 파일명
파일명은 그 파일이 다루는 도메인을 나타냅니다. 기술적 역할을 나타내는 이름은 금지합니다.
| 구분 | 준수 지침 (Do) | 금지 지침 (Don't) |
|---|---|---|
| 모델 파일 | model/risk-assessment.ts |
model/types.ts |
| 유틸 파일 | lib/format-assessment-grade.ts |
lib/utils.ts |
| 요청 함수 | api/fetch-risk-assessments.ts |
api/index-api.ts |
| 상수 | config/assessment-grade.ts |
config/constants.ts |
types.ts와 utils.ts가 금지되는 이유는 서로 무관한 도메인이 한 파일에 섞여 응집도가 떨어지고, 파일명만으로 내용을 알 수 없어 탐색 비용이 늘기 때문입니다.
세그먼트에 도메인 관심사가 하나뿐이면 파일명이 슬라이스명과 같아도 됩니다.
클래스명과 선택자 규약은 명명 규칙이 원본입니다.
9. 자동 강제
| 규칙 | 강제 수단 |
|---|---|
| 임포트 방향 | Steiger forbidden-imports, no-higher-level-imports |
| 공개 API 경유 | Steiger public-api, no-public-api-sidestep, no-layer-public-api |
| 동일 계층 크로스임포트 | Steiger no-cross-imports |
| 조기 추출 억제 | Steiger insignificant-slice, excessive-slicing |
app 계층의 ui 세그먼트 |
Steiger no-ui-in-app |
| 폐기 계층 사용 | Steiger no-processes |
| 도메인 기반 파일명 | Steiger inconsistent-naming, ambiguous-slice-names, segments-by-purpose (부분) |
providedIn: 'root' 위치 |
ESLint no-restricted-syntax |
| 계층 간 상대 경로 임포트 | ESLint no-restricted-imports |
Injector 직접 사용 |
ESLint no-restricted-imports |
npx steiger ./src규칙의 활성화 여부와 예외 설정의 원본은 steiger.config.ts입니다. 본 문서에 설정값을 옮겨 적지 않습니다.
shared에 비즈니스 로직이 들어가는 것을 검출하는 자동 수단은 없습니다. 코드 리뷰에서 확인합니다.