diff --git a/PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md b/PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md index 58713954..b8c1e06a 100644 --- a/PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md +++ b/PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md @@ -6,23 +6,25 @@ TemplateStandardService는 **6개의 Prisma 호출**이 있으며, 템플릿 표 ### 📊 기본 정보 -| 항목 | 내용 | -| --------------- | ----------------------------------------------------------- | -| 파일 위치 | `backend-node/src/services/templateStandardService.ts` | -| 파일 크기 | 395 라인 | -| Prisma 호출 | 6개 | -| **현재 진행률** | **0/6 (0%)** 🔄 **진행 예정** | -| 복잡도 | 낮음 (기본 CRUD + DISTINCT) | -| 우선순위 | 🟢 낮음 (Phase 3.9) | -| **상태** | ⏳ **대기 중** | +| 항목 | 내용 | +| --------------- | ------------------------------------------------------ | +| 파일 위치 | `backend-node/src/services/templateStandardService.ts` | +| 파일 크기 | 395 라인 | +| Prisma 호출 | 6개 | +| **현재 진행률** | **7/7 (100%)** ✅ **전환 완료** | +| 복잡도 | 낮음 (기본 CRUD + DISTINCT) | +| 우선순위 | 🟢 낮음 (Phase 3.9) | +| **상태** | ✅ **완료** | ### 🎯 전환 목표 -- ⏳ **6개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체** -- ⏳ 템플릿 CRUD 기능 정상 동작 -- ⏳ DISTINCT 쿼리 전환 -- ⏳ 모든 단위 테스트 통과 -- ⏳ **Prisma import 완전 제거** +- ✅ **7개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체** +- ✅ 템플릿 CRUD 기능 정상 동작 +- ✅ DISTINCT 쿼리 전환 +- ✅ Promise.all 병렬 쿼리 (목록 + 개수) +- ✅ 동적 UPDATE 쿼리 (11개 필드) +- ✅ TypeScript 컴파일 성공 +- ✅ **Prisma import 완전 제거** --- @@ -31,6 +33,7 @@ TemplateStandardService는 **6개의 Prisma 호출**이 있으며, 템플릿 표 ### 주요 Prisma 호출 (6개) #### 1. **getTemplateByCode()** - 템플릿 단건 조회 + ```typescript // Line 76 return await prisma.template_standards.findUnique({ @@ -42,6 +45,7 @@ return await prisma.template_standards.findUnique({ ``` #### 2. **createTemplate()** - 템플릿 생성 + ```typescript // Line 86 const existing = await prisma.template_standards.findUnique({ @@ -62,6 +66,7 @@ return await prisma.template_standards.create({ ``` #### 3. **updateTemplate()** - 템플릿 수정 + ```typescript // Line 164 return await prisma.template_standards.update({ @@ -79,6 +84,7 @@ return await prisma.template_standards.update({ ``` #### 4. **deleteTemplate()** - 템플릿 삭제 + ```typescript // Line 181 await prisma.template_standards.delete({ @@ -92,6 +98,7 @@ await prisma.template_standards.delete({ ``` #### 5. **getTemplateCategories()** - 카테고리 목록 (DISTINCT) + ```typescript // Line 262 const categories = await prisma.template_standards.findMany({ @@ -112,6 +119,7 @@ const categories = await prisma.template_standards.findMany({ ### 1단계: 기본 CRUD 전환 (4개 함수) **함수 목록**: + - `getTemplateByCode()` - 단건 조회 (findUnique) - `createTemplate()` - 생성 (findUnique + create) - `updateTemplate()` - 수정 (update) @@ -120,6 +128,7 @@ const categories = await prisma.template_standards.findMany({ ### 2단계: 추가 기능 전환 (1개 함수) **함수 목록**: + - `getTemplateCategories()` - 카테고리 목록 (findMany + distinct) --- @@ -337,14 +346,18 @@ return categories.map((c) => c.category); ## 🔧 주요 기술적 과제 ### 1. 복합 기본 키 + `template_standards` 테이블은 `(template_code, company_code)` 복합 기본 키를 사용합니다. + - WHERE 절에서 두 컬럼 모두 지정 필요 - Prisma의 `template_code_company_code` 표현식을 `template_code = $1 AND company_code = $2`로 변환 ### 2. JSON 필드 + `layout_config` 필드는 JSON 타입으로, INSERT/UPDATE 시 `JSON.stringify()` 필요합니다. ### 3. DISTINCT + NULL 제외 + 카테고리 목록 조회 시 `DISTINCT` 사용하며, NULL 값은 `WHERE category IS NOT NULL`로 제외합니다. --- @@ -352,6 +365,7 @@ return categories.map((c) => c.category); ## 📋 체크리스트 ### 코드 전환 + - [ ] import 문 수정 (`prisma` → `query, queryOne`) - [ ] getTemplateByCode() - findUnique → queryOne (복합 키) - [ ] createTemplate() - findUnique + create → queryOne (중복 확인 + INSERT) @@ -362,6 +376,7 @@ return categories.map((c) => c.category); - [ ] Prisma import 완전 제거 ### 테스트 + - [ ] 단위 테스트 작성 (6개) - [ ] 통합 테스트 작성 (2개) - [ ] TypeScript 컴파일 성공 @@ -372,12 +387,15 @@ return categories.map((c) => c.category); ## 💡 특이사항 ### 복합 기본 키 패턴 + 이 서비스는 `(template_code, company_code)` 복합 기본 키를 사용하므로, 모든 조회/수정/삭제 작업에서 두 컬럼을 모두 WHERE 조건에 포함해야 합니다. ### JSON 레이아웃 설정 + `layout_config` 필드는 템플릿의 레이아웃 설정을 JSON 형태로 저장합니다. Raw Query 전환 시 `JSON.stringify()`를 사용하여 문자열로 변환해야 합니다. ### 카테고리 관리 + 템플릿은 카테고리별로 분류되며, `getTemplateCategories()` 메서드로 고유한 카테고리 목록을 조회할 수 있습니다. --- @@ -388,4 +406,3 @@ return categories.map((c) => c.category); **우선순위**: 🟢 낮음 (Phase 3.9) **상태**: ⏳ **대기 중** **특이사항**: 복합 기본 키, JSON 필드, DISTINCT 쿼리 포함 - diff --git a/PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md b/PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md index 8bb385d3..056b62c3 100644 --- a/PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md +++ b/PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md @@ -130,7 +130,7 @@ backend-node/ (루트) - `collectionService.ts` (0개) - ✅ **전환 완료** (Phase 3.6) - `layoutService.ts` (0개) - ✅ **전환 완료** (Phase 3.7) - `dbTypeCategoryService.ts` (0개) - ✅ **전환 완료** (Phase 3.8) -- `templateStandardService.ts` (6개) - 템플릿 표준 +- `templateStandardService.ts` (0개) - ✅ **전환 완료** (Phase 3.9) - `eventTriggerService.ts` (6개) - JSON 검색 쿼리 #### 🟡 **중간 (단순 CRUD) - 3순위** @@ -1204,12 +1204,22 @@ describe("Performance Benchmarks", () => { - [x] 중복 검사 (카테고리 생성 시) - [x] TypeScript 컴파일 성공 - [x] Prisma import 완전 제거 +- [x] **TemplateStandardService 전환 (7개)** ✅ **완료** (Phase 3.9) + - [x] 7개 Prisma 호출 전환 완료 (템플릿 CRUD, 카테고리) + - [x] 템플릿 목록 조회 (복잡한 OR 조건, Promise.all) + - [x] 템플릿 생성 (중복 검사 + INSERT) + - [x] 동적 UPDATE 쿼리 (11개 필드 조건부 업데이트) + - [x] 템플릿 삭제 (DELETE) + - [x] 정렬 순서 일괄 업데이트 (Promise.all) + - [x] DISTINCT 쿼리 (카테고리 목록) + - [x] TypeScript 컴파일 성공 + - [x] Prisma import 완전 제거 - [ ] 배치 관련 서비스 전환 (26개) ⭐ 대규모 신규 발견 - [ ] BatchExternalDbService (8개) - [ ] BatchExecutionLogService (7개), BatchManagementService (5개) - [ ] BatchSchedulerService (4개) -- [ ] 표준 관리 서비스 전환 (6개) - - [ ] TemplateStandardService (6개) - [계획서](PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md) +- [x] **표준 관리 서비스 전환 (7개)** ✅ **완료** (Phase 3.9) + - [x] TemplateStandardService (7개) - [계획서](PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md) - [ ] 데이터플로우 관련 서비스 (6개) ⭐ 신규 발견 - [ ] DataflowControlService (6개) - [ ] 기타 중요 서비스 (8개) ⭐ 신규 발견 diff --git a/backend-node/src/services/templateStandardService.ts b/backend-node/src/services/templateStandardService.ts index f9d436c2..609b5595 100644 --- a/backend-node/src/services/templateStandardService.ts +++ b/backend-node/src/services/templateStandardService.ts @@ -1,6 +1,4 @@ -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); +import { query, queryOne } from "../database/db"; /** * 템플릿 표준 관리 서비스 @@ -30,42 +28,57 @@ export class TemplateStandardService { const skip = (page - 1) * limit; - // 기본 필터 조건 - const where: any = {}; + // 동적 WHERE 조건 생성 + const conditions: string[] = []; + const values: any[] = []; + let paramIndex = 1; if (active && active !== "all") { - where.is_active = active; + conditions.push(`is_active = $${paramIndex++}`); + values.push(active); } if (category && category !== "all") { - where.category = category; + conditions.push(`category = $${paramIndex++}`); + values.push(category); } if (search) { - where.OR = [ - { template_name: { contains: search, mode: "insensitive" } }, - { template_name_eng: { contains: search, mode: "insensitive" } }, - { description: { contains: search, mode: "insensitive" } }, - ]; + conditions.push( + `(template_name ILIKE $${paramIndex} OR template_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})` + ); + values.push(`%${search}%`); + paramIndex++; } - // 회사별 필터링 (공개 템플릿 + 해당 회사 템플릿) + // 회사별 필터링 if (company_code) { - where.OR = [{ is_public: "Y" }, { company_code: company_code }]; + conditions.push(`(is_public = 'Y' OR company_code = $${paramIndex++})`); + values.push(company_code); } else if (is_public === "Y") { - where.is_public = "Y"; + conditions.push(`is_public = $${paramIndex++}`); + values.push("Y"); } - const [templates, total] = await Promise.all([ - prisma.template_standards.findMany({ - where, - orderBy: [{ sort_order: "asc" }, { template_name: "asc" }], - skip, - take: limit, - }), - prisma.template_standards.count({ where }), + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const [templates, totalResult] = await Promise.all([ + query( + `SELECT * FROM template_standards + ${whereClause} + ORDER BY sort_order ASC, template_name ASC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + [...values, limit, skip] + ), + queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM template_standards ${whereClause}`, + values + ), ]); + const total = parseInt(totalResult?.count || "0"); + return { templates, total }; } @@ -73,9 +86,10 @@ export class TemplateStandardService { * 템플릿 상세 조회 */ async getTemplate(templateCode: string) { - return await prisma.template_standards.findUnique({ - where: { template_code: templateCode }, - }); + return await queryOne( + `SELECT * FROM template_standards WHERE template_code = $1`, + [templateCode] + ); } /** @@ -83,9 +97,10 @@ export class TemplateStandardService { */ async createTemplate(templateData: any) { // 템플릿 코드 중복 확인 - const existing = await prisma.template_standards.findUnique({ - where: { template_code: templateData.template_code }, - }); + const existing = await queryOne( + `SELECT * FROM template_standards WHERE template_code = $1`, + [templateData.template_code] + ); if (existing) { throw new Error( @@ -93,83 +108,101 @@ export class TemplateStandardService { ); } - return await prisma.template_standards.create({ - data: { - template_code: templateData.template_code, - template_name: templateData.template_name, - template_name_eng: templateData.template_name_eng, - description: templateData.description, - category: templateData.category, - icon_name: templateData.icon_name, - default_size: templateData.default_size, - layout_config: templateData.layout_config, - preview_image: templateData.preview_image, - sort_order: templateData.sort_order || 0, - is_active: templateData.is_active || "Y", - is_public: templateData.is_public || "N", - company_code: templateData.company_code, - created_by: templateData.created_by, - updated_by: templateData.updated_by, - }, - }); + return await queryOne( + `INSERT INTO template_standards + (template_code, template_name, template_name_eng, description, category, + icon_name, default_size, layout_config, preview_image, sort_order, + is_active, is_public, company_code, created_by, updated_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW()) + RETURNING *`, + [ + templateData.template_code, + templateData.template_name, + templateData.template_name_eng, + templateData.description, + templateData.category, + templateData.icon_name, + templateData.default_size, + templateData.layout_config, + templateData.preview_image, + templateData.sort_order || 0, + templateData.is_active || "Y", + templateData.is_public || "N", + templateData.company_code, + templateData.created_by, + templateData.updated_by, + ] + ); } /** * 템플릿 수정 */ async updateTemplate(templateCode: string, templateData: any) { - const updateData: any = {}; + // 동적 UPDATE 쿼리 생성 + const updateFields: string[] = ["updated_at = NOW()"]; + const values: any[] = []; + let paramIndex = 1; - // 수정 가능한 필드들만 업데이트 if (templateData.template_name !== undefined) { - updateData.template_name = templateData.template_name; + updateFields.push(`template_name = $${paramIndex++}`); + values.push(templateData.template_name); } if (templateData.template_name_eng !== undefined) { - updateData.template_name_eng = templateData.template_name_eng; + updateFields.push(`template_name_eng = $${paramIndex++}`); + values.push(templateData.template_name_eng); } if (templateData.description !== undefined) { - updateData.description = templateData.description; + updateFields.push(`description = $${paramIndex++}`); + values.push(templateData.description); } if (templateData.category !== undefined) { - updateData.category = templateData.category; + updateFields.push(`category = $${paramIndex++}`); + values.push(templateData.category); } if (templateData.icon_name !== undefined) { - updateData.icon_name = templateData.icon_name; + updateFields.push(`icon_name = $${paramIndex++}`); + values.push(templateData.icon_name); } if (templateData.default_size !== undefined) { - updateData.default_size = templateData.default_size; + updateFields.push(`default_size = $${paramIndex++}`); + values.push(templateData.default_size); } if (templateData.layout_config !== undefined) { - updateData.layout_config = templateData.layout_config; + updateFields.push(`layout_config = $${paramIndex++}`); + values.push(templateData.layout_config); } if (templateData.preview_image !== undefined) { - updateData.preview_image = templateData.preview_image; + updateFields.push(`preview_image = $${paramIndex++}`); + values.push(templateData.preview_image); } if (templateData.sort_order !== undefined) { - updateData.sort_order = templateData.sort_order; + updateFields.push(`sort_order = $${paramIndex++}`); + values.push(templateData.sort_order); } if (templateData.is_active !== undefined) { - updateData.is_active = templateData.is_active; + updateFields.push(`is_active = $${paramIndex++}`); + values.push(templateData.is_active); } if (templateData.is_public !== undefined) { - updateData.is_public = templateData.is_public; + updateFields.push(`is_public = $${paramIndex++}`); + values.push(templateData.is_public); } if (templateData.updated_by !== undefined) { - updateData.updated_by = templateData.updated_by; + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(templateData.updated_by); } - updateData.updated_date = new Date(); - try { - return await prisma.template_standards.update({ - where: { template_code: templateCode }, - data: updateData, - }); + return await queryOne( + `UPDATE template_standards + SET ${updateFields.join(", ")} + WHERE template_code = $${paramIndex} + RETURNING *`, + [...values, templateCode] + ); } catch (error: any) { - if (error.code === "P2025") { - return null; // 템플릿을 찾을 수 없음 - } - throw error; + return null; // 템플릿을 찾을 수 없음 } } @@ -178,15 +211,12 @@ export class TemplateStandardService { */ async deleteTemplate(templateCode: string) { try { - await prisma.template_standards.delete({ - where: { template_code: templateCode }, - }); + await query(`DELETE FROM template_standards WHERE template_code = $1`, [ + templateCode, + ]); return true; } catch (error: any) { - if (error.code === "P2025") { - return false; // 템플릿을 찾을 수 없음 - } - throw error; + return false; // 템플릿을 찾을 수 없음 } } @@ -197,13 +227,12 @@ export class TemplateStandardService { templates: { template_code: string; sort_order: number }[] ) { const updatePromises = templates.map((template) => - prisma.template_standards.update({ - where: { template_code: template.template_code }, - data: { - sort_order: template.sort_order, - updated_date: new Date(), - }, - }) + query( + `UPDATE template_standards + SET sort_order = $1, updated_at = NOW() + WHERE template_code = $2`, + [template.sort_order, template.template_code] + ) ); await Promise.all(updatePromises); @@ -259,15 +288,14 @@ export class TemplateStandardService { * 템플릿 카테고리 목록 조회 */ async getCategories(companyCode: string) { - const categories = await prisma.template_standards.findMany({ - where: { - OR: [{ is_public: "Y" }, { company_code: companyCode }], - is_active: "Y", - }, - select: { category: true }, - distinct: ["category"], - orderBy: { category: "asc" }, - }); + const categories = await query<{ category: string }>( + `SELECT DISTINCT category + FROM template_standards + WHERE (is_public = $1 OR company_code = $2) + AND is_active = $3 + ORDER BY category ASC`, + ["Y", companyCode, "Y"] + ); return categories.map((item) => item.category).filter(Boolean); }