feat: 배치 관리 시스템 구현

 주요 기능:
- 배치 설정 관리 (생성/수정/삭제/실행)
- 배치 실행 로그 관리 및 모니터링
- 배치 스케줄러 자동 실행 (cron 기반)
- 외부 DB 연결을 통한 데이터 동기화
- Oracle, MSSQL, MariaDB 커넥터 지원

🔧 백엔드 구현:
- BatchManagementController: 배치 설정 CRUD
- BatchExecutionLogController: 실행 로그 관리
- BatchSchedulerService: 자동 스케줄링
- BatchExternalDbService: 외부 DB 연동
- 배치 관련 테이블 스키마 추가

🎨 프론트엔드 구현:
- 배치 관리 대시보드 UI
- 배치 생성/수정 폼
- 실행 로그 모니터링 화면
- 수동 실행 및 상태 관리

🛡️ 안전성:
- 기존 시스템과 독립적 구현
- 트랜잭션 기반 안전한 데이터 처리
- 에러 핸들링 및 로깅 강화
This commit is contained in:
2025-09-25 11:04:16 +09:00
parent 4abf5b31c0
commit 949aab0b73
33 changed files with 8549 additions and 961 deletions

View File

@@ -0,0 +1,183 @@
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
// @ts-ignore
import * as mssql from 'mssql';
export class MSSQLConnector implements DatabaseConnector {
private pool: mssql.ConnectionPool | null = null;
private config: ConnectionConfig;
constructor(config: ConnectionConfig) {
this.config = config;
}
async connect(): Promise<void> {
if (!this.pool) {
const config: mssql.config = {
server: this.config.host,
port: this.config.port,
user: this.config.user,
password: this.config.password,
database: this.config.database,
options: {
encrypt: this.config.ssl === true,
trustServerCertificate: true
},
connectionTimeout: this.config.connectionTimeoutMillis || 15000,
requestTimeout: this.config.queryTimeoutMillis || 15000
};
this.pool = await new mssql.ConnectionPool(config).connect();
}
}
async disconnect(): Promise<void> {
if (this.pool) {
await this.pool.close();
this.pool = null;
}
}
async testConnection(): Promise<ConnectionTestResult> {
const startTime = Date.now();
try {
await this.connect();
// 버전 정보 조회
const versionResult = await this.pool!.request().query('SELECT @@VERSION as version');
// 데이터베이스 크기 조회
const sizeResult = await this.pool!.request()
.input('dbName', mssql.VarChar, this.config.database)
.query(`
SELECT SUM(size * 8 * 1024) as size
FROM sys.master_files
WHERE database_id = DB_ID(@dbName)
`);
const responseTime = Date.now() - startTime;
return {
success: true,
message: "MSSQL 연결이 성공했습니다.",
details: {
response_time: responseTime,
server_version: versionResult.recordset[0]?.version || "알 수 없음",
database_size: this.formatBytes(parseInt(sizeResult.recordset[0]?.size || "0")),
},
};
} catch (error: any) {
return {
success: false,
message: "MSSQL 연결에 실패했습니다.",
error: {
code: "CONNECTION_FAILED",
details: error.message || "알 수 없는 오류",
},
};
}
}
async executeQuery(query: string): Promise<QueryResult> {
try {
await this.connect();
const result = await this.pool!.request().query(query);
return {
rows: result.recordset,
rowCount: result.rowsAffected[0],
};
} catch (error: any) {
throw new Error(`쿼리 실행 오류: ${error.message}`);
}
}
async getTables(): Promise<TableInfo[]> {
try {
await this.connect();
const result = await this.pool!.request().query(`
SELECT
t.TABLE_NAME as table_name,
c.COLUMN_NAME as column_name,
c.DATA_TYPE as data_type,
c.IS_NULLABLE as is_nullable,
c.COLUMN_DEFAULT as column_default,
CAST(p.value AS NVARCHAR(MAX)) as description
FROM INFORMATION_SCHEMA.TABLES t
LEFT JOIN INFORMATION_SCHEMA.COLUMNS c
ON c.TABLE_NAME = t.TABLE_NAME
LEFT JOIN sys.extended_properties p
ON p.major_id = OBJECT_ID(t.TABLE_NAME)
AND p.minor_id = 0
AND p.name = 'MS_Description'
WHERE t.TABLE_TYPE = 'BASE TABLE'
ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION
`);
// 결과를 TableInfo[] 형식으로 변환
const tables = new Map<string, TableInfo>();
result.recordset.forEach((row: any) => {
if (!tables.has(row.table_name)) {
tables.set(row.table_name, {
table_name: row.table_name,
columns: [],
description: row.description || null
});
}
if (row.column_name) {
tables.get(row.table_name)!.columns.push({
column_name: row.column_name,
data_type: row.data_type,
is_nullable: row.is_nullable === 'YES' ? 'Y' : 'N',
column_default: row.column_default
});
}
});
return Array.from(tables.values());
} catch (error: any) {
throw new Error(`테이블 목록 조회 오류: ${error.message}`);
}
}
async getColumns(tableName: string): Promise<any[]> {
try {
await this.connect();
const result = await this.pool!.request()
.input('tableName', mssql.VarChar, tableName)
.query(`
SELECT
c.COLUMN_NAME as name,
c.DATA_TYPE as type,
c.IS_NULLABLE as nullable,
c.COLUMN_DEFAULT as default_value,
c.CHARACTER_MAXIMUM_LENGTH as max_length,
c.NUMERIC_PRECISION as precision,
c.NUMERIC_SCALE as scale,
CAST(p.value AS NVARCHAR(MAX)) as description
FROM INFORMATION_SCHEMA.COLUMNS c
LEFT JOIN sys.columns sc
ON sc.object_id = OBJECT_ID(@tableName)
AND sc.name = c.COLUMN_NAME
LEFT JOIN sys.extended_properties p
ON p.major_id = sc.object_id
AND p.minor_id = sc.column_id
AND p.name = 'MS_Description'
WHERE c.TABLE_NAME = @tableName
ORDER BY c.ORDINAL_POSITION
`);
return result.recordset;
} catch (error: any) {
throw new Error(`컬럼 정보 조회 오류: ${error.message}`);
}
}
private formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
}

