feat: Phase 3.7 LayoutService Raw Query 전환 완료

10개 Prisma 호출을 모두 Raw Query로 전환
- 레이아웃 목록 조회 (getLayouts - 복잡한 OR 조건, Promise.all)
- 레이아웃 단건 조회 (getLayoutById - OR 조건)
- 레이아웃 생성 (createLayout - JSON 필드)
- 레이아웃 수정 (updateLayout - 동적 UPDATE, 10개 필드)
- 레이아웃 삭제 (deleteLayout - Soft Delete)
- 레이아웃 복제 (duplicateLayout - 기존 함수 재사용)
- 카테고리별 통계 (getLayoutCountsByCategory - GROUP BY)
- 코드 자동 생성 (generateLayoutCode - LIKE 검색)

주요 기술적 해결:
- 복잡한 OR 조건 처리 (company_code OR is_public)
- 동적 WHERE 조건 생성 (ILIKE 다중 검색)
- 동적 UPDATE 쿼리 (10개 필드 조건부 업데이트)
- JSON 필드 처리 (default_size, layout_config, zones_config)
- GROUP BY 통계 쿼리 (카테고리별 개수)
- LIKE 검색 (코드 생성 시 패턴 검색)
- Promise.all 병렬 쿼리 (목록 + 개수 동시 조회)
- safeJSONStringify 헬퍼 함수 활용

TypeScript 컴파일 성공
Prisma import 완전 제거

Phase 3 진행률: 97/162 (59.9%)
전체 진행률: 348/444 (78.4%)
This commit is contained in:
kjs 2025-10-01 11:25:08 +09:00
parent 45ec38790b
commit 4c20d93c87
2 changed files with 167 additions and 124 deletions

View File

