From ab9ddaa190665f624861f911cc9fed3f2ba5a2f4 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 11 Dec 2025 15:25:48 +0900 Subject: [PATCH] =?UTF-8?q?=EC=99=B8=EB=B6=80=20DB=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EB=81=8A=EA=B9=80=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/digitalTwinDataController.ts | 140 +++++++++++------- .../externalDbConnectionPoolService.ts | 82 +++++++++- 2 files changed, 159 insertions(+), 63 deletions(-) diff --git a/backend-node/src/controllers/digitalTwinDataController.ts b/backend-node/src/controllers/digitalTwinDataController.ts index 80cb8ccd..e80a44dc 100644 --- a/backend-node/src/controllers/digitalTwinDataController.ts +++ b/backend-node/src/controllers/digitalTwinDataController.ts @@ -1,43 +1,25 @@ import { Request, Response } from "express"; -import { pool, queryOne } from "../database/db"; import logger from "../utils/logger"; -import { PasswordEncryption } from "../utils/passwordEncryption"; -import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; +import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService"; -// 외부 DB 커넥터를 가져오는 헬퍼 함수 +// 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용) export async function getExternalDbConnector(connectionId: number) { - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); + const poolService = ExternalDbConnectionPoolService.getInstance(); - if (!connection) { - throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`); - } - - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, + // 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체) + return { + executeQuery: async (sql: string, params?: any[]) => { + const result = await poolService.executeQuery(connectionId, sql, params); + return { rows: result }; + }, }; - - // DB 커넥터 생성 - return await DatabaseConnectorFactory.createConnector( - connection.db_type || "mariadb", - config, - connectionId - ); } // 동적 계층 구조 데이터 조회 (범용) -export const getHierarchyData = async (req: Request, res: Response): Promise => { +export const getHierarchyData = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, hierarchyConfig } = req.body; @@ -48,7 +30,9 @@ export const getHierarchyData = async (req: Request, res: Response): Promise ({ level: l.level, count: l.data.length })), + levelCounts: result.levels.map((l: any) => ({ + level: l.level, + count: l.data.length, + })), }); return res.json({ @@ -112,22 +99,35 @@ export const getHierarchyData = async (req: Request, res: Response): Promise => { +export const getChildrenData = async ( + req: Request, + res: Response +): Promise => { try { - const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body; + const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = + req.body; - if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) { + if ( + !externalDbConnectionId || + !hierarchyConfig || + !parentLevel || + !parentKey + ) { return res.status(400).json({ success: false, message: "필수 파라미터가 누락되었습니다.", }); } - const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const connector = await getExternalDbConnector( + Number(externalDbConnectionId) + ); const config = JSON.parse(hierarchyConfig); // 다음 레벨 찾기 - const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1); + const nextLevel = config.levels?.find( + (l: any) => l.level === parentLevel + 1 + ); if (!nextLevel) { return res.json({ @@ -168,7 +168,10 @@ export const getChildrenData = async (req: Request, res: Response): Promise => { +export const getWarehouses = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, tableName } = req.query; @@ -186,7 +189,9 @@ export const getWarehouses = async (req: Request, res: Response): Promise => { +export const getAreas = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, warehouseKey, tableName } = req.query; @@ -226,7 +234,9 @@ export const getAreas = async (req: Request, res: Response): Promise = }); } - const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const connector = await getExternalDbConnector( + Number(externalDbConnectionId) + ); const query = ` SELECT * FROM ${tableName} @@ -258,7 +268,10 @@ export const getAreas = async (req: Request, res: Response): Promise = }; // 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 -export const getLocations = async (req: Request, res: Response): Promise => { +export const getLocations = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, areaKey, tableName } = req.query; @@ -269,7 +282,9 @@ export const getLocations = async (req: Request, res: Response): Promise => { +export const getMaterials = async ( + req: Request, + res: Response +): Promise => { try { - const { - externalDbConnectionId, - locaKey, + const { + externalDbConnectionId, + locaKey, tableName, keyColumn, locationKeyColumn, - layerColumn + layerColumn, } = req.query; - if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) { + if ( + !externalDbConnectionId || + !locaKey || + !tableName || + !locationKeyColumn + ) { return res.status(400).json({ success: false, message: "필수 파라미터가 누락되었습니다.", }); } - const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const connector = await getExternalDbConnector( + Number(externalDbConnectionId) + ); // 동적 쿼리 생성 - const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : ''; + const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : ""; const query = ` SELECT * FROM ${tableName} WHERE ${locationKeyColumn} = '${locaKey}' @@ -356,7 +381,10 @@ export const getMaterials = async (req: Request, res: Response): Promise => { +export const getMaterialCounts = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, locationKeys, tableName } = req.body; @@ -367,7 +395,9 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise `'${key}'`).join(","); diff --git a/backend-node/src/services/externalDbConnectionPoolService.ts b/backend-node/src/services/externalDbConnectionPoolService.ts index 940787c3..975fafe5 100644 --- a/backend-node/src/services/externalDbConnectionPoolService.ts +++ b/backend-node/src/services/externalDbConnectionPoolService.ts @@ -113,6 +113,7 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { lastUsedAt: Date; activeConnections = 0; maxConnections: number; + private isPoolClosed = false; constructor(config: ExternalDbConnection) { this.connectionId = config.id!; @@ -131,6 +132,9 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { waitForConnections: true, queueLimit: 0, connectTimeout: (config.connection_timeout || 30) * 1000, + // 연결 유지 및 자동 재연결 설정 + enableKeepAlive: true, + keepAliveInitialDelay: 10000, // 10초마다 keep-alive 패킷 전송 ssl: config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined, }); @@ -149,15 +153,46 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { `[${this.dbType.toUpperCase()}] 연결 반환 (${this.activeConnections}/${this.maxConnections})` ); }); + + // 연결 오류 이벤트 처리 + this.pool.on("error", (err) => { + logger.error(`[${this.dbType.toUpperCase()}] 연결 풀 오류:`, err); + // 연결이 닫힌 경우 플래그 설정 + if (err.message.includes("closed state")) { + this.isPoolClosed = true; + } + }); } async query(sql: string, params?: any[]): Promise { this.lastUsedAt = new Date(); - const [rows] = await this.pool.execute(sql, params); - return rows; + + // 연결 풀이 닫힌 상태인지 확인 + if (this.isPoolClosed) { + throw new Error("연결 풀이 닫힌 상태입니다. 재연결이 필요합니다."); + } + + try { + const [rows] = await this.pool.execute(sql, params); + return rows; + } catch (error: any) { + // 연결 닫힘 오류 감지 + if ( + error.message.includes("closed state") || + error.code === "PROTOCOL_CONNECTION_LOST" || + error.code === "ECONNRESET" + ) { + this.isPoolClosed = true; + logger.warn( + `[${this.dbType.toUpperCase()}] 연결 끊김 감지 (ID: ${this.connectionId})` + ); + } + throw error; + } } async disconnect(): Promise { + this.isPoolClosed = true; await this.pool.end(); logger.info( `[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})` @@ -165,6 +200,10 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { } isHealthy(): boolean { + // 연결 풀이 닫혔으면 비정상 + if (this.isPoolClosed) { + return false; + } return this.activeConnections < this.maxConnections; } } @@ -230,9 +269,11 @@ export class ExternalDbConnectionPoolService { ): Promise { logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`); - // DB 연결 정보 조회 + // DB 연결 정보 조회 (실제 비밀번호 포함) const connectionResult = - await ExternalDbConnectionService.getConnectionById(connectionId); + await ExternalDbConnectionService.getConnectionByIdWithPassword( + connectionId + ); if (!connectionResult.success || !connectionResult.data) { throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`); @@ -296,16 +337,19 @@ export class ExternalDbConnectionPoolService { } /** - * 쿼리 실행 (자동으로 연결 풀 관리) + * 쿼리 실행 (자동으로 연결 풀 관리 + 재시도 로직) */ async executeQuery( connectionId: number, sql: string, - params?: any[] + params?: any[], + retryCount = 0 ): Promise { - const pool = await this.getPool(connectionId); + const MAX_RETRIES = 2; try { + const pool = await this.getPool(connectionId); + logger.debug( `📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...` ); @@ -314,7 +358,29 @@ export class ExternalDbConnectionPoolService { `✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건` ); return result; - } catch (error) { + } catch (error: any) { + // 연결 끊김 오류인 경우 재시도 + const isConnectionError = + error.message?.includes("closed state") || + error.message?.includes("연결 풀이 닫힌 상태") || + error.code === "PROTOCOL_CONNECTION_LOST" || + error.code === "ECONNRESET" || + error.code === "ETIMEDOUT"; + + if (isConnectionError && retryCount < MAX_RETRIES) { + logger.warn( + `🔄 연결 오류 감지, 재시도 중... (${retryCount + 1}/${MAX_RETRIES}) (ID: ${connectionId})` + ); + + // 기존 풀 제거 후 새로 생성 + await this.removePool(connectionId); + + // 잠시 대기 후 재시도 + await new Promise((resolve) => setTimeout(resolve, 500)); + + return this.executeQuery(connectionId, sql, params, retryCount + 1); + } + logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error); throw error; }