feat: Phase 3.6 CollectionService 전환 완료 및 Phase 3.7-3.9 계획서 작성

CollectionService 전환 완료:
- 11개 Prisma 호출을 모두 Raw Query로 전환
- 수집 설정 CRUD (getCollectionConfigs, getCollectionConfigById, createCollectionConfig, updateCollectionConfig, deleteCollectionConfig)
- 수집 작업 관리 (executeCollection, getCollectionJobs, getCollectionHistory)
- 동적 WHERE 조건 생성 (ILIKE 검색, OR 조건)
- 동적 UPDATE 쿼리 (변경된 필드만 업데이트)
- JSON 필드 처리 (collection_options)
- LEFT JOIN (작업 목록 조회 시 설정 정보 포함)
- 비동기 작업 처리 (setTimeout 내 query 사용)
- 필드명 수정 (schedule_expression → schedule_cron)
- TypeScript 컴파일 성공
- Prisma import 완전 제거

Phase 3 남은 서비스 계획서 작성:
- PHASE3.7_LAYOUT_SERVICE_MIGRATION.md (10개 호출)
  - 레이아웃 표준 관리 (CRUD, 통계, JSON 필드)
- PHASE3.8_DB_TYPE_CATEGORY_SERVICE_MIGRATION.md (10개 호출)
  - DB 타입 카테고리 관리 (CRUD, 통계, UPSERT)
- PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md (6개 호출)
  - 템플릿 표준 관리 (복합 키, JSON 필드, DISTINCT)

Phase 3 진행률: 87/162 (53.7%)
전체 진행률: 338/444 (76.1%)
This commit is contained in:
kjs 2025-10-01 11:20:21 +09:00
parent 7fb2ce582c
commit 45ec38790b
5 changed files with 1395 additions and 98 deletions

View File

