diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-05 20:15:42 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-05 20:15:42 +0900 |
| commit | b191144ec07c2f7eb9ad33ea6f2d7e31b6e00fce (patch) | |
| tree | 8f3665d022a71233630d5ca04c87c3674250df2c | |
| parent | 551129656039aae409b3af51ce4acbb59f60229f (diff) | |
(김준회) 결재 패턴 README.md 정리
| -rw-r--r-- | lib/approval/README.md | 1845 |
1 files changed, 656 insertions, 1189 deletions
diff --git a/lib/approval/README.md b/lib/approval/README.md index bc6a59d3..40f783c9 100644 --- a/lib/approval/README.md +++ b/lib/approval/README.md @@ -1,414 +1,395 @@ -# 결재 관리 시스템 (Approval Workflow) +# 결재 워크플로우 시스템 (Approval Workflow) -Knox 결재 시스템과 연동된 자동화된 결재 워크플로우 모듈입니다. +Knox 결재 시스템과 연동된 **Saga 패턴 기반** 자동화 결재 워크플로우 모듈입니다. -## 📋 목차 - -- [개요](#개요) -- [아키텍처](#아키텍처) -- [주요 구성 요소](#주요-구성-요소) -- [동작 흐름](#동작-흐름) -- [사용 방법](#사용-방법) -- [파일 구조](#파일-구조) - -## 개요 - -이 시스템은 다음과 같은 기능을 제공합니다: +> **최신 업데이트:** Saga Orchestrator 패턴으로 전면 리팩터링 완료 (2024-11) +> - 기존 래퍼 함수 제거, Saga 클래스로 완전 전환 +> - 비즈니스 프로세스가 명시적으로 표현됨 +> - 상세 내용: [SAGA_PATTERN.md](./SAGA_PATTERN.md) -1. **결재 요청 자동화** - 비즈니스 액션을 Knox 결재 시스템에 자동으로 상신 -2. **템플릿 기반 결재 문서** - 재사용 가능한 HTML 템플릿으로 결재 문서 생성 -3. **자동 실행** - 결재 승인 후 대기 중이던 액션 자동 실행 -4. **상태 폴링** - 1분마다 자동으로 결재 상태 확인 및 처리 +## 📋 목차 -## 아키텍처 +- [빠른 시작](#-빠른-시작) +- [Saga 패턴 아키텍처](#-saga-패턴-아키텍처) +- [주요 기능](#-주요-기능) +- [사용 방법](#-사용-방법) +- [API 레퍼런스](#-api-레퍼런스) +- [파일 구조](#-파일-구조) +- [개발 가이드](#-개발-가이드) +- [트러블슈팅](#-트러블슈팅) -``` -┌─────────────────┐ -│ 사용자 요청 │ -│ (UI/API) │ -└────────┬────────┘ - │ - ▼ -┌─────────────────────────────────────────┐ -│ withApproval() - Saga Pattern │ -│ 1. DB 저장 (pending_actions, pending) │ -│ 2. Knox 결재 상신 │ -│ 3. 실패 시 보상 트랜잭션 (→ failed) │ -└────────┬────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────┐ -│ Knox 결재 시스템 │ -│ - 결재자들이 승인/반려 처리 │ -└────────┬────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────┐ -│ Polling Service (1분 간격) │ -│ - checkPendingApprovals() │ -│ - Knox API로 상태 일괄 확인 │ -│ - 상태 변경 감지 │ -└────────┬────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────┐ -│ executeApprovedAction() │ -│ - 등록된 핸들러 호출 │ -│ - 실제 비즈니스 로직 실행 │ -│ - pending_actions 상태 업데이트 │ -└─────────────────────────────────────────┘ -``` - -### 트랜잭션 관리 (Saga Pattern) +--- -Knox는 외부 시스템이므로 일반적인 DB 트랜잭션으로 롤백할 수 없습니다. -따라서 **Saga Pattern**을 적용하여 데이터 정합성을 보장합니다. +## 🚀 빠른 시작 -#### 처리 순서 +### 1. 핸들러 등록 (앱 초기화 시 1회) ```typescript -// withApproval() 내부 -try { - // 1. DB에 먼저 pendingAction 저장 (롤백 가능) - const [pendingAction] = await db.insert(pendingActions).values({ - apInfId: submitRequest.apInfId, - actionType, - actionPayload, - status: 'pending', // 대기 상태 - }); - - // 2. Knox 결재 상신 (외부 API, 롤백 불가) - await submitApproval(submitRequest, user); - - // 3. 성공 시 정상 반환 - return { approvalId, status: 'pending_approval' }; - -} catch (error) { - // 4. Knox 실패 시 보상 트랜잭션 (Compensating Transaction) - await db.update(pendingActions) - .set({ - status: 'failed', - errorMessage: '...', - }); - throw error; +// instrumentation.ts +import { registerActionHandler } from '@/lib/approval'; + +export async function register() { + const { initializeApprovalHandlers } = await import('@/lib/approval/handlers-registry'); + await initializeApprovalHandlers(); } ``` -#### 장점 +### 2. 결재 상신 -| 시나리오 | 동작 | 결과 | -|---------|------|------| -| DB 저장 실패 | Knox 상신 안 함 | ✅ 전체 실패 (정상) | -| Knox 상신 실패 | DB를 'failed'로 업데이트 | ✅ 실패 추적 가능 | -| 모두 성공 | 정상 진행 | ✅ 결재 대기 상태 | +```typescript +'use server'; -이를 통해 "Knox는 성공했지만 DB는 실패" 같은 데이터 불일치 상황을 방지합니다. +import { ApprovalSubmissionSaga } from '@/lib/approval'; -## 주요 구성 요소 +export async function requestWithApproval(data: RequestData) { + // 1. 템플릿 변수 준비 + const variables = await mapToTemplateVariables(data); -### 1. **types.ts** - 타입 정의 + // 2. Saga로 결재 상신 + const saga = new ApprovalSubmissionSaga( + 'my_action_type', // 핸들러 등록 시 사용한 키 + { id: data.id }, // 결재 승인 후 실행될 payload + { + title: '결재 요청', + templateName: '결재 템플릿', + variables, + approvers: ['EP001', 'EP002'], + currentUser: { + id: user.id, + epId: user.epId, + email: user.email, + } + } + ); -```typescript -interface ApprovalConfig { - title: string; // 결재 제목 - description?: string; // 결재 설명 - templateName: string; // 템플릿 이름 - variables: Record<string, string>; // 템플릿 변수 - approvers?: string[]; // 결재자 EP ID 배열 - currentUser: { - id: number; - epId: string | null; - email?: string; - }; + return await saga.execute(); + // → { pendingActionId: 1, approvalId: 'AP-2024-001', status: 'pending_approval' } } ``` -### 2. **approval-workflow.ts** - 결재 워크플로우 핵심 - -#### 주요 함수: - -- **`registerActionHandler(actionType, handler)`** - - 액션 타입별 실행 핸들러 등록 - - 앱 시작 시 한 번만 실행 - -- **`withApproval(actionType, payload, config)`** - - 결재가 필요한 액션을 래핑 - - Knox에 결재 상신 및 pending_actions에 저장 - - 템플릿 기반 결재 문서 생성 - -- **`executeApprovedAction(apInfId)`** - - 결재 승인 후 자동 실행 - - 등록된 핸들러로 실제 비즈니스 로직 수행 - -- **`handleRejectedAction(apInfId, reason)`** - - 결재 반려 처리 - - pending_actions 상태 업데이트 +### 3. 자동 실행 (폴링 서비스가 처리) -### 3. **approval-polling-service.ts** - 자동 상태 확인 +결재 승인 시 등록된 핸들러가 자동으로 실행됩니다. 별도 작업 불필요! -#### 주요 함수: +--- -- **`startApprovalPollingScheduler()`** - - 1분마다 자동 실행되는 스케줄러 시작 - - `instrumentation.ts`에서 앱 시작 시 호출 +## 🏗️ Saga 패턴 아키텍처 + +### 전체 흐름 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 1. 결재 상신 (UI) │ +│ ApprovalSubmissionSaga.execute() │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────┐ + │ 7단계 Orchestration: │ + │ 1. 핸들러 초기화 │ + │ 2. 템플릿 준비 │ + │ 3. 결재선 생성 │ + │ 4. 결재 요청 생성 │ + │ 5. ⚠️ DB 저장 (Saga 핵심) │ + │ 6. Knox 상신 │ + │ 7. 캐시 무효화 │ + └──────────────┬──────────────────────┘ + │ + 성공 ▼ ▼ 실패 + ┌──────────┐ ┌─────────────┐ + │ 반환 │ │ 보상 트랜잭션 │ + └──────────┘ │ status→failed│ + └─────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 2. Knox 결재 시스템 │ +│ (결재자들이 승인/반려 처리) │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 3. 폴링 서비스 (1분마다 자동 실행) │ +│ checkPendingApprovals() │ +│ - Knox API로 상태 일괄 조회 (최대 1000건) │ +│ - 상태 변경 감지 │ +└──────────────────────┬──────────────────────────────────┘ + │ + 승인 시 ▼ +┌─────────────────────────────────────────────────────────┐ +│ 4. 액션 자동 실행 (백그라운드) │ +│ ApprovalExecutionSaga.execute() │ +│ - 등록된 핸들러 호출 │ +│ - 비즈니스 로직 실행 │ +│ - 상태 업데이트 (executed) │ +└─────────────────────────────────────────────────────────┘ +``` + +### Saga 패턴의 핵심: 분산 트랜잭션 관리 + +Knox는 외부 시스템이므로 DB 트랜잭션으로 롤백할 수 없습니다. +**Saga 패턴**으로 데이터 정합성을 보장합니다. + +```typescript +// ApprovalSubmissionSaga 내부 +async execute() { + try { + // 1~4단계: 준비 작업 + await this.initializeHandlers(); + await this.prepareApprovalTemplate(); + await this.createApprovalLines(); + await this.createSubmitRequest(); + + // 5단계: ⚠️ DB에 먼저 저장 (Knox 상신 전) + await this.savePendingAction(); + + // 6단계: Knox 상신 (외부 API, 롤백 불가) + await this.submitToKnox(); + + // 7단계: 캐시 무효화 + await this.invalidateCache(); + + return { pendingActionId, approvalId, status: 'pending_approval' }; + + } catch (error) { + // 실패 시 보상 트랜잭션 (Compensating Transaction) + await this.compensate(error); + throw error; + } +} +``` -- **`checkPendingApprovals()`** - - 진행 중인 결재 상태 일괄 확인 - - 상태 변경 시 자동으로 후속 처리 실행 +**장점:** +- Knox 상신 전 DB 저장 실패 → 전체 실패 (정상) +- Knox 상신 실패 → DB에 실패 기록 남음 (추적 가능) +- Knox 상신 성공 후 DB 업데이트 실패 위험 제거 -- **`checkSingleApprovalStatus(apInfId)`** - - 특정 결재 상태를 즉시 확인 (수동 트리거용) - - UI의 "새로고침" 버튼에서 사용 +--- -### 4. **template-utils.ts** - 템플릿 관리 +## 🎯 주요 기능 -#### 주요 함수: +### 1. Saga Orchestrator (핵심) -- **`getApprovalTemplateByName(name)`** - - 템플릿 이름으로 조회 - - `approval_templates` 테이블에서 가져옴 +세 가지 Saga 클래스로 결재 프로세스를 명시적으로 관리: -- **`replaceTemplateVariables(content, variables)`** - - `{{변수명}}` 형태를 실제 값으로 치환 +| Saga 클래스 | 역할 | 단계 수 | +|------------|------|--------| +| `ApprovalSubmissionSaga` | 결재 상신 | 7단계 | +| `ApprovalExecutionSaga` | 결재 승인 후 액션 실행 | 7단계 | +| `ApprovalRejectionSaga` | 결재 반려 처리 | 4단계 | -- **`htmlTableConverter(data, columns)`** - - 배열 데이터를 HTML 테이블로 변환 +### 2. 템플릿 시스템 -- **`htmlDescriptionList(items)`** - - 키-값 쌍을 HTML 정의 목록으로 변환 +DB에 저장된 HTML 템플릿으로 결재 문서 생성: -- **`htmlListConverter(items, ordered)`** - - 배열을 HTML 리스트로 변환 +```typescript +// 템플릿에 변수 삽입 +const variables = { + '업체명': 'ABC 협력업체', + '담당자': '홍길동', + '요청일': '2024-11-05' +}; -### 5. **handlers-registry.ts** - 핸들러 등록소 +const saga = new ApprovalSubmissionSaga( + 'vendor_registration', + payload, + { + templateName: '정규업체 등록', // DB에서 조회 + variables, // 변수 치환 + // ... + } +); +``` -모든 결재 가능한 액션의 핸들러를 중앙에서 관리: +**HTML 변환 유틸리티:** ```typescript -export async function initializeApprovalHandlers() { - // PQ 실사 의뢰 - registerActionHandler('pq_investigation_request', - requestPQInvestigationInternal); - - // PQ 실사 재의뢰 - registerActionHandler('pq_investigation_rerequest', - reRequestPQInvestigationInternal); - - // 정규업체 등록 - registerActionHandler('vendor_regular_registration', - registerVendorInternal); - - // ... 추가 핸들러 -} -``` +import { + htmlTableConverter, + htmlListConverter, + htmlDescriptionList +} from '@/lib/approval'; -## 동작 흐름 +// 테이블 생성 +const table = await htmlTableConverter(data, [ + { key: 'name', label: '이름' }, + { key: 'email', label: '이메일' } +]); -### 전체 프로세스 +// 리스트 생성 +const list = await htmlListConverter(['항목1', '항목2']); +// Description List +const dl = await htmlDescriptionList({ + '제목': '내용', + '담당자': '홍길동' +}); ``` -1. 앱 시작 - └─> instrumentation.ts에서 initializeApprovalHandlers() 호출 - └─> startApprovalPollingScheduler() 시작 - -2. 사용자가 결재 필요 액션 요청 - └─> withApproval() 호출 - └─> 템플릿 조회 및 변수 치환 - └─> Knox 결재 상신 - └─> pending_actions 테이블에 저장 (status: 'pending') - -3. 결재자가 Knox에서 승인/반려 - -4. 폴링 서비스 (1분마다) - └─> checkPendingApprovals() 실행 - └─> Knox API로 상태 일괄 확인 - └─> 상태 변경 감지 - -5. 승인된 경우 - └─> executeApprovedAction() 호출 - └─> 등록된 핸들러로 실제 비즈니스 로직 실행 - └─> pending_actions 상태: 'executed' - -6. 반려된 경우 - └─> handleRejectedAction() 호출 - └─> pending_actions 상태: 'rejected' -``` - -### 결재 상태 코드 -| 상태 코드 | 의미 | 폴링 대상 | 후속 처리 | -|---------|-----|---------|---------| -| -2 | 암호화중 | ✓ | - | -| -1 | 예약상신 | ✓ | - | -| 0 | 보류 | ✓ | - | -| 1 | 진행중 | ✓ | - | -| 2 | 완결 | - | executeApprovedAction() | -| 3 | 반려 | - | handleRejectedAction() | -| 4 | 상신취소 | - | handleRejectedAction() | -| 5 | 전결 | - | executeApprovedAction() | -| 6 | 후완결 | - | executeApprovedAction() | +### 3. 자동 폴링 서비스 -## 🚀 결재 시스템 사용 방법 (완전 가이드) - -### 📌 중요: Next.js 서버 액션과 핸들러 등록 - -#### Next.js 서버 액션의 격리 문제 - -Next.js의 `'use server'` 서버 액션은 **완전히 격리된 실행 컨텍스트**에서 동작합니다: +1분마다 Knox API를 호출하여 결재 상태 자동 확인: ```typescript -// instrumentation.ts (앱 시작 시) -await initializeApprovalHandlers(); -// → 메모리 컨텍스트 A에 핸들러 등록 - -// 서버 액션 실행 시 -'use server' -export async function myAction() { - await withApproval(...); - // → 메모리 컨텍스트 B (격리됨!) - // → 컨텍스트 A의 핸들러를 볼 수 없음 ❌ +// instrumentation.ts +import { startApprovalPollingScheduler } from '@/lib/approval'; + +export async function register() { + // 1분마다 자동 실행 + await startApprovalPollingScheduler(); } ``` -#### 해결 방법: Lazy 초기화 +**배치 처리 최적화:** +- 최대 1000건씩 일괄 조회 +- Knox API 부하 최소화 +- 에러 격리 (일부 실패해도 계속 진행) + +### 4. 캐시 관리 -이 시스템은 **Lazy 초기화**를 사용하여 자동으로 문제를 해결합니다: +Next.js의 `revalidatePath`를 활용한 실시간 UI 업데이트: ```typescript -// approval-workflow.ts -async function ensureHandlersInitialized() { - if (!handlersInitialized) { - // 각 컨텍스트에서 처음 호출 시 자동 초기화 - await initializeApprovalHandlers(); - handlersInitialized = true; - } -} +import { revalidateApprovalLogs } from '@/lib/approval'; -// withApproval() 호출 시 자동 실행 -export async function withApproval(...) { - await ensureHandlersInitialized(); // ✅ 자동으로 핸들러 등록 - // ... 결재 로직 -} +// 결재 상신/승인/반려 시 자동 호출됨 +await revalidateApprovalLogs(); ``` -**즉, 개발자는 핸들러만 등록하면 나머지는 자동으로 처리됩니다!** ✨ +**자동 무효화 시점:** +- ✅ 결재 상신 시 +- ✅ 결재 승인/반려 시 +- ✅ 액션 실행 완료 시 -### 📋 전체 프로세스 개요 +자세한 내용: [README_CACHE.md](./README_CACHE.md) -``` -1. handlers-registry.ts에 핸들러 등록 (1회) - ↓ -2. 각 기능별로 서버 액션 작성 ('use server') - ↓ -3. 서버 액션 내에서 withApproval() 호출 - ↓ -4. 자동으로 핸들러 Lazy 초기화됨 ✨ - ↓ -5. Knox 결재 상신 + DB 저장 (Saga Pattern) - ↓ -6. 폴링 서비스가 자동으로 승인 감지 및 실행 -``` +--- -## 사용 방법 +## 💻 사용 방법 -### 1단계: 핸들러 구현 및 등록 +### Step 1: 비즈니스 핸들러 작성 ```typescript // lib/my-feature/handlers.ts -export async function myActionInternal(payload: MyPayload) { - // 실제 비즈니스 로직 - const result = await db.insert(myTable).values(payload); +'use server'; + +export async function myBusinessLogicInternal(payload: { + id: number; + reason: string; +}) { + // 결재 승인 후 실행될 실제 비즈니스 로직 + const result = await db.insert(myTable).values({ + id: payload.id, + reason: payload.reason, + status: 'approved', + }); + return result; } +// 템플릿 변수 매핑 +export async function mapToTemplateVariables(data: MyData) { + return { + '요청자': data.requesterName, + '요청일': formatDate(new Date()), + '사유': data.reason, + }; +} +``` + +### Step 2: 핸들러 등록 + +```typescript // lib/approval/handlers-registry.ts -import { myActionInternal } from '@/lib/my-feature/handlers'; +import { registerActionHandler } from './approval-workflow'; export async function initializeApprovalHandlers() { - // ... 기존 핸들러들 + console.log('[Approval Handlers] Registering all handlers...'); + + // 1. 내 기능 핸들러 등록 + const { myBusinessLogicInternal } = await import('@/lib/my-feature/handlers'); + registerActionHandler('my_action_type', myBusinessLogicInternal); + + // 2. 다른 핸들러들... - registerActionHandler('my_action_type', myActionInternal); + console.log('[Approval Handlers] All handlers registered'); } ``` -### 2단계: 결재 템플릿 준비 +### Step 3: 결재 서버 액션 작성 -데이터베이스 `approval_templates` 테이블에 템플릿 추가: +```typescript +// lib/my-feature/approval-actions.ts +'use server'; -```sql -INSERT INTO approval_templates (name, content) VALUES ( - '나의 액션 결재', - '<div> - <h2>{{제목}}</h2> - <p>{{설명}}</p> - {{상세_테이블}} - </div>' -); -``` +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import { mapToTemplateVariables } from './handlers'; -### 3단계: 서버 액션 작성 +export async function requestMyActionWithApproval(data: { + id: number; + reason: string; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; +}) { + // 1. 입력 검증 + if (!data.currentUser.epId) { + throw new Error('Knox EP ID가 필요합니다'); + } -```typescript -// lib/my-feature/actions.ts -'use server'; + // 2. 템플릿 변수 매핑 + const variables = await mapToTemplateVariables(data); -import { withApproval } from '@/lib/approval'; -import { htmlTableConverter, htmlDescriptionList } from '@/lib/approval/template-utils'; - -export async function myActionWithApproval(data: MyData) { - // 1. 템플릿 변수 준비 (HTML 변환 포함) - const detailTable = await htmlTableConverter( - data.items, - [ - { key: 'name', label: '이름' }, - { key: 'value', label: '값' } - ] - ); - - const variables = { - '제목': data.title, - '설명': data.description, - '상세_테이블': detailTable, - }; - - // 2. 결재 워크플로우 시작 - const result = await withApproval( - 'my_action_type', // 등록한 핸들러 타입 - data, // 실행할 때 전달할 payload + // 3. Saga로 결재 상신 + const saga = new ApprovalSubmissionSaga( + 'my_action_type', // 핸들러 등록 시 사용한 키 { - title: `결재 요청 - ${data.title}`, - templateName: '나의 액션 결재', + id: data.id, + reason: data.reason, + }, + { + title: `내 기능 요청 - ${data.id}`, + description: `ID ${data.id}에 대한 승인 요청`, + templateName: '내 기능 템플릿', variables, - approvers: data.approvers, // 결재자 EP ID 배열 + approvers: data.approvers, currentUser: data.currentUser, } ); - - return result; + + return await saga.execute(); } ``` -### 4단계: UI에서 호출 +### Step 4: UI에서 호출 -```tsx -// components/my-feature/my-form.tsx +```typescript +// components/my-feature/my-dialog.tsx 'use client'; -export function MyForm() { +import { requestMyActionWithApproval } from '@/lib/my-feature/approval-actions'; +import { useSession } from 'next-auth/react'; + +export function MyDialog() { + const { data: session } = useSession(); + const handleSubmit = async (formData) => { try { - const result = await myActionWithApproval({ - title: formData.title, - description: formData.description, - items: formData.items, + const result = await requestMyActionWithApproval({ + id: formData.id, + reason: formData.reason, + currentUser: { + id: Number(session?.user?.id), + epId: session?.user?.epId || null, + email: session?.user?.email || undefined, + }, approvers: selectedApprovers, - currentUser: session.user, }); - toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`); - router.push(`/approval/log`); + if (result.status === 'pending_approval') { + toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`); + } } catch (error) { - toast.error('결재 상신에 실패했습니다.'); + toast.error('결재 상신 실패'); } }; @@ -418,1058 +399,544 @@ export function MyForm() { --- -## 💡 베스트 프랙티스 +## 📚 API 레퍼런스 -### ✅ 권장 패턴 +### Saga 클래스 -#### 1. 핸들러와 서버 액션 분리 +#### ApprovalSubmissionSaga -```typescript -// ✅ 좋은 예: 핸들러는 Internal 함수로 분리 -// lib/my-feature/handlers.ts -export async function myActionInternal(payload: MyPayload) { - // 실제 비즈니스 로직 (DB 조작 등) - // 결재 승인 후 실행될 순수 로직 - return await db.insert(myTable).values(payload); -} - -// lib/my-feature/approval-actions.ts -'use server'; -export async function myActionWithApproval(data: MyData) { - // 결재 워크플로우 래핑 - return await withApproval('my_action', data, config); -} -``` +결재를 Knox 시스템에 상신합니다. ```typescript -// ❌ 나쁜 예: 핸들러에 서버 액션 지시어 사용 -// lib/my-feature/handlers.ts -'use server'; // ❌ 핸들러에는 불필요 -export async function myActionInternal(payload: MyPayload) { - return await db.insert(myTable).values(payload); -} +constructor( + actionType: string, // 핸들러 등록 시 사용한 키 + actionPayload: T, // 결재 승인 후 실행될 데이터 + approvalConfig: ApprovalConfig +) + +async execute(): Promise<ApprovalResult> +// → { pendingActionId, approvalId, status: 'pending_approval' } ``` -#### 2. 템플릿 변수는 서버 액션에서 준비 +**ApprovalConfig:** ```typescript -// ✅ 좋은 예: 서버 액션에서 템플릿 변수 매핑 -// lib/my-feature/approval-actions.ts -'use server'; - -export async function myActionWithApproval(data: MyData) { - // 1. 템플릿 변수 매핑 함수 분리 - const variables = await mapToTemplateVariables(data); - - // 2. withApproval 호출 - return await withApproval('my_action', data, { - templateName: '나의 액션 결재', - variables, - ...config - }); -} - -// 변수 매핑 로직 분리 -async function mapToTemplateVariables(data: MyData) { - return { - '제목': data.title, - '설명': data.description, - '상세_테이블': await htmlTableConverter(data.items, columns), +interface ApprovalConfig { + title: string; // 결재 제목 + description?: string; // 결재 설명 + templateName: string; // DB에 저장된 템플릿명 + variables: Record<string, string>; // 템플릿 변수 + approvers?: string[]; // Knox EP ID 배열 + currentUser: { + id: number; + epId: string | null; + email?: string; }; } ``` -#### 3. 파일 구조 컨벤션 - -``` -lib/my-feature/ -├── handlers.ts # 비즈니스 로직 (Internal 함수) -│ └── myActionInternal() # 결재 승인 후 실행될 순수 로직 -│ -├── approval-actions.ts # 결재 서버 액션 ('use server') -│ └── myActionWithApproval() -│ └── mapToTemplateVariables() -│ -├── service.ts # 일반 서비스 함수 -│ └── getMyData() -│ └── validateMyData() -│ -└── types.ts # 타입 정의 - └── MyPayload - └── MyData -``` +#### ApprovalExecutionSaga -#### 4. 에러 처리 전략 +결재 승인 후 액션을 실행합니다. (폴링 서비스가 자동 호출) ```typescript -// ✅ 좋은 예: 단계별 에러 처리 -'use server'; - -export async function myActionWithApproval(data: MyData) { - try { - // 1. 사전 검증 - if (!data.currentUser.epId) { - throw new Error('Knox EP ID가 필요합니다'); - } - - // 2. 비즈니스 로직 검증 - const validation = await validateData(data); - if (!validation.success) { - throw new Error(validation.error); - } - - // 3. 템플릿 변수 준비 - const variables = await mapToTemplateVariables(data); - - // 4. 결재 워크플로우 - return await withApproval('my_action', data, { - title: `결재 요청 - ${data.title}`, - templateName: '나의 액션 결재', - variables, - approvers: data.approvers, - currentUser: data.currentUser, - }); - - } catch (error) { - // 5. 에러 로깅 - console.error('[MyAction] 결재 요청 실패:', error); - - // 6. 사용자 친화적 에러 메시지 - if (error instanceof Error) { - throw new Error(`결재 요청 중 오류 발생: ${error.message}`); - } - throw new Error('결재 요청 중 알 수 없는 오류가 발생했습니다'); - } -} -``` - -#### 5. UI 컴포넌트 패턴 - -```tsx -// ✅ 좋은 예: 결재 다이얼로그 패턴 -'use client'; +constructor(apInfId: string) // Knox 결재 ID -export function MyFeatureToolbar() { - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [isApprovalDialogOpen, setIsApprovalDialogOpen] = useState(false); - const [formData, setFormData] = useState<MyData | null>(null); - const [variables, setVariables] = useState<Record<string, string>>({}); - - // Step 1: 폼 데이터 입력 - const handleFormSubmit = async (data: MyData) => { - // 템플릿 변수 미리 생성 - const vars = await mapToTemplateVariables(data); - setVariables(vars); - setFormData(data); - - // 폼 다이얼로그 닫고 결재선 선택 다이얼로그 열기 - setIsDialogOpen(false); - setIsApprovalDialogOpen(true); - }; - - // Step 2: 결재선 선택 후 제출 - const handleApprovalSubmit = async (approvers: ApprovalLineItem[]) => { - if (!formData) return; - - const approverEpIds = approvers - .filter(line => line.seq !== "0" && line.epId) - .map(line => line.epId!); - - const result = await myActionWithApproval({ - ...formData, - approvers: approverEpIds, - }); - - if (result.status === 'pending_approval') { - toast.success('결재가 상신되었습니다'); - window.location.reload(); - } - }; - - return ( - <> - <Button onClick={() => setIsDialogOpen(true)}> - 액션 실행 - </Button> - - {/* Step 1: 데이터 입력 */} - <MyFormDialog - open={isDialogOpen} - onOpenChange={setIsDialogOpen} - onSubmit={handleFormSubmit} - /> - - {/* Step 2: 결재선 선택 */} - <ApprovalPreviewDialog - open={isApprovalDialogOpen} - onOpenChange={setIsApprovalDialogOpen} - templateName="나의 액션 결재" - variables={variables} - onSubmit={handleApprovalSubmit} - /> - </> - ); -} +async execute(): Promise<any> +// → 핸들러 실행 결과 ``` -### ❌ 피해야 할 패턴 +#### ApprovalRejectionSaga -#### 1. 핸들러에서 Knox API 직접 호출 +결재 반려를 처리합니다. (폴링 서비스가 자동 호출) ```typescript -// ❌ 나쁜 예: 핸들러에서 결재 로직 포함 -export async function myActionInternal(payload: MyPayload) { - // ❌ 핸들러는 순수 비즈니스 로직만! - const approval = await submitApproval(...); - return await db.insert(myTable).values(payload); -} +constructor(apInfId: string, reason?: string) -// ✅ 좋은 예: 핸들러는 순수 로직만 -export async function myActionInternal(payload: MyPayload) { - return await db.insert(myTable).values(payload); -} +async execute(): Promise<void> ``` -#### 2. 서버 액션에서 DB 조작 +### 핸들러 레지스트리 ```typescript -// ❌ 나쁜 예: 서버 액션에 비즈니스 로직 포함 -'use server'; -export async function myActionWithApproval(data: MyData) { - // ❌ DB 조작은 핸들러에서! - await db.insert(myTable).values(data); - - return await withApproval(...); -} +// 핸들러 등록 +function registerActionHandler( + actionType: string, + handler: (payload: any) => Promise<any> +): void -// ✅ 좋은 예: 서버 액션은 결재 래핑만 -'use server'; -export async function myActionWithApproval(data: MyData) { - // withApproval만 호출 - // 실제 DB 조작은 핸들러(myActionInternal)에서 실행됨 - return await withApproval('my_action', data, config); -} +// 등록된 핸들러 조회 (디버깅용) +function getRegisteredHandlers(): string[] ``` -#### 3. 핸들러를 서버 액션으로 직접 export +### 폴링 서비스 ```typescript -// ❌ 나쁜 예: 핸들러를 서버 액션으로 노출 -'use server'; -export async function myAction(data: MyData) { - // 이렇게 하면 결재 없이 바로 실행됨! - return await db.insert(myTable).values(data); -} +// 폴링 스케줄러 시작 (instrumentation.ts에서 호출) +async function startApprovalPollingScheduler(): Promise<cron.ScheduledTask> -// ✅ 좋은 예: 핸들러는 Internal로 분리 -export async function myActionInternal(payload: MyPayload) { - return await db.insert(myTable).values(payload); -} +// 수동으로 pending 결재 확인 +async function checkPendingApprovals(): Promise<{ + success: boolean; + checked: number; + updated: number; + executed: number; +}> -// 별도 파일에서 서버 액션으로 래핑 -'use server'; -export async function myActionWithApproval(data: MyData) { - return await withApproval('my_action', data, config); -} +// 특정 결재 상태 확인 (UI에서 "새로고침" 버튼) +async function checkSingleApprovalStatus(apInfId: string): Promise<{ + success: boolean; + oldStatus: string; + newStatus: string; + updated: boolean; + executed: boolean; +}> ``` ---- +### 템플릿 유틸리티 -## 🔧 실전 예제: PQ 실사 의뢰 +```typescript +// HTML 테이블 생성 +async function htmlTableConverter( + data: any[], + columns: Array<{ key: string; label: string }> +): Promise<string> -### 파일 구조 +// HTML 리스트 생성 +async function htmlListConverter(items: string[]): Promise<string> -``` -lib/vendor-investigation/ -├── handlers.ts # 핸들러 -│ ├── requestPQInvestigationInternal() -│ ├── reRequestPQInvestigationInternal() -│ └── mapPQInvestigationToTemplateVariables() -│ -├── approval-actions.ts # 서버 액션 -│ ├── requestPQInvestigationWithApproval() -│ └── reRequestPQInvestigationWithApproval() -│ -└── service.ts # 일반 서비스 - ├── getInvestigations() - └── updateInvestigationStatus() -``` +// HTML Description List 생성 +async function htmlDescriptionList( + items: Record<string, string> +): Promise<string> -### 1. 핸들러 (handlers.ts) +// 템플릿 조회 +async function getApprovalTemplateByName( + name: string +): Promise<Template | null> -```typescript -// lib/vendor-investigation/handlers.ts - -/** - * PQ 실사 의뢰 핸들러 (Internal) - * 결재 승인 후 실행될 순수 비즈니스 로직 - */ -export async function requestPQInvestigationInternal(payload: { - pqSubmissionIds: number[]; - qmManagerId: number; - forecastedAt: Date; - investigationAddress: string; - investigationNotes?: string; -}) { - // 트랜잭션으로 실사 생성 - return await db.transaction(async (tx) => { - const investigations = []; - - for (const pqId of payload.pqSubmissionIds) { - const [investigation] = await tx.insert(vendorInvestigations).values({ - pqSubmissionId: pqId, - qmManagerId: payload.qmManagerId, - forecastedAt: payload.forecastedAt, - investigationAddress: payload.investigationAddress, - investigationNotes: payload.investigationNotes, - investigationStatus: 'PLANNED', - }).returning(); - - investigations.push(investigation); - } - - return investigations; - }); -} - -/** - * 템플릿 변수 매핑 함수 - */ -export async function mapPQInvestigationToTemplateVariables(data: { - vendorNames: string; - qmManagerName: string; - qmManagerEmail?: string; - forecastedAt: Date; - investigationAddress: string; - investigationNotes?: string; - requestedAt: Date; -}) { - return { - '협력사명': data.vendorNames, - 'QM담당자': data.qmManagerName, - 'QM담당자_이메일': data.qmManagerEmail || 'N/A', - '예정일자': data.forecastedAt.toLocaleDateString('ko-KR'), - '실사주소': data.investigationAddress, - '실사비고': data.investigationNotes || '없음', - '요청일시': data.requestedAt.toLocaleString('ko-KR'), - }; -} +// 템플릿 변수 치환 +async function replaceTemplateVariables( + content: string, + variables: Record<string, string> +): Promise<string> ``` -### 2. 서버 액션 (approval-actions.ts) +### 캐시 관리 ```typescript -// lib/vendor-investigation/approval-actions.ts -'use server'; - -import { withApproval } from '@/lib/approval/approval-workflow'; -import { - mapPQInvestigationToTemplateVariables -} from './handlers'; - -/** - * PQ 실사 의뢰 결재 서버 액션 - * UI에서 호출하는 함수 - */ -export async function requestPQInvestigationWithApproval(data: { - pqSubmissionIds: number[]; - vendorNames: string; - qmManagerId: number; - qmManagerName: string; - qmManagerEmail?: string; - forecastedAt: Date; - investigationAddress: string; - investigationNotes?: string; - currentUser: { id: number; epId: string | null; email?: string }; - approvers?: string[]; -}) { - // 1. 입력 검증 - if (!data.currentUser.epId) { - throw new Error('Knox EP ID가 필요합니다'); - } - - if (data.pqSubmissionIds.length === 0) { - throw new Error('실사 의뢰할 PQ를 선택해주세요'); - } - - // 2. 템플릿 변수 매핑 - const requestedAt = new Date(); - const variables = await mapPQInvestigationToTemplateVariables({ - vendorNames: data.vendorNames, - qmManagerName: data.qmManagerName, - qmManagerEmail: data.qmManagerEmail, - forecastedAt: data.forecastedAt, - investigationAddress: data.investigationAddress, - investigationNotes: data.investigationNotes, - requestedAt, - }); - - // 3. 결재 워크플로우 시작 - return await withApproval( - 'pq_investigation_request', - { - pqSubmissionIds: data.pqSubmissionIds, - qmManagerId: data.qmManagerId, - forecastedAt: data.forecastedAt, - investigationAddress: data.investigationAddress, - investigationNotes: data.investigationNotes, - }, - { - title: `Vendor 실사의뢰 - ${data.vendorNames}`, - description: `${data.vendorNames}에 대한 실사 의뢰`, - templateName: 'Vendor 실사의뢰', - variables, - approvers: data.approvers, - currentUser: data.currentUser, - } - ); -} -``` +// 결재 로그 캐시 무효화 +async function revalidateApprovalLogs(): Promise<void> -### 3. 핸들러 등록 (handlers-registry.ts) +// Pending Actions 캐시 무효화 +async function revalidatePendingActions(): Promise<void> -```typescript -// lib/approval/handlers-registry.ts -import { registerActionHandler } from './approval-workflow'; +// 특정 결재 상세 캐시 무효화 +async function revalidateApprovalDetail(apInfId: string): Promise<void> -export async function initializeApprovalHandlers() { - console.log('[Approval Handlers] Registering all handlers...'); - - // PQ 실사 의뢰 핸들러 등록 - const { - requestPQInvestigationInternal - } = await import('@/lib/vendor-investigation/handlers'); - - registerActionHandler( - 'pq_investigation_request', // actionType - requestPQInvestigationInternal // handler - ); - - console.log('[Approval Handlers] All handlers registered successfully'); -} +// 모든 결재 관련 캐시 무효화 +async function revalidateAllApprovalCaches(): Promise<void> ``` -### 4. UI 컴포넌트 - -```tsx -// components/pq/request-investigation-button.tsx -'use client'; +--- -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { ApprovalPreviewDialog } from '@/components/approval/ApprovalPreviewDialog'; -import { requestPQInvestigationWithApproval } from '@/lib/vendor-investigation/approval-actions'; -import { mapPQInvestigationToTemplateVariables } from '@/lib/vendor-investigation/handlers'; +## 📁 파일 구조 -export function RequestInvestigationButton({ selectedPQs, currentUser }) { - const [isApprovalDialogOpen, setIsApprovalDialogOpen] = useState(false); - const [variables, setVariables] = useState({}); - const [formData, setFormData] = useState(null); - - const handleOpenDialog = async () => { - // 폼 데이터 수집 (실제로는 별도 다이얼로그에서) - const data = { - pqSubmissionIds: selectedPQs.map(pq => pq.id), - vendorNames: selectedPQs.map(pq => pq.vendorName).join(', '), - qmManagerId: 123, - qmManagerName: '홍길동', - forecastedAt: new Date(), - investigationAddress: '경기도 ...', - }; - - // 템플릿 변수 미리 생성 - const vars = await mapPQInvestigationToTemplateVariables({ - ...data, - requestedAt: new Date(), - }); - - setVariables(vars); - setFormData(data); - setIsApprovalDialogOpen(true); - }; - - const handleApprovalSubmit = async (approvers) => { - const approverEpIds = approvers - .filter(line => line.seq !== "0" && line.epId) - .map(line => line.epId); - - const result = await requestPQInvestigationWithApproval({ - ...formData, - currentUser, - approvers: approverEpIds, - }); - - if (result.status === 'pending_approval') { - toast.success('실사 의뢰가 상신되었습니다'); - window.location.reload(); - } - }; - - return ( - <> - <Button onClick={handleOpenDialog}> - 실사 의뢰 - </Button> - - <ApprovalPreviewDialog - open={isApprovalDialogOpen} - onOpenChange={setIsApprovalDialogOpen} - templateName="Vendor 실사의뢰" - variables={variables} - title={`Vendor 실사의뢰 - ${formData?.vendorNames}`} - currentUser={currentUser} - onSubmit={handleApprovalSubmit} - /> - </> - ); -} +``` +lib/approval/ +├── approval-saga.ts # Saga 클래스 (메인 로직) +│ ├── ApprovalSubmissionSaga # 결재 상신 +│ ├── ApprovalExecutionSaga # 액션 실행 +│ └── ApprovalRejectionSaga # 반려 처리 +│ +├── approval-workflow.ts # 핸들러 레지스트리 +│ ├── registerActionHandler() +│ ├── getRegisteredHandlers() +│ └── ensureHandlersInitialized() +│ +├── approval-polling-service.ts # 폴링 서비스 +│ ├── startApprovalPollingScheduler() +│ ├── checkPendingApprovals() +│ └── checkSingleApprovalStatus() +│ +├── handlers-registry.ts # 핸들러 중앙 등록소 +│ └── initializeApprovalHandlers() +│ +├── template-utils.ts # 템플릿 유틸리티 +│ ├── getApprovalTemplateByName() +│ ├── replaceTemplateVariables() +│ ├── htmlTableConverter() +│ ├── htmlListConverter() +│ └── htmlDescriptionList() +│ +├── cache-utils.ts # 캐시 관리 +│ ├── revalidateApprovalLogs() +│ ├── revalidatePendingActions() +│ └── revalidateApprovalDetail() +│ +├── types.ts # 타입 정의 +│ ├── ApprovalConfig +│ ├── ApprovalResult +│ └── TemplateVariables +│ +├── index.ts # 공개 API Export +│ +└── README.md # 이 문서 ``` --- -## ✅ 새로운 결재 기능 추가 체크리스트 +## 🛠️ 개발 가이드 -새로운 결재 기능을 추가할 때 다음 순서로 작업하세요: +### 새로운 결재 타입 추가하기 -### 1. 핸들러 작성 (lib/[feature]/handlers.ts) - -- [ ] `[actionName]Internal()` 함수 작성 - - 결재 승인 후 실행될 순수 비즈니스 로직 - - DB 트랜잭션 처리 포함 - - `'use server'` 지시어 **사용하지 않음** - -- [ ] `mapToTemplateVariables()` 함수 작성 (선택적) - - 템플릿 변수 매핑 로직 - - HTML 변환 함수 활용 (htmlTableConverter 등) +**1. 비즈니스 로직 작성** ```typescript -// ✅ 체크포인트 -export async function myActionInternal(payload: MyPayload) { - // 순수 비즈니스 로직만 포함 - return await db.insert(...).values(payload); -} - -export async function mapToTemplateVariables(data: MyData) { - return { - '변수1': data.field1, - '변수2': data.field2, - }; +// lib/my-new-feature/handlers.ts +export async function myNewFeatureInternal(payload: MyPayload) { + // 실제 DB 작업 + return await db.insert(...); } ``` -### 2. 서버 액션 작성 (lib/[feature]/approval-actions.ts) +**2. 핸들러 등록** -- [ ] 파일 상단에 `'use server'` 지시어 추가 -- [ ] `[actionName]WithApproval()` 함수 작성 - - 입력 검증 (EP ID 등) - - 템플릿 변수 준비 - - `withApproval()` 호출 - ```typescript -// ✅ 체크포인트 -'use server'; - -export async function myActionWithApproval(data: MyData) { - // 1. 검증 - if (!data.currentUser.epId) { - throw new Error('Knox EP ID가 필요합니다'); - } - - // 2. 템플릿 변수 - const variables = await mapToTemplateVariables(data); +// lib/approval/handlers-registry.ts +export async function initializeApprovalHandlers() { + // 기존 핸들러들... - // 3. 결재 워크플로우 - return await withApproval('my_action_type', payload, config); + // 새 핸들러 추가 + const { myNewFeatureInternal } = await import('@/lib/my-new-feature/handlers'); + registerActionHandler('my_new_feature', myNewFeatureInternal); } ``` -### 3. 핸들러 등록 (lib/approval/handlers-registry.ts) - -- [ ] `initializeApprovalHandlers()` 함수 내에 핸들러 등록 추가 +**3. 결재 서버 액션 작성** ```typescript -// ✅ 체크포인트 -export async function initializeApprovalHandlers() { - // ... 기존 핸들러들 - - const { myActionInternal } = await import('@/lib/my-feature/handlers'); - registerActionHandler('my_action_type', myActionInternal); - - // ... 추가 핸들러들 +// lib/my-new-feature/approval-actions.ts +export async function requestMyNewFeatureWithApproval(data) { + const saga = new ApprovalSubmissionSaga('my_new_feature', payload, config); + return await saga.execute(); } ``` -### 4. 결재 템플릿 생성 (DB) - -- [ ] `approval_templates` 테이블에 템플릿 추가 - - `name`: 한국어 템플릿명 - - `content`: HTML 템플릿 ({{변수}} 포함) +**4. 템플릿 생성 (DB)** ```sql --- ✅ 체크포인트 -INSERT INTO approval_templates (name, content, created_at) -VALUES ( - '나의 액션 결재', - '<div> - <h2>{{제목}}</h2> - <p>{{설명}}</p> - {{상세_테이블}} - </div>', - NOW() +INSERT INTO approval_templates (name, content, description) VALUES ( + '내 새 기능', + '<div>... {{변수명}} ...</div>', + '내 새 기능 결재 템플릿' ); ``` -### 5. UI 컴포넌트 작성 +끝! 폴링 서비스가 자동으로 처리합니다. -- [ ] 데이터 입력 다이얼로그 (선택적) -- [ ] 결재선 선택 다이얼로그 연동 - - `ApprovalPreviewDialog` 컴포넌트 사용 - - 템플릿 변수 미리 준비 - - 결재자 EP ID 추출 - -```tsx -// ✅ 체크포인트 -const handleApprovalSubmit = async (approvers) => { - const approverEpIds = approvers - .filter(line => line.seq !== "0" && line.epId) - .map(line => line.epId); - - const result = await myActionWithApproval({ - ...formData, - approvers: approverEpIds, - }); - - if (result.status === 'pending_approval') { - toast.success('결재가 상신되었습니다'); - } -}; -``` +### 테스트하기 -### 6. 테스트 +```typescript +import { ApprovalSubmissionSaga } from '@/lib/approval'; -- [ ] 로컬 환경에서 결재 상신 테스트 - - 핸들러 자동 초기화 확인 (`[Approval Workflow] Lazy initializing handlers...` 로그) - - Knox 결재 상신 성공 확인 - - `pending_actions` 테이블에 레코드 생성 확인 - -- [ ] Knox에서 결재 승인/반려 테스트 - - 폴링 서비스가 상태 변경 감지 확인 - - 승인 시 핸들러 실행 및 DB 업데이트 확인 - - 반려 시 상태 업데이트 확인 +describe('ApprovalSubmissionSaga', () => { + it('should submit approval successfully', async () => { + const saga = new ApprovalSubmissionSaga( + 'test_action', + { id: 123 }, + { + title: 'Test', + templateName: 'Test Template', + variables: {}, + currentUser: { id: 1, epId: 'EP001' } + } + ); + + const result = await saga.execute(); + + expect(result.status).toBe('pending_approval'); + expect(result.approvalId).toBeDefined(); + }); -- [ ] 에러 케이스 테스트 - - EP ID 없는 사용자 - - Knox API 실패 시 보상 트랜잭션 - - 핸들러 실행 실패 시 에러 처리 - -### 7. 문서화 (선택적) - -- [ ] 코드에 JSDoc 주석 추가 -- [ ] 팀 위키 또는 문서에 사용 방법 기록 - ---- - -## 🎓 핵심 개념 요약 - -### Lazy 초기화의 작동 원리 - -```typescript -// 각 실행 컨텍스트별로 독립적으로 동작 -┌─────────────────────────────────────┐ -│ 서버 액션 호출 (컨텍스트 A) │ -│ → ensureHandlersInitialized() │ -│ → 처음 호출: 핸들러 등록 ✅ │ -│ → 이후 호출: 스킵 (이미 등록됨) │ -└─────────────────────────────────────┘ - -┌─────────────────────────────────────┐ -│ 폴링 서비스 (컨텍스트 B) │ -│ → ensureHandlersInitialized() │ -│ → 처음 호출: 핸들러 등록 ✅ │ -│ → 이후 호출: 스킵 (이미 등록됨) │ -└─────────────────────────────────────┘ + it('should compensate on Knox failure', async () => { + // Knox API를 모킹하여 실패 시뮬레이션 + mockKnoxAPI.submitApproval.mockRejectedValue(new Error('Knox Error')); + + const saga = new ApprovalSubmissionSaga(...); + + await expect(saga.execute()).rejects.toThrow('Knox Error'); + + // pendingAction이 'failed' 상태인지 확인 + const pendingAction = await db.query.pendingActions.findFirst(...); + expect(pendingAction.status).toBe('failed'); + }); +}); ``` -**핵심**: 각 컨텍스트는 독립적이지만, 같은 코드를 통해 핸들러를 등록하므로 일관성 보장! +### 디버깅 -### Saga Pattern (트랜잭션 관리) +**1. 로그 확인** ```typescript -// Knox는 외부 시스템이므로 일반 DB 트랜잭션으로 롤백 불가 -// 따라서 다음 순서로 처리: - -1. DB에 pending_actions 저장 (status: 'pending') - ↓ -2. Knox 결재 상신 시도 - ↓ -3-A. 성공 → 상태 유지 -3-B. 실패 → 보상 트랜잭션 (status: 'failed') +// 각 Saga 단계마다 상세 로그 출력 +[ApprovalSaga] Starting approval saga for vendor_registration +[ApprovalSaga] Step 1: Initializing handlers +[ApprovalSaga] ✓ Handlers initialized +[ApprovalSaga] Step 2: Preparing approval template +... +[ApprovalSaga] ✅ Saga completed successfully ``` -이를 통해 **Knox만 올라가고 DB는 없는** 불일치 상황을 방지합니다. +**2. 등록된 핸들러 확인** -### 핸들러 vs 서버 액션 - -| 구분 | 핸들러 (Internal) | 서버 액션 (WithApproval) | -|------|-------------------|-------------------------| -| **역할** | 순수 비즈니스 로직 | 결재 워크플로우 래핑 | -| **실행 시점** | 결재 승인 후 | 사용자 요청 시 | -| **'use server'** | ❌ 사용 안 함 | ✅ 반드시 사용 | -| **DB 조작** | ✅ 허용 | ❌ 비권장 | -| **결재 로직** | ❌ 포함 안 함 | ✅ withApproval() 호출 | -| **예시** | `myActionInternal()` | `myActionWithApproval()` | - -### 파일 구조 컨벤션 +```typescript +import { getRegisteredHandlers } from '@/lib/approval'; +console.log(getRegisteredHandlers()); +// → ['pq_investigation_request', 'vendor_regular_registration', ...] ``` -lib/my-feature/ -├── handlers.ts # Internal 함수 (결재 승인 후 실행) -├── approval-actions.ts # 서버 액션 ('use server') -├── service.ts # 일반 서비스 함수 -└── types.ts # 타입 정의 -lib/approval/ -└── handlers-registry.ts # 모든 핸들러 등록 (중앙 관리) -``` +**3. Pending Actions 상태 확인** ---- - -## 파일 구조 - -``` -lib/approval/ -├── index.ts # 모듈 export (진입점) -├── types.ts # 타입 정의 -├── approval-workflow.ts # 결재 워크플로우 핵심 로직 -├── approval-polling-service.ts # 자동 상태 확인 서비스 -├── template-utils.ts # 템플릿 관리 및 HTML 유틸리티 -├── handlers-registry.ts # 핸들러 중앙 등록소 -├── example-usage.ts # 사용 예시 -└── README.md # 이 문서 +```sql +SELECT * FROM pending_actions +WHERE status = 'pending' +ORDER BY created_at DESC; ``` -## 데이터베이스 스키마 - -### approval_logs (결재 로그) - -Knox에서 동기화된 결재 정보: +**4. 폴링 서비스 로그** ```typescript -{ - apInfId: string; // Knox 결재 ID (PK) - status: string; // 결재 상태 (-2~6) - title: string; // 결재 제목 - content: string; // 결재 내용 (HTML) - // ... -} +[Approval Polling] Found 5 pending approvals to check +[Approval Polling] Status changed: AP-2024-001 (1 → 2) +[Approval Polling] ✅ Executed approved action: AP-2024-001 ``` -### approval_templates (결재 템플릿) +### 성능 최적화 -재사용 가능한 결재 문서 템플릿: +**1. 배치 크기 조정** ```typescript -{ - id: number; // 템플릿 ID (PK) - name: string; // 템플릿 이름 (한국어) - content: string; // HTML 템플릿 ({{변수}} 포함) - // ... -} +// approval-polling-service.ts +const batchSize = 1000; // Knox API 호출 시 배치 크기 ``` -### pending_actions (대기 액션) - -결재 승인 후 실행될 액션 정보: +**2. 폴링 간격 조정** ```typescript -{ - id: number; // 액션 ID (PK) - apInfId: string; // Knox 결재 ID (FK) - actionType: string; // 핸들러 타입 - actionPayload: jsonb; // 실행 데이터 - status: string; // 'pending' | 'executed' | 'rejected' | 'failed' - executedAt?: Date; // 실행 시간 - executionResult?: jsonb; // 실행 결과 - errorMessage?: string; // 에러 메시지 - // ... -} +// approval-polling-service.ts +cron.schedule( + '* * * * *', // 1분마다 (필요시 조정) + async () => { ... } +); ``` -## 주의사항 - -### 1. 핸들러 등록 시점 +**3. 캐시 전략** -- 핸들러는 **앱 시작 시 한 번만** 등록되어야 합니다 -- `instrumentation.ts`에서 `initializeApprovalHandlers()` 호출 -- 핸들러 등록 누락 시 `withApproval()`에서 에러 발생 +```typescript +// 특정 결재만 무효화 (더 효율적) +await revalidateApprovalDetail(apInfId); -### 2. 템플릿 변수 이름 +// vs 전체 무효화 (부담) +await revalidateAllApprovalCaches(); +``` -- 템플릿에서 `{{변수명}}`으로 정의 -- `variables` 객체의 키와 정확히 일치해야 함 -- 치환되지 않은 변수는 콘솔 경고 출력 +--- -### 3. 폴링 간격 +## 🔧 트러블슈팅 -- 기본 1분 간격으로 설정 -- 실시간성이 중요한 경우 간격 조정 가능 -- 하지만 Knox API 부하 고려 필요 +### 1. Knox EP ID가 없음 -### 4. 트랜잭션 관리 (중요!) +**증상:** +``` +Error: Knox EP ID가 필요합니다 +``` -#### Saga Pattern 적용 +**원인:** 사용자의 `epId`가 `null` -Knox는 외부 시스템이므로 DB 트랜잭션으로 롤백할 수 없습니다. -따라서 다음 순서를 **반드시** 준수해야 합니다: +**해결:** ```typescript -// ✅ 올바른 순서 -1. DB에 pendingAction 생성 (status: 'pending') -2. Knox 결재 상신 시도 -3-A. 성공 → pendingAction 유지 -3-B. 실패 → pendingAction을 'failed'로 업데이트 (보상 트랜잭션) -``` +// UI에서 미리 체크 +if (!session?.user?.epId) { + toast.error('Knox EP ID가 없습니다. 시스템 관리자에게 문의하세요.'); + return; +} -```typescript -// ❌ 잘못된 순서 (데이터 불일치 발생) -1. Knox 결재 상신 -2. DB 저장 ← 이 시점에 실패하면 Knox만 올라가고 DB에 기록 없음! +// 또는 버튼 비활성화 +<Button disabled={!session?.user?.epId}> + 결재 요청 +</Button> ``` -#### 실패 추적 - -모든 실패는 `pending_actions` 테이블에 기록됩니다: +### 2. 핸들러를 찾을 수 없음 -```sql -SELECT * FROM pending_actions -WHERE status = 'failed' -ORDER BY created_at DESC; +**증상:** +``` +Error: Handler not found for action type: my_action ``` -관리자가 로그를 확인하고 수동으로 재처리할 수 있습니다. +**원인:** 핸들러가 등록되지 않음 -### 5. 에러 처리 +**해결:** -- `executeApprovedAction()` 실패 시 `pending_actions.status = 'failed'` -- 에러 메시지는 `errorMessage` 필드에 저장 -- 실패한 액션은 자동 재시도하지 않음 (수동 재처리 필요) -- Knox 상신 실패는 보상 트랜잭션으로 'failed' 상태 기록 +```typescript +// 1. handlers-registry.ts에서 등록 확인 +registerActionHandler('my_action', myActionInternal); -### 6. EP ID 검증 +// 2. instrumentation.ts에서 초기화 확인 +await initializeApprovalHandlers(); -결재 기능을 사용하려면 사용자에게 Knox EP ID가 **필수**입니다: +// 3. 등록된 핸들러 목록 확인 +console.log(getRegisteredHandlers()); +``` -```typescript -// UI에서 사전 검증 권장 -if (!session?.user?.epId) { - toast.error('Knox EP ID가 없어 결재 기능을 사용할 수 없습니다.'); - return; -} +### 3. 템플릿을 찾을 수 없음 -// 서버에서도 검증됨 -await withApproval(...); // EP ID 없으면 에러 발생 +**증상:** +``` +Warning: Template not found: 내 템플릿 ``` -EP ID가 없는 사용자는 시스템 관리자에게 문의하여 등록해야 합니다. +**원인:** DB에 템플릿이 없음 -## 확장 가능성 +**해결:** -### 새로운 결재 타입 추가 +```sql +-- DB에 템플릿 확인 +SELECT * FROM approval_templates WHERE name = '내 템플릿'; -1. 핸들러 함수 작성 (`lib/[feature]/handlers.ts`) -2. `handlers-registry.ts`에 등록 -3. 템플릿 생성 (DB `approval_templates`) -4. 서버 액션 작성 (withApproval 호출) -5. UI 연동 +-- 없으면 생성 +INSERT INTO approval_templates (name, content, description) VALUES (...); +``` -### 알림 기능 추가 +### 4. 폴링 서비스가 동작하지 않음 -```typescript -// approval-workflow.ts의 executeApprovedAction() 내부 -if (result) { - // 요청자에게 승인 알림 - await sendNotification(pendingAction.createdBy, '결재가 승인되었습니다'); -} -``` +**증상:** 결재 승인했는데 액션이 실행되지 않음 -### 결재 히스토리 조회 +**확인:** ```typescript -export async function getApprovalHistory(userId: number) { - return await db.query.approvalLogs.findMany({ - where: eq(approvalLogs.createdBy, userId), - orderBy: desc(approvalLogs.createdAt), - }); +// 1. instrumentation.ts에서 시작 확인 +export async function register() { + await startApprovalPollingScheduler(); // ✅ 이 줄 있나? } -``` -## 디버깅 +// 2. 로그 확인 +[Approval Polling] Starting approval polling scheduler... +[Approval Polling] Scheduler started - running every minute -### 등록된 핸들러 확인 +// 3. pending_actions 테이블 확인 +SELECT * FROM pending_actions WHERE status = 'pending'; +``` -```typescript -import { getRegisteredHandlers } from '@/lib/approval'; +### 5. 캐시가 갱신되지 않음 -const handlers = getRegisteredHandlers(); -console.log('Registered handlers:', handlers); -``` +**증상:** UI에 최신 결재 상태가 반영되지 않음 -### 특정 결재 상태 수동 확인 +**해결:** ```typescript -import { checkSingleApprovalStatus } from '@/lib/approval'; +// 1. 수동으로 캐시 무효화 +import { revalidateApprovalLogs } from '@/lib/approval'; +await revalidateApprovalLogs(); -const result = await checkSingleApprovalStatus('AP-2024-001'); -console.log('Status check result:', result); +// 2. API 경로 확인 (app/api/revalidate/approval/route.ts) +// POST /api/revalidate/approval 호출 ``` -### 폴링 로그 확인 +자세한 내용: [README_CACHE.md](./README_CACHE.md) -폴링 서비스는 다음과 같은 로그를 출력합니다: +### 6. Knox 상신 실패 +**증상:** ``` -[Approval Polling] Starting approval status check... -[Approval Polling] Found 5 pending approvals to check -[Approval Polling] Status changed: AP-2024-001 (1 → 2) -[Approval Polling] ✅ Executed approved action: AP-2024-001 -[Approval Polling] Summary: { checked: 5, updated: 1, executed: 1 } +Error: Knox 상신 실패: Network error ``` -### 트랜잭션 관리 로그 +**확인:** -`withApproval()` 실행 시 다음과 같은 로그가 출력됩니다: +```typescript +// 1. Knox API 설정 확인 +// lib/knox-api/approval/approval.ts -**성공 케이스:** -``` -[Approval Workflow] Creating pending action for pq_investigation_request -[Approval Workflow] Pending action created: 123 -[Approval Workflow] Submitting to Knox: AP-2024-001 -[Approval Workflow] ✅ Knox submission successful: AP-2024-001 -``` +// 2. 네트워크 연결 확인 -**실패 케이스 (Knox 상신 실패):** -``` -[Approval Workflow] Creating pending action for pq_investigation_request -[Approval Workflow] Pending action created: 124 -[Approval Workflow] Submitting to Knox: AP-2024-002 -[Approval Workflow] ❌ Error during approval workflow: Knox API timeout -[Approval Workflow] Marking pending action as failed: 124 -[Approval Workflow] Pending action marked as failed: 124 +// 3. pending_actions 상태 확인 +SELECT * FROM pending_actions +WHERE status = 'failed' +ORDER BY created_at DESC; ``` -### 실패한 결재 조회 +**복구:** -```sql --- 실패한 결재 목록 -SELECT - id, - action_type, - error_message, - created_at -FROM pending_actions -WHERE status = 'failed' -ORDER BY created_at DESC; - --- 실패 사유별 통계 -SELECT - error_message, - COUNT(*) as count -FROM pending_actions -WHERE status = 'failed' -GROUP BY error_message -ORDER BY count DESC; +```typescript +// Saga 패턴 덕분에 DB에 실패 기록이 남아있음 +// 문제 해결 후 재시도 가능 +const saga = new ApprovalSubmissionSaga(...); +await saga.execute(); ``` -## 관련 파일 - -### 핵심 파일 -- `lib/approval/approval-workflow.ts` - Saga Pattern 트랜잭션 관리 구현 -- `lib/approval/approval-polling-service.ts` - 결재 상태 자동 확인 -- `lib/approval/handlers-registry.ts` - 핸들러 중앙 등록소 -- `instrumentation.ts` - 앱 시작 시 핸들러 등록 및 폴링 시작 +--- -### Knox 연동 -- `lib/knox-api/approval/` - Knox API 통신 모듈 +## 📖 추가 문서 -### 데이터베이스 -- `db/schema/knox/approvals.ts` - 결재 관련 스키마 -- `db/schema/knox/pending-actions.ts` - 대기 액션 스키마 +- **[SAGA_PATTERN.md](./SAGA_PATTERN.md)** - Saga 패턴 상세 설명 및 리팩터링 과정 +- **[README_CACHE.md](./README_CACHE.md)** - 캐시 전략 및 관리 방법 +- **[USAGE_PATTERN_ANALYSIS.md](./USAGE_PATTERN_ANALYSIS.md)** - 실제 사용 패턴 분석 및 개선 제안 +- **[ARCHITECTURE_REVIEW.md](./ARCHITECTURE_REVIEW.md)** - 아키텍처 평가 및 베스트 프랙티스 -### 예시 구현 -- `lib/vendor-investigation/handlers.ts` - PQ 실사 핸들러 -- `lib/vendor-investigation/approval-actions.ts` - PQ 실사 결재 서버 액션 -- `lib/vendor-regular-registrations/handlers.ts` - 정규업체 등록 핸들러 -- `lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx` - UI 구현 예시 +--- -### 문서 -- `lib/approval/README.md` - 이 문서 -- `lib/approval/ARCHITECTURE_REVIEW.md` - 아키텍처 상세 분석 -- `lib/approval/USAGE_PATTERN_ANALYSIS.md` - 사용 패턴 분석 -- `lib/approval/EP_ID_VALIDATION_FIX.md` - EP ID 검증 개선 +## 🎯 핵심 요약 -## 문의 +### 언제 사용하는가? +- ✅ 비즈니스 액션에 결재 승인이 필요한 경우 +- ✅ Knox 결재 시스템과 자동 연동이 필요한 경우 +- ✅ 결재 승인 후 자동으로 액션을 실행하고 싶은 경우 -문제 발생 시 다음을 확인하세요: +### 왜 Saga 패턴인가? +- ✅ Knox는 외부 시스템 → 일반 트랜잭션 불가 +- ✅ DB 저장 → Knox 상신 순서로 데이터 정합성 보장 +- ✅ 실패 시 보상 트랜잭션으로 일관성 유지 -### 1. 핸들러 등록 확인 -```typescript -const handlers = getRegisteredHandlers(); -console.log('Registered handlers:', handlers); -// 예상: ['pq_investigation_request', 'vendor_regular_registration', ...] -``` +### 어떻게 사용하는가? -### 2. 템플릿 존재 확인 -```typescript -const template = await getApprovalTemplateByName('Vendor 실사의뢰'); -console.log('Template found:', !!template); -``` +1. **핸들러 작성 및 등록** (1회) +2. **Saga로 결재 상신** (필요할 때마다) +3. **폴링이 자동 실행** (설정만 하면 끝) -### 3. 폴링 서비스 실행 확인 -```bash -# 로그에서 폴링 시작 메시지 확인 -grep "Approval Polling" logs/app.log -``` +### 코드 3줄 요약 -### 4. Knox API 연결 확인 -- Knox 상신 에러가 발생하면 `pending_actions`에 'failed' 상태로 기록됨 -- `error_message` 필드에서 상세 원인 확인 +```typescript +// 1. 핸들러 등록 +registerActionHandler('my_action', myActionHandler); -### 5. EP ID 확인 -```sql --- EP ID가 없는 사용자 조회 -SELECT id, name, email -FROM users -WHERE ep_id IS NULL; -``` +// 2. 결재 상신 +const saga = new ApprovalSubmissionSaga('my_action', payload, config); +const result = await saga.execute(); -### 6. 실패한 액션 확인 -```sql --- 최근 실패한 액션 조회 -SELECT - id, - action_type, - status, - error_message, - created_at -FROM pending_actions -WHERE status IN ('failed', 'rejected') -ORDER BY created_at DESC -LIMIT 10; +// 3. 끝! (폴링이 자동 실행) ``` -### 트러블슈팅 +--- -#### Knox 상신 성공했지만 DB에 없는 경우 -→ 불가능합니다. Saga Pattern으로 **DB 먼저 저장 후** Knox 상신하므로 이런 상황은 발생하지 않습니다. +## 📝 변경 이력 -#### Knox 상신 실패 시 -→ `pending_actions`에 'failed' 상태로 기록됩니다. `error_message`에서 원인을 확인하고 수동으로 재처리하세요. +### 2024-11 - Saga 패턴 전면 리팩터링 +- ✅ 기존 래퍼 함수 제거 +- ✅ Saga Orchestrator 클래스 도입 +- ✅ 비즈니스 프로세스 명시화 (7단계) +- ✅ 보상 트랜잭션 명확화 +- ✅ 코드 가독성 및 유지보수성 향상 -#### 결재 승인했지만 실행 안 되는 경우 -1. 폴링 서비스가 실행 중인지 확인 (로그) -2. `pending_actions` 테이블에 해당 apInfId가 있는지 확인 -3. 핸들러가 등록되어 있는지 확인 (`getRegisteredHandlers()`) +### 이전 +- 템플릿 시스템 구현 +- 폴링 서비스 최적화 (배치 처리) +- 캐시 무효화 전략 개선 +- 핸들러 레지스트리 패턴 도입 -#### EP ID가 없어서 결재 못 하는 경우 -→ 시스템 관리자에게 문의하여 Knox EP ID를 부여받아야 합니다. +--- +**문의 및 이슈:** 개발팀에 문의하세요. |
