jskim-node #423

Merged
kjs merged 27 commits from jskim-node into main 2026-03-20 16:10:33 +09:00
5 changed files with 506 additions and 13 deletions
Showing only changes of commit 5949ea22b5 - Show all commits

View File

@ -0,0 +1,355 @@
# 배치 스케줄러 + 노드 플로우 연동 계획서
## 1. 배경 및 목적
### 현재 상태
현재 시스템에는 두 개의 독립적인 실행 엔진이 있다:
| 시스템 | 역할 | 트리거 방식 |
|--------|------|-------------|
| **배치 스케줄러** | Cron 기반 자동 실행 (데이터 복사만 가능) | 시간 기반 (node-cron) |
| **노드 플로우 엔진** | 조건/변환/INSERT/UPDATE/DELETE 등 복합 로직 | 버튼 클릭 (수동) |
### 문제
- 배치는 **INSERT/UPSERT만** 가능하고, 조건 기반 UPDATE/DELETE를 못 함
- 노드 플로우는 강력하지만 **수동 실행만** 가능 (버튼 클릭 필수)
- "퇴사일이 지나면 자동으로 퇴사 처리" 같은 **시간 기반 비즈니스 로직**을 구현할 수 없음
### 목표
배치 스케줄러가 노드 플로우를 자동 실행할 수 있도록 연동하여,
시간 기반 비즈니스 로직 자동화를 지원한다.
```
[배치 스케줄러] ──Cron 트리거──> [노드 플로우 실행 엔진]
│ │
│ ├── 테이블 소스 조회
│ ├── 조건 분기
│ ├── UPDATE / DELETE / INSERT
│ ├── 이메일 발송
│ └── 로깅
└── 실행 로그 기록 (batch_execution_logs)
```
---
## 2. 사용 시나리오
### 시나리오 A: 자동 퇴사 처리
```
매일 자정 실행:
1. user_info에서 퇴사일 <= NOW() AND 상태 != '퇴사' 인 사람 조회
2. 해당 사용자의 상태를 '퇴사'로 UPDATE
3. 관리자에게 이메일 알림 발송
```
### 시나리오 B: 월말 재고 마감
```
매월 1일 00:00 실행:
1. 전월 재고 데이터를 재고마감 테이블로 INSERT
2. 이월 수량 계산 후 UPDATE
```
### 시나리오 C: 미납 알림
```
매일 09:00 실행:
1. 납기일이 지난 미납 주문 조회
2. 담당자에게 이메일 발송
3. 알림 로그 INSERT
```
### 시나리오 D: 외부 API 연동 자동화
```
매시간 실행:
1. 외부 REST API에서 데이터 조회
2. 조건 필터링 (변경된 데이터만)
3. 내부 테이블에 UPSERT
```
---
## 3. 구현 범위
### 3.1 DB 변경 (batch_configs 테이블 확장)
```sql
-- batch_configs 테이블에 컬럼 추가
ALTER TABLE batch_configs
ADD COLUMN execution_type VARCHAR(20) DEFAULT 'mapping',
ADD COLUMN node_flow_id INTEGER DEFAULT NULL,
ADD COLUMN node_flow_context JSONB DEFAULT NULL;
-- execution_type: 'mapping' (기존 데이터 복사) | 'node_flow' (노드 플로우 실행)
-- node_flow_id: node_flows 테이블의 flow_id (FK)
-- node_flow_context: 플로우 실행 시 전달할 컨텍스트 데이터 (선택)
COMMENT ON COLUMN batch_configs.execution_type IS '실행 타입: mapping(기존 데이터 복사), node_flow(노드 플로우 실행)';
COMMENT ON COLUMN batch_configs.node_flow_id IS '연결된 노드 플로우 ID (execution_type이 node_flow일 때 사용)';
COMMENT ON COLUMN batch_configs.node_flow_context IS '플로우 실행 시 전달할 컨텍스트 데이터 (JSON)';
```
기존 데이터에 영향 없음 (`DEFAULT 'mapping'`으로 하위 호환성 보장)
### 3.2 백엔드 변경
#### BatchSchedulerService 수정 (핵심)
`executeBatchConfig()` 메서드에서 `execution_type` 분기:
```
executeBatchConfig(config)
├── config.execution_type === 'mapping'
│ └── 기존 executeBatchMappings() (변경 없음)
└── config.execution_type === 'node_flow'
└── NodeFlowExecutionService.executeFlow()
├── 노드 플로우 조회
├── 위상 정렬
├── 레벨별 실행
└── 결과 반환
```
수정 파일:
- `backend-node/src/services/batchSchedulerService.ts`
- `executeBatchConfig()` 에 node_flow 분기 추가
- 노드 플로우 실행 결과를 배치 로그 형식으로 변환
#### 배치 설정 API 수정
수정 파일:
- `backend-node/src/types/batchTypes.ts`
- `BatchConfig` 인터페이스에 `execution_type`, `node_flow_id`, `node_flow_context` 추가
- `CreateBatchConfigRequest`, `UpdateBatchConfigRequest` 에도 추가
- `backend-node/src/services/batchService.ts`
- `createBatchConfig()` - 새 필드 INSERT
- `updateBatchConfig()` - 새 필드 UPDATE
- `backend-node/src/controllers/batchManagementController.ts`
- 생성/수정 시 새 필드 처리
#### 노드 플로우 목록 API (배치용)
추가 파일/수정:
- `backend-node/src/routes/batchManagementRoutes.ts`
- `GET /api/batch-management/node-flows` 추가 (배치 설정 UI에서 플로우 선택용)
### 3.3 프론트엔드 변경
#### 배치 생성/편집 UI 수정
수정 파일:
- `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx`
- `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx`
변경 내용:
- "실행 타입" 선택 추가 (기존 매핑 / 노드 플로우)
- 노드 플로우 선택 시: 플로우 드롭다운 표시 (기존 매핑 설정 숨김)
- 노드 플로우 선택 시: 컨텍스트 데이터 입력 (선택사항, JSON)
```
┌─────────────────────────────────────────┐
│ 배치 설정 │
├─────────────────────────────────────────┤
│ 배치명: [자동 퇴사 처리 ] │
│ 설명: [퇴사일 경과 사용자 자동 처리] │
│ Cron: [0 0 * * * ] │
│ │
│ 실행 타입: ○ 데이터 매핑 ● 노드 플로우 │
│ │
│ ┌─ 노드 플로우 선택 ─────────────────┐ │
│ │ [▾ 자동 퇴사 처리 플로우 ] │ │
│ │ │ │
│ │ 플로우 설명: user_info에서 퇴사일..│ │
│ │ 노드 수: 4개 │ │
│ └────────────────────────────────────┘ │
│ │
│ [취소] [저장] │
└─────────────────────────────────────────┘
```
#### 배치 목록 UI 수정
수정 파일:
- `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx`
변경 내용:
- 목록에 "실행 타입" 컬럼 추가 (배지: `매핑` / `플로우`)
- 노드 플로우 타입은 연결된 플로우명 표시
---
## 4. 변경 파일 목록
### DB
| 파일 | 변경 | 설명 |
|------|------|------|
| `db/migrations/XXXX_batch_node_flow_integration.sql` | 신규 | ALTER TABLE batch_configs |
### 백엔드
| 파일 | 변경 | 설명 |
|------|------|------|
| `backend-node/src/services/batchSchedulerService.ts` | 수정 | executeBatchConfig에 node_flow 분기 |
| `backend-node/src/types/batchTypes.ts` | 수정 | BatchConfig 타입에 새 필드 추가 |
| `backend-node/src/services/batchService.ts` | 수정 | create/update에 새 필드 처리 |
| `backend-node/src/controllers/batchManagementController.ts` | 수정 | 새 필드 API 처리 |
| `backend-node/src/routes/batchManagementRoutes.ts` | 수정 | node-flows 목록 엔드포인트 추가 |
### 프론트엔드
| 파일 | 변경 | 설명 |
|------|------|------|
| `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` | 수정 | 목록에 실행 타입 표시 |
| `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 |
| `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 |
---
## 5. 핵심 구현 상세
### 5.1 BatchSchedulerService 변경 (가장 중요)
```typescript
// batchSchedulerService.ts - executeBatchConfig 메서드 수정
static async executeBatchConfig(config: any) {
const startTime = new Date();
let executionLog: any = null;
try {
// ... 실행 로그 생성 (기존 코드 유지) ...
let result;
// 실행 타입에 따라 분기
if (config.execution_type === 'node_flow' && config.node_flow_id) {
// 노드 플로우 실행
result = await this.executeNodeFlow(config);
} else {
// 기존 매핑 실행 (하위 호환)
result = await this.executeBatchMappings(config);
}
// ... 실행 로그 업데이트 (기존 코드 유지) ...
return result;
} catch (error) {
// ... 에러 처리 (기존 코드 유지) ...
}
}
/**
* 노드 플로우 실행 (신규)
*/
private static async executeNodeFlow(config: any) {
const { NodeFlowExecutionService } = await import('./nodeFlowExecutionService');
const context = {
sourceData: [],
dataSourceType: 'none',
nodeResults: new Map(),
executionOrder: [],
buttonContext: {
buttonId: `batch_${config.id}`,
companyCode: config.company_code,
userId: config.created_by || 'batch_system',
formData: config.node_flow_context || {},
},
};
const flowResult = await NodeFlowExecutionService.executeFlow(
config.node_flow_id,
context
);
// 노드 플로우 결과를 배치 로그 형식으로 변환
return {
totalRecords: flowResult.totalNodes || 0,
successRecords: flowResult.successNodes || 0,
failedRecords: flowResult.failedNodes || 0,
};
}
```
### 5.2 실행 결과 매핑
노드 플로우 결과 → 배치 로그 변환:
| 노드 플로우 결과 | 배치 로그 필드 | 설명 |
|------------------|---------------|------|
| 전체 노드 수 | total_records | 실행 대상 노드 수 |
| 성공 노드 수 | success_records | 성공적으로 실행된 노드 |
| 실패 노드 수 | failed_records | 실패한 노드 |
| 에러 메시지 | error_message | 첫 번째 실패 노드의 에러 |
### 5.3 보안 고려사항
- 배치에서 실행되는 노드 플로우도 **company_code** 필터링 적용
- 배치 설정의 company_code와 노드 플로우의 company_code가 일치해야 함
- 최고 관리자(`*`)는 모든 플로우 실행 가능
- 실행 로그에 `batch_system`으로 사용자 기록
---
## 6. 구현 순서
### Phase 1: DB + 백엔드 (1일)
1. 마이그레이션 SQL 작성 및 실행
2. `batchTypes.ts` 타입 수정
3. `batchService.ts` create/update 수정
4. `batchSchedulerService.ts` 핵심 분기 로직 추가
5. `batchManagementRoutes.ts` 노드 플로우 목록 API 추가
6. 수동 실행 테스트 (`POST /batch-configs/:id/execute`)
### Phase 2: 프론트엔드 (1일)
1. 배치 생성 페이지에 실행 타입 선택 추가
2. 노드 플로우 드롭다운 구현
3. 배치 편집 페이지 동일 적용
4. 배치 목록에 실행 타입 배지 표시
### Phase 3: 테스트 및 검증 (0.5일)
1. 테스트용 노드 플로우 생성 (간단한 UPDATE)
2. 배치 설정에 연결
3. 수동 실행 테스트
4. Cron 스케줄 자동 실행 테스트
5. 실행 로그 확인
---
## 7. 리스크 및 대응
### 7.1 노드 플로우 실행 시간 초과
- **리스크**: 복잡한 플로우가 오래 걸려서 다음 스케줄과 겹칠 수 있음
- **대응**: 실행 중인 배치는 중복 실행 방지 (락 메커니즘) - Phase 2 이후 고려
### 7.2 노드 플로우 삭제 시 배치 깨짐
- **리스크**: 연결된 노드 플로우가 삭제되면 배치 실행 실패
- **대응**:
- 플로우 존재 여부 체크 후 실행
- 실패 시 로그에 "플로우를 찾을 수 없습니다" 기록
- (향후) 플로우 삭제 시 연결된 배치가 있으면 경고
### 7.3 멀티 인스턴스 환경
- **리스크**: 서버가 여러 대일 때 같은 배치가 중복 실행
- **대응**: 현재 단일 인스턴스 운영이므로 당장은 문제 없음. 향후 Redis 기반 분산 락 고려
---
## 8. 기대 효과
1. **시간 기반 비즈니스 자동화**: 수동 작업 없이 조건 충족 시 자동 처리
2. **기존 인프라 재활용**: 검증된 배치 스케줄러(1,200+건 성공) + 강력한 노드 플로우 엔진
3. **최소 코드 변경**: DB 컬럼 3개 + 백엔드 분기 1개 + 프론트 UI 확장
4. **확장성**: 향후 이벤트 트리거(데이터 변경 감지) 등으로 확장 가능

