diff --git a/.gitignore b/.gitignore
index 80cd476eb..c5082bba3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,4 +27,4 @@ settings.json
.npmrc
.cursor
-apps/**/tsconfig.tsbuildinfo
\ No newline at end of file
+apps/**/tsconfig.tsbuildinfo
diff --git a/.husky/pre-push b/.husky/pre-push
new file mode 100755
index 000000000..82c73e102
--- /dev/null
+++ b/.husky/pre-push
@@ -0,0 +1,3 @@
+#!/bin/sh
+BRANCH=$(git rev-parse --abbrev-ref HEAD)
+pnpm turbo run build --filter="...[origin/$BRANCH]"
diff --git a/apps/_template/.env.example b/apps/_template/.env.example
new file mode 100644
index 000000000..b2d706024
--- /dev/null
+++ b/apps/_template/.env.example
@@ -0,0 +1,5 @@
+# apps/as/shared/config/env.ts 참고
+NEXT_PUBLIC_PORT=
+NEXT_PUBLIC_PROXY_HOST=
+NEXT_PUBLIC_SITE_ID=
+NEXT_PUBLIC_SERVER_API_URL=
diff --git a/apps/_template/.eslintrc.json b/apps/_template/.eslintrc.json
new file mode 100644
index 000000000..ea782d70a
--- /dev/null
+++ b/apps/_template/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": "next/core-web-vitals"
+}
diff --git a/apps/_template/.gitignore b/apps/_template/.gitignore
new file mode 100644
index 000000000..fd3dbb571
--- /dev/null
+++ b/apps/_template/.gitignore
@@ -0,0 +1,36 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/apps/_template/.prettierrc b/apps/_template/.prettierrc
new file mode 100644
index 000000000..059b4e33c
--- /dev/null
+++ b/apps/_template/.prettierrc
@@ -0,0 +1,8 @@
+{
+ "semi": true,
+ "trailingComma": "all",
+ "singleQuote": false,
+ "printWidth": 100,
+ "tabWidth": 2,
+ "plugins": ["prettier-plugin-tailwindcss"]
+}
diff --git a/apps/_template/README.md b/apps/_template/README.md
new file mode 100644
index 000000000..7e14528f8
--- /dev/null
+++ b/apps/_template/README.md
@@ -0,0 +1,248 @@
+# AS Application
+
+Next.js 애플리케이션 with FSD(Feature Sliced Design) 아키텍처
+
+## 기술 스택
+
+- **프레임워크**: Next.js 15 + TypeScript
+- **스타일링**: Tailwind CSS
+- **상태관리**: Zustand
+- **데이터 페칭**: TanStack Query (React Query)
+- **폼 관리**: React Hook Form + Zod
+- **테이블**: TanStack Table
+- **HTTP 클라이언트**: Axios
+- **날짜 처리**: Day.js
+
+## 프로젝트 구조 (FSD)
+
+이 프로젝트는 **Feature-Sliced Design v2.1**을 따르며, 계층적 아키텍처로 구성되어 있습니다.
+
+```
+as/
+├── app/ # App 레이어 (Next.js App Router)
+│ ├── (routes)/ # 라우트 구조
+│ ├── layout.tsx # 루트 레이아웃
+│ ├── page.tsx # 홈 페이지
+│ ├── globals.css # 글로벌 스타일
+│ └── providers.tsx # 전역 providers
+│
+├── widgets/ # Widgets 레이어
+│ └── [widget-name]/ # 큰 독립적 UI 블록 (여러 페이지에서 재사용)
+│ ├── ui/
+│ ├── model/
+│ └── index.ts
+│
+├── features/ # Features 레이어
+│ └── [feature-name]/ # 사용자 상호작용 기능 (여러 페이지에서 재사용)
+│ ├── ui/
+│ ├── api/
+│ ├── model/
+│ └── index.ts
+│
+├── entities/ # Entities 레이어
+│ └── [entity-name]/ # 비즈니스 엔티티 (User, Product 등)
+│ ├── model/
+│ ├── api/
+│ ├── ui/
+│ └── index.ts
+│
+└── shared/ # Shared 레이어
+ ├── ui/ # UI Kit (비즈니스 로직 없는 공통 컴포넌트)
+ ├── api/ # API 클라이언트 설정
+ ├── lib/ # 내부 라이브러리 (dates, colors 등)
+ ├── config/ # 환경 설정, feature flags
+ ├── types/ # 공통 타입 정의
+ └── routes/ # 라우트 상수
+```
+
+### 계층 구조 이해하기
+
+FSD는 **3단계 계층**으로 구성됩니다:
+
+1. **Layers (레이어)**: 책임과 의존성에 따른 수평적 분리 (app, pages, widgets, features, entities, shared)
+2. **Slices (슬라이스)**: 비즈니스 도메인에 따른 수직적 분리 (user, product, auth-login 등)
+3. **Segments (세그먼트)**: 기술적 목적에 따른 분리 (ui, api, model, lib 등)
+
+> 📚 각 레이어의 자세한 설명은 해당 폴더의 README.md를 참고하세요.
+
+## FSD 레이어 설명
+
+FSD는 계층적 아키텍처로, 각 레이어는 책임의 범위와 의존성의 정도에 따라 구분됩니다. 7개의 표준화된 레이어가 있으며, 프로젝트에 필요한 레이어만 사용하면 됩니다.
+
+### 레이어 계층 구조 (위에서 아래로)
+
+```
+app/ ↑ 가장 높은 책임, 가장 많은 의존성
+pages/ |
+widgets/ |
+features/ |
+entities/ |
+shared/ ↓ 가장 낮은 책임, 의존성 없음
+```
+
+### 1. App
+- **목적**: 앱 전체에 영향을 주는 기술적/비즈니스적 사항
+- **내용**: Context providers, 글로벌 스타일, 스토어 설정, 라우터 설정, 애널리틱스
+- **특징**: 슬라이스가 없고 세그먼트로만 구성
+- **의존성**: 모든 하위 레이어 사용 가능
+
+### 2. Pages (app 디렉토리)
+- **목적**: 웹사이트나 애플리케이션을 구성하는 페이지/화면
+- **내용**: 페이지 UI, 로딩 상태, 에러 바운더리, 데이터 fetching
+- **특징**: 한 페이지당 한 슬라이스 (유사한 페이지는 그룹화 가능)
+- **의존성**: `widgets`, `features`, `entities`, `shared` 사용 가능
+
+> 💡 **FSD v2.1 권장사항**: "pages-first" 접근 방식을 따라, 재사용되지 않는 큰 UI 블록과 비즈니스 로직은 페이지 슬라이스에 유지하세요.
+
+### 3. Widgets
+- **목적**: 큰 규모의 독립적인 UI 블록
+- **내용**: 여러 features와 entities를 조합한 복합 UI
+- **사용 시기**: 여러 페이지에서 재사용되거나, 페이지에 여러 개의 큰 독립적 블록이 있을 때
+- **의존성**: `features`, `entities`, `shared` 사용 가능 (다른 `widgets` 불가)
+
+### 4. Features
+- **목적**: 사용자가 수행하는 주요 상호작용과 비즈니스 기능
+- **내용**: 사용자 시나리오 (예: 로그인, 상품 검색, 댓글 작성)
+- **사용 시기**: 여러 페이지에서 재사용되는 기능
+- **의존성**: `entities`, `shared` 사용 가능 (다른 `features` 불가)
+
+### 5. Entities
+- **목적**: 비즈니스가 다루는 실제 개념 (User, Product, Order 등)
+- **내용**: 데이터 모델, 타입, API 요청, 엔티티의 시각적 표현
+- **특징**: 비즈니스 용어로 명명
+- **의존성**: 다른 `entities`, `shared` 사용 가능
+
+### 6. Shared
+- **목적**: 애플리케이션의 기반 제공
+- **내용**: API 클라이언트, UI Kit, 유틸리티 라이브러리, 글로벌 설정
+- **특징**: 비즈니스 로직 없음, 슬라이스가 없고 세그먼트로만 구성
+- **의존성**: 외부 라이브러리만 사용 가능 (다른 레이어 불가)
+
+## 레이어 간 Import 규칙
+
+> **핵심 규칙**: 슬라이스 내의 모듈(파일)은 **오직 하위 레이어**에 있는 다른 슬라이스만 import할 수 있습니다.
+
+예시:
+- `features/auth-login/ui/form.tsx`는 `entities/user`와 `shared/ui`를 import 가능
+- 하지만 다른 `features/post-create`나 상위 레이어인 `widgets`는 import 불가
+- 같은 슬라이스 내부(`features/auth-login/`)의 다른 파일들은 자유롭게 import 가능
+
+## Public API 규칙
+
+> **슬라이스 외부의 모듈은 슬라이스의 Public API만 참조할 수 있으며, 내부 파일 구조에 직접 접근할 수 없습니다.**
+
+모든 슬라이스(및 슬라이스가 없는 레이어의 세그먼트)는 반드시 `index.ts`를 통해 Public API를 정의해야 합니다.
+
+```typescript
+// ✅ 올바른 import
+import { LoginForm } from 'features/auth-login';
+
+// ❌ 잘못된 import (내부 구조 직접 접근)
+import { LoginForm } from 'features/auth-login/ui/login-form';
+```
+
+## 시작하기
+
+```bash
+# 의존성 설치
+pnpm install
+
+# 개발 서버 실행
+pnpm run dev
+
+# 프로덕션 빌드
+pnpm run build
+
+# 프로덕션 서버 실행
+pnpm start
+```
+
+## 환경 변수
+
+`.env.local` 파일 생성:
+
+```env
+NEXT_PUBLIC_API_URL=http://localhost:3000/api
+```
+
+## FSD 핵심 규칙
+
+### 1. Import 규칙
+
+> **슬라이스 내의 모듈(파일)은 오직 하위 레이어에 있는 슬라이스만 import할 수 있습니다.**
+
+```typescript
+// ✅ 올바른 import (하위 레이어)
+// features/auth-login/ui/form.tsx
+import { User } from 'entities/user';
+import { Button } from 'shared/ui/button';
+
+// ❌ 잘못된 import (같은 레이어)
+// features/auth-login/ui/form.tsx
+import { PostForm } from 'features/post-create'; // 금지!
+
+// ❌ 잘못된 import (상위 레이어)
+// features/auth-login/ui/form.tsx
+import { Header } from 'widgets/header'; // 금지!
+```
+
+### 2. Public API 규칙
+
+> **슬라이스 외부에서는 Public API(index.ts)를 통해서만 접근해야 합니다.**
+
+```typescript
+// ✅ 올바른 import (Public API 사용)
+import { LoginForm } from 'features/auth-login';
+
+// ❌ 잘못된 import (내부 구조 직접 접근)
+import { LoginForm } from 'features/auth-login/ui/login-form';
+```
+
+**Public API 작성 시 주의사항**:
+- 와일드카드 re-export(`export * from`) 사용 지양
+- 필요한 것만 명시적으로 export
+- 슬라이스의 인터페이스를 명확하게 표현
+
+```typescript
+// ❌ 나쁜 예시
+export * from './ui/Comment';
+export * from './model/comments';
+
+// ✅ 좋은 예시
+export { Comment } from './ui/comment';
+export { CommentForm } from './ui/comment-form';
+export { useComments } from './model/use-comments';
+export type { Comment, CommentFormData } from './model/types';
+```
+
+### 3. 슬라이스 독립성
+
+- **Zero Coupling**: 같은 레이어의 슬라이스들은 서로 독립적이어야 합니다
+- **High Cohesion**: 슬라이스는 주요 목표와 관련된 대부분의 코드를 포함해야 합니다
+- 예외: Entities 레이어에서 필요한 경우 `@x` 표기법으로 cross-import 가능
+
+### 4. 세그먼트 명명 규칙
+
+세그먼트 이름은 **목적**을 설명해야 하며, **본질**을 설명하면 안 됩니다:
+
+- ❌ **나쁜 이름**: `components`, `hooks`, `types` (무엇인지만 설명)
+- ✅ **좋은 이름**: `ui`, `api`, `model`, `lib` (왜 있는지 설명)
+
+## 코드 규칙
+
+- TypeScript 사용 (strict mode)
+- 함수형 컴포넌트 사용
+- Named exports 선호
+- Tailwind CSS 사용
+- ESLint + Prettier 적용
+- Public API는 명시적으로 정의 (와일드카드 export 지양)
+
+## 아키텍처 린팅
+
+코드베이스의 FSD 규칙 준수를 자동으로 검증하려면 [Steiger](https://github.com/feature-sliced/steiger)를 사용하세요.
+
+```bash
+# Steiger 설치 및 실행
+pnpm add -D @feature-sliced/steiger
+npx steiger src
+```
diff --git a/apps/_template/app/(with-base-layout)/layout.tsx b/apps/_template/app/(with-base-layout)/layout.tsx
new file mode 100644
index 000000000..1d182a0ee
--- /dev/null
+++ b/apps/_template/app/(with-base-layout)/layout.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { useCallback } from "react";
+import { usePathname, useRouter } from "next/navigation";
+import { useQueryClient } from "@tanstack/react-query";
+import { BaseLayout } from "@common/components";
+import type { MenuItem } from "@/entities/menu";
+import { PageLayout } from "@/shared/ui/page-layout";
+
+export default function WithBaseLayout({ children }: { children: React.ReactNode }) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const queryClient = useQueryClient();
+
+ const handleMenuClick = useCallback(
+ (item: MenuItem) => {
+ router.push(item.urlPath);
+ },
+ [router],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/_template/app/(with-base-layout)/page.tsx b/apps/_template/app/(with-base-layout)/page.tsx
new file mode 100644
index 000000000..e118e732e
--- /dev/null
+++ b/apps/_template/app/(with-base-layout)/page.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+export default function MainPage() {
+ return (
+
+
Home
+
+ );
+}
diff --git a/apps/_template/app/README.md b/apps/_template/app/README.md
new file mode 100644
index 000000000..5ab63ea34
--- /dev/null
+++ b/apps/_template/app/README.md
@@ -0,0 +1,169 @@
+# App Layer
+
+**App**은 애플리케이션 전체에 영향을 미치는 기술적, 비즈니스적 사항을 다루는 최상위 레이어입니다.
+
+## 목적
+
+- 애플리케이션의 진입점(entrypoint) 제공
+- 전역 Context Providers 설정
+- 라우터 구성
+- 글로벌 스타일 정의
+- 전역 상태 저장소 설정
+- 애널리틱스 및 모니터링 설정
+
+## 특징
+
+- **슬라이스 없음**: App 레이어는 Shared처럼 슬라이스가 없고 세그먼트로만 구성됩니다
+- **전체 앱 관심사**: 특정 비즈니스 도메인이 아닌 앱 전체와 관련된 코드만 포함
+- **최상위 레이어**: 모든 하위 레이어의 모듈을 자유롭게 import 가능
+
+## 구조 (Next.js App Router)
+
+Next.js를 사용하는 경우, App 레이어는 주로 `app/` 디렉토리로 표현됩니다:
+
+```
+app/
+├── (routes)/ # 라우트 그룹 및 페이지
+│ ├── (auth)/
+│ │ ├── login/
+│ │ │ └── page.tsx
+│ │ └── register/
+│ │ └── page.tsx
+│ └── (main)/
+│ ├── dashboard/
+│ │ └── page.tsx
+│ └── settings/
+│ └── page.tsx
+├── layout.tsx # 루트 레이아웃
+├── page.tsx # 홈 페이지
+├── globals.css # 글로벌 스타일
+├── providers.tsx # 전역 Providers 설정
+└── error.tsx # 글로벌 에러 핸들러
+```
+
+### 일반적인 FSD 세그먼트 구조
+
+FSD 표준 세그먼트를 사용하는 경우:
+
+```
+app/
+├── entrypoint/ # 애플리케이션 진입점 (프레임워크별)
+│ └── index.tsx
+├── providers/ # Context Providers
+│ ├── query-provider.tsx
+│ ├── theme-provider.tsx
+│ └── index.tsx
+├── styles/ # 글로벌 스타일
+│ ├── globals.css
+│ └── theme.css
+├── store/ # 글로벌 상태 저장소 설정
+│ └── index.ts
+└── routes/ # 라우터 설정
+ └── index.tsx
+```
+
+## 예시
+
+### Next.js Providers 설정
+
+```typescript
+// app/providers.tsx
+'use client';
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ThemeProvider } from 'next-themes';
+import { useState } from 'react';
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ const [queryClient] = useState(() => new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 60 * 1000,
+ },
+ },
+ }));
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+// app/layout.tsx
+import { Providers } from './providers';
+import './globals.css';
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+```
+
+### 글로벌 설정
+
+```typescript
+// app/config/analytics.ts
+export function initAnalytics() {
+ if (typeof window !== 'undefined') {
+ // 애널리틱스 초기화
+ }
+}
+
+// app/config/monitoring.ts
+export function initMonitoring() {
+ // 에러 모니터링 설정
+}
+```
+
+## Next.js App Router와 FSD
+
+Next.js의 `app/` 디렉토리는 FSD의 **App 레이어**와 **Pages 레이어**를 동시에 담당합니다:
+
+- **루트 레벨 파일들** (`layout.tsx`, `providers.tsx`, `globals.css`): App 레이어
+- **라우트 폴더들** (`dashboard/`, `login/` 등): Pages 레이어
+
+각 페이지별 비즈니스 로직은:
+- 재사용되지 않는 UI는 페이지 슬라이스(`app/dashboard/` 등) 안에 유지
+- 재사용되는 기능은 `features/` 레이어로 분리
+- 여러 페이지에서 재사용되는 큰 UI 블록은 `widgets/` 레이어로 분리
+
+## 레이어 의존성 규칙
+
+✅ **허용되는 의존성**:
+- 모든 하위 레이어 (`pages`, `widgets`, `features`, `entities`, `shared`)
+- 같은 `app` 레이어 내의 다른 세그먼트
+- 외부 라이브러리
+
+❌ **금지된 의존성**:
+- 없음 (App은 최상위 레이어)
+
+## 주의사항
+
+1. **비대화 방지**: App 레이어가 너무 커지면 관리가 어려워집니다. 대부분의 로직은 하위 레이어에 배치하세요
+2. **전역 관심사만**: 특정 페이지나 기능에만 관련된 코드는 해당 레이어로 이동
+3. **라우터 레벨 로직**: 라우터 및 서버 레벨 로직은 이 레이어에 유지
+4. **Processes 레이어 사용 지양**: Processes 레이어는 deprecated되었으므로 해당 로직은 `app`이나 `features`에 배치
+
+## FSD v2.1 권장사항
+
+최신 FSD 버전(v2.1)은 **"pages-first"** 접근 방식을 권장합니다:
+
+- 더 많은 코드를 페이지 슬라이스에 유지
+- 큰 UI 블록과 재사용되지 않는 비즈니스 로직은 즉시 추출하지 말고 페이지에 보관
+- 실제로 재사용이 필요할 때만 `features`나 `widgets`로 분리
+
+이 접근 방식은 과도한 추상화를 방지하고 코드를 더 쉽게 탐색할 수 있게 합니다.
diff --git a/apps/_template/app/globals.css b/apps/_template/app/globals.css
new file mode 100644
index 000000000..60bc92fb6
--- /dev/null
+++ b/apps/_template/app/globals.css
@@ -0,0 +1,15 @@
+@import "@common/assets/styles/base.css";
+@import "@common/assets/styles/diveicon.css";
+@import "@common/components/index.css";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body {
+ color: var(--foreground);
+ background: var(--background);
+ font-family: var(--font-noto-sans-kr), sans-serif;
+ font-size: 10px;
+}
diff --git a/apps/_template/app/layout.tsx b/apps/_template/app/layout.tsx
new file mode 100644
index 000000000..d152e0da3
--- /dev/null
+++ b/apps/_template/app/layout.tsx
@@ -0,0 +1,30 @@
+import type { Metadata } from "next";
+import { Noto_Sans_KR } from "next/font/google";
+import { Providers } from "./providers";
+import "./globals.css";
+
+const notoSansKR = Noto_Sans_KR({
+ subsets: ["latin"],
+ weight: ["100", "300", "400", "500", "700", "900"],
+ display: "swap",
+ variable: "--font-noto-sans-kr",
+});
+
+export const metadata: Metadata = {
+ title: "행정지원시스템",
+ description: "행정지원시스템",
+};
+
+interface RootLayoutProps {
+ children: React.ReactNode;
+}
+
+export default function RootLayout({ children }: RootLayoutProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/_template/app/providers.tsx b/apps/_template/app/providers.tsx
new file mode 100644
index 000000000..bc12c443b
--- /dev/null
+++ b/apps/_template/app/providers.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { queryClient } from "@/shared/lib";
+
+interface ProvidersProps {
+ children: React.ReactNode;
+}
+
+export function Providers({ children }: ProvidersProps) {
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/apps/_template/entities/README.md b/apps/_template/entities/README.md
new file mode 100644
index 000000000..b28c10790
--- /dev/null
+++ b/apps/_template/entities/README.md
@@ -0,0 +1,114 @@
+# Entities Layer
+
+**Entities**는 프로젝트가 다루는 실제 비즈니스 개념을 나타내는 레이어입니다. 비즈니스에서 제품을 설명할 때 사용하는 용어들(예: User, Product, Order)이 주로 엔티티가 됩니다.
+
+## 목적
+
+- 비즈니스 엔티티의 데이터 모델 및 타입 정의
+- 엔티티 관련 API 요청 함수 관리
+- 엔티티의 시각적 표현 제공 (재사용 가능한 UI)
+- 여러 페이지에서 동일한 외관을 재사용할 수 있도록 지원
+
+## 구조
+
+각 엔티티 슬라이스는 다음 세그먼트로 구성됩니다:
+
+```
+entities/
+└── [entity-name]/
+ ├── model/ # 데이터 저장소, 검증 스키마, 상태 관리
+ ├── api/ # 엔티티 관련 API 요청 함수
+ ├── ui/ # 엔티티 시각적 표현 (선택적)
+ ├── lib/ # 엔티티 관련 유틸리티 (선택적)
+ ├── @x/ # 다른 엔티티로부터의 cross-import용 Public API (선택적)
+ └── index.ts # Public API
+```
+
+### 세그먼트 설명
+
+- **model**: 데이터 모델, 타입 정의, 상태 저장소 (Zustand 등)
+- **api**: 백엔드 API 호출 함수
+- **ui**: 엔티티를 표시하는 UI 컴포넌트 (완전한 UI 블록이 아니라 재사용 가능한 표현)
+- **lib**: 엔티티 관련 유틸리티 함수
+- **@x**: 다른 엔티티가 이 엔티티를 import할 때 사용하는 명시적 cross-import API
+- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
+
+## 예시
+
+```typescript
+// entities/user/model/user.ts
+export interface User {
+ id: string;
+ name: string;
+ email: string;
+ role: 'admin' | 'user';
+}
+
+// entities/user/api/get-user.ts
+import type { User } from '../model/user';
+
+export async function getUser(id: string): Promise {
+ const response = await fetch(`/api/users/${id}`);
+ return response.json();
+}
+
+// entities/user/ui/user-card.tsx
+import type { User } from '../model/user';
+
+interface UserCardProps {
+ user: User;
+ onClick?: () => void;
+}
+
+export function UserCard({ user, onClick }: UserCardProps) {
+ return (
+
+
{user.name}
+
{user.email}
+
+ );
+}
+
+// entities/user/index.ts
+export { type User } from './model/user';
+export { getUser } from './api/get-user';
+export { UserCard } from './ui/user-card';
+```
+
+## 엔티티 간 관계
+
+엔티티가 서로 연결되어 있을 때는 `@x` 표기법을 사용하여 명시적으로 cross-import를 관리하는 것이 권장됩니다:
+
+```typescript
+// entities/artist/model/artist.ts
+import type { Song } from 'entities/song/@x/artist';
+
+export interface Artist {
+ name: string;
+ songs: Array;
+}
+
+// entities/song/@x/artist.ts
+export type { Song } from '../model/song';
+```
+
+> `@x` 폴더를 사용하면 엔티티 간 연결을 명확하게 만들어 함께 리팩터링이 필요한 코드를 쉽게 찾을 수 있습니다.
+
+## 레이어 의존성 규칙
+
+✅ **허용되는 의존성**:
+- `shared` 레이어의 모듈
+- 같은 엔티티 슬라이스 내의 다른 세그먼트
+- 다른 엔티티 슬라이스 (필요한 경우, `@x` 표기법 권장)
+
+❌ **금지된 의존성**:
+- `features` 레이어
+- `widgets` 레이어
+- `pages` 레이어
+- `app` 레이어
+
+## 주의사항
+
+1. **UI 컴포넌트는 완전한 비즈니스 로직을 포함하지 않음**: UI는 주로 props나 slots를 통해 다양한 비즈니스 로직을 연결할 수 있도록 설계
+2. **엔티티 간 비즈니스 로직은 상위 레이어에**: 엔티티 간 상호작용의 비즈니스 로직은 Features나 Pages 레이어에서 처리
+3. **데이터 관리에 집중**: 복잡한 비즈니스 로직보다는 데이터 모델, 검증, 저장에 집중
diff --git a/apps/_template/entities/menu/index.ts b/apps/_template/entities/menu/index.ts
new file mode 100644
index 000000000..cb55d9a1c
--- /dev/null
+++ b/apps/_template/entities/menu/index.ts
@@ -0,0 +1 @@
+export { type MenuItem } from "./model/types";
diff --git a/apps/_template/entities/menu/model/types.ts b/apps/_template/entities/menu/model/types.ts
new file mode 100644
index 000000000..9692821e3
--- /dev/null
+++ b/apps/_template/entities/menu/model/types.ts
@@ -0,0 +1,19 @@
+export interface MenuItem {
+ menuCd: number;
+ menuKornNm: string;
+ menuEngNm: string;
+ parentId: number | null;
+ sortSeq: number;
+ iconNm?: string;
+ children: MenuItem[] | null;
+ levelNo: number;
+ urlPath: string;
+ menuLnkgTypeCd: string;
+ menuLnkgSn: number;
+ menuPath: string;
+ qrcd: string;
+ useYn: string;
+ otptYn: string;
+ newWndwYn: string;
+ requiredUrlPath: boolean;
+}
diff --git a/apps/_template/eslint.config.mjs b/apps/_template/eslint.config.mjs
new file mode 100644
index 000000000..05e726d1b
--- /dev/null
+++ b/apps/_template/eslint.config.mjs
@@ -0,0 +1,18 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Override default ignores of eslint-config-next.
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ]),
+]);
+
+export default eslintConfig;
diff --git a/apps/_template/features/README.md b/apps/_template/features/README.md
new file mode 100644
index 000000000..2be9a3e17
--- /dev/null
+++ b/apps/_template/features/README.md
@@ -0,0 +1,130 @@
+# Features Layer
+
+**Features**는 앱의 주요 사용자 상호작용과 기능을 구현하는 레이어입니다. 사용자가 실제로 수행하는 작업들로, 주로 비즈니스 엔티티와 관련이 있습니다.
+
+## 목적
+
+- 사용자가 중요하게 생각하는 주요 상호작용 구현
+- 재사용 가능한 비즈니스 기능 제공
+- 여러 페이지에서 사용되는 기능의 중복 제거
+
+## Feature로 만들어야 하는 경우
+
+> ⚠️ **중요**: 모든 것을 feature로 만들 필요는 없습니다.
+
+Feature로 만드는 것이 좋은 지표:
+- **여러 페이지에서 재사용됨**: 예를 들어, 여러 에디터가 있고 모두 댓글 기능이 있다면, 댓글은 재사용 가능한 feature입니다
+- **중요한 비즈니스 기능**: 신규 프로젝트 참여자가 Features 폴더를 보고 앱의 주요 기능을 빠르게 파악할 수 있어야 합니다
+
+Feature가 너무 많으면 중요한 기능이 묻히므로, 신규 참여자의 경험을 최적화하여 결정하세요.
+
+## 구조
+
+각 feature 슬라이스는 다음 세그먼트로 구성됩니다:
+
+```
+features/
+└── [feature-name]/
+ ├── ui/ # 상호작용을 수행하는 UI (폼, 버튼 등)
+ ├── api/ # 액션 수행에 필요한 API 호출 (선택적)
+ ├── model/ # 검증 및 내부 상태 관리 (선택적)
+ ├── lib/ # 유틸리티 함수 (선택적)
+ ├── config/ # feature flags (선택적)
+ └── index.ts # Public API
+```
+
+### 세그먼트 설명
+
+- **ui**: 사용자 상호작용을 위한 UI 컴포넌트 (폼, 모달, 버튼 등)
+- **api**: feature 실행에 필요한 API 요청 함수
+- **model**: 폼 검증, 내부 상태, 비즈니스 로직
+- **lib**: feature 내부에서만 사용되는 유틸리티
+- **config**: feature flags, feature별 설정값
+- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
+
+## 예시
+
+```typescript
+// features/auth-login/ui/login-form.tsx
+import { useState } from 'react';
+import { loginUser } from '../api/login-user';
+import type { LoginCredentials } from '../model/types';
+
+export function LoginForm() {
+ const [credentials, setCredentials] = useState({
+ email: '',
+ password: ''
+ });
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ await loginUser(credentials);
+ };
+
+ return (
+
+ );
+}
+
+// features/auth-login/api/login-user.ts
+import type { LoginCredentials } from '../model/types';
+
+export async function loginUser(credentials: LoginCredentials) {
+ const response = await fetch('/api/auth/login', {
+ method: 'POST',
+ body: JSON.stringify(credentials),
+ });
+ return response.json();
+}
+
+// features/auth-login/model/types.ts
+export interface LoginCredentials {
+ email: string;
+ password: string;
+}
+
+// features/auth-login/index.ts
+export { LoginForm } from './ui/login-form';
+export { loginUser } from './api/login-user';
+export type { LoginCredentials } from './model/types';
+```
+
+## 레이어 의존성 규칙
+
+✅ **허용되는 의존성**:
+- `shared` 레이어의 모듈
+- `entities` 레이어의 모듈
+- 같은 feature 슬라이스 내의 다른 세그먼트
+
+❌ **금지된 의존성**:
+- 다른 `features` 슬라이스 (feature 간 직접 의존 금지)
+- `widgets` 레이어
+- `pages` 레이어
+- `app` 레이어
+
+> 💡 **팁**: 여러 features가 공통 기능을 필요로 한다면, 그 기능을 `shared` 레이어로 이동시키거나 `entities` 레이어에서 관리하세요.
+
+## 명명 규칙
+
+- 소문자와 대시 사용: `auth-login`, `post-create`, `comment-edit`
+- 동사 형태 권장: 사용자 액션을 나타내는 이름 사용
+- 명확하고 구체적으로: 기능의 목적이 명확하게 드러나도록
+
+## 주의사항
+
+1. **하나의 기능에만 집중**: 각 feature는 단일 사용자 시나리오를 구현해야 합니다
+2. **재사용성 고려**: 여러 페이지에서 사용될 수 있도록 설계
+3. **독립성 유지**: 다른 features에 의존하지 않고 독립적으로 작동
+4. **적절한 추상화**: 너무 많은 feature를 만들지 말고, 중요한 기능 위주로 구성
diff --git a/apps/_template/next.config.ts b/apps/_template/next.config.ts
new file mode 100644
index 000000000..8864de0e7
--- /dev/null
+++ b/apps/_template/next.config.ts
@@ -0,0 +1,8 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ reactStrictMode: true,
+ transpilePackages: ["@common/assets"],
+};
+
+export default nextConfig;
diff --git a/apps/_template/package.json b/apps/_template/package.json
new file mode 100644
index 000000000..fb27f8af7
--- /dev/null
+++ b/apps/_template/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "as",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "lint:fsd": "steiger ."
+ },
+ "dependencies": {
+ "@common/assets": "workspace:*",
+ "@common/components": "workspace:*",
+ "@common/network": "workspace:*",
+ "@hookform/resolvers": "^4.1.3",
+ "@tanstack/react-query": "^5.75.5",
+ "@tanstack/react-query-devtools": "^5.75.5",
+ "@tanstack/react-table": "^8.21.2",
+ "axios": "^1.8.1",
+ "dayjs": "^1.11.13",
+ "next": "15.3.8",
+ "react-hook-form": "^7.54.2",
+ "zod": "^3.24.2"
+ },
+ "devDependencies": {
+ "eslint": "^9.0.0",
+ "eslint-config-next": "15.4.0",
+ "postcss": "^8.4.49",
+ "prettier": "^3.4.2",
+ "prettier-plugin-tailwindcss": "^0.6.11",
+ "steiger": "^0.5.11",
+ "tailwindcss": "^3.4.14",
+ "typescript": "^5.7.2"
+ }
+}
\ No newline at end of file
diff --git a/apps/_template/postcss.config.mjs b/apps/_template/postcss.config.mjs
new file mode 100644
index 000000000..1a69fd2a4
--- /dev/null
+++ b/apps/_template/postcss.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+ plugins: {
+ tailwindcss: {},
+ },
+};
+
+export default config;
diff --git a/apps/_template/shared/README.md b/apps/_template/shared/README.md
new file mode 100644
index 000000000..2b247021d
--- /dev/null
+++ b/apps/_template/shared/README.md
@@ -0,0 +1,149 @@
+# Shared Layer
+
+**Shared**는 애플리케이션의 기반을 형성하는 레이어입니다. 외부 세계(백엔드, 서드파티 라이브러리, 환경)와의 연결을 생성하고, 높은 응집력을 가진 자체 라이브러리를 정의하는 곳입니다.
+
+## 목적
+
+- 외부 시스템 및 라이브러리와의 통합
+- 프로젝트 전체에서 사용되는 공통 코드 제공
+- 비즈니스 로직이 없는 재사용 가능한 유틸리티 및 UI 제공
+- 글로벌 설정 및 환경 변수 관리
+
+## 특징
+
+- **슬라이스 없음**: Shared 레이어는 비즈니스 도메인이 없으므로 슬라이스로 나뉘지 않습니다
+- **세그먼트 구성**: 기술적 목적에 따라 세그먼트로 직접 구성됩니다
+- **자유로운 import**: Shared 내의 모든 파일은 서로를 참조하고 import할 수 있습니다
+
+## 구조
+
+```
+shared/
+├── ui/ # 애플리케이션 UI Kit
+│ ├── button/
+│ ├── input/
+│ ├── modal/
+│ └── index.ts
+├── api/ # API 클라이언트 및 백엔드 엔드포인트 함수
+│ ├── client.ts
+│ └── index.ts
+├── lib/ # 내부 라이브러리 모음
+│ ├── dates/ # 날짜 관련 유틸리티
+│ ├── colors/ # 색상 관련 유틸리티
+│ ├── text/ # 텍스트 처리 유틸리티
+│ └── index.ts
+├── config/ # 환경 변수, 글로벌 feature flags
+│ ├── env.ts
+│ └── index.ts
+├── types/ # 공통 타입 정의
+│ └── index.ts
+├── i18n/ # 번역 설정, 글로벌 번역 문자열
+│ └── index.ts
+└── routes/ # 라우트 상수 또는 패턴
+ └── index.ts
+```
+
+## 세그먼트 설명
+
+### 📁 ui
+애플리케이션의 UI Kit로, 비즈니스 로직을 포함하지 않는 재사용 가능한 UI 컴포넌트들입니다.
+
+- Button, Input, Modal 같은 기본 컴포넌트
+- 비즈니스 로직은 없지만 UI 로직은 포함 가능 (예: Autocomplete, SearchBar)
+- 비즈니스 테마 컴포넌트도 허용 (예: 회사 로고, 페이지 레이아웃)
+
+### 📁 api
+API 클라이언트 및 특정 백엔드 엔드포인트 요청 함수입니다.
+
+- HTTP 클라이언트 설정 (Axios, Fetch 등)
+- 인증 인터셉터
+- 공통 에러 핸들링
+
+### 📁 lib
+내부 라이브러리 모음으로, 각 라이브러리는 **하나의 관심사**에 집중해야 합니다.
+
+> ⚠️ **중요**: `helpers`나 `utils` 폴더처럼 덤프 폴더로 만들지 마세요. 각 라이브러리는 명확한 초점 영역(예: dates, colors, text)을 가져야 하며, README로 문서화해야 합니다.
+
+### 📁 config
+환경 변수, 글로벌 feature flags 및 앱 전체 설정입니다.
+
+### 📁 types
+프로젝트 전체에서 사용되는 공통 타입 정의입니다.
+
+### 📁 i18n
+번역 설정 코드 및 글로벌 번역 문자열입니다.
+
+### 📁 routes
+라우트 상수 또는 라우트 매칭 패턴입니다.
+
+## 예시
+
+```typescript
+// shared/ui/button/button.tsx
+interface ButtonProps {
+ children: React.ReactNode;
+ variant?: 'primary' | 'secondary';
+ onClick?: () => void;
+}
+
+export function Button({ children, variant = 'primary', onClick }: ButtonProps) {
+ return (
+
+ );
+}
+
+// shared/api/client.ts
+import axios from 'axios';
+
+export const apiClient = axios.create({
+ baseURL: process.env.NEXT_PUBLIC_API_URL,
+ timeout: 10000,
+});
+
+// shared/lib/dates/format-date.ts
+import dayjs from 'dayjs';
+
+export function formatDate(date: Date, format: string = 'YYYY-MM-DD'): string {
+ return dayjs(date).format(format);
+}
+
+// shared/config/env.ts
+export const ENV = {
+ apiUrl: process.env.NEXT_PUBLIC_API_URL!,
+ isDev: process.env.NODE_ENV === 'development',
+} as const;
+```
+
+## 레이어 의존성 규칙
+
+✅ **허용되는 의존성**:
+- 같은 `shared` 레이어 내의 다른 세그먼트 (자유로운 cross-import)
+- 외부 라이브러리 및 패키지
+
+❌ **금지된 의존성**:
+- `entities` 레이어
+- `features` 레이어
+- `widgets` 레이어
+- `pages` 레이어
+- `app` 레이어
+
+> 💡 **원칙**: Shared는 가장 낮은 레이어이므로 상위 레이어에 의존하지 않습니다.
+
+## 세그먼트 명명 규칙
+
+세그먼트 이름은 **내용의 목적**을 설명해야 하며, **본질**을 설명하면 안 됩니다.
+
+❌ **나쁜 예시**: `components`, `hooks`, `types` (본질을 설명)
+✅ **좋은 예시**: `ui`, `api`, `lib/dates`, `config` (목적을 설명)
+
+## 주의사항
+
+1. **비즈니스 로직 없음**: Shared는 순수한 기술적 코드만 포함
+2. **높은 재사용성**: 앱 전체에서 사용될 수 있어야 함
+3. **명확한 라이브러리 구조**: `lib` 내부는 관심사별로 명확하게 분리
+4. **문서화**: 각 라이브러리는 README로 사용법과 목적을 문서화
diff --git a/apps/_template/shared/api/client.ts b/apps/_template/shared/api/client.ts
new file mode 100644
index 000000000..d7c4a8f1d
--- /dev/null
+++ b/apps/_template/shared/api/client.ts
@@ -0,0 +1,9 @@
+import { Client } from "@common/network";
+import { PROXY_HOST, SITE_ID } from "@/shared/config/env";
+
+const BASE_URL = `${PROXY_HOST}/server`;
+
+export const apiClient = Client.getInstance({
+ baseURL: BASE_URL,
+ siteId: SITE_ID,
+});
diff --git a/apps/_template/shared/api/index.ts b/apps/_template/shared/api/index.ts
new file mode 100644
index 000000000..c5eb98066
--- /dev/null
+++ b/apps/_template/shared/api/index.ts
@@ -0,0 +1 @@
+export { apiClient } from "./client";
diff --git a/apps/_template/shared/config/env.ts b/apps/_template/shared/config/env.ts
new file mode 100644
index 000000000..4f08c722a
--- /dev/null
+++ b/apps/_template/shared/config/env.ts
@@ -0,0 +1,11 @@
+export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
+export const PROXY_HOST =
+ process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
+export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
+export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "AS";
+export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
+export const ACCESS_TOKEN =
+ process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
+export const REFRESH_TOKEN =
+ process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
+export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
diff --git a/apps/_template/shared/config/index.ts b/apps/_template/shared/config/index.ts
new file mode 100644
index 000000000..c76f8cd47
--- /dev/null
+++ b/apps/_template/shared/config/index.ts
@@ -0,0 +1 @@
+export * from "./env";
diff --git a/apps/_template/shared/lib/dayjs.ts b/apps/_template/shared/lib/dayjs.ts
new file mode 100644
index 000000000..28b3f908b
--- /dev/null
+++ b/apps/_template/shared/lib/dayjs.ts
@@ -0,0 +1,12 @@
+import dayjs from "dayjs";
+import "dayjs/locale/ko";
+import utc from "dayjs/plugin/utc";
+import timezone from "dayjs/plugin/timezone";
+import relativeTime from "dayjs/plugin/relativeTime";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(relativeTime);
+dayjs.locale("ko");
+
+export { dayjs };
diff --git a/apps/_template/shared/lib/index.ts b/apps/_template/shared/lib/index.ts
new file mode 100644
index 000000000..51e9c2c5d
--- /dev/null
+++ b/apps/_template/shared/lib/index.ts
@@ -0,0 +1,2 @@
+export { queryClient } from "./query-client";
+export { dayjs } from "./dayjs";
diff --git a/apps/_template/shared/lib/query-client.ts b/apps/_template/shared/lib/query-client.ts
new file mode 100644
index 000000000..0da2fb899
--- /dev/null
+++ b/apps/_template/shared/lib/query-client.ts
@@ -0,0 +1,11 @@
+import { QueryClient } from "@tanstack/react-query";
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ refetchOnWindowFocus: false,
+ staleTime: 60 * 1000,
+ },
+ },
+});
diff --git a/apps/_template/shared/model/index.ts b/apps/_template/shared/model/index.ts
new file mode 100644
index 000000000..15ddf4254
--- /dev/null
+++ b/apps/_template/shared/model/index.ts
@@ -0,0 +1,29 @@
+export interface BaseEntity {
+ id: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface PaginationParams {
+ page: number;
+ limit: number;
+}
+
+export interface PaginationResponse {
+ data: T[];
+ total: number;
+ page: number;
+ limit: number;
+}
+
+export interface ApiResponse {
+ success: boolean;
+ data: T;
+ message?: string;
+}
+
+export interface ApiError {
+ message: string;
+ code?: string;
+ status?: number;
+}
diff --git a/apps/_template/shared/ui/index.ts b/apps/_template/shared/ui/index.ts
new file mode 100644
index 000000000..460542995
--- /dev/null
+++ b/apps/_template/shared/ui/index.ts
@@ -0,0 +1 @@
+export { PageLayout } from "./page-layout";
diff --git a/apps/_template/shared/ui/page-layout.tsx b/apps/_template/shared/ui/page-layout.tsx
new file mode 100644
index 000000000..ecefc36bd
--- /dev/null
+++ b/apps/_template/shared/ui/page-layout.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { PageTitle } from "@common/components";
+
+const mockCurrentMenuInfo = {
+ menuCd: 0,
+ menuKornNm: "메인",
+ menuEngNm: "Main",
+ parentId: null,
+ sortSeq: 0,
+ children: null,
+ levelNo: 0,
+ urlPath: "/",
+ menuLnkgTypeCd: "",
+ menuLnkgSn: 0,
+ menuPath: "메인",
+ qrcd: "",
+ useYn: "Y",
+ otptYn: "Y",
+ newWndwYn: "N",
+ requiredUrlPath: false,
+};
+
+function PageLayout({ children }: { children: React.ReactNode }) {
+ const menuPaths = mockCurrentMenuInfo.menuPath.split(">").map((path) => path.trim());
+
+ return (
+
+ );
+}
+
+export { PageLayout };
diff --git a/apps/_template/tailwind.config.ts b/apps/_template/tailwind.config.ts
new file mode 100644
index 000000000..c02405aca
--- /dev/null
+++ b/apps/_template/tailwind.config.ts
@@ -0,0 +1,29 @@
+import type { Config } from "tailwindcss";
+import presetConfig from "../../tailwind.config.preset";
+
+const config: Config = {
+ presets: [presetConfig],
+ content: [
+ "./pages/**/*.{js,ts,jsx,tsx,mdx}",
+ "./shared/**/*.{js,ts,jsx,tsx,mdx}",
+ "./entities/**/*.{js,ts,jsx,tsx,mdx}",
+ "./features/**/*.{js,ts,jsx,tsx,mdx}",
+ "./widgets/**/*.{js,ts,jsx,tsx,mdx}",
+ "./app/**/*.{js,ts,jsx,tsx,mdx}",
+ "../../packages/common/components/dist/**/*.{js,ts,jsx,tsx,mdx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ background: "var(--background)",
+ foreground: "var(--foreground)",
+ },
+ fontFamily: {
+ sans: ["var(--font-noto-sans-kr)", "Noto Sans KR", "sans-serif"],
+ },
+ },
+ },
+ plugins: [],
+};
+
+export default config;
diff --git a/apps/_template/tsconfig.json b/apps/_template/tsconfig.json
new file mode 100644
index 000000000..15b023415
--- /dev/null
+++ b/apps/_template/tsconfig.json
@@ -0,0 +1,35 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"],
+ "@/app/*": ["./app/*"],
+ "@/shared/*": ["./shared/*"],
+ "@/entities/*": ["./entities/*"],
+ "@/features/*": ["./features/*"],
+ "@/widgets/*": ["./widgets/*"],
+ "@/pages/*": ["./pages/*"],
+ "@common/components": ["../../packages/common/components/dist/"],
+ "@common/network": ["../../packages/common/network/"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/apps/_template/widgets/README.md b/apps/_template/widgets/README.md
new file mode 100644
index 000000000..cd160abd9
--- /dev/null
+++ b/apps/_template/widgets/README.md
@@ -0,0 +1,135 @@
+# Widgets Layer
+
+**Widgets**는 큰 규모의 독립적인 UI 블록을 위한 레이어입니다. 여러 features와 entities를 조합하여 완전한 기능을 제공하는 복합 컴포넌트를 담습니다.
+
+## 목적
+
+- 큰 규모의 독립적인 UI 블록 구성
+- 여러 features와 entities를 하나의 응집력 있는 UI로 결합
+- 여러 페이지에서 재사용되는 복잡한 UI 제공
+- 페이지 레벨 레이아웃 및 섹션 관리
+
+## Widget으로 만들어야 하는 경우
+
+Widget은 다음 조건에 해당할 때 사용하세요:
+
+✅ **Widget으로 만들기 좋은 경우**:
+- 여러 페이지에서 재사용되는 큰 UI 블록
+- 페이지에 여러 개의 큰 독립적 블록이 있고, 이것이 그 중 하나인 경우
+- 페이지 레벨 레이아웃 컴포넌트 (nested routing 시스템 사용 시)
+
+❌ **Widget으로 만들지 말아야 하는 경우**:
+- UI 블록이 페이지의 대부분을 차지하고 재사용되지 않는 경우 → 페이지 슬라이스 내부에 직접 배치
+- 작은 재사용 컴포넌트 → `shared/ui`에 배치
+
+## 구조
+
+각 widget 슬라이스는 다음 세그먼트로 구성됩니다:
+
+```
+widgets/
+└── [widget-name]/
+ ├── ui/ # 위젯의 메인 UI 컴포넌트
+ ├── model/ # 위젯 내부 상태 관리 (선택적)
+ ├── api/ # 위젯 전용 API 호출 (선택적)
+ ├── lib/ # 위젯 내부 유틸리티 (선택적)
+ └── index.ts # Public API
+```
+
+### 세그먼트 설명
+
+- **ui**: 여러 features와 entities를 조합한 위젯 UI
+- **model**: 위젯 내부 상태 및 로직 (선택적)
+- **api**: 위젯이 필요로 하는 데이터 fetching (선택적)
+- **lib**: 위젯 내부에서만 사용되는 헬퍼 함수 (선택적)
+- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
+
+## 예시
+
+```typescript
+// widgets/header/ui/header.tsx
+import { Logo } from 'shared/ui/logo';
+import { Navigation } from 'shared/ui/navigation';
+import { UserMenu } from 'features/user-menu';
+import { NotificationBell } from 'features/notifications';
+
+export function Header() {
+ return (
+
+ );
+}
+
+// widgets/product-list/ui/product-list.tsx
+import { useProducts } from '../api/use-products';
+import { ProductCard } from 'entities/product';
+import { AddToCartButton } from 'features/cart-add-item';
+
+export function ProductList() {
+ const { data: products, isLoading } = useProducts();
+
+ if (isLoading) return 로딩 중...
;
+
+ return (
+
+ {products.map((product) => (
+
+ ))}
+
+ );
+}
+
+// widgets/product-list/api/use-products.ts
+import { useQuery } from '@tanstack/react-query';
+import { getProducts } from 'entities/product';
+
+export function useProducts() {
+ return useQuery({
+ queryKey: ['products'],
+ queryFn: getProducts,
+ });
+}
+
+// widgets/product-list/index.ts
+export { ProductList } from './ui/product-list';
+```
+
+## 레이어 의존성 규칙
+
+✅ **허용되는 의존성**:
+- `shared` 레이어의 모듈
+- `entities` 레이어의 모듈
+- `features` 레이어의 모듈
+- 같은 widget 슬라이스 내의 다른 세그먼트
+
+❌ **금지된 의존성**:
+- 다른 `widgets` 슬라이스 (widget 간 직접 의존 금지)
+- `pages` 레이어
+- `app` 레이어
+
+## 명명 규칙
+
+- 소문자와 대시 사용: `header`, `sidebar`, `product-list`
+- 명사 형태 권장: UI 블록의 성격을 나타내는 이름 사용
+- 구체적으로: 위젯의 역할이 명확하게 드러나도록
+
+## Nested Routing 사용 시
+
+Remix 같은 nested routing 시스템을 사용한다면:
+- Widgets 레이어를 flat routing의 Pages 레이어처럼 사용 가능
+- 완전한 라우터 블록 생성 (데이터 fetching, loading states, error boundaries 포함)
+- 페이지 레이아웃을 이 레이어에 저장 가능
+
+## 주의사항
+
+1. **독립성 유지**: 각 위젯은 독립적으로 작동 가능해야 합니다
+2. **적절한 크기**: 너무 작은 컴포넌트는 `shared/ui`에, 재사용되지 않으면 `pages`에 배치
+3. **응집력**: 관련된 features와 entities를 의미 있게 조합
+4. **재사용 최적화**: 여러 페이지에서 동일한 방식으로 사용될 수 있도록 설계
diff --git a/apps/as/next-env.d.ts b/apps/as/next-env.d.ts
new file mode 100644
index 000000000..1b3be0840
--- /dev/null
+++ b/apps/as/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/cs/src/entities/link/model/link-interface.ts b/apps/cs/src/entities/link/model/link-interface.ts
index 244bb4c63..67cd75807 100644
--- a/apps/cs/src/entities/link/model/link-interface.ts
+++ b/apps/cs/src/entities/link/model/link-interface.ts
@@ -118,7 +118,7 @@ export const linkFormSchema = z.object({
last_link_rslt_cd: z.string().max(4).nullable().optional(),
last_link_excn_dt: z.string().max(40).nullable().optional(),
next_link_excn_dt: z.string().max(40).nullable().optional(),
- last_link_err_cd: z.string().max(10).nullable().optional(),
+ last_link_err_cd: z.string().max(50, { message: '마지막 연계 오류 코드는 50자 이하입니다.' }).nullable().optional(),
mdfcn_dt: z.string().nullable().optional(),
mdfr_id: z.string().nullable().optional(),
link_cycle_mi: z.coerce.number().nullable().optional().refine((val) => {
diff --git a/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx b/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx
deleted file mode 100644
index 44f790366..000000000
--- a/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx
+++ /dev/null
@@ -1,540 +0,0 @@
-"use client";
-
-import React, {
- useCallback,
- useEffect,
- useLayoutEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-
-/**
- * CaseTabsCarousel.tsx (FINAL OPTIMIZED)
- * - 탭별 데이터
- * - 카드 perPage(1/2/3) 반응형
- * - 카드 perPage개 = 1그룹, 좌/우 클릭 시 1그룹 단위 이동
- * - 3그룹 가상화(prev/cur/next)로 DOM 최소화
- * - px 기반 트랙 이동 + "측정 박스 분리"로 좌/우 비침 방지
- * - stale state 제거(방향/인덱스 ref 관리)로 슬라이드 안정성 강화
- * - BUGFIX 1) onTransitionEnd 버블링 방지 (target===currentTarget, transform만 처리)
- * - BUGFIX 2) 슬라이드 중(busy) clipWidth 변화로 트랙 스냅(-W) 덮어쓰지 않게 분리
- */
-
-type TabKey = "rain" | "reuse" | "sewage" | "global";
-
-type CaseItem = {
- id: string;
- title: string;
- desc: string;
- imageSrc: string;
- href?: string;
-};
-
-const TABS: { key: TabKey; label: string }[] = [
- { key: "rain", label: "빗물 재이용 사례" },
- { key: "reuse", label: "중수 재이용 사례" },
- { key: "sewage", label: "하수 재이용 사례" },
- { key: "global", label: "해외 재이용 사례" },
-];
-
-// 실제 데이터로 교체해서 쓰세요(구조 유지)
-const MOCK: Record = {
- rain: [
- {
- id: "rain-0",
- title: "경기도 수원시 빗물이용과 중수도",
- desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…",
- imageSrc: "/images/landing/rain_01.png",
- href: "/reuse/business/rain/suwon",
- },
- // {
- // id: "rain-2",
- // title: "경기도 수원시 빗물이용과 중수도",
- // desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…",
- // imageSrc: "/images/landing/rain_01.png",
- // href: "#",
- // },
- ],
- reuse: Array.from({ length: 6 }).map((_, i) => ({
- id: `reuse-${i}`,
- title: `중수 재이용 사례 ${i + 1}`,
- desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
- imageSrc: "/images/landing/sample_case.png",
- href: "#",
- })),
- sewage: [
- {
- id: "sewage-1",
- title: "경기도 파주시 하수 재이용",
- desc: "운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에...",
- imageSrc: "/images/landing/sewage_01.png",
- href: "/reuse/business/sewage/paju",
- },
- {
- id: "sewage-2",
- title: "충남보령 하수재이용",
- desc: "보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업...",
- imageSrc: "/images/landing/sewage_02.png",
- href: "/reuse/business/sewage/bolyeong",
- },
- {
- id: "sewage-3",
- title: "여수시 하수처리수 재이용",
- desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로...",
- imageSrc: "/images/landing/sewage_03.png",
- href: "/reuse/business/sewage/yeosu",
- },
- {
- id: "sewage-4",
- title: "청주시 하수 재이용 시설",
- desc: "청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO)...",
- imageSrc: "/images/landing/sewage_04.png",
- href: "/reuse/business/sewage/cheongju",
- },
- {
- id: "sewage-5",
- title: "수원시 황구지천 하천유지용수",
- desc: "황구지천 공공하수처리시설 하수처리수 재이용사업으로 향후 호매실천 및 금곡천 건천화 방지로...",
- imageSrc: "/images/landing/sewage_05.png",
- href: "/reuse/business/sewage/hwanggu",
- },
- {
- id: "sewage-6",
- title: "구리시 도로자동청소시스템",
- desc: "고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용...",
- imageSrc: "/images/landing/sewage_06.png",
- href: "/reuse/business/sewage/guri",
- },
- ...Array.from({ length: 6 }).map((_, i) => ({
- id: `sewage-x-${i}`,
- title: `하수 재이용 사례 ${i + 4}`,
- desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
- imageSrc: "/images/landing/sample_case.png",
- href: "#",
- })),
- ],
- global: Array.from({ length: 6 }).map((_, i) => ({
- id: `global-${i}`,
- title: `해외 재이용 사례 ${i + 1}`,
- desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
- imageSrc: "/images/landing/sample_case.png",
- href: "#",
- })),
-};
-
-/* ---------------- utils ---------------- */
-function chunk(arr: T[], size: number) {
- const res: T[][] = [];
- if (size <= 0) return res;
- for (let i = 0; i < arr.length; i += size) res.push(arr.slice(i, i + size));
- return res;
-}
-function mod(n: number, m: number) {
- return ((n % m) + m) % m;
-}
-function usePrefersReducedMotion() {
- const [reduced, setReduced] = useState(false);
- useEffect(() => {
- const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
- const onChange = () => setReduced(mq.matches);
- onChange();
- mq.addEventListener?.("change", onChange);
- return () => mq.removeEventListener?.("change", onChange);
- }, []);
- return reduced;
-}
-
-/**
- * wrapRef: perPage 계산용
- * clipRef: "패딩 없는 측정 박스" 폭(px) 계산용
- */
-function useCarouselMeasure() {
- const wrapRef = useRef(null);
- const clipRef = useRef(null);
-
- const [perPage, setPerPage] = useState(3);
- const [clipWidth, setClipWidth] = useState(0);
-
- // perPage: container width로 반응형 결정 (rAF로 디바운스)
- useEffect(() => {
- const el = wrapRef.current;
- if (!el) return;
-
- let raf = 0;
-
- const calc = () => {
- const w = el.clientWidth;
- if (w < 640) setPerPage(1);
- else if (w < 1024) setPerPage(2);
- else setPerPage(3);
- };
-
- const ro = new ResizeObserver(() => {
- cancelAnimationFrame(raf);
- raf = requestAnimationFrame(calc);
- });
-
- calc();
- ro.observe(el);
- return () => {
- cancelAnimationFrame(raf);
- ro.disconnect();
- };
- }, []);
-
- // clipWidth: 패딩 없는 박스에서 width를 정확히 측정 (sub-pixel 반올림)
- useEffect(() => {
- const el = clipRef.current;
- if (!el) return;
-
- let raf = 0;
-
- const calc = () => {
- const w = Math.round(el.getBoundingClientRect().width);
- setClipWidth(w);
- };
-
- const ro = new ResizeObserver(() => {
- cancelAnimationFrame(raf);
- raf = requestAnimationFrame(calc);
- });
-
- calc();
- ro.observe(el);
- return () => {
- cancelAnimationFrame(raf);
- ro.disconnect();
- };
- }, []);
-
- return { wrapRef, clipRef, perPage, clipWidth };
-}
-
-/* ---------------- component ---------------- */
-export default function CaseTabsCarousel() {
- const [active, setActive] = useState("rain");
-
- // 탭별 마지막 그룹 인덱스 기억
- const tabIndexRef = useRef>({
- rain: 0,
- reuse: 0,
- sewage: 0,
- global: 0,
- });
-
- const reducedMotion = usePrefersReducedMotion();
- const { wrapRef, clipRef, perPage, clipWidth } = useCarouselMeasure();
-
- const items = useMemo(() => MOCK[active] ?? [], [active]);
- const groups = useMemo(() => chunk(items, perPage), [items, perPage]);
- const groupCount = groups.length;
-
- const [cur, setCur] = useState(0);
- const curRef = useRef(0);
-
- // px 기반 트랙
- const [trackPx, setTrackPx] = useState(0);
- const [anim, setAnim] = useState(false);
- const [busy, setBusy] = useState(false);
-
- // 방향 ref (onTransitionEnd에서 trackPx 비교하지 않음)
- const dirRef = useRef<-1 | 1 | 0>(0);
-
- // cur 최신값 유지
- useEffect(() => {
- curRef.current = cur;
- }, [cur]);
-
- /**
- * 리셋 로직 분리:
- * - 탭/perPage/groupCount 바뀔 때만 "인덱스/상태" 리셋
- * - clipWidth 변화는 별도 effect에서 "busy 아닐 때만" 중앙(-W)로 배치
- */
- useLayoutEffect(() => {
- const saved = tabIndexRef.current[active] ?? 0;
- const nextCur = groupCount ? mod(saved, groupCount) : 0;
-
- dirRef.current = 0;
- setCur(nextCur);
- setBusy(false);
- setAnim(false);
- }, [active, perPage, groupCount]);
-
- // clipWidth 변화로 트랙 위치를 잡되, 슬라이드 중(busy)엔 덮어쓰지 않음
- useLayoutEffect(() => {
- if (busy) return;
- if (clipWidth > 0) setTrackPx(-clipWidth);
- else setTrackPx(0);
- }, [clipWidth, busy]);
-
- // 3그룹 가상화
- const viewGroups = useMemo(() => {
- if (!groupCount) return [[], [], []] as CaseItem[][];
- return [
- groups[mod(cur - 1, groupCount)] ?? [],
- groups[mod(cur, groupCount)] ?? [],
- groups[mod(cur + 1, groupCount)] ?? [],
- ];
- }, [groups, cur, groupCount]);
-
- const canSlide = groupCount > 1 && clipWidth > 0;
-
- const go = useCallback(
- (dir: -1 | 1) => {
- if (!canSlide) return;
- if (busy) return;
-
- dirRef.current = dir;
-
- setBusy(true);
- setAnim(!reducedMotion);
-
- // 중앙(-W) 기준: prev=0, next=-2W
- setTrackPx(dir === 1 ? -2 * clipWidth : 0);
-
- // reduced motion이면 즉시 확정
- if (reducedMotion) {
- const nextCur = mod(curRef.current + dir, groupCount);
- tabIndexRef.current[active] = nextCur;
-
- setCur(nextCur);
- setAnim(false);
- setTrackPx(-clipWidth);
- setBusy(false);
- dirRef.current = 0;
- }
- },
- [active, busy, canSlide, clipWidth, groupCount, reducedMotion]
- );
-
- // BUGFIX 1) 자식 transitionend 버블링 방지 + transform만 처리
- const onTransitionEnd = useCallback(
- (e: React.TransitionEvent) => {
- // 자식 transitionend 버블링 방지
- if (e.target !== e.currentTarget) return;
- if (e.propertyName !== "transform") return;
-
- if (!busy || reducedMotion) return;
-
- const dir = dirRef.current;
- if (dir !== 1 && dir !== -1) return;
-
- const nextCur = mod(curRef.current + dir, groupCount);
- tabIndexRef.current[active] = nextCur;
-
- // 1) 먼저 transition을 끄고(중요)
- setAnim(false);
-
- // 2) cur를 회전 + 트랙을 즉시 중앙(-W)로 스냅
- // (이 순간은 "무조건 애니메이션 없이" 처리되어야 함)
- setCur(nextCur);
- setTrackPx(-clipWidth);
-
- // 3) 다음 프레임(2번 rAF)에서 다시 애니메이션 켜고 busy 해제
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- setAnim(!reducedMotion);
- setBusy(false);
- dirRef.current = 0;
- });
- });
- },
- [active, busy, clipWidth, groupCount, reducedMotion]
-);
-
- const goDot = useCallback(
- (idx: number) => {
- if (!groupCount) return;
- if (busy) return;
-
- const target = mod(idx, groupCount);
- tabIndexRef.current[active] = target;
-
- dirRef.current = 0;
- setCur(target);
- setAnim(false);
- if (clipWidth > 0) setTrackPx(-clipWidth);
- },
- [active, busy, clipWidth, groupCount]
- );
-
- const trackStyle: React.CSSProperties = useMemo(
- () => ({
- transform: `translate3d(${trackPx}px, 0, 0)`,
- transition: anim ? "transform 700ms cubic-bezier(.22,.61,.36,1)" : "none",
- willChange: busy ? "transform" : undefined,
- }),
- [trackPx, anim, busy]
- );
-
- return (
-
- {/* 탭 바 */}
-
-
-
- {TABS.map((t) => {
- const isActive = t.key === active;
-
- return (
-
- );
- })}
-
-
-
-
- {/* 슬라이더 */}
-
-
- {/* 좌우 버튼 */}
-
-
-
-
- {/* 중요: clip(가림)과 measure(측정) 분리 */}
-
-
- {/* 측정은 패딩 없는 박스에서 */}
-
- {/* track */}
-
- {viewGroups.map((group, gi) => (
-
- {/* 그룹 내부: perPage 컬럼 */}
-
- {(group ?? []).map((c) => (
-
-
-

-
-
-
-
- ))}
-
-
- ))}
-
-
-
-
-
- {/* 도트 */}
-
- {Array.from({ length: Math.max(groupCount, 1) }).map((_, i) => (
-
-
-
-
- );
-}
diff --git a/apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx b/apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx
deleted file mode 100644
index 6c50d5a5c..000000000
--- a/apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-// apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx
-"use client";
-
-import { useEffect, useMemo, useRef, useState } from "react";
-
-type Slide = { src: string; alt?: string };
-
-type Props = {
- slides: Slide[];
- className?: string;
- maxWidthClassName?: string; // 예: "max-w-[760px]"
-};
-
-export default function CaseImageSlider({
- slides,
- className = "",
- maxWidthClassName = "max-w-[760px]",
-}: Props) {
- const safeSlides = useMemo(() => slides.filter(Boolean), [slides]);
- const [idx, setIdx] = useState(0);
- const [dragging, setDragging] = useState(false);
-
- const startX = useRef(0);
- const deltaX = useRef(0);
-
- const total = safeSlides.length;
- const canSlide = total > 1;
-
- const go = (dir: number) => {
- if (!canSlide) return;
- setIdx((prev) => {
- const next = prev + dir;
- if (next < 0) return total - 1;
- if (next >= total) return 0;
- return next;
- });
- };
-
- // ← → 키보드 지원(선택)
- useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- if (!canSlide) return;
- if (e.key === "ArrowLeft") go(-1);
- if (e.key === "ArrowRight") go(1);
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, [canSlide, total]);
-
- // 드래그(마우스/터치)로 넘기기
- const onPointerDown = (e: React.PointerEvent) => {
- if (!canSlide) return;
- setDragging(true);
- startX.current = e.clientX;
- deltaX.current = 0;
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
- };
-
- const onPointerMove = (e: React.PointerEvent) => {
- if (!dragging) return;
- deltaX.current = e.clientX - startX.current;
- };
-
- const onPointerUp = () => {
- if (!dragging) return;
- setDragging(false);
-
- const threshold = 60; // 넘김 감도
- if (deltaX.current > threshold) go(-1);
- else if (deltaX.current < -threshold) go(1);
-
- deltaX.current = 0;
- };
-
- if (!total) return null;
-
- return (
-
-
-
- {/* 좌측 버튼 */}
-
-
-
- {/* 우측 버튼 */}
-
-
-
- {/* 뷰포트 */}
-
-
- {safeSlides.map((s, i) => (
-
-

-
- ))}
-
-
-
-
- {/* 도트 */}
- {canSlide && (
-
- {safeSlides.map((_, i) => {
- const active = i === idx;
- return (
-
- )}
-
-
- );
-}
diff --git a/apps/dm/app/(landing)/reuse/_components/Lnb.tsx b/apps/dm/app/(landing)/reuse/_components/Lnb.tsx
deleted file mode 100644
index c7d834844..000000000
--- a/apps/dm/app/(landing)/reuse/_components/Lnb.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-// apps/dm/app/(landing)/reuse/_components/Lnb.tsx
-"use client";
-
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { useMemo, useState } from "react";
-
-export type LnbItem = {
- label: string;
- href?: string; // href 없는 부모(2뎁스 컨테이너)도 허용
- children?: LnbItem[];
-};
-
-function isExactActive(pathname: string, href?: string) {
- if (!href) return false;
- return pathname === href;
-}
-
-export default function Lnb({ title, items }: { title: string; items: LnbItem[] }) {
- const pathname = usePathname();
-
- // ✅ 현재 경로가 속한 2depth 그룹을 기본 open
- const defaultOpenLabel = useMemo(() => {
- const found = items.find((it) => it.children?.some((c) => isExactActive(pathname, c.href)));
- return found?.label ?? null;
- }, [items, pathname]);
-
- // ✅ 아코디언: 한 번에 하나만 열리도록 openLabel 1개만 관리
- const [openLabel, setOpenLabel] = useState(defaultOpenLabel);
-
- return (
-
- );
-}
diff --git a/apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx b/apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx
deleted file mode 100644
index 4ddc08812..000000000
--- a/apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-// apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx
-"use client";
-
-import Link from "next/link";
-import { useEffect, useState } from "react";
-import { useRouter } from "next/navigation";
-
-export default function LoginStatus() {
- const router = useRouter();
- const [userName, setUserName] = useState(null);
-
- useEffect(() => {
- // 예시: 로그인 시 저장한 값
- const name = localStorage.getItem("userName");
- setUserName(name);
- }, []);
-
- const handleLogout = () => {
- localStorage.removeItem("accessToken");
- localStorage.removeItem("userName");
- router.refresh(); // 헤더 상태 갱신
- };
-
- // 🔹 로그인 전
- if (!userName) {
- return (
-
- 로그인
- →
-
- );
- }
-
- // 🔹 로그인 후
- return (
-
-
- {userName}님 환영합니다.
-
-
-
- );
-}
diff --git a/apps/dm/app/(landing)/reuse/_components/SideIndicator.tsx b/apps/dm/app/(landing)/reuse/_components/SideIndicator.tsx
deleted file mode 100644
index 8f4cea164..000000000
--- a/apps/dm/app/(landing)/reuse/_components/SideIndicator.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-"use client";
-
-import { useEffect, useRef, useState } from "react";
-import styles from "../reuse.module.css";
-
-export interface SideIndicatorItem {
- id: string;
- label: string; // 상단/공용 라벨
- sideLabel?: string; // 사이드 인디게이터 전용 라벨(옵션)
-}
-
-export default function SideIndicator({
- items,
- activeId,
-}: {
- items: SideIndicatorItem[];
- activeId: string;
-}) {
- const [manualActiveId, setManualActiveId] = useState(null);
- const clearTimerRef = useRef(null);
-
- const currentId = (manualActiveId ?? activeId ?? "main") || "main";
-
- // ✅ manualActiveId가 observer(activeId)와 불일치하기 시작하면 즉시 해제 (stuck 방지)
- useEffect(() => {
- if (!manualActiveId) return;
- if (activeId && activeId !== manualActiveId) setManualActiveId(null);
- }, [activeId, manualActiveId]);
-
- // ✅ fallback: 스무스 스크롤 시간이 길어도 manual이 오래 남지 않게 타임아웃 해제
- useEffect(() => {
- return () => {
- if (clearTimerRef.current) window.clearTimeout(clearTimerRef.current);
- };
- }, []);
-
- const scrollTo = (id: string) => {
- setManualActiveId(id);
-
- // 기존 타이머 제거 후 새로 설정
- if (clearTimerRef.current) window.clearTimeout(clearTimerRef.current);
- clearTimerRef.current = window.setTimeout(() => {
- setManualActiveId(null);
- }, 1600);
-
- if (id === "main") {
- window.scrollTo({ top: 0, behavior: "smooth" });
- return;
- }
-
- document.getElementById(id)?.scrollIntoView({
- behavior: "smooth",
- block: "start",
- });
- };
-
- return (
-
- );
-}
diff --git a/apps/dm/app/(landing)/reuse/_components/SubPageHeader.tsx b/apps/dm/app/(landing)/reuse/_components/SubPageHeader.tsx
deleted file mode 100644
index fc3bade23..000000000
--- a/apps/dm/app/(landing)/reuse/_components/SubPageHeader.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-"use client";
-
-import Link from "next/link";
-
-export type Crumb = { label: string; href?: string };
-
-type Props = {
- title: string;
- crumbs: Crumb[];
- note?: string;
- underlineWidthClass?: string;
- className?: string;
- wrapClassName?: string;
- hideFooterLine?: boolean;
- hideGuideText?: boolean;
-};
-
-export default function SubPageHeader({
- title,
- crumbs,
- note = "* 는 필수 값입니다.",
- underlineWidthClass = "w-40",
- className = "",
- wrapClassName = "",
- hideFooterLine = false,
- hideGuideText = false,
-}: Props) {
- return (
-
-
-
- {/* 좌측 타이틀 */}
-
-
- {/* 우측 브레드크럼 */}
-
-
- {/* 하단 라인 + 안내문 */}
- {!hideGuideText && (
-
- *
- {note.replace("*", "")}
-
- )}
-
-
- );
-}
diff --git a/apps/dm/app/(landing)/reuse/_components/TopNav.tsx b/apps/dm/app/(landing)/reuse/_components/TopNav.tsx
deleted file mode 100644
index 078d5e0d4..000000000
--- a/apps/dm/app/(landing)/reuse/_components/TopNav.tsx
+++ /dev/null
@@ -1,465 +0,0 @@
-// apps/dm/app/(landing)/reuse/_components/TopNav.tsx
-"use client";
-
-import Link from "next/link";
-import { useEffect, useId, useState } from "react";
-import { useRouter } from "next/navigation";
-
-const NAV_ITEMS = [
- { href: "/reuse/intro/law", label: "물 재이용 소개" },
- { href: "/reuse/cases/rain", label: "물 재이용 현황" },
- { href: "/reuse/business/rain/suwon", label: "물 재이용 사례" },
- { href: "/reuse/apply/place", label: "수요신청" },
-];
-
-// ✅ 필요 시 실제 “수요처등록” 페이지로 경로만 교체하세요.
-const REGISTER_HREF = "/reuse/apply/place";
-
-// ✅ 로그인 정보 저장 키(원하는대로 변경 가능)
-const LS_TOKEN_KEY = "reuse_accessToken";
-const LS_USER_KEY = "reuse_userName";
-
-type LoginModalProps = {
- open: boolean;
- onClose: () => void;
- onSuccess?: (payload: { userName: string; accessToken?: string }) => void;
-};
-
-type FieldKey = "bizNo" | "workerNo" | "pw";
-
-function isBizNoValid(v: string) {
- // 예: 123-45-67890
- return /^\d{3}-\d{2}-\d{5}$/.test(v);
-}
-
-function inputCls(opts: { hasError?: boolean; disabled?: boolean }) {
- const base =
- "w-full h-[48px] rounded-[4px] border px-5 text-[18px] outline-none";
- if (opts.disabled) return `${base} border-gray-200 bg-gray-50 text-gray-400`;
- if (opts.hasError)
- return `${base} border-red-500 focus:border-red-500 ring-1 ring-red-400`;
- return `${base} border-gray-300 focus:border-sky-400`;
-}
-
-function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
- const titleId = useId();
- const router = useRouter();
-
- const [bizNo, setBizNo] = useState("");
- const [workerNo, setWorkerNo] = useState("");
- const [noWorker, setNoWorker] = useState(false);
- const [pw, setPw] = useState("");
-
- // ✅ 유효성/서버 에러 표시용
- const [errors, setErrors] = useState>>({});
- const [touched, setTouched] = useState>>({});
- const [submitting, setSubmitting] = useState(false);
-
- useEffect(() => {
- if (!open) return;
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- window.addEventListener("keydown", onKeyDown);
-
- // 스크롤 잠금
- const prev = document.body.style.overflow;
- document.body.style.overflow = "hidden";
-
- // 모달 오픈 시 에러/터치 초기화
- setErrors({});
- setTouched({});
-
- return () => {
- window.removeEventListener("keydown", onKeyDown);
- document.body.style.overflow = prev;
- };
- }, [open, onClose]);
-
- useEffect(() => {
- if (noWorker) {
- setWorkerNo("");
- setErrors((p) => {
- const { workerNo: _w, ...rest } = p;
- return rest;
- });
- }
- }, [noWorker]);
-
- if (!open) return null;
-
- const validate = () => {
- const next: Partial> = {};
-
- const b = bizNo.trim();
- const w = workerNo.trim();
- const p = pw.trim();
-
- if (!b) next.bizNo = "사업자등록번호를 입력해주세요.";
- else if (!isBizNoValid(b))
- next.bizNo = "사업자등록번호 형식이 올바르지 않습니다. (예: 123-45-67890)";
-
- if (!noWorker && !w) next.workerNo = "종사자등록번호를 입력해주세요.";
-
- if (!p) next.pw = "비밀번호를 입력해주세요.";
-
- setErrors(next);
- return next;
- };
-
- const markTouched = (key: FieldKey) =>
- setTouched((p) => ({ ...p, [key]: true }));
-
- const markAllTouched = () =>
- setTouched({ bizNo: true, workerNo: true, pw: true });
-
- const submit = async (e: React.FormEvent) => {
- e.preventDefault();
- markAllTouched();
-
- const v = validate();
- if (Object.keys(v).length) return;
-
- try {
- setSubmitting(true);
-
- // ✅ TODO: 실제 로그인 API 연결 지점
- // const res = await fetch("/api/demand/login", {
- // method: "POST",
- // headers: { "Content-Type": "application/json" },
- // body: JSON.stringify({ bizNo, workerNo: noWorker ? null : workerNo, pw }),
- // });
- // if (!res.ok) { ... }
- // const data = await res.json();
- // onSuccess?.({ userName: data.userName, accessToken: data.token });
-
- // --- 데모: 비밀번호가 "1234"면 성공 처리(원하면 제거) ---
- const serverOk = pw.trim() === "1234";
- if (!serverOk) {
- setErrors((p) => ({ ...p, pw: "잘못된 비밀번호입니다. (데모: 1234 입력 시 성공)" }));
- setTouched((p) => ({ ...p, pw: true }));
- return;
- }
-
- onSuccess?.({ userName: "마인드원", accessToken: "demo_token" });
- onClose();
- router.refresh();
- } finally {
- setSubmitting(false);
- }
- };
-
- return (
-
- {/* Backdrop */}
-
-
- {/* Dialog */}
-
-
- {/* Header */}
-
-
-
-
- 수요처 로그인
-
-
- {/* title underline gradient bar */}
-
-
-
- {/* Body */}
-
-
-
-
- );
-}
-
-export default function TopNav() {
- const router = useRouter();
-
- const [loginOpen, setLoginOpen] = useState(false);
- const [userName, setUserName] = useState(null);
-
- // ✅ 초기 로드 시 로그인 상태 반영
- useEffect(() => {
- const name = localStorage.getItem(LS_USER_KEY);
- setUserName(name);
- }, []);
-
- const openLogin = () => setLoginOpen(true);
-
- const logout = () => {
- localStorage.removeItem(LS_TOKEN_KEY);
- localStorage.removeItem(LS_USER_KEY);
- setUserName(null);
- router.refresh();
- };
-
- return (
- <>
-
- {/* ✅ 좌/중/우 정렬이 되도록 컨테이너 구성 */}
-
-
- {/* 좌측: 로고 */}
-
-
-

-
-
-
- {/* 중앙: 상단 메뉴 */}
-
-
- {/* ✅ 우측: 로그인 버튼 / 로그인 정보 + 로그아웃 */}
-
- {!userName ? (
-
- ) : (
- <>
-
- {userName}님 환영합니다.
-
-
- >
- )}
-
-
-
-
-
- {/* ✅ 로그인 모달 */}
- setLoginOpen(false)}
- onSuccess={({ userName, accessToken }) => {
- // ✅ 로그인 성공 처리: 저장 + 헤더 갱신
- localStorage.setItem(LS_USER_KEY, userName);
- if (accessToken) localStorage.setItem(LS_TOKEN_KEY, accessToken);
- setUserName(userName);
- setLoginOpen(false);
- router.refresh();
- }}
- />
- >
- );
-}
diff --git a/apps/dm/app/(landing)/reuse/_constants/lnb.ts b/apps/dm/app/(landing)/reuse/_constants/lnb.ts
deleted file mode 100644
index 49c9ae1ae..000000000
--- a/apps/dm/app/(landing)/reuse/_constants/lnb.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-// apps/dm/app/(landing)/reuse/_constants/lnb.ts
-import type { LnbItem } from "../_components/Lnb";
-
-export const LNB_DEMAND: LnbItem[] = [
- { href: "/reuse/apply/place", label: "수요처등록" },
- { href: "/reuse/apply/register", label: "수요정보등록" },
- { href: "/reuse/apply/status", label: "수요신청현황" },
-];
-
-export const LNB_INTRO = [
- { label: "물 재이용 법", href: "/reuse/intro/law" },
- { label: "빗물 이용시설", href: "/reuse/intro/rain" },
- { label: "중수도", href: "/reuse/intro/greywater" },
- { label: "하수처리수", href: "/reuse/intro/treated" },
-];
-
-export const LNB_CASES = [
- { label: "빗물이용시설(현황)", href: "/reuse/cases/rain" },
- { label: "중수도(현황)", href: "/reuse/cases/greywater" },
- { label: "하수처리수 재이용시설(현황)", href: "/reuse/cases/treated" },
-];
-
-// 물 재이용 사례처럼 2depth가 있는 메뉴
-export const LNB_BUSINESS: LnbItem[] = [
- {
- label: "빗물 재이용 사례",
- children: [
- { label: "경기도 수원시", href: "/reuse/business/rain/suwon" },
- // { label: "충청남도 보령시", href: "/reuse/business/rain/bolyeong" },
- // { label: "충청북도 청주시", href: "/reuse/business/rain/cheongju" },
- ],
- },
- {
- label: "중수 재이용 사례",
- children: [{ label: "사례 목록", href: "/reuse/business/graywater" }],
- },
- {
- label: "하수 재이용 사례",
- children: [
- { label: "파주시", href: "/reuse/business/sewage/paju" },
- { label: "충청남도 보령시", href: "/reuse/business/sewage/bolyeong" },
- { label: "여수", href: "/reuse/business/sewage/yeosu" },
- { label: "청주", href: "/reuse/business/sewage/cheongju" },
- { label: "수원시 황구지천", href: "/reuse/business/sewage/hwanggu" },
- { label: "구리시 도로자동청소시스템", href: "/reuse/business/sewage/guri" },
- ],
- },
- {
- label: "해외 재이용 사례",
- children: [
- { label: "사례 개요", href: "/reuse/business/globalCase/overview" },
- { label: "대만", href: "/reuse/business/globalCase/taiwan" },
- { label: "호주", href: "/reuse/business/globalCase/australia" },
- { label: "싱가포르", href: "/reuse/business/globalCase/singapore" },
- { label: "EU", href: "/reuse/business/globalCase/eu" }
- ],
- },
-];
diff --git a/apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts b/apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts
deleted file mode 100644
index 3a253e95d..000000000
--- a/apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-"use client";
-import { useEffect, useRef, useState } from "react";
-
-export function useScrollSpy(sectionIds: string[], options?: IntersectionObserverInit) {
- const [activeId, setActiveId] = useState(sectionIds[0] ?? "");
- const ratiosRef = useRef