From 17bc89b35c7252b16a97277b164ac8bbe90a7098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 20 Mar 2026 15:00:31 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20[guide]=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EA=B0=9C=EB=B0=9C=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=206=EA=B0=9C=20=EC=B1=95=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EB=AA=A8=EB=93=88=EB=B6=84=EB=A6=AC,=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=ED=8C=A8=ED=84=B4,=20=ED=8F=BC=EA=B2=80?= =?UTF-8?q?=EC=A6=9D,=20=EB=AA=A8=EB=B0=94=EC=9D=BC,=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=B2=98=EB=A6=AC,=20=EB=B0=B0=ED=8F=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sam-docs/frontend/v1/00-overview.md | 8 +- sam-docs/frontend/v1/10-module-separation.md | 142 +++++++++++++ sam-docs/frontend/v1/11-page-patterns.md | 202 +++++++++++++++++++ sam-docs/frontend/v1/12-form-validation.md | 153 ++++++++++++++ sam-docs/frontend/v1/13-mobile-patterns.md | 168 +++++++++++++++ sam-docs/frontend/v1/14-error-handling.md | 192 ++++++++++++++++++ sam-docs/frontend/v1/15-deployment.md | 172 ++++++++++++++++ 7 files changed, 1036 insertions(+), 1 deletion(-) create mode 100644 sam-docs/frontend/v1/10-module-separation.md create mode 100644 sam-docs/frontend/v1/11-page-patterns.md create mode 100644 sam-docs/frontend/v1/12-form-validation.md create mode 100644 sam-docs/frontend/v1/13-mobile-patterns.md create mode 100644 sam-docs/frontend/v1/14-error-handling.md create mode 100644 sam-docs/frontend/v1/15-deployment.md diff --git a/sam-docs/frontend/v1/00-overview.md b/sam-docs/frontend/v1/00-overview.md index 1b6c66cf..f6cdc73e 100644 --- a/sam-docs/frontend/v1/00-overview.md +++ b/sam-docs/frontend/v1/00-overview.md @@ -1,7 +1,7 @@ # SAM ERP 프론트엔드 개발 가이드 > **대상**: SAM ERP 프론트엔드 신규/기존 개발자 -> **최종 업데이트**: 2026-03-13 +> **최종 업데이트**: 2026-03-20 --- @@ -19,6 +19,12 @@ | [07-hooks.md](./07-hooks.md) | 공통 Hooks | | [08-utilities.md](./08-utilities.md) | 유틸리티 함수 (포맷터, URL 빌더, 인쇄 등) | | [09-coding-conventions.md](./09-coding-conventions.md) | 코딩 컨벤션 및 필수 규칙 | +| [10-module-separation.md](./10-module-separation.md) | 멀티테넌트 모듈 분리 아키텍처 | +| [11-page-patterns.md](./11-page-patterns.md) | 페이지 패턴 (리스트, 상세/폼, 검색 모달) | +| [12-form-validation.md](./12-form-validation.md) | 폼 검증 (Zod 스키마, 트러블슈팅) | +| [13-mobile-patterns.md](./13-mobile-patterns.md) | 모바일 반응형 패턴 | +| [14-error-handling.md](./14-error-handling.md) | 에러 핸들링 (ApiError, Next.js 에러 파일) | +| [15-deployment.md](./15-deployment.md) | 배포 가이드 (Vercel) | --- diff --git a/sam-docs/frontend/v1/10-module-separation.md b/sam-docs/frontend/v1/10-module-separation.md new file mode 100644 index 00000000..53ce5c23 --- /dev/null +++ b/sam-docs/frontend/v1/10-module-separation.md @@ -0,0 +1,142 @@ +# 10. 모듈 분리 아키텍처 + +> 대상: 프론트엔드 개발자 +> 최종 업데이트: 2026-03-20 + +--- + +## 목차 + +| 번호 | 항목 | +|------|------| +| 10.1 | [개요](#101-개요) | +| 10.2 | [모듈 구조](#102-모듈-구조) | +| 10.3 | [핵심 패턴: moduleAware](#103-핵심-패턴-moduleaware) | +| 10.4 | [라우트 가드](#104-라우트-가드) | +| 10.5 | [크로스 모듈 임포트 규칙](#105-크로스-모듈-임포트-규칙) | +| 10.6 | [적용 영역](#106-적용-영역) | + +--- + +## 10.1 개요 + +SAM ERP는 멀티테넌트 시스템으로, 테넌트별 업종(`tenant.options.industry`)에 따라 필요한 모듈만 활성화합니다. + +**핵심 원칙**: `industry` 미설정 시 모든 모듈 활성화 (기존 동작 100% 유지) + +| 테넌트 | industry | 활성 모듈 | +|--------|----------|----------| +| 경동 | `manufacturing` | production, quality | +| 주일 | `construction` | construction | +| 미설정 | - | 전체 (기존 호환) | + +--- + +## 10.2 모듈 구조 + +### 파일 구조 + +``` +src/modules/ + types.ts # ModuleId, TenantIndustry, MODULE_REGISTRY + tenant-config.ts # INDUSTRY_MODULE_MAP (업종별 활성 모듈 매핑) + index.ts # getModuleForRoute, getEnabledDashboardSections + +src/hooks/useModules.ts # React Hook +``` + +### ModuleId 목록 + +| ModuleId | 설명 | 라우트 접두사 | +|----------|------|-------------| +| `common` | 공통 ERP | - | +| `production` | 생산관리 | `/production` | +| `quality` | 품질관리 | `/quality` | +| `construction` | 시공관리 | `/business/construction` | +| `vehicle-management` | 차량관리 | `/vehicle-management` | + +### useModules Hook + +```typescript +const { isEnabled, tenantIndustry, enabledModules } = useModules(); + +// 특정 모듈 활성화 여부 확인 +if (isEnabled('production')) { + // 생산관리 관련 UI 표시 +} +``` + +--- + +## 10.3 핵심 패턴: moduleAware + +```typescript +const { isEnabled, tenantIndustry } = useModules(); +const moduleAware = !!tenantIndustry; // industry 미설정 -> false -> 전부 허용 + +if (!moduleAware) return allData; // 기존과 동일 +return filteredData; // 모듈 기반 필터링 +``` + +- `moduleAware = false` -> 필터링 없음, 모든 기능 노출 +- `moduleAware = true` -> 테넌트 업종에 맞는 모듈만 노출 + +--- + +## 10.4 라우트 가드 + +### 라우트 기반 가드 (ModuleGuard) + +`/production/*`, `/quality/*` 등 전용 라우트는 layout.tsx에서 자동 차단: + +```typescript +// src/app/[locale]/(protected)/production/layout.tsx + + {children} + +``` + +### 명시적 가드 + +공통 라우트 내 모듈 의존 페이지는 직접 체크: + +```typescript +// /sales/*/production-orders 같은 공통 라우트 내 모듈 의존 페이지 +if (tenantIndustry && !isEnabled('production')) { + return
생산관리 모듈이 활성화되어 있지 않습니다.
; +} +``` + +--- + +## 10.5 크로스 모듈 임포트 규칙 + +| 방향 | 허용 | 비고 | +|------|------|------| +| Common -> Tenant | 직접 import 금지 | `// MODULE_SEPARATION_OK` 주석 + 공유 래퍼만 허용 | +| Tenant -> Common | 자유 | - | +| Tenant -> Tenant | 금지 | dynamic import만 허용 | + +검증 스크립트: `scripts/verify-module-separation.sh` + +### MODULE.md 경계 마커 + +각 테넌트 모듈 디렉토리에 `MODULE.md` 파일로 모듈 경계 문서화: + +``` +src/components/production/MODULE.md +src/components/quality/MODULE.md +src/components/business/construction/MODULE.md +src/components/vehicle-management/MODULE.md +``` + +--- + +## 10.6 적용 영역 + +| 영역 | 적용 방식 | +|------|----------| +| CEO 대시보드 | `moduleAware` 패턴으로 섹션 필터링 | +| 사이드바 메뉴 | `isEnabled()` 체크로 메뉴 숨김 | +| 라우트 접근 | ModuleGuard 또는 명시적 가드 | +| API 호출 | 비활성 모듈 관련 API 호출 차단 | diff --git a/sam-docs/frontend/v1/11-page-patterns.md b/sam-docs/frontend/v1/11-page-patterns.md new file mode 100644 index 00000000..859ccdc2 --- /dev/null +++ b/sam-docs/frontend/v1/11-page-patterns.md @@ -0,0 +1,202 @@ +# 11. 페이지 패턴 가이드 + +> 대상: 프론트엔드 개발자 +> 최종 업데이트: 2026-03-20 + +--- + +## 목차 + +| 번호 | 항목 | +|------|------| +| 11.1 | [리스트 페이지](#111-리스트-페이지) | +| 11.2 | [상세/폼 페이지](#112-상세폼-페이지) | +| 11.3 | [검색 모달](#113-검색-모달) | +| 11.4 | [공통 기능은 공통 컴포넌트에서](#114-공통-기능은-공통-컴포넌트에서) | + +--- + +## 11.1 리스트 페이지 + +### UniversalListPage (권장) + +대부분의 리스트 페이지는 `UniversalListPage`로 구성합니다. + +```typescript +const config: UniversalListConfig = { + // 검색 (클라이언트 사이드 필터링) + clientSideFiltering: true, + searchFilter: (item, searchValue) => { + const q = searchValue.toLowerCase(); + return item.name.toLowerCase().includes(q) || item.code.toLowerCase().includes(q); + }, + searchPlaceholder: '이름, 코드 검색...', + + // 컬럼 정의 + columns: [...], + + // 모바일 카드 + renderMobileCard: (item) => , +}; +``` + +### IntegratedListTemplateV2 필수 적용 항목 + +IntegratedListTemplateV2 사용 시 다음 10가지를 반드시 확인: + +| # | 항목 | 필수 | +|---|------|------| +| 1 | `useColumnSettings` + `ColumnSettingsPopover` | O | +| 2 | `searchValue` + `onSearchChange` (or clientSideFiltering) | O | +| 3 | 체크박스 `Set` 패턴 | O | +| 4 | 페이지네이션 | O | +| 5 | `renderMobileCard` | O | +| 6 | 테이블 행 클릭 -> 상세 이동 | O | +| 7 | 헤더 레이아웃 (StatCards, 필터) | O | +| 8 | `tableHeaderActions` (테이블 내 필터) | 선택 | +| 9 | `filterConfig` (필터 설정) | 선택 | +| 10 | 탭 구성 | 선택 | + +### 검색 패턴 (필수) + +```typescript +// ✅ 올바른 패턴 -- UniversalListPage + clientSideFiltering +const config: UniversalListConfig = { + clientSideFiltering: true, + searchFilter: (item, searchValue) => { + const q = searchValue.toLowerCase(); + return item.name.toLowerCase().includes(q); + }, +}; +// 데이터를 한 번 API로 로드 -> 검색은 메모리에서 즉시 필터링 -> 깜빡임 없음 +``` + +```typescript +// ❌ 금지 패턴 -- IntegratedListTemplateV2에 onSearchChange 직접 연결 + { setSearchTerm(q); }} +/> +// 키입력마다 state 변경 -> re-render -> 화면 깜빡임, 한글 조합 불가 +``` + +--- + +## 11.2 상세/폼 페이지 + +### 라우팅 규칙 + +- 별도 `/new` 경로 금지 -> `?mode=new` 쿼리파라미터 사용 +- 별도 `/edit` 경로 금지 -> `?mode=edit` 쿼리파라미터 사용 + +```typescript +// ✅ 올바른 패턴 +router.push('/some-page?mode=new'); +router.push('/some-page/123?mode=edit'); + +// ❌ 금지 패턴 +router.push('/some-page/new'); +router.push('/some-page/123/edit'); +``` + +### 페이지 구조 + +```typescript +export default function SomePage() { + const searchParams = useSearchParams(); + const mode = searchParams.get('mode'); + + if (mode === 'new') return ; + return ; +} +``` + +### 헤더 표준 + +| 위치 | 요소 | +|------|------| +| 상단 좌측 | 페이지 제목 (`

