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:
parent
7fb2ce582c
commit
45ec38790b
|
|
@ -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 쿼리 포함
|
||||
|
||||
|
|
@ -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 포함
|
||||
|
||||
|
|
@ -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 쿼리 포함
|
||||
|
||||
|
|
@ -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개 호출 ⭐ 대폭 확장**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue