summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-05 20:15:42 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-05 20:15:42 +0900
commitb191144ec07c2f7eb9ad33ea6f2d7e31b6e00fce (patch)
tree8f3665d022a71233630d5ca04c87c3674250df2c
parent551129656039aae409b3af51ce4acbb59f60229f (diff)
(김준회) 결재 패턴 README.md 정리
-rw-r--r--lib/approval/README.md1845
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를 부여받아야 합니다.
+---
+**문의 및 이슈:** 개발팀에 문의하세요.