# 결재 워크플로우 시스템 (Approval Workflow) Knox 결재 시스템과 연동된 **Saga 패턴 기반** 자동화 결재 워크플로우 모듈입니다. > **최신 업데이트:** Saga Orchestrator 패턴으로 전면 리팩터링 완료 (2024-11) > - 기존 래퍼 함수 제거, Saga 클래스로 완전 전환 > - 비즈니스 프로세스가 명시적으로 표현됨 > - 상세 내용: [SAGA_PATTERN.md](./SAGA_PATTERN.md) ## 📋 목차 - [빠른 시작](#-빠른-시작) - [Saga 패턴 아키텍처](#-saga-패턴-아키텍처) - [주요 기능](#-주요-기능) - [사용 방법](#-사용-방법) - [API 레퍼런스](#-api-레퍼런스) - [파일 구조](#-파일-구조) - [개발 가이드](#-개발-가이드) - [트러블슈팅](#-트러블슈팅) --- ## 🚀 빠른 시작 ### 1. 핸들러 등록 (앱 초기화 시 1회) ```typescript // instrumentation.ts import { registerActionHandler } from '@/lib/approval'; export async function register() { const { initializeApprovalHandlers } = await import('@/lib/approval/handlers-registry'); await initializeApprovalHandlers(); } ``` ### 2. 결재 상신 ```typescript 'use server'; import { ApprovalSubmissionSaga } from '@/lib/approval'; export async function requestWithApproval(data: RequestData) { // 1. 템플릿 변수 준비 const variables = await mapToTemplateVariables(data); // 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, } } ); return await saga.execute(); // → { pendingActionId: 1, approvalId: 'AP-2024-001', status: 'pending_approval' } } ``` ### 3. 자동 실행 (폴링 서비스가 처리) 결재 승인 시 등록된 핸들러가 자동으로 실행됩니다. 별도 작업 불필요! --- ## 🏗️ 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; } } ``` **장점:** - Knox 상신 전 DB 저장 실패 → 전체 실패 (정상) - Knox 상신 실패 → DB에 실패 기록 남음 (추적 가능) - Knox 상신 성공 후 DB 업데이트 실패 위험 제거 --- ## 🎯 주요 기능 ### 1. Saga Orchestrator (핵심) 세 가지 Saga 클래스로 결재 프로세스를 명시적으로 관리: | Saga 클래스 | 역할 | 단계 수 | |------------|------|--------| | `ApprovalSubmissionSaga` | 결재 상신 | 7단계 | | `ApprovalExecutionSaga` | 결재 승인 후 액션 실행 | 7단계 | | `ApprovalRejectionSaga` | 결재 반려 처리 | 4단계 | ### 2. 템플릿 시스템 DB에 저장된 HTML 템플릿으로 결재 문서 생성: ```typescript // 템플릿에 변수 삽입 const variables = { '업체명': 'ABC 협력업체', '담당자': '홍길동', '요청일': '2024-11-05' }; const saga = new ApprovalSubmissionSaga( 'vendor_registration', payload, { templateName: '정규업체 등록', // DB에서 조회 variables, // 변수 치환 // ... } ); ``` **HTML 변환 유틸리티:** ```typescript 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({ '제목': '내용', '담당자': '홍길동' }); ``` ### 3. 자동 폴링 서비스 1분마다 Knox API를 호출하여 결재 상태 자동 확인: ```typescript // instrumentation.ts import { startApprovalPollingScheduler } from '@/lib/approval'; export async function register() { // 1분마다 자동 실행 await startApprovalPollingScheduler(); } ``` **배치 처리 최적화:** - 최대 1000건씩 일괄 조회 - Knox API 부하 최소화 - 에러 격리 (일부 실패해도 계속 진행) ### 4. 캐시 관리 Next.js의 `revalidatePath`를 활용한 실시간 UI 업데이트: ```typescript import { revalidateApprovalLogs } from '@/lib/approval'; // 결재 상신/승인/반려 시 자동 호출됨 await revalidateApprovalLogs(); ``` **자동 무효화 시점:** - ✅ 결재 상신 시 - ✅ 결재 승인/반려 시 - ✅ 액션 실행 완료 시 자세한 내용: [README_CACHE.md](./README_CACHE.md) --- ## 💻 사용 방법 ### Step 1: 비즈니스 핸들러 작성 ```typescript // lib/my-feature/handlers.ts '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 { 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. 다른 핸들러들... console.log('[Approval Handlers] All handlers registered'); } ``` ### Step 3: 결재 서버 액션 작성 ```typescript // lib/my-feature/approval-actions.ts 'use server'; import { ApprovalSubmissionSaga } from '@/lib/approval'; import { mapToTemplateVariables } from './handlers'; 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가 필요합니다'); } // 2. 템플릿 변수 매핑 const variables = await mapToTemplateVariables(data); // 3. Saga로 결재 상신 const saga = new ApprovalSubmissionSaga( 'my_action_type', // 핸들러 등록 시 사용한 키 { id: data.id, reason: data.reason, }, { title: `내 기능 요청 - ${data.id}`, description: `ID ${data.id}에 대한 승인 요청`, templateName: '내 기능 템플릿', variables, approvers: data.approvers, currentUser: data.currentUser, } ); return await saga.execute(); } ``` ### Step 4: UI에서 호출 (미리보기 다이얼로그 사용) **⚠️ 중요: 모든 결재 상신은 반드시 미리보기 다이얼로그를 거쳐야 합니다.** 사용자가 결재 문서 내용을 확인하고 결재선을 직접 설정하는 과정이 필수입니다. ```typescript // components/my-feature/my-dialog-with-preview.tsx 'use client'; import { useState } from 'react'; import { ApprovalPreviewDialog } from '@/lib/approval/client'; // ⚠️ /client에서 import import { requestMyActionWithApproval } from '@/lib/my-feature/approval-actions'; import { useSession } from 'next-auth/react'; export function MyDialogWithPreview() { const { data: session } = useSession(); const [showPreview, setShowPreview] = useState(false); const [previewData, setPreviewData] = useState(null); const handleApproveClick = async (formData) => { // 1. 템플릿 변수 준비 const variables = await prepareTemplateVariables(formData); // 2. 미리보기 데이터 설정 setPreviewData({ variables, title: `내 기능 요청 - ${formData.id}`, description: `ID ${formData.id}에 대한 승인 요청`, formData, // 나중에 사용할 데이터 저장 }); // 3. 미리보기 다이얼로그 열기 setShowPreview(true); }; const handlePreviewConfirm = async (approvalData: { approvers: string[]; title: string; description?: string; }) => { try { const result = await requestMyActionWithApproval({ ...previewData.formData, currentUser: { id: Number(session?.user?.id), epId: session?.user?.epId || null, email: session?.user?.email || undefined, }, approvers: approvalData.approvers, // 미리보기에서 설정한 결재선 }); if (result.status === 'pending_approval') { toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`); } } catch (error) { toast.error('결재 상신 실패'); } }; return ( <> {/* 결재 미리보기 다이얼로그 */} {previewData && session?.user?.epId && ( )} ); } ``` **미리보기 다이얼로그가 필수인 이유:** - ✅ 결재 문서 내용 최종 확인 (데이터 정확성 검증) - ✅ 결재선(결재자) 직접 선택 (올바른 결재 경로 설정) - ✅ 결재 제목/설명 커스터마이징 (명확한 의사소통) - ✅ 사용자가 결재 내용을 인지하고 책임감 있게 상신 - ✅ 반응형 UI (Desktop: Dialog, Mobile: Drawer) --- ## 📚 API 레퍼런스 ### Saga 클래스 #### ApprovalSubmissionSaga 결재를 Knox 시스템에 상신합니다. ```typescript constructor( actionType: string, // 핸들러 등록 시 사용한 키 actionPayload: T, // 결재 승인 후 실행될 데이터 approvalConfig: ApprovalConfig ) async execute(): Promise // → { pendingActionId, approvalId, status: 'pending_approval' } ``` **ApprovalConfig:** ```typescript interface ApprovalConfig { title: string; // 결재 제목 description?: string; // 결재 설명 templateName: string; // DB에 저장된 템플릿명 variables: Record; // 템플릿 변수 approvers?: string[]; // Knox EP ID 배열 currentUser: { id: number; epId: string | null; email?: string; }; } ``` #### ApprovalExecutionSaga 결재 승인 후 액션을 실행합니다. (폴링 서비스가 자동 호출) ```typescript constructor(apInfId: string) // Knox 결재 ID async execute(): Promise // → 핸들러 실행 결과 ``` #### ApprovalRejectionSaga 결재 반려를 처리합니다. (폴링 서비스가 자동 호출) ```typescript constructor(apInfId: string, reason?: string) async execute(): Promise ``` ### 핸들러 레지스트리 ```typescript // 핸들러 등록 function registerActionHandler( actionType: string, handler: (payload: any) => Promise ): void // 등록된 핸들러 조회 (디버깅용) function getRegisteredHandlers(): string[] ``` ### 폴링 서비스 ```typescript // 폴링 스케줄러 시작 (instrumentation.ts에서 호출) async function startApprovalPollingScheduler(): Promise // 수동으로 pending 결재 확인 async function checkPendingApprovals(): Promise<{ success: boolean; checked: number; updated: number; executed: number; }> // 특정 결재 상태 확인 (UI에서 "새로고침" 버튼) async function checkSingleApprovalStatus(apInfId: string): Promise<{ success: boolean; oldStatus: string; newStatus: string; updated: boolean; executed: boolean; }> ``` ### 템플릿 유틸리티 ```typescript // HTML 테이블 생성 async function htmlTableConverter( data: any[], columns: Array<{ key: string; label: string }> ): Promise // HTML 리스트 생성 async function htmlListConverter(items: string[]): Promise // HTML Description List 생성 async function htmlDescriptionList( items: Record ): Promise // 템플릿 조회 async function getApprovalTemplateByName( name: string ): Promise