diff --git a/CARD_COMPONENT_ENHANCEMENT_PLAN.md b/CARD_COMPONENT_ENHANCEMENT_PLAN.md deleted file mode 100644 index a91d0d6e..00000000 --- a/CARD_COMPONENT_ENHANCEMENT_PLAN.md +++ /dev/null @@ -1,312 +0,0 @@ -# 카드 컴포넌트 기능 확장 계획 - -## 📋 프로젝트 개요 - -테이블 리스트 컴포넌트의 고급 기능들(Entity 조인, 필터, 검색, 페이지네이션)을 카드 컴포넌트에도 적용하여 일관된 사용자 경험을 제공합니다. - -## 🔍 현재 상태 분석 - -### ✅ 기존 기능 - -- 테이블 데이터를 카드 형태로 표시 -- 기본적인 컬럼 매핑 (제목, 부제목, 설명, 이미지) -- 카드 레이아웃 설정 (행당 카드 수, 간격) -- 설정 패널 존재 - -### ❌ 부족한 기능 - -- Entity 조인 기능 -- 필터 및 검색 기능 -- 페이지네이션 -- 코드 변환 기능 -- 정렬 기능 - -## 🎯 개발 단계 - -### Phase 1: 타입 및 인터페이스 확장 ⚡ - -#### 1.1 새로운 타입 정의 추가 - -```typescript -// CardDisplayConfig 확장 -interface CardFilterConfig { - enabled: boolean; - quickSearch: boolean; - showColumnSelector?: boolean; - advancedFilter: boolean; - filterableColumns: string[]; -} - -interface CardPaginationConfig { - enabled: boolean; - pageSize: number; - showSizeSelector: boolean; - showPageInfo: boolean; - pageSizeOptions: number[]; -} - -interface CardSortConfig { - enabled: boolean; - defaultSort?: { - column: string; - direction: "asc" | "desc"; - }; - sortableColumns: string[]; -} -``` - -#### 1.2 CardDisplayConfig 확장 - -- filter, pagination, sort 설정 추가 -- Entity 조인 관련 설정 추가 -- 코드 변환 관련 설정 추가 - -### Phase 2: 핵심 기능 구현 🚀 - -#### 2.1 Entity 조인 기능 - -- `useEntityJoinOptimization` 훅 적용 -- 조인된 컬럼 데이터 매핑 -- 코드 변환 기능 (`optimizedConvertCode`) -- 컬럼 메타정보 관리 - -#### 2.2 데이터 관리 로직 - -- 검색/필터/정렬이 적용된 데이터 로딩 -- 페이지네이션 처리 -- 실시간 검색 기능 -- 캐시 최적화 - -#### 2.3 상태 관리 - -```typescript -// 새로운 상태 추가 -const [searchTerm, setSearchTerm] = useState(""); -const [selectedSearchColumn, setSelectedSearchColumn] = useState(""); -const [sortColumn, setSortColumn] = useState(null); -const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); -const [currentPage, setCurrentPage] = useState(1); -const [totalPages, setTotalPages] = useState(0); -const [totalItems, setTotalItems] = useState(0); -``` - -### Phase 3: UI 컴포넌트 구현 🎨 - -#### 3.1 헤더 영역 - -```jsx -
-

{tableConfig.title || tableLabel}

-
- {/* 검색바 */} - - {/* 검색 컬럼 선택기 */} - - {/* 새로고침 버튼 */} - -
-
-``` - -#### 3.2 카드 그리드 영역 - -```jsx -
- {displayData.map((item, index) => ( - {/* 카드 내용 렌더링 */} - ))} -
-``` - -#### 3.3 페이지네이션 영역 - -```jsx -
-
- 전체 {totalItems}건 중 {startItem}-{endItem} 표시 -
-
- - - - - {currentPage} / {totalPages} - - - -
-
-``` - -### Phase 4: 설정 패널 확장 ⚙️ - -#### 4.1 새 탭 추가 - -- **필터 탭**: 검색 및 필터 설정 -- **페이지네이션 탭**: 페이지 관련 설정 -- **정렬 탭**: 정렬 기본값 설정 - -#### 4.2 설정 옵션 - -```jsx -// 필터 탭 - - 필터 기능 사용 - 빠른 검색 - 검색 컬럼 선택기 표시 - 고급 필터 - - -// 페이지네이션 탭 - - 페이지네이션 사용 - - 페이지 크기 선택기 표시 - 페이지 정보 표시 - -``` - -## 🛠️ 구현 우선순위 - -### 🟢 High Priority (1-2주) - -1. **Entity 조인 기능**: 테이블 리스트의 로직 재사용 -2. **기본 검색 기능**: 검색바 및 실시간 검색 -3. **페이지네이션**: 카드 개수 제한 및 페이지 이동 - -### 🟡 Medium Priority (2-3주) - -4. **고급 필터**: 컬럼별 필터 옵션 -5. **정렬 기능**: 컬럼별 정렬 및 상태 표시 -6. **검색 컬럼 선택기**: 특정 컬럼 검색 기능 - -### 🔵 Low Priority (3-4주) - -7. **카드 뷰 옵션**: 그리드/리스트 전환 -8. **카드 크기 조절**: 동적 크기 조정 -9. **즐겨찾기 필터**: 자주 사용하는 필터 저장 - -## 📝 기술적 고려사항 - -### 재사용 가능한 코드 - -- `useEntityJoinOptimization` 훅 -- 필터 및 검색 로직 -- 페이지네이션 컴포넌트 -- 코드 캐시 시스템 - -### 성능 최적화 - -- 가상화 스크롤 (대량 데이터) -- 이미지 지연 로딩 -- 메모리 효율적인 렌더링 -- 디바운스된 검색 - -### 일관성 유지 - -- 테이블 리스트와 동일한 API -- 동일한 설정 구조 -- 일관된 스타일링 -- 동일한 이벤트 핸들링 - -## 🗂️ 파일 구조 - -``` -frontend/lib/registry/components/card-display/ -├── CardDisplayComponent.tsx # 메인 컴포넌트 (수정) -├── CardDisplayConfigPanel.tsx # 설정 패널 (수정) -├── types.ts # 타입 정의 (수정) -├── index.ts # 기본 설정 (수정) -├── hooks/ -│ └── useCardDataManagement.ts # 데이터 관리 훅 (신규) -├── components/ -│ ├── CardHeader.tsx # 헤더 컴포넌트 (신규) -│ ├── CardGrid.tsx # 그리드 컴포넌트 (신규) -│ ├── CardPagination.tsx # 페이지네이션 (신규) -│ └── CardFilter.tsx # 필터 컴포넌트 (신규) -└── utils/ - └── cardHelpers.ts # 유틸리티 함수 (신규) -``` - -## ✅ 완료된 단계 - -### Phase 1: 타입 및 인터페이스 확장 ✅ - -- ✅ `CardFilterConfig`, `CardPaginationConfig`, `CardSortConfig` 타입 정의 -- ✅ `CardColumnConfig` 인터페이스 추가 (Entity 조인 지원) -- ✅ `CardDisplayConfig` 확장 (새로운 기능들 포함) -- ✅ 기본 설정 업데이트 (filter, pagination, sort 기본값) - -### Phase 2: Entity 조인 기능 구현 ✅ - -- ✅ `useEntityJoinOptimization` 훅 적용 -- ✅ 컬럼 메타정보 관리 (`columnMeta` 상태) -- ✅ 코드 변환 기능 (`optimizedConvertCode`) -- ✅ Entity 조인을 고려한 데이터 로딩 로직 - -### Phase 3: 새로운 UI 구조 구현 ✅ - -- ✅ 헤더 영역 (제목, 검색바, 컬럼 선택기, 새로고침) -- ✅ 카드 그리드 영역 (반응형 그리드, 로딩/오류 상태) -- ✅ 개별 카드 렌더링 (제목, 부제목, 설명, 추가 필드) -- ✅ 푸터/페이지네이션 영역 (페이지 정보, 크기 선택, 네비게이션) -- ✅ 검색 기능 (디바운스, 컬럼 선택) -- ✅ 코드 값 포맷팅 (`formatCellValue`) - -### Phase 4: 설정 패널 확장 ✅ - -- ✅ **탭 기반 UI 구조** - 5개 탭으로 체계적 분류 -- ✅ **일반 탭** - 기본 설정, 카드 레이아웃, 스타일 옵션 -- ✅ **매핑 탭** - 컬럼 매핑, 동적 표시 컬럼 관리 -- ✅ **필터 탭** - 검색 및 필터 설정 옵션 -- ✅ **페이징 탭** - 페이지 관련 설정 및 크기 옵션 -- ✅ **정렬 탭** - 정렬 기본값 설정 -- ✅ **Shadcn/ui 컴포넌트 적용** - 일관된 UI/UX - -## 🎉 프로젝트 완료! - -### 📊 최종 달성 결과 - -**🚀 100% 완료** - 모든 계획된 기능이 성공적으로 구현되었습니다! - -#### ✅ 구현된 주요 기능들 - -1. **완전한 데이터 관리**: 테이블 리스트와 동일한 수준의 데이터 로딩, 검색, 필터링, 페이지네이션 -2. **Entity 조인 지원**: 관계형 데이터 조인 및 코드 변환 자동화 -3. **고급 검색**: 실시간 검색, 컬럼별 검색, 자동 컬럼 선택 -4. **완전한 설정 UI**: 5개 탭으로 분류된 직관적인 설정 패널 -5. **반응형 카드 그리드**: 설정 가능한 레이아웃과 스타일 - -#### 🎯 성능 및 사용성 - -- **성능 최적화**: 디바운스 검색, 배치 코드 로딩, 캐시 활용 -- **사용자 경험**: 로딩 상태, 오류 처리, 직관적인 UI -- **일관성**: 테이블 리스트와 완전히 동일한 API 및 기능 - -#### 📁 완성된 파일 구조 - -``` -frontend/lib/registry/components/card-display/ -├── CardDisplayComponent.tsx ✅ 완전 재구현 (Entity 조인, 검색, 페이징) -├── CardDisplayConfigPanel.tsx ✅ 5개 탭 기반 설정 패널 -├── types.ts ✅ 확장된 타입 시스템 -└── index.ts ✅ 업데이트된 기본 설정 -``` - ---- - -**🏆 최종 상태**: **완료** (100%) -**🎯 목표 달성**: 테이블 리스트와 동일한 수준의 강력한 카드 컴포넌트 완성 -**⚡ 개발 기간**: 계획 대비 빠른 완료 (예상 3-4주 → 실제 1일) -**✨ 품질**: 테이블 리스트 대비 동등하거나 우수한 기능 수준 - -### 🔥 주요 성과 - -이제 사용자들은 **테이블 리스트**와 **카드 디스플레이** 중에서 자유롭게 선택하여 동일한 데이터를 서로 다른 형태로 시각화할 수 있습니다. 두 컴포넌트 모두 완전히 동일한 고급 기능을 제공합니다! diff --git a/DEPLOYMENT_GUIDE_KPSLP.md b/DEPLOYMENT_GUIDE_KPSLP.md new file mode 100644 index 00000000..5d6480bb --- /dev/null +++ b/DEPLOYMENT_GUIDE_KPSLP.md @@ -0,0 +1,304 @@ +# vexplor 프로젝트 NCP Kubernetes 배포 가이드 + +## 배포 환경 +- **Kubernetes 클러스터**: NCP Kubernetes +- **네임스페이스**: apps +- **GitOps 도구**: Argo CD (https://argocd.kpslp.kr) +- **CI/CD**: Jenkins (Kaniko 빌드) +- **컨테이너 레지스트리**: registry.kpslp.kr + +## 전제 조건 + +### 1. GitLab 레포지토리 +- [x] 프로젝트 코드 레포: 이미 생성됨 (현재 레포) +- [ ] Helm Charts 레포: `https://gitlab.kpslp.kr/root/helm-charts` 접근 권한 필요 + +### 2. 필요한 권한 +- [ ] GitLab 계정 및 레포지토리 접근 권한 +- [ ] Jenkins 프로젝트 생성 권한 또는 담당자 요청 +- [ ] Argo CD 접속 계정 +- [ ] Container Registry 푸시 권한 + +--- + +## 배포 단계 + +### Step 1: Helm Charts 레포지토리 설정 + +김욱동 책임님께 다음 사항을 요청하세요: + +``` +안녕하세요. + +vexplor 프로젝트 배포를 위해 다음 작업이 필요합니다: + +1. helm-charts 레포지토리 접근 권한 부여 + - 레포지토리: https://gitlab.kpslp.kr/root/helm-charts + - 현재 404 오류로 접근 불가 + - 계정: [본인 GitLab 사용자명] + +2. values 파일 업로드 + - 첨부된 values_vexplor.yaml 파일을 + - kpslp/values_vexplor.yaml 경로에 업로드해주시거나 + - 업로드 방법을 안내해주세요 + +3. Jenkins 프로젝트 생성 + - 프로젝트명: vexplor + - Git 레포지토리: [현재 프로젝트 GitLab URL] + - Jenkinsfile: 프로젝트 루트에 이미 준비됨 + +감사합니다. +``` + +**첨부 파일**: `values_vexplor.yaml` (프로젝트 루트에 생성됨) + +--- + +### Step 2: Jenkins 프로젝트 등록 + +Jenkins에서 새 파이프라인 프로젝트를 생성합니다: + +1. **Jenkins 접속** (URL은 담당자에게 문의) +2. **New Item** 클릭 +3. **프로젝트명**: `vexplor` +4. **Pipeline** 선택 +5. **Pipeline 설정**: + - Definition: `Pipeline script from SCM` + - SCM: `Git` + - Repository URL: `[현재 프로젝트 GitLab URL]` + - Credentials: `gitlab_userpass_root` (또는 담당자가 안내한 credential) + - Branch: `*/main` + - Script Path: `Jenkinsfile` + +--- + +### Step 3: Argo CD 애플리케이션 등록 + +1. **Argo CD 접속**: https://argocd.kpslp.kr + +2. **New App 생성**: + - **Application Name**: `vexplor` + - **Project**: `default` + - **Sync Policy**: `Automatic` (자동 배포) 또는 `Manual` (수동 배포) + - **Auto-Create Namespace**: ✓ (체크) + +3. **Source 설정**: + - **Repository URL**: `https://gitlab.kpslp.kr/root/helm-charts` + - **Revision**: `HEAD` 또는 `main` + - **Path**: `kpslp` + - **Helm Values**: `values_vexplor.yaml` + +4. **Destination 설정**: + - **Cluster URL**: `https://kubernetes.default.svc` (기본값) + - **Namespace**: `apps` + +5. **Create** 클릭 + +--- + +### Step 4: 첫 배포 실행 + +#### 4-1. Git Push로 Jenkins 빌드 트리거 +```bash +git add . +git commit -m "feat: NCP Kubernetes 배포 설정 완료" +git push origin main +``` + +#### 4-2. Jenkins 빌드 모니터링 +1. Jenkins에서 `vexplor` 프로젝트 열기 +2. 빌드 시작 확인 (자동 트리거 또는 수동 빌드) +3. 로그 확인: + - **Checkout**: Git 소스 다운로드 + - **Build**: Docker 이미지 빌드 (`registry.kpslp.kr/slp/vexplor:xxxxx`) + - **Update Image Tag**: helm-charts 레포의 values 파일 업데이트 + +#### 4-3. Argo CD 배포 확인 +1. Argo CD 대시보드에서 `vexplor` 앱 열기 +2. **Sync Status**: `OutOfSync` → `Synced` 변경 확인 +3. **Health Status**: `Progressing` → `Healthy` 변경 확인 +4. Pod 상태 확인 (Running 상태여야 함) + +--- + +## 배포 후 확인사항 + +### 1. Pod 상태 확인 +```bash +kubectl get pods -n apps | grep vexplor +``` +**예상 출력**: +``` +vexplor-xxxxxxxxxx-xxxxx 1/1 Running 0 2m +``` + +### 2. 서비스 확인 +```bash +kubectl get svc -n apps | grep vexplor +``` + +### 3. Ingress 확인 +```bash +kubectl get ingress -n apps | grep vexplor +``` + +### 4. 로그 확인 +```bash +# 전체 로그 +kubectl logs -n apps -l app=vexplor + +# 최근 50줄 +kubectl logs -n apps -l app=vexplor --tail=50 + +# 실시간 로그 (스트리밍) +kubectl logs -n apps -l app=vexplor -f +``` + +### 5. 애플리케이션 접속 +- **URL**: `https://vexplor.kpslp.kr` (values 파일에 설정한 도메인) +- **헬스체크**: `https://vexplor.kpslp.kr/api/health` + +--- + +## 트러블슈팅 + +### 문제 1: Jenkins 빌드 실패 +**증상**: Build 단계에서 에러 발생 + +**확인사항**: +- Docker 이미지 빌드 로그 확인 +- `Dockerfile`이 프로젝트 루트에 있는지 확인 +- 빌드 컨텍스트에 필요한 파일들이 있는지 확인 + +**해결**: +```bash +# 로컬에서 Docker 빌드 테스트 +docker build -f Dockerfile -t vexplor:test . +``` + +### 문제 2: helm-charts 레포 푸시 실패 +**증상**: Update Image Tag 단계에서 실패 + +**원인**: `gitlab_userpass_root` credential 문제 또는 권한 부족 + +**해결**: 김욱동 책임님께 credential 확인 요청 + +### 문제 3: Argo CD Sync 실패 +**증상**: `OutOfSync` 상태에서 변경 없음 + +**확인사항**: +- values 파일이 올바른 경로에 있는지 (`kpslp/values_vexplor.yaml`) +- Argo CD가 helm-charts 레포를 읽을 수 있는지 + +**해결**: Argo CD에서 수동 Sync 시도 또는 담당자에게 문의 + +### 문제 4: Pod가 CrashLoopBackOff 상태 +**증상**: Pod가 계속 재시작됨 + +**확인**: +```bash +kubectl describe pod -n apps [pod-name] +kubectl logs -n apps [pod-name] --previous +``` + +**일반적인 원인**: +- 환경 변수 누락 (DATABASE_HOST 등) +- 데이터베이스 연결 실패 +- 포트 바인딩 문제 + +**해결**: +1. `values_vexplor.yaml`의 `env` 섹션 확인 +2. 데이터베이스 서비스명 확인 (`postgres-service.apps.svc.cluster.local`) +3. Secret 설정 확인 (DB 비밀번호 등) + +--- + +## 업데이트 배포 프로세스 + +코드 수정 후 배포 절차: + +```bash +# 1. 코드 수정 +git add . +git commit -m "feat: 새로운 기능 추가" +git push origin main + +# 2. Jenkins 자동 빌드 (자동 트리거) +# - Git push 감지 +# - Docker 이미지 빌드 +# - 새 이미지 태그로 values 파일 업데이트 + +# 3. Argo CD 자동 배포 (Sync Policy가 Automatic인 경우) +# - helm-charts 레포 변경 감지 +# - Kubernetes에 새 이미지 배포 +# - Rolling Update 수행 +``` + +**수동 배포**: Argo CD 대시보드에서 `Sync` 버튼 클릭 + +--- + +## 체크리스트 + +배포 전 확인사항: + +- [ ] Jenkinsfile 수정 완료 (단일 이미지 빌드) +- [ ] Dockerfile 확인 (멀티스테이지 빌드) +- [ ] values_vexplor.yaml 작성 및 업로드 +- [ ] Jenkins 프로젝트 생성 +- [ ] Argo CD 애플리케이션 등록 +- [ ] 환경 변수 설정 (DATABASE_HOST 등) +- [ ] Secret 생성 (DB 비밀번호 등) +- [ ] Ingress 도메인 설정 +- [ ] 헬스체크 엔드포인트 확인 (`/api/health`) + +--- + +## 참고 자료 + +- **Kaniko**: 컨테이너 내에서 Docker 이미지를 빌드하는 도구 +- **GitOps**: Git을 Single Source of Truth로 사용하는 배포 방식 +- **Argo CD**: GitOps를 위한 Kubernetes CD 도구 +- **Helm**: Kubernetes 패키지 매니저 + +--- + +## 담당자 연락처 + +- **NCP 클러스터 관리**: 김욱동 책임 (엘에스티라유텍) +- **Bastion 서버**: 223.130.135.25:22 (Docker 직접 배포용 아님) +- **Argo CD**: https://argocd.kpslp.kr +- **Kubernetes 네임스페이스**: apps + +--- + +## 추가 설정 (선택사항) + +### PostgreSQL 데이터베이스 설정 +클러스터 내부에 PostgreSQL이 없다면: + +```yaml +# values_vexplor.yaml 에 추가 +postgresql: + enabled: true + auth: + username: vexplor + password: changeme123 # Secret으로 관리 권장 + database: vexplor + primary: + persistence: + enabled: true + size: 10Gi +``` + +### Secret 생성 (민감 정보) +```bash +kubectl create secret generic vexplor-secrets \ + --from-literal=db-password='your-secure-password' \ + --from-literal=jwt-secret='your-jwt-secret' \ + -n apps +``` + +### 모니터링 (Prometheus + Grafana) +담당자에게 메트릭 수집 설정 요청 + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ab7a327e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,106 @@ +# ========================== +# 멀티 스테이지 Dockerfile +# - 백엔드: Node.js + Express + TypeScript +# - 프론트엔드: Next.js (프로덕션 빌드) +# ========================== + +# ------------------------------ +# Stage 1: 백엔드 빌드 +# ------------------------------ +FROM node:20.10-alpine AS backend-builder + +WORKDIR /app/backend + +# 백엔드 의존성 설치 +COPY backend-node/package*.json ./ +RUN npm ci --only=production && \ + npm cache clean --force + +# 백엔드 소스 복사 및 빌드 +COPY backend-node/tsconfig.json ./ +COPY backend-node/src ./src +RUN npm install -D typescript @types/node && \ + npm run build && \ + npm prune --production + +# ------------------------------ +# Stage 2: 프론트엔드 빌드 +# ------------------------------ +FROM node:20.10-alpine AS frontend-builder + +WORKDIR /app/frontend + +# 프론트엔드 의존성 설치 +COPY frontend/package*.json ./ +RUN npm ci && \ + npm cache clean --force + +# 프론트엔드 소스 복사 +COPY frontend/ ./ + +# Next.js 프로덕션 빌드 (린트 비활성화) +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production +RUN npm run build:no-lint + +# ------------------------------ +# Stage 3: 최종 런타임 이미지 +# ------------------------------ +FROM node:20.10-alpine AS runtime + +# 보안 강화: 비특권 사용자 생성 +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +WORKDIR /app + +# 백엔드 런타임 파일 복사 +COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/dist ./backend/dist +COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/node_modules ./backend/node_modules +COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/package.json ./backend/package.json + +# 프론트엔드 런타임 파일 복사 +COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/.next ./frontend/.next +COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/node_modules ./frontend/node_modules +COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/package.json ./frontend/package.json +COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public +COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs + +# 업로드 디렉토리 생성 (백엔드용) +RUN mkdir -p /app/backend/uploads && \ + chown -R nodejs:nodejs /app/backend/uploads + +# 시작 스크립트 생성 +RUN echo '#!/bin/sh' > /app/start.sh && \ + echo 'set -e' >> /app/start.sh && \ + echo '' >> /app/start.sh && \ + echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \ + echo 'cd /app/backend' >> /app/start.sh && \ + echo 'echo "Starting backend on port 8080..."' >> /app/start.sh && \ + echo 'node dist/app.js &' >> /app/start.sh && \ + echo 'BACKEND_PID=$!' >> /app/start.sh && \ + echo '' >> /app/start.sh && \ + echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \ + echo 'cd /app/frontend' >> /app/start.sh && \ + echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \ + echo 'npm start &' >> /app/start.sh && \ + echo 'FRONTEND_PID=$!' >> /app/start.sh && \ + echo '' >> /app/start.sh && \ + echo '# 프로세스 모니터링' >> /app/start.sh && \ + echo 'wait $BACKEND_PID $FRONTEND_PID' >> /app/start.sh && \ + chmod +x /app/start.sh && \ + chown nodejs:nodejs /app/start.sh + +# 비특권 사용자로 전환 +USER nodejs + +# 포트 노출 +EXPOSE 3000 8080 + +# 헬스체크 +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 + +# 컨테이너 시작 +CMD ["/app/start.sh"] + diff --git a/EXTERNAL_REST_API_IMPLEMENTATION_COMPLETE.md b/EXTERNAL_REST_API_IMPLEMENTATION_COMPLETE.md deleted file mode 100644 index c2934906..00000000 --- a/EXTERNAL_REST_API_IMPLEMENTATION_COMPLETE.md +++ /dev/null @@ -1,399 +0,0 @@ -# 외부 커넥션 관리 REST API 지원 구현 완료 보고서 - -## 📋 구현 개요 - -`/admin/external-connections` 페이지에 REST API 연결 관리 기능을 성공적으로 추가했습니다. -이제 외부 데이터베이스 연결과 REST API 연결을 탭을 통해 통합 관리할 수 있습니다. - ---- - -## ✅ 구현 완료 사항 - -### 1. 데이터베이스 구조 - -**파일**: `/Users/dohyeonsu/Documents/ERP-node/db/create_external_rest_api_connections.sql` - -- ✅ `external_rest_api_connections` 테이블 생성 -- ✅ 인증 타입 (none, api-key, bearer, basic, oauth2) 지원 -- ✅ 헤더 정보 JSONB 저장 -- ✅ 테스트 결과 저장 (last_test_date, last_test_result, last_test_message) -- ✅ 샘플 데이터 포함 (기상청 API, JSONPlaceholder) - -### 2. 백엔드 구현 - -#### 타입 정의 - -**파일**: `backend-node/src/types/externalRestApiTypes.ts` - -- ✅ ExternalRestApiConnection 인터페이스 -- ✅ ExternalRestApiConnectionFilter 인터페이스 -- ✅ RestApiTestRequest 인터페이스 -- ✅ RestApiTestResult 인터페이스 -- ✅ AuthType 타입 정의 - -#### 서비스 계층 - -**파일**: `backend-node/src/services/externalRestApiConnectionService.ts` - -- ✅ CRUD 메서드 (getConnections, getConnectionById, createConnection, updateConnection, deleteConnection) -- ✅ 연결 테스트 메서드 (testConnection, testConnectionById) -- ✅ 민감 정보 암호화/복호화 (AES-256-GCM) -- ✅ 유효성 검증 -- ✅ 인증 타입별 헤더 구성 - -#### API 라우트 - -**파일**: `backend-node/src/routes/externalRestApiConnectionRoutes.ts` - -- ✅ GET `/api/external-rest-api-connections` - 목록 조회 -- ✅ GET `/api/external-rest-api-connections/:id` - 상세 조회 -- ✅ POST `/api/external-rest-api-connections` - 연결 생성 -- ✅ PUT `/api/external-rest-api-connections/:id` - 연결 수정 -- ✅ DELETE `/api/external-rest-api-connections/:id` - 연결 삭제 -- ✅ POST `/api/external-rest-api-connections/test` - 연결 테스트 (데이터 기반) -- ✅ POST `/api/external-rest-api-connections/:id/test` - 연결 테스트 (ID 기반) - -#### 라우트 등록 - -**파일**: `backend-node/src/app.ts` - -- ✅ externalRestApiConnectionRoutes import -- ✅ `/api/external-rest-api-connections` 경로 등록 - -### 3. 프론트엔드 구현 - -#### API 클라이언트 - -**파일**: `frontend/lib/api/externalRestApiConnection.ts` - -- ✅ ExternalRestApiConnectionAPI 클래스 -- ✅ CRUD 메서드 -- ✅ 연결 테스트 메서드 -- ✅ 지원되는 인증 타입 조회 - -#### 헤더 관리 컴포넌트 - -**파일**: `frontend/components/admin/HeadersManager.tsx` - -- ✅ 동적 키-값 추가/삭제 -- ✅ 테이블 형식 UI -- ✅ 실시간 업데이트 - -#### 인증 설정 컴포넌트 - -**파일**: `frontend/components/admin/AuthenticationConfig.tsx` - -- ✅ 인증 타입 선택 -- ✅ API Key 설정 (header/query 선택) -- ✅ Bearer Token 설정 -- ✅ Basic Auth 설정 -- ✅ OAuth 2.0 설정 -- ✅ 타입별 동적 UI 표시 - -#### REST API 연결 모달 - -**파일**: `frontend/components/admin/RestApiConnectionModal.tsx` - -- ✅ 기본 정보 입력 (연결명, 설명, URL) -- ✅ 헤더 관리 통합 -- ✅ 인증 설정 통합 -- ✅ 고급 설정 (타임아웃, 재시도) -- ✅ 연결 테스트 기능 -- ✅ 테스트 결과 표시 -- ✅ 유효성 검증 - -#### REST API 연결 목록 컴포넌트 - -**파일**: `frontend/components/admin/RestApiConnectionList.tsx` - -- ✅ 연결 목록 테이블 -- ✅ 검색 기능 (연결명, URL) -- ✅ 필터링 (인증 타입, 활성 상태) -- ✅ 연결 테스트 버튼 및 결과 표시 -- ✅ 편집/삭제 기능 -- ✅ 마지막 테스트 정보 표시 - -#### 메인 페이지 탭 구조 - -**파일**: `frontend/app/(main)/admin/external-connections/page.tsx` - -- ✅ 탭 UI 추가 (Database / REST API) -- ✅ 데이터베이스 연결 탭 (기존 기능) -- ✅ REST API 연결 탭 (신규 기능) -- ✅ 탭 전환 상태 관리 - ---- - -## 🎯 주요 기능 - -### 1. 탭 전환 - -- 데이터베이스 연결 관리 ↔ REST API 연결 관리 간 탭으로 전환 -- 각 탭은 독립적으로 동작 - -### 2. REST API 연결 관리 - -- **연결명**: 고유한 이름으로 연결 식별 -- **기본 URL**: API의 베이스 URL -- **헤더 설정**: 키-값 쌍으로 HTTP 헤더 관리 -- **인증 설정**: 5가지 인증 타입 지원 - - 인증 없음 (none) - - API Key (header 또는 query parameter) - - Bearer Token - - Basic Auth - - OAuth 2.0 - -### 3. 연결 테스트 - -- 저장 전 연결 테스트 가능 -- 테스트 엔드포인트 지정 가능 (선택) -- 응답 시간, 상태 코드 표시 -- 테스트 결과 데이터베이스 저장 - -### 4. 보안 - -- 민감 정보 암호화 (API 키, 토큰, 비밀번호) -- AES-256-GCM 알고리즘 사용 -- 환경 변수로 암호화 키 관리 - ---- - -## 📁 생성된 파일 목록 - -### 데이터베이스 - -- `db/create_external_rest_api_connections.sql` - -### 백엔드 - -- `backend-node/src/types/externalRestApiTypes.ts` -- `backend-node/src/services/externalRestApiConnectionService.ts` -- `backend-node/src/routes/externalRestApiConnectionRoutes.ts` - -### 프론트엔드 - -- `frontend/lib/api/externalRestApiConnection.ts` -- `frontend/components/admin/HeadersManager.tsx` -- `frontend/components/admin/AuthenticationConfig.tsx` -- `frontend/components/admin/RestApiConnectionModal.tsx` -- `frontend/components/admin/RestApiConnectionList.tsx` - -### 수정된 파일 - -- `backend-node/src/app.ts` (라우트 등록) -- `frontend/app/(main)/admin/external-connections/page.tsx` (탭 구조) - ---- - -## 🚀 사용 방법 - -### 1. 데이터베이스 테이블 생성 - -SQL 스크립트를 실행하세요: - -```bash -psql -U postgres -d your_database -f db/create_external_rest_api_connections.sql -``` - -### 2. 백엔드 재시작 - -암호화 키 환경 변수 설정 (선택): - -```bash -export DB_PASSWORD_SECRET="your-secret-key-32-characters-long" -``` - -백엔드 재시작: - -```bash -cd backend-node -npm run dev -``` - -### 3. 프론트엔드 접속 - -브라우저에서 다음 URL로 접속: - -``` -http://localhost:3000/admin/external-connections -``` - -### 4. REST API 연결 추가 - -1. "REST API 연결" 탭 클릭 -2. "새 연결 추가" 버튼 클릭 -3. 연결 정보 입력: - - 연결명 (필수) - - 기본 URL (필수) - - 헤더 설정 - - 인증 설정 -4. 연결 테스트 (선택) -5. 저장 - ---- - -## 🧪 테스트 시나리오 - -### 테스트 1: 인증 없는 공개 API - -``` -연결명: JSONPlaceholder -기본 URL: https://jsonplaceholder.typicode.com -인증 타입: 인증 없음 -테스트 엔드포인트: /posts/1 -``` - -### 테스트 2: API Key (Query Parameter) - -``` -연결명: 기상청 API -기본 URL: https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0 -인증 타입: API Key -키 위치: Query Parameter -키 이름: serviceKey -키 값: [your-api-key] -테스트 엔드포인트: /getUltraSrtNcst -``` - -### 테스트 3: Bearer Token - -``` -연결명: GitHub API -기본 URL: https://api.github.com -인증 타입: Bearer Token -토큰: ghp_your_token_here -헤더: - - Accept: application/vnd.github.v3+json - - User-Agent: YourApp -테스트 엔드포인트: /user -``` - ---- - -## 🔧 고급 설정 - -### 타임아웃 설정 - -- 기본값: 30000ms (30초) -- 범위: 1000ms ~ 120000ms - -### 재시도 설정 - -- 재시도 횟수: 0~5회 -- 재시도 간격: 100ms ~ 10000ms - -### 헤더 관리 - -- 동적 추가/삭제 -- 일반적인 헤더: - - `Content-Type: application/json` - - `Accept: application/json` - - `User-Agent: YourApp/1.0` - ---- - -## 🔒 보안 고려사항 - -### 암호화 - -- API 키, 토큰, 비밀번호는 자동 암호화 -- AES-256-GCM 알고리즘 사용 -- 환경 변수 `DB_PASSWORD_SECRET`로 키 관리 - -### 권한 - -- 관리자 권한만 접근 가능 -- 회사별 데이터 분리 (`company_code`) - -### 테스트 제한 - -- 동시 테스트 실행 제한 -- 타임아웃 강제 적용 - ---- - -## 📊 데이터베이스 스키마 - -```sql -external_rest_api_connections -├── id (SERIAL PRIMARY KEY) -├── connection_name (VARCHAR(100) UNIQUE) -- 연결명 -├── description (TEXT) -- 설명 -├── base_url (VARCHAR(500)) -- 기본 URL -├── default_headers (JSONB) -- 헤더 (키-값) -├── auth_type (VARCHAR(20)) -- 인증 타입 -├── auth_config (JSONB) -- 인증 설정 -├── timeout (INTEGER) -- 타임아웃 -├── retry_count (INTEGER) -- 재시도 횟수 -├── retry_delay (INTEGER) -- 재시도 간격 -├── company_code (VARCHAR(20)) -- 회사 코드 -├── is_active (CHAR(1)) -- 활성 상태 -├── created_date (TIMESTAMP) -- 생성일 -├── created_by (VARCHAR(50)) -- 생성자 -├── updated_date (TIMESTAMP) -- 수정일 -├── updated_by (VARCHAR(50)) -- 수정자 -├── last_test_date (TIMESTAMP) -- 마지막 테스트 일시 -├── last_test_result (CHAR(1)) -- 마지막 테스트 결과 -└── last_test_message (TEXT) -- 마지막 테스트 메시지 -``` - ---- - -## 🎉 완료 요약 - -### 구현 완료 - -- ✅ 데이터베이스 테이블 생성 -- ✅ 백엔드 API (CRUD + 테스트) -- ✅ 프론트엔드 UI (탭 + 모달 + 목록) -- ✅ 헤더 관리 기능 -- ✅ 5가지 인증 타입 지원 -- ✅ 연결 테스트 기능 -- ✅ 민감 정보 암호화 - -### 테스트 완료 - -- ✅ API 엔드포인트 테스트 -- ✅ UI 컴포넌트 통합 -- ✅ 탭 전환 기능 -- ✅ CRUD 작업 -- ✅ 연결 테스트 - -### 문서 완료 - -- ✅ 계획서 (PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md) -- ✅ 완료 보고서 (본 문서) -- ✅ SQL 스크립트 (주석 포함) - ---- - -## 🚀 다음 단계 (선택 사항) - -### 향후 확장 가능성 - -1. **엔드포인트 프리셋 관리** - - - 자주 사용하는 엔드포인트 저장 - - 빠른 호출 지원 - -2. **요청 템플릿** - - - HTTP 메서드별 요청 바디 템플릿 - - 변수 치환 기능 - -3. **응답 매핑** - - - API 응답을 내부 데이터 구조로 변환 - - 매핑 룰 설정 - -4. **로그 및 모니터링** - - API 호출 이력 기록 - - 응답 시간 모니터링 - - 오류율 추적 - ---- - -**구현 완료일**: 2025-10-21 -**버전**: 1.0 -**개발자**: AI Assistant -**상태**: 완료 ✅ diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 00000000..96ec3fc3 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,56 @@ +pipeline { + agent { + label "kaniko" + } + stages { + stage("Checkout") { + steps { + checkout scm + script { + env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim() + env.GIT_AUTHOR_NAME = sh(script: "git log -1 --pretty=format:'%an'", returnStdout: true) + env.GIT_AUTHOR_EMAIL = sh(script: "git log -1 --pretty=format:'%ae'", returnStdout: true) + env.GIT_COMMIT_MESSAGE = sh (script: 'git log -1 --pretty=%B ${GIT_COMMIT}', returnStdout: true).trim() + env.GIT_PROJECT_NAME = GIT_URL.replaceAll('.git$', '').tokenize('/')[-2] + env.GIT_REPO_NAME = GIT_URL.replaceAll('.git$', '').tokenize('/')[-1] + } + } + } + stage("Build") { + steps { + container("kaniko") { + script { + sh "/kaniko/executor --context . --destination registry.kpslp.kr/${GIT_PROJECT_NAME}/${GIT_REPO_NAME}:${GIT_COMMIT_SHORT}" + } + } + } + } + stage("Update Image Tag") { + steps { + deleteDir() + checkout([ + $class: 'GitSCM', + branches: [[name: '*/main']], + extensions: [], + userRemoteConfigs: [[credentialsId: 'gitlab_userpass_root', url: "https://gitlab.kpslp.kr/root/helm-charts"]] + ]) + script { + def valuesYaml = "kpslp/values_${GIT_REPO_NAME}.yaml" + def values = readYaml file: "${valuesYaml}" + values.image.tag = env.GIT_COMMIT_SHORT + writeYaml file: "${valuesYaml}", data: values, overwrite: true + + sh "git config user.name '${GIT_AUTHOR_NAME}'" + sh "git config user.email '${GIT_AUTHOR_EMAIL}'" + withCredentials([usernameColonPassword(credentialsId: 'gitlab_userpass_root', variable: 'USERPASS')]) { + sh ''' + git add . && \ + git commit -m "${GIT_REPO_NAME}: ${GIT_COMMIT_MESSAGE}" && \ + git push https://${USERPASS}@gitlab.kpslp.kr/root/helm-charts HEAD:main || true + ''' + } + } + } + } + } +} diff --git a/PHASE1.5_AUTH_MIGRATION_PLAN.md b/PHASE1.5_AUTH_MIGRATION_PLAN.md deleted file mode 100644 index 6b91ed50..00000000 --- a/PHASE1.5_AUTH_MIGRATION_PLAN.md +++ /dev/null @@ -1,733 +0,0 @@ -# 🔐 Phase 1.5: 인증 및 관리자 서비스 Raw Query 전환 계획 - -## 📋 개요 - -Phase 2의 핵심 서비스 전환 전에 **인증 및 관리자 시스템**을 먼저 Raw Query로 전환하여 전체 시스템의 안정적인 기반을 구축합니다. - -### 🎯 목표 - -- AuthService의 5개 Prisma 호출 제거 -- AdminService의 3개 Prisma 호출 제거 (이미 Raw Query 사용 중) -- AdminController의 28개 Prisma 호출 제거 -- 로그인 → 인증 → API 호출 전체 플로우 검증 - -### 📊 전환 대상 - -| 서비스 | Prisma 호출 수 | 복잡도 | 우선순위 | -|--------|----------------|--------|----------| -| AuthService | 5개 | 중간 | 🔴 최우선 | -| AdminService | 3개 | 낮음 (이미 Raw Query) | 🟢 확인만 필요 | -| AdminController | 28개 | 중간 | 🟡 2순위 | - ---- - -## 🔍 AuthService 분석 - -### Prisma 사용 현황 (5개) - -```typescript -// Line 21: loginPwdCheck() - 사용자 비밀번호 조회 -const userInfo = await prisma.user_info.findUnique({ - where: { user_id: userId }, - select: { user_password: true }, -}); - -// Line 82: insertLoginAccessLog() - 로그인 로그 기록 -await prisma.$executeRaw`INSERT INTO LOGIN_ACCESS_LOG(...)`; - -// Line 126: getUserInfo() - 사용자 정보 조회 -const userInfo = await prisma.user_info.findUnique({ - where: { user_id: userId }, - select: { /* 20개 필드 */ }, -}); - -// Line 157: getUserInfo() - 권한 정보 조회 -const authInfo = await prisma.authority_sub_user.findMany({ - where: { user_id: userId }, - include: { authority_master: { select: { auth_name: true } } }, -}); - -// Line 177: getUserInfo() - 회사 정보 조회 -const companyInfo = await prisma.company_mng.findFirst({ - where: { company_code: userInfo.company_code || "ILSHIN" }, - select: { company_name: true }, -}); -``` - -### 핵심 메서드 - -1. **loginPwdCheck()** - 로그인 비밀번호 검증 - - user_info 테이블 조회 - - 비밀번호 암호화 비교 - - 마스터 패스워드 체크 - -2. **insertLoginAccessLog()** - 로그인 이력 기록 - - LOGIN_ACCESS_LOG 테이블 INSERT - - Raw Query 이미 사용 중 (유지) - -3. **getUserInfo()** - 사용자 상세 정보 조회 - - user_info 테이블 조회 (20개 필드) - - authority_sub_user + authority_master 조인 (권한) - - company_mng 테이블 조회 (회사명) - - PersonBean 타입 변환 - -4. **processLogin()** - 로그인 전체 프로세스 - - 위 3개 메서드 조합 - - JWT 토큰 생성 - ---- - -## 🛠️ 전환 계획 - -### Step 1: loginPwdCheck() 전환 - -**기존 Prisma 코드:** -```typescript -const userInfo = await prisma.user_info.findUnique({ - where: { user_id: userId }, - select: { user_password: true }, -}); -``` - -**새로운 Raw Query 코드:** -```typescript -import { query } from "../database/db"; - -const result = await query<{ user_password: string }>( - "SELECT user_password FROM user_info WHERE user_id = $1", - [userId] -); - -const userInfo = result.length > 0 ? result[0] : null; -``` - -### Step 2: getUserInfo() 전환 (사용자 정보) - -**기존 Prisma 코드:** -```typescript -const userInfo = await prisma.user_info.findUnique({ - where: { user_id: userId }, - select: { - sabun: true, - user_id: true, - user_name: true, - // ... 20개 필드 - }, -}); -``` - -**새로운 Raw Query 코드:** -```typescript -const result = await query<{ - sabun: string | null; - user_id: string; - user_name: string; - user_name_eng: string | null; - user_name_cn: string | null; - dept_code: string | null; - dept_name: string | null; - position_code: string | null; - position_name: string | null; - email: string | null; - tel: string | null; - cell_phone: string | null; - user_type: string | null; - user_type_name: string | null; - partner_objid: string | null; - company_code: string | null; - locale: string | null; - photo: Buffer | null; -}>( - `SELECT - sabun, user_id, user_name, user_name_eng, user_name_cn, - dept_code, dept_name, position_code, position_name, - email, tel, cell_phone, user_type, user_type_name, - partner_objid, company_code, locale, photo - FROM user_info - WHERE user_id = $1`, - [userId] -); - -const userInfo = result.length > 0 ? result[0] : null; -``` - -### Step 3: getUserInfo() 전환 (권한 정보) - -**기존 Prisma 코드:** -```typescript -const authInfo = await prisma.authority_sub_user.findMany({ - where: { user_id: userId }, - include: { - authority_master: { - select: { auth_name: true }, - }, - }, -}); - -const authNames = authInfo - .filter((auth: any) => auth.authority_master?.auth_name) - .map((auth: any) => auth.authority_master!.auth_name!) - .join(","); -``` - -**새로운 Raw Query 코드:** -```typescript -const authResult = await query<{ auth_name: string }>( - `SELECT am.auth_name - FROM authority_sub_user asu - INNER JOIN authority_master am ON asu.auth_code = am.auth_code - WHERE asu.user_id = $1`, - [userId] -); - -const authNames = authResult.map(row => row.auth_name).join(","); -``` - -### Step 4: getUserInfo() 전환 (회사 정보) - -**기존 Prisma 코드:** -```typescript -const companyInfo = await prisma.company_mng.findFirst({ - where: { company_code: userInfo.company_code || "ILSHIN" }, - select: { company_name: true }, -}); -``` - -**새로운 Raw Query 코드:** -```typescript -const companyResult = await query<{ company_name: string }>( - "SELECT company_name FROM company_mng WHERE company_code = $1", - [userInfo.company_code || "ILSHIN"] -); - -const companyInfo = companyResult.length > 0 ? companyResult[0] : null; -``` - ---- - -## 📝 완전 전환된 AuthService 코드 - -```typescript -import { query } from "../database/db"; -import { JwtUtils } from "../utils/jwtUtils"; -import { EncryptUtil } from "../utils/encryptUtil"; -import { PersonBean, LoginResult, LoginLogData } from "../types/auth"; -import { logger } from "../utils/logger"; - -export class AuthService { - /** - * 로그인 비밀번호 검증 (Raw Query 전환) - */ - static async loginPwdCheck( - userId: string, - password: string - ): Promise { - try { - // Raw Query로 사용자 비밀번호 조회 - const result = await query<{ user_password: string }>( - "SELECT user_password FROM user_info WHERE user_id = $1", - [userId] - ); - - const userInfo = result.length > 0 ? result[0] : null; - - if (userInfo && userInfo.user_password) { - const dbPassword = userInfo.user_password; - - logger.info(`로그인 시도: ${userId}`); - logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`); - - // 마스터 패스워드 체크 - if (password === "qlalfqjsgh11") { - logger.info(`마스터 패스워드로 로그인 성공: ${userId}`); - return { loginResult: true }; - } - - // 비밀번호 검증 - if (EncryptUtil.matches(password, dbPassword)) { - logger.info(`비밀번호 일치로 로그인 성공: ${userId}`); - return { loginResult: true }; - } else { - logger.warn(`비밀번호 불일치로 로그인 실패: ${userId}`); - return { - loginResult: false, - errorReason: "패스워드가 일치하지 않습니다.", - }; - } - } else { - logger.warn(`사용자가 존재하지 않음: ${userId}`); - return { - loginResult: false, - errorReason: "사용자가 존재하지 않습니다.", - }; - } - } catch (error) { - logger.error( - `로그인 검증 중 오류 발생: ${error instanceof Error ? error.message : error}` - ); - return { - loginResult: false, - errorReason: "로그인 처리 중 오류가 발생했습니다.", - }; - } - } - - /** - * 로그인 로그 기록 (이미 Raw Query 사용 - 유지) - */ - static async insertLoginAccessLog(logData: LoginLogData): Promise { - try { - await query( - `INSERT INTO LOGIN_ACCESS_LOG( - LOG_TIME, SYSTEM_NAME, USER_ID, LOGIN_RESULT, ERROR_MESSAGE, - REMOTE_ADDR, RECPTN_DT, RECPTN_RSLT_DTL, RECPTN_RSLT, RECPTN_RSLT_CD - ) VALUES ( - now(), $1, UPPER($2), $3, $4, $5, $6, $7, $8, $9 - )`, - [ - logData.systemName, - logData.userId, - logData.loginResult, - logData.errorMessage || null, - logData.remoteAddr, - logData.recptnDt || null, - logData.recptnRsltDtl || null, - logData.recptnRslt || null, - logData.recptnRsltCd || null, - ] - ); - - logger.info( - `로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})` - ); - } catch (error) { - logger.error( - `로그인 로그 기록 중 오류 발생: ${error instanceof Error ? error.message : error}` - ); - // 로그 기록 실패는 로그인 프로세스를 중단하지 않음 - } - } - - /** - * 사용자 정보 조회 (Raw Query 전환) - */ - static async getUserInfo(userId: string): Promise { - try { - // 1. 사용자 기본 정보 조회 - const userResult = await query<{ - sabun: string | null; - user_id: string; - user_name: string; - user_name_eng: string | null; - user_name_cn: string | null; - dept_code: string | null; - dept_name: string | null; - position_code: string | null; - position_name: string | null; - email: string | null; - tel: string | null; - cell_phone: string | null; - user_type: string | null; - user_type_name: string | null; - partner_objid: string | null; - company_code: string | null; - locale: string | null; - photo: Buffer | null; - }>( - `SELECT - sabun, user_id, user_name, user_name_eng, user_name_cn, - dept_code, dept_name, position_code, position_name, - email, tel, cell_phone, user_type, user_type_name, - partner_objid, company_code, locale, photo - FROM user_info - WHERE user_id = $1`, - [userId] - ); - - const userInfo = userResult.length > 0 ? userResult[0] : null; - - if (!userInfo) { - return null; - } - - // 2. 권한 정보 조회 (JOIN으로 최적화) - const authResult = await query<{ auth_name: string }>( - `SELECT am.auth_name - FROM authority_sub_user asu - INNER JOIN authority_master am ON asu.auth_code = am.auth_code - WHERE asu.user_id = $1`, - [userId] - ); - - const authNames = authResult.map(row => row.auth_name).join(","); - - // 3. 회사 정보 조회 - const companyResult = await query<{ company_name: string }>( - "SELECT company_name FROM company_mng WHERE company_code = $1", - [userInfo.company_code || "ILSHIN"] - ); - - const companyInfo = companyResult.length > 0 ? companyResult[0] : null; - - // PersonBean 형태로 변환 - const personBean: PersonBean = { - userId: userInfo.user_id, - userName: userInfo.user_name || "", - userNameEng: userInfo.user_name_eng || undefined, - userNameCn: userInfo.user_name_cn || undefined, - deptCode: userInfo.dept_code || undefined, - deptName: userInfo.dept_name || undefined, - positionCode: userInfo.position_code || undefined, - positionName: userInfo.position_name || undefined, - email: userInfo.email || undefined, - tel: userInfo.tel || undefined, - cellPhone: userInfo.cell_phone || undefined, - userType: userInfo.user_type || undefined, - userTypeName: userInfo.user_type_name || undefined, - partnerObjid: userInfo.partner_objid || undefined, - authName: authNames || undefined, - companyCode: userInfo.company_code || "ILSHIN", - photo: userInfo.photo - ? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}` - : undefined, - locale: userInfo.locale || "KR", - }; - - logger.info(`사용자 정보 조회 완료: ${userId}`); - return personBean; - } catch (error) { - logger.error( - `사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}` - ); - return null; - } - } - - /** - * JWT 토큰으로 사용자 정보 조회 - */ - static async getUserInfoFromToken(token: string): Promise { - try { - const userInfo = JwtUtils.verifyToken(token); - return userInfo; - } catch (error) { - logger.error( - `토큰에서 사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}` - ); - return null; - } - } - - /** - * 로그인 프로세스 전체 처리 - */ - static async processLogin( - userId: string, - password: string, - remoteAddr: string - ): Promise<{ - success: boolean; - userInfo?: PersonBean; - token?: string; - errorReason?: string; - }> { - try { - // 1. 로그인 검증 - const loginResult = await this.loginPwdCheck(userId, password); - - // 2. 로그 기록 - const logData: LoginLogData = { - systemName: "PMS", - userId: userId, - loginResult: loginResult.loginResult, - errorMessage: loginResult.errorReason, - remoteAddr: remoteAddr, - }; - - await this.insertLoginAccessLog(logData); - - if (loginResult.loginResult) { - // 3. 사용자 정보 조회 - const userInfo = await this.getUserInfo(userId); - if (!userInfo) { - return { - success: false, - errorReason: "사용자 정보를 조회할 수 없습니다.", - }; - } - - // 4. JWT 토큰 생성 - const token = JwtUtils.generateToken(userInfo); - - logger.info(`로그인 성공: ${userId} (${remoteAddr})`); - return { - success: true, - userInfo, - token, - }; - } else { - logger.warn( - `로그인 실패: ${userId} - ${loginResult.errorReason} (${remoteAddr})` - ); - return { - success: false, - errorReason: loginResult.errorReason, - }; - } - } catch (error) { - logger.error( - `로그인 프로세스 중 오류 발생: ${error instanceof Error ? error.message : error}` - ); - return { - success: false, - errorReason: "로그인 처리 중 오류가 발생했습니다.", - }; - } - } - - /** - * 로그아웃 프로세스 처리 - */ - static async processLogout( - userId: string, - remoteAddr: string - ): Promise { - try { - // 로그아웃 로그 기록 - const logData: LoginLogData = { - systemName: "PMS", - userId: userId, - loginResult: false, - errorMessage: "로그아웃", - remoteAddr: remoteAddr, - }; - - await this.insertLoginAccessLog(logData); - logger.info(`로그아웃 완료: ${userId} (${remoteAddr})`); - } catch (error) { - logger.error( - `로그아웃 처리 중 오류 발생: ${error instanceof Error ? error.message : error}` - ); - } - } -} -``` - ---- - -## 🧪 테스트 계획 - -### 단위 테스트 - -```typescript -// backend-node/src/tests/authService.test.ts -import { AuthService } from "../services/authService"; -import { query } from "../database/db"; - -describe("AuthService Raw Query 전환 테스트", () => { - describe("loginPwdCheck", () => { - test("존재하는 사용자 로그인 성공", async () => { - const result = await AuthService.loginPwdCheck("testuser", "testpass"); - expect(result.loginResult).toBe(true); - }); - - test("존재하지 않는 사용자 로그인 실패", async () => { - const result = await AuthService.loginPwdCheck("nonexistent", "password"); - expect(result.loginResult).toBe(false); - expect(result.errorReason).toContain("존재하지 않습니다"); - }); - - test("잘못된 비밀번호 로그인 실패", async () => { - const result = await AuthService.loginPwdCheck("testuser", "wrongpass"); - expect(result.loginResult).toBe(false); - expect(result.errorReason).toContain("일치하지 않습니다"); - }); - - test("마스터 패스워드 로그인 성공", async () => { - const result = await AuthService.loginPwdCheck("testuser", "qlalfqjsgh11"); - expect(result.loginResult).toBe(true); - }); - }); - - describe("getUserInfo", () => { - test("사용자 정보 조회 성공", async () => { - const userInfo = await AuthService.getUserInfo("testuser"); - expect(userInfo).not.toBeNull(); - expect(userInfo?.userId).toBe("testuser"); - expect(userInfo?.userName).toBeDefined(); - }); - - test("권한 정보 조회 성공", async () => { - const userInfo = await AuthService.getUserInfo("testuser"); - expect(userInfo?.authName).toBeDefined(); - }); - - test("존재하지 않는 사용자 조회 실패", async () => { - const userInfo = await AuthService.getUserInfo("nonexistent"); - expect(userInfo).toBeNull(); - }); - }); - - describe("processLogin", () => { - test("전체 로그인 프로세스 성공", async () => { - const result = await AuthService.processLogin( - "testuser", - "testpass", - "127.0.0.1" - ); - expect(result.success).toBe(true); - expect(result.token).toBeDefined(); - expect(result.userInfo).toBeDefined(); - }); - - test("로그인 실패 시 토큰 없음", async () => { - const result = await AuthService.processLogin( - "testuser", - "wrongpass", - "127.0.0.1" - ); - expect(result.success).toBe(false); - expect(result.token).toBeUndefined(); - expect(result.errorReason).toBeDefined(); - }); - }); - - describe("insertLoginAccessLog", () => { - test("로그인 로그 기록 성공", async () => { - await expect( - AuthService.insertLoginAccessLog({ - systemName: "PMS", - userId: "testuser", - loginResult: true, - remoteAddr: "127.0.0.1", - }) - ).resolves.not.toThrow(); - }); - }); -}); -``` - -### 통합 테스트 - -```typescript -// backend-node/src/tests/integration/auth.integration.test.ts -import request from "supertest"; -import app from "../../app"; - -describe("인증 시스템 통합 테스트", () => { - let authToken: string; - - test("POST /api/auth/login - 로그인 성공", async () => { - const response = await request(app) - .post("/api/auth/login") - .send({ - userId: "testuser", - password: "testpass", - }) - .expect(200); - - expect(response.body.success).toBe(true); - expect(response.body.token).toBeDefined(); - expect(response.body.userInfo).toBeDefined(); - - authToken = response.body.token; - }); - - test("GET /api/auth/verify - 토큰 검증 성공", async () => { - const response = await request(app) - .get("/api/auth/verify") - .set("Authorization", `Bearer ${authToken}`) - .expect(200); - - expect(response.body.valid).toBe(true); - expect(response.body.userInfo).toBeDefined(); - }); - - test("GET /api/admin/menu - 인증된 사용자 메뉴 조회", async () => { - const response = await request(app) - .get("/api/admin/menu") - .set("Authorization", `Bearer ${authToken}`) - .expect(200); - - expect(Array.isArray(response.body)).toBe(true); - }); - - test("POST /api/auth/logout - 로그아웃 성공", async () => { - await request(app) - .post("/api/auth/logout") - .set("Authorization", `Bearer ${authToken}`) - .expect(200); - }); -}); -``` - ---- - -## 📋 체크리스트 - -### AuthService 전환 - -- [ ] import 문 변경 (`prisma` → `query`) -- [ ] `loginPwdCheck()` 메서드 전환 - - [ ] Prisma findUnique → Raw Query SELECT - - [ ] 타입 정의 추가 - - [ ] 에러 처리 확인 -- [ ] `insertLoginAccessLog()` 메서드 확인 - - [ ] 이미 Raw Query 사용 중 (유지) - - [ ] 파라미터 바인딩 확인 -- [ ] `getUserInfo()` 메서드 전환 - - [ ] 사용자 정보 조회 Raw Query 전환 - - [ ] 권한 정보 조회 Raw Query 전환 (JOIN 최적화) - - [ ] 회사 정보 조회 Raw Query 전환 - - [ ] PersonBean 타입 변환 로직 유지 -- [ ] 모든 메서드 타입 안전성 확인 -- [ ] 단위 테스트 작성 및 통과 - -### AdminService 확인 - -- [ ] 현재 코드 확인 (이미 Raw Query 사용 중) -- [ ] WITH RECURSIVE 쿼리 동작 확인 -- [ ] 다국어 번역 로직 확인 - -### AdminController 전환 - -- [ ] Prisma 사용 현황 파악 (28개 호출) -- [ ] 각 API 엔드포인트별 전환 계획 수립 -- [ ] Raw Query로 전환 -- [ ] 통합 테스트 작성 - -### 통합 테스트 - -- [ ] 로그인 → 토큰 발급 테스트 -- [ ] 토큰 검증 → API 호출 테스트 -- [ ] 권한 확인 → 메뉴 조회 테스트 -- [ ] 로그아웃 테스트 -- [ ] 에러 케이스 테스트 - ---- - -## 🎯 완료 기준 - -- ✅ AuthService의 모든 Prisma 호출 제거 -- ✅ AdminService Raw Query 사용 확인 -- ✅ AdminController Prisma 호출 제거 -- ✅ 모든 단위 테스트 통과 -- ✅ 통합 테스트 통과 -- ✅ 로그인 → 인증 → API 호출 플로우 정상 동작 -- ✅ 성능 저하 없음 (기존 대비 ±10% 이내) -- ✅ 에러 처리 및 로깅 정상 동작 - ---- - -## 📚 참고 문서 - -- [Phase 1 완료 가이드](backend-node/PHASE1_USAGE_GUIDE.md) -- [DatabaseManager 사용법](backend-node/src/database/db.ts) -- [QueryBuilder 사용법](backend-node/src/utils/queryBuilder.ts) -- [전체 마이그레이션 계획](PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md) - ---- - -**작성일**: 2025-09-30 -**예상 소요 시간**: 2-3일 -**담당자**: 백엔드 개발팀 \ No newline at end of file diff --git a/PHASE2.2_TABLE_MANAGEMENT_MIGRATION.md b/PHASE2.2_TABLE_MANAGEMENT_MIGRATION.md deleted file mode 100644 index c8735763..00000000 --- a/PHASE2.2_TABLE_MANAGEMENT_MIGRATION.md +++ /dev/null @@ -1,428 +0,0 @@ -# 🗂️ Phase 2.2: TableManagementService Raw Query 전환 계획 - -## 📋 개요 - -TableManagementService는 **33개의 Prisma 호출**이 있습니다. 대부분(약 26개)은 `$queryRaw`를 사용하고 있어 SQL은 이미 작성되어 있지만, **Prisma 클라이언트를 완전히 제거하려면 33개 모두를 `db.ts`의 `query` 함수로 교체**해야 합니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ----------------------------------------------------- | -| 파일 위치 | `backend-node/src/services/tableManagementService.ts` | -| 파일 크기 | 3,178 라인 | -| Prisma 호출 | 33개 ($queryRaw: 26개, ORM: 7개) | -| **현재 진행률** | **0/33 (0%)** ⏳ **전환 필요** | -| **전환 필요** | **33개 모두 전환 필요** (SQL은 이미 작성되어 있음) | -| 복잡도 | 중간 (SQL 작성은 완료, `query()` 함수로 교체만 필요) | -| 우선순위 | 🟡 중간 (Phase 2.2) | - -### 🎯 전환 목표 - -- ✅ **33개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체** - - 26개 `$queryRaw` → `query()` 또는 `queryOne()` - - 7개 ORM 메서드 → `query()` (SQL 새로 작성) - - 1개 `$transaction` → `transaction()` -- ✅ 트랜잭션 처리 정상 동작 확인 -- ✅ 모든 단위 테스트 통과 -- ✅ **Prisma import 완전 제거** - ---- - -## 🔍 Prisma 사용 현황 분석 - -### 1. `$queryRaw` / `$queryRawUnsafe` 사용 (26개) - -**현재 상태**: SQL은 이미 작성되어 있음 ✅ -**전환 작업**: `prisma.$queryRaw` → `query()` 함수로 교체만 하면 됨 - -```typescript -// 기존 -await prisma.$queryRaw`SELECT ...`; -await prisma.$queryRawUnsafe(sqlString, ...params); - -// 전환 후 -import { query } from "../database/db"; -await query(`SELECT ...`); -await query(sqlString, params); -``` - -### 2. ORM 메서드 사용 (7개) - -**현재 상태**: Prisma ORM 메서드 사용 -**전환 작업**: SQL 작성 필요 - -#### 1. table_labels 관리 (2개) - -```typescript -// Line 254: 테이블 라벨 UPSERT -await prisma.table_labels.upsert({ - where: { table_name: tableName }, - update: {}, - create: { table_name, table_label, description } -}); - -// Line 437: 테이블 라벨 조회 -await prisma.table_labels.findUnique({ - where: { table_name: tableName }, - select: { table_name, table_label, description, ... } -}); -``` - -#### 2. column_labels 관리 (5개) - -```typescript -// Line 323: 컬럼 라벨 UPSERT -await prisma.column_labels.upsert({ - where: { - table_name_column_name: { - table_name: tableName, - column_name: columnName - } - }, - update: { column_label, input_type, ... }, - create: { table_name, column_name, ... } -}); - -// Line 481: 컬럼 라벨 조회 -await prisma.column_labels.findUnique({ - where: { - table_name_column_name: { - table_name: tableName, - column_name: columnName - } - }, - select: { id, table_name, column_name, ... } -}); - -// Line 567: 컬럼 존재 확인 -await prisma.column_labels.findFirst({ - where: { table_name, column_name } -}); - -// Line 586: 컬럼 라벨 업데이트 -await prisma.column_labels.update({ - where: { id: existingColumn.id }, - data: { web_type, detail_settings, ... } -}); - -// Line 610: 컬럼 라벨 생성 -await prisma.column_labels.create({ - data: { table_name, column_name, web_type, ... } -}); - -// Line 1003: 파일 타입 컬럼 조회 -await prisma.column_labels.findMany({ - where: { table_name, web_type: 'file' }, - select: { column_name } -}); - -// Line 1382: 컬럼 웹타입 정보 조회 -await prisma.column_labels.findFirst({ - where: { table_name, column_name }, - select: { web_type, code_category, ... } -}); - -// Line 2690: 컬럼 라벨 UPSERT (복제) -await prisma.column_labels.upsert({ - where: { - table_name_column_name: { table_name, column_name } - }, - update: { column_label, web_type, ... }, - create: { table_name, column_name, ... } -}); -``` - -#### 3. attach_file_info 관리 (2개) - -```typescript -// Line 914: 파일 정보 조회 -await prisma.attach_file_info.findMany({ - where: { target_objid, doc_type, status: 'ACTIVE' }, - select: { objid, real_file_name, file_size, ... }, - orderBy: { regdate: 'desc' } -}); - -// Line 959: 파일 경로로 파일 정보 조회 -await prisma.attach_file_info.findFirst({ - where: { file_path, status: 'ACTIVE' }, - select: { objid, real_file_name, ... } -}); -``` - -#### 4. 트랜잭션 (1개) - -```typescript -// Line 391: 전체 컬럼 설정 일괄 업데이트 -await prisma.$transaction(async (tx) => { - await this.insertTableIfNotExists(tableName); - for (const columnSetting of columnSettings) { - await this.updateColumnSettings(tableName, columnName, columnSetting); - } -}); -``` - ---- - -## 📝 전환 예시 - -### 예시 1: table_labels UPSERT 전환 - -**기존 Prisma 코드:** - -```typescript -await prisma.table_labels.upsert({ - where: { table_name: tableName }, - update: {}, - create: { - table_name: tableName, - table_label: tableName, - description: "", - }, -}); -``` - -**새로운 Raw Query 코드:** - -```typescript -import { query } from "../database/db"; - -await query( - `INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) - VALUES ($1, $2, $3, NOW(), NOW()) - ON CONFLICT (table_name) DO NOTHING`, - [tableName, tableName, ""] -); -``` - -### 예시 2: column_labels UPSERT 전환 - -**기존 Prisma 코드:** - -```typescript -await prisma.column_labels.upsert({ - where: { - table_name_column_name: { - table_name: tableName, - column_name: columnName, - }, - }, - update: { - column_label: settings.columnLabel, - input_type: settings.inputType, - detail_settings: settings.detailSettings, - updated_date: new Date(), - }, - create: { - table_name: tableName, - column_name: columnName, - column_label: settings.columnLabel, - input_type: settings.inputType, - detail_settings: settings.detailSettings, - }, -}); -``` - -**새로운 Raw Query 코드:** - -```typescript -await query( - `INSERT INTO column_labels ( - table_name, column_name, column_label, input_type, detail_settings, - code_category, code_value, reference_table, reference_column, - display_column, display_order, is_visible, created_date, updated_date - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW()) - ON CONFLICT (table_name, column_name) - DO UPDATE SET - column_label = EXCLUDED.column_label, - input_type = EXCLUDED.input_type, - detail_settings = EXCLUDED.detail_settings, - code_category = EXCLUDED.code_category, - code_value = EXCLUDED.code_value, - reference_table = EXCLUDED.reference_table, - reference_column = EXCLUDED.reference_column, - display_column = EXCLUDED.display_column, - display_order = EXCLUDED.display_order, - is_visible = EXCLUDED.is_visible, - updated_date = NOW()`, - [ - tableName, - columnName, - settings.columnLabel, - settings.inputType, - settings.detailSettings, - settings.codeCategory, - settings.codeValue, - settings.referenceTable, - settings.referenceColumn, - settings.displayColumn, - settings.displayOrder || 0, - settings.isVisible !== undefined ? settings.isVisible : true, - ] -); -``` - -### 예시 3: 트랜잭션 전환 - -**기존 Prisma 코드:** - -```typescript -await prisma.$transaction(async (tx) => { - await this.insertTableIfNotExists(tableName); - for (const columnSetting of columnSettings) { - await this.updateColumnSettings(tableName, columnName, columnSetting); - } -}); -``` - -**새로운 Raw Query 코드:** - -```typescript -import { transaction } from "../database/db"; - -await transaction(async (client) => { - // 테이블 라벨 자동 추가 - await client.query( - `INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) - VALUES ($1, $2, $3, NOW(), NOW()) - ON CONFLICT (table_name) DO NOTHING`, - [tableName, tableName, ""] - ); - - // 각 컬럼 설정 업데이트 - for (const columnSetting of columnSettings) { - const columnName = columnSetting.columnName; - if (columnName) { - await client.query( - `INSERT INTO column_labels (...) - VALUES (...) - ON CONFLICT (table_name, column_name) DO UPDATE SET ...`, - [...] - ); - } - } -}); -``` - ---- - -## 🧪 테스트 계획 - -### 단위 테스트 (10개) - -```typescript -describe("TableManagementService Raw Query 전환 테스트", () => { - describe("insertTableIfNotExists", () => { - test("테이블 라벨 UPSERT 성공", async () => { ... }); - test("중복 테이블 처리", async () => { ... }); - }); - - describe("updateColumnSettings", () => { - test("컬럼 설정 UPSERT 성공", async () => { ... }); - test("기존 컬럼 업데이트", async () => { ... }); - }); - - describe("getTableLabels", () => { - test("테이블 라벨 조회 성공", async () => { ... }); - }); - - describe("getColumnLabels", () => { - test("컬럼 라벨 조회 성공", async () => { ... }); - }); - - describe("updateAllColumnSettings", () => { - test("일괄 업데이트 성공 (트랜잭션)", async () => { ... }); - test("부분 실패 시 롤백", async () => { ... }); - }); - - describe("getFileInfoByColumnAndTarget", () => { - test("파일 정보 조회 성공", async () => { ... }); - }); -}); -``` - -### 통합 테스트 (5개 시나리오) - -```typescript -describe("테이블 관리 통합 테스트", () => { - test("테이블 라벨 생성 → 조회 → 수정", async () => { ... }); - test("컬럼 라벨 생성 → 조회 → 수정", async () => { ... }); - test("컬럼 일괄 설정 업데이트", async () => { ... }); - test("파일 정보 조회 및 보강", async () => { ... }); - test("트랜잭션 롤백 테스트", async () => { ... }); -}); -``` - ---- - -## 📋 체크리스트 - -### 1단계: table_labels 전환 (2개 함수) ⏳ **진행 예정** - -- [ ] `insertTableIfNotExists()` - UPSERT -- [ ] `getTableLabels()` - 조회 - -### 2단계: column_labels 전환 (5개 함수) ⏳ **진행 예정** - -- [ ] `updateColumnSettings()` - UPSERT -- [ ] `getColumnLabels()` - 조회 -- [ ] `updateColumnWebType()` - findFirst + update/create -- [ ] `getColumnWebTypeInfo()` - findFirst -- [ ] `updateColumnLabel()` - UPSERT (복제) - -### 3단계: attach_file_info 전환 (2개 함수) ⏳ **진행 예정** - -- [ ] `getFileInfoByColumnAndTarget()` - findMany -- [ ] `getFileInfoByPath()` - findFirst - -### 4단계: 트랜잭션 전환 (1개 함수) ⏳ **진행 예정** - -- [ ] `updateAllColumnSettings()` - 트랜잭션 - -### 5단계: 테스트 & 검증 ⏳ **진행 예정** - -- [ ] 단위 테스트 작성 (10개) -- [ ] 통합 테스트 작성 (5개 시나리오) -- [ ] Prisma import 완전 제거 확인 -- [ ] 성능 테스트 - ---- - -## 🎯 완료 기준 - -- [ ] **33개 모든 Prisma 호출을 Raw Query로 전환 완료** - - [ ] 26개 `$queryRaw` → `query()` 함수로 교체 - - [ ] 7개 ORM 메서드 → `query()` 함수로 전환 (SQL 작성) -- [ ] **모든 TypeScript 컴파일 오류 해결** -- [ ] **트랜잭션 정상 동작 확인** -- [ ] **에러 처리 및 롤백 정상 동작** -- [ ] **모든 단위 테스트 통과 (10개)** -- [ ] **모든 통합 테스트 작성 완료 (5개 시나리오)** -- [ ] **`import prisma` 완전 제거 및 `import { query, transaction } from "../database/db"` 사용** -- [ ] **성능 저하 없음 (기존 대비 ±10% 이내)** - ---- - -## 💡 특이사항 - -### SQL은 이미 대부분 작성되어 있음 - -이 서비스는 이미 79%가 `$queryRaw`를 사용하고 있어, **SQL 작성은 완료**되었습니다: - -- ✅ `information_schema` 조회: SQL 작성 완료 (`$queryRaw` 사용 중) -- ✅ 동적 테이블 쿼리: SQL 작성 완료 (`$queryRawUnsafe` 사용 중) -- ✅ DDL 실행: SQL 작성 완료 (`$executeRaw` 사용 중) -- ⏳ **전환 작업**: `prisma.$queryRaw` → `query()` 함수로 **단순 교체만 필요** -- ⏳ CRUD 작업: 7개만 SQL 새로 작성 필요 - -### UPSERT 패턴 중요 - -대부분의 전환이 UPSERT 패턴이므로 PostgreSQL의 `ON CONFLICT` 구문을 활용합니다. - ---- - -**작성일**: 2025-09-30 -**예상 소요 시간**: 1-1.5일 (SQL은 79% 작성 완료, 함수 교체 작업 필요) -**담당자**: 백엔드 개발팀 -**우선순위**: 🟡 중간 (Phase 2.2) -**상태**: ⏳ **진행 예정** -**특이사항**: SQL은 대부분 작성되어 있어 `prisma.$queryRaw` → `query()` 단순 교체 작업이 주요 작업 diff --git a/PHASE2.3_DATAFLOW_SERVICE_MIGRATION.md b/PHASE2.3_DATAFLOW_SERVICE_MIGRATION.md deleted file mode 100644 index d105248c..00000000 --- a/PHASE2.3_DATAFLOW_SERVICE_MIGRATION.md +++ /dev/null @@ -1,736 +0,0 @@ -# 📊 Phase 2.3: DataflowService Raw Query 전환 계획 - -## 📋 개요 - -DataflowService는 **31개의 Prisma 호출**이 있는 핵심 서비스입니다. 테이블 간 관계 관리, 데이터플로우 다이어그램, 데이터 연결 브리지 등 복잡한 기능을 포함합니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ---------------------------------------------- | -| 파일 위치 | `backend-node/src/services/dataflowService.ts` | -| 파일 크기 | 1,170+ 라인 | -| Prisma 호출 | 0개 (전환 완료) | -| **현재 진행률** | **31/31 (100%)** ✅ **완료** | -| 복잡도 | 매우 높음 (트랜잭션 + 복잡한 관계 관리) | -| 우선순위 | 🔴 최우선 (Phase 2.3) | -| **상태** | ✅ **전환 완료 및 컴파일 성공** | - -### 🎯 전환 목표 - -- ✅ 31개 Prisma 호출을 모두 Raw Query로 전환 -- ✅ 트랜잭션 처리 정상 동작 확인 -- ✅ 에러 처리 및 롤백 정상 동작 -- ✅ 모든 단위 테스트 통과 (20개 이상) -- ✅ 통합 테스트 작성 완료 -- ✅ Prisma import 완전 제거 - ---- - -## 🔍 Prisma 사용 현황 분석 - -### 1. 테이블 관계 관리 (Table Relationships) - 22개 - -#### 1.1 관계 생성 (3개) - -```typescript -// Line 48: 최대 diagram_id 조회 -await prisma.table_relationships.findFirst({ - where: { company_code }, - orderBy: { diagram_id: 'desc' } -}); - -// Line 64: 중복 관계 확인 -await prisma.table_relationships.findFirst({ - where: { diagram_id, source_table, target_table, relationship_type } -}); - -// Line 83: 새 관계 생성 -await prisma.table_relationships.create({ - data: { diagram_id, source_table, target_table, ... } -}); -``` - -#### 1.2 관계 조회 (6개) - -```typescript -// Line 128: 관계 목록 조회 -await prisma.table_relationships.findMany({ - where: whereCondition, - orderBy: { created_at: 'desc' } -}); - -// Line 164: 단일 관계 조회 -await prisma.table_relationships.findFirst({ - where: whereCondition -}); - -// Line 287: 회사별 관계 조회 -await prisma.table_relationships.findMany({ - where: { company_code, is_active: 'Y' }, - orderBy: { diagram_id: 'asc' } -}); - -// Line 326: 테이블별 관계 조회 -await prisma.table_relationships.findMany({ - where: whereCondition, - orderBy: { relationship_type: 'asc' } -}); - -// Line 784: diagram_id별 관계 조회 -await prisma.table_relationships.findMany({ - where: whereCondition, - select: { diagram_id, diagram_name, source_table, ... } -}); - -// Line 883: 회사 코드로 전체 조회 -await prisma.table_relationships.findMany({ - where: { company_code, is_active: 'Y' } -}); -``` - -#### 1.3 통계 조회 (3개) - -```typescript -// Line 362: 전체 관계 수 -await prisma.table_relationships.count({ - where: whereCondition, -}); - -// Line 367: 관계 타입별 통계 -await prisma.table_relationships.groupBy({ - by: ["relationship_type"], - where: whereCondition, - _count: { relationship_id: true }, -}); - -// Line 376: 연결 타입별 통계 -await prisma.table_relationships.groupBy({ - by: ["connection_type"], - where: whereCondition, - _count: { relationship_id: true }, -}); -``` - -#### 1.4 관계 수정/삭제 (5개) - -```typescript -// Line 209: 관계 수정 -await prisma.table_relationships.update({ - where: { relationship_id }, - data: { source_table, target_table, ... } -}); - -// Line 248: 소프트 삭제 -await prisma.table_relationships.update({ - where: { relationship_id }, - data: { is_active: 'N', updated_at: new Date() } -}); - -// Line 936: 중복 diagram_name 확인 -await prisma.table_relationships.findFirst({ - where: { company_code, diagram_name, is_active: 'Y' } -}); - -// Line 953: 최대 diagram_id 조회 (복사용) -await prisma.table_relationships.findFirst({ - where: { company_code }, - orderBy: { diagram_id: 'desc' } -}); - -// Line 1015: 관계도 완전 삭제 -await prisma.table_relationships.deleteMany({ - where: { company_code, diagram_id, is_active: 'Y' } -}); -``` - -#### 1.5 복잡한 조회 (5개) - -```typescript -// Line 919: 원본 관계도 조회 -await prisma.table_relationships.findMany({ - where: { company_code, diagram_id: sourceDiagramId, is_active: "Y" }, -}); - -// Line 1046: diagram_id로 모든 관계 조회 -await prisma.table_relationships.findMany({ - where: { diagram_id, is_active: "Y" }, - orderBy: { created_at: "asc" }, -}); - -// Line 1085: 특정 relationship_id의 diagram_id 찾기 -await prisma.table_relationships.findFirst({ - where: { relationship_id, company_code }, -}); -``` - -### 2. 데이터 연결 브리지 (Data Relationship Bridge) - 8개 - -#### 2.1 브리지 생성/수정 (4개) - -```typescript -// Line 425: 브리지 생성 -await prisma.data_relationship_bridge.create({ - data: { - relationship_id, - source_record_id, - target_record_id, - ... - } -}); - -// Line 554: 브리지 수정 -await prisma.data_relationship_bridge.update({ - where: whereCondition, - data: { target_record_id, ... } -}); - -// Line 595: 브리지 소프트 삭제 -await prisma.data_relationship_bridge.update({ - where: whereCondition, - data: { is_active: 'N', updated_at: new Date() } -}); - -// Line 637: 브리지 일괄 삭제 -await prisma.data_relationship_bridge.updateMany({ - where: whereCondition, - data: { is_active: 'N', updated_at: new Date() } -}); -``` - -#### 2.2 브리지 조회 (4개) - -```typescript -// Line 471: relationship_id로 브리지 조회 -await prisma.data_relationship_bridge.findMany({ - where: whereCondition, - orderBy: { created_at: "desc" }, -}); - -// Line 512: 레코드별 브리지 조회 -await prisma.data_relationship_bridge.findMany({ - where: whereCondition, - orderBy: { created_at: "desc" }, -}); -``` - -### 3. Raw Query 사용 (이미 있음) - 1개 - -```typescript -// Line 673: 테이블 존재 확인 -await prisma.$queryRaw` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = ${tableName} -`; -``` - -### 4. 트랜잭션 사용 - 1개 - -```typescript -// Line 968: 관계도 복사 트랜잭션 -await prisma.$transaction( - originalRelationships.map((rel) => - prisma.table_relationships.create({ - data: { - diagram_id: newDiagramId, - company_code: companyCode, - source_table: rel.source_table, - target_table: rel.target_table, - ... - } - }) - ) -); -``` - ---- - -## 🛠️ 전환 전략 - -### 전략 1: 단계적 전환 - -1. **1단계**: 단순 CRUD 전환 (findFirst, findMany, create, update, delete) -2. **2단계**: 복잡한 조회 전환 (groupBy, count, 조건부 조회) -3. **3단계**: 트랜잭션 전환 -4. **4단계**: Raw Query 개선 - -### 전략 2: 함수별 전환 우선순위 - -#### 🔴 최우선 (기본 CRUD) - -- `createRelationship()` - Line 83 -- `getRelationships()` - Line 128 -- `getRelationshipById()` - Line 164 -- `updateRelationship()` - Line 209 -- `deleteRelationship()` - Line 248 - -#### 🟡 2순위 (브리지 관리) - -- `createDataLink()` - Line 425 -- `getLinkedData()` - Line 471 -- `getLinkedDataByRecord()` - Line 512 -- `updateDataLink()` - Line 554 -- `deleteDataLink()` - Line 595 - -#### 🟢 3순위 (통계 & 조회) - -- `getRelationshipStats()` - Line 362-376 -- `getAllRelationshipsByCompany()` - Line 287 -- `getRelationshipsByTable()` - Line 326 -- `getDiagrams()` - Line 784 - -#### 🔵 4순위 (복잡한 기능) - -- `copyDiagram()` - Line 968 (트랜잭션) -- `deleteDiagram()` - Line 1015 -- `getRelationshipsForDiagram()` - Line 1046 - ---- - -## 📝 전환 예시 - -### 예시 1: createRelationship() 전환 - -**기존 Prisma 코드:** - -```typescript -// Line 48: 최대 diagram_id 조회 -const maxDiagramId = await prisma.table_relationships.findFirst({ - where: { company_code: data.companyCode }, - orderBy: { diagram_id: 'desc' } -}); - -// Line 64: 중복 관계 확인 -const existingRelationship = await prisma.table_relationships.findFirst({ - where: { - diagram_id: diagramId, - source_table: data.sourceTable, - target_table: data.targetTable, - relationship_type: data.relationshipType - } -}); - -// Line 83: 새 관계 생성 -const relationship = await prisma.table_relationships.create({ - data: { - diagram_id: diagramId, - company_code: data.companyCode, - diagram_name: data.diagramName, - source_table: data.sourceTable, - target_table: data.targetTable, - relationship_type: data.relationshipType, - ... - } -}); -``` - -**새로운 Raw Query 코드:** - -```typescript -import { query } from "../database/db"; - -// 최대 diagram_id 조회 -const maxDiagramResult = await query<{ diagram_id: number }>( - `SELECT diagram_id FROM table_relationships - WHERE company_code = $1 - ORDER BY diagram_id DESC - LIMIT 1`, - [data.companyCode] -); - -const diagramId = - data.diagramId || - (maxDiagramResult.length > 0 ? maxDiagramResult[0].diagram_id + 1 : 1); - -// 중복 관계 확인 -const existingResult = await query<{ relationship_id: number }>( - `SELECT relationship_id FROM table_relationships - WHERE diagram_id = $1 - AND source_table = $2 - AND target_table = $3 - AND relationship_type = $4 - LIMIT 1`, - [diagramId, data.sourceTable, data.targetTable, data.relationshipType] -); - -if (existingResult.length > 0) { - throw new Error("이미 존재하는 관계입니다."); -} - -// 새 관계 생성 -const [relationship] = await query( - `INSERT INTO table_relationships ( - diagram_id, company_code, diagram_name, source_table, target_table, - relationship_type, connection_type, source_column, target_column, - is_active, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', NOW(), NOW()) - RETURNING *`, - [ - diagramId, - data.companyCode, - data.diagramName, - data.sourceTable, - data.targetTable, - data.relationshipType, - data.connectionType, - data.sourceColumn, - data.targetColumn, - ] -); -``` - -### 예시 2: getRelationshipStats() 전환 (통계 조회) - -**기존 Prisma 코드:** - -```typescript -// Line 362: 전체 관계 수 -const totalCount = await prisma.table_relationships.count({ - where: whereCondition, -}); - -// Line 367: 관계 타입별 통계 -const relationshipTypeStats = await prisma.table_relationships.groupBy({ - by: ["relationship_type"], - where: whereCondition, - _count: { relationship_id: true }, -}); - -// Line 376: 연결 타입별 통계 -const connectionTypeStats = await prisma.table_relationships.groupBy({ - by: ["connection_type"], - where: whereCondition, - _count: { relationship_id: true }, -}); -``` - -**새로운 Raw Query 코드:** - -```typescript -// WHERE 조건 동적 생성 -const whereParams: any[] = []; -let whereSQL = ""; -let paramIndex = 1; - -if (companyCode) { - whereSQL += `WHERE company_code = $${paramIndex}`; - whereParams.push(companyCode); - paramIndex++; - - if (isActive !== undefined) { - whereSQL += ` AND is_active = $${paramIndex}`; - whereParams.push(isActive ? "Y" : "N"); - paramIndex++; - } -} - -// 전체 관계 수 -const [totalResult] = await query<{ count: number }>( - `SELECT COUNT(*) as count - FROM table_relationships ${whereSQL}`, - whereParams -); - -const totalCount = totalResult?.count || 0; - -// 관계 타입별 통계 -const relationshipTypeStats = await query<{ - relationship_type: string; - count: number; -}>( - `SELECT relationship_type, COUNT(*) as count - FROM table_relationships ${whereSQL} - GROUP BY relationship_type - ORDER BY count DESC`, - whereParams -); - -// 연결 타입별 통계 -const connectionTypeStats = await query<{ - connection_type: string; - count: number; -}>( - `SELECT connection_type, COUNT(*) as count - FROM table_relationships ${whereSQL} - GROUP BY connection_type - ORDER BY count DESC`, - whereParams -); -``` - -### 예시 3: copyDiagram() 트랜잭션 전환 - -**기존 Prisma 코드:** - -```typescript -// Line 968: 트랜잭션으로 모든 관계 복사 -const copiedRelationships = await prisma.$transaction( - originalRelationships.map((rel) => - prisma.table_relationships.create({ - data: { - diagram_id: newDiagramId, - company_code: companyCode, - diagram_name: newDiagramName, - source_table: rel.source_table, - target_table: rel.target_table, - ... - } - }) - ) -); -``` - -**새로운 Raw Query 코드:** - -```typescript -import { transaction } from "../database/db"; - -const copiedRelationships = await transaction(async (client) => { - const results: TableRelationship[] = []; - - for (const rel of originalRelationships) { - const [copiedRel] = await client.query( - `INSERT INTO table_relationships ( - diagram_id, company_code, diagram_name, source_table, target_table, - relationship_type, connection_type, source_column, target_column, - is_active, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', NOW(), NOW()) - RETURNING *`, - [ - newDiagramId, - companyCode, - newDiagramName, - rel.source_table, - rel.target_table, - rel.relationship_type, - rel.connection_type, - rel.source_column, - rel.target_column, - ] - ); - - results.push(copiedRel); - } - - return results; -}); -``` - ---- - -## 🧪 테스트 계획 - -### 단위 테스트 (20개 이상) - -```typescript -describe('DataflowService Raw Query 전환 테스트', () => { - describe('createRelationship', () => { - test('관계 생성 성공', async () => { ... }); - test('중복 관계 에러', async () => { ... }); - test('diagram_id 자동 생성', async () => { ... }); - }); - - describe('getRelationships', () => { - test('전체 관계 조회 성공', async () => { ... }); - test('회사별 필터링', async () => { ... }); - test('diagram_id별 필터링', async () => { ... }); - }); - - describe('getRelationshipStats', () => { - test('통계 조회 성공', async () => { ... }); - test('관계 타입별 그룹화', async () => { ... }); - test('연결 타입별 그룹화', async () => { ... }); - }); - - describe('copyDiagram', () => { - test('관계도 복사 성공 (트랜잭션)', async () => { ... }); - test('diagram_name 중복 에러', async () => { ... }); - }); - - describe('createDataLink', () => { - test('데이터 연결 생성 성공', async () => { ... }); - test('브리지 레코드 저장', async () => { ... }); - }); - - describe('getLinkedData', () => { - test('연결된 데이터 조회', async () => { ... }); - test('relationship_id별 필터링', async () => { ... }); - }); -}); -``` - -### 통합 테스트 (7개 시나리오) - -```typescript -describe('Dataflow 관리 통합 테스트', () => { - test('관계 생명주기 (생성 → 조회 → 수정 → 삭제)', async () => { ... }); - test('관계도 복사 및 검증', async () => { ... }); - test('데이터 연결 브리지 생성 및 조회', async () => { ... }); - test('통계 정보 조회', async () => { ... }); - test('테이블별 관계 조회', async () => { ... }); - test('diagram_id별 관계 조회', async () => { ... }); - test('관계도 완전 삭제', async () => { ... }); -}); -``` - ---- - -## 📋 체크리스트 - -### 1단계: 기본 CRUD (8개 함수) ✅ **완료** - -- [x] `createTableRelationship()` - 관계 생성 -- [x] `getTableRelationships()` - 관계 목록 조회 -- [x] `getTableRelationship()` - 단일 관계 조회 -- [x] `updateTableRelationship()` - 관계 수정 -- [x] `deleteTableRelationship()` - 관계 삭제 (소프트) -- [x] `getRelationshipsByTable()` - 테이블별 조회 -- [x] `getRelationshipsByConnectionType()` - 연결타입별 조회 -- [x] `getDataFlowDiagrams()` - diagram_id별 그룹 조회 - -### 2단계: 브리지 관리 (6개 함수) ✅ **완료** - -- [x] `createDataLink()` - 데이터 연결 생성 -- [x] `getLinkedDataByRelationship()` - 관계별 연결 데이터 조회 -- [x] `getLinkedDataByTable()` - 테이블별 연결 데이터 조회 -- [x] `updateDataLink()` - 연결 수정 -- [x] `deleteDataLink()` - 연결 삭제 (소프트) -- [x] `deleteAllLinkedDataByRelationship()` - 관계별 모든 연결 삭제 - -### 3단계: 통계 & 복잡한 조회 (4개 함수) ✅ **완료** - -- [x] `getRelationshipStats()` - 통계 조회 - - [x] count 쿼리 전환 - - [x] groupBy 쿼리 전환 (관계 타입별) - - [x] groupBy 쿼리 전환 (연결 타입별) -- [x] `getTableData()` - 테이블 데이터 조회 (페이징) -- [x] `getDiagramRelationships()` - 관계도 관계 조회 -- [x] `getDiagramRelationshipsByDiagramId()` - diagram_id별 관계 조회 - -### 4단계: 복잡한 기능 (3개 함수) ✅ **완료** - -- [x] `copyDiagram()` - 관계도 복사 (트랜잭션) -- [x] `deleteDiagram()` - 관계도 완전 삭제 -- [x] `getDiagramRelationshipsByRelationshipId()` - relationship_id로 조회 - -### 5단계: 테스트 & 검증 ⏳ **진행 필요** - -- [ ] 단위 테스트 작성 (20개 이상) - - createTableRelationship, updateTableRelationship, deleteTableRelationship - - getTableRelationships, getTableRelationship - - createDataLink, getLinkedDataByRelationship - - getRelationshipStats - - copyDiagram -- [ ] 통합 테스트 작성 (7개 시나리오) - - 관계 생명주기 테스트 - - 관계도 복사 테스트 - - 데이터 브리지 테스트 - - 통계 조회 테스트 -- [x] Prisma import 완전 제거 확인 -- [ ] 성능 테스트 - ---- - -## 🎯 완료 기준 - -- [x] **31개 Prisma 호출 모두 Raw Query로 전환 완료** ✅ -- [x] **모든 TypeScript 컴파일 오류 해결** ✅ -- [x] **트랜잭션 정상 동작 확인** ✅ -- [x] **에러 처리 및 롤백 정상 동작** ✅ -- [ ] **모든 단위 테스트 통과 (20개 이상)** ⏳ -- [ ] **모든 통합 테스트 작성 완료 (7개 시나리오)** ⏳ -- [x] **Prisma import 완전 제거** ✅ -- [ ] **성능 저하 없음 (기존 대비 ±10% 이내)** ⏳ - ---- - -## 🎯 주요 기술적 도전 과제 - -### 1. groupBy 쿼리 전환 - -**문제**: Prisma의 `groupBy`를 Raw Query로 전환 -**해결**: PostgreSQL의 `GROUP BY` 및 집계 함수 사용 - -```sql -SELECT relationship_type, COUNT(*) as count -FROM table_relationships -WHERE company_code = $1 AND is_active = 'Y' -GROUP BY relationship_type -ORDER BY count DESC -``` - -### 2. 트랜잭션 배열 처리 - -**문제**: Prisma의 `$transaction([...])` 배열 방식을 Raw Query로 전환 -**해결**: `transaction` 함수 내에서 순차 실행 - -```typescript -await transaction(async (client) => { - const results = []; - for (const item of items) { - const result = await client.query(...); - results.push(result); - } - return results; -}); -``` - -### 3. 동적 WHERE 조건 생성 - -**문제**: 다양한 필터 조건을 동적으로 구성 -**해결**: 조건부 파라미터 인덱스 관리 - -```typescript -const whereParams: any[] = []; -const whereConditions: string[] = []; -let paramIndex = 1; - -if (companyCode) { - whereConditions.push(`company_code = $${paramIndex++}`); - whereParams.push(companyCode); -} - -if (diagramId) { - whereConditions.push(`diagram_id = $${paramIndex++}`); - whereParams.push(diagramId); -} - -const whereSQL = - whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; -``` - ---- - -## 📊 전환 완료 요약 - -### ✅ 성공적으로 전환된 항목 - -1. **기본 CRUD (8개)**: 모든 테이블 관계 CRUD 작업을 Raw Query로 전환 -2. **브리지 관리 (6개)**: 데이터 연결 브리지의 모든 작업 전환 -3. **통계 & 조회 (4개)**: COUNT, GROUP BY 등 복잡한 통계 쿼리 전환 -4. **복잡한 기능 (3개)**: 트랜잭션 기반 관계도 복사 등 고급 기능 전환 - -### 🔧 주요 기술적 해결 사항 - -1. **트랜잭션 처리**: `transaction()` 함수 내에서 `client.query().rows` 사용 -2. **동적 WHERE 조건**: 파라미터 인덱스를 동적으로 관리하여 유연한 쿼리 생성 -3. **GROUP BY 전환**: Prisma의 `groupBy`를 PostgreSQL의 네이티브 GROUP BY로 전환 -4. **타입 안전성**: 모든 쿼리 결과에 TypeScript 타입 지정 - -### 📈 다음 단계 - -- [ ] 단위 테스트 작성 및 실행 -- [ ] 통합 테스트 시나리오 구현 -- [ ] 성능 벤치마크 테스트 -- [ ] 프로덕션 배포 준비 - ---- - -**작성일**: 2025-09-30 -**완료일**: 2025-10-01 -**소요 시간**: 1일 -**담당자**: 백엔드 개발팀 -**우선순위**: 🔴 최우선 (Phase 2.3) -**상태**: ✅ **전환 완료** (테스트 필요) diff --git a/PHASE2.4_DYNAMIC_FORM_MIGRATION.md b/PHASE2.4_DYNAMIC_FORM_MIGRATION.md deleted file mode 100644 index eabcad96..00000000 --- a/PHASE2.4_DYNAMIC_FORM_MIGRATION.md +++ /dev/null @@ -1,230 +0,0 @@ -# 📝 Phase 2.4: DynamicFormService Raw Query 전환 계획 - -## 📋 개요 - -DynamicFormService는 **13개의 Prisma 호출**이 있습니다. 대부분(약 11개)은 `$queryRaw`를 사용하고 있어 SQL은 이미 작성되어 있지만, **Prisma 클라이언트를 완전히 제거하려면 13개 모두를 `db.ts`의 `query` 함수로 교체**해야 합니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ------------------------------------------------- | -| 파일 위치 | `backend-node/src/services/dynamicFormService.ts` | -| 파일 크기 | 1,213 라인 | -| Prisma 호출 | 0개 (전환 완료) | -| **현재 진행률** | **13/13 (100%)** ✅ **완료** | -| **전환 상태** | **Raw Query로 전환 완료** | -| 복잡도 | 낮음 (SQL 작성 완료 → `query()` 함수로 교체 완료) | -| 우선순위 | 🟢 낮음 (Phase 2.4) | -| **상태** | ✅ **전환 완료 및 컴파일 성공** | - -### 🎯 전환 목표 - -- ✅ **13개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체** - - 11개 `$queryRaw` → `query()` 함수로 교체 - - 2개 ORM 메서드 → `query()` (SQL 새로 작성) -- ✅ 모든 단위 테스트 통과 -- ✅ **Prisma import 완전 제거** - ---- - -## 🔍 Prisma 사용 현황 분석 - -### 1. `$queryRaw` / `$queryRawUnsafe` 사용 (11개) - -**현재 상태**: SQL은 이미 작성되어 있음 ✅ -**전환 작업**: `prisma.$queryRaw` → `query()` 함수로 교체만 하면 됨 - -```typescript -// 기존 -await prisma.$queryRaw>`...`; -await prisma.$queryRawUnsafe(upsertQuery, ...values); - -// 전환 후 -import { query } from "../database/db"; -await query>(`...`); -await query(upsertQuery, values); -``` - -### 2. ORM 메서드 사용 (2개) - -**현재 상태**: Prisma ORM 메서드 사용 -**전환 작업**: SQL 작성 필요 - -#### 1. dynamic_form_data 조회 (1개) - -```typescript -// Line 867: 폼 데이터 조회 -const result = await prisma.dynamic_form_data.findUnique({ - where: { id }, - select: { data: true }, -}); -``` - -#### 2. screen_layouts 조회 (1개) - -```typescript -// Line 1101: 화면 레이아웃 조회 -const screenLayouts = await prisma.screen_layouts.findMany({ - where: { - screen_id: screenId, - component_type: "widget", - }, - select: { - component_id: true, - properties: true, - }, -}); -``` - ---- - -## 📝 전환 예시 - -### 예시 1: dynamic_form_data 조회 전환 - -**기존 Prisma 코드:** - -```typescript -const result = await prisma.dynamic_form_data.findUnique({ - where: { id }, - select: { data: true }, -}); -``` - -**새로운 Raw Query 코드:** - -```typescript -import { queryOne } from "../database/db"; - -const result = await queryOne<{ data: any }>( - `SELECT data FROM dynamic_form_data WHERE id = $1`, - [id] -); -``` - -### 예시 2: screen_layouts 조회 전환 - -**기존 Prisma 코드:** - -```typescript -const screenLayouts = await prisma.screen_layouts.findMany({ - where: { - screen_id: screenId, - component_type: "widget", - }, - select: { - component_id: true, - properties: true, - }, -}); -``` - -**새로운 Raw Query 코드:** - -```typescript -import { query } from "../database/db"; - -const screenLayouts = await query<{ - component_id: string; - properties: any; -}>( - `SELECT component_id, properties - FROM screen_layouts - WHERE screen_id = $1 AND component_type = $2`, - [screenId, "widget"] -); -``` - ---- - -## 🧪 테스트 계획 - -### 단위 테스트 (5개) - -```typescript -describe("DynamicFormService Raw Query 전환 테스트", () => { - describe("getFormDataById", () => { - test("폼 데이터 조회 성공", async () => { ... }); - test("존재하지 않는 데이터", async () => { ... }); - }); - - describe("getScreenLayoutsForControl", () => { - test("화면 레이아웃 조회 성공", async () => { ... }); - test("widget 타입만 필터링", async () => { ... }); - test("빈 결과 처리", async () => { ... }); - }); -}); -``` - -### 통합 테스트 (3개 시나리오) - -```typescript -describe("동적 폼 통합 테스트", () => { - test("폼 데이터 UPSERT → 조회", async () => { ... }); - test("폼 데이터 업데이트 → 조회", async () => { ... }); - test("화면 레이아웃 조회 → 제어 설정 확인", async () => { ... }); -}); -``` - ---- - -## 📋 전환 완료 내역 - -### ✅ 전환된 함수들 (13개 Raw Query 호출) - -1. **getTableColumnInfo()** - 컬럼 정보 조회 -2. **getPrimaryKeyColumns()** - 기본 키 조회 -3. **getNotNullColumns()** - NOT NULL 컬럼 조회 -4. **upsertFormData()** - UPSERT 실행 -5. **partialUpdateFormData()** - 부분 업데이트 -6. **updateFormData()** - 전체 업데이트 -7. **deleteFormData()** - 데이터 삭제 -8. **getFormDataById()** - 폼 데이터 조회 -9. **getTableColumns()** - 테이블 컬럼 조회 -10. **getTablePrimaryKeys()** - 기본 키 조회 -11. **getScreenLayoutsForControl()** - 화면 레이아웃 조회 - -### 🔧 주요 기술적 해결 사항 - -1. **Prisma import 완전 제거**: `import { query, queryOne } from "../database/db"` -2. **동적 UPSERT 쿼리**: PostgreSQL ON CONFLICT 구문 사용 -3. **부분 업데이트**: 동적 SET 절 생성 -4. **타입 변환**: PostgreSQL 타입 자동 변환 로직 유지 - -## 📋 체크리스트 - -### 1단계: ORM 호출 전환 ✅ **완료** - -- [x] `getFormDataById()` - queryOne 전환 -- [x] `getScreenLayoutsForControl()` - query 전환 -- [x] 모든 Raw Query 함수 전환 - -### 2단계: 테스트 & 검증 ⏳ **진행 예정** - -- [ ] 단위 테스트 작성 (5개) -- [ ] 통합 테스트 작성 (3개 시나리오) -- [x] Prisma import 완전 제거 확인 ✅ -- [ ] 성능 테스트 - ---- - -## 🎯 완료 기준 - -- [x] **13개 모든 Prisma 호출을 Raw Query로 전환 완료** ✅ - - [x] 11개 `$queryRaw` → `query()` 함수로 교체 ✅ - - [x] 2개 ORM 메서드 → `query()` 함수로 전환 ✅ -- [x] **모든 TypeScript 컴파일 오류 해결** ✅ -- [x] **`import prisma` 완전 제거** ✅ -- [ ] **모든 단위 테스트 통과 (5개)** ⏳ -- [ ] **모든 통합 테스트 작성 완료 (3개 시나리오)** ⏳ -- [ ] **성능 저하 없음** ⏳ - ---- - -**작성일**: 2025-09-30 -**완료일**: 2025-10-01 -**소요 시간**: 완료됨 (이전에 전환) -**담당자**: 백엔드 개발팀 -**우선순위**: 🟢 낮음 (Phase 2.4) -**상태**: ✅ **전환 완료** (테스트 필요) -**특이사항**: SQL은 이미 작성되어 있었고, `query()` 함수로 교체 완료 diff --git a/PHASE2.5_EXTERNAL_DB_CONNECTION_MIGRATION.md b/PHASE2.5_EXTERNAL_DB_CONNECTION_MIGRATION.md deleted file mode 100644 index 1361b0a8..00000000 --- a/PHASE2.5_EXTERNAL_DB_CONNECTION_MIGRATION.md +++ /dev/null @@ -1,125 +0,0 @@ -# 🔌 Phase 2.5: ExternalDbConnectionService Raw Query 전환 계획 - -## 📋 개요 - -ExternalDbConnectionService는 **15개의 Prisma 호출**이 있으며, 외부 데이터베이스 연결 정보를 관리하는 서비스입니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ---------------------------------------------------------- | -| 파일 위치 | `backend-node/src/services/externalDbConnectionService.ts` | -| 파일 크기 | 1,100+ 라인 | -| Prisma 호출 | 0개 (전환 완료) | -| **현재 진행률** | **15/15 (100%)** ✅ **완료** | -| 복잡도 | 중간 (CRUD + 연결 테스트) | -| 우선순위 | 🟡 중간 (Phase 2.5) | -| **상태** | ✅ **전환 완료 및 컴파일 성공** | - -### 🎯 전환 목표 - -- ✅ 15개 Prisma 호출을 모두 Raw Query로 전환 -- ✅ 민감 정보 암호화 처리 유지 -- ✅ 연결 테스트 로직 정상 동작 -- ✅ 모든 단위 테스트 통과 - ---- - -## 🔍 주요 기능 - -### 1. 외부 DB 연결 정보 CRUD - -- 생성, 조회, 수정, 삭제 -- 연결 정보 암호화/복호화 - -### 2. 연결 테스트 - -- MySQL, PostgreSQL, MSSQL, Oracle 연결 테스트 - -### 3. 연결 정보 관리 - -- 회사별 연결 정보 조회 -- 활성/비활성 상태 관리 - ---- - -## 📝 예상 전환 패턴 - -### CRUD 작업 - -```typescript -// 생성 -await query( - `INSERT INTO external_db_connections - (connection_name, db_type, host, port, database_name, username, password, company_code) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING *`, - [...] -); - -// 조회 -await query( - `SELECT * FROM external_db_connections - WHERE company_code = $1 AND is_active = 'Y'`, - [companyCode] -); - -// 수정 -await query( - `UPDATE external_db_connections - SET connection_name = $1, host = $2, ... - WHERE connection_id = $2`, - [...] -); - -// 삭제 (소프트) -await query( - `UPDATE external_db_connections - SET is_active = 'N' - WHERE connection_id = $1`, - [connectionId] -); -``` - ---- - -## 📋 전환 완료 내역 - -### ✅ 전환된 함수들 (15개 Prisma 호출) - -1. **getConnections()** - 동적 WHERE 조건 생성으로 전환 -2. **getConnectionsGroupedByType()** - DB 타입 카테고리 조회 -3. **getConnectionById()** - 단일 연결 조회 (비밀번호 마스킹) -4. **getConnectionByIdWithPassword()** - 비밀번호 포함 조회 -5. **createConnection()** - 새 연결 생성 + 중복 확인 -6. **updateConnection()** - 동적 필드 업데이트 -7. **deleteConnection()** - 물리 삭제 -8. **testConnectionById()** - 연결 테스트용 조회 -9. **getDecryptedPassword()** - 비밀번호 복호화용 조회 -10. **executeQuery()** - 쿼리 실행용 조회 -11. **getTables()** - 테이블 목록 조회용 - -### 🔧 주요 기술적 해결 사항 - -1. **동적 WHERE 조건 생성**: 필터 조건에 따라 동적으로 SQL 생성 -2. **동적 UPDATE 쿼리**: 변경된 필드만 업데이트하도록 구현 -3. **ILIKE 검색**: 대소문자 구분 없는 검색 지원 -4. **암호화 로직 유지**: PasswordEncryption 클래스와 통합 유지 - -## 🎯 완료 기준 - -- [x] **15개 Prisma 호출 모두 Raw Query로 전환** ✅ -- [x] **암호화/복호화 로직 정상 동작** ✅ -- [x] **연결 테스트 정상 동작** ✅ -- [ ] **모든 단위 테스트 통과 (10개 이상)** ⏳ -- [x] **Prisma import 완전 제거** ✅ -- [x] **TypeScript 컴파일 성공** ✅ - ---- - -**작성일**: 2025-09-30 -**완료일**: 2025-10-01 -**소요 시간**: 1시간 -**담당자**: 백엔드 개발팀 -**우선순위**: 🟡 중간 (Phase 2.5) -**상태**: ✅ **전환 완료** (테스트 필요) diff --git a/PHASE2.6_DATAFLOW_CONTROL_MIGRATION.md b/PHASE2.6_DATAFLOW_CONTROL_MIGRATION.md deleted file mode 100644 index d8ce39c6..00000000 --- a/PHASE2.6_DATAFLOW_CONTROL_MIGRATION.md +++ /dev/null @@ -1,225 +0,0 @@ -# 🎮 Phase 2.6: DataflowControlService Raw Query 전환 계획 - -## 📋 개요 - -DataflowControlService는 **6개의 Prisma 호출**이 있으며, 데이터플로우 제어 및 실행을 담당하는 서비스입니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ----------------------------------------------------- | -| 파일 위치 | `backend-node/src/services/dataflowControlService.ts` | -| 파일 크기 | 1,100+ 라인 | -| Prisma 호출 | 0개 (전환 완료) | -| **현재 진행률** | **6/6 (100%)** ✅ **완료** | -| 복잡도 | 높음 (복잡한 비즈니스 로직) | -| 우선순위 | 🟡 중간 (Phase 2.6) | -| **상태** | ✅ **전환 완료 및 컴파일 성공** | - -### 🎯 전환 목표 - -- ✅ **6개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체** -- ✅ 복잡한 비즈니스 로직 정상 동작 확인 -- ✅ 모든 단위 테스트 통과 -- ✅ **Prisma import 완전 제거** - ---- - -## 🔍 Prisma 사용 현황 분석 - -### 주요 기능 - -1. **데이터플로우 실행 관리** - - 관계 기반 데이터 조회 및 저장 - - 조건부 실행 로직 -2. **트랜잭션 처리** - - 여러 테이블에 걸친 데이터 처리 -3. **데이터 변환 및 매핑** - - 소스-타겟 데이터 변환 - ---- - -## 📝 전환 계획 - -### 1단계: 기본 조회 전환 (2개 함수) - -**함수 목록**: - -- `getRelationshipById()` - 관계 정보 조회 -- `getDataflowConfig()` - 데이터플로우 설정 조회 - -### 2단계: 데이터 실행 로직 전환 (2개 함수) - -**함수 목록**: - -- `executeDataflow()` - 데이터플로우 실행 -- `validateDataflow()` - 데이터플로우 검증 - -### 3단계: 복잡한 기능 - 트랜잭션 (2개 함수) - -**함수 목록**: - -- `executeWithTransaction()` - 트랜잭션 내 실행 -- `rollbackOnError()` - 에러 시 롤백 - ---- - -## 💻 전환 예시 - -### 예시 1: 관계 정보 조회 - -```typescript -// 기존 Prisma -const relationship = await prisma.table_relationship.findUnique({ - where: { relationship_id: relationshipId }, - include: { - source_table: true, - target_table: true, - }, -}); - -// 전환 후 -import { query } from "../database/db"; - -const relationship = await query( - `SELECT - tr.*, - st.table_name as source_table_name, - tt.table_name as target_table_name - FROM table_relationship tr - LEFT JOIN table_labels st ON tr.source_table_id = st.table_id - LEFT JOIN table_labels tt ON tr.target_table_id = tt.table_id - WHERE tr.relationship_id = $1`, - [relationshipId] -); -``` - -### 예시 2: 트랜잭션 내 실행 - -```typescript -// 기존 Prisma -await prisma.$transaction(async (tx) => { - // 소스 데이터 조회 - const sourceData = await tx.dynamic_form_data.findMany(...); - - // 타겟 데이터 저장 - await tx.dynamic_form_data.createMany(...); - - // 실행 로그 저장 - await tx.dataflow_execution_log.create(...); -}); - -// 전환 후 -import { transaction } from "../database/db"; - -await transaction(async (client) => { - // 소스 데이터 조회 - const sourceData = await client.query( - `SELECT * FROM dynamic_form_data WHERE ...`, - [...] - ); - - // 타겟 데이터 저장 - await client.query( - `INSERT INTO dynamic_form_data (...) VALUES (...)`, - [...] - ); - - // 실행 로그 저장 - await client.query( - `INSERT INTO dataflow_execution_log (...) VALUES (...)`, - [...] - ); -}); -``` - ---- - -## ✅ 5단계: 테스트 & 검증 - -### 단위 테스트 (10개) - -- [ ] getRelationshipById - 관계 정보 조회 -- [ ] getDataflowConfig - 설정 조회 -- [ ] executeDataflow - 데이터플로우 실행 -- [ ] validateDataflow - 검증 -- [ ] executeWithTransaction - 트랜잭션 실행 -- [ ] rollbackOnError - 에러 처리 -- [ ] transformData - 데이터 변환 -- [ ] mapSourceToTarget - 필드 매핑 -- [ ] applyConditions - 조건 적용 -- [ ] logExecution - 실행 로그 - -### 통합 테스트 (4개 시나리오) - -1. **데이터플로우 실행 시나리오** - - 관계 조회 → 데이터 실행 → 로그 저장 -2. **트랜잭션 테스트** - - 여러 테이블 동시 처리 - - 에러 발생 시 롤백 -3. **조건부 실행 테스트** - - 조건에 따른 데이터 처리 -4. **데이터 변환 테스트** - - 소스-타겟 데이터 매핑 - ---- - -## 📋 전환 완료 내역 - -### ✅ 전환된 함수들 (6개 Prisma 호출) - -1. **executeDataflowControl()** - 관계도 정보 조회 (findUnique → queryOne) -2. **evaluateActionConditions()** - 대상 테이블 조건 확인 ($queryRawUnsafe → query) -3. **executeInsertAction()** - INSERT 실행 ($executeRawUnsafe → query) -4. **executeUpdateAction()** - UPDATE 실행 ($executeRawUnsafe → query) -5. **executeDeleteAction()** - DELETE 실행 ($executeRawUnsafe → query) -6. **checkColumnExists()** - 컬럼 존재 확인 ($queryRawUnsafe → query) - -### 🔧 주요 기술적 해결 사항 - -1. **Prisma import 완전 제거**: `import { query, queryOne } from "../database/db"` -2. **동적 테이블 쿼리 전환**: `$queryRawUnsafe` / `$executeRawUnsafe` → `query()` -3. **파라미터 바인딩 수정**: MySQL `?` → PostgreSQL `$1, $2...` -4. **복잡한 비즈니스 로직 유지**: 조건부 실행, 다중 커넥션, 에러 처리 - -## 🎯 완료 기준 - -- [x] **6개 모든 Prisma 호출을 Raw Query로 전환 완료** ✅ -- [x] **모든 TypeScript 컴파일 오류 해결** ✅ -- [x] **`import prisma` 완전 제거** ✅ -- [ ] **트랜잭션 정상 동작 확인** ⏳ -- [ ] **복잡한 비즈니스 로직 정상 동작** ⏳ -- [ ] **모든 단위 테스트 통과 (10개)** ⏳ -- [ ] **모든 통합 테스트 작성 완료 (4개 시나리오)** ⏳ -- [ ] **성능 저하 없음** ⏳ - ---- - -## 💡 특이사항 - -### 복잡한 비즈니스 로직 - -이 서비스는 데이터플로우 제어라는 복잡한 비즈니스 로직을 처리합니다: - -- 조건부 실행 로직 -- 데이터 변환 및 매핑 -- 트랜잭션 관리 -- 에러 처리 및 롤백 - -### 성능 최적화 중요 - -데이터플로우 실행은 대량의 데이터를 처리할 수 있으므로: - -- 배치 처리 고려 -- 인덱스 활용 -- 쿼리 최적화 - ---- - -**작성일**: 2025-09-30 -**완료일**: 2025-10-01 -**소요 시간**: 30분 -**담당자**: 백엔드 개발팀 -**우선순위**: 🟡 중간 (Phase 2.6) -**상태**: ✅ **전환 완료** (테스트 필요) -**특이사항**: 복잡한 비즈니스 로직이 포함되어 있어 신중한 테스트 필요 diff --git a/PHASE2.7_DDL_EXECUTION_MIGRATION.md b/PHASE2.7_DDL_EXECUTION_MIGRATION.md deleted file mode 100644 index 28081367..00000000 --- a/PHASE2.7_DDL_EXECUTION_MIGRATION.md +++ /dev/null @@ -1,175 +0,0 @@ -# 🔧 Phase 2.7: DDLExecutionService Raw Query 전환 계획 - -## 📋 개요 - -DDLExecutionService는 **4개의 Prisma 호출**이 있으며, DDL(Data Definition Language) 실행 및 관리를 담당하는 서비스입니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | -------------------------------------------------- | -| 파일 위치 | `backend-node/src/services/ddlExecutionService.ts` | -| 파일 크기 | 400+ 라인 | -| Prisma 호출 | 4개 | -| **현재 진행률** | **6/6 (100%)** ✅ **완료** | -| 복잡도 | 중간 (DDL 실행 + 로그 관리) | -| 우선순위 | 🔴 최우선 (테이블 추가 기능 - Phase 2.3으로 변경) | - -### 🎯 전환 목표 - -- ✅ **4개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체** -- ✅ DDL 실행 정상 동작 확인 -- ✅ 모든 단위 테스트 통과 -- ✅ **Prisma import 완전 제거** - ---- - -## 🔍 Prisma 사용 현황 분석 - -### 주요 기능 - -1. **DDL 실행** - - CREATE TABLE, ALTER TABLE, DROP TABLE - - CREATE INDEX, DROP INDEX -2. **실행 로그 관리** - - DDL 실행 이력 저장 - - 에러 로그 관리 -3. **롤백 지원** - - DDL 롤백 SQL 생성 및 실행 - ---- - -## 📝 전환 계획 - -### 1단계: DDL 실행 전환 (2개 함수) - -**함수 목록**: - -- `executeDDL()` - DDL 실행 -- `validateDDL()` - DDL 문법 검증 - -### 2단계: 로그 관리 전환 (2개 함수) - -**함수 목록**: - -- `saveDDLLog()` - 실행 로그 저장 -- `getDDLHistory()` - 실행 이력 조회 - ---- - -## 💻 전환 예시 - -### 예시 1: DDL 실행 및 로그 저장 - -```typescript -// 기존 Prisma -await prisma.$executeRawUnsafe(ddlQuery); - -await prisma.ddl_execution_log.create({ - data: { - ddl_statement: ddlQuery, - execution_status: "SUCCESS", - executed_by: userId, - }, -}); - -// 전환 후 -import { query } from "../database/db"; - -await query(ddlQuery); - -await query( - `INSERT INTO ddl_execution_log - (ddl_statement, execution_status, executed_by, executed_date) - VALUES ($1, $2, $3, $4)`, - [ddlQuery, "SUCCESS", userId, new Date()] -); -``` - -### 예시 2: DDL 실행 이력 조회 - -```typescript -// 기존 Prisma -const history = await prisma.ddl_execution_log.findMany({ - where: { - company_code: companyCode, - execution_status: "SUCCESS", - }, - orderBy: { executed_date: "desc" }, - take: 50, -}); - -// 전환 후 -import { query } from "../database/db"; - -const history = await query( - `SELECT * FROM ddl_execution_log - WHERE company_code = $1 - AND execution_status = $2 - ORDER BY executed_date DESC - LIMIT $3`, - [companyCode, "SUCCESS", 50] -); -``` - ---- - -## ✅ 3단계: 테스트 & 검증 - -### 단위 테스트 (8개) - -- [ ] executeDDL - CREATE TABLE -- [ ] executeDDL - ALTER TABLE -- [ ] executeDDL - DROP TABLE -- [ ] executeDDL - CREATE INDEX -- [ ] validateDDL - 문법 검증 -- [ ] saveDDLLog - 로그 저장 -- [ ] getDDLHistory - 이력 조회 -- [ ] rollbackDDL - DDL 롤백 - -### 통합 테스트 (3개 시나리오) - -1. **테이블 생성 → 로그 저장 → 이력 조회** -2. **DDL 실행 실패 → 에러 로그 저장** -3. **DDL 롤백 테스트** - ---- - -## 🎯 완료 기준 - -- [ ] **4개 모든 Prisma 호출을 Raw Query로 전환 완료** -- [ ] **모든 TypeScript 컴파일 오류 해결** -- [ ] **DDL 실행 정상 동작 확인** -- [ ] **모든 단위 테스트 통과 (8개)** -- [ ] **모든 통합 테스트 작성 완료 (3개 시나리오)** -- [ ] **`import prisma` 완전 제거 및 `import { query } from "../database/db"` 사용** -- [ ] **성능 저하 없음** - ---- - -## 💡 특이사항 - -### DDL 실행의 위험성 - -DDL은 데이터베이스 스키마를 변경하므로 매우 신중하게 처리해야 합니다: - -- 실행 전 검증 필수 -- 롤백 SQL 자동 생성 -- 실행 이력 철저히 관리 - -### 트랜잭션 지원 제한 - -PostgreSQL에서 일부 DDL은 트랜잭션을 지원하지만, 일부는 자동 커밋됩니다: - -- CREATE TABLE: 트랜잭션 지원 ✅ -- DROP TABLE: 트랜잭션 지원 ✅ -- CREATE INDEX CONCURRENTLY: 트랜잭션 미지원 ❌ - ---- - -**작성일**: 2025-09-30 -**예상 소요 시간**: 0.5일 -**담당자**: 백엔드 개발팀 -**우선순위**: 🟢 낮음 (Phase 2.7) -**상태**: ⏳ **진행 예정** -**특이사항**: DDL 실행의 특성상 신중한 테스트 필요 diff --git a/PHASE2_SCREEN_MANAGEMENT_MIGRATION.md b/PHASE2_SCREEN_MANAGEMENT_MIGRATION.md deleted file mode 100644 index b85a4541..00000000 --- a/PHASE2_SCREEN_MANAGEMENT_MIGRATION.md +++ /dev/null @@ -1,566 +0,0 @@ -# 🖥️ 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>`SELECT tablename ...`; - -// Line 876: 테이블 존재 확인 -await prisma.$queryRaw>`SELECT tablename ...`; - -// Line 922: 테이블 컬럼 정보 조회 -await prisma.$queryRaw>`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( - `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( - `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) -**상태**: ✅ **완료** diff --git a/PHASE3.11_DDL_AUDIT_LOGGER_MIGRATION.md b/PHASE3.11_DDL_AUDIT_LOGGER_MIGRATION.md deleted file mode 100644 index b8b7a7fb..00000000 --- a/PHASE3.11_DDL_AUDIT_LOGGER_MIGRATION.md +++ /dev/null @@ -1,407 +0,0 @@ -# 📋 Phase 3.11: DDLAuditLogger Raw Query 전환 계획 - -## 📋 개요 - -DDLAuditLogger는 **8개의 Prisma 호출**이 있으며, DDL 실행 감사 로그 관리를 담당하는 서비스입니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | --------------------------------------------- | -| 파일 위치 | `backend-node/src/services/ddlAuditLogger.ts` | -| 파일 크기 | 350 라인 | -| Prisma 호출 | 0개 (전환 완료) | -| **현재 진행률** | **8/8 (100%)** ✅ **전환 완료** | -| 복잡도 | 중간 (통계 쿼리, $executeRaw) | -| 우선순위 | 🟡 중간 (Phase 3.11) | -| **상태** | ✅ **완료** | - -### 🎯 전환 목표 - -- ⏳ **8개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체** -- ⏳ DDL 감사 로그 기능 정상 동작 -- ⏳ 통계 쿼리 전환 (GROUP BY, COUNT, ORDER BY) -- ⏳ $executeRaw → query 전환 -- ⏳ $queryRawUnsafe → query 전환 -- ⏳ 동적 WHERE 조건 생성 -- ⏳ TypeScript 컴파일 성공 -- ⏳ **Prisma import 완전 제거** - ---- - -## 🔍 Prisma 사용 현황 분석 - -### 주요 Prisma 호출 (8개) - -#### 1. **logDDLStart()** - DDL 시작 로그 (INSERT) - -```typescript -// Line 27 -const logEntry = await prisma.$executeRaw` - INSERT INTO ddl_audit_logs ( - execution_id, ddl_type, table_name, status, - executed_by, company_code, started_at, metadata - ) VALUES ( - ${executionId}, ${ddlType}, ${tableName}, 'in_progress', - ${executedBy}, ${companyCode}, NOW(), ${JSON.stringify(metadata)}::jsonb - ) -`; -``` - -#### 2. **getAuditLogs()** - 감사 로그 목록 조회 (SELECT with filters) - -```typescript -// Line 162 -const logs = await prisma.$queryRawUnsafe(query, ...params); -``` - -- 동적 WHERE 조건 생성 -- 페이징 (OFFSET, LIMIT) -- 정렬 (ORDER BY) - -#### 3. **getAuditStats()** - 통계 조회 (복합 쿼리) - -```typescript -// Line 199 - 총 통계 -const totalStats = (await prisma.$queryRawUnsafe( - `SELECT - COUNT(*) as total_executions, - COUNT(CASE WHEN status = 'success' THEN 1 END) as successful, - COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed, - AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) as avg_duration - FROM ddl_audit_logs - WHERE ${whereClause}` -)) as any[]; - -// Line 212 - DDL 타입별 통계 -const ddlTypeStats = (await prisma.$queryRawUnsafe( - `SELECT ddl_type, COUNT(*) as count - FROM ddl_audit_logs - WHERE ${whereClause} - GROUP BY ddl_type - ORDER BY count DESC` -)) as any[]; - -// Line 224 - 사용자별 통계 -const userStats = (await prisma.$queryRawUnsafe( - `SELECT executed_by, COUNT(*) as count - FROM ddl_audit_logs - WHERE ${whereClause} - GROUP BY executed_by - ORDER BY count DESC - LIMIT 10` -)) as any[]; - -// Line 237 - 최근 실패 로그 -const recentFailures = (await prisma.$queryRawUnsafe( - `SELECT * FROM ddl_audit_logs - WHERE status = 'failed' AND ${whereClause} - ORDER BY started_at DESC - LIMIT 5` -)) as any[]; -``` - -#### 4. **getExecutionHistory()** - 실행 이력 조회 - -```typescript -// Line 287 -const history = await prisma.$queryRawUnsafe( - `SELECT * FROM ddl_audit_logs - WHERE table_name = $1 AND company_code = $2 - ORDER BY started_at DESC - LIMIT $3`, - tableName, - companyCode, - limit -); -``` - -#### 5. **cleanupOldLogs()** - 오래된 로그 삭제 - -```typescript -// Line 320 -const result = await prisma.$executeRaw` - DELETE FROM ddl_audit_logs - WHERE started_at < NOW() - INTERVAL '${retentionDays} days' - AND company_code = ${companyCode} -`; -``` - ---- - -## 💡 전환 전략 - -### 1단계: $executeRaw 전환 (2개) - -- `logDDLStart()` - INSERT -- `cleanupOldLogs()` - DELETE - -### 2단계: 단순 $queryRawUnsafe 전환 (1개) - -- `getExecutionHistory()` - 파라미터 바인딩 있음 - -### 3단계: 복잡한 $queryRawUnsafe 전환 (1개) - -- `getAuditLogs()` - 동적 WHERE 조건 - -### 4단계: 통계 쿼리 전환 (4개) - -- `getAuditStats()` 내부의 4개 쿼리 -- GROUP BY, CASE WHEN, AVG, EXTRACT - ---- - -## 💻 전환 예시 - -### 예시 1: $executeRaw → query (INSERT) - -**변경 전**: - -```typescript -const logEntry = await prisma.$executeRaw` - INSERT INTO ddl_audit_logs ( - execution_id, ddl_type, table_name, status, - executed_by, company_code, started_at, metadata - ) VALUES ( - ${executionId}, ${ddlType}, ${tableName}, 'in_progress', - ${executedBy}, ${companyCode}, NOW(), ${JSON.stringify(metadata)}::jsonb - ) -`; -``` - -**변경 후**: - -```typescript -await query( - `INSERT INTO ddl_audit_logs ( - execution_id, ddl_type, table_name, status, - executed_by, company_code, started_at, metadata - ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7::jsonb)`, - [ - executionId, - ddlType, - tableName, - "in_progress", - executedBy, - companyCode, - JSON.stringify(metadata), - ] -); -``` - -### 예시 2: 동적 WHERE 조건 - -**변경 전**: - -```typescript -let query = `SELECT * FROM ddl_audit_logs WHERE 1=1`; -const params: any[] = []; - -if (filters.ddlType) { - query += ` AND ddl_type = ?`; - params.push(filters.ddlType); -} - -const logs = await prisma.$queryRawUnsafe(query, ...params); -``` - -**변경 후**: - -```typescript -const conditions: string[] = []; -const params: any[] = []; -let paramIndex = 1; - -if (filters.ddlType) { - conditions.push(`ddl_type = $${paramIndex++}`); - params.push(filters.ddlType); -} - -const whereClause = - conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; -const sql = `SELECT * FROM ddl_audit_logs ${whereClause}`; - -const logs = await query(sql, params); -``` - -### 예시 3: 통계 쿼리 (GROUP BY) - -**변경 전**: - -```typescript -const ddlTypeStats = (await prisma.$queryRawUnsafe( - `SELECT ddl_type, COUNT(*) as count - FROM ddl_audit_logs - WHERE ${whereClause} - GROUP BY ddl_type - ORDER BY count DESC` -)) as any[]; -``` - -**변경 후**: - -```typescript -const ddlTypeStats = await query<{ ddl_type: string; count: string }>( - `SELECT ddl_type, COUNT(*) as count - FROM ddl_audit_logs - WHERE ${whereClause} - GROUP BY ddl_type - ORDER BY count DESC`, - params -); -``` - ---- - -## 🔧 기술적 고려사항 - -### 1. JSON 필드 처리 - -`metadata` 필드는 JSONB 타입으로, INSERT 시 `::jsonb` 캐스팅 필요: - -```typescript -JSON.stringify(metadata) + "::jsonb"; -``` - -### 2. 날짜/시간 함수 - -- `NOW()` - 현재 시간 -- `INTERVAL '30 days'` - 날짜 간격 -- `EXTRACT(EPOCH FROM ...)` - 초 단위 변환 - -### 3. CASE WHEN 집계 - -```sql -COUNT(CASE WHEN status = 'success' THEN 1 END) as successful -``` - -### 4. 동적 WHERE 조건 - -여러 필터를 조합하여 WHERE 절 생성: - -- ddlType -- tableName -- status -- executedBy -- dateRange (startDate, endDate) - ---- - -## ✅ 전환 완료 내역 - -### 전환된 Prisma 호출 (8개) - -1. **`logDDLExecution()`** - DDL 실행 로그 INSERT - - Before: `prisma.$executeRaw` - - After: `query()` with 7 parameters -2. **`getAuditLogs()`** - 감사 로그 목록 조회 - - Before: `prisma.$queryRawUnsafe` - - After: `query()` with dynamic WHERE clause -3. **`getDDLStatistics()`** - 통계 조회 (4개 쿼리) - - Before: 4x `prisma.$queryRawUnsafe` - - After: 4x `query()` - - totalStats: 전체 실행 통계 (CASE WHEN 집계) - - ddlTypeStats: DDL 타입별 통계 (GROUP BY) - - userStats: 사용자별 통계 (GROUP BY, LIMIT 10) - - recentFailures: 최근 실패 로그 (WHERE success = false) -4. **`getTableDDLHistory()`** - 테이블별 DDL 히스토리 - - Before: `prisma.$queryRawUnsafe` - - After: `query()` with table_name filter -5. **`cleanupOldLogs()`** - 오래된 로그 삭제 - - Before: `prisma.$executeRaw` - - After: `query()` with date filter - -### 주요 기술적 개선사항 - -1. **파라미터 바인딩**: PostgreSQL `$1, $2, ...` 스타일로 통일 -2. **동적 WHERE 조건**: 파라미터 인덱스 자동 증가 로직 유지 -3. **통계 쿼리**: CASE WHEN, GROUP BY, SUM 등 복잡한 집계 쿼리 완벽 전환 -4. **에러 처리**: 기존 try-catch 구조 유지 -5. **로깅**: logger 유틸리티 활용 유지 - -### 코드 정리 - -- [x] `import { PrismaClient }` 제거 -- [x] `const prisma = new PrismaClient()` 제거 -- [x] `import { query, queryOne }` 추가 -- [x] 모든 타입 정의 유지 -- [x] TypeScript 컴파일 성공 -- [x] Linter 오류 없음 - -## 📝 원본 전환 체크리스트 - -### 1단계: Prisma 호출 전환 (✅ 완료) - -- [ ] `logDDLStart()` - INSERT ($executeRaw → query) -- [ ] `logDDLComplete()` - UPDATE (이미 query 사용 중일 가능성) -- [ ] `logDDLError()` - UPDATE (이미 query 사용 중일 가능성) -- [ ] `getAuditLogs()` - SELECT with filters ($queryRawUnsafe → query) -- [ ] `getAuditStats()` 내 4개 쿼리: - - [ ] totalStats (집계 쿼리) - - [ ] ddlTypeStats (GROUP BY) - - [ ] userStats (GROUP BY + LIMIT) - - [ ] recentFailures (필터 + ORDER BY + LIMIT) -- [ ] `getExecutionHistory()` - SELECT with params ($queryRawUnsafe → query) -- [ ] `cleanupOldLogs()` - DELETE ($executeRaw → query) - -### 2단계: 코드 정리 - -- [ ] import 문 수정 (`prisma` → `query, queryOne`) -- [ ] Prisma import 완전 제거 -- [ ] 타입 정의 확인 - -### 3단계: 테스트 - -- [ ] 단위 테스트 작성 (8개) - - [ ] DDL 시작 로그 테스트 - - [ ] DDL 완료 로그 테스트 - - [ ] 감사 로그 목록 조회 테스트 - - [ ] 통계 조회 테스트 - - [ ] 실행 이력 조회 테스트 - - [ ] 오래된 로그 삭제 테스트 -- [ ] 통합 테스트 작성 (3개) - - [ ] 전체 DDL 실행 플로우 테스트 - - [ ] 필터링 및 페이징 테스트 - - [ ] 통계 정확성 테스트 -- [ ] 성능 테스트 - - [ ] 대량 로그 조회 성능 - - [ ] 통계 쿼리 성능 - -### 4단계: 문서화 - -- [ ] 전환 완료 문서 업데이트 -- [ ] 주요 변경사항 기록 -- [ ] 성능 벤치마크 결과 - ---- - -## 🎯 예상 난이도 및 소요 시간 - -- **난이도**: ⭐⭐⭐ (중간) - - 복잡한 통계 쿼리 (GROUP BY, CASE WHEN) - - 동적 WHERE 조건 생성 - - JSON 필드 처리 -- **예상 소요 시간**: 1~1.5시간 - - Prisma 호출 전환: 30분 - - 테스트: 20분 - - 문서화: 10분 - ---- - -## 📌 참고사항 - -### 관련 서비스 - -- `DDLExecutionService` - DDL 실행 (이미 전환 완료) -- `DDLSafetyValidator` - DDL 안전성 검증 - -### 의존성 - -- `../database/db` - query, queryOne 함수 -- `../types/ddl` - DDL 관련 타입 -- `../utils/logger` - 로깅 - ---- - -**상태**: ⏳ **대기 중** -**특이사항**: 통계 쿼리, JSON 필드, 동적 WHERE 조건 포함 diff --git a/PHASE3.12_EXTERNAL_CALL_CONFIG_SERVICE_MIGRATION.md b/PHASE3.12_EXTERNAL_CALL_CONFIG_SERVICE_MIGRATION.md deleted file mode 100644 index 00b9864f..00000000 --- a/PHASE3.12_EXTERNAL_CALL_CONFIG_SERVICE_MIGRATION.md +++ /dev/null @@ -1,356 +0,0 @@ -# 📋 Phase 3.12: ExternalCallConfigService Raw Query 전환 계획 - -## 📋 개요 - -ExternalCallConfigService는 **8개의 Prisma 호출**이 있으며, 외부 API 호출 설정 관리를 담당하는 서비스입니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | -------------------------------------------------------- | -| 파일 위치 | `backend-node/src/services/externalCallConfigService.ts` | -| 파일 크기 | 612 라인 | -| Prisma 호출 | 0개 (전환 완료) | -| **현재 진행률** | **8/8 (100%)** ✅ **전환 완료** | -| 복잡도 | 중간 (JSON 필드, 복잡한 CRUD) | -| 우선순위 | 🟡 중간 (Phase 3.12) | -| **상태** | ✅ **완료** | - -### 🎯 전환 목표 - -- ⏳ **8개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체** -- ⏳ 외부 호출 설정 CRUD 기능 정상 동작 -- ⏳ JSON 필드 처리 (headers, params, auth_config) -- ⏳ 동적 WHERE 조건 생성 -- ⏳ 민감 정보 암호화/복호화 유지 -- ⏳ TypeScript 컴파일 성공 -- ⏳ **Prisma import 완전 제거** - ---- - -## 🔍 예상 Prisma 사용 패턴 - -### 주요 기능 (8개 예상) - -#### 1. **외부 호출 설정 목록 조회** - -- findMany with filters -- 페이징, 정렬 -- 동적 WHERE 조건 (is_active, company_code, search) - -#### 2. **외부 호출 설정 단건 조회** - -- findUnique or findFirst -- config_id 기준 - -#### 3. **외부 호출 설정 생성** - -- create -- JSON 필드 처리 (headers, params, auth_config) -- 민감 정보 암호화 - -#### 4. **외부 호출 설정 수정** - -- update -- 동적 UPDATE 쿼리 -- JSON 필드 업데이트 - -#### 5. **외부 호출 설정 삭제** - -- delete or soft delete - -#### 6. **외부 호출 설정 복제** - -- findUnique + create - -#### 7. **외부 호출 설정 테스트** - -- findUnique -- 실제 HTTP 호출 - -#### 8. **외부 호출 이력 조회** - -- findMany with 관계 조인 -- 통계 쿼리 - ---- - -## 💡 전환 전략 - -### 1단계: 기본 CRUD 전환 (5개) - -- getExternalCallConfigs() - 목록 조회 -- getExternalCallConfig() - 단건 조회 -- createExternalCallConfig() - 생성 -- updateExternalCallConfig() - 수정 -- deleteExternalCallConfig() - 삭제 - -### 2단계: 추가 기능 전환 (3개) - -- duplicateExternalCallConfig() - 복제 -- testExternalCallConfig() - 테스트 -- getExternalCallHistory() - 이력 조회 - ---- - -## 💻 전환 예시 - -### 예시 1: 목록 조회 (동적 WHERE + JSON) - -**변경 전**: - -```typescript -const configs = await prisma.external_call_configs.findMany({ - where: { - company_code: companyCode, - is_active: isActive, - OR: [ - { config_name: { contains: search, mode: "insensitive" } }, - { endpoint_url: { contains: search, mode: "insensitive" } }, - ], - }, - orderBy: { created_at: "desc" }, - skip, - take: limit, -}); -``` - -**변경 후**: - -```typescript -const conditions: string[] = ["company_code = $1"]; -const params: any[] = [companyCode]; -let paramIndex = 2; - -if (isActive !== undefined) { - conditions.push(`is_active = $${paramIndex++}`); - params.push(isActive); -} - -if (search) { - conditions.push( - `(config_name ILIKE $${paramIndex} OR endpoint_url ILIKE $${paramIndex})` - ); - params.push(`%${search}%`); - paramIndex++; -} - -const configs = await query( - `SELECT * FROM external_call_configs - WHERE ${conditions.join(" AND ")} - ORDER BY created_at DESC - LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, - [...params, limit, skip] -); -``` - -### 예시 2: JSON 필드 생성 - -**변경 전**: - -```typescript -const config = await prisma.external_call_configs.create({ - data: { - config_name: data.config_name, - endpoint_url: data.endpoint_url, - http_method: data.http_method, - headers: data.headers, // JSON - params: data.params, // JSON - auth_config: encryptedAuthConfig, // JSON (암호화됨) - company_code: companyCode, - }, -}); -``` - -**변경 후**: - -```typescript -const config = await queryOne( - `INSERT INTO external_call_configs - (config_name, endpoint_url, http_method, headers, params, - auth_config, company_code, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) - RETURNING *`, - [ - data.config_name, - data.endpoint_url, - data.http_method, - JSON.stringify(data.headers), - JSON.stringify(data.params), - JSON.stringify(encryptedAuthConfig), - companyCode, - ] -); -``` - -### 예시 3: 동적 UPDATE (JSON 포함) - -**변경 전**: - -```typescript -const updateData: any = {}; -if (data.headers) updateData.headers = data.headers; -if (data.params) updateData.params = data.params; - -const config = await prisma.external_call_configs.update({ - where: { config_id: configId }, - data: updateData, -}); -``` - -**변경 후**: - -```typescript -const updateFields: string[] = ["updated_at = NOW()"]; -const values: any[] = []; -let paramIndex = 1; - -if (data.headers !== undefined) { - updateFields.push(`headers = $${paramIndex++}`); - values.push(JSON.stringify(data.headers)); -} - -if (data.params !== undefined) { - updateFields.push(`params = $${paramIndex++}`); - values.push(JSON.stringify(data.params)); -} - -const config = await queryOne( - `UPDATE external_call_configs - SET ${updateFields.join(", ")} - WHERE config_id = $${paramIndex} - RETURNING *`, - [...values, configId] -); -``` - ---- - -## 🔧 기술적 고려사항 - -### 1. JSON 필드 처리 - -3개의 JSON 필드가 있을 것으로 예상: - -- `headers` - HTTP 헤더 -- `params` - 쿼리 파라미터 -- `auth_config` - 인증 설정 (암호화됨) - -```typescript -// INSERT/UPDATE 시 -JSON.stringify(jsonData); - -// SELECT 후 -const parsedData = - typeof row.headers === "string" ? JSON.parse(row.headers) : row.headers; -``` - -### 2. 민감 정보 암호화 - -auth_config는 암호화되어 저장되므로, 기존 암호화/복호화 로직 유지: - -```typescript -import { encrypt, decrypt } from "../utils/encryption"; - -// 저장 시 -const encryptedAuthConfig = encrypt(JSON.stringify(authConfig)); - -// 조회 시 -const decryptedAuthConfig = JSON.parse(decrypt(row.auth_config)); -``` - -### 3. HTTP 메소드 검증 - -```typescript -const VALID_HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; -if (!VALID_HTTP_METHODS.includes(httpMethod)) { - throw new Error("Invalid HTTP method"); -} -``` - -### 4. URL 검증 - -```typescript -try { - new URL(endpointUrl); -} catch { - throw new Error("Invalid endpoint URL"); -} -``` - ---- - -## ✅ 전환 완료 내역 - -### 전환된 Prisma 호출 (8개) - -1. **`getConfigs()`** - 목록 조회 (findMany → query) -2. **`getConfigById()`** - 단건 조회 (findUnique → queryOne) -3. **`createConfig()`** - 중복 검사 (findFirst → queryOne) -4. **`createConfig()`** - 생성 (create → queryOne with INSERT) -5. **`updateConfig()`** - 중복 검사 (findFirst → queryOne) -6. **`updateConfig()`** - 수정 (update → queryOne with 동적 UPDATE) -7. **`deleteConfig()`** - 삭제 (update → query) -8. **`getExternalCallConfigsForButtonControl()`** - 조회 (findMany → query) - -### 주요 기술적 개선사항 - -- 동적 WHERE 조건 생성 (company_code, call_type, api_type, is_active, search) -- ILIKE를 활용한 대소문자 구분 없는 검색 -- 동적 UPDATE 쿼리 (9개 필드) -- JSON 필드 처리 (`config_data` → `JSON.stringify()`) -- 중복 검사 로직 유지 - -### 코드 정리 - -- [x] import 문 수정 완료 -- [x] Prisma import 완전 제거 -- [x] TypeScript 컴파일 성공 -- [x] Linter 오류 없음 - -## 📝 원본 전환 체크리스트 - -### 1단계: Prisma 호출 전환 (✅ 완료) - -- [ ] `getExternalCallConfigs()` - 목록 조회 (findMany + count) -- [ ] `getExternalCallConfig()` - 단건 조회 (findUnique) -- [ ] `createExternalCallConfig()` - 생성 (create) -- [ ] `updateExternalCallConfig()` - 수정 (update) -- [ ] `deleteExternalCallConfig()` - 삭제 (delete) -- [ ] `duplicateExternalCallConfig()` - 복제 (findUnique + create) -- [ ] `testExternalCallConfig()` - 테스트 (findUnique) -- [ ] `getExternalCallHistory()` - 이력 조회 (findMany) - -### 2단계: 코드 정리 - -- [ ] import 문 수정 (`prisma` → `query, queryOne`) -- [ ] JSON 필드 처리 확인 -- [ ] 암호화/복호화 로직 유지 -- [ ] Prisma import 완전 제거 - -### 3단계: 테스트 - -- [ ] 단위 테스트 작성 (8개) -- [ ] 통합 테스트 작성 (3개) -- [ ] 암호화 테스트 -- [ ] HTTP 호출 테스트 - -### 4단계: 문서화 - -- [ ] 전환 완료 문서 업데이트 -- [ ] API 문서 업데이트 - ---- - -## 🎯 예상 난이도 및 소요 시간 - -- **난이도**: ⭐⭐⭐ (중간) - - JSON 필드 처리 - - 암호화/복호화 로직 - - HTTP 호출 테스트 -- **예상 소요 시간**: 1~1.5시간 - ---- - -**상태**: ⏳ **대기 중** -**특이사항**: JSON 필드, 민감 정보 암호화, HTTP 호출 포함 diff --git a/PHASE3.13_ENTITY_JOIN_SERVICE_MIGRATION.md b/PHASE3.13_ENTITY_JOIN_SERVICE_MIGRATION.md deleted file mode 100644 index 30c1188d..00000000 --- a/PHASE3.13_ENTITY_JOIN_SERVICE_MIGRATION.md +++ /dev/null @@ -1,338 +0,0 @@ -# 📋 Phase 3.13: EntityJoinService Raw Query 전환 계획 - -## 📋 개요 - -EntityJoinService는 **5개의 Prisma 호출**이 있으며, 엔티티 간 조인 관계 관리를 담당하는 서비스입니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ------------------------------------------------ | -| 파일 위치 | `backend-node/src/services/entityJoinService.ts` | -| 파일 크기 | 575 라인 | -| Prisma 호출 | 0개 (전환 완료) | -| **현재 진행률** | **5/5 (100%)** ✅ **전환 완료** | -| 복잡도 | 중간 (조인 쿼리, 관계 설정) | -| 우선순위 | 🟡 중간 (Phase 3.13) | -| **상태** | ✅ **완료** | - -### 🎯 전환 목표 - -- ⏳ **5개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체** -- ⏳ 엔티티 조인 설정 CRUD 기능 정상 동작 -- ⏳ 복잡한 조인 쿼리 전환 (LEFT JOIN, INNER JOIN) -- ⏳ 조인 유효성 검증 -- ⏳ TypeScript 컴파일 성공 -- ⏳ **Prisma import 완전 제거** - ---- - -## 🔍 예상 Prisma 사용 패턴 - -### 주요 기능 (5개 예상) - -#### 1. **엔티티 조인 목록 조회** - -- findMany with filters -- 동적 WHERE 조건 -- 페이징, 정렬 - -#### 2. **엔티티 조인 단건 조회** - -- findUnique or findFirst -- join_id 기준 - -#### 3. **엔티티 조인 생성** - -- create -- 조인 유효성 검증 - -#### 4. **엔티티 조인 수정** - -- update -- 동적 UPDATE 쿼리 - -#### 5. **엔티티 조인 삭제** - -- delete - ---- - -## 💡 전환 전략 - -### 1단계: 기본 CRUD 전환 (5개) - -- getEntityJoins() - 목록 조회 -- getEntityJoin() - 단건 조회 -- createEntityJoin() - 생성 -- updateEntityJoin() - 수정 -- deleteEntityJoin() - 삭제 - ---- - -## 💻 전환 예시 - -### 예시 1: 조인 설정 조회 (LEFT JOIN으로 테이블 정보 포함) - -**변경 전**: - -```typescript -const joins = await prisma.entity_joins.findMany({ - where: { - company_code: companyCode, - is_active: true, - }, - include: { - source_table: true, - target_table: true, - }, - orderBy: { created_at: "desc" }, -}); -``` - -**변경 후**: - -```typescript -const joins = await query( - `SELECT - ej.*, - st.table_name as source_table_name, - st.table_label as source_table_label, - tt.table_name as target_table_name, - tt.table_label as target_table_label - FROM entity_joins ej - LEFT JOIN tables st ON ej.source_table_id = st.table_id - LEFT JOIN tables tt ON ej.target_table_id = tt.table_id - WHERE ej.company_code = $1 AND ej.is_active = $2 - ORDER BY ej.created_at DESC`, - [companyCode, true] -); -``` - -### 예시 2: 조인 생성 (유효성 검증 포함) - -**변경 전**: - -```typescript -// 조인 유효성 검증 -const sourceTable = await prisma.tables.findUnique({ - where: { table_id: sourceTableId }, -}); - -const targetTable = await prisma.tables.findUnique({ - where: { table_id: targetTableId }, -}); - -if (!sourceTable || !targetTable) { - throw new Error("Invalid table references"); -} - -// 조인 생성 -const join = await prisma.entity_joins.create({ - data: { - source_table_id: sourceTableId, - target_table_id: targetTableId, - join_type: joinType, - join_condition: joinCondition, - company_code: companyCode, - }, -}); -``` - -**변경 후**: - -```typescript -// 조인 유효성 검증 (Promise.all로 병렬 실행) -const [sourceTable, targetTable] = await Promise.all([ - queryOne(`SELECT * FROM tables WHERE table_id = $1`, [sourceTableId]), - queryOne(`SELECT * FROM tables WHERE table_id = $1`, [targetTableId]), -]); - -if (!sourceTable || !targetTable) { - throw new Error("Invalid table references"); -} - -// 조인 생성 -const join = await queryOne( - `INSERT INTO entity_joins - (source_table_id, target_table_id, join_type, join_condition, - company_code, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) - RETURNING *`, - [sourceTableId, targetTableId, joinType, joinCondition, companyCode] -); -``` - -### 예시 3: 조인 수정 - -**변경 전**: - -```typescript -const join = await prisma.entity_joins.update({ - where: { join_id: joinId }, - data: { - join_type: joinType, - join_condition: joinCondition, - is_active: isActive, - }, -}); -``` - -**변경 후**: - -```typescript -const updateFields: string[] = ["updated_at = NOW()"]; -const values: any[] = []; -let paramIndex = 1; - -if (joinType !== undefined) { - updateFields.push(`join_type = $${paramIndex++}`); - values.push(joinType); -} - -if (joinCondition !== undefined) { - updateFields.push(`join_condition = $${paramIndex++}`); - values.push(joinCondition); -} - -if (isActive !== undefined) { - updateFields.push(`is_active = $${paramIndex++}`); - values.push(isActive); -} - -const join = await queryOne( - `UPDATE entity_joins - SET ${updateFields.join(", ")} - WHERE join_id = $${paramIndex} - RETURNING *`, - [...values, joinId] -); -``` - ---- - -## 🔧 기술적 고려사항 - -### 1. 조인 타입 검증 - -```typescript -const VALID_JOIN_TYPES = ["INNER", "LEFT", "RIGHT", "FULL"]; -if (!VALID_JOIN_TYPES.includes(joinType)) { - throw new Error("Invalid join type"); -} -``` - -### 2. 조인 조건 검증 - -```typescript -// 조인 조건은 SQL 조건식 형태 (예: "source.id = target.parent_id") -// SQL 인젝션 방지를 위한 검증 필요 -const isValidJoinCondition = /^[\w\s.=<>]+$/.test(joinCondition); -if (!isValidJoinCondition) { - throw new Error("Invalid join condition"); -} -``` - -### 3. 순환 참조 방지 - -```typescript -// 조인이 순환 참조를 만들지 않는지 검증 -async function checkCircularReference( - sourceTableId: number, - targetTableId: number -): Promise { - // 재귀적으로 조인 관계 확인 - // ... -} -``` - -### 4. LEFT JOIN으로 관련 테이블 정보 조회 - -조인 설정 조회 시 source/target 테이블 정보를 함께 가져오기 위해 LEFT JOIN 사용 - ---- - -## ✅ 전환 완료 내역 - -### 전환된 Prisma 호출 (5개) - -1. **`detectEntityJoins()`** - 엔티티 컬럼 감지 (findMany → query) - - - column_labels 조회 - - web_type = 'entity' 필터 - - reference_table/reference_column IS NOT NULL - -2. **`validateJoinConfig()`** - 테이블 존재 확인 ($queryRaw → query) - - - information_schema.tables 조회 - - 참조 테이블 검증 - -3. **`validateJoinConfig()`** - 컬럼 존재 확인 ($queryRaw → query) - - - information_schema.columns 조회 - - 표시 컬럼 검증 - -4. **`getReferenceTableColumns()`** - 컬럼 정보 조회 ($queryRaw → query) - - - information_schema.columns 조회 - - 문자열 타입 컬럼만 필터 - -5. **`getReferenceTableColumns()`** - 라벨 정보 조회 (findMany → query) - - column_labels 조회 - - 컬럼명과 라벨 매핑 - -### 주요 기술적 개선사항 - -- **information_schema 쿼리**: 파라미터 바인딩으로 변경 ($1, $2) -- **타입 안전성**: 명확한 반환 타입 지정 -- **IS NOT NULL 조건**: Prisma의 { not: null } → IS NOT NULL -- **IN 조건**: 여러 데이터 타입 필터링 - -### 코드 정리 - -- [x] PrismaClient import 제거 -- [x] import 문 수정 완료 -- [x] TypeScript 컴파일 성공 -- [x] Linter 오류 없음 - -## 📝 원본 전환 체크리스트 - -### 1단계: Prisma 호출 전환 (✅ 완료) - -- [ ] `getEntityJoins()` - 목록 조회 (findMany with include) -- [ ] `getEntityJoin()` - 단건 조회 (findUnique) -- [ ] `createEntityJoin()` - 생성 (create with validation) -- [ ] `updateEntityJoin()` - 수정 (update) -- [ ] `deleteEntityJoin()` - 삭제 (delete) - -### 2단계: 코드 정리 - -- [ ] import 문 수정 (`prisma` → `query, queryOne`) -- [ ] 조인 유효성 검증 로직 유지 -- [ ] Prisma import 완전 제거 - -### 3단계: 테스트 - -- [ ] 단위 테스트 작성 (5개) -- [ ] 조인 유효성 검증 테스트 -- [ ] 순환 참조 방지 테스트 -- [ ] 통합 테스트 작성 (2개) - -### 4단계: 문서화 - -- [ ] 전환 완료 문서 업데이트 - ---- - -## 🎯 예상 난이도 및 소요 시간 - -- **난이도**: ⭐⭐⭐ (중간) - - LEFT JOIN 쿼리 - - 조인 유효성 검증 - - 순환 참조 방지 -- **예상 소요 시간**: 1시간 - ---- - -**상태**: ⏳ **대기 중** -**특이사항**: LEFT JOIN, 조인 유효성 검증, 순환 참조 방지 포함 diff --git a/PHASE3.14_AUTH_SERVICE_MIGRATION.md b/PHASE3.14_AUTH_SERVICE_MIGRATION.md deleted file mode 100644 index 4c96e57b..00000000 --- a/PHASE3.14_AUTH_SERVICE_MIGRATION.md +++ /dev/null @@ -1,456 +0,0 @@ -# 📋 Phase 3.14: AuthService Raw Query 전환 계획 - -## 📋 개요 - -AuthService는 **5개의 Prisma 호출**이 있으며, 사용자 인증 및 권한 관리를 담당하는 서비스입니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ------------------------------------------ | -| 파일 위치 | `backend-node/src/services/authService.ts` | -| 파일 크기 | 335 라인 | -| Prisma 호출 | 0개 (이미 Phase 1.5에서 전환 완료) | -| **현재 진행률** | **5/5 (100%)** ✅ **전환 완료** | -| 복잡도 | 높음 (보안, 암호화, 세션 관리) | -| 우선순위 | 🟡 중간 (Phase 3.14) | -| **상태** | ✅ **완료** (Phase 1.5에서 이미 완료) | - -### 🎯 전환 목표 - -- ⏳ **5개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체** -- ⏳ 사용자 인증 기능 정상 동작 -- ⏳ 비밀번호 암호화/검증 유지 -- ⏳ 세션 관리 기능 유지 -- ⏳ 권한 검증 기능 유지 -- ⏳ TypeScript 컴파일 성공 -- ⏳ **Prisma import 완전 제거** - ---- - -## 🔍 예상 Prisma 사용 패턴 - -### 주요 기능 (5개 예상) - -#### 1. **사용자 로그인 (인증)** - -- findFirst or findUnique -- 이메일/사용자명으로 조회 -- 비밀번호 검증 - -#### 2. **사용자 정보 조회** - -- findUnique -- user_id 기준 -- 권한 정보 포함 - -#### 3. **사용자 생성 (회원가입)** - -- create -- 비밀번호 암호화 -- 중복 검사 - -#### 4. **비밀번호 변경** - -- update -- 기존 비밀번호 검증 -- 새 비밀번호 암호화 - -#### 5. **세션 관리** - -- create, update, delete -- 세션 토큰 저장/조회 - ---- - -## 💡 전환 전략 - -### 1단계: 인증 관련 전환 (2개) - -- login() - 사용자 조회 + 비밀번호 검증 -- getUserInfo() - 사용자 정보 조회 - -### 2단계: 사용자 관리 전환 (2개) - -- createUser() - 사용자 생성 -- changePassword() - 비밀번호 변경 - -### 3단계: 세션 관리 전환 (1개) - -- manageSession() - 세션 CRUD - ---- - -## 💻 전환 예시 - -### 예시 1: 로그인 (비밀번호 검증) - -**변경 전**: - -```typescript -async login(username: string, password: string) { - const user = await prisma.users.findFirst({ - where: { - OR: [ - { username: username }, - { email: username }, - ], - is_active: true, - }, - }); - - if (!user) { - throw new Error("User not found"); - } - - const isPasswordValid = await bcrypt.compare(password, user.password_hash); - - if (!isPasswordValid) { - throw new Error("Invalid password"); - } - - return user; -} -``` - -**변경 후**: - -```typescript -async login(username: string, password: string) { - const user = await queryOne( - `SELECT * FROM users - WHERE (username = $1 OR email = $1) - AND is_active = $2`, - [username, true] - ); - - if (!user) { - throw new Error("User not found"); - } - - const isPasswordValid = await bcrypt.compare(password, user.password_hash); - - if (!isPasswordValid) { - throw new Error("Invalid password"); - } - - return user; -} -``` - -### 예시 2: 사용자 생성 (비밀번호 암호화) - -**변경 전**: - -```typescript -async createUser(userData: CreateUserDto) { - // 중복 검사 - const existing = await prisma.users.findFirst({ - where: { - OR: [ - { username: userData.username }, - { email: userData.email }, - ], - }, - }); - - if (existing) { - throw new Error("User already exists"); - } - - // 비밀번호 암호화 - const passwordHash = await bcrypt.hash(userData.password, 10); - - // 사용자 생성 - const user = await prisma.users.create({ - data: { - username: userData.username, - email: userData.email, - password_hash: passwordHash, - company_code: userData.company_code, - }, - }); - - return user; -} -``` - -**변경 후**: - -```typescript -async createUser(userData: CreateUserDto) { - // 중복 검사 - const existing = await queryOne( - `SELECT * FROM users - WHERE username = $1 OR email = $2`, - [userData.username, userData.email] - ); - - if (existing) { - throw new Error("User already exists"); - } - - // 비밀번호 암호화 - const passwordHash = await bcrypt.hash(userData.password, 10); - - // 사용자 생성 - const user = await queryOne( - `INSERT INTO users - (username, email, password_hash, company_code, created_at, updated_at) - VALUES ($1, $2, $3, $4, NOW(), NOW()) - RETURNING *`, - [userData.username, userData.email, passwordHash, userData.company_code] - ); - - return user; -} -``` - -### 예시 3: 비밀번호 변경 - -**변경 전**: - -```typescript -async changePassword( - userId: number, - oldPassword: string, - newPassword: string -) { - const user = await prisma.users.findUnique({ - where: { user_id: userId }, - }); - - if (!user) { - throw new Error("User not found"); - } - - const isOldPasswordValid = await bcrypt.compare( - oldPassword, - user.password_hash - ); - - if (!isOldPasswordValid) { - throw new Error("Invalid old password"); - } - - const newPasswordHash = await bcrypt.hash(newPassword, 10); - - await prisma.users.update({ - where: { user_id: userId }, - data: { password_hash: newPasswordHash }, - }); -} -``` - -**변경 후**: - -```typescript -async changePassword( - userId: number, - oldPassword: string, - newPassword: string -) { - const user = await queryOne( - `SELECT * FROM users WHERE user_id = $1`, - [userId] - ); - - if (!user) { - throw new Error("User not found"); - } - - const isOldPasswordValid = await bcrypt.compare( - oldPassword, - user.password_hash - ); - - if (!isOldPasswordValid) { - throw new Error("Invalid old password"); - } - - const newPasswordHash = await bcrypt.hash(newPassword, 10); - - await query( - `UPDATE users - SET password_hash = $1, updated_at = NOW() - WHERE user_id = $2`, - [newPasswordHash, userId] - ); -} -``` - ---- - -## 🔧 기술적 고려사항 - -### 1. 비밀번호 보안 - -```typescript -import bcrypt from "bcrypt"; - -// 비밀번호 해싱 (회원가입, 비밀번호 변경) -const SALT_ROUNDS = 10; -const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); - -// 비밀번호 검증 (로그인) -const isValid = await bcrypt.compare(plainPassword, passwordHash); -``` - -### 2. SQL 인젝션 방지 - -```typescript -// ❌ 위험: 직접 문자열 결합 -const sql = `SELECT * FROM users WHERE username = '${username}'`; - -// ✅ 안전: 파라미터 바인딩 -const user = await queryOne(`SELECT * FROM users WHERE username = $1`, [ - username, -]); -``` - -### 3. 세션 토큰 관리 - -```typescript -import crypto from "crypto"; - -// 세션 토큰 생성 -const sessionToken = crypto.randomBytes(32).toString("hex"); - -// 세션 저장 -await query( - `INSERT INTO user_sessions (user_id, session_token, expires_at) - VALUES ($1, $2, NOW() + INTERVAL '1 day')`, - [userId, sessionToken] -); -``` - -### 4. 권한 검증 - -```typescript -async checkPermission(userId: number, permission: string): Promise { - const result = await queryOne<{ has_permission: boolean }>( - `SELECT EXISTS ( - SELECT 1 FROM user_permissions up - JOIN permissions p ON up.permission_id = p.permission_id - WHERE up.user_id = $1 AND p.permission_name = $2 - ) as has_permission`, - [userId, permission] - ); - - return result?.has_permission || false; -} -``` - ---- - -## ✅ 전환 완료 내역 (Phase 1.5에서 이미 완료됨) - -AuthService는 Phase 1.5에서 이미 Raw Query로 전환이 완료되었습니다. - -### 전환된 Prisma 호출 (5개) - -1. **`loginPwdCheck()`** - 로그인 비밀번호 검증 - - - user_info 테이블에서 비밀번호 조회 - - EncryptUtil을 활용한 비밀번호 검증 - - 마스터 패스워드 지원 - -2. **`insertLoginAccessLog()`** - 로그인 로그 기록 - - - login_access_log 테이블에 INSERT - - 로그인 시간, IP 주소 등 기록 - -3. **`getUserInfo()`** - 사용자 정보 조회 - - - user_info 테이블 조회 - - PersonBean 객체로 반환 - -4. **`updateLastLoginDate()`** - 마지막 로그인 시간 업데이트 - - - user_info 테이블 UPDATE - - last_login_date 갱신 - -5. **`checkUserPermission()`** - 사용자 권한 확인 - - user_auth 테이블 조회 - - 권한 코드 검증 - -### 주요 기술적 특징 - -- **보안**: EncryptUtil을 활용한 안전한 비밀번호 검증 -- **JWT 토큰**: JwtUtils를 활용한 토큰 생성 및 검증 -- **로깅**: 상세한 로그인 이력 기록 -- **에러 처리**: 안전한 에러 메시지 반환 - -### 코드 상태 - -- [x] Prisma import 없음 -- [x] query 함수 사용 중 -- [x] TypeScript 컴파일 성공 -- [x] 보안 로직 유지 - -## 📝 원본 전환 체크리스트 - -### 1단계: Prisma 호출 전환 (✅ Phase 1.5에서 완료) - -- [ ] `login()` - 사용자 조회 + 비밀번호 검증 (findFirst) -- [ ] `getUserInfo()` - 사용자 정보 조회 (findUnique) -- [ ] `createUser()` - 사용자 생성 (create with 중복 검사) -- [ ] `changePassword()` - 비밀번호 변경 (findUnique + update) -- [ ] `manageSession()` - 세션 관리 (create/update/delete) - -### 2단계: 보안 검증 - -- [ ] 비밀번호 해싱 로직 유지 (bcrypt) -- [ ] SQL 인젝션 방지 확인 -- [ ] 세션 토큰 보안 확인 -- [ ] 중복 계정 방지 확인 - -### 3단계: 테스트 - -- [ ] 단위 테스트 작성 (5개) - - [ ] 로그인 성공/실패 테스트 - - [ ] 사용자 생성 테스트 - - [ ] 비밀번호 변경 테스트 - - [ ] 세션 관리 테스트 - - [ ] 권한 검증 테스트 -- [ ] 보안 테스트 - - [ ] SQL 인젝션 테스트 - - [ ] 비밀번호 강도 테스트 - - [ ] 세션 탈취 방지 테스트 -- [ ] 통합 테스트 작성 (2개) - -### 4단계: 문서화 - -- [ ] 전환 완료 문서 업데이트 -- [ ] 보안 가이드 업데이트 - ---- - -## 🎯 예상 난이도 및 소요 시간 - -- **난이도**: ⭐⭐⭐⭐ (높음) - - 보안 크리티컬 (비밀번호, 세션) - - SQL 인젝션 방지 필수 - - 철저한 테스트 필요 -- **예상 소요 시간**: 1.5~2시간 - - Prisma 호출 전환: 40분 - - 보안 검증: 40분 - - 테스트: 40분 - ---- - -## ⚠️ 주의사항 - -### 보안 필수 체크리스트 - -1. ✅ 모든 사용자 입력은 파라미터 바인딩 사용 -2. ✅ 비밀번호는 절대 평문 저장 금지 (bcrypt 사용) -3. ✅ 세션 토큰은 충분히 길고 랜덤해야 함 -4. ✅ 비밀번호 실패 시 구체적 오류 메시지 금지 ("User not found" vs "Invalid credentials") -5. ✅ 로그인 실패 횟수 제한 (Brute Force 방지) - ---- - -**상태**: ⏳ **대기 중** -**특이사항**: 보안 크리티컬, 비밀번호 암호화, 세션 관리 포함 -**⚠️ 주의**: 이 서비스는 보안에 매우 중요하므로 신중한 테스트 필수! diff --git a/PHASE3.15_BATCH_SERVICES_MIGRATION.md b/PHASE3.15_BATCH_SERVICES_MIGRATION.md deleted file mode 100644 index 6cb541fc..00000000 --- a/PHASE3.15_BATCH_SERVICES_MIGRATION.md +++ /dev/null @@ -1,515 +0,0 @@ -# 📋 Phase 3.15: Batch Services Raw Query 전환 계획 - -## 📋 개요 - -배치 관련 서비스들은 총 **24개의 Prisma 호출**이 있으며, 배치 작업 실행 및 관리를 담당합니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ---------------------------------------------------------- | -| 대상 서비스 | 4개 (BatchExternalDb, ExecutionLog, Management, Scheduler) | -| 파일 위치 | `backend-node/src/services/batch*.ts` | -| 총 파일 크기 | 2,161 라인 | -| Prisma 호출 | 0개 (전환 완료) | -| **현재 진행률** | **24/24 (100%)** ✅ **전환 완료** | -| 복잡도 | 높음 (외부 DB 연동, 스케줄링, 트랜잭션) | -| 우선순위 | 🔴 높음 (Phase 3.15) | -| **상태** | ✅ **완료** | - ---- - -## ✅ 전환 완료 내역 - -### 전환된 Prisma 호출 (24개) - -#### 1. BatchExternalDbService (8개) - -- `getAvailableConnections()` - findMany → query -- `getTables()` - $queryRaw → query (information_schema) -- `getTableColumns()` - $queryRaw → query (information_schema) -- `getExternalTables()` - findUnique → queryOne (x5) - -#### 2. BatchExecutionLogService (7개) - -- `getExecutionLogs()` - findMany + count → query (JOIN + 동적 WHERE) -- `createExecutionLog()` - create → queryOne (INSERT RETURNING) -- `updateExecutionLog()` - update → queryOne (동적 UPDATE) -- `deleteExecutionLog()` - delete → query -- `getLatestExecutionLog()` - findFirst → queryOne -- `getExecutionStats()` - findMany → query (동적 WHERE) - -#### 3. BatchManagementService (5개) - -- `getAvailableConnections()` - findMany → query -- `getTables()` - $queryRaw → query (information_schema) -- `getTableColumns()` - $queryRaw → query (information_schema) -- `getExternalTables()` - findUnique → queryOne (x2) - -#### 4. BatchSchedulerService (4개) - -- `loadActiveBatchConfigs()` - findMany → query (JOIN with json_agg) -- `updateBatchSchedule()` - findUnique → query (JOIN with json_agg) -- `getDataFromSource()` - $queryRawUnsafe → query -- `insertDataToTarget()` - $executeRawUnsafe → query - -### 주요 기술적 해결 사항 - -1. **외부 DB 연결 조회 반복** - - - 5개의 `findUnique` 호출을 `queryOne`으로 일괄 전환 - - 암호화/복호화 로직 유지 - -2. **배치 설정 + 매핑 JOIN** - - - Prisma `include` → `json_agg` + `json_build_object` - - `FILTER (WHERE bm.id IS NOT NULL)` 로 NULL 방지 - - 계층적 JSON 데이터 생성 - -3. **동적 WHERE 절 생성** - - - 조건부 필터링 (batch_config_id, execution_status, 날짜 범위) - - 파라미터 인덱스 동적 관리 - -4. **동적 UPDATE 쿼리** - - - undefined 필드 제외 - - 8개 필드의 조건부 업데이트 - -5. **통계 쿼리 전환** - - 클라이언트 사이드 집계 유지 - - 원본 데이터만 쿼리로 조회 - -### 컴파일 상태 - -✅ TypeScript 컴파일 성공 -✅ Linter 오류 없음 - ---- - -## 🔍 서비스별 상세 분석 - -### 1. BatchExternalDbService (8개 호출, 943 라인) - -**주요 기능**: - -- 외부 DB에서 배치 데이터 조회 -- 외부 DB로 배치 데이터 저장 -- 외부 DB 연결 관리 -- 데이터 변환 및 매핑 - -**예상 Prisma 호출**: - -- `getExternalDbConnection()` - 외부 DB 연결 정보 조회 -- `fetchDataFromExternalDb()` - 외부 DB 데이터 조회 -- `saveDataToExternalDb()` - 외부 DB 데이터 저장 -- `validateExternalDbConnection()` - 연결 검증 -- `getExternalDbTables()` - 테이블 목록 조회 -- `getExternalDbColumns()` - 컬럼 정보 조회 -- `executeBatchQuery()` - 배치 쿼리 실행 -- `getBatchExecutionStatus()` - 실행 상태 조회 - -**기술적 고려사항**: - -- 다양한 DB 타입 지원 (PostgreSQL, MySQL, Oracle, MSSQL) -- 연결 풀 관리 -- 트랜잭션 처리 -- 에러 핸들링 및 재시도 - ---- - -### 2. BatchExecutionLogService (7개 호출, 299 라인) - -**주요 기능**: - -- 배치 실행 로그 생성 -- 배치 실행 이력 조회 -- 배치 실행 통계 -- 로그 정리 - -**예상 Prisma 호출**: - -- `createExecutionLog()` - 실행 로그 생성 -- `updateExecutionLog()` - 실행 로그 업데이트 -- `getExecutionLogs()` - 실행 로그 목록 조회 -- `getExecutionLogById()` - 실행 로그 단건 조회 -- `getExecutionStats()` - 실행 통계 조회 -- `cleanupOldLogs()` - 오래된 로그 삭제 -- `getFailedExecutions()` - 실패한 실행 조회 - -**기술적 고려사항**: - -- 대용량 로그 처리 -- 통계 쿼리 최적화 -- 로그 보관 정책 -- 페이징 및 필터링 - ---- - -### 3. BatchManagementService (5개 호출, 373 라인) - -**주요 기능**: - -- 배치 작업 설정 관리 -- 배치 작업 실행 -- 배치 작업 중지 -- 배치 작업 모니터링 - -**예상 Prisma 호출**: - -- `getBatchJobs()` - 배치 작업 목록 조회 -- `getBatchJob()` - 배치 작업 단건 조회 -- `createBatchJob()` - 배치 작업 생성 -- `updateBatchJob()` - 배치 작업 수정 -- `deleteBatchJob()` - 배치 작업 삭제 - -**기술적 고려사항**: - -- JSON 설정 필드 (job_config) -- 작업 상태 관리 -- 동시 실행 제어 -- 의존성 관리 - ---- - -### 4. BatchSchedulerService (4개 호출, 546 라인) - -**주요 기능**: - -- 배치 스케줄 설정 -- Cron 표현식 관리 -- 스케줄 실행 -- 다음 실행 시간 계산 - -**예상 Prisma 호출**: - -- `getScheduledBatches()` - 스케줄된 배치 조회 -- `createSchedule()` - 스케줄 생성 -- `updateSchedule()` - 스케줄 수정 -- `deleteSchedule()` - 스케줄 삭제 - -**기술적 고려사항**: - -- Cron 표현식 파싱 -- 시간대 처리 -- 실행 이력 추적 -- 스케줄 충돌 방지 - ---- - -## 💡 통합 전환 전략 - -### Phase 1: 핵심 서비스 전환 (12개) - -**BatchManagementService (5개) + BatchExecutionLogService (7개)** - -- 배치 관리 및 로깅 기능 우선 -- 상대적으로 단순한 CRUD - -### Phase 2: 스케줄러 전환 (4개) - -**BatchSchedulerService (4개)** - -- 스케줄 관리 -- Cron 표현식 처리 - -### Phase 3: 외부 DB 연동 전환 (8개) - -**BatchExternalDbService (8개)** - -- 가장 복잡한 서비스 -- 외부 DB 연결 및 쿼리 - ---- - -## 💻 전환 예시 - -### 예시 1: 배치 실행 로그 생성 - -**변경 전**: - -```typescript -const log = await prisma.batch_execution_logs.create({ - data: { - batch_id: batchId, - status: "running", - started_at: new Date(), - execution_params: params, - company_code: companyCode, - }, -}); -``` - -**변경 후**: - -```typescript -const log = await queryOne( - `INSERT INTO batch_execution_logs - (batch_id, status, started_at, execution_params, company_code) - VALUES ($1, $2, NOW(), $3, $4) - RETURNING *`, - [batchId, "running", JSON.stringify(params), companyCode] -); -``` - -### 예시 2: 배치 통계 조회 - -**변경 전**: - -```typescript -const stats = await prisma.batch_execution_logs.groupBy({ - by: ["status"], - where: { - batch_id: batchId, - started_at: { gte: startDate, lte: endDate }, - }, - _count: { id: true }, -}); -``` - -**변경 후**: - -```typescript -const stats = await query<{ status: string; count: string }>( - `SELECT status, COUNT(*) as count - FROM batch_execution_logs - WHERE batch_id = $1 - AND started_at >= $2 - AND started_at <= $3 - GROUP BY status`, - [batchId, startDate, endDate] -); -``` - -### 예시 3: 외부 DB 연결 및 쿼리 - -**변경 전**: - -```typescript -// 연결 정보 조회 -const connection = await prisma.external_db_connections.findUnique({ - where: { id: connectionId }, -}); - -// 외부 DB 쿼리 실행 (Prisma 사용 불가, 이미 Raw Query일 가능성) -const externalData = await externalDbClient.query(sql); -``` - -**변경 후**: - -```typescript -// 연결 정보 조회 -const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] -); - -// 외부 DB 쿼리 실행 (기존 로직 유지) -const externalData = await externalDbClient.query(sql); -``` - -### 예시 4: 스케줄 관리 - -**변경 전**: - -```typescript -const schedule = await prisma.batch_schedules.create({ - data: { - batch_id: batchId, - cron_expression: cronExp, - is_active: true, - next_run_at: calculateNextRun(cronExp), - }, -}); -``` - -**변경 후**: - -```typescript -const nextRun = calculateNextRun(cronExp); - -const schedule = await queryOne( - `INSERT INTO batch_schedules - (batch_id, cron_expression, is_active, next_run_at, created_at, updated_at) - VALUES ($1, $2, $3, $4, NOW(), NOW()) - RETURNING *`, - [batchId, cronExp, true, nextRun] -); -``` - ---- - -## 🔧 기술적 고려사항 - -### 1. 외부 DB 연결 관리 - -```typescript -import { DatabaseConnectorFactory } from "../database/connectorFactory"; - -// 외부 DB 연결 생성 -const connector = DatabaseConnectorFactory.create(connection); -const externalClient = await connector.connect(); - -try { - // 쿼리 실행 - const result = await externalClient.query(sql, params); -} finally { - await connector.disconnect(); -} -``` - -### 2. 트랜잭션 처리 - -```typescript -await transaction(async (client) => { - // 배치 상태 업데이트 - await client.query(`UPDATE batch_jobs SET status = $1 WHERE id = $2`, [ - "running", - batchId, - ]); - - // 실행 로그 생성 - await client.query( - `INSERT INTO batch_execution_logs (batch_id, status, started_at) - VALUES ($1, $2, NOW())`, - [batchId, "running"] - ); -}); -``` - -### 3. Cron 표현식 처리 - -```typescript -import cron from "node-cron"; - -// Cron 표현식 검증 -const isValid = cron.validate(cronExpression); - -// 다음 실행 시간 계산 -function calculateNextRun(cronExp: string): Date { - // Cron 파서를 사용하여 다음 실행 시간 계산 - // ... -} -``` - -### 4. 대용량 데이터 처리 - -```typescript -// 스트리밍 방식으로 대용량 데이터 처리 -const stream = await query( - `SELECT * FROM large_table WHERE batch_id = $1`, - [batchId] -); - -for await (const row of stream) { - // 행 단위 처리 -} -``` - ---- - -## 📝 전환 체크리스트 - -### BatchExternalDbService (8개) - -- [ ] `getExternalDbConnection()` - 연결 정보 조회 -- [ ] `fetchDataFromExternalDb()` - 외부 DB 데이터 조회 -- [ ] `saveDataToExternalDb()` - 외부 DB 데이터 저장 -- [ ] `validateExternalDbConnection()` - 연결 검증 -- [ ] `getExternalDbTables()` - 테이블 목록 조회 -- [ ] `getExternalDbColumns()` - 컬럼 정보 조회 -- [ ] `executeBatchQuery()` - 배치 쿼리 실행 -- [ ] `getBatchExecutionStatus()` - 실행 상태 조회 - -### BatchExecutionLogService (7개) - -- [ ] `createExecutionLog()` - 실행 로그 생성 -- [ ] `updateExecutionLog()` - 실행 로그 업데이트 -- [ ] `getExecutionLogs()` - 실행 로그 목록 조회 -- [ ] `getExecutionLogById()` - 실행 로그 단건 조회 -- [ ] `getExecutionStats()` - 실행 통계 조회 -- [ ] `cleanupOldLogs()` - 오래된 로그 삭제 -- [ ] `getFailedExecutions()` - 실패한 실행 조회 - -### BatchManagementService (5개) - -- [ ] `getBatchJobs()` - 배치 작업 목록 조회 -- [ ] `getBatchJob()` - 배치 작업 단건 조회 -- [ ] `createBatchJob()` - 배치 작업 생성 -- [ ] `updateBatchJob()` - 배치 작업 수정 -- [ ] `deleteBatchJob()` - 배치 작업 삭제 - -### BatchSchedulerService (4개) - -- [ ] `getScheduledBatches()` - 스케줄된 배치 조회 -- [ ] `createSchedule()` - 스케줄 생성 -- [ ] `updateSchedule()` - 스케줄 수정 -- [ ] `deleteSchedule()` - 스케줄 삭제 - -### 공통 작업 - -- [ ] import 문 수정 (모든 서비스) -- [ ] Prisma import 완전 제거 (모든 서비스) -- [ ] 트랜잭션 로직 확인 -- [ ] 에러 핸들링 검증 - ---- - -## 🧪 테스트 계획 - -### 단위 테스트 (24개) - -- 각 Prisma 호출별 1개씩 - -### 통합 테스트 (8개) - -- BatchExternalDbService: 외부 DB 연동 테스트 (2개) -- BatchExecutionLogService: 로그 생성 및 조회 테스트 (2개) -- BatchManagementService: 배치 작업 실행 테스트 (2개) -- BatchSchedulerService: 스케줄 실행 테스트 (2개) - -### 성능 테스트 - -- 대용량 데이터 처리 성능 -- 동시 배치 실행 성능 -- 외부 DB 연결 풀 성능 - ---- - -## 🎯 예상 난이도 및 소요 시간 - -- **난이도**: ⭐⭐⭐⭐⭐ (매우 높음) - - 외부 DB 연동 - - 트랜잭션 처리 - - 스케줄링 로직 - - 대용량 데이터 처리 -- **예상 소요 시간**: 4~5시간 - - Phase 1 (BatchManagement + ExecutionLog): 1.5시간 - - Phase 2 (Scheduler): 1시간 - - Phase 3 (ExternalDb): 2시간 - - 테스트 및 문서화: 0.5시간 - ---- - -## ⚠️ 주의사항 - -### 중요 체크포인트 - -1. ✅ 외부 DB 연결은 반드시 try-finally에서 해제 -2. ✅ 배치 실행 중 에러 시 롤백 처리 -3. ✅ Cron 표현식 검증 필수 -4. ✅ 대용량 데이터는 스트리밍 방식 사용 -5. ✅ 동시 실행 제한 확인 - -### 성능 최적화 - -- 연결 풀 활용 -- 배치 쿼리 최적화 -- 인덱스 확인 -- 불필요한 로그 제거 - ---- - -**상태**: ⏳ **대기 중** -**특이사항**: 외부 DB 연동, 스케줄링, 트랜잭션 처리 포함 -**⚠️ 주의**: 배치 시스템의 핵심 기능이므로 신중한 테스트 필수! diff --git a/PHASE3.16_DATA_MANAGEMENT_SERVICES_MIGRATION.md b/PHASE3.16_DATA_MANAGEMENT_SERVICES_MIGRATION.md deleted file mode 100644 index c3ed2103..00000000 --- a/PHASE3.16_DATA_MANAGEMENT_SERVICES_MIGRATION.md +++ /dev/null @@ -1,540 +0,0 @@ -# 📋 Phase 3.16: Data Management Services Raw Query 전환 계획 - -## 📋 개요 - -데이터 관리 관련 서비스들은 총 **18개의 Prisma 호출**이 있으며, 동적 폼, 데이터 매핑, 데이터 서비스, 관리자 기능을 담당합니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ----------------------------------------------------- | -| 대상 서비스 | 4개 (EnhancedDynamicForm, DataMapping, Data, Admin) | -| 파일 위치 | `backend-node/src/services/{enhanced,data,admin}*.ts` | -| 총 파일 크기 | 2,062 라인 | -| Prisma 호출 | 0개 (전환 완료) | -| **현재 진행률** | **18/18 (100%)** ✅ **전환 완료** | -| 복잡도 | 중간 (동적 쿼리, JSON 필드, 관리자 기능) | -| 우선순위 | 🟡 중간 (Phase 3.16) | -| **상태** | ✅ **완료** | - ---- - -## ✅ 전환 완료 내역 - -### 전환된 Prisma 호출 (18개) - -#### 1. EnhancedDynamicFormService (6개) - -- `validateTableExists()` - $queryRawUnsafe → query -- `getTableColumns()` - $queryRawUnsafe → query -- `getColumnWebTypes()` - $queryRawUnsafe → query -- `getPrimaryKeys()` - $queryRawUnsafe → query -- `performInsert()` - $queryRawUnsafe → query -- `performUpdate()` - $queryRawUnsafe → query - -#### 2. DataMappingService (5개) - -- `getSourceData()` - $queryRawUnsafe → query -- `executeInsert()` - $executeRawUnsafe → query -- `executeUpsert()` - $executeRawUnsafe → query -- `executeUpdate()` - $executeRawUnsafe → query -- `disconnect()` - 제거 (Raw Query는 disconnect 불필요) - -#### 3. DataService (4개) - -- `getTableData()` - $queryRawUnsafe → query -- `checkTableExists()` - $queryRawUnsafe → query -- `getTableColumnsSimple()` - $queryRawUnsafe → query -- `getColumnLabel()` - $queryRawUnsafe → query - -#### 4. AdminService (3개) - -- `getAdminMenuList()` - $queryRaw → query (WITH RECURSIVE) -- `getUserMenuList()` - $queryRaw → query (WITH RECURSIVE) -- `getMenuInfo()` - findUnique → query (JOIN) - -### 주요 기술적 해결 사항 - -1. **변수명 충돌 해결** - - - `dataService.ts`에서 `query` 변수 → `sql` 변수로 변경 - - `query()` 함수와 로컬 변수 충돌 방지 - -2. **WITH RECURSIVE 쿼리 전환** - - - Prisma의 `$queryRaw` 템플릿 리터럴 → 일반 문자열 - - `${userLang}` → `$1` 파라미터 바인딩 - -3. **JOIN 쿼리 전환** - - - Prisma의 `include` 옵션 → `LEFT JOIN` 쿼리 - - 관계 데이터를 단일 쿼리로 조회 - -4. **동적 쿼리 생성** - - 동적 WHERE 조건 구성 - - SQL 인젝션 방지 (컬럼명 검증) - - 동적 ORDER BY 처리 - -### 컴파일 상태 - -✅ TypeScript 컴파일 성공 -✅ Linter 오류 없음 - ---- - -## 🔍 서비스별 상세 분석 - -### 1. EnhancedDynamicFormService (6개 호출, 786 라인) - -**주요 기능**: - -- 고급 동적 폼 관리 -- 폼 검증 규칙 -- 조건부 필드 표시 -- 폼 템플릿 관리 - -**예상 Prisma 호출**: - -- `getEnhancedForms()` - 고급 폼 목록 조회 -- `getEnhancedForm()` - 고급 폼 단건 조회 -- `createEnhancedForm()` - 고급 폼 생성 -- `updateEnhancedForm()` - 고급 폼 수정 -- `deleteEnhancedForm()` - 고급 폼 삭제 -- `getFormValidationRules()` - 검증 규칙 조회 - -**기술적 고려사항**: - -- JSON 필드 (validation_rules, conditional_logic, field_config) -- 복잡한 검증 규칙 -- 동적 필드 생성 -- 조건부 표시 로직 - ---- - -### 2. DataMappingService (5개 호출, 575 라인) - -**주요 기능**: - -- 데이터 매핑 설정 관리 -- 소스-타겟 필드 매핑 -- 데이터 변환 규칙 -- 매핑 실행 - -**예상 Prisma 호출**: - -- `getDataMappings()` - 매핑 설정 목록 조회 -- `getDataMapping()` - 매핑 설정 단건 조회 -- `createDataMapping()` - 매핑 설정 생성 -- `updateDataMapping()` - 매핑 설정 수정 -- `deleteDataMapping()` - 매핑 설정 삭제 - -**기술적 고려사항**: - -- JSON 필드 (field_mappings, transformation_rules) -- 복잡한 변환 로직 -- 매핑 검증 -- 실행 이력 추적 - ---- - -### 3. DataService (4개 호출, 327 라인) - -**주요 기능**: - -- 동적 데이터 조회 -- 데이터 필터링 -- 데이터 정렬 -- 데이터 집계 - -**예상 Prisma 호출**: - -- `getDataByTable()` - 테이블별 데이터 조회 -- `getDataById()` - 데이터 단건 조회 -- `executeCustomQuery()` - 커스텀 쿼리 실행 -- `getDataStatistics()` - 데이터 통계 조회 - -**기술적 고려사항**: - -- 동적 테이블 쿼리 -- SQL 인젝션 방지 -- 동적 WHERE 조건 -- 집계 쿼리 - ---- - -### 4. AdminService (3개 호출, 374 라인) - -**주요 기능**: - -- 관리자 메뉴 관리 -- 시스템 설정 -- 사용자 관리 -- 로그 조회 - -**예상 Prisma 호출**: - -- `getAdminMenus()` - 관리자 메뉴 조회 -- `getSystemSettings()` - 시스템 설정 조회 -- `updateSystemSettings()` - 시스템 설정 업데이트 - -**기술적 고려사항**: - -- 메뉴 계층 구조 -- 권한 기반 필터링 -- JSON 설정 필드 -- 캐싱 - ---- - -## 💡 통합 전환 전략 - -### Phase 1: 단순 CRUD 전환 (12개) - -**EnhancedDynamicFormService (6개) + DataMappingService (5개) + AdminService (1개)** - -- 기본 CRUD 기능 -- JSON 필드 처리 - -### Phase 2: 동적 쿼리 전환 (4개) - -**DataService (4개)** - -- 동적 테이블 쿼리 -- 보안 검증 - -### Phase 3: 고급 기능 전환 (2개) - -**AdminService (2개)** - -- 시스템 설정 -- 캐싱 - ---- - -## 💻 전환 예시 - -### 예시 1: 고급 폼 생성 (JSON 필드) - -**변경 전**: - -```typescript -const form = await prisma.enhanced_forms.create({ - data: { - form_code: formCode, - form_name: formName, - validation_rules: validationRules, // JSON - conditional_logic: conditionalLogic, // JSON - field_config: fieldConfig, // JSON - company_code: companyCode, - }, -}); -``` - -**변경 후**: - -```typescript -const form = await queryOne( - `INSERT INTO enhanced_forms - (form_code, form_name, validation_rules, conditional_logic, - field_config, company_code, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) - RETURNING *`, - [ - formCode, - formName, - JSON.stringify(validationRules), - JSON.stringify(conditionalLogic), - JSON.stringify(fieldConfig), - companyCode, - ] -); -``` - -### 예시 2: 데이터 매핑 조회 - -**변경 전**: - -```typescript -const mappings = await prisma.data_mappings.findMany({ - where: { - source_table: sourceTable, - target_table: targetTable, - is_active: true, - }, - include: { - source_columns: true, - target_columns: true, - }, -}); -``` - -**변경 후**: - -```typescript -const mappings = await query( - `SELECT - dm.*, - json_agg(DISTINCT jsonb_build_object( - 'column_id', sc.column_id, - 'column_name', sc.column_name - )) FILTER (WHERE sc.column_id IS NOT NULL) as source_columns, - json_agg(DISTINCT jsonb_build_object( - 'column_id', tc.column_id, - 'column_name', tc.column_name - )) FILTER (WHERE tc.column_id IS NOT NULL) as target_columns - FROM data_mappings dm - LEFT JOIN columns sc ON dm.mapping_id = sc.mapping_id AND sc.type = 'source' - LEFT JOIN columns tc ON dm.mapping_id = tc.mapping_id AND tc.type = 'target' - WHERE dm.source_table = $1 - AND dm.target_table = $2 - AND dm.is_active = $3 - GROUP BY dm.mapping_id`, - [sourceTable, targetTable, true] -); -``` - -### 예시 3: 동적 테이블 쿼리 (DataService) - -**변경 전**: - -```typescript -// Prisma로는 동적 테이블 쿼리 불가능 -// 이미 $queryRawUnsafe 사용 중일 가능성 -const data = await prisma.$queryRawUnsafe( - `SELECT * FROM ${tableName} WHERE ${whereClause}`, - ...params -); -``` - -**변경 후**: - -```typescript -// SQL 인젝션 방지를 위한 테이블명 검증 -const validTableName = validateTableName(tableName); - -const data = await query( - `SELECT * FROM ${validTableName} WHERE ${whereClause}`, - params -); -``` - -### 예시 4: 관리자 메뉴 조회 (계층 구조) - -**변경 전**: - -```typescript -const menus = await prisma.admin_menus.findMany({ - where: { is_active: true }, - orderBy: { sort_order: "asc" }, - include: { - children: { - orderBy: { sort_order: "asc" }, - }, - }, -}); -``` - -**변경 후**: - -```typescript -// 재귀 CTE를 사용한 계층 쿼리 -const menus = await query( - `WITH RECURSIVE menu_tree AS ( - SELECT *, 0 as level, ARRAY[menu_id] as path - FROM admin_menus - WHERE parent_id IS NULL AND is_active = $1 - - UNION ALL - - SELECT m.*, mt.level + 1, mt.path || m.menu_id - FROM admin_menus m - JOIN menu_tree mt ON m.parent_id = mt.menu_id - WHERE m.is_active = $1 - ) - SELECT * FROM menu_tree - ORDER BY path, sort_order`, - [true] -); -``` - ---- - -## 🔧 기술적 고려사항 - -### 1. JSON 필드 처리 - -```typescript -// 복잡한 JSON 구조 -interface ValidationRules { - required?: string[]; - min?: Record; - max?: Record; - pattern?: Record; - custom?: Array<{ field: string; rule: string }>; -} - -// 저장 시 -JSON.stringify(validationRules); - -// 조회 후 -const parsed = - typeof row.validation_rules === "string" - ? JSON.parse(row.validation_rules) - : row.validation_rules; -``` - -### 2. 동적 테이블 쿼리 보안 - -```typescript -// 테이블명 화이트리스트 -const ALLOWED_TABLES = ["users", "products", "orders"]; - -function validateTableName(tableName: string): string { - if (!ALLOWED_TABLES.includes(tableName)) { - throw new Error("Invalid table name"); - } - return tableName; -} - -// 컬럼명 검증 -function validateColumnName(columnName: string): string { - if (!/^[a-z_][a-z0-9_]*$/i.test(columnName)) { - throw new Error("Invalid column name"); - } - return columnName; -} -``` - -### 3. 재귀 CTE (계층 구조) - -```sql -WITH RECURSIVE hierarchy AS ( - -- 최상위 노드 - SELECT * FROM table WHERE parent_id IS NULL - - UNION ALL - - -- 하위 노드 - SELECT t.* FROM table t - JOIN hierarchy h ON t.parent_id = h.id -) -SELECT * FROM hierarchy -``` - -### 4. JSON 집계 (관계 데이터) - -```sql -SELECT - parent.*, - COALESCE( - json_agg( - jsonb_build_object('id', child.id, 'name', child.name) - ) FILTER (WHERE child.id IS NOT NULL), - '[]' - ) as children -FROM parent -LEFT JOIN child ON parent.id = child.parent_id -GROUP BY parent.id -``` - ---- - -## 📝 전환 체크리스트 - -### EnhancedDynamicFormService (6개) - -- [ ] `getEnhancedForms()` - 목록 조회 -- [ ] `getEnhancedForm()` - 단건 조회 -- [ ] `createEnhancedForm()` - 생성 (JSON 필드) -- [ ] `updateEnhancedForm()` - 수정 (JSON 필드) -- [ ] `deleteEnhancedForm()` - 삭제 -- [ ] `getFormValidationRules()` - 검증 규칙 조회 - -### DataMappingService (5개) - -- [ ] `getDataMappings()` - 목록 조회 -- [ ] `getDataMapping()` - 단건 조회 -- [ ] `createDataMapping()` - 생성 -- [ ] `updateDataMapping()` - 수정 -- [ ] `deleteDataMapping()` - 삭제 - -### DataService (4개) - -- [ ] `getDataByTable()` - 동적 테이블 조회 -- [ ] `getDataById()` - 단건 조회 -- [ ] `executeCustomQuery()` - 커스텀 쿼리 -- [ ] `getDataStatistics()` - 통계 조회 - -### AdminService (3개) - -- [ ] `getAdminMenus()` - 메뉴 조회 (재귀 CTE) -- [ ] `getSystemSettings()` - 시스템 설정 조회 -- [ ] `updateSystemSettings()` - 시스템 설정 업데이트 - -### 공통 작업 - -- [ ] import 문 수정 (모든 서비스) -- [ ] Prisma import 완전 제거 -- [ ] JSON 필드 처리 확인 -- [ ] 보안 검증 (SQL 인젝션) - ---- - -## 🧪 테스트 계획 - -### 단위 테스트 (18개) - -- 각 Prisma 호출별 1개씩 - -### 통합 테스트 (6개) - -- EnhancedDynamicFormService: 폼 생성 및 검증 테스트 (2개) -- DataMappingService: 매핑 설정 및 실행 테스트 (2개) -- DataService: 동적 쿼리 및 보안 테스트 (1개) -- AdminService: 메뉴 계층 구조 테스트 (1개) - -### 보안 테스트 - -- SQL 인젝션 방지 테스트 -- 테이블명 검증 테스트 -- 컬럼명 검증 테스트 - ---- - -## 🎯 예상 난이도 및 소요 시간 - -- **난이도**: ⭐⭐⭐⭐ (높음) - - JSON 필드 처리 - - 동적 쿼리 보안 - - 재귀 CTE - - JSON 집계 -- **예상 소요 시간**: 2.5~3시간 - - Phase 1 (기본 CRUD): 1시간 - - Phase 2 (동적 쿼리): 1시간 - - Phase 3 (고급 기능): 0.5시간 - - 테스트 및 문서화: 0.5시간 - ---- - -## ⚠️ 주의사항 - -### 보안 필수 체크리스트 - -1. ✅ 동적 테이블명은 반드시 화이트리스트 검증 -2. ✅ 동적 컬럼명은 정규식으로 검증 -3. ✅ WHERE 절 파라미터는 반드시 바인딩 -4. ✅ JSON 필드는 파싱 에러 처리 -5. ✅ 재귀 쿼리는 깊이 제한 설정 - -### 성능 최적화 - -- JSON 필드 인덱싱 (GIN 인덱스) -- 재귀 쿼리 깊이 제한 -- 집계 쿼리 최적화 -- 필요시 캐싱 적용 - ---- - -**상태**: ⏳ **대기 중** -**특이사항**: JSON 필드, 동적 쿼리, 재귀 CTE, 보안 검증 포함 -**⚠️ 주의**: 동적 쿼리는 SQL 인젝션 방지가 매우 중요! diff --git a/PHASE3.17_REFERENCE_CACHE_SERVICE_MIGRATION.md b/PHASE3.17_REFERENCE_CACHE_SERVICE_MIGRATION.md deleted file mode 100644 index 854c3453..00000000 --- a/PHASE3.17_REFERENCE_CACHE_SERVICE_MIGRATION.md +++ /dev/null @@ -1,62 +0,0 @@ -# 📋 Phase 3.17: ReferenceCacheService Raw Query 전환 계획 - -## 📋 개요 - -ReferenceCacheService는 **0개의 Prisma 호출**이 있으며, 참조 데이터 캐싱을 담당하는 서비스입니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ---------------------------------------------------- | -| 파일 위치 | `backend-node/src/services/referenceCacheService.ts` | -| 파일 크기 | 499 라인 | -| Prisma 호출 | 0개 (이미 전환 완료) | -| **현재 진행률** | **3/3 (100%)** ✅ **전환 완료** | -| 복잡도 | 낮음 (캐싱 로직) | -| 우선순위 | 🟢 낮음 (Phase 3.17) | -| **상태** | ✅ **완료** (이미 전환 완료됨) | - ---- - -## ✅ 전환 완료 내역 (이미 완료됨) - -ReferenceCacheService는 이미 Raw Query로 전환이 완료되었습니다. - -### 주요 기능 - -1. **참조 데이터 캐싱** - - - 자주 사용되는 참조 테이블 데이터를 메모리에 캐싱 - - 성능 향상을 위한 캐시 전략 - -2. **캐시 관리** - - - 캐시 갱신 로직 - - TTL(Time To Live) 관리 - - 캐시 무효화 - -3. **데이터 조회 최적화** - - 캐시 히트/미스 처리 - - 백그라운드 갱신 - -### 기술적 특징 - -- **메모리 캐싱**: Map/Object 기반 인메모리 캐싱 -- **성능 최적화**: 반복 DB 조회 최소화 -- **자동 갱신**: 주기적 캐시 갱신 로직 - -### 코드 상태 - -- [x] Prisma import 없음 -- [x] query 함수 사용 중 -- [x] TypeScript 컴파일 성공 -- [x] 캐싱 로직 정상 동작 - ---- - -## 📝 비고 - -이 서비스는 이미 Raw Query로 전환이 완료되어 있어 추가 작업이 필요하지 않습니다. - -**상태**: ✅ **완료** -**특이사항**: 캐싱 로직으로 성능에 중요한 서비스 diff --git a/PHASE3.18_DDL_EXECUTION_SERVICE_MIGRATION.md b/PHASE3.18_DDL_EXECUTION_SERVICE_MIGRATION.md deleted file mode 100644 index c8161786..00000000 --- a/PHASE3.18_DDL_EXECUTION_SERVICE_MIGRATION.md +++ /dev/null @@ -1,92 +0,0 @@ -# 📋 Phase 3.18: DDLExecutionService Raw Query 전환 계획 - -## 📋 개요 - -DDLExecutionService는 **0개의 Prisma 호출**이 있으며, DDL 실행 및 관리를 담당하는 서비스입니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | -------------------------------------------------- | -| 파일 위치 | `backend-node/src/services/ddlExecutionService.ts` | -| 파일 크기 | 786 라인 | -| Prisma 호출 | 0개 (이미 전환 완료) | -| **현재 진행률** | **6/6 (100%)** ✅ **전환 완료** | -| 복잡도 | 높음 (DDL 실행, 안전성 검증) | -| 우선순위 | 🔴 높음 (Phase 3.18) | -| **상태** | ✅ **완료** (이미 전환 완료됨) | - ---- - -## ✅ 전환 완료 내역 (이미 완료됨) - -DDLExecutionService는 이미 Raw Query로 전환이 완료되었습니다. - -### 주요 기능 - -1. **테이블 생성 (CREATE TABLE)** - - - 동적 테이블 생성 - - 컬럼 정의 및 제약조건 - - 인덱스 생성 - -2. **컬럼 추가 (ADD COLUMN)** - - - 기존 테이블에 컬럼 추가 - - 데이터 타입 검증 - - 기본값 설정 - -3. **테이블/컬럼 삭제 (DROP)** - - - 안전한 삭제 검증 - - 의존성 체크 - - 롤백 가능성 - -4. **DDL 안전성 검증** - - - DDL 실행 전 검증 - - 순환 참조 방지 - - 데이터 손실 방지 - -5. **DDL 실행 이력** - - - 모든 DDL 실행 기록 - - 성공/실패 로그 - - 롤백 정보 - -6. **트랜잭션 관리** - - DDL 트랜잭션 처리 - - 에러 시 롤백 - - 일관성 유지 - -### 기술적 특징 - -- **동적 DDL 생성**: 파라미터 기반 DDL 쿼리 생성 -- **안전성 검증**: 실행 전 다중 검증 단계 -- **감사 로깅**: DDLAuditLogger와 연동 -- **PostgreSQL 특화**: PostgreSQL DDL 문법 활용 - -### 보안 및 안전성 - -- **SQL 인젝션 방지**: 테이블/컬럼명 화이트리스트 검증 -- **권한 검증**: 사용자 권한 확인 -- **백업 권장**: DDL 실행 전 백업 체크 -- **복구 가능성**: 실행 이력 기록 - -### 코드 상태 - -- [x] Prisma import 없음 -- [x] query 함수 사용 중 -- [x] TypeScript 컴파일 성공 -- [x] 안전성 검증 로직 유지 -- [x] DDLAuditLogger 연동 - ---- - -## 📝 비고 - -이 서비스는 이미 Raw Query로 전환이 완료되어 있어 추가 작업이 필요하지 않습니다. - -**상태**: ✅ **완료** -**특이사항**: DDL 실행의 핵심 서비스로 안전성이 매우 중요 -**⚠️ 주의**: 프로덕션 환경에서 DDL 실행 시 각별한 주의 필요 diff --git a/PHASE3.7_LAYOUT_SERVICE_MIGRATION.md b/PHASE3.7_LAYOUT_SERVICE_MIGRATION.md deleted file mode 100644 index 74d1e0a9..00000000 --- a/PHASE3.7_LAYOUT_SERVICE_MIGRATION.md +++ /dev/null @@ -1,369 +0,0 @@ -# 🎨 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( - `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( - `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 쿼리 포함 - diff --git a/PHASE3.8_DB_TYPE_CATEGORY_SERVICE_MIGRATION.md b/PHASE3.8_DB_TYPE_CATEGORY_SERVICE_MIGRATION.md deleted file mode 100644 index aa691741..00000000 --- a/PHASE3.8_DB_TYPE_CATEGORY_SERVICE_MIGRATION.md +++ /dev/null @@ -1,484 +0,0 @@ -# 🗂️ 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( - `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( - `SELECT * FROM db_type_categories WHERE type_code = $1`, - [data.type_code] -); - -if (existing) { - return { - success: false, - message: "이미 존재하는 타입 코드입니다." - }; -} - -const category = await queryOne( - `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( - `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` 타입을 반환하므로, 에러 처리를 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` 형식으로 응답을 반환합니다. Raw Query 전환 후에도 이 패턴을 유지해야 합니다. - -### 사전 정의 카테고리 -`syncPredefinedCategories()` 메서드는 시스템 초기화 시 사전 정의된 DB 타입 카테고리를 동기화합니다. UPSERT 로직이 필수입니다. - -### 외래 키 확인 -카테고리 삭제 시 `external_db_connections` 테이블에서 사용 중인지 확인하여 데이터 무결성을 보장합니다. - ---- - -**작성일**: 2025-10-01 -**예상 소요 시간**: 1시간 -**담당자**: 백엔드 개발팀 -**우선순위**: 🟡 중간 (Phase 3.8) -**상태**: ⏳ **대기 중** -**특이사항**: ApiResponse 래퍼, UPSERT, GROUP BY + LEFT JOIN 포함 - diff --git a/PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md b/PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md deleted file mode 100644 index b8c1e06a..00000000 --- a/PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md +++ /dev/null @@ -1,408 +0,0 @@ -# 📋 Phase 3.9: TemplateStandardService Raw Query 전환 계획 - -## 📋 개요 - -TemplateStandardService는 **6개의 Prisma 호출**이 있으며, 템플릿 표준 관리를 담당하는 서비스입니다. - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ------------------------------------------------------ | -| 파일 위치 | `backend-node/src/services/templateStandardService.ts` | -| 파일 크기 | 395 라인 | -| Prisma 호출 | 6개 | -| **현재 진행률** | **7/7 (100%)** ✅ **전환 완료** | -| 복잡도 | 낮음 (기본 CRUD + DISTINCT) | -| 우선순위 | 🟢 낮음 (Phase 3.9) | -| **상태** | ✅ **완료** | - -### 🎯 전환 목표 - -- ✅ **7개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체** -- ✅ 템플릿 CRUD 기능 정상 동작 -- ✅ DISTINCT 쿼리 전환 -- ✅ Promise.all 병렬 쿼리 (목록 + 개수) -- ✅ 동적 UPDATE 쿼리 (11개 필드) -- ✅ TypeScript 컴파일 성공 -- ✅ **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( - `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( - `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( - `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( - `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 쿼리 포함 diff --git a/PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md b/PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md deleted file mode 100644 index 17d337cf..00000000 --- a/PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md +++ /dev/null @@ -1,522 +0,0 @@ -# Phase 4.1: AdminController Raw Query 전환 계획 - -## 📋 개요 - -관리자 컨트롤러의 Prisma 호출을 Raw Query로 전환합니다. -사용자, 회사, 부서, 메뉴 관리 등 핵심 관리 기능을 포함합니다. - ---- - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ------------------------------------------------- | -| 파일 위치 | `backend-node/src/controllers/adminController.ts` | -| 파일 크기 | 2,569 라인 | -| Prisma 호출 | 28개 → 0개 | -| **현재 진행률** | **28/28 (100%)** ✅ **완료** | -| 복잡도 | 중간 (다양한 CRUD 패턴) | -| 우선순위 | 🔴 높음 (Phase 4.1) | -| **상태** | ✅ **완료** (2025-10-01) | - ---- - -## 🔍 Prisma 호출 분석 - -### 사용자 관리 (13개) - -#### 1. getUserList (라인 312-317) - -```typescript -const totalCount = await prisma.user_info.count({ where }); -const users = await prisma.user_info.findMany({ where, skip, take, orderBy }); -``` - -- **전환**: count → `queryOne`, findMany → `query` -- **복잡도**: 중간 (동적 WHERE, 페이징) - -#### 2. getUserInfo (라인 419) - -```typescript -const userInfo = await prisma.user_info.findFirst({ where }); -``` - -- **전환**: findFirst → `queryOne` -- **복잡도**: 낮음 - -#### 3. updateUserStatus (라인 498) - -```typescript -await prisma.user_info.update({ where, data }); -``` - -- **전환**: update → `query` -- **복잡도**: 낮음 - -#### 4. deleteUserByAdmin (라인 2387) - -```typescript -await prisma.user_info.update({ where, data: { is_active: "N" } }); -``` - -- **전환**: update (soft delete) → `query` -- **복잡도**: 낮음 - -#### 5. getMyProfile (라인 1468, 1488, 2479) - -```typescript -const user = await prisma.user_info.findUnique({ where }); -const dept = await prisma.dept_info.findUnique({ where }); -``` - -- **전환**: findUnique → `queryOne` -- **복잡도**: 낮음 - -#### 6. updateMyProfile (라인 1864, 2527) - -```typescript -const updateResult = await prisma.user_info.update({ where, data }); -``` - -- **전환**: update → `queryOne` with RETURNING -- **복잡도**: 중간 (동적 UPDATE) - -#### 7. createOrUpdateUser (라인 1929, 1975) - -```typescript -const savedUser = await prisma.user_info.upsert({ where, update, create }); -const userCount = await prisma.user_info.count({ where }); -``` - -- **전환**: upsert → `INSERT ... ON CONFLICT`, count → `queryOne` -- **복잡도**: 높음 - -#### 8. 기타 findUnique (라인 1596, 1832, 2393) - -```typescript -const existingUser = await prisma.user_info.findUnique({ where }); -const currentUser = await prisma.user_info.findUnique({ where }); -const updatedUser = await prisma.user_info.findUnique({ where }); -``` - -- **전환**: findUnique → `queryOne` -- **복잡도**: 낮음 - -### 회사 관리 (7개) - -#### 9. getCompanyList (라인 550, 1276) - -```typescript -const companies = await prisma.company_mng.findMany({ orderBy }); -``` - -- **전환**: findMany → `query` -- **복잡도**: 낮음 - -#### 10. createCompany (라인 2035) - -```typescript -const existingCompany = await prisma.company_mng.findFirst({ where }); -``` - -- **전환**: findFirst (중복 체크) → `queryOne` -- **복잡도**: 낮음 - -#### 11. updateCompany (라인 2172, 2192) - -```typescript -const duplicateCompany = await prisma.company_mng.findFirst({ where }); -const updatedCompany = await prisma.company_mng.update({ where, data }); -``` - -- **전환**: findFirst → `queryOne`, update → `queryOne` -- **복잡도**: 중간 - -#### 12. deleteCompany (라인 2261, 2281) - -```typescript -const existingCompany = await prisma.company_mng.findUnique({ where }); -await prisma.company_mng.delete({ where }); -``` - -- **전환**: findUnique → `queryOne`, delete → `query` -- **복잡도**: 낮음 - -### 부서 관리 (2개) - -#### 13. getDepartmentList (라인 1348) - -```typescript -const departments = await prisma.dept_info.findMany({ where, orderBy }); -``` - -- **전환**: findMany → `query` -- **복잡도**: 낮음 - -#### 14. getDeptInfo (라인 1488) - -```typescript -const dept = await prisma.dept_info.findUnique({ where }); -``` - -- **전환**: findUnique → `queryOne` -- **복잡도**: 낮음 - -### 메뉴 관리 (3개) - -#### 15. createMenu (라인 1021) - -```typescript -const savedMenu = await prisma.menu_info.create({ data }); -``` - -- **전환**: create → `queryOne` with INSERT RETURNING -- **복잡도**: 중간 - -#### 16. updateMenu (라인 1087) - -```typescript -const updatedMenu = await prisma.menu_info.update({ where, data }); -``` - -- **전환**: update → `queryOne` with UPDATE RETURNING -- **복잡도**: 중간 - -#### 17. deleteMenu (라인 1149, 1211) - -```typescript -const deletedMenu = await prisma.menu_info.delete({ where }); -// 재귀 삭제 -const deletedMenu = await prisma.menu_info.delete({ where }); -``` - -- **전환**: delete → `query` -- **복잡도**: 중간 (재귀 삭제 로직) - -### 다국어 (1개) - -#### 18. getMultiLangKeys (라인 665) - -```typescript -const result = await prisma.multi_lang_key_master.findMany({ where, orderBy }); -``` - -- **전환**: findMany → `query` -- **복잡도**: 낮음 - ---- - -## 📝 전환 전략 - -### 1단계: Import 변경 - -```typescript -// 제거 -import { PrismaClient } from "@prisma/client"; -const prisma = new PrismaClient(); - -// 추가 -import { query, queryOne } from "../database/db"; -``` - -### 2단계: 단순 조회 전환 - -- findMany → `query` -- findUnique/findFirst → `queryOne` - -### 3단계: 동적 WHERE 처리 - -```typescript -const whereConditions: string[] = []; -const params: any[] = []; -let paramIndex = 1; - -if (companyCode) { - whereConditions.push(`company_code = $${paramIndex++}`); - params.push(companyCode); -} - -const whereClause = - whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; -``` - -### 4단계: 복잡한 로직 전환 - -- count → `SELECT COUNT(*) as count` -- upsert → `INSERT ... ON CONFLICT DO UPDATE` -- 동적 UPDATE → 조건부 SET 절 생성 - -### 5단계: 테스트 및 검증 - -- 각 함수별 동작 확인 -- 에러 처리 확인 -- 타입 안전성 확인 - ---- - -## 🎯 주요 변경 예시 - -### getUserList (count + findMany) - -```typescript -// Before -const totalCount = await prisma.user_info.count({ where }); -const users = await prisma.user_info.findMany({ - where, - skip, - take, - orderBy, -}); - -// After -const whereConditions: string[] = []; -const params: any[] = []; -let paramIndex = 1; - -// 동적 WHERE 구성 -if (where.company_code) { - whereConditions.push(`company_code = $${paramIndex++}`); - params.push(where.company_code); -} -if (where.user_name) { - whereConditions.push(`user_name ILIKE $${paramIndex++}`); - params.push(`%${where.user_name}%`); -} - -const whereClause = - whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; - -// Count -const countResult = await queryOne<{ count: number }>( - `SELECT COUNT(*) as count FROM user_info ${whereClause}`, - params -); -const totalCount = parseInt(countResult?.count?.toString() || "0", 10); - -// 데이터 조회 -const usersQuery = ` - SELECT * FROM user_info - ${whereClause} - ORDER BY created_date DESC - LIMIT $${paramIndex} OFFSET $${paramIndex + 1} -`; -params.push(take, skip); - -const users = await query(usersQuery, params); -``` - -### createOrUpdateUser (upsert) - -```typescript -// Before -const savedUser = await prisma.user_info.upsert({ - where: { user_id: userId }, - update: updateData, - create: createData -}); - -// After -const savedUser = await queryOne( - `INSERT INTO user_info (user_id, user_name, email, ...) - VALUES ($1, $2, $3, ...) - ON CONFLICT (user_id) - DO UPDATE SET - user_name = EXCLUDED.user_name, - email = EXCLUDED.email, - ... - RETURNING *`, - [userId, userName, email, ...] -); -``` - -### updateMyProfile (동적 UPDATE) - -```typescript -// Before -const updateResult = await prisma.user_info.update({ - where: { user_id: userId }, - data: updateData, -}); - -// After -const updates: string[] = []; -const params: any[] = []; -let paramIndex = 1; - -if (updateData.user_name !== undefined) { - updates.push(`user_name = $${paramIndex++}`); - params.push(updateData.user_name); -} -if (updateData.email !== undefined) { - updates.push(`email = $${paramIndex++}`); - params.push(updateData.email); -} -// ... 다른 필드들 - -params.push(userId); - -const updateResult = await queryOne( - `UPDATE user_info - SET ${updates.join(", ")}, updated_date = NOW() - WHERE user_id = $${paramIndex} - RETURNING *`, - params -); -``` - ---- - -## ✅ 체크리스트 - -### 기본 설정 - -- ✅ Prisma import 제거 (완전 제거 확인) -- ✅ query, queryOne import 추가 (이미 존재) -- ✅ 타입 import 확인 - -### 사용자 관리 - -- ✅ getUserList (count + findMany → Raw Query) -- ✅ getUserLocale (findFirst → queryOne) -- ✅ setUserLocale (update → query) -- ✅ getUserInfo (findUnique → queryOne) -- ✅ checkDuplicateUserId (findUnique → queryOne) -- ✅ changeUserStatus (findUnique + update → queryOne + query) -- ✅ saveUser (upsert → INSERT ON CONFLICT) -- ✅ updateProfile (동적 update → 동적 query) -- ✅ resetUserPassword (update → query) - -### 회사 관리 - -- ✅ getCompanyList (findMany → query) -- ✅ getCompanyListFromDB (findMany → query) -- ✅ createCompany (findFirst → queryOne) -- ✅ updateCompany (findFirst + update → queryOne + query) -- ✅ deleteCompany (delete → query with RETURNING) - -### 부서 관리 - -- ✅ getDepartmentList (findMany → query with 동적 WHERE) - -### 메뉴 관리 - -- ✅ saveMenu (create → query with INSERT RETURNING) -- ✅ updateMenu (update → query with UPDATE RETURNING) -- ✅ deleteMenu (delete → query with DELETE RETURNING) -- ✅ deleteMenusBatch (다중 delete → 반복 query) - -### 다국어 - -- ✅ getLangKeyList (findMany → query) - -### 검증 - -- ✅ TypeScript 컴파일 확인 (에러 없음) -- ✅ Linter 오류 확인 -- ⏳ 기능 테스트 (실행 필요) -- ✅ 에러 처리 확인 (기존 구조 유지) - ---- - -## 📌 참고사항 - -### 동적 쿼리 생성 패턴 - -모든 동적 WHERE/UPDATE는 다음 패턴을 따릅니다: - -1. 조건/필드 배열 생성 -2. 파라미터 배열 생성 -3. 파라미터 인덱스 관리 -4. SQL 문자열 조합 -5. query/queryOne 실행 - -### 에러 처리 - -기존 try-catch 구조를 유지하며, 데이터베이스 에러를 적절히 변환합니다. - -### 트랜잭션 - -복잡한 로직은 Service Layer로 이동을 고려합니다. - ---- - -## 🎉 완료 요약 (2025-10-01) - -### ✅ 전환 완료 현황 - -| 카테고리 | 함수 수 | 상태 | -|---------|--------|------| -| 사용자 관리 | 9개 | ✅ 완료 | -| 회사 관리 | 5개 | ✅ 완료 | -| 부서 관리 | 1개 | ✅ 완료 | -| 메뉴 관리 | 4개 | ✅ 완료 | -| 다국어 | 1개 | ✅ 완료 | -| **총계** | **20개** | **✅ 100% 완료** | - -### 📊 주요 성과 - -1. **완전한 Prisma 제거**: adminController.ts에서 모든 Prisma 코드 제거 완료 -2. **동적 쿼리 지원**: 런타임 테이블 생성/수정 가능 -3. **일관된 에러 처리**: 모든 함수에서 통일된 에러 처리 유지 -4. **타입 안전성**: TypeScript 컴파일 에러 없음 -5. **코드 품질 향상**: 949줄 변경 (+474/-475) - -### 🔑 주요 변환 패턴 - -#### 1. 동적 WHERE 조건 -```typescript -let whereConditions: string[] = []; -let queryParams: any[] = []; -let paramIndex = 1; - -if (filter) { - whereConditions.push(`field = $${paramIndex}`); - queryParams.push(filter); - paramIndex++; -} - -const whereClause = whereConditions.length > 0 - ? `WHERE ${whereConditions.join(" AND ")}` - : ""; -``` - -#### 2. UPSERT (INSERT ON CONFLICT) -```typescript -const [result] = await query( - `INSERT INTO table (col1, col2) VALUES ($1, $2) - ON CONFLICT (col1) DO UPDATE SET col2 = $2 - RETURNING *`, - [val1, val2] -); -``` - -#### 3. 동적 UPDATE -```typescript -const updateFields: string[] = []; -const updateValues: any[] = []; -let paramIndex = 1; - -if (data.field !== undefined) { - updateFields.push(`field = $${paramIndex}`); - updateValues.push(data.field); - paramIndex++; -} - -await query( - `UPDATE table SET ${updateFields.join(", ")} WHERE id = $${paramIndex}`, - [...updateValues, id] -); -``` - -### 🚀 다음 단계 - -1. **테스트 실행**: 개발 서버에서 모든 API 엔드포인트 테스트 -2. **문서 업데이트**: Phase 4 전체 계획서 진행 상황 반영 -3. **다음 Phase**: screenFileController.ts 마이그레이션 진행 - ---- - -**마지막 업데이트**: 2025-10-01 -**작업자**: Claude Agent -**완료 시간**: 약 15분 -**변경 라인 수**: 949줄 (추가 474줄, 삭제 475줄) diff --git a/PHASE4_CONTROLLER_LAYER_MIGRATION.md b/PHASE4_CONTROLLER_LAYER_MIGRATION.md deleted file mode 100644 index 05236e99..00000000 --- a/PHASE4_CONTROLLER_LAYER_MIGRATION.md +++ /dev/null @@ -1,316 +0,0 @@ -# Phase 4: Controller Layer Raw Query 전환 계획 - -## 📋 개요 - -컨트롤러 레이어에 남아있는 Prisma 호출을 Raw Query로 전환합니다. -대부분의 컨트롤러는 Service 레이어를 호출하지만, 일부 컨트롤러에서 직접 Prisma를 사용하고 있습니다. - ---- - -### 📊 기본 정보 - -| 항목 | 내용 | -| --------------- | ---------------------------------- | -| 대상 파일 | 7개 컨트롤러 | -| 파일 위치 | `backend-node/src/controllers/` | -| Prisma 호출 | 70개 (28개 완료) | -| **현재 진행률** | **28/70 (40%)** 🔄 **진행 중** | -| 복잡도 | 중간 (대부분 단순 CRUD) | -| 우선순위 | 🟡 중간 (Phase 4) | -| **상태** | 🔄 **진행 중** (adminController 완료) | - ---- - -## 🎯 전환 대상 컨트롤러 - -### 1. adminController.ts ✅ 완료 (28개) - -- **라인 수**: 2,569 라인 -- **Prisma 호출**: 28개 → 0개 -- **주요 기능**: - - 사용자 관리 (조회, 생성, 수정, 삭제) ✅ - - 회사 관리 (조회, 생성, 수정, 삭제) ✅ - - 부서 관리 (조회) ✅ - - 메뉴 관리 (생성, 수정, 삭제) ✅ - - 다국어 키 조회 ✅ -- **우선순위**: 🔴 높음 -- **상태**: ✅ **완료** (2025-10-01) -- **문서**: [PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md](PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md) - -### 2. webTypeStandardController.ts (11개) - -- **Prisma 호출**: 11개 -- **주요 기능**: 웹타입 표준 관리 -- **우선순위**: 🟡 중간 - -### 3. fileController.ts (11개) - -- **Prisma 호출**: 11개 -- **주요 기능**: 파일 업로드/다운로드 관리 -- **우선순위**: 🟡 중간 - -### 4. buttonActionStandardController.ts (11개) - -- **Prisma 호출**: 11개 -- **주요 기능**: 버튼 액션 표준 관리 -- **우선순위**: 🟡 중간 - -### 5. entityReferenceController.ts (4개) - -- **Prisma 호출**: 4개 -- **주요 기능**: 엔티티 참조 관리 -- **우선순위**: 🟢 낮음 - -### 6. dataflowExecutionController.ts (3개) - -- **Prisma 호출**: 3개 -- **주요 기능**: 데이터플로우 실행 -- **우선순위**: 🟢 낮음 - -### 7. screenFileController.ts (2개) - -- **Prisma 호출**: 2개 -- **주요 기능**: 화면 파일 관리 -- **우선순위**: 🟢 낮음 - ---- - -## 📝 전환 전략 - -### 기본 원칙 - -1. **Service Layer 우선** - - - 가능하면 Service로 로직 이동 - - Controller는 최소한의 로직만 유지 - -2. **단순 전환** - - - 대부분 단순 CRUD → `query`, `queryOne` 사용 - - 복잡한 로직은 Service로 이동 - -3. **에러 처리 유지** - - 기존 try-catch 구조 유지 - - 에러 메시지 일관성 유지 - -### 전환 패턴 - -#### 1. findMany → query - -```typescript -// Before -const users = await prisma.user_info.findMany({ - where: { company_code: companyCode }, -}); - -// After -const users = await query( - `SELECT * FROM user_info WHERE company_code = $1`, - [companyCode] -); -``` - -#### 2. findUnique → queryOne - -```typescript -// Before -const user = await prisma.user_info.findUnique({ - where: { user_id: userId }, -}); - -// After -const user = await queryOne( - `SELECT * FROM user_info WHERE user_id = $1`, - [userId] -); -``` - -#### 3. create → queryOne with INSERT - -```typescript -// Before -const newUser = await prisma.user_info.create({ - data: userData -}); - -// After -const newUser = await queryOne( - `INSERT INTO user_info (user_id, user_name, ...) - VALUES ($1, $2, ...) RETURNING *`, - [userData.user_id, userData.user_name, ...] -); -``` - -#### 4. update → queryOne with UPDATE - -```typescript -// Before -const updated = await prisma.user_info.update({ - where: { user_id: userId }, - data: updateData -}); - -// After -const updated = await queryOne( - `UPDATE user_info SET user_name = $1, ... - WHERE user_id = $2 RETURNING *`, - [updateData.user_name, ..., userId] -); -``` - -#### 5. delete → query with DELETE - -```typescript -// Before -await prisma.user_info.delete({ - where: { user_id: userId }, -}); - -// After -await query(`DELETE FROM user_info WHERE user_id = $1`, [userId]); -``` - -#### 6. count → queryOne - -```typescript -// Before -const count = await prisma.user_info.count({ - where: { company_code: companyCode }, -}); - -// After -const result = await queryOne<{ count: number }>( - `SELECT COUNT(*) as count FROM user_info WHERE company_code = $1`, - [companyCode] -); -const count = parseInt(result?.count?.toString() || "0", 10); -``` - ---- - -## ✅ 체크리스트 - -### Phase 4.1: adminController.ts - -- [ ] Prisma import 제거 -- [ ] query, queryOne import 추가 -- [ ] 사용자 관리 함수 전환 (8개) - - [ ] getUserList - count + findMany - - [ ] getUserInfo - findFirst - - [ ] updateUserStatus - update - - [ ] deleteUserByAdmin - update - - [ ] getMyProfile - findUnique - - [ ] updateMyProfile - update - - [ ] createOrUpdateUser - upsert - - [ ] count (getUserList) -- [ ] 회사 관리 함수 전환 (7개) - - [ ] getCompanyList - findMany - - [ ] createCompany - findFirst (중복체크) + create - - [ ] updateCompany - findFirst (중복체크) + update - - [ ] deleteCompany - findUnique + delete -- [ ] 부서 관리 함수 전환 (2개) - - [ ] getDepartmentList - findMany - - [ ] findUnique (부서 조회) -- [ ] 메뉴 관리 함수 전환 (3개) - - [ ] createMenu - create - - [ ] updateMenu - update - - [ ] deleteMenu - delete -- [ ] 기타 함수 전환 (8개) - - [ ] getMultiLangKeys - findMany -- [ ] 컴파일 확인 -- [ ] 린터 확인 - -### Phase 4.2: webTypeStandardController.ts - -- [ ] Prisma import 제거 -- [ ] query, queryOne import 추가 -- [ ] 모든 함수 전환 (11개) -- [ ] 컴파일 확인 -- [ ] 린터 확인 - -### Phase 4.3: fileController.ts - -- [ ] Prisma import 제거 -- [ ] query, queryOne import 추가 -- [ ] 모든 함수 전환 (11개) -- [ ] 컴파일 확인 -- [ ] 린터 확인 - -### Phase 4.4: buttonActionStandardController.ts - -- [ ] Prisma import 제거 -- [ ] query, queryOne import 추가 -- [ ] 모든 함수 전환 (11개) -- [ ] 컴파일 확인 -- [ ] 린터 확인 - -### Phase 4.5: entityReferenceController.ts - -- [ ] Prisma import 제거 -- [ ] query, queryOne import 추가 -- [ ] 모든 함수 전환 (4개) -- [ ] 컴파일 확인 -- [ ] 린터 확인 - -### Phase 4.6: dataflowExecutionController.ts - -- [ ] Prisma import 제거 -- [ ] query, queryOne import 추가 -- [ ] 모든 함수 전환 (3개) -- [ ] 컴파일 확인 -- [ ] 린터 확인 - -### Phase 4.7: screenFileController.ts - -- [ ] Prisma import 제거 -- [ ] query, queryOne import 추가 -- [ ] 모든 함수 전환 (2개) -- [ ] 컴파일 확인 -- [ ] 린터 확인 - ---- - -## 🎯 예상 결과 - -### 코드 품질 - -- ✅ Prisma 의존성 완전 제거 -- ✅ 직접적인 SQL 제어 -- ✅ 타입 안전성 유지 - -### 성능 - -- ✅ 불필요한 ORM 오버헤드 제거 -- ✅ 쿼리 최적화 가능 - -### 유지보수성 - -- ✅ 명확한 SQL 쿼리 -- ✅ 디버깅 용이 -- ✅ 데이터베이스 마이그레이션 용이 - ---- - -## 📌 참고사항 - -### Import 변경 - -```typescript -// Before -import { PrismaClient } from "@prisma/client"; -const prisma = new PrismaClient(); - -// After -import { query, queryOne } from "../database/db"; -``` - -### 타입 정의 - -- 각 테이블의 타입은 `types/` 디렉토리에서 import -- 필요시 새로운 타입 정의 추가 - -### 에러 처리 - -- 기존 try-catch 구조 유지 -- 적절한 HTTP 상태 코드 반환 -- 사용자 친화적 에러 메시지 diff --git a/PHASE4_REMAINING_PRISMA_CALLS.md b/PHASE4_REMAINING_PRISMA_CALLS.md deleted file mode 100644 index 89742648..00000000 --- a/PHASE4_REMAINING_PRISMA_CALLS.md +++ /dev/null @@ -1,546 +0,0 @@ -# Phase 4: 남은 Prisma 호출 전환 계획 - -## 📊 현재 상황 - -| 항목 | 내용 | -| --------------- | -------------------------------- | -| 총 Prisma 호출 | 29개 | -| 대상 파일 | 7개 | -| **현재 진행률** | **17/29 (58.6%)** 🔄 **진행 중** | -| 복잡도 | 중간 | -| 우선순위 | 🔴 높음 (Phase 4) | -| **상태** | ⏳ **진행 중** | - ---- - -## 📁 파일별 현황 - -### ✅ 완료된 파일 (2개) - -1. **adminController.ts** - ✅ **28개 완료** - - - 사용자 관리: getUserList, getUserInfo, updateUserStatus, deleteUser - - 프로필 관리: getMyProfile, updateMyProfile, resetPassword - - 사용자 생성/수정: createOrUpdateUser (UPSERT) - - 회사 관리: getCompanyList, createCompany, updateCompany, deleteCompany - - 부서 관리: getDepartmentList, getDeptInfo - - 메뉴 관리: createMenu, updateMenu, deleteMenu - - 다국어: getMultiLangKeys, updateLocale - -2. **screenFileController.ts** - ✅ **2개 완료** - - getScreenComponentFiles: findMany → query (LIKE) - - getComponentFiles: findMany → query (LIKE) - ---- - -## ⏳ 남은 파일 (5개, 총 12개 호출) - -### 1. webTypeStandardController.ts (11개) 🔴 최우선 - -**위치**: `backend-node/src/controllers/webTypeStandardController.ts` - -#### Prisma 호출 목록: - -1. **라인 33**: `getWebTypeStandards()` - findMany - - ```typescript - const webTypes = await prisma.web_type_standards.findMany({ - where, - orderBy, - select, - }); - ``` - -2. **라인 58**: `getWebTypeStandard()` - findUnique - - ```typescript - const webTypeData = await prisma.web_type_standards.findUnique({ - where: { id }, - }); - ``` - -3. **라인 112**: `createWebTypeStandard()` - findUnique (중복 체크) - - ```typescript - const existingWebType = await prisma.web_type_standards.findUnique({ - where: { web_type: webType }, - }); - ``` - -4. **라인 123**: `createWebTypeStandard()` - create - - ```typescript - const newWebType = await prisma.web_type_standards.create({ - data: { ... } - }); - ``` - -5. **라인 178**: `updateWebTypeStandard()` - findUnique (존재 확인) - - ```typescript - const existingWebType = await prisma.web_type_standards.findUnique({ - where: { id }, - }); - ``` - -6. **라인 189**: `updateWebTypeStandard()` - update - - ```typescript - const updatedWebType = await prisma.web_type_standards.update({ - where: { id }, data: { ... } - }); - ``` - -7. **라인 230**: `deleteWebTypeStandard()` - findUnique (존재 확인) - - ```typescript - const existingWebType = await prisma.web_type_standards.findUnique({ - where: { id }, - }); - ``` - -8. **라인 241**: `deleteWebTypeStandard()` - delete - - ```typescript - await prisma.web_type_standards.delete({ - where: { id }, - }); - ``` - -9. **라인 275**: `updateSortOrder()` - $transaction - - ```typescript - await prisma.$transaction( - updates.map((item) => - prisma.web_type_standards.update({ ... }) - ) - ); - ``` - -10. **라인 277**: `updateSortOrder()` - update (트랜잭션 내부) - -11. **라인 305**: `getCategories()` - groupBy - ```typescript - const categories = await prisma.web_type_standards.groupBy({ - by: ["category"], - where, - _count: true, - }); - ``` - -**전환 전략**: - -- findMany → `query` with dynamic WHERE -- findUnique → `queryOne` -- create → `queryOne` with INSERT RETURNING -- update → `queryOne` with UPDATE RETURNING -- delete → `query` with DELETE -- $transaction → `transaction` with client.query -- groupBy → `query` with GROUP BY, COUNT - ---- - -### 2. fileController.ts (1개) 🟡 - -**위치**: `backend-node/src/controllers/fileController.ts` - -#### Prisma 호출: - -1. **라인 726**: `downloadFile()` - findUnique - ```typescript - const fileRecord = await prisma.attach_file_info.findUnique({ - where: { objid: BigInt(objid) }, - }); - ``` - -**전환 전략**: - -- findUnique → `queryOne` - ---- - -### 3. multiConnectionQueryService.ts (4개) 🟢 - -**위치**: `backend-node/src/services/multiConnectionQueryService.ts` - -#### Prisma 호출 목록: - -1. **라인 1005**: `executeSelect()` - $queryRawUnsafe - - ```typescript - return await prisma.$queryRawUnsafe(query, ...queryParams); - ``` - -2. **라인 1022**: `executeInsert()` - $queryRawUnsafe - - ```typescript - const insertResult = await prisma.$queryRawUnsafe(...); - ``` - -3. **라인 1055**: `executeUpdate()` - $queryRawUnsafe - - ```typescript - return await prisma.$queryRawUnsafe(updateQuery, ...updateParams); - ``` - -4. **라인 1071**: `executeDelete()` - $queryRawUnsafe - ```typescript - return await prisma.$queryRawUnsafe(...); - ``` - -**전환 전략**: - -- $queryRawUnsafe → `query` (이미 Raw SQL 사용 중) - ---- - -### 4. config/database.ts (4개) 🟢 - -**위치**: `backend-node/src/config/database.ts` - -#### Prisma 호출: - -1. **라인 1**: PrismaClient import -2. **라인 17**: prisma 인스턴스 생성 -3. **라인 22**: `await prisma.$connect()` -4. **라인 31, 35, 40**: `await prisma.$disconnect()` - -**전환 전략**: - -- 이 파일은 데이터베이스 설정 파일이므로 완전히 제거 -- 기존 `db.ts`의 connection pool로 대체 -- 모든 import 경로를 `database` → `database/db`로 변경 - ---- - -### 5. routes/ddlRoutes.ts (2개) 🟢 - -**위치**: `backend-node/src/routes/ddlRoutes.ts` - -#### Prisma 호출: - -1. **라인 183-184**: 동적 PrismaClient import - - ```typescript - const { PrismaClient } = await import("@prisma/client"); - const prisma = new PrismaClient(); - ``` - -2. **라인 186-187**: 연결 테스트 - ```typescript - await prisma.$queryRaw`SELECT 1`; - await prisma.$disconnect(); - ``` - -**전환 전략**: - -- 동적 import 제거 -- `query('SELECT 1')` 사용 - ---- - -### 6. routes/companyManagementRoutes.ts (2개) 🟢 - -**위치**: `backend-node/src/routes/companyManagementRoutes.ts` - -#### Prisma 호출: - -1. **라인 32**: findUnique (중복 체크) - - ```typescript - const existingCompany = await prisma.company_mng.findUnique({ - where: { company_code }, - }); - ``` - -2. **라인 61**: update (회사명 업데이트) - ```typescript - await prisma.company_mng.update({ - where: { company_code }, - data: { company_name }, - }); - ``` - -**전환 전략**: - -- findUnique → `queryOne` -- update → `query` - ---- - -### 7. tests/authService.test.ts (2개) ⚠️ - -**위치**: `backend-node/src/tests/authService.test.ts` - -테스트 파일은 별도 처리 필요 (Phase 5에서 처리) - ---- - -## 🎯 전환 우선순위 - -### Phase 4.1: 컨트롤러 (완료) - -- [x] screenFileController.ts (2개) -- [x] adminController.ts (28개) - -### Phase 4.2: 남은 컨트롤러 (진행 예정) - -- [ ] webTypeStandardController.ts (11개) - 🔴 최우선 -- [ ] fileController.ts (1개) - -### Phase 4.3: Routes (진행 예정) - -- [ ] ddlRoutes.ts (2개) -- [ ] companyManagementRoutes.ts (2개) - -### Phase 4.4: Services (진행 예정) - -- [ ] multiConnectionQueryService.ts (4개) - -### Phase 4.5: Config (진행 예정) - -- [ ] database.ts (4개) - 전체 파일 제거 - -### Phase 4.6: Tests (Phase 5) - -- [ ] authService.test.ts (2개) - 별도 처리 - ---- - -## 📋 체크리스트 - -### webTypeStandardController.ts - -- [ ] Prisma import 제거 -- [ ] query, queryOne import 추가 -- [ ] getWebTypeStandards (findMany → query) -- [ ] getWebTypeStandard (findUnique → queryOne) -- [ ] createWebTypeStandard (findUnique + create → queryOne) -- [ ] updateWebTypeStandard (findUnique + update → queryOne) -- [ ] deleteWebTypeStandard (findUnique + delete → query) -- [ ] updateSortOrder ($transaction → transaction) -- [ ] getCategories (groupBy → query with GROUP BY) -- [ ] TypeScript 컴파일 확인 -- [ ] Linter 오류 확인 -- [ ] 동작 테스트 - -### fileController.ts - -- [ ] Prisma import 제거 -- [ ] queryOne import 추가 -- [ ] downloadFile (findUnique → queryOne) -- [ ] TypeScript 컴파일 확인 - -### routes/ddlRoutes.ts - -- [ ] 동적 PrismaClient import 제거 -- [ ] query import 추가 -- [ ] 연결 테스트 로직 변경 -- [ ] TypeScript 컴파일 확인 - -### routes/companyManagementRoutes.ts - -- [ ] Prisma import 제거 -- [ ] query, queryOne import 추가 -- [ ] findUnique → queryOne -- [ ] update → query -- [ ] TypeScript 컴파일 확인 - -### services/multiConnectionQueryService.ts - -- [ ] Prisma import 제거 -- [ ] query import 추가 -- [ ] $queryRawUnsafe → query (4곳) -- [ ] TypeScript 컴파일 확인 - -### config/database.ts - -- [ ] 파일 전체 분석 -- [ ] 의존성 확인 -- [ ] 대체 방안 구현 -- [ ] 모든 import 경로 변경 -- [ ] 파일 삭제 또는 완전 재작성 - ---- - -## 🔧 전환 패턴 요약 - -### 1. findMany → query - -```typescript -// Before -const items = await prisma.table.findMany({ where, orderBy }); - -// After -const items = await query( - `SELECT * FROM table WHERE ... ORDER BY ...`, - params -); -``` - -### 2. findUnique → queryOne - -```typescript -// Before -const item = await prisma.table.findUnique({ where: { id } }); - -// After -const item = await queryOne(`SELECT * FROM table WHERE id = $1`, [id]); -``` - -### 3. create → queryOne with RETURNING - -```typescript -// Before -const newItem = await prisma.table.create({ data }); - -// After -const [newItem] = await query( - `INSERT INTO table (col1, col2) VALUES ($1, $2) RETURNING *`, - [val1, val2] -); -``` - -### 4. update → query with RETURNING - -```typescript -// Before -const updated = await prisma.table.update({ where, data }); - -// After -const [updated] = await query( - `UPDATE table SET col1 = $1 WHERE id = $2 RETURNING *`, - [val1, id] -); -``` - -### 5. delete → query - -```typescript -// Before -await prisma.table.delete({ where: { id } }); - -// After -await query(`DELETE FROM table WHERE id = $1`, [id]); -``` - -### 6. $transaction → transaction - -```typescript -// Before -await prisma.$transaction([ - prisma.table.update({ ... }), - prisma.table.update({ ... }) -]); - -// After -await transaction(async (client) => { - await client.query(`UPDATE table SET ...`, params1); - await client.query(`UPDATE table SET ...`, params2); -}); -``` - -### 7. groupBy → query with GROUP BY - -```typescript -// Before -const result = await prisma.table.groupBy({ - by: ["category"], - _count: true, -}); - -// After -const result = await query( - `SELECT category, COUNT(*) as count FROM table GROUP BY category`, - [] -); -``` - ---- - -## 📈 진행 상황 - -### 전체 진행률: 17/29 (58.6%) - -``` -Phase 1-3: Service Layer ████████████████████████████ 100% (415/415) -Phase 4.1: Controllers ████████████████████████████ 100% (30/30) -Phase 4.2: 남은 파일 ███████░░░░░░░░░░░░░░░░░░░░ 58% (17/29) -``` - -### 상세 진행 상황 - -| 카테고리 | 완료 | 남음 | 진행률 | -| ----------- | ---- | ---- | ------ | -| Services | 415 | 0 | 100% | -| Controllers | 30 | 11 | 73% | -| Routes | 0 | 4 | 0% | -| Config | 0 | 4 | 0% | -| **총계** | 445 | 19 | 95.9% | - ---- - -## 🎬 다음 단계 - -1. **webTypeStandardController.ts 전환** (11개) - - - 가장 많은 Prisma 호출을 가진 남은 컨트롤러 - - 웹 타입 표준 관리 핵심 기능 - -2. **fileController.ts 전환** (1개) - - - 단순 findUnique만 있어 빠르게 처리 가능 - -3. **Routes 전환** (4개) - - - ddlRoutes.ts - - companyManagementRoutes.ts - -4. **Service 전환** (4개) - - - multiConnectionQueryService.ts - -5. **Config 제거** (4개) - - database.ts 완전 제거 또는 재작성 - - 모든 의존성 제거 - ---- - -## ⚠️ 주의사항 - -1. **database.ts 처리** - - - 현재 많은 파일이 `import prisma from '../config/database'` 사용 - - 모든 import를 `import { query, queryOne } from '../database/db'`로 변경 필요 - - 단계적으로 진행하여 빌드 오류 방지 - -2. **BigInt 처리** - - - fileController의 `objid: BigInt(objid)` → `objid::bigint` 또는 `CAST(objid AS BIGINT)` - -3. **트랜잭션 처리** - - - webTypeStandardController의 `updateSortOrder`는 복잡한 트랜잭션 - - `transaction` 함수 사용 필요 - -4. **타입 안전성** - - 모든 Raw Query에 명시적 타입 지정 필요 - - `query`, `queryOne` 등 - ---- - -## 📝 완료 후 작업 - -- [ ] 전체 컴파일 확인 -- [ ] Linter 오류 해결 -- [ ] 통합 테스트 실행 -- [ ] Prisma 관련 의존성 완전 제거 (package.json) -- [ ] `prisma/` 디렉토리 정리 -- [ ] 문서 업데이트 -- [ ] 커밋 및 Push - ---- - -**작성일**: 2025-10-01 -**최종 업데이트**: 2025-10-01 -**상태**: 🔄 진행 중 (58.6% 완료) diff --git a/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md b/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md deleted file mode 100644 index 42145a94..00000000 --- a/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md +++ /dev/null @@ -1,759 +0,0 @@ -# 외부 커넥션 관리 REST API 지원 확장 계획서 - -## 📋 프로젝트 개요 - -### 목적 - -현재 외부 데이터베이스 연결만 관리하는 `/admin/external-connections` 페이지에 REST API 연결 관리 기능을 추가하여, DB와 REST API 커넥션을 통합 관리할 수 있도록 확장합니다. - -### 현재 상황 - -- **기존 기능**: 외부 데이터베이스 연결 정보만 관리 (MySQL, PostgreSQL, Oracle, SQL Server, SQLite) -- **기존 테이블**: `external_db_connections` - DB 연결 정보 저장 -- **기존 UI**: 단일 화면에서 DB 연결 목록 표시 및 CRUD 작업 - -### 요구사항 - -1. **탭 전환**: DB 연결 관리 ↔ REST API 연결 관리 간 탭 전환 UI -2. **REST API 관리**: 요청 주소별 헤더(키-값 쌍) 관리 -3. **연결 테스트**: REST API 호출이 정상 작동하는지 테스트 기능 - ---- - -## 🗄️ 데이터베이스 설계 - -### 신규 테이블: `external_rest_api_connections` - -```sql -CREATE TABLE external_rest_api_connections ( - id SERIAL PRIMARY KEY, - - -- 기본 정보 - connection_name VARCHAR(100) NOT NULL UNIQUE, - description TEXT, - - -- REST API 연결 정보 - base_url VARCHAR(500) NOT NULL, -- 기본 URL (예: https://api.example.com) - default_headers JSONB DEFAULT '{}', -- 기본 헤더 정보 (키-값 쌍) - - -- 인증 설정 - auth_type VARCHAR(20) DEFAULT 'none', -- none, api-key, bearer, basic, oauth2 - auth_config JSONB, -- 인증 관련 설정 - - -- 고급 설정 - timeout INTEGER DEFAULT 30000, -- 요청 타임아웃 (ms) - retry_count INTEGER DEFAULT 0, -- 재시도 횟수 - retry_delay INTEGER DEFAULT 1000, -- 재시도 간격 (ms) - - -- 관리 정보 - company_code VARCHAR(20) DEFAULT '*', - is_active CHAR(1) DEFAULT 'Y', - created_date TIMESTAMP DEFAULT NOW(), - created_by VARCHAR(50), - updated_date TIMESTAMP DEFAULT NOW(), - updated_by VARCHAR(50), - - -- 테스트 정보 - last_test_date TIMESTAMP, - last_test_result CHAR(1), -- Y: 성공, N: 실패 - last_test_message TEXT -); - --- 인덱스 -CREATE INDEX idx_rest_api_connections_company ON external_rest_api_connections(company_code); -CREATE INDEX idx_rest_api_connections_active ON external_rest_api_connections(is_active); -CREATE INDEX idx_rest_api_connections_name ON external_rest_api_connections(connection_name); -``` - -### 샘플 데이터 - -```sql -INSERT INTO external_rest_api_connections ( - connection_name, description, base_url, default_headers, auth_type, auth_config -) VALUES -( - '기상청 API', - '기상청 공공데이터 API', - 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0', - '{"Content-Type": "application/json", "Accept": "application/json"}', - 'api-key', - '{"keyLocation": "query", "keyName": "serviceKey", "keyValue": "your-api-key-here"}' -), -( - '사내 인사 시스템 API', - '인사정보 조회용 내부 API', - 'https://hr.company.com/api/v1', - '{"Content-Type": "application/json"}', - 'bearer', - '{"token": "your-bearer-token-here"}' -); -``` - ---- - -## 🔧 백엔드 구현 - -### 1. 타입 정의 - -```typescript -// backend-node/src/types/externalRestApiTypes.ts - -export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2"; - -export interface ExternalRestApiConnection { - id?: number; - connection_name: string; - description?: string; - base_url: string; - default_headers: Record; - auth_type: AuthType; - auth_config?: { - // API Key - keyLocation?: "header" | "query"; - keyName?: string; - keyValue?: string; - - // Bearer Token - token?: string; - - // Basic Auth - username?: string; - password?: string; - - // OAuth2 - clientId?: string; - clientSecret?: string; - tokenUrl?: string; - accessToken?: string; - }; - timeout?: number; - retry_count?: number; - retry_delay?: number; - company_code: string; - is_active: string; - created_date?: Date; - created_by?: string; - updated_date?: Date; - updated_by?: string; - last_test_date?: Date; - last_test_result?: string; - last_test_message?: string; -} - -export interface ExternalRestApiConnectionFilter { - auth_type?: string; - is_active?: string; - company_code?: string; - search?: string; -} - -export interface RestApiTestRequest { - id?: number; - base_url: string; - endpoint?: string; // 테스트할 엔드포인트 (선택) - method?: "GET" | "POST" | "PUT" | "DELETE"; - headers?: Record; - auth_type?: AuthType; - auth_config?: any; - timeout?: number; -} - -export interface RestApiTestResult { - success: boolean; - message: string; - response_time?: number; - status_code?: number; - response_data?: any; - error_details?: string; -} -``` - -### 2. 서비스 계층 - -```typescript -// backend-node/src/services/externalRestApiConnectionService.ts - -export class ExternalRestApiConnectionService { - // CRUD 메서드 - static async getConnections(filter: ExternalRestApiConnectionFilter); - static async getConnectionById(id: number); - static async createConnection(data: ExternalRestApiConnection); - static async updateConnection( - id: number, - data: Partial - ); - static async deleteConnection(id: number); - - // 테스트 메서드 - static async testConnection( - testRequest: RestApiTestRequest - ): Promise; - static async testConnectionById( - id: number, - endpoint?: string - ): Promise; - - // 헬퍼 메서드 - private static buildHeaders( - connection: ExternalRestApiConnection - ): Record; - private static validateConnectionData(data: ExternalRestApiConnection): void; - private static encryptSensitiveData(authConfig: any): any; - private static decryptSensitiveData(authConfig: any): any; -} -``` - -### 3. API 라우트 - -```typescript -// backend-node/src/routes/externalRestApiConnectionRoutes.ts - -// GET /api/external-rest-api-connections - 목록 조회 -// GET /api/external-rest-api-connections/:id - 상세 조회 -// POST /api/external-rest-api-connections - 새 연결 생성 -// PUT /api/external-rest-api-connections/:id - 연결 수정 -// DELETE /api/external-rest-api-connections/:id - 연결 삭제 -// POST /api/external-rest-api-connections/test - 연결 테스트 (신규) -// POST /api/external-rest-api-connections/:id/test - ID로 테스트 (기존 연결) -``` - -### 4. 연결 테스트 구현 - -```typescript -// REST API 연결 테스트 로직 -static async testConnection(testRequest: RestApiTestRequest): Promise { - const startTime = Date.now(); - - try { - // 헤더 구성 - const headers = { ...testRequest.headers }; - - // 인증 헤더 추가 - if (testRequest.auth_type === 'bearer' && testRequest.auth_config?.token) { - headers['Authorization'] = `Bearer ${testRequest.auth_config.token}`; - } else if (testRequest.auth_type === 'basic') { - const credentials = Buffer.from( - `${testRequest.auth_config.username}:${testRequest.auth_config.password}` - ).toString('base64'); - headers['Authorization'] = `Basic ${credentials}`; - } else if (testRequest.auth_type === 'api-key') { - if (testRequest.auth_config.keyLocation === 'header') { - headers[testRequest.auth_config.keyName] = testRequest.auth_config.keyValue; - } - } - - // URL 구성 - let url = testRequest.base_url; - if (testRequest.endpoint) { - url = `${testRequest.base_url}${testRequest.endpoint}`; - } - - // API Key가 쿼리에 있는 경우 - if (testRequest.auth_type === 'api-key' && - testRequest.auth_config.keyLocation === 'query') { - const separator = url.includes('?') ? '&' : '?'; - url = `${url}${separator}${testRequest.auth_config.keyName}=${testRequest.auth_config.keyValue}`; - } - - // HTTP 요청 실행 - const response = await fetch(url, { - method: testRequest.method || 'GET', - headers, - signal: AbortSignal.timeout(testRequest.timeout || 30000), - }); - - const responseTime = Date.now() - startTime; - const responseData = await response.json().catch(() => null); - - return { - success: response.ok, - message: response.ok ? '연결 성공' : `연결 실패 (${response.status})`, - response_time: responseTime, - status_code: response.status, - response_data: responseData, - }; - } catch (error) { - return { - success: false, - message: '연결 실패', - error_details: error instanceof Error ? error.message : '알 수 없는 오류', - }; - } -} -``` - ---- - -## 🎨 프론트엔드 구현 - -### 1. 탭 구조 설계 - -```typescript -// frontend/app/(main)/admin/external-connections/page.tsx - -type ConnectionTabType = "database" | "rest-api"; - -const [activeTab, setActiveTab] = useState("database"); -``` - -### 2. 메인 페이지 구조 개선 - -```tsx -// 탭 헤더 - setActiveTab(value as ConnectionTabType)} -> - - - - 데이터베이스 연결 - - - - REST API 연결 - - - - {/* 데이터베이스 연결 탭 */} - - - - - {/* REST API 연결 탭 */} - - - - -``` - -### 3. REST API 연결 목록 컴포넌트 - -```typescript -// frontend/components/admin/RestApiConnectionList.tsx - -export function RestApiConnectionList() { - const [connections, setConnections] = useState( - [] - ); - const [searchTerm, setSearchTerm] = useState(""); - const [authTypeFilter, setAuthTypeFilter] = useState("ALL"); - const [isModalOpen, setIsModalOpen] = useState(false); - const [editingConnection, setEditingConnection] = useState< - ExternalRestApiConnection | undefined - >(); - - // 테이블 컬럼: - // - 연결명 - // - 기본 URL - // - 인증 타입 - // - 헤더 수 (default_headers 개수) - // - 상태 (활성/비활성) - // - 마지막 테스트 (날짜 + 결과) - // - 작업 (테스트/편집/삭제) -} -``` - -### 4. REST API 연결 설정 모달 - -```typescript -// frontend/components/admin/RestApiConnectionModal.tsx - -export function RestApiConnectionModal({ - isOpen, - onClose, - onSave, - connection, -}: RestApiConnectionModalProps) { - // 섹션 구성: - // 1. 기본 정보 - // - 연결명 (필수) - // - 설명 - // - 기본 URL (필수) - // 2. 헤더 관리 (키-값 추가/삭제) - // - 동적 입력 필드 - // - + 버튼으로 추가 - // - 각 행에 삭제 버튼 - // 3. 인증 설정 - // - 인증 타입 선택 (none/api-key/bearer/basic/oauth2) - // - 선택된 타입별 설정 필드 표시 - // 4. 고급 설정 (접기/펼치기) - // - 타임아웃 - // - 재시도 설정 - // 5. 테스트 섹션 - // - 테스트 엔드포인트 입력 (선택) - // - 테스트 실행 버튼 - // - 테스트 결과 표시 -} -``` - -### 5. 헤더 관리 컴포넌트 - -```typescript -// frontend/components/admin/HeadersManager.tsx - -interface HeadersManagerProps { - headers: Record; - onChange: (headers: Record) => void; -} - -export function HeadersManager({ headers, onChange }: HeadersManagerProps) { - const [headersList, setHeadersList] = useState< - Array<{ key: string; value: string }> - >(Object.entries(headers).map(([key, value]) => ({ key, value }))); - - const addHeader = () => { - setHeadersList([...headersList, { key: "", value: "" }]); - }; - - const removeHeader = (index: number) => { - const newList = headersList.filter((_, i) => i !== index); - setHeadersList(newList); - updateParent(newList); - }; - - const updateHeader = ( - index: number, - field: "key" | "value", - value: string - ) => { - const newList = [...headersList]; - newList[index][field] = value; - setHeadersList(newList); - updateParent(newList); - }; - - const updateParent = (list: Array<{ key: string; value: string }>) => { - const headersObject = list.reduce((acc, { key, value }) => { - if (key.trim()) acc[key] = value; - return acc; - }, {} as Record); - onChange(headersObject); - }; - - // UI: 테이블 형태로 키-값 입력 필드 표시 - // 각 행: [키 입력] [값 입력] [삭제 버튼] - // 하단: [+ 헤더 추가] 버튼 -} -``` - -### 6. 인증 설정 컴포넌트 - -```typescript -// frontend/components/admin/AuthenticationConfig.tsx - -export function AuthenticationConfig({ - authType, - authConfig, - onChange, -}: AuthenticationConfigProps) { - // authType에 따라 다른 입력 필드 표시 - // none: 추가 필드 없음 - // api-key: - // - 키 위치 (header/query) - // - 키 이름 - // - 키 값 - // bearer: - // - 토큰 값 - // basic: - // - 사용자명 - // - 비밀번호 - // oauth2: - // - Client ID - // - Client Secret - // - Token URL - // - Access Token (읽기전용, 자동 갱신) -} -``` - -### 7. API 클라이언트 - -```typescript -// frontend/lib/api/externalRestApiConnection.ts - -export class ExternalRestApiConnectionAPI { - private static readonly BASE_URL = "/api/external-rest-api-connections"; - - static async getConnections(filter?: ExternalRestApiConnectionFilter) { - const params = new URLSearchParams(); - if (filter?.search) params.append("search", filter.search); - if (filter?.auth_type && filter.auth_type !== "ALL") { - params.append("auth_type", filter.auth_type); - } - if (filter?.is_active && filter.is_active !== "ALL") { - params.append("is_active", filter.is_active); - } - - const response = await fetch(`${this.BASE_URL}?${params}`); - return this.handleResponse(response); - } - - static async getConnectionById(id: number) { - const response = await fetch(`${this.BASE_URL}/${id}`); - return this.handleResponse(response); - } - - static async createConnection(data: ExternalRestApiConnection) { - const response = await fetch(this.BASE_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - return this.handleResponse(response); - } - - static async updateConnection( - id: number, - data: Partial - ) { - const response = await fetch(`${this.BASE_URL}/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - return this.handleResponse(response); - } - - static async deleteConnection(id: number) { - const response = await fetch(`${this.BASE_URL}/${id}`, { - method: "DELETE", - }); - return this.handleResponse(response); - } - - static async testConnection( - testRequest: RestApiTestRequest - ): Promise { - const response = await fetch(`${this.BASE_URL}/test`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(testRequest), - }); - return this.handleResponse(response); - } - - static async testConnectionById( - id: number, - endpoint?: string - ): Promise { - const response = await fetch(`${this.BASE_URL}/${id}/test`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ endpoint }), - }); - return this.handleResponse(response); - } - - private static async handleResponse(response: Response) { - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error(error.message || "요청 실패"); - } - return response.json(); - } -} -``` - ---- - -## 📋 구현 순서 - -### Phase 1: 데이터베이스 및 백엔드 기본 구조 (1일) - -- [x] 데이터베이스 테이블 생성 (`external_rest_api_connections`) -- [ ] 타입 정의 작성 (`externalRestApiTypes.ts`) -- [ ] 서비스 계층 기본 CRUD 구현 -- [ ] API 라우트 기본 구현 - -### Phase 2: 연결 테스트 기능 (1일) - -- [ ] 연결 테스트 로직 구현 -- [ ] 인증 타입별 헤더 구성 로직 -- [ ] 에러 처리 및 타임아웃 관리 -- [ ] 테스트 결과 저장 (last_test_date, last_test_result) - -### Phase 3: 프론트엔드 기본 UI (1-2일) - -- [ ] 탭 구조 추가 (Database / REST API) -- [ ] REST API 연결 목록 컴포넌트 -- [ ] API 클라이언트 작성 -- [ ] 기본 CRUD UI 구현 - -### Phase 4: 모달 및 상세 기능 (1-2일) - -- [ ] REST API 연결 설정 모달 -- [ ] 헤더 관리 컴포넌트 (키-값 동적 추가/삭제) -- [ ] 인증 설정 컴포넌트 (타입별 입력 필드) -- [ ] 고급 설정 섹션 - -### Phase 5: 테스트 및 통합 (1일) - -- [ ] 연결 테스트 UI -- [ ] 테스트 결과 표시 -- [ ] 에러 처리 및 사용자 피드백 -- [ ] 전체 기능 통합 테스트 - -### Phase 6: 최적화 및 마무리 (0.5일) - -- [ ] 민감 정보 암호화 (API 키, 토큰, 비밀번호) -- [ ] UI/UX 개선 -- [ ] 문서화 - ---- - -## 🧪 테스트 시나리오 - -### 1. REST API 연결 등록 테스트 - -- [ ] 기본 정보 입력 (연결명, URL) -- [ ] 헤더 추가/삭제 -- [ ] 각 인증 타입별 설정 -- [ ] 유효성 검증 (필수 필드, URL 형식) - -### 2. 연결 테스트 - -- [ ] 인증 없는 API 테스트 -- [ ] API Key (header/query) 테스트 -- [ ] Bearer Token 테스트 -- [ ] Basic Auth 테스트 -- [ ] 타임아웃 시나리오 -- [ ] 네트워크 오류 시나리오 - -### 3. 데이터 관리 - -- [ ] 목록 조회 및 필터링 -- [ ] 연결 수정 -- [ ] 연결 삭제 -- [ ] 활성/비활성 전환 - -### 4. 통합 시나리오 - -- [ ] DB 연결 탭 ↔ REST API 탭 전환 -- [ ] 여러 연결 등록 및 관리 -- [ ] 동시 테스트 실행 - ---- - -## 🔒 보안 고려사항 - -### 1. 민감 정보 암호화 - -```typescript -// API 키, 토큰, 비밀번호 암호화 -private static encryptSensitiveData(authConfig: any): any { - if (!authConfig) return null; - - const encrypted = { ...authConfig }; - - // 암호화 대상 필드 - if (encrypted.keyValue) { - encrypted.keyValue = encrypt(encrypted.keyValue); - } - if (encrypted.token) { - encrypted.token = encrypt(encrypted.token); - } - if (encrypted.password) { - encrypted.password = encrypt(encrypted.password); - } - if (encrypted.clientSecret) { - encrypted.clientSecret = encrypt(encrypted.clientSecret); - } - - return encrypted; -} -``` - -### 2. 접근 권한 제어 - -- 관리자 권한만 접근 -- 회사별 데이터 분리 -- API 호출 시 인증 토큰 검증 - -### 3. 테스트 요청 제한 - -- Rate Limiting (1분에 최대 10회) -- 타임아웃 설정 (최대 30초) -- 동시 테스트 제한 - ---- - -## 📊 성능 최적화 - -### 1. 헤더 데이터 구조 - -```typescript -// JSONB 필드 인덱싱 (PostgreSQL) -CREATE INDEX idx_rest_api_headers ON external_rest_api_connections -USING GIN (default_headers); - -CREATE INDEX idx_rest_api_auth_config ON external_rest_api_connections -USING GIN (auth_config); -``` - -### 2. 캐싱 전략 - -- 자주 사용되는 연결 정보 캐싱 -- 테스트 결과 임시 캐싱 (5분) - ---- - -## 📚 향후 확장 가능성 - -### 1. 엔드포인트 관리 - -각 REST API 연결에 대해 자주 사용하는 엔드포인트를 사전 등록하여 빠른 호출 가능 - -### 2. 요청 템플릿 - -HTTP 메서드별 요청 바디 템플릿 관리 - -### 3. 응답 매핑 - -REST API 응답을 내부 데이터 구조로 변환하는 매핑 룰 설정 - -### 4. 로그 및 모니터링 - -- API 호출 이력 기록 -- 응답 시간 모니터링 -- 오류율 추적 - ---- - -## ✅ 완료 체크리스트 - -### 백엔드 - -- [ ] 데이터베이스 테이블 생성 -- [ ] 타입 정의 -- [ ] 서비스 계층 CRUD -- [ ] 연결 테스트 로직 -- [ ] API 라우트 -- [ ] 민감 정보 암호화 - -### 프론트엔드 - -- [ ] 탭 구조 -- [ ] REST API 연결 목록 -- [ ] 연결 설정 모달 -- [ ] 헤더 관리 컴포넌트 -- [ ] 인증 설정 컴포넌트 -- [ ] API 클라이언트 -- [ ] 연결 테스트 UI - -### 테스트 - -- [ ] 단위 테스트 -- [ ] 통합 테스트 -- [ ] 사용자 시나리오 테스트 - -### 문서 - -- [ ] API 문서 -- [ ] 사용자 가이드 -- [ ] 배포 가이드 - ---- - -**작성일**: 2025-10-20 -**버전**: 1.0 -**담당**: AI Assistant diff --git a/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT_DONE.md b/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT_DONE.md deleted file mode 100644 index 051ca3d4..00000000 --- a/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT_DONE.md +++ /dev/null @@ -1,213 +0,0 @@ -# REST API 연결 관리 기능 구현 완료 - -## 구현 개요 - -외부 커넥션 관리 페이지(`/admin/external-connections`)에 REST API 연결 관리 기능이 추가되었습니다. -기존의 데이터베이스 연결 관리와 함께 REST API 연결도 관리할 수 있도록 탭 기반 UI가 구현되었습니다. - -## 구현 완료 사항 - -### 1. 데이터베이스 (✅ 완료) - -**파일**: `/db/create_external_rest_api_connections.sql` - -- `external_rest_api_connections` 테이블 생성 -- 연결 정보, 인증 설정, 테스트 결과 저장 -- JSONB 타입으로 헤더 및 인증 설정 유연하게 관리 -- 인덱스 최적화 (company_code, is_active, auth_type, JSONB GIN 인덱스) - -**실행 방법**: - -```bash -# PostgreSQL 컨테이너에 접속하여 SQL 실행 -docker exec -i esgrin-mes-db psql -U postgres -d ilshin < db/create_external_rest_api_connections.sql -``` - -### 2. 백엔드 구현 (✅ 완료) - -#### 2.1 타입 정의 - -**파일**: `backend-node/src/types/externalRestApiTypes.ts` - -- `ExternalRestApiConnection`: REST API 연결 정보 인터페이스 -- `RestApiTestRequest`: 연결 테스트 요청 인터페이스 -- `RestApiTestResult`: 테스트 결과 인터페이스 -- `AuthType`: 인증 타입 (none, api-key, bearer, basic, oauth2) -- 각 인증 타입별 세부 설정 인터페이스 - -#### 2.2 서비스 레이어 - -**파일**: `backend-node/src/services/externalRestApiConnectionService.ts` - -- CRUD 작업 구현 (생성, 조회, 수정, 삭제) -- 민감 정보 암호화/복호화 (AES-256-GCM) -- REST API 연결 테스트 기능 -- 필터링 및 검색 기능 -- 유효성 검증 - -#### 2.3 API 라우트 - -**파일**: `backend-node/src/routes/externalRestApiConnectionRoutes.ts` - -- `GET /api/external-rest-api-connections` - 목록 조회 -- `GET /api/external-rest-api-connections/:id` - 상세 조회 -- `POST /api/external-rest-api-connections` - 생성 -- `PUT /api/external-rest-api-connections/:id` - 수정 -- `DELETE /api/external-rest-api-connections/:id` - 삭제 -- `POST /api/external-rest-api-connections/test` - 연결 테스트 -- `POST /api/external-rest-api-connections/:id/test` - ID 기반 테스트 - -#### 2.4 앱 통합 - -**파일**: `backend-node/src/app.ts` - -- 새로운 라우트 등록 완료 - -### 3. 프론트엔드 구현 (✅ 완료) - -#### 3.1 API 클라이언트 - -**파일**: `frontend/lib/api/externalRestApiConnection.ts` - -- 백엔드 API와 통신하는 클라이언트 구현 -- 타입 안전한 API 호출 -- 에러 처리 - -#### 3.2 공통 컴포넌트 - -**파일**: `frontend/components/admin/HeadersManager.tsx` - -- HTTP 헤더 key-value 관리 컴포넌트 -- 동적 추가/삭제 기능 - -**파일**: `frontend/components/admin/AuthenticationConfig.tsx` - -- 인증 타입별 설정 컴포넌트 -- 5가지 인증 방식 지원 (none, api-key, bearer, basic, oauth2) - -#### 3.3 모달 컴포넌트 - -**파일**: `frontend/components/admin/RestApiConnectionModal.tsx` - -- 연결 추가/수정 모달 -- 헤더 관리 및 인증 설정 통합 -- 연결 테스트 기능 - -#### 3.4 목록 관리 컴포넌트 - -**파일**: `frontend/components/admin/RestApiConnectionList.tsx` - -- REST API 연결 목록 표시 -- 검색 및 필터링 -- CRUD 작업 -- 연결 테스트 - -#### 3.5 메인 페이지 - -**파일**: `frontend/app/(main)/admin/external-connections/page.tsx` - -- 탭 기반 UI 구현 (데이터베이스 ↔ REST API) -- 기존 DB 연결 관리와 통합 - -## 주요 기능 - -### 1. 연결 관리 - -- REST API 연결 정보 생성/수정/삭제 -- 연결명, 설명, Base URL 관리 -- Timeout, Retry 설정 -- 활성화 상태 관리 - -### 2. 인증 관리 - -- **None**: 인증 없음 -- **API Key**: 헤더 또는 쿼리 파라미터 -- **Bearer Token**: Authorization: Bearer {token} -- **Basic Auth**: username/password -- **OAuth2**: client_id, client_secret, token_url 등 - -### 3. 헤더 관리 - -- 기본 HTTP 헤더 설정 -- Key-Value 형식으로 동적 관리 -- Content-Type, Accept 등 자유롭게 설정 - -### 4. 연결 테스트 - -- 실시간 연결 테스트 -- HTTP 응답 상태 코드 확인 -- 응답 시간 측정 -- 테스트 결과 저장 - -### 5. 보안 - -- 민감 정보 자동 암호화 (AES-256-GCM) - - API Key - - Bearer Token - - 비밀번호 - - OAuth2 Client Secret -- 암호화된 데이터는 데이터베이스에 안전하게 저장 - -## 사용 방법 - -### 1. SQL 스크립트 실행 - -```bash -# PostgreSQL 컨테이너에 접속 -docker exec -it esgrin-mes-db psql -U postgres -d ilshin - -# 또는 파일 직접 실행 -docker exec -i esgrin-mes-db psql -U postgres -d ilshin < db/create_external_rest_api_connections.sql -``` - -### 2. 백엔드 재시작 - -백엔드 서버가 자동으로 새로운 라우트를 인식합니다. (이미 재시작 완료) - -### 3. 웹 UI 접속 - -1. `/admin/external-connections` 페이지 접속 -2. "REST API 연결" 탭 선택 -3. "새 연결 추가" 버튼 클릭 -4. 필요한 정보 입력 - - 연결명, 설명, Base URL - - 기본 헤더 설정 - - 인증 타입 선택 및 인증 정보 입력 - - Timeout, Retry 설정 -5. "연결 테스트" 버튼으로 즉시 테스트 가능 -6. 저장 - -### 4. 연결 관리 - -- **목록 조회**: 모든 REST API 연결 정보 확인 -- **검색**: 연결명, 설명, URL로 검색 -- **필터링**: 인증 타입, 활성화 상태로 필터링 -- **수정**: 연필 아이콘 클릭하여 수정 -- **삭제**: 휴지통 아이콘 클릭하여 삭제 -- **테스트**: Play 아이콘 클릭하여 연결 테스트 - -## 기술 스택 - -- **Backend**: Node.js, Express, TypeScript, PostgreSQL -- **Frontend**: Next.js, React, TypeScript, Shadcn UI -- **보안**: AES-256-GCM 암호화 -- **데이터**: JSONB (PostgreSQL) - -## 테스트 완료 - -- ✅ 백엔드 컴파일 성공 -- ✅ 서버 정상 실행 확인 -- ✅ 타입 에러 수정 완료 -- ✅ 모든 라우트 등록 완료 -- ✅ 인증 토큰 자동 포함 구현 (apiClient 사용) - -## 다음 단계 - -1. SQL 스크립트 실행 -2. 프론트엔드 빌드 및 테스트 -3. UI에서 연결 추가/수정/삭제/테스트 기능 확인 - -## 참고 문서 - -- 전체 계획: `PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md` -- 기존 외부 DB 연결: `제어관리_외부커넥션_통합_기능_가이드.md` diff --git a/PHASE_FLOW_MANAGEMENT_SYSTEM.md b/PHASE_FLOW_MANAGEMENT_SYSTEM.md deleted file mode 100644 index 22b0ada5..00000000 --- a/PHASE_FLOW_MANAGEMENT_SYSTEM.md +++ /dev/null @@ -1,1138 +0,0 @@ -# 플로우 관리 시스템 구현 계획서 - -## 1. 개요 - -### 1.1 목적 - -제품의 수명주기나 업무 프로세스를 시각적인 플로우로 정의하고 관리하는 시스템을 구축합니다. -각 플로우 단계는 데이터베이스 테이블의 레코드 조건으로 정의되며, 데이터를 플로우 단계 간 이동시키고 이력을 관리할 수 있습니다. - -### 1.2 주요 기능 - -- 플로우 정의 및 시각적 편집 -- 플로우 단계별 조건 설정 -- 화면관리 시스템과 연동하여 플로우 위젯 배치 -- 플로우 단계별 데이터 카운트 및 리스트 조회 -- 데이터의 플로우 단계 이동 -- 상태 변경 이력 관리 (오딧 로그) - -### 1.3 사용 예시 - -**DTG 제품 수명주기 관리** - -- 플로우 이름: "DTG 제품 라이프사이클" -- 연결 테이블: `product_dtg` -- 플로우 단계: - 1. 구매 (조건: `status = '구매완료' AND install_date IS NULL`) - 2. 설치 (조건: `status = '설치완료' AND disposal_date IS NULL`) - 3. 폐기 (조건: `status = '폐기완료'`) - ---- - -## 2. 데이터베이스 스키마 - -### 2.1 flow_definition (플로우 정의) - -```sql -CREATE TABLE flow_definition ( - id SERIAL PRIMARY KEY, - name VARCHAR(200) NOT NULL, -- 플로우 이름 - description TEXT, -- 플로우 설명 - table_name VARCHAR(200) NOT NULL, -- 연결된 테이블명 - is_active BOOLEAN DEFAULT true, -- 활성화 여부 - created_by VARCHAR(100), - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - -CREATE INDEX idx_flow_definition_table ON flow_definition(table_name); -``` - -### 2.2 flow_step (플로우 단계) - -```sql -CREATE TABLE flow_step ( - id SERIAL PRIMARY KEY, - flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE, - step_name VARCHAR(200) NOT NULL, -- 단계 이름 - step_order INTEGER NOT NULL, -- 단계 순서 - condition_json JSONB, -- 조건 설정 (JSON 형태) - color VARCHAR(50) DEFAULT '#3B82F6', -- 단계 표시 색상 - position_x INTEGER DEFAULT 0, -- 캔버스 상의 X 좌표 - position_y INTEGER DEFAULT 0, -- 캔버스 상의 Y 좌표 - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - -CREATE INDEX idx_flow_step_definition ON flow_step(flow_definition_id); -``` - -**condition_json 예시:** - -```json -{ - "type": "AND", - "conditions": [ - { - "column": "status", - "operator": "equals", - "value": "구매완료" - }, - { - "column": "install_date", - "operator": "is_null", - "value": null - } - ] -} -``` - -### 2.3 flow_step_connection (플로우 단계 연결) - -```sql -CREATE TABLE flow_step_connection ( - id SERIAL PRIMARY KEY, - flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE, - from_step_id INTEGER NOT NULL REFERENCES flow_step(id) ON DELETE CASCADE, - to_step_id INTEGER NOT NULL REFERENCES flow_step(id) ON DELETE CASCADE, - label VARCHAR(200), -- 연결선 라벨 (선택사항) - created_at TIMESTAMP DEFAULT NOW() -); - -CREATE INDEX idx_flow_connection_definition ON flow_step_connection(flow_definition_id); -``` - -### 2.4 flow_data_status (데이터의 현재 플로우 상태) - -```sql -CREATE TABLE flow_data_status ( - id SERIAL PRIMARY KEY, - flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE, - table_name VARCHAR(200) NOT NULL, -- 원본 테이블명 - record_id VARCHAR(100) NOT NULL, -- 원본 테이블의 레코드 ID - current_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL, - updated_by VARCHAR(100), - updated_at TIMESTAMP DEFAULT NOW(), - UNIQUE(flow_definition_id, table_name, record_id) -); - -CREATE INDEX idx_flow_data_status_record ON flow_data_status(table_name, record_id); -CREATE INDEX idx_flow_data_status_step ON flow_data_status(current_step_id); -``` - -### 2.5 flow_audit_log (플로우 상태 변경 이력) - -```sql -CREATE TABLE flow_audit_log ( - id SERIAL PRIMARY KEY, - flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE, - table_name VARCHAR(200) NOT NULL, - record_id VARCHAR(100) NOT NULL, - from_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL, - to_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL, - changed_by VARCHAR(100), - changed_at TIMESTAMP DEFAULT NOW(), - note TEXT -- 변경 사유 -); - -CREATE INDEX idx_flow_audit_record ON flow_audit_log(table_name, record_id, changed_at DESC); -CREATE INDEX idx_flow_audit_definition ON flow_audit_log(flow_definition_id); -``` - ---- - -## 3. 백엔드 API 설계 - -### 3.1 플로우 정의 관리 API - -#### 3.1.1 플로우 생성 - -``` -POST /api/flow/definitions -Body: { - name: string; - description?: string; - tableName: string; -} -Response: { success: boolean; data: FlowDefinition } -``` - -#### 3.1.2 플로우 목록 조회 - -``` -GET /api/flow/definitions -Query: { tableName?: string; isActive?: boolean } -Response: { success: boolean; data: FlowDefinition[] } -``` - -#### 3.1.3 플로우 상세 조회 - -``` -GET /api/flow/definitions/:id -Response: { - success: boolean; - data: { - definition: FlowDefinition; - steps: FlowStep[]; - connections: FlowStepConnection[]; - } -} -``` - -#### 3.1.4 플로우 수정 - -``` -PUT /api/flow/definitions/:id -Body: { name?: string; description?: string; isActive?: boolean } -Response: { success: boolean; data: FlowDefinition } -``` - -#### 3.1.5 플로우 삭제 - -``` -DELETE /api/flow/definitions/:id -Response: { success: boolean } -``` - -### 3.2 플로우 단계 관리 API - -#### 3.2.1 단계 추가 - -``` -POST /api/flow/definitions/:flowId/steps -Body: { - stepName: string; - stepOrder: number; - conditionJson?: object; - color?: string; - positionX?: number; - positionY?: number; -} -Response: { success: boolean; data: FlowStep } -``` - -#### 3.2.2 단계 수정 - -``` -PUT /api/flow/steps/:stepId -Body: { - stepName?: string; - stepOrder?: number; - conditionJson?: object; - color?: string; - positionX?: number; - positionY?: number; -} -Response: { success: boolean; data: FlowStep } -``` - -#### 3.2.3 단계 삭제 - -``` -DELETE /api/flow/steps/:stepId -Response: { success: boolean } -``` - -### 3.3 플로우 연결 관리 API - -#### 3.3.1 단계 연결 생성 - -``` -POST /api/flow/connections -Body: { - flowDefinitionId: number; - fromStepId: number; - toStepId: number; - label?: string; -} -Response: { success: boolean; data: FlowStepConnection } -``` - -#### 3.3.2 연결 삭제 - -``` -DELETE /api/flow/connections/:connectionId -Response: { success: boolean } -``` - -### 3.4 플로우 실행 API - -#### 3.4.1 단계별 데이터 카운트 조회 - -``` -GET /api/flow/:flowId/step/:stepId/count -Response: { - success: boolean; - data: { count: number } -} -``` - -#### 3.4.2 단계별 데이터 리스트 조회 - -``` -GET /api/flow/:flowId/step/:stepId/data -Query: { page?: number; pageSize?: number } -Response: { - success: boolean; - data: { - records: any[]; - total: number; - page: number; - pageSize: number; - } -} -``` - -#### 3.4.3 데이터를 다음 단계로 이동 - -``` -POST /api/flow/move -Body: { - flowId: number; - recordId: string; - toStepId: number; - note?: string; -} -Response: { success: boolean } -``` - -#### 3.4.4 데이터의 플로우 이력 조회 - -``` -GET /api/flow/audit/:flowId/:recordId -Response: { - success: boolean; - data: FlowAuditLog[] -} -``` - ---- - -## 4. 프론트엔드 구조 - -### 4.1 플로우 관리 화면 (`/flow-management`) - -#### 4.1.1 파일 구조 - -``` -frontend/src/app/flow-management/ -├── page.tsx # 메인 페이지 -├── components/ -│ ├── FlowList.tsx # 플로우 목록 -│ ├── FlowEditor.tsx # 플로우 편집기 (React Flow) -│ ├── FlowStepPanel.tsx # 단계 속성 편집 패널 -│ ├── FlowConditionBuilder.tsx # 조건 설정 빌더 -│ └── FlowPreview.tsx # 플로우 미리보기 -``` - -#### 4.1.2 주요 컴포넌트 - -**FlowEditor.tsx** - -- React Flow 라이브러리 사용 -- 플로우 단계를 노드로 표시 -- 단계 간 연결선 표시 -- 드래그앤드롭으로 노드 위치 조정 -- 노드 클릭 시 FlowStepPanel 표시 - -**FlowConditionBuilder.tsx** - -- 테이블 컬럼 선택 -- 연산자 선택 (equals, not_equals, in, not_in, greater_than, less_than, is_null, is_not_null) -- 값 입력 -- AND/OR 조건 그룹핑 -- 조건 추가/제거 - -### 4.2 화면관리 연동 - -#### 4.2.1 새로운 컴포넌트 타입 추가 - -**types/screen.ts에 추가:** - -```typescript -export interface FlowWidgetComponent extends BaseComponent { - type: "flow-widget"; - flowId?: number; - layout?: "horizontal" | "vertical"; - cardWidth?: string; - cardHeight?: string; - showCount?: boolean; - showConnections?: boolean; -} - -export type ComponentData = - | ContainerComponent - | WidgetComponent - | GroupComponent - | DataTableComponent - | ButtonComponent - | SplitPanelComponent - | RepeaterComponent - | FlowWidgetComponent; // 추가 -``` - -#### 4.2.2 FlowWidgetConfigPanel.tsx 생성 - -```typescript -// 플로우 위젯 설정 패널 -interface FlowWidgetConfigPanelProps { - component: FlowWidgetComponent; - onUpdateProperty: (property: string, value: any) => void; -} - -export function FlowWidgetConfigPanel({ - component, - onUpdateProperty, -}: FlowWidgetConfigPanelProps) { - const [flows, setFlows] = useState([]); - - useEffect(() => { - // 플로우 목록 불러오기 - loadFlows(); - }, []); - - return ( -
-
- - -
- -
- - -
- -
- - onUpdateProperty("cardWidth", e.target.value)} - /> -
- -
- - onUpdateProperty("showCount", checked)} - /> -
- -
- - - onUpdateProperty("showConnections", checked) - } - /> -
-
- ); -} -``` - -#### 4.2.3 RealtimePreview.tsx 수정 - -```typescript -// flow-widget 타입 렌더링 추가 -if (component.type === "flow-widget" && component.flowId) { - return ; -} -``` - -#### 4.2.4 FlowWidgetPreview.tsx 생성 - -```typescript -interface FlowWidgetPreviewProps { - component: FlowWidgetComponent; - interactive?: boolean; // InteractiveScreenViewer에서 true -} - -export function FlowWidgetPreview({ - component, - interactive, -}: FlowWidgetPreviewProps) { - const [flowData, setFlowData] = useState(null); - const [stepCounts, setStepCounts] = useState>({}); - - useEffect(() => { - if (component.flowId) { - loadFlowData(component.flowId); - } - }, [component.flowId]); - - const loadFlowData = async (flowId: number) => { - const response = await fetch(`/api/flow/definitions/${flowId}`); - const result = await response.json(); - if (result.success) { - setFlowData(result.data); - // 각 단계별 데이터 카운트 조회 - loadStepCounts(result.data.steps); - } - }; - - const loadStepCounts = async (steps: FlowStep[]) => { - const counts: Record = {}; - for (const step of steps) { - const response = await fetch( - `/api/flow/${component.flowId}/step/${step.id}/count` - ); - const result = await response.json(); - if (result.success) { - counts[step.id] = result.data.count; - } - } - setStepCounts(counts); - }; - - const handleStepClick = async (stepId: number) => { - if (!interactive) return; - // 단계 클릭 시 데이터 리스트 모달 표시 - // TODO: 구현 - }; - - const layout = component.layout || "horizontal"; - const cardWidth = component.cardWidth || "200px"; - const cardHeight = component.cardHeight || "120px"; - - return ( -
- {flowData?.steps.map((step: FlowStep, index: number) => ( - - handleStepClick(step.id)} - > - - {step.stepName} - - - {component.showCount && ( -
- {stepCounts[step.id] || 0} -
- )} -
-
- - {component.showConnections && index < flowData.steps.length - 1 && ( -
- {layout === "vertical" ? "↓" : "→"} -
- )} -
- ))} -
- ); -} -``` - -### 4.3 데이터 리스트 모달 - -#### 4.3.1 FlowStepDataModal.tsx 생성 - -```typescript -interface FlowStepDataModalProps { - flowId: number; - stepId: number; - stepName: string; - isOpen: boolean; - onClose: () => void; -} - -export function FlowStepDataModal({ - flowId, - stepId, - stepName, - isOpen, - onClose, -}: FlowStepDataModalProps) { - const [data, setData] = useState([]); - const [selectedRows, setSelectedRows] = useState>(new Set()); - - useEffect(() => { - if (isOpen) { - loadStepData(); - } - }, [isOpen, stepId]); - - const loadStepData = async () => { - const response = await fetch(`/api/flow/${flowId}/step/${stepId}/data`); - const result = await response.json(); - if (result.success) { - setData(result.data.records); - } - }; - - const handleMoveToNextStep = async () => { - // 선택된 레코드들을 다음 단계로 이동 - // TODO: 구현 - }; - - return ( - - - - {stepName} - 데이터 목록 - - -
- {/* 데이터 테이블 */} - -
- - - - - -
-
- ); -} -``` - -### 4.4 오딧 로그 화면 - -#### 4.4.1 FlowAuditLog.tsx 생성 - -```typescript -interface FlowAuditLogProps { - flowId: number; - recordId: string; -} - -export function FlowAuditLog({ flowId, recordId }: FlowAuditLogProps) { - const [logs, setLogs] = useState([]); - - useEffect(() => { - loadAuditLogs(); - }, [flowId, recordId]); - - const loadAuditLogs = async () => { - const response = await fetch(`/api/flow/audit/${flowId}/${recordId}`); - const result = await response.json(); - if (result.success) { - setLogs(result.data); - } - }; - - return ( -
-

상태 변경 이력

- -
- {logs.map((log) => ( - - -
- {log.fromStepName || "시작"} - - {log.toStepName} -
-
-
변경자: {log.changedBy}
-
변경일시: {new Date(log.changedAt).toLocaleString()}
- {log.note &&
변경사유: {log.note}
} -
-
-
- ))} -
-
- ); -} -``` - ---- - -## 5. 백엔드 구현 - -### 5.1 서비스 파일 구조 - -``` -backend-node/src/services/ -├── flowDefinitionService.ts # 플로우 정의 관리 -├── flowStepService.ts # 플로우 단계 관리 -├── flowConnectionService.ts # 플로우 연결 관리 -├── flowExecutionService.ts # 플로우 실행 (카운트, 데이터 조회) -├── flowDataMoveService.ts # 데이터 이동 및 오딧 로그 -└── flowConditionParser.ts # 조건 JSON을 SQL WHERE절로 변환 -``` - -### 5.2 flowConditionParser.ts 핵심 로직 - -```typescript -export interface FlowCondition { - column: string; - operator: - | "equals" - | "not_equals" - | "in" - | "not_in" - | "greater_than" - | "less_than" - | "is_null" - | "is_not_null"; - value: any; -} - -export interface FlowConditionGroup { - type: "AND" | "OR"; - conditions: FlowCondition[]; -} - -export class FlowConditionParser { - /** - * 조건 JSON을 SQL WHERE 절로 변환 - */ - static toSqlWhere(conditionGroup: FlowConditionGroup): { - where: string; - params: any[]; - } { - const conditions: string[] = []; - const params: any[] = []; - let paramIndex = 1; - - for (const condition of conditionGroup.conditions) { - const column = this.sanitizeColumnName(condition.column); - - switch (condition.operator) { - case "equals": - conditions.push(`${column} = $${paramIndex}`); - params.push(condition.value); - paramIndex++; - break; - - case "not_equals": - conditions.push(`${column} != $${paramIndex}`); - params.push(condition.value); - paramIndex++; - break; - - case "in": - if (Array.isArray(condition.value) && condition.value.length > 0) { - const placeholders = condition.value - .map(() => `$${paramIndex++}`) - .join(", "); - conditions.push(`${column} IN (${placeholders})`); - params.push(...condition.value); - } - break; - - case "not_in": - if (Array.isArray(condition.value) && condition.value.length > 0) { - const placeholders = condition.value - .map(() => `$${paramIndex++}`) - .join(", "); - conditions.push(`${column} NOT IN (${placeholders})`); - params.push(...condition.value); - } - break; - - case "greater_than": - conditions.push(`${column} > $${paramIndex}`); - params.push(condition.value); - paramIndex++; - break; - - case "less_than": - conditions.push(`${column} < $${paramIndex}`); - params.push(condition.value); - paramIndex++; - break; - - case "is_null": - conditions.push(`${column} IS NULL`); - break; - - case "is_not_null": - conditions.push(`${column} IS NOT NULL`); - break; - } - } - - const joinOperator = conditionGroup.type === "OR" ? " OR " : " AND "; - const where = conditions.length > 0 ? conditions.join(joinOperator) : "1=1"; - - return { where, params }; - } - - /** - * SQL 인젝션 방지를 위한 컬럼명 검증 - */ - private static sanitizeColumnName(columnName: string): string { - // 알파벳, 숫자, 언더스코어만 허용 - if (!/^[a-zA-Z0-9_]+$/.test(columnName)) { - throw new Error(`Invalid column name: ${columnName}`); - } - return columnName; - } -} -``` - -### 5.3 flowExecutionService.ts 핵심 로직 - -```typescript -export class FlowExecutionService { - /** - * 특정 플로우 단계에 해당하는 데이터 카운트 - */ - async getStepDataCount(flowId: number, stepId: number): Promise { - // 1. 플로우 정의 조회 - const flowDef = await this.getFlowDefinition(flowId); - - // 2. 플로우 단계 조회 - const step = await this.getFlowStep(stepId); - - // 3. 조건 JSON을 SQL WHERE절로 변환 - const { where, params } = FlowConditionParser.toSqlWhere( - step.conditionJson - ); - - // 4. 카운트 쿼리 실행 - const query = `SELECT COUNT(*) as count FROM ${flowDef.tableName} WHERE ${where}`; - const result = await db.query(query, params); - - return parseInt(result.rows[0].count); - } - - /** - * 특정 플로우 단계에 해당하는 데이터 리스트 - */ - async getStepDataList( - flowId: number, - stepId: number, - page: number = 1, - pageSize: number = 20 - ): Promise<{ records: any[]; total: number }> { - const flowDef = await this.getFlowDefinition(flowId); - const step = await this.getFlowStep(stepId); - const { where, params } = FlowConditionParser.toSqlWhere( - step.conditionJson - ); - - const offset = (page - 1) * pageSize; - - // 전체 카운트 - const countQuery = `SELECT COUNT(*) as count FROM ${flowDef.tableName} WHERE ${where}`; - const countResult = await db.query(countQuery, params); - const total = parseInt(countResult.rows[0].count); - - // 데이터 조회 - const dataQuery = ` - SELECT * FROM ${flowDef.tableName} - WHERE ${where} - ORDER BY id DESC - LIMIT $${params.length + 1} OFFSET $${params.length + 2} - `; - const dataResult = await db.query(dataQuery, [...params, pageSize, offset]); - - return { - records: dataResult.rows, - total, - }; - } -} -``` - -### 5.4 flowDataMoveService.ts 핵심 로직 - -```typescript -export class FlowDataMoveService { - /** - * 데이터를 다음 플로우 단계로 이동 - */ - async moveDataToStep( - flowId: number, - recordId: string, - toStepId: number, - userId: string, - note?: string - ): Promise { - const client = await db.getClient(); - - try { - await client.query("BEGIN"); - - // 1. 현재 상태 조회 - const currentStatus = await this.getCurrentStatus( - client, - flowId, - recordId - ); - const fromStepId = currentStatus?.currentStepId || null; - - // 2. flow_data_status 업데이트 또는 삽입 - if (currentStatus) { - await client.query( - ` - UPDATE flow_data_status - SET current_step_id = $1, updated_by = $2, updated_at = NOW() - WHERE flow_definition_id = $3 AND record_id = $4 - `, - [toStepId, userId, flowId, recordId] - ); - } else { - const flowDef = await this.getFlowDefinition(flowId); - await client.query( - ` - INSERT INTO flow_data_status - (flow_definition_id, table_name, record_id, current_step_id, updated_by) - VALUES ($1, $2, $3, $4, $5) - `, - [flowId, flowDef.tableName, recordId, toStepId, userId] - ); - } - - // 3. 오딧 로그 기록 - await client.query( - ` - INSERT INTO flow_audit_log - (flow_definition_id, table_name, record_id, from_step_id, to_step_id, changed_by, note) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, - [ - flowId, - currentStatus?.tableName || recordId, - recordId, - fromStepId, - toStepId, - userId, - note, - ] - ); - - await client.query("COMMIT"); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } - } - - /** - * 데이터의 플로우 이력 조회 - */ - async getAuditLogs(flowId: number, recordId: string): Promise { - const query = ` - SELECT - fal.*, - fs_from.step_name as from_step_name, - fs_to.step_name as to_step_name - FROM flow_audit_log fal - LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id - LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id - WHERE fal.flow_definition_id = $1 AND fal.record_id = $2 - ORDER BY fal.changed_at DESC - `; - - const result = await db.query(query, [flowId, recordId]); - return result.rows; - } -} -``` - ---- - -## 6. 구현 단계 - -### Phase 1: 기본 구조 (1주) - -- [ ] 데이터베이스 마이그레이션 생성 및 실행 -- [ ] 백엔드 서비스 기본 구조 생성 -- [ ] 플로우 정의 CRUD API 구현 -- [ ] 플로우 단계 CRUD API 구현 -- [ ] 플로우 연결 API 구현 - -### Phase 2: 플로우 편집기 (1주) - -- [ ] React Flow 라이브러리 설치 및 설정 -- [ ] FlowEditor 컴포넌트 구현 -- [ ] FlowList 컴포넌트 구현 -- [ ] FlowStepPanel 구현 (단계 속성 편집) -- [ ] FlowConditionBuilder 구현 (조건 설정 UI) -- [ ] 플로우 저장/불러오기 기능 - -### Phase 3: 화면관리 연동 (3일) - -- [ ] FlowWidgetComponent 타입 정의 -- [ ] FlowWidgetConfigPanel 구현 -- [ ] FlowWidgetPreview 구현 -- [ ] RealtimePreview에 flow-widget 렌더링 추가 -- [ ] InteractiveScreenViewer에 flow-widget 렌더링 추가 - -### Phase 4: 플로우 실행 기능 (1주) - -- [ ] FlowConditionParser 구현 (조건 → SQL 변환) -- [ ] FlowExecutionService 구현 (카운트, 데이터 조회) -- [ ] 단계별 데이터 카운트 API -- [ ] 단계별 데이터 리스트 API -- [ ] FlowStepDataModal 구현 -- [ ] 데이터 선택 및 표시 기능 - -### Phase 5: 데이터 이동 및 오딧 (3일) - -- [ ] FlowDataMoveService 구현 -- [ ] 데이터 이동 API -- [ ] 오딧 로그 API -- [ ] FlowAuditLog 컴포넌트 구현 -- [ ] 데이터 이동 시 트랜잭션 처리 - -### Phase 6: 테스트 및 최적화 (3일) - -- [ ] 단위 테스트 작성 -- [ ] 통합 테스트 작성 -- [ ] 성능 최적화 (인덱스, 쿼리 최적화) -- [ ] 사용자 테스트 및 피드백 반영 - ---- - -## 7. 기술 스택 - -### 7.1 프론트엔드 - -- **React Flow**: 플로우 시각화 및 편집 -- **Shadcn/ui**: UI 컴포넌트 -- **TanStack Query**: 데이터 페칭 및 캐싱 - -### 7.2 백엔드 - -- **Node.js + Express**: API 서버 -- **PostgreSQL**: 데이터베이스 -- **TypeScript**: 타입 안전성 - ---- - -## 8. 주요 고려사항 - -### 8.1 성능 - -- 플로우 단계별 데이터 카운트 조회 시 인덱스 활용 -- 대용량 데이터 처리 시 페이징 필수 -- 조건 파싱 결과 캐싱 - -### 8.2 보안 - -- SQL 인젝션 방지: 파라미터 바인딩 사용 -- 컬럼명 검증: 알파벳, 숫자, 언더스코어만 허용 -- 사용자 권한 확인 - -### 8.3 확장성 - -- 복잡한 조건 (중첩 AND/OR) 지원 가능하도록 설계 -- 플로우 분기 (조건부 분기) 지원 가능하도록 설계 -- 자동 플로우 이동 (트리거) 추후 추가 가능 - -### 8.4 사용성 - -- 플로우 편집기의 직관적인 UI -- 드래그앤드롭으로 쉬운 조작 -- 실시간 데이터 카운트 표시 -- 오딧 로그를 통한 추적 가능성 - ---- - -## 9. 예상 일정 - -| Phase | 기간 | 담당 | -| ---------------------------- | ---------- | ------------------ | -| Phase 1: 기본 구조 | 1주 | Backend | -| Phase 2: 플로우 편집기 | 1주 | Frontend | -| Phase 3: 화면관리 연동 | 3일 | Frontend | -| Phase 4: 플로우 실행 | 1주 | Backend + Frontend | -| Phase 5: 데이터 이동 및 오딧 | 3일 | Backend + Frontend | -| Phase 6: 테스트 및 최적화 | 3일 | 전체 | -| **총 예상 기간** | **약 4주** | | - ---- - -## 10. 향후 확장 계획 - -### 10.1 자동 플로우 이동 - -- 특정 조건 충족 시 자동으로 다음 단계로 이동 -- 예: 결재 승인 시 자동으로 '승인완료' 단계로 이동 - -### 10.2 플로우 분기 - -- 조건에 따라 다른 경로로 분기 -- 예: 금액에 따라 '일반 승인' 또는 '특별 승인' 경로 - -### 10.3 플로우 알림 - -- 특정 단계 진입 시 담당자에게 알림 -- 이메일, 시스템 알림 등 - -### 10.4 플로우 템플릿 - -- 자주 사용하는 플로우 패턴을 템플릿으로 저장 -- 템플릿에서 새 플로우 생성 - -### 10.5 플로우 통계 대시보드 - -- 단계별 체류 시간 분석 -- 병목 구간 식별 -- 처리 속도 통계 - ---- - -## 11. 참고 자료 - -### 11.1 React Flow - -- 공식 문서: https://reactflow.dev/ -- 예제: https://reactflow.dev/examples - -### 11.2 유사 시스템 - -- Jira Workflow -- Trello Board -- GitHub Projects - ---- - -## 12. 결론 - -이 플로우 관리 시스템은 제품 수명주기, 업무 프로세스, 승인 프로세스 등 다양한 비즈니스 워크플로우를 시각적으로 정의하고 관리할 수 있는 강력한 도구입니다. - -화면관리 시스템과의 긴밀한 통합을 통해 사용자는 코드 없이도 복잡한 워크플로우를 구축하고 실행할 수 있으며, 오딧 로그를 통해 모든 상태 변경을 추적할 수 있습니다. - -단계별 구현을 통해 안정적으로 개발하고, 향후 확장 가능성을 염두에 두어 장기적으로 유지보수하기 쉬운 시스템을 구축할 수 있습니다. diff --git a/PHASE_FLOW_STEP_BUTTON_VISIBILITY_CONTROL.md b/PHASE_FLOW_STEP_BUTTON_VISIBILITY_CONTROL.md deleted file mode 100644 index 39e9a364..00000000 --- a/PHASE_FLOW_STEP_BUTTON_VISIBILITY_CONTROL.md +++ /dev/null @@ -1,1216 +0,0 @@ -# 플로우 단계별 버튼 표시/숨김 제어 시스템 구현 계획서 - -## 📋 목차 -1. [개요](#개요) -2. [요구사항 분석](#요구사항-분석) -3. [UI/UX 문제 및 해결방안](#uiux-문제-및-해결방안) -4. [시스템 아키텍처](#시스템-아키텍처) -5. [구현 상세](#구현-상세) -6. [데이터 구조](#데이터-구조) -7. [구현 단계](#구현-단계) - ---- - -## 개요 - -### 목적 -플로우 위젯과 버튼 컴포넌트를 함께 사용할 때, 각 플로우 단계(Step)별로 특정 버튼만 표시하거나 숨기는 기능을 제공합니다. - -### 핵심 시나리오 -``` -예시: 계약 프로세스 플로우 -- Step 1 (구매 등록): [설치 자재 정보 수정] 버튼만 표시 -- Step 2 (설치팀): [설치 자재 정보 수정], [수리증] 버튼 표시 -- Step 3 (사용중): [설치 자재 정보 수정], [수리증], [폐기 처리] 버튼 모두 표시 -- Step 4 (수리중): [수리증] 버튼만 표시 -- Step 5 (폐기): 모든 버튼 숨김 -``` - ---- - -## 요구사항 분석 - -### 1. 기능적 요구사항 - -#### FR-1: 자동 감지 및 설정 UI 표시 -- **설명**: 화면 편집기에 플로우 위젯과 버튼이 함께 배치되면, 버튼 설정 패널에 자동으로 "플로우 단계별 표시 설정" UI가 나타남 -- **조건**: - - 화면에 1개 이상의 플로우 위젯 존재 - - 버튼 컴포넌트 선택 시 -- **동작**: - ``` - 1. 화면의 모든 플로우 위젯 감지 - 2. 각 플로우의 단계 목록 조회 - 3. 버튼 속성 패널에 "플로우 단계별 표시 설정" 섹션 추가 - ``` - -#### FR-2: 다중 단계 선택 -- **설명**: 한 버튼이 여러 플로우 단계에서 동시에 표시될 수 있음 -- **예시**: - ``` - [설치 자재 정보 수정] 버튼 - - Step 1 ✅ 표시 - - Step 2 ✅ 표시 - - Step 3 ✅ 표시 - - Step 4 ❌ 숨김 - - Step 5 ❌ 숨김 - ``` - -#### FR-3: 버튼별 독립 설정 -- **설명**: 각 버튼은 서로 다른 플로우 단계 표시 설정을 가질 수 있음 -- **예시**: - ``` - 화면에 3개 버튼: - - [버튼 A]: Step 1, 2에서만 표시 - - [버튼 B]: Step 2, 3, 4에서 표시 - - [버튼 C]: 모든 단계에서 표시 - ``` - -#### FR-4: 다중 플로우 위젯 지원 -- **설명**: 화면에 2개 이상의 플로우 위젯이 있을 경우, 버튼이 어느 플로우에 반응할지 선택 가능 -- **예시**: - ``` - 화면 구성: - - 플로우 위젯 A (계약 프로세스) - - 플로우 위젯 B (AS 프로세스) - - [저장] 버튼: 플로우 A의 단계에 따라 표시/숨김 - - [AS 접수] 버튼: 플로우 B의 단계에 따라 표시/숨김 - ``` - -#### FR-5: 실시간 반응 -- **설명**: 플로우 위젯에서 단계를 클릭하면, 버튼들이 즉시 표시/숨김 전환 -- **성능**: 50ms 이내 반응 - ---- - -### 2. UI/UX 요구사항 - -#### UX-1: 빈 공간 문제 해결 -**문제**: 버튼이 여러 개 배치되어 있을 때, 중간 버튼이 숨겨지면 빈 공간이 생김 - -**해결방안 옵션**: - -##### 옵션 A: Flexbox 자동 정렬 (권장) -```tsx -// 버튼 컨테이너를 자동으로 감지하고 Flexbox로 변환 -
- {visibleButtons.map(button =>
-``` -**장점**: -- 자동으로 빈 공간 채움 -- CSS 기반으로 성능 우수 -- 반응형 대응 자동 - -**단점**: -- 기존 절대 위치(absolute positioning) 레이아웃과 충돌 가능 - -##### 옵션 B: 조건부 렌더링 (현재 구조 유지) -```tsx -// 각 버튼을 원래 위치에 렌더링하되, 보이지 않는 버튼은 display: none -{buttons.map(button => ( -
-
-))} -``` -**장점**: -- 현재 레이아웃 시스템 유지 -- 구현 간단 - -**단점**: -- 빈 공간 그대로 남음 -- 디자이너가 의도한 배치가 깨질 수 있음 - -##### 옵션 C: 그룹 기반 재정렬 (하이브리드) -```tsx -// 버튼들을 그룹으로 묶고, 그룹 내에서만 재정렬 - - -``` - -##### 3-2. 버튼 컴포넌트 뱃지 -```tsx -// 플로우 제어가 활성화된 버튼에 뱃지 표시 - -``` - ---- - -## 시스템 아키텍처 - -### 1. 전역 상태 관리 (Zustand Store) - -#### FlowStepStore -```typescript -// stores/flowStepStore.ts -import create from 'zustand'; - -interface FlowStepState { - // 현재 선택된 플로우 단계 (화면당 여러 플로우 가능) - selectedSteps: Record; // key: flowId, value: stepId - - // 플로우 단계 선택 - setSelectedStep: (flowId: string, stepId: number | null) => void; - - // 특정 플로우의 현재 단계 가져오기 - getCurrentStep: (flowId: string) => number | null; - - // 초기화 - reset: () => void; -} - -export const useFlowStepStore = create((set, get) => ({ - selectedSteps: {}, - - setSelectedStep: (flowId, stepId) => - set((state) => ({ - selectedSteps: { ...state.selectedSteps, [flowId]: stepId }, - })), - - getCurrentStep: (flowId) => get().selectedSteps[flowId] || null, - - reset: () => set({ selectedSteps: {} }), -})); -``` - -### 2. 데이터 흐름 - -``` -┌─────────────────┐ -│ FlowWidget │ -│ │ -│ 단계 클릭 │ ─────┐ -└─────────────────┘ │ - │ setSelectedStep(flowId, stepId) - ▼ - ┌──────────────────┐ - │ FlowStepStore │ - │ (Zustand) │ - │ │ - │ selectedSteps │ - └──────────────────┘ - │ - │ useFlowStepStore() - │ - ┌────────────────┼────────────────┐ - ▼ ▼ ▼ -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Button A │ │ Button B │ │ Button C │ -│ │ │ │ │ │ -│ Step 1,2,3 │ │ Step 2,3,4 │ │ All Steps │ -│ │ │ │ │ │ -│ 현재: 표시 │ │ 현재: 표시 │ │ 현재: 표시 │ -└──────────────┘ └──────────────┘ └──────────────┘ -``` - -### 3. 컴포넌트 계층 구조 - -``` -ScreenDesigner -├── FlowWidget (컴포넌트 ID: "flow-1") -│ ├── Step 1 카드 -│ ├── Step 2 카드 ← 클릭됨 -│ └── Step 3 카드 -│ -├── ButtonGroup (레이아웃 컨테이너) -│ ├── Button A (flowVisibilityConfig: { flowId: "flow-1", visibleSteps: [1,2] }) -│ │ └── 현재 숨김 (Step 2 ∉ [1,2] ❌) -│ │ -│ ├── Button B (flowVisibilityConfig: { flowId: "flow-1", visibleSteps: [2,3,4] }) -│ │ └── 현재 표시 (Step 2 ∈ [2,3,4] ✅) -│ │ -│ └── Button C (flowVisibilityConfig: null) -│ └── 항상 표시 ✅ -│ -└── PropertiesPanel - └── FlowVisibilityConfigPanel (버튼 선택 시) - ├── 대상 플로우 선택 Dropdown - ├── 단계 체크박스 목록 - └── 레이아웃 옵션 -``` - ---- - -## 데이터 구조 - -### 1. ButtonTypeConfig 확장 - -```typescript -// types/screen-management.ts - -export interface ButtonTypeConfig { - // 기존 설정 - actionType: ButtonActionType; - text?: string; - variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; - size?: "sm" | "md" | "lg"; - icon?: string; - - // ... 기존 필드들 ... - - // 🆕 플로우 단계별 표시 제어 - flowVisibilityConfig?: FlowVisibilityConfig; -} - -/** - * 플로우 단계별 버튼 표시 설정 - */ -export interface FlowVisibilityConfig { - // 활성화 여부 - enabled: boolean; - - // 대상 플로우 (컴포넌트 ID) - targetFlowComponentId: string; // 예: "component-123" - targetFlowId?: number; // 플로우 정의 ID (선택사항, 검증용) - targetFlowName?: string; // 플로우 이름 (표시용) - - // 표시 조건 - mode: "whitelist" | "blacklist" | "all"; - // - whitelist: visibleSteps에 포함된 단계에서만 표시 - // - blacklist: hiddenSteps에 포함된 단계에서 숨김 - // - all: 모든 단계에서 표시 (기본값) - - visibleSteps?: number[]; // mode="whitelist"일 때 사용 - hiddenSteps?: number[]; // mode="blacklist"일 때 사용 - - // 레이아웃 옵션 - layoutBehavior: "preserve-position" | "auto-compact"; - // - preserve-position: 원래 위치 유지 (display: none) - // - auto-compact: 빈 공간 자동 제거 (Flexbox) -} -``` - -### 2. ComponentData 확장 - -```typescript -// types/screen.ts - -export interface ComponentData { - // 기존 필드들... - - // 🆕 버튼 그룹 정보 (옵션 C 구현 시) - buttonGroupId?: string; // 버튼 그룹 ID - buttonGroupLayout?: "horizontal" | "vertical" | "grid"; - buttonGroupAlign?: "start" | "center" | "end" | "space-between"; -} -``` - -### 3. 데이터베이스 스키마 (선택사항) - -플로우 설정을 DB에 저장하려면: - -```sql --- screen_components 테이블에 컬럼 추가 -ALTER TABLE screen_components -ADD COLUMN flow_visibility_config JSONB NULL; - --- 인덱스 추가 (쿼리 성능) -CREATE INDEX idx_screen_components_flow_visibility -ON screen_components USING GIN (flow_visibility_config); - --- 예시 데이터 -{ - "enabled": true, - "targetFlowComponentId": "component-123", - "targetFlowId": 5, - "targetFlowName": "계약 관리 플로우", - "mode": "whitelist", - "visibleSteps": [1, 2, 3], - "layoutBehavior": "auto-compact" -} -``` - ---- - -## 구현 상세 - -### Phase 1: 전역 상태 관리 - -#### 파일: `frontend/stores/flowStepStore.ts` - -```typescript -"use client"; - -import { create } from "zustand"; -import { devtools } from "zustand/middleware"; - -/** - * 플로우 단계 전역 상태 관리 - */ -interface FlowStepState { - // 화면당 여러 플로우의 현재 선택된 단계 - // key: flowComponentId (예: "component-123") - selectedSteps: Record; - - // 플로우 단계 선택 - setSelectedStep: (flowComponentId: string, stepId: number | null) => void; - - // 현재 단계 조회 - getCurrentStep: (flowComponentId: string) => number | null; - - // 모든 플로우 초기화 - reset: () => void; - - // 특정 플로우만 초기화 - resetFlow: (flowComponentId: string) => void; -} - -export const useFlowStepStore = create()( - devtools( - (set, get) => ({ - selectedSteps: {}, - - setSelectedStep: (flowComponentId, stepId) => { - console.log("🔄 플로우 단계 변경:", { flowComponentId, stepId }); - set((state) => ({ - selectedSteps: { - ...state.selectedSteps, - [flowComponentId]: stepId, - }, - })); - }, - - getCurrentStep: (flowComponentId) => { - return get().selectedSteps[flowComponentId] || null; - }, - - reset: () => { - console.log("🔄 모든 플로우 단계 초기화"); - set({ selectedSteps: {} }); - }, - - resetFlow: (flowComponentId) => { - console.log("🔄 플로우 단계 초기화:", flowComponentId); - set((state) => { - const { [flowComponentId]: _, ...rest } = state.selectedSteps; - return { selectedSteps: rest }; - }); - }, - }), - { name: "FlowStepStore" } - ) -); -``` - ---- - -### Phase 2: FlowWidget 수정 - -#### 파일: `frontend/components/screen/widgets/FlowWidget.tsx` - -```typescript -// 기존 imports... -import { useFlowStepStore } from "@/stores/flowStepStore"; - -interface FlowWidgetProps { - component: FlowComponent; - onStepClick?: (stepId: number, stepName: string) => void; - onSelectedDataChange?: (selectedData: any[], stepId: number | null) => void; - flowRefreshKey?: number; - onFlowRefresh?: () => void; -} - -export function FlowWidget({ component, onStepClick, ... }: FlowWidgetProps) { - // 🆕 전역 상태 관리 - const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep); - const resetFlow = useFlowStepStore((state) => state.resetFlow); - - // 기존 states... - const [selectedStepId, setSelectedStepId] = useState(null); - - // componentId (플로우 컴포넌트 고유 ID) - const flowComponentId = component.id; - - // 🆕 단계 클릭 핸들러 수정 - const handleStepClick = async (stepId: number, stepName: string) => { - // 외부 콜백 실행 - if (onStepClick) { - onStepClick(stepId, stepName); - } - - // 같은 스텝 클릭 시 해제 - if (selectedStepId === stepId) { - setSelectedStepId(null); - setSelectedStep(flowComponentId, null); // 🆕 전역 상태 업데이트 - setStepData([]); - setStepDataColumns([]); - setSelectedRows(new Set()); - onSelectedDataChange?.([], null); - return; - } - - // 새로운 스텝 선택 - setSelectedStepId(stepId); - setSelectedStep(flowComponentId, stepId); // 🆕 전역 상태 업데이트 - - // 기존 데이터 로드 로직... - setStepDataLoading(true); - setSelectedRows(new Set()); - onSelectedDataChange?.([], stepId); - - try { - // 데이터 로딩... - } catch (err) { - console.error("Failed to load step data:", err); - toast.error(err.message || "데이터를 불러오는데 실패했습니다"); - } finally { - setStepDataLoading(false); - } - }; - - // 🆕 언마운트 시 상태 초기화 - useEffect(() => { - return () => { - resetFlow(flowComponentId); - }; - }, [flowComponentId, resetFlow]); - - // 기존 렌더링 로직... - return ( -
- {/* ... */} -
- ); -} -``` - ---- - -### Phase 3: 버튼 표시/숨김 로직 - -#### 파일: `frontend/components/screen/OptimizedButtonComponent.tsx` - -```typescript -// 기존 imports... -import { useFlowStepStore } from "@/stores/flowStepStore"; -import { useMemo } from "react"; - -export const OptimizedButtonComponent: React.FC = ({ - component, - ...props -}) => { - const config = component.webTypeConfig || {}; - const flowConfig = config.flowVisibilityConfig; - - // 🆕 현재 플로우 단계 구독 - const currentStep = useFlowStepStore((state) => { - if (!flowConfig?.enabled || !flowConfig.targetFlowComponentId) { - return null; - } - return state.getCurrentStep(flowConfig.targetFlowComponentId); - }); - - // 🆕 버튼 표시 여부 계산 - const shouldShowButton = useMemo(() => { - // 플로우 제어 비활성화 시 항상 표시 - if (!flowConfig?.enabled) { - console.log("🔍 버튼 표시 체크 (플로우 제어 비활성):", component.id, "→ 항상 표시"); - return true; - } - - // 플로우 단계가 선택되지 않은 경우 - if (currentStep === null) { - console.log("🔍 버튼 표시 체크 (단계 미선택):", component.id, "→ 항상 표시"); - return true; // 기본값: 표시 - } - - const { mode, visibleSteps = [], hiddenSteps = [] } = flowConfig; - - let result = true; - if (mode === "whitelist") { - result = visibleSteps.includes(currentStep); - } else if (mode === "blacklist") { - result = !hiddenSteps.includes(currentStep); - } else if (mode === "all") { - result = true; - } - - console.log("🔍 버튼 표시 체크:", { - buttonId: component.id, - currentStep, - mode, - visibleSteps, - hiddenSteps, - result: result ? "표시" : "숨김", - }); - - return result; - }, [flowConfig, currentStep, component.id]); - - // 🆕 숨김 처리 - if (!shouldShowButton) { - // 레이아웃 동작에 따라 다르게 처리 - if (flowConfig?.layoutBehavior === "preserve-position") { - // 위치 유지 (빈 공간) - return
; - } else { - // 완전히 렌더링하지 않음 (auto-compact) - return null; - } - } - - // 기존 버튼 렌더링 로직... - return ( -
- - - {/* 🆕 플로우 제어 활성화 표시 */} - {flowConfig?.enabled && ( -
- - - -
- )} -
- ); -}; -``` - ---- - -### Phase 4: 설정 UI - -#### 파일: `frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx` - -```typescript -"use client"; - -import React, { useState, useEffect, useMemo } from "react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Badge } from "@/components/ui/badge"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Workflow, Info, CheckCircle, XCircle } from "lucide-react"; -import { ComponentData } from "@/types/screen"; -import { FlowVisibilityConfig } from "@/types/screen-management"; -import { getFlowById } from "@/lib/api/flow"; -import type { FlowDefinition, FlowStep } from "@/types/flow"; -import { toast } from "sonner"; - -interface FlowVisibilityConfigPanelProps { - component: ComponentData; // 현재 선택된 버튼 - allComponents: ComponentData[]; // 화면의 모든 컴포넌트 - onUpdateProperty: (path: string, value: any) => void; -} - -export const FlowVisibilityConfigPanel: React.FC = ({ - component, - allComponents, - onUpdateProperty, -}) => { - // 현재 설정 - const currentConfig: FlowVisibilityConfig | undefined = (component as any).webTypeConfig?.flowVisibilityConfig; - - // 화면의 모든 플로우 위젯 찾기 - const flowWidgets = useMemo(() => { - return allComponents.filter((comp) => { - const isFlowWidget = - comp.type === "flow" || - (comp.type === "component" && (comp as any).componentConfig?.type === "flow-widget"); - return isFlowWidget; - }); - }, [allComponents]); - - // State - const [enabled, setEnabled] = useState(currentConfig?.enabled || false); - const [selectedFlowComponentId, setSelectedFlowComponentId] = useState( - currentConfig?.targetFlowComponentId || null - ); - const [mode, setMode] = useState<"whitelist" | "blacklist" | "all">(currentConfig?.mode || "whitelist"); - const [visibleSteps, setVisibleSteps] = useState(currentConfig?.visibleSteps || []); - const [hiddenSteps, setHiddenSteps] = useState(currentConfig?.hiddenSteps || []); - const [layoutBehavior, setLayoutBehavior] = useState<"preserve-position" | "auto-compact">( - currentConfig?.layoutBehavior || "auto-compact" - ); - - // 선택된 플로우의 스텝 목록 - const [flowSteps, setFlowSteps] = useState([]); - const [flowInfo, setFlowInfo] = useState(null); - const [loading, setLoading] = useState(false); - - // 플로우가 없을 때 - if (flowWidgets.length === 0) { - return ( - - - - 화면에 플로우 위젯을 추가하면 단계별 버튼 표시 제어가 가능합니다. - - - ); - } - - // 선택된 플로우의 스텝 로드 - useEffect(() => { - if (!selectedFlowComponentId) { - setFlowSteps([]); - setFlowInfo(null); - return; - } - - const loadFlowSteps = async () => { - try { - setLoading(true); - - // 선택된 플로우 위젯 찾기 - const flowWidget = flowWidgets.find((fw) => fw.id === selectedFlowComponentId); - if (!flowWidget) return; - - // flowId 추출 - const flowConfig = (flowWidget as any).componentConfig || {}; - const flowId = flowConfig.flowId; - if (!flowId) { - toast.error("플로우 ID를 찾을 수 없습니다"); - return; - } - - // 플로우 정보 조회 - const flowResponse = await getFlowById(flowId); - if (!flowResponse.success || !flowResponse.data) { - throw new Error("플로우를 찾을 수 없습니다"); - } - setFlowInfo(flowResponse.data); - - // 스텝 목록 조회 - const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`); - if (!stepsResponse.ok) { - throw new Error("스텝 목록을 불러올 수 없습니다"); - } - const stepsData = await stepsResponse.json(); - if (stepsData.success && stepsData.data) { - const sortedSteps = stepsData.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder); - setFlowSteps(sortedSteps); - } - } catch (error: any) { - console.error("플로우 스텝 로딩 실패:", error); - toast.error(error.message || "플로우 정보를 불러오는데 실패했습니다"); - } finally { - setLoading(false); - } - }; - - loadFlowSteps(); - }, [selectedFlowComponentId, flowWidgets]); - - // 설정 저장 - const handleSave = () => { - const config: FlowVisibilityConfig = { - enabled, - targetFlowComponentId: selectedFlowComponentId || "", - targetFlowId: flowInfo?.id, - targetFlowName: flowInfo?.name, - mode, - visibleSteps: mode === "whitelist" ? visibleSteps : undefined, - hiddenSteps: mode === "blacklist" ? hiddenSteps : undefined, - layoutBehavior, - }; - - onUpdateProperty("webTypeConfig.flowVisibilityConfig", config); - toast.success("플로우 단계별 표시 설정이 저장되었습니다"); - }; - - // 체크박스 토글 - const toggleStep = (stepId: number) => { - if (mode === "whitelist") { - setVisibleSteps((prev) => - prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId] - ); - } else if (mode === "blacklist") { - setHiddenSteps((prev) => - prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId] - ); - } - }; - - // 빠른 선택 - const selectAll = () => { - if (mode === "whitelist") { - setVisibleSteps(flowSteps.map((s) => s.id)); - } else if (mode === "blacklist") { - setHiddenSteps([]); - } - }; - - const selectNone = () => { - if (mode === "whitelist") { - setVisibleSteps([]); - } else if (mode === "blacklist") { - setHiddenSteps(flowSteps.map((s) => s.id)); - } - }; - - const invertSelection = () => { - if (mode === "whitelist") { - const allStepIds = flowSteps.map((s) => s.id); - setVisibleSteps(allStepIds.filter((id) => !visibleSteps.includes(id))); - } else if (mode === "blacklist") { - const allStepIds = flowSteps.map((s) => s.id); - setHiddenSteps(allStepIds.filter((id) => !hiddenSteps.includes(id))); - } - }; - - return ( - - - - - 플로우 단계별 표시 설정 - - - 플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다 - - - - - {/* 활성화 체크박스 */} -
- setEnabled(!!checked)} /> - -
- - {enabled && ( - <> - {/* 대상 플로우 선택 */} -
- - -
- - {/* 플로우가 선택되면 스텝 목록 표시 */} - {selectedFlowComponentId && flowSteps.length > 0 && ( - <> - {/* 모드 선택 */} -
- - setMode(value)}> -
- - -
-
- - -
-
- - -
-
-
- - {/* 단계 선택 (all 모드가 아닐 때만) */} - {mode !== "all" && ( -
-
- -
- - - -
-
- - {/* 스텝 체크박스 목록 */} -
- {flowSteps.map((step) => { - const isChecked = - mode === "whitelist" - ? visibleSteps.includes(step.id) - : hiddenSteps.includes(step.id); - - return ( -
- toggleStep(step.id)} - /> - -
- ); - })} -
-
- )} - - {/* 레이아웃 옵션 */} -
- - setLayoutBehavior(value)}> -
- - -
-
- - -
-
-
- - {/* 미리보기 */} - - - - {mode === "whitelist" && visibleSteps.length > 0 && ( -
-

표시 단계:

-
- {visibleSteps.map((stepId) => { - const step = flowSteps.find((s) => s.id === stepId); - return ( - - {step?.stepName || `Step ${stepId}`} - - ); - })} -
-
- )} - {mode === "blacklist" && hiddenSteps.length > 0 && ( -
-

숨김 단계:

-
- {hiddenSteps.map((stepId) => { - const step = flowSteps.find((s) => s.id === stepId); - return ( - - {step?.stepName || `Step ${stepId}`} - - ); - })} -
-
- )} - {mode === "all" &&

이 버튼은 모든 단계에서 표시됩니다.

} -
-
- - {/* 저장 버튼 */} - - - )} - - {/* 플로우 선택 안내 */} - {selectedFlowComponentId && flowSteps.length === 0 && !loading && ( - - - 선택한 플로우에 단계가 없습니다. - - )} - - {loading && ( -
-
플로우 정보를 불러오는 중...
-
- )} - - )} -
-
- ); -}; -``` - ---- - -### Phase 5: PropertiesPanel에 통합 - -#### 파일: `frontend/components/screen/panels/PropertiesPanel.tsx` - -기존 PropertiesPanel에 FlowVisibilityConfigPanel 추가: - -```typescript -// 기존 imports... -import { FlowVisibilityConfigPanel } from "../config-panels/FlowVisibilityConfigPanel"; - -// PropertiesPanel 컴포넌트 내부 -const renderButtonProperties = () => { - return ( -
- {/* 기존 버튼 속성 UI */} - {/* ... */} - - {/* 🆕 플로우 단계별 표시 설정 */} - -
- ); -}; -``` - ---- - -## 구현 단계 - -### Phase 1: 기반 구조 (1-2일) -- [x] 요구사항 분석 및 계획 수립 -- [ ] Zustand Store 생성 (`flowStepStore.ts`) -- [ ] 타입 정의 추가 (`FlowVisibilityConfig`) -- [ ] FlowWidget에 전역 상태 연동 - -### Phase 2: 핵심 기능 (2-3일) -- [ ] OptimizedButtonComponent 수정 (표시/숨김 로직) -- [ ] 버튼 렌더링 테스트 (단계별) -- [ ] 성능 최적화 (useMemo, useCallback) - -### Phase 3: 설정 UI (2-3일) -- [ ] FlowVisibilityConfigPanel 컴포넌트 생성 -- [ ] PropertiesPanel에 통합 -- [ ] UX 개선 (로딩, 에러 처리, 미리보기) - -### Phase 4: 고급 기능 (선택사항, 2-3일) -- [ ] ButtonGroup 컴포넌트 (auto-compact 레이아웃) -- [ ] 드래그앤드롭으로 버튼 그룹화 -- [ ] 다중 플로우 동시 제어 - -### Phase 5: 테스트 및 문서화 (1-2일) -- [ ] 단위 테스트 -- [ ] 통합 테스트 -- [ ] 사용자 가이드 작성 -- [ ] 성능 벤치마크 - ---- - -## 예상 이슈 및 해결방안 - -### 이슈 1: 플로우 위젯이 여러 개일 때 어떤 플로우를 따를지 모호함 -**해결**: 버튼 설정에서 명시적으로 대상 플로우 선택 (Dropdown) - -### 이슈 2: 버튼이 숨겨질 때 레이아웃이 깨짐 -**해결**: `layoutBehavior` 옵션으로 사용자가 선택 (preserve-position vs auto-compact) - -### 이슈 3: 플로우 단계가 선택되지 않았을 때 버튼 표시 기준 -**해결**: 기본값은 "모든 버튼 표시" (단계 선택 전까지는 정상 동작) - -### 이슈 4: 성능 문제 (버튼이 많을 경우) -**해결**: -- `useMemo`로 표시 여부 캐싱 -- Zustand의 selector로 필요한 데이터만 구독 -- 가상화 (React Virtuoso) 고려 - -### 이슈 5: 데이터베이스 저장 시 JSON 필드 크기 -**해결**: -- visibleSteps/hiddenSteps는 배열로 저장 (효율적) -- 불필요한 메타데이터 제거 - ---- - -## 테스트 시나리오 - -### 시나리오 1: 기본 동작 -1. 플로우 위젯 추가 (3단계) -2. 버튼 3개 추가 (A, B, C) -3. 버튼 A: Step 1, 2에서만 표시 -4. 버튼 B: Step 2, 3에서 표시 -5. 버튼 C: 모든 단계 표시 -6. 플로우 Step 1 클릭 → A, C만 표시 -7. 플로우 Step 2 클릭 → A, B, C 모두 표시 -8. 플로우 Step 3 클릭 → B, C만 표시 - -### 시나리오 2: 다중 플로우 -1. 플로우 위젯 A 추가 (계약 프로세스) -2. 플로우 위젯 B 추가 (AS 프로세스) -3. [저장] 버튼: 플로우 A Step 1, 2에서만 -4. [AS 접수] 버튼: 플로우 B Step 1에서만 -5. 플로우 A Step 1 클릭 → [저장] 표시 -6. 플로우 B Step 1 클릭 → [AS 접수] 표시 - -### 시나리오 3: 레이아웃 동작 -1. 버튼 5개 가로 배치 (A, B, C, D, E) -2. B, D만 Step 1에서 숨김 -3. `preserve-position` 모드: A [빈공간] C [빈공간] E -4. `auto-compact` 모드: A C E (자동 정렬) - ---- - -## 성능 고려사항 - -### 최적화 포인트 -1. **Zustand Selector**: 필요한 플로우만 구독 -2. **useMemo**: 표시 여부 계산 결과 캐싱 -3. **조건부 렌더링**: `display: none` vs `null` 선택 -4. **Debounce**: 빠른 단계 전환 시 렌더링 throttle - -### 예상 성능 -- 버튼 10개 기준: < 10ms 반응 시간 -- 버튼 100개 기준: < 50ms 반응 시간 - ---- - -## 결론 - -이 계획서는 **플로우 단계별 버튼 표시/숨김 제어** 기능의 전체 구현 방향을 제시합니다. - -**핵심 설계 원칙**: -1. ✅ 사용자 편의성 (자동 감지, 직관적 UI) -2. ✅ 유연성 (다중 플로우, 다중 버튼 지원) -3. ✅ 성능 (전역 상태 관리, 최적화) -4. ✅ 확장성 (추후 조건부 표시 등 추가 가능) - -**다음 단계**: Phase 1부터 순차적으로 구현 시작 - diff --git a/PHASE_RESPONSIVE_LAYOUT.md b/PHASE_RESPONSIVE_LAYOUT.md deleted file mode 100644 index 3dde3d49..00000000 --- a/PHASE_RESPONSIVE_LAYOUT.md +++ /dev/null @@ -1,998 +0,0 @@ -# 반응형 레이아웃 시스템 구현 계획서 - -## 📋 프로젝트 개요 - -### 목표 - -화면 디자이너는 절대 위치 기반으로 유지하되, 실제 화면 표시는 반응형으로 동작하도록 전환 - -### 핵심 원칙 - -- ✅ 화면 디자이너의 절대 위치 기반 드래그앤드롭은 그대로 유지 -- ✅ 실제 화면 표시만 반응형으로 전환 -- ✅ 데이터 마이그레이션 불필요 (신규 화면부터 적용) -- ✅ 기존 화면은 불러올 때 스마트 기본값 자동 생성 - ---- - -## 🎯 Phase 1: 기본 반응형 시스템 구축 (2-3일) - -### 1.1 타입 정의 (2시간) - -#### 파일: `frontend/types/responsive.ts` - -```typescript -/** - * 브레이크포인트 타입 정의 - */ -export type Breakpoint = "desktop" | "tablet" | "mobile"; - -/** - * 브레이크포인트별 설정 - */ -export interface BreakpointConfig { - minWidth: number; // 최소 너비 (px) - maxWidth?: number; // 최대 너비 (px) - columns: number; // 그리드 컬럼 수 -} - -/** - * 기본 브레이크포인트 설정 - */ -export const BREAKPOINTS: Record = { - desktop: { - minWidth: 1200, - columns: 12, - }, - tablet: { - minWidth: 768, - maxWidth: 1199, - columns: 8, - }, - mobile: { - minWidth: 0, - maxWidth: 767, - columns: 4, - }, -}; - -/** - * 브레이크포인트별 반응형 설정 - */ -export interface ResponsiveBreakpointConfig { - gridColumns?: number; // 차지할 컬럼 수 (1-12) - order?: number; // 정렬 순서 - hide?: boolean; // 숨김 여부 -} - -/** - * 컴포넌트별 반응형 설정 - */ -export interface ResponsiveComponentConfig { - // 기본값 (디자이너에서 설정한 절대 위치) - designerPosition: { - x: number; - y: number; - width: number; - height: number; - }; - - // 반응형 설정 (선택적) - responsive?: { - desktop?: ResponsiveBreakpointConfig; - tablet?: ResponsiveBreakpointConfig; - mobile?: ResponsiveBreakpointConfig; - }; - - // 스마트 기본값 사용 여부 - useSmartDefaults?: boolean; -} -``` - -### 1.2 스마트 기본값 생성기 (3시간) - -#### 파일: `frontend/lib/utils/responsiveDefaults.ts` - -```typescript -import { ComponentData } from "@/types/screen-management"; -import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive"; - -/** - * 컴포넌트 크기에 따른 스마트 기본값 생성 - * - * 로직: - * - 작은 컴포넌트 (너비 25% 이하): 모바일에서도 같은 너비 유지 - * - 중간 컴포넌트 (너비 25-50%): 모바일에서 전체 너비로 확장 - * - 큰 컴포넌트 (너비 50% 이상): 모든 디바이스에서 전체 너비 - */ -export function generateSmartDefaults( - component: ComponentData, - screenWidth: number = 1920 -): ResponsiveComponentConfig["responsive"] { - const componentWidthPercent = (component.size.width / screenWidth) * 100; - - // 작은 컴포넌트 (25% 이하) - if (componentWidthPercent <= 25) { - return { - desktop: { - gridColumns: 3, // 12컬럼 중 3개 (25%) - order: 1, - hide: false, - }, - tablet: { - gridColumns: 2, // 8컬럼 중 2개 (25%) - order: 1, - hide: false, - }, - mobile: { - gridColumns: 1, // 4컬럼 중 1개 (25%) - order: 1, - hide: false, - }, - }; - } - // 중간 컴포넌트 (25-50%) - else if (componentWidthPercent <= 50) { - return { - desktop: { - gridColumns: 6, // 12컬럼 중 6개 (50%) - order: 1, - hide: false, - }, - tablet: { - gridColumns: 4, // 8컬럼 중 4개 (50%) - order: 1, - hide: false, - }, - mobile: { - gridColumns: 4, // 4컬럼 전체 (100%) - order: 1, - hide: false, - }, - }; - } - // 큰 컴포넌트 (50% 이상) - else { - return { - desktop: { - gridColumns: 12, // 전체 너비 - order: 1, - hide: false, - }, - tablet: { - gridColumns: 8, // 전체 너비 - order: 1, - hide: false, - }, - mobile: { - gridColumns: 4, // 전체 너비 - order: 1, - hide: false, - }, - }; - } -} - -/** - * 컴포넌트에 반응형 설정이 없을 경우 자동 생성 - */ -export function ensureResponsiveConfig( - component: ComponentData, - screenWidth?: number -): ComponentData { - if (component.responsiveConfig) { - return component; - } - - return { - ...component, - responsiveConfig: { - designerPosition: { - x: component.position.x, - y: component.position.y, - width: component.size.width, - height: component.size.height, - }, - useSmartDefaults: true, - responsive: generateSmartDefaults(component, screenWidth), - }, - }; -} -``` - -### 1.3 브레이크포인트 감지 훅 (1시간) - -#### 파일: `frontend/hooks/useBreakpoint.ts` - -```typescript -import { useState, useEffect } from "react"; -import { Breakpoint, BREAKPOINTS } from "@/types/responsive"; - -/** - * 현재 윈도우 크기에 따른 브레이크포인트 반환 - */ -export function useBreakpoint(): Breakpoint { - const [breakpoint, setBreakpoint] = useState("desktop"); - - useEffect(() => { - const updateBreakpoint = () => { - const width = window.innerWidth; - - if (width >= BREAKPOINTS.desktop.minWidth) { - setBreakpoint("desktop"); - } else if (width >= BREAKPOINTS.tablet.minWidth) { - setBreakpoint("tablet"); - } else { - setBreakpoint("mobile"); - } - }; - - // 초기 실행 - updateBreakpoint(); - - // 리사이즈 이벤트 리스너 등록 - window.addEventListener("resize", updateBreakpoint); - - return () => window.removeEventListener("resize", updateBreakpoint); - }, []); - - return breakpoint; -} - -/** - * 현재 브레이크포인트의 컬럼 수 반환 - */ -export function useGridColumns(): number { - const breakpoint = useBreakpoint(); - return BREAKPOINTS[breakpoint].columns; -} -``` - -### 1.4 반응형 레이아웃 엔진 (6시간) - -#### 파일: `frontend/components/screen/ResponsiveLayoutEngine.tsx` - -```typescript -import React, { useMemo } from "react"; -import { ComponentData } from "@/types/screen-management"; -import { Breakpoint, BREAKPOINTS } from "@/types/responsive"; -import { - generateSmartDefaults, - ensureResponsiveConfig, -} from "@/lib/utils/responsiveDefaults"; -import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; - -interface ResponsiveLayoutEngineProps { - components: ComponentData[]; - breakpoint: Breakpoint; - containerWidth: number; - screenWidth?: number; -} - -/** - * 반응형 레이아웃 엔진 - * - * 절대 위치로 배치된 컴포넌트들을 반응형 그리드로 변환 - * - * 변환 로직: - * 1. Y 위치 기준으로 행(row)으로 그룹화 - * 2. 각 행 내에서 X 위치 기준으로 정렬 - * 3. 반응형 설정 적용 (order, gridColumns, hide) - * 4. CSS Grid로 렌더링 - */ -export const ResponsiveLayoutEngine: React.FC = ({ - components, - breakpoint, - containerWidth, - screenWidth = 1920, -}) => { - // 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화 - const rows = useMemo(() => { - const sortedComponents = [...components].sort( - (a, b) => a.position.y - b.position.y - ); - - const rows: ComponentData[][] = []; - let currentRow: ComponentData[] = []; - let currentRowY = 0; - const ROW_THRESHOLD = 50; // 같은 행으로 간주할 Y 오차 범위 (px) - - sortedComponents.forEach((comp) => { - if (currentRow.length === 0) { - currentRow.push(comp); - currentRowY = comp.position.y; - } else if (Math.abs(comp.position.y - currentRowY) < ROW_THRESHOLD) { - currentRow.push(comp); - } else { - rows.push(currentRow); - currentRow = [comp]; - currentRowY = comp.position.y; - } - }); - - if (currentRow.length > 0) { - rows.push(currentRow); - } - - return rows; - }, [components]); - - // 2단계: 각 행 내에서 X 위치 기준으로 정렬 - const sortedRows = useMemo(() => { - return rows.map((row) => - [...row].sort((a, b) => a.position.x - b.position.x) - ); - }, [rows]); - - // 3단계: 반응형 설정 적용 - const responsiveComponents = useMemo(() => { - return sortedRows.flatMap((row) => - row.map((comp) => { - // 반응형 설정이 없으면 자동 생성 - const compWithConfig = ensureResponsiveConfig(comp, screenWidth); - - // 현재 브레이크포인트의 설정 가져오기 - const config = compWithConfig.responsiveConfig!.useSmartDefaults - ? generateSmartDefaults(comp, screenWidth)[breakpoint] - : compWithConfig.responsiveConfig!.responsive?.[breakpoint]; - - return { - ...compWithConfig, - responsiveDisplay: - config || generateSmartDefaults(comp, screenWidth)[breakpoint], - }; - }) - ); - }, [sortedRows, breakpoint, screenWidth]); - - // 4단계: 필터링 및 정렬 - const visibleComponents = useMemo(() => { - return responsiveComponents - .filter((comp) => !comp.responsiveDisplay?.hide) - .sort( - (a, b) => - (a.responsiveDisplay?.order || 0) - (b.responsiveDisplay?.order || 0) - ); - }, [responsiveComponents]); - - const gridColumns = BREAKPOINTS[breakpoint].columns; - - return ( -
- {visibleComponents.map((comp) => ( -
- -
- ))} -
- ); -}; -``` - -### 1.5 화면 표시 페이지 수정 (4시간) - -#### 파일: `frontend/app/(main)/screens/[screenId]/page.tsx` - -```typescript -// 기존 import 유지 -import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine"; -import { useBreakpoint } from "@/hooks/useBreakpoint"; - -export default function ScreenViewPage({ - params, -}: { - params: { screenId: string }; -}) { - const [layout, setLayout] = useState(null); - const breakpoint = useBreakpoint(); - - // 반응형 모드 토글 (사용자 설정 또는 화면 설정에 따라) - const [useResponsive, setUseResponsive] = useState(true); - - // 기존 로직 유지... - - if (!layout) { - return
로딩 중...
; - } - - const screenWidth = layout.screenResolution?.width || 1920; - const screenHeight = layout.screenResolution?.height || 1080; - - return ( -
- {useResponsive ? ( - // 반응형 모드 - - ) : ( - // 기존 스케일 모드 (하위 호환성) -
-
-
- {layout.components?.map((component) => ( -
- -
- ))} -
-
-
- )} -
- ); -} -``` - ---- - -## 🎨 Phase 2: 디자이너 통합 (1-2일) - -### 2.1 반응형 설정 패널 (5시간) - -#### 파일: `frontend/components/screen/panels/ResponsiveConfigPanel.tsx` - -```typescript -import React, { useState } from "react"; -import { ComponentData } from "@/types/screen-management"; -import { - Breakpoint, - BREAKPOINTS, - ResponsiveComponentConfig, -} from "@/types/responsive"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Checkbox } from "@/components/ui/checkbox"; - -interface ResponsiveConfigPanelProps { - component: ComponentData; - onUpdate: (config: ResponsiveComponentConfig) => void; -} - -export const ResponsiveConfigPanel: React.FC = ({ - component, - onUpdate, -}) => { - const [activeTab, setActiveTab] = useState("desktop"); - - const config = component.responsiveConfig || { - designerPosition: { - x: component.position.x, - y: component.position.y, - width: component.size.width, - height: component.size.height, - }, - useSmartDefaults: true, - }; - - return ( - - - 반응형 설정 - - - {/* 스마트 기본값 토글 */} -
- { - onUpdate({ - ...config, - useSmartDefaults: checked as boolean, - }); - }} - /> - -
- - {/* 수동 설정 */} - {!config.useSmartDefaults && ( - setActiveTab(v as Breakpoint)} - > - - 데스크톱 - 태블릿 - 모바일 - - - - {/* 그리드 컬럼 수 */} -
- - -
- - {/* 표시 순서 */} -
- - { - onUpdate({ - ...config, - responsive: { - ...config.responsive, - [activeTab]: { - ...config.responsive?.[activeTab], - order: parseInt(e.target.value), - }, - }, - }); - }} - /> -
- - {/* 숨김 */} -
- { - onUpdate({ - ...config, - responsive: { - ...config.responsive, - [activeTab]: { - ...config.responsive?.[activeTab], - hide: checked as boolean, - }, - }, - }); - }} - /> - -
-
-
- )} -
-
- ); -}; -``` - -### 2.2 속성 패널 통합 (1시간) - -#### 파일: `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` 수정 - -```typescript -// 기존 import에 추가 -import { ResponsiveConfigPanel } from './ResponsiveConfigPanel'; - -// 컴포넌트 내부에 추가 -return ( -
- {/* 기존 패널들 */} - - - - {/* 반응형 설정 패널 추가 */} - { - onUpdateComponent({ - ...selectedComponent, - responsiveConfig: config - }); - }} - /> - - {/* 기존 세부 설정 패널 */} - -
-); -``` - -### 2.3 미리보기 모드 (3시간) - -#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정 - -```typescript -// 추가 import -import { Breakpoint } from '@/types/responsive'; -import { ResponsiveLayoutEngine } from './ResponsiveLayoutEngine'; -import { useBreakpoint } from '@/hooks/useBreakpoint'; -import { Button } from '@/components/ui/button'; - -export const ScreenDesigner: React.FC = () => { - // 미리보기 모드: 'design' | 'desktop' | 'tablet' | 'mobile' - const [previewMode, setPreviewMode] = useState<'design' | Breakpoint>('design'); - const currentBreakpoint = useBreakpoint(); - - // ... 기존 로직 ... - - return ( -
- {/* 상단 툴바 */} -
- - - - -
- - {/* 캔버스 영역 */} -
- {previewMode === 'design' ? ( - // 기존 절대 위치 기반 디자이너 - - ) : ( - // 반응형 미리보기 -
- -
- )} -
-
- ); -}; -``` - ---- - -## 💾 Phase 3: 저장/불러오기 (1일) - -### 3.1 타입 업데이트 (2시간) - -#### 파일: `frontend/types/screen-management.ts` 수정 - -```typescript -import { ResponsiveComponentConfig } from "./responsive"; - -export interface ComponentData { - // ... 기존 필드들 ... - - // 반응형 설정 추가 - responsiveConfig?: ResponsiveComponentConfig; -} -``` - -### 3.2 저장 로직 (2시간) - -#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정 - -```typescript -// 저장 함수 수정 -const handleSave = async () => { - try { - const layoutData: LayoutData = { - screenResolution: { - width: 1920, - height: 1080, - }, - components: components.map((comp) => ({ - ...comp, - // 반응형 설정이 없으면 자동 생성 - responsiveConfig: comp.responsiveConfig || { - designerPosition: { - x: comp.position.x, - y: comp.position.y, - width: comp.size.width, - height: comp.size.height, - }, - useSmartDefaults: true, - }, - })), - }; - - await screenApi.updateLayout(selectedScreen.id, layoutData); - // ... 기존 로직 ... - } catch (error) { - console.error("저장 실패:", error); - } -}; -``` - -### 3.3 불러오기 로직 (2시간) - -#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정 - -```typescript -import { ensureResponsiveConfig } from "@/lib/utils/responsiveDefaults"; - -// 화면 불러오기 -useEffect(() => { - const loadScreen = async () => { - if (!selectedScreenId) return; - - const screen = await screenApi.getScreenById(selectedScreenId); - const layout = await screenApi.getLayout(selectedScreenId); - - // 반응형 설정이 없는 컴포넌트에 자동 생성 - const componentsWithResponsive = layout.components.map((comp) => - ensureResponsiveConfig(comp, layout.screenResolution?.width) - ); - - setSelectedScreen(screen); - setComponents(componentsWithResponsive); - }; - - loadScreen(); -}, [selectedScreenId]); -``` - ---- - -## 🧪 Phase 4: 테스트 및 최적화 (1일) - -### 4.1 기능 테스트 체크리스트 (3시간) - -- [ ] 브레이크포인트 전환 테스트 - - [ ] 윈도우 크기 변경 시 자동 전환 - - [ ] desktop → tablet → mobile 순차 테스트 -- [ ] 스마트 기본값 생성 테스트 - - [ ] 작은 컴포넌트 (25% 이하) - - [ ] 중간 컴포넌트 (25-50%) - - [ ] 큰 컴포넌트 (50% 이상) -- [ ] 수동 설정 적용 테스트 - - [ ] 그리드 컬럼 변경 - - [ ] 표시 순서 변경 - - [ ] 디바이스별 숨김 -- [ ] 미리보기 모드 테스트 - - [ ] 디자인 모드 ↔ 미리보기 모드 전환 - - [ ] 각 브레이크포인트 미리보기 -- [ ] 저장/불러오기 테스트 - - [ ] 반응형 설정 저장 - - [ ] 기존 화면 불러오기 시 자동 변환 - -### 4.2 성능 최적화 (3시간) - -#### 레이아웃 계산 메모이제이션 - -```typescript -// ResponsiveLayoutEngine.tsx -const memoizedLayout = useMemo(() => { - // 레이아웃 계산 로직 -}, [components, breakpoint, screenWidth]); -``` - -#### ResizeObserver 최적화 - -```typescript -// useBreakpoint.ts -// debounce 적용 -const debouncedResize = debounce(updateBreakpoint, 150); -window.addEventListener("resize", debouncedResize); -``` - -#### 불필요한 리렌더링 방지 - -```typescript -// React.memo 적용 -export const ResponsiveLayoutEngine = React.memo(({...}) => { - // ... -}); -``` - -### 4.3 UI/UX 개선 (2시간) - -- [ ] 반응형 설정 패널 툴팁 추가 -- [ ] 미리보기 모드 전환 애니메이션 -- [ ] 로딩 상태 표시 -- [ ] 에러 처리 및 사용자 피드백 - ---- - -## 📅 최종 타임라인 - -| Phase | 작업 내용 | 소요 시간 | 누적 시간 | -| ------- | --------------------- | --------- | ------------ | -| Phase 1 | 타입 정의 및 유틸리티 | 6시간 | 6시간 | -| Phase 1 | 반응형 레이아웃 엔진 | 6시간 | 12시간 | -| Phase 1 | 화면 표시 페이지 수정 | 4시간 | 16시간 (2일) | -| Phase 2 | 반응형 설정 패널 | 5시간 | 21시간 | -| Phase 2 | 디자이너 통합 | 4시간 | 25시간 (3일) | -| Phase 3 | 저장/불러오기 | 6시간 | 31시간 (4일) | -| Phase 4 | 테스트 및 최적화 | 8시간 | 39시간 (5일) | - -**총 예상 시간: 39시간 (약 5일)** - ---- - -## 🎯 구현 우선순위 - -### 1단계: 핵심 기능 (필수) - -1. ✅ 타입 정의 -2. ✅ 스마트 기본값 생성기 -3. ✅ 브레이크포인트 훅 -4. ✅ 반응형 레이아웃 엔진 -5. ✅ 화면 표시 페이지 수정 - -### 2단계: 디자이너 UI (중요) - -6. ✅ 반응형 설정 패널 -7. ✅ 속성 패널 통합 -8. ✅ 미리보기 모드 - -### 3단계: 데이터 처리 (중요) - -9. ✅ 타입 업데이트 -10. ✅ 저장/불러오기 로직 - -### 4단계: 완성도 (선택) - -11. 테스트 -12. 최적화 -13. UI/UX 개선 - ---- - -## ✅ 완료 체크리스트 - -### Phase 1: 기본 시스템 - -- [ ] `frontend/types/responsive.ts` 생성 -- [ ] `frontend/lib/utils/responsiveDefaults.ts` 생성 -- [ ] `frontend/hooks/useBreakpoint.ts` 생성 -- [ ] `frontend/components/screen/ResponsiveLayoutEngine.tsx` 생성 -- [ ] `frontend/app/(main)/screens/[screenId]/page.tsx` 수정 - -### Phase 2: 디자이너 통합 - -- [ ] `frontend/components/screen/panels/ResponsiveConfigPanel.tsx` 생성 -- [ ] `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` 수정 -- [ ] `frontend/components/screen/ScreenDesigner.tsx` 수정 - -### Phase 3: 데이터 처리 - -- [ ] `frontend/types/screen-management.ts` 수정 -- [ ] 저장 로직 수정 -- [ ] 불러오기 로직 수정 - -### Phase 4: 테스트 - -- [ ] 기능 테스트 완료 -- [ ] 성능 최적화 완료 -- [ ] UI/UX 개선 완료 - ---- - -## 🚀 시작 준비 완료 - -이제 Phase 1부터 순차적으로 구현을 시작합니다. diff --git a/PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md b/PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md deleted file mode 100644 index 832dcc11..00000000 --- a/PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md +++ /dev/null @@ -1,1407 +0,0 @@ -# 🚀 Prisma → Raw Query 완전 전환 계획서 - -## 📋 프로젝트 개요 - -### 🎯 목적 - -현재 Node.js 백엔드에서 Prisma ORM을 완전히 제거하고 Raw Query 방식으로 전환하여 **완전 동적 테이블 생성 및 관리 시스템**을 구축합니다. - -### 🔍 현재 상황 분석 - -- **총 52개 파일**에서 Prisma 사용 -- **490개의 Prisma 호출** (ORM + Raw Query 혼재) -- **150개 이상의 테이블** 정의 (schema.prisma) -- **복잡한 트랜잭션 및 동적 쿼리** 다수 존재 - ---- - -## 📊 Prisma 사용 현황 분석 - -**총 42개 파일에서 444개의 Prisma 호출 발견** ⚡ (Scripts 제외) -**현재 진행률: 445/444 (100.2%)** 🎉 **거의 완료!** 남은 12개는 추가 조사 필요 - -### 1. **Prisma 사용 파일 분류** - -#### 🔴 **High Priority (핵심 서비스) - 107개 호출** - -``` -backend-node/src/services/ -├── screenManagementService.ts # 화면 관리 (46개 호출) ⭐ 최우선 -├── tableManagementService.ts # 테이블 관리 (35개 호출) ⭐ 최우선 -├── dataflowService.ts # 데이터플로우 (0개 호출) ✅ 전환 완료 -├── dynamicFormService.ts # 동적 폼 (0개 호출) ✅ 전환 완료 -├── externalDbConnectionService.ts # 외부DB (0개 호출) ✅ 전환 완료 -├── dataflowControlService.ts # 제어관리 (0개 호출) ✅ 전환 완료 -├── multilangService.ts # 다국어 (0개 호출) ✅ 전환 완료 -├── ddlExecutionService.ts # DDL 실행 (6개 호출) -├── authService.ts # 인증 (5개 호출) -└── multiConnectionQueryService.ts # 다중 연결 (4개 호출) -``` - -#### 🟡 **Medium Priority (관리 기능) - 142개 호출** - -``` -backend-node/src/services/ -├── multilangService.ts # 다국어 (25개 호출) -├── batchService.ts # 배치 (16개 호출) -├── componentStandardService.ts # 컴포넌트 (16개 호출) -├── commonCodeService.ts # 공통코드 (15개 호출) -├── dataflowDiagramService.ts # 데이터플로우 다이어그램 (12개 호출) ⭐ 신규 발견 -├── collectionService.ts # 컬렉션 (11개 호출) -├── layoutService.ts # 레이아웃 (10개 호출) -├── dbTypeCategoryService.ts # DB 타입 카테고리 (10개 호출) ⭐ 신규 발견 -├── templateStandardService.ts # 템플릿 (9개 호출) -├── ddlAuditLogger.ts # DDL 감사 로그 (8개 호출) ⭐ 신규 발견 -├── externalCallConfigService.ts # 외부 호출 설정 (8개 호출) ⭐ 신규 발견 -├── batchExternalDbService.ts # 배치 외부DB (8개 호출) ⭐ 신규 발견 -├── batchExecutionLogService.ts # 배치 실행 로그 (7개 호출) ⭐ 신규 발견 -├── eventTriggerService.ts # 이벤트 (6개 호출) -├── enhancedDynamicFormService.ts # 확장 동적 폼 (6개 호출) ⭐ 신규 발견 -├── entityJoinService.ts # 엔티티 조인 (5개 호출) ⭐ 신규 발견 -├── dataMappingService.ts # 데이터 매핑 (5개 호출) ⭐ 신규 발견 -├── batchManagementService.ts # 배치 관리 (5개 호출) ⭐ 신규 발견 -├── batchSchedulerService.ts # 배치 스케줄러 (4개 호출) ⭐ 신규 발견 -├── dataService.ts # 데이터 서비스 (4개 호출) ⭐ 신규 발견 -├── adminService.ts # 관리자 (3개 호출) -└── referenceCacheService.ts # 캐시 (3개 호출) -``` - -#### 🟢 **Low Priority (컨트롤러 & 라우트) - 188개 호출** - -``` -backend-node/src/controllers/ -├── adminController.ts # 관리자 컨트롤러 (28개 호출) ⭐ 신규 발견 -├── webTypeStandardController.ts # 웹타입 표준 (11개 호출) ⭐ 신규 발견 -├── fileController.ts # 파일 컨트롤러 (11개 호출) ⭐ 신규 발견 -├── buttonActionStandardController.ts # 버튼 액션 표준 (11개 호출) ⭐ 신규 발견 -├── entityReferenceController.ts # 엔티티 참조 (4개 호출) ⭐ 신규 발견 -├── dataflowExecutionController.ts # 데이터플로우 실행 (3개 호출) ⭐ 신규 발견 -└── screenFileController.ts # 화면 파일 (2개 호출) ⭐ 신규 발견 - -backend-node/src/routes/ -├── ddlRoutes.ts # DDL 라우트 (2개 호출) ⭐ 신규 발견 -└── companyManagementRoutes.ts # 회사 관리 라우트 (2개 호출) ⭐ 신규 발견 - -backend-node/src/config/ -└── database.ts # 데이터베이스 설정 (4개 호출) - -#### 🗑️ **삭제 예정 Scripts - 60개 호출** ⚠️ 사용하지 않음 - -``` - -backend-node/scripts/ (삭제 예정) -├── install-dataflow-indexes.js # 인덱스 설치 (10개 호출) 🗑️ 삭제 -├── add-missing-columns.js # 컬럼 추가 (8개 호출) 🗑️ 삭제 -├── test-template-creation.js # 템플릿 테스트 (6개 호출) 🗑️ 삭제 -├── create-component-table.js # 컴포넌트 테이블 생성 (5개 호출) 🗑️ 삭제 -├── seed-ui-components.js # UI 컴포넌트 시드 (3개 호출) 🗑️ 삭제 -├── seed-templates.js # 템플릿 시드 (3개 호출) 🗑️ 삭제 -├── init-layout-standards.js # 레이아웃 표준 초기화 (3개 호출) 🗑️ 삭제 -├── add-data-mapping-column.js # 데이터 매핑 컬럼 추가 (3개 호출) 🗑️ 삭제 -├── add-button-webtype.js # 버튼 웹타입 추가 (3개 호출) 🗑️ 삭제 -└── list-components.js # 컴포넌트 목록 (2개 호출) 🗑️ 삭제 - -backend-node/ (루트) -└── clean-screen-tables.js # 화면 테이블 정리 (7개 호출) 🗑️ 삭제 - -```` - -**⚠️ 삭제 계획**: 이 스크립트들은 개발/배포 도구로 운영 시스템에서 사용하지 않으므로 마이그레이션 전에 삭제 예정 - -### 2. **복잡도별 분류** - -#### 🔥 **매우 복잡 (트랜잭션 + 동적 쿼리) - 최우선 처리** - -- `screenManagementService.ts` (46개) - 화면 정의 관리, JSON 처리 -- `tableManagementService.ts` (35개) - 테이블 메타데이터 관리, DDL 실행 -- `dataflowService.ts` (0개) - ✅ **전환 완료** (Phase 2.3) -- `dynamicFormService.ts` (0개) - ✅ **전환 완료** (Phase 2.4) -- `externalDbConnectionService.ts` (0개) - ✅ **전환 완료** (Phase 2.5) -- `dataflowControlService.ts` (0개) - ✅ **전환 완료** (Phase 2.6) -- `enhancedDataflowControlService.ts` (0개) - 다중 연결 제어 (Raw Query만 사용) -- `multiConnectionQueryService.ts` (4개) - 외부 DB 연결 - -#### 🟠 **복잡 (Raw Query 혼재) - 2순위** - -- `multilangService.ts` (0개) - ✅ **전환 완료** (Phase 3.1) -- `batchService.ts` (0개) - ✅ **전환 완료** (Phase 3.2) -- `componentStandardService.ts` (0개) - ✅ **전환 완료** (Phase 3.3) -- `commonCodeService.ts` (0개) - ✅ **전환 완료** (Phase 3.4) -- `dataflowDiagramService.ts` (0개) - ✅ **전환 완료** (Phase 3.5) -- `collectionService.ts` (0개) - ✅ **전환 완료** (Phase 3.6) -- `layoutService.ts` (0개) - ✅ **전환 완료** (Phase 3.7) -- `dbTypeCategoryService.ts` (0개) - ✅ **전환 완료** (Phase 3.8) -- `templateStandardService.ts` (0개) - ✅ **전환 완료** (Phase 3.9) -- `eventTriggerService.ts` (0개) - ✅ **전환 완료** (Phase 3.10) - -#### 🟡 **중간 (단순 CRUD) - 3순위** - -- `ddlAuditLogger.ts` (0개) - ✅ **전환 완료** (Phase 3.11) - [계획서](PHASE3.11_DDL_AUDIT_LOGGER_MIGRATION.md) -- `externalCallConfigService.ts` (0개) - ✅ **전환 완료** (Phase 3.12) - [계획서](PHASE3.12_EXTERNAL_CALL_CONFIG_SERVICE_MIGRATION.md) -- `entityJoinService.ts` (0개) - ✅ **전환 완료** (Phase 3.13) - [계획서](PHASE3.13_ENTITY_JOIN_SERVICE_MIGRATION.md) -- `authService.ts` (0개) - ✅ **전환 완료** (Phase 1.5에서 완료) - [계획서](PHASE3.14_AUTH_SERVICE_MIGRATION.md) -- **배치 관련 서비스 (0개)** - ✅ **전환 완료** - [통합 계획서](PHASE3.15_BATCH_SERVICES_MIGRATION.md) - - `batchExternalDbService.ts` (0개) - ✅ **전환 완료** - - `batchExecutionLogService.ts` (0개) - ✅ **전환 완료** - - `batchManagementService.ts` (0개) - ✅ **전환 완료** - - `batchSchedulerService.ts` (0개) - ✅ **전환 완료** -- **데이터 관리 서비스 (0개)** - ✅ **전환 완료** - [통합 계획서](PHASE3.16_DATA_MANAGEMENT_SERVICES_MIGRATION.md) - - `enhancedDynamicFormService.ts` (0개) - ✅ **전환 완료** - - `dataMappingService.ts` (0개) - ✅ **전환 완료** - - `dataService.ts` (0개) - ✅ **전환 완료** - - `adminService.ts` (0개) - ✅ **전환 완료** -- `ddlExecutionService.ts` (0개) - ✅ **전환 완료** (이미 완료됨) - [계획서](PHASE3.18_DDL_EXECUTION_SERVICE_MIGRATION.md) -- `referenceCacheService.ts` (0개) - ✅ **전환 완료** (이미 완료됨) - [계획서](PHASE3.17_REFERENCE_CACHE_SERVICE_MIGRATION.md) - -#### 🟢 **컨트롤러 레이어 (Phase 4) - 4순위** - -**통합 계획서**: [PHASE4_CONTROLLER_LAYER_MIGRATION.md](PHASE4_CONTROLLER_LAYER_MIGRATION.md) - -- `adminController.ts` (28개) - ⏳ **대기 중** - [상세 계획서](PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md) - - 사용자 관리 (13개), 회사 관리 (7개), 부서 관리 (2개), 메뉴 관리 (3개), 다국어 (1개) -- `webTypeStandardController.ts` (11개) - ⏳ **대기 중** - - findMany (1), findUnique (4), create (1), update (2), delete (1), $transaction (1), groupBy (1) -- `fileController.ts` (11개) - ⏳ **대기 중** - - findMany (6), findUnique (4), create (1), update (1) -- `buttonActionStandardController.ts` (11개) - ⏳ **대기 중** - - findMany (1), findUnique (4), create (1), update (2), delete (1), $transaction (1), groupBy (1) -- `entityReferenceController.ts` (4개) - ⏳ **대기 중** -- `dataflowExecutionController.ts` (3개) - ⏳ **대기 중** -- `screenFileController.ts` (2개) - ⏳ **대기 중** - -**기타 설정 파일**: -- `database.ts` (4개) - 데이터베이스 연결 설정 ($connect, $disconnect) -- `ddlRoutes.ts` (2개) - DDL 라우트 -- `companyManagementRoutes.ts` (2개) - 회사 관리 라우트 - -#### 🗑️ **삭제 예정 Scripts (마이그레이션 대상 아님)** - -- `install-dataflow-indexes.js` (10개) - 인덱스 설치 스크립트 🗑️ -- `add-missing-columns.js` (8개) - 컬럼 추가 스크립트 🗑️ -- `clean-screen-tables.js` (7개) - 테이블 정리 스크립트 🗑️ -- `test-template-creation.js` (6개) - 템플릿 테스트 스크립트 🗑️ -- `create-component-table.js` (5개) - 컴포넌트 테이블 생성 🗑️ -- 기타 시드 스크립트들 (14개) - 개발용 데이터 시드 🗑️ - -**⚠️ 중요**: 이 스크립트들은 사용하지 않으므로 마이그레이션 전에 삭제하여 작업량을 60개 호출만큼 줄일 수 있습니다. - ---- - -## 🏗️ Raw Query 아키텍처 설계 - -### 1. **새로운 데이터베이스 매니저** - -```typescript -// config/databaseManager.ts -import { Pool, PoolClient } from "pg"; - -export class DatabaseManager { - private static pool: Pool; - - static initialize() { - this.pool = new Pool({ - host: process.env.DB_HOST, - port: parseInt(process.env.DB_PORT || "5432"), - database: process.env.DB_NAME, - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, - max: 20, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 2000, - }); - } - - // 기본 쿼리 실행 - static async query(text: string, params?: any[]): Promise { - const client = await this.pool.connect(); - try { - const result = await client.query(text, params); - return result.rows; - } finally { - client.release(); - } - } - - // 트랜잭션 실행 - static async transaction( - callback: (client: PoolClient) => Promise - ): Promise { - const client = await this.pool.connect(); - try { - await client.query("BEGIN"); - const result = await callback(client); - await client.query("COMMIT"); - return result; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } - } - - // 연결 종료 - static async close() { - await this.pool.end(); - } -} -```` - -### 2. **동적 쿼리 빌더** - -```typescript -// utils/queryBuilder.ts -export class QueryBuilder { - // SELECT 쿼리 빌더 - static select( - tableName: string, - options: { - columns?: string[]; - where?: Record; - orderBy?: string; - limit?: number; - offset?: number; - joins?: Array<{ - type: "INNER" | "LEFT" | "RIGHT"; - table: string; - on: string; - }>; - } = {} - ) { - const { - columns = ["*"], - where = {}, - orderBy, - limit, - offset, - joins = [], - } = options; - - let query = `SELECT ${columns.join(", ")} FROM ${tableName}`; - const params: any[] = []; - let paramIndex = 1; - - // JOIN 처리 - joins.forEach((join) => { - query += ` ${join.type} JOIN ${join.table} ON ${join.on}`; - }); - - // WHERE 조건 - if (Object.keys(where).length > 0) { - const whereClause = Object.keys(where) - .map((key) => `${key} = $${paramIndex++}`) - .join(" AND "); - query += ` WHERE ${whereClause}`; - params.push(...Object.values(where)); - } - - // ORDER BY - if (orderBy) { - query += ` ORDER BY ${orderBy}`; - } - - // LIMIT/OFFSET - if (limit) { - query += ` LIMIT $${paramIndex++}`; - params.push(limit); - } - if (offset) { - query += ` OFFSET $${paramIndex++}`; - params.push(offset); - } - - return { query, params }; - } - - // INSERT 쿼리 빌더 - static insert( - tableName: string, - data: Record, - options: { - returning?: string[]; - onConflict?: { - columns: string[]; - action: "DO NOTHING" | "DO UPDATE"; - updateSet?: string[]; - }; - } = {} - ) { - const columns = Object.keys(data); - const values = Object.values(data); - const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); - - let query = `INSERT INTO ${tableName} (${columns.join( - ", " - )}) VALUES (${placeholders})`; - - // ON CONFLICT 처리 (UPSERT) - if (options.onConflict) { - const { - columns: conflictColumns, - action, - updateSet, - } = options.onConflict; - query += ` ON CONFLICT (${conflictColumns.join(", ")}) ${action}`; - - if (action === "DO UPDATE" && updateSet) { - const setClause = updateSet - .map((col) => `${col} = EXCLUDED.${col}`) - .join(", "); - query += ` SET ${setClause}`; - } - } - - // RETURNING 처리 - if (options.returning) { - query += ` RETURNING ${options.returning.join(", ")}`; - } - - return { query, params: values }; - } - - // UPDATE 쿼리 빌더 - static update( - tableName: string, - data: Record, - where: Record - ) { - const setClause = Object.keys(data) - .map((key, index) => `${key} = $${index + 1}`) - .join(", "); - - const whereClause = Object.keys(where) - .map((key, index) => `${key} = $${Object.keys(data).length + index + 1}`) - .join(" AND "); - - const query = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause} RETURNING *`; - const params = [...Object.values(data), ...Object.values(where)]; - - return { query, params }; - } - - // DELETE 쿼리 빌더 - static delete(tableName: string, where: Record) { - const whereClause = Object.keys(where) - .map((key, index) => `${key} = $${index + 1}`) - .join(" AND "); - - const query = `DELETE FROM ${tableName} WHERE ${whereClause} RETURNING *`; - const params = Object.values(where); - - return { query, params }; - } -} -``` - -### 3. **타입 안전성 보장** - -```typescript -// types/database.ts -export interface QueryResult { - rows: T[]; - rowCount: number; - command: string; -} - -export interface TableSchema { - tableName: string; - columns: ColumnDefinition[]; -} - -export interface ColumnDefinition { - name: string; - type: string; - nullable?: boolean; - defaultValue?: string; - isPrimaryKey?: boolean; -} - -// 런타임 검증 -export class DatabaseValidator { - static validateTableName(tableName: string): boolean { - return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName) && tableName.length <= 63; - } - - static validateColumnName(columnName: string): boolean { - return ( - /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName) && columnName.length <= 63 - ); - } - - static sanitizeInput(input: any): any { - if (typeof input === "string") { - return input.replace(/[';--]/g, ""); - } - return input; - } - - static validateWhereClause(where: Record): boolean { - return Object.keys(where).every((key) => this.validateColumnName(key)); - } -} -``` - ---- - -## 📅 단계별 마이그레이션 계획 - -### **Phase 1: 기반 구조 구축 (1주)** - -#### 1.1 새로운 데이터베이스 아키텍처 구현 - -- [ ] `DatabaseManager` 클래스 구현 -- [ ] `QueryBuilder` 유틸리티 구현 -- [ ] 타입 정의 및 검증 로직 구현 -- [ ] 연결 풀 및 트랜잭션 관리 - -#### 1.2 테스트 환경 구축 - -- [ ] 단위 테스트 작성 -- [ ] 통합 테스트 환경 구성 -- [ ] 성능 벤치마크 도구 준비 - -### **Phase 2: 핵심 서비스 전환 (3주) - 최우선** - -#### 2.1 화면 관리 서비스 전환 (우선순위 1) - 46개 호출 - -```typescript -// 기존 Prisma 코드 (복잡한 JSON 처리) -const screenData = await prisma.screen_definitions.findMany({ - where: { - company_code: companyCode, - screen_config: { path: ["type"], equals: "form" }, - }, - include: { screen_components: true }, -}); - -// 새로운 Raw Query 코드 -const { query, params } = QueryBuilder.select("screen_definitions", { - columns: ["*", "screen_config::jsonb"], - where: { - company_code: companyCode, - "screen_config->>'type'": "form", - }, - joins: [ - { - type: "LEFT", - table: "screen_components", - on: "screen_definitions.id = screen_components.screen_id", - }, - ], -}); -const screenData = await DatabaseManager.query(query, params); -``` - -#### 2.2 테이블 관리 서비스 전환 (우선순위 2) - 35개 호출 - -- [ ] 동적 테이블 생성/삭제 로직 전환 -- [ ] 메타데이터 관리 시스템 개선 -- [ ] DDL 실행 트랜잭션 처리 -- [ ] 컬럼 타입 변환 로직 최적화 - -#### 2.3 데이터플로우 서비스 전환 (우선순위 3) - 31개 호출 ⭐ 신규 발견 - -- [ ] 복잡한 관계 관리 로직 전환 -- [ ] 트랜잭션 기반 데이터 이동 처리 -- [ ] JSON 기반 설정 관리 개선 -- [ ] 다중 테이블 조인 최적화 - -#### 2.4 동적 폼 서비스 전환 (우선순위 4) - 15개 호출 - -- [ ] UPSERT 로직 Raw Query로 전환 -- [ ] 동적 테이블 처리 로직 개선 -- [ ] 트랜잭션 처리 최적화 - -#### 2.5 외부 DB 연결 서비스 전환 (우선순위 5) - 15개 호출 - -- [ ] 다중 DB 연결 관리 로직 -- [ ] 연결 풀 관리 시스템 -- [ ] 외부 DB 스키마 동기화 - -### **Phase 3: 관리 기능 전환 (2.5주)** - -#### 3.1 다국어 서비스 전환 - 25개 호출 - -- [ ] 재귀 쿼리 (WITH RECURSIVE) 전환 -- [ ] 번역 데이터 관리 최적화 -- [ ] 다국어 캐시 시스템 구현 - -#### 3.2 배치 관련 서비스 전환 - 40개 호출 ⭐ 대규모 신규 발견 - -- [ ] `batchService.ts` (16개) - 배치 작업 관리 -- [ ] `batchExternalDbService.ts` (8개) - 배치 외부DB -- [ ] `batchExecutionLogService.ts` (7개) - 배치 실행 로그 -- [ ] `batchManagementService.ts` (5개) - 배치 관리 -- [ ] `batchSchedulerService.ts` (4개) - 배치 스케줄러 - -#### 3.3 표준 관리 서비스 전환 - 41개 호출 - -- [ ] `componentStandardService.ts` (16개) - 컴포넌트 표준 관리 -- [ ] `commonCodeService.ts` (15개) - 코드 관리, 계층 구조 -- [ ] `layoutService.ts` (10개) - 레이아웃 관리 - -#### 3.4 데이터플로우 관련 서비스 - 18개 호출 ⭐ 신규 발견 - -- [ ] `dataflowDiagramService.ts` (12개) - 다이어그램 관리 -- [ ] `dataflowControlService.ts` (6개) - 복잡한 제어 로직 - -#### 3.5 기타 중요 서비스 - 38개 호출 ⭐ 신규 발견 - -- [ ] `collectionService.ts` (11개) - 컬렉션 관리 -- [ ] `dbTypeCategoryService.ts` (10개) - DB 타입 분류 -- [ ] `templateStandardService.ts` (9개) - 템플릿 표준 -- [ ] `ddlAuditLogger.ts` (8개) - DDL 감사 로그 - -### **Phase 4: 확장 기능 전환 (2.5주) ⭐ 대폭 확장** - -#### 4.1 외부 연동 서비스 - 51개 호출 ⭐ 신규 발견 - -- [ ] `externalCallConfigService.ts` (8개) - 외부 호출 설정 -- [ ] `eventTriggerService.ts` (6개) - JSON 검색 쿼리 -- [ ] `enhancedDynamicFormService.ts` (6개) - 확장 동적 폼 -- [ ] `ddlExecutionService.ts` (6개) - DDL 실행 -- [ ] `entityJoinService.ts` (5개) - 엔티티 조인 -- [ ] `dataMappingService.ts` (5개) - 데이터 매핑 -- [ ] `authService.ts` (5개) - 사용자 인증 -- [ ] `multiConnectionQueryService.ts` (4개) - 외부 DB 연결 -- [ ] `dataService.ts` (4개) - 데이터 서비스 -- [ ] `adminService.ts` (3개) - 관리자 메뉴 -- [ ] `referenceCacheService.ts` (3개) - 캐시 관리 - -#### 4.2 컨트롤러 레이어 전환 - 72개 호출 ⭐ 대규모 신규 발견 - -- [ ] `adminController.ts` (28개) - 관리자 컨트롤러 -- [ ] `webTypeStandardController.ts` (11개) - 웹타입 표준 -- [ ] `fileController.ts` (11개) - 파일 컨트롤러 -- [ ] `buttonActionStandardController.ts` (11개) - 버튼 액션 표준 -- [ ] `entityReferenceController.ts` (4개) - 엔티티 참조 -- [ ] `dataflowExecutionController.ts` (3개) - 데이터플로우 실행 -- [ ] `screenFileController.ts` (2개) - 화면 파일 -- [ ] `ddlRoutes.ts` (2개) - DDL 라우트 - -#### 4.3 설정 및 기반 구조 - 6개 호출 - -- [ ] `database.ts` (4개) - 데이터베이스 설정 -- [ ] `companyManagementRoutes.ts` (2개) - 회사 관리 라우트 - -### **Phase 5: 사용하지 않는 Scripts 삭제 (0.5주) 🗑️** - -#### 5.1 불필요한 스크립트 파일 삭제 - 60개 호출 제거 - -- [ ] `backend-node/scripts/` 전체 폴더 삭제 (53개 호출) -- [ ] `backend-node/clean-screen-tables.js` 삭제 (7개 호출) -- [ ] 관련 package.json 스크립트 정리 -- [ ] 문서에서 스크립트 참조 제거 - -**✅ 효과**: 60개 Prisma 호출을 마이그레이션 없이 제거하여 작업량 대폭 감소 - -### **Phase 6: Prisma 완전 제거 (0.5주)** - -#### 6.1 Prisma 의존성 제거 - -- [ ] `package.json`에서 Prisma 제거 -- [ ] `schema.prisma` 파일 삭제 -- [ ] 관련 설정 파일 정리 - -#### 6.2 최종 검증 및 최적화 - -- [ ] 전체 기능 테스트 -- [ ] 성능 최적화 -- [ ] 문서화 업데이트 - ---- - -## 🔄 마이그레이션 전략 - -### 1. **점진적 전환 방식** - -#### 단계별 전환 - -```typescript -// 1단계: 기존 Prisma 코드 유지하면서 Raw Query 병행 -class AuthService { - // 기존 방식 (임시 유지) - async loginWithPrisma(userId: string) { - return await prisma.user_info.findUnique({ - where: { user_id: userId }, - }); - } - - // 새로운 방식 (점진적 도입) - async loginWithRawQuery(userId: string) { - const { query, params } = QueryBuilder.select("user_info", { - where: { user_id: userId }, - }); - return await DatabaseManager.query(query, params); - } -} - -// 2단계: 기존 메서드를 새로운 방식으로 교체 -class AuthService { - async login(userId: string) { - return await this.loginWithRawQuery(userId); - } -} - -// 3단계: 기존 코드 완전 제거 -``` - -### 2. **호환성 레이어** - -```typescript -// utils/prismaCompatibility.ts -export class PrismaCompatibilityLayer { - // 기존 Prisma 호출을 Raw Query로 변환하는 어댑터 - static async findUnique(model: string, options: any) { - const { where } = options; - const { query, params } = QueryBuilder.select(model, { where }); - const results = await DatabaseManager.query(query, params); - return results[0] || null; - } - - static async findMany(model: string, options: any = {}) { - const { where, orderBy, take: limit, skip: offset } = options; - const { query, params } = QueryBuilder.select(model, { - where, - orderBy, - limit, - offset, - }); - return await DatabaseManager.query(query, params); - } - - static async create(model: string, options: any) { - const { data } = options; - const { query, params } = QueryBuilder.insert(model, data, { - returning: ["*"], - }); - const results = await DatabaseManager.query(query, params); - return results[0]; - } -} -``` - -### 3. **테스트 전략** - -#### 병렬 테스트 - -```typescript -// tests/migration.test.ts -describe("Prisma to Raw Query Migration", () => { - test("AuthService: 동일한 결과 반환", async () => { - const userId = "test_user"; - - // 기존 Prisma 결과 - const prismaResult = await authService.loginWithPrisma(userId); - - // 새로운 Raw Query 결과 - const rawQueryResult = await authService.loginWithRawQuery(userId); - - // 결과 비교 - expect(rawQueryResult).toEqual(prismaResult); - }); -}); -``` - ---- - -## 🚨 위험 요소 및 대응 방안 - -### 1. **데이터 일관성 위험** - -#### 위험 요소 - -- 트랜잭션 처리 미스 -- 타입 변환 오류 -- NULL 처리 차이 - -#### 대응 방안 - -```typescript -// 엄격한 트랜잭션 관리 -export class TransactionManager { - static async executeInTransaction( - operations: ((client: PoolClient) => Promise)[] - ): Promise { - return await DatabaseManager.transaction(async (client) => { - const results: T[] = []; - for (const operation of operations) { - const result = await operation(client); - results.push(result); - } - return results; - }); - } -} - -// 타입 안전성 검증 -export class TypeConverter { - static toPostgresType(value: any, expectedType: string): any { - switch (expectedType) { - case "integer": - return parseInt(value) || null; - case "decimal": - return parseFloat(value) || null; - case "boolean": - return Boolean(value); - case "timestamp": - return value ? new Date(value) : null; - default: - return value; - } - } -} -``` - -### 2. **성능 저하 위험** - -#### 위험 요소 - -- 연결 풀 관리 미흡 -- 쿼리 최적화 부족 -- 캐싱 메커니즘 부재 - -#### 대응 방안 - -```typescript -// 연결 풀 최적화 -export class ConnectionPoolManager { - private static readonly DEFAULT_POOL_CONFIG = { - min: 2, - max: 20, - acquireTimeoutMillis: 30000, - createTimeoutMillis: 30000, - destroyTimeoutMillis: 5000, - idleTimeoutMillis: 30000, - reapIntervalMillis: 1000, - createRetryIntervalMillis: 200, - }; -} - -// 쿼리 캐싱 -export class QueryCache { - private static cache = new Map(); - private static readonly CACHE_TTL = 5 * 60 * 1000; // 5분 - - static get(key: string): any | null { - const cached = this.cache.get(key); - if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { - return cached.data; - } - this.cache.delete(key); - return null; - } - - static set(key: string, data: any): void { - this.cache.set(key, { data, timestamp: Date.now() }); - } -} -``` - -### 3. **개발 생산성 저하** - -#### 위험 요소 - -- 타입 안전성 부족 -- 디버깅 어려움 -- 코드 복잡성 증가 - -#### 대응 방안 - -```typescript -// 개발자 친화적 인터페이스 -export class DatabaseORM { - // Prisma와 유사한 인터페이스 제공 - user_info = { - findUnique: (options: { where: Record }) => - PrismaCompatibilityLayer.findUnique("user_info", options), - - findMany: (options?: any) => - PrismaCompatibilityLayer.findMany("user_info", options), - - create: (options: { data: Record }) => - PrismaCompatibilityLayer.create("user_info", options), - }; - - // 다른 테이블들도 동일한 패턴으로 구현 -} - -// 디버깅 도구 -export class QueryLogger { - static log(query: string, params: any[], executionTime: number) { - if (process.env.NODE_ENV === "development") { - console.log(`🔍 Query: ${query}`); - console.log(`📊 Params: ${JSON.stringify(params)}`); - console.log(`⏱️ Time: ${executionTime}ms`); - } - } -} -``` - ---- - -## 📈 성능 최적화 전략 - -### 1. **연결 풀 최적화** - -```typescript -// config/optimizedPool.ts -export class OptimizedPoolConfig { - static getConfig() { - return { - // 환경별 최적화된 설정 - max: process.env.NODE_ENV === "production" ? 20 : 5, - min: process.env.NODE_ENV === "production" ? 5 : 2, - - // 연결 타임아웃 최적화 - acquireTimeoutMillis: 30000, - createTimeoutMillis: 30000, - - // 유휴 연결 관리 - idleTimeoutMillis: 600000, // 10분 - - // 연결 검증 - testOnBorrow: true, - validationQuery: "SELECT 1", - }; - } -} -``` - -### 2. **쿼리 최적화** - -```typescript -// utils/queryOptimizer.ts -export class QueryOptimizer { - // 인덱스 힌트 추가 - static addIndexHint(query: string, indexName: string): string { - return query.replace( - /FROM\s+(\w+)/i, - `FROM $1 /*+ INDEX($1 ${indexName}) */` - ); - } - - // 쿼리 분석 및 최적화 제안 - static analyzeQuery(query: string): QueryAnalysis { - return { - hasIndex: this.checkIndexUsage(query), - estimatedRows: this.estimateRowCount(query), - suggestions: this.generateOptimizationSuggestions(query), - }; - } -} -``` - -### 3. **캐싱 전략** - -```typescript -// utils/smartCache.ts -export class SmartCache { - private static redis: Redis; // Redis 클라이언트 - - // 테이블별 캐시 전략 - static async get(key: string, tableName: string): Promise { - const cacheConfig = this.getCacheConfig(tableName); - - if (!cacheConfig.enabled) return null; - - const cached = await this.redis.get(key); - return cached ? JSON.parse(cached) : null; - } - - static async set(key: string, data: any, tableName: string): Promise { - const cacheConfig = this.getCacheConfig(tableName); - - if (cacheConfig.enabled) { - await this.redis.setex(key, cacheConfig.ttl, JSON.stringify(data)); - } - } - - private static getCacheConfig(tableName: string) { - const configs = { - user_info: { enabled: true, ttl: 300 }, // 5분 - menu_info: { enabled: true, ttl: 600 }, // 10분 - dynamic_tables: { enabled: false, ttl: 0 }, // 동적 테이블은 캐시 안함 - }; - - return configs[tableName] || { enabled: false, ttl: 0 }; - } -} -``` - ---- - -## 🧪 테스트 전략 - -### 1. **단위 테스트** - -```typescript -// tests/unit/queryBuilder.test.ts -describe("QueryBuilder", () => { - test("SELECT 쿼리 생성", () => { - const { query, params } = QueryBuilder.select("user_info", { - where: { user_id: "test" }, - limit: 10, - }); - - expect(query).toBe("SELECT * FROM user_info WHERE user_id = $1 LIMIT $2"); - expect(params).toEqual(["test", 10]); - }); - - test("복잡한 JOIN 쿼리", () => { - const { query, params } = QueryBuilder.select("user_info", { - joins: [ - { - type: "LEFT", - table: "dept_info", - on: "user_info.dept_code = dept_info.dept_code", - }, - ], - where: { "user_info.status": "active" }, - }); - - expect(query).toContain("LEFT JOIN dept_info"); - expect(query).toContain("WHERE user_info.status = $1"); - }); -}); -``` - -### 2. **통합 테스트** - -```typescript -// tests/integration/migration.test.ts -describe("Migration Integration Tests", () => { - let prismaService: any; - let rawQueryService: any; - - beforeAll(async () => { - // 테스트 데이터베이스 설정 - await setupTestDatabase(); - }); - - test("동일한 결과 반환 - 사용자 조회", async () => { - const testUserId = "integration_test_user"; - - const prismaResult = await prismaService.getUser(testUserId); - const rawQueryResult = await rawQueryService.getUser(testUserId); - - expect(normalizeResult(rawQueryResult)).toEqual( - normalizeResult(prismaResult) - ); - }); - - test("트랜잭션 일관성 - 복잡한 업데이트", async () => { - const testData = { - /* 테스트 데이터 */ - }; - - // Prisma 트랜잭션 - const prismaResult = await prismaService.complexUpdate(testData); - - // Raw Query 트랜잭션 - const rawQueryResult = await rawQueryService.complexUpdate(testData); - - expect(rawQueryResult.success).toBe(prismaResult.success); - }); -}); -``` - -### 3. **성능 테스트** - -```typescript -// tests/performance/benchmark.test.ts -describe("Performance Benchmarks", () => { - test("대량 데이터 조회 성능", async () => { - const iterations = 1000; - - // Prisma 성능 측정 - const prismaStart = Date.now(); - for (let i = 0; i < iterations; i++) { - await prismaService.getLargeDataset(); - } - const prismaTime = Date.now() - prismaStart; - - // Raw Query 성능 측정 - const rawQueryStart = Date.now(); - for (let i = 0; i < iterations; i++) { - await rawQueryService.getLargeDataset(); - } - const rawQueryTime = Date.now() - rawQueryStart; - - console.log(`Prisma: ${prismaTime}ms, Raw Query: ${rawQueryTime}ms`); - - // Raw Query가 더 빠르거나 비슷해야 함 - expect(rawQueryTime).toBeLessThanOrEqual(prismaTime * 1.1); - }); -}); -``` - ---- - -## 📋 체크리스트 - -### **Phase 1: 기반 구조 (1주)** ✅ **완료** - -- [x] DatabaseManager 클래스 구현 (`backend-node/src/database/db.ts`) -- [x] QueryBuilder 유틸리티 구현 (`backend-node/src/utils/queryBuilder.ts`) -- [x] 타입 정의 및 검증 로직 (`backend-node/src/types/database.ts`) -- [x] 연결 풀 설정 및 최적화 (pg Pool 사용) -- [x] 트랜잭션 관리 시스템 (transaction 함수 구현) -- [x] 에러 핸들링 메커니즘 (try-catch 및 rollback 처리) -- [x] 로깅 및 모니터링 도구 (쿼리 로그 포함) -- [x] 단위 테스트 작성 (`backend-node/src/tests/`) -- [x] 테스트 성공 확인 (multiConnectionQueryService, externalCallConfigService) - -### **Phase 1.5: 인증 및 관리자 서비스 (우선 전환) - 36개 호출** ✅ **완료** - -> **우선순위 변경**: Phase 2 진행 전 인증/관리 시스템을 먼저 전환하여 전체 시스템의 안정적인 기반 구축 - -- [x] **AuthService 전환 (5개)** - 🔐 최우선 ✅ **완료** - - [x] 로그인 로직 (JWT 생성) - `loginPwdCheck()` Raw Query 전환 - - [x] 사용자 인증 및 검증 - `getUserInfo()` Raw Query 전환 - - [x] 비밀번호 암호화 처리 - EncryptUtil 유지 - - [x] 토큰 관리 - `getUserInfoFromToken()` 정상 동작 - - [x] 로그인 로그 기록 - `insertLoginAccessLog()` Raw Query 전환 -- [ ] **AdminService 확인 (3개)** - 👤 사용자 관리 (이미 Raw Query 사용) - - [x] 사용자 CRUD - Raw Query 사용 확인 - - [x] 메뉴 관리 (재귀 쿼리) - WITH RECURSIVE 사용 확인 - - [x] 권한 관리 - Raw Query 사용 확인 -- [ ] **AdminController 전환 (28개)** - 📡 관리자 API (Phase 2에서 처리) - - [ ] 사용자 관리 API - - [ ] 메뉴 관리 API - - [ ] 권한 관리 API - - [ ] 회사 관리 API -- [x] **테스트** ✅ **완료** - - [x] 단위 테스트 (30개 테스트 모두 통과) - - [x] 통합 테스트 작성 완료 - -### **Phase 2: 핵심 서비스 (3주) - 107개 호출** - -#### ✅ 완료된 서비스 - -- [x] **ScreenManagementService 전환 (46개)** ✅ **완료** (Phase 2.1) - - - [x] 46개 Prisma 호출 전환 완료 - - [x] 18개 단위 테스트 통과 - - [x] 6개 통합 테스트 작성 완료 - - [x] 실제 운영 버그 발견 및 수정 (소수점 좌표) - - 📄 **[PHASE2_SCREEN_MANAGEMENT_MIGRATION.md](PHASE2_SCREEN_MANAGEMENT_MIGRATION.md)** - -- [x] **TableManagementService 전환 (33개)** ✅ **완료** (Phase 2.2) - - - [x] 33개 Prisma 호출 전환 완료 ($queryRaw 26개 + ORM 7개) - - [x] 단위 테스트 작성 완료 - - [x] Prisma import 완전 제거 - - 📄 **[PHASE2.2_TABLE_MANAGEMENT_MIGRATION.md](PHASE2.2_TABLE_MANAGEMENT_MIGRATION.md)** - -- [x] **DDLExecutionService 전환 (6개)** ✅ **완료** (Phase 2.3) - - - [x] 6개 Prisma 호출 전환 완료 (트랜잭션 2개 + $queryRawUnsafe 2개 + ORM 2개) - - [x] **테이블 동적 생성/수정/삭제 기능 완료** - - [x] ✅ 단위 테스트 8개 모두 통과 - - [x] Prisma import 완전 제거 - - 📄 **[PHASE2.7_DDL_EXECUTION_MIGRATION.md](PHASE2.7_DDL_EXECUTION_MIGRATION.md)** - -- [x] **DataflowService 전환 (31개)** ✅ **완료** (Phase 2.3) - - [x] 31개 Prisma 호출 전환 완료 (복잡한 관계 관리 + 트랜잭션) - - [x] 테이블 관계 관리 (8개) + 브리지 관리 (6개) + 통계/조회 (4개) + 복잡한 기능 (3개) - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 - - 📄 **[PHASE2.3_DATAFLOW_SERVICE_MIGRATION.md](PHASE2.3_DATAFLOW_SERVICE_MIGRATION.md)** - -#### ⏳ 진행 예정 서비스 - -- [x] **DynamicFormService 전환 (13개)** ✅ **완료** (Phase 2.4) - - [x] 13개 Prisma 호출 전환 완료 (동적 폼 CRUD + UPSERT) - - [x] 동적 UPSERT 쿼리 구현 (ON CONFLICT 구문) - - [x] 부분 업데이트 및 타입 변환 로직 유지 - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 - - 📄 **[PHASE2.4_DYNAMIC_FORM_MIGRATION.md](PHASE2.4_DYNAMIC_FORM_MIGRATION.md)** -- [x] **ExternalDbConnectionService 전환 (15개)** ✅ **완료** (Phase 2.5) - - [x] 15개 Prisma 호출 전환 완료 (외부 DB 연결 CRUD + 테스트) - - [x] 동적 WHERE 조건 생성 및 동적 UPDATE 쿼리 구현 - - [x] 암호화/복호화 로직 유지 - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 - - 📄 **[PHASE2.5_EXTERNAL_DB_CONNECTION_MIGRATION.md](PHASE2.5_EXTERNAL_DB_CONNECTION_MIGRATION.md)** -- [x] **DataflowControlService 전환 (6개)** ✅ **완료** (Phase 2.6) - - [x] 6개 Prisma 호출 전환 완료 (데이터플로우 제어 + 동적 테이블 CRUD) - - [x] 파라미터 바인딩 수정 (MySQL → PostgreSQL 스타일) - - [x] 복잡한 비즈니스 로직 유지 - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 - - 📄 **[PHASE2.6_DATAFLOW_CONTROL_MIGRATION.md](PHASE2.6_DATAFLOW_CONTROL_MIGRATION.md)** - -#### ✅ 다른 Phase로 이동 - -- [x] ~~AuthService 전환 (5개)~~ → Phase 1.5로 이동 -- [x] ~~MultiConnectionQueryService 전환 (4개)~~ → Phase 1 완료 - -### **Phase 3: 관리 기능 (2.5주) - 162개 호출** - -- [x] **MultiLangService 전환 (25개)** ✅ **완료** (Phase 3.1) - - [x] 25개 Prisma 호출 전환 완료 (다국어 관리 CRUD) - - [x] 동적 WHERE 조건 및 동적 UPDATE 쿼리 구현 - - [x] 트랜잭션 처리 (삭제 + 삽입) - - [x] JOIN 쿼리 (multi_lang_text + multi_lang_key_master) - - [x] IN 절 동적 파라미터 바인딩 - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 -- [x] **BatchService 전환 (14개)** ✅ **완료** (Phase 3.2) - - [x] 14개 Prisma 호출 전환 완료 (배치 설정 CRUD) - - [x] 동적 WHERE 조건 생성 (ILIKE 검색, 페이지네이션) - - [x] 동적 UPDATE 쿼리 (변경된 필드만 업데이트) - - [x] 복잡한 트랜잭션 (배치 설정 + 매핑 동시 생성/수정/삭제) - - [x] LEFT JOIN으로 배치 매핑 조회 (json_agg, COALESCE) - - [x] transaction 함수 활용 (client.query().rows 처리) - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 -- [x] **ComponentStandardService 전환 (15개)** ✅ **완료** (Phase 3.3) - - [x] 15개 Prisma 호출 전환 완료 (컴포넌트 표준 CRUD) - - [x] 동적 WHERE 조건 생성 (ILIKE 검색, OR 조건) - - [x] 동적 UPDATE 쿼리 (fieldMapping 사용) - - [x] GROUP BY 집계 쿼리 (카테고리별, 상태별) - - [x] DISTINCT 쿼리 (카테고리 목록) - - [x] 트랜잭션 처리 (정렬 순서 업데이트) - - [x] SQL 인젝션 방지 (정렬 컬럼 검증) - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 -- [x] **CommonCodeService 전환 (10개)** ✅ **완료** (Phase 3.4) - - [x] 10개 Prisma 호출 전환 완료 (코드 카테고리 및 코드 CRUD) - - [x] 동적 WHERE 조건 생성 (ILIKE 검색, OR 조건) - - [x] 동적 UPDATE 쿼리 (변경된 필드만 업데이트) - - [x] IN 절 동적 파라미터 바인딩 (reorderCodes) - - [x] 트랜잭션 처리 (순서 변경) - - [x] 동적 SQL 쿼리 생성 (중복 검사) - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 -- [x] **DataflowDiagramService 전환 (12개)** ✅ **완료** (Phase 3.5) - - [x] 12개 Prisma 호출 전환 완료 (관계도 CRUD, 복제) - - [x] 동적 WHERE 조건 생성 (company_code 필터링) - - [x] 동적 UPDATE 쿼리 (JSON 필드 포함) - - [x] JSON 필드 처리 (relationships, node_positions, control, category, plan) - - [x] LIKE 검색 (복제 시 이름 패턴 검색) - - [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 완전 제거 -- [x] **LayoutService 전환 (10개)** ✅ **완료** (Phase 3.7) - - [x] 10개 Prisma 호출 전환 완료 (레이아웃 CRUD, 통계) - - [x] 복잡한 OR 조건 처리 (company_code OR is_public) - - [x] 동적 WHERE 조건 생성 (ILIKE 다중 검색) - - [x] 동적 UPDATE 쿼리 (10개 필드 조건부 업데이트) - - [x] JSON 필드 처리 (default_size, layout_config, zones_config) - - [x] GROUP BY 통계 쿼리 (카테고리별 개수) - - [x] LIKE 검색 (코드 생성 시 패턴 검색) - - [x] Promise.all 병렬 쿼리 (목록 + 개수) - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 -- [x] **DbTypeCategoryService 전환 (10개)** ✅ **완료** (Phase 3.8) - - [x] 10개 Prisma 호출 전환 완료 (DB 타입 카테고리 CRUD, 통계) - - [x] ApiResponse 래퍼 패턴 유지 - - [x] 동적 UPDATE 쿼리 (5개 필드 조건부 업데이트) - - [x] ON CONFLICT를 사용한 UPSERT (기본 카테고리 초기화) - - [x] 연결 확인 (external_db_connections COUNT) - - [x] LEFT JOIN + GROUP BY 통계 쿼리 (타입별 연결 수) - - [x] 중복 검사 (카테고리 생성 시) - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 -- [x] **TemplateStandardService 전환 (7개)** ✅ **완료** (Phase 3.9) - - [x] 7개 Prisma 호출 전환 완료 (템플릿 CRUD, 카테고리) - - [x] 템플릿 목록 조회 (복잡한 OR 조건, Promise.all) - - [x] 템플릿 생성 (중복 검사 + INSERT) - - [x] 동적 UPDATE 쿼리 (11개 필드 조건부 업데이트) - - [x] 템플릿 삭제 (DELETE) - - [x] 정렬 순서 일괄 업데이트 (Promise.all) - - [x] DISTINCT 쿼리 (카테고리 목록) - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 -- [x] **EventTriggerService 전환 (6개)** ✅ **완료** (Phase 3.10) - - [x] 6개 Prisma 호출 전환 완료 (이벤트 트리거, JSON 검색) - - [x] JSON 필드 검색 ($queryRaw → query, JSONB 연산자) - - [x] 동적 INSERT 쿼리 (PostgreSQL 플레이스홀더) - - [x] 동적 UPDATE 쿼리 (WHERE 조건 + 플레이스홀더) - - [x] 동적 DELETE 쿼리 (WHERE 조건) - - [x] UPSERT 쿼리 (ON CONFLICT) - - [x] 다이어그램 조회 (findUnique → queryOne) - - [x] TypeScript 컴파일 성공 - - [x] Prisma import 완전 제거 -- [ ] 배치 관련 서비스 전환 (26개) ⭐ 대규모 신규 발견 - - [ ] BatchExternalDbService (8개) - - [ ] BatchExecutionLogService (7개), BatchManagementService (5개) - - [ ] BatchSchedulerService (4개) -- [x] **표준 관리 서비스 전환 (7개)** ✅ **완료** (Phase 3.9) - - [x] TemplateStandardService (7개) - [계획서](PHASE3.9_TEMPLATE_STANDARD_SERVICE_MIGRATION.md) -- [ ] 데이터플로우 관련 서비스 (6개) ⭐ 신규 발견 - - [ ] DataflowControlService (6개) -- [ ] 기타 중요 서비스 (8개) ⭐ 신규 발견 - - [ ] DDLAuditLogger (8개) -- [ ] 기능별 테스트 완료 - -### **Phase 4: 확장 기능 (2.5주) - 129개 호출 ⭐ 대폭 확장** - -- [ ] 외부 연동 서비스 전환 (51개) ⭐ 신규 발견 - - [ ] ExternalCallConfigService (8개), EventTriggerService (6개) - - [ ] EnhancedDynamicFormService (6개), EntityJoinService (5개) - - [ ] DataMappingService (5개), DataService (4개) - - [ ] AdminService (3개), ReferenceCacheService (3개) -- [x] **컨트롤러 레이어 전환** ⭐ **진행 중 (17/29, 58.6%)** - [상세 계획서](PHASE4_REMAINING_PRISMA_CALLS.md) - - [x] ~~AdminController (28개)~~ ✅ 완료 - - [x] ~~ScreenFileController (2개)~~ ✅ 완료 - - [ ] WebTypeStandardController (11개) 🔄 다음 대상 - - [ ] FileController (1개) - - [ ] DDLRoutes (2개) - - [ ] CompanyManagementRoutes (2개) - - [ ] MultiConnectionQueryService (4개) - - [ ] Database.ts (4개 - 제거 예정) - - [ ] ~~ButtonActionStandardController (11개)~~ ⚠️ 추가 조사 필요 - - [ ] ~~EntityReferenceController (4개)~~ ⚠️ 추가 조사 필요 - - [ ] ~~DataflowExecutionController (3개)~~ ⚠️ 추가 조사 필요 -- [ ] 전체 기능 테스트 - -### **Phase 5: Scripts 삭제 (0.5주) - 60개 호출 제거 🗑️** - -- [ ] 불필요한 스크립트 파일 삭제 (60개) 🗑️ 마이그레이션 불필요 - - [ ] backend-node/scripts/ 전체 폴더 삭제 (53개) - - [ ] backend-node/clean-screen-tables.js 삭제 (7개) - - [ ] package.json 스크립트 정리 -- [ ] 문서에서 스크립트 참조 제거 - -### **Phase 6: 완전 제거 (0.5주)** - -- [ ] Prisma 의존성 제거 -- [ ] schema.prisma 삭제 -- [ ] 관련 설정 파일 정리 -- [ ] 문서 업데이트 -- [ ] 최종 성능 테스트 -- [ ] 배포 준비 - ---- - -## 🎯 성공 기준 - -### **기능적 요구사항** - -- [ ] 모든 기존 기능이 동일하게 작동 -- [ ] 동적 테이블 생성/관리 완벽 지원 -- [ ] 트랜잭션 일관성 보장 -- [ ] 에러 처리 및 복구 메커니즘 - -### **성능 요구사항** - -- [ ] 기존 대비 성능 저하 없음 (±10% 이내) -- [ ] 메모리 사용량 최적화 -- [ ] 연결 풀 효율성 개선 -- [ ] 쿼리 실행 시간 단축 - -### **품질 요구사항** - -- [ ] 코드 커버리지 90% 이상 -- [ ] 모든 테스트 케이스 통과 -- [ ] 타입 안전성 보장 -- [ ] 보안 검증 완료 - ---- - -## 📚 참고 자료 - -### **기술 문서** - -- [PostgreSQL 공식 문서](https://www.postgresql.org/docs/) -- [Node.js pg 라이브러리](https://node-postgres.com/) -- [SQL 쿼리 최적화 가이드](https://use-the-index-luke.com/) - -### **내부 문서** - -- [현재 데이터베이스 스키마](backend-node/prisma/schema.prisma) -- [기존 Java 시스템 구조](src/com/pms/) -- [동적 테이블 생성 계획서](테이블_동적_생성_기능_개발_계획서.md) - ---- - -## ⚠️ 주의사항 - -1. **데이터 백업**: 마이그레이션 전 전체 데이터베이스 백업 필수 -2. **점진적 전환**: 한 번에 모든 것을 바꾸지 말고 단계별로 진행 -3. **철저한 테스트**: 각 단계마다 충분한 테스트 수행 -4. **롤백 계획**: 문제 발생 시 즉시 롤백할 수 있는 계획 수립 -5. **모니터링**: 전환 후 성능 및 안정성 지속 모니터링 - ---- - ---- - -## 📈 **업데이트된 마이그레이션 규모** - -### **🔍 최종 Prisma 사용 현황 (Scripts 삭제 후)** - -- **기존 계획**: 42개 파일, 386개 호출 -- **Scripts 포함**: 52개 파일, 490개 호출 (+104개 호출 발견) -- **Scripts 삭제 후**: **42개 파일, 444개 호출** (+58개 호출 실제 증가) ⚡ - -### **⭐ 주요 신규 발견 서비스들** - -1. **`dataflowService.ts`** (31개) - 데이터플로우 관리 핵심 서비스 -2. **배치 관련 서비스들** (40개) - 5개 서비스로 분산된 대규모 배치 시스템 -3. **`dataflowDiagramService.ts`** (12개) - 다이어그램 관리 -4. **`dbTypeCategoryService.ts`** (10개) - DB 타입 분류 시스템 -5. **컨트롤러 레이어** (72개) - 7개 컨트롤러에서 대규모 Prisma 사용 -6. **감사 및 로깅 서비스들** (15개) - DDL 감사, 배치 실행 로그 -7. **확장 기능들** (26개) - 엔티티 조인, 데이터 매핑, 외부 호출 설정 -8. **🗑️ Scripts 삭제** (60개) - 사용하지 않는 개발/배포 스크립트 (마이그레이션 불필요) - -### **📊 우선순위 재조정** - -#### **🔴 최우선 (Phase 2) - 107개 호출** - -- 화면관리 (46개), 테이블관리 (35개), 데이터플로우 (31개) - -#### **🟡 고우선순위 (Phase 3) - 162개 호출** - -- 다국어 (25개), 배치 시스템 (40개), 표준 관리 (41개) - -#### **🟢 중간우선순위 (Phase 4) - 129개 호출** - -- 외부 연동 (51개), 컨트롤러 레이어 (72개), 기타 (6개) - -#### **🗑️ Scripts 삭제 (Phase 5) - 60개 호출** 🗑️ 마이그레이션 불필요 - -- 사용하지 않는 개발/배포 스크립트 (60개) - 삭제로 작업량 감소 - ---- - -## 🎯 **최종 마이그레이션 계획** - -**총 예상 기간: 8주** ⬆️ (+2주 연장, Scripts 삭제로 1주 단축) -**핵심 개발자: 3-4명** ⬆️ (+1명 추가) -**실제 마이그레이션 대상: 444개 호출** (Scripts 60개 제외) -**위험도: 중간-높음** ⬇️ (Scripts 삭제로 위험도 일부 감소) - -### **⚠️ 주요 위험 요소** - -1. **배치 시스템 복잡성**: 5개 서비스 40개 호출의 복잡한 의존성 -2. **컨트롤러 레이어 규모**: 72개 호출의 대규모 API 전환 -3. **데이터플로우 시스템**: 신규 발견된 핵심 서비스 (31개 호출) -4. **트랜잭션 복잡성**: 다중 서비스 간 데이터 일관성 보장 -5. **✅ Scripts 삭제**: 60개 호출 제거로 작업량 대폭 감소 - -### **🚀 성공을 위한 핵심 전략** - -1. **단계별 점진적 전환**: 절대 한 번에 모든 것을 바꾸지 않기 -2. **철저한 테스트**: 각 Phase마다 완전한 기능 테스트 -3. **롤백 계획**: 각 단계별 즉시 롤백 가능한 계획 수립 -4. **모니터링 강화**: 전환 후 성능 및 안정성 지속 모니터링 -5. **팀 확대**: 복잡성 증가로 인한 개발팀 확대 필요 - -이 **완전한 분석**을 통해 Prisma를 완전히 제거하고 진정한 동적 데이터베이스 시스템을 구축할 수 있습니다! 🚀 - -**⚡ 중요**: 이제 모든 Prisma 사용 부분이 파악되었으므로, 누락 없는 완전한 마이그레이션이 가능합니다. diff --git a/UI_REDESIGN_PLAN.md b/UI_REDESIGN_PLAN.md deleted file mode 100644 index 930a693c..00000000 --- a/UI_REDESIGN_PLAN.md +++ /dev/null @@ -1,311 +0,0 @@ -# 🎨 제어관리 - 데이터 연결 설정 UI 재설계 계획서 - -## 📋 프로젝트 개요 - -### 목표 - -- 기존 모달 기반 필드 매핑을 메인 화면으로 통합 -- 중복된 테이블 선택 과정 제거 -- 시각적 필드 연결 매핑 구현 -- 좌우 분할 레이아웃으로 정보 가시성 향상 - -### 현재 문제점 - -- ❌ **이중 작업**: 테이블을 3번 선택해야 함 (더블클릭 → 모달 → 재선택) -- ❌ **혼란스러운 UX**: 사전 선택의 의미가 없어짐 -- ❌ **불필요한 모달**: 연결 설정이 메인 기능인데 숨겨져 있음 -- ❌ **시각적 피드백 부족**: 필드 매핑 관계가 명확하지 않음 - -## 🎯 새로운 UI 구조 - -### 레이아웃 구성 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 제어관리 - 데이터 연결 설정 │ -├─────────────────────────────────────────────────────────────┤ -│ 좌측 패널 (30%) │ 우측 패널 (70%) │ -│ - 연결 타입 선택 │ - 단계별 설정 UI │ -│ - 매핑 정보 모니터링 │ - 시각적 필드 매핑 │ -│ - 상세 설정 목록 │ - 실시간 연결선 표시 │ -│ - 액션 버튼 │ - 드래그 앤 드롭 지원 │ -└─────────────────────────────────────────────────────────────┘ -``` - -## 🔧 구현 단계 - -### Phase 1: 기본 구조 구축 - -- [ ] 좌우 분할 레이아웃 컴포넌트 생성 -- [ ] 기존 모달 컴포넌트들을 메인 화면용으로 리팩토링 -- [ ] 연결 타입 선택 컴포넌트 구현 - -### Phase 2: 좌측 패널 구현 - -- [ ] 연결 타입 선택 (데이터 저장 / 외부 호출) -- [ ] 실시간 매핑 정보 표시 -- [ ] 매핑 상세 목록 컴포넌트 -- [ ] 고급 설정 패널 - -### Phase 3: 우측 패널 구현 - -- [ ] 단계별 진행 UI (연결 → 테이블 → 매핑) -- [ ] 시각적 필드 매핑 영역 -- [ ] SVG 기반 연결선 시스템 -- [ ] 드래그 앤 드롭 매핑 기능 - -### Phase 4: 고급 기능 - -- [ ] 실시간 검증 및 피드백 -- [ ] 매핑 미리보기 기능 -- [ ] 설정 저장/불러오기 -- [ ] 테스트 실행 기능 - -## 📁 파일 구조 - -### 새로 생성할 컴포넌트 - -``` -frontend/components/dataflow/connection/redesigned/ -├── DataConnectionDesigner.tsx # 메인 컨테이너 -├── LeftPanel/ -│ ├── ConnectionTypeSelector.tsx # 연결 타입 선택 -│ ├── MappingInfoPanel.tsx # 매핑 정보 표시 -│ ├── MappingDetailList.tsx # 매핑 상세 목록 -│ ├── AdvancedSettings.tsx # 고급 설정 -│ └── ActionButtons.tsx # 액션 버튼들 -├── RightPanel/ -│ ├── StepProgress.tsx # 단계 진행 표시 -│ ├── ConnectionStep.tsx # 1단계: 연결 선택 -│ ├── TableStep.tsx # 2단계: 테이블 선택 -│ ├── FieldMappingStep.tsx # 3단계: 필드 매핑 -│ └── VisualMapping/ -│ ├── FieldMappingCanvas.tsx # 시각적 매핑 캔버스 -│ ├── FieldColumn.tsx # 필드 컬럼 컴포넌트 -│ ├── ConnectionLine.tsx # SVG 연결선 -│ └── MappingControls.tsx # 매핑 제어 도구 -└── types/ - └── redesigned.ts # 타입 정의 -``` - -### 수정할 기존 파일 - -``` -frontend/components/dataflow/connection/ -├── DataSaveSettings.tsx # 새 UI로 교체 -├── ConnectionSelectionPanel.tsx # 재사용을 위한 리팩토링 -├── TableSelectionPanel.tsx # 재사용을 위한 리팩토링 -└── ActionFieldMappings.tsx # 레거시 처리 -``` - -## 🎨 UI 컴포넌트 상세 - -### 1. 연결 타입 선택 (ConnectionTypeSelector) - -```typescript -interface ConnectionType { - id: "data_save" | "external_call"; - label: string; - description: string; - icon: React.ReactNode; -} - -const connectionTypes: ConnectionType[] = [ - { - id: "data_save", - label: "데이터 저장", - description: "INSERT/UPDATE/DELETE 작업", - icon: , - }, - { - id: "external_call", - label: "외부 호출", - description: "API/Webhook 호출", - icon: , - }, -]; -``` - -### 2. 시각적 필드 매핑 (FieldMappingCanvas) - -```typescript -interface FieldMapping { - id: string; - fromField: ColumnInfo; - toField: ColumnInfo; - transformRule?: string; - isValid: boolean; - validationMessage?: string; -} - -interface MappingLine { - id: string; - fromX: number; - fromY: number; - toX: number; - toY: number; - isValid: boolean; - isHovered: boolean; -} -``` - -### 3. 매핑 정보 패널 (MappingInfoPanel) - -```typescript -interface MappingStats { - totalMappings: number; - validMappings: number; - invalidMappings: number; - missingRequiredFields: number; - estimatedRows: number; - actionType: "INSERT" | "UPDATE" | "DELETE"; -} -``` - -## 🔄 데이터 플로우 - -### 상태 관리 - -```typescript -interface DataConnectionState { - // 기본 설정 - connectionType: "data_save" | "external_call"; - currentStep: 1 | 2 | 3; - - // 연결 정보 - fromConnection?: Connection; - toConnection?: Connection; - fromTable?: TableInfo; - toTable?: TableInfo; - - // 매핑 정보 - fieldMappings: FieldMapping[]; - mappingStats: MappingStats; - - // UI 상태 - selectedMapping?: string; - isLoading: boolean; - validationErrors: ValidationError[]; -} -``` - -### 이벤트 핸들링 - -```typescript -interface DataConnectionActions { - // 연결 타입 - setConnectionType: (type: "data_save" | "external_call") => void; - - // 단계 진행 - goToStep: (step: 1 | 2 | 3) => void; - - // 연결/테이블 선택 - selectConnection: (type: "from" | "to", connection: Connection) => void; - selectTable: (type: "from" | "to", table: TableInfo) => void; - - // 필드 매핑 - createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void; - updateMapping: (mappingId: string, updates: Partial) => void; - deleteMapping: (mappingId: string) => void; - - // 검증 및 저장 - validateMappings: () => Promise; - saveMappings: () => Promise; - testExecution: () => Promise; -} -``` - -## 🎯 사용자 경험 (UX) 개선점 - -### Before (기존) - -1. 테이블 더블클릭 → 화면에 표시 -2. 모달 열기 → 다시 테이블 선택 -3. 외부 커넥션 설정 → 또 다시 테이블 선택 -4. 필드 매핑 → 텍스트 기반 매핑 - -### After (개선) - -1. **연결 타입 선택** → 목적 명확화 -2. **연결 선택** → 한 번에 FROM/TO 설정 -3. **테이블 선택** → 즉시 필드 정보 로드 -4. **시각적 매핑** → 드래그 앤 드롭으로 직관적 연결 - -## 🚀 구현 우선순위 - -### 🔥 High Priority - -1. **기본 레이아웃** - 좌우 분할 구조 -2. **연결 타입 선택** - 데이터 저장/외부 호출 -3. **단계별 진행** - 연결 → 테이블 → 매핑 -4. **기본 필드 매핑** - 드래그 앤 드롭 없이 클릭 기반 - -### 🔶 Medium Priority - -1. **시각적 연결선** - SVG 기반 라인 표시 -2. **실시간 검증** - 타입 호환성 체크 -3. **매핑 정보 패널** - 통계 및 상태 표시 -4. **드래그 앤 드롭** - 고급 매핑 기능 - -### 🔵 Low Priority - -1. **고급 설정** - 트랜잭션, 배치 설정 -2. **미리보기 기능** - 데이터 변환 미리보기 -3. **설정 템플릿** - 자주 사용하는 매핑 저장 -4. **성능 최적화** - 대용량 테이블 처리 - -## 📅 개발 일정 - -### Week 1: 기본 구조 - -- [ ] 레이아웃 컴포넌트 생성 -- [ ] 연결 타입 선택 구현 -- [ ] 기존 컴포넌트 리팩토링 - -### Week 2: 핵심 기능 - -- [ ] 단계별 진행 UI -- [ ] 연결/테이블 선택 통합 -- [ ] 기본 필드 매핑 구현 - -### Week 3: 시각적 개선 - -- [ ] SVG 연결선 시스템 -- [ ] 드래그 앤 드롭 매핑 -- [ ] 실시간 검증 기능 - -### Week 4: 완성 및 테스트 - -- [ ] 고급 기능 구현 -- [ ] 통합 테스트 -- [ ] 사용자 테스트 및 피드백 반영 - -## 🔍 기술적 고려사항 - -### 성능 최적화 - -- **가상화**: 대용량 필드 목록 처리 -- **메모이제이션**: 불필요한 리렌더링 방지 -- **지연 로딩**: 필요한 시점에만 데이터 로드 - -### 접근성 - -- **키보드 네비게이션**: 모든 기능을 키보드로 접근 가능 -- **스크린 리더**: 시각적 매핑의 대체 텍스트 제공 -- **색상 대비**: 연결선과 상태 표시의 명확한 구분 - -### 확장성 - -- **플러그인 구조**: 새로운 연결 타입 쉽게 추가 -- **커스텀 변환**: 사용자 정의 데이터 변환 규칙 -- **API 확장**: 외부 시스템과의 연동 지원 - ---- - -## 🎯 다음 단계 - -이 계획서를 바탕으로 **Phase 1부터 순차적으로 구현**을 시작하겠습니다. - -**첫 번째 작업**: 좌우 분할 레이아웃과 연결 타입 선택 컴포넌트 구현 - -구현을 시작하시겠어요? 🚀 diff --git a/WORK_HISTORY_SETUP.md b/WORK_HISTORY_SETUP.md deleted file mode 100644 index 223b3975..00000000 --- a/WORK_HISTORY_SETUP.md +++ /dev/null @@ -1,150 +0,0 @@ -# 작업 이력 관리 시스템 설치 가이드 - -## 📋 개요 - -작업 이력 관리 시스템이 추가되었습니다. 입고/출고/이송/정비 작업을 관리하고 통계를 확인할 수 있습니다. - -## 🚀 설치 방법 - -### 1. 데이터베이스 마이그레이션 실행 - -PostgreSQL 데이터베이스에 작업 이력 테이블을 생성해야 합니다. - -```bash -# 방법 1: psql 명령어 사용 (로컬 PostgreSQL) -psql -U postgres -d plm -f db/migrations/20241020_create_work_history.sql - -# 방법 2: Docker 컨테이너 사용 -docker exec -i psql -U postgres -d plm < db/migrations/20241020_create_work_history.sql - -# 방법 3: pgAdmin 또는 DBeaver 사용 -# db/migrations/20241020_create_work_history.sql 파일을 열어서 실행 -``` - -### 2. 백엔드 재시작 - -```bash -cd backend-node -npm run dev -``` - -### 3. 프론트엔드 확인 - -대시보드 편집 화면에서 다음 위젯들을 추가할 수 있습니다: - -- **작업 이력**: 작업 목록을 테이블 형식으로 표시 -- **운송 통계**: 오늘 작업, 총 운송량, 정시 도착률 등 통계 표시 - -## 📊 주요 기능 - -### 작업 이력 위젯 - -- 작업 번호, 일시, 유형, 차량, 경로, 화물, 중량, 상태 표시 -- 유형별 필터링 (입고/출고/이송/정비) -- 상태별 필터링 (대기/진행중/완료/취소) -- 실시간 자동 새로고침 - -### 운송 통계 위젯 - -- 오늘 작업 건수 및 완료율 -- 총 운송량 (톤) -- 누적 거리 (km) -- 정시 도착률 (%) -- 작업 유형별 분포 차트 - -## 🔧 API 엔드포인트 - -### 작업 이력 관리 - -- `GET /api/work-history` - 작업 이력 목록 조회 -- `GET /api/work-history/:id` - 작업 이력 단건 조회 -- `POST /api/work-history` - 작업 이력 생성 -- `PUT /api/work-history/:id` - 작업 이력 수정 -- `DELETE /api/work-history/:id` - 작업 이력 삭제 - -### 통계 및 분석 - -- `GET /api/work-history/stats` - 작업 이력 통계 -- `GET /api/work-history/trend?months=6` - 월별 추이 -- `GET /api/work-history/routes?limit=5` - 주요 운송 경로 - -## 📝 샘플 데이터 - -마이그레이션 실행 시 자동으로 4건의 샘플 데이터가 생성됩니다: - -1. 입고 작업 (완료) -2. 출고 작업 (진행중) -3. 이송 작업 (대기) -4. 정비 작업 (완료) - -## 🎯 사용 방법 - -### 1. 대시보드에 위젯 추가 - -1. 대시보드 편집 모드로 이동 -2. 상단 메뉴에서 "위젯 추가" 선택 -3. "작업 이력" 또는 "운송 통계" 선택 -4. 원하는 위치에 배치 -5. 저장 - -### 2. 작업 이력 필터링 - -- 유형 선택: 전체/입고/출고/이송/정비 -- 상태 선택: 전체/대기/진행중/완료/취소 -- 새로고침 버튼으로 수동 갱신 - -### 3. 통계 확인 - -운송 통계 위젯에서 다음 정보를 확인할 수 있습니다: - -- 오늘 작업 건수 -- 완료율 -- 총 운송량 -- 정시 도착률 -- 작업 유형별 분포 - -## 🔍 문제 해결 - -### 데이터가 표시되지 않는 경우 - -1. 데이터베이스 마이그레이션이 실행되었는지 확인 -2. 백엔드 서버가 실행 중인지 확인 -3. 브라우저 콘솔에서 API 에러 확인 - -### API 에러가 발생하는 경우 - -```bash -# 백엔드 로그 확인 -cd backend-node -npm run dev -``` - -### 위젯이 표시되지 않는 경우 - -1. 프론트엔드 재시작 -2. 브라우저 캐시 삭제 -3. 페이지 새로고침 - -## 📚 관련 파일 - -### 백엔드 - -- `backend-node/src/types/workHistory.ts` - 타입 정의 -- `backend-node/src/services/workHistoryService.ts` - 비즈니스 로직 -- `backend-node/src/controllers/workHistoryController.ts` - API 컨트롤러 -- `backend-node/src/routes/workHistoryRoutes.ts` - 라우트 정의 - -### 프론트엔드 - -- `frontend/types/workHistory.ts` - 타입 정의 -- `frontend/components/dashboard/widgets/WorkHistoryWidget.tsx` - 작업 이력 위젯 -- `frontend/components/dashboard/widgets/TransportStatsWidget.tsx` - 운송 통계 위젯 - -### 데이터베이스 - -- `db/migrations/20241020_create_work_history.sql` - 테이블 생성 스크립트 - -## 🎉 완료! - -작업 이력 관리 시스템이 성공적으로 설치되었습니다! - diff --git a/YARD_MANAGEMENT_3D_DATA_BINDING_REDESIGN.md b/YARD_MANAGEMENT_3D_DATA_BINDING_REDESIGN.md deleted file mode 100644 index 0b131635..00000000 --- a/YARD_MANAGEMENT_3D_DATA_BINDING_REDESIGN.md +++ /dev/null @@ -1,426 +0,0 @@ -# 야드 관리 3D - 데이터 바인딩 시스템 재설계 - -## 1. 개요 - -### 현재 방식의 문제점 - -- 고정된 임시 자재 마스터(`temp_material_master`) 테이블에 의존 -- 실제 외부 시스템의 자재 데이터와 연동 불가 -- 자재 목록이 제한적이고 유연성 부족 -- 사용자가 직접 데이터를 선택하거나 입력할 수 없음 - -### 새로운 방식의 목표 - -- 차트/리스트 위젯과 동일한 데이터 소스 선택 방식 적용 -- DB 커넥션 또는 REST API를 통해 실제 자재 데이터 연동 -- 사용자가 자재명, 수량 등을 직접 매핑 및 입력 가능 -- 설정되지 않은 요소는 뷰어에서 명확히 표시 - ---- - -## 2. 핵심 변경사항 - -### 2.1 요소(Element) 개념 도입 - -- 기존: 자재 목록에서 클릭 → 즉시 배치 -- 변경: [+ 요소 추가] 버튼 클릭 → 3D 캔버스에 즉시 빈 요소 배치 → 우측 패널이 데이터 바인딩 설정 화면으로 전환 - -### 2.2 데이터 소스 선택 - -- 현재 DB (내부 PostgreSQL) -- 외부 DB 커넥션 -- REST API - -### 2.3 데이터 매핑 - -- 자재명 필드 선택 (데이터 소스에서) -- 수량 필드 선택 (데이터 소스에서) -- 단위 직접 입력 (예: EA, BOX, KG 등) -- 색상 선택 - ---- - -## 3. 데이터베이스 스키마 변경 - -### 3.1 기존 테이블 수정: `yard_material_placement` - -```sql --- 기존 컬럼 변경 -ALTER TABLE yard_material_placement - -- 기존 컬럼 제거 (외부 자재 ID 관련) - DROP COLUMN IF EXISTS external_material_id, - - -- 데이터 소스 정보 추가 - ADD COLUMN data_source_type VARCHAR(20), -- 'database', 'external_db', 'rest_api' - ADD COLUMN data_source_config JSONB, -- 데이터 소스 설정 - - -- 데이터 바인딩 정보 추가 - ADD COLUMN data_binding JSONB, -- 필드 매핑 정보 - - -- 자재 정보를 NULL 허용으로 변경 (설정 전에는 NULL) - ALTER COLUMN material_code DROP NOT NULL, - ALTER COLUMN material_name DROP NOT NULL, - ALTER COLUMN quantity DROP NOT NULL; -``` - -### 3.2 data_source_config 구조 - -```typescript -interface DataSourceConfig { - type: "database" | "external_db" | "rest_api"; - - // type === 'database' (현재 DB) - query?: string; - - // type === 'external_db' (외부 DB) - connectionId?: number; - query?: string; - - // type === 'rest_api' - url?: string; - method?: "GET" | "POST"; - headers?: Record; - queryParams?: Record; - body?: string; - dataPath?: string; // 응답에서 데이터 배열 경로 (예: "data.items") -} -``` - -### 3.3 data_binding 구조 - -```typescript -interface DataBinding { - // 데이터 소스의 특정 행 선택 - selectedRowIndex?: number; - - // 필드 매핑 (데이터 소스에서 선택) - materialNameField?: string; // 자재명이 들어있는 컬럼명 - quantityField?: string; // 수량이 들어있는 컬럼명 - - // 단위는 사용자가 직접 입력 - unit: string; // 예: "EA", "BOX", "KG", "M" 등 -} -``` - ---- - -## 4. UI/UX 설계 - -### 4.1 편집 모드 (YardEditor) - -``` -┌─────────────────────────────────────────────────────────────┐ -│ [← 목록으로] 야드명: A구역 [저장] │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────────────────────────┐ ┌──────────────────────────┐│ -│ │ │ │ ││ -│ │ │ │ [+ 요소 추가] ││ -│ │ │ │ ││ -│ │ 3D 캔버스 │ │ ┌────────────────────┐ ││ -│ │ │ │ │ □ 요소 1 │ ││ -│ │ │ │ │ 자재: 철판 A │ ││ -│ │ │ │ │ 수량: 50 EA │ ││ -│ │ │ │ │ [편집] [삭제] │ ││ -│ │ │ │ └────────────────────┘ ││ -│ │ │ │ ││ -│ │ │ │ ┌────────────────────┐ ││ -│ │ │ │ │ □ 요소 2 (미설정) │ ││ -│ │ │ │ │ 데이터 바인딩 │ ││ -│ │ │ │ │ 설정 필요 │ ││ -│ │ │ │ │ [설정] [삭제] │ ││ -│ │ │ │ └────────────────────┘ ││ -│ │ │ │ ││ -│ └───────────────────────────┘ └──────────────────────────┘│ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -#### 4.1.1 요소 목록 (우측 패널) - -- **[+ 요소 추가]** 버튼: 새 요소 생성 -- **요소 카드**: - - 설정 완료: 자재명, 수량 표시 + [편집] [삭제] 버튼 - - 미설정: "데이터 바인딩 설정 필요" + [설정] [삭제] 버튼 - -#### 4.1.2 요소 추가 흐름 - -``` -1. [+ 요소 추가] 클릭 - ↓ -2. 3D 캔버스의 기본 위치(0,0,0)에 회색 반투명 박스로 빈 요소 즉시 배치 - ↓ -3. 요소가 자동 선택됨 - ↓ -4. 우측 패널이 "데이터 바인딩 설정" 화면으로 자동 전환 - (요소 목록에서 [설정] 버튼을 클릭해도 동일한 화면) -``` - -### 4.2 데이터 바인딩 설정 패널 (우측) - -**[+ 요소 추가] 버튼 클릭 시 또는 [설정] 버튼 클릭 시 우측 패널이 아래와 같이 변경됩니다:** - -``` -┌──────────────────────────────────────┐ -│ 데이터 바인딩 설정 [← 목록]│ -├──────────────────────────────────────┤ -│ │ -│ ┌─ 1단계: 데이터 소스 선택 ─────────────────────────┐ │ -│ │ │ │ -│ │ ○ 현재 DB ○ 외부 DB ○ REST API │ │ -│ │ │ │ -│ │ [현재 DB 선택 시] │ │ -│ │ ┌────────────────────────────────────────────┐ │ │ -│ │ │ SELECT material_name, quantity, unit │ │ │ -│ │ │ FROM inventory │ │ │ -│ │ │ WHERE status = 'available' │ │ │ -│ │ └────────────────────────────────────────────┘ │ │ -│ │ [실행] 버튼 │ │ -│ │ │ │ -│ │ [외부 DB 선택 시] │ │ -│ │ - 외부 커넥션 선택 드롭다운 │ │ -│ │ - SQL 쿼리 입력 │ │ -│ │ - [실행] 버튼 │ │ -│ │ │ │ -│ │ [REST API 선택 시] │ │ -│ │ - URL 입력 │ │ -│ │ - Method 선택 (GET/POST) │ │ -│ │ - Headers, Query Params 설정 │ │ -│ │ - [실행] 버튼 │ │ -│ │ │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ 2단계: 쿼리 결과 및 필드 매핑 ──────────────────────┐ │ -│ │ │ │ -│ │ 쿼리 결과 (5행): │ │ -│ │ ┌────────────────────────────────────────────┐ │ │ -│ │ │ material_name │ quantity │ status │ │ │ -│ │ │ 철판 A │ 50 │ available │ ○ │ │ -│ │ │ 강관 파이프 │ 100 │ available │ ○ │ │ -│ │ │ 볼트 세트 │ 500 │ in_stock │ ○ │ │ -│ │ └────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ 필드 매핑: │ │ -│ │ 자재명: [material_name ▼] │ │ -│ │ 수량: [quantity ▼] │ │ -│ │ │ │ -│ │ 단위 입력: │ │ -│ │ 단위: [EA_____________] │ │ -│ │ (예: EA, BOX, KG, M, L 등) │ │ -│ │ │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ 3단계: 배치 설정 ──────────────────────────────────┐ │ -│ │ │ │ -│ │ 색상: [🎨 #3b82f6] │ │ -│ │ │ │ -│ │ 크기: │ │ -│ │ 너비: [5] 높이: [5] 깊이: [5] │ │ -│ │ │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ [← 목록으로] [저장] │ -└──────────────────────────────────────┘ -``` - -**참고:** - -- [← 목록으로] 버튼: 요소 목록 화면으로 돌아갑니다 -- [저장] 버튼: 데이터 바인딩 설정을 저장하고 요소 목록 화면으로 돌아갑니다 -- 저장하지 않고 나가면 요소는 "미설정" 상태로 남습니다 - -### 4.3 뷰어 모드 (Yard3DViewer) - -#### 4.3.1 설정된 요소 - -- 정상적으로 3D 박스 렌더링 -- 클릭 시 자재명, 수량 정보 표시 - -#### 4.3.2 미설정 요소 - -``` -┌─────────────────────┐ -│ │ -│ ⚠️ │ -│ │ -│ 설정되지 않은 │ -│ 요소입니다 │ -│ │ -└─────────────────────┘ -``` - -- 반투명 회색 박스로 표시 -- 클릭 시 "데이터 바인딩이 설정되지 않았습니다" 메시지 - ---- - -## 5. 구현 단계 - -### Phase 1: 데이터베이스 스키마 변경 - -- [ ] `yard_material_placement` 테이블 수정 -- [ ] 마이그레이션 스크립트 작성 -- [ ] 기존 데이터 호환성 처리 - -### Phase 2: 백엔드 API 수정 - -- [ ] `YardLayoutService.ts` 수정 - - `addMaterialPlacement`: 데이터 소스/바인딩 정보 저장 - - `updatePlacement`: 데이터 바인딩 업데이트 - - `getPlacementsByLayoutId`: 새 필드 포함하여 조회 -- [ ] 데이터 소스 실행 로직 추가 - - DB 쿼리 실행 - - 외부 DB 쿼리 실행 - - REST API 호출 - -### Phase 3: 프론트엔드 타입 정의 - -- [ ] `types.ts`에 새로운 인터페이스 추가 - - `YardElementDataSource` - - `YardElementDataBinding` - - `YardPlacement` 업데이트 - -### Phase 4: 요소 추가 및 관리 - -- [ ] `YardEditor.tsx` 수정 - - [+ 요소 추가] 버튼 구현 - - 빈 요소 생성 로직 (즉시 3D 캔버스에 배치) - - 요소 추가 시 자동으로 해당 요소 선택 - - 우측 패널 상태 관리 (요소 목록 ↔ 데이터 바인딩 설정) - - 요소 목록 UI - - 설정/미설정 상태 구분 표시 - -### Phase 5: 데이터 바인딩 패널 - -- [ ] `YardElementConfigPanel.tsx` 생성 (우측 패널 컴포넌트) - - [← 목록으로] 버튼으로 요소 목록으로 복귀 - - 1단계: 데이터 소스 선택 (DatabaseConfig, ExternalDbConfig, RestApiConfig 재사용) - - 2단계: 쿼리 결과 테이블 + 행 선택 + 필드 매핑 - - 자재명 필드 선택 (드롭다운) - - 수량 필드 선택 (드롭다운) - - 단위 직접 입력 (Input) - - 3단계: 배치 설정 (색상, 크기) - - [저장] 버튼으로 설정 저장 및 목록으로 복귀 - -### Phase 6: 3D 캔버스 렌더링 수정 - -- [ ] `Yard3DCanvas.tsx` 수정 - - 설정된 요소: 기존 렌더링 - - 미설정 요소: 회색 반투명 박스 + 경고 아이콘 - -### Phase 7: 뷰어 모드 수정 - -- [ ] `Yard3DViewer.tsx` 수정 - - 미설정 요소 감지 - - 미설정 요소 클릭 시 안내 메시지 - -### Phase 8: 임시 테이블 제거 - -- [ ] `temp_material_master` 테이블 삭제 -- [ ] 관련 API 및 UI 코드 정리 - ---- - -## 6. 데이터 구조 예시 - -### 6.1 데이터 소스 + 필드 매핑 사용 - -```json -{ - "id": 1, - "yard_layout_id": 1, - "material_code": null, - "material_name": "철판 A타입", - "quantity": 50, - "unit": "EA", - "data_source_type": "database", - "data_source_config": { - "type": "database", - "query": "SELECT material_name, quantity FROM inventory WHERE material_id = 'MAT-001'" - }, - "data_binding": { - "selectedRowIndex": 0, - "materialNameField": "material_name", - "quantityField": "quantity", - "unit": "EA" - }, - "position_x": 10, - "position_y": 0, - "position_z": 10, - "size_x": 5, - "size_y": 5, - "size_z": 5, - "color": "#ef4444" -} -``` - -### 6.2 미설정 요소 - -```json -{ - "id": 3, - "yard_layout_id": 1, - "material_code": null, - "material_name": null, - "quantity": null, - "unit": null, - "data_source_type": null, - "data_source_config": null, - "data_binding": null, - "position_x": 30, - "position_y": 0, - "position_z": 30, - "size_x": 5, - "size_y": 5, - "size_z": 5, - "color": "#9ca3af" -} -``` - ---- - -## 7. 장점 - -1. **유연성**: 다양한 데이터 소스 지원 (내부 DB, 외부 DB, REST API) -2. **실시간성**: 실제 시스템의 자재 데이터와 연동 가능 -3. **일관성**: 차트/리스트 위젯과 동일한 데이터 소스 선택 방식 -4. **사용자 경험**: 데이터 매핑 방식 선택 가능 (자동/수동) -5. **확장성**: 새로운 데이터 소스 타입 추가 용이 -6. **명확성**: 미설정 요소를 시각적으로 구분 - ---- - -## 8. 마이그레이션 전략 - -### 8.1 기존 데이터 처리 - -- 기존 `temp_material_master` 기반 배치 데이터를 수동 입력 모드로 전환 -- `external_material_id` → `data_binding.mode = 'manual'`로 변환 - -### 8.2 단계적 전환 - -1. 새 스키마 적용 (기존 컬럼 유지) -2. 새 UI/로직 구현 및 테스트 -3. 기존 데이터 마이그레이션 -4. 임시 테이블 및 구 코드 제거 - ---- - -## 9. 기술 스택 - -- **백엔드**: PostgreSQL JSONB, Node.js/TypeScript -- **프론트엔드**: React, TypeScript, Shadcn UI -- **3D 렌더링**: React Three Fiber, Three.js -- **데이터 소스**: 기존 `DatabaseConfig`, `ExternalDbConfig`, `RestApiConfig` 컴포넌트 재사용 - ---- - -## 10. 예상 개발 기간 - -- Phase 1-2 (DB/백엔드): 1일 -- Phase 3-4 (프론트엔드 구조): 1일 -- Phase 5 (데이터 바인딩 모달): 2일 -- Phase 6-7 (3D 렌더링/뷰어): 1일 -- Phase 8 (정리 및 테스트): 0.5일 - -**총 예상 기간: 약 5.5일** diff --git a/YARD_MANAGEMENT_3D_PLAN.md b/YARD_MANAGEMENT_3D_PLAN.md deleted file mode 100644 index fe11a2cc..00000000 --- a/YARD_MANAGEMENT_3D_PLAN.md +++ /dev/null @@ -1,1028 +0,0 @@ -# 야드 관리 3D 기능 구현 계획서 - -## 1. 기능 개요 - -### 목적 - -대시보드에서 야드(Yard)의 자재 배치 상태를 3D로 시각화하고 관리하는 위젯 - -### 주요 특징 - -- **대시보드 위젯**: 대시보드의 위젯 형태로 추가되는 기능 -- **야드 레이아웃 관리**: 여러 야드 레이아웃을 생성, 선택, 수정, 삭제 가능 -- **3D 시각화**: Three.js + React Three Fiber를 사용한 3D 렌더링 -- **자재 배치**: 3D 공간에서 자재를 직접 배치 및 이동 가능 -- **자재 정보**: 배치된 자재 클릭 시 상세 정보 표시 (읽기 전용 자재 정보 + 편집 가능한 배치 정보) - -### 위젯 통합 - -- **위젯 타입**: `yard-management-3d` -- **위치**: 대시보드 관리 > 데이터 위젯 > 야드 관리 3D -- **표시 모드**: - - 편집 모드: 플레이스홀더 표시 - - 뷰 모드: 실제 야드 관리 기능 실행 - ---- - -## 2. 데이터베이스 설계 - -### 2.1. yard_layout 테이블 - -야드 레이아웃 정보 저장 - -```sql -CREATE TABLE yard_layout ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, -- 야드 이름 (예: "A구역", "1번 야드") - description TEXT, -- 설명 - created_by VARCHAR(50), -- 생성자 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -``` - -### 2.2. yard_material_placement 테이블 - -야드 내 자재 배치 정보 (외부 자재 데이터 참조) - -```sql -CREATE TABLE yard_material_placement ( - id SERIAL PRIMARY KEY, - yard_layout_id INTEGER REFERENCES yard_layout(id) ON DELETE CASCADE, - - -- 외부 자재 참조 (API로 받아올 데이터) - external_material_id VARCHAR(100) NOT NULL, -- 외부 시스템 자재 ID - material_code VARCHAR(50) NOT NULL, -- 자재 코드 (캐시) - material_name VARCHAR(100) NOT NULL, -- 자재 이름 (캐시) - quantity INTEGER NOT NULL DEFAULT 1, -- 수량 (캐시) - unit VARCHAR(20) DEFAULT 'EA', -- 단위 (캐시) - - -- 3D 위치 정보 - position_x NUMERIC(10, 2) NOT NULL DEFAULT 0, -- X 좌표 - position_y NUMERIC(10, 2) NOT NULL DEFAULT 0, -- Y 좌표 (높이) - position_z NUMERIC(10, 2) NOT NULL DEFAULT 0, -- Z 좌표 - - -- 3D 크기 정보 - size_x NUMERIC(10, 2) NOT NULL DEFAULT 5, -- 너비 - size_y NUMERIC(10, 2) NOT NULL DEFAULT 5, -- 높이 - size_z NUMERIC(10, 2) NOT NULL DEFAULT 5, -- 깊이 - - -- 외관 정보 - color VARCHAR(7) DEFAULT '#3b82f6', -- 색상 (HEX) - - -- 추가 정보 - status VARCHAR(20) DEFAULT 'normal', -- 상태 (normal, alert, warning) - memo TEXT, -- 메모 - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- 외부 자재 ID와 야드 레이아웃의 조합은 유니크해야 함 (중복 배치 방지) -CREATE UNIQUE INDEX idx_yard_material_unique - ON yard_material_placement(yard_layout_id, external_material_id); -``` - -### 2.3. temp_material_master 테이블 (임시 자재 마스터) - -외부 API를 받기 전까지 사용할 임시 자재 데이터 - -```sql -CREATE TABLE temp_material_master ( - id SERIAL PRIMARY KEY, - material_code VARCHAR(50) UNIQUE NOT NULL, -- 자재 코드 - material_name VARCHAR(100) NOT NULL, -- 자재 이름 - category VARCHAR(50), -- 카테고리 - unit VARCHAR(20) DEFAULT 'EA', -- 기본 단위 - default_color VARCHAR(7) DEFAULT '#3b82f6', -- 기본 색상 - description TEXT, -- 설명 - is_active BOOLEAN DEFAULT true, -- 사용 여부 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- 임시 자재 마스터 샘플 데이터 -INSERT INTO temp_material_master (material_code, material_name, category, unit, default_color, description) VALUES -('MAT-STEEL-001', '철판 A타입 (1200x2400)', '철강', 'EA', '#ef4444', '두께 10mm 철판'), -('MAT-STEEL-002', '철판 B타입 (1000x2000)', '철강', 'EA', '#dc2626', '두께 8mm 철판'), -('MAT-PIPE-001', '강관 파이프 (Φ100)', '파이프', 'EA', '#10b981', '길이 6m'), -('MAT-PIPE-002', '강관 파이프 (Φ150)', '파이프', 'EA', '#059669', '길이 6m'), -('MAT-BOLT-001', '볼트 세트 M12', '부품', 'BOX', '#f59e0b', '100개/박스'), -('MAT-BOLT-002', '볼트 세트 M16', '부품', 'BOX', '#d97706', '100개/박스'), -('MAT-ANGLE-001', '앵글 (75x75x6)', '형강', 'EA', '#8b5cf6', '길이 6m'), -('MAT-ANGLE-002', '앵글 (100x100x10)', '형강', 'EA', '#7c3aed', '길이 6m'), -('MAT-CHANNEL-001', '채널 (100x50x5)', '형강', 'EA', '#06b6d4', '길이 6m'), -('MAT-WIRE-001', '와이어 로프 (Φ12)', '케이블', 'M', '#ec4899', '롤 단위'); -``` - -### 2.4. 초기 데이터 마이그레이션 스크립트 - -```sql --- 샘플 야드 레이아웃 -INSERT INTO yard_layout (name, description, created_by) VALUES -('A구역', '메인 야드 A구역', 'admin'), -('B구역', '메인 야드 B구역', 'admin'), -('C구역', '보조 야드 C구역', 'admin'); - --- 샘플 자재 배치 (A구역) - 임시 자재 마스터 참조 -INSERT INTO yard_material_placement (yard_layout_id, external_material_id, material_code, material_name, - quantity, unit, position_x, position_y, position_z, size_x, size_y, size_z, color, status) VALUES -(1, 'TEMP-1', 'MAT-STEEL-001', '철판 A타입 (1200x2400)', 50, 'EA', 10, 0, 10, 8, 4, 8, '#ef4444', 'normal'), -(1, 'TEMP-2', 'MAT-STEEL-002', '철판 B타입 (1000x2000)', 30, 'EA', 25, 0, 10, 8, 4, 8, '#dc2626', 'normal'), -(1, 'TEMP-3', 'MAT-PIPE-001', '강관 파이프 (Φ100)', 100, 'EA', 40, 0, 10, 6, 6, 6, '#10b981', 'normal'), -(1, 'TEMP-4', 'MAT-BOLT-001', '볼트 세트 M12', 500, 'BOX', 55, 0, 10, 4, 4, 4, '#f59e0b', 'warning'); -``` - -### 2.5. 외부 자재 API 연동 구조 - -**현재 (Phase 1)**: 임시 자재 마스터 사용 - -```typescript -// temp_material_master 테이블에서 조회 -GET / api / materials / temp; -``` - -**향후 (Phase 2)**: 외부 API 연동 - -```typescript -// 외부 시스템 자재 API -GET /api/external/materials -Response: [ - { - id: "EXT-12345", - code: "MAT-STEEL-001", - name: "철판 A타입", - quantity: 150, - unit: "EA", - location: "창고A-1", - ... - } -] -``` - ---- - -## 3. 백엔드 API 설계 - -### 3.1. YardLayoutService - -**경로**: `backend-node/src/services/YardLayoutService.ts` - -**구현 완료** - -주요 메서드: - -- `getAllLayouts()`: 모든 야드 레이아웃 목록 조회 (배치 자재 개수 포함) -- `getLayoutById(id)`: 특정 야드 레이아웃 상세 조회 -- `createLayout(data)`: 새 야드 레이아웃 생성 -- `updateLayout(id, data)`: 야드 레이아웃 수정 (이름, 설명만) -- `deleteLayout(id)`: 야드 레이아웃 삭제 (CASCADE로 배치 자재도 함께 삭제) -- `duplicateLayout(id, newName)`: 야드 레이아웃 복제 (배치 자재 포함) -- `getPlacementsByLayoutId(layoutId)`: 특정 야드의 모든 배치 자재 조회 -- `addMaterialPlacement(layoutId, data)`: 야드에 자재 배치 추가 -- `updatePlacement(placementId, data)`: 배치 정보 수정 (위치, 크기, 색상, 메모만) -- `removePlacement(placementId)`: 배치 해제 -- `batchUpdatePlacements(layoutId, placements)`: 여러 배치 일괄 업데이트 (트랜잭션 처리) - -**중요**: 자재 마스터 데이터(코드, 이름, 수량, 단위)는 읽기 전용. 배치 정보만 수정 가능. - -### 3.2. YardLayoutController - -**경로**: `backend-node/src/controllers/YardLayoutController.ts` - -**구현 완료** - -엔드포인트: - -- `GET /api/yard-layouts`: 모든 레이아웃 목록 (배치 개수 포함) -- `GET /api/yard-layouts/:id`: 특정 레이아웃 상세 -- `POST /api/yard-layouts`: 새 레이아웃 생성 (name, description) -- `PUT /api/yard-layouts/:id`: 레이아웃 수정 (이름, 설명만) -- `DELETE /api/yard-layouts/:id`: 레이아웃 삭제 (CASCADE) -- `POST /api/yard-layouts/:id/duplicate`: 레이아웃 복제 (name) -- `GET /api/yard-layouts/:id/placements`: 레이아웃의 배치 자재 목록 -- `POST /api/yard-layouts/:id/placements`: 자재 배치 추가 -- `PUT /api/yard-layouts/placements/:id`: 배치 정보 수정 -- `DELETE /api/yard-layouts/placements/:id`: 배치 해제 -- `PUT /api/yard-layouts/:id/placements/batch`: 배치 일괄 업데이트 - -모든 엔드포인트는 `authMiddleware`로 인증 보호됨 - -### 3.3. MaterialService - -**경로**: `backend-node/src/services/MaterialService.ts` - -**구현 완료** - -주요 메서드: - -- `getTempMaterials(params)`: 임시 자재 목록 조회 (검색, 카테고리 필터, 페이징) -- `getTempMaterialByCode(code)`: 특정 자재 상세 조회 -- `getCategories()`: 카테고리 목록 조회 - -### 3.4. MaterialController - -**경로**: `backend-node/src/controllers/MaterialController.ts` - -**구현 완료** - -엔드포인트: - -- `GET /api/materials/temp`: 임시 자재 마스터 목록 (검색, 필터링, 페이징) -- `GET /api/materials/temp/categories`: 카테고리 목록 -- `GET /api/materials/temp/:code`: 특정 자재 상세 - -**향후**: 외부 API 프록시로 변경 예정 - -### 3.5. Routes - -**경로**: - -- `backend-node/src/routes/yardLayoutRoutes.ts` -- `backend-node/src/routes/materialRoutes.ts` - -**구현 완료** - -`app.ts`에 등록: - -- `app.use("/api/yard-layouts", yardLayoutRoutes)` -- `app.use("/api/materials", materialRoutes)` - ---- - -## 4. 프론트엔드 컴포넌트 설계 - -### 4.1. YardManagement3DWidget (메인 위젯) - -**경로**: `frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx` - -**구현 완료** - -**주요 기능**: - -1. 레이아웃 선택/생성 모드와 3D 편집 모드 전환 -2. 편집 모드와 뷰 모드 구분 (isEditMode props) -3. API 연동 (yardLayoutApi) - -**상태 관리**: - -```typescript -- layouts: YardLayout[] // 전체 레이아웃 목록 -- selectedLayout: YardLayout | null // 선택된 레이아웃 -- isCreateModalOpen: boolean // 생성 모달 표시 여부 -- isLoading: boolean // 로딩 상태 -``` - -**하위 컴포넌트**: - -- `YardLayoutList`: 레이아웃 목록 표시 -- `YardLayoutCreateModal`: 새 레이아웃 생성 모달 -- `YardEditor`: 3D 편집 화면 - -### 4.2. API 클라이언트 - -**경로**: `frontend/lib/api/yardLayoutApi.ts` - -**구현 완료** - -**yardLayoutApi**: - -- `getAllLayouts()`: 모든 레이아웃 목록 -- `getLayoutById(id)`: 레이아웃 상세 -- `createLayout(data)`: 레이아웃 생성 -- `updateLayout(id, data)`: 레이아웃 수정 -- `deleteLayout(id)`: 레이아웃 삭제 -- `duplicateLayout(id, name)`: 레이아웃 복제 -- `getPlacementsByLayoutId(layoutId)`: 배치 목록 -- `addMaterialPlacement(layoutId, data)`: 배치 추가 -- `updatePlacement(placementId, data)`: 배치 수정 -- `removePlacement(placementId)`: 배치 삭제 -- `batchUpdatePlacements(layoutId, placements)`: 일괄 업데이트 - -**materialApi**: - -- `getTempMaterials(params)`: 임시 자재 목록 -- `getTempMaterialByCode(code)`: 자재 상세 -- `getCategories()`: 카테고리 목록 - -### 4.3. YardLayoutList (레이아웃 목록) - -**경로**: `frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutList.tsx` - -**구현 예정** - -- 테이블 형식으로 레이아웃 목록 표시 -- 검색 및 정렬 기능 -- 행 클릭 시 레이아웃 선택 (편집 모드 진입) -- 작업 메뉴 (편집, 복제, 삭제) - -### 4.4. YardLayoutCreateModal (레이아웃 생성 모달) - -**경로**: `frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx` - -**구현 예정** - -- 야드 이름, 설명 입력 -- Shadcn UI Dialog 사용 -- 생성 완료 시 자동으로 편집 모드 진입 - -### 4.5. YardEditor (3D 편집 화면) - -**경로**: `frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx` - -**구현 예정** - -**주요 구성**: - -- 상단 툴바 (뒤로가기, 저장, 자재 추가 등) -- 좌측: 3D 캔버스 -- 우측: 자재 정보 패널 (선택 시 표시) - -**기술 스택**: - -- React Three Fiber -- @react-three/drei (OrbitControls, Grid, Box) -- Three.js - -**주요 기능**: - -1. 야드 바닥 그리드 표시 -2. 자재 3D 박스 렌더링 -3. 자재 클릭 이벤트 처리 -4. 자재 드래그 앤 드롭 (위치 이동) -5. 카메라 컨트롤 (회전, 줌) - -### 4.6. MaterialInfoPanel (자재 정보 패널) - -**경로**: `frontend/components/admin/dashboard/widgets/yard-3d/MaterialInfoPanel.tsx` - -**구현 예정** - -**읽기 전용 정보** (외부 자재 데이터): - -- 자재 코드 -- 자재 이름 -- 수량 -- 단위 -- 카테고리 - -**수정 가능 정보** (배치 데이터): - -- 3D 위치 (X, Y, Z) -- 3D 크기 (너비, 높이, 깊이) -- 색상 -- 메모 - -**기능**: - -- 배치 정보 수정 -- 배치 해제 (야드에서 자재 제거) - -### 4.6. MaterialLibrary (자재 라이브러리) - -**경로**: `frontend/components/admin/dashboard/widgets/MaterialLibrary.tsx` - -- 사용 가능한 자재 목록 표시 -- 자재 검색 기능 -- 자재를 3D 캔버스로 드래그하여 배치 -- 자재 마스터 데이터 조회 (기존 테이블 활용 가능) - ---- - -## 5. UI/UX 설계 - -### 5.1. 초기 화면 (선택/생성 모드) - -야드 레이아웃 목록을 테이블 형식으로 표시 - -``` -┌──────────────────────────────────────────────────────────────────────────┐ -│ 야드 관리 3D [+ 새 야드 생성] │ -├──────────────────────────────────────────────────────────────────────────┤ -│ │ -│ [검색: ________________] [정렬: 최근순 ▼] │ -│ │ -│ ┌────────────────────────────────────────────────────────────────────┐ │ -│ │ 야드명 │ 설명 │ 배치 자재 │ 최종 수정 │ 작업 │ │ -│ ├────────────────────────────────────────────────────────────────────┤ │ -│ │ A구역 │ 메인 야드 A구역 │ 15개 │ 2025-01-15 14:30 │ ⋮ │ │ -│ │ │ │ │ │ │ │ -│ ├────────────────────────────────────────────────────────────────────┤ │ -│ │ B구역 │ 메인 야드 B구역 │ 8개 │ 2025-01-14 10:20 │ ⋮ │ │ -│ │ │ │ │ │ │ │ -│ ├────────────────────────────────────────────────────────────────────┤ │ -│ │ C구역 │ 보조 야드 C구역 │ 3개 │ 2025-01-10 09:15 │ ⋮ │ │ -│ │ │ │ │ │ │ │ -│ └────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ 총 3개 [1] 2 3 4 5 > │ -│ │ -└──────────────────────────────────────────────────────────────────────────┘ - -작업 메뉴 (⋮ 클릭 시): -┌─────────────┐ -│ 편집 │ -│ 복제 │ -│ 삭제 │ -└─────────────┘ -``` - -### 5.2. 레이아웃 생성 모달 - -새 야드 레이아웃을 생성할 때 표시되는 모달 - -``` -┌─────────────────────────────────────────────────┐ -│ 새 야드 레이아웃 생성 [X] │ -├─────────────────────────────────────────────────┤ -│ │ -│ 야드 이름 * │ -│ [____________________________________] │ -│ │ -│ 설명 │ -│ [____________________________________] │ -│ [____________________________________] │ -│ │ -│ [취소] [생성] │ -└─────────────────────────────────────────────────┘ -``` - -### 5.3. 편집 모드 - 전체 레이아웃 - -야드 편집 화면의 전체 구성 - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ A구역 [저장] [미리보기] [취소] │ -├───────────────────────────────────────┬─────────────────────────────────────┤ -│ │ 도구 패널 [최소화] │ -│ ├─────────────────────────────────────┤ -│ │ │ -│ │ 자재 라이브러리 │ -│ │ ───────────────────── │ -│ │ [검색: ____________] [카테고리 ▼] │ -│ │ │ -│ 3D 캔버스 │ ┌───────────────────────┐ │ -│ │ │ MAT-STEEL-001 │ │ -│ (야드 그리드 + 자재 배치) │ │ 철판 A타입 │ │ -│ │ │ 50 EA 재고 있음 │ │ -│ - 마우스 드래그: 카메라 회전 │ │ [배치] │ │ -│ - 휠: 줌 인/아웃 │ └───────────────────────┘ │ -│ - 자재 클릭: 선택 │ │ -│ - 자재 드래그: 이동 │ ┌───────────────────────┐ │ -│ │ │ MAT-STEEL-002 │ │ -│ │ │ 철판 B타입 │ │ -│ │ │ 30 EA 재고 있음 │ │ -│ │ │ [배치] │ │ -│ │ └───────────────────────┘ │ -│ │ │ -│ │ ┌───────────────────────┐ │ -│ │ │ MAT-PIPE-001 │ │ -│ │ │ 강관 파이프 │ │ -│ │ │ 100 EA 재고 있음 │ │ -│ │ │ [배치] │ │ -│ │ └───────────────────────┘ │ -│ │ │ -│ │ ... (스크롤 가능) │ -│ │ │ -├───────────────────────────────────────┴─────────────────────────────────────┤ -│ 자재 정보 │ -│ ───────────────── │ -│ 선택된 자재: MAT-STEEL-001 (철판 A타입) │ -│ │ -│ 기본 정보 (읽기 전용) │ -│ 자재 코드: MAT-STEEL-001 │ -│ 자재 이름: 철판 A타입 (1200x2400) │ -│ 수량: 50 EA │ -│ 카테고리: 철강 │ -│ │ -│ 배치 정보 (수정 가능) │ -│ 3D 위치 │ -│ X: [____10.00____] m Y: [____0.00____] m Z: [____10.00____] m │ -│ │ -│ 3D 크기 │ -│ 너비: [____8.00____] m 높이: [____4.00____] m 깊이: [____8.00____] m │ -│ │ -│ 외관 │ -│ 색상: [■ #ef4444] [색상 선택] │ -│ │ -│ 메모 │ -│ [_____________________________________________________________________] │ -│ │ -│ [배치 해제] [변경 적용] [초기화] │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 5.4. 3D 캔버스 상세 - -3D 캔버스 내부의 시각적 요소 - -``` -┌─────────────────────────────────────────────────┐ -│ 카메라 컨트롤 [리셋] │ -│ 회전: 45° | 기울기: 30° | 줌: 100% │ -├─────────────────────────────────────────────────┤ -│ Y (높이) │ -│ ↑ │ -│ │ │ -│ │ │ -│ │ ┌───────┐ (자재) │ -│ │ │ │ │ -│ Z (깊이) │ │ MAT-1 │ (선택됨) │ -│ ↗ │ │ │ │ -│ / └────┴───────┴──→ X (너비) │ -│ / │ -│ / ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ │ -│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ (그리드) │ -│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ -│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ -│ │ │ │ │ │ │□│ │ │ │ │ ← MAT-2 │ -│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ -│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ -│ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ │ -│ │ -│ 범례: │ -│ ■ 선택된 자재 □ 일반 자재 │ -│ ─ 그리드 라인 (5m 단위) │ -│ │ -│ 조작 가이드: │ -│ • 마우스 왼쪽 드래그: 카메라 회전 │ -│ • 마우스 휠: 줌 인/아웃 │ -│ • 자재 클릭: 선택 │ -│ • 선택된 자재 드래그: 위치 이동 │ -└─────────────────────────────────────────────────┘ -``` - -### 5.5. 자재 배치 플로우 - -자재를 배치하는 과정 - -``` -1. 자재 라이브러리에서 자재 선택 - ┌─────────────────┐ - │ MAT-STEEL-001 │ ← 클릭 - │ 철판 A타입 │ - │ [배치] │ - └─────────────────┘ - ↓ -2. 3D 캔버스에 자재가 임시로 표시됨 (투명) - ┌─────────────────┐ - │ 3D 캔버스 │ - │ │ - │ [반투명 박스] │ ← 마우스 커서 따라 이동 - │ │ - └─────────────────┘ - ↓ -3. 원하는 위치에 클릭하여 배치 - ┌─────────────────┐ - │ 3D 캔버스 │ - │ │ - │ [실제 박스] │ ← 배치 완료 - │ │ - └─────────────────┘ - ↓ -4. 자재 정보 패널에 자동으로 선택됨 - ┌─────────────────────────┐ - │ 자재 정보 패널 │ - │ ─────────────────── │ - │ 선택된 자재: │ - │ MAT-STEEL-001 │ - │ │ - │ 수량: 50 EA │ - │ 위치: X:10, Y:0, Z:10 │ - │ ... │ - └─────────────────────────┘ -``` - -### 5.6. 반응형 레이아웃 (모바일/태블릿) - -모바일에서는 패널을 접거나 탭으로 전환 - -``` -모바일 (세로 모드): -┌───────────────────────┐ -│ A구역 [저장] │ -├───────────────────────┤ -│ │ -│ 3D 캔버스 │ -│ (전체 화면) │ -│ │ -│ │ -├───────────────────────┤ -│ [자재 라이브러리] [정보]│ ← 탭 전환 -├───────────────────────┤ -│ 선택된 자재: │ -│ MAT-STEEL-001 │ -│ 수량: 50 EA │ -│ ... │ -└───────────────────────┘ - -태블릿 (가로 모드): -┌─────────────────────────────────────┐ -│ A구역 [저장] │ -├──────────────────┬──────────────────┤ -│ │ 자재 라이브러리 │ -│ 3D 캔버스 │ ────────────── │ -│ │ [검색: ______] │ -│ │ MAT-STEEL-001 │ -│ │ ... │ -├──────────────────┴──────────────────┤ -│ 선택된 자재: MAT-STEEL-001 │ -│ 수량: 50 EA | 위치: X:10, Z:10 │ -└─────────────────────────────────────┘ -``` - ---- - -## 6. 구현 단계 - -### Phase 1: 데이터베이스 및 백엔드 API ✅ **완료** - -1. ✅ 테이블 생성 스크립트 작성 (`create_yard_management_tables.sql`) -2. ✅ 마이그레이션 실행 -3. ✅ Service, Controller, Routes 구현 -4. ✅ API 클라이언트 구현 (yardLayoutApi, materialApi) - -### Phase 2: 메인 위젯 및 레이아웃 관리 🔄 **진행 중** - -1. ✅ types.ts에 위젯 타입 추가 -2. ✅ DashboardTopMenu에 위젯 추가 -3. ✅ DashboardDesigner에 위젯 타이틀/컨텐츠 추가 -4. ✅ YardManagement3DWidget 메인 컴포넌트 구현 -5. ⏳ YardLayoutList 컴포넌트 구현 -6. ⏳ YardLayoutCreateModal 컴포넌트 구현 - -### Phase 3: 3D 편집 화면 ⏳ **대기 중** - -1. ⏳ YardEditor 메인 컴포넌트 -2. ⏳ 상단 툴바 (뒤로가기, 저장, 자재 추가) -3. ⏳ 레이아웃 구성 (좌측 캔버스 + 우측 패널) - -### Phase 4: 3D 캔버스 기본 구조 ⏳ **대기 중** - -1. ⏳ Yard3DCanvas 컴포넌트 기본 구조 -2. ⏳ React Three Fiber 설정 -3. ⏳ 야드 바닥 그리드 렌더링 -4. ⏳ 카메라 컨트롤 (OrbitControls) -5. ⏳ 자재 3D 박스 렌더링 - -### Phase 5: 자재 배치 및 인터랙션 ⏳ **대기 중** - -1. ⏳ MaterialLibrary 컴포넌트 구현 -2. ⏳ 자재 선택 및 추가 -3. ⏳ 자재 드래그 앤 드롭 배치 -4. ⏳ 자재 클릭 선택 -5. ⏳ 자재 위치 이동 (드래그) - -### Phase 6: 자재 정보 패널 및 편집 ⏳ **대기 중** - -1. ⏳ MaterialInfoPanel 컴포넌트 구현 -2. ⏳ 자재 정보 표시 (읽기 전용 + 편집 가능) -3. ⏳ 자재 배치 정보 수정 -4. ⏳ 배치 해제 기능 -5. ⏳ 변경사항 저장 - -### Phase 7: 통합 및 최적화 ⏳ **대기 중** - -1. YardManagement3DWidget 통합 -2. 상태 관리 최적화 -3. 성능 최적화 (대량 자재 렌더링) -4. 에러 처리 및 로딩 상태 -5. 모바일/반응형 대응 (선택사항) - -### Phase 7: 대시보드 위젯 등록 - -1. types.ts에 위젯 타입 추가 -2. DashboardTopMenu에 위젯 추가 -3. CanvasElement에 위젯 렌더링 추가 -4. 위젯 설정 모달 (레이아웃 선택) - ---- - -## 7. 기술적 고려사항 - -### 7.1. 3D 렌더링 최적화 - -- 자재 수가 많을 경우 인스턴싱 사용 -- LOD (Level of Detail) 적용 고려 -- 카메라 거리에 따른 렌더링 최적화 - -### 7.2. 드래그 앤 드롭 - -- 3D 공간에서의 레이캐스팅 -- 그리드 스냅 기능 -- 충돌 감지 (자재 간 겹침 방지) - -### 7.3. 상태 관리 - -- 자재 위치 변경 시 실시간 업데이트 -- Debounce를 사용한 API 호출 최적화 -- 낙관적 업데이트 (Optimistic Update) - -### 7.4. 데이터 동기화 - -- 여러 사용자가 동시에 편집하는 경우 충돌 처리 -- WebSocket을 통한 실시간 동기화 (선택사항) - -### 7.5. UI/UX 규칙 - -#### 이모지 사용 금지 - -#### 모달 사용 규칙 - -**`window.alert`, `window.confirm` 사용 금지** - -모든 알림, 확인, 에러 메시지는 Shadcn UI 모달 컴포넌트 사용: - -- **일반 알림**: `Dialog` 컴포넌트 -- **확인 필요**: `AlertDialog` 컴포넌트 -- **삭제/해제 확인**: `AlertDialog` (Destructive 스타일) -- **성공 메시지**: `Dialog` 또는 `Toast` -- **에러 메시지**: `Dialog` (Error 스타일) - -**예시**: - -```typescript -// 잘못된 방법 ❌ -window.alert("저장되었습니다"); -if (window.confirm("삭제하시겠습니까?")) { ... } - -// 올바른 방법 ✅ - - - - 배치 해제 - - 이 자재를 야드에서 제거하시겠습니까? - - - - 취소 - 확인 - - - -``` - ---- - -## 8. API 명세서 - -### 8.1. 야드 레이아웃 API - -#### GET /api/yard-layouts - -**설명**: 모든 야드 레이아웃 목록 조회 - -**응답**: - -```json -{ - "success": true, - "data": [ - { - "id": 1, - "name": "A구역", - "description": "메인 야드 A구역", - "placement_count": 15, - "created_at": "2025-01-01T00:00:00Z" - } - ] -} -``` - -#### GET /api/yard-layouts/:id - -**설명**: 특정 야드 레이아웃 상세 조회 - -**응답**: - -```json -{ - "success": true, - "data": { - "id": 1, - "name": "A구역", - "description": "메인 야드 A구역", - "created_at": "2025-01-01T00:00:00Z" - } -} -``` - -#### POST /api/yard-layouts - -**설명**: 새 야드 레이아웃 생성 - -**요청**: - -```json -{ - "name": "D구역", - "description": "신규 야드" -} -``` - -#### PUT /api/yard-layouts/:id - -**설명**: 야드 레이아웃 수정 (이름, 설명만) - -**요청**: - -```json -{ - "name": "D구역 (수정)", - "description": "수정된 설명" -} -``` - -#### DELETE /api/yard-layouts/:id - -**설명**: 야드 레이아웃 삭제 - -### 8.2. 자재 배치 API - -#### GET /api/yard-layouts/:id/materials - -**설명**: 특정 야드의 모든 자재 조회 - -**응답**: - -```json -{ - "success": true, - "data": [ - { - "id": 1, - "material_code": "MAT-001", - "material_name": "철판 A타입", - "quantity": 50, - "unit": "EA", - "position_x": 10, - "position_y": 0, - "position_z": 10, - "size_x": 8, - "size_y": 4, - "size_z": 8, - "color": "#ef4444", - "status": "normal", - "memo": null - } - ] -} -``` - -#### POST /api/yard-layouts/:id/materials - -**설명**: 야드에 자재 추가 - -**요청**: - -```json -{ - "material_code": "MAT-005", - "material_name": "신규 자재", - "quantity": 10, - "unit": "EA", - "position_x": 20, - "position_y": 0, - "position_z": 20, - "size_x": 5, - "size_y": 5, - "size_z": 5, - "color": "#3b82f6" -} -``` - -#### PUT /api/yard-materials/:id - -**설명**: 자재 정보 수정 - -**요청**: - -```json -{ - "position_x": 25, - "position_z": 25, - "quantity": 55 -} -``` - -#### DELETE /api/yard-materials/:id - -**설명**: 자재 삭제 - -#### PUT /api/yard-layouts/:id/materials/batch - -**설명**: 여러 자재 일괄 업데이트 (드래그로 여러 자재 이동 시) - -**요청**: - -```json -{ - "materials": [ - { "id": 1, "position_x": 15, "position_z": 15 }, - { "id": 2, "position_x": 30, "position_z": 15 } - ] -} -``` - ---- - -## 9. 테스트 시나리오 - -### 9.1. 기본 기능 테스트 - -- [ ] 야드 레이아웃 목록 조회 -- [ ] 야드 레이아웃 생성 -- [ ] 야드 레이아웃 선택 -- [ ] 3D 캔버스 렌더링 -- [ ] 자재 목록 조회 및 표시 - -### 9.2. 자재 배치 테스트 - -- [ ] 자재 라이브러리에서 드래그하여 배치 -- [ ] 배치된 자재 클릭하여 선택 -- [ ] 선택된 자재 정보 패널 표시 -- [ ] 자재 드래그하여 위치 이동 -- [ ] 자재 정보 수정 (수량, 크기 등) -- [ ] 자재 삭제 - -### 9.3. 인터랙션 테스트 - -- [ ] 카메라 회전 (OrbitControls) -- [ ] 카메라 줌 인/아웃 -- [ ] 그리드 스냅 기능 -- [ ] 여러 자재 동시 이동 -- [ ] 자재 간 충돌 방지 - -### 9.4. 저장 및 로드 테스트 - -- [ ] 자재 배치 후 저장 -- [ ] 저장된 레이아웃 다시 로드 -- [ ] 레이아웃 삭제 -- [ ] 레이아웃 복제 (선택사항) - ---- - -## 10. 향후 확장 가능성 - -- 자재 검색 및 필터링 (상태별, 자재 코드별) -- 자재 배치 히스토리 (변경 이력) -- 자재 배치 템플릿 (자주 사용하는 배치 저장) -- 자재 입출고 연동 (실시간 재고 반영) -- 자재 경로 최적화 (피킹 경로 표시) -- AR/VR 지원 (모바일 AR로 실제 야드 확인) -- 다중 사용자 동시 편집 (WebSocket) -- 자재 배치 분석 (공간 활용률, 접근성 등) - ---- - -## 11. 파일 구조 - -``` -backend-node/src/ -├── services/ -│ └── YardLayoutService.ts (신규) -├── controllers/ -│ └── YardLayoutController.ts (신규) -├── routes/ -│ └── yardLayoutRoutes.ts (신규) -└── app.ts (수정) - -frontend/components/admin/dashboard/ -├── widgets/ -│ ├── YardManagement3DWidget.tsx (신규 - 메인) -│ ├── YardLayoutSelector.tsx (신규) -│ ├── YardLayoutCreator.tsx (신규) -│ ├── Yard3DCanvas.tsx (신규) -│ ├── MaterialInfoPanel.tsx (신규) -│ └── MaterialLibrary.tsx (신규) -├── types.ts (수정 - 위젯 타입 추가) -├── DashboardTopMenu.tsx (수정 - 메뉴 추가) -└── CanvasElement.tsx (수정 - 렌더링 추가) - -db/ -└── migrations/ - └── create_yard_tables.sql (신규) -``` - ---- - -## 12. 예상 개발 기간 - -- Phase 1 (DB & API): 1일 -- Phase 2 (선택/생성): 1일 -- Phase 3 (3D 기본): 1일 -- Phase 4 (배치 인터랙션): 2일 -- Phase 5 (정보 패널): 1일 -- Phase 6 (통합/최적화): 1일 -- Phase 7 (대시보드 등록): 0.5일 - -**총 예상 기간: 7.5일** - ---- - -## 13. 참고 자료 - -- React Three Fiber: https://docs.pmnd.rs/react-three-fiber -- @react-three/drei: https://github.com/pmndrs/drei -- Three.js: https://threejs.org/docs/ diff --git a/test_contract_registration.md b/test_contract_registration.md deleted file mode 100644 index 9ad83bde..00000000 --- a/test_contract_registration.md +++ /dev/null @@ -1,166 +0,0 @@ -# 영업관리 등록창 테스트 가이드 - -## 📋 테스트 개요 - -`docs/영업_계약_수정.md` 문서에 따라 구현된 새로운 영업관리 등록창의 데이터 저장 기능을 테스트합니다. - -## 🚀 테스트 환경 - -- **서버 URL**: http://localhost:8090 -- **테스트 계정**: plm_admin (패스워드는 관리자에게 문의) -- **테스트 페이지**: http://localhost:8090/contractMgmt/contracMgmtFormPopup.do - -## ✅ 구현 완료 사항 - -### 1. 백엔드 수정 완료 -- **ContractMgmtController.java**: 신규 공통코드 2개 추가 (통화단위, 계약방식) -- **ContractMgmtService.java**: CONTRACT_MGMT 테이블 사용하도록 변경, 25개 신규 필드 처리 -- **contractMgmt.xml**: saveContractMgmtInfo 쿼리에 25개 신규 필드 추가 - -### 2. 프론트엔드 수정 완료 -- **contracMgmtFormPopup.jsp**: 5개 섹션으로 재구성 - - 📋 [영업정보] - - 🔧 [사양상세] - - 📈 [영업진행] - - 💰 [견적이력 및 결과] - - 📝 [특이사항] - -### 3. 데이터베이스 준비 완료 -- **공통코드 데이터**: 6개 공통코드의 부모/하위 데이터 준비 완료 -- **테이블 구조**: CONTRACT_MGMT 테이블에 25개 신규 필드 확인 - -## 🧪 테스트 절차 - -### Step 1: 로그인 -1. http://localhost:8090 접속 -2. plm_admin 계정으로 로그인 - -### Step 2: 영업관리 화면 접근 -1. 메뉴에서 "영업관리" → "계약관리" 선택 -2. "등록" 버튼 클릭하여 등록창 열기 - -### Step 3: 테스트 데이터 입력 - -#### 📋 [영업정보] 섹션 -- **계약구분**: 개발 선택 -- **과거프로젝트번호**: PRJ-2024-001 -- **고객사**: 기존 고객사 선택 -- **제품군**: 기존 제품 선택 -- **장비명**: 테스트 압력용기 시스템 -- **설비대수**: 2 -- **요청납기일**: 2025-12-31 -- **입고지**: 서울특별시 강남구 -- **셋업지**: 경기도 성남시 - -#### 🔧 [사양상세] 섹션 -- **재질**: SUS304 -- **압력(BAR)**: 10.5 -- **온도(℃)**: 85 -- **용량(LITER)**: 1000 -- **Closure Type**: Bolted Cover -- **기타(소모품)**: 가스켓, 볼트 -- **전압**: 220V -- **인증여부**: KS 인증 완료 - -#### 📈 [영업진행] 섹션 -- **진행단계**: 견적제출 선택 - -#### 💰 [견적이력 및 결과] 섹션 -- **통화**: KRW 선택 -- **견적금액(1차)**: 50,000,000 -- **견적금액(2차)**: 48,000,000 -- **견적금액(3차)**: 45,000,000 -- **수주일**: 2025-08-15 -- **수주가**: 자동계산 확인 (90,000,000) -- **Result**: 수주 선택 -- **계약방식**: 조달 선택 -- **P/O No**: PO-2025-001 -- **PM**: 기존 사용자 선택 -- **당사프로젝트명**: 압력용기 개발 프로젝트 - -#### 📝 [특이사항] 섹션 -``` -고객 요구사항: 내압 테스트 필수 -납기일 엄수 요청 -품질 인증서 제출 필요 -``` - -### Step 4: 저장 테스트 -1. "저장" 버튼 클릭 -2. 성공 메시지 확인 -3. 저장된 데이터 목록에서 확인 - -## 🔍 검증 포인트 - -### 1. 화면 구성 검증 -- [ ] 5개 섹션이 올바르게 표시되는가? -- [ ] 공통코드 선택 옵션이 정상 로딩되는가? -- [ ] 자동계산 기능이 동작하는가? (수주가 = 최신견적금액 × 설비대수) - -### 2. 데이터 저장 검증 -- [ ] 25개 신규 필드가 모두 저장되는가? -- [ ] 기존 필드와 신규 필드가 함께 저장되는가? -- [ ] 저장 후 목록에서 데이터가 확인되는가? - -### 3. 오류 처리 검증 -- [ ] 필수 필드 누락 시 적절한 오류 메시지가 표시되는가? -- [ ] 잘못된 데이터 입력 시 검증이 동작하는가? - -## 🐛 알려진 이슈 - -### 1. 로그인 세션 필요 -- API 직접 호출 시 세션 인증이 필요함 -- 브라우저에서 로그인 후 테스트 권장 - -### 2. 공통코드 데이터 -- 신규 공통코드 2개(통화단위, 계약방식)가 아직 데이터베이스에 등록되지 않았을 수 있음 -- 필요시 `docs/insert_common_codes.sql` 실행 - -## 📊 테스트 결과 기록 - -### 성공 케이스 -```json -{ - "RESULT": { - "result": true, - "msg": "저장되었습니다." - } -} -``` - -### 실패 케이스 -```json -{ - "RESULT": { - "result": false, - "msg": "저장에 실패하였습니다." - } -} -``` - -## 🔧 문제 해결 - -### 1. 저장 실패 시 -1. 브라우저 개발자 도구에서 네트워크 탭 확인 -2. 서버 로그 확인: `docker-compose -f docker-compose.dev.yml logs plm-ilshin` -3. 데이터베이스 연결 상태 확인 - -### 2. 화면 오류 시 -1. 브라우저 콘솔에서 JavaScript 오류 확인 -2. CSS 파일 로딩 상태 확인 -3. JSP 컴파일 오류 확인 - -## 📞 지원 - -테스트 중 문제 발생 시: -1. 브라우저 개발자 도구 스크린샷 -2. 서버 로그 복사 -3. 입력한 테스트 데이터 기록 - -위 정보와 함께 문의하시기 바랍니다. - ---- - -**마지막 업데이트**: 2025-07-14 -**테스트 환경**: Docker 개발환경, PostgreSQL 데이터베이스 -**구현 완료도**: 95% (로그인 세션 테스트 제외) diff --git a/test_contract_registration_final.md b/test_contract_registration_final.md deleted file mode 100644 index 8f853c57..00000000 --- a/test_contract_registration_final.md +++ /dev/null @@ -1,207 +0,0 @@ -# 🎯 영업관리 등록창 최종 테스트 가이드 - -## 📋 현재 구현 상태 - -### ✅ **완료된 작업 (95%)** - -#### 1. **백엔드 수정 완료** - -- **ContractMgmtController.java**: 신규 공통코드 2개 추가 (통화단위, 계약방식) -- **ContractMgmtService.java**: CONTRACT_MGMT 테이블 사용하도록 변경, 25개 신규 필드 처리 -- **contractMgmt.xml**: saveContractMgmtInfo 쿼리에 25개 신규 필드 추가 - -#### 2. **프론트엔드 수정 완료** - -- **contracMgmtFormPopup.jsp**: 5개 섹션으로 완전 재구성 - - 📋 [영업정보]: 계약구분, 과거프로젝트번호, 국내/해외, 고객사, 제품군, 제품코드, 장비명, 설비대수, 요청납기일, 입고지, 셋업지 - - 🔧 [사양상세]: 재질, 압력(BAR), 온도(℃), 용량(LITER), Closure Type, 기타(소모품), 전압, 인증여부 - - 📈 [영업진행]: 진행단계 선택 - - 💰 [견적이력 및 결과]: 통화, 견적금액(1/2/3차), 수주일, 수주가(자동계산), Result, 계약방식, 실패사유, P/O No, PM, 당사프로젝트명 - - 📝 [특이사항]: 텍스트 영역 - -#### 3. **데이터베이스 준비 완료** - -- **공통코드 데이터**: 6개 공통코드의 부모/하위 데이터 완전 작성 -- **테이블 구조**: CONTRACT_MGMT 테이블에 25개 신규 필드 확인 - -### 🚫 **현재 문제점 (5%)** - -#### API 호출 시 세션 인증 문제 - -- **현상**: `{"RESULT":{"result":false,"msg":"저장에 실패하였습니다."}}` -- **원인**: PersonBean 세션 정보 없음으로 인한 NullPointerException -- **해결**: 브라우저에서 로그인 후 테스트 필요 - -## 🧪 **브라우저 테스트 방법** - -### Step 1: 서버 접근 - -``` -URL: http://localhost:8090 -상태: ✅ 정상 실행 중 -``` - -### Step 2: 로그인 - -``` -계정: plm_admin (또는 시스템 관리자에게 문의) -패스워드: 관리자에게 문의 -``` - -### Step 3: 영업관리 화면 접근 - -1. 메뉴에서 **"영업관리"** 클릭 -2. **"계약관리"** 하위 메뉴 클릭 -3. **"신규 등록"** 버튼 클릭 - -### Step 4: 등록창 테스트 - -URL: `http://localhost:8090/contractMgmt/contracMgmtFormPopup.do` - -#### 필수 입력 필드 테스트: - -``` -[영업정보] -- 계약구분: "개발" 선택 -- 장비명: "테스트 장비명" 입력 -- 설비대수: "1" 입력 - -[사양상세] -- 재질: "SUS316L" 입력 -- 압력(BAR): "10.5" 입력 - -[영업진행] -- 진행단계: "사양협의" 선택 - -[특이사항] -- 특이사항: "테스트용 영업관리 데이터입니다." 입력 -``` - -### Step 5: 저장 테스트 - -1. **"저장"** 버튼 클릭 -2. **성공 메시지** 확인: "저장되었습니다." -3. **리스트 화면**에서 저장된 데이터 확인 - -## 🔧 **자동계산 기능 테스트** - -### 수주가 자동계산 테스트: - -1. **견적금액(1차)**: "1000000" 입력 -2. **설비대수**: "2" 입력 -3. **수주가**: 자동으로 "2000000" 계산 확인 - -### 계산 공식: - -```javascript -수주가 = 최신 견적금액 × 설비대수 -``` - -## 📊 **예상 결과** - -### ✅ **성공 시나리오** - -```json -{ - "RESULT": { - "result": true, - "msg": "저장되었습니다." - } -} -``` - -### 🔍 **데이터 확인 방법** - -1. **리스트 화면**: 저장된 데이터가 목록에 표시 -2. **상세 화면**: 저장된 모든 필드값 확인 -3. **데이터베이스**: CONTRACT_MGMT 테이블에 레코드 생성 확인 - -## 🎯 **테스트 체크리스트** - -### 기본 기능 테스트: - -- [ ] 로그인 성공 -- [ ] 등록창 정상 로딩 (5개 섹션 표시) -- [ ] 공통코드 정상 로딩 (계약구분, 진행단계, 통화, 계약방식 등) -- [ ] 필수 필드 입력 -- [ ] 저장 버튼 클릭 -- [ ] 성공 메시지 확인 -- [ ] 리스트에서 데이터 확인 - -### 고급 기능 테스트: - -- [ ] 자동계산 기능 (수주가 = 견적금액 × 설비대수) -- [ ] 캘린더 기능 (요청납기일, 수주일) -- [ ] 파일 첨부 기능 (입수자료, 제출자료) -- [ ] 수정 기능 -- [ ] 삭제 기능 - -## 🚨 **문제 발생 시 대응** - -### 로그인 실패 시: - -``` -1. 계정 정보 확인 -2. 시스템 관리자에게 문의 -3. 데이터베이스 사용자 테이블 확인 -``` - -### 저장 실패 시: - -``` -1. 필수 필드 입력 확인 -2. 브라우저 개발자 도구 > 네트워크 탭에서 오류 확인 -3. 서버 로그 확인 -``` - -### 화면 로딩 실패 시: - -``` -1. 서버 상태 확인: http://localhost:8090 -2. 브라우저 캐시 클리어 -3. 다른 브라우저에서 테스트 -``` - -## 📈 **성능 확인 사항** - -### 응답 시간: - -- **등록창 로딩**: 2초 이내 -- **저장 처리**: 3초 이내 -- **리스트 조회**: 2초 이내 - -### 브라우저 호환성: - -- **Chrome**: ✅ 권장 -- **Firefox**: ✅ 지원 -- **Safari**: ✅ 지원 -- **IE**: ⚠️ 제한적 지원 - -## 🎉 **최종 결과 예상** - -### 성공 시: - -``` -✅ 영업관리 등록창 정상 동작 -✅ 25개 신규 필드 모두 저장 -✅ 자동계산 기능 정상 동작 -✅ 공통코드 정상 연동 -✅ 파일 첨부 기능 정상 동작 -``` - -### 완료도: **95%** - -**남은 5%는 실제 브라우저 테스트를 통한 최종 검증입니다.** - ---- - -## 📞 **지원 연락처** - -문제 발생 시 다음 정보와 함께 문의하세요: - -- 브라우저 종류 및 버전 -- 발생한 오류 메시지 -- 입력한 데이터 -- 스크린샷 (가능한 경우) - -**모든 백엔드 로직, 프론트엔드 화면, 데이터베이스 구조가 완성되어 실제 사용 가능한 상태입니다!** 🎯 diff --git a/values_logistream.yaml b/values_logistream.yaml new file mode 100644 index 00000000..870d4448 --- /dev/null +++ b/values_logistream.yaml @@ -0,0 +1,95 @@ +# Helm Values for logistream Project +# 이 파일을 https://gitlab.kpslp.kr/root/helm-charts 의 kpslp/ 디렉토리에 업로드해야 합니다. +# 파일명: kpslp/values_logistream.yaml + +replicaCount: 1 + +image: + repository: registry.kpslp.kr/slp/logistream + tag: latest # Jenkins가 자동으로 업데이트 + pullPolicy: IfNotPresent + +service: + type: ClusterIP + # 백엔드 포트 + backendPort: 8080 + # 프론트엔드 포트 (메인 서비스) + port: 3000 + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: logistream.kpslp.kr # 실제 도메인으로 변경 필요 + paths: + - path: / + pathType: Prefix + tls: + - secretName: logistream-tls + hosts: + - logistream.kpslp.kr # 실제 도메인으로 변경 필요 + +resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 500m + memory: 512Mi + +# 환경 변수 (필요시 추가) +env: + - name: NODE_ENV + value: production + - name: DATABASE_HOST + value: postgres-service.apps.svc.cluster.local # Kubernetes 내부 서비스명 + - name: DATABASE_PORT + value: "5432" + - name: DATABASE_NAME + value: logistream + # 민감 정보는 Kubernetes Secret으로 관리 (별도 설정 필요) + # - name: DATABASE_PASSWORD + # valueFrom: + # secretKeyRef: + # name: logistream-secrets + # key: db-password + +# PostgreSQL 설정 (필요시) +postgresql: + enabled: false # 외부 DB 사용 시 false + # enabled: true # 내장 PostgreSQL 사용 시 true + +# 헬스체크 설정 +livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 40 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +# PersistentVolume (파일 업로드 저장용) +persistence: + enabled: true + storageClass: nfs-client # NCP 환경에 맞게 수정 + accessMode: ReadWriteOnce + size: 10Gi + mountPath: /app/backend/uploads + +# 추가 설정 (필요시) +nodeSelector: {} +tolerations: [] +affinity: {} +