feat: Phase 3.3 ComponentStandardService Raw Query 전환 완료

15개 Prisma 호출을 모두 Raw Query로 전환
- 컴포넌트 조회 (getComponents, getComponent)
- 컴포넌트 CRUD (createComponent, updateComponent, deleteComponent)
- 정렬 순서 업데이트 (updateSortOrder)
- 컴포넌트 복제 (duplicateComponent)
- 카테고리 조회 (getCategories)
- 통계 조회 (getStatistics)
- 중복 체크 (checkDuplicate)

주요 기술적 해결:
- 동적 WHERE 조건 생성 (ILIKE 검색, OR 조건)
- 동적 UPDATE 쿼리 (fieldMapping 사용)
- GROUP BY 집계 쿼리 (카테고리별, 상태별)
- DISTINCT 쿼리 (카테고리 목록)
- 트랜잭션 처리 (정렬 순서 업데이트)
- SQL 인젝션 방지 (정렬 컬럼 검증)

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

Phase 3 진행률: 54/162 (33.3%)
전체 진행률: 305/444 (68.7%)
This commit is contained in:
kjs 2025-10-01 10:48:31 +09:00
parent c37b74a8bb
commit 2331e3fd20
1 changed files with 207 additions and 106 deletions

View File

@ -1,6 +1,4 @@
import { PrismaClient } from "@prisma/client"; import { query, queryOne, transaction } from "../database/db";
const prisma = new PrismaClient();
export interface ComponentStandardData { export interface ComponentStandardData {
component_code: string; component_code: string;
@ -49,49 +47,78 @@ class ComponentStandardService {
offset = 0, offset = 0,
} = params; } = params;
const where: any = {}; const whereConditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;
// 활성화 상태 필터 // 활성화 상태 필터
if (active) { if (active) {
where.is_active = active; whereConditions.push(`is_active = $${paramIndex++}`);
values.push(active);
} }
// 카테고리 필터 // 카테고리 필터
if (category && category !== "all") { if (category && category !== "all") {
where.category = category; whereConditions.push(`category = $${paramIndex++}`);
values.push(category);
} }
// 공개 여부 필터 // 공개 여부 필터
if (is_public) { if (is_public) {
where.is_public = is_public; whereConditions.push(`is_public = $${paramIndex++}`);
values.push(is_public);
} }
// 회사별 필터 (공개 컴포넌트 + 해당 회사 컴포넌트) // 회사별 필터 (공개 컴포넌트 + 해당 회사 컴포넌트)
if (company_code) { if (company_code) {
where.OR = [{ is_public: "Y" }, { company_code }]; whereConditions.push(
`(is_public = 'Y' OR company_code = $${paramIndex++})`
);
values.push(company_code);
} }
// 검색 조건 // 검색 조건
if (search) { if (search) {
where.OR = [ whereConditions.push(
...(where.OR || []), `(component_name ILIKE $${paramIndex} OR component_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
{ component_name: { contains: search, mode: "insensitive" } }, );
{ component_name_eng: { contains: search, mode: "insensitive" } }, values.push(`%${search}%`);
{ description: { contains: search, mode: "insensitive" } }, paramIndex++;
];
} }
const orderBy: any = {}; const whereClause =
orderBy[sort] = order; whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const components = await prisma.component_standards.findMany({ // 정렬 컬럼 검증 (SQL 인젝션 방지)
where, const validSortColumns = [
orderBy, "sort_order",
take: limit, "component_name",
skip: offset, "category",
}); "created_date",
"updated_date",
];
const sortColumn = validSortColumns.includes(sort) ? sort : "sort_order";
const sortOrder = order === "desc" ? "DESC" : "ASC";
const total = await prisma.component_standards.count({ where }); // 컴포넌트 조회
const components = await query<any>(
`SELECT * FROM component_standards
${whereClause}
ORDER BY ${sortColumn} ${sortOrder}
${limit ? `LIMIT $${paramIndex++}` : ""}
${limit ? `OFFSET $${paramIndex++}` : ""}`,
limit ? [...values, limit, offset] : values
);
// 전체 개수 조회
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM component_standards ${whereClause}`,
values
);
const total = parseInt(countResult?.count || "0");
return { return {
components, components,
@ -105,9 +132,10 @@ class ComponentStandardService {
* *
*/ */
async getComponent(component_code: string) { async getComponent(component_code: string) {
const component = await prisma.component_standards.findUnique({ const component = await queryOne<any>(
where: { component_code }, `SELECT * FROM component_standards WHERE component_code = $1`,
}); [component_code]
);
if (!component) { if (!component) {
throw new Error(`컴포넌트를 찾을 수 없습니다: ${component_code}`); throw new Error(`컴포넌트를 찾을 수 없습니다: ${component_code}`);
@ -121,9 +149,10 @@ class ComponentStandardService {
*/ */
async createComponent(data: ComponentStandardData) { async createComponent(data: ComponentStandardData) {
// 중복 코드 확인 // 중복 코드 확인
const existing = await prisma.component_standards.findUnique({ const existing = await queryOne<any>(
where: { component_code: data.component_code }, `SELECT * FROM component_standards WHERE component_code = $1`,
}); [data.component_code]
);
if (existing) { if (existing) {
throw new Error( throw new Error(
@ -138,13 +167,31 @@ class ComponentStandardService {
delete (createData as any).active; delete (createData as any).active;
} }
const component = await prisma.component_standards.create({ const component = await queryOne<any>(
data: { `INSERT INTO component_standards
...createData, (component_code, component_name, component_name_eng, description, category,
created_date: new Date(), icon_name, default_size, component_config, preview_image, sort_order,
updated_date: new Date(), is_active, is_public, company_code, created_by, updated_by, created_date, updated_date)
}, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW())
}); RETURNING *`,
[
createData.component_code,
createData.component_name,
createData.component_name_eng || null,
createData.description || null,
createData.category,
createData.icon_name || null,
createData.default_size || null,
createData.component_config,
createData.preview_image || null,
createData.sort_order || 0,
createData.is_active || "Y",
createData.is_public || "N",
createData.company_code,
createData.created_by || null,
createData.updated_by || null,
]
);
return component; return component;
} }
@ -165,13 +212,41 @@ class ComponentStandardService {
delete (updateData as any).active; delete (updateData as any).active;
} }
const component = await prisma.component_standards.update({ // 동적 UPDATE 쿼리 생성
where: { component_code }, const updateFields: string[] = ["updated_date = NOW()"];
data: { const values: any[] = [];
...updateData, let paramIndex = 1;
updated_date: new Date(),
}, const fieldMapping: { [key: string]: string } = {
}); component_name: "component_name",
component_name_eng: "component_name_eng",
description: "description",
category: "category",
icon_name: "icon_name",
default_size: "default_size",
component_config: "component_config",
preview_image: "preview_image",
sort_order: "sort_order",
is_active: "is_active",
is_public: "is_public",
company_code: "company_code",
updated_by: "updated_by",
};
for (const [key, dbField] of Object.entries(fieldMapping)) {
if (key in updateData) {
updateFields.push(`${dbField} = $${paramIndex++}`);
values.push((updateData as any)[key]);
}
}
const component = await queryOne<any>(
`UPDATE component_standards
SET ${updateFields.join(", ")}
WHERE component_code = $${paramIndex}
RETURNING *`,
[...values, component_code]
);
return component; return component;
} }
@ -182,9 +257,10 @@ class ComponentStandardService {
async deleteComponent(component_code: string) { async deleteComponent(component_code: string) {
const existing = await this.getComponent(component_code); const existing = await this.getComponent(component_code);
await prisma.component_standards.delete({ await query(
where: { component_code }, `DELETE FROM component_standards WHERE component_code = $1`,
}); [component_code]
);
return { message: `컴포넌트가 삭제되었습니다: ${component_code}` }; return { message: `컴포넌트가 삭제되었습니다: ${component_code}` };
} }
@ -195,14 +271,16 @@ class ComponentStandardService {
async updateSortOrder( async updateSortOrder(
updates: Array<{ component_code: string; sort_order: number }> updates: Array<{ component_code: string; sort_order: number }>
) { ) {
const transactions = updates.map(({ component_code, sort_order }) => await transaction(async (client) => {
prisma.component_standards.update({ for (const { component_code, sort_order } of updates) {
where: { component_code }, await client.query(
data: { sort_order, updated_date: new Date() }, `UPDATE component_standards
}) SET sort_order = $1, updated_date = NOW()
); WHERE component_code = $2`,
[sort_order, component_code]
await prisma.$transaction(transactions); );
}
});
return { message: "정렬 순서가 업데이트되었습니다." }; return { message: "정렬 순서가 업데이트되었습니다." };
} }
@ -218,33 +296,38 @@ class ComponentStandardService {
const source = await this.getComponent(source_code); const source = await this.getComponent(source_code);
// 새 코드 중복 확인 // 새 코드 중복 확인
const existing = await prisma.component_standards.findUnique({ const existing = await queryOne<any>(
where: { component_code: new_code }, `SELECT * FROM component_standards WHERE component_code = $1`,
}); [new_code]
);
if (existing) { if (existing) {
throw new Error(`이미 존재하는 컴포넌트 코드입니다: ${new_code}`); throw new Error(`이미 존재하는 컴포넌트 코드입니다: ${new_code}`);
} }
const component = await prisma.component_standards.create({ const component = await queryOne<any>(
data: { `INSERT INTO component_standards
component_code: new_code, (component_code, component_name, component_name_eng, description, category,
component_name: new_name, icon_name, default_size, component_config, preview_image, sort_order,
component_name_eng: source?.component_name_eng, is_active, is_public, company_code, created_date, updated_date)
description: source?.description, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
category: source?.category, RETURNING *`,
icon_name: source?.icon_name, [
default_size: source?.default_size as any, new_code,
component_config: source?.component_config as any, new_name,
preview_image: source?.preview_image, source?.component_name_eng,
sort_order: source?.sort_order, source?.description,
is_active: source?.is_active, source?.category,
is_public: source?.is_public, source?.icon_name,
company_code: source?.company_code || "DEFAULT", source?.default_size,
created_date: new Date(), source?.component_config,
updated_date: new Date(), source?.preview_image,
}, source?.sort_order,
}); source?.is_active,
source?.is_public,
source?.company_code || "DEFAULT",
]
);
return component; return component;
} }
@ -253,19 +336,20 @@ class ComponentStandardService {
* *
*/ */
async getCategories(company_code?: string) { async getCategories(company_code?: string) {
const where: any = { const whereConditions: string[] = ["is_active = 'Y'"];
is_active: "Y", const values: any[] = [];
};
if (company_code) { if (company_code) {
where.OR = [{ is_public: "Y" }, { company_code }]; whereConditions.push(`(is_public = 'Y' OR company_code = $1)`);
values.push(company_code);
} }
const categories = await prisma.component_standards.findMany({ const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
where,
select: { category: true }, const categories = await query<{ category: string }>(
distinct: ["category"], `SELECT DISTINCT category FROM component_standards ${whereClause} ORDER BY category`,
}); values
);
return categories return categories
.map((item) => item.category) .map((item) => item.category)
@ -276,36 +360,48 @@ class ComponentStandardService {
* *
*/ */
async getStatistics(company_code?: string) { async getStatistics(company_code?: string) {
const where: any = { const whereConditions: string[] = ["is_active = 'Y'"];
is_active: "Y", const values: any[] = [];
};
if (company_code) { if (company_code) {
where.OR = [{ is_public: "Y" }, { company_code }]; whereConditions.push(`(is_public = 'Y' OR company_code = $1)`);
values.push(company_code);
} }
const total = await prisma.component_standards.count({ where }); const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
const byCategory = await prisma.component_standards.groupBy({ // 전체 개수
by: ["category"], const totalResult = await queryOne<{ count: string }>(
where, `SELECT COUNT(*) as count FROM component_standards ${whereClause}`,
_count: { category: true }, values
}); );
const total = parseInt(totalResult?.count || "0");
const byStatus = await prisma.component_standards.groupBy({ // 카테고리별 집계
by: ["is_active"], const byCategory = await query<{ category: string; count: string }>(
_count: { is_active: true }, `SELECT category, COUNT(*) as count
}); FROM component_standards
${whereClause}
GROUP BY category`,
values
);
// 상태별 집계
const byStatus = await query<{ is_active: string; count: string }>(
`SELECT is_active, COUNT(*) as count
FROM component_standards
GROUP BY is_active`
);
return { return {
total, total,
byCategory: byCategory.map((item) => ({ byCategory: byCategory.map((item) => ({
category: item.category, category: item.category,
count: item._count.category, count: parseInt(item.count),
})), })),
byStatus: byStatus.map((item) => ({ byStatus: byStatus.map((item) => ({
status: item.is_active, status: item.is_active,
count: item._count.is_active, count: parseInt(item.count),
})), })),
}; };
} }
@ -317,16 +413,21 @@ class ComponentStandardService {
component_code: string, component_code: string,
company_code?: string company_code?: string
): Promise<boolean> { ): Promise<boolean> {
const whereClause: any = { component_code }; const whereConditions: string[] = ["component_code = $1"];
const values: any[] = [component_code];
// 회사 코드가 있고 "*"가 아닌 경우에만 조건 추가 // 회사 코드가 있고 "*"가 아닌 경우에만 조건 추가
if (company_code && company_code !== "*") { if (company_code && company_code !== "*") {
whereClause.company_code = company_code; whereConditions.push("company_code = $2");
values.push(company_code);
} }
const existingComponent = await prisma.component_standards.findFirst({ const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
where: whereClause,
}); const existingComponent = await queryOne<any>(
`SELECT * FROM component_standards ${whereClause} LIMIT 1`,
values
);
return !!existingComponent; return !!existingComponent;
} }