diff --git a/backend-node/check-db.js b/backend-node/check-db.js deleted file mode 100644 index 941dc30d..00000000 --- a/backend-node/check-db.js +++ /dev/null @@ -1,59 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); -const prisma = new PrismaClient(); - -async function checkDatabase() { - try { - console.log("=== 데이터베이스 연결 확인 ==="); - const userCount = await prisma.user_info.count(); - console.log(`총 사용자 수: ${userCount}`); - - if (userCount > 0) { - const users = await prisma.user_info.findMany({ - take: 10, - select: { - user_id: true, - user_name: true, - dept_name: true, - company_code: true, - }, - }); - console.log("\n=== 사용자 목록 (대소문자 확인) ==="); - users.forEach((user, index) => { - console.log( - `${index + 1}. "${user.user_id}" - ${user.user_name || "이름 없음"} (${user.dept_name || "부서 없음"})` - ); - }); - - console.log("\n=== 특정 사용자 검색 테스트 ==="); - const userLower = await prisma.user_info.findUnique({ - where: { user_id: "arvin" }, - }); - console.log('소문자 "arvin" 검색 결과:', userLower ? "찾음" : "없음"); - const userUpper = await prisma.user_info.findUnique({ - where: { user_id: "ARVIN" }, - }); - console.log('대문자 "ARVIN" 검색 결과:', userUpper ? "찾음" : "없음"); - - const rawUsers = await prisma.$queryRaw` - SELECT user_id, user_name, dept_name - FROM user_info - WHERE user_id IN ('arvin', 'ARVIN', 'Arvin') - LIMIT 5 - `; - console.log("\n=== 원본 데이터 확인 ==="); - rawUsers.forEach((user) => { - console.log(`"${user.user_id}" - ${user.user_name || "이름 없음"}`); - }); - } - - // 로그인 로그 확인 - const logCount = await prisma.login_access_log.count(); - console.log(`\n총 로그인 로그 수: ${logCount}`); - } catch (error) { - console.error("오류 발생:", error); - } finally { - await prisma.$disconnect(); - } -} - -checkDatabase(); diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index d35c0bcc..11546174 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -39,6 +39,35 @@ model external_call_configs { @@index([is_active]) } +model external_db_connections { + id Int @id @default(autoincrement()) + connection_name String @db.VarChar(100) + description String? @db.Text + db_type String @db.VarChar(20) + host String @db.VarChar(255) + port Int + database_name String @db.VarChar(100) + username String @db.VarChar(100) + password String @db.Text + connection_timeout Int? @default(30) + query_timeout Int? @default(60) + max_connections Int? @default(10) + ssl_enabled String @default("N") @db.Char(1) + ssl_cert_path String? @db.VarChar(500) + connection_options Json? + company_code String @default("*") @db.VarChar(20) + is_active String @default("Y") @db.Char(1) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + @@index([company_code]) + @@index([is_active]) + @@index([db_type]) + @@index([connection_name]) +} + model admin_supply_mng { objid Decimal @id @default(0) @db.Decimal supply_code String? @default("NULL::character varying") @db.VarChar(100) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index e0519334..494ca474 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -30,6 +30,7 @@ import layoutRoutes from "./routes/layoutRoutes"; import dataRoutes from "./routes/dataRoutes"; import externalCallRoutes from "./routes/externalCallRoutes"; import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; +import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -123,6 +124,7 @@ app.use("/api/screen", screenStandardRoutes); app.use("/api/data", dataRoutes); app.use("/api/external-calls", externalCallRoutes); app.use("/api/external-call-configs", externalCallConfigRoutes); +app.use("/api/external-db-connections", externalDbConnectionRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/routes/externalDbConnectionRoutes.ts b/backend-node/src/routes/externalDbConnectionRoutes.ts new file mode 100644 index 00000000..9c6e86e1 --- /dev/null +++ b/backend-node/src/routes/externalDbConnectionRoutes.ts @@ -0,0 +1,242 @@ +// 외부 DB 연결 API 라우트 +// 작성일: 2024-12-17 + +import { Router, Response } from "express"; +import { ExternalDbConnectionService } from "../services/externalDbConnectionService"; +import { + ExternalDbConnection, + ExternalDbConnectionFilter, +} from "../types/externalDbTypes"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { AuthenticatedRequest } from "../types/auth"; + +const router = Router(); + +/** + * GET /api/external-db-connections + * 외부 DB 연결 목록 조회 + */ +router.get( + "/", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const filter: ExternalDbConnectionFilter = { + db_type: req.query.db_type as string, + is_active: req.query.is_active as string, + company_code: req.query.company_code as string, + search: req.query.search as string, + }; + + // 빈 값 제거 + Object.keys(filter).forEach((key) => { + if (!filter[key as keyof ExternalDbConnectionFilter]) { + delete filter[key as keyof ExternalDbConnectionFilter]; + } + }); + + const result = await ExternalDbConnectionService.getConnections(filter); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(400).json(result); + } + } catch (error) { + console.error("외부 DB 연결 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * GET /api/external-db-connections/:id + * 특정 외부 DB 연결 조회 + */ +router.get( + "/:id", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const result = await ExternalDbConnectionService.getConnectionById(id); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(404).json(result); + } + } catch (error) { + console.error("외부 DB 연결 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * POST /api/external-db-connections + * 새 외부 DB 연결 생성 + */ +router.post( + "/", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const connectionData: ExternalDbConnection = req.body; + + // 사용자 정보 추가 + if (req.user) { + connectionData.created_by = req.user.userId; + connectionData.updated_by = req.user.userId; + } + + const result = + await ExternalDbConnectionService.createConnection(connectionData); + + if (result.success) { + return res.status(201).json(result); + } else { + return res.status(400).json(result); + } + } catch (error) { + console.error("외부 DB 연결 생성 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * PUT /api/external-db-connections/:id + * 외부 DB 연결 수정 + */ +router.put( + "/:id", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const updateData: Partial = req.body; + + // 사용자 정보 추가 + if (req.user) { + updateData.updated_by = req.user.userId; + } + + const result = await ExternalDbConnectionService.updateConnection( + id, + updateData + ); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(400).json(result); + } + } catch (error) { + console.error("외부 DB 연결 수정 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * DELETE /api/external-db-connections/:id + * 외부 DB 연결 삭제 (논리 삭제) + */ +router.delete( + "/:id", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const result = await ExternalDbConnectionService.deleteConnection(id); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(404).json(result); + } + } catch (error) { + console.error("외부 DB 연결 삭제 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * GET /api/external-db-connections/types/supported + * 지원하는 DB 타입 목록 조회 + */ +router.get( + "/types/supported", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const { DB_TYPE_OPTIONS, DB_TYPE_DEFAULTS } = await import( + "../types/externalDbTypes" + ); + + return res.status(200).json({ + success: true, + data: { + types: DB_TYPE_OPTIONS, + defaults: DB_TYPE_DEFAULTS, + }, + message: "지원하는 DB 타입 목록을 조회했습니다.", + }); + } catch (error) { + console.error("DB 타입 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +export default router; diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts new file mode 100644 index 00000000..f614253f --- /dev/null +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -0,0 +1,374 @@ +// 외부 DB 연결 서비스 +// 작성일: 2024-12-17 + +import { PrismaClient } from "@prisma/client"; +import { + ExternalDbConnection, + ExternalDbConnectionFilter, + ApiResponse, +} from "../types/externalDbTypes"; +import { PasswordEncryption } from "../utils/passwordEncryption"; + +const prisma = new PrismaClient(); + +export class ExternalDbConnectionService { + /** + * 외부 DB 연결 목록 조회 + */ + static async getConnections( + filter: ExternalDbConnectionFilter + ): Promise> { + try { + const where: any = {}; + + // 필터 조건 적용 + if (filter.db_type) { + where.db_type = filter.db_type; + } + + if (filter.is_active) { + where.is_active = filter.is_active; + } + + if (filter.company_code) { + where.company_code = filter.company_code; + } + + // 검색 조건 적용 (연결명 또는 설명에서 검색) + if (filter.search && filter.search.trim()) { + where.OR = [ + { + connection_name: { + contains: filter.search.trim(), + mode: "insensitive", + }, + }, + { + description: { + contains: filter.search.trim(), + mode: "insensitive", + }, + }, + ]; + } + + const connections = await prisma.external_db_connections.findMany({ + where, + orderBy: [{ is_active: "desc" }, { connection_name: "asc" }], + }); + + // 비밀번호는 반환하지 않음 (보안) + const safeConnections = connections.map((conn) => ({ + ...conn, + password: "***ENCRYPTED***", // 실제 비밀번호 대신 마스킹 + description: conn.description || undefined, + })) as ExternalDbConnection[]; + + return { + success: true, + data: safeConnections, + message: `${connections.length}개의 연결 설정을 조회했습니다.`, + }; + } catch (error) { + console.error("외부 DB 연결 목록 조회 실패:", error); + return { + success: false, + message: "연결 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 특정 외부 DB 연결 조회 + */ + static async getConnectionById( + id: number + ): Promise> { + try { + const connection = await prisma.external_db_connections.findUnique({ + where: { id }, + }); + + if (!connection) { + return { + success: false, + message: "해당 연결 설정을 찾을 수 없습니다.", + }; + } + + // 비밀번호는 반환하지 않음 (보안) + const safeConnection = { + ...connection, + password: "***ENCRYPTED***", + description: connection.description || undefined, + } as ExternalDbConnection; + + return { + success: true, + data: safeConnection, + message: "연결 설정을 조회했습니다.", + }; + } catch (error) { + console.error("외부 DB 연결 조회 실패:", error); + return { + success: false, + message: "연결 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 새 외부 DB 연결 생성 + */ + static async createConnection( + data: ExternalDbConnection + ): Promise> { + try { + // 데이터 검증 + this.validateConnectionData(data); + + // 연결명 중복 확인 + const existingConnection = await prisma.external_db_connections.findFirst( + { + where: { + connection_name: data.connection_name, + company_code: data.company_code, + }, + } + ); + + if (existingConnection) { + return { + success: false, + message: "이미 존재하는 연결명입니다.", + }; + } + + // 비밀번호 암호화 + const encryptedPassword = PasswordEncryption.encrypt(data.password); + + const newConnection = await prisma.external_db_connections.create({ + data: { + connection_name: data.connection_name, + description: data.description, + db_type: data.db_type, + host: data.host, + port: data.port, + database_name: data.database_name, + username: data.username, + password: encryptedPassword, + connection_timeout: data.connection_timeout, + query_timeout: data.query_timeout, + max_connections: data.max_connections, + ssl_enabled: data.ssl_enabled, + ssl_cert_path: data.ssl_cert_path, + connection_options: data.connection_options as any, + company_code: data.company_code, + is_active: data.is_active, + created_by: data.created_by, + updated_by: data.updated_by, + created_date: new Date(), + updated_date: new Date(), + }, + }); + + // 비밀번호는 반환하지 않음 + const safeConnection = { + ...newConnection, + password: "***ENCRYPTED***", + description: newConnection.description || undefined, + } as ExternalDbConnection; + + return { + success: true, + data: safeConnection, + message: "연결 설정이 생성되었습니다.", + }; + } catch (error) { + console.error("외부 DB 연결 생성 실패:", error); + return { + success: false, + message: "연결 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 외부 DB 연결 수정 + */ + static async updateConnection( + id: number, + data: Partial + ): Promise> { + try { + // 기존 연결 확인 + const existingConnection = + await prisma.external_db_connections.findUnique({ + where: { id }, + }); + + if (!existingConnection) { + return { + success: false, + message: "해당 연결 설정을 찾을 수 없습니다.", + }; + } + + // 연결명 중복 확인 (자신 제외) + if (data.connection_name) { + const duplicateConnection = + await prisma.external_db_connections.findFirst({ + where: { + connection_name: data.connection_name, + company_code: + data.company_code || existingConnection.company_code, + id: { not: id }, + }, + }); + + if (duplicateConnection) { + return { + success: false, + message: "이미 존재하는 연결명입니다.", + }; + } + } + + // 업데이트 데이터 준비 + const updateData: any = { + ...data, + updated_date: new Date(), + }; + + // 비밀번호가 변경된 경우 암호화 + if (data.password && data.password !== "***ENCRYPTED***") { + updateData.password = PasswordEncryption.encrypt(data.password); + } else { + // 비밀번호 필드 제거 (변경하지 않음) + delete updateData.password; + } + + const updatedConnection = await prisma.external_db_connections.update({ + where: { id }, + data: updateData, + }); + + // 비밀번호는 반환하지 않음 + const safeConnection = { + ...updatedConnection, + password: "***ENCRYPTED***", + description: updatedConnection.description || undefined, + } as ExternalDbConnection; + + return { + success: true, + data: safeConnection, + message: "연결 설정이 수정되었습니다.", + }; + } catch (error) { + console.error("외부 DB 연결 수정 실패:", error); + return { + success: false, + message: "연결 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 외부 DB 연결 삭제 (논리 삭제) + */ + static async deleteConnection(id: number): Promise> { + try { + const existingConnection = + await prisma.external_db_connections.findUnique({ + where: { id }, + }); + + if (!existingConnection) { + return { + success: false, + message: "해당 연결 설정을 찾을 수 없습니다.", + }; + } + + // 논리 삭제 (is_active를 'N'으로 변경) + await prisma.external_db_connections.update({ + where: { id }, + data: { + is_active: "N", + updated_date: new Date(), + }, + }); + + return { + success: true, + message: "연결 설정이 삭제되었습니다.", + }; + } catch (error) { + console.error("외부 DB 연결 삭제 실패:", error); + return { + success: false, + message: "연결 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 연결 데이터 검증 + */ + private static validateConnectionData(data: ExternalDbConnection): void { + const requiredFields = [ + "connection_name", + "db_type", + "host", + "port", + "database_name", + "username", + "password", + "company_code", + ]; + + for (const field of requiredFields) { + if (!data[field as keyof ExternalDbConnection]) { + throw new Error(`필수 필드가 누락되었습니다: ${field}`); + } + } + + // 포트 번호 유효성 검사 + if (data.port < 1 || data.port > 65535) { + throw new Error("유효하지 않은 포트 번호입니다. (1-65535)"); + } + + // DB 타입 유효성 검사 + const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite"]; + if (!validDbTypes.includes(data.db_type)) { + throw new Error("지원하지 않는 DB 타입입니다."); + } + } + + /** + * 저장된 연결의 실제 비밀번호 조회 (내부용) + */ + static async getDecryptedPassword(id: number): Promise { + try { + const connection = await prisma.external_db_connections.findUnique({ + where: { id }, + select: { password: true }, + }); + + if (!connection) { + return null; + } + + return PasswordEncryption.decrypt(connection.password); + } catch (error) { + console.error("비밀번호 복호화 실패:", error); + return null; + } + } +} diff --git a/backend-node/src/types/externalDbTypes.ts b/backend-node/src/types/externalDbTypes.ts new file mode 100644 index 00000000..3053cfbd --- /dev/null +++ b/backend-node/src/types/externalDbTypes.ts @@ -0,0 +1,109 @@ +// 외부 DB 연결 관련 타입 정의 +// 작성일: 2024-12-17 + +export interface ExternalDbConnection { + id?: number; + connection_name: string; + description?: string; + db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite"; + host: string; + port: number; + database_name: string; + username: string; + password: string; + connection_timeout?: number; + query_timeout?: number; + max_connections?: number; + ssl_enabled?: string; + ssl_cert_path?: string; + connection_options?: Record; + company_code: string; + is_active: string; + created_date?: Date; + created_by?: string; + updated_date?: Date; + updated_by?: string; +} + +export interface ExternalDbConnectionFilter { + db_type?: string; + is_active?: string; + company_code?: string; + search?: string; +} + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +// DB 타입 옵션 +export const DB_TYPE_OPTIONS = [ + { value: "mysql", label: "MySQL" }, + { value: "postgresql", label: "PostgreSQL" }, + { value: "oracle", label: "Oracle" }, + { value: "mssql", label: "SQL Server" }, + { value: "sqlite", label: "SQLite" }, +]; + +// DB 타입별 기본 설정 +export const DB_TYPE_DEFAULTS = { + mysql: { port: 3306, driver: "mysql2" }, + postgresql: { port: 5432, driver: "pg" }, + oracle: { port: 1521, driver: "oracledb" }, + mssql: { port: 1433, driver: "mssql" }, + sqlite: { port: 0, driver: "sqlite3" }, +}; + +// 활성 상태 옵션 +export const ACTIVE_STATUS_OPTIONS = [ + { value: "Y", label: "활성" }, + { value: "N", label: "비활성" }, + { value: "", label: "전체" }, +]; + +// 연결 옵션 스키마 (각 DB 타입별 추가 옵션) +export interface MySQLConnectionOptions { + charset?: string; + timezone?: string; + connectTimeout?: number; + acquireTimeout?: number; + multipleStatements?: boolean; +} + +export interface PostgreSQLConnectionOptions { + schema?: string; + ssl?: boolean | object; + application_name?: string; + statement_timeout?: number; +} + +export interface OracleConnectionOptions { + serviceName?: string; + sid?: string; + connectString?: string; + poolMin?: number; + poolMax?: number; +} + +export interface SQLServerConnectionOptions { + encrypt?: boolean; + trustServerCertificate?: boolean; + requestTimeout?: number; + connectionTimeout?: number; +} + +export interface SQLiteConnectionOptions { + mode?: string; + cache?: string; + foreign_keys?: boolean; +} + +export type SupportedConnectionOptions = + | MySQLConnectionOptions + | PostgreSQLConnectionOptions + | OracleConnectionOptions + | SQLServerConnectionOptions + | SQLiteConnectionOptions; diff --git a/backend-node/src/utils/passwordEncryption.ts b/backend-node/src/utils/passwordEncryption.ts new file mode 100644 index 00000000..61dd9717 --- /dev/null +++ b/backend-node/src/utils/passwordEncryption.ts @@ -0,0 +1,113 @@ +// 비밀번호 암호화/복호화 유틸리티 +// 작성일: 2024-12-17 + +import crypto from "crypto"; + +export class PasswordEncryption { + private static readonly ALGORITHM = "aes-256-cbc"; + private static readonly SECRET_KEY = + process.env.DB_PASSWORD_SECRET || + "default-fallback-key-change-in-production"; + private static readonly IV_LENGTH = 16; // AES-CBC의 경우 16바이트 + + /** + * 비밀번호를 암호화합니다. + * @param password 암호화할 평문 비밀번호 + * @returns 암호화된 비밀번호 (base64 인코딩) + */ + static encrypt(password: string): string { + try { + // 랜덤 IV 생성 + const iv = crypto.randomBytes(this.IV_LENGTH); + + // 암호화 키 생성 (SECRET_KEY를 해시하여 32바이트 키 생성) + const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32); + + // 암호화 객체 생성 + const cipher = crypto.createCipher("aes-256-cbc", key); + + // 암호화 실행 + let encrypted = cipher.update(password, "utf8", "hex"); + encrypted += cipher.final("hex"); + + // IV와 암호화된 데이터를 결합하여 반환 + return `${iv.toString("hex")}:${encrypted}`; + } catch (error) { + console.error("Password encryption failed:", error); + throw new Error("비밀번호 암호화에 실패했습니다."); + } + } + + /** + * 암호화된 비밀번호를 복호화합니다. + * @param encryptedPassword 암호화된 비밀번호 + * @returns 복호화된 평문 비밀번호 + */ + static decrypt(encryptedPassword: string): string { + try { + // IV와 암호화된 데이터 분리 + const parts = encryptedPassword.split(":"); + if (parts.length !== 2) { + throw new Error("잘못된 암호화된 비밀번호 형식입니다."); + } + + const iv = Buffer.from(parts[0], "hex"); + const encrypted = parts[1]; + + // 암호화 키 생성 (암호화 시와 동일) + const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32); + + // 복호화 객체 생성 + const decipher = crypto.createDecipher("aes-256-cbc", key); + + // 복호화 실행 + let decrypted = decipher.update(encrypted, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } catch (error) { + console.error("Password decryption failed:", error); + throw new Error("비밀번호 복호화에 실패했습니다."); + } + } + + /** + * 암호화 키가 설정되어 있는지 확인합니다. + * @returns 키 설정 여부 + */ + static isKeyConfigured(): boolean { + return ( + process.env.DB_PASSWORD_SECRET !== undefined && + process.env.DB_PASSWORD_SECRET !== "" + ); + } + + /** + * 암호화/복호화 기능을 테스트합니다. + * @returns 테스트 결과 + */ + static testEncryption(): { success: boolean; message: string } { + try { + const testPassword = "test123!@#"; + const encrypted = this.encrypt(testPassword); + const decrypted = this.decrypt(encrypted); + + if (testPassword === decrypted) { + return { + success: true, + message: "암호화/복호화 테스트가 성공했습니다.", + }; + } else { + return { + success: false, + message: "암호화/복호화 결과가 일치하지 않습니다.", + }; + } + } catch (error) { + return { + success: false, + message: `암호화/복호화 테스트 실패: ${error}`, + }; + } + } +} diff --git a/docs/external-connection-management-plan.md b/docs/external-connection-management-plan.md new file mode 100644 index 00000000..270b230c --- /dev/null +++ b/docs/external-connection-management-plan.md @@ -0,0 +1,411 @@ +# 외부 커넥션 관리 시스템 구현 계획서 + +## 📋 프로젝트 개요 + +### 목적 + +- 제어관리 시스템에서 외부 데이터베이스에 접근할 수 있도록 DB 접속 정보를 중앙 관리 +- 관리자가 외부 DB 연결 설정을 쉽게 등록, 수정, 삭제, 테스트할 수 있는 시스템 구축 + +### 주요 기능 + +- 외부 DB 접속 정보 CRUD 관리 +- 다양한 DB 타입 지원 (MySQL, PostgreSQL, Oracle, SQL Server, SQLite) +- 연결 테스트 기능 +- 비밀번호 암호화 저장 +- 회사별 접속 정보 관리 + +## 🗄️ 데이터베이스 설계 + +### 테이블: `external_db_connections` + +```sql +CREATE TABLE external_db_connections ( + id SERIAL PRIMARY KEY, + connection_name VARCHAR(100) NOT NULL, + description TEXT, + + -- DB 연결 정보 + db_type VARCHAR(20) NOT NULL, -- mysql, postgresql, oracle, mssql, sqlite + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL, + database_name VARCHAR(100) NOT NULL, + username VARCHAR(100) NOT NULL, + password TEXT NOT NULL, -- 암호화된 비밀번호 + + -- 고급 설정 + connection_timeout INTEGER DEFAULT 30, + query_timeout INTEGER DEFAULT 60, + max_connections INTEGER DEFAULT 10, + ssl_enabled CHAR(1) DEFAULT 'N', + ssl_cert_path VARCHAR(500), + connection_options JSONB, -- 추가 연결 옵션 + + -- 관리 정보 + company_code VARCHAR(20) DEFAULT '*', + is_active CHAR(1) DEFAULT 'Y', + created_date TIMESTAMP DEFAULT NOW(), + created_by VARCHAR(50), + updated_date TIMESTAMP DEFAULT NOW(), + updated_by VARCHAR(50) +); + +-- 인덱스 +CREATE INDEX idx_external_db_connections_company ON external_db_connections(company_code); +CREATE INDEX idx_external_db_connections_active ON external_db_connections(is_active); +CREATE INDEX idx_external_db_connections_type ON external_db_connections(db_type); +``` + +### 샘플 데이터 + +```sql +INSERT INTO external_db_connections ( + connection_name, description, db_type, host, port, + database_name, username, password, company_code +) VALUES +( + '영업팀 MySQL', + '영업팀에서 사용하는 고객 데이터베이스', + 'mysql', + 'sales-db.company.com', + 3306, + 'sales_db', + 'sales_user', + 'encrypted_password_here', + 'COMP001' +), +( + '재무팀 PostgreSQL', + '재무 데이터 및 회계 정보', + 'postgresql', + 'finance-db.company.com', + 5432, + 'finance_db', + 'finance_user', + 'encrypted_password_here', + 'COMP001' +); +``` + +## 🔧 백엔드 구현 + +### 1. Prisma 모델 정의 + +```typescript +// prisma/schema.prisma +model external_db_connections { + id Int @id @default(autoincrement()) + connection_name String @db.VarChar(100) + description String? @db.Text + db_type String @db.VarChar(20) + host String @db.VarChar(255) + port Int + database_name String @db.VarChar(100) + username String @db.VarChar(100) + password String @db.Text + connection_timeout Int? @default(30) + query_timeout Int? @default(60) + max_connections Int? @default(10) + ssl_enabled String @default("N") @db.Char(1) + ssl_cert_path String? @db.VarChar(500) + connection_options Json? + company_code String @default("*") @db.VarChar(20) + is_active String @default("Y") @db.Char(1) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + @@index([company_code]) + @@index([is_active]) + @@index([db_type]) +} +``` + +### 2. 타입 정의 + +```typescript +// backend-node/src/types/externalDbTypes.ts +export interface ExternalDbConnection { + id?: number; + connection_name: string; + description?: string; + db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite"; + host: string; + port: number; + database_name: string; + username: string; + password: string; + connection_timeout?: number; + query_timeout?: number; + max_connections?: number; + ssl_enabled?: string; + ssl_cert_path?: string; + connection_options?: Record; + company_code: string; + is_active: string; + created_date?: Date; + created_by?: string; + updated_date?: Date; + updated_by?: string; +} + +export interface ExternalDbConnectionFilter { + db_type?: string; + is_active?: string; + company_code?: string; + search?: string; +} + +export interface ConnectionTestRequest { + id?: number; + connection_name?: string; + db_type: string; + host: string; + port: number; + database_name: string; + username: string; + password: string; + connection_timeout?: number; + ssl_enabled?: string; + ssl_cert_path?: string; +} + +export interface ConnectionTestResult { + success: boolean; + message: string; + connection_time?: number; + server_version?: string; + error_details?: string; +} + +export const DB_TYPE_OPTIONS = [ + { value: "mysql", label: "MySQL" }, + { value: "postgresql", label: "PostgreSQL" }, + { value: "oracle", label: "Oracle" }, + { value: "mssql", label: "SQL Server" }, + { value: "sqlite", label: "SQLite" }, +]; + +export const DB_TYPE_DEFAULTS = { + mysql: { port: 3306, driver: "mysql2" }, + postgresql: { port: 5432, driver: "pg" }, + oracle: { port: 1521, driver: "oracledb" }, + mssql: { port: 1433, driver: "mssql" }, + sqlite: { port: 0, driver: "sqlite3" }, +}; +``` + +### 3. 서비스 계층 + +```typescript +// backend-node/src/services/externalDbConnectionService.ts +export class ExternalDbConnectionService { + // CRUD 메서드들 + static async getConnections(filter: ExternalDbConnectionFilter); + static async getConnectionById(id: number); + static async createConnection(data: ExternalDbConnection); + static async updateConnection( + id: number, + data: Partial + ); + static async deleteConnection(id: number); // 논리 삭제 + static async testConnection(connectionData: ConnectionTestRequest); + + // 유틸리티 메서드들 + private static encryptPassword(password: string): string; + private static decryptPassword(encryptedPassword: string): string; + private static validateConnectionData(data: ExternalDbConnection): void; +} +``` + +### 4. API 라우트 + +```typescript +// backend-node/src/routes/externalDbConnectionRoutes.ts +// GET /api/external-db-connections - 목록 조회 +// GET /api/external-db-connections/:id - 상세 조회 +// POST /api/external-db-connections - 새 연결 생성 +// PUT /api/external-db-connections/:id - 연결 수정 +// DELETE /api/external-db-connections/:id - 연결 삭제 (논리삭제) +// POST /api/external-db-connections/test - 연결 테스트 +``` + +## 🎨 프론트엔드 구현 + +### 1. API 클라이언트 + +```typescript +// frontend/lib/api/externalDbConnection.ts +export class ExternalDbConnectionAPI { + static async getConnections(filter?: ExternalDbConnectionFilter); + static async getConnectionById(id: number); + static async createConnection(data: ExternalDbConnection); + static async updateConnection( + id: number, + data: Partial + ); + static async deleteConnection(id: number); + static async testConnection(connectionData: ConnectionTestRequest); +} +``` + +### 2. 메인 페이지 + +```typescript +// frontend/app/(main)/admin/external-connections/page.tsx +- 연결 목록 테이블 (리스트형) +- 검색 및 필터링 (DB 타입, 상태, 회사) +- 새 연결 추가 버튼 +- 각 행별 편집/삭제/테스트 버튼 +``` + +### 3. 연결 설정 모달 + +```typescript +// frontend/components/admin/ExternalDbConnectionModal.tsx +- 기본 정보 입력 (연결명, 설명) +- DB 연결 정보 (타입, 호스트, 포트, DB명, 계정) +- 고급 설정 (타임아웃, SSL 등) - 접기/펼치기 +- 연결 테스트 버튼 +- 저장/취소 버튼 +``` + +### 4. 연결 테스트 다이얼로그 + +```typescript +// frontend/components/admin/ConnectionTestDialog.tsx +- 테스트 진행 상태 표시 +- 연결 결과 (성공/실패, 응답시간, 서버 버전) +- 오류 상세 정보 표시 +``` + +## 🔒 보안 구현 + +### 1. 비밀번호 암호화 + +```typescript +// backend-node/src/utils/passwordEncryption.ts +export class PasswordEncryption { + private static readonly ALGORITHM = "aes-256-gcm"; + private static readonly SECRET_KEY = process.env.DB_PASSWORD_SECRET; + + static encrypt(password: string): string; + static decrypt(encryptedPassword: string): string; +} +``` + +### 2. 환경 변수 설정 + +```env +# .env +DB_PASSWORD_SECRET=your-super-secret-encryption-key-here +``` + +### 3. 접근 권한 제어 + +- 관리자 권한만 접근 가능 +- 회사별 데이터 분리 +- API 호출 시 인증 토큰 검증 + +## 📅 구현 일정 + +### Phase 1: 데이터베이스 및 백엔드 (2-3일) + +- [ ] 데이터베이스 테이블 생성 +- [ ] Prisma 모델 정의 +- [ ] 타입 정의 작성 +- [ ] 서비스 계층 구현 +- [ ] API 라우트 구현 +- [ ] 비밀번호 암호화 구현 + +### Phase 2: 프론트엔드 기본 구현 (2-3일) + +- [ ] API 클라이언트 작성 +- [ ] 메인 페이지 구현 (리스트) +- [ ] 연결 설정 모달 구현 +- [ ] 기본 CRUD 기능 구현 + +### Phase 3: 고급 기능 및 테스트 (1-2일) + +- [ ] 연결 테스트 기능 구현 +- [ ] 고급 설정 옵션 구현 +- [ ] 에러 처리 및 검증 강화 +- [ ] UI/UX 개선 + +### Phase 4: 통합 및 배포 (1일) + +- [ ] 메뉴 등록 +- [ ] 권한 설정 +- [ ] 전체 테스트 +- [ ] 문서화 완료 + +## 🧪 테스트 계획 + +### 1. 단위 테스트 + +- 비밀번호 암호화/복호화 +- 연결 데이터 검증 +- API 엔드포인트 테스트 + +### 2. 통합 테스트 + +- 실제 DB 연결 테스트 (다양한 DB 타입) +- 프론트엔드-백엔드 연동 테스트 +- 권한 및 보안 테스트 + +### 3. 사용자 테스트 + +- 관리자 시나리오 테스트 +- UI/UX 사용성 테스트 +- 오류 상황 처리 테스트 + +## 🚀 배포 및 운영 + +### 1. 환경 설정 + +- 프로덕션 환경 암호화 키 설정 +- DB 접속 권한 최소화 +- 로그 모니터링 설정 + +### 2. 모니터링 + +- 외부 DB 연결 상태 모니터링 +- 연결 풀 사용률 모니터링 +- 쿼리 성능 모니터링 + +### 3. 백업 및 복구 + +- 연결 설정 정보 백업 +- 암호화 키 관리 +- 장애 복구 절차 + +## 📚 참고사항 + +### 1. 지원 DB 드라이버 + +- MySQL: `mysql2` +- PostgreSQL: `pg` +- Oracle: `oracledb` +- SQL Server: `mssql` +- SQLite: `sqlite3` + +### 2. 연결 풀 관리 + +- 각 DB별 연결 풀 생성 +- 최대 연결 수 제한 +- 유휴 연결 정리 + +### 3. 확장 가능성 + +- NoSQL DB 지원 (MongoDB, Redis) +- API 연결 지원 +- 파일 시스템 연결 지원 + +--- + +**작성일**: 2024년 12월 17일 +**작성자**: AI Assistant +**버전**: 1.0 + + diff --git a/frontend/components/admin/ExternalDbConnectionModal.tsx b/frontend/components/admin/ExternalDbConnectionModal.tsx new file mode 100644 index 00000000..db6c582a --- /dev/null +++ b/frontend/components/admin/ExternalDbConnectionModal.tsx @@ -0,0 +1,432 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Database, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react"; +import { toast } from "sonner"; +import { + ExternalDbConnectionAPI, + ExternalDbConnection, + DB_TYPE_OPTIONS, + DB_TYPE_DEFAULTS, +} from "@/lib/api/externalDbConnection"; + +interface ExternalDbConnectionModalProps { + isOpen: boolean; + onClose: () => void; + onSave: () => void; + editingConnection?: ExternalDbConnection | null; +} + +export function ExternalDbConnectionModal({ + isOpen, + onClose, + onSave, + editingConnection, +}: ExternalDbConnectionModalProps) { + const [formData, setFormData] = useState>({ + connection_name: "", + description: "", + db_type: "mysql", + host: "", + port: 3306, + database_name: "", + username: "", + password: "", + connection_timeout: 30, + query_timeout: 60, + max_connections: 10, + ssl_enabled: "N", + ssl_cert_path: "", + company_code: "*", + is_active: "Y", + }); + + const [loading, setLoading] = useState(false); + const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + // 편집 모드일 때 기존 데이터 로드 + useEffect(() => { + if (isOpen) { + if (editingConnection) { + setFormData({ + ...editingConnection, + password: "", // 보안상 비밀번호는 빈 값으로 시작 + }); + setShowAdvancedSettings(true); // 편집 시 고급 설정 펼치기 + } else { + // 새 연결 생성 시 기본값 설정 + setFormData({ + connection_name: "", + description: "", + db_type: "mysql", + host: "", + port: 3306, + database_name: "", + username: "", + password: "", + connection_timeout: 30, + query_timeout: 60, + max_connections: 10, + ssl_enabled: "N", + ssl_cert_path: "", + company_code: "*", + is_active: "Y", + }); + setShowAdvancedSettings(false); + } + setShowPassword(false); + } + }, [isOpen, editingConnection]); + + // DB 타입 변경 시 기본 포트 설정 + const handleDbTypeChange = (dbType: string) => { + const defaultPort = DB_TYPE_DEFAULTS[dbType as keyof typeof DB_TYPE_DEFAULTS]?.port || 3306; + setFormData((prev) => ({ + ...prev, + db_type: dbType as ExternalDbConnection["db_type"], + port: defaultPort, + })); + }; + + // 폼 데이터 변경 핸들러 + const handleInputChange = (field: keyof ExternalDbConnection, value: string | number) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + }; + + // 저장 + const handleSave = async () => { + // 필수 필드 검증 + if ( + !formData.connection_name || + !formData.db_type || + !formData.host || + !formData.port || + !formData.database_name || + !formData.username + ) { + toast.error("필수 필드를 모두 입력해주세요."); + return; + } + + // 편집 모드에서 비밀번호가 비어있으면 기존 비밀번호 유지 + if (editingConnection && !formData.password) { + formData.password = "***ENCRYPTED***"; // 서버에서 기존 비밀번호 유지하도록 표시 + } else if (!editingConnection && !formData.password) { + toast.error("새 연결 생성 시 비밀번호는 필수입니다."); + return; + } + + try { + setLoading(true); + + const connectionData = { + ...formData, + port: Number(formData.port), + connection_timeout: Number(formData.connection_timeout), + query_timeout: Number(formData.query_timeout), + max_connections: Number(formData.max_connections), + } as ExternalDbConnection; + + let response; + if (editingConnection?.id) { + response = await ExternalDbConnectionAPI.updateConnection(editingConnection.id, connectionData); + } else { + response = await ExternalDbConnectionAPI.createConnection(connectionData); + } + + if (response.success) { + toast.success(editingConnection ? "연결이 수정되었습니다." : "연결이 생성되었습니다."); + onSave(); + } else { + toast.error(response.message || "저장 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("저장 오류:", error); + toast.error("저장 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + // 취소 + const handleCancel = () => { + onClose(); + }; + + // 저장 버튼 비활성화 조건 + const isSaveDisabled = () => { + return ( + loading || + !formData.connection_name || + !formData.host || + !formData.port || + !formData.database_name || + !formData.username || + (!editingConnection && !formData.password) + ); + }; + + return ( + + + + + + {editingConnection ? "외부 DB 연결 수정" : "새 외부 DB 연결 추가"} + + + +
+ {/* 기본 정보 */} +
+

기본 정보

+ +
+
+ + handleInputChange("connection_name", e.target.value)} + placeholder="예: 영업팀 MySQL" + /> +
+
+ + +
+
+ +
+ +