View File

@ -306,16 +306,126 @@ select {
}
}
/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */
[data-sonner-toaster] [data-sonner-toast] {
animation: none !important;
transition: none !important;
opacity: 1 !important;
transform: none !important;
/* ===== Sonner Toast - B안 (하단 중앙 스낵바) ===== */
/* 기본 토스트: 다크 배경 스낵바 */
[data-sonner-toaster] [data-sonner-toast].sonner-toast-snackbar {
--normal-bg: hsl(222 30% 16%);
--normal-text: hsl(210 20% 92%);
--normal-border: hsl(222 20% 24%);
background: var(--normal-bg);
color: var(--normal-text);
border: 1px solid var(--normal-border);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
gap: 10px;
}
[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] {
animation: none !important;
/* 다크모드 토스트 */
.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-snackbar {
--normal-bg: hsl(220 25% 14%);
--normal-border: hsl(220 20% 22%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
/* 성공 토스트 - 좌측 초록 바 */
[data-sonner-toaster] [data-sonner-toast].sonner-toast-success {
--success-bg: hsl(222 30% 16%);
--success-text: hsl(210 20% 92%);
--success-border: hsl(222 20% 24%);
background: var(--success-bg) !important;
color: var(--success-text) !important;
border: 1px solid var(--success-border) !important;
border-left: 3px solid hsl(142 76% 42%) !important;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}
.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-success {
--success-bg: hsl(220 25% 14%) !important;
--success-border: hsl(220 20% 22%) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
/* 에러 토스트 - 좌측 빨간 바 + 약간 붉은 배경 */
[data-sonner-toaster] [data-sonner-toast].sonner-toast-error {
--error-bg: hsl(0 30% 14%);
--error-text: hsl(0 20% 92%);
--error-border: hsl(0 20% 22%);
background: var(--error-bg) !important;
color: var(--error-text) !important;
border: 1px solid var(--error-border) !important;
border-left: 3px solid hsl(0 72% 55%) !important;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}
.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-error {
--error-bg: hsl(0 25% 10%) !important;
--error-border: hsl(0 20% 18%) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
/* 경고 토스트 - 좌측 노란 바 */
[data-sonner-toaster] [data-sonner-toast].sonner-toast-warning {
background: hsl(222 30% 16%) !important;
color: hsl(210 20% 92%) !important;
border: 1px solid hsl(222 20% 24%) !important;
border-left: 3px solid hsl(38 92% 50%) !important;
border-radius: 10px;
}
/* info 토스트 - 좌측 파란 바 */
[data-sonner-toaster] [data-sonner-toast].sonner-toast-info {
background: hsl(222 30% 16%) !important;
color: hsl(210 20% 92%) !important;
border: 1px solid hsl(222 20% 24%) !important;
border-left: 3px solid hsl(217 91% 60%) !important;
border-radius: 10px;
}
/* 토스트 내부 설명 텍스트 */
[data-sonner-toaster] [data-sonner-toast] [data-description] {
color: hsl(210 15% 70%) !important;
font-size: 12px !important;
}
/* 토스트 닫기 버튼: 토스트 안쪽 우측 상단 배치 */
[data-sonner-toaster] [data-sonner-toast] [data-close-button] {
position: absolute !important;
top: 50% !important;
right: 8px !important;
left: auto !important;
transform: translateY(-50%) !important;
width: 20px !important;
height: 20px !important;
background: transparent !important;
border: none !important;
border-radius: 4px !important;
color: hsl(210 15% 55%) !important;
opacity: 0.6;
transition: opacity 0.15s, background 0.15s;
}
[data-sonner-toaster] [data-sonner-toast] [data-close-button]:hover {
background: hsl(220 20% 24%) !important;
color: hsl(210 20% 85%) !important;
opacity: 1;
}
/* 토스트 액션 버튼 */
[data-sonner-toaster] [data-sonner-toast] [data-button] {
color: hsl(217 91% 68%) !important;
font-weight: 700;
font-size: 12px;
}
/* 애니메이션 제어: 부드러운 슬라이드 업만 허용, 나머지 제거 */
[data-sonner-toaster] [data-sonner-toast][data-removed="true"] {
animation: none !important;
}

View File

@ -4,7 +4,7 @@ import "./globals.css";
import { ThemeProvider } from "@/components/providers/ThemeProvider";
import { QueryProvider } from "@/providers/QueryProvider";
import { RegistryProvider } from "./registry-provider";
import { Toaster } from "sonner";
import { Toaster } from "@/components/ui/sonner";
const inter = Inter({
subsets: ["latin"],
@ -45,7 +45,7 @@ export default function RootLayout({
<ThemeProvider>
<QueryProvider>
<RegistryProvider>{children}</RegistryProvider>
<Toaster position="top-right" />
<Toaster />
</QueryProvider>
</ThemeProvider>
{/* Portal 컨테이너 */}

View File

@ -0,0 +1,28 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as SonnerToaster } from "sonner";
export function Toaster() {
const { theme = "system" } = useTheme();
return (
<SonnerToaster
position="bottom-center"
theme={theme as "light" | "dark" | "system"}
closeButton
richColors
duration={2500}
toastOptions={{
classNames: {
toast: "sonner-toast-snackbar",
success: "sonner-toast-success",
error: "sonner-toast-error",
warning: "sonner-toast-warning",
info: "sonner-toast-info",
closeButton: "sonner-close-btn",
},
}}
/>
);
}

View File

@ -6317,7 +6317,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
"hover:bg-accent cursor-pointer border-b border-border/50 transition-[background] duration-75",
index % 2 === 0 ? "bg-background" : "bg-muted/20",
isRowSelected && "!bg-primary/10 hover:!bg-primary/15",
isRowFocused && "ring-primary/50 ring-1 ring-inset",
isRowFocused && "bg-accent/50",
isDragEnabled && "cursor-grab active:cursor-grabbing",
isDragging && "bg-muted opacity-50",
isDropTarget && "border-t-primary border-t-2",
@ -6381,10 +6381,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]",
column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset",
isCellFocused && !editingCell && "bg-primary/5 shadow-[inset_0_0_0_2px_hsl(var(--primary))]",
editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0",
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40",
cellValidationError && "bg-red-50 shadow-[inset_0_0_0_2px_hsl(0_84%_60%)] dark:bg-red-950/40",
isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50",
column.editable === false && "bg-muted/10 dark:bg-muted/10",
// 코드 컬럼: mono 폰트 + primary 색상