# 결재 워크플로우 시스템 (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
// 템플릿 변수 치환
async function replaceTemplateVariables(
content: string,
variables: Record
): Promise
```
### UI 컴포넌트
#### ApprovalPreviewDialog
결재 문서 미리보기 및 결재선 설정 다이얼로그 컴포넌트입니다.
```typescript
interface ApprovalPreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
templateName: string; // DB에서 조회할 템플릿 이름
variables: Record; // 템플릿 변수
title: string; // 결재 제목
description?: string; // 결재 설명
currentUser: {
id: number;
epId: string;
name?: string;
email?: string;
deptName?: string;
};
defaultApprovers?: string[]; // 초기 결재선 (EP ID 배열)
onConfirm: (data: {
approvers: string[];
title: string;
description?: string;
}) => Promise;
allowTitleEdit?: boolean; // 제목 수정 허용 (기본: true)
allowDescriptionEdit?: boolean; // 설명 수정 허용 (기본: true)
}
```
**주요 기능:**
- 템플릿 실시간 미리보기 (변수 자동 치환)
- 결재선 선택 UI (ApprovalLineSelector 통합)
- 제목/설명 수정
- 반응형 디자인 (Desktop: Dialog, Mobile: Drawer)
- 로딩 상태 자동 처리
**사용 예시:**
```typescript
// ⚠️ 클라이언트 컴포넌트는 반드시 /client에서 import
import { ApprovalPreviewDialog } from '@/lib/approval/client';
{
await submitApproval(approvers);
}}
/>
```
### 캐시 관리
```typescript
// 결재 로그 캐시 무효화
async function revalidateApprovalLogs(): Promise
// Pending Actions 캐시 무효화
async function revalidatePendingActions(): Promise
// 특정 결재 상세 캐시 무효화
async function revalidateApprovalDetail(apInfId: string): Promise
// 모든 결재 관련 캐시 무효화
async function revalidateAllApprovalCaches(): Promise
```
---
## 📁 파일 구조
```
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()
│
├── approval-preview-dialog.tsx # 결재 미리보기 다이얼로그 [클라이언트]
│ └── ApprovalPreviewDialog # 템플릿 미리보기 + 결재선 설정
│
├── cache-utils.ts # 캐시 관리 [서버]
│ ├── revalidateApprovalLogs()
│ ├── revalidatePendingActions()
│ └── revalidateApprovalDetail()
│
├── types.ts # 타입 정의
│ ├── ApprovalConfig
│ ├── ApprovalResult
│ └── TemplateVariables
│
├── index.ts # 서버 전용 API Export
├── client.ts # 클라이언트 컴포넌트 Export ⚠️
│
└── README.md # 이 문서
```
### Import 경로 가이드
**⚠️ 중요: 서버/클라이언트 코드는 반드시 분리해서 import 해야 합니다.**
```typescript
// ✅ 서버 액션 또는 서버 컴포넌트에서
import {
ApprovalSubmissionSaga,
getApprovalTemplateByName,
htmlTableConverter
} from '@/lib/approval';
// ✅ 클라이언트 컴포넌트에서
import { ApprovalPreviewDialog } from '@/lib/approval/client';
// ❌ 잘못된 사용 (서버 코드가 클라이언트 번들에 포함됨)
import { ApprovalPreviewDialog } from '@/lib/approval';
```
---
## 🛠️ 개발 가이드
### 새로운 결재 타입 추가하기
**1. 비즈니스 로직 작성**
```typescript
// lib/my-new-feature/handlers.ts
export async function myNewFeatureInternal(payload: MyPayload) {
// 실제 DB 작업
return await db.insert(...);
}
```
**2. 핸들러 등록**
```typescript
// lib/approval/handlers-registry.ts
export async function initializeApprovalHandlers() {
// 기존 핸들러들...
// 새 핸들러 추가
const { myNewFeatureInternal } = await import('@/lib/my-new-feature/handlers');
registerActionHandler('my_new_feature', myNewFeatureInternal);
}
```
**3. 결재 서버 액션 작성**
```typescript
// 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)**
```sql
INSERT INTO approval_templates (name, content, description) VALUES (
'내 새 기능',
'... {{변수명}} ...
',
'내 새 기능 결재 템플릿'
);
```
끝! 폴링 서비스가 자동으로 처리합니다.
### ⚠️ Request Context 주의사항 (필수)
**핵심: 결재 핸들러는 Cronjob 환경에서 실행됩니다!**
결재 승인 후 실행되는 핸들러는 **폴링 서비스(Cronjob)**에 의해 호출됩니다.
이 환경에서는 **Request Context가 존재하지 않으므로** 다음 함수들을 **절대 사용할 수 없습니다**:
```typescript
// ❌ Cronjob 환경에서 사용 불가
import { headers } from 'next/headers';
import { getServerSession } from 'next-auth';
export async function myHandler(payload) {
const headersList = headers(); // ❌ Error: headers() called outside request scope
const session = await getServerSession(); // ❌ Error: cannot access request
revalidatePath('/some-path'); // ❌ Error: revalidatePath outside request scope
}
```
#### 올바른 해결 방법
**1. 유저 정보가 필요한 경우 → Payload에 포함**
```typescript
// ✅ 결재 상신 시 currentUser를 payload에 포함
export async function requestWithApproval(data: RequestData) {
const saga = new ApprovalSubmissionSaga(
'my_action',
{
id: data.id,
currentUser: { // ⚠️ 핸들러에서 필요한 유저 정보 포함
id: session.user.id,
name: session.user.name,
email: session.user.email,
epId: session.user.epId,
},
},
{ ... }
);
}
// ✅ 핸들러에서 payload의 currentUser 사용
export async function myHandlerInternal(payload: {
id: number;
currentUser: {
id: string | number;
name?: string | null;
email?: string | null;
epId?: string | null;
};
}) {
// payload에서 유저 정보 사용
const userId = payload.currentUser.id;
// DB 작업
await db.insert(myTable).values({
createdBy: userId,
...
});
}
```
**2. Session/Payload 분기 처리 (기존 함수 호환)**
기존 함수를 cronjob과 일반 환경에서 모두 사용하려면 분기 처리:
```typescript
// ✅ Session/Payload 분기 처리
export async function myServiceFunction({
id,
currentUser: providedUser // 선택적 파라미터
}: {
id: number;
currentUser?: {
id: string | number;
name?: string | null;
email?: string | null;
};
}) {
let currentUser;
if (providedUser) {
// ✅ Cronjob 환경: payload에서 받은 유저 정보 사용
currentUser = providedUser;
} else {
// ✅ 일반 환경: session에서 유저 정보 가져오기
const session = await getServerSession(authOptions);
if (!session?.user) {
throw new Error("인증이 필요합니다.");
}
currentUser = session.user;
}
// 이제 currentUser 안전하게 사용 가능
await db.insert(...).values({
createdBy: currentUser.id,
...
});
}
```
**3. Revalidate는 API 경로 사용**
```typescript
// ❌ 직접 호출 불가
revalidatePath('/my-path');
// ✅ API 경로를 통해 호출
await fetch('/api/revalidate/my-resource', {
method: 'POST',
});
```
#### 실제 사례: RFQ 발송
```typescript
// lib/rfq-last/service.ts
export interface SendRfqParams {
rfqId: number;
vendors: VendorForSend[];
attachmentIds: number[];
currentUser?: { // ⚠️ Cronjob 환경을 위한 선택적 파라미터
id: string | number;
name?: string | null;
email?: string | null;
epId?: string | null;
};
}
export async function sendRfqToVendors({
rfqId,
vendors,
attachmentIds,
currentUser: providedUser
}: SendRfqParams) {
let currentUser;
if (providedUser) {
// ✅ Cronjob 환경: payload에서 받은 유저 정보 사용
currentUser = providedUser;
} else {
// ✅ 일반 환경: session에서 유저 정보 가져오기
const session = await getServerSession(authOptions);
if (!session?.user) {
throw new Error("인증이 필요합니다.");
}
currentUser = session.user;
}
// ... 나머지 로직
}
// lib/rfq-last/approval-handlers.ts
export async function sendRfqWithApprovalInternal(payload: {
rfqId: number;
vendors: any[];
attachmentIds: number[];
currentUser: { // ⚠️ 필수 정보
id: string | number;
name?: string | null;
email?: string | null;
epId?: string | null;
};
}) {
// ✅ payload의 currentUser를 서비스 함수에 전달
const result = await sendRfqToVendors({
rfqId: payload.rfqId,
vendors: payload.vendors,
attachmentIds: payload.attachmentIds,
currentUser: payload.currentUser, // ⚠️ 전달
});
return result;
}
// lib/rfq-last/approval-actions.ts
export async function requestRfqSendWithApproval(data: RfqSendApprovalData) {
const saga = new ApprovalSubmissionSaga(
'rfq_send_with_attachments',
{
rfqId: data.rfqId,
vendors: data.vendors,
attachmentIds: data.attachmentIds,
currentUser: { // ⚠️ payload에 포함
id: data.currentUser.id,
name: data.currentUser.name,
email: data.currentUser.email,
epId: data.currentUser.epId,
},
},
{ ... }
);
}
```
#### 체크리스트
새로운 결재 핸들러를 작성할 때 다음을 확인하세요:
- [ ] 핸들러 함수에서 `headers()` 사용하지 않음
- [ ] 핸들러 함수에서 `getServerSession()` 직접 호출하지 않음
- [ ] 핸들러 함수에서 `revalidatePath()` 직접 호출하지 않음
- [ ] 유저 정보가 필요하면 payload에 `currentUser` 포함
- [ ] 기존 서비스 함수는 session/payload 분기 처리
- [ ] 캐시 무효화는 API 경로 사용
#### 참고 문서
- [CRONJOB_CONTEXT_FIX.md](./CRONJOB_CONTEXT_FIX.md) - Request Context 문제 상세 해결 가이드
- [Next.js Dynamic API Error](https://nextjs.org/docs/messages/next-dynamic-api-wrong-context)
### 테스트하기
```typescript
import { ApprovalSubmissionSaga } from '@/lib/approval';
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();
});
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');
});
});
```
### 디버깅
**1. 로그 확인**
```typescript
// 각 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
```
**2. 등록된 핸들러 확인**
```typescript
import { getRegisteredHandlers } from '@/lib/approval';
console.log(getRegisteredHandlers());
// → ['pq_investigation_request', 'vendor_regular_registration', ...]
```
**3. Pending Actions 상태 확인**
```sql
SELECT * FROM pending_actions
WHERE status = 'pending'
ORDER BY created_at DESC;
```
**4. 폴링 서비스 로그**
```typescript
[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
```
### 성능 최적화
**1. 배치 크기 조정**
```typescript
// approval-polling-service.ts
const batchSize = 1000; // Knox API 호출 시 배치 크기
```
**2. 폴링 간격 조정**
```typescript
// approval-polling-service.ts
cron.schedule(
'* * * * *', // 1분마다 (필요시 조정)
async () => { ... }
);
```
**3. 캐시 전략**
```typescript
// 특정 결재만 무효화 (더 효율적)
await revalidateApprovalDetail(apInfId);
// vs 전체 무효화 (부담)
await revalidateAllApprovalCaches();
```
---
## 🔧 트러블슈팅
### 1. Knox EP ID가 없음
**증상:**
```
Error: Knox EP ID가 필요합니다
```
**원인:** 사용자의 `epId`가 `null`
**해결:**
```typescript
// UI에서 미리 체크
if (!session?.user?.epId) {
toast.error('Knox EP ID가 없습니다. 시스템 관리자에게 문의하세요.');
return;
}
// 또는 버튼 비활성화
```
### 2. 핸들러를 찾을 수 없음
**증상:**
```
Error: Handler not found for action type: my_action
```
**원인:** 핸들러가 등록되지 않음
**해결:**
```typescript
// 1. handlers-registry.ts에서 등록 확인
registerActionHandler('my_action', myActionInternal);
// 2. instrumentation.ts에서 초기화 확인
await initializeApprovalHandlers();
// 3. 등록된 핸들러 목록 확인
console.log(getRegisteredHandlers());
```
### 3. 템플릿을 찾을 수 없음
**증상:**
```
Warning: Template not found: 내 템플릿
```
**원인:** DB에 템플릿이 없음
**해결:**
```sql
-- DB에 템플릿 확인
SELECT * FROM approval_templates WHERE name = '내 템플릿';
-- 없으면 생성
INSERT INTO approval_templates (name, content, description) VALUES (...);
```
### 4. 폴링 서비스가 동작하지 않음
**증상:** 결재 승인했는데 액션이 실행되지 않음
**확인:**
```typescript
// 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';
```
### 5. 캐시가 갱신되지 않음
**증상:** UI에 최신 결재 상태가 반영되지 않음
**해결:**
```typescript
// 1. 수동으로 캐시 무효화
import { revalidateApprovalLogs } from '@/lib/approval';
await revalidateApprovalLogs();
// 2. API 경로 확인 (app/api/revalidate/approval/route.ts)
// POST /api/revalidate/approval 호출
```
자세한 내용: [README_CACHE.md](./README_CACHE.md)
### 6. Knox 상신 실패
**증상:**
```
Error: Knox 상신 실패: Network error
```
**확인:**
```typescript
// 1. Knox API 설정 확인
// lib/knox-api/approval/approval.ts
// 2. 네트워크 연결 확인
// 3. pending_actions 상태 확인
SELECT * FROM pending_actions
WHERE status = 'failed'
ORDER BY created_at DESC;
```
**복구:**
```typescript
// Saga 패턴 덕분에 DB에 실패 기록이 남아있음
// 문제 해결 후 재시도 가능
const saga = new ApprovalSubmissionSaga(...);
await saga.execute();
```
### 7. Request Context 오류 (headers/session) ⚠️
**증상:**
```
Error: `headers` was called outside a request scope
Error: Cannot access request in cronjob context
```
**원인:** 결재 핸들러는 Cronjob 환경에서 실행되므로 Request Context가 없음
**해결:**
```typescript
// ❌ 잘못된 코드
export async function myHandler(payload) {
const session = await getServerSession(authOptions); // ❌ Error!
const userId = session.user.id;
}
// ✅ 올바른 코드 - payload에서 유저 정보 사용
export async function myHandler(payload: {
id: number;
currentUser: {
id: string | number;
name?: string | null;
email?: string | null;
};
}) {
const userId = payload.currentUser.id; // ✅ OK
}
// ✅ 결재 상신 시 currentUser 포함
const saga = new ApprovalSubmissionSaga(
'my_action',
{
id: data.id,
currentUser: { // ⚠️ 필수
id: session.user.id,
name: session.user.name,
email: session.user.email,
epId: session.user.epId,
},
},
{ ... }
);
```
**기존 서비스 함수 호환:**
```typescript
// ✅ Session/Payload 분기 처리
export async function myServiceFunction({
id,
currentUser: providedUser
}: {
id: number;
currentUser?: { id: string | number; ... };
}) {
let currentUser;
if (providedUser) {
// Cronjob 환경
currentUser = providedUser;
} else {
// 일반 환경
const session = await getServerSession(authOptions);
currentUser = session.user;
}
// 안전하게 사용
await db.insert(...).values({ createdBy: currentUser.id });
}
```
**자세한 내용:** [Request Context 주의사항](#️-request-context-주의사항-필수) 섹션 참조
---
## 📖 추가 문서
- **[CRONJOB_CONTEXT_FIX.md](./CRONJOB_CONTEXT_FIX.md)** - Request Context 문제 상세 해결 가이드 ⚠️
- **[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)** - 아키텍처 평가 및 베스트 프랙티스
---
## 🎯 핵심 요약
### 언제 사용하는가?
- ✅ 비즈니스 액션에 결재 승인이 필요한 경우
- ✅ Knox 결재 시스템과 자동 연동이 필요한 경우
- ✅ 결재 승인 후 자동으로 액션을 실행하고 싶은 경우
- ✅ **모든 결재는 미리보기 다이얼로그를 통해 상신해야 함 (필수)**
### 왜 Saga 패턴인가?
- ✅ Knox는 외부 시스템 → 일반 트랜잭션 불가
- ✅ DB 저장 → Knox 상신 순서로 데이터 정합성 보장
- ✅ 실패 시 보상 트랜잭션으로 일관성 유지
### 어떻게 사용하는가?
1. **핸들러 작성 및 등록** (1회)
2. **UI에서 미리보기 다이얼로그 열기** (필수)
3. **사용자가 결재선 설정 후 상신**
4. **폴링이 자동 실행** (설정만 하면 끝)
### 코드 요약
```typescript
// 1. 핸들러 등록 (서버 시작 시 1회)
registerActionHandler('my_action', myActionHandler);
// 2. UI에서 미리보기 다이얼로그 열기
const { variables } = await prepareTemplateVariables(data);
setShowPreview(true);
// 3. 사용자가 확인 후 결재 상신
{
await submitApproval(approvers);
}}
/>
// 4. 끝! (폴링이 자동 실행)
```
---
## 📝 변경 이력
### 2024-11-06 - Request Context 호환성 개선 (RFQ 발송)
- ✅ Cronjob 환경에서 Request Context 오류 해결
- ✅ `headers()`, `getServerSession()` 호출 문제 수정
- ✅ Session/Payload 분기 처리 패턴 도입
- ✅ `currentUser`를 payload에 포함하는 표준 패턴 확립
- ✅ 기존 서비스 함수 호환성 유지 (선택적 `currentUser` 파라미터)
- ✅ RFQ 발송 핸들러에 적용 및 검증
- ✅ README에 "Request Context 주의사항" 섹션 추가
- ✅ 트러블슈팅 가이드 업데이트
### 2024-11-06 - 결재 미리보기 다이얼로그 도입
- ✅ `ApprovalPreviewDialog` 공통 컴포넌트 추가
- ✅ 모든 결재 상신은 미리보기를 거치도록 프로세스 변경
- ✅ 사용자가 결재 문서와 결재선을 확인하는 필수 단계 추가
- ✅ 템플릿 실시간 미리보기 및 변수 치환 기능
- ✅ 결재선 선택 UI 통합 (ApprovalLineSelector)
- ✅ 반응형 디자인 (Desktop: Dialog, Mobile: Drawer)
- ✅ 서버/클라이언트 코드 분리 (`index.ts` / `client.ts`)
### 2024-11-05 - Saga 패턴 전면 리팩터링
- ✅ 기존 래퍼 함수 제거
- ✅ Saga Orchestrator 클래스 도입
- ✅ 비즈니스 프로세스 명시화 (7단계)
- ✅ 보상 트랜잭션 명확화
- ✅ 코드 가독성 및 유지보수성 향상
### 이전
- 템플릿 시스템 구현
- 폴링 서비스 최적화 (배치 처리)
- 캐시 무효화 전략 개선
- 핸들러 레지스트리 패턴 도입
---
**문의 및 이슈:** 개발팀에 문의하세요.