@ -0,0 +1,369 @@
# 🎨 Phase 3.7: LayoutService Raw Query 전환 계획
## 📋 개요
LayoutService는 **10개의 Prisma 호출**이 있으며, 레이아웃 표준 관리를 담당하는 서비스입니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | --------------------------------------------- |
| 파일 위치 | `backend-node/src/services/layoutService.ts` |
| 파일 크기 | 425+ 라인 |
| Prisma 호출 | 10개 |
| **현재 진행률** | **0/10 (0%)** 🔄 **진행 예정** |
| 복잡도 | 중간 (JSON 필드, 검색, 통계) |
| 우선순위 | 🟡 중간 (Phase 3.7) |
| **상태** | ⏳ **대기 중** |
### 🎯 전환 목표
- ⏳ **10개 모든 Prisma 호출을 `db.ts``query()`, `queryOne()` 함수로 교체**
- ⏳ JSON 필드 처리 (layout_config, sections)
- ⏳ 복잡한 검색 조건 처리
- ⏳ GROUP BY 통계 쿼리 전환
- ⏳ 모든 단위 테스트 통과
- ⏳ **Prisma import 완전 제거**
---
## 🔍 Prisma 사용 현황 분석
### 주요 Prisma 호출 (10개)
#### 1. **getLayouts()** - 레이아웃 목록 조회
```typescript
// Line 92, 102
const total = await prisma.layout_standards.count({ where });
const layouts = await prisma.layout_standards.findMany({
where,
skip,
take: size,
orderBy: { updated_date: "desc" },
});
```
#### 2. **getLayoutByCode()** - 레이아웃 단건 조회
```typescript
// Line 152
const layout = await prisma.layout_standards.findFirst({
where: { layout_code: code, company_code: companyCode },
});
```
#### 3. **createLayout()** - 레이아웃 생성
```typescript
// Line 199
const layout = await prisma.layout_standards.create({
data: {
layout_code,
layout_name,
layout_type,
category,
layout_config: safeJSONStringify(layout_config),
sections: safeJSONStringify(sections),
// ... 기타 필드
},
});
```
#### 4. **updateLayout()** - 레이아웃 수정
```typescript
// Line 230, 267
const existing = await prisma.layout_standards.findFirst({
where: { layout_code: code, company_code: companyCode },
});
const updated = await prisma.layout_standards.update({
where: { id: existing.id },
data: { ... },
});
```
#### 5. **deleteLayout()** - 레이아웃 삭제
```typescript
// Line 283, 295
const existing = await prisma.layout_standards.findFirst({
where: { layout_code: code, company_code: companyCode },
});
await prisma.layout_standards.update({
where: { id: existing.id },
data: { is_active: "N", updated_by, updated_date: new Date() },
});
```
#### 6. **getLayoutStatistics()** - 레이아웃 통계
```typescript
// Line 345
const counts = await prisma.layout_standards.groupBy({
by: ["category", "layout_type"],
where: { company_code: companyCode, is_active: "Y" },
_count: { id: true },
});
```
#### 7. **getLayoutCategories()** - 카테고리 목록
```typescript
// Line 373
const existingCodes = await prisma.layout_standards.findMany({
where: { company_code: companyCode },
select: { category: true },
distinct: ["category"],
});
```
---
## 📝 전환 계획
### 1단계: 기본 CRUD 전환 (5개 함수)
**함수 목록**:
- `getLayouts()` - 목록 조회 (count + findMany)
- `getLayoutByCode()` - 단건 조회 (findFirst)
- `createLayout()` - 생성 (create)
- `updateLayout()` - 수정 (findFirst + update)
- `deleteLayout()` - 삭제 (findFirst + update - soft delete)
### 2단계: 통계 및 집계 전환 (2개 함수)
**함수 목록**:
- `getLayoutStatistics()` - 통계 (groupBy)
- `getLayoutCategories()` - 카테고리 목록 (findMany + distinct)
---
## 💻 전환 예시
### 예시 1: 레이아웃 목록 조회 (동적 WHERE + 페이지네이션)
```typescript
// 기존 Prisma
const where: any = { company_code: companyCode };
if (category) where.category = category;
if (layoutType) where.layout_type = layoutType;
if (searchTerm) {
where.OR = [
{ layout_name: { contains: searchTerm, mode: "insensitive" } },
{ layout_code: { contains: searchTerm, mode: "insensitive" } },
];
}
const total = await prisma.layout_standards.count({ where });
const layouts = await prisma.layout_standards.findMany({
where,
skip,
take: size,
orderBy: { updated_date: "desc" },
});
// 전환 후
import { query, queryOne } from "../database/db";
const whereConditions: string[] = ["company_code = $1"];
const values: any[] = [companyCode];
let paramIndex = 2;
if (category) {
whereConditions.push(`category = $${paramIndex++}`);
values.push(category);
}
if (layoutType) {
whereConditions.push(`layout_type = $${paramIndex++}`);
values.push(layoutType);
}
if (searchTerm) {
whereConditions.push(
`(layout_name ILIKE $${paramIndex} OR layout_code ILIKE $${paramIndex})`
);
values.push(`%${searchTerm}%`);
paramIndex++;
}
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
// 총 개수 조회
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM layout_standards ${whereClause}`,
values
);
const total = parseInt(countResult?.count || "0");
// 데이터 조회
const layouts = await query<any>(
`SELECT * FROM layout_standards
${whereClause}
ORDER BY updated_date DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
[...values, size, skip]
);
```
### 예시 2: JSON 필드 처리 (레이아웃 생성)
```typescript
// 기존 Prisma
const layout = await prisma.layout_standards.create({
data: {
layout_code,
layout_name,
layout_config: safeJSONStringify(layout_config), // JSON 필드
sections: safeJSONStringify(sections), // JSON 필드
company_code: companyCode,
created_by: createdBy,
},
});
// 전환 후
const layout = await queryOne<any>(
`INSERT INTO layout_standards
(layout_code, layout_name, layout_type, category, layout_config, sections,
company_code, is_active, created_by, updated_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
RETURNING *`,
[
layout_code,
layout_name,
layout_type,
category,
safeJSONStringify(layout_config), // JSON 필드는 문자열로 변환
safeJSONStringify(sections),
companyCode,
"Y",
createdBy,
updatedBy,
]
);
```
### 예시 3: GROUP BY 통계 쿼리
```typescript
// 기존 Prisma
const counts = await prisma.layout_standards.groupBy({
by: ["category", "layout_type"],
where: { company_code: companyCode, is_active: "Y" },
_count: { id: true },
});
// 전환 후
const counts = await query<{
category: string;
layout_type: string;
count: string;
}>(
`SELECT category, layout_type, COUNT(*) as count
FROM layout_standards
WHERE company_code = $1 AND is_active = $2
GROUP BY category, layout_type`,
[companyCode, "Y"]
);
// 결과 포맷팅
const formattedCounts = counts.map((row) => ({
category: row.category,
layout_type: row.layout_type,
_count: { id: parseInt(row.count) },
}));
```
### 예시 4: DISTINCT 쿼리 (카테고리 목록)
```typescript
// 기존 Prisma
const existingCodes = await prisma.layout_standards.findMany({
where: { company_code: companyCode },
select: { category: true },
distinct: ["category"],
});
// 전환 후
const existingCodes = await query<{ category: string }>(
`SELECT DISTINCT category
FROM layout_standards
WHERE company_code = $1
ORDER BY category`,
[companyCode]
);
```
---
## ✅ 완료 기준
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [ ] **동적 WHERE 조건 생성 (ILIKE, OR)**
- [ ] **JSON 필드 처리 (layout_config, sections)**
- [ ] **GROUP BY 집계 쿼리 전환**
- [ ] **DISTINCT 쿼리 전환**
- [ ] **모든 TypeScript 컴파일 오류 해결**
- [ ] **`import prisma` 완전 제거**
- [ ] **모든 단위 테스트 통과 (10개)**
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
---
## 🔧 주요 기술적 과제
### 1. JSON 필드 처리
- `layout_config`, `sections` 필드는 JSON 타입
- INSERT/UPDATE 시 `JSON.stringify()` 또는 `safeJSONStringify()` 사용
- SELECT 시 PostgreSQL이 자동으로 JSON 객체로 반환
### 2. 동적 검색 조건
- category, layoutType, searchTerm에 따른 동적 WHERE 절
- OR 조건 처리 (layout_name OR layout_code)
### 3. Soft Delete
- `deleteLayout()`는 실제 삭제가 아닌 `is_active = 'N'` 업데이트
- UPDATE 쿼리 사용
### 4. 통계 쿼리
- `groupBy``GROUP BY` + `COUNT(*)` 전환
- 결과 포맷팅 필요 (`_count.id` 형태로 변환)
---
## 📋 체크리스트
### 코드 전환
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
- [ ] getLayouts() - count + findMany → query + queryOne
- [ ] getLayoutByCode() - findFirst → queryOne
- [ ] createLayout() - create → queryOne (INSERT)
- [ ] updateLayout() - findFirst + update → queryOne (동적 UPDATE)
- [ ] deleteLayout() - findFirst + update → queryOne (UPDATE is_active)
- [ ] getLayoutStatistics() - groupBy → query (GROUP BY)
- [ ] getLayoutCategories() - findMany + distinct → query (DISTINCT)
- [ ] JSON 필드 처리 확인 (safeJSONStringify)
- [ ] Prisma import 완전 제거
### 테스트
- [ ] 단위 테스트 작성 (10개)
- [ ] 통합 테스트 작성 (3개)
- [ ] TypeScript 컴파일 성공
- [ ] 성능 벤치마크 테스트
---
## 💡 특이사항
### JSON 필드 헬퍼 함수
이 서비스는 `safeJSONParse()`, `safeJSONStringify()` 헬퍼 함수를 사용하여 JSON 필드를 안전하게 처리합니다. Raw Query 전환 후에도 이 함수들을 계속 사용해야 합니다.
### Soft Delete 패턴
레이아웃 삭제는 실제 DELETE가 아닌 `is_active = 'N'` 업데이트로 처리되므로, UPDATE 쿼리를 사용해야 합니다.
### 통계 쿼리 결과 포맷
Prisma의 `groupBy``_count: { id: number }` 형태로 반환하지만, Raw Query는 `count: string`으로 반환하므로 포맷팅이 필요합니다.
---
**작성일**: 2025-10-01
**예상 소요 시간**: 1시간
**담당자**: 백엔드 개발팀
**우선순위**: 🟡 중간 (Phase 3.7)
**상태**: ⏳ **대기 중**
**특이사항**: JSON 필드 처리, GROUP BY, DISTINCT 쿼리 포함

View File

@ -0,0 +1,484 @@
# 🗂️ Phase 3.8: DbTypeCategoryService Raw Query 전환 계획
## 📋 개요
DbTypeCategoryService는 **10개의 Prisma 호출**이 있으며, 데이터베이스 타입 카테고리 관리를 담당하는 서비스입니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | ------------------------------------------------------ |
| 파일 위치 | `backend-node/src/services/dbTypeCategoryService.ts` |
| 파일 크기 | 320+ 라인 |
| Prisma 호출 | 10개 |
| **현재 진행률** | **0/10 (0%)** 🔄 **진행 예정** |
| 복잡도 | 중간 (CRUD, 통계, UPSERT) |
| 우선순위 | 🟡 중간 (Phase 3.8) |
| **상태** | ⏳ **대기 중** |
### 🎯 전환 목표
- ⏳ **10개 모든 Prisma 호출을 `db.ts``query()`, `queryOne()` 함수로 교체**
- ⏳ ApiResponse 래퍼 패턴 유지
- ⏳ GROUP BY 통계 쿼리 전환
- ⏳ UPSERT 로직 전환 (ON CONFLICT)
- ⏳ 모든 단위 테스트 통과
- ⏳ **Prisma import 완전 제거**
---
## 🔍 Prisma 사용 현황 분석
### 주요 Prisma 호출 (10개)
#### 1. **getAllCategories()** - 카테고리 목록 조회
```typescript
// Line 45
const categories = await prisma.db_type_categories.findMany({
where: { is_active: true },
orderBy: [
{ sort_order: 'asc' },
{ display_name: 'asc' }
]
});
```
#### 2. **getCategoryByTypeCode()** - 카테고리 단건 조회
```typescript
// Line 73
const category = await prisma.db_type_categories.findUnique({
where: { type_code: typeCode }
});
```
#### 3. **createCategory()** - 카테고리 생성
```typescript
// Line 105, 116
const existing = await prisma.db_type_categories.findUnique({
where: { type_code: data.type_code }
});
const category = await prisma.db_type_categories.create({
data: {
type_code: data.type_code,
display_name: data.display_name,
icon: data.icon,
color: data.color,
sort_order: data.sort_order ?? 0,
is_active: true,
}
});
```
#### 4. **updateCategory()** - 카테고리 수정
```typescript
// Line 146
const category = await prisma.db_type_categories.update({
where: { type_code: typeCode },
data: updateData
});
```
#### 5. **deleteCategory()** - 카테고리 삭제 (연결 확인)
```typescript
// Line 179, 193
const connectionsCount = await prisma.external_db_connections.count({
where: { db_type: typeCode }
});
await prisma.db_type_categories.update({
where: { type_code: typeCode },
data: { is_active: false }
});
```
#### 6. **getCategoryStatistics()** - 카테고리별 통계
```typescript
// Line 220, 229
const stats = await prisma.external_db_connections.groupBy({
by: ['db_type'],
_count: { id: true }
});
const categories = await prisma.db_type_categories.findMany({
where: { is_active: true }
});
```
#### 7. **syncPredefinedCategories()** - 사전 정의 카테고리 동기화
```typescript
// Line 300
await prisma.db_type_categories.upsert({
where: { type_code: category.type_code },
update: {
display_name: category.display_name,
icon: category.icon,
color: category.color,
sort_order: category.sort_order,
},
create: {
type_code: category.type_code,
display_name: category.display_name,
icon: category.icon,
color: category.color,
sort_order: category.sort_order,
is_active: true,
},
});
```
---
## 📝 전환 계획
### 1단계: 기본 CRUD 전환 (5개 함수)
**함수 목록**:
- `getAllCategories()` - 목록 조회 (findMany)
- `getCategoryByTypeCode()` - 단건 조회 (findUnique)
- `createCategory()` - 생성 (findUnique + create)
- `updateCategory()` - 수정 (update)
- `deleteCategory()` - 삭제 (count + update - soft delete)
### 2단계: 통계 및 UPSERT 전환 (2개 함수)
**함수 목록**:
- `getCategoryStatistics()` - 통계 (groupBy + findMany)
- `syncPredefinedCategories()` - 동기화 (upsert)
---
## 💻 전환 예시
### 예시 1: 카테고리 목록 조회 (정렬)
```typescript
// 기존 Prisma
const categories = await prisma.db_type_categories.findMany({
where: { is_active: true },
orderBy: [
{ sort_order: 'asc' },
{ display_name: 'asc' }
]
});
// 전환 후
import { query } from "../database/db";
const categories = await query<DbTypeCategory>(
`SELECT * FROM db_type_categories
WHERE is_active = $1
ORDER BY sort_order ASC, display_name ASC`,
[true]
);
```
### 예시 2: 카테고리 생성 (중복 확인)
```typescript
// 기존 Prisma
const existing = await prisma.db_type_categories.findUnique({
where: { type_code: data.type_code }
});
if (existing) {
return {
success: false,
message: "이미 존재하는 타입 코드입니다."
};
}
const category = await prisma.db_type_categories.create({
data: {
type_code: data.type_code,
display_name: data.display_name,
icon: data.icon,
color: data.color,
sort_order: data.sort_order ?? 0,
is_active: true,
}
});
// 전환 후
import { query, queryOne } from "../database/db";
const existing = await queryOne<DbTypeCategory>(
`SELECT * FROM db_type_categories WHERE type_code = $1`,
[data.type_code]
);
if (existing) {
return {
success: false,
message: "이미 존재하는 타입 코드입니다."
};
}
const category = await queryOne<DbTypeCategory>(
`INSERT INTO db_type_categories
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
RETURNING *`,
[
data.type_code,
data.display_name,
data.icon || null,
data.color || null,
data.sort_order ?? 0,
true,
]
);
```
### 예시 3: 동적 UPDATE (변경된 필드만)
```typescript
// 기존 Prisma
const updateData: any = {};
if (data.display_name !== undefined) updateData.display_name = data.display_name;
if (data.icon !== undefined) updateData.icon = data.icon;
if (data.color !== undefined) updateData.color = data.color;
if (data.sort_order !== undefined) updateData.sort_order = data.sort_order;
if (data.is_active !== undefined) updateData.is_active = data.is_active;
const category = await prisma.db_type_categories.update({
where: { type_code: typeCode },
data: updateData
});
// 전환 후
const updateFields: string[] = ["updated_at = NOW()"];
const values: any[] = [];
let paramIndex = 1;
if (data.display_name !== undefined) {
updateFields.push(`display_name = $${paramIndex++}`);
values.push(data.display_name);
}
if (data.icon !== undefined) {
updateFields.push(`icon = $${paramIndex++}`);
values.push(data.icon);
}
if (data.color !== undefined) {
updateFields.push(`color = $${paramIndex++}`);
values.push(data.color);
}
if (data.sort_order !== undefined) {
updateFields.push(`sort_order = $${paramIndex++}`);
values.push(data.sort_order);
}
if (data.is_active !== undefined) {
updateFields.push(`is_active = $${paramIndex++}`);
values.push(data.is_active);
}
const category = await queryOne<DbTypeCategory>(
`UPDATE db_type_categories
SET ${updateFields.join(", ")}
WHERE type_code = $${paramIndex}
RETURNING *`,
[...values, typeCode]
);
```
### 예시 4: 삭제 전 연결 확인
```typescript
// 기존 Prisma
const connectionsCount = await prisma.external_db_connections.count({
where: { db_type: typeCode }
});
if (connectionsCount > 0) {
return {
success: false,
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
};
}
await prisma.db_type_categories.update({
where: { type_code: typeCode },
data: { is_active: false }
});
// 전환 후
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM external_db_connections WHERE db_type = $1`,
[typeCode]
);
const connectionsCount = parseInt(countResult?.count || "0");
if (connectionsCount > 0) {
return {
success: false,
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
};
}
await query(
`UPDATE db_type_categories SET is_active = $1, updated_at = NOW() WHERE type_code = $2`,
[false, typeCode]
);
```
### 예시 5: GROUP BY 통계 + JOIN
```typescript
// 기존 Prisma
const stats = await prisma.external_db_connections.groupBy({
by: ['db_type'],
_count: { id: true }
});
const categories = await prisma.db_type_categories.findMany({
where: { is_active: true }
});
// 전환 후
const stats = await query<{
type_code: string;
display_name: string;
connection_count: string;
}>(
`SELECT
c.type_code,
c.display_name,
COUNT(e.id) as connection_count
FROM db_type_categories c
LEFT JOIN external_db_connections e ON c.type_code = e.db_type
WHERE c.is_active = $1
GROUP BY c.type_code, c.display_name
ORDER BY c.sort_order ASC`,
[true]
);
// 결과 포맷팅
const result = stats.map(row => ({
type_code: row.type_code,
display_name: row.display_name,
connection_count: parseInt(row.connection_count),
}));
```
### 예시 6: UPSERT (ON CONFLICT)
```typescript
// 기존 Prisma
await prisma.db_type_categories.upsert({
where: { type_code: category.type_code },
update: {
display_name: category.display_name,
icon: category.icon,
color: category.color,
sort_order: category.sort_order,
},
create: {
type_code: category.type_code,
display_name: category.display_name,
icon: category.icon,
color: category.color,
sort_order: category.sort_order,
is_active: true,
},
});
// 전환 후
await query(
`INSERT INTO db_type_categories
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (type_code)
DO UPDATE SET
display_name = EXCLUDED.display_name,
icon = EXCLUDED.icon,
color = EXCLUDED.color,
sort_order = EXCLUDED.sort_order,
updated_at = NOW()`,
[
category.type_code,
category.display_name,
category.icon || null,
category.color || null,
category.sort_order || 0,
true,
]
);
```
---
## ✅ 완료 기준
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [ ] **동적 UPDATE 쿼리 생성**
- [ ] **GROUP BY + LEFT JOIN 통계 쿼리**
- [ ] **ON CONFLICT를 사용한 UPSERT**
- [ ] **ApiResponse 래퍼 패턴 유지**
- [ ] **모든 TypeScript 컴파일 오류 해결**
- [ ] **`import prisma` 완전 제거**
- [ ] **모든 단위 테스트 통과 (10개)**
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
---
## 🔧 주요 기술적 과제
### 1. ApiResponse 래퍼 패턴
모든 함수가 `ApiResponse<T>` 타입을 반환하므로, 에러 처리를 try-catch로 감싸고 일관된 응답 형식을 유지해야 합니다.
### 2. Soft Delete 패턴
`deleteCategory()`는 실제 DELETE가 아닌 `is_active = false` 업데이트로 처리됩니다.
### 3. 연결 확인
카테고리 삭제 전 `external_db_connections` 테이블에서 사용 중인지 확인해야 합니다.
### 4. UPSERT 로직
PostgreSQL의 `ON CONFLICT` 절을 사용하여 Prisma의 `upsert` 기능을 구현합니다.
### 5. 통계 쿼리 최적화
`groupBy` + 별도 조회 대신, 하나의 `LEFT JOIN` + `GROUP BY` 쿼리로 최적화 가능합니다.
---
## 📋 체크리스트
### 코드 전환
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
- [ ] getAllCategories() - findMany → query
- [ ] getCategoryByTypeCode() - findUnique → queryOne
- [ ] createCategory() - findUnique + create → queryOne (중복 확인 + INSERT)
- [ ] updateCategory() - update → queryOne (동적 UPDATE)
- [ ] deleteCategory() - count + update → queryOne + query
- [ ] getCategoryStatistics() - groupBy + findMany → query (LEFT JOIN)
- [ ] syncPredefinedCategories() - upsert → query (ON CONFLICT)
- [ ] ApiResponse 래퍼 유지
- [ ] Prisma import 완전 제거
### 테스트
- [ ] 단위 테스트 작성 (10개)
- [ ] 통합 테스트 작성 (3개)
- [ ] TypeScript 컴파일 성공
- [ ] 성능 벤치마크 테스트
---
## 💡 특이사항
### ApiResponse 패턴
이 서비스는 모든 메서드가 `ApiResponse<T>` 형식으로 응답을 반환합니다. Raw Query 전환 후에도 이 패턴을 유지해야 합니다.
### 사전 정의 카테고리
`syncPredefinedCategories()` 메서드는 시스템 초기화 시 사전 정의된 DB 타입 카테고리를 동기화합니다. UPSERT 로직이 필수입니다.
### 외래 키 확인
카테고리 삭제 시 `external_db_connections` 테이블에서 사용 중인지 확인하여 데이터 무결성을 보장합니다.
---
**작성일**: 2025-10-01
**예상 소요 시간**: 1시간
**담당자**: 백엔드 개발팀
**우선순위**: 🟡 중간 (Phase 3.8)
**상태**: ⏳ **대기 중**
**특이사항**: ApiResponse 래퍼, UPSERT, GROUP BY + LEFT JOIN 포함

View File

@ -0,0 +1,391 @@
# 📋 Phase 3.9: TemplateStandardService Raw Query 전환 계획
## 📋 개요
TemplateStandardService는 **6개의 Prisma 호출**이 있으며, 템플릿 표준 관리를 담당하는 서비스입니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | ----------------------------------------------------------- |
| 파일 위치 | `backend-node/src/services/templateStandardService.ts` |
| 파일 크기 | 395 라인 |
| Prisma 호출 | 6개 |
| **현재 진행률** | **0/6 (0%)** 🔄 **진행 예정** |
| 복잡도 | 낮음 (기본 CRUD + DISTINCT) |
| 우선순위 | 🟢 낮음 (Phase 3.9) |
| **상태** | ⏳ **대기 중** |
### 🎯 전환 목표
- ⏳ **6개 모든 Prisma 호출을 `db.ts``query()`, `queryOne()` 함수로 교체**
- ⏳ 템플릿 CRUD 기능 정상 동작
- ⏳ DISTINCT 쿼리 전환
- ⏳ 모든 단위 테스트 통과
- ⏳ **Prisma import 완전 제거**
---
## 🔍 Prisma 사용 현황 분석
### 주요 Prisma 호출 (6개)
#### 1. **getTemplateByCode()** - 템플릿 단건 조회
```typescript
// Line 76
return await prisma.template_standards.findUnique({
where: {
template_code: templateCode,
company_code: companyCode,
},
});
```
#### 2. **createTemplate()** - 템플릿 생성
```typescript
// Line 86
const existing = await prisma.template_standards.findUnique({
where: {
template_code: data.template_code,
company_code: data.company_code,
},
});
// Line 96
return await prisma.template_standards.create({
data: {
...data,
created_date: new Date(),
updated_date: new Date(),
},
});
```
#### 3. **updateTemplate()** - 템플릿 수정
```typescript
// Line 164
return await prisma.template_standards.update({
where: {
template_code_company_code: {
template_code: templateCode,
company_code: companyCode,
},
},
data: {
...data,
updated_date: new Date(),
},
});
```
#### 4. **deleteTemplate()** - 템플릿 삭제
```typescript
// Line 181
await prisma.template_standards.delete({
where: {
template_code_company_code: {
template_code: templateCode,
company_code: companyCode,
},
},
});
```
#### 5. **getTemplateCategories()** - 카테고리 목록 (DISTINCT)
```typescript
// Line 262
const categories = await prisma.template_standards.findMany({
where: {
company_code: companyCode,
},
select: {
category: true,
},
distinct: ["category"],
});
```
---
## 📝 전환 계획
### 1단계: 기본 CRUD 전환 (4개 함수)
**함수 목록**:
- `getTemplateByCode()` - 단건 조회 (findUnique)
- `createTemplate()` - 생성 (findUnique + create)
- `updateTemplate()` - 수정 (update)
- `deleteTemplate()` - 삭제 (delete)
### 2단계: 추가 기능 전환 (1개 함수)
**함수 목록**:
- `getTemplateCategories()` - 카테고리 목록 (findMany + distinct)
---
## 💻 전환 예시
### 예시 1: 복합 키 조회
```typescript
// 기존 Prisma
return await prisma.template_standards.findUnique({
where: {
template_code: templateCode,
company_code: companyCode,
},
});
// 전환 후
import { queryOne } from "../database/db";
return await queryOne<any>(
`SELECT * FROM template_standards
WHERE template_code = $1 AND company_code = $2`,
[templateCode, companyCode]
);
```
### 예시 2: 중복 확인 후 생성
```typescript
// 기존 Prisma
const existing = await prisma.template_standards.findUnique({
where: {
template_code: data.template_code,
company_code: data.company_code,
},
});
if (existing) {
throw new Error("이미 존재하는 템플릿 코드입니다.");
}
return await prisma.template_standards.create({
data: {
...data,
created_date: new Date(),
updated_date: new Date(),
},
});
// 전환 후
const existing = await queryOne<any>(
`SELECT * FROM template_standards
WHERE template_code = $1 AND company_code = $2`,
[data.template_code, data.company_code]
);
if (existing) {
throw new Error("이미 존재하는 템플릿 코드입니다.");
}
return await queryOne<any>(
`INSERT INTO template_standards
(template_code, template_name, category, template_type, layout_config,
description, is_active, company_code, created_by, updated_by,
created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
RETURNING *`,
[
data.template_code,
data.template_name,
data.category,
data.template_type,
JSON.stringify(data.layout_config),
data.description,
data.is_active,
data.company_code,
data.created_by,
data.updated_by,
]
);
```
### 예시 3: 복합 키 UPDATE
```typescript
// 기존 Prisma
return await prisma.template_standards.update({
where: {
template_code_company_code: {
template_code: templateCode,
company_code: companyCode,
},
},
data: {
...data,
updated_date: new Date(),
},
});
// 전환 후
// 동적 UPDATE 쿼리 생성
const updateFields: string[] = ["updated_date = NOW()"];
const values: any[] = [];
let paramIndex = 1;
if (data.template_name !== undefined) {
updateFields.push(`template_name = $${paramIndex++}`);
values.push(data.template_name);
}
if (data.category !== undefined) {
updateFields.push(`category = $${paramIndex++}`);
values.push(data.category);
}
if (data.template_type !== undefined) {
updateFields.push(`template_type = $${paramIndex++}`);
values.push(data.template_type);
}
if (data.layout_config !== undefined) {
updateFields.push(`layout_config = $${paramIndex++}`);
values.push(JSON.stringify(data.layout_config));
}
if (data.description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(data.description);
}
if (data.is_active !== undefined) {
updateFields.push(`is_active = $${paramIndex++}`);
values.push(data.is_active);
}
if (data.updated_by !== undefined) {
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(data.updated_by);
}
return await queryOne<any>(
`UPDATE template_standards
SET ${updateFields.join(", ")}
WHERE template_code = $${paramIndex++} AND company_code = $${paramIndex}
RETURNING *`,
[...values, templateCode, companyCode]
);
```
### 예시 4: 복합 키 DELETE
```typescript
// 기존 Prisma
await prisma.template_standards.delete({
where: {
template_code_company_code: {
template_code: templateCode,
company_code: companyCode,
},
},
});
// 전환 후
import { query } from "../database/db";
await query(
`DELETE FROM template_standards
WHERE template_code = $1 AND company_code = $2`,
[templateCode, companyCode]
);
```
### 예시 5: DISTINCT 쿼리
```typescript
// 기존 Prisma
const categories = await prisma.template_standards.findMany({
where: {
company_code: companyCode,
},
select: {
category: true,
},
distinct: ["category"],
});
return categories
.map((c) => c.category)
.filter((c): c is string => c !== null && c !== undefined)
.sort();
// 전환 후
const categories = await query<{ category: string }>(
`SELECT DISTINCT category
FROM template_standards
WHERE company_code = $1 AND category IS NOT NULL
ORDER BY category ASC`,
[companyCode]
);
return categories.map((c) => c.category);
```
---
## ✅ 완료 기준
- [ ] **6개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [ ] **복합 기본 키 처리 (template_code + company_code)**
- [ ] **동적 UPDATE 쿼리 생성**
- [ ] **DISTINCT 쿼리 전환**
- [ ] **JSON 필드 처리 (layout_config)**
- [ ] **모든 TypeScript 컴파일 오류 해결**
- [ ] **`import prisma` 완전 제거**
- [ ] **모든 단위 테스트 통과 (6개)**
- [ ] **통합 테스트 작성 완료 (2개 시나리오)**
---
## 🔧 주요 기술적 과제
### 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`로 제외합니다.
---
## 📋 체크리스트
### 코드 전환
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
- [ ] getTemplateByCode() - findUnique → queryOne (복합 키)
- [ ] createTemplate() - findUnique + create → queryOne (중복 확인 + INSERT)
- [ ] updateTemplate() - update → queryOne (동적 UPDATE, 복합 키)
- [ ] deleteTemplate() - delete → query (복합 키)
- [ ] getTemplateCategories() - findMany + distinct → query (DISTINCT)
- [ ] JSON 필드 처리 (layout_config)
- [ ] Prisma import 완전 제거
### 테스트
- [ ] 단위 테스트 작성 (6개)
- [ ] 통합 테스트 작성 (2개)
- [ ] TypeScript 컴파일 성공
- [ ] 성능 벤치마크 테스트
---
## 💡 특이사항
### 복합 기본 키 패턴
이 서비스는 `(template_code, company_code)` 복합 기본 키를 사용하므로, 모든 조회/수정/삭제 작업에서 두 컬럼을 모두 WHERE 조건에 포함해야 합니다.
### JSON 레이아웃 설정
`layout_config` 필드는 템플릿의 레이아웃 설정을 JSON 형태로 저장합니다. Raw Query 전환 시 `JSON.stringify()`를 사용하여 문자열로 변환해야 합니다.
### 카테고리 관리
템플릿은 카테고리별로 분류되며, `getTemplateCategories()` 메서드로 고유한 카테고리 목록을 조회할 수 있습니다.
---
**작성일**: 2025-10-01
**예상 소요 시간**: 45분
**담당자**: 백엔드 개발팀
**우선순위**: 🟢 낮음 (Phase 3.9)
**상태**: ⏳ **대기 중**
**특이사항**: 복합 기본 키, JSON 필드, DISTINCT 쿼리 포함

View File

@ -127,7 +127,7 @@ backend-node/ (루트)
- `componentStandardService.ts` (0개) - ✅ **전환 완료** (Phase 3.3)
- `commonCodeService.ts` (0개) - ✅ **전환 완료** (Phase 3.4)
- `dataflowDiagramService.ts` (0개) - ✅ **전환 완료** (Phase 3.5)
- `collectionService.ts` (11개) - 컬렉션 관리
- `collectionService.ts` (0개) - ✅ **전환 완료** (Phase 3.6)
- `layoutService.ts` (10개) - 레이아웃 관리
- `dbTypeCategoryService.ts` (10개) - DB 타입 분류 ⭐ 신규 발견
- `templateStandardService.ts` (9개) - 템플릿 표준
@ -1174,17 +1174,27 @@ describe("Performance Benchmarks", () => {
- [x] 복잡한 복제 로직 (이름 번호 증가)
- [x] TypeScript 컴파일 성공
- [x] Prisma import 완전 제거
- [x] **CollectionService 전환 (11개)****완료** (Phase 3.6)
- [x] 11개 Prisma 호출 전환 완료 (수집 설정 CRUD, 작업 관리)
- [x] 동적 WHERE 조건 생성 (ILIKE 검색, OR 조건)
- [x] 동적 UPDATE 쿼리 (변경된 필드만 업데이트)
- [x] JSON 필드 처리 (collection_options)
- [x] LEFT JOIN (작업 목록 조회 시 설정 정보 포함)
- [x] 비동기 작업 처리 (setTimeout 내 query 사용)
- [x] TypeScript 컴파일 성공
- [x] Prisma import 완전 제거
- [ ] 배치 관련 서비스 전환 (26개) ⭐ 대규모 신규 발견
- [ ] BatchExternalDbService (8개)
- [ ] BatchExecutionLogService (7개), BatchManagementService (5개)
- [ ] BatchSchedulerService (4개)
- [ ] 표준 관리 서비스 전환 (10개)
- [ ] LayoutService (10개)
- [ ] 표준 관리 서비스 전환 (16개)
- [ ] LayoutService (10개) - [계획서](PHASE3.7_LAYOUT_SERVICE_MIGRATION.md)
- [ ] TemplateStandardService (6개) - [계획서](PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md)
- [ ] 데이터플로우 관련 서비스 (6개) ⭐ 신규 발견
- [ ] DataflowControlService (6개)
- [ ] 기타 중요 서비스 (38개) ⭐ 신규 발견
- [ ] CollectionService (11개), DbTypeCategoryService (10개)
- [ ] TemplateStandardService (9개), DDLAuditLogger (8개)
- [ ] 기타 중요 서비스 (18개) ⭐ 신규 발견
- [ ] DbTypeCategoryService (10개) - [계획서](PHASE3.8_DB_TYPE_CATEGORY_SERVICE_MIGRATION.md)
- [ ] DDLAuditLogger (8개)
- [ ] 기능별 테스트 완료
### **Phase 4: 확장 기능 (2.5주) - 129개 호출 ⭐ 대폭 확장**

View File

@ -1,7 +1,7 @@
// 수집 관리 서비스
// 작성일: 2024-12-23
import { PrismaClient } from "@prisma/client";
import { query, queryOne, transaction } from "../database/db";
import {
DataCollectionConfig,
CollectionFilter,
@ -9,8 +9,6 @@ import {
CollectionHistory,
} from "../types/collectionManagement";
const prisma = new PrismaClient();
export class CollectionService {
/**
*
@ -18,40 +16,44 @@ export class CollectionService {
static async getCollectionConfigs(
filter: CollectionFilter
): Promise<DataCollectionConfig[]> {
const whereCondition: any = {
company_code: filter.company_code || "*",
};
const whereConditions: string[] = ["company_code = $1"];
const values: any[] = [filter.company_code || "*"];
let paramIndex = 2;
if (filter.config_name) {
whereCondition.config_name = {
contains: filter.config_name,
mode: "insensitive",
};
whereConditions.push(`config_name ILIKE $${paramIndex++}`);
values.push(`%${filter.config_name}%`);
}
if (filter.source_connection_id) {
whereCondition.source_connection_id = filter.source_connection_id;
whereConditions.push(`source_connection_id = $${paramIndex++}`);
values.push(filter.source_connection_id);
}
if (filter.collection_type) {
whereCondition.collection_type = filter.collection_type;
whereConditions.push(`collection_type = $${paramIndex++}`);
values.push(filter.collection_type);
}
if (filter.is_active) {
whereCondition.is_active = filter.is_active === "Y";
whereConditions.push(`is_active = $${paramIndex++}`);
values.push(filter.is_active === "Y");
}
if (filter.search) {
whereCondition.OR = [
{ config_name: { contains: filter.search, mode: "insensitive" } },
{ description: { contains: filter.search, mode: "insensitive" } },
];
whereConditions.push(
`(config_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
);
values.push(`%${filter.search}%`);
paramIndex++;
}
const configs = await prisma.data_collection_configs.findMany({
where: whereCondition,
orderBy: { created_date: "desc" },
});
const configs = await query<any>(
`SELECT * FROM data_collection_configs
WHERE ${whereConditions.join(" AND ")}
ORDER BY created_date DESC`,
values
);
return configs.map((config: any) => ({
...config,
@ -65,9 +67,10 @@ export class CollectionService {
static async getCollectionConfigById(
id: number
): Promise<DataCollectionConfig | null> {
const config = await prisma.data_collection_configs.findUnique({
where: { id },
});
const config = await queryOne<any>(
`SELECT * FROM data_collection_configs WHERE id = $1`,
[id]
);
if (!config) return null;
@ -84,15 +87,26 @@ export class CollectionService {
data: DataCollectionConfig
): Promise<DataCollectionConfig> {
const { id, collection_options, ...createData } = data;
const config = await prisma.data_collection_configs.create({
data: {
...createData,
is_active: data.is_active,
collection_options: collection_options || undefined,
created_date: new Date(),
updated_date: new Date(),
},
});
const config = await queryOne<any>(
`INSERT INTO data_collection_configs
(config_name, company_code, source_connection_id, collection_type,
collection_options, schedule_cron, is_active, description,
created_by, updated_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
RETURNING *`,
[
createData.config_name,
createData.company_code,
createData.source_connection_id,
createData.collection_type,
collection_options ? JSON.stringify(collection_options) : null,
createData.schedule_cron,
data.is_active,
createData.description,
createData.created_by,
createData.updated_by,
]
);
return {
...config,
@ -107,19 +121,52 @@ export class CollectionService {
id: number,
data: Partial<DataCollectionConfig>
): Promise<DataCollectionConfig> {
const updateData: any = {
...data,
updated_date: new Date(),
};
const updateFields: string[] = ["updated_date = NOW()"];
const values: any[] = [];
let paramIndex = 1;
if (data.config_name !== undefined) {
updateFields.push(`config_name = $${paramIndex++}`);
values.push(data.config_name);
}
if (data.source_connection_id !== undefined) {
updateFields.push(`source_connection_id = $${paramIndex++}`);
values.push(data.source_connection_id);
}
if (data.collection_type !== undefined) {
updateFields.push(`collection_type = $${paramIndex++}`);
values.push(data.collection_type);
}
if (data.collection_options !== undefined) {
updateFields.push(`collection_options = $${paramIndex++}`);
values.push(
data.collection_options ? JSON.stringify(data.collection_options) : null
);
}
if (data.schedule_cron !== undefined) {
updateFields.push(`schedule_cron = $${paramIndex++}`);
values.push(data.schedule_cron);
}
if (data.is_active !== undefined) {
updateData.is_active = data.is_active;
updateFields.push(`is_active = $${paramIndex++}`);
values.push(data.is_active);
}
if (data.description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(data.description);
}
if (data.updated_by !== undefined) {
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(data.updated_by);
}
const config = await prisma.data_collection_configs.update({
where: { id },
data: updateData,
});
const config = await queryOne<any>(
`UPDATE data_collection_configs
SET ${updateFields.join(", ")}
WHERE id = $${paramIndex}
RETURNING *`,
[...values, id]
);
return {
...config,
@ -131,18 +178,17 @@ export class CollectionService {
*
*/
static async deleteCollectionConfig(id: number): Promise<void> {
await prisma.data_collection_configs.delete({
where: { id },
});
await query(`DELETE FROM data_collection_configs WHERE id = $1`, [id]);
}
/**
*
*/
static async executeCollection(configId: number): Promise<CollectionJob> {
const config = await prisma.data_collection_configs.findUnique({
where: { id: configId },
});
const config = await queryOne<any>(
`SELECT * FROM data_collection_configs WHERE id = $1`,
[configId]
);
if (!config) {
throw new Error("수집 설정을 찾을 수 없습니다.");
@ -153,14 +199,13 @@ export class CollectionService {
}
// 수집 작업 기록 생성
const job = await prisma.data_collection_jobs.create({
data: {
config_id: configId,
job_status: "running",
started_at: new Date(),
created_date: new Date(),
},
});
const job = await queryOne<any>(
`INSERT INTO data_collection_jobs
(config_id, job_status, started_at, created_date)
VALUES ($1, $2, NOW(), NOW())
RETURNING *`,
[configId, "running"]
);
// 실제 수집 작업 실행 로직은 여기에 구현
// 현재는 시뮬레이션으로 처리
@ -171,24 +216,23 @@ export class CollectionService {
const recordsCollected = Math.floor(Math.random() * 1000) + 100;
await prisma.data_collection_jobs.update({
where: { id: job.id },
data: {
job_status: "completed",
completed_at: new Date(),
records_processed: recordsCollected,
},
});
await query(
`UPDATE data_collection_jobs
SET job_status = $1, completed_at = NOW(), records_processed = $2
WHERE id = $3`,
["completed", recordsCollected, job.id]
);
} catch (error) {
await prisma.data_collection_jobs.update({
where: { id: job.id },
data: {
job_status: "failed",
completed_at: new Date(),
error_message:
error instanceof Error ? error.message : "알 수 없는 오류",
},
});
await query(
`UPDATE data_collection_jobs
SET job_status = $1, completed_at = NOW(), error_message = $2
WHERE id = $3`,
[
"failed",
error instanceof Error ? error.message : "알 수 없는 오류",
job.id,
]
);
}
}, 0);
@ -199,24 +243,21 @@ export class CollectionService {
*
*/
static async getCollectionJobs(configId?: number): Promise<CollectionJob[]> {
const whereCondition: any = {};
let sql = `
SELECT j.*, c.config_name, c.collection_type
FROM data_collection_jobs j
LEFT JOIN data_collection_configs c ON j.config_id = c.id
`;
const values: any[] = [];
if (configId) {
whereCondition.config_id = configId;
sql += ` WHERE j.config_id = $1`;
values.push(configId);
}
const jobs = await prisma.data_collection_jobs.findMany({
where: whereCondition,
orderBy: { started_at: "desc" },
include: {
config: {
select: {
config_name: true,
collection_type: true,
},
},
},
});
sql += ` ORDER BY j.started_at DESC`;
const jobs = await query<any>(sql, values);
return jobs as CollectionJob[];
}
@ -227,11 +268,13 @@ export class CollectionService {
static async getCollectionHistory(
configId: number
): Promise<CollectionHistory[]> {
const history = await prisma.data_collection_jobs.findMany({
where: { config_id: configId },
orderBy: { started_at: "desc" },
take: 50, // 최근 50개 이력
});
const history = await query<any>(
`SELECT * FROM data_collection_jobs
WHERE config_id = $1
ORDER BY started_at DESC
LIMIT 50`,
[configId]
);
return history.map((item: any) => ({
id: item.id,