[as] Sentry Replay 프론트엔드 설정 추가 테스트 제외처리
This commit is contained in:
@@ -40,3 +40,5 @@ next-env.d.ts
|
||||
.env*
|
||||
!.env.example
|
||||
/start_cs.log
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"7cf08488f825bec651e1-31e606ad69300bb6fcd0",
|
||||
"7cf08488f825bec651e1-9eda3d84416d28db938c",
|
||||
"7cf08488f825bec651e1-bbbe7f77241a3995edcb"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("FAQ 페이지 E2E 테스트", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 임시 URL. 실제 라우팅 경로에 맞게 수정 필요
|
||||
await page.goto("/post/faq");
|
||||
});
|
||||
|
||||
test("1. FAQ 목록 화면이 정상적으로 렌더링되는지 확인", async ({ page }) => {
|
||||
// TODO: 실제 렌더링되는 요소에 맞게 selector 및 텍스트 수정 필요
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Image 게시판 페이지 E2E 테스트", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 임시 URL. 실제 라우팅 경로에 맞게 수정 필요
|
||||
await page.goto("/post/image");
|
||||
});
|
||||
|
||||
test("1. 이미지 목록 화면이 정상적으로 렌더링되는지 확인", async ({ page }) => {
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Movie 게시판 페이지 E2E 테스트", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 임시 URL. 실제 라우팅 경로에 맞게 수정 필요
|
||||
await page.goto("/post/movie");
|
||||
});
|
||||
|
||||
test("1. 동영상 목록 화면이 정상적으로 렌더링되는지 확인", async ({ page }) => {
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Normal 게시판 페이지 E2E 테스트", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 임시 URL. 실제 라우팅 경로에 맞게 수정 필요
|
||||
await page.goto("/post/normal");
|
||||
});
|
||||
|
||||
test("1. 일반 게시판 목록 화면이 정상적으로 렌더링되는지 확인", async ({ page }) => {
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Notice 게시판 페이지 E2E 테스트", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 임시 URL. 실제 라우팅 경로에 맞게 수정 필요
|
||||
await page.goto("/post/notice");
|
||||
});
|
||||
|
||||
test("1. 공지사항 목록 화면이 정상적으로 렌더링되는지 확인", async ({ page }) => {
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
# Multi-Domain JWT 토큰 공유 분석
|
||||
|
||||
## 현재 구조 진단
|
||||
|
||||
### 도메인 구성
|
||||
| 앱 | 도메인 | 포트(로컬) |
|
||||
|----|--------|-----------|
|
||||
| cs | `cs.onasys.co.kr` | 3000 |
|
||||
| as | `as.onasys.co.kr` | 3003 |
|
||||
| backend | 공통 1개 | 8000 |
|
||||
|
||||
### 핵심 문제
|
||||
|
||||
브라우저 쿠키는 **Same-Origin Policy** 적용: `cs.onasys.co.kr`에서 설정한 쿠키는 `as.onasys.co.kr`에서 **읽을 수 없음**.
|
||||
|
||||
현재 두 앱 모두 `NEXT_PUBLIC_COOKIE_DOMAIN` 환경변수가 **설정되지 않음** → 쿠키 `domain` 속성 없음 → 각 앱이 자기 도메인에만 쿠키 저장.
|
||||
|
||||
### 코드 흐름 분석
|
||||
|
||||
```
|
||||
authConfig.cookieDomain = process.env.NEXT_PUBLIC_COOKIE_DOMAIN (현재 undefined)
|
||||
↓
|
||||
token-manager.ts: getCookieDomain() → undefined 반환
|
||||
↓
|
||||
setCookie(key, value, { domain: undefined, ... }) ← domain 없이 저장
|
||||
↓
|
||||
결과: cs.onasys.co.kr 쿠키 ≠ as.onasys.co.kr 쿠키 (공유 불가)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 해결 방법: Parent Domain Cookie
|
||||
|
||||
### 원리
|
||||
쿠키 `Domain` 속성을 **부모 도메인**으로 설정하면 모든 서브도메인이 공유:
|
||||
|
||||
```
|
||||
Domain=.onasys.co.kr
|
||||
→ cs.onasys.co.kr ✅ 읽기/쓰기 가능
|
||||
→ as.onasys.co.kr ✅ 읽기/쓰기 가능
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 방법
|
||||
|
||||
### Step 1: 환경변수 추가
|
||||
|
||||
**apps/cs/.env.production** (또는 `.env.local` 배포용)
|
||||
```env
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=.onasys.co.kr
|
||||
```
|
||||
|
||||
**apps/as/.env.production**
|
||||
```env
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=.onasys.co.kr
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 반드시 앞에 `.` (점)을 붙여야 서브도메인 전체에 적용됩니다.
|
||||
|
||||
### Step 2: 코드 변경 불필요
|
||||
|
||||
`token-manager.ts`의 `getCookieDomain()`이 이미 `config.cookieDomain`을 사용하고 있으므로, 환경변수만 설정하면 됩니다.
|
||||
|
||||
```ts
|
||||
// packages/common/auth/token-manager.ts (현재 코드)
|
||||
const getCookieDomain = (): string | undefined => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
if (config.cookieDomain) return config.cookieDomain; // ← 이미 준비됨
|
||||
return undefined;
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: 백엔드 CORS 확인
|
||||
|
||||
백엔드가 `credentials: true`와 함께 올바른 `Access-Control-Allow-Origin`을 반환하는지 확인:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: https://cs.onasys.co.kr (또는 as.onasys.co.kr)
|
||||
Access-Control-Allow-Credentials: true
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> `Access-Control-Allow-Origin: *` 와 `credentials: true`는 동시에 사용 불가.
|
||||
> 명시적 도메인 또는 동적 오리진 허용 필요.
|
||||
|
||||
---
|
||||
|
||||
## SameSite 주의사항
|
||||
|
||||
현재 토큰 매니저 설정:
|
||||
```ts
|
||||
setCookie(key, value, {
|
||||
secure: isHttps,
|
||||
sameSite: "lax", // ← 서브도메인 간 이동 시 쿠키 전송됨 (OK)
|
||||
domain,
|
||||
path: "/",
|
||||
});
|
||||
```
|
||||
|
||||
- `sameSite: "lax"` → 서브도메인 간 네비게이션에서 쿠키 전송 **가능** ✅
|
||||
- `sameSite: "strict"` → 다른 도메인에서의 요청에 쿠키 미전송 ❌
|
||||
- `sameSite: "none"` + `secure: true` → 크로스사이트 iframe 등에서도 전송 (필요 시)
|
||||
|
||||
배포 환경 HTTPS 사용 시 `secure: true` 자동 적용되므로 문제 없음.
|
||||
|
||||
---
|
||||
|
||||
## 세션 공유 흐름 (적용 후)
|
||||
|
||||
```
|
||||
사용자가 cs.onasys.co.kr 에서 로그인
|
||||
↓
|
||||
setCookie("access-token", jwt, { domain: ".onasys.co.kr" })
|
||||
↓
|
||||
브라우저: .onasys.co.kr 쿠키 저장
|
||||
↓
|
||||
사용자가 as.onasys.co.kr 으로 이동
|
||||
↓
|
||||
브라우저가 자동으로 ".onasys.co.kr" 쿠키 전송
|
||||
↓
|
||||
as 앱의 미들웨어: req.cookies.get("access-token") ✅ 정상 읽힘
|
||||
↓
|
||||
백엔드 API 호출 시 Authorization 헤더에 토큰 포함
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 추가 고려사항
|
||||
|
||||
### 로그아웃 처리
|
||||
한 앱에서 로그아웃 시 **도메인 지정 쿠키 삭제** 필요:
|
||||
```ts
|
||||
// removeToken 에서 domain 이 자동 적용됨 (현재 코드 OK)
|
||||
removeCookie(key, { domain: ".onasys.co.kr", path: "/" });
|
||||
```
|
||||
|
||||
### 토큰 갱신 (Refresh Token)
|
||||
`refresh-token` 쿠키도 같은 `domain` 설정 적용 → 두 앱 모두 silent refresh 가능.
|
||||
|
||||
### 로컬 개발 환경
|
||||
로컬에서는 `localhost`가 서브도메인을 지원하지 않으므로 별도 처리 불필요 (`.env.local`에는 설정하지 않음).
|
||||
|
||||
---
|
||||
|
||||
## 최소 변경 요약
|
||||
|
||||
| 위치 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `apps/cs/.env.production` | `NEXT_PUBLIC_COOKIE_DOMAIN=.onasys.co.kr` 추가 |
|
||||
| `apps/as/.env.production` | `NEXT_PUBLIC_COOKIE_DOMAIN=.onasys.co.kr` 추가 |
|
||||
| 소스 코드 | **변경 없음** (이미 cookieDomain 지원 구현됨) |
|
||||
| 백엔드 | CORS `Access-Control-Allow-Origin` 명시적 도메인 설정 확인 |
|
||||
@@ -0,0 +1,84 @@
|
||||
# AS 프로젝트 기술 스택 분석 (Tech Stack)
|
||||
|
||||
본 문서는 `as` (Administrative Support / 전자결재 및 공공행정 지원 시스템) 애플리케이션의 핵심 프로그래밍 스택과 아키텍처 구조를 분석하여 정리한 문서입니다. 본 프로젝트는 Next.js 15와 FSD(Feature Sliced Design) 아키텍처를 도입하여 복잡한 비즈니스 로직을 격리하고 모노레포 환경에서 유연하게 패키지를 재사용하고 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Framework & Runtime
|
||||
|
||||
* **Next.js 15.5.18**:
|
||||
* **App Router** 구조를 기본으로 채택하여 파일 기반 라우팅, 서버 컴포넌트(RSC), 미들웨어 등을 적극적으로 활용합니다.
|
||||
* `src/pages`와 `app/` 라우터를 조합하여 Next.js의 진입점(Layout, Page)은 최소화하고 비즈니스 모듈은 FSD 아키텍처 계층으로 격리하는 하이브리드 구성을 취하고 있습니다.
|
||||
* **React 19 & TypeScript**:
|
||||
* 최신 React의 강력한 타입 지원 기능을 활용하여 컴포넌트와 비즈니스 데이터의 정적 타입을 보장합니다.
|
||||
* **pnpm Monorepo**:
|
||||
* `workspace:*` 의존성 참조 형식을 사용하여 동일 레포지토리 내의 공통 모듈(`@common/auth`, `@common/components`, `@common/network`, `@common/assets`)을 패키지 형태로 안전하게 참조합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Styling (CSS & Design System)
|
||||
|
||||
* **Tailwind CSS (v3.4)**:
|
||||
* 유틸리티 퍼스트 CSS 프레임워크를 기반으로 하며, `postcss` 및 `autoprefixer`를 활용하여 스타일을 구성합니다.
|
||||
* **Class Variance Authority (CVA)**:
|
||||
* 컴포넌트의 Variants(버튼 크기, 활성/비활성 색상, 디자인 테마 등)를 선언적이고 타입 안전하게 관리하기 위해 사용됩니다.
|
||||
* **Lucide React**:
|
||||
* 통합적이고 일관된 아이콘 팩으로 화면 요소를 설계할 때 기본 아이콘 라이브러리로 활용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. State Management (상태 관리)
|
||||
|
||||
애플리케이션 내의 상태는 성격에 따라 3가지 레이어로 명확히 구분되어 관리됩니다.
|
||||
|
||||
### ① 전역 서버 상태 (Global Server State)
|
||||
* **TanStack Query v5 (React Query)**:
|
||||
* 서버 데이터 fetch, cache, sync, update 등을 담당합니다.
|
||||
* 커스텀 훅 패턴을 활용하여 비즈니스 데이터 처리부와 UI 컴포넌트를 분리합니다.
|
||||
* `ReactQueryDevtools`를 탑재하여 개발 단계에서 쿼리 캐시 상태를 실시간 모니터링합니다.
|
||||
|
||||
### ② 전역 클라이언트 상태 (Global Client State)
|
||||
* **Zustand (Vanilla)**:
|
||||
* `zustand/vanilla`의 `createStore`를 활용하여 글로벌 상태(사용자 정보, 알림창, 사이드바 메뉴 등)를 관리합니다.
|
||||
* **Next.js SSR 친화적 상태 공유 패턴**:
|
||||
Next.js의 서버 사이드 렌더링(SSR) 시 싱글톤 상태 오염 문제를 해결하기 위해, React Context (`StoreProvider`, `StoreContext`)와 `useRef`를 조합하여 요청 단위별 독립된 스토어 인스턴스를 생성하고 훅(`useUserStore` 등)을 통해 구독합니다.
|
||||
|
||||
### ③ 폼 상태 관리 및 검증 (Form State)
|
||||
* **React Hook Form & Zod**:
|
||||
* 결재 및 행정 신청 폼이 다수 존재하는 시스템 특성상, 입력 폼의 상태 변화를 비제어 컴포넌트 기반으로 최적화하여 렌더링 성능을 개선합니다.
|
||||
* `zod` 스키마 빌더를 이용해 폼 유효성 검증 규칙을 직관적으로 선언하고 `@hookform/resolvers`로 바인딩합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Components & Libraries
|
||||
|
||||
* **Radix UI**:
|
||||
* `@radix-ui/react-tooltip` 등 마크업 구조와 접근성이 보장된 Headless UI 라이브러리를 차용하여 스타일 종속성 없이 접근성 높은 컴포넌트를 구성합니다.
|
||||
* **TanStack Table v8 (React Table)**:
|
||||
* 다양한 필터링, 정렬, 페이지네이션 요구사항이 존재하는 목록 화면을 처리하기 위해 사용되는 Headless Table 라이브러리입니다.
|
||||
* **dnd-kit (`@dnd-kit/core`, `@dnd-kit/sortable`)**:
|
||||
* 결재선 지정 화면 등에서 마우스 및 터치 인터랙션을 이용한 행 정렬(Drag & Drop)을 매끄럽게 처리하기 위해 도입되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Network & Utils
|
||||
|
||||
* **Axios**:
|
||||
* HTTP 클라이언트 라이브러리로, 인터셉터(Interceptor)를 통해 공통 인증 헤더 관리, 토큰 재발급, 에러 공통 처리를 수행합니다.
|
||||
* **Tus JS Client**:
|
||||
* 대용량 및 첨부 파일 업로드 시 네트워크 단절 상황에 대응할 수 있도록 Resumable(이어올리기) 프로토콜인 **Tus Protocol**을 사용하여 안정적인 업로드를 지원합니다.
|
||||
* **Day.js**:
|
||||
* 표준화된 시간 포맷팅 및 날짜 연산을 가볍고 빠르게 처리합니다.
|
||||
* **JS Cookie**:
|
||||
* 클라이언트 브라우저의 쿠키 설정 및 조회에 활용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. Monitoring & Linting (품질 관리)
|
||||
|
||||
* **OpenTelemetry**:
|
||||
* 클라이언트 및 서버 측의 네트워크 지연 시간, 주요 사용자 인터랙션 트레이싱을 위해 APM 수집 어댑터가 구현되어 있습니다 (`instrumentation-client.ts`, `instrumentation.ts`).
|
||||
* **Sentry (`@sentry/nextjs`)**:
|
||||
* 프로덕션 환경에서의 실시간 에러 추적 및 분석을 담당합니다.
|
||||
* **Steiger**:
|
||||
* FSD 아키텍처 패턴을 물리적인 파일 경로 분석을 통해 규제하는 정적 분석 도구입니다. 레이어 간 비정상적인 의존성(예: 하위 레이어가 상위 레이어를 참조하는 역참조 등)을 빌드타임/린트 타임에 강력하게 통제합니다.
|
||||
@@ -0,0 +1,167 @@
|
||||
# AS 프로젝트 교육 자료: 초급 과정 (Beginner Course)
|
||||
|
||||
**대상**: React 및 Next.js 기초를 이해하고 있으며, AS 프로젝트에 신규 참여하여 UI 컴포넌트 및 기본 화면 단위를 구현해야 하는 개발자
|
||||
**목표**: FSD 아키텍처 규칙을 준수하며 Tailwind CSS와 CVA를 사용해 UI를 개발하고, React Hook Form과 Zod로 폼 컴포넌트를 설계할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Feature-Sliced Design (FSD) 아키텍처 기초
|
||||
|
||||
FSD는 기능 기반의 디렉토리 설계 규칙으로, 코드의 유지보수성과 확장성을 극대화하기 위해 탄생한 표준 스펙입니다.
|
||||
|
||||
### ① FSD의 3단계 계층 구조
|
||||
1. **Layers (레이어)**: 가장 큰 단위로, 최상단에서 최하단까지 엄격한 단방향 의존성 규칙을 가집니다.
|
||||
* `app`: 애플리케이션의 설정, Providers, 글로벌 스타일을 정의합니다. (의존성 가장 높음)
|
||||
* `pages`: 실제 라우팅 단위 화면을 구성합니다.
|
||||
* `widgets`: 독립적인 대형 UI 블록(예: 헤더, 복합 사이드바, 특정 복합 그리드 등)을 정의합니다.
|
||||
* `features`: 사용자의 행동 및 비즈니스 기능(예: `auth-login`, `search-box`, `add-comment`)을 다룹니다.
|
||||
* `entities`: 도메인 단위의 비즈니스 개념(예: `user`, `approval-line`, `budget`)과 그에 대한 단순 조회 UI, API, 상태를 정의합니다.
|
||||
* `shared`: 특정 도메인에 종속되지 않는 범용 재사용 기술 자원(UI Kit, API Client, Util 라이브러리)을 모아둡니다. (의존성 가장 낮음)
|
||||
2. **Slices (슬라이스)**: 도메인에 따른 수직적 분리 단위입니다 (예: `entities/user`, `features/auth-login`).
|
||||
3. **Segments (세그먼트)**: 기술적 역할에 따른 물리적 파일 분류입니다.
|
||||
* `ui/`: React 컴포넌트 파일들
|
||||
* `model/`: 스토어 상태, 데이터 타입, 스키마 등
|
||||
* `api/`: 서버 통신용 Axios 함수 및 쿼리 훅
|
||||
* `lib/`: 내부 Helper 유틸 함수
|
||||
|
||||
### ② FSD 수직적 수평적 import 규칙 (중요!)
|
||||
* **규칙 1 (수직적 규칙)**: 레이어는 자신보다 **하위 레이어만 import**할 수 있습니다. 상위 레이어를 import하는 것은 엄격히 금지됩니다. (예: `features`는 `entities`를 import할 수 있으나, `entities`가 `features`나 `widgets`를 import할 수 없음)
|
||||
* **규칙 2 (수평적 규칙)**: 동일한 레이어 내부의 다른 슬라이스끼리는 **서로 직접 import할 수 없습니다**. (예: `features/auth-login`에서 `features/post-create`를 import할 수 없음. 이 경우에는 상위 레이어인 `pages`나 `widgets`에서 두 슬라이스를 조합해야 함)
|
||||
* **규칙 3 (Public API)**: 각 슬라이스 외부에서는 반드시 슬라이스 루트의 `index.ts` (Public API)를 거쳐서만 모듈을 가져와야 합니다. 슬라이스 내부 폴더 구조에 직접 접근하는 행위(`import ... from '@/features/auth/ui/LoginForm'`)는 린트 에러(`Steiger` 경고)를 발생시킵니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tailwind CSS & Class Variance Authority (CVA) 실무
|
||||
|
||||
AS 프로젝트는 유틸리티 클래스 중복을 최소화하고, 컴포넌트의 디자인 변형(Variants)을 안전하게 관리하기 위해 **CVA**를 사용합니다.
|
||||
|
||||
### CVA 활용 예시 (`shared/ui/button.tsx`)
|
||||
```typescript
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/shared/lib"; // clsx와 tailwind-merge 조합 유틸
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-slate-900 text-white hover:bg-slate-800",
|
||||
destructive: "bg-red-500 text-white hover:bg-red-600",
|
||||
outline: "border border-slate-200 bg-transparent hover:bg-slate-100",
|
||||
ghost: "hover:bg-slate-100 hover:text-slate-950",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. React Hook Form & Zod를 활용한 폼 검증
|
||||
|
||||
AS 프로젝트는 성능 최적화를 위해 불필요한 리렌더링을 방지하는 **React Hook Form**과 타입 안전한 스키마 검증기 **Zod**를 페어로 사용합니다.
|
||||
|
||||
### ① 스키마 정의 (`lib/regist-schema.ts` 또는 `model/schema.ts`)
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
export const registrationSchema = z.zodObject({
|
||||
bizNm: z.string().min(2, "사업명은 필수 항목이며 2글자 이상이어야 합니다."),
|
||||
bgtBizCd: z.string().min(1, "확정예산사업번호를 입력해주세요."),
|
||||
bgtAplyYr: z.string().regex(/^\d{4}$/, "예산신청연도는 4자리 숫자여야 합니다."),
|
||||
email: z.string().email("올바른 이메일 형식이 아닙니다."),
|
||||
privacyConsent: z.boolean().refine((val) => val === true, {
|
||||
message: "개인정보 제공에 동의하셔야 합니다.",
|
||||
}),
|
||||
});
|
||||
|
||||
export type RegistrationFormValues = z.infer<typeof registrationSchema>;
|
||||
```
|
||||
|
||||
### ② 컴포넌트 구현 및 바인딩
|
||||
```typescript
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { registrationSchema, RegistrationFormValues } from "./schema";
|
||||
|
||||
export function RegistrationForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegistrationFormValues>({
|
||||
resolver: zodResolver(registrationSchema),
|
||||
defaultValues: {
|
||||
bizNm: "",
|
||||
bgtBizCd: "",
|
||||
bgtAplyYr: new Date().getFullYear().toString(),
|
||||
email: "",
|
||||
privacyConsent: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: RegistrationFormValues) => {
|
||||
console.log("Submit Data:", data);
|
||||
// API 호출 로직은 중급 과정에서 설명
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 p-4 border rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">사업명</label>
|
||||
<input {...register("bizNm")} className="mt-1 block w-full border rounded p-2" />
|
||||
{errors.bizNm && <p className="text-red-500 text-xs mt-1">{errors.bizNm.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium">예산사업번호</label>
|
||||
<input {...register("bgtBizCd")} className="mt-1 block w-full border rounded p-2" />
|
||||
{errors.bgtBizCd && <p className="text-red-500 text-xs mt-1">{errors.bgtBizCd.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input type="checkbox" {...register("privacyConsent")} />
|
||||
<span className="text-sm">개인정보 수집 동의</span>
|
||||
</label>
|
||||
{errors.privacyConsent && <p className="text-red-500 text-xs mt-1">{errors.privacyConsent.message}</p>}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isSubmitting} className="bg-blue-600 text-white p-2 rounded">
|
||||
등록하기
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 초급 실습 과제
|
||||
|
||||
### 과제 내용
|
||||
* `src/shared/ui` 하위에 CVA를 활용하여 커스텀 뱃지(Badge) 컴포넌트를 설계하세요. (예: `variant` 옵션으로 `'success'`, `'warning'`, `'error'`, `'info'` 상태를 가짐)
|
||||
* 사용자 정보 입력 폼 스키마를 `zod`로 설계하고, `react-hook-form`으로 가상의 회원가입 컴포넌트를 만들어보세요. (이메일 검증, 비밀번호 8자 이상 검증 필수)
|
||||
* **제약 조건**: 해당 모듈들을 생성할 때 FSD 레이어 규칙(`shared`와 `features` 레이어 배치 위치)을 준수하고, Steiger 린팅 규칙에 어긋나지 않도록 `index.ts`를 통해 Public API 형태로 외부에 노출하세요.
|
||||
@@ -0,0 +1,192 @@
|
||||
# AS 프로젝트 교육 자료: 중급 과정 (Intermediate Course)
|
||||
|
||||
**대상**: FSD 아키텍처와 마크업 설계에 대한 기본 이해를 갖춘 개발자
|
||||
**목표**: Next.js App Router 연동 방식을 이해하고, TanStack Query로 API 통신을 수행하며, Zustand와 Context를 결합하여 SSR 환경에 최적화된 상태 관리를 다룰 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Next.js App Router와 FSD 라우팅 패턴
|
||||
|
||||
AS 프로젝트는 Next.js의 `app/` 라우터를 페이지 진입점으로 최소화하고, 모든 비즈니스 로직과 복잡한 뷰(View) 컴포넌트는 `src/pages` 레이어에 담아 관리합니다.
|
||||
|
||||
### 라우팅 흐름 예시
|
||||
* **Next.js 경로 정의**: `app/(with-base-layout)/fnrs-cnsltn/page.tsx`
|
||||
* **구현 파일**:
|
||||
```typescript
|
||||
// app/(with-base-layout)/fnrs-cnsltn/page.tsx
|
||||
export { FnrsListPage as default } from "@/pages/fnrs-cnsltn";
|
||||
```
|
||||
* **실제 뷰 컴포넌트**: `src/pages/fnrs-cnsltn/ui/fnrs-list-page.tsx`
|
||||
* Next.js `app` 폴더 내부에 로직을 구현하지 않고, 단순하게 `src/pages` 슬라이스를 re-export함으로써 라우팅 구조와 비즈니스 모듈의 종속성을 완벽히 분리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. SSR 환경에 안전한 Zustand 상태 관리 패턴
|
||||
|
||||
Next.js와 같은 SSR 프레임워크에서 Zustand 스토어를 단순 싱글톤 객체(`export const useStore = create(...)`)로 정의하면, 서버 측 메모리 상에서 **여러 사용자 간의 전역 상태 오염(Store State Leak)**이 발생합니다.
|
||||
이를 극부하기 위해 AS 프로젝트는 **Vanilla Store + React Context + useRef** 방식을 채택하고 있습니다.
|
||||
|
||||
### ① 스토어 정의 (`src/shared/store/user-store.ts`)
|
||||
```typescript
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export interface IUserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export type UserState = {
|
||||
userInfo: IUserInfo | null;
|
||||
};
|
||||
|
||||
export type UserActions = {
|
||||
setUserInfo: (userInfo: IUserInfo | null) => void;
|
||||
};
|
||||
|
||||
export type UserStore = UserState & UserActions;
|
||||
|
||||
export const defaultInitState: UserState = {
|
||||
userInfo: null,
|
||||
};
|
||||
|
||||
export const createUserStore = (initState: UserState = defaultInitState) => {
|
||||
return createStore<UserStore>()((set) => ({
|
||||
...initState,
|
||||
setUserInfo: (userInfo) => set(() => ({ userInfo })),
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
### ② Context 및 Provider 정의 (`src/shared/providers/store-provider.tsx`)
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { createContext, useRef } from "react";
|
||||
import { createUserStore } from "../store/user-store";
|
||||
|
||||
type StoreContextType = {
|
||||
userStore: ReturnType<typeof createUserStore>;
|
||||
};
|
||||
|
||||
export const StoreContext = createContext<StoreContextType | undefined>(undefined);
|
||||
|
||||
export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
// useRef를 사용하여 렌더링마다 스토어가 재성성되는 것을 방지하고 독립적인 인스턴스를 유지
|
||||
const storeRef = useRef<StoreContextType | null>(null);
|
||||
if (storeRef.current === null) {
|
||||
storeRef.current = {
|
||||
userStore: createUserStore(),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</StoreContext.Provider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### ③ Custom Hook을 통한 사용 (`src/shared/lib/use-user-store.ts`)
|
||||
```typescript
|
||||
import { useContext } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import { StoreContext } from "../providers/store-provider";
|
||||
import { UserStore } from "../store/user-store";
|
||||
|
||||
export function useUserStore<T>(selector: (store: UserStore) => T): T {
|
||||
const storeContext = useContext(StoreContext);
|
||||
if (!storeContext) {
|
||||
throw new Error("useUserStore는 반드시 StoreProvider 내부에서 사용되어야 합니다.");
|
||||
}
|
||||
return useStore(storeContext.userStore, selector);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. TanStack Query v5를 활용한 서버 상태(Server State) 관리
|
||||
|
||||
AS 프로젝트는 비즈니스 도메인의 API와 데이터를 처리하기 위해 React Query(TanStack Query)를 적극 활용합니다.
|
||||
|
||||
### ① API 호출 정의 (`entities/fnrs-cnsltn/api/fetcher.ts`)
|
||||
```typescript
|
||||
import { client } from "@/shared/api";
|
||||
import { FnrsDetailDto } from "../model/types";
|
||||
|
||||
// client는 Axios 기반으로 공통 Interceptor가 설정된 인스턴스입니다.
|
||||
export const fetchFnrsDetail = async (docNo: string): Promise<FnrsDetailDto> => {
|
||||
const response = await client.get(`/as-fnrs-service/api/v1/fnrs-cnsltn/${docNo}`);
|
||||
return response.data;
|
||||
};
|
||||
```
|
||||
|
||||
### ② Custom Query Hook 구현 (`entities/fnrs-cnsltn/api/use-fnrs-detail.ts`)
|
||||
```typescript
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchFnrsDetail } from "./fetcher";
|
||||
|
||||
export const fnrsKeys = {
|
||||
all: ["fnrs"] as const,
|
||||
detail: (docNo: string) => [...fnrsKeys.all, "detail", docNo] as const,
|
||||
};
|
||||
|
||||
export function useFnrsDetail(docNo: string) {
|
||||
return useQuery({
|
||||
queryKey: fnrsKeys.detail(docNo),
|
||||
queryFn: () => fetchFnrsDetail(docNo),
|
||||
enabled: !!docNo,
|
||||
staleTime: 5 * 60 * 1000, // 5분 캐싱 유지
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### ③ 컴포넌트에서의 데이터 연동 (`pages/fnrs-cnsltn/ui/detail-page.tsx`)
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useFnrsDetail } from "@/entities/fnrs-cnsltn";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
interface DetailPageProps {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export function FnrsDetailPage({ params }: DetailPageProps) {
|
||||
const { data: detailData, isLoading, isError } = useFnrsDetail(params.id);
|
||||
|
||||
if (isLoading) return <Skeleton className="h-64 w-full" />;
|
||||
if (isError || !detailData) return <div>상세 데이터를 가져오는 중 에러가 발생했습니다.</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white shadow rounded-lg">
|
||||
<h2 className="text-xl font-bold mb-4">{detailData.bizNm}</h2>
|
||||
<p className="text-gray-600">예산 연도: {detailData.bgtAplyYr}년</p>
|
||||
<p className="mt-2">{detailData.bizCn}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Monorepo 공통 모듈 (`@common/*`) 사용법
|
||||
|
||||
AS 프로젝트는 모노레포 내부의 다른 앱들과 로직을 공유하기 위해 다음과 같은 패키지를 사용합니다:
|
||||
|
||||
* **`@common/network`**:
|
||||
* 공통 Axios 클라이언트 래퍼(`Client.getInstance()`)를 가져와 공통 헤더 및 환경 변수를 설정하여 프로젝트 API 클라이언트(`client.ts`)를 초기화합니다.
|
||||
* **`@common/auth`**:
|
||||
* 로그인 토큰 검증, 세션 연장 기능 등 전역 보안 절차를 제어합니다.
|
||||
* **`@common/components`**:
|
||||
* 워크스페이스 공통 UI 컴포넌트(글로벌 레이아웃 프레임, 특수 템플릿 등)를 가져와 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 중급 실습 과제
|
||||
|
||||
### 과제 내용
|
||||
* `entities/fnrs-cnsltn` 아래에 특정 재원협의 목록을 가져오는 `useFnrsList` Query Hook을 작성하세요.
|
||||
* 가져온 목록 데이터를 바탕으로 `shared/ui/table` 컴포넌트(혹은 TanStack Table)를 활용해 목록 그리드를 출력하는 `pages/fnrs-cnsltn/ui/fnrs-list-page.tsx` 페이지를 만드세요.
|
||||
* **제약 조건**: 검색 필터링(사업명 검색, 예산 신청 연도)을 React Query의 `queryKey`와 동기화하여 검색 조건 변경 시 자동으로 목록이 갱신되도록 하세요.
|
||||
@@ -0,0 +1,205 @@
|
||||
# AS 프로젝트 교육 자료: 고급 과정 (Advanced Course)
|
||||
|
||||
**대상**: 대규모 컴포넌트 설계와 복잡한 클라이언트 인터랙션을 처리해야 하는 시니어급 개발자
|
||||
**목표**: FSD 아키텍처 의존성 린팅 툴(Steiger)을 이해하고, `@dnd-kit` 기반의 결재선 드래그 앤 드롭을 구현하며, `tus-js-client`를 통한 대용량 이어올리기 및 OpenTelemetry 분산 트레이싱을 연동할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Steiger & FSD 아키텍처 아키텍처 규칙 검증
|
||||
|
||||
**Steiger**는 FSD 아키텍처 규범을 실시간으로 감시하는 파일 구조 정적 분석 린터입니다.
|
||||
|
||||
### ① FSD 핵심 위반 검출 및 조치법
|
||||
* **비정상 의존 관계 (Violation of Layer Hierarchy)**:
|
||||
* *상황*: `entities/user` 내부 컴포넌트에서 `features/auth-login` 컴포넌트를 import하려는 시도.
|
||||
* *해결*: 하위 레이어는 상위 레이어에 대해 절대 알 수 없어야 합니다. 해당 컴포넌트의 책임을 상위 레이어(`pages` 혹은 `widgets`)로 격상시키거나 하위 레이어의 성격에 맞게 분리하세요.
|
||||
* **Public API 규칙 위반 (Slicing Boundaries Bypass)**:
|
||||
* *상황*: `import { UserProfile } from "@/entities/user/ui/profile"` 형태로 직접 파일 경로로 접근하는 경우.
|
||||
* *해결*: `entities/user/index.ts`에서 `export { UserProfile } from "./ui/profile"`을 선언하고, 사용하는 곳에서는 `import { UserProfile } from "@/entities/user"` 로 Public API만 접근하도록 변경해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. `@dnd-kit`을 활용한 결재선 관리 화면 및 DnD 구현
|
||||
|
||||
결재선 관리(Approval Line Management) 화면은 드래그 앤 드롭으로 결재자 순서를 변경하며, 그리드 상에서 인라인 폼 편집을 처리하는 매우 복잡한 뷰입니다.
|
||||
|
||||
### ① 핵심 설계 요구사항
|
||||
1. **단일 행 편집 잠금**: 신규 결재선 추가 혹은 특정 행 수정 시, 해당 행을 제외한 다른 행의 입력 및 드래그 인터랙션은 모두 비활성화되어야 합니다 (`editingRowKey` 상태 관리).
|
||||
2. **드래그 정렬**: `@dnd-kit`을 사용하여 결재자 순서 변경을 UI에 즉시 반영한 뒤, **[결재선 저장]** 버튼을 통해 전체 순서를 일괄 저장합니다.
|
||||
|
||||
### ② 결재선 DnD 구현 예제 (`ui/components/sortable-apln-table.tsx`)
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { SortableAplnRow } from "./sortable-apln-row";
|
||||
|
||||
export interface IAplnMember {
|
||||
aplnId: string;
|
||||
atrzSeqNo: number;
|
||||
atrzSeComCd: string; // 검토자, 승인자 구분
|
||||
aprvrUuid: string;
|
||||
aprvrNm: string;
|
||||
}
|
||||
|
||||
export function SortableAplnTable() {
|
||||
const [items, setItems] = useState<IAplnMember[]>([
|
||||
{ aplnId: "1", atrzSeqNo: 1, atrzSeComCd: "RVW", aprvrUuid: "user-01", aprvrNm: "김검토" },
|
||||
{ aplnId: "2", atrzSeqNo: 2, atrzSeComCd: "APR", aprvrUuid: "user-02", aprvrNm: "이승인" },
|
||||
]);
|
||||
|
||||
// 편집 중인 행 키 (null이 아니면 드래그 및 타 행 편집 금지)
|
||||
const [editingRowKey, setEditingRowKey] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // 클릭 클릭 중 미세한 움직임으로 드래그 활성화 방지
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
// 편집 중일 때는 드래그를 차단합니다.
|
||||
if (editingRowKey) return;
|
||||
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setItems((prevItems) => {
|
||||
const oldIndex = prevItems.findIndex((item) => item.aplnId === active.id);
|
||||
const newIndex = prevItems.findIndex((item) => item.aplnId === over.id);
|
||||
const reordered = arrayMove(prevItems, oldIndex, newIndex);
|
||||
|
||||
// 정렬 후 시퀀스 넘버(atrzSeqNo)를 다시 인덱스 기준으로 갱신합니다.
|
||||
return reordered.map((item, index) => ({
|
||||
...item,
|
||||
atrzSeqNo: index + 1,
|
||||
}));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-10">정렬</th>
|
||||
<th>순서</th>
|
||||
<th>구분</th>
|
||||
<th>결재자</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<SortableContext
|
||||
items={items.map((item) => item.aplnId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<SortableAplnRow
|
||||
key={item.aplnId}
|
||||
item={item}
|
||||
isEditing={editingRowKey === item.aplnId}
|
||||
isDisableDrag={!!editingRowKey} // 편집 모드가 켜져 있으면 전체 드래그 비활성화
|
||||
onEditStart={() => setEditingRowKey(item.aplnId)}
|
||||
onEditCancel={() => setEditingRowKey(null)}
|
||||
onEditSave={(updated) => {
|
||||
setItems((prev) =>
|
||||
prev.map((p) => (p.aplnId === item.aplnId ? { ...p, ...updated } : p))
|
||||
);
|
||||
setEditingRowKey(null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</tbody>
|
||||
</table>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 대용량 파일 분할 업로드 (Tus Protocol)
|
||||
|
||||
일반적인 HTTP POST 업로드는 대용량 파일 전송 중 대역폭 변화나 일시적인 네트워크 끊김이 발생할 경우 처음부터 다시 업로드해야 하는 심각한 취약점이 있습니다.
|
||||
AS 프로젝트는 이어올리기가 가능한 **Tus Protocol** 규격의 파일 업로더인 `tus-js-client`를 활용합니다.
|
||||
|
||||
```typescript
|
||||
import * as tus from "tus-js-client";
|
||||
|
||||
export function uploadFileWithTus(file: File, onProgress: (pct: number) => void) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: `${process.env.NEXT_PUBLIC_API_URL}/files/upload`,
|
||||
retryDelays: [0, 1000, 3000, 5000], // 연결 실패 시 재시도 간격
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
filetype: file.type,
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Tus upload failed:", error);
|
||||
reject(error);
|
||||
},
|
||||
onProgress: (bytesSent, bytesTotal) => {
|
||||
const percentage = Math.round((bytesSent / bytesTotal) * 100);
|
||||
onProgress(percentage);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 서버에서 부여받은 최종 파일 접근 경로 또는 고유 ID 획득
|
||||
console.log("Upload complete:", upload.url);
|
||||
resolve(upload.url || "");
|
||||
},
|
||||
});
|
||||
|
||||
// 업로드 개시
|
||||
upload.start();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. OpenTelemetry를 활용한 분산 트레이싱 (APM)
|
||||
|
||||
AS 프로젝트에는 애플리케이션의 엔드 투 엔드(End-to-End) 요청 성능 분석과 응답 속도 지연 병목 지점 감시를 위해 **OpenTelemetry(OTel)**가 연동되어 있습니다.
|
||||
|
||||
* `instrumentation.ts` : Next.js 서버 사이드 API 요청 및 서버 렌더링에 대한 Span을 생성하고 익스포터로 전송합니다.
|
||||
* `instrumentation-client.ts` : 브라우저 클라이언트 사이드 환경에서 API 호출(`fetch`, `xhr`) 및 주요 사용자 Interaction에 대해 브라우저 환경 성능 Span을 계측합니다.
|
||||
|
||||
### OTel 동작 핵심 컨셉
|
||||
1. **Context Propagation**: 클라이언트에서 서버로 HTTP 요청을 보낼 때 Trace Context Header(`traceparent`)를 실어 보냄으로써, 브라우저에서의 동작과 백엔드 API 서비스에서의 병목 지점 간의 연관 관계를 하나로 통합해서 관찰할 수 있게 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 고급 실습 과제
|
||||
|
||||
### 과제 내용
|
||||
* `ui/components/sortable-apln-table.tsx`를 확장하여, 새로운 결재선을 리스트 맨 밑에 추가하는 **[행 추가]** 기능을 구현하세요.
|
||||
* **행 추가 시** 새로 생성된 행은 즉시 **편집 상태**(`editingRowKey = 'new-temp'`)여야 하며, 사용자가 저장을 누르면 REST API(`POST /api/v1/elapr-apln`)를 호출하고 임시 키 대신 서버에서 생성된 진짜 ID로 대체되어야 합니다.
|
||||
* 사용자가 입력을 다 채우지 않고 취소를 누르면, 해당 추가되었던 행이 목록에서 사라져야 합니다.
|
||||
* **제약 조건**: 모든 API 통신은 TanStack Query의 Mutation(`useMutation`) 및 캐시 무효화(`invalidateQueries`)를 통해 완전한 동기화 흐름을 가지도록 개발하세요.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Steiger 사용법 및 실제 구성 가이드
|
||||
|
||||
본 문서는 `as` 프로젝트에서 FSD(Feature Sliced Design) 아키텍처 의존성 및 구조 검증을 위해 사용되는 **Steiger** 린터의 실제 구성과 사용법을 다룹니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Steiger 개요
|
||||
* **Steiger**는 FSD 아키텍처의 철학(단방향 의존성 흐름, Public API 접근 등)이 코드베이스에서 물리적으로 올바르게 지켜지고 있는지를 검증하는 FSD 전용 정적 분석 린터입니다.
|
||||
* 일반적인 코드 포맷터나 문법 린터(ESLint, Prettier)와 달리, **파일 시스템의 폴더 구조와 파일 간의 import 관계**를 추적하여 아키텍처 위반 사항을 진단합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 프로젝트 내 실제 구성 및 실행 방법
|
||||
|
||||
### ① 실행 스크립트 정의
|
||||
`apps/as/package.json` 파일에 다음과 같이 린트 스크립트가 내장되어 있습니다.
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"lint:fsd": "steiger ."
|
||||
}
|
||||
```
|
||||
|
||||
### ② 린트 실행 명령어
|
||||
터미널에서 아래 명령을 실행하여 `as` 애플리케이션의 FSD 구조를 검사합니다.
|
||||
|
||||
```bash
|
||||
# 모노레포 루트 디렉토리 기준
|
||||
pnpm --filter as lint:fsd
|
||||
|
||||
# 또는 apps/as 디렉토리 기준
|
||||
pnpm run lint:fsd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Steiger의 기본 동작 원리
|
||||
|
||||
별도의 설정 파일(`steiger.config.ts`)이 프로젝트에 생성되어 있지 않은 경우, Steiger는 아래와 같이 동작합니다.
|
||||
|
||||
1. **기본 FSD 규칙셋(Standard Ruleset) 자동 채택**: `@feature-sliced/steiger-plugin`의 권장(recommended) 규칙들을 메모리에 로드하여 적용합니다.
|
||||
2. **프로젝트 루트 탐색**: 실행 인자로 전달된 경로(여기서는 현재 경로인 `.`) 아래의 모든 디렉토리를 순회하며 `src/` 내부의 레이어 구조를 감지합니다.
|
||||
3. **의존성 트리 분석**: `shared` ➔ `entities` ➔ `features` ➔ `widgets` ➔ `pages` ➔ `app` 순으로 올라가는 레이어 위계를 확인하여, 역참조(예: `entities`가 `features`를 가져옴)가 발생하는 위치를 찾아냅니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Steiger가 감시하는 주요 FSD 아키텍처 규칙
|
||||
|
||||
Steiger가 실시간으로 코드를 분석하며 잡아내는 대표적인 아키텍처 오류들은 다음과 같습니다.
|
||||
|
||||
| 규칙명 (Rule ID) | 설명 및 예시 |
|
||||
| :--- | :--- |
|
||||
| **`fsd/no-public-api-bypass`** | 슬라이스의 Public API(`index.ts`)를 통하지 않고 내부 파일 경로를 직접 임포트할 때 감지합니다.<br>➔ *오류*: `import { Form } from '@/features/auth/ui/form'`<br>➔ *해결*: `import { Form } from '@/features/auth'` |
|
||||
| **`fsd/no-forbidden-imports`** | 상위 레이어가 하위 레이어의 모듈을 가져와 상호 의존하거나 순환 참조가 발생할 때 감지합니다.<br>➔ *오류*: `entities/user` 슬라이스 안에서 `widgets/header`를 참조하는 경우 |
|
||||
| **`fsd/no-segmentless-slices`** | 슬라이스 디렉토리 바로 아래에 세그먼트(`ui`, `model`, `api` 등) 없이 코드 파일이 직접 위치할 때 감지합니다. |
|
||||
|
||||
---
|
||||
|
||||
## 5. 알려진 팁 및 문제 해결 (tsconfck 관련 오류)
|
||||
|
||||
일부 환경에서 `steiger .`를 실행할 때, 내부 컴파일러 설정 검사기인 `tsconfck` 라이브러리가 `.eslintrc.json`과 같은 설정 파일 내의 `"extends": "next/core-web-vitals"` 속성을 TypeScript 설정 파일의 `extends`로 오인하여 모듈 해석 오류(`MODULE_NOT_FOUND`)를 유발하는 경우가 발생할 수 있습니다.
|
||||
|
||||
이와 같은 환경적 이슈가 발생할 경우 아래의 우회법을 참고하십시오.
|
||||
|
||||
1. **빌드 파이프라인에서 파일 범위 한정**:
|
||||
Steiger 검사 시 설정 파일들이 불필요하게 스캔 대상에 포함되지 않도록 빌드 린트 파이프라인 설정을 조정하거나, 대상 소스 코드 경로(`src`)를 명확하게 지정하여 탐색 범위를 제한합니다.
|
||||
2. **steiger 설정 파일 커스텀**:
|
||||
프로젝트의 필요에 따라 `steiger.config.ts` 파일과 해당 플러그인을 프로젝트 devDependencies에 명시적으로 추가하여 `ignores` 옵션으로 `.eslintrc.json` 등 설정 파싱 에러를 유발하는 대상을 제외하도록 설정하여 운영합니다.
|
||||
Reference in New Issue
Block a user