View File

@@ -0,0 +1,136 @@
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
// @ts-ignore
import * as mysql from 'mysql2/promise';
export class MariaDBConnector implements DatabaseConnector {
private connection: mysql.Connection | null = null;
private config: ConnectionConfig;
constructor(config: ConnectionConfig) {
this.config = config;
}
async connect(): Promise<void> {
if (!this.connection) {
this.connection = await mysql.createConnection({
host: this.config.host,
port: this.config.port,
user: this.config.user,
password: this.config.password,
database: this.config.database,
connectTimeout: this.config.connectionTimeoutMillis,
ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl,
});
}
}
async disconnect(): Promise<void> {
if (this.connection) {
await this.connection.end();
this.connection = null;
}
}
async testConnection(): Promise<ConnectionTestResult> {
const startTime = Date.now();
try {
await this.connect();
const [rows] = await this.connection!.query("SELECT VERSION() as version");
const version = (rows as any[])[0]?.version || "Unknown";
const responseTime = Date.now() - startTime;
await this.disconnect();
return {
success: true,
message: "MariaDB/MySQL 연결이 성공했습니다.",
details: {
response_time: responseTime,
server_version: version,
},
};
} catch (error: any) {
await this.disconnect();
return {
success: false,
message: "MariaDB/MySQL 연결에 실패했습니다.",
error: {
code: "CONNECTION_FAILED",
details: error.message || "알 수 없는 오류",
},
};
}
}
async executeQuery(query: string): Promise<QueryResult> {
try {
await this.connect();
const [rows, fields] = await this.connection!.query(query);
await this.disconnect();
return {
rows: rows as any[],
fields: fields as any[],
};
} catch (error: any) {
await this.disconnect();
throw new Error(`쿼리 실행 실패: ${error.message}`);
}
}
async getTables(): Promise<TableInfo[]> {
try {
await this.connect();
const [rows] = await this.connection!.query(`
SELECT
TABLE_NAME as table_name,
TABLE_COMMENT as description
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
ORDER BY TABLE_NAME;
`);
const tables: TableInfo[] = [];
for (const row of rows as any[]) {
const columns = await this.getColumns(row.table_name);
tables.push({
table_name: row.table_name,
description: row.description || null,
columns: columns,
});
}
await this.disconnect();
return tables;
} catch (error: any) {
await this.disconnect();
throw new Error(`테이블 목록 조회 실패: ${error.message}`);
}
}
async getColumns(tableName: string): Promise<any[]> {
try {
console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`);
await this.connect();
console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`);
const [rows] = await this.connection!.query(`
SELECT
COLUMN_NAME as column_name,
DATA_TYPE as data_type,
IS_NULLABLE as is_nullable,
COLUMN_DEFAULT as column_default
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION;
`, [tableName]);
console.log(`[MariaDBConnector] 쿼리 결과:`, rows);
console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array');
await this.disconnect();
return rows as any[];
} catch (error: any) {
console.error(`[MariaDBConnector] getColumns 오류:`, error);
await this.disconnect();
throw new Error(`컬럼 정보 조회 실패: ${error.message}`);
}
}
}

View File

@@ -0,0 +1,235 @@
// @ts-ignore
import * as oracledb from 'oracledb';
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
export class OracleConnector implements DatabaseConnector {
private connection: oracledb.Connection | null = null;
private config: ConnectionConfig;
constructor(config: ConnectionConfig) {
this.config = config;
// Oracle XE 21c 특화 설정
// oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT;
// oracledb.autoCommit = true;
}
async connect(): Promise<void> {
try {
// Oracle XE 21c 연결 문자열 구성
const connectionString = this.buildConnectionString();
const connectionConfig: any = {
user: this.config.user,
password: this.config.password,
connectString: connectionString
};
this.connection = await oracledb.getConnection(connectionConfig);
console.log('Oracle XE 21c 연결 성공');
} catch (error: any) {
console.error('Oracle XE 21c 연결 실패:', error);
throw new Error(`Oracle 연결 실패: ${error.message}`);
}
}
private buildConnectionString(): string {
const { host, port, database } = this.config;
// Oracle XE 21c는 기본적으로 XE 서비스명을 사용
// 다양한 연결 문자열 형식 지원
if (database.includes('/') || database.includes(':')) {
// 이미 완전한 연결 문자열인 경우
return database;
}
// Oracle XE 21c 표준 형식
return `${host}:${port}/${database}`;
}
async disconnect(): Promise<void> {
if (this.connection) {
try {
await this.connection.close();
this.connection = null;
console.log('Oracle 연결 해제됨');
} catch (error: any) {
console.error('Oracle 연결 해제 실패:', error);
}
}
}
async testConnection(): Promise<ConnectionTestResult> {
try {
if (!this.connection) {
await this.connect();
}
// Oracle XE 21c 버전 확인 쿼리
const result = await this.connection!.execute(
'SELECT BANNER FROM V$VERSION WHERE BANNER LIKE \'Oracle%\''
);
console.log('Oracle 버전:', result.rows);
return {
success: true,
message: '연결 성공',
details: {
server_version: (result.rows as any)?.[0]?.BANNER || 'Unknown'
}
};
} catch (error: any) {
console.error('Oracle 연결 테스트 실패:', error);
return {
success: false,
message: '연결 실패',
details: {
server_version: error.message
}
};
}
}
async executeQuery(query: string, params: any[] = []): Promise<QueryResult> {
if (!this.connection) {
await this.connect();
}
try {
const startTime = Date.now();
// Oracle XE 21c 쿼리 실행 옵션
const options: any = {
outFormat: oracledb.OUT_FORMAT_OBJECT, // OBJECT format
maxRows: 10000, // XE 제한 고려
fetchArraySize: 100
};
const result = await this.connection!.execute(query, params, options);
const executionTime = Date.now() - startTime;
console.log('Oracle 쿼리 실행 결과:', {
query,
rowCount: result.rows?.length || 0,
metaData: result.metaData?.length || 0,
executionTime: `${executionTime}ms`,
actualRows: result.rows,
metaDataInfo: result.metaData
});
return {
rows: result.rows || [],
rowCount: result.rowsAffected || (result.rows?.length || 0),
fields: this.extractFieldInfo(result.metaData || [])
};
} catch (error: any) {
console.error('Oracle 쿼리 실행 실패:', error);
throw new Error(`쿼리 실행 실패: ${error.message}`);
}
}
private extractFieldInfo(metaData: any[]): any[] {
return metaData.map(field => ({
name: field.name,
type: this.mapOracleType(field.dbType),
length: field.precision || field.byteSize,
nullable: field.nullable
}));
}
private mapOracleType(oracleType: any): string {
// Oracle XE 21c 타입 매핑 (간단한 방식)
if (typeof oracleType === 'string') {
return oracleType;
}
return 'UNKNOWN';
}
async getTables(): Promise<TableInfo[]> {
try {
// 현재 사용자 스키마의 테이블들만 조회
const query = `
SELECT table_name, USER as owner
FROM user_tables
ORDER BY table_name
`;
console.log('Oracle 테이블 조회 시작 - 사용자:', this.config.user);
const result = await this.executeQuery(query);
console.log('사용자 스키마 테이블 조회 결과:', result.rows);
const tables = result.rows.map((row: any) => ({
table_name: row.TABLE_NAME,
columns: [],
description: null
}));
console.log(`${tables.length}개의 사용자 테이블을 찾았습니다.`);
return tables;
} catch (error: any) {
console.error('Oracle 테이블 목록 조회 실패:', error);
throw new Error(`테이블 목록 조회 실패: ${error.message}`);
}
}
async getColumns(tableName: string): Promise<any[]> {
try {
console.log(`[OracleConnector] getColumns 호출: tableName=${tableName}`);
const query = `
SELECT
column_name,
data_type,
data_length,
data_precision,
data_scale,
nullable,
data_default
FROM user_tab_columns
WHERE table_name = UPPER(:tableName)
ORDER BY column_id
`;
console.log(`[OracleConnector] 쿼리 실행 시작: ${query}`);
const result = await this.executeQuery(query, [tableName]);
console.log(`[OracleConnector] 쿼리 결과:`, result.rows);
console.log(`[OracleConnector] 결과 개수:`, result.rows ? result.rows.length : 'null/undefined');
const mappedResult = result.rows.map((row: any) => ({
column_name: row.COLUMN_NAME,
data_type: this.formatOracleDataType(row),
is_nullable: row.NULLABLE === 'Y' ? 'YES' : 'NO',
column_default: row.DATA_DEFAULT
}));
console.log(`[OracleConnector] 매핑된 결과:`, mappedResult);
return mappedResult;
} catch (error: any) {
console.error('[OracleConnector] getColumns 오류:', error);
throw new Error(`테이블 컬럼 조회 실패: ${error.message}`);
}
}
private formatOracleDataType(row: any): string {
const { DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE } = row;
switch (DATA_TYPE) {
case 'NUMBER':
if (DATA_PRECISION && DATA_SCALE !== null) {
return `NUMBER(${DATA_PRECISION},${DATA_SCALE})`;
} else if (DATA_PRECISION) {
return `NUMBER(${DATA_PRECISION})`;
}
return 'NUMBER';
case 'VARCHAR2':
case 'CHAR':
return `${DATA_TYPE}(${DATA_LENGTH})`;
default:
return DATA_TYPE;
}
}
}