Merge pull request 'feature/screen-management' (#91) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/91
This commit is contained in:
kjs 2025-10-13 17:48:52 +09:00
commit 534d174234
18 changed files with 2333 additions and 773 deletions

View File

@ -236,11 +236,15 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
if (fieldMap[searchField as string]) {
if (searchField === "tel") {
whereConditions.push(`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`);
whereConditions.push(
`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`
);
queryParams.push(`%${searchValue}%`);
paramIndex++;
} else {
whereConditions.push(`${fieldMap[searchField as string]} ILIKE $${paramIndex}`);
whereConditions.push(
`${fieldMap[searchField as string]} ILIKE $${paramIndex}`
);
queryParams.push(`%${searchValue}%`);
paramIndex++;
}
@ -271,7 +275,9 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
// 전화번호 검색
if (search_tel && typeof search_tel === "string" && search_tel.trim()) {
whereConditions.push(`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`);
whereConditions.push(
`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`
);
queryParams.push(`%${search_tel.trim()}%`);
paramIndex++;
hasAdvancedSearch = true;
@ -305,9 +311,10 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
paramIndex++;
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 총 개수 조회
const countQuery = `
@ -345,7 +352,11 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const users = await query<any>(usersQuery, [...queryParams, Number(countPerPage), offset]);
const users = await query<any>(usersQuery, [
...queryParams,
Number(countPerPage),
offset,
]);
// 응답 데이터 가공
const processedUsers = users.map((user) => ({
@ -365,7 +376,9 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
status: user.status || "active",
companyCode: user.company_code || null,
locale: user.locale || null,
regDate: user.regdate ? new Date(user.regdate).toISOString().split("T")[0] : null,
regDate: user.regdate
? new Date(user.regdate).toISOString().split("T")[0]
: null,
}));
const response = {
@ -498,10 +511,10 @@ export const setUserLocale = async (
}
// Raw Query로 사용자 로케일 저장
await query(
"UPDATE user_info SET locale = $1 WHERE user_id = $2",
[locale, req.user.userId]
);
await query("UPDATE user_info SET locale = $1 WHERE user_id = $2", [
locale,
req.user.userId,
]);
logger.info("사용자 로케일을 데이터베이스에 저장 완료", {
locale,
@ -680,9 +693,13 @@ export async function getLangKeyList(
langKey: row.lang_key,
description: row.description,
isActive: row.is_active,
createdDate: row.created_date ? new Date(row.created_date).toISOString() : null,
createdDate: row.created_date
? new Date(row.created_date).toISOString()
: null,
createdBy: row.created_by,
updatedDate: row.updated_date ? new Date(row.updated_date).toISOString() : null,
updatedDate: row.updated_date
? new Date(row.updated_date).toISOString()
: null,
updatedBy: row.updated_by,
}));
@ -1008,8 +1025,20 @@ export async function saveMenu(
const menuData = req.body;
logger.info("메뉴 저장 요청", { menuData, user: req.user });
// 사용자의 company_code 확인
if (!req.user?.companyCode) {
res.status(400).json({
success: false,
message: "사용자의 회사 코드를 찾을 수 없습니다.",
error: "Missing company_code",
});
return;
}
// Raw Query를 사용한 메뉴 저장
const objid = Date.now(); // 고유 ID 생성
const companyCode = req.user.companyCode;
const [savedMenu] = await query<any>(
`INSERT INTO menu_info (
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
@ -1030,7 +1059,7 @@ export async function saveMenu(
new Date(),
menuData.status || "active",
menuData.systemName || null,
menuData.companyCode || "*",
companyCode,
menuData.langKey || null,
menuData.langKeyDesc || null,
]
@ -1079,6 +1108,18 @@ export async function updateMenu(
user: req.user,
});
// 사용자의 company_code 확인
if (!req.user?.companyCode) {
res.status(400).json({
success: false,
message: "사용자의 회사 코드를 찾을 수 없습니다.",
error: "Missing company_code",
});
return;
}
const companyCode = req.user.companyCode;
// Raw Query를 사용한 메뉴 수정
const [updatedMenu] = await query<any>(
`UPDATE menu_info SET
@ -1106,7 +1147,7 @@ export async function updateMenu(
menuData.menuDesc || null,
menuData.status || "active",
menuData.systemName || null,
menuData.companyCode || "*",
companyCode,
menuData.langKey || null,
menuData.langKeyDesc || null,
Number(menuId),
@ -1356,9 +1397,10 @@ export const getDepartmentList = async (
paramIndex++;
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const departments = await query<any>(
`SELECT
@ -1970,7 +2012,9 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
);
// 기존 사용자인지 새 사용자인지 확인 (regdate로 판단)
const isUpdate = savedUser.regdate && new Date(savedUser.regdate).getTime() < Date.now() - 1000;
const isUpdate =
savedUser.regdate &&
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
logger.info(isUpdate ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", {
userId: userData.userId,

View File

@ -85,6 +85,42 @@ router.get(
}
);
/**
* GET /api/external-db-connections/pool-status
*
*/
router.get(
"/pool-status",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const { ExternalDbConnectionPoolService } = await import(
"../services/externalDbConnectionPoolService"
);
const poolService = ExternalDbConnectionPoolService.getInstance();
const poolsStatus = poolService.getPoolsStatus();
return res.status(200).json({
success: true,
data: {
totalPools: poolsStatus.length,
activePools: poolsStatus.filter((p) => p.activeConnections > 0)
.length,
pools: poolsStatus,
},
message: `${poolsStatus.length}개의 연결 풀 상태를 조회했습니다.`,
});
} catch (error) {
console.error("연결 풀 상태 조회 오류:", error);
return res.status(500).json({
success: false,
message: "서버 내부 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
);
/**
* GET /api/external-db-connections/grouped
* DB DB

View File

@ -0,0 +1,436 @@
// 외부 DB 연결 풀 관리 서비스
// 작성일: 2025-01-13
// 연결 풀 고갈 방지를 위한 중앙 관리 시스템
import { Pool } from "pg";
import mysql from "mysql2/promise";
import { ExternalDbConnection } from "../types/externalDbTypes";
import { ExternalDbConnectionService } from "./externalDbConnectionService";
import { PasswordEncryption } from "../utils/passwordEncryption";
import logger from "../utils/logger";
/**
*
* DB
*/
interface ConnectionPoolWrapper {
pool: any; // 실제 연결 풀 객체
dbType: string;
connectionId: number;
createdAt: Date;
lastUsedAt: Date;
activeConnections: number;
maxConnections: number;
// 통일된 쿼리 실행 인터페이스
query(sql: string, params?: any[]): Promise<any>;
// 연결 풀 종료
disconnect(): Promise<void>;
// 연결 풀 상태 확인
isHealthy(): boolean;
}
/**
* PostgreSQL
*/
class PostgresPoolWrapper implements ConnectionPoolWrapper {
pool: Pool;
dbType = "postgresql";
connectionId: number;
createdAt: Date;
lastUsedAt: Date;
activeConnections = 0;
maxConnections: number;
constructor(config: ExternalDbConnection) {
this.connectionId = config.id!;
this.createdAt = new Date();
this.lastUsedAt = new Date();
this.maxConnections = config.max_connections || 10;
this.pool = new Pool({
host: config.host,
port: config.port,
database: config.database_name,
user: config.username,
password: config.password,
max: this.maxConnections,
min: 2, // 최소 연결 수
idleTimeoutMillis: 30000, // 30초 동안 사용되지 않으면 연결 해제
connectionTimeoutMillis: (config.connection_timeout || 30) * 1000,
statement_timeout: (config.query_timeout || 60) * 1000,
ssl: config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false,
});
// 연결 풀 이벤트 리스너
this.pool.on("connect", () => {
this.activeConnections++;
logger.debug(
`[PostgreSQL] 새 연결 생성 (${this.activeConnections}/${this.maxConnections})`
);
});
this.pool.on("remove", () => {
this.activeConnections--;
logger.debug(
`[PostgreSQL] 연결 제거 (${this.activeConnections}/${this.maxConnections})`
);
});
this.pool.on("error", (err) => {
logger.error(`[PostgreSQL] 연결 풀 오류:`, err);
});
}
async query(sql: string, params?: any[]): Promise<any> {
this.lastUsedAt = new Date();
const result = await this.pool.query(sql, params);
return result.rows;
}
async disconnect(): Promise<void> {
await this.pool.end();
logger.info(`[PostgreSQL] 연결 풀 종료 (ID: ${this.connectionId})`);
}
isHealthy(): boolean {
return (
this.pool.totalCount > 0 && this.activeConnections < this.maxConnections
);
}
}
/**
* MySQL/MariaDB
*/
class MySQLPoolWrapper implements ConnectionPoolWrapper {
pool: mysql.Pool;
dbType: string;
connectionId: number;
createdAt: Date;
lastUsedAt: Date;
activeConnections = 0;
maxConnections: number;
constructor(config: ExternalDbConnection) {
this.connectionId = config.id!;
this.dbType = config.db_type;
this.createdAt = new Date();
this.lastUsedAt = new Date();
this.maxConnections = config.max_connections || 10;
this.pool = mysql.createPool({
host: config.host,
port: config.port,
database: config.database_name,
user: config.username,
password: config.password,
connectionLimit: this.maxConnections,
waitForConnections: true,
queueLimit: 0,
connectTimeout: (config.connection_timeout || 30) * 1000,
ssl:
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
});
// 연결 획득/해제 이벤트 추적
this.pool.on("acquire", () => {
this.activeConnections++;
logger.debug(
`[${this.dbType.toUpperCase()}] 연결 획득 (${this.activeConnections}/${this.maxConnections})`
);
});
this.pool.on("release", () => {
this.activeConnections--;
logger.debug(
`[${this.dbType.toUpperCase()}] 연결 반환 (${this.activeConnections}/${this.maxConnections})`
);
});
}
async query(sql: string, params?: any[]): Promise<any> {
this.lastUsedAt = new Date();
const [rows] = await this.pool.execute(sql, params);
return rows;
}
async disconnect(): Promise<void> {
await this.pool.end();
logger.info(
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
);
}
isHealthy(): boolean {
return this.activeConnections < this.maxConnections;
}
}
/**
* DB
*
*/
export class ExternalDbConnectionPoolService {
private static instance: ExternalDbConnectionPoolService;
private pools: Map<number, ConnectionPoolWrapper> = new Map();
private readonly IDLE_TIMEOUT = 10 * 60 * 1000; // 10분 동안 사용되지 않으면 풀 제거
private readonly HEALTH_CHECK_INTERVAL = 60 * 1000; // 1분마다 헬스 체크
private healthCheckTimer?: NodeJS.Timeout;
private constructor() {
this.startHealthCheck();
logger.info("🔌 외부 DB 연결 풀 서비스 초기화 완료");
}
/**
*
*/
static getInstance(): ExternalDbConnectionPoolService {
if (!ExternalDbConnectionPoolService.instance) {
ExternalDbConnectionPoolService.instance =
new ExternalDbConnectionPoolService();
}
return ExternalDbConnectionPoolService.instance;
}
/**
* ( )
*/
async getPool(connectionId: number): Promise<ConnectionPoolWrapper> {
// 기존 풀이 있으면 반환
if (this.pools.has(connectionId)) {
const pool = this.pools.get(connectionId)!;
pool.lastUsedAt = new Date();
// 헬스 체크
if (!pool.isHealthy()) {
logger.warn(
`⚠️ 연결 풀 비정상 감지 (ID: ${connectionId}), 재생성 중...`
);
await this.removePool(connectionId);
return this.createPool(connectionId);
}
logger.debug(`✅ 기존 연결 풀 재사용 (ID: ${connectionId})`);
return pool;
}
// 새로운 풀 생성
return this.createPool(connectionId);
}
/**
*
*/
private async createPool(
connectionId: number
): Promise<ConnectionPoolWrapper> {
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
// DB 연결 정보 조회
const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId);
if (!connectionResult.success || !connectionResult.data) {
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
}
const config = connectionResult.data;
// 비활성화된 연결은 사용 불가
if (config.is_active !== "Y") {
throw new Error(`비활성화된 연결입니다 (ID: ${connectionId})`);
}
// 비밀번호 복호화
try {
config.password = PasswordEncryption.decrypt(config.password);
} catch (error) {
logger.error(`비밀번호 복호화 실패 (ID: ${connectionId}):`, error);
throw new Error("비밀번호 복호화에 실패했습니다");
}
// DB 타입에 따라 적절한 풀 생성
let pool: ConnectionPoolWrapper;
switch (config.db_type.toLowerCase()) {
case "postgresql":
pool = new PostgresPoolWrapper(config);
break;
case "mysql":
case "mariadb":
pool = new MySQLPoolWrapper(config);
break;
case "oracle":
case "mssql":
// TODO: Oracle과 MSSQL 지원 추가
throw new Error(`${config.db_type}는 아직 지원되지 않습니다`);
default:
throw new Error(`지원하지 않는 DB 타입: ${config.db_type}`);
}
this.pools.set(connectionId, pool);
logger.info(
`✅ 연결 풀 생성 완료 (ID: ${connectionId}, 타입: ${config.db_type}, 최대: ${pool.maxConnections})`
);
return pool;
}
/**
*
*/
async removePool(connectionId: number): Promise<void> {
const pool = this.pools.get(connectionId);
if (pool) {
await pool.disconnect();
this.pools.delete(connectionId);
logger.info(`🗑️ 연결 풀 제거됨 (ID: ${connectionId})`);
}
}
/**
* ( )
*/
async executeQuery(
connectionId: number,
sql: string,
params?: any[]
): Promise<any> {
const pool = await this.getPool(connectionId);
try {
logger.debug(
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
);
const result = await pool.query(sql, params);
logger.debug(
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}`
);
return result;
} catch (error) {
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
throw error;
}
}
/**
* ( )
*/
async testConnection(connectionId: number): Promise<boolean> {
try {
const pool = await this.getPool(connectionId);
// 간단한 쿼리로 연결 테스트
const testQuery =
pool.dbType === "postgresql" ? "SELECT 1 as test" : "SELECT 1 as test";
await pool.query(testQuery);
logger.info(`✅ 연결 테스트 성공 (ID: ${connectionId})`);
return true;
} catch (error) {
logger.error(`❌ 연결 테스트 실패 (ID: ${connectionId}):`, error);
return false;
}
}
/**
*
*/
private startHealthCheck(): void {
this.healthCheckTimer = setInterval(() => {
const now = Date.now();
this.pools.forEach(async (pool, connectionId) => {
const idleTime = now - pool.lastUsedAt.getTime();
// 유휴 시간 초과 시 풀 제거
if (idleTime > this.IDLE_TIMEOUT) {
logger.info(
`🧹 유휴 연결 풀 정리 (ID: ${connectionId}, 유휴: ${Math.round(idleTime / 1000)}초)`
);
await this.removePool(connectionId);
}
// 헬스 체크
if (!pool.isHealthy()) {
logger.warn(
`⚠️ 비정상 연결 풀 감지 (ID: ${connectionId}), 재생성 예약`
);
await this.removePool(connectionId);
}
});
// 상태 로깅
if (this.pools.size > 0) {
logger.debug(
`📊 연결 풀 상태: 총 ${this.pools.size}개, 활성: ${Array.from(this.pools.values()).filter((p) => p.activeConnections > 0).length}`
);
}
}, this.HEALTH_CHECK_INTERVAL);
logger.info("🔍 헬스 체크 타이머 시작 (간격: 1분)");
}
/**
* ( )
*/
async closeAll(): Promise<void> {
logger.info(`🛑 모든 연결 풀 종료 중... (총 ${this.pools.size}개)`);
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
const closePromises = Array.from(this.pools.keys()).map((connectionId) =>
this.removePool(connectionId)
);
await Promise.all(closePromises);
logger.info("✅ 모든 연결 풀 종료 완료");
}
/**
*
*/
getPoolsStatus(): Array<{
connectionId: number;
dbType: string;
activeConnections: number;
maxConnections: number;
createdAt: Date;
lastUsedAt: Date;
idleSeconds: number;
}> {
const now = Date.now();
return Array.from(this.pools.entries()).map(([connectionId, pool]) => ({
connectionId,
dbType: pool.dbType,
activeConnections: pool.activeConnections,
maxConnections: pool.maxConnections,
createdAt: pool.createdAt,
lastUsedAt: pool.lastUsedAt,
idleSeconds: Math.round((now - pool.lastUsedAt.getTime()) / 1000),
}));
}
}
// 애플리케이션 종료 시 연결 풀 정리
process.on("SIGINT", async () => {
logger.info("🛑 SIGINT 신호 수신, 연결 풀 정리 중...");
await ExternalDbConnectionPoolService.getInstance().closeAll();
process.exit(0);
});
process.on("SIGTERM", async () => {
logger.info("🛑 SIGTERM 신호 수신, 연결 풀 정리 중...");
await ExternalDbConnectionPoolService.getInstance().closeAll();
process.exit(0);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,491 @@
# 외부 DB 연결 풀 관리 가이드
## 📋 개요
외부 DB 연결 풀 서비스는 여러 외부 데이터베이스와의 연결을 효율적으로 관리하여 **연결 풀 고갈을 방지**하고 성능을 최적화합니다.
### 주요 기능
- ✅ **자동 연결 풀 관리**: 연결 생성, 재사용, 정리 자동화
- ✅ **연결 풀 고갈 방지**: 최대 연결 수 제한 및 모니터링
- ✅ **유휴 연결 정리**: 10분 이상 사용되지 않은 풀 자동 제거
- ✅ **헬스 체크**: 1분마다 모든 풀 상태 검사
- ✅ **다중 DB 지원**: PostgreSQL, MySQL, MariaDB
- ✅ **싱글톤 패턴**: 전역적으로 단일 인스턴스 사용
---
## 🏗️ 아키텍처
```
┌─────────────────────────────────────────┐
│ NodeFlowExecutionService │
│ (외부 DB 소스/액션 노드) │
└──────────────┬──────────────────────────┘
┌─────────────────────────────────────────┐
│ ExternalDbConnectionPoolService │
│ (싱글톤 인스턴스) │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Connection Pool Map │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ ID: 1 → PostgresPool │ │ │
│ │ │ ID: 2 → MySQLPool │ │ │
│ │ │ ID: 3 → MariaDBPool │ │ │
│ │ └──────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
│ │
│ - 자동 풀 생성/제거 │
│ - 헬스 체크 (1분마다) │
│ - 유휴 풀 정리 (10분) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ External Databases │
│ - PostgreSQL │
│ - MySQL │
│ - MariaDB │
└─────────────────────────────────────────┘
```
---
## 🔧 연결 풀 설정
### PostgreSQL 연결 풀
```typescript
{
max: 10, // 최대 연결 수
min: 2, // 최소 연결 수
idleTimeoutMillis: 30000, // 30초 유휴 시 연결 해제
connectionTimeoutMillis: 30000, // 연결 타임아웃 30초
statement_timeout: 60000, // 쿼리 타임아웃 60초
}
```
### MySQL/MariaDB 연결 풀
```typescript
{
connectionLimit: 10, // 최대 연결 수
waitForConnections: true,
queueLimit: 0, // 대기열 무제한
connectTimeout: 30000, // 연결 타임아웃 30초
}
```
---
## 📊 연결 풀 라이프사이클
### 1. 풀 생성
```typescript
// 첫 요청 시 자동 생성
const pool = await poolService.getPool(connectionId);
```
**생성 시점**:
- 외부 DB 소스 노드 첫 실행 시
- 외부 DB 액션 노드 첫 실행 시
**생성 과정**:
1. DB 연결 정보 조회 (`external_db_connections` 테이블)
2. 비밀번호 복호화
3. DB 타입에 맞는 연결 풀 생성 (PostgreSQL, MySQL, MariaDB)
4. 이벤트 리스너 등록 (연결 획득/해제 추적)
### 2. 풀 재사용
```typescript
// 기존 풀이 있으면 재사용
if (this.pools.has(connectionId)) {
const pool = this.pools.get(connectionId)!;
pool.lastUsedAt = new Date(); // 사용 시간 갱신
return pool;
}
```
**재사용 조건**:
- 동일한 `connectionId`로 요청
- 풀이 정상 상태 (`isHealthy()` 통과)
### 3. 자동 정리
**유휴 시간 초과 (10분)**:
```typescript
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10분
if (now - pool.lastUsedAt.getTime() > IDLE_TIMEOUT) {
await this.removePool(connectionId);
}
```
**헬스 체크 실패**:
```typescript
if (!pool.isHealthy()) {
await this.removePool(connectionId);
}
```
---
## 🔍 헬스 체크 시스템
### 주기적 헬스 체크
```typescript
const HEALTH_CHECK_INTERVAL = 60 * 1000; // 1분마다
setInterval(() => {
this.pools.forEach(async (pool, connectionId) => {
// 유휴 시간 체크
const idleTime = now - pool.lastUsedAt.getTime();
if (idleTime > IDLE_TIMEOUT) {
await this.removePool(connectionId);
}
// 헬스 체크
if (!pool.isHealthy()) {
await this.removePool(connectionId);
}
});
}, HEALTH_CHECK_INTERVAL);
```
### 헬스 체크 조건
#### PostgreSQL
```typescript
isHealthy(): boolean {
return this.pool.totalCount > 0
&& this.activeConnections < this.maxConnections;
}
```
#### MySQL/MariaDB
```typescript
isHealthy(): boolean {
return this.activeConnections < this.maxConnections;
}
```
---
## 💻 사용 방법
### 1. 외부 DB 소스 노드에서 사용
```typescript
// nodeFlowExecutionService.ts
private static async executeExternalDBSource(
node: FlowNode,
context: ExecutionContext
): Promise<any[]> {
const { connectionId, tableName } = node.data;
// 연결 풀 서비스 사용
const { ExternalDbConnectionPoolService } = await import(
"./externalDbConnectionPoolService"
);
const poolService = ExternalDbConnectionPoolService.getInstance();
const sql = `SELECT * FROM ${tableName}`;
const result = await poolService.executeQuery(connectionId, sql);
return result;
}
```
### 2. 외부 DB 액션 노드에서 사용
```typescript
// 기존 createExternalConnector가 자동으로 연결 풀 사용
const connector = await this.createExternalConnector(connectionId, dbType);
// executeQuery 호출 시 내부적으로 연결 풀 사용
const result = await connector.executeQuery(sql, params);
```
### 3. 연결 풀 상태 조회
**API 엔드포인트**:
```
GET /api/external-db-connections/pool-status
```
**응답 예시**:
```json
{
"success": true,
"data": {
"totalPools": 3,
"activePools": 2,
"pools": [
{
"connectionId": 1,
"dbType": "postgresql",
"activeConnections": 2,
"maxConnections": 10,
"createdAt": "2025-01-13T10:00:00.000Z",
"lastUsedAt": "2025-01-13T10:05:00.000Z",
"idleSeconds": 45
},
{
"connectionId": 2,
"dbType": "mysql",
"activeConnections": 0,
"maxConnections": 10,
"createdAt": "2025-01-13T09:50:00.000Z",
"lastUsedAt": "2025-01-13T09:55:00.000Z",
"idleSeconds": 600
}
]
},
"message": "3개의 연결 풀 상태를 조회했습니다."
}
```
---
## 🚨 연결 풀 고갈 방지 메커니즘
### 1. 최대 연결 수 제한
```typescript
// 데이터베이스 설정 기준
max_connections: config.max_connections || 10;
```
각 외부 DB 연결마다 최대 연결 수를 설정하여 무제한 연결 방지.
### 2. 연결 재사용
```typescript
// 동일한 connectionId 요청 시 기존 풀 재사용
const pool = await poolService.getPool(connectionId);
```
매번 새 연결을 생성하지 않고 기존 풀 재사용.
### 3. 자동 연결 해제
```typescript
// PostgreSQL: 30초 유휴 시 자동 해제
idleTimeoutMillis: 30000;
```
사용되지 않는 연결은 자동으로 해제하여 리소스 절약.
### 4. 전역 풀 정리
```typescript
// 10분 이상 미사용 풀 제거
if (idleTime > IDLE_TIMEOUT) {
await this.removePool(connectionId);
}
```
장시간 사용되지 않는 풀 자체를 제거.
### 5. 애플리케이션 종료 시 정리
```typescript
process.on("SIGINT", async () => {
await ExternalDbConnectionPoolService.getInstance().closeAll();
process.exit(0);
});
```
프로세스 종료 시 모든 연결 정상 종료.
---
## 📈 모니터링 및 로깅
### 연결 이벤트 로깅
```typescript
// 연결 획득
pool.on("acquire", () => {
logger.debug(`[PostgreSQL] 연결 획득 (2/10)`);
});
// 연결 반환
pool.on("release", () => {
logger.debug(`[PostgreSQL] 연결 반환 (1/10)`);
});
// 에러 발생
pool.on("error", (err) => {
logger.error(`[PostgreSQL] 연결 풀 오류:`, err);
});
```
### 정기 상태 로깅
```typescript
// 1분마다 상태 출력
logger.debug(`📊 연결 풀 상태: 총 3개, 활성: 2개`);
```
### 주요 로그 메시지
| 레벨 | 메시지 | 의미 |
| ------- | ---------------------------------------------------------- | --------------- |
| `info` | `🔧 새 연결 풀 생성 중 (ID: 1)...` | 새 풀 생성 시작 |
| `info` | `✅ 연결 풀 생성 완료 (ID: 1, 타입: postgresql, 최대: 10)` | 풀 생성 완료 |
| `debug` | `✅ 기존 연결 풀 재사용 (ID: 1)` | 기존 풀 재사용 |
| `info` | `🧹 유휴 연결 풀 정리 (ID: 2, 유휴: 620초)` | 유휴 풀 제거 |
| `warn` | `⚠️ 연결 풀 비정상 감지 (ID: 3), 재생성 중...` | 헬스 체크 실패 |
| `error` | `❌ 쿼리 실행 실패 (ID: 1)` | 쿼리 오류 |
---
## 🔒 보안 고려사항
### 1. 비밀번호 보호
```typescript
// 비밀번호 복호화는 풀 생성 시에만 수행
config.password = PasswordEncryption.decrypt(config.password);
```
메모리에 평문 비밀번호를 최소한으로 유지.
### 2. 연결 정보 검증
```typescript
if (config.is_active !== "Y") {
throw new Error(`비활성화된 연결입니다 (ID: ${connectionId})`);
}
```
비활성화된 연결은 사용 불가.
### 3. 타임아웃 설정
```typescript
connectionTimeoutMillis: 30000, // 30초
statement_timeout: 60000, // 60초
```
무한 대기 방지.
---
## 🐛 트러블슈팅
### 문제 1: 연결 풀 고갈
**증상**: "Connection pool exhausted" 오류
**원인**:
- 동시 요청이 최대 연결 수 초과
- 쿼리가 너무 오래 실행되어 연결 점유
**해결**:
1. `max_connections` 값 증가 (`external_db_connections` 테이블)
2. 쿼리 최적화 (인덱스, LIMIT 추가)
3. `query_timeout` 값 조정
### 문제 2: 메모리 누수
**증상**: 메모리 사용량 지속 증가
**원인**:
- 연결 풀이 정리되지 않음
- 헬스 체크 실패
**해결**:
1. 연결 풀 상태 확인: `GET /api/external-db-connections/pool-status`
2. 수동 재시작으로 모든 풀 정리
3. 로그에서 `🧹 유휴 연결 풀 정리` 메시지 확인
### 문제 3: 연결 시간 초과
**증상**: "Connection timeout" 오류
**원인**:
- DB 서버 응답 없음
- 네트워크 문제
- 방화벽 차단
**해결**:
1. DB 서버 상태 확인
2. 네트워크 연결 확인
3. `connection_timeout` 값 증가
---
## ⚙️ 설정 권장사항
### 소규모 시스템 (동시 사용자 < 50)
```typescript
{
max_connections: 5,
connection_timeout: 30,
query_timeout: 60,
}
```
### 중규모 시스템 (동시 사용자 50-200)
```typescript
{
max_connections: 10,
connection_timeout: 30,
query_timeout: 90,
}
```
### 대규모 시스템 (동시 사용자 > 200)
```typescript
{
max_connections: 20,
connection_timeout: 60,
query_timeout: 120,
}
```
---
## 📚 참고 자료
- [PostgreSQL Connection Pooling](https://node-postgres.com/features/pooling)
- [MySQL Connection Pool](https://github.com/mysqljs/mysql#pooling-connections)
- [Node.js Best Practices - Database Connection Management](https://github.com/goldbergyoni/nodebestpractices)
---
## 🎯 결론
외부 DB 연결 풀 서비스는 다음을 보장합니다:
**효율성**: 연결 재사용으로 성능 향상
**안정성**: 연결 풀 고갈 방지
**자동화**: 생성/정리/모니터링 자동화
**확장성**: 다중 DB 및 대규모 트래픽 지원
**최소한의 설정**으로 **최대한의 안정성**을 제공합니다! 🚀

View File

@ -1,7 +1,7 @@
"use client";
/**
*
*
*/
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
@ -12,7 +12,7 @@ export default function NodeEditorPage() {
{/* 페이지 헤더 */}
<div className="border-b bg-white p-4">
<div className="mx-auto">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-600">
</p>

View File

@ -16,7 +16,6 @@ import { TableSourceNode } from "./nodes/TableSourceNode";
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
import { ReferenceLookupNode } from "./nodes/ReferenceLookupNode";
import { ConditionNode } from "./nodes/ConditionNode";
import { FieldMappingNode } from "./nodes/FieldMappingNode";
import { InsertActionNode } from "./nodes/InsertActionNode";
import { UpdateActionNode } from "./nodes/UpdateActionNode";
import { DeleteActionNode } from "./nodes/DeleteActionNode";
@ -35,7 +34,6 @@ const nodeTypes = {
referenceLookup: ReferenceLookupNode,
// 변환/조건
condition: ConditionNode,
fieldMapping: FieldMappingNode,
dataTransform: DataTransformNode,
// 액션
insertAction: InsertActionNode,
@ -60,11 +58,14 @@ function FlowEditorInner() {
onNodesChange,
onEdgesChange,
onConnect,
onNodeDragStart,
addNode,
showPropertiesPanel,
selectNodes,
selectedNodes,
removeNodes,
undo,
redo,
} = useFlowEditorStore();
/**
@ -80,17 +81,37 @@ function FlowEditorInner() {
);
/**
* (Delete/Backspace )
* (Delete/Backspace , Ctrl+Z/Y로 Undo/Redo)
*/
const onKeyDown = useCallback(
(event: React.KeyboardEvent) => {
// Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
if ((event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey) {
event.preventDefault();
console.log("⏪ Undo");
undo();
return;
}
// Redo: Ctrl+Y (Windows/Linux) or Cmd+Shift+Z (Mac) or Ctrl+Shift+Z
if (
((event.ctrlKey || event.metaKey) && event.key === "y") ||
((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z")
) {
event.preventDefault();
console.log("⏩ Redo");
redo();
return;
}
// Delete: Delete/Backspace 키로 노드 삭제
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
event.preventDefault();
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
removeNodes(selectedNodes);
}
},
[selectedNodes, removeNodes],
[selectedNodes, removeNodes, undo, redo],
);
/**
@ -170,6 +191,7 @@ function FlowEditorInner() {
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeDragStart={onNodeDragStart}
onSelectionChange={onSelectionChange}
onDragOver={onDragOver}
onDrop={onDrop}

View File

@ -25,6 +25,10 @@ export function FlowToolbar() {
isSaving,
selectedNodes,
removeNodes,
undo,
redo,
canUndo,
canRedo,
} = useFlowEditorStore();
const [showLoadDialog, setShowLoadDialog] = useState(false);
@ -108,10 +112,10 @@ export function FlowToolbar() {
<div className="h-6 w-px bg-gray-200" />
{/* 실행 취소/다시 실행 */}
<Button variant="ghost" size="sm" title="실행 취소" disabled>
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
<Undo2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" title="다시 실행" disabled>
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
<Redo2 className="h-4 w-4" />
</Button>

View File

@ -1,66 +0,0 @@
"use client";
/**
*
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { ArrowLeftRight } from "lucide-react";
import type { FieldMappingNodeData } from "@/types/node-editor";
export const FieldMappingNode = memo(({ data, selected }: NodeProps<FieldMappingNodeData>) => {
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-purple-500 shadow-lg" : "border-gray-200"
}`}
>
{/* 입력 핸들 */}
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white" />
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-purple-500 px-3 py-2 text-white">
<ArrowLeftRight className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold"> </div>
<div className="text-xs opacity-80">{data.displayName || "데이터 매핑"}</div>
</div>
</div>
{/* 본문 */}
<div className="p-3">
{data.mappings && data.mappings.length > 0 ? (
<div className="space-y-1">
<div className="text-xs font-medium text-gray-700"> : ({data.mappings.length})</div>
<div className="max-h-[150px] space-y-1 overflow-y-auto">
{data.mappings.slice(0, 5).map((mapping) => (
<div key={mapping.id} className="rounded bg-gray-50 px-2 py-1 text-xs">
<div className="flex items-center justify-between">
<span className="font-mono text-gray-600">{mapping.sourceField || "정적값"}</span>
<span className="text-purple-500"></span>
<span className="font-mono text-gray-700">{mapping.targetField}</span>
</div>
{mapping.transform && <div className="mt-0.5 text-xs text-gray-400">: {mapping.transform}</div>}
{mapping.staticValue !== undefined && (
<div className="mt-0.5 text-xs text-gray-400">: {String(mapping.staticValue)}</div>
)}
</div>
))}
{data.mappings.length > 5 && (
<div className="text-xs text-gray-400">... {data.mappings.length - 5}</div>
)}
</div>
</div>
) : (
<div className="text-center text-xs text-gray-400"> </div>
)}
</div>
{/* 출력 핸들 */}
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white" />
</div>
);
});
FieldMappingNode.displayName = "FieldMappingNode";

View File

@ -10,7 +10,6 @@ import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { TableSourceProperties } from "./properties/TableSourceProperties";
import { ReferenceLookupProperties } from "./properties/ReferenceLookupProperties";
import { InsertActionProperties } from "./properties/InsertActionProperties";
import { FieldMappingProperties } from "./properties/FieldMappingProperties";
import { ConditionProperties } from "./properties/ConditionProperties";
import { UpdateActionProperties } from "./properties/UpdateActionProperties";
import { DeleteActionProperties } from "./properties/DeleteActionProperties";
@ -84,9 +83,6 @@ function NodePropertiesRenderer({ node }: { node: any }) {
case "insertAction":
return <InsertActionProperties nodeId={node.id} data={node.data} />;
case "fieldMapping":
return <FieldMappingProperties nodeId={node.id} data={node.data} />;
case "condition":
return <ConditionProperties nodeId={node.id} data={node.data} />;

View File

@ -122,6 +122,24 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
} else {
fields.push(...upperFields);
}
} else if (sourceNode.type === "restAPISource") {
// REST API Source: responseFields 사용
if (sourceData.responseFields && Array.isArray(sourceData.responseFields)) {
console.log("🔍 [ConditionProperties] REST API 필드:", sourceData.responseFields);
fields.push(
...sourceData.responseFields.map((f: any) => ({
name: f.name || f.fieldName,
label: f.label || f.displayName || f.name,
type: f.dataType || f.type,
})),
);
} else {
console.log("⚠️ [ConditionProperties] REST API에 필드 없음:", sourceData);
}
} else if (sourceNode.type === "condition") {
// 조건 노드: 재귀적으로 상위 노드 필드 수집 (통과 노드)
console.log("✅ [ConditionProperties] 조건 노드 통과 → 상위 탐색");
fields.push(...getAllSourceFields(sourceNode.id, visited));
} else if (
sourceNode.type === "insertAction" ||
sourceNode.type === "updateAction" ||
@ -130,6 +148,10 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
) {
// Action 노드: 재귀적으로 상위 노드 필드 수집
fields.push(...getAllSourceFields(sourceNode.id, visited));
} else {
// 기타 모든 노드: 재귀적으로 상위 노드 필드 수집 (통과 노드로 처리)
console.log(`✅ [ConditionProperties] 통과 노드 (${sourceNode.type}) → 상위 탐색`);
fields.push(...getAllSourceFields(sourceNode.id, visited));
}
}

View File

@ -1,191 +0,0 @@
"use client";
/**
*
*/
import { useEffect, useState } from "react";
import { Plus, Trash2, ArrowRight } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import type { FieldMappingNodeData } from "@/types/node-editor";
interface FieldMappingPropertiesProps {
nodeId: string;
data: FieldMappingNodeData;
}
export function FieldMappingProperties({ nodeId, data }: FieldMappingPropertiesProps) {
const { updateNode } = useFlowEditorStore();
const [displayName, setDisplayName] = useState(data.displayName || "데이터 매핑");
const [mappings, setMappings] = useState(data.mappings || []);
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
setDisplayName(data.displayName || "데이터 매핑");
setMappings(data.mappings || []);
}, [data]);
const handleAddMapping = () => {
setMappings([
...mappings,
{
id: `mapping_${Date.now()}`,
sourceField: "",
targetField: "",
transform: undefined,
staticValue: undefined,
},
]);
};
const handleRemoveMapping = (id: string) => {
setMappings(mappings.filter((m) => m.id !== id));
};
const handleMappingChange = (id: string, field: string, value: any) => {
const newMappings = mappings.map((m) => (m.id === id ? { ...m, [field]: value } : m));
setMappings(newMappings);
};
const handleSave = () => {
updateNode(nodeId, {
displayName,
mappings,
});
};
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
{/* 기본 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
<div>
<Label htmlFor="displayName" className="text-xs">
</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="mt-1"
placeholder="노드 표시 이름"
/>
</div>
</div>
{/* 매핑 규칙 */}
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{mappings.length > 0 ? (
<div className="space-y-2">
{mappings.map((mapping, index) => (
<div key={mapping.id} className="rounded border bg-purple-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-purple-700"> #{index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveMapping(mapping.id)}
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
{/* 소스 → 타겟 */}
<div className="flex items-center gap-2">
<div className="flex-1">
<Label className="text-xs text-gray-600"> </Label>
<Input
value={mapping.sourceField || ""}
onChange={(e) => handleMappingChange(mapping.id, "sourceField", e.target.value)}
placeholder="입력 필드"
className="mt-1 h-8 text-xs"
/>
</div>
<div className="pt-5">
<ArrowRight className="h-4 w-4 text-purple-500" />
</div>
<div className="flex-1">
<Label className="text-xs text-gray-600"> </Label>
<Input
value={mapping.targetField}
onChange={(e) => handleMappingChange(mapping.id, "targetField", e.target.value)}
placeholder="출력 필드"
className="mt-1 h-8 text-xs"
/>
</div>
</div>
{/* 변환 함수 */}
<div>
<Label className="text-xs text-gray-600"> ()</Label>
<Input
value={mapping.transform || ""}
onChange={(e) => handleMappingChange(mapping.id, "transform", e.target.value)}
placeholder="예: UPPER(), TRIM(), CONCAT()"
className="mt-1 h-8 text-xs"
/>
</div>
{/* 정적 값 */}
<div>
<Label className="text-xs text-gray-600"> ()</Label>
<Input
value={mapping.staticValue || ""}
onChange={(e) => handleMappingChange(mapping.id, "staticValue", e.target.value)}
placeholder="고정 값 (소스 필드 대신 사용)"
className="mt-1 h-8 text-xs"
/>
</div>
</div>
</div>
))}
</div>
) : (
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
. "추가" .
</div>
)}
</div>
{/* 저장 버튼 */}
<div className="flex gap-2">
<Button onClick={handleSave} className="flex-1" size="sm">
</Button>
</div>
{/* 안내 */}
<div className="space-y-2">
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
💡 <strong> </strong>:
</div>
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
💡 <strong> </strong>:
</div>
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
💡 <strong> </strong>: (SQL )
</div>
</div>
</div>
</ScrollArea>
);
}

