617 lines
35 KiB
Markdown
617 lines
35 KiB
Markdown
# FSD(Feature-Sliced Design) v2.1과 Next.js App Router 결합 아키텍처 가이드
|
|
|
|
## 1. 개요 (Overview)
|
|
|
|
본 문서는 **디지털 행정지원 시스템([`apps/as`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as))**에 적용된 **Feature-Sliced Design(FSD v2.1)** 방법론과 **Next.js App Router**의 상호 결합 아키텍처를 상세히 설명하는 개발 가이드입니다.
|
|
|
|
현대 프론트엔드 개발에서 Next.js App Router는 파일 시스템 기반 라우팅과 서버 컴포넌트(RSC) 환경을 강력하게 제공하지만, 프로젝트 규모가 커질수록 `app/` 폴더 내부에 비즈니스 로직과 UI 컴포넌트가 무질서하게 혼재되는 "아키텍처 부패(Architecture Erosion)" 현상이 발생하기 쉽습니다.
|
|
|
|
AS 모듈은 이러한 문제를 방지하기 위해 **FSD v2.1 사양**과 **FSD 공식 Next.js 가이드**를 채택하여, **"Next.js App Router는 얇은 라우팅 진입점(Thin Routing Entry Point)만 담당하고, 실제 UI와 비즈니스 도메인은 `src/` 하위의 FSD 계층에 완전히 격리하는 1:1 매핑 결합 패턴"**을 구현했습니다.
|
|
|
|
---
|
|
|
|
## 2. Feature-Sliced Design (FSD) v2.1 핵심 개념
|
|
|
|
### 2.1 FSD란 무엇인가?
|
|
|
|
**Feature-Sliced Design(FSD)**은 프론트엔드 애플리케이션의 유지보수성, 확장성, 예측 가능성을 극대화하기 위해 고안된 **계층형 모듈식 프론트엔드 아키텍처 설계 방법론**입니다.
|
|
|
|
코드를 단순히 기술적 분류(components, hooks, utils, types 등)로 나누는 기존 방식과 달리, FSD는 **비즈니스 도메인과 책임의 범위(Scope of Responsibility)**에 따라 코드를 구조화합니다.
|
|
|
|
```
|
|
FSD 계층 구조 (Top-Down 의존성 방향)
|
|
|
|
┌────────────────────────────────────────────────────────┐
|
|
│ app (최상위: 전역 설정, 프로바이더, 글로벌 스타일) │ ▲ 가장 높은 책임
|
|
├────────────────────────────────────────────────────────┤ │ 가장 많은 의존성
|
|
│ pages (화면 슬라이스: 라우트에 대응하는 페이지 컴포넌트) │ │
|
|
├────────────────────────────────────────────────────────┤ │
|
|
│ widgets (복합 UI 블록: 헤더, 사이드바, 도메인 복합 섹션) │ │ 단방향 참조
|
|
├────────────────────────────────────────────────────────┤ │ (상위 -> 하위만 허용)
|
|
│ features (사용자 상호작용/액션: 로그인 폼, 필터, 검색 등) │ │
|
|
├────────────────────────────────────────────────────────┤ │
|
|
│ entities (비즈니스 도메인 모델: 하수도계획, 사용자, 결재 등)│ │
|
|
├────────────────────────────────────────────────────────┤ ▼ 가장 낮은 책임
|
|
│ shared (도메인 무관 기반 인프라: UI Kit, API 클라이언트)│ 의존성 없음
|
|
└────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 2.2 FSD의 3계층 구조 체계
|
|
|
|
FSD는 코드베이스를 세 가지 차원으로 체계화합니다:
|
|
|
|
1. **Layers (레이어 - 수평적 분리)**:
|
|
- 애플리케이션 내에서의 책임과 권한 수준에 따른 최상위 분류 (표준 6개 계층).
|
|
2. **Slices (슬라이스 - 수직적 비즈니스 분리)**:
|
|
- 레이어 내부를 특정 **비즈니스 도메인 단위**로 분할한 디렉토리.
|
|
- 예: `entities/sewer-bsc-plan`, `entities/auth`, `pages/dashboard`
|
|
- *주의: `app`과 `shared` 레이어는 도메인이 없으므로 슬라이스를 갖지 않습니다.*
|
|
3. **Segments (세그먼트 - 기술적 분리)**:
|
|
- 슬라이스 내부를 기술적 목적에 따라 나눈 폴더.
|
|
- 표준 세그먼트: `ui`(마크업/컴포넌트), `model`(타입/상태/스토어), `api`(서버 통신/쿼리), `lib`(유틸리티/헬퍼).
|
|
|
|
### 2.3 6개 표준 레이어의 상세 역할
|
|
|
|
| 레이어 (Layer) | 슬라이스 유무 | 책임 및 역할 | `apps/as` 실제 구현 사례 |
|
|
| :--- | :---: | :--- | :--- |
|
|
| **`app`** | ❌ (세그먼트만) | 앱 전역 설정, 전역 Context Providers, 글로벌 스타일, 최상위 레이아웃 | [`src/app/providers/index.tsx`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/app/providers/index.tsx)<br/>[`src/app/layouts/base-layout.tsx`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/app/layouts/base-layout.tsx)<br/>[`src/app/styles/globals.css`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/app/styles/globals.css) |
|
|
| **`pages`** | ⭕ | 라우트에 1:1로 대응되는 화면 조립 단위. 하위 레이어들을 조합하여 완성된 화면 UI 구성 | [`src/pages/dashboard`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/pages/dashboard)<br/>[`src/pages/sewer-bsc-plan`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/pages/sewer-bsc-plan)<br/>[`src/pages/approval-line-management`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/pages/approval-line-management) |
|
|
| **`widgets`** | ⭕ | 여러 feature/entity를 결합한 독립적인 대형 UI 블록. 페이지 간 재사용 가능 | [`src/widgets/pbadms-sprt`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/widgets/pbadms-sprt) (행정지원 공통 폼)<br/>[`src/widgets/hangulgiangi`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/widgets/hangulgiangi) (웹한글기안기)<br/>[`src/widgets/ognz-user-tree`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/widgets/ognz-user-tree) (조직도) |
|
|
| **`features`** | ⭕ | 사용자 중심의 핵심 상호작용 및 비즈니스 액션 시나리오 (버튼 클릭, 모달 오픈 등) | 검색 필터링, 신청 승인 액션 등 (프로젝트 규모에 따라 `pages`나 `widgets`에 통합 가능) |
|
|
| **`entities`** | ⭕ | 비즈니스 실세계 개념 및 데이터 모델(타입, API 호출, 캐싱 쿼리, 상태) | [`src/entities/sewer-bsc-plan`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/entities/sewer-bsc-plan)<br/>[`src/entities/auth`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/entities/auth)<br/>[`src/entities/elapr`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/entities/elapr)<br/>[`src/entities/tus`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/entities/tus) |
|
|
| **`shared`** | ❌ (세그먼트만) | 비즈니스 로직이 전혀 없는 재사용 가능한 기반 코드. UI 라이브러리, HTTP 클라이언트, 유틸 | [`src/shared/api/client.ts`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/shared/api/client.ts)<br/>[`src/shared/lib/use-tus.ts`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/shared/lib/use-tus.ts)<br/>[`src/shared/ui/confirm-dialog.tsx`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/shared/ui/confirm-dialog.tsx) |
|
|
|
|
### 2.4 FSD의 2대 절대 불변 규칙
|
|
|
|
#### 규칙 1: 단방향 하향 의존성 규칙 (Unidirectional Dependency Rule)
|
|
> **"모듈은 오직 자신보다 물리적으로 '하위 레이어'에 위치한 모듈만 import할 수 있다."**
|
|
|
|
- **허용**: `pages` ➔ `widgets`, `features`, `entities`, `shared`
|
|
- **허용**: `widgets` ➔ `features`, `entities`, `shared`
|
|
- **허용**: `entities` ➔ `shared`
|
|
- ❌ **절대 금지 (동일 레이어 교차 참조)**: `entities/user`가 `entities/product`를 직접 import 불가 (필요시 교차 슬라이스 `@x` 표기법 또는 상위 위젯에서 합성).
|
|
- ❌ **절대 금지 (상위 레이어 역참조)**: `entities`가 `widgets`나 `pages`를 import 불가.
|
|
|
|
```typescript
|
|
// ✅ 올바른 참조 (하위 레이어 import)
|
|
// src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-form-page.tsx (pages 레이어)
|
|
import { AttachmentsFormSection } from "@/widgets/pbadms-sprt"; // widgets (하위)
|
|
import { useSewerBscPlanPage } from "@/entities/sewer-bsc-plan"; // entities (하위)
|
|
import { useAlertStore } from "@/shared/lib"; // shared (하위)
|
|
|
|
// ❌ 잘못된 참조 (규칙 위반)
|
|
// src/entities/sewer-bsc-plan/api/service.ts (entities 레이어)
|
|
import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan"; // 🚫 상위 레이어 역참조!
|
|
import { AttachmentsFormSection } from "@/widgets/pbadms-sprt"; // 🚫 상위 레이어 역참조!
|
|
```
|
|
|
|
#### 규칙 2: Public API (index.ts) 캡슐화 규칙
|
|
> **"슬라이스 외부에서는 반드시 해당 슬라이스의 Public API(`index.ts`)를 통해서만 접근해야 한다."**
|
|
|
|
슬라이스 내부의 세부 구현 파일(예: `ui/form-dialog.tsx`, `model/internal-types.ts`)을 외부에서 직접 경로로 import하는 것을 엄격히 금지합니다.
|
|
|
|
```typescript
|
|
// ✅ 올바른 import (Public API 사용)
|
|
import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan";
|
|
import { authService } from "@/entities/auth";
|
|
|
|
// ❌ 잘못된 import (내부 파일 직접 침투)
|
|
import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan/ui/sewer-bsc-plan-list-page";
|
|
import { authService } from "@/entities/auth/api/auth-service";
|
|
```
|
|
|
|
### 2.5 아키텍처 린터: Steiger (`pnpm run lint:fsd`)
|
|
|
|
AS 모듈은 FSD 아키텍처 규칙이 코드베이스 전체에서 자동으로 강제되도록 **Steiger** 린터를 도입했습니다:
|
|
```bash
|
|
# FSD 규칙 위반 여부 정적 분석 실행
|
|
pnpm run lint:fsd
|
|
```
|
|
Steiger는 다음과 같은 오류를 감지하고 빌드를 차단합니다:
|
|
- 상위 레이어를 역참조하는 구문
|
|
- 같은 레이어 내에서 다른 슬라이스를 무단 참조하는 구문
|
|
- Public API(`index.ts`)를 우회하여 내부 깊은 경로를 직접 import하는 구문
|
|
|
|
---
|
|
|
|
## 3. Next.js App Router 핵심 개념
|
|
|
|
### 3.1 파일 시스템 기반 라우팅 메커니즘
|
|
|
|
Next.js App Router는 프로젝트의 `app/` 디렉토리 아래의 폴더 계층 구조가 그대로 웹 애플리케이션의 URL 경로(URL Pathname)로 1:1 매핑되는 파일 기반 라우팅 시스템입니다.
|
|
|
|
폴더가 URL 세그먼트를 정의하고, 해당 폴더 안의 특수 파일들이 화면과 동작을 정의합니다.
|
|
|
|
### 3.2 App Router 특수 파일 컨벤션
|
|
|
|
| 파일명 | 역할 및 렌더링 동작 |
|
|
| :--- | :--- |
|
|
| **`page.tsx`** | 해당 URL 경로의 고유한 화면(Leaf UI)을 렌더링하는 엔트리 파일. |
|
|
| **`layout.tsx`** | 여러 하위 페이지가 공유하는 공통 레이아웃. 페이지 이동 시 재렌더링되지 않고 상태(State)가 보존됨. |
|
|
| **`loading.tsx`** | 리액트 `Suspense`의 `fallback`으로 자동 래핑되는 로딩 스켈레톤 UI. |
|
|
| **`error.tsx`** | 하위 컴포넌트 렌더링 중 에러 발생 시 자동 포착하는 React `ErrorBoundary`. |
|
|
| **`not-found.tsx`** | `notFound()` 함수 호출 시 렌더링되는 404 전용 UI. |
|
|
| **`route.ts`** | 웹 페이지가 아닌 REST API 엔드포인트를 제공하는 서버 핸들러 (BFF 역할). |
|
|
|
|
### 3.3 라우트 그룹 (Route Groups: `(folder)`)
|
|
|
|
소괄호 `()`로 감싼 폴더는 **URL 경로 세그먼트에 포함되지 않으면서** 라우트들을 논리적으로 그룹화하고, **서로 다른 레이아웃을 독립적으로 적용**할 수 있도록 해주는 핵심 기능입니다.
|
|
|
|
```
|
|
app/
|
|
├── (auth)/ # URL에 '/auth'가 붙지 않음
|
|
│ ├── auth/login/page.tsx # 실제 URL: /auth/login
|
|
│ └── layout.tsx # (auth) 그룹 전용 가벼운 레이아웃 (GNB 없음)
|
|
│
|
|
└── (main)/ # URL에 '/main'이 붙지 않음
|
|
├── (with-base-layout)/ # URL에 '/with-base-layout'이 붙지 않음
|
|
│ ├── dashboard/page.tsx# 실제 URL: /dashboard
|
|
│ └── layout.tsx # BaseLayoutWrapper (GNB, LNB, Footer 포함)
|
|
└── layout.tsx # Providers, 테마, 세션체크가 포함된 루트 레이아웃
|
|
```
|
|
|
|
### 3.4 동적 세그먼트 (Dynamic Routes: `[id]`)
|
|
|
|
대괄호 `[]`로 감싼 폴더는 URL 경로의 해당 자리를 동적 매개변수(Parameter)로 캡처합니다.
|
|
- `app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx` ➔ `/sewer-bsc-plan/101` 접근 시 `id = "101"` 캡처.
|
|
- **Next.js 15 변경점**: `params`와 `searchParams`가 **비동기 Promise**로 전달되므로 반드시 `await params`로 언래핑해야 합니다.
|
|
|
|
---
|
|
|
|
## 4. FSD v2.1과 Next.js App Router의 충돌 지점과 해결 전략
|
|
|
|
### 4.1 근본적인 아키텍처적 충돌
|
|
|
|
Next.js App Router와 FSD는 코드베이스의 구조화 철학에서 본질적인 충돌을 겪습니다:
|
|
|
|
```
|
|
[ Next.js 철학 ] : "모든 것은 app/ 디렉토리 안에 파일 시스템 구조로 모여야 한다."
|
|
VS
|
|
[ FSD v2.1 철학 ] : "코드는 비즈니스 도메인과 책임 계층(Layer/Slice)에 따라 분리되어야 한다."
|
|
```
|
|
|
|
#### 발생할 수 있는 안티패턴 (Naive 접근법의 문제점):
|
|
만약 FSD를 무시하고 Next.js 권장대로 `app/` 폴더 안에 컴포넌트, 훅, 비즈니스 코드를 직접 넣는다면:
|
|
1. `app/(main)/(with-base-layout)/sewer-bsc-plan/_components/form.tsx`
|
|
2. `app/(main)/(with-base-layout)/sewer-bsc-plan/_hooks/use-sewer.ts`
|
|
3. 다른 라우트(예: `fnrs-cnsltn`)에서 `sewer-bsc-plan`의 컴포넌트나 훅을 가져다 쓰려고 할 때 상대 경로가 꼬이고 레이어 경계가 무너짐.
|
|
4. 재사용 가능한 비즈니스 엔티티가 특정 라우트 폴더 내부에 깊이 매몰되어 응집도가 파괴됨.
|
|
|
|
### 4.2 해결 전략: "얇은 라우팅 진입점 패턴" (Thin Routing Entry Point)
|
|
|
|
FSD 공식 가이드와 `apps/as` 프로젝트는 이 충돌을 **역할의 완벽한 분리**로 해결했습니다:
|
|
|
|
```mermaid
|
|
graph LR
|
|
subgraph NextJS["Next.js 영역 (app/)"]
|
|
direction TB
|
|
A["app/ (Route Groups)"]
|
|
A1["layout.tsx<br/>(URL 구조 정의)"]
|
|
A2["page.tsx<br/>(파라미터 추출 & Re-export)"]
|
|
end
|
|
|
|
subgraph FSD["FSD v2.1 아키텍처 영역 (src/)"]
|
|
direction TB
|
|
B["src/app/<br/>(Providers & Layout 구현체)"]
|
|
C["src/pages/<br/>(화면 컴포넌트 & 조립)"]
|
|
D["src/widgets/<br/>(복합 UI 블록)"]
|
|
E["src/entities/<br/>(비즈니스 도메인)"]
|
|
F["src/shared/<br/>(기반 인프라 & UI Kit)"]
|
|
end
|
|
|
|
A1 -.->|"import"| B
|
|
A2 -.->|"import & Re-export"| C
|
|
C --> D
|
|
C --> E
|
|
C --> F
|
|
D --> E
|
|
D --> F
|
|
E --> F
|
|
```
|
|
|
|
1. **`app/` 디렉토리의 역할 (Routing Only)**:
|
|
- 오직 Next.js가 요구하는 **라우팅 진입점, 레이아웃 트리 합성, URL 파라미터 추출**만 수행합니다.
|
|
- 내부 구현 코드(비즈니스 로직, 폼 마크업, CSS 스타일 등)는 **단 한 줄도 작성하지 않습니다.**
|
|
- `page.tsx`는 `src/pages`의 Public API에서 화면 컴포넌트를 가져와 re-export합니다.
|
|
2. **`src/` 디렉토리의 역할 (FSD Codebase Only)**:
|
|
- 모든 실제 컴포넌트, 상태, 훅, API는 FSD 표준 계층에 따라 `src/`에 존재합니다.
|
|
- `src/pages`는 Next.js에 종속되지 않은 순수한 React 컴포넌트로 작성되어 테스트 및 유지보수가 용이합니다.
|
|
|
|
### 4.3 루트의 빈 `pages/` 폴더 트릭
|
|
|
|
`apps/as` 프로젝트 루트를 보면 빈 [`pages/README.md`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/pages/README.md)만 들어있는 빈 `pages` 디렉토리가 존재합니다.
|
|
|
|
#### 왜 빈 `pages/` 폴더가 필요한가?
|
|
Next.js는 프로젝트 내에 `src/pages` 디렉토리가 존재하면, 기본적으로 이를 **Pages Router(레거시 라우팅 시스템)**의 라우트로 자동 인식하려고 시도합니다.
|
|
이로 인해 Next.js 빌드 시 다음과 같은 충돌이 발생할 수 있습니다:
|
|
- `src/pages/sewer-bsc-plan/ui/...` 파일들을 Pages Router의 페이지 경로로 오인하여 빌드 에러 유발.
|
|
|
|
#### FSD 공식 해결책:
|
|
프로젝트 루트에 **빈 `pages/` 폴더**를 생성해 두면, Next.js는 루트의 `pages/`를 우선 확인하고 아무 라우트도 없다고 판단하여 `src/pages`를 Pages Router로 스캔하지 않습니다.
|
|
결과적으로 `src/pages`는 순수한 **FSD의 Pages 레이어**로서만 안전하게 동작할 수 있게 됩니다.
|
|
|
|
---
|
|
|
|
## 5. `apps/as` 프로젝트의 실제 결합 구현 분석
|
|
|
|
### 5.1 레이아웃 계층의 FSD 합성 구조
|
|
|
|
`app/`의 다중 레이아웃과 `src/app`의 전역 설정이 어떻게 결합되는지 살펴보겠습니다:
|
|
|
|
#### 1) 메인 루트 레이아웃: `app/(main)/layout.tsx`
|
|
`app` 레이아웃은 Next.js 서버 기능(쿠키 읽기)을 처리하고, FSD의 `src/app/providers`를 불러와 감싸줍니다:
|
|
|
|
```tsx
|
|
// apps/as/app/(main)/layout.tsx
|
|
import type { Metadata } from "next";
|
|
import { cookies } from "next/headers";
|
|
import { Providers } from "@/app/providers"; // FSD App Layer에서 가져옴
|
|
import "@/app/styles/globals.css";
|
|
|
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
const theme = (await cookies()).get("site-theme-key");
|
|
|
|
return (
|
|
<html lang="ko" data-theme={theme?.value}>
|
|
<body>
|
|
<Providers>{children}</Providers>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### 2) 전역 프로바이더: `src/app/providers/index.tsx`
|
|
FSD App 레이어에 위치하며, React Query, Zustand Store, Confirm, Auth, Menu Provider를 결합합니다:
|
|
|
|
```tsx
|
|
// apps/as/src/app/providers/index.tsx
|
|
"use client";
|
|
|
|
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
|
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
import { queryClient } from "@/shared/lib";
|
|
import { StoreProvider } from "@/shared/providers";
|
|
import { AuthWrapper } from "@/entities/auth";
|
|
import { MenuWrapper } from "@/entities/menu";
|
|
|
|
export function Providers({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<StoreProvider>
|
|
<ConfirmProvider>
|
|
<AuthWrapper>
|
|
<MenuWrapper>{children}</MenuWrapper>
|
|
</AuthWrapper>
|
|
</ConfirmProvider>
|
|
</StoreProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### 3) 공통 프레임 레이아웃: `app/(main)/(with-base-layout)/layout.tsx`
|
|
GNB, LNB 사이드바, 푸터를 포함하는 레이아웃 역시 FSD App 레이어의 `BaseLayoutWrapper`를 re-export합니다:
|
|
|
|
```tsx
|
|
// apps/as/app/(main)/(with-base-layout)/layout.tsx
|
|
import { BaseLayoutWrapper } from "@/app/layouts/base-layout"; // FSD App Layer
|
|
|
|
export default function WithBaseLayout({ children }: { children: React.ReactNode }) {
|
|
return <BaseLayoutWrapper>{children}</BaseLayoutWrapper>;
|
|
}
|
|
```
|
|
|
|
### 5.2 화면 라우트 1:1 매핑 구현
|
|
|
|
#### 1) 단순 목록 화면: 하수도 기본계획 목록 (`sewer-bsc-plan/page.tsx`)
|
|
|
|
Next.js 라우트 파일은 단 11줄로 구성되며, FSD의 `src/pages/sewer-bsc-plan` 컴포넌트를 Suspense로 감싸기만 합니다:
|
|
|
|
```tsx
|
|
// apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/page.tsx
|
|
import { Suspense } from "react";
|
|
import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan"; // FSD Public API
|
|
|
|
export default function Page() {
|
|
return (
|
|
<Suspense>
|
|
<SewerBscPlanListPage />
|
|
</Suspense>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### 2) 동적 파라미터 화면: 하수도 기본계획 상세 (`sewer-bsc-plan/[id]/page.tsx`)
|
|
|
|
Next.js 15의 비동기 `params`와 `searchParams`를 `await`로 추출하여, FSD 컴포넌트의 props로 주입합니다:
|
|
|
|
```tsx
|
|
// apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx
|
|
import { SewerBscPlanDetailGate } from "@/pages/sewer-bsc-plan"; // FSD Public API
|
|
|
|
interface PageProps {
|
|
params: Promise<{ id: string }>;
|
|
searchParams: Promise<{ chgCycl?: string }>;
|
|
}
|
|
|
|
export default async function Page({ params, searchParams }: PageProps) {
|
|
// Next.js 15 Server Component에서 비동기 파라미터 언래핑
|
|
const { id } = await params;
|
|
const { chgCycl } = await searchParams;
|
|
const propChgCycl = chgCycl ? Number(chgCycl) : undefined;
|
|
|
|
// FSD Pages 컴포넌트에 순수 props로 전달
|
|
return <SewerBscPlanDetailGate id={id} propChgCycl={propChgCycl} />;
|
|
}
|
|
```
|
|
|
|
### 5.3 FSD Pages 레이어 내부 조립 (Composition Pattern)
|
|
|
|
실제 비즈니스 로직과 화면 UI는 [`src/pages/sewer-bsc-plan`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/src/pages/sewer-bsc-plan) 슬라이스 안에서 하위 위젯과 엔티티를 조합하여 구성됩니다:
|
|
|
|
```tsx
|
|
// apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-form-page.tsx
|
|
"use client";
|
|
|
|
// 1. 하위 shared 레이어 import
|
|
import { getListHref, useAlertStore, useUserStore } from "@/shared/lib";
|
|
import { SectionCollapsibleGroup } from "@/shared/ui";
|
|
|
|
// 2. 하위 entities 레이어 import
|
|
import { useAcntgSeMngTree } from "@/entities/acntg";
|
|
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
|
|
import { sewerBscPlanService } from "@/entities/sewer-bsc-plan";
|
|
|
|
// 3. 하위 widgets 레이어 import (행정지원 공통 컴포넌트)
|
|
import {
|
|
AttachmentsFormSection,
|
|
ChecklistFormSection,
|
|
ContactInfoFormSection,
|
|
PrivacyConsentSection,
|
|
} from "@/widgets/pbadms-sprt";
|
|
|
|
export function SewerBscPlanFormPage({ mode, initialData }: Props) {
|
|
// react-hook-form 및 비즈니스 데이터 처리
|
|
// widgets과 entities에서 가져온 블록들을 선언적으로 배치
|
|
return (
|
|
<form onSubmit={...}>
|
|
<SectionCollapsibleGroup>
|
|
<ContactInfoFormSection />
|
|
<AttachmentsFormSection />
|
|
<ChecklistFormSection />
|
|
<PrivacyConsentSection />
|
|
</SectionCollapsibleGroup>
|
|
</form>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 5.4 FSD Public API 작성 규칙 준수
|
|
|
|
모든 슬라이스는 최상단 `index.ts`를 통해 외부로 공개할 컴포넌트와 타입만 명시적으로 내보냅니다:
|
|
|
|
```typescript
|
|
// apps/as/src/pages/sewer-bsc-plan/index.ts
|
|
export { SewerBscPlanDetailGate } from "./ui/sewer-bsc-plan-detail-gate";
|
|
export { SewerBscPlanEditPage } from "./ui/sewer-bsc-plan-edit-page";
|
|
export { SewerBscPlanFormPage } from "./ui/sewer-bsc-plan-form-page";
|
|
export { SewerBscPlanListPage } from "./ui/sewer-bsc-plan-list-page";
|
|
export { SewerBscPlanRegistPage } from "./ui/sewer-bsc-plan-regist-page";
|
|
export { SewerBscPlanViewPage } from "./ui/sewer-bsc-plan-view-page";
|
|
```
|
|
|
|
슬라이스 내부의 세부 구현 파일(예: `lib/fnrs-cst-form-utils.ts`, `sections/basic-info-form-section.tsx`)은 외부로 노출되지 않고 슬라이스 내부에 안전하게 캡슐화됩니다.
|
|
|
|
---
|
|
|
|
## 6. 개발 가이드라인 및 의사결정 트리 (Best Practices)
|
|
|
|
### 6.1 신규 화면 추가 시 작업 순서 (Step-by-Step Workflow)
|
|
|
|
새로운 업무 화면(예: `water-quality-mng` 수질 관리)을 개발할 때 권장되는 작업 절차는 다음과 같습니다:
|
|
|
|
```
|
|
[1단계: Entity 정의]
|
|
src/entities/water-quality-mng/
|
|
├── api/ (Axios 요청 및 React Query Hooks 작성)
|
|
├── model/ (DTO 인터페이스 및 Zod 스키마 정의)
|
|
└── index.ts (Public API export)
|
|
↓
|
|
[2단계: 필요 시 Widget 작성]
|
|
src/widgets/water-quality-mng/ (여러 화면에서 재사용될 복합 UI인 경우만 생성)
|
|
↓
|
|
[3단계: Page 조립]
|
|
src/pages/water-quality-mng/
|
|
├── ui/ (목록, 등록, 상세 컴포넌트 작성)
|
|
└── index.ts (Public API export)
|
|
↓
|
|
[4단계: Next.js App Router 연결]
|
|
app/(main)/(with-base-layout)/water-quality-mng/
|
|
├── page.tsx (src/pages에서 컴포넌트 re-export)
|
|
└── [id]/page.tsx (동적 파라미터 언래핑 후 컴포넌트 re-export)
|
|
↓
|
|
[5단계: 아키텍처 린트 검증]
|
|
pnpm run lint:fsd 실행하여 의존성 규칙 준수 확인
|
|
```
|
|
|
|
### 6.2 컴포넌트 위치 결정 의사결정 트리
|
|
|
|
새로운 코드나 컴포넌트를 작성할 때 어떤 레이어에 배치해야 할지 판단하는 기준입니다:
|
|
|
|
```mermaid
|
|
graph TD
|
|
Q1{"Q1. 비즈니스 도메인(업무 개념)과 관련이 있는가?"}
|
|
Q1 -->|아니오| L_SHARED["shared/ 에 배치<br/>(UI Kit, 순수 유틸, 공통 클라이언트)"]
|
|
Q1 -->|예| Q2{"Q2. 특정 라우트 화면 하나에서만 쓰이는가?"}
|
|
|
|
Q2 -->|예| L_PAGES["src/pages/[slice]/ 에 배치<br/>(화면 전용 컴포넌트, 전용 폼)"]
|
|
Q2 -->|아니오| Q3{"Q3. 여러 화면에서 재사용되는 큰 규모의 UI 블록인가?"}
|
|
|
|
Q3 -->|예| L_WIDGETS["widgets/[slice]/ 에 배치<br/>(행정지원 공통 폼, 조직도, 기안기)"]
|
|
Q3 -->|아니오| Q4{"Q4. 사용자의 능동적인 인터랙션/액션 단위인가?"}
|
|
|
|
Q4 -->|예| L_FEATURES["features/[slice]/ 에 배치<br/>(로그인 액션, 검색 필터 버튼)"]
|
|
Q4 -->|아니오| L_ENTITIES["entities/[slice]/ 에 배치<br/>(데이터 모델, DTO, API 쿼리, 기본 카드 뷰)"]
|
|
```
|
|
|
|
### 6.3 Server Component와 Client Component 경계 관리
|
|
|
|
- **`app/` 디렉토리의 `page.tsx`**:
|
|
- 기본적으로 **Server Component**로 유지하는 것이 좋습니다.
|
|
- 비동기 `params`, `searchParams`를 서버에서 안전하게 수신하고, 필요한 경우 메타데이터(`generateMetadata`)를 구성할 수 있습니다.
|
|
- **`src/pages/` 디렉토리의 화면 컴포넌트**:
|
|
- 사용자 입력, 폼 상태(`react-hook-form`), 클라이언트 상태(`Zustand`), 데이터 페칭(`TanStack Query`)을 활발히 사용하므로 상단에 `"use client";`를 명시합니다.
|
|
- Next.js 서버 컴포넌트(`page.tsx`)가 클라이언트 컴포넌트(`SewerBscPlanListPage`)를 자식으로 임포트하는 자연스러운 **RSC 경계 분리**가 완성됩니다.
|
|
|
|
---
|
|
|
|
## 7. 결론 (Summary)
|
|
|
|
`apps/as` 모듈이 채택한 **FSD v2.1과 Next.js App Router의 결합 아키텍처**는 다음과 같은 결정적 장점을 제공합니다:
|
|
|
|
1. **프레임워크 종속성 격리**:
|
|
- Next.js의 프레임워크 전용 파일들(`layout.tsx`, `page.tsx`)을 얇은 라우팅 어댑터로 격리함으로써, 비즈니스 코드가 Next.js 버전에 종속되어 레거시화되는 것을 방지합니다.
|
|
2. **높은 도메인 응집도와 낮은 결합도**:
|
|
- 하수도기본계획, 재원협의 등 각 업무 도메인의 모델과 API가 `entities/`에 명확히 캡슐화되어 다른 화면이나 위젯에서 안전하게 재사용됩니다.
|
|
3. **엄격한 아키텍처 린팅 보장**:
|
|
- `steiger`를 통해 개발자의 실수로 인한 상위 레이어 역참조나 순환 참조를 사전에 차단하여 장기적인 유지보수 비용을 크게 낮춥니다.
|
|
|
|
---
|
|
|
|
## 8. Next.js 내부 소스 레벨의 라우트 그룹 (Route Groups) 인식 원리 및 구성 분석
|
|
|
|
Next.js에서 소괄호로 감싼 폴더인 **라우트 그룹(`(folder)`)**이 어떻게 URL 경로에서 투명하게 제외되고, 독립적인 레이아웃 경계를 형성하는지 Next.js 코어 소스코드와 빌드 엔진 관점에서 상세히 분석합니다.
|
|
|
|
### 8.1 Next.js 코어 소스코드 분석: 라우트 그룹 인식 메커니즘
|
|
|
|
Next.js의 내부 패키지([`next/dist`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/node_modules/next/dist))를 분석하면, 라우트 그룹을 감지하고 처리하는 핵심 함수들을 확인할 수 있습니다.
|
|
|
|
#### 1) 라우트 그룹 판별 함수: `isGroupSegment`
|
|
Next.js는 세그먼트 문자열이 라우트 그룹인지 판별할 때 정규식(RegExp) 대신 성능 최적화(Performance Purpose)를 위해 첫 글자와 끝 글자를 직접 검사합니다:
|
|
|
|
```typescript
|
|
// next/src/shared/lib/segment.ts (next/dist/shared/lib/segment.js)
|
|
export function isGroupSegment(segment: string) {
|
|
// O(1) 고속 연산을 위해 배열 인덱스 활용
|
|
return segment[0] === '(' && segment.endsWith(')')
|
|
}
|
|
```
|
|
- **판정 기준**: 폴더명 세그먼트의 첫 글자가 여는 소괄호 `(`이고, 마지막 글자가 닫는 소괄호 `)`이면 `GroupSegment`로 판단합니다.
|
|
- 예: `(auth)` ➔ `true`, `(main)` ➔ `true`, `(with-base-layout)` ➔ `true`, `[id]` ➔ `false`, `auth` ➔ `false`.
|
|
|
|
#### 2) URL 노출 여부 결정 함수: `doesStaticSegmentAppearInURL`
|
|
클라이언트 라우터가 라우트 트리에서 실제 브라우저 주소창(URL)에 노출될 경로를 조립할 때 호출하는 함수입니다:
|
|
|
|
```typescript
|
|
// next/src/client/route-params.ts (next/dist/client/route-params.js)
|
|
export function doesStaticSegmentAppearInURL(segment: string): boolean {
|
|
if (
|
|
segment === ROOT_SEGMENT_REQUEST_KEY ||
|
|
segment.startsWith(PAGE_SEGMENT_KEY) ||
|
|
// ★ 라우트 그룹은 URL에 나타나지 않으므로 false 반환 (생략)
|
|
(segment[0] === '(' && segment.endsWith(')')) ||
|
|
segment === DEFAULT_SEGMENT_KEY ||
|
|
segment === '/_not-found'
|
|
) {
|
|
return false
|
|
} else {
|
|
// 그 외 일반 세그먼트는 URL에 포함됨
|
|
return true
|
|
}
|
|
}
|
|
```
|
|
- 세그먼트가 라우트 그룹인 경우 `false`를 리턴하여 URL Pathname 생성 목록에서 완전히 제외(Skip)합니다.
|
|
|
|
#### 3) 클라이언트 라우터의 경로 정규화: `normalizeSegments`
|
|
페이지 이동 및 브라우저 히스토리 갱신 시 FlightRouterState 트리에서 실제 URL을 계산하는 로직입니다:
|
|
|
|
```typescript
|
|
// next/src/client/components/router-reducer/compute-changed-path.ts
|
|
function normalizeSegments(segments: string[]): string {
|
|
return (
|
|
segments.reduce((acc, segment) => {
|
|
segment = removeLeadingSlash(segment)
|
|
// ★ 빈 문자열이거나 라우트 그룹인 경우 누적 URL에 추가하지 않고 통과
|
|
if (segment === '' || isGroupSegment(segment)) {
|
|
return acc
|
|
}
|
|
|
|
return `${acc}/${segment}`
|
|
}, '') || '/'
|
|
)
|
|
}
|
|
```
|
|
- 경로 배열 `['(main)', '(with-base-layout)', 'dashboard']`가 전달되면 `isGroupSegment`가 `(main)`과 `(with-base-layout)`을 건너뛰어 최종적으로 `"/dashboard"`라는 정규화된 URL을 산출합니다.
|
|
|
|
#### 4) Webpack 빌드 타임의 로더 트리 생성: `next-app-loader`
|
|
Next.js 빌드 시 웹팩 로더([`next-app-loader`](file:///Users/changwng/workspace.ona/front/frontend_as/apps/as/node_modules/next/dist/build/webpack/loaders/next-app-loader))는 파일 시스템을 스캔하여 계층적 트리(`FlightRouterState`)를 빌드합니다:
|
|
|
|
```typescript
|
|
// next/src/build/webpack/loaders/next-app-loader/index.ts
|
|
// 1. 첫 번째 레이어가 그룹 라우트인지 확인하여 루트 레이어로 취급
|
|
const isFirstLayerGroupRoute =
|
|
segments.length === 1 &&
|
|
subSegmentPath.filter((seg) => isGroupSegment(seg)).length === 1
|
|
|
|
// 2. 서로 다른 라우트 그룹이 동일한 URL 경로로 충돌하는지 정적 검사
|
|
if (!hasCurrentParallelPage && !isIncomingParallelPage) {
|
|
throw new Error(
|
|
`You cannot have two parallel pages that resolve to the same path. Please check ${existingChildrenPath} and ${appPath}. Refer to the route group docs for more information: https://nextjs.org/docs/app/building-your-application/routing/route-groups`
|
|
)
|
|
}
|
|
```
|
|
- 빌드 엔진은 라우트 그룹을 **"레이아웃과 에러 바운더리를 독립적으로 분기하는 가상 트리 노드"**로 유지하면서, 최종 URL 라우트 맵에서는 동일한 URL에 중복된 페이지가 매핑되지 않도록 엄격히 검증합니다.
|
|
|
|
---
|
|
|
|
### 8.2 세팅 및 구성(Configuration & Structure) 측면 분석
|
|
|
|
#### 1) 별도 환경설정 불필요 (Zero Configuration Convention)
|
|
Next.js의 라우트 그룹은 `next.config.ts`나 `tsconfig.json`에 특별한 플래그를 켤 필요가 없습니다.
|
|
Next.js 컴파일러와 런타임에 소괄호 `(name)` 패턴이 **예약된 명명 규칙(Reserved Naming Convention)**으로 내장되어 있기 때문입니다.
|
|
|
|
#### 2) `apps/as`의 다중 루트 레이아웃(Multiple Root Layouts) 구성 전략
|
|
`apps/as`는 라우트 그룹을 활용해 **서로 다른 HTML 셸을 가진 2개의 독립 루트 레이아웃**을 구성했습니다:
|
|
|
|
```
|
|
apps/as/app/
|
|
├── (auth)/ # 그룹 1: 인증 화면군
|
|
│ ├── auth/login/page.tsx # URL: /auth/login
|
|
│ └── layout.tsx # 독립 RootLayout: Provider 없이 가벼운 <html><body> 제공
|
|
│
|
|
└── (main)/ # 그룹 2: 메인 업무 포털 화면군
|
|
├── layout.tsx # 독립 RootLayout: 테마, QueryClient, Auth, Menu Provider 포함
|
|
└── (with-base-layout)/ # 중첩 그룹: 공통 프레임 레이아웃
|
|
├── layout.tsx # BaseLayoutWrapper: Header, Sidebar, Footer 포함
|
|
├── dashboard/page.tsx # URL: /dashboard
|
|
└── sewer-bsc-plan/... # URL: /sewer-bsc-plan
|
|
```
|
|
|
|
- **최상위 `app/layout.tsx` 부재의 의미**:
|
|
- `app/` 바로 아래에 `layout.tsx`를 두지 않고, `(auth)/layout.tsx`와 `(main)/layout.tsx`로 분리함으로써 **로그인 페이지 진입 시 불필요한 무거운 전역 Provider와 레이아웃 JS 번들이 로드되는 것을 원천 차단**했습니다.
|
|
- 각각의 레이아웃이 독자적인 `<html>`과 `<body>` 태그를 반환할 수 있습니다.
|
|
|
|
#### 3) 중첩 라우트 그룹 (Nested Route Groups) 구성
|
|
`app/(main)/(with-base-layout)`처럼 라우트 그룹 안에 또 다른 라우트 그룹을 중첩할 수 있습니다:
|
|
- `(main)`: 앱 전역 상태, 인증/세션 검사, 테마 컨텍스트 주입.
|
|
- `(with-base-layout)`: 시각적 GNB 헤더 및 LNB 사이드바 주입.
|
|
- 향후 기본 레이아웃이 필요 없는 팝업형 업무 창이 필요할 경우, `app/(main)/(without-base-layout)/popup/page.tsx` 형태로 확장할 수 있는 유연한 구조를 확보했습니다.
|
|
|
|
#### 4) 특수 폴더 명명 규칙 비교표
|
|
|
|
| 표기법 | Next.js 해석 | URL 포함 여부 | 주요 용도 |
|
|
| :--- | :--- | :---: | :--- |
|
|
| **`(folder)`** | **Route Group** | ❌ **제외됨** | 레이아웃 분리, 모듈 그룹화, 다중 루트 레이아웃 |
|
|
| **`_folder`** | **Private Folder** | ❌ **제외됨 (라우팅 불가)** | 라우트 대상에서 완전히 제외하고 내부 컴포넌트 격리 |
|
|
| **`[folder]`** | **Dynamic Segment** | ⭕ **포함됨 (가변값)** | 동적 URL 파라미터 매핑 (`[id]`, `[code]`) |
|
|
| **`@folder`** | **Parallel Route** | ❌ **제외됨** | 동일 레이아웃 내 독립 슬롯 렌더링 (병렬 라우트) |
|
|
| **`folder`** | **Static Segment** | ⭕ **포함됨 (고정값)** | 일반적인 정적 URL 세그먼트 |
|
|
|
|
#### 5) 라우트 그룹 구성 시 주의점 (Gotchas)
|
|
1. **경로 충돌(Collision)**:
|
|
- `app/(auth)/about/page.tsx`와 `app/(main)/about/page.tsx`를 동시에 생성하면 두 파일 모두 `/about`이라는 동일한 URL로 해석되므로 Next.js 빌드 단계에서 `You cannot have two parallel pages that resolve to the same path` 에러가 발생합니다.
|
|
2. **루트 레이아웃 간 이동 시 전체 리로드(Full Page Reload)**:
|
|
- 최상위 `<html>`, `<body>`를 각각 소유하는 라우트 그룹 간(예: `(auth)/auth/login` ➔ `(main)/dashboard`) 이동 시에는 React 상태가 보존되는 클라이언트 사이드 전환(Soft Navigation)이 아니라 브라우저 전체가 새로고침되는 하드 네비게이션(Hard Navigation)이 일어납니다. (로그인 후 세션 초기화 측면에서는 오히려 권장되는 동작입니다.)
|
|
|