@ -128,7 +128,7 @@ backend-node/ (루트)
- `commonCodeService.ts` (0개) - ✅ **전환 완료** (Phase 3.4)
- `dataflowDiagramService.ts` (0개) - ✅ **전환 완료** (Phase 3.5)
- `collectionService.ts` (0개) - ✅ **전환 완료** (Phase 3.6)
- `layoutService.ts` (10개) - 레이아웃 관리
- `layoutService.ts` (0개) - ✅ **전환 완료** (Phase 3.7)
- `dbTypeCategoryService.ts` (10개) - DB 타입 분류 ⭐ 신규 발견
- `templateStandardService.ts` (9개) - 템플릿 표준
- `eventTriggerService.ts` (6개) - JSON 검색 쿼리
@ -1183,12 +1183,22 @@ describe("Performance Benchmarks", () => {
- [x] 비동기 작업 처리 (setTimeout 내 query 사용)
- [x] TypeScript 컴파일 성공
- [x] Prisma import 완전 제거
- [x] **LayoutService 전환 (10개)****완료** (Phase 3.7)
- [x] 10개 Prisma 호출 전환 완료 (레이아웃 CRUD, 통계)
- [x] 복잡한 OR 조건 처리 (company_code OR is_public)
- [x] 동적 WHERE 조건 생성 (ILIKE 다중 검색)
- [x] 동적 UPDATE 쿼리 (10개 필드 조건부 업데이트)
- [x] JSON 필드 처리 (default_size, layout_config, zones_config)
- [x] GROUP BY 통계 쿼리 (카테고리별 개수)
- [x] LIKE 검색 (코드 생성 시 패턴 검색)
- [x] Promise.all 병렬 쿼리 (목록 + 개수)
- [x] TypeScript 컴파일 성공
- [x] Prisma import 완전 제거
- [ ] 배치 관련 서비스 전환 (26개) ⭐ 대규모 신규 발견
- [ ] BatchExternalDbService (8개)
- [ ] BatchExecutionLogService (7개), BatchManagementService (5개)
- [ ] BatchSchedulerService (4개)
- [ ] 표준 관리 서비스 전환 (16개)
- [ ] LayoutService (10개) - [계획서](PHASE3.7_LAYOUT_SERVICE_MIGRATION.md)
- [ ] 표준 관리 서비스 전환 (6개)
- [ ] TemplateStandardService (6개) - [계획서](PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md)
- [ ] 데이터플로우 관련 서비스 (6개) ⭐ 신규 발견
- [ ] DataflowControlService (6개)

View File

@ -1,5 +1,4 @@
import { PrismaClient } from "@prisma/client";
import prisma from "../config/database";
import { query, queryOne } from "../database/db";
import {
CreateLayoutRequest,
UpdateLayoutRequest,
@ -77,42 +76,59 @@ export class LayoutService {
const skip = (page - 1) * size;
// 검색 조건 구성
const where: any = {
is_active: "Y",
OR: [
{ company_code: companyCode },
...(includePublic ? [{ is_public: "Y" }] : []),
],
};
// 동적 WHERE 조건 구성
const whereConditions: string[] = ["is_active = $1"];
const values: any[] = ["Y"];
let paramIndex = 2;
// company_code OR is_public 조건
if (includePublic) {
whereConditions.push(
`(company_code = $${paramIndex} OR is_public = $${paramIndex + 1})`
);
values.push(companyCode, "Y");
paramIndex += 2;
} else {
whereConditions.push(`company_code = $${paramIndex++}`);
values.push(companyCode);
}
if (category) {
where.category = category;
whereConditions.push(`category = $${paramIndex++}`);
values.push(category);
}
if (layoutType) {
where.layout_type = layoutType;
whereConditions.push(`layout_type = $${paramIndex++}`);
values.push(layoutType);
}
if (searchTerm) {
where.OR = [
...where.OR,
{ layout_name: { contains: searchTerm, mode: "insensitive" } },
{ layout_name_eng: { contains: searchTerm, mode: "insensitive" } },
{ description: { contains: searchTerm, mode: "insensitive" } },
];
whereConditions.push(
`(layout_name ILIKE $${paramIndex} OR layout_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
);
values.push(`%${searchTerm}%`);
paramIndex++;
}
const [data, total] = await Promise.all([
prisma.layout_standards.findMany({
where,
skip,
take: size,
orderBy: [{ sort_order: "asc" }, { created_date: "desc" }],
}),
prisma.layout_standards.count({ where }),
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
const [data, countResult] = await Promise.all([
query<any>(
`SELECT * FROM layout_standards
${whereClause}
ORDER BY sort_order ASC, created_date DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
[...values, size, skip]
),
queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM layout_standards ${whereClause}`,
values
),
]);
const total = parseInt(countResult?.count || "0");
return {
data: data.map(
(layout) =>
@ -149,13 +165,13 @@ export class LayoutService {
layoutCode: string,
companyCode: string
): Promise<LayoutStandard | null> {
const layout = await prisma.layout_standards.findFirst({
where: {
layout_code: layoutCode,
is_active: "Y",
OR: [{ company_code: companyCode }, { is_public: "Y" }],
},
});
const layout = await queryOne<any>(
`SELECT * FROM layout_standards
WHERE layout_code = $1 AND is_active = $2
AND (company_code = $3 OR is_public = $4)
LIMIT 1`,
[layoutCode, "Y", companyCode, "Y"]
);
if (!layout) return null;
@ -196,24 +212,31 @@ export class LayoutService {
companyCode
);
const layout = await prisma.layout_standards.create({
data: {
layout_code: layoutCode,
layout_name: request.layoutName,
layout_name_eng: request.layoutNameEng,
description: request.description,
layout_type: request.layoutType,
category: request.category,
icon_name: request.iconName,
default_size: safeJSONStringify(request.defaultSize) as any,
layout_config: safeJSONStringify(request.layoutConfig) as any,
zones_config: safeJSONStringify(request.zonesConfig) as any,
is_public: request.isPublic ? "Y" : "N",
company_code: companyCode,
created_by: userId,
updated_by: userId,
},
});
const layout = await queryOne<any>(
`INSERT INTO layout_standards
(layout_code, layout_name, layout_name_eng, description, layout_type, category,
icon_name, default_size, layout_config, zones_config, is_public, is_active,
company_code, created_by, updated_by, created_date, updated_date, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW(), 0)
RETURNING *`,
[
layoutCode,
request.layoutName,
request.layoutNameEng,
request.description,
request.layoutType,
request.category,
request.iconName,
safeJSONStringify(request.defaultSize),
safeJSONStringify(request.layoutConfig),
safeJSONStringify(request.zonesConfig),
request.isPublic ? "Y" : "N",
"Y",
companyCode,
userId,
userId,
]
);
return this.mapToLayoutStandard(layout);
}
@ -227,47 +250,69 @@ export class LayoutService {
userId: string
): Promise<LayoutStandard | null> {
// 수정 권한 확인
const existing = await prisma.layout_standards.findFirst({
where: {
layout_code: request.layoutCode,
company_code: companyCode,
is_active: "Y",
},
});
const existing = await queryOne<any>(
`SELECT * FROM layout_standards
WHERE layout_code = $1 AND company_code = $2 AND is_active = $3`,
[request.layoutCode, companyCode, "Y"]
);
if (!existing) {
throw new Error("레이아웃을 찾을 수 없거나 수정 권한이 없습니다.");
}
const updateData: any = {
updated_by: userId,
updated_date: new Date(),
};
// 동적 UPDATE 쿼리 생성
const updateFields: string[] = ["updated_by = $1", "updated_date = NOW()"];
const values: any[] = [userId];
let paramIndex = 2;
// 수정할 필드만 업데이트
if (request.layoutName !== undefined)
updateData.layout_name = request.layoutName;
if (request.layoutNameEng !== undefined)
updateData.layout_name_eng = request.layoutNameEng;
if (request.description !== undefined)
updateData.description = request.description;
if (request.layoutType !== undefined)
updateData.layout_type = request.layoutType;
if (request.category !== undefined) updateData.category = request.category;
if (request.iconName !== undefined) updateData.icon_name = request.iconName;
if (request.defaultSize !== undefined)
updateData.default_size = safeJSONStringify(request.defaultSize) as any;
if (request.layoutConfig !== undefined)
updateData.layout_config = safeJSONStringify(request.layoutConfig) as any;
if (request.zonesConfig !== undefined)
updateData.zones_config = safeJSONStringify(request.zonesConfig) as any;
if (request.isPublic !== undefined)
updateData.is_public = request.isPublic ? "Y" : "N";
if (request.layoutName !== undefined) {
updateFields.push(`layout_name = $${paramIndex++}`);
values.push(request.layoutName);
}
if (request.layoutNameEng !== undefined) {
updateFields.push(`layout_name_eng = $${paramIndex++}`);
values.push(request.layoutNameEng);
}
if (request.description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(request.description);
}
if (request.layoutType !== undefined) {
updateFields.push(`layout_type = $${paramIndex++}`);
values.push(request.layoutType);
}
if (request.category !== undefined) {
updateFields.push(`category = $${paramIndex++}`);
values.push(request.category);
}
if (request.iconName !== undefined) {
updateFields.push(`icon_name = $${paramIndex++}`);
values.push(request.iconName);
}
if (request.defaultSize !== undefined) {
updateFields.push(`default_size = $${paramIndex++}`);
values.push(safeJSONStringify(request.defaultSize));
}
if (request.layoutConfig !== undefined) {
updateFields.push(`layout_config = $${paramIndex++}`);
values.push(safeJSONStringify(request.layoutConfig));
}
if (request.zonesConfig !== undefined) {
updateFields.push(`zones_config = $${paramIndex++}`);
values.push(safeJSONStringify(request.zonesConfig));
}
if (request.isPublic !== undefined) {
updateFields.push(`is_public = $${paramIndex++}`);
values.push(request.isPublic ? "Y" : "N");
}
const updated = await prisma.layout_standards.update({
where: { layout_code: request.layoutCode },
data: updateData,
});
const updated = await queryOne<any>(
`UPDATE layout_standards
SET ${updateFields.join(", ")}
WHERE layout_code = $${paramIndex}
RETURNING *`,
[...values, request.layoutCode]
);
return this.mapToLayoutStandard(updated);
}
@ -280,26 +325,22 @@ export class LayoutService {
companyCode: string,
userId: string
): Promise<boolean> {
const existing = await prisma.layout_standards.findFirst({
where: {
layout_code: layoutCode,
company_code: companyCode,
is_active: "Y",
},
});
const existing = await queryOne<any>(
`SELECT * FROM layout_standards
WHERE layout_code = $1 AND company_code = $2 AND is_active = $3`,
[layoutCode, companyCode, "Y"]
);
if (!existing) {
throw new Error("레이아웃을 찾을 수 없거나 삭제 권한이 없습니다.");
}
await prisma.layout_standards.update({
where: { layout_code: layoutCode },
data: {
is_active: "N",
updated_by: userId,
updated_date: new Date(),
},
});
await query(
`UPDATE layout_standards
SET is_active = $1, updated_by = $2, updated_date = NOW()
WHERE layout_code = $3`,
["N", userId, layoutCode]
);
return true;
}
@ -342,20 +383,17 @@ export class LayoutService {
async getLayoutCountsByCategory(
companyCode: string
): Promise<Record<string, number>> {
const counts = await prisma.layout_standards.groupBy({
by: ["category"],
_count: {
layout_code: true,
},
where: {
is_active: "Y",
OR: [{ company_code: companyCode }, { is_public: "Y" }],
},
});
const counts = await query<{ category: string; count: string }>(
`SELECT category, COUNT(*) as count
FROM layout_standards
WHERE is_active = $1 AND (company_code = $2 OR is_public = $3)
GROUP BY category`,
["Y", companyCode, "Y"]
);
return counts.reduce(
(acc: Record<string, number>, item: any) => {
acc[item.category] = item._count.layout_code;
acc[item.category] = parseInt(item.count);
return acc;
},
{} as Record<string, number>
@ -370,16 +408,11 @@ export class LayoutService {
companyCode: string
): Promise<string> {
const prefix = `${layoutType.toUpperCase()}_${companyCode}`;
const existingCodes = await prisma.layout_standards.findMany({
where: {
layout_code: {
startsWith: prefix,
},
},
select: {
layout_code: true,
},
});
const existingCodes = await query<{ layout_code: string }>(
`SELECT layout_code FROM layout_standards
WHERE layout_code LIKE $1`,
[`${prefix}%`]
);
const maxNumber = existingCodes.reduce((max: number, item: any) => {
const match = item.layout_code.match(/_(\d+)$/);