ERP-node/PHASE3.7_LAYOUT_SERVICE_MIG...

10 KiB

🎨 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.tsquery(), queryOne() 함수로 교체
  • JSON 필드 처리 (layout_config, sections)
  • 복잡한 검색 조건 처리
  • GROUP BY 통계 쿼리 전환
  • 모든 단위 테스트 통과
  • Prisma import 완전 제거

🔍 Prisma 사용 현황 분석

주요 Prisma 호출 (10개)

1. getLayouts() - 레이아웃 목록 조회

// 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() - 레이아웃 단건 조회

// Line 152
const layout = await prisma.layout_standards.findFirst({
  where: { layout_code: code, company_code: companyCode },
});

3. createLayout() - 레이아웃 생성

// 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() - 레이아웃 수정

// 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() - 레이아웃 삭제

// 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() - 레이아웃 통계

// 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() - 카테고리 목록

// 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 + 페이지네이션)

// 기존 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 필드 처리 (레이아웃 생성)

// 기존 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 통계 쿼리

// 기존 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 쿼리 (카테고리 목록)

// 기존 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. 통계 쿼리

  • groupByGROUP BY + COUNT(*) 전환
  • 결과 포맷팅 필요 (_count.id 형태로 변환)

📋 체크리스트

코드 전환

  • import 문 수정 (prismaquery, 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 쿼리 포함