View File

@ -137,8 +137,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
const getAllSourceFields = (
targetNodeId: string,
visitedNodes = new Set<string>(),
): { fields: Array<{ name: string; label?: string }>; hasRestAPI: boolean } => {
sourcePath: string[] = [], // 🔥 소스 경로 추적
): { fields: Array<{ name: string; label?: string; sourcePath?: string[] }>; hasRestAPI: boolean } => {
if (visitedNodes.has(targetNodeId)) {
console.log(`⚠️ 순환 참조 감지: ${targetNodeId} (이미 방문함)`);
return { fields: [], hasRestAPI: false };
}
visitedNodes.add(targetNodeId);
@ -147,19 +149,27 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
const sourceNodeIds = inputEdges.map((edge) => edge.source);
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
const fields: Array<{ name: string; label?: string }> = [];
// 🔥 다중 소스 감지
if (sourceNodes.length > 1) {
console.log(`⚠️ 다중 소스 감지: ${sourceNodes.length}개 노드 연결됨`);
console.log(" 소스 노드들:", sourceNodes.map((n) => `${n.id}(${n.type})`).join(", "));
}
const fields: Array<{ name: string; label?: string; sourcePath?: string[] }> = [];
let foundRestAPI = false;
sourceNodes.forEach((node) => {
console.log(`🔍 노드 ${node.id} 타입: ${node.type}`);
console.log(`🔍 노드 ${node.id} 데이터:`, node.data);
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
// 🔥 현재 노드를 경로에 추가
const currentPath = [...sourcePath, `${node.id}(${node.type})`];
// 1⃣ 데이터 변환 노드: 변환된 필드 + 상위 노드의 원본 필드
if (node.type === "dataTransform") {
console.log("✅ 데이터 변환 노드 발견");
// 상위 노드의 원본 필드 먼저 수집
const upperResult = getAllSourceFields(node.id, visitedNodes);
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
const upperFields = upperResult.fields;
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
console.log(` 📤 상위 노드에서 ${upperFields.length}개 필드 가져옴`);
@ -167,7 +177,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
// 변환된 필드 추가 (in-place 변환 고려)
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
console.log(` 📊 ${(node.data as any).transformations.length}개 변환 발견`);
const inPlaceFields = new Set<string>(); // in-place 변환된 필드 추적
const inPlaceFields = new Set<string>();
(node.data as any).transformations.forEach((transform: any) => {
const targetField = transform.targetField || transform.sourceField;
@ -176,32 +186,29 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
console.log(` 🔹 변환: ${transform.sourceField}${targetField} ${isInPlace ? "(in-place)" : ""}`);
if (isInPlace) {
// in-place: 원본 필드를 덮어쓰므로, 원본 필드는 이미 upperFields에 있음
inPlaceFields.add(transform.sourceField);
} else if (targetField) {
// 새 필드 생성
fields.push({
name: targetField,
label: transform.targetFieldLabel || targetField,
sourcePath: currentPath,
});
}
});
// 상위 필드 중 in-place 변환되지 않은 것만 추가
// 상위 필드 추가
upperFields.forEach((field) => {
if (!inPlaceFields.has(field.name)) {
fields.push(field);
} else {
// in-place 변환된 필드도 추가 (변환 후 값)
fields.push(field);
}
});
} else {
// 변환이 없으면 상위 필드만 추가
fields.push(...upperFields);
}
}
// REST API 소스 노드인 경우
// 2REST API 소스 노드
else if (node.type === "restAPISource") {
console.log("✅ REST API 소스 노드 발견");
foundRestAPI = true;
@ -216,6 +223,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
fields.push({
name: fieldName,
label: fieldLabel,
sourcePath: currentPath,
});
}
});
@ -223,26 +231,44 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
console.log("⚠️ REST API 노드에 responseFields 없음");
}
}
// 일반 소스 노드인 경우 (테이블 소스 등)
else {
// 3⃣ 테이블/외부DB 소스 노드
else if (node.type === "tableSource" || node.type === "externalDBSource") {
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
const displayName = (node.data as any).displayName || (node.data as any).tableName || node.id;
if (nodeFields && Array.isArray(nodeFields)) {
console.log(`노드 ${node.id}에서 ${nodeFields.length}개 필드 발견`);
console.log(`${node.type}[${displayName}] 노드에서 ${nodeFields.length}개 필드 발견`);
nodeFields.forEach((field: any) => {
const fieldName = field.name || field.fieldName || field.column_name;
const fieldLabel = field.label || field.displayName || field.label_ko;
if (fieldName) {
// 🔥 다중 소스인 경우 필드명에 소스 표시
const displayLabel =
sourceNodes.length > 1 ? `${fieldLabel || fieldName} [${displayName}]` : fieldLabel || fieldName;
fields.push({
name: fieldName,
label: fieldLabel,
label: displayLabel,
sourcePath: currentPath,
});
}
});
} else {
console.log(`❌ 노드 ${node.id}에 fields 없음`);
console.log(`⚠️ ${node.type} 노드에 필드 정의 없음 → 상위 노드 탐색`);
// 필드가 없으면 상위 노드로 계속 탐색
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
}
}
// 4⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
else {
console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`);
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
console.log(` 📤 상위 노드에서 ${upperResult.fields.length}개 필드 가져옴`);
}
});
return { fields, hasRestAPI: foundRestAPI };
@ -251,8 +277,30 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
console.log("🔍 INSERT 노드 ID:", nodeId);
const result = getAllSourceFields(nodeId);
// 중복 제거
const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values());
console.log("📊 필드 수집 완료:");
console.log(` - 총 필드 수: ${result.fields.length}`);
console.log(` - REST API 포함: ${result.hasRestAPI}`);
// 🔥 중복 제거 개선: 필드명이 같아도 소스가 다르면 모두 표시
const fieldMap = new Map<string, (typeof result.fields)[number]>();
const duplicateFields = new Set<string>();
result.fields.forEach((field) => {
const key = `${field.name}`;
if (fieldMap.has(key)) {
duplicateFields.add(field.name);
}
// 중복이면 마지막 값으로 덮어씀 (기존 동작 유지)
fieldMap.set(key, field);
});
if (duplicateFields.size > 0) {
console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`);
console.warn(" → 마지막으로 발견된 필드만 표시됩니다.");
console.warn(" → 다중 소스 사용 시 필드명이 겹치지 않도록 주의하세요!");
}
const uniqueFields = Array.from(fieldMap.values());
setSourceFields(uniqueFields);
setHasRestAPISource(result.hasRestAPI);

View File

@ -166,14 +166,12 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
let foundRestAPI = false;
sourceNodes.forEach((node) => {
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
// 1데이터 변환 노드: 변환된 필드 + 상위 노드의 원본 필드
if (node.type === "dataTransform") {
// 상위 노드의 원본 필드 먼저 수집
const upperResult = getAllSourceFields(node.id, visitedNodes);
const upperFields = upperResult.fields;
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
// 변환된 필드 추가 (in-place 변환 고려)
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
const inPlaceFields = new Set<string>();
@ -191,7 +189,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
}
});
// 상위 필드 추가 (모두 포함, in-place는 변환 후 값)
upperFields.forEach((field) => {
fields.push(field);
});
@ -199,7 +196,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
fields.push(...upperFields);
}
}
// REST API 소스 노드인 경우
// 2REST API 소스 노드
else if (node.type === "restAPISource") {
foundRestAPI = true;
const responseFields = (node.data as any).responseFields;
@ -216,21 +213,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
});
}
}
// 일반 소스 노드인 경우
else if (node.type === "tableSource" && (node.data as any).fields) {
(node.data as any).fields.forEach((field: any) => {
fields.push({
name: field.name,
label: field.label || field.displayName,
// 3⃣ 테이블/외부DB 소스 노드
else if (node.type === "tableSource" || node.type === "externalDBSource") {
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
if (nodeFields && Array.isArray(nodeFields)) {
nodeFields.forEach((field: any) => {
const fieldName = field.name || field.fieldName || field.column_name;
const fieldLabel = field.label || field.displayName || field.label_ko;
if (fieldName) {
fields.push({
name: fieldName,
label: fieldLabel,
});
}
});
});
} else if (node.type === "externalDBSource" && (node.data as any).fields) {
(node.data as any).fields.forEach((field: any) => {
fields.push({
name: field.name,
label: field.label || field.displayName,
});
});
} else {
// 필드가 없으면 상위 노드로 계속 탐색
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
}
}
// 4⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
else {
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
}
});

View File

@ -153,14 +153,12 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
let foundRestAPI = false;
sourceNodes.forEach((node) => {
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
// 1데이터 변환 노드: 변환된 필드 + 상위 노드의 원본 필드
if (node.type === "dataTransform") {
// 상위 노드의 원본 필드 먼저 수집
const upperResult = getAllSourceFields(node.id, visitedNodes);
const upperFields = upperResult.fields;
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
// 변환된 필드 추가 (in-place 변환 고려)
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
const inPlaceFields = new Set<string>();
@ -178,7 +176,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
}
});
// 상위 필드 추가 (모두 포함, in-place는 변환 후 값)
upperFields.forEach((field) => {
fields.push(field);
});
@ -186,7 +183,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
fields.push(...upperFields);
}
}
// REST API 소스 노드인 경우
// 2REST API 소스 노드
else if (node.type === "restAPISource") {
foundRestAPI = true;
const responseFields = (node.data as any).responseFields;
@ -203,21 +200,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
});
}
}
// 일반 소스 노드인 경우
else if (node.type === "tableSource" && (node.data as any).fields) {
(node.data as any).fields.forEach((field: any) => {
fields.push({
name: field.name,
label: field.label || field.displayName,
// 3⃣ 테이블/외부DB 소스 노드
else if (node.type === "tableSource" || node.type === "externalDBSource") {
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
if (nodeFields && Array.isArray(nodeFields)) {
nodeFields.forEach((field: any) => {
const fieldName = field.name || field.fieldName || field.column_name;
const fieldLabel = field.label || field.displayName || field.label_ko;
if (fieldName) {
fields.push({
name: fieldName,
label: fieldLabel,
});
}
});
});
} else if (node.type === "externalDBSource" && (node.data as any).fields) {
(node.data as any).fields.forEach((field: any) => {
fields.push({
name: field.name,
label: field.label || field.displayName,
});
});
} else {
// 필드가 없으면 상위 노드로 계속 탐색
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
}
}
// 4⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
else {
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
}
});

View File

@ -52,14 +52,6 @@ export const NODE_PALETTE: NodePaletteItem[] = [
category: "transform",
color: "#EAB308", // 노란색
},
{
type: "fieldMapping",
label: "필드 매핑",
icon: "🔀",
description: "소스 필드를 타겟 필드로 매핑합니다",
category: "transform",
color: "#8B5CF6", // 보라색
},
{
type: "dataTransform",
label: "데이터 변환",

View File

@ -7,17 +7,38 @@ import { Connection, Edge, EdgeChange, Node, NodeChange, addEdge, applyNodeChang
import type { FlowNode, FlowEdge, NodeType, ValidationResult } from "@/types/node-editor";
import { createNodeFlow, updateNodeFlow } from "../api/nodeFlows";
// 🔥 Debounce 유틸리티
function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function (...args: Parameters<T>) {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// 🔥 외부 커넥션 캐시 타입
interface ExternalConnectionCache {
data: any[];
timestamp: number;
}
// 🔥 히스토리 스냅샷 타입
interface HistorySnapshot {
nodes: FlowNode[];
edges: FlowEdge[];
}
interface FlowEditorState {
// 노드 및 엣지
nodes: FlowNode[];
edges: FlowEdge[];
// 🔥 히스토리 관리
history: HistorySnapshot[];
historyIndex: number;
maxHistorySize: number;
isRestoringHistory: boolean; // 🔥 히스토리 복원 중 플래그
// 선택 상태
selectedNodes: string[];
selectedEdges: string[];
@ -39,12 +60,23 @@ interface FlowEditorState {
// 🔥 외부 커넥션 캐시 (전역 캐싱)
externalConnectionsCache: ExternalConnectionCache | null;
// ========================================================================
// 🔥 히스토리 관리 (Undo/Redo)
// ========================================================================
saveToHistory: () => void;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
// ========================================================================
// 노드 관리
// ========================================================================
setNodes: (nodes: FlowNode[]) => void;
onNodesChange: (changes: NodeChange[]) => void;
onNodeDragStart: () => void; // 노드 드래그 시작 시 히스토리 저장 (변경 전 상태)
addNode: (node: FlowNode) => void;
updateNode: (id: string, data: Partial<FlowNode["data"]>) => void;
removeNode: (id: string) => void;
@ -113,325 +145,527 @@ interface FlowEditorState {
getConnectedNodes: (nodeId: string) => { incoming: FlowNode[]; outgoing: FlowNode[] };
}
export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
// 초기 상태
nodes: [],
edges: [],
selectedNodes: [],
selectedEdges: [],
flowId: null,
flowName: "새 제어 플로우",
flowDescription: "",
isExecuting: false,
isSaving: false,
showValidationPanel: false,
showPropertiesPanel: true,
validationResult: null,
externalConnectionsCache: null, // 🔥 캐시 초기화
// 🔥 Debounced 히스토리 저장 함수 (스토어 외부에 생성)
let debouncedSaveToHistory: (() => void) | null = null;
// ========================================================================
// 노드 관리
// ========================================================================
export const useFlowEditorStore = create<FlowEditorState>((set, get) => {
// 🔥 Debounced 히스토리 저장 함수 초기화
if (!debouncedSaveToHistory) {
debouncedSaveToHistory = debounce(() => {
get().saveToHistory();
}, 500); // 500ms 지연
}
setNodes: (nodes) => set({ nodes }),
return {
// 초기 상태
nodes: [],
edges: [],
history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장
historyIndex: 0,
maxHistorySize: 50,
isRestoringHistory: false, // 🔥 히스토리 복원 중 플래그 초기화
selectedNodes: [],
selectedEdges: [],
flowId: null,
flowName: "새 제어 플로우",
flowDescription: "",
isExecuting: false,
isSaving: false,
showValidationPanel: false,
showPropertiesPanel: true,
validationResult: null,
externalConnectionsCache: null, // 🔥 캐시 초기화
onNodesChange: (changes) => {
set({
nodes: applyNodeChanges(changes, get().nodes) as FlowNode[],
});
},
// ========================================================================
// 🔥 히스토리 관리 (Undo/Redo)
// ========================================================================
addNode: (node) => {
set((state) => ({
nodes: [...state.nodes, node],
}));
},
saveToHistory: () => {
const { nodes, edges, history, historyIndex, maxHistorySize } = get();
updateNode: (id, data) => {
set((state) => ({
nodes: state.nodes.map((node) =>
node.id === id
? {
...node,
data: { ...node.data, ...data },
}
: node,
),
}));
},
removeNode: (id) => {
set((state) => ({
nodes: state.nodes.filter((node) => node.id !== id),
edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
}));
},
removeNodes: (ids) => {
set((state) => ({
nodes: state.nodes.filter((node) => !ids.includes(node.id)),
edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)),
}));
},
// ========================================================================
// 엣지 관리
// ========================================================================
setEdges: (edges) => set({ edges }),
onEdgesChange: (changes) => {
set({
edges: applyEdgeChanges(changes, get().edges) as FlowEdge[],
});
},
onConnect: (connection) => {
// 연결 검증
const validation = validateConnection(connection, get().nodes);
if (!validation.valid) {
console.warn("연결 검증 실패:", validation.error);
return;
}
set((state) => ({
edges: addEdge(
{
...connection,
type: "smoothstep",
animated: false,
data: {
validation: { valid: true },
},
},
state.edges,
) as FlowEdge[],
}));
},
removeEdge: (id) => {
set((state) => ({
edges: state.edges.filter((edge) => edge.id !== id),
}));
},
removeEdges: (ids) => {
set((state) => ({
edges: state.edges.filter((edge) => !ids.includes(edge.id)),
}));
},
// ========================================================================
// 선택 관리
// ========================================================================
selectNode: (id, multi = false) => {
set((state) => ({
selectedNodes: multi ? [...state.selectedNodes, id] : [id],
}));
},
selectNodes: (ids) => {
set({
selectedNodes: ids,
showPropertiesPanel: ids.length > 0, // 노드가 선택되면 속성창 자동으로 열기
});
},
selectEdge: (id, multi = false) => {
set((state) => ({
selectedEdges: multi ? [...state.selectedEdges, id] : [id],
}));
},
clearSelection: () => {
set({ selectedNodes: [], selectedEdges: [] });
},
// ========================================================================
// 플로우 관리
// ========================================================================
loadFlow: (id, name, description, nodes, edges) => {
set({
flowId: id,
flowName: name,
flowDescription: description,
nodes,
edges,
selectedNodes: [],
selectedEdges: [],
});
},
clearFlow: () => {
set({
flowId: null,
flowName: "새 제어 플로우",
flowDescription: "",
nodes: [],
edges: [],
selectedNodes: [],
selectedEdges: [],
validationResult: null,
});
},
setFlowName: (name) => set({ flowName: name }),
setFlowDescription: (description) => set({ flowDescription: description }),
saveFlow: async () => {
const { flowId, flowName, flowDescription, nodes, edges } = get();
if (!flowName || flowName.trim() === "") {
return { success: false, message: "플로우 이름을 입력해주세요." };
}
// 검증
const validation = get().validateFlow();
if (!validation.valid) {
return { success: false, message: `검증 실패: ${validation.errors[0]?.message || "오류가 있습니다."}` };
}
set({ isSaving: true });
try {
// 플로우 데이터 직렬화
const flowData = {
nodes: nodes.map((node) => ({
id: node.id,
type: node.type,
position: node.position,
data: node.data,
})),
edges: edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
})),
// 현재 상태를 스냅샷으로 저장
const snapshot: HistorySnapshot = {
nodes: JSON.parse(JSON.stringify(nodes)),
edges: JSON.parse(JSON.stringify(edges)),
};
const result = flowId
? await updateNodeFlow({
flowId,
flowName,
flowDescription,
flowData: JSON.stringify(flowData),
})
: await createNodeFlow({
flowName,
flowDescription,
flowData: JSON.stringify(flowData),
});
// historyIndex 이후의 히스토리 제거 (새로운 변경이 발생했으므로)
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push(snapshot);
set({ flowId: result.flowId });
return { success: true, flowId: result.flowId, message: "저장 완료!" };
} catch (error) {
console.error("플로우 저장 오류:", error);
return { success: false, message: error instanceof Error ? error.message : "저장 중 오류 발생" };
} finally {
set({ isSaving: false });
}
},
// 최대 크기 제한
if (newHistory.length > maxHistorySize) {
newHistory.shift();
}
exportFlow: () => {
const { flowName, flowDescription, nodes, edges } = get();
const flowData = {
flowName,
flowDescription,
nodes,
edges,
version: "1.0",
exportedAt: new Date().toISOString(),
};
return JSON.stringify(flowData, null, 2);
},
console.log("📸 히스토리 저장:", {
노드수: nodes.length,
엣지수: edges.length,
히스토리크기: newHistory.length,
현재인덱스: newHistory.length - 1,
});
// ========================================================================
// 검증
// ========================================================================
set({
history: newHistory,
historyIndex: newHistory.length - 1,
});
},
validateFlow: () => {
const { nodes, edges } = get();
const result = performFlowValidation(nodes, edges);
set({ validationResult: result });
return result;
},
undo: () => {
const { history, historyIndex } = get();
setValidationResult: (result) => set({ validationResult: result }),
console.log("⏪ Undo 시도:", { historyIndex, historyLength: history.length });
// ========================================================================
// UI 상태
// ========================================================================
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
const snapshot = history[newIndex];
setIsExecuting: (value) => set({ isExecuting: value }),
setIsSaving: (value) => set({ isSaving: value }),
setShowValidationPanel: (value) => set({ showValidationPanel: value }),
setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }),
console.log("✅ Undo 실행:", {
이전인덱스: historyIndex,
새인덱스: newIndex,
노드수: snapshot.nodes.length,
엣지수: snapshot.edges.length,
});
// ========================================================================
// 유틸리티
// ========================================================================
// 🔥 히스토리 복원 중 플래그 설정
set({ isRestoringHistory: true });
getNodeById: (id) => {
return get().nodes.find((node) => node.id === id);
},
// 노드와 엣지 복원
set({
nodes: JSON.parse(JSON.stringify(snapshot.nodes)),
edges: JSON.parse(JSON.stringify(snapshot.edges)),
historyIndex: newIndex,
});
getEdgeById: (id) => {
return get().edges.find((edge) => edge.id === id);
},
// 🔥 다음 틱에서 플래그 해제 (ReactFlow 이벤트가 모두 처리된 후)
setTimeout(() => {
set({ isRestoringHistory: false });
}, 0);
} else {
console.log("❌ Undo 불가: 히스토리가 없음");
}
},
getConnectedNodes: (nodeId) => {
const { nodes, edges } = get();
redo: () => {
const { history, historyIndex } = get();
const incoming = edges
.filter((edge) => edge.target === nodeId)
.map((edge) => nodes.find((node) => node.id === edge.source))
.filter((node): node is FlowNode => node !== undefined);
console.log("⏩ Redo 시도:", { historyIndex, historyLength: history.length });
const outgoing = edges
.filter((edge) => edge.source === nodeId)
.map((edge) => nodes.find((node) => node.id === edge.target))
.filter((node): node is FlowNode => node !== undefined);
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
const snapshot = history[newIndex];
return { incoming, outgoing };
},
console.log("✅ Redo 실행:", {
이전인덱스: historyIndex,
새인덱스: newIndex,
노드수: snapshot.nodes.length,
엣지수: snapshot.edges.length,
});
// ========================================================================
// 🔥 외부 커넥션 캐시 관리
// ========================================================================
// 🔥 히스토리 복원 중 플래그 설정
set({ isRestoringHistory: true });
setExternalConnectionsCache: (data) => {
set({
externalConnectionsCache: {
data,
timestamp: Date.now(),
},
});
},
// 노드와 엣지 복원
set({
nodes: JSON.parse(JSON.stringify(snapshot.nodes)),
edges: JSON.parse(JSON.stringify(snapshot.edges)),
historyIndex: newIndex,
});
clearExternalConnectionsCache: () => {
set({ externalConnectionsCache: null });
},
// 🔥 다음 틱에서 플래그 해제 (ReactFlow 이벤트가 모두 처리된 후)
setTimeout(() => {
set({ isRestoringHistory: false });
}, 0);
} else {
console.log("❌ Redo 불가: 되돌릴 히스토리가 없음");
}
},
getExternalConnectionsCache: () => {
const cache = get().externalConnectionsCache;
if (!cache) return null;
canUndo: () => {
const { historyIndex } = get();
return historyIndex > 0;
},
// 🔥 5분 후 캐시 만료
const CACHE_DURATION = 5 * 60 * 1000; // 5분
const isExpired = Date.now() - cache.timestamp > CACHE_DURATION;
canRedo: () => {
const { history, historyIndex } = get();
return historyIndex < history.length - 1;
},
if (isExpired) {
// ========================================================================
// 노드 관리
// ========================================================================
setNodes: (nodes) => set({ nodes }),
onNodesChange: (changes) => {
set({
nodes: applyNodeChanges(changes, get().nodes) as FlowNode[],
});
},
onNodeDragStart: () => {
// 🔥 히스토리 복원 중이면 저장하지 않음
if (get().isRestoringHistory) {
console.log("⏭️ 히스토리 복원 중, 저장 스킵");
return;
}
// 노드 드래그 시작 시 히스토리 저장 (변경 전 상태)
get().saveToHistory();
console.log("🎯 노드 이동 시작, 변경 전 상태 히스토리 저장");
},
addNode: (node) => {
// 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
set((state) => ({
nodes: [...state.nodes, node],
}));
},
updateNode: (id, data) => {
// 🔥 Debounced 히스토리 저장 (500ms 지연 - 타이핑 중에는 저장 안됨)
// 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory && debouncedSaveToHistory) {
debouncedSaveToHistory();
}
set((state) => ({
nodes: state.nodes.map((node) =>
node.id === id
? {
...node,
data: { ...node.data, ...data },
}
: node,
),
}));
},
removeNode: (id) => {
// 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
set((state) => ({
nodes: state.nodes.filter((node) => node.id !== id),
edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
}));
},
removeNodes: (ids) => {
// 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
set((state) => ({
nodes: state.nodes.filter((node) => !ids.includes(node.id)),
edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)),
}));
},
// ========================================================================
// 엣지 관리
// ========================================================================
setEdges: (edges) => set({ edges }),
onEdgesChange: (changes) => {
// 엣지 삭제(remove) 타입이 있으면 히스토리 저장
// 🔥 히스토리 복원 중이 아닐 때만 저장
const hasRemove = changes.some((change) => change.type === "remove");
if (hasRemove && !get().isRestoringHistory) {
get().saveToHistory();
console.log("🔗 엣지 삭제, 변경 전 상태 히스토리 저장");
}
set({
edges: applyEdgeChanges(changes, get().edges) as FlowEdge[],
});
},
onConnect: (connection) => {
// 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
// 연결 검증
const validation = validateConnection(connection, get().nodes);
if (!validation.valid) {
console.warn("연결 검증 실패:", validation.error);
return;
}
set((state) => ({
edges: addEdge(
{
...connection,
type: "smoothstep",
animated: false,
data: {
validation: { valid: true },
},
},
state.edges,
) as FlowEdge[],
}));
},
removeEdge: (id) => {
// 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
set((state) => ({
edges: state.edges.filter((edge) => edge.id !== id),
}));
},
removeEdges: (ids) => {
// 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
set((state) => ({
edges: state.edges.filter((edge) => !ids.includes(edge.id)),
}));
},
// ========================================================================
// 선택 관리
// ========================================================================
selectNode: (id, multi = false) => {
set((state) => ({
selectedNodes: multi ? [...state.selectedNodes, id] : [id],
}));
},
selectNodes: (ids) => {
set({
selectedNodes: ids,
showPropertiesPanel: ids.length > 0, // 노드가 선택되면 속성창 자동으로 열기
});
},
selectEdge: (id, multi = false) => {
set((state) => ({
selectedEdges: multi ? [...state.selectedEdges, id] : [id],
}));
},
clearSelection: () => {
set({ selectedNodes: [], selectedEdges: [] });
},
// ========================================================================
// 플로우 관리
// ========================================================================
loadFlow: (id, name, description, nodes, edges) => {
console.log("📂 플로우 로드:", { id, name, 노드수: nodes.length, 엣지수: edges.length });
set({
flowId: id,
flowName: name,
flowDescription: description,
nodes,
edges,
selectedNodes: [],
selectedEdges: [],
// 로드된 상태를 히스토리의 첫 번째 스냅샷으로 저장
history: [{ nodes: JSON.parse(JSON.stringify(nodes)), edges: JSON.parse(JSON.stringify(edges)) }],
historyIndex: 0,
});
},
clearFlow: () => {
console.log("🔄 플로우 초기화");
set({
flowId: null,
flowName: "새 제어 플로우",
flowDescription: "",
nodes: [],
edges: [],
selectedNodes: [],
selectedEdges: [],
validationResult: null,
history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장
historyIndex: 0,
});
},
setFlowName: (name) => set({ flowName: name }),
setFlowDescription: (description) => set({ flowDescription: description }),
saveFlow: async () => {
const { flowId, flowName, flowDescription, nodes, edges } = get();
if (!flowName || flowName.trim() === "") {
return { success: false, message: "플로우 이름을 입력해주세요." };
}
// 🔥 검증 (순환 참조 및 기타 에러 체크)
const validation = get().validateFlow();
// 🔥 검증 실패 시 상세 메시지와 함께 저장 차단
if (!validation.valid) {
const errorMessages = validation.errors
.filter((err) => err.severity === "error")
.map((err) => err.message)
.join(", ");
// 🔥 검증 패널 표시하여 사용자가 오류를 확인할 수 있도록
set({ validationResult: validation, showValidationPanel: true });
return {
success: false,
message: `플로우를 저장할 수 없습니다: ${errorMessages}`,
};
}
set({ isSaving: true });
try {
// 플로우 데이터 직렬화
const flowData = {
nodes: nodes.map((node) => ({
id: node.id,
type: node.type,
position: node.position,
data: node.data,
})),
edges: edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
})),
};
const result = flowId
? await updateNodeFlow({
flowId,
flowName,
flowDescription,
flowData: JSON.stringify(flowData),
})
: await createNodeFlow({
flowName,
flowDescription,
flowData: JSON.stringify(flowData),
});
set({ flowId: result.flowId });
return { success: true, flowId: result.flowId, message: "저장 완료!" };
} catch (error) {
console.error("플로우 저장 오류:", error);
return { success: false, message: error instanceof Error ? error.message : "저장 중 오류 발생" };
} finally {
set({ isSaving: false });
}
},
exportFlow: () => {
const { flowName, flowDescription, nodes, edges } = get();
const flowData = {
flowName,
flowDescription,
nodes,
edges,
version: "1.0",
exportedAt: new Date().toISOString(),
};
return JSON.stringify(flowData, null, 2);
},
// ========================================================================
// 검증
// ========================================================================
validateFlow: () => {
const { nodes, edges } = get();
const result = performFlowValidation(nodes, edges);
set({ validationResult: result });
return result;
},
setValidationResult: (result) => set({ validationResult: result }),
// ========================================================================
// UI 상태
// ========================================================================
setIsExecuting: (value) => set({ isExecuting: value }),
setIsSaving: (value) => set({ isSaving: value }),
setShowValidationPanel: (value) => set({ showValidationPanel: value }),
setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }),
// ========================================================================
// 유틸리티
// ========================================================================
getNodeById: (id) => {
return get().nodes.find((node) => node.id === id);
},
getEdgeById: (id) => {
return get().edges.find((edge) => edge.id === id);
},
getConnectedNodes: (nodeId) => {
const { nodes, edges } = get();
const incoming = edges
.filter((edge) => edge.target === nodeId)
.map((edge) => nodes.find((node) => node.id === edge.source))
.filter((node): node is FlowNode => node !== undefined);
const outgoing = edges
.filter((edge) => edge.source === nodeId)
.map((edge) => nodes.find((node) => node.id === edge.target))
.filter((node): node is FlowNode => node !== undefined);
return { incoming, outgoing };
},
// ========================================================================
// 🔥 외부 커넥션 캐시 관리
// ========================================================================
setExternalConnectionsCache: (data) => {
set({
externalConnectionsCache: {
data,
timestamp: Date.now(),
},
});
},
clearExternalConnectionsCache: () => {
set({ externalConnectionsCache: null });
return null;
}
},
return cache.data;
},
}));
getExternalConnectionsCache: () => {
const cache = get().externalConnectionsCache;
if (!cache) return null;
// 🔥 5분 후 캐시 만료
const CACHE_DURATION = 5 * 60 * 1000; // 5분
const isExpired = Date.now() - cache.timestamp > CACHE_DURATION;
if (isExpired) {
set({ externalConnectionsCache: null });
return null;
}
return cache.data;
},
}; // 🔥 return 블록 종료
}); // 🔥 create 함수 종료
// ============================================================================
// 헬퍼 함수들

View File

@ -14,7 +14,6 @@ export type NodeType =
| "restAPISource" // REST API 소스
| "referenceLookup" // 참조 테이블 조회 (내부 DB 전용)
| "condition" // 조건 분기
| "fieldMapping" // 필드 매핑
| "dataTransform" // 데이터 변환
| "insertAction" // INSERT 액션
| "updateAction" // UPDATE 액션