`) | +| 상단 우측 | `<- 목록으로` 링크 | + +### 하단 Sticky 액션 바 (필수) + +```typescript +
+
+ + +
+
+``` + +| 모드 | 좌측 | 우측 | +|------|------|------| +| 등록 (new) | 취소 | 저장 | +| 상세 (view) | 취소 (목록으로) | 수정 | +| 수정 (edit) | 취소 | 저장 | + +### 폼 레이아웃 + +- Card 내부에 버튼 넣지 않음 -> sticky 하단 바 사용 +- 아이콘 포함: 취소(`X`), 저장(`Save`), 수정(`Pencil`) + +--- + +## 11.3 검색 모달 + +### SearchableSelectionModal (필수) + +검색+선택 기능이 필요한 모달은 반드시 `SearchableSelectionModal`을 사용합니다. + +```typescript +// ✅ 올바른 패턴 + + open={open} + onOpenChange={setOpen} + title="거래처 선택" + fetchData={async () => { + const result = await getVendors(); + return result.success ? result.data : []; + }} + columns={[ + { key: 'vendorName', label: '거래처명' }, + { key: 'businessNumber', label: '사업자번호' }, + ]} + onSelect={(vendor) => { + setSelectedVendor(vendor); + }} + searchFilter={(item, query) => + item.vendorName.includes(query) || item.businessNumber?.includes(query) + } +/> +``` + +```typescript +// ❌ 금지 패턴 -- Dialog + Table 직접 조합 + + + ...
+
+``` + +--- + +## 11.4 공통 기능은 공통 컴포넌트에서 + +리스트 페이지 전체에 적용해야 하는 기능은 개별 페이지 수정 금지. 반드시 공통 레이어에서 처리. + +| 기능 | 수정 위치 | 개별 페이지 수정 | +|------|----------|----------------| +| 검색 상태 보존 | `UniversalListPage` | 금지 | +| 검색 X(클리어) 버튼 | `SearchFilter` + `IntegratedListTemplateV2` | 금지 | +| 검색 디바운스 | `UniversalListPage` 내부 300ms | 금지 | +| 체크박스 선택 | `IntegratedListTemplateV2` | 금지 | +| 페이지네이션 | `IntegratedListTemplateV2` | 금지 | +| 모바일 카드/인피니티 | `IntegratedListTemplateV2` | 금지 | +| 컬럼 설정 | `useColumnSettings` + `ColumnSettingsPopover` | 금지 | + +**원칙**: "26개 페이지에 하나씩 적용" -> 잘못된 접근. "공통 1곳 수정 -> 전체 자동 적용" -> 올바른 접근. diff --git a/sam-docs/frontend/v1/12-form-validation.md b/sam-docs/frontend/v1/12-form-validation.md new file mode 100644 index 00000000..f2ec76ee --- /dev/null +++ b/sam-docs/frontend/v1/12-form-validation.md @@ -0,0 +1,153 @@ +# 12. 폼 검증 (Zod) + +> 대상: 프론트엔드 개발자 +> 최종 업데이트: 2026-03-20 + +--- + +## 목차 + +| 번호 | 항목 | +|------|------| +| 12.1 | [적용 범위](#121-적용-범위) | +| 12.2 | [기본 패턴](#122-기본-패턴) | +| 12.3 | [트러블슈팅](#123-트러블슈팅) | +| 12.4 | [체크리스트](#124-체크리스트) | + +--- + +## 12.1 적용 범위 + +| 대상 | Zod 적용 | +|------|---------| +| 신규 폼 | 필수 | +| 기존 폼 (정상 작동 중) | 건드리지 않음 | +| 단순 필드 1-2개 인라인 폼 | 불필요 (오버엔지니어링) | +| 신규 서버 액션 API 응답 | 선택적 | + +--- + +## 12.2 기본 패턴 + +### 스키마 정의 + 타입 추출 + +```typescript +import { z } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +// 1. 스키마 정의 (타입 + 검증 한 번에) +const formSchema = z.object({ + itemName: z.string().min(1, '품목명을 입력하세요'), + quantity: z.number().min(1, '1 이상 입력하세요'), + status: z.enum(['active', 'inactive']), + memo: z.string().optional(), +}); + +// 2. 스키마에서 타입 추출 (별도 interface 정의 불필요) +type FormData = z.infer; + +// 3. useForm에 zodResolver 연결 +const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { itemName: '', quantity: 1, status: 'active' }, +}); +``` + +### 규칙 + +- **스키마 위치**: 컴포넌트 파일 상단 또는 같은 디렉토리의 `schema.ts` +- **타입 추출**: `z.infer` 사용, 별도 `interface` 중복 정의 금지 +- **에러 메시지**: 한글로 작성 (사용자에게 직접 표시됨) +- **`as` 캐스트 지양**: Zod 스키마로 타입이 보장되므로 불필요 + +--- + +## 12.3 트러블슈팅 + +### 문제 1: 에러 메시지가 영어로 나옴 + +```typescript +// ❌ 기본 에러 메시지 (영어) +const schema = z.object({ + name: z.string().min(1), +}); +// -> "String must contain at least 1 character(s)" + +// ✅ 한글 메시지 명시 +const schema = z.object({ + name: z.string().min(1, '이름을 입력하세요'), +}); +``` + +### 문제 2: 불필요한 필드까지 검증됨 + +수정 폼에서 등록 전용 필드가 검증되는 경우: + +```typescript +// ✅ .omit()으로 불필요 필드 제외 +const editSchema = createSchema.omit({ password: true }); +``` + +### 문제 3: 조건부 필수 필드 + +특정 값 선택 시에만 다른 필드가 필수인 경우: + +```typescript +// ✅ .superRefine() 사용 +const schema = z.object({ + type: z.enum(['individual', 'company']), + companyName: z.string().optional(), +}).superRefine((data, ctx) => { + if (data.type === 'company' && !data.companyName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: '법인명을 입력하세요', + path: ['companyName'], + }); + } +}); +``` + +### 문제 4: .omit() 후 refinement 소실 + +`.omit()`은 `.refine()`, `.superRefine()` 등 체이닝된 검증을 제거합니다. + +```typescript +// ❌ refinement가 사라짐 +const base = z.object({ a: z.string(), b: z.string() }) + .refine((d) => d.a !== d.b, { message: 'a와 b는 달라야 합니다' }); +const partial = base.omit({ b: true }); // refine 소실! + +// ✅ .omit() 후 refinement 재적용 +const partial = base.omit({ b: true }); +const withRefine = partial.refine(...); // 필요 시 다시 추가 +``` + +### 문제 5: 폼 필드명과 스키마 키 불일치 + +```typescript +// ❌ 폼에서는 camelCase, 스키마에서는 snake_case +const schema = z.object({ item_name: z.string() }); +// useForm에서 register('itemName') -> 검증 안 됨 + +// ✅ 동일한 키 사용 +const schema = z.object({ itemName: z.string() }); +``` + +--- + +## 12.4 체크리스트 + +신규 폼 작성 시 확인: + +| # | 항목 | +|---|------| +| 1 | 스키마 정의 완료 (모든 필드 포함) | +| 2 | `z.infer`로 타입 추출 (별도 interface 없음) | +| 3 | `zodResolver` 연결 | +| 4 | 에러 메시지 한글 작성 | +| 5 | 조건부 필수 -> `.superRefine()` 사용 | +| 6 | 수정 폼 -> `.omit()` 적용 시 refinement 재확인 | +| 7 | 스키마 키와 폼 필드명 일치 확인 | +| 8 | `as` 캐스트 제거 | diff --git a/sam-docs/frontend/v1/13-mobile-patterns.md b/sam-docs/frontend/v1/13-mobile-patterns.md new file mode 100644 index 00000000..629195f9 --- /dev/null +++ b/sam-docs/frontend/v1/13-mobile-patterns.md @@ -0,0 +1,168 @@ +# 13. 모바일 반응형 패턴 + +> 대상: 프론트엔드 개발자 +> 최종 업데이트: 2026-03-20 + +--- + +## 목차 + +| 번호 | 항목 | +|------|------| +| 13.1 | [대상 디바이스](#131-대상-디바이스) | +| 13.2 | [그리드 레이아웃](#132-그리드-레이아웃) | +| 13.3 | [테이블 대응](#133-테이블-대응) | +| 13.4 | [모달/다이얼로그](#134-모달다이얼로그) | +| 13.5 | [버튼/액션](#135-버튼액션) | +| 13.6 | [텍스트 처리](#136-텍스트-처리) | +| 13.7 | [체크리스트](#137-체크리스트) | + +--- + +## 13.1 대상 디바이스 + +| 디바이스 | 너비 | Tailwind 접두사 | +|---------|------|----------------| +| Galaxy Z Fold 5 (접힌) | 344px | 기본 (접두사 없음) | +| 일반 모바일 | 375-430px | 기본 | +| 커스텀 브레이크포인트 | 480px | `xs:` | +| 태블릿 | 768px | `md:` | +| 데스크탑 | 1024px+ | `lg:` | + +커스텀 브레이크포인트 `xs` (480px)는 `tailwind.config.ts`에 정의: + +```typescript +// tailwind.config.ts +theme: { + screens: { + xs: '480px', + // sm, md, lg, xl 기본값 유지 + } +} +``` + +--- + +## 13.2 그리드 레이아웃 + +**모바일 퍼스트**: 항상 `grid-cols-1`에서 시작, 위로 확장 + +```typescript +// ✅ 올바른 패턴 +
+ +// ❌ 데스크탑 퍼스트 +
+``` + +### 폼 레이아웃 예시 + +```typescript +// 기본 정보 폼 (4컬럼 -> 모바일 1컬럼) +
+
견적번호
+
접수일
+
수주처
+
현장명
+
+``` + +--- + +## 13.3 테이블 대응 + +### 방법 1: IntegratedListTemplateV2 모바일 카드 (권장) + +```typescript + ( + + )} +/> +``` + +### 방법 2: 가로 스크롤 + +```typescript +
+ + {/* 테이블 내용 */} +
+
+``` + +### 방법 3: 컬럼 숨김 + +```typescript +비고 +{item.memo} +``` + +--- + +## 13.4 모달/다이얼로그 + +```typescript +// ✅ 모바일에서 화면 벗어나지 않게 + +``` + +모바일에서 모달 내 폼: +- 필드를 1컬럼으로 쌓기 +- 하단 버튼은 `flex-col` 또는 `flex-wrap` + +--- + +## 13.5 버튼/액션 + +```typescript +// ✅ 모바일: 아이콘만, 태블릿+: 아이콘+텍스트 + +``` + +버튼 그룹 래핑: + +```typescript +
+ + +
+``` + +--- + +## 13.6 텍스트 처리 + +| 상황 | 클래스 | +|------|--------| +| 한 줄 말줄임 | `truncate` | +| 여러 줄 말줄임 | `line-clamp-2` | +| 줄바꿈 방지 (한글) | `break-keep` | +| 긴 단어 강제 줄바꿈 | `break-all` | +| 반응형 폰트 | `text-sm md:text-base` | + +--- + +## 13.7 체크리스트 + +새 페이지/컴포넌트 작성 시: + +| # | 항목 | +|---|------| +| 1 | 그리드: `grid-cols-1`에서 시작하는가? | +| 2 | 테이블: 모바일 카드 또는 가로 스크롤 적용했는가? | +| 3 | 모달: `max-w-[calc(100vw-2rem)]` 적용했는가? | +| 4 | 버튼: 모바일 아이콘만/데스크탑 아이콘+텍스트 패턴인가? | +| 5 | 긴 텍스트: `truncate` 또는 `line-clamp` 처리했는가? | +| 6 | Galaxy Fold (344px) 너비에서 깨지지 않는가? | +| 7 | 줌 방지: input `font-size` 16px 이상인가? | diff --git a/sam-docs/frontend/v1/14-error-handling.md b/sam-docs/frontend/v1/14-error-handling.md new file mode 100644 index 00000000..2a553c3a --- /dev/null +++ b/sam-docs/frontend/v1/14-error-handling.md @@ -0,0 +1,192 @@ +# 14. 에러 핸들링 + +> 대상: 프론트엔드 개발자 +> 최종 업데이트: 2026-03-20 + +--- + +## 목차 + +| 번호 | 항목 | +|------|------| +| 14.1 | [API 에러 핸들러](#141-api-에러-핸들러) | +| 14.2 | [에러 클래스](#142-에러-클래스) | +| 14.3 | [컴포넌트에서 에러 처리](#143-컴포넌트에서-에러-처리) | +| 14.4 | [Next.js 에러 파일](#144-nextjs-에러-파일) | +| 14.5 | [에러 표시 패턴](#145-에러-표시-패턴) | + +--- + +## 14.1 API 에러 핸들러 + +`src/lib/api/error-handler.ts`에서 HTTP 상태별 처리: + +| 상태코드 | 처리 | 동작 | +|---------|------|------| +| 401 | 인증 만료 | `/login`으로 리다이렉트 | +| 403 | 권한 없음 | ApiError throw | +| 400 + `duplicate_id` | 품목코드 중복 | DuplicateCodeError throw | +| 422 | Validation 에러 | 상세 필드 에러 포함 ApiError throw | +| 기타 | 일반 에러 | ApiError throw | + +### 422 Validation 에러 폴백 + +API 응답 포맷이 2가지 존재하므로 폴백 처리: + +```typescript +// data.errors (Laravel 기본) || data.error.details (커스텀 포맷) +const validationErrors = data.errors || data.error?.details; +``` + +이 폴백은 다음 3곳에 동일하게 적용: +- `error-handler.ts` (Server Action 경로) +- `client.ts` (클라이언트 API 경로) +- `index.ts` (직접 fetch 경로) + +--- + +## 14.2 에러 클래스 + +### ApiError + +```typescript +class ApiError extends Error { + constructor( + public status: number, + public message: string, + public errors?: Record // 필드별 에러 메시지 + ) {} +} +``` + +### DuplicateCodeError + +```typescript +class DuplicateCodeError extends ApiError { + constructor( + public message: string, + public duplicateId: number, + public duplicateCode: string + ) {} +} +``` + +### getErrorMessage 유틸리티 + +```typescript +import { getErrorMessage } from '@/lib/api/error-handler'; + +// 에러 객체에서 사용자 친화적 메시지 추출 +const message = getErrorMessage(error); +// ApiError: "[422] 입력값을 확인해주세요." +// Error: error.message +// unknown: "알 수 없는 오류가 발생했습니다" +``` + +--- + +## 14.3 컴포넌트에서 에러 처리 + +### 기본 패턴 (Server Action 호출) + +```typescript +try { + const result = await saveItem(formData); + if (!result.success) { + toast.error(result.error || '저장 실패'); + return; + } + toast.success('저장 완료'); +} catch (error) { + if (isNextRedirectError(error)) throw error; // redirect는 재throw + toast.error(getErrorMessage(error)); +} +``` + +### Validation 에러 상세 표시 + +```typescript +if (error instanceof ApiError && error.errors) { + const firstKey = Object.keys(error.errors)[0]; + const firstError = error.errors[firstKey]; + toast.error(`${error.message}\n${firstKey}: ${firstError[0]}`); +} +``` + +### redirect 에러 구분 + +Next.js의 `redirect()`는 내부적으로 에러를 throw하므로 catch에서 구분 필요: + +```typescript +import { isNextRedirectError } from '@/lib/utils/redirect-error'; + +catch (error) { + if (isNextRedirectError(error)) throw error; // 반드시 재throw + // 실제 에러만 처리 +} +``` + +--- + +## 14.4 Next.js 에러 파일 + +| 파일 | 용도 | 'use client' | 위치 우선순위 | +|------|------|-------------|-------------| +| `error.tsx` | 런타임 에러 경계 | 필수 | 특정 라우트 > 그룹 > locale > root | +| `not-found.tsx` | 404 페이지 | 불필요 | 특정 라우트 > locale > root | +| `global-error.tsx` | 루트 레이아웃 에러 | 필수 | root만 | +| `loading.tsx` | 로딩 상태 (Suspense) | 불필요 | 특정 라우트 > 그룹 | + +### error.tsx 필수 구조 + +```typescript +'use client'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+

오류가 발생했습니다

+

{error.message}

+ +
+ ); +} +``` + +### global-error.tsx 필수 구조 + +```typescript +'use client'; + +// 반드시 , 포함 +export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) { + return ( + + +

오류가 발생했습니다

+ + + + ); +} +``` + +--- + +## 14.5 에러 표시 패턴 + +| 상황 | 방법 | +|------|------| +| API 호출 실패 | `toast.error(getErrorMessage(error))` | +| 폼 검증 실패 | Zod + react-hook-form 자동 표시 | +| 필드별 서버 에러 | `error.errors` 파싱 후 toast | +| 치명적 에러 | `error.tsx` 에러 바운더리 | +| 네트워크 에러 | toast + 재시도 안내 | + +**금지**: `alert()`, `confirm()`, `prompt()` 사용 금지 -> `toast` (sonner) 또는 `Dialog` 사용 diff --git a/sam-docs/frontend/v1/15-deployment.md b/sam-docs/frontend/v1/15-deployment.md new file mode 100644 index 00000000..2d5ce0c0 --- /dev/null +++ b/sam-docs/frontend/v1/15-deployment.md @@ -0,0 +1,172 @@ +# 15. 배포 가이드 (Vercel) + +> 대상: 프론트엔드 개발자, DevOps +> 최종 업데이트: 2026-03-20 + +--- + +## 목차 + +| 번호 | 항목 | +|------|------| +| 15.1 | [배포 환경](#151-배포-환경) | +| 15.2 | [환경 변수](#152-환경-변수) | +| 15.3 | [빌드 설정](#153-빌드-설정) | +| 15.4 | [Puppeteer 설정](#154-puppeteer-설정) | +| 15.5 | [배포 체크리스트](#155-배포-체크리스트) | +| 15.6 | [비용 관리](#156-비용-관리) | + +--- + +## 15.1 배포 환경 + +| 환경 | 브랜치 | URL | 용도 | +|------|--------|-----|------| +| Development | `develop` | localhost:3000 | 로컬 개발 | +| Stage | `stage` | stage.xxx.com | QA/테스트 | +| Production | `main` | app.xxx.com | 실서비스 | + +환경 구분은 `NEXT_PUBLIC_APP_ENV` 환경변수로 판별: + +```typescript +const env = process.env.NEXT_PUBLIC_APP_ENV; // 'local' | 'development' | 'staging' | 'production' +``` + +--- + +## 15.2 환경 변수 + +### 필수 환경 변수 + +| 변수 | 설명 | 예시 | +|------|------|------| +| `NEXT_PUBLIC_API_URL` | 백엔드 API 주소 | `https://api.xxx.com` | +| `NEXT_PUBLIC_APP_ENV` | 실행 환경 | `production` | +| `JWT_SECRET` | JWT 토큰 시크릿 | (보안) | + +### Vercel 설정 방법 + +1. Vercel Dashboard -> Settings -> Environment Variables +2. 환경별(Production/Preview/Development) 분리 설정 +3. 민감 정보(JWT_SECRET 등)는 Encrypted로 저장 + +--- + +## 15.3 빌드 설정 + +### vercel.json + +```json +{ + "functions": { + "app/**/*.ts": { + "memory": 1024, + "maxDuration": 30 + } + }, + "regions": ["icn1"] +} +``` + +| 설정 | 값 | 설명 | +|------|-----|------| +| memory | 1024MB | PDF 생성 등 무거운 작업 대응 | +| maxDuration | 30초 | 함수 실행 타임아웃 | +| regions | icn1 | 서울 리전 (한국 서비스) | + +### TypeScript strict mode + +Vercel 빌드 시 `tsc --noEmit`이 실행되므로 타입 에러가 있으면 배포 실패: + +```bash +# 배포 전 로컬에서 확인 +npx tsc --noEmit +``` + +--- + +## 15.4 Puppeteer 설정 + +PDF 생성(견적서, 거래명세서 등)을 위한 Puppeteer 환경별 분기: + +```typescript +// 로컬: 시스템 Chrome 사용 +// Vercel: puppeteer-core + @sparticuz/chromium (경량) + +const browser = await (isVercel + ? puppeteerCore.launch({ + args: chromium.args, + executablePath: await chromium.executablePath(), + headless: chromium.headless, + }) + : puppeteer.launch({ headless: true }) +); +``` + +### 패키지 구성 + +```json +{ + "dependencies": { + "puppeteer-core": "^x.x.x", + "@sparticuz/chromium": "^x.x.x" + }, + "devDependencies": { + "puppeteer": "^x.x.x" + } +} +``` + +- `puppeteer`: 로컬 개발용 (devDependencies) +- `puppeteer-core` + `@sparticuz/chromium`: Vercel 배포용 (dependencies) + +--- + +## 15.5 배포 체크리스트 + +### 배포 전 + +| # | 항목 | +|---|------| +| 1 | `npx tsc --noEmit` 에러 없음 | +| 2 | `npm run build` 성공 | +| 3 | 환경 변수 Vercel에 설정 완료 | +| 4 | 백엔드 CORS에 Vercel 도메인 추가 | +| 5 | API URL이 올바른 환경을 가리키는지 확인 | + +### 배포 후 + +| # | 항목 | +|---|------| +| 1 | 로그인 정상 동작 (쿠키/토큰) | +| 2 | API 호출 정상 (CORS 에러 없음) | +| 3 | PDF 생성 정상 (Puppeteer) | +| 4 | 모바일 접속 확인 | + +### 백엔드 CORS 설정 + +```php +// Laravel: config/cors.php +'allowed_origins' => [ + 'https://your-app.vercel.app', + 'https://your-custom-domain.com', +], +``` + +--- + +## 15.6 비용 관리 + +### 예상 비용 (50개 회사 기준) + +| 항목 | 월 비용 | +|------|--------| +| Serverless Functions | ~$15-20 | +| Bandwidth | ~$5-8 | +| 합계 | ~$22-28 | + +### 비용 절감 전략 + +- **Edge Middleware**: 인증 체크 등 가벼운 로직을 Edge에서 처리 (Functions 비용 95% 절감 가능) +- **정적 페이지 최적화**: ISR(Incremental Static Regeneration) 적용 가능한 페이지 식별 +- **이미지 최적화**: `next/image` 활용으로 Bandwidth 절감