35 KiB
FSD(Feature-Sliced Design) v2.1과 Next.js App Router 결합 아키텍처 가이드
1. 개요 (Overview)
본 문서는 **디지털 행정지원 시스템(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는 코드베이스를 세 가지 차원으로 체계화합니다:
- Layers (레이어 - 수평적 분리):
- 애플리케이션 내에서의 책임과 권한 수준에 따른 최상위 분류 (표준 6개 계층).
- Slices (슬라이스 - 수직적 비즈니스 분리):
- 레이어 내부를 특정 비즈니스 도메인 단위로 분할한 디렉토리.
- 예:
entities/sewer-bsc-plan,entities/auth,pages/dashboard - 주의:
app과shared레이어는 도메인이 없으므로 슬라이스를 갖지 않습니다.
- Segments (세그먼트 - 기술적 분리):
- 슬라이스 내부를 기술적 목적에 따라 나눈 폴더.
- 표준 세그먼트:
ui(마크업/컴포넌트),model(타입/상태/스토어),api(서버 통신/쿼리),lib(유틸리티/헬퍼).
2.3 6개 표준 레이어의 상세 역할
| 레이어 (Layer) | 슬라이스 유무 | 책임 및 역할 | apps/as 실제 구현 사례 |
|---|---|---|---|
app |
❌ (세그먼트만) | 앱 전역 설정, 전역 Context Providers, 글로벌 스타일, 최상위 레이아웃 | src/app/providers/index.tsxsrc/app/layouts/base-layout.tsxsrc/app/styles/globals.css |
pages |
⭕ | 라우트에 1:1로 대응되는 화면 조립 단위. 하위 레이어들을 조합하여 완성된 화면 UI 구성 | src/pages/dashboardsrc/pages/sewer-bsc-plansrc/pages/approval-line-management |
widgets |
⭕ | 여러 feature/entity를 결합한 독립적인 대형 UI 블록. 페이지 간 재사용 가능 | src/widgets/pbadms-sprt (행정지원 공통 폼)src/widgets/hangulgiangi (웹한글기안기)src/widgets/ognz-user-tree (조직도) |
features |
⭕ | 사용자 중심의 핵심 상호작용 및 비즈니스 액션 시나리오 (버튼 클릭, 모달 오픈 등) | 검색 필터링, 신청 승인 액션 등 (프로젝트 규모에 따라 pages나 widgets에 통합 가능) |
entities |
⭕ | 비즈니스 실세계 개념 및 데이터 모델(타입, API 호출, 캐싱 쿼리, 상태) | src/entities/sewer-bsc-plansrc/entities/authsrc/entities/elaprsrc/entities/tus |
shared |
❌ (세그먼트만) | 비즈니스 로직이 전혀 없는 재사용 가능한 기반 코드. UI 라이브러리, HTTP 클라이언트, 유틸 | src/shared/api/client.tssrc/shared/lib/use-tus.tssrc/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 불가.
// ✅ 올바른 참조 (하위 레이어 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하는 것을 엄격히 금지합니다.
// ✅ 올바른 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 린터를 도입했습니다:
# 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/ 폴더 안에 컴포넌트, 훅, 비즈니스 코드를 직접 넣는다면:
app/(main)/(with-base-layout)/sewer-bsc-plan/_components/form.tsxapp/(main)/(with-base-layout)/sewer-bsc-plan/_hooks/use-sewer.ts- 다른 라우트(예:
fnrs-cnsltn)에서sewer-bsc-plan의 컴포넌트나 훅을 가져다 쓰려고 할 때 상대 경로가 꼬이고 레이어 경계가 무너짐. - 재사용 가능한 비즈니스 엔티티가 특정 라우트 폴더 내부에 깊이 매몰되어 응집도가 파괴됨.
4.2 해결 전략: "얇은 라우팅 진입점 패턴" (Thin Routing Entry Point)
FSD 공식 가이드와 apps/as 프로젝트는 이 충돌을 역할의 완벽한 분리로 해결했습니다:
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
app/디렉토리의 역할 (Routing Only):- 오직 Next.js가 요구하는 라우팅 진입점, 레이아웃 트리 합성, URL 파라미터 추출만 수행합니다.
- 내부 구현 코드(비즈니스 로직, 폼 마크업, CSS 스타일 등)는 단 한 줄도 작성하지 않습니다.
page.tsx는src/pages의 Public API에서 화면 컴포넌트를 가져와 re-export합니다.
src/디렉토리의 역할 (FSD Codebase Only):- 모든 실제 컴포넌트, 상태, 훅, API는 FSD 표준 계층에 따라
src/에 존재합니다. src/pages는 Next.js에 종속되지 않은 순수한 React 컴포넌트로 작성되어 테스트 및 유지보수가 용이합니다.
- 모든 실제 컴포넌트, 상태, 훅, API는 FSD 표준 계층에 따라
4.3 루트의 빈 pages/ 폴더 트릭
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를 불러와 감싸줍니다:
// 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를 결합합니다:
// 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합니다:
// 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로 감싸기만 합니다:
// 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로 주입합니다:
// 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 슬라이스 안에서 하위 위젯과 엔티티를 조합하여 구성됩니다:
// 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를 통해 외부로 공개할 컴포넌트와 타입만 명시적으로 내보냅니다:
// 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 컴포넌트 위치 결정 의사결정 트리
새로운 코드나 컴포넌트를 작성할 때 어떤 레이어에 배치해야 할지 판단하는 기준입니다:
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의 결합 아키텍처는 다음과 같은 결정적 장점을 제공합니다:
- 프레임워크 종속성 격리:
- Next.js의 프레임워크 전용 파일들(
layout.tsx,page.tsx)을 얇은 라우팅 어댑터로 격리함으로써, 비즈니스 코드가 Next.js 버전에 종속되어 레거시화되는 것을 방지합니다.
- Next.js의 프레임워크 전용 파일들(
- 높은 도메인 응집도와 낮은 결합도:
- 하수도기본계획, 재원협의 등 각 업무 도메인의 모델과 API가
entities/에 명확히 캡슐화되어 다른 화면이나 위젯에서 안전하게 재사용됩니다.
- 하수도기본계획, 재원협의 등 각 업무 도메인의 모델과 API가
- 엄격한 아키텍처 린팅 보장:
steiger를 통해 개발자의 실수로 인한 상위 레이어 역참조나 순환 참조를 사전에 차단하여 장기적인 유지보수 비용을 크게 낮춥니다.
8. Next.js 내부 소스 레벨의 라우트 그룹 (Route Groups) 인식 원리 및 구성 분석
Next.js에서 소괄호로 감싼 폴더인 **라우트 그룹((folder))**이 어떻게 URL 경로에서 투명하게 제외되고, 독립적인 레이아웃 경계를 형성하는지 Next.js 코어 소스코드와 빌드 엔진 관점에서 상세히 분석합니다.
8.1 Next.js 코어 소스코드 분석: 라우트 그룹 인식 메커니즘
Next.js의 내부 패키지(next/dist)를 분석하면, 라우트 그룹을 감지하고 처리하는 핵심 함수들을 확인할 수 있습니다.
1) 라우트 그룹 판별 함수: isGroupSegment
Next.js는 세그먼트 문자열이 라우트 그룹인지 판별할 때 정규식(RegExp) 대신 성능 최적화(Performance Purpose)를 위해 첫 글자와 끝 글자를 직접 검사합니다:
// 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)에 노출될 경로를 조립할 때 호출하는 함수입니다:
// 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을 계산하는 로직입니다:
// 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)는 파일 시스템을 스캔하여 계층적 트리(FlightRouterState)를 빌드합니다:
// 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)
- 경로 충돌(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에러가 발생합니다.
- 루트 레이아웃 간 이동 시 전체 리로드(Full Page Reload):
- 최상위
<html>,<body>를 각각 소유하는 라우트 그룹 간(예:(auth)/auth/login➔(main)/dashboard) 이동 시에는 React 상태가 보존되는 클라이언트 사이드 전환(Soft Navigation)이 아니라 브라우저 전체가 새로고침되는 하드 네비게이션(Hard Navigation)이 일어납니다. (로그인 후 세션 초기화 측면에서는 오히려 권장되는 동작입니다.)
- 최상위