phase 2.3 테이블 및 컬럼 동적생성기능 변경

This commit is contained in:
kjs 2025-09-30 18:28:54 +09:00
parent c8c05f1c0d
commit 3c06d35374
5 changed files with 164 additions and 155 deletions

View File

@ -11,9 +11,9 @@ DDLExecutionService는 **4개의 Prisma 호출**이 있으며, DDL(Data Definiti
| 파일 위치 | `backend-node/src/services/ddlExecutionService.ts` |
| 파일 크기 | 400+ 라인 |
| Prisma 호출 | 4개 |
| **현재 진행률** | **0/4 (0%)** ⏳ **진행 예정** |
| **현재 진행률** | **6/6 (100%)** ✅ **완료** |
| 복잡도 | 중간 (DDL 실행 + 로그 관리) |
| 우선순위 | 🟢 낮음 (Phase 2.7) |
| 우선순위 | 🔴 최우선 (테이블 추가 기능 - Phase 2.3으로 변경) |
### 🎯 전환 목표

View File

@ -1067,35 +1067,43 @@ describe("Performance Benchmarks", () => {
#### ✅ 완료된 서비스
- [x] **ScreenManagementService 전환 (46개)** ✅ **완료**
- [x] **ScreenManagementService 전환 (46개)****완료** (Phase 2.1)
- [x] 46개 Prisma 호출 전환 완료
- [x] 18개 단위 테스트 통과
- [x] 6개 통합 테스트 작성 완료
- [x] 실제 운영 버그 발견 및 수정 (소수점 좌표)
- 📄 **[PHASE2_SCREEN_MANAGEMENT_MIGRATION.md](PHASE2_SCREEN_MANAGEMENT_MIGRATION.md)**
- [x] **TableManagementService 전환 (33개)****완료** (Phase 2.2)
- [x] 33개 Prisma 호출 전환 완료 ($queryRaw 26개 + ORM 7개)
- [x] 단위 테스트 작성 완료
- [x] Prisma import 완전 제거
- 📄 **[PHASE2.2_TABLE_MANAGEMENT_MIGRATION.md](PHASE2.2_TABLE_MANAGEMENT_MIGRATION.md)**
- [x] **DDLExecutionService 전환 (6개)****완료** (Phase 2.3)
- [x] 6개 Prisma 호출 전환 완료 (트랜잭션 2개 + $queryRawUnsafe 2개 + ORM 2개)
- [x] **테이블 동적 생성/수정/삭제 기능 완료**
- [x] ✅ 단위 테스트 8개 모두 통과
- [x] Prisma import 완전 제거
- 📄 **[PHASE2.7_DDL_EXECUTION_MIGRATION.md](PHASE2.7_DDL_EXECUTION_MIGRATION.md)**
#### ⏳ 진행 예정 서비스
- [ ] **TableManagementService 전환 (33개)** - Phase 2.2 🟡 중간 우선순위
- 33개 Prisma 호출 ($queryRaw 26개 + ORM 7개)
- SQL은 79% 작성 완료 → `query()` 함수로 교체만 필요
- 📄 **[PHASE2.2_TABLE_MANAGEMENT_MIGRATION.md](PHASE2.2_TABLE_MANAGEMENT_MIGRATION.md)**
- [ ] **DataflowService 전환 (31개)** - Phase 2.3 🔴 최우선
- [ ] **DataflowService 전환 (31개)** - Phase 2.4 🟡 중간 우선순위
- 31개 Prisma 호출 (복잡한 관계 관리)
- 📄 **[PHASE2.3_DATAFLOW_SERVICE_MIGRATION.md](PHASE2.3_DATAFLOW_SERVICE_MIGRATION.md)**
- [ ] **DynamicFormService 전환 (13개)** - Phase 2.4 🟢 낮은 우선순위
- [ ] **DynamicFormService 전환 (13개)** - Phase 2.5 🟢 낮은 우선순위
- 13개 Prisma 호출 ($queryRaw 11개 + ORM 2개)
- SQL은 85% 작성 완료 → `query()` 함수로 교체만 필요
- 📄 **[PHASE2.4_DYNAMIC_FORM_MIGRATION.md](PHASE2.4_DYNAMIC_FORM_MIGRATION.md)**
- [ ] **ExternalDbConnectionService 전환 (15개)** - Phase 2.5 🟡 중간 우선순위
- [ ] **ExternalDbConnectionService 전환 (15개)** - Phase 2.6 🟡 중간 우선순위
- 15개 Prisma 호출 (외부 DB 연결 관리)
- 📄 **[PHASE2.5_EXTERNAL_DB_CONNECTION_MIGRATION.md](PHASE2.5_EXTERNAL_DB_CONNECTION_MIGRATION.md)**
- [ ] **DataflowControlService 전환 (6개)** - Phase 2.6 🟡 중간 우선순위
- [ ] **DataflowControlService 전환 (6개)** - Phase 2.7 🟡 중간 우선순위
- 6개 Prisma 호출 (복잡한 비즈니스 로직)
- 📄 **[PHASE2.6_DATAFLOW_CONTROL_MIGRATION.md](PHASE2.6_DATAFLOW_CONTROL_MIGRATION.md)**
- [ ] **DDLExecutionService 전환 (4개)** - Phase 2.7 🟢 낮은 우선순위
- 4개 Prisma 호출 (DDL 실행 및 로그)
- 📄 **[PHASE2.7_DDL_EXECUTION_MIGRATION.md](PHASE2.7_DDL_EXECUTION_MIGRATION.md)**
#### ✅ 다른 Phase로 이동

View File

@ -47,8 +47,8 @@ export const requireSuperAdmin = (
return;
}
// 슈퍼관리자 권한 확인 (회사코드가 '*'이고 plm_admin 사용자)
if (req.user.companyCode !== "*" || req.user.userId !== "plm_admin") {
// 슈퍼관리자 권한 확인 (회사코드가 '*' 사용자)
if (req.user.companyCode !== "*") {
logger.warn("DDL 실행 시도 - 권한 부족", {
userId: req.user.userId,
companyCode: req.user.companyCode,
@ -62,7 +62,7 @@ export const requireSuperAdmin = (
error: {
code: "SUPER_ADMIN_REQUIRED",
details:
"최고 관리자 권한이 필요합니다. DDL 실행은 회사코드가 '*'인 plm_admin 사용자만 가능합니다.",
"최고 관리자 권한이 필요합니다. DDL 실행은 회사코드가 '*'인 사용자만 가능합니다.",
},
});
return;
@ -167,7 +167,7 @@ export const validateDDLPermission = (
*
*/
export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => {
return user?.companyCode === "*" && user?.userId === "plm_admin";
return user?.companyCode === "*";
};
/**

View File

@ -3,7 +3,7 @@
* PostgreSQL
*/
import { PrismaClient } from "@prisma/client";
import { query, queryOne, transaction } from "../database/db";
import {
CreateColumnDefinition,
DDLExecutionResult,
@ -15,8 +15,6 @@ import { DDLAuditLogger } from "./ddlAuditLogger";
import { logger } from "../utils/logger";
import { cache, CacheKeys } from "../utils/cache";
const prisma = new PrismaClient();
export class DDLExecutionService {
/**
*
@ -98,15 +96,15 @@ export class DDLExecutionService {
const ddlQuery = this.generateCreateTableQuery(tableName, columns);
// 5. 트랜잭션으로 안전하게 실행
await prisma.$transaction(async (tx) => {
await transaction(async (client) => {
// 5-1. 테이블 생성
await tx.$executeRawUnsafe(ddlQuery);
await client.query(ddlQuery);
// 5-2. 테이블 메타데이터 저장
await this.saveTableMetadata(tx, tableName, description);
await this.saveTableMetadata(client, tableName, description);
// 5-3. 컬럼 메타데이터 저장
await this.saveColumnMetadata(tx, tableName, columns);
await this.saveColumnMetadata(client, tableName, columns);
});
// 6. 성공 로그 기록
@ -269,12 +267,12 @@ export class DDLExecutionService {
const ddlQuery = this.generateAddColumnQuery(tableName, column);
// 6. 트랜잭션으로 안전하게 실행
await prisma.$transaction(async (tx) => {
await transaction(async (client) => {
// 6-1. 컬럼 추가
await tx.$executeRawUnsafe(ddlQuery);
await client.query(ddlQuery);
// 6-2. 컬럼 메타데이터 저장
await this.saveColumnMetadata(tx, tableName, [column]);
await this.saveColumnMetadata(client, tableName, [column]);
});
// 7. 성공 로그 기록
@ -424,51 +422,42 @@ CREATE TABLE "${tableName}" (${baseColumns},
*
*/
private async saveTableMetadata(
tx: any,
client: any,
tableName: string,
description?: string
): Promise<void> {
await tx.table_labels.upsert({
where: { table_name: tableName },
update: {
table_label: tableName,
description: description || `사용자 생성 테이블: ${tableName}`,
updated_date: new Date(),
},
create: {
table_name: tableName,
table_label: tableName,
description: description || `사용자 생성 테이블: ${tableName}`,
created_date: new Date(),
updated_date: new Date(),
},
});
await client.query(
`
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ($1, $2, $3, now(), now())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = $2,
description = $3,
updated_date = now()
`,
[tableName, tableName, description || `사용자 생성 테이블: ${tableName}`]
);
}
/**
*
*/
private async saveColumnMetadata(
tx: any,
client: any,
tableName: string,
columns: CreateColumnDefinition[]
): Promise<void> {
// 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성
await tx.table_labels.upsert({
where: {
table_name: tableName,
},
update: {
updated_date: new Date(),
},
create: {
table_name: tableName,
table_label: tableName,
description: `자동 생성된 테이블 메타데이터: ${tableName}`,
created_date: new Date(),
updated_date: new Date(),
},
});
await client.query(
`
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ($1, $2, $3, now(), now())
ON CONFLICT (table_name)
DO UPDATE SET updated_date = now()
`,
[tableName, tableName, `자동 생성된 테이블 메타데이터: ${tableName}`]
);
// 기본 컬럼들 정의 (모든 테이블에 자동으로 추가되는 시스템 컬럼)
const defaultColumns = [
@ -516,20 +505,23 @@ CREATE TABLE "${tableName}" (${baseColumns},
// 기본 컬럼들을 table_type_columns에 등록
for (const defaultCol of defaultColumns) {
await tx.$executeRaw`
await client.query(
`
INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES (
${tableName}, ${defaultCol.name}, ${defaultCol.inputType}, '{}',
'Y', ${defaultCol.order}, now(), now()
$1, $2, $3, '{}',
'Y', $4, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
input_type = ${defaultCol.inputType},
display_order = ${defaultCol.order},
updated_date = now();
`;
input_type = $3,
display_order = $4,
updated_date = now()
`,
[tableName, defaultCol.name, defaultCol.inputType, defaultCol.order]
);
}
// 사용자 정의 컬럼들을 table_type_columns에 등록
@ -538,89 +530,98 @@ CREATE TABLE "${tableName}" (${baseColumns},
const inputType = this.convertWebTypeToInputType(
column.webType || "text"
);
const detailSettings = JSON.stringify(column.detailSettings || {});
await tx.$executeRaw`
await client.query(
`
INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES (
${tableName}, ${column.name}, ${inputType}, ${JSON.stringify(column.detailSettings || {})},
'Y', ${i}, now(), now()
$1, $2, $3, $4,
'Y', $5, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
input_type = ${inputType},
detail_settings = ${JSON.stringify(column.detailSettings || {})},
display_order = ${i},
updated_date = now();
`;
input_type = $3,
detail_settings = $4,
display_order = $5,
updated_date = now()
`,
[tableName, column.name, inputType, detailSettings, i]
);
}
// 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성)
// 1. 기본 컬럼들을 column_labels에 등록
for (const defaultCol of defaultColumns) {
await tx.column_labels.upsert({
where: {
table_name_column_name: {
table_name: tableName,
column_name: defaultCol.name,
},
},
update: {
column_label: defaultCol.label,
input_type: defaultCol.inputType,
detail_settings: JSON.stringify({}),
description: defaultCol.description,
display_order: defaultCol.order,
is_visible: defaultCol.isVisible,
updated_date: new Date(),
},
create: {
table_name: tableName,
column_name: defaultCol.name,
column_label: defaultCol.label,
input_type: defaultCol.inputType,
detail_settings: JSON.stringify({}),
description: defaultCol.description,
display_order: defaultCol.order,
is_visible: defaultCol.isVisible,
created_date: new Date(),
updated_date: new Date(),
},
});
await client.query(
`
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = $3,
input_type = $4,
detail_settings = $5,
description = $6,
display_order = $7,
is_visible = $8,
updated_date = now()
`,
[
tableName,
defaultCol.name,
defaultCol.label,
defaultCol.inputType,
JSON.stringify({}),
defaultCol.description,
defaultCol.order,
defaultCol.isVisible,
]
);
}
// 2. 사용자 정의 컬럼들을 column_labels에 등록
for (const column of columns) {
await tx.column_labels.upsert({
where: {
table_name_column_name: {
table_name: tableName,
column_name: column.name,
},
},
update: {
column_label: column.label || column.name,
input_type: this.convertWebTypeToInputType(column.webType || "text"),
detail_settings: JSON.stringify(column.detailSettings || {}),
description: column.description,
display_order: column.order || 0,
is_visible: true,
updated_date: new Date(),
},
create: {
table_name: tableName,
column_name: column.name,
column_label: column.label || column.name,
input_type: this.convertWebTypeToInputType(column.webType || "text"),
detail_settings: JSON.stringify(column.detailSettings || {}),
description: column.description,
display_order: column.order || 0,
is_visible: true,
created_date: new Date(),
updated_date: new Date(),
},
});
const inputType = this.convertWebTypeToInputType(
column.webType || "text"
);
const detailSettings = JSON.stringify(column.detailSettings || {});
await client.query(
`
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = $3,
input_type = $4,
detail_settings = $5,
description = $6,
display_order = $7,
is_visible = $8,
updated_date = now()
`,
[
tableName,
column.name,
column.label || column.name,
inputType,
detailSettings,
column.description,
column.order || 0,
true,
]
);
}
}
@ -679,18 +680,18 @@ CREATE TABLE "${tableName}" (${baseColumns},
*/
private async checkTableExists(tableName: string): Promise<boolean> {
try {
const result = await prisma.$queryRawUnsafe(
const result = await queryOne<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
);
)
`,
tableName
[tableName]
);
return (result as any)[0]?.exists || false;
return result?.exists || false;
} catch (error) {
logger.error("테이블 존재 확인 오류:", error);
return false;
@ -705,20 +706,19 @@ CREATE TABLE "${tableName}" (${baseColumns},
columnName: string
): Promise<boolean> {
try {
const result = await prisma.$queryRawUnsafe(
const result = await queryOne<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
);
)
`,
tableName,
columnName
[tableName, columnName]
);
return (result as any)[0]?.exists || false;
return result?.exists || false;
} catch (error) {
logger.error("컬럼 존재 확인 오류:", error);
return false;
@ -734,15 +734,16 @@ CREATE TABLE "${tableName}" (${baseColumns},
} | null> {
try {
// 테이블 정보 조회
const tableInfo = await prisma.table_labels.findUnique({
where: { table_name: tableName },
});
const tableInfo = await queryOne(
`SELECT * FROM table_labels WHERE table_name = $1`,
[tableName]
);
// 컬럼 정보 조회
const columns = await prisma.column_labels.findMany({
where: { table_name: tableName },
orderBy: { display_order: "asc" },
});
const columns = await query(
`SELECT * FROM column_labels WHERE table_name = $1 ORDER BY display_order ASC`,
[tableName]
);
if (!tableInfo) {
return null;

View File

@ -76,8 +76,8 @@ export default function TableManagementPage() {
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
// 최고 관리자 여부 확인
const isSuperAdmin = user?.companyCode === "*" && user?.userId === "plm_admin";
// 최고 관리자 여부 확인 (회사코드가 "*"인 경우)
const isSuperAdmin = user?.companyCode === "*";
// 다국어 텍스트 로드
useEffect(() => {
@ -541,9 +541,9 @@ export default function TableManagementPage() {
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
return (
<div className="w-full max-w-none px-4 py-8 space-y-8">
<div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
@ -593,7 +593,7 @@ export default function TableManagementPage() {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
{/* 테이블 목록 */}
<Card className="lg:col-span-1 shadow-sm">
<Card className="shadow-sm lg:col-span-1">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5 text-gray-600" />
@ -663,7 +663,7 @@ export default function TableManagementPage() {
</Card>
{/* 컬럼 타입 관리 */}
<Card className="lg:col-span-4 shadow-sm">
<Card className="shadow-sm lg:col-span-4">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5 text-gray-600" />