567 lines
16 KiB
Markdown
567 lines
16 KiB
Markdown
# 🖥️ Phase 2.1: ScreenManagementService Raw Query 전환 계획
|
|
|
|
## 📋 개요
|
|
|
|
ScreenManagementService는 **46개의 Prisma 호출**이 있는 가장 복잡한 서비스입니다. 화면 정의, 레이아웃, 메뉴 할당, 템플릿 등 다양한 기능을 포함합니다.
|
|
|
|
### 📊 기본 정보
|
|
|
|
| 항목 | 내용 |
|
|
| --------------- | ------------------------------------------------------ |
|
|
| 파일 위치 | `backend-node/src/services/screenManagementService.ts` |
|
|
| 파일 크기 | 1,700+ 라인 |
|
|
| Prisma 호출 | 46개 |
|
|
| **현재 진행률** | **46/46 (100%)** ✅ **완료** |
|
|
| 복잡도 | 매우 높음 |
|
|
| 우선순위 | 🔴 최우선 |
|
|
|
|
### 🎯 전환 현황 (2025-09-30 업데이트)
|
|
|
|
- ✅ **Stage 1 완료**: 기본 CRUD (8개 함수) - Commit: 13c1bc4, 0e8d1d4
|
|
- ✅ **Stage 2 완료**: 레이아웃 관리 (2개 함수, 4 Prisma 호출) - Commit: 67dced7
|
|
- ✅ **Stage 3 완료**: 템플릿 & 메뉴 관리 (5개 함수) - Commit: 74351e8
|
|
- ✅ **Stage 4 완료**: 복잡한 기능 (트랜잭션) - **모든 46개 Prisma 호출 전환 완료**
|
|
|
|
---
|
|
|
|
## 🔍 Prisma 사용 현황 분석
|
|
|
|
### 1. 화면 정의 관리 (Screen Definitions) - 18개
|
|
|
|
```typescript
|
|
// Line 53: 화면 코드 중복 확인
|
|
await prisma.screen_definitions.findFirst({ where: { screen_code, is_active: { not: "D" } } })
|
|
|
|
// Line 70: 화면 생성
|
|
await prisma.screen_definitions.create({ data: { ... } })
|
|
|
|
// Line 99: 화면 목록 조회 (페이징)
|
|
await prisma.screen_definitions.findMany({ where, skip, take, orderBy })
|
|
|
|
// Line 105: 화면 총 개수
|
|
await prisma.screen_definitions.count({ where })
|
|
|
|
// Line 166: 전체 화면 목록
|
|
await prisma.screen_definitions.findMany({ where })
|
|
|
|
// Line 178: 화면 코드로 조회
|
|
await prisma.screen_definitions.findFirst({ where: { screen_code } })
|
|
|
|
// Line 205: 화면 ID로 조회
|
|
await prisma.screen_definitions.findFirst({ where: { screen_id } })
|
|
|
|
// Line 221: 화면 존재 확인
|
|
await prisma.screen_definitions.findUnique({ where: { screen_id } })
|
|
|
|
// Line 236: 화면 업데이트
|
|
await prisma.screen_definitions.update({ where, data })
|
|
|
|
// Line 268: 화면 복사 - 원본 조회
|
|
await prisma.screen_definitions.findUnique({ where, include: { screen_layouts } })
|
|
|
|
// Line 292: 화면 순서 변경 - 전체 조회
|
|
await prisma.screen_definitions.findMany({ where })
|
|
|
|
// Line 486: 화면 템플릿 적용 - 존재 확인
|
|
await prisma.screen_definitions.findUnique({ where })
|
|
|
|
// Line 557: 화면 복사 - 존재 확인
|
|
await prisma.screen_definitions.findUnique({ where })
|
|
|
|
// Line 578: 화면 복사 - 중복 확인
|
|
await prisma.screen_definitions.findFirst({ where })
|
|
|
|
// Line 651: 화면 삭제 - 존재 확인
|
|
await prisma.screen_definitions.findUnique({ where })
|
|
|
|
// Line 672: 화면 삭제 (물리 삭제)
|
|
await prisma.screen_definitions.delete({ where })
|
|
|
|
// Line 700: 삭제된 화면 조회
|
|
await prisma.screen_definitions.findMany({ where: { is_active: "D" } })
|
|
|
|
// Line 706: 삭제된 화면 개수
|
|
await prisma.screen_definitions.count({ where })
|
|
|
|
// Line 763: 일괄 삭제 - 화면 조회
|
|
await prisma.screen_definitions.findMany({ where })
|
|
|
|
// Line 1083: 레이아웃 저장 - 화면 확인
|
|
await prisma.screen_definitions.findUnique({ where })
|
|
|
|
// Line 1181: 레이아웃 조회 - 화면 확인
|
|
await prisma.screen_definitions.findUnique({ where })
|
|
|
|
// Line 1655: 위젯 데이터 저장 - 화면 존재 확인
|
|
await prisma.screen_definitions.findMany({ where })
|
|
```
|
|
|
|
### 2. 레이아웃 관리 (Screen Layouts) - 4개
|
|
|
|
```typescript
|
|
// Line 1096: 레이아웃 삭제
|
|
await prisma.screen_layouts.deleteMany({ where: { screen_id } });
|
|
|
|
// Line 1107: 레이아웃 생성 (단일)
|
|
await prisma.screen_layouts.create({ data });
|
|
|
|
// Line 1152: 레이아웃 생성 (다중)
|
|
await prisma.screen_layouts.create({ data });
|
|
|
|
// Line 1193: 레이아웃 조회
|
|
await prisma.screen_layouts.findMany({ where });
|
|
```
|
|
|
|
### 3. 템플릿 관리 (Screen Templates) - 2개
|
|
|
|
```typescript
|
|
// Line 1303: 템플릿 목록 조회
|
|
await prisma.screen_templates.findMany({ where });
|
|
|
|
// Line 1317: 템플릿 생성
|
|
await prisma.screen_templates.create({ data });
|
|
```
|
|
|
|
### 4. 메뉴 할당 (Screen Menu Assignments) - 5개
|
|
|
|
```typescript
|
|
// Line 446: 메뉴 할당 조회
|
|
await prisma.screen_menu_assignments.findMany({ where });
|
|
|
|
// Line 1346: 메뉴 할당 중복 확인
|
|
await prisma.screen_menu_assignments.findFirst({ where });
|
|
|
|
// Line 1358: 메뉴 할당 생성
|
|
await prisma.screen_menu_assignments.create({ data });
|
|
|
|
// Line 1376: 화면별 메뉴 할당 조회
|
|
await prisma.screen_menu_assignments.findMany({ where });
|
|
|
|
// Line 1401: 메뉴 할당 삭제
|
|
await prisma.screen_menu_assignments.deleteMany({ where });
|
|
```
|
|
|
|
### 5. 테이블 레이블 (Table Labels) - 3개
|
|
|
|
```typescript
|
|
// Line 117: 테이블 레이블 조회 (페이징)
|
|
await prisma.table_labels.findMany({ where, skip, take });
|
|
|
|
// Line 713: 테이블 레이블 조회 (전체)
|
|
await prisma.table_labels.findMany({ where });
|
|
```
|
|
|
|
### 6. 컬럼 레이블 (Column Labels) - 2개
|
|
|
|
```typescript
|
|
// Line 948: 웹타입 정보 조회
|
|
await prisma.column_labels.findMany({ where, select });
|
|
|
|
// Line 1456: 컬럼 레이블 UPSERT
|
|
await prisma.column_labels.upsert({ where, create, update });
|
|
```
|
|
|
|
### 7. Raw Query 사용 (이미 있음) - 6개
|
|
|
|
```typescript
|
|
// Line 627: 화면 순서 변경 (일괄 업데이트)
|
|
await prisma.$executeRaw`UPDATE screen_definitions SET display_order = ...`;
|
|
|
|
// Line 833: 테이블 목록 조회
|
|
await prisma.$queryRaw<Array<{ table_name: string }>>`SELECT tablename ...`;
|
|
|
|
// Line 876: 테이블 존재 확인
|
|
await prisma.$queryRaw<Array<{ table_name: string }>>`SELECT tablename ...`;
|
|
|
|
// Line 922: 테이블 컬럼 정보 조회
|
|
await prisma.$queryRaw<Array<ColumnInfo>>`SELECT column_name, data_type ...`;
|
|
|
|
// Line 1418: 컬럼 정보 조회 (상세)
|
|
await prisma.$queryRaw`SELECT column_name, data_type ...`;
|
|
```
|
|
|
|
### 8. 트랜잭션 사용 - 3개
|
|
|
|
```typescript
|
|
// Line 521: 화면 템플릿 적용 트랜잭션
|
|
await prisma.$transaction(async (tx) => { ... })
|
|
|
|
// Line 593: 화면 복사 트랜잭션
|
|
await prisma.$transaction(async (tx) => { ... })
|
|
|
|
// Line 788: 일괄 삭제 트랜잭션
|
|
await prisma.$transaction(async (tx) => { ... })
|
|
|
|
// Line 1697: 위젯 데이터 저장 트랜잭션
|
|
await prisma.$transaction(async (tx) => { ... })
|
|
```
|
|
|
|
---
|
|
|
|
## 🛠️ 전환 전략
|
|
|
|
### 전략 1: 단계적 전환
|
|
|
|
1. **1단계**: 단순 CRUD 전환 (findFirst, findMany, create, update, delete)
|
|
2. **2단계**: 복잡한 조회 전환 (include, join)
|
|
3. **3단계**: 트랜잭션 전환
|
|
4. **4단계**: Raw Query 개선
|
|
|
|
### 전략 2: 함수별 전환 우선순위
|
|
|
|
#### 🔴 최우선 (기본 CRUD)
|
|
|
|
- `createScreen()` - Line 70
|
|
- `getScreensByCompany()` - Line 99-105
|
|
- `getScreenByCode()` - Line 178
|
|
- `getScreenById()` - Line 205
|
|
- `updateScreen()` - Line 236
|
|
- `deleteScreen()` - Line 672
|
|
|
|
#### 🟡 2순위 (레이아웃)
|
|
|
|
- `saveLayout()` - Line 1096-1152
|
|
- `getLayout()` - Line 1193
|
|
- `deleteLayout()` - Line 1096
|
|
|
|
#### 🟢 3순위 (템플릿 & 메뉴)
|
|
|
|
- `getTemplates()` - Line 1303
|
|
- `createTemplate()` - Line 1317
|
|
- `assignToMenu()` - Line 1358
|
|
- `getMenuAssignments()` - Line 1376
|
|
- `removeMenuAssignment()` - Line 1401
|
|
|
|
#### 🔵 4순위 (복잡한 기능)
|
|
|
|
- `copyScreen()` - Line 593 (트랜잭션)
|
|
- `applyTemplate()` - Line 521 (트랜잭션)
|
|
- `bulkDelete()` - Line 788 (트랜잭션)
|
|
- `reorderScreens()` - Line 627 (Raw Query)
|
|
|
|
---
|
|
|
|
## 📝 전환 예시
|
|
|
|
### 예시 1: createScreen() 전환
|
|
|
|
**기존 Prisma 코드:**
|
|
|
|
```typescript
|
|
// Line 53: 중복 확인
|
|
const existingScreen = await prisma.screen_definitions.findFirst({
|
|
where: {
|
|
screen_code: screenData.screenCode,
|
|
is_active: { not: "D" },
|
|
},
|
|
});
|
|
|
|
// Line 70: 생성
|
|
const screen = await prisma.screen_definitions.create({
|
|
data: {
|
|
screen_name: screenData.screenName,
|
|
screen_code: screenData.screenCode,
|
|
table_name: screenData.tableName,
|
|
company_code: screenData.companyCode,
|
|
description: screenData.description,
|
|
created_by: screenData.createdBy,
|
|
},
|
|
});
|
|
```
|
|
|
|
**새로운 Raw Query 코드:**
|
|
|
|
```typescript
|
|
import { query } from "../database/db";
|
|
|
|
// 중복 확인
|
|
const existingResult = await query<{ screen_id: number }>(
|
|
`SELECT screen_id FROM screen_definitions
|
|
WHERE screen_code = $1 AND is_active != 'D'
|
|
LIMIT 1`,
|
|
[screenData.screenCode]
|
|
);
|
|
|
|
if (existingResult.length > 0) {
|
|
throw new Error("이미 존재하는 화면 코드입니다.");
|
|
}
|
|
|
|
// 생성
|
|
const [screen] = await query<ScreenDefinition>(
|
|
`INSERT INTO screen_definitions (
|
|
screen_name, screen_code, table_name, company_code, description, created_by
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *`,
|
|
[
|
|
screenData.screenName,
|
|
screenData.screenCode,
|
|
screenData.tableName,
|
|
screenData.companyCode,
|
|
screenData.description,
|
|
screenData.createdBy,
|
|
]
|
|
);
|
|
```
|
|
|
|
### 예시 2: getScreensByCompany() 전환 (페이징)
|
|
|
|
**기존 Prisma 코드:**
|
|
|
|
```typescript
|
|
const [screens, total] = await Promise.all([
|
|
prisma.screen_definitions.findMany({
|
|
where: whereClause,
|
|
skip: (page - 1) * size,
|
|
take: size,
|
|
orderBy: { created_at: "desc" },
|
|
}),
|
|
prisma.screen_definitions.count({ where: whereClause }),
|
|
]);
|
|
```
|
|
|
|
**새로운 Raw Query 코드:**
|
|
|
|
```typescript
|
|
const offset = (page - 1) * size;
|
|
const whereSQL =
|
|
companyCode !== "*"
|
|
? "WHERE company_code = $1 AND is_active != 'D'"
|
|
: "WHERE is_active != 'D'";
|
|
const params =
|
|
companyCode !== "*" ? [companyCode, size, offset] : [size, offset];
|
|
|
|
const [screens, totalResult] = await Promise.all([
|
|
query<ScreenDefinition>(
|
|
`SELECT * FROM screen_definitions
|
|
${whereSQL}
|
|
ORDER BY created_at DESC
|
|
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
|
params
|
|
),
|
|
query<{ count: number }>(
|
|
`SELECT COUNT(*) as count FROM screen_definitions ${whereSQL}`,
|
|
companyCode !== "*" ? [companyCode] : []
|
|
),
|
|
]);
|
|
|
|
const total = totalResult[0]?.count || 0;
|
|
```
|
|
|
|
### 예시 3: 트랜잭션 전환
|
|
|
|
**기존 Prisma 코드:**
|
|
|
|
```typescript
|
|
await prisma.$transaction(async (tx) => {
|
|
const newScreen = await tx.screen_definitions.create({ data: { ... } });
|
|
await tx.screen_layouts.createMany({ data: layouts });
|
|
});
|
|
```
|
|
|
|
**새로운 Raw Query 코드:**
|
|
|
|
```typescript
|
|
import { transaction } from "../database/db";
|
|
|
|
await transaction(async (client) => {
|
|
const [newScreen] = await client.query(
|
|
`INSERT INTO screen_definitions (...) VALUES (...) RETURNING *`,
|
|
[...]
|
|
);
|
|
|
|
for (const layout of layouts) {
|
|
await client.query(
|
|
`INSERT INTO screen_layouts (...) VALUES (...)`,
|
|
[...]
|
|
);
|
|
}
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 🧪 테스트 계획
|
|
|
|
### 단위 테스트
|
|
|
|
```typescript
|
|
describe("ScreenManagementService Raw Query 전환 테스트", () => {
|
|
describe("createScreen", () => {
|
|
test("화면 생성 성공", async () => { ... });
|
|
test("중복 화면 코드 에러", async () => { ... });
|
|
});
|
|
|
|
describe("getScreensByCompany", () => {
|
|
test("페이징 조회 성공", async () => { ... });
|
|
test("회사별 필터링", async () => { ... });
|
|
});
|
|
|
|
describe("copyScreen", () => {
|
|
test("화면 복사 성공 (트랜잭션)", async () => { ... });
|
|
test("레이아웃 함께 복사", async () => { ... });
|
|
});
|
|
});
|
|
```
|
|
|
|
### 통합 테스트
|
|
|
|
```typescript
|
|
describe("화면 관리 통합 테스트", () => {
|
|
test("화면 생성 → 조회 → 수정 → 삭제", async () => { ... });
|
|
test("화면 복사 → 레이아웃 확인", async () => { ... });
|
|
test("메뉴 할당 → 조회 → 해제", async () => { ... });
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 체크리스트
|
|
|
|
### 1단계: 기본 CRUD (8개 함수) ✅ **완료**
|
|
|
|
- [x] `createScreen()` - 화면 생성
|
|
- [x] `getScreensByCompany()` - 화면 목록 (페이징)
|
|
- [x] `getScreenByCode()` - 화면 코드로 조회
|
|
- [x] `getScreenById()` - 화면 ID로 조회
|
|
- [x] `updateScreen()` - 화면 업데이트
|
|
- [x] `deleteScreen()` - 화면 삭제
|
|
- [x] `getScreens()` - 전체 화면 목록 조회
|
|
- [x] `getScreen()` - 회사 코드 필터링 포함 조회
|
|
|
|
### 2단계: 레이아웃 관리 (2개 함수) ✅ **완료**
|
|
|
|
- [x] `saveLayout()` - 레이아웃 저장 (메타데이터 + 컴포넌트)
|
|
- [x] `getLayout()` - 레이아웃 조회
|
|
- [x] 레이아웃 삭제 로직 (saveLayout 내부에 포함)
|
|
|
|
### 3단계: 템플릿 & 메뉴 (5개 함수) ✅ **완료**
|
|
|
|
- [x] `getTemplatesByCompany()` - 템플릿 목록
|
|
- [x] `createTemplate()` - 템플릿 생성
|
|
- [x] `assignScreenToMenu()` - 메뉴 할당
|
|
- [x] `getScreensByMenu()` - 메뉴별 화면 조회
|
|
- [x] `unassignScreenFromMenu()` - 메뉴 할당 해제
|
|
- [ ] 테이블 레이블 조회 (getScreensByCompany 내부에 포함됨)
|
|
|
|
### 4단계: 복잡한 기능 (4개 함수) ✅ **완료**
|
|
|
|
- [x] `copyScreen()` - 화면 복사 (트랜잭션)
|
|
- [x] `generateScreenCode()` - 화면 코드 자동 생성
|
|
- [x] `checkScreenDependencies()` - 화면 의존성 체크 (메뉴 할당 포함)
|
|
- [x] 모든 유틸리티 메서드 Raw Query 전환
|
|
|
|
### 5단계: 테스트 & 검증 ✅ **완료**
|
|
|
|
- [x] 단위 테스트 작성 (18개 테스트 통과)
|
|
- createScreen, updateScreen, deleteScreen
|
|
- getScreensByCompany, getScreenById
|
|
- saveLayout, getLayout
|
|
- getTemplatesByCompany, assignScreenToMenu
|
|
- copyScreen, generateScreenCode
|
|
- getTableColumns
|
|
- [x] 통합 테스트 작성 (6개 시나리오)
|
|
- 화면 생명주기 테스트 (생성 → 조회 → 수정 → 삭제 → 복원 → 영구삭제)
|
|
- 화면 복사 및 레이아웃 테스트
|
|
- 테이블 정보 조회 테스트
|
|
- 일괄 작업 테스트
|
|
- 화면 코드 자동 생성 테스트
|
|
- [x] Prisma import 완전 제거 확인
|
|
- [ ] 성능 테스트 (추후 실행 예정)
|
|
|
|
---
|
|
|
|
## 🎯 완료 기준
|
|
|
|
- ✅ **46개 Prisma 호출 모두 Raw Query로 전환 완료**
|
|
- ✅ **모든 TypeScript 컴파일 오류 해결**
|
|
- ✅ **트랜잭션 정상 동작 확인**
|
|
- ✅ **에러 처리 및 롤백 정상 동작**
|
|
- ✅ **모든 단위 테스트 통과 (18개)**
|
|
- ✅ **모든 통합 테스트 작성 완료 (6개 시나리오)**
|
|
- ✅ **Prisma import 완전 제거**
|
|
- [ ] 성능 저하 없음 (기존 대비 ±10% 이내) - 추후 측정 예정
|
|
|
|
## 📊 테스트 결과
|
|
|
|
### 단위 테스트 (18개)
|
|
|
|
```
|
|
✅ createScreen - 화면 생성 (2개 테스트)
|
|
✅ getScreensByCompany - 화면 목록 페이징 (2개 테스트)
|
|
✅ updateScreen - 화면 업데이트 (2개 테스트)
|
|
✅ deleteScreen - 화면 삭제 (2개 테스트)
|
|
✅ saveLayout - 레이아웃 저장 (2개 테스트)
|
|
- 기본 저장, 소수점 좌표 반올림 처리
|
|
✅ getLayout - 레이아웃 조회 (1개 테스트)
|
|
✅ getTemplatesByCompany - 템플릿 목록 (1개 테스트)
|
|
✅ assignScreenToMenu - 메뉴 할당 (2개 테스트)
|
|
✅ copyScreen - 화면 복사 (1개 테스트)
|
|
✅ generateScreenCode - 화면 코드 자동 생성 (2개 테스트)
|
|
✅ getTableColumns - 테이블 컬럼 정보 (1개 테스트)
|
|
|
|
Test Suites: 1 passed
|
|
Tests: 18 passed
|
|
Time: 1.922s
|
|
```
|
|
|
|
### 통합 테스트 (6개 시나리오)
|
|
|
|
```
|
|
✅ 화면 생명주기 테스트
|
|
- 생성 → 조회 → 수정 → 삭제 → 복원 → 영구삭제
|
|
✅ 화면 복사 및 레이아웃 테스트
|
|
- 화면 복사 → 레이아웃 저장 → 레이아웃 확인 → 레이아웃 수정
|
|
✅ 테이블 정보 조회 테스트
|
|
- 테이블 목록 조회 → 특정 테이블 정보 조회
|
|
✅ 일괄 작업 테스트
|
|
- 여러 화면 생성 → 일괄 삭제
|
|
✅ 화면 코드 자동 생성 테스트
|
|
- 순차적 화면 코드 생성 검증
|
|
✅ 메뉴 할당 테스트 (skip - 실제 메뉴 데이터 필요)
|
|
```
|
|
|
|
---
|
|
|
|
## 🐛 버그 수정 및 개선사항
|
|
|
|
### 실제 운영 환경에서 발견된 이슈
|
|
|
|
#### 1. 소수점 좌표 저장 오류 (해결 완료)
|
|
|
|
**문제**:
|
|
|
|
```
|
|
invalid input syntax for type integer: "1602.666666666667"
|
|
```
|
|
|
|
- `position_x`, `position_y`, `width`, `height` 컬럼이 `integer` 타입
|
|
- 격자 계산 시 소수점 값이 발생하여 저장 실패
|
|
|
|
**해결**:
|
|
|
|
```typescript
|
|
Math.round(component.position.x), // 정수로 반올림
|
|
Math.round(component.position.y),
|
|
Math.round(component.size.width),
|
|
Math.round(component.size.height),
|
|
```
|
|
|
|
**테스트 추가**:
|
|
|
|
- 소수점 좌표 저장 테스트 케이스 추가
|
|
- 반올림 처리 검증
|
|
|
|
**영향 범위**:
|
|
|
|
- `saveLayout()` 함수
|
|
- `copyScreen()` 함수 (레이아웃 복사 시)
|
|
|
|
---
|
|
|
|
**작성일**: 2025-09-30
|
|
**완료일**: 2025-09-30
|
|
**예상 소요 시간**: 2-3일 → **실제 소요 시간**: 1일
|
|
**담당자**: 백엔드 개발팀
|
|
**우선순위**: 🔴 최우선 (Phase 2.1)
|
|
**상태**: ✅ **완료**
|