370 lines
10 KiB
Markdown
370 lines
10 KiB
Markdown
# 🎨 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 쿼리 포함
|
|
|