Merge remote-tracking branch 'upstream/main'

This commit is contained in:
dohyeons 2025-11-10 18:27:29 +09:00
commit 0613847c1f
84 changed files with 5686 additions and 18415 deletions

View File

@ -0,0 +1,281 @@
# AI-개발자 협업 작업 수칙
## 핵심 원칙: "추측 금지, 확인 필수"
AI는 코드 작성 전에 반드시 실제 상황을 확인해야 합니다.
---
## 1. 데이터베이스 관련 작업
### 필수 확인 사항
- ✅ **항상 MCP Postgres로 실제 테이블 구조를 먼저 확인**
- ✅ 컬럼명, 데이터 타입, 제약조건을 추측하지 말고 쿼리로 확인
- ✅ 변경 후 실제로 데이터가 어떻게 보이는지 SELECT로 검증
### 확인 방법
```sql
-- 테이블 구조 확인
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = '테이블명'
ORDER BY ordinal_position;
-- 실제 데이터 확인
SELECT * FROM 테이블명 LIMIT 5;
```
### 금지 사항
- ❌ "아마도 `created_at` 컬럼일 것입니다" → 확인 필수!
- ❌ "보통 이렇게 되어있습니다" → 이 프로젝트에서 확인!
- ❌ 다른 테이블 구조를 보고 추측 → 각 테이블마다 확인!
---
## 2. 코드 수정 작업
### 작업 전
1. **관련 파일 읽기**: 수정할 파일의 현재 상태 확인
2. **의존성 파악**: 다른 파일에 영향이 있는지 검색
3. **기존 패턴 확인**: 프로젝트의 코딩 스타일 준수
### 작업 중
1. **한 번에 하나씩**: 하나의 명확한 작업만 수행
2. **로그 추가**: 디버깅이 필요하면 명확한 로그 추가
3. **점진적 수정**: 큰 변경은 여러 단계로 나눔
### 작업 후
1. **로그 제거**: 디버깅 로그는 반드시 제거
2. **테스트 제안**: 브라우저로 테스트할 것을 제안
3. **변경사항 요약**: 무엇을 어떻게 바꿨는지 명확히 설명
---
## 3. 확인 및 검증
### 확인 도구 사용
- **MCP Postgres**: 데이터베이스 구조 및 데이터 확인
- **MCP Browser**: 실제 화면에서 동작 확인
- **codebase_search**: 관련 코드 패턴 검색
- **grep**: 특정 문자열 사용처 찾기
### 검증 프로세스
1. **변경 전 상태 확인** → 문제 파악
2. **변경 적용**
3. **변경 후 상태 확인** → 해결 검증
4. **부작용 확인** → 다른 기능에 영향 없는지
### 사용자 피드백 대응
- 사용자가 "확인 안하지?"라고 하면:
1. 즉시 사과
2. MCP/브라우저로 실제 확인
3. 정확한 정보를 바탕으로 재작업
---
## 4. 커뮤니케이션
### 작업 시작 시
```
✅ 좋은 예:
"MCP로 item_info 테이블 구조를 먼저 확인하겠습니다."
❌ 나쁜 예:
"보통 created_at 컬럼이 있을 것이므로 수정하겠습니다."
```
### 작업 완료 시
```
✅ 좋은 예:
"완료! 두 가지를 수정했습니다:
1. 기본 높이를 40px → 30px로 변경 (ScreenDesigner.tsx:2174)
2. 숨김 컬럼을 created_date, updated_date, writer, company_code로 수정 (TablesPanel.tsx:57)
테스트해보세요!"
❌ 나쁜 예:
"수정했습니다!"
```
### 불확실할 때
```
✅ 좋은 예:
"컬럼명이 created_at인지 created_date인지 확실하지 않습니다.
MCP로 확인해도 될까요?"
❌ 나쁜 예:
"created_at일 것 같으니 일단 이렇게 하겠습니다."
```
---
## 5. 금지 사항
### 절대 금지
1. ❌ **확인 없이 "완료했습니다" 말하기**
- 반드시 실제로 확인하고 보고
2. ❌ **이전에 실패한 방법 반복하기**
- 같은 실수를 두 번 하지 않기
3. ❌ **디버깅 로그를 남겨둔 채 작업 종료**
- 모든 console.log 제거 확인
4. ❌ **추측으로 답변하기**
- "아마도", "보통", "일반적으로" 금지
- 확실하지 않으면 먼저 확인
5. ❌ **여러 문제를 한 번에 수정하려고 시도**
- 한 번에 하나씩 해결
---
## 6. 프로젝트 특별 규칙
### 백엔드 관련
- 🔥 **백엔드 재시작 절대 금지** (사용자 명시 규칙)
- 🔥 Node.js 프로세스를 건드리지 않음
### 데이터베이스 관련
- 🔥 **멀티테넌시 규칙 준수**
- 모든 쿼리에 `company_code` 필터링 필수
- `company_code = "*"`는 최고 관리자 전용
- 자세한 내용: `.cursor/rules/multi-tenancy-guide.mdc`
### API 관련
- 🔥 **API 클라이언트 사용 필수**
- `fetch()` 직접 사용 금지
- `lib/api/` 의 클라이언트 함수 사용
- 환경별 URL 자동 처리
### UI 관련
- 🔥 **shadcn/ui 스타일 가이드 준수**
- CSS 변수 사용 (하드코딩 금지)
- 중첩 박스 금지 (명시 요청 전까지)
- 이모지 사용 금지 (명시 요청 전까지)
---
## 7. 에러 처리
### 에러 발생 시 프로세스
1. **에러 로그 전체 읽기**
- 스택 트레이스 확인
- 에러 메시지 정확히 파악
2. **근본 원인 파악**
- 증상이 아닌 원인 찾기
- 왜 이 에러가 발생했는지 이해
3. **해결책 적용**
- 임시방편이 아닌 근본적 해결
- 같은 에러가 재발하지 않도록
4. **검증**
- 실제로 에러가 해결되었는지 확인
- 다른 부작용은 없는지 확인
### 에러 로깅
```typescript
// ✅ 좋은 로그 (디버깅 시)
console.log("🔍 [컴포넌트명] 작업명:", {
관련변수1,
관련변수2,
예상결과,
});
// ❌ 나쁜 로그
console.log("here");
console.log(data); // 무슨 데이터인지 알 수 없음
```
---
## 8. 작업 완료 체크리스트
모든 작업 완료 전에 다음을 확인:
- [ ] 실제 데이터베이스/파일을 확인했는가?
- [ ] 변경사항이 의도대로 작동하는가?
- [ ] 디버깅 로그를 모두 제거했는가?
- [ ] 다른 기능에 부작용이 없는가?
- [ ] 멀티테넌시 규칙을 준수했는가?
- [ ] 사용자에게 명확히 설명했는가?
---
## 9. 모범 사례
### 데이터베이스 확인 예시
```typescript
// 1. MCP로 테이블 구조 확인
mcp_postgres_query: SELECT column_name FROM information_schema.columns
WHERE table_name = 'item_info';
// 2. 실제 컬럼명 확인 후 코드 작성
const hiddenColumns = new Set([
'id',
'created_date', // ✅ 실제 확인한 컬럼명
'updated_date', // ✅ 실제 확인한 컬럼명
'writer', // ✅ 실제 확인한 컬럼명
'company_code'
]);
```
### 브라우저 테스트 제안 예시
```
"수정이 완료되었습니다!
다음을 테스트해주세요:
1. 화면관리 > 테이블 탭 열기
2. item_info 테이블 확인
3. 기본 5개 컬럼(id, created_date 등)이 안 보이는지 확인
4. 새 컬럼 드래그앤드롭 시 높이가 30px인지 확인
브라우저 테스트를 원하시면 말씀해주세요!"
```
---
## 10. 요약: 핵심 3원칙
1. **확인 우선** 🔍
- 추측하지 말고, 항상 확인하고 작업
2. **한 번에 하나** 🎯
- 여러 문제를 동시에 해결하려 하지 말기
3. **철저한 마무리** ✨
- 로그 제거, 테스트, 명확한 설명
---
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**

View File

@ -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<string | null>(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
<div className="card-header">
<h3>{tableConfig.title || tableLabel}</h3>
<div className="search-controls">
{/* 검색바 */}
<Input placeholder="검색..." />
{/* 검색 컬럼 선택기 */}
<select>...</select>
{/* 새로고침 버튼 */}
<Button></Button>
</div>
</div>
```
#### 3.2 카드 그리드 영역
```jsx
<div
className="card-grid"
style={{
display: "grid",
gridTemplateColumns: `repeat(${cardsPerRow}, 1fr)`,
gap: `${cardSpacing}px`,
}}
>
{displayData.map((item, index) => (
<Card key={index}>{/* 카드 내용 렌더링 */}</Card>
))}
</div>
```
#### 3.3 페이지네이션 영역
```jsx
<div className="card-pagination">
<div>
전체 {totalItems}건 중 {startItem}-{endItem} 표시
</div>
<div>
<select>페이지 크기</select>
<Button>◀◀</Button>
<Button></Button>
<span>
{currentPage} / {totalPages}
</span>
<Button></Button>
<Button>▶▶</Button>
</div>
</div>
```
### Phase 4: 설정 패널 확장 ⚙️
#### 4.1 새 탭 추가
- **필터 탭**: 검색 및 필터 설정
- **페이지네이션 탭**: 페이지 관련 설정
- **정렬 탭**: 정렬 기본값 설정
#### 4.2 설정 옵션
```jsx
// 필터 탭
<TabsContent value="filter">
<Checkbox>필터 기능 사용</Checkbox>
<Checkbox>빠른 검색</Checkbox>
<Checkbox>검색 컬럼 선택기 표시</Checkbox>
<Checkbox>고급 필터</Checkbox>
</TabsContent>
// 페이지네이션 탭
<TabsContent value="pagination">
<Checkbox>페이지네이션 사용</Checkbox>
<Input label="페이지 크기" />
<Checkbox>페이지 크기 선택기 표시</Checkbox>
<Checkbox>페이지 정보 표시</Checkbox>
</TabsContent>
```
## 🛠️ 구현 우선순위
### 🟢 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일)
**✨ 품질**: 테이블 리스트 대비 동등하거나 우수한 기능 수준
### 🔥 주요 성과
이제 사용자들은 **테이블 리스트**와 **카드 디스플레이** 중에서 자유롭게 선택하여 동일한 데이터를 서로 다른 형태로 시각화할 수 있습니다. 두 컴포넌트 모두 완전히 동일한 고급 기능을 제공합니다!

304
DEPLOYMENT_GUIDE_KPSLP.md Normal file
View File

@ -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)
담당자에게 메트릭 수집 설정 요청

106
Dockerfile Normal file
View File

@ -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"]

View File

@ -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
**상태**: 완료 ✅

56
Jenkinsfile vendored Normal file
View File

@ -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
'''
}
}
}
}
}
}

View File

@ -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<LoginResult> {
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<void> {
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<PersonBean | null> {
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<PersonBean | null> {
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<void> {
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일
**담당자**: 백엔드 개발팀

View File

@ -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()` 단순 교체 작업이 주요 작업

View File

@ -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<TableRelationship>(
`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<TableRelationship>(
`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)
**상태**: ✅ **전환 완료** (테스트 필요)

View File

@ -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<Array<{ column_name; data_type }>>`...`;
await prisma.$queryRawUnsafe(upsertQuery, ...values);
// 전환 후
import { query } from "../database/db";
await query<Array<{ column_name: string; data_type: string }>>(`...`);
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()` 함수로 교체 완료

View File

@ -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)
**상태**: ✅ **전환 완료** (테스트 필요)

View File

@ -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<TableRelationship>(
`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)
**상태**: ✅ **전환 완료** (테스트 필요)
**특이사항**: 복잡한 비즈니스 로직이 포함되어 있어 신중한 테스트 필요

View File

@ -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<DDLLog[]>(
`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 실행의 특성상 신중한 테스트 필요

View File

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

View File

@ -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<any>(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<any>()` with dynamic WHERE clause
3. **`getDDLStatistics()`** - 통계 조회 (4개 쿼리)
- Before: 4x `prisma.$queryRawUnsafe`
- After: 4x `query<any>()`
- totalStats: 전체 실행 통계 (CASE WHEN 집계)
- ddlTypeStats: DDL 타입별 통계 (GROUP BY)
- userStats: 사용자별 통계 (GROUP BY, LIMIT 10)
- recentFailures: 최근 실패 로그 (WHERE success = false)
4. **`getTableDDLHistory()`** - 테이블별 DDL 히스토리
- Before: `prisma.$queryRawUnsafe`
- After: `query<any>()` 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 조건 포함

View File

@ -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<any>(
`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<any>(
`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<any>(
`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 호출 포함

View File

@ -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<any>(
`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<any>(`SELECT * FROM tables WHERE table_id = $1`, [sourceTableId]),
queryOne<any>(`SELECT * FROM tables WHERE table_id = $1`, [targetTableId]),
]);
if (!sourceTable || !targetTable) {
throw new Error("Invalid table references");
}
// 조인 생성
const join = await queryOne<any>(
`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<any>(
`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<boolean> {
// 재귀적으로 조인 관계 확인
// ...
}
```
### 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, 조인 유효성 검증, 순환 참조 방지 포함

View File

@ -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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<boolean> {
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 방지)
---
**상태**: ⏳ **대기 중**
**특이사항**: 보안 크리티컬, 비밀번호 암호화, 세션 관리 포함
**⚠️ 주의**: 이 서비스는 보안에 매우 중요하므로 신중한 테스트 필수!

View File

@ -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<any>(
`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<any>(
`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<any>(
`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<any>(
`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 연동, 스케줄링, 트랜잭션 처리 포함
**⚠️ 주의**: 배치 시스템의 핵심 기능이므로 신중한 테스트 필수!

View File

@ -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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<string, number>;
max?: Record<string, number>;
pattern?: Record<string, string>;
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 인젝션 방지가 매우 중요!

View File

@ -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로 전환이 완료되어 있어 추가 작업이 필요하지 않습니다.
**상태**: ✅ **완료**
**특이사항**: 캐싱 로직으로 성능에 중요한 서비스

View File

@ -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 실행 시 각별한 주의 필요

View File

@ -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<any>(
`SELECT * FROM layout_standards
${whereClause}
ORDER BY updated_date DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
[...values, size, skip]
);
```
### 예시 2: JSON 필드 처리 (레이아웃 생성)
```typescript
// 기존 Prisma
const layout = await prisma.layout_standards.create({
data: {
layout_code,
layout_name,
layout_config: safeJSONStringify(layout_config), // JSON 필드
sections: safeJSONStringify(sections), // JSON 필드
company_code: companyCode,
created_by: createdBy,
},
});
// 전환 후
const layout = await queryOne<any>(
`INSERT INTO layout_standards
(layout_code, layout_name, layout_type, category, layout_config, sections,
company_code, is_active, created_by, updated_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
RETURNING *`,
[
layout_code,
layout_name,
layout_type,
category,
safeJSONStringify(layout_config), // JSON 필드는 문자열로 변환
safeJSONStringify(sections),
companyCode,
"Y",
createdBy,
updatedBy,
]
);
```
### 예시 3: GROUP BY 통계 쿼리
```typescript
// 기존 Prisma
const counts = await prisma.layout_standards.groupBy({
by: ["category", "layout_type"],
where: { company_code: companyCode, is_active: "Y" },
_count: { id: true },
});
// 전환 후
const counts = await query<{
category: string;
layout_type: string;
count: string;
}>(
`SELECT category, layout_type, COUNT(*) as count
FROM layout_standards
WHERE company_code = $1 AND is_active = $2
GROUP BY category, layout_type`,
[companyCode, "Y"]
);
// 결과 포맷팅
const formattedCounts = counts.map((row) => ({
category: row.category,
layout_type: row.layout_type,
_count: { id: parseInt(row.count) },
}));
```
### 예시 4: DISTINCT 쿼리 (카테고리 목록)
```typescript
// 기존 Prisma
const existingCodes = await prisma.layout_standards.findMany({
where: { company_code: companyCode },
select: { category: true },
distinct: ["category"],
});
// 전환 후
const existingCodes = await query<{ category: string }>(
`SELECT DISTINCT category
FROM layout_standards
WHERE company_code = $1
ORDER BY category`,
[companyCode]
);
```
---
## ✅ 완료 기준
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [ ] **동적 WHERE 조건 생성 (ILIKE, OR)**
- [ ] **JSON 필드 처리 (layout_config, sections)**
- [ ] **GROUP BY 집계 쿼리 전환**
- [ ] **DISTINCT 쿼리 전환**
- [ ] **모든 TypeScript 컴파일 오류 해결**
- [ ] **`import prisma` 완전 제거**
- [ ] **모든 단위 테스트 통과 (10개)**
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
---
## 🔧 주요 기술적 과제
### 1. JSON 필드 처리
- `layout_config`, `sections` 필드는 JSON 타입
- INSERT/UPDATE 시 `JSON.stringify()` 또는 `safeJSONStringify()` 사용
- SELECT 시 PostgreSQL이 자동으로 JSON 객체로 반환
### 2. 동적 검색 조건
- category, layoutType, searchTerm에 따른 동적 WHERE 절
- OR 조건 처리 (layout_name OR layout_code)
### 3. Soft Delete
- `deleteLayout()`는 실제 삭제가 아닌 `is_active = 'N'` 업데이트
- UPDATE 쿼리 사용
### 4. 통계 쿼리
- `groupBy``GROUP BY` + `COUNT(*)` 전환
- 결과 포맷팅 필요 (`_count.id` 형태로 변환)
---
## 📋 체크리스트
### 코드 전환
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
- [ ] getLayouts() - count + findMany → query + queryOne
- [ ] getLayoutByCode() - findFirst → queryOne
- [ ] createLayout() - create → queryOne (INSERT)
- [ ] updateLayout() - findFirst + update → queryOne (동적 UPDATE)
- [ ] deleteLayout() - findFirst + update → queryOne (UPDATE is_active)
- [ ] getLayoutStatistics() - groupBy → query (GROUP BY)
- [ ] getLayoutCategories() - findMany + distinct → query (DISTINCT)
- [ ] JSON 필드 처리 확인 (safeJSONStringify)
- [ ] Prisma import 완전 제거
### 테스트
- [ ] 단위 테스트 작성 (10개)
- [ ] 통합 테스트 작성 (3개)
- [ ] TypeScript 컴파일 성공
- [ ] 성능 벤치마크 테스트
---
## 💡 특이사항
### JSON 필드 헬퍼 함수
이 서비스는 `safeJSONParse()`, `safeJSONStringify()` 헬퍼 함수를 사용하여 JSON 필드를 안전하게 처리합니다. Raw Query 전환 후에도 이 함수들을 계속 사용해야 합니다.
### Soft Delete 패턴
레이아웃 삭제는 실제 DELETE가 아닌 `is_active = 'N'` 업데이트로 처리되므로, UPDATE 쿼리를 사용해야 합니다.
### 통계 쿼리 결과 포맷
Prisma의 `groupBy``_count: { id: number }` 형태로 반환하지만, Raw Query는 `count: string`으로 반환하므로 포맷팅이 필요합니다.
---
**작성일**: 2025-10-01
**예상 소요 시간**: 1시간
**담당자**: 백엔드 개발팀
**우선순위**: 🟡 중간 (Phase 3.7)
**상태**: ⏳ **대기 중**
**특이사항**: JSON 필드 처리, GROUP BY, DISTINCT 쿼리 포함

View File

@ -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<DbTypeCategory>(
`SELECT * FROM db_type_categories
WHERE is_active = $1
ORDER BY sort_order ASC, display_name ASC`,
[true]
);
```
### 예시 2: 카테고리 생성 (중복 확인)
```typescript
// 기존 Prisma
const existing = await prisma.db_type_categories.findUnique({
where: { type_code: data.type_code }
});
if (existing) {
return {
success: false,
message: "이미 존재하는 타입 코드입니다."
};
}
const category = await prisma.db_type_categories.create({
data: {
type_code: data.type_code,
display_name: data.display_name,
icon: data.icon,
color: data.color,
sort_order: data.sort_order ?? 0,
is_active: true,
}
});
// 전환 후
import { query, queryOne } from "../database/db";
const existing = await queryOne<DbTypeCategory>(
`SELECT * FROM db_type_categories WHERE type_code = $1`,
[data.type_code]
);
if (existing) {
return {
success: false,
message: "이미 존재하는 타입 코드입니다."
};
}
const category = await queryOne<DbTypeCategory>(
`INSERT INTO db_type_categories
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
RETURNING *`,
[
data.type_code,
data.display_name,
data.icon || null,
data.color || null,
data.sort_order ?? 0,
true,
]
);
```
### 예시 3: 동적 UPDATE (변경된 필드만)
```typescript
// 기존 Prisma
const updateData: any = {};
if (data.display_name !== undefined) updateData.display_name = data.display_name;
if (data.icon !== undefined) updateData.icon = data.icon;
if (data.color !== undefined) updateData.color = data.color;
if (data.sort_order !== undefined) updateData.sort_order = data.sort_order;
if (data.is_active !== undefined) updateData.is_active = data.is_active;
const category = await prisma.db_type_categories.update({
where: { type_code: typeCode },
data: updateData
});
// 전환 후
const updateFields: string[] = ["updated_at = NOW()"];
const values: any[] = [];
let paramIndex = 1;
if (data.display_name !== undefined) {
updateFields.push(`display_name = $${paramIndex++}`);
values.push(data.display_name);
}
if (data.icon !== undefined) {
updateFields.push(`icon = $${paramIndex++}`);
values.push(data.icon);
}
if (data.color !== undefined) {
updateFields.push(`color = $${paramIndex++}`);
values.push(data.color);
}
if (data.sort_order !== undefined) {
updateFields.push(`sort_order = $${paramIndex++}`);
values.push(data.sort_order);
}
if (data.is_active !== undefined) {
updateFields.push(`is_active = $${paramIndex++}`);
values.push(data.is_active);
}
const category = await queryOne<DbTypeCategory>(
`UPDATE db_type_categories
SET ${updateFields.join(", ")}
WHERE type_code = $${paramIndex}
RETURNING *`,
[...values, typeCode]
);
```
### 예시 4: 삭제 전 연결 확인
```typescript
// 기존 Prisma
const connectionsCount = await prisma.external_db_connections.count({
where: { db_type: typeCode }
});
if (connectionsCount > 0) {
return {
success: false,
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
};
}
await prisma.db_type_categories.update({
where: { type_code: typeCode },
data: { is_active: false }
});
// 전환 후
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM external_db_connections WHERE db_type = $1`,
[typeCode]
);
const connectionsCount = parseInt(countResult?.count || "0");
if (connectionsCount > 0) {
return {
success: false,
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
};
}
await query(
`UPDATE db_type_categories SET is_active = $1, updated_at = NOW() WHERE type_code = $2`,
[false, typeCode]
);
```
### 예시 5: GROUP BY 통계 + JOIN
```typescript
// 기존 Prisma
const stats = await prisma.external_db_connections.groupBy({
by: ['db_type'],
_count: { id: true }
});
const categories = await prisma.db_type_categories.findMany({
where: { is_active: true }
});
// 전환 후
const stats = await query<{
type_code: string;
display_name: string;
connection_count: string;
}>(
`SELECT
c.type_code,
c.display_name,
COUNT(e.id) as connection_count
FROM db_type_categories c
LEFT JOIN external_db_connections e ON c.type_code = e.db_type
WHERE c.is_active = $1
GROUP BY c.type_code, c.display_name
ORDER BY c.sort_order ASC`,
[true]
);
// 결과 포맷팅
const result = stats.map(row => ({
type_code: row.type_code,
display_name: row.display_name,
connection_count: parseInt(row.connection_count),
}));
```
### 예시 6: UPSERT (ON CONFLICT)
```typescript
// 기존 Prisma
await prisma.db_type_categories.upsert({
where: { type_code: category.type_code },
update: {
display_name: category.display_name,
icon: category.icon,
color: category.color,
sort_order: category.sort_order,
},
create: {
type_code: category.type_code,
display_name: category.display_name,
icon: category.icon,
color: category.color,
sort_order: category.sort_order,
is_active: true,
},
});
// 전환 후
await query(
`INSERT INTO db_type_categories
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (type_code)
DO UPDATE SET
display_name = EXCLUDED.display_name,
icon = EXCLUDED.icon,
color = EXCLUDED.color,
sort_order = EXCLUDED.sort_order,
updated_at = NOW()`,
[
category.type_code,
category.display_name,
category.icon || null,
category.color || null,
category.sort_order || 0,
true,
]
);
```
---
## ✅ 완료 기준
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [ ] **동적 UPDATE 쿼리 생성**
- [ ] **GROUP BY + LEFT JOIN 통계 쿼리**
- [ ] **ON CONFLICT를 사용한 UPSERT**
- [ ] **ApiResponse 래퍼 패턴 유지**
- [ ] **모든 TypeScript 컴파일 오류 해결**
- [ ] **`import prisma` 완전 제거**
- [ ] **모든 단위 테스트 통과 (10개)**
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
---
## 🔧 주요 기술적 과제
### 1. ApiResponse 래퍼 패턴
모든 함수가 `ApiResponse<T>` 타입을 반환하므로, 에러 처리를 try-catch로 감싸고 일관된 응답 형식을 유지해야 합니다.
### 2. Soft Delete 패턴
`deleteCategory()`는 실제 DELETE가 아닌 `is_active = false` 업데이트로 처리됩니다.
### 3. 연결 확인
카테고리 삭제 전 `external_db_connections` 테이블에서 사용 중인지 확인해야 합니다.
### 4. UPSERT 로직
PostgreSQL의 `ON CONFLICT` 절을 사용하여 Prisma의 `upsert` 기능을 구현합니다.
### 5. 통계 쿼리 최적화
`groupBy` + 별도 조회 대신, 하나의 `LEFT JOIN` + `GROUP BY` 쿼리로 최적화 가능합니다.
---
## 📋 체크리스트
### 코드 전환
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
- [ ] getAllCategories() - findMany → query
- [ ] getCategoryByTypeCode() - findUnique → queryOne
- [ ] createCategory() - findUnique + create → queryOne (중복 확인 + INSERT)
- [ ] updateCategory() - update → queryOne (동적 UPDATE)
- [ ] deleteCategory() - count + update → queryOne + query
- [ ] getCategoryStatistics() - groupBy + findMany → query (LEFT JOIN)
- [ ] syncPredefinedCategories() - upsert → query (ON CONFLICT)
- [ ] ApiResponse 래퍼 유지
- [ ] Prisma import 완전 제거
### 테스트
- [ ] 단위 테스트 작성 (10개)
- [ ] 통합 테스트 작성 (3개)
- [ ] TypeScript 컴파일 성공
- [ ] 성능 벤치마크 테스트
---
## 💡 특이사항
### ApiResponse 패턴
이 서비스는 모든 메서드가 `ApiResponse<T>` 형식으로 응답을 반환합니다. Raw Query 전환 후에도 이 패턴을 유지해야 합니다.
### 사전 정의 카테고리
`syncPredefinedCategories()` 메서드는 시스템 초기화 시 사전 정의된 DB 타입 카테고리를 동기화합니다. UPSERT 로직이 필수입니다.
### 외래 키 확인
카테고리 삭제 시 `external_db_connections` 테이블에서 사용 중인지 확인하여 데이터 무결성을 보장합니다.
---
**작성일**: 2025-10-01
**예상 소요 시간**: 1시간
**담당자**: 백엔드 개발팀
**우선순위**: 🟡 중간 (Phase 3.8)
**상태**: ⏳ **대기 중**
**특이사항**: ApiResponse 래퍼, UPSERT, GROUP BY + LEFT JOIN 포함

View File

@ -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<any>(
`SELECT * FROM template_standards
WHERE template_code = $1 AND company_code = $2`,
[templateCode, companyCode]
);
```
### 예시 2: 중복 확인 후 생성
```typescript
// 기존 Prisma
const existing = await prisma.template_standards.findUnique({
where: {
template_code: data.template_code,
company_code: data.company_code,
},
});
if (existing) {
throw new Error("이미 존재하는 템플릿 코드입니다.");
}
return await prisma.template_standards.create({
data: {
...data,
created_date: new Date(),
updated_date: new Date(),
},
});
// 전환 후
const existing = await queryOne<any>(
`SELECT * FROM template_standards
WHERE template_code = $1 AND company_code = $2`,
[data.template_code, data.company_code]
);
if (existing) {
throw new Error("이미 존재하는 템플릿 코드입니다.");
}
return await queryOne<any>(
`INSERT INTO template_standards
(template_code, template_name, category, template_type, layout_config,
description, is_active, company_code, created_by, updated_by,
created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
RETURNING *`,
[
data.template_code,
data.template_name,
data.category,
data.template_type,
JSON.stringify(data.layout_config),
data.description,
data.is_active,
data.company_code,
data.created_by,
data.updated_by,
]
);
```
### 예시 3: 복합 키 UPDATE
```typescript
// 기존 Prisma
return await prisma.template_standards.update({
where: {
template_code_company_code: {
template_code: templateCode,
company_code: companyCode,
},
},
data: {
...data,
updated_date: new Date(),
},
});
// 전환 후
// 동적 UPDATE 쿼리 생성
const updateFields: string[] = ["updated_date = NOW()"];
const values: any[] = [];
let paramIndex = 1;
if (data.template_name !== undefined) {
updateFields.push(`template_name = $${paramIndex++}`);
values.push(data.template_name);
}
if (data.category !== undefined) {
updateFields.push(`category = $${paramIndex++}`);
values.push(data.category);
}
if (data.template_type !== undefined) {
updateFields.push(`template_type = $${paramIndex++}`);
values.push(data.template_type);
}
if (data.layout_config !== undefined) {
updateFields.push(`layout_config = $${paramIndex++}`);
values.push(JSON.stringify(data.layout_config));
}
if (data.description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(data.description);
}
if (data.is_active !== undefined) {
updateFields.push(`is_active = $${paramIndex++}`);
values.push(data.is_active);
}
if (data.updated_by !== undefined) {
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(data.updated_by);
}
return await queryOne<any>(
`UPDATE template_standards
SET ${updateFields.join(", ")}
WHERE template_code = $${paramIndex++} AND company_code = $${paramIndex}
RETURNING *`,
[...values, templateCode, companyCode]
);
```
### 예시 4: 복합 키 DELETE
```typescript
// 기존 Prisma
await prisma.template_standards.delete({
where: {
template_code_company_code: {
template_code: templateCode,
company_code: companyCode,
},
},
});
// 전환 후
import { query } from "../database/db";
await query(
`DELETE FROM template_standards
WHERE template_code = $1 AND company_code = $2`,
[templateCode, companyCode]
);
```
### 예시 5: DISTINCT 쿼리
```typescript
// 기존 Prisma
const categories = await prisma.template_standards.findMany({
where: {
company_code: companyCode,
},
select: {
category: true,
},
distinct: ["category"],
});
return categories
.map((c) => c.category)
.filter((c): c is string => c !== null && c !== undefined)
.sort();
// 전환 후
const categories = await query<{ category: string }>(
`SELECT DISTINCT category
FROM template_standards
WHERE company_code = $1 AND category IS NOT NULL
ORDER BY category ASC`,
[companyCode]
);
return categories.map((c) => c.category);
```
---
## ✅ 완료 기준
- [ ] **6개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [ ] **복합 기본 키 처리 (template_code + company_code)**
- [ ] **동적 UPDATE 쿼리 생성**
- [ ] **DISTINCT 쿼리 전환**
- [ ] **JSON 필드 처리 (layout_config)**
- [ ] **모든 TypeScript 컴파일 오류 해결**
- [ ] **`import prisma` 완전 제거**
- [ ] **모든 단위 테스트 통과 (6개)**
- [ ] **통합 테스트 작성 완료 (2개 시나리오)**
---
## 🔧 주요 기술적 과제
### 1. 복합 기본 키
`template_standards` 테이블은 `(template_code, company_code)` 복합 기본 키를 사용합니다.
- WHERE 절에서 두 컬럼 모두 지정 필요
- Prisma의 `template_code_company_code` 표현식을 `template_code = $1 AND company_code = $2`로 변환
### 2. JSON 필드
`layout_config` 필드는 JSON 타입으로, INSERT/UPDATE 시 `JSON.stringify()` 필요합니다.
### 3. DISTINCT + NULL 제외
카테고리 목록 조회 시 `DISTINCT` 사용하며, NULL 값은 `WHERE category IS NOT NULL`로 제외합니다.
---
## 📋 체크리스트
### 코드 전환
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
- [ ] getTemplateByCode() - findUnique → queryOne (복합 키)
- [ ] createTemplate() - findUnique + create → queryOne (중복 확인 + INSERT)
- [ ] updateTemplate() - update → queryOne (동적 UPDATE, 복합 키)
- [ ] deleteTemplate() - delete → query (복합 키)
- [ ] getTemplateCategories() - findMany + distinct → query (DISTINCT)
- [ ] JSON 필드 처리 (layout_config)
- [ ] Prisma import 완전 제거
### 테스트
- [ ] 단위 테스트 작성 (6개)
- [ ] 통합 테스트 작성 (2개)
- [ ] TypeScript 컴파일 성공
- [ ] 성능 벤치마크 테스트
---
## 💡 특이사항
### 복합 기본 키 패턴
이 서비스는 `(template_code, company_code)` 복합 기본 키를 사용하므로, 모든 조회/수정/삭제 작업에서 두 컬럼을 모두 WHERE 조건에 포함해야 합니다.
### JSON 레이아웃 설정
`layout_config` 필드는 템플릿의 레이아웃 설정을 JSON 형태로 저장합니다. Raw Query 전환 시 `JSON.stringify()`를 사용하여 문자열로 변환해야 합니다.
### 카테고리 관리
템플릿은 카테고리별로 분류되며, `getTemplateCategories()` 메서드로 고유한 카테고리 목록을 조회할 수 있습니다.
---
**작성일**: 2025-10-01
**예상 소요 시간**: 45분
**담당자**: 백엔드 개발팀
**우선순위**: 🟢 낮음 (Phase 3.9)
**상태**: ⏳ **대기 중**
**특이사항**: 복합 기본 키, JSON 필드, DISTINCT 쿼리 포함

View File

@ -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<T>`
- findUnique/findFirst → `queryOne<T>`
### 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<UserInfo>(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<UserInfo>(
`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<UserInfo>(
`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<any>(
`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줄)

View File

@ -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<UserInfo>(
`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<UserInfo>(
`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<UserInfo>(
`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<UserInfo>(
`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 상태 코드 반환
- 사용자 친화적 에러 메시지

View File

@ -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<WebTypeStandard>` with dynamic WHERE
- findUnique → `queryOne<WebTypeStandard>`
- 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<AttachFileInfo>`
---
### 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<any>` (이미 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<T>(
`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<T>(`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<T>(
`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<T>(
`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<T>(
`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<WebTypeStandard>`, `queryOne<AttachFileInfo>`
---
## 📝 완료 후 작업
- [ ] 전체 컴파일 확인
- [ ] Linter 오류 해결
- [ ] 통합 테스트 실행
- [ ] Prisma 관련 의존성 완전 제거 (package.json)
- [ ] `prisma/` 디렉토리 정리
- [ ] 문서 업데이트
- [ ] 커밋 및 Push
---
**작성일**: 2025-10-01
**최종 업데이트**: 2025-10-01
**상태**: 🔄 진행 중 (58.6% 완료)

View File

@ -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<string, string>;
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<string, string>;
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<ExternalRestApiConnection>
);
static async deleteConnection(id: number);
// 테스트 메서드
static async testConnection(
testRequest: RestApiTestRequest
): Promise<RestApiTestResult>;
static async testConnectionById(
id: number,
endpoint?: string
): Promise<RestApiTestResult>;
// 헬퍼 메서드
private static buildHeaders(
connection: ExternalRestApiConnection
): Record<string, string>;
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<RestApiTestResult> {
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<ConnectionTabType>("database");
```
### 2. 메인 페이지 구조 개선
```tsx
// 탭 헤더
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as ConnectionTabType)}
>
<TabsList className="grid w-[400px] grid-cols-2">
<TabsTrigger value="database" className="flex items-center gap-2">
<Database className="h-4 w-4" />
데이터베이스 연결
</TabsTrigger>
<TabsTrigger value="rest-api" className="flex items-center gap-2">
<Globe className="h-4 w-4" />
REST API 연결
</TabsTrigger>
</TabsList>
{/* 데이터베이스 연결 탭 */}
<TabsContent value="database">
<DatabaseConnectionList />
</TabsContent>
{/* REST API 연결 탭 */}
<TabsContent value="rest-api">
<RestApiConnectionList />
</TabsContent>
</Tabs>
```
### 3. REST API 연결 목록 컴포넌트
```typescript
// frontend/components/admin/RestApiConnectionList.tsx
export function RestApiConnectionList() {
const [connections, setConnections] = useState<ExternalRestApiConnection[]>(
[]
);
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<string, string>;
onChange: (headers: Record<string, string>) => 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<string, string>);
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<ExternalRestApiConnection>
) {
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<RestApiTestResult> {
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<RestApiTestResult> {
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

View File

@ -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`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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<Breakpoint, BreakpointConfig> = {
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<Breakpoint>("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<ResponsiveLayoutEngineProps> = ({
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 (
<div
className="responsive-grid w-full"
style={{
display: "grid",
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
gap: "16px",
padding: "16px",
}}
>
{visibleComponents.map((comp) => (
<div
key={comp.id}
className="responsive-grid-item"
style={{
gridColumn: `span ${
comp.responsiveDisplay?.gridColumns || gridColumns
}`,
}}
>
<DynamicComponentRenderer component={comp} isPreview={true} />
</div>
))}
</div>
);
};
```
### 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<LayoutData | null>(null);
const breakpoint = useBreakpoint();
// 반응형 모드 토글 (사용자 설정 또는 화면 설정에 따라)
const [useResponsive, setUseResponsive] = useState(true);
// 기존 로직 유지...
if (!layout) {
return <div>로딩 중...</div>;
}
const screenWidth = layout.screenResolution?.width || 1920;
const screenHeight = layout.screenResolution?.height || 1080;
return (
<div className="h-full w-full bg-white">
{useResponsive ? (
// 반응형 모드
<ResponsiveLayoutEngine
components={layout.components || []}
breakpoint={breakpoint}
containerWidth={window.innerWidth}
screenWidth={screenWidth}
/>
) : (
// 기존 스케일 모드 (하위 호환성)
<div className="overflow-auto" style={{ padding: "16px 0" }}>
<div
style={{
width: `${screenWidth * scale}px`,
minHeight: `${screenHeight * scale}px`,
marginLeft: "16px",
marginRight: "16px",
}}
>
<div
className="relative bg-white"
style={{
width: `${screenWidth}px`,
minHeight: `${screenHeight}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
{layout.components?.map((component) => (
<div
key={component.id}
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width:
component.style?.width || `${component.size.width}px`,
minHeight:
component.style?.height || `${component.size.height}px`,
zIndex: component.position.z || 1,
}}
>
<DynamicComponentRenderer
component={component}
isPreview={true}
/>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
```
---
## 🎨 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<ResponsiveConfigPanelProps> = ({
component,
onUpdate,
}) => {
const [activeTab, setActiveTab] = useState<Breakpoint>("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 (
<Card>
<CardHeader>
<CardTitle>반응형 설정</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 스마트 기본값 토글 */}
<div className="flex items-center space-x-2">
<Checkbox
id="smartDefaults"
checked={config.useSmartDefaults}
onCheckedChange={(checked) => {
onUpdate({
...config,
useSmartDefaults: checked as boolean,
});
}}
/>
<Label htmlFor="smartDefaults">스마트 기본값 사용 (권장)</Label>
</div>
{/* 수동 설정 */}
{!config.useSmartDefaults && (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as Breakpoint)}
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="desktop">데스크톱</TabsTrigger>
<TabsTrigger value="tablet">태블릿</TabsTrigger>
<TabsTrigger value="mobile">모바일</TabsTrigger>
</TabsList>
<TabsContent value={activeTab} className="space-y-4">
{/* 그리드 컬럼 수 */}
<div className="space-y-2">
<Label>너비 (그리드 컬럼)</Label>
<Select
value={config.responsive?.[
activeTab
]?.gridColumns?.toString()}
onValueChange={(v) => {
onUpdate({
...config,
responsive: {
...config.responsive,
[activeTab]: {
...config.responsive?.[activeTab],
gridColumns: parseInt(v),
},
},
});
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="컬럼 수 선택" />
</SelectTrigger>
<SelectContent>
{[...Array(BREAKPOINTS[activeTab].columns)].map((_, i) => {
const cols = i + 1;
const percent = (
(cols / BREAKPOINTS[activeTab].columns) *
100
).toFixed(0);
return (
<SelectItem key={cols} value={cols.toString()}>
{cols} / {BREAKPOINTS[activeTab].columns} ({percent}%)
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* 표시 순서 */}
<div className="space-y-2">
<Label>표시 순서</Label>
<Input
type="number"
min="1"
value={config.responsive?.[activeTab]?.order || 1}
onChange={(e) => {
onUpdate({
...config,
responsive: {
...config.responsive,
[activeTab]: {
...config.responsive?.[activeTab],
order: parseInt(e.target.value),
},
},
});
}}
/>
</div>
{/* 숨김 */}
<div className="flex items-center space-x-2">
<Checkbox
id={`hide-${activeTab}`}
checked={config.responsive?.[activeTab]?.hide || false}
onCheckedChange={(checked) => {
onUpdate({
...config,
responsive: {
...config.responsive,
[activeTab]: {
...config.responsive?.[activeTab],
hide: checked as boolean,
},
},
});
}}
/>
<Label htmlFor={`hide-${activeTab}`}>
{activeTab === "desktop"
? "데스크톱"
: activeTab === "tablet"
? "태블릿"
: "모바일"}
에서 숨김
</Label>
</div>
</TabsContent>
</Tabs>
)}
</CardContent>
</Card>
);
};
```
### 2.2 속성 패널 통합 (1시간)
#### 파일: `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` 수정
```typescript
// 기존 import에 추가
import { ResponsiveConfigPanel } from './ResponsiveConfigPanel';
// 컴포넌트 내부에 추가
return (
<div className="space-y-4">
{/* 기존 패널들 */}
<PropertiesPanel ... />
<StyleEditor ... />
{/* 반응형 설정 패널 추가 */}
<ResponsiveConfigPanel
component={selectedComponent}
onUpdate={(config) => {
onUpdateComponent({
...selectedComponent,
responsiveConfig: config
});
}}
/>
{/* 기존 세부 설정 패널 */}
<DetailSettingsPanel ... />
</div>
);
```
### 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 (
<div className="h-full flex flex-col">
{/* 상단 툴바 */}
<div className="flex gap-2 p-2 border-b bg-white">
<Button
variant={previewMode === 'design' ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewMode('design')}
>
디자인 모드
</Button>
<Button
variant={previewMode === 'desktop' ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewMode('desktop')}
>
데스크톱 미리보기
</Button>
<Button
variant={previewMode === 'tablet' ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewMode('tablet')}
>
태블릿 미리보기
</Button>
<Button
variant={previewMode === 'mobile' ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewMode('mobile')}
>
모바일 미리보기
</Button>
</div>
{/* 캔버스 영역 */}
<div className="flex-1 overflow-auto">
{previewMode === 'design' ? (
// 기존 절대 위치 기반 디자이너
<Canvas ... />
) : (
// 반응형 미리보기
<div
className="mx-auto border border-gray-300"
style={{
width: previewMode === 'desktop' ? '100%' :
previewMode === 'tablet' ? '768px' :
'375px',
minHeight: '100%'
}}
>
<ResponsiveLayoutEngine
components={components}
breakpoint={previewMode}
containerWidth={
previewMode === 'desktop' ? window.innerWidth :
previewMode === 'tablet' ? 768 :
375
}
screenWidth={selectedScreen?.screenResolution?.width || 1920}
/>
</div>
)}
</div>
</div>
);
};
```
---
## 💾 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<ResponsiveLayoutEngineProps>(({...}) => {
// ...
});
```
### 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부터 순차적으로 구현을 시작합니다.

File diff suppressed because it is too large Load Diff

View File

@ -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: <Database />,
},
{
id: "external_call",
label: "외부 호출",
description: "API/Webhook 호출",
icon: <Globe />,
},
];
```
### 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<FieldMapping>) => void;
deleteMapping: (mappingId: string) => void;
// 검증 및 저장
validateMappings: () => Promise<ValidationResult>;
saveMappings: () => Promise<void>;
testExecution: () => Promise<TestResult>;
}
```
## 🎯 사용자 경험 (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부터 순차적으로 구현**을 시작하겠습니다.
**첫 번째 작업**: 좌우 분할 레이아웃과 연결 타입 선택 컴포넌트 구현
구현을 시작하시겠어요? 🚀

View File

@ -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 <DB_CONTAINER_NAME> 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` - 테이블 생성 스크립트
## 🎉 완료!
작업 이력 관리 시스템이 성공적으로 설치되었습니다!

View File

@ -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<string, string>;
queryParams?: Record<string, string>;
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일**

File diff suppressed because it is too large Load Diff

View File

@ -39,6 +39,44 @@ router.get("/available/:menuObjid?", authenticateToken, async (req: Authenticate
}
});
// 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화)
router.get("/available-for-screen", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName } = req.query;
try {
// tableName 필수 검증
if (!tableName || typeof tableName !== "string") {
return res.status(400).json({
success: false,
error: "tableName is required",
});
}
const rules = await numberingRuleService.getAvailableRulesForScreen(
companyCode,
tableName
);
logger.info("화면용 채번 규칙 조회 성공", {
companyCode,
tableName,
count: rules.length,
});
return res.json({ success: true, data: rules });
} catch (error: any) {
logger.error("화면용 채번 규칙 조회 실패", {
error: error.message,
tableName,
});
return res.status(500).json({
success: false,
error: error.message,
});
}
});
// 특정 규칙 조회
router.get("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;

View File

@ -49,6 +49,33 @@ export class EntityJoinService {
const joinConfigs: EntityJoinConfig[] = [];
// 🎯 writer 컬럼 자동 감지 및 조인 설정 추가
const tableColumns = await query<{ column_name: string }>(
`SELECT column_name
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
AND column_name = 'writer'`,
[tableName]
);
if (tableColumns.length > 0) {
const writerJoinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: "writer",
referenceTable: "user_info",
referenceColumn: "user_id",
displayColumns: ["user_name"],
displayColumn: "user_name",
aliasColumn: "writer_name",
separator: " - ",
};
if (await this.validateJoinConfig(writerJoinConfig)) {
joinConfigs.push(writerJoinConfig);
}
}
for (const column of entityColumns) {
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
column_name: column.column_name,

View File

@ -401,6 +401,117 @@ class NumberingRuleService {
}
}
/**
* ( - )
* @param companyCode
* @param tableName
* @returns
*/
async getAvailableRulesForScreen(
companyCode: string,
tableName: string
): Promise<NumberingRuleConfig[]> {
try {
logger.info("화면용 채번 규칙 조회", {
companyCode,
tableName,
});
const pool = getPool();
// 멀티테넌시: 최고 관리자 vs 일반 회사
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 회사의 규칙 조회 가능 (최고 관리자 전용 규칙 제외)
query = `
SELECT
rule_id AS "ruleId",
rule_name AS "ruleName",
description,
separator,
reset_period AS "resetPeriod",
current_sequence AS "currentSequence",
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code != '*'
AND table_name = $1
ORDER BY created_at DESC
`;
params = [tableName];
logger.info("최고 관리자: 일반 회사 채번 규칙 조회");
} else {
// 일반 회사: 자신의 규칙만 조회
query = `
SELECT
rule_id AS "ruleId",
rule_name AS "ruleName",
description,
separator,
reset_period AS "resetPeriod",
current_sequence AS "currentSequence",
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code = $1
AND table_name = $2
ORDER BY created_at DESC
`;
params = [companyCode, tableName];
}
const result = await pool.query(query, params);
// 각 규칙의 파트 정보 로드
for (const rule of result.rows) {
const partsQuery = `
SELECT
id,
part_order AS "order",
part_type AS "partType",
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts
WHERE rule_id = $1
AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [
rule.ruleId,
companyCode === "*" ? rule.companyCode : companyCode,
]);
rule.parts = partsResult.rows;
}
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}`, {
companyCode,
tableName,
});
return result.rows;
} catch (error: any) {
logger.error("화면용 채번 규칙 조회 실패", error);
throw error;
}
}
/**
*
*/

View File

@ -0,0 +1,188 @@
# 마이그레이션 046 오류 수정
## 🚨 발생한 오류
```
SQL Error [23514]: ERROR: check constraint "check_menu_scope_requires_menu_objid"
of relation "numbering_rules" is violated by some row
```
## 🔍 원인 분석
기존 데이터베이스에 `scope_type='menu'`인데 `menu_objid`가 NULL인 레코드가 존재했습니다.
제약조건을 추가하기 전에 이러한 **불완전한 데이터를 먼저 정리**해야 했습니다.
## ✅ 수정 내용
마이그레이션 파일 `046_update_numbering_rules_scope_type.sql`**데이터 정리 단계** 추가:
### 1. 추가된 데이터 정리 로직 (제약조건 추가 전)
```sql
-- 3. 기존 데이터 정리 (제약조건 추가 전 필수!)
-- 3.1. menu 타입인데 menu_objid가 NULL인 경우 → global로 변경
UPDATE numbering_rules
SET scope_type = 'global',
table_name = NULL
WHERE scope_type = 'menu' AND menu_objid IS NULL;
-- 3.2. global 타입인데 table_name이 있는 경우 → table로 변경
UPDATE numbering_rules
SET scope_type = 'table'
WHERE scope_type = 'global' AND table_name IS NOT NULL;
-- 3.3. 정리 결과 확인 (로그)
DO $$
DECLARE
menu_count INTEGER;
global_count INTEGER;
table_count INTEGER;
BEGIN
SELECT COUNT(*) INTO menu_count FROM numbering_rules WHERE scope_type = 'menu';
SELECT COUNT(*) INTO global_count FROM numbering_rules WHERE scope_type = 'global';
SELECT COUNT(*) INTO table_count FROM numbering_rules WHERE scope_type = 'table';
RAISE NOTICE '=== 데이터 정리 완료 ===';
RAISE NOTICE 'Menu 규칙: % 개', menu_count;
RAISE NOTICE 'Global 규칙: % 개', global_count;
RAISE NOTICE 'Table 규칙: % 개', table_count;
RAISE NOTICE '=========================';
END $$;
```
### 2. 실행 순서 변경
**변경 전:**
1. scope_type 제약조건 추가
2. ❌ 유효성 제약조건 추가 (여기서 오류 발생!)
3. 데이터 마이그레이션
**변경 후:**
1. scope_type 제약조건 추가
2. ✅ **기존 데이터 정리** (추가)
3. 유효성 제약조건 추가
4. 인덱스 생성
5. 통계 업데이트
## 🔄 재실행 방법
### 옵션 1: 전체 롤백 후 재실행 (권장)
```sql
-- 1. 기존 마이그레이션 롤백
BEGIN;
-- 제약조건 제거
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_scope_type;
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_table_scope_requires_table_name;
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_global_scope_no_table_name;
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_menu_scope_requires_menu_objid;
-- 인덱스 제거
DROP INDEX IF EXISTS idx_numbering_rules_scope_table;
DROP INDEX IF EXISTS idx_numbering_rules_scope_menu;
COMMIT;
-- 2. 수정된 마이그레이션 재실행
\i /Users/kimjuseok/ERP-node/db/migrations/046_update_numbering_rules_scope_type.sql
```
### 옵션 2: 데이터 정리만 수동 실행 후 재시도
```sql
-- 1. 데이터 정리
UPDATE numbering_rules
SET scope_type = 'global',
table_name = NULL
WHERE scope_type = 'menu' AND menu_objid IS NULL;
UPDATE numbering_rules
SET scope_type = 'table'
WHERE scope_type = 'global' AND table_name IS NOT NULL;
-- 2. 제약조건 추가
ALTER TABLE numbering_rules
ADD CONSTRAINT check_menu_scope_requires_menu_objid
CHECK (
(scope_type = 'menu' AND menu_objid IS NOT NULL)
OR scope_type != 'menu'
);
-- 3. 나머지 제약조건들...
```
## 🧪 검증 쿼리
마이그레이션 실행 전에 문제 데이터 확인:
```sql
-- 문제가 되는 레코드 확인
SELECT
rule_id,
rule_name,
scope_type,
table_name,
menu_objid,
company_code
FROM numbering_rules
WHERE
(scope_type = 'menu' AND menu_objid IS NULL)
OR (scope_type = 'global' AND table_name IS NOT NULL)
OR (scope_type = 'table' AND table_name IS NULL);
```
마이그레이션 실행 후 검증:
```sql
-- 1. scope_type별 개수
SELECT scope_type, COUNT(*) as count
FROM numbering_rules
GROUP BY scope_type;
-- 2. 제약조건 확인
SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'numbering_rules'::regclass
AND conname LIKE '%scope%';
-- 3. 인덱스 확인
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'numbering_rules'
AND indexname LIKE '%scope%';
```
## 📝 수정 내역
- ✅ 제약조건 추가 전 데이터 정리 로직 추가
- ✅ 중복된 데이터 마이그레이션 코드 제거
- ✅ 섹션 번호 재정렬
- ✅ 데이터 정리 결과 로그 추가
## 🎯 다음 단계
1. **현재 상태 확인**
```bash
psql -h localhost -U postgres -d ilshin -f /Users/kimjuseok/ERP-node/db/check_numbering_rules.sql
```
2. **롤백 (필요시)**
- 기존 제약조건 제거
3. **수정된 마이그레이션 재실행**
```bash
PGPASSWORD=<비밀번호> psql -h localhost -U postgres -d ilshin -f /Users/kimjuseok/ERP-node/db/migrations/046_update_numbering_rules_scope_type.sql
```
4. **검증**
- 제약조건 확인
- 데이터 개수 확인
- 인덱스 확인
---
**수정 완료!** 이제 마이그레이션을 다시 실행하면 성공할 것입니다. 🎉

View File

@ -0,0 +1,151 @@
# 채번 규칙 마이그레이션 오류 긴급 수정
## 🚨 발생한 오류들
### 오류 1: check_table_scope_requires_table_name
```
SQL Error [23514]: ERROR: new row for relation "numbering_rules" violates check constraint "check_table_scope_requires_table_name"
```
**원인**: `scope_type='table'`인데 `table_name=NULL`
### 오류 2: check_global_scope_no_table_name
```
SQL Error [23514]: ERROR: new row for relation "numbering_rules" violates check constraint "check_global_scope_no_table_name"
```
**원인**: `scope_type='global'`인데 `table_name=''` (빈 문자열)
### 근본 원인
마이그레이션이 부분적으로 실행되어 데이터와 제약조건이 불일치 상태입니다.
## ✅ 해결 방법
### 🎯 가장 쉬운 방법 (권장)
**PgAdmin 또는 DBeaver에서 `046_SIMPLE_FIX.sql` 실행**
이 파일은 다음을 자동으로 처리합니다:
1. ✅ 기존 제약조건 모두 제거
2. ✅ `table_name` NULL → 빈 문자열로 변경
3. ✅ `scope_type`을 모두 'table'로 변경
4. ✅ 결과 확인
```sql
-- db/migrations/046_SIMPLE_FIX.sql 전체 내용을 복사하여 실행하세요
```
**실행 후**:
- `046_update_numbering_rules_scope_type.sql` 전체 실행
- 완료!
---
### 옵션 2: 명령줄에서 실행
```bash
# 1. 긴급 수정 SQL 실행
psql -h localhost -U postgres -d ilshin -f db/fix_existing_numbering_rules.sql
# 2. 전체 마이그레이션 실행
psql -h localhost -U postgres -d ilshin -f db/migrations/046_update_numbering_rules_scope_type.sql
```
---
### 옵션 3: Docker 컨테이너 내부에서 실행
```bash
# 1. Docker 컨테이너 확인
docker ps | grep postgres
# 2. 컨테이너 내부 접속
docker exec -it <CONTAINER_NAME> psql -U postgres -d ilshin
# 3. SQL 실행
UPDATE numbering_rules SET table_name = '' WHERE table_name IS NULL;
# 4. 확인
SELECT COUNT(*) FROM numbering_rules WHERE table_name IS NULL;
-- 결과: 0
# 5. 종료
\q
```
---
## 🔍 왜 이 문제가 발생했나?
### 기존 마이그레이션 순서 (잘못됨)
```sql
-- 1. scope_type 변경 (먼저 실행됨)
UPDATE numbering_rules SET scope_type = 'table' WHERE scope_type IN ('global', 'menu');
-- 2. table_name 정리 (나중에 실행됨)
UPDATE numbering_rules SET table_name = '' WHERE table_name IS NULL;
-- 3. 제약조건 추가
ALTER TABLE numbering_rules ADD CONSTRAINT check_table_scope_requires_table_name ...
```
**문제점**:
- `scope_type='table'`로 변경된 후
- 아직 `table_name=NULL`인 상태
- 이 상태에서 INSERT/UPDATE 시도 시 제약조건 위반
### 수정된 마이그레이션 순서 (올바름)
```sql
-- 1. table_name 정리 (먼저 실행!)
UPDATE numbering_rules SET table_name = '' WHERE table_name IS NULL;
-- 2. scope_type 변경
UPDATE numbering_rules SET scope_type = 'table' WHERE scope_type IN ('global', 'menu');
-- 3. 제약조건 추가
ALTER TABLE numbering_rules ADD CONSTRAINT check_table_scope_requires_table_name ...
```
---
## 📋 실행 체크리스트
- [ ] 옵션 1, 2, 또는 3 중 하나 선택하여 데이터 수정 완료
- [ ] `SELECT COUNT(*) FROM numbering_rules WHERE table_name IS NULL;` 실행 → 결과가 `0`인지 확인
- [ ] 전체 마이그레이션 `046_update_numbering_rules_scope_type.sql` 실행
- [ ] 백엔드 재시작
- [ ] 프론트엔드에서 채번 규칙 테스트
---
## 🎯 완료 후 확인사항
### SQL로 최종 확인
```sql
-- 1. 모든 규칙이 table 타입인지
SELECT scope_type, COUNT(*)
FROM numbering_rules
GROUP BY scope_type;
-- 결과: table만 나와야 함
-- 2. table_name이 NULL인 규칙이 없는지
SELECT COUNT(*)
FROM numbering_rules
WHERE table_name IS NULL;
-- 결과: 0
-- 3. 샘플 데이터 확인
SELECT
rule_id,
rule_name,
scope_type,
table_name,
company_code
FROM numbering_rules
LIMIT 5;
```
---
## 💡 추가 정보
수정된 마이그레이션 파일(`046_update_numbering_rules_scope_type.sql`)은 이제 올바른 순서로 실행되도록 업데이트되었습니다. 하지만 **이미 실행된 부분적인 마이그레이션**으로 인해 데이터가 불일치 상태일 수 있으므로, 위의 긴급 수정을 먼저 실행하는 것이 안전합니다.

View File

@ -0,0 +1,276 @@
# 마이그레이션 046: 채번규칙 scope_type 확장
## 📋 목적
메뉴 기반 채번규칙 필터링을 **테이블 기반 필터링**으로 전환하여 더 직관적이고 유지보수하기 쉬운 시스템 구축
### 주요 변경사항
1. `scope_type` 값 확장: `'global'`, `'menu'``'global'`, `'table'`, `'menu'`
2. 기존 데이터 자동 마이그레이션 (`global` + `table_name``table`)
3. 유효성 검증 제약조건 추가
4. 멀티테넌시 인덱스 최적화
---
## 🚀 실행 방법
### Docker 환경 (권장)
```bash
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/046_update_numbering_rules_scope_type.sql
```
### 로컬 PostgreSQL
```bash
psql -U postgres -d ilshin -f db/migrations/046_update_numbering_rules_scope_type.sql
```
### pgAdmin / DBeaver
1. `db/migrations/046_update_numbering_rules_scope_type.sql` 파일 열기
2. 전체 내용 복사
3. SQL 쿼리 창에 붙여넣기
4. 실행 (F5 또는 Execute)
---
## ✅ 검증 방법
### 1. 제약조건 확인
```sql
SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'numbering_rules'::regclass
AND conname LIKE '%scope%';
```
**예상 결과**:
```
conname | pg_get_constraintdef
--------------------------------------|---------------------
check_scope_type | CHECK (scope_type IN ('global', 'table', 'menu'))
check_table_scope_requires_table_name | CHECK (...)
check_global_scope_no_table_name | CHECK (...)
check_menu_scope_requires_menu_objid | CHECK (...)
```
### 2. 인덱스 확인
```sql
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'numbering_rules'
AND indexname LIKE '%scope%'
ORDER BY indexname;
```
**예상 결과**:
```
indexname | indexdef
------------------------------------|----------
idx_numbering_rules_scope_menu | CREATE INDEX ... (scope_type, menu_objid, company_code)
idx_numbering_rules_scope_table | CREATE INDEX ... (scope_type, table_name, company_code)
```
### 3. 데이터 마이그레이션 확인
```sql
-- scope_type별 개수
SELECT scope_type, COUNT(*) as count
FROM numbering_rules
GROUP BY scope_type;
```
**예상 결과**:
```
scope_type | count
-----------|------
global | X개 (table_name이 NULL인 규칙들)
table | Y개 (table_name이 있는 규칙들)
menu | Z개 (menu_objid가 있는 규칙들)
```
### 4. 유효성 검증
```sql
-- 이 쿼리들은 모두 0개를 반환해야 정상
-- 1) global인데 table_name이 있는 규칙 (없어야 함)
SELECT COUNT(*) as invalid_global
FROM numbering_rules
WHERE scope_type = 'global' AND table_name IS NOT NULL;
-- 2) table인데 table_name이 없는 규칙 (없어야 함)
SELECT COUNT(*) as invalid_table
FROM numbering_rules
WHERE scope_type = 'table' AND table_name IS NULL;
-- 3) menu인데 menu_objid가 없는 규칙 (없어야 함)
SELECT COUNT(*) as invalid_menu
FROM numbering_rules
WHERE scope_type = 'menu' AND menu_objid IS NULL;
```
**모든 카운트가 0이어야 정상**
### 5. 회사별 데이터 격리 확인 (멀티테넌시)
```sql
-- 회사별 규칙 개수
SELECT
company_code,
scope_type,
COUNT(*) as count
FROM numbering_rules
GROUP BY company_code, scope_type
ORDER BY company_code, scope_type;
```
**각 회사의 데이터가 독립적으로 존재해야 함**
---
## 🚨 롤백 방법 (문제 발생 시)
```sql
BEGIN;
-- 제약조건 제거
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_scope_type;
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_table_scope_requires_table_name;
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_global_scope_no_table_name;
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_menu_scope_requires_menu_objid;
-- 인덱스 제거
DROP INDEX IF EXISTS idx_numbering_rules_scope_table;
DROP INDEX IF EXISTS idx_numbering_rules_scope_menu;
-- 데이터 롤백 (table → global)
UPDATE numbering_rules
SET scope_type = 'global'
WHERE scope_type = 'table';
-- 기존 제약조건 복원
ALTER TABLE numbering_rules
ADD CONSTRAINT check_scope_type
CHECK (scope_type IN ('global', 'menu'));
-- 기존 인덱스 복원
CREATE INDEX IF NOT EXISTS idx_numbering_rules_table
ON numbering_rules(table_name, column_name);
COMMIT;
```
---
## 📊 마이그레이션 내용 상세
### 변경 사항
| 항목 | 변경 전 | 변경 후 |
|------|---------|---------|
| **scope_type 값** | 'global', 'menu' | 'global', 'table', 'menu' |
| **유효성 검증** | 없음 | table/global/menu 타입별 제약조건 추가 |
| **인덱스** | (table_name, column_name) | (scope_type, table_name, company_code)<br>(scope_type, menu_objid, company_code) |
| **데이터** | global + table_name | table 타입으로 자동 변경 |
### 영향받는 데이터
```sql
-- 자동으로 변경되는 규칙 조회
SELECT
rule_id,
rule_name,
scope_type as old_scope_type,
'table' as new_scope_type,
table_name,
company_code
FROM numbering_rules
WHERE scope_type = 'global'
AND table_name IS NOT NULL;
```
---
## ⚠️ 주의사항
1. **백업 필수**: 마이그레이션 실행 전 반드시 데이터베이스 백업
2. **트랜잭션**: 전체 마이그레이션이 하나의 트랜잭션으로 실행됨 (실패 시 자동 롤백)
3. **성능**: 규칙이 많으면 실행 시간이 길어질 수 있음 (보통 1초 이내)
4. **멀티테넌시**: 모든 회사의 데이터가 안전하게 마이그레이션됨
5. **하위 호환성**: 기존 기능 100% 유지 (자동 변환)
---
## 🔍 문제 해결
### 제약조건 충돌 발생 시
```sql
-- 문제가 되는 데이터 확인
SELECT rule_id, rule_name, scope_type, table_name, menu_objid
FROM numbering_rules
WHERE
(scope_type = 'table' AND table_name IS NULL)
OR (scope_type = 'global' AND table_name IS NOT NULL)
OR (scope_type = 'menu' AND menu_objid IS NULL);
-- 수동 수정 후 다시 마이그레이션 실행
```
### 인덱스 생성 실패 시
```sql
-- 기존 인덱스 확인
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'numbering_rules';
-- 충돌하는 인덱스 삭제 후 다시 실행
DROP INDEX IF EXISTS <충돌하는_인덱스명>;
```
---
## 📈 성능 개선 효과
### Before (기존)
```sql
-- 단일 인덱스: (table_name, column_name)
-- company_code 필터링 시 Full Table Scan 가능성
```
### After (변경 후)
```sql
-- 복합 인덱스: (scope_type, table_name, company_code)
-- 멀티테넌시 쿼리 성능 향상 (회사별 격리 최적화)
-- WHERE 절과 ORDER BY 절 모두 인덱스 활용 가능
```
**예상 성능 향상**: 회사별 규칙 조회 시 **3-5배 빠름**
---
## 📞 지원
- **작성자**: 개발팀
- **작성일**: 2025-11-08
- **관련 문서**: `/채번규칙_테이블기반_필터링_구현_계획서.md`
- **이슈 발생 시**: 롤백 스크립트 실행 후 개발팀 문의
---
## 다음 단계
마이그레이션 완료 후:
1. ✅ 검증 쿼리 실행
2. ⬜ 백엔드 API 수정 (Phase 2)
3. ⬜ 프론트엔드 수정 (Phase 3-5)
4. ⬜ 통합 테스트
**마이그레이션 준비 완료!** 🚀

View File

@ -0,0 +1,656 @@
# 엑셀 다운로드 기능 개선 계획서
## 📋 문서 정보
- **작성일**: 2025-01-10
- **작성자**: AI Developer
- **상태**: 계획 단계
- **우선순위**: 🔴 높음 (보안 취약점 포함)
---
## 🚨 현재 문제점
### 1. 보안 취약점 (Critical)
- ❌ **멀티테넌시 규칙 위반**: 모든 회사의 데이터를 가져옴
- ❌ **회사 필터링 없음**: `dynamicFormApi.getTableData` 호출 시 `autoFilter` 미적용
- ❌ **데이터 유출 위험**: 회사 A 사용자가 회사 B, C, D의 데이터를 다운로드 가능
- ❌ **규정 위반**: GDPR, 개인정보보호법 등 법적 문제
**관련 코드**:
```typescript
// frontend/lib/utils/buttonActions.ts (2043-2048 라인)
const response = await dynamicFormApi.getTableData(context.tableName, {
page: 1,
pageSize: 10000, // 최대 10,000개 행
sortBy: context.sortBy || "id",
sortOrder: context.sortOrder || "asc",
// ❌ autoFilter 없음 - company_code 필터링 안됨
// ❌ search 없음 - 사용자 필터 조건 무시
});
```
### 2. 기능 문제
- ❌ **모든 컬럼 포함**: 화면에 표시되지 않는 컬럼도 다운로드됨
- ❌ **필터 조건 무시**: 사용자가 설정한 검색/필터가 적용되지 않음
- ❌ **DB 컬럼명 사용**: 사용자 친화적이지 않음 (예: `user_id` 대신 `사용자 ID`)
- ❌ **컬럼 순서 불일치**: 화면 표시 순서와 다름
### 3. 우선순위 문제
현재 다운로드 데이터 우선순위:
1. ✅ 선택된 행 데이터 (`context.selectedRowsData`)
2. ✅ 화면 표시 데이터 (`context.tableDisplayData`)
3. ✅ 전역 저장소 데이터 (`tableDisplayStore`)
4. ❌ **테이블 전체 데이터** (API 호출) ← **보안 위험!**
---
## 🎯 개선 목표
### 1. 보안 강화
- ✅ **멀티테넌시 준수**: 현재 사용자의 회사 데이터만 다운로드
- ✅ **필터 조건 적용**: 사용자가 설정한 검색/필터 조건 반영
- ✅ **권한 검증**: 데이터 접근 권한 확인
- ✅ **감사 로그**: 다운로드 이력 기록
### 2. 사용자 경험 개선
- ✅ **화면 표시 컬럼만**: 사용자가 선택한 컬럼만 다운로드
- ✅ **컬럼 순서 유지**: 화면 표시 순서와 동일
- ✅ **라벨명 사용**: 한글 컬럼명 (예: `사용자 ID`, `부서명`)
- ✅ **정렬 유지**: 화면 정렬 상태 반영
### 3. 데이터 정확성
- ✅ **필터링된 데이터**: 화면에 보이는 조건과 동일한 데이터
- ✅ **선택 우선**: 사용자가 행을 선택했으면 선택된 행만
- ✅ **데이터 일관성**: 화면 ↔ 엑셀 데이터 일치
---
## 📐 개선 계획
### Phase 1: 데이터 소스 우선순위 재정의
#### 새로운 우선순위
```
1. 선택된 행 데이터 (가장 높은 우선순위)
- 출처: context.selectedRowsData
- 설명: 사용자가 체크박스로 선택한 행
- 특징: 필터/정렬 이미 적용됨, 가장 명확한 의도
- 처리: 그대로 사용
2. 화면 표시 데이터 (두 번째 우선순위)
- 출처: tableDisplayStore.getTableData(tableName)
- 설명: 현재 화면에 표시 중인 데이터
- 특징: 필터/정렬/페이징 적용됨, 가장 안전
- 처리:
- 현재 페이지 데이터만 (기본)
- 또는 전체 페이지 데이터 (옵션)
3. API 호출 - 필터 조건 포함 (최후 수단)
- 출처: entityJoinApi.getTableDataWithJoins()
- 설명: 위의 데이터가 없을 때만
- 특징:
- ✅ company_code 자동 필터링 (autoFilter: true)
- ✅ 검색/필터 조건 전달
- ✅ 정렬 조건 전달
- 제한: 최대 10,000개 행
4. ❌ 테이블 전체 데이터 (제거)
- 보안상 위험하므로 완전 제거
- 대신 경고 메시지 표시
```
### Phase 2: ButtonActionContext 확장
#### 현재 구조
```typescript
interface ButtonActionContext {
tableName?: string;
formData?: Record<string, any>;
selectedRowsData?: any[];
tableDisplayData?: any[];
columnOrder?: string[];
sortBy?: string;
sortOrder?: "asc" | "desc";
}
```
#### 추가 필드
```typescript
interface ButtonActionContext {
// ... 기존 필드
// 🆕 필터 및 검색 조건
filterConditions?: Record<string, any>; // 필터 조건 (예: { status: "active", dept: "dev" })
searchTerm?: string; // 검색어
searchColumn?: string; // 검색 대상 컬럼
// 🆕 컬럼 정보
visibleColumns?: string[]; // 화면에 표시 중인 컬럼 목록 (순서 포함)
columnLabels?: Record<string, string>; // 컬럼명 → 라벨명 매핑
// 🆕 페이징 정보
currentPage?: number; // 현재 페이지
pageSize?: number; // 페이지 크기
totalItems?: number; // 전체 항목 수
// 🆕 엑셀 옵션
excelScope?: "selected" | "current-page" | "all-filtered"; // 다운로드 범위
}
```
### Phase 3: TableListComponent 수정
#### 위치
`frontend/lib/registry/components/table-list/TableListComponent.tsx`
#### 변경 사항
```typescript
// 버튼 클릭 시 context 생성
const buttonContext: ButtonActionContext = {
tableName: tableConfig.selectedTable,
// 기존
selectedRowsData: selectedRows,
tableDisplayData: data, // 현재 페이지 데이터
columnOrder: visibleColumns.map((col) => col.columnName),
sortBy: sortColumn,
sortOrder: sortDirection,
// 🆕 추가
filterConditions: searchValues, // 필터 조건
searchTerm: searchTerm, // 검색어
visibleColumns: visibleColumns.map((col) => col.columnName), // 표시 컬럼
columnLabels: columnLabels, // 컬럼 라벨 (한글)
currentPage: currentPage, // 현재 페이지
pageSize: localPageSize, // 페이지 크기
totalItems: totalItems, // 전체 항목 수
excelScope: selectedRows.length > 0 ? "selected" : "current-page", // 기본: 현재 페이지
};
```
### Phase 4: handleExcelDownload 수정
#### 4-1. 데이터 소스 선택 로직
```typescript
private static async handleExcelDownload(
config: ButtonActionConfig,
context: ButtonActionContext
): Promise<boolean> {
try {
let dataToExport: any[] = [];
let dataSource: string = "unknown";
// 1순위: 선택된 행 데이터
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
dataToExport = context.selectedRowsData;
dataSource = "selected";
console.log("✅ 선택된 행 사용:", dataToExport.length);
}
// 2순위: 화면 표시 데이터
else if (context.tableDisplayData && context.tableDisplayData.length > 0) {
dataToExport = context.tableDisplayData;
dataSource = "current-page";
console.log("✅ 현재 페이지 데이터 사용:", dataToExport.length);
}
// 3순위: 전역 저장소 데이터
else if (context.tableName) {
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
const storedData = tableDisplayStore.getTableData(context.tableName);
if (storedData && storedData.data.length > 0) {
dataToExport = storedData.data;
dataSource = "store";
console.log("✅ 저장소 데이터 사용:", dataToExport.length);
}
}
// 4순위: API 호출 (필터 조건 포함) - 최후 수단
if (dataToExport.length === 0 && context.tableName) {
console.log("⚠️ 화면 데이터 없음 - API 호출 필요");
// 사용자 확인 (선택사항)
const confirmed = await this.confirmLargeDownload(context.totalItems || 0);
if (!confirmed) {
return false;
}
dataToExport = await this.fetchFilteredData(context);
dataSource = "api";
}
// 데이터 없음
if (dataToExport.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return false;
}
// ... 계속
}
}
```
#### 4-2. API 호출 메서드 (필터 조건 포함)
```typescript
private static async fetchFilteredData(
context: ButtonActionContext
): Promise<any[]> {
try {
console.log("🔄 필터된 데이터 조회 중...", {
tableName: context.tableName,
filterConditions: context.filterConditions,
searchTerm: context.searchTerm,
sortBy: context.sortBy,
sortOrder: context.sortOrder,
});
const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
const response = await entityJoinApi.getTableDataWithJoins(
context.tableName!,
{
page: 1,
size: 10000, // 최대 10,000개
sortBy: context.sortBy || "id",
sortOrder: context.sortOrder || "asc",
search: context.filterConditions, // ✅ 필터 조건
enableEntityJoin: true, // ✅ Entity 조인
autoFilter: true, // ✅ company_code 자동 필터링
}
);
if (response.success && response.data) {
console.log("✅ API 데이터 조회 완료:", {
count: response.data.length,
total: response.total,
});
return response.data;
} else {
console.error("❌ API 응답 실패:", response);
return [];
}
} catch (error) {
console.error("❌ API 호출 오류:", error);
toast.error("데이터를 가져오는데 실패했습니다.");
return [];
}
}
```
#### 4-3. 컬럼 필터링 및 라벨 적용
```typescript
private static applyColumnFiltering(
data: any[],
context: ButtonActionContext
): any[] {
// 표시 컬럼이 지정되지 않았으면 모든 컬럼 사용
const visibleColumns = context.visibleColumns || Object.keys(data[0] || {});
const columnLabels = context.columnLabels || {};
console.log("🔧 컬럼 필터링 및 라벨 적용:", {
totalColumns: Object.keys(data[0] || {}).length,
visibleColumns: visibleColumns.length,
hasLabels: Object.keys(columnLabels).length > 0,
});
return data.map(row => {
const filteredRow: Record<string, any> = {};
visibleColumns.forEach(columnName => {
// 라벨 우선 사용, 없으면 컬럼명 사용
const label = columnLabels[columnName] || columnName;
filteredRow[label] = row[columnName];
});
return filteredRow;
});
}
```
#### 4-4. 대용량 다운로드 확인
```typescript
private static async confirmLargeDownload(totalItems: number): Promise<boolean> {
if (totalItems === 0) {
return true; // 데이터 없으면 확인 불필요
}
if (totalItems > 1000) {
const confirmed = window.confirm(
`총 ${totalItems.toLocaleString()}개의 데이터를 다운로드합니다.\n` +
`(최대 10,000개까지만 다운로드됩니다)\n\n` +
`계속하시겠습니까?`
);
return confirmed;
}
return true; // 1000개 이하는 자동 진행
}
```
#### 4-5. 전체 흐름
```typescript
private static async handleExcelDownload(
config: ButtonActionConfig,
context: ButtonActionContext
): Promise<boolean> {
try {
// 1. 데이터 소스 선택
let dataToExport = await this.selectDataSource(context);
if (dataToExport.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return false;
}
// 2. 최대 행 수 제한
const MAX_ROWS = 10000;
if (dataToExport.length > MAX_ROWS) {
toast.warning(`최대 ${MAX_ROWS.toLocaleString()}개 행까지만 다운로드됩니다.`);
dataToExport = dataToExport.slice(0, MAX_ROWS);
}
// 3. 컬럼 필터링 및 라벨 적용
dataToExport = this.applyColumnFiltering(dataToExport, context);
// 4. 정렬 적용 (필요 시)
if (context.sortBy) {
dataToExport = this.applySorting(dataToExport, context.sortBy, context.sortOrder);
}
// 5. 엑셀 파일 생성
const { exportToExcel } = await import("@/lib/utils/excelExport");
const fileName = config.excelFileName ||
`${context.tableName}_${new Date().toISOString().split("T")[0]}.xlsx`;
const sheetName = config.excelSheetName || "Sheet1";
const includeHeaders = config.excelIncludeHeaders !== false;
await exportToExcel(dataToExport, fileName, sheetName, includeHeaders);
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
// 6. 감사 로그 (선택사항)
this.logExcelDownload(context, dataToExport.length);
return true;
} catch (error) {
console.error("❌ 엑셀 다운로드 실패:", error);
toast.error("엑셀 다운로드에 실패했습니다.");
return false;
}
}
```
---
## 🔧 구현 단계
### Step 1: 타입 정의 업데이트
**파일**: `frontend/lib/utils/buttonActions.ts`
- [ ] `ButtonActionContext` 인터페이스에 새 필드 추가
- [ ] `ExcelDownloadScope` 타입 정의 추가
- [ ] JSDoc 주석 추가
**예상 작업 시간**: 10분
---
### Step 2: TableListComponent 수정
**파일**: `frontend/lib/registry/components/table-list/TableListComponent.tsx`
- [ ] 버튼 context 생성 시 필터/컬럼/라벨 정보 추가
- [ ] `columnLabels` 생성 로직 추가
- [ ] `visibleColumns` 목록 생성
**예상 작업 시간**: 20분
---
### Step 3: handleExcelDownload 리팩토링
**파일**: `frontend/lib/utils/buttonActions.ts`
- [ ] 데이터 소스 선택 로직 분리 (`selectDataSource`)
- [ ] API 호출 메서드 추가 (`fetchFilteredData`)
- [ ] 컬럼 필터링 메서드 추가 (`applyColumnFiltering`)
- [ ] 대용량 확인 메서드 추가 (`confirmLargeDownload`)
- [ ] 정렬 메서드 개선 (`applySorting`)
- [ ] 기존 코드 정리 (불필요한 로그 제거)
**예상 작업 시간**: 40분
---
### Step 4: 테스트
**테스트 시나리오**:
1. **선택된 행 다운로드**
- 체크박스로 여러 행 선택
- 엑셀 다운로드 버튼 클릭
- 예상: 선택된 행만 다운로드
- 확인: 라벨명, 컬럼 순서, 데이터 정확성
2. **현재 페이지 다운로드**
- 행 선택 없이 엑셀 다운로드
- 예상: 현재 페이지 데이터만
- 확인: 페이지 크기만큼 다운로드
3. **필터 적용 다운로드**
- 검색어 입력 또는 필터 설정
- 엑셀 다운로드
- 예상: 필터된 결과만
- 확인: 화면 데이터와 일치
4. **멀티테넌시 테스트**
- 회사 A로 로그인
- 엑셀 다운로드
- 확인: 회사 A 데이터만
- 회사 B로 로그인
- 엑셀 다운로드
- 확인: 회사 B 데이터만
5. **대용량 데이터 테스트**
- 10,000개 이상 데이터 조회
- 엑셀 다운로드
- 예상: 10,000개까지만 + 경고 메시지
6. **컬럼 라벨 테스트**
- 엑셀 파일 열기
- 확인: DB 컬럼명이 아닌 한글 라벨명
**예상 작업 시간**: 30분
---
### Step 5: 문서화 및 커밋
- [ ] 코드 주석 추가
- [ ] README 업데이트 (있다면)
- [ ] 커밋 메시지 작성
**예상 작업 시간**: 10분
---
## ⏱️ 총 예상 시간
**약 2시간** (코딩 + 테스트)
---
## ⚠️ 주의사항
### 1. 하위 호환성
- 기존 `context.tableDisplayData`를 사용하는 코드가 있을 수 있음
- 새 필드는 모두 선택사항(`?`)으로 정의
- 기존 동작은 유지하면서 점진적으로 개선
### 2. 성능
- API 호출 시 최대 10,000개 제한
- 대용량 데이터는 페이징 권장
- 브라우저 메모리 제한 고려
### 3. 보안
- **절대 `autoFilter: false` 사용 금지**
- 모든 API 호출에 `autoFilter: true` 필수
- 감사 로그 기록 권장
### 4. 사용자 경험
- 다운로드 중 로딩 표시
- 완료/실패 토스트 메시지
- 대용량 다운로드 시 확인 창
---
## 📊 예상 결과
### Before (현재)
```
엑셀 다운로드:
❌ 모든 회사의 데이터 (보안 위험!)
❌ 모든 컬럼 포함 (불필요한 정보)
❌ 필터 조건 무시
❌ DB 컬럼명 (user_id, dept_code)
❌ 정렬 상태 무시
```
### After (개선)
```
엑셀 다운로드:
✅ 현재 회사 데이터만 (멀티테넌시 준수)
✅ 화면 표시 컬럼만 (사용자 선택)
✅ 필터 조건 적용 (검색/필터 반영)
✅ 한글 라벨명 (사용자 ID, 부서명)
✅ 정렬 상태 유지 (화면과 동일)
✅ 컬럼 순서 유지 (화면과 동일)
```
---
## 🔗 관련 파일
### 수정 대상
1. `frontend/lib/utils/buttonActions.ts`
- `ButtonActionContext` 인터페이스
- `handleExcelDownload` 메서드
2. `frontend/lib/registry/components/table-list/TableListComponent.tsx`
- 버튼 context 생성 로직
### 참고 파일
1. `frontend/lib/api/entityJoin.ts`
- `getTableDataWithJoins` API
2. `frontend/lib/utils/excelExport.ts`
- `exportToExcel` 함수
3. `.cursor/rules/multi-tenancy-guide.mdc`
- 멀티테넌시 규칙
---
## 📝 후속 작업 (선택사항)
### 1. 엑셀 다운로드 옵션 UI
사용자가 다운로드 범위를 선택할 수 있는 모달:
```
[ ] 선택된 행만 (N개)
[x] 현재 페이지 (20개)
[ ] 필터된 전체 데이터 (최대 10,000개)
```
### 2. 엑셀 스타일링
- 헤더 배경색
- 자동 너비 조정
- 필터 버튼 추가
### 3. CSV 내보내기
- 대용량 데이터에 적합
- 가벼운 파일 크기
### 4. 감사 로그
- 누가, 언제, 어떤 데이터를 다운로드했는지 기록
- 보안 감사 추적
---
## ✅ 체크리스트
### 계획 단계
- [x] 계획서 작성 완료
- [x] 사용자 검토 및 승인
- [x] 수정 사항 반영
### 구현 단계
- [x] Step 1: 타입 정의 업데이트
- [x] Step 2: TableListComponent 수정
- [x] Step 3: handleExcelDownload 리팩토링
- [ ] Step 4: 테스트 완료 (사용자 테스트 필요)
- [ ] Step 5: 문서화 및 커밋 (대기 중)
### 배포 단계
- [ ] 코드 리뷰
- [ ] QA 테스트
- [ ] 프로덕션 배포
- [ ] 모니터링
---
## 🤝 승인
- [ ] 개발팀 리뷰
- [ ] 보안팀 검토
- [ ] 사용자 승인
- [ ] 최종 승인
---
**작성 완료**: 2025-01-10
**다음 업데이트**: 구현 완료 후

View File

@ -0,0 +1,275 @@
# 엑셀 다운로드 개선 계획 v2 (수정)
## 📋 문서 정보
- **작성일**: 2025-01-10
- **작성자**: AI Developer
- **버전**: 2.0 (사용자 피드백 반영)
- **상태**: 구현 대기
---
## 🎯 변경된 요구사항 (사용자 피드백)
### 사용자가 원하는 동작
1. ❌ **선택된 행만 다운로드 기능 제거** (불필요)
2. ✅ **항상 필터링된 전체 데이터 다운로드** (현재 화면 기준)
3. ✅ **화면에 표시된 컬럼만** 다운로드
4. ✅ **컬럼 라벨(한글) 우선** 사용
5. ✅ **멀티테넌시 준수** (company_code 필터링)
### 현재 문제
1. 🐛 **행 선택 안 했을 때**: "다운로드할 데이터가 없습니다" 에러
2. ❌ **선택된 행만 다운로드**: 사용자가 원하지 않는 동작
3. ❌ **모든 컬럼 포함**: 화면에 표시되지 않는 컬럼도 다운로드됨
4. ❌ **필터 조건 무시**: 사용자가 설정한 검색/필터가 적용되지 않음
5. ❌ **멀티테넌시 위반**: 모든 회사의 데이터를 가져올 가능성
---
## 🔄 수정된 다운로드 동작 흐름
### Before (현재 - 잘못된 동작)
```
엑셀 다운로드 버튼 클릭
1. 선택된 행이 있는가?
├─ Yes → 선택된 행만 다운로드 ❌ (사용자가 원하지 않음)
└─ No → 현재 페이지 데이터만 (10개 등) ❌ (전체가 아님)
```
### After (수정 - 올바른 동작)
```
엑셀 다운로드 버튼 클릭
🔒 멀티테넌시: company_code 자동 필터링
🔍 필터 조건: 사용자가 설정한 검색/필터 적용
📊 데이터 조회: 전체 필터링된 데이터 (최대 10,000개)
🎨 컬럼 필터링: 화면에 표시된 컬럼만
🏷️ 라벨 적용: 컬럼명 → 한글 라벨명
💾 엑셀 다운로드
```
---
## 🎯 수정된 데이터 우선순위
### ❌ 제거: 선택된 행 다운로드
```typescript
// ❌ 삭제할 코드
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
dataToExport = context.selectedRowsData; // 불필요!
}
```
### ✅ 새로운 우선순위
```typescript
// ✅ 항상 API 호출로 전체 필터링된 데이터 가져오기
const response = await entityJoinApi.getTableDataWithJoins(context.tableName, {
page: 1,
size: 10000, // 최대 10,000개
sortBy: context.sortBy || "id",
sortOrder: (context.sortOrder || "asc") as "asc" | "desc",
search: context.filterConditions, // ✅ 필터 조건
searchTerm: context.searchTerm, // ✅ 검색어
autoFilter: true, // ✅ company_code 자동 필터링 (멀티테넌시)
enableEntityJoin: true, // ✅ Entity 조인 (writer_name 등)
});
dataToExport = response.data; // 필터링된 전체 데이터
```
---
## 📝 수정 사항
### 1. `buttonActions.ts` - handleExcelDownload 리팩토링
**파일**: `frontend/lib/utils/buttonActions.ts`
#### 변경 전
```typescript
// ❌ 잘못된 우선순위
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
dataToExport = context.selectedRowsData; // 선택된 행만
}
else if (context.tableDisplayData && context.tableDisplayData.length > 0) {
dataToExport = context.tableDisplayData; // 현재 페이지만
}
```
#### 변경 후
```typescript
private static async handleExcelDownload(
config: ButtonActionConfig,
context: ButtonActionContext
): Promise<boolean> {
try {
let dataToExport: any[] = [];
// ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기
if (context.tableName) {
const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
const response = await entityJoinApi.getTableDataWithJoins(context.tableName, {
page: 1,
size: 10000, // 최대 10,000개
sortBy: context.sortBy || "id",
sortOrder: (context.sortOrder || "asc") as "asc" | "desc",
search: context.filterConditions, // ✅ 필터 조건
searchTerm: context.searchTerm, // ✅ 검색어
autoFilter: true, // ✅ company_code 자동 필터링 (멀티테넌시)
enableEntityJoin: true, // ✅ Entity 조인
});
if (response.success && response.data) {
dataToExport = response.data;
} else {
toast.error("데이터를 가져오는데 실패했습니다.");
return false;
}
} else {
toast.error("테이블 정보가 없습니다.");
return false;
}
// 데이터가 없으면 종료
if (dataToExport.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return false;
}
// 🎨 컬럼 필터링 및 라벨 적용
if (context.visibleColumns && context.visibleColumns.length > 0) {
const visibleColumns = context.visibleColumns;
const columnLabels = context.columnLabels || {};
dataToExport = dataToExport.map((row) => {
const filteredRow: Record<string, any> = {};
visibleColumns.forEach((columnName) => {
// 라벨 우선 사용, 없으면 컬럼명 사용
const label = columnLabels[columnName] || columnName;
filteredRow[label] = row[columnName];
});
return filteredRow;
});
}
// 💾 엑셀 파일 생성
const { exportToExcel } = await import("@/lib/utils/excelExport");
const fileName =
config.excelFileName || `${context.tableName}_${new Date().toISOString().split("T")[0]}.xlsx`;
const sheetName = config.excelSheetName || "Sheet1";
await exportToExcel(dataToExport, fileName, {
sheetName,
includeHeaders: config.excelIncludeHeaders !== false,
});
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
return true;
} catch (error) {
console.error("엑셀 다운로드 오류:", error);
toast.error("엑셀 다운로드 중 오류가 발생했습니다.");
return false;
}
}
```
---
## 🔒 보안 강화 (멀티테넌시)
### Before (위험)
```typescript
// ❌ 모든 회사 데이터 노출
await dynamicFormApi.getTableData(tableName, {
pageSize: 10000, // 필터 없음!
});
```
### After (안전)
```typescript
// ✅ 멀티테넌시 준수
await entityJoinApi.getTableDataWithJoins(tableName, {
size: 10000,
search: filterConditions, // 필터 조건
searchTerm: searchTerm, // 검색어
autoFilter: true, // company_code 자동 필터링 ✅
enableEntityJoin: true, // Entity 조인 ✅
});
```
---
## ✅ 구현 체크리스트
### Step 1: handleExcelDownload 단순화
- [ ] 선택된 행 다운로드 로직 제거 (`context.selectedRowsData` 체크 삭제)
- [ ] 화면 표시 데이터 로직 제거 (`context.tableDisplayData` 체크 삭제)
- [ ] 항상 API 호출로 변경 (entityJoinApi.getTableDataWithJoins)
- [ ] 멀티테넌시 필수 적용 (`autoFilter: true`)
- [ ] 필터 조건 전달 (`search`, `searchTerm`)
### Step 2: 컬럼 필터링 및 라벨 적용
- [ ] `context.visibleColumns`로 필터링
- [ ] `context.columnLabels`로 라벨 변환
- [ ] 라벨 우선, 없으면 컬럼명 사용
### Step 3: 테스트
- [ ] 필터 없이 다운로드 → 전체 데이터 (company_code 필터링)
- [ ] 검색어 입력 후 다운로드 → 검색된 데이터만
- [ ] 필터 설정 후 다운로드 → 필터링된 데이터만
- [ ] 컬럼 숨기기 후 다운로드 → 표시된 컬럼만
- [ ] 멀티테넌시 테스트 → 다른 회사 데이터 안 보임
- [ ] 10,000개 제한 확인
### Step 4: 문서화
- [ ] 주석 추가
- [ ] 계획서 업데이트
- [ ] 커밋 메시지 작성
---
## 🚀 예상 효과
1. **보안 강화**: 멀티테넌시 100% 준수
2. **사용자 경험 개선**: 필터링된 전체 데이터 다운로드
3. **직관적인 동작**: 화면에 보이는 대로 다운로드
4. **한글 지원**: 컬럼 라벨명으로 엑셀 생성
---
## 🤝 승인
**사용자 승인**: ⬜ 대기 중
---
**작성 완료**: 2025-01-10
**다음 업데이트**: 구현 완료 후

View File

@ -364,7 +364,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
</ResizableDialogHeader>
<div className="flex flex-1 items-center justify-center overflow-auto">
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
@ -374,13 +374,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
) : screenData ? (
<div
className="relative bg-white"
className="relative bg-white mx-auto"
style={{
width: screenDimensions?.width || 800,
height: screenDimensions?.height || 600,
width: `${screenDimensions?.width || 800}px`,
height: `${screenDimensions?.height || 600}px`,
transformOrigin: "center center",
maxWidth: "100%",
maxHeight: "100%",
}}
>
{screenData.components.map((component) => {

View File

@ -25,6 +25,7 @@ interface NumberingRuleDesignerProps {
maxRules?: number;
isPreview?: boolean;
className?: string;
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
}
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
@ -34,6 +35,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
maxRules = 6,
isPreview = false,
className = "",
currentTableName,
}) => {
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
@ -131,17 +133,32 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
try {
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
// 저장 전에 현재 화면의 테이블명 자동 설정
const ruleToSave = {
...currentRule,
scopeType: "table" as const, // 항상 table로 고정
tableName: currentTableName || currentRule.tableName || "", // 현재 테이블명 자동 설정
};
console.log("💾 채번 규칙 저장:", {
currentTableName,
"currentRule.tableName": currentRule.tableName,
"ruleToSave.tableName": ruleToSave.tableName,
"ruleToSave.scopeType": ruleToSave.scopeType,
ruleToSave
});
let response;
if (existing) {
response = await updateNumberingRule(currentRule.ruleId, currentRule);
response = await updateNumberingRule(ruleToSave.ruleId, ruleToSave);
} else {
response = await createNumberingRule(currentRule);
response = await createNumberingRule(ruleToSave);
}
if (response.success && response.data) {
setSavedRules((prev) => {
if (existing) {
return prev.map((r) => (r.ruleId === currentRule.ruleId ? response.data! : r));
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? response.data! : r));
} else {
return [...prev, response.data!];
}
@ -160,7 +177,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} finally {
setLoading(false);
}
}, [currentRule, savedRules, onSave]);
}, [currentRule, savedRules, onSave, currentTableName]);
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
setSelectedRuleId(rule.ruleId);
@ -196,6 +213,8 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
);
const handleNewRule = useCallback(() => {
console.log("📋 새 규칙 생성 - currentTableName:", currentTableName);
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: "새 채번 규칙",
@ -203,14 +222,17 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "menu",
scopeType: "table", // 기본값을 table로 설정
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
};
console.log("📋 생성된 규칙 정보:", newRule);
setSelectedRuleId(newRule.ruleId);
setCurrentRule(newRule);
toast.success("새 규칙이 생성되었습니다");
}, []);
}, [currentTableName]);
return (
<div className={`flex h-full gap-4 ${className}`}>
@ -312,6 +334,8 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</Button>
</div>
<div className="space-y-3">
{/* 첫 번째 줄: 규칙명 + 미리보기 */}
<div className="flex items-center gap-3">
<div className="flex-1 space-y-2">
<Label className="text-sm font-medium"></Label>
@ -328,6 +352,20 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div>
</div>
{/* 두 번째 줄: 자동 감지된 테이블 정보 표시 */}
{currentTableName && (
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
{currentTableName}
</div>
<p className="text-muted-foreground text-xs">
({currentTableName})
</p>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>

View File

@ -401,15 +401,14 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const applyStyles = (element: React.ReactElement) => {
if (!comp.style) return element;
// ✅ 격자 시스템 잔재 제거: style.width, style.height는 무시
// size.width, size.height가 부모 컨테이너에서 적용되므로
const { width, height, ...styleWithoutSize } = comp.style;
return React.cloneElement(element, {
style: {
...element.props.style, // 기존 스타일 유지
...comp.style,
// 크기는 부모 컨테이너에서 처리하므로 제거 (하지만 다른 스타일은 유지)
width: "100%",
height: "100%",
minHeight: "100%",
maxHeight: "100%",
...styleWithoutSize, // width/height 제외한 스타일만 적용
boxSizing: "border-box",
},
});
@ -1887,7 +1886,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return (
<>
<div className="h-full w-full">
<div className="h-full" style={{ width: '100%', height: '100%' }}>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
@ -1897,7 +1896,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
)}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
<div className="h-full w-full">{renderInteractiveWidget(componentForRendering)}</div>
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
</div>
{/* 개선된 검증 패널 (선택적 표시) */}

View File

@ -343,10 +343,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const applyStyles = (element: React.ReactElement) => {
if (!comp.style) return element;
// ✅ 격자 시스템 잔재 제거: style.width, style.height는 무시
// size.width, size.height가 부모 컨테이너에서 적용되므로
const { width, height, ...styleWithoutSize } = comp.style;
return React.cloneElement(element, {
style: {
...element.props.style,
...comp.style,
...styleWithoutSize, // width/height 제외한 스타일만 적용
width: "100%",
height: "100%",
minHeight: "100%",
@ -676,14 +680,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 메인 렌더링
const { type, position, size, style = {} } = component;
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
const componentStyle = {
position: "absolute" as const,
left: position?.x || 0,
top: position?.y || 0,
width: size?.width || 200,
height: size?.height || 10,
zIndex: position?.z || 1,
...style,
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
height: size?.height || 10,
};
return (

View File

@ -563,7 +563,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
{type === "widget" && !isFileComponent(component) && (
<div className="pointer-events-none h-full w-full">
<div className="h-full w-full">
<WidgetRenderer
component={component}
isDesignMode={isDesignMode}

View File

@ -214,22 +214,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
if (component.componentConfig?.type === "table-list") {
// 디자인 해상도 기준으로 픽셀 반환
const screenWidth = 1920; // 기본 디자인 해상도
console.log("📏 [getWidth] table-list 픽셀 사용:", {
componentId: id,
label: component.label,
width: `${screenWidth}px`,
});
return `${screenWidth}px`;
}
// 모든 컴포넌트는 size.width 픽셀 사용
const width = `${size?.width || 100}px`;
console.log("📐 [getWidth] 픽셀 기준 통일:", {
componentId: id,
label: component.label,
width,
sizeWidth: size?.width,
});
return width;
};
@ -268,13 +257,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
}
: component;
// componentStyle에서 width, height 제거 (size.width, size.height만 사용)
const { width: _styleWidth, height: _styleHeight, ...restComponentStyle } = componentStyle || {};
const baseStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
width: getWidth(), // getWidth()가 모든 우선순위를 처리
height: getHeight(),
...restComponentStyle, // width/height 제외한 스타일 먼저 적용
width: getWidth(), // size.width로 덮어쓰기
height: getHeight(), // size.height로 덮어쓰기
zIndex: component.type === "layout" ? 1 : position.z || 2,
...componentStyle,
right: undefined,
};
@ -286,33 +278,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
if (outerDivRef.current && innerDivRef.current) {
const outerRect = outerDivRef.current.getBoundingClientRect();
const innerRect = innerDivRef.current.getBoundingClientRect();
const computedOuter = window.getComputedStyle(outerDivRef.current);
const computedInner = window.getComputedStyle(innerDivRef.current);
console.log("📐 [DOM 실제 크기 상세]:", {
componentId: id,
label: component.label,
gridColumns: (component as any).gridColumns,
"1. baseStyle.width": baseStyle.width,
"2. 외부 div (파란 테두리)": {
width: `${outerRect.width}px`,
height: `${outerRect.height}px`,
computedWidth: computedOuter.width,
computedHeight: computedOuter.height,
},
"3. 내부 div (컨텐츠 래퍼)": {
width: `${innerRect.width}px`,
height: `${innerRect.height}px`,
computedWidth: computedInner.width,
computedHeight: computedInner.height,
className: innerDivRef.current.className,
inlineStyle: innerDivRef.current.getAttribute("style"),
},
"4. 너비 비교": {
"외부 / 내부": `${outerRect.width}px / ${innerRect.width}px`,
: `${((innerRect.width / outerRect.width) * 100).toFixed(2)}%`,
},
});
// 크기 측정 완료
}
}, [id, component.label, (component as any).gridColumns, baseStyle.width]);

View File

@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, ResizableDialogTitle } from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button";
import { X, Save, Loader2 } from "lucide-react";
import { toast } from "sonner";
@ -200,8 +200,21 @@ export const SaveModal: React.FC<SaveModalProps> = ({
const calculateDynamicSize = () => {
if (!components.length) return { width: 800, height: 600 };
const maxX = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)));
const maxY = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 40)));
const maxX = Math.max(...components.map((c) => {
const x = c.position?.x || 0;
const width = typeof c.size?.width === 'number'
? c.size.width
: parseInt(String(c.size?.width || 200), 10);
return x + width;
}));
const maxY = Math.max(...components.map((c) => {
const y = c.position?.y || 0;
const height = typeof c.size?.height === 'number'
? c.size.height
: parseInt(String(c.size?.height || 40), 10);
return y + height;
}));
const padding = 40;
return {
@ -213,9 +226,16 @@ export const SaveModal: React.FC<SaveModalProps> = ({
const dynamicSize = calculateDynamicSize();
return (
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
<DialogContent className={`${modalSizeClasses[modalSize]} max-h-[90vh] gap-0 p-0`}>
<DialogHeader className="border-b px-6 py-4">
<ResizableDialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
<ResizableDialogContent
modalId={`save-modal-${screenId}`}
defaultWidth={dynamicSize.width + 48}
defaultHeight={dynamicSize.height + 120}
minWidth={400}
minHeight={300}
className="gap-0 p-0"
>
<ResizableDialogHeader className="border-b px-6 py-4 flex-shrink-0">
<div className="flex items-center justify-between">
<ResizableDialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</ResizableDialogTitle>
<div className="flex items-center gap-2">
@ -237,9 +257,9 @@ export const SaveModal: React.FC<SaveModalProps> = ({
</Button>
</div>
</div>
</DialogHeader>
</ResizableDialogHeader>
<div className="overflow-auto p-6">
<div className="overflow-auto p-6 flex-1">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
@ -248,21 +268,42 @@ export const SaveModal: React.FC<SaveModalProps> = ({
<div
className="relative bg-white"
style={{
width: dynamicSize.width,
height: dynamicSize.height,
overflow: "hidden",
width: `${dynamicSize.width}px`,
height: `${dynamicSize.height}px`,
minWidth: `${dynamicSize.width}px`,
minHeight: `${dynamicSize.height}px`,
}}
>
<div className="relative" style={{ minHeight: "300px" }}>
{components.map((component, index) => (
<div className="relative" style={{ width: `${dynamicSize.width}px`, height: `${dynamicSize.height}px` }}>
{components.map((component, index) => {
// ✅ 격자 시스템 잔재 제거: size의 픽셀 값만 사용
const widthPx = typeof component.size?.width === 'number'
? component.size.width
: parseInt(String(component.size?.width || 200), 10);
const heightPx = typeof component.size?.height === 'number'
? component.size.height
: parseInt(String(component.size?.height || 40), 10);
// 디버깅: 실제 크기 확인
if (index === 0) {
console.log('🔍 SaveModal 컴포넌트 크기:', {
componentId: component.id,
'size.width (원본)': component.size?.width,
'size.width 타입': typeof component.size?.width,
'widthPx (계산)': widthPx,
'style.width': component.style?.width,
});
}
return (
<div
key={component.id}
style={{
position: "absolute",
top: component.position?.y || 0,
left: component.position?.x || 0,
width: component.size?.width || 200,
height: component.size?.height || 40,
width: `${widthPx}px`, // ✅ 픽셀 단위 강제
height: `${heightPx}px`, // ✅ 픽셀 단위 강제
zIndex: component.position?.z || 1000 + index,
}}
>
@ -307,14 +348,15 @@ export const SaveModal: React.FC<SaveModalProps> = ({
/>
)}
</div>
))}
);
})}
</div>
</div>
) : (
<div className="text-muted-foreground py-12 text-center"> .</div>
)}
</div>
</DialogContent>
</Dialog>
</ResizableDialogContent>
</ResizableDialog>
);
};

View File

@ -25,18 +25,44 @@ import {
restoreAbsolutePositions,
} from "@/lib/utils/groupingUtils";
import {
calculateGridInfo,
snapToGrid,
snapSizeToGrid,
generateGridLines,
updateSizeFromGridColumns,
adjustGridColumnsFromSize,
alignGroupChildrenToGrid,
calculateOptimalGroupSize,
normalizeGroupChildPositions,
updateSizeFromGridColumns,
calculateWidthFromColumns,
GridSettings as GridUtilSettings,
snapSizeToGrid,
snapToGrid,
} from "@/lib/utils/gridUtils";
// 10px 단위 스냅 함수
const snapTo10px = (value: number): number => {
return Math.round(value / 10) * 10;
};
const snapPositionTo10px = (position: Position): Position => {
return {
x: snapTo10px(position.x),
y: snapTo10px(position.y),
z: position.z,
};
};
const snapSizeTo10px = (size: { width: number; height: number }): { width: number; height: number } => {
return {
width: snapTo10px(size.width),
height: snapTo10px(size.height),
};
};
// calculateGridInfo 더미 함수 (하위 호환성을 위해 유지)
const calculateGridInfo = (width: number, height: number, settings: any) => {
return {
columnWidth: 10,
totalWidth: width,
totalHeight: height,
columns: settings.columns || 12,
gap: settings.gap || 0,
padding: settings.padding || 0,
};
};
import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
@ -57,7 +83,6 @@ import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
import { ComponentsPanel } from "./panels/ComponentsPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import GridPanel from "./panels/GridPanel";
import ResolutionPanel from "./panels/ResolutionPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
@ -281,55 +306,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const canvasRef = useRef<HTMLDivElement>(null);
// 격자 정보 계산
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const gridInfo = useMemo(() => {
if (!layout.gridSettings) return null;
// 캔버스 크기 계산 (해상도 설정 우선)
let width = screenResolution.width;
let height = screenResolution.height;
// 해상도가 설정되지 않은 경우 기본값 사용
if (!width || !height) {
width = canvasSize.width || window.innerWidth - 100;
height = canvasSize.height || window.innerHeight - 200;
}
const newGridInfo = calculateGridInfo(width, height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
return newGridInfo;
}, [layout.gridSettings, screenResolution]);
// 격자 라인 생성
// 10px 격자 라인 생성 (시각적 가이드용)
const gridLines = useMemo(() => {
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
if (!layout.gridSettings?.showGrid) return [];
// 캔버스 크기는 해상도 크기 사용
const width = screenResolution.width;
const height = screenResolution.height;
const lines: Array<{ type: "vertical" | "horizontal"; position: number }> = [];
const lines = generateGridLines(width, height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
// 10px 단위로 격자 라인 생성
for (let x = 0; x <= width; x += 10) {
lines.push({ type: "vertical", position: x });
}
for (let y = 0; y <= height; y += 10) {
lines.push({ type: "horizontal", position: y });
}
// 수직선과 수평선을 하나의 배열로 합치기
const allLines = [
...lines.verticalLines.map((pos) => ({ type: "vertical" as const, position: pos })),
...lines.horizontalLines.map((pos) => ({ type: "horizontal" as const, position: pos })),
];
return allLines;
}, [gridInfo, layout.gridSettings, screenResolution]);
return lines;
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
// 필터된 테이블 목록
const filteredTables = useMemo(() => {
@ -527,64 +521,61 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const finalKey = pathParts[pathParts.length - 1];
current[finalKey] = value;
// gridColumns 변경 시 크기 자동 업데이트
if (path === "gridColumns" && gridInfo) {
const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = updatedSize;
}
// gridColumns 변경 시 크기 자동 업데이트 제거 (격자 시스템 제거됨)
// if (path === "gridColumns" && prevLayout.gridSettings) {
// const updatedSize = updateSizeFromGridColumns(newComp, prevLayout.gridSettings as GridUtilSettings);
// newComp.size = updatedSize;
// }
// 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외)
if (
(path === "size.width" || path === "size.height") &&
prevLayout.gridSettings?.snapToGrid &&
gridInfo &&
newComp.type !== "group"
) {
// 현재 해상도에 맞는 격자 정보로 스냅 적용
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: prevLayout.gridSettings.columns,
gap: prevLayout.gridSettings.gap,
padding: prevLayout.gridSettings.padding,
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
});
const snappedSize = snapSizeToGrid(
newComp.size,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
newComp.size = snappedSize;
// 크기 변경 시 격자 스냅 적용 제거 (직접 입력 시 불필요)
// 드래그/리사이즈 시에는 별도 로직에서 처리됨
// if (
// (path === "size.width" || path === "size.height") &&
// prevLayout.gridSettings?.snapToGrid &&
// newComp.type !== "group"
// ) {
// const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
// columns: prevLayout.gridSettings.columns,
// gap: prevLayout.gridSettings.gap,
// padding: prevLayout.gridSettings.padding,
// snapToGrid: prevLayout.gridSettings.snapToGrid || false,
// });
// const snappedSize = snapSizeToGrid(
// newComp.size,
// currentGridInfo,
// prevLayout.gridSettings as GridUtilSettings,
// );
// newComp.size = snappedSize;
//
// const adjustedColumns = adjustGridColumnsFromSize(
// newComp,
// currentGridInfo,
// prevLayout.gridSettings as GridUtilSettings,
// );
// if (newComp.gridColumns !== adjustedColumns) {
// newComp.gridColumns = adjustedColumns;
// }
// }
// 크기 변경 시 gridColumns도 자동 조정
const adjustedColumns = adjustGridColumnsFromSize(
newComp,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
if (newComp.gridColumns !== adjustedColumns) {
newComp.gridColumns = adjustedColumns;
}
}
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정
if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: prevLayout.gridSettings.columns,
gap: prevLayout.gridSettings.gap,
padding: prevLayout.gridSettings.padding,
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
});
// gridColumns에 맞는 정확한 너비 계산
const newWidth = calculateWidthFromColumns(
newComp.gridColumns,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
newComp.size = {
...newComp.size,
width: newWidth,
};
}
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정 제거 (격자 시스템 제거됨)
// if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
// const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
// columns: prevLayout.gridSettings.columns,
// gap: prevLayout.gridSettings.gap,
// padding: prevLayout.gridSettings.padding,
// snapToGrid: prevLayout.gridSettings.snapToGrid || false,
// });
//
// const newWidth = calculateWidthFromColumns(
// newComp.gridColumns,
// currentGridInfo,
// prevLayout.gridSettings as GridUtilSettings,
// );
// newComp.size = {
// ...newComp.size,
// width: newWidth,
// };
// }
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
if (
@ -634,7 +625,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
} else if (newComp.type !== "group") {
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
const snappedPosition = snapToGrid(
const snappedPosition = snapPositionTo10px(
newComp.position,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
@ -684,7 +675,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return newLayout;
});
},
[gridInfo, saveToHistory], // 🔧 layout, selectedComponent 제거!
[saveToHistory],
);
// 컴포넌트 시스템 초기화
@ -899,9 +890,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
layoutToUse = safeMigrateLayout(response, canvasWidth);
}
// 🔄 webTypeConfig를 autoGeneration으로 변환
const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter");
const convertedComponents = convertLayoutComponents(layoutToUse.components);
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
const layoutWithDefaultGrid = {
...layoutToUse,
components: convertedComponents, // 변환된 컴포넌트 사용
gridSettings: {
columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12
gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16
@ -1088,7 +1084,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid,
snapToGrid: true, // 항상 10px 스냅 활성화
};
const adjustedComponents = layout.components.map((comp) => {
@ -1203,7 +1199,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid,
snapToGrid: true,
};
finalComponents = scaledComponents.map((comp) => {
@ -1273,7 +1269,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid,
snapToGrid: true,
};
const adjustedComponents = layout.components.map((comp) => {
@ -1445,7 +1441,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 격자 스냅 적용
const finalPosition =
layout.gridSettings?.snapToGrid && currentGridInfo
? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
? snapPositionTo10px(
{ x: absoluteX, y: absoluteY, z: 1 },
currentGridInfo,
layout.gridSettings as GridUtilSettings,
)
: { x: absoluteX, y: absoluteY, z: 1 };
if (templateComp.type === "container") {
@ -1511,7 +1511,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
templateSize: templateComp.size,
calculatedSize,
hasGridInfo: !!currentGridInfo,
hasGridSettings: !!layout.gridSettings?.snapToGrid,
hasGridSettings: !!layout.gridSettings,
});
return {
@ -1802,7 +1802,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
},
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
[layout, selectedScreen, saveToHistory],
);
// 레이아웃 드래그 처리
@ -1811,8 +1811,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dropX = e.clientX - rect.left;
const dropY = e.clientY - rect.top;
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산
const dropX = (e.clientX - rect.left) / zoomLevel;
const dropY = (e.clientY - rect.top) / zoomLevel;
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = layout.gridSettings
@ -1830,9 +1831,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
: { x: dropX, y: dropY, z: 1 };
console.log("🏗️ 레이아웃 드롭:", {
console.log("🏗️ 레이아웃 드롭 (줌 보정):", {
zoomLevel,
layoutType: layoutData.layoutType,
zonesCount: layoutData.zones.length,
mouseRaw: { x: e.clientX - rect.left, y: e.clientY - rect.top },
dropPosition: { x: dropX, y: dropY },
snappedPosition,
});
@ -1869,7 +1872,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
},
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory],
[layout, screenResolution, saveToHistory, zoomLevel],
);
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
@ -1954,32 +1957,47 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const componentWidth = component.defaultSize?.width || 120;
const componentHeight = component.defaultSize?.height || 36;
// 방법 1: 마우스 포인터를 컴포넌트 중심으로 (현재 방식)
const dropX_centered = e.clientX - rect.left - componentWidth / 2;
const dropY_centered = e.clientY - rect.top - componentHeight / 2;
// 🔥 중요: 줌 레벨과 transform-origin을 고려한 마우스 위치 계산
// 1. 캔버스가 scale() 변환되어 있음 (transform-origin: top center)
// 2. 캔버스가 justify-center로 중앙 정렬되어 있음
// 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 (사용자가 원할 수도 있는 방식)
const dropX_topleft = e.clientX - rect.left;
const dropY_topleft = e.clientY - rect.top;
// 실제 캔버스 논리적 크기
const canvasLogicalWidth = screenResolution.width;
// 화면상 캔버스 실제 크기 (스케일 적용 후)
const canvasVisualWidth = canvasLogicalWidth * zoomLevel;
// 중앙 정렬로 인한 왼쪽 오프셋 계산
// rect.left는 이미 중앙 정렬된 위치를 반영하고 있음
// 마우스의 캔버스 내 상대 위치 (스케일 보정)
const mouseXInCanvas = (e.clientX - rect.left) / zoomLevel;
const mouseYInCanvas = (e.clientY - rect.top) / zoomLevel;
// 방법 1: 마우스 포인터를 컴포넌트 중심으로
const dropX_centered = mouseXInCanvas - componentWidth / 2;
const dropY_centered = mouseYInCanvas - componentHeight / 2;
// 방법 2: 마우스 포인터를 컴포넌트 좌상단으로
const dropX_topleft = mouseXInCanvas;
const dropY_topleft = mouseYInCanvas;
// 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록
const dropX = dropX_topleft;
const dropY = dropY_topleft;
console.log("🎯 위치 계산 디버깅:", {
"1. 마우스 위치": { clientX: e.clientX, clientY: e.clientY },
"2. 캔버스 위치": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
"3. 캔버스 내 상대 위치": { x: e.clientX - rect.left, y: e.clientY - rect.top },
"4. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
"5a. 중심 방식 좌상단": { x: dropX_centered, y: dropY_centered },
"5b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
"6. 선택된 방식": { dropX, dropY },
"7. 예상 컴포넌트 중심": { x: dropX + componentWidth / 2, y: dropY + componentHeight / 2 },
"8. 마우스와 중심 일치 확인": {
match:
Math.abs(dropX + componentWidth / 2 - (e.clientX - rect.left)) < 1 &&
Math.abs(dropY + componentHeight / 2 - (e.clientY - rect.top)) < 1,
},
console.log("🎯 위치 계산 디버깅 (줌 레벨 + 중앙정렬 반영):", {
"1. 줌 레벨": zoomLevel,
"2. 마우스 위치 (화면)": { clientX: e.clientX, clientY: e.clientY },
"3. 캔버스 위치 (rect)": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
"4. 캔버스 논리적 크기": { width: canvasLogicalWidth, height: screenResolution.height },
"5. 캔버스 시각적 크기": { width: canvasVisualWidth, height: screenResolution.height * zoomLevel },
"6. 마우스 캔버스 내 상대위치 (줌 전)": { x: e.clientX - rect.left, y: e.clientY - rect.top },
"7. 마우스 캔버스 내 상대위치 (줌 보정)": { x: mouseXInCanvas, y: mouseYInCanvas },
"8. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
"9a. 중심 방식": { x: dropX_centered, y: dropY_centered },
"9b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
"10. 최종 선택": { dropX, dropY },
});
// 현재 해상도에 맞는 격자 정보 계산
@ -1999,7 +2017,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && currentGridInfo
? snapToGrid({ x: boundedX, y: boundedY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
? snapPositionTo10px(
{ x: boundedX, y: boundedY, z: 1 },
currentGridInfo,
layout.gridSettings as GridUtilSettings,
)
: { x: boundedX, y: boundedY, z: 1 };
console.log("🧩 컴포넌트 드롭:", {
@ -2108,21 +2130,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
});
}
// 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산
if (layout.gridSettings?.snapToGrid && gridInfo) {
// gridColumns에 맞는 정확한 너비 계산
const calculatedWidth = calculateWidthFromColumns(
gridColumns,
gridInfo,
layout.gridSettings as GridUtilSettings,
);
// 컴포넌트별 최소 크기 보장
const minWidth = isTableList ? 120 : isCardDisplay ? 400 : component.defaultSize.width;
// 10px 단위로 너비 스냅
if (layout.gridSettings?.snapToGrid) {
componentSize = {
...component.defaultSize,
width: Math.max(calculatedWidth, minWidth),
width: snapTo10px(component.defaultSize.width),
height: snapTo10px(component.defaultSize.height),
};
}
@ -2211,7 +2224,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
},
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
[layout, selectedScreen, saveToHistory],
);
// 드래그 앤 드롭 처리
@ -2286,74 +2299,44 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
} else if (type === "column") {
// console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 격자 스냅이 활성화된 경우 정확한 격자 크기로 생성, 아니면 기본값
const defaultWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings)
: 200;
console.log("🎯 컴포넌트 생성 시 크기 계산:", {
screenResolution: `${screenResolution.width}x${screenResolution.height}`,
gridSettings: layout.gridSettings,
currentGridInfo: currentGridInfo
? {
columnWidth: currentGridInfo.columnWidth.toFixed(2),
totalWidth: currentGridInfo.totalWidth,
}
: null,
defaultWidth: defaultWidth.toFixed(2),
snapToGrid: layout.gridSettings?.snapToGrid,
});
// 웹타입별 기본 그리드 컬럼 수 계산
const getDefaultGridColumns = (widgetType: string): number => {
// 웹타입별 기본 너비 계산 (10px 단위 고정)
const getDefaultWidth = (widgetType: string): number => {
const widthMap: Record<string, number> = {
// 텍스트 입력 계열 (넓게)
text: 4, // 1/3 (33%)
email: 4, // 1/3 (33%)
tel: 3, // 1/4 (25%)
url: 4, // 1/3 (33%)
textarea: 6, // 절반 (50%)
// 텍스트 입력 계열
text: 200,
email: 200,
tel: 150,
url: 250,
textarea: 300,
// 숫자/날짜 입력 (중간)
number: 2, // 2/12 (16.67%)
decimal: 2, // 2/12 (16.67%)
date: 3, // 1/4 (25%)
datetime: 3, // 1/4 (25%)
time: 2, // 2/12 (16.67%)
// 숫자/날짜 입력
number: 120,
decimal: 120,
date: 150,
datetime: 180,
time: 120,
// 선택 입력 (중간)
select: 3, // 1/4 (25%)
radio: 3, // 1/4 (25%)
checkbox: 2, // 2/12 (16.67%)
boolean: 2, // 2/12 (16.67%)
// 선택 입력
select: 180,
radio: 180,
checkbox: 120,
boolean: 120,
// 코드/참조 (넓게)
code: 3, // 1/4 (25%)
entity: 4, // 1/3 (33%)
// 코드/참조
code: 180,
entity: 200,
// 파일/이미지 (넓게)
file: 4, // 1/3 (33%)
image: 3, // 1/4 (25%)
// 파일/이미지
file: 250,
image: 200,
// 기타
button: 2, // 2/12 (16.67%)
label: 2, // 2/12 (16.67%)
button: 100,
label: 100,
};
const defaultColumns = widthMap[widgetType] || 3; // 기본값 3 (1/4, 25%)
console.log("🎯 [ScreenDesigner] getDefaultGridColumns:", { widgetType, defaultColumns });
return defaultColumns;
return widthMap[widgetType] || 200; // 기본값 200px
};
// 웹타입별 기본 높이 계산
@ -2365,7 +2348,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
file: 240, // 파일 업로드 (40 * 6)
};
return heightMap[widgetType] || 40; // 기본값 40
return heightMap[widgetType] || 30; // 기본값 30px로 변경
};
// 웹타입별 기본 설정 생성
@ -2521,24 +2504,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const componentId = getComponentIdFromWebType(column.widgetType);
// console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
// 웹타입별 적절한 gridColumns 계산
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
// gridColumns에 맞는 실제 너비 계산
const componentWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(
calculatedGridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
)
: defaultWidth;
// 웹타입별 기본 너비 계산 (10px 단위 고정)
const componentWidth = getDefaultWidth(column.widgetType);
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
widgetType: column.widgetType,
calculatedGridColumns,
componentWidth,
defaultWidth,
});
newComponent = {
@ -2553,25 +2524,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
gridColumns: calculatedGridColumns,
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
style: {
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelDisplay: false, // 라벨 숨김
labelFontSize: "12px",
labelColor: "#212121",
labelFontWeight: "500",
labelMarginBottom: "6px",
width: `${(calculatedGridColumns / (layout.gridSettings?.columns || 12)) * 100}%`, // 퍼센트 너비
},
componentConfig: {
type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType),
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -2587,36 +2557,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const componentId = getComponentIdFromWebType(column.widgetType);
// console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
// 웹타입별 적절한 gridColumns 계산
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
// gridColumns에 맞는 실제 너비 계산
const componentWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(
calculatedGridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
)
: defaultWidth;
// 웹타입별 기본 너비 계산 (10px 단위 고정)
const componentWidth = getDefaultWidth(column.widgetType);
console.log("🎯 캔버스 컴포넌트 생성:", {
widgetType: column.widgetType,
calculatedGridColumns,
componentWidth,
defaultWidth,
});
// 🔍 이미지 타입 드래그앤드롭 디버깅
// if (column.widgetType === "image") {
// console.log("🖼️ 이미지 컬럼 드래그앤드롭:", {
// columnName: column.columnName,
// widgetType: column.widgetType,
// componentId,
// column,
// });
// }
newComponent = {
id: generateComponentId(),
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
@ -2628,25 +2576,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
gridColumns: calculatedGridColumns,
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
style: {
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
labelDisplay: false, // 라벨 숨김
labelFontSize: "14px",
labelColor: "#000000", // 순수한 검정
labelFontWeight: "500",
labelMarginBottom: "8px",
width: `${(calculatedGridColumns / (layout.gridSettings?.columns || 12)) * 100}%`, // 퍼센트 너비
},
componentConfig: {
type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType),
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -2659,31 +2606,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return;
}
// 격자 스냅 적용 (그룹 컴포넌트 제외)
// 10px 단위 스냅 적용 (그룹 컴포넌트 제외)
if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") {
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
newComponent.position = snapPositionTo10px(newComponent.position);
newComponent.size = snapSizeTo10px(newComponent.size);
const gridUtilSettings = {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
};
newComponent.position = snapToGrid(newComponent.position, currentGridInfo, gridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, currentGridInfo, gridUtilSettings);
console.log("🧲 새 컴포넌트 격자 스냅 적용:", {
console.log("🧲 새 컴포넌트 10px 스냅 적용:", {
type: newComponent.type,
resolution: `${screenResolution.width}x${screenResolution.height}`,
snappedPosition: newComponent.position,
snappedSize: newComponent.size,
columnWidth: currentGridInfo.columnWidth,
});
}
@ -2710,7 +2641,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// console.error("드롭 처리 실패:", error);
}
},
[layout, gridInfo, saveToHistory],
[layout, saveToHistory],
);
// 파일 컴포넌트 업데이트 처리
@ -2826,7 +2757,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 컴포넌트 드래그 시작
const startComponentDrag = useCallback(
(component: ComponentData, event: React.MouseEvent) => {
(component: ComponentData, event: React.MouseEvent | React.DragEvent) => {
event.preventDefault();
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
@ -2839,9 +2770,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}));
}
// 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
const relativeMouseX = event.clientX - rect.left;
const relativeMouseY = event.clientY - rect.top;
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산
// 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요
const relativeMouseX = (event.clientX - rect.left) / zoomLevel;
const relativeMouseY = (event.clientY - rect.top) / zoomLevel;
// 다중 선택된 컴포넌트들 확인
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
@ -2866,13 +2798,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
// console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
console.log("마우스 위치:", {
console.log("마우스 위치 (줌 보정):", {
zoomLevel,
clientX: event.clientX,
clientY: event.clientY,
rectLeft: rect.left,
rectTop: rect.top,
relativeX: relativeMouseX,
relativeY: relativeMouseY,
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
componentX: component.position.x,
componentY: component.position.y,
grabOffsetX: relativeMouseX - component.position.x,
@ -2906,7 +2839,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
justFinishedDrag: false,
});
},
[groupState.selectedComponents, layout.components, dragState.justFinishedDrag],
[groupState.selectedComponents, layout.components, dragState.justFinishedDrag, zoomLevel],
);
// 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트)
@ -2916,9 +2849,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const rect = canvasRef.current.getBoundingClientRect();
// 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
const relativeMouseX = event.clientX - rect.left;
const relativeMouseY = event.clientY - rect.top;
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산
// 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요
const relativeMouseX = (event.clientX - rect.left) / zoomLevel;
const relativeMouseY = (event.clientY - rect.top) / zoomLevel;
// 컴포넌트 크기 가져오기
const draggedComp = layout.components.find((c) => c.id === dragState.draggedComponent.id);
@ -2936,8 +2870,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
// 드래그 상태 업데이트
console.log("🔥 ScreenDesigner updateDragPosition:", {
console.log("🔥 ScreenDesigner updateDragPosition (줌 보정):", {
zoomLevel,
draggedComponentId: dragState.draggedComponent.id,
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
oldPosition: dragState.currentPosition,
newPosition: newPosition,
});
@ -2961,7 +2898,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 실제 레이아웃 업데이트는 endDrag에서 처리
// 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시
},
[dragState.isDragging, dragState.draggedComponent, dragState.grabOffset],
[dragState.isDragging, dragState.draggedComponent, dragState.grabOffset, zoomLevel],
);
// 드래그 종료
@ -2983,7 +2920,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외)
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
finalPosition = snapToGrid(
finalPosition = snapPositionTo10px(
{
x: dragState.currentPosition.x,
y: dragState.currentPosition.y,
@ -3143,7 +3080,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
justFinishedDrag: false,
}));
}, 100);
}, [dragState, layout, gridInfo, saveToHistory]);
}, [dragState, layout, saveToHistory]);
// 드래그 선택 시작
const startSelectionDrag = useCallback(
@ -3638,8 +3575,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
console.log("🔧 그룹 생성 시작:", {
selectedCount: selectedComponents.length,
snapToGrid: layout.gridSettings?.snapToGrid,
gridInfo: currentGridInfo,
snapToGrid: true,
});
// 컴포넌트 크기 조정 기반 그룹 크기 계산
@ -3803,12 +3739,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
size: optimizedGroupSize,
gridColumns: groupComponent.gridColumns,
componentsScaled: !!scaledComponents.length,
gridAligned: layout.gridSettings?.snapToGrid,
gridAligned: true,
});
toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`);
},
[layout, saveToHistory, gridInfo],
[layout, saveToHistory],
);
// 그룹 생성 함수 (다이얼로그 표시)
@ -3904,36 +3840,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
endSelectionDrag,
]);
// 캔버스 크기 초기화 및 리사이즈 이벤트 처리
useEffect(() => {
const updateCanvasSize = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
}
};
// 초기 크기 설정
updateCanvasSize();
// 리사이즈 이벤트 리스너
window.addEventListener("resize", updateCanvasSize);
return () => window.removeEventListener("resize", updateCanvasSize);
}, []);
// 컴포넌트 마운트 후 캔버스 크기 업데이트
useEffect(() => {
const timer = setTimeout(() => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
}
}, 100);
return () => clearTimeout(timer);
}, [selectedScreen]);
// 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단)
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
@ -4222,7 +4128,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
return (
<ScreenPreviewProvider isPreviewMode={true}>
<ScreenPreviewProvider isPreviewMode={false}>
<div className="bg-background flex h-full w-full flex-col">
{/* 상단 슬림 툴바 */}
<SlimToolbar
@ -4335,7 +4241,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return (
<div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg">
<div className="flex flex-col gap-2 p-3">
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
<div className="text-muted-foreground mb-1 flex items-center gap-2 text-xs">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
@ -4416,7 +4322,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
</div>
);
})()}
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
<div
className="flex justify-center"
style={{
@ -4435,7 +4341,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
minHeight: `${screenResolution.height}px`,
flexShrink: 0,
transform: `scale(${zoomLevel})`,
transformOrigin: "top center",
transformOrigin: "top center", // 중앙 기준으로 스케일
}}
>
<div

View File

@ -752,17 +752,27 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
// console.log("🎨 selectedComponent 전체:", selectedComponent);
const handleConfigChange = (newConfig: WebTypeConfig) => {
// console.log("🔧 WebTypeConfig 업데이트:", {
// widgetType: widget.widgetType,
// oldConfig: currentConfig,
// newConfig,
// componentId: widget.id,
// isEqual: JSON.stringify(currentConfig) === JSON.stringify(newConfig),
// });
// 강제 새 객체 생성으로 React 변경 감지 보장
const freshConfig = { ...newConfig };
onUpdateProperty(widget.id, "webTypeConfig", freshConfig);
// TextTypeConfig의 자동입력 설정을 autoGeneration으로도 매핑
const textConfig = newConfig as any;
if (textConfig.autoInput && textConfig.autoValueType === "numbering_rule" && textConfig.numberingRuleId) {
onUpdateProperty(widget.id, "autoGeneration", {
type: "numbering_rule",
enabled: true,
options: {
numberingRuleId: textConfig.numberingRuleId,
},
});
} else if (textConfig.autoInput === false) {
// 자동입력이 비활성화되면 autoGeneration도 비활성화
onUpdateProperty(widget.id, "autoGeneration", {
type: "none",
enabled: false,
});
}
};
// 1순위: DB에서 지정된 설정 패널 사용
@ -776,7 +786,13 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
if (ConfigPanelComponent) {
// console.log(`🎨 ✅ ConfigPanelComponent 렌더링 시작`);
return <ConfigPanelComponent config={currentConfig} onConfigChange={handleConfigChange} />;
return (
<ConfigPanelComponent
config={currentConfig}
onConfigChange={handleConfigChange}
tableName={currentTableName} // 화면 테이블명 전달
/>
);
} else {
// console.log(`🎨 ❌ ConfigPanelComponent가 null - WebTypeConfigPanel 사용`);
return (

View File

@ -7,23 +7,20 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap, RefreshCw } from "lucide-react";
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
import { GridSettings, ScreenResolution } from "@/types/screen";
import { calculateGridInfo } from "@/lib/utils/gridUtils";
interface GridPanelProps {
gridSettings: GridSettings;
onGridSettingsChange: (settings: GridSettings) => void;
onResetGrid: () => void;
onForceGridUpdate?: () => void; // 강제 격자 재조정 추가
screenResolution?: ScreenResolution; // 해상도 정보 추가
screenResolution?: ScreenResolution;
}
export const GridPanel: React.FC<GridPanelProps> = ({
gridSettings,
onGridSettingsChange,
onResetGrid,
onForceGridUpdate,
screenResolution,
}) => {
const updateSetting = (key: keyof GridSettings, value: any) => {
@ -33,25 +30,6 @@ export const GridPanel: React.FC<GridPanelProps> = ({
});
};
// 실제 격자 정보 계산
const actualGridInfo = screenResolution
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: gridSettings.columns,
gap: gridSettings.gap,
padding: gridSettings.padding,
snapToGrid: gridSettings.snapToGrid || false,
})
: null;
// 실제 표시되는 컬럼 수 계산 (항상 설정된 개수를 표시하되, 너비가 너무 작으면 경고)
const actualColumns = gridSettings.columns;
// 컬럼이 너무 작은지 확인
const isColumnsTooSmall =
screenResolution && actualGridInfo
? actualGridInfo.columnWidth < 30 // 30px 미만이면 너무 작다고 판단
: false;
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
@ -62,26 +40,11 @@ export const GridPanel: React.FC<GridPanelProps> = ({
<h3 className="text-sm font-semibold"> </h3>
</div>
<div className="flex items-center gap-1.5">
{onForceGridUpdate && (
<Button
size="sm"
variant="outline"
onClick={onForceGridUpdate}
className="h-7 px-2 text-xs" style={{ fontSize: "12px" }}
title="현재 해상도에 맞게 모든 컴포넌트를 격자에 재정렬합니다"
>
<RefreshCw className="mr-1 h-3 w-3" />
</Button>
)}
<Button size="sm" variant="outline" onClick={onResetGrid} className="h-7 px-2 text-xs">
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 주요 토글들 */}
<div className="space-y-2.5">
@ -121,82 +84,14 @@ export const GridPanel: React.FC<GridPanelProps> = ({
{/* 설정 영역 */}
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* 격자 구조 */}
{/* 10px 단위 스냅 안내 */}
<div className="space-y-3">
<h4 className="text-xs font-semibold"> </h4>
<h4 className="text-xs font-semibold"> </h4>
<div className="space-y-2">
<Label htmlFor="columns" className="text-xs font-medium">
</Label>
<div className="flex items-center gap-2">
<Input
id="columns"
type="number"
min={1}
max={24}
value={gridSettings.columns}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= 24) {
updateSetting("columns", value);
}
}}
className="h-8 text-xs"
/>
<span className="text-muted-foreground text-xs">/ 24</span>
</div>
<Slider
id="columns-slider"
min={1}
max={24}
step={1}
value={[gridSettings.columns]}
onValueChange={([value]) => updateSetting("columns", value)}
className="w-full"
/>
<div className="text-muted-foreground flex justify-between text-xs">
<span>1</span>
<span>24</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="gap" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.gap}px</span>
</Label>
<Slider
id="gap"
min={0}
max={40}
step={2}
value={[gridSettings.gap]}
onValueChange={([value]) => updateSetting("gap", value)}
className="w-full"
/>
<div className="text-muted-foreground flex justify-between text-xs">
<span>0px</span>
<span>40px</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="padding" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.padding}px</span>
</Label>
<Slider
id="padding"
min={0}
max={60}
step={4}
value={[gridSettings.padding]}
onValueChange={([value]) => updateSetting("padding", value)}
className="w-full"
/>
<div className="text-muted-foreground flex justify-between text-xs">
<span>0px</span>
<span>60px</span>
</div>
<div className="bg-muted/50 rounded-md p-3">
<p className="text-xs text-muted-foreground">
10px .
</p>
</div>
</div>
@ -204,10 +99,10 @@ export const GridPanel: React.FC<GridPanelProps> = ({
{/* 격자 스타일 */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900"> </h4>
<h4 className="text-xs font-semibold"> </h4>
<div>
<Label htmlFor="gridColor" className="text-sm font-medium">
<Label htmlFor="gridColor" className="text-xs font-medium">
</Label>
<div className="mt-1 flex items-center space-x-2">
@ -223,13 +118,13 @@ export const GridPanel: React.FC<GridPanelProps> = ({
value={gridSettings.gridColor || "#d1d5db"}
onChange={(e) => updateSetting("gridColor", e.target.value)}
placeholder="#d1d5db"
className="flex-1"
className="flex-1 text-xs"
/>
</div>
</div>
<div>
<Label htmlFor="gridOpacity" className="mb-2 block text-sm font-medium">
<Label htmlFor="gridOpacity" className="mb-2 block text-xs font-medium">
: {Math.round((gridSettings.gridOpacity || 0.5) * 100)}%
</Label>
<Slider
@ -241,7 +136,7 @@ export const GridPanel: React.FC<GridPanelProps> = ({
onValueChange={([value]) => updateSetting("gridOpacity", value)}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>10%</span>
<span>100%</span>
</div>
@ -252,38 +147,33 @@ export const GridPanel: React.FC<GridPanelProps> = ({
{/* 미리보기 */}
<div className="space-y-3">
<h4 className="font-medium text-gray-900"></h4>
<h4 className="text-xs font-semibold"></h4>
<div
className="rounded-md border border-gray-200 bg-white p-4"
className="rounded-md border bg-white p-4"
style={{
backgroundImage: gridSettings.showGrid
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
: "none",
backgroundSize: gridSettings.showGrid ? `${100 / gridSettings.columns}% 20px` : "none",
backgroundSize: gridSettings.showGrid ? "10px 10px" : "none",
opacity: gridSettings.gridOpacity || 0.5,
}}
>
<div className="bg-primary/20 flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300">
<span className="text-primary text-xs"> </span>
<span className="text-primary text-xs">10px </span>
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="border-t border-gray-200 bg-gray-50 p-3">
<div className="text-muted-foreground text-xs">💡 </div>
<div className="border-t bg-muted/30 p-3">
{screenResolution && (
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
{/* 해상도 및 격자 정보 */}
{screenResolution && actualGridInfo && (
<>
<Separator />
<div className="space-y-3">
<h4 className="font-medium text-gray-900"> </h4>
<div className="space-y-2 text-xs" style={{ fontSize: "12px" }}>
<div className="space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-mono">
@ -292,28 +182,11 @@ export const GridPanel: React.FC<GridPanelProps> = ({
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className={`font-mono ${isColumnsTooSmall ? "text-destructive" : "text-gray-900"}`}>
{actualGridInfo.columnWidth.toFixed(1)}px
{isColumnsTooSmall && " (너무 작음)"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-mono">
{(screenResolution.width - gridSettings.padding * 2).toLocaleString()}px
</span>
</div>
{isColumnsTooSmall && (
<div className="rounded-md bg-yellow-50 p-2 text-xs text-yellow-800">
💡 . .
</div>
)}
<span className="text-muted-foreground"> :</span>
<span className="font-mono text-primary">10px</span>
</div>
</div>
</div>
</>
)}
</div>
</div>

View File

@ -53,12 +53,22 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
onDragStart,
placedColumns = new Set(),
}) => {
// 이미 배치된 컬럼을 제외한 테이블 정보 생성
// 시스템 컬럼 목록 (숨김 처리)
const systemColumns = new Set([
'id',
'created_date',
'updated_date',
'writer',
'company_code'
]);
// 이미 배치된 컬럼과 시스템 컬럼을 제외한 테이블 정보 생성
const tablesWithAvailableColumns = tables.map((table) => ({
...table,
columns: table.columns.filter((col) => {
const columnKey = `${table.tableName}.${col.columnName}`;
return !placedColumns.has(columnKey);
// 시스템 컬럼이거나 이미 배치된 컬럼은 제외
return !systemColumns.has(col.columnName.toLowerCase()) && !placedColumns.has(columnKey);
}),
}));

View File

@ -105,8 +105,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
const { webTypes } = useWebTypes({ active: "Y" });
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
// 높이 입력 로컬 상태 (격자 스냅 방지)
// 높이/너비 입력 로컬 상태 (자유 입력 허용)
const [localHeight, setLocalHeight] = useState<string>("");
const [localWidth, setLocalWidth] = useState<string>("");
// 새로운 컴포넌트 시스템의 webType 동기화
useEffect(() => {
@ -125,6 +126,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}
}, [selectedComponent?.size?.height, selectedComponent?.id]);
// 너비 값 동기화
useEffect(() => {
if (selectedComponent?.size?.width !== undefined) {
setLocalWidth(String(selectedComponent.size.width));
}
}, [selectedComponent?.size?.width, selectedComponent?.id]);
// 격자 설정 업데이트 함수 (early return 이전에 정의)
const updateGridSetting = (key: string, value: any) => {
if (onGridSettingsChange && gridSettings) {
@ -139,6 +147,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
const renderGridSettings = () => {
if (!gridSettings || !onGridSettingsChange) return null;
// 최대 컬럼 수 계산
const MIN_COLUMN_WIDTH = 30;
const maxColumns = currentResolution
? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
: 24;
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
@ -180,65 +195,12 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
/>
</div>
{/* 컬럼 수 */}
<div className="space-y-1">
<Label htmlFor="columns" className="text-xs font-medium">
</Label>
<div className="flex items-center gap-2">
<Input
id="columns"
type="number"
min={1}
step="1"
value={gridSettings.columns}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1) {
updateGridSetting("columns", value);
}
}}
className="h-6 px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
placeholder="1 이상의 숫자"
/>
</div>
<p className="text-muted-foreground text-[10px]">
1
{/* 10px 단위 스냅 안내 */}
<div className="bg-muted/50 rounded-md p-2">
<p className="text-[10px] text-muted-foreground">
10px .
</p>
</div>
{/* 간격 */}
<div className="space-y-1">
<Label htmlFor="gap" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.gap}px</span>
</Label>
<Slider
id="gap"
min={0}
max={40}
step={2}
value={[gridSettings.gap]}
onValueChange={([value]) => updateGridSetting("gap", value)}
className="w-full"
/>
</div>
{/* 여백 */}
<div className="space-y-1">
<Label htmlFor="padding" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.padding}px</span>
</Label>
<Slider
id="padding"
min={0}
max={60}
step={4}
value={[gridSettings.padding]}
onValueChange={([value]) => updateGridSetting("padding", value)}
className="w-full"
/>
</div>
</div>
</div>
);
@ -374,22 +336,26 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
type="number"
value={localHeight}
onChange={(e) => {
// 입력 중에는 로컬 상태만 업데이트 (격자 스냅 방지)
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
setLocalHeight(e.target.value);
}}
onBlur={(e) => {
// 포커스를 잃을 때만 실제로 업데이트
// 포커스를 잃을 때 10px 단위로 스냅
const value = parseInt(e.target.value) || 0;
if (value >= 1) {
handleUpdate("size.height", value);
if (value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.height", snappedValue);
setLocalHeight(String(snappedValue));
}
}}
onKeyDown={(e) => {
// Enter 키를 누르면 즉시 적용
// Enter 키를 누르면 즉시 적용 (10px 단위로 스냅)
if (e.key === "Enter") {
const value = parseInt(e.currentTarget.value) || 0;
if (value >= 1) {
handleUpdate("size.height", value);
if (value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.height", snappedValue);
setLocalHeight(String(snappedValue));
}
e.currentTarget.blur(); // 포커스 제거
}
@ -447,38 +413,47 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
)}
{/* Grid Columns + Z-Index (같은 행) */}
{/* Width + Z-Index (같은 행) */}
<div className="grid grid-cols-2 gap-2">
{(selectedComponent as any).gridColumns !== undefined && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Label className="text-xs"> (px)</Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={1}
max={gridSettings?.columns || 12}
min={10}
max={3840}
step="1"
value={(selectedComponent as any).gridColumns || 1}
value={localWidth}
onChange={(e) => {
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
setLocalWidth(e.target.value);
}}
onBlur={(e) => {
// 포커스를 잃을 때 10px 단위로 스냅
const value = parseInt(e.target.value, 10);
const maxColumns = gridSettings?.columns || 12;
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
handleUpdate("gridColumns", value);
// width를 퍼센트로 계산하여 업데이트
const widthPercent = (value / maxColumns) * 100;
handleUpdate("style.width", `${widthPercent}%`);
if (!isNaN(value) && value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.width", snappedValue);
setLocalWidth(String(snappedValue));
}
}}
onKeyDown={(e) => {
// Enter 키를 누르면 즉시 적용 (10px 단위로 스냅)
if (e.key === "Enter") {
const value = parseInt(e.currentTarget.value, 10);
if (!isNaN(value) && value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.width", snappedValue);
setLocalWidth(String(snappedValue));
}
e.currentTarget.blur(); // 포커스 제거
}
}}
className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
/{gridSettings?.columns || 12}
</span>
</div>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">Z-Index</Label>
<Input

View File

@ -7,15 +7,24 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { TextTypeConfig } from "@/types/screen";
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
interface TextTypeConfigPanelProps {
config: TextTypeConfig;
onConfigChange: (config: TextTypeConfig) => void;
tableName?: string; // 화면의 테이블명 (선택)
menuObjid?: number; // 메뉴 objid (선택)
}
export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config, onConfigChange }) => {
export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
config,
onConfigChange,
tableName,
menuObjid,
}) => {
console.log("🔍 TextTypeConfigPanel 마운트:", { tableName, menuObjid, config });
// 기본값이 설정된 config 사용
const safeConfig = {
minLength: undefined,
@ -54,16 +63,46 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
// 채번 규칙 목록 로드
useEffect(() => {
const loadRules = async () => {
console.log("🔄 채번 규칙 로드 시작:", {
autoValueType: localValues.autoValueType,
tableName,
hasTableName: !!tableName,
});
setLoadingRules(true);
try {
// TODO: 현재 메뉴 objid를 화면 정보에서 가져와야 함
// 지금은 menuObjid 없이 호출 (global 규칙만 조회)
const response = await getAvailableNumberingRules();
let response;
// 테이블명이 있으면 테이블 기반 필터링 사용
if (tableName) {
console.log("📋 테이블 기반 채번 규칙 조회 API 호출:", { tableName });
response = await getAvailableNumberingRulesForScreen(tableName);
console.log("📋 API 응답:", response);
} else {
// 테이블명이 없으면 빈 배열 (테이블 필수)
console.warn("⚠️ 테이블명이 없어 채번 규칙을 조회할 수 없습니다");
setNumberingRules([]);
setLoadingRules(false);
return;
}
if (response.success && response.data) {
setNumberingRules(response.data);
console.log("✅ 채번 규칙 로드 성공:", {
count: response.data.length,
rules: response.data.map((r: any) => ({
ruleId: r.ruleId,
ruleName: r.ruleName,
tableName: r.tableName,
})),
});
} else {
console.warn("⚠️ 채번 규칙 조회 실패:", response.error);
setNumberingRules([]);
}
} catch (error) {
console.error("채번 규칙 목록 로드 실패:", error);
console.error("❌ 채번 규칙 목록 로드 실패:", error);
setNumberingRules([]);
} finally {
setLoadingRules(false);
}
@ -71,9 +110,12 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
// autoValueType이 numbering_rule일 때만 로드
if (localValues.autoValueType === "numbering_rule") {
console.log("✅ autoValueType === 'numbering_rule', 규칙 로드 시작");
loadRules();
} else {
console.log("⏭️ autoValueType !== 'numbering_rule', 규칙 로드 스킵:", localValues.autoValueType);
}
}, [localValues.autoValueType]);
}, [localValues.autoValueType, tableName]);
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {

View File

@ -14,10 +14,17 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
required,
className,
style,
isDesignMode = false, // 디자인 모드 플래그
...restProps
}) => {
const handleClick = () => {
const handleClick = (e: React.MouseEvent) => {
// 디자인 모드에서는 아무것도 하지 않고 그냥 이벤트 전파
if (isDesignMode) {
return;
}
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
// console.log("Button clicked:", config);
console.log("Button clicked:", config);
// onChange를 통해 클릭 이벤트 전달
if (onChange) {
@ -25,6 +32,25 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
}
};
// 디자인 모드에서는 div로 렌더링하여 버튼 동작 완전 차단
if (isDesignMode) {
return (
<div
onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파
className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white ${className || ""} `}
style={{
...style,
width: "100%",
height: "100%",
cursor: "pointer", // 선택 가능하도록 포인터 표시
}}
title={config?.tooltip || placeholder}
>
{config?.label || config?.text || value || placeholder || "버튼"}
</div>
);
}
return (
<button
type="button"

View File

@ -55,6 +55,7 @@ interface ResizableDialogContentProps
modalId?: string; // localStorage 저장용 고유 ID
userId?: string; // 사용자별 저장용
open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
disableFlexLayout?: boolean; // 🆕 flex 레이아웃 비활성화 (absolute 레이아웃용)
}
const ResizableDialogContent = React.forwardRef<
@ -74,6 +75,7 @@ const ResizableDialogContent = React.forwardRef<
modalId,
userId = "guest",
open: externalOpen, // 🆕 외부에서 전달받은 open 상태
disableFlexLayout = false, // 🆕 flex 레이아웃 비활성화
style: userStyle,
...props
},
@ -373,7 +375,11 @@ const ResizableDialogContent = React.forwardRef<
minHeight: `${minHeight}px`,
}}
>
<div ref={contentRef} className="flex flex-col h-full overflow-auto">
<div
ref={contentRef}
className="h-full w-full"
style={{ display: 'block', overflow: 'hidden' }}
>
{children}
</div>

View File

@ -22,7 +22,7 @@ export async function getNumberingRules(): Promise<ApiResponse<NumberingRuleConf
}
/**
*
* ( , )
* @param menuObjid objid ()
* @returns
*/
@ -40,6 +40,27 @@ export async function getAvailableNumberingRules(
}
}
/**
* ( - )
* @param tableName ()
* @returns
*/
export async function getAvailableNumberingRulesForScreen(
tableName: string
): Promise<ApiResponse<NumberingRuleConfig[]>> {
try {
const response = await apiClient.get("/numbering-rules/available-for-screen", {
params: { tableName },
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.message || "화면용 규칙 조회 실패",
};
}
}
export async function getNumberingRuleById(ruleId: string): Promise<ApiResponse<NumberingRuleConfig>> {
try {
const response = await apiClient.get(`/numbering-rules/${ruleId}`);

View File

@ -148,19 +148,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const tableName = (component as any).tableName;
const columnName = (component as any).columnName;
console.log("🔍 DynamicComponentRenderer 컴포넌트 타입 확인:", {
componentId: component.id,
componentType,
inputType,
webType,
tableName,
columnName,
componentConfig: (component as any).componentConfig,
});
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
if ((inputType === "category" || webType === "category") && tableName && columnName) {
console.log("✅ 카테고리 타입 감지 → CategorySelectComponent 렌더링");
try {
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const fieldName = columnName || component.id;
@ -303,14 +292,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
componentType === "split-panel-layout" ||
componentType?.includes("layout");
console.log("🔍 [DynamicComponentRenderer] 높이 처리:", {
componentId: component.id,
componentType,
isLayoutComponent,
hasHeight: !!component.style?.height,
height: component.style?.height
});
const { height: _height, ...styleWithoutHeight } = component.style || {};
// 숨김 값 추출

View File

@ -528,14 +528,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
}
return (
<>
<div style={componentStyle} className={className} {...safeDomProps}>
<button
type={componentConfig.actionType || "button"}
disabled={componentConfig.disabled || false}
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
style={{
// 공통 버튼 스타일
const buttonElementStyle: React.CSSProperties = {
width: "100%",
height: "100%",
minHeight: "40px",
@ -562,14 +556,36 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
...(isInteractive && component.style ? Object.fromEntries(
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
) : {}),
}}
};
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
return (
<>
<div style={componentStyle} className={className} {...safeDomProps}>
{isDesignMode ? (
// 디자인 모드: div로 렌더링하여 선택 가능하게 함
<div
className="transition-colors duration-150 hover:opacity-90"
style={buttonElementStyle}
onClick={handleClick}
>
{buttonContent}
</div>
) : (
// 일반 모드: button으로 렌더링
<button
type={componentConfig.actionType || "button"}
disabled={componentConfig.disabled || false}
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
style={buttonElementStyle}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{/* 🔧 빈 문자열도 허용 (undefined일 때만 기본값 적용) */}
{processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"}
{buttonContent}
</button>
)}
</div>
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}

View File

@ -74,20 +74,12 @@ export const CategorySelectComponent: React.FC<
setError(null);
try {
console.log("📦 카테고리 값 조회:", { tableName, columnName });
const response = await getCategoryValues(tableName, columnName);
if (response.success && response.data) {
// 활성화된 값만 필터링
const activeValues = response.data.filter((v) => v.isActive !== false);
setCategoryValues(activeValues);
console.log("✅ 카테고리 값 조회 성공:", {
total: response.data.length,
active: activeValues.length,
values: activeValues,
});
} else {
setError("카테고리 값을 불러올 수 없습니다");
console.error("❌ 카테고리 값 조회 실패:", response);

View File

@ -273,7 +273,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
// daterange 타입 전용 UI
if (webType === "daterange") {
return (
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
@ -298,7 +298,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
}
}}
className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
@ -325,7 +325,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
}
}}
className={cn(
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
@ -341,7 +341,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
// year 타입 전용 UI (number input with YYYY format)
if (webType === "year") {
return (
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
@ -367,7 +367,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
}
}}
className={cn(
"box-border h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
@ -380,7 +380,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
}
return (
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
@ -401,7 +401,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
required={componentConfig.required || false}
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
className={cn(
"box-border h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",

View File

@ -8,19 +8,24 @@ interface NumberingRuleWrapperProps {
config: NumberingRuleComponentConfig;
onChange?: (config: NumberingRuleComponentConfig) => void;
isPreview?: boolean;
tableName?: string; // 현재 화면의 테이블명
}
export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
config,
onChange,
isPreview = false,
tableName,
}) => {
console.log("📋 NumberingRuleWrapper: 테이블명 전달", { tableName, config });
return (
<div className="h-full w-full">
<NumberingRuleDesigner
maxRules={config.maxRules || 6}
isPreview={isPreview}
className="h-full"
currentTableName={tableName} // 테이블명 전달
/>
</div>
);

View File

@ -160,6 +160,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// searchTerm 제거 - 클라이언트 사이드에서 필터링
});
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
if (leftColumn && result.data.length > 0) {
result.data.sort((a, b) => {
const aValue = String(a[leftColumn] || '');
const bValue = String(b[leftColumn] || '');
return aValue.localeCompare(bValue, 'ko-KR');
});
}
// 계층 구조 빌드
const hierarchicalData = buildHierarchy(result.data);
setLeftData(hierarchicalData);
@ -173,7 +183,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} finally {
setIsLoadingLeft(false);
}
}, [componentConfig.leftPanel?.tableName, isDesignMode, toast, buildHierarchy]);
}, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, buildHierarchy]);
// 우측 데이터 로드
const loadRightData = useCallback(
@ -293,9 +303,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 추가 버튼 핸들러
const handleAddClick = useCallback((panel: "left" | "right") => {
setAddModalPanel(panel);
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
if (panel === "right" && selectedLeftItem && componentConfig.leftPanel?.leftColumn && componentConfig.rightPanel?.rightColumn) {
const leftColumnValue = selectedLeftItem[componentConfig.leftPanel.leftColumn];
setAddModalFormData({
[componentConfig.rightPanel.rightColumn]: leftColumnValue
});
} else {
setAddModalFormData({});
}
setShowAddModal(true);
}, []);
}, [selectedLeftItem, componentConfig]);
// 수정 버튼 핸들러
const handleEditClick = useCallback((panel: "left" | "right", item: any) => {
@ -1316,10 +1336,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return modalColumns?.map((col, index) => {
// 항목별 추가 버튼으로 열렸을 때, parentColumn은 미리 채워져 있고 수정 불가
const isPreFilled = addModalPanel === "left-item"
const isItemAddPreFilled = addModalPanel === "left-item"
&& componentConfig.leftPanel?.itemAddConfig?.parentColumn === col.name
&& addModalFormData[col.name];
// 우측 패널 추가 시, 조인 컬럼(rightColumn)은 미리 채워져 있고 수정 불가
const isRightJoinPreFilled = addModalPanel === "right"
&& componentConfig.rightPanel?.rightColumn === col.name
&& addModalFormData[col.name];
const isPreFilled = isItemAddPreFilled || isRightJoinPreFilled;
return (
<div key={index}>
<Label htmlFor={col.name} className="text-xs sm:text-sm">

View File

@ -9,6 +9,7 @@ import { Slider } from "@/components/ui/slider";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { SplitPanelLayoutConfig } from "./types";
@ -284,7 +285,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
console.log(" - availableRightTables:", availableRightTables.length, "개");
return (
<div className="space-y-6">
<div className="space-y-4">
{/* 관계 타입 선택 */}
<div className="space-y-3">
<h3 className="text-sm font-semibold"> </h3>
@ -324,9 +325,14 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</Select>
</div>
{/* 좌측 패널 설정 (마스터) */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> ()</h3>
{/* 좌측 패널 설정 (Accordion) */}
<Accordion type="single" collapsible defaultValue="left-panel" className="w-full">
<AccordionItem value="left-panel" className="border rounded-lg px-4">
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
()
</AccordionTrigger>
<AccordionContent className="overflow-visible">
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label> </Label>
@ -808,10 +814,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* 우측 패널 설정 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> ({relationshipType === "detail" ? "상세" : "조인"})</h3>
{/* 우측 패널 설정 (Accordion) */}
<Accordion type="single" collapsible defaultValue="right-panel" className="w-full">
<AccordionItem value="right-panel" className="border rounded-lg px-4">
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
({relationshipType === "detail" ? "상세" : "조인"})
</AccordionTrigger>
<AccordionContent className="overflow-visible">
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label> </Label>
@ -1358,10 +1372,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* 레이아웃 설정 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> </h3>
{/* 레이아웃 설정 (Accordion) */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="layout" className="border rounded-lg px-4">
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
</AccordionTrigger>
<AccordionContent className="overflow-visible">
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label> : {config.splitRatio || 30}%</Label>
@ -1390,6 +1412,9 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View File

@ -323,16 +323,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return reordered;
});
console.log("📊 초기 화면 표시 데이터 전달:", { count: initialData.length, firstRow: initialData[0] });
// 전역 저장소에 데이터 저장
if (tableConfig.selectedTable) {
// 컬럼 라벨 매핑 생성
const labels: Record<string, string> = {};
visibleColumns.forEach((col) => {
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
});
tableDisplayStore.setTableData(
tableConfig.selectedTable,
initialData,
parsedOrder.filter((col) => col !== "__checkbox__"),
sortColumn,
sortDirection,
{
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
searchTerm: searchTerm || undefined,
visibleColumns: visibleColumns.map((col) => col.columnName),
columnLabels: labels,
currentPage: currentPage,
pageSize: localPageSize,
totalItems: totalItems,
},
);
}
@ -624,33 +637,44 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
referenceTable: col.additionalJoinInfo!.referenceTable,
}));
const hasEntityJoins = entityJoinColumns.length > 0;
let response;
if (hasEntityJoins) {
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page,
size: pageSize,
sortBy,
sortOrder,
search: filters,
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
});
} else {
response = await tableTypeApi.getTableData(tableConfig.selectedTable, {
page,
size: pageSize,
sortBy,
sortOrder,
search: filters,
});
}
setData(response.data || []);
setTotalPages(response.totalPages || 0);
setTotalItems(response.total || 0);
setError(null);
// 🎯 Store에 필터 조건 저장 (엑셀 다운로드용)
const labels: Record<string, string> = {};
visibleColumns.forEach((col) => {
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
});
tableDisplayStore.setTableData(
tableConfig.selectedTable,
response.data || [],
visibleColumns.map((col) => col.columnName),
sortBy,
sortOrder,
{
filterConditions: filters,
searchTerm: search,
visibleColumns: visibleColumns.map((col) => col.columnName),
columnLabels: labels,
currentPage: page,
pageSize: pageSize,
totalItems: response.total || 0,
}
);
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
setData([]);
@ -788,12 +812,28 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const cleanColumnOrder = (
columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName)
).filter((col) => col !== "__checkbox__");
// 컬럼 라벨 정보도 함께 저장
const labels: Record<string, string> = {};
visibleColumns.forEach((col) => {
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
});
tableDisplayStore.setTableData(
tableConfig.selectedTable,
reorderedData,
cleanColumnOrder,
newSortColumn,
newSortDirection,
{
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
searchTerm: searchTerm || undefined,
visibleColumns: visibleColumns.map((col) => col.columnName),
columnLabels: labels,
currentPage: currentPage,
pageSize: localPageSize,
totalItems: totalItems,
},
);
}
} else {
@ -1062,6 +1102,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
if (value === null || value === undefined) return "-";
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
if (column.columnName === "writer" && rowData && rowData.writer_name) {
return rowData.writer_name;
}
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
if (column.entityDisplayConfig && rowData) {
// displayColumns 또는 selectedColumns 둘 다 체크
@ -1155,6 +1200,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return String(value);
}
// 날짜 타입 포맷팅 (yyyy-mm-dd)
if (inputType === "date" || inputType === "datetime") {
if (value) {
try {
const date = new Date(value);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} catch {
return String(value);
}
}
return "-";
}
// 숫자 타입 포맷팅
if (inputType === "number" || inputType === "decimal") {
if (value !== null && value !== undefined && value !== "") {
@ -1179,7 +1240,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (value) {
try {
const date = new Date(value);
return date.toLocaleDateString("ko-KR");
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} catch {
return value;
}
@ -2144,7 +2208,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr
key={index}
className={cn(
"bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12",
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
)}
onClick={(e) => handleRowClick(row, index, e)}
>
@ -2173,8 +2237,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<td
key={column.columnName}
className={cn(
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
"text-foreground overflow-hidden text-xs text-ellipsis whitespace-nowrap font-normal sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
)}
style={{
@ -2210,7 +2274,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr
key={index}
className={cn(
"bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12",
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
)}
onClick={(e) => handleRowClick(row, index, e)}
>
@ -2239,8 +2303,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<td
key={column.columnName}
className={cn(
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
"text-foreground overflow-hidden text-xs text-ellipsis whitespace-nowrap font-normal sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
)}
style={{

View File

@ -100,16 +100,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
const currentFormValue = formData?.[component.columnName];
const currentComponentValue = component.value;
console.log("🔧 TextInput 자동생성 체크:", {
componentId: component.id,
columnName: component.columnName,
autoGenType: testAutoGeneration.type,
ruleId: testAutoGeneration.options?.numberingRuleId,
currentFormValue,
currentComponentValue,
autoGeneratedValue,
isInteractive,
});
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {

View File

@ -8,19 +8,20 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { TextInputConfig } from "./types";
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
export interface TextInputConfigPanelProps {
config: TextInputConfig;
onChange: (config: Partial<TextInputConfig>) => void;
screenTableName?: string; // 🆕 현재 화면의 테이블명
}
/**
* TextInput
* UI
*/
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange }) => {
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange, screenTableName }) => {
// 채번 규칙 목록 상태
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
@ -30,9 +31,20 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
const loadRules = async () => {
setLoadingRules(true);
try {
const response = await getAvailableNumberingRules();
let response;
// 🆕 테이블명이 있으면 테이블 기반 필터링, 없으면 전체 조회
if (screenTableName) {
console.log("🔍 TextInputConfigPanel: 테이블 기반 채번 규칙 로드", { screenTableName });
response = await getAvailableNumberingRulesForScreen(screenTableName);
} else {
console.log("🔍 TextInputConfigPanel: 전체 채번 규칙 로드 (테이블명 없음)");
response = await getAvailableNumberingRules();
}
if (response.success && response.data) {
setNumberingRules(response.data);
console.log("✅ 채번 규칙 로드 완료:", response.data.length, "개");
}
} catch (error) {
console.error("채번 규칙 목록 로드 실패:", error);
@ -45,7 +57,7 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
if (config.autoGeneration?.type === "numbering_rule") {
loadRules();
}
}, [config.autoGeneration?.type]);
}, [config.autoGeneration?.type, screenTableName]);
const handleChange = (key: keyof TextInputConfig, value: any) => {
onChange({ [key]: value });
@ -174,7 +186,12 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
) : (
numberingRules.map((rule) => (
<SelectItem key={rule.ruleId} value={rule.ruleId}>
{rule.ruleName} ({rule.ruleId})
{rule.ruleName}
{rule.description && (
<span className="text-muted-foreground ml-2 text-xs">
- {rule.description}
</span>
)}
</SelectItem>
))
)}

View File

@ -113,6 +113,16 @@ export interface ButtonActionContext {
sortOrder?: "asc" | "desc"; // 정렬 방향
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
tableDisplayData?: any[]; // 화면에 표시된 데이터 (정렬 및 컬럼 순서 적용됨)
// 🆕 엑셀 다운로드 개선을 위한 추가 필드
filterConditions?: Record<string, any>; // 필터 조건 (예: { status: "active", dept: "dev" })
searchTerm?: string; // 검색어
searchColumn?: string; // 검색 대상 컬럼
visibleColumns?: string[]; // 화면에 표시 중인 컬럼 목록 (순서 포함)
columnLabels?: Record<string, string>; // 컬럼명 → 라벨명 매핑 (한글)
currentPage?: number; // 현재 페이지
pageSize?: number; // 페이지 크기
totalItems?: number; // 전체 항목 수
}
/**
@ -1936,162 +1946,74 @@ export class ButtonActionExecutor {
*/
private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("📥 엑셀 다운로드 시작:", { config, context });
console.log("🔍 context.columnOrder 확인:", {
hasColumnOrder: !!context.columnOrder,
columnOrderLength: context.columnOrder?.length,
columnOrder: context.columnOrder,
});
console.log("🔍 context.tableDisplayData 확인:", {
hasTableDisplayData: !!context.tableDisplayData,
tableDisplayDataLength: context.tableDisplayData?.length,
tableDisplayDataFirstRow: context.tableDisplayData?.[0],
tableDisplayDataColumns: context.tableDisplayData?.[0] ? Object.keys(context.tableDisplayData[0]) : [],
});
// 동적 import로 엑셀 유틸리티 로드
const { exportToExcel } = await import("@/lib/utils/excelExport");
let dataToExport: any[] = [];
// 1순위: 선택된 행 데이터
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
dataToExport = context.selectedRowsData;
console.log("✅ 선택된 행 데이터 사용:", dataToExport.length);
// 선택된 행도 정렬 적용
if (context.sortBy) {
console.log("🔄 선택된 행 데이터 정렬 적용:", {
sortBy: context.sortBy,
sortOrder: context.sortOrder,
});
dataToExport = [...dataToExport].sort((a, b) => {
const aVal = a[context.sortBy!];
const bVal = b[context.sortBy!];
// null/undefined 처리
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// 숫자 비교 (문자열이어도 숫자로 변환 가능하면 숫자로 비교)
const aNum = Number(aVal);
const bNum = Number(bVal);
// 둘 다 유효한 숫자이고, 원본 값이 빈 문자열이 아닌 경우
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== "" && bVal !== "") {
return context.sortOrder === "desc" ? bNum - aNum : aNum - bNum;
}
// 문자열 비교 (대소문자 구분 없이, 숫자 포함 문자열도 자연스럽게 정렬)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
// 자연스러운 정렬 (숫자 포함 문자열)
const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: 'base' });
return context.sortOrder === "desc" ? -comparison : comparison;
});
console.log("✅ 정렬 완료:", {
firstRow: dataToExport[0],
lastRow: dataToExport[dataToExport.length - 1],
firstSortValue: dataToExport[0]?.[context.sortBy],
lastSortValue: dataToExport[dataToExport.length - 1]?.[context.sortBy],
});
}
}
// 2순위: 화면 표시 데이터 (컬럼 순서 포함, 정렬 적용됨)
else if (context.tableDisplayData && context.tableDisplayData.length > 0) {
dataToExport = context.tableDisplayData;
console.log("✅ 화면 표시 데이터 사용 (context):", {
count: dataToExport.length,
firstRow: dataToExport[0],
columns: Object.keys(dataToExport[0] || {}),
});
}
// 2.5순위: 전역 저장소에서 화면 표시 데이터 조회
else if (context.tableName) {
// ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기
if (context.tableName) {
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
const storedData = tableDisplayStore.getTableData(context.tableName);
if (storedData && storedData.data.length > 0) {
dataToExport = storedData.data;
console.log("✅ 화면 표시 데이터 사용 (전역 저장소):", {
tableName: context.tableName,
count: dataToExport.length,
firstRow: dataToExport[0],
lastRow: dataToExport[dataToExport.length - 1],
columns: Object.keys(dataToExport[0] || {}),
columnOrder: storedData.columnOrder,
sortBy: storedData.sortBy,
sortOrder: storedData.sortOrder,
// 정렬 컬럼의 첫/마지막 값 확인
firstSortValue: storedData.sortBy ? dataToExport[0]?.[storedData.sortBy] : undefined,
lastSortValue: storedData.sortBy ? dataToExport[dataToExport.length - 1]?.[storedData.sortBy] : undefined,
});
}
// 3순위: 테이블 전체 데이터 (API 호출)
else {
console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName);
console.log("📊 정렬 정보:", {
sortBy: context.sortBy,
sortOrder: context.sortOrder,
});
// 필터 조건은 저장소 또는 context에서 가져오기
const filterConditions = storedData?.filterConditions || context.filterConditions;
const searchTerm = storedData?.searchTerm || context.searchTerm;
try {
const { dynamicFormApi } = await import("@/lib/api/dynamicForm");
const response = await dynamicFormApi.getTableData(context.tableName, {
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const apiParams = {
page: 1,
pageSize: 10000, // 최대 10,000개 행
sortBy: context.sortBy || "id", // 화면 정렬 또는 기본 정렬
sortOrder: context.sortOrder || "asc", // 화면 정렬 방향 또는 오름차순
});
size: 10000, // 최대 10,000개
sortBy: context.sortBy || storedData?.sortBy || "id",
sortOrder: (context.sortOrder || storedData?.sortOrder || "asc") as "asc" | "desc",
search: filterConditions, // ✅ 필터 조건
enableEntityJoin: true, // ✅ Entity 조인
autoFilter: true, // ✅ company_code 자동 필터링 (멀티테넌시)
};
console.log("📦 API 응답 구조:", {
response,
responseSuccess: response.success,
responseData: response.data,
responseDataType: typeof response.data,
responseDataIsArray: Array.isArray(response.data),
responseDataLength: Array.isArray(response.data) ? response.data.length : "N/A",
});
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
const response = await entityJoinApi.getTableDataWithJoins(context.tableName, apiParams);
if (response.success && response.data) {
// 🔒 멀티테넌시 확인
const allData = Array.isArray(response) ? response : response?.data || [];
const companyCodesInData = [...new Set(allData.map((row: any) => row.company_code))];
if (companyCodesInData.length > 1) {
console.error("❌ 멀티테넌시 위반! 여러 회사의 데이터가 섞여있습니다:", companyCodesInData);
}
// entityJoinApi는 EntityJoinResponse 또는 data 배열을 반환
if (Array.isArray(response)) {
// 배열로 직접 반환된 경우
dataToExport = response;
} else if (response && 'data' in response) {
// EntityJoinResponse 객체인 경우
dataToExport = response.data;
console.log("✅ 테이블 전체 데이터 조회 완료:", {
count: dataToExport.length,
firstRow: dataToExport[0],
});
} else {
console.error("❌ API 응답에 데이터가 없습니다:", response);
console.error("❌ 예상치 못한 응답 형식:", response);
toast.error("데이터를 가져오는데 실패했습니다.");
return false;
}
} catch (error) {
console.error("❌ 테이블 데이터 조회 실패:", error);
console.error("엑셀 다운로드: 데이터 조회 실패:", error);
toast.error("데이터를 가져오는데 실패했습니다.");
return false;
}
}
}
// 4순위: 폼 데이터
// 폴백: 폼 데이터
else if (context.formData && Object.keys(context.formData).length > 0) {
dataToExport = [context.formData];
console.log("✅ 폼 데이터 사용:", dataToExport);
}
console.log("📊 최종 다운로드 데이터:", {
selectedRowsData: context.selectedRowsData,
selectedRowsLength: context.selectedRowsData?.length,
formData: context.formData,
tableName: context.tableName,
dataToExport,
dataToExportType: typeof dataToExport,
dataToExportIsArray: Array.isArray(dataToExport),
dataToExportLength: Array.isArray(dataToExport) ? dataToExport.length : "N/A",
});
// 테이블명도 없고 폼 데이터도 없으면 에러
else {
toast.error("다운로드할 데이터 소스가 없습니다.");
return false;
}
// 배열이 아니면 배열로 변환
if (!Array.isArray(dataToExport)) {
console.warn("⚠️ dataToExport가 배열이 아닙니다. 변환 시도:", dataToExport);
// 객체인 경우 배열로 감싸기
if (typeof dataToExport === "object" && dataToExport !== null) {
dataToExport = [dataToExport];
} else {
@ -2110,66 +2032,196 @@ export class ButtonActionExecutor {
const sheetName = config.excelSheetName || "Sheet1";
const includeHeaders = config.excelIncludeHeaders !== false;
// 🆕 컬럼 순서 재정렬 (화면에 표시된 순서대로)
let columnOrder: string[] | undefined = context.columnOrder;
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
let visibleColumns: string[] | undefined = undefined;
let columnLabels: Record<string, string> | undefined = undefined;
// columnOrder가 없으면 tableDisplayData에서 추출 시도
if (!columnOrder && context.tableDisplayData && context.tableDisplayData.length > 0) {
columnOrder = Object.keys(context.tableDisplayData[0]);
console.log("📊 tableDisplayData에서 컬럼 순서 추출:", columnOrder);
try {
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
const { apiClient } = await import("@/lib/api/client");
const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`);
if (layoutResponse.data?.success && layoutResponse.data?.data) {
let layoutData = layoutResponse.data.data;
// components가 문자열이면 파싱
if (typeof layoutData.components === 'string') {
layoutData.components = JSON.parse(layoutData.components);
}
if (columnOrder && columnOrder.length > 0 && dataToExport.length > 0) {
console.log("🔄 컬럼 순서 재정렬 시작:", {
columnOrder,
originalColumns: Object.keys(dataToExport[0] || {}),
// 테이블 리스트 컴포넌트 찾기
const findTableListComponent = (components: any[]): any => {
if (!Array.isArray(components)) return null;
for (const comp of components) {
// componentType이 'table-list'인지 확인
const isTableList = comp.componentType === 'table-list';
// componentConfig 안에서 테이블명 확인
const matchesTable =
comp.componentConfig?.selectedTable === context.tableName ||
comp.componentConfig?.tableName === context.tableName;
if (isTableList && matchesTable) {
return comp;
}
if (comp.children && comp.children.length > 0) {
const found = findTableListComponent(comp.children);
if (found) return found;
}
}
return null;
};
const tableListComponent = findTableListComponent(layoutData.components || []);
if (tableListComponent && tableListComponent.componentConfig?.columns) {
const columns = tableListComponent.componentConfig.columns;
// visible이 true인 컬럼만 추출
visibleColumns = columns
.filter((col: any) => col.visible !== false)
.map((col: any) => col.columnName);
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
try {
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
params: { page: 1, size: 9999 }
});
dataToExport = dataToExport.map((row: any) => {
const reorderedRow: any = {};
if (columnsResponse.data?.success && columnsResponse.data?.data) {
let columnData = columnsResponse.data.data;
// 1. columnOrder에 있는 컬럼들을 순서대로 추가
columnOrder!.forEach((colName: string) => {
if (colName in row) {
reorderedRow[colName] = row[colName];
// data가 객체이고 columns 필드가 있으면 추출
if (columnData.columns && Array.isArray(columnData.columns)) {
columnData = columnData.columns;
}
if (Array.isArray(columnData)) {
columnLabels = {};
// API에서 가져온 라벨로 매핑
columnData.forEach((colData: any) => {
const colName = colData.column_name || colData.columnName;
// 우선순위: column_label > label > displayName > columnName
const labelValue = colData.column_label || colData.label || colData.displayName || colName;
if (colName && labelValue) {
columnLabels![colName] = labelValue;
}
});
// 2. columnOrder에 없는 나머지 컬럼들 추가 (끝에 배치)
Object.keys(row).forEach((key) => {
if (!(key in reorderedRow)) {
reorderedRow[key] = row[key];
}
}
} catch (error) {
// 실패 시 컴포넌트 설정의 displayName 사용
columnLabels = {};
columns.forEach((col: any) => {
if (col.columnName) {
columnLabels![col.columnName] = col.displayName || col.label || col.columnName;
}
});
return reorderedRow;
});
console.log("✅ 컬럼 순서 재정렬 완료:", {
reorderedColumns: Object.keys(dataToExport[0] || {}),
});
}
} else {
console.log("⏭️ 컬럼 순서 재정렬 스킵:", {
hasColumnOrder: !!columnOrder,
columnOrderLength: columnOrder?.length,
hasTableDisplayData: !!context.tableDisplayData,
dataToExportLength: dataToExport.length,
});
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
}
}
} catch (error) {
console.error("❌ 화면 레이아웃 조회 실패:", error);
}
console.log("📥 엑셀 다운로드 실행:", {
fileName,
sheetName,
includeHeaders,
dataCount: dataToExport.length,
firstRow: dataToExport[0],
columnOrder: context.columnOrder,
// 🎨 카테고리 값들 조회 (한 번만)
const categoryMap: Record<string, Record<string, string>> = {};
let categoryColumns: string[] = [];
// 백엔드에서 카테고리 컬럼 정보 가져오기
if (context.tableName) {
try {
const { getCategoryColumns, getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const categoryColumnsResponse = await getCategoryColumns(context.tableName);
if (categoryColumnsResponse.success && categoryColumnsResponse.data) {
// 백엔드에서 정의된 카테고리 컬럼들
categoryColumns = categoryColumnsResponse.data.map((col: any) =>
col.column_name || col.columnName || col.name
).filter(Boolean); // undefined 제거
// 각 카테고리 컬럼의 값들 조회
for (const columnName of categoryColumns) {
try {
const valuesResponse = await getCategoryValues(context.tableName, columnName, false);
if (valuesResponse.success && valuesResponse.data) {
// valueCode → valueLabel 매핑
categoryMap[columnName] = {};
valuesResponse.data.forEach((catValue: any) => {
const code = catValue.valueCode || catValue.category_value_id;
const label = catValue.valueLabel || catValue.label || code;
if (code) {
categoryMap[columnName][code] = label;
}
});
}
} catch (error) {
console.error(`❌ 카테고리 "${columnName}" 조회 실패:`, error);
}
}
}
} catch (error) {
console.error("❌ 카테고리 정보 조회 실패:", error);
}
}
// 🎨 컬럼 필터링 및 라벨 적용 (항상 실행)
if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) {
dataToExport = dataToExport.map((row: any) => {
const filteredRow: Record<string, any> = {};
visibleColumns.forEach((columnName: string) => {
// __checkbox__ 컬럼은 제외
if (columnName === "__checkbox__") return;
if (columnName in row) {
// 라벨 우선 사용, 없으면 컬럼명 사용
const label = columnLabels?.[columnName] || columnName;
// 🎯 Entity 조인된 값 우선 사용
let value = row[columnName];
// writer → writer_name 사용
if (columnName === 'writer' && row['writer_name']) {
value = row['writer_name'];
}
// 다른 엔티티 필드들도 _name 우선 사용
else if (row[`${columnName}_name`]) {
value = row[`${columnName}_name`];
}
// 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만)
else if (categoryMap[columnName] && typeof value === 'string' && categoryMap[columnName][value]) {
value = categoryMap[columnName][value];
}
filteredRow[label] = value;
}
});
return filteredRow;
});
}
// 최대 행 수 제한
const MAX_ROWS = 10000;
if (dataToExport.length > MAX_ROWS) {
toast.warning(`최대 ${MAX_ROWS.toLocaleString()}개 행까지만 다운로드됩니다.`);
dataToExport = dataToExport.slice(0, MAX_ROWS);
}
// 엑셀 다운로드 실행
await exportToExcel(dataToExport, fileName, sheetName, includeHeaders);
toast.success(config.successMessage || "엑셀 파일이 다운로드되었습니다.");
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
return true;
} catch (error) {
console.error("❌ 엑셀 다운로드 실패:", error);

View File

@ -19,6 +19,8 @@ import { DashboardConfigPanel } from "@/components/screen/config-panels/Dashboar
export type ConfigPanelComponent = React.ComponentType<{
config: any;
onConfigChange: (config: any) => void;
tableName?: string; // 화면 테이블명 (선택)
menuObjid?: number; // 메뉴 objid (선택)
}>;
// ButtonConfigPanel 래퍼 (config/onConfigChange → component/onUpdateProperty 변환)

View File

@ -15,17 +15,28 @@ export function calculateGridInfo(
containerHeight: number,
gridSettings: GridSettings,
): GridInfo {
const { columns, gap, padding } = gridSettings;
const { gap, padding } = gridSettings;
let { columns } = gridSettings;
// 사용 가능한 너비 계산 (패딩 제외)
// 🔥 최소 컬럼 너비를 보장하기 위한 최대 컬럼 수 계산
const MIN_COLUMN_WIDTH = 30; // 최소 컬럼 너비 30px
const availableWidth = containerWidth - padding * 2;
const maxPossibleColumns = Math.floor((availableWidth + gap) / (MIN_COLUMN_WIDTH + gap));
// 설정된 컬럼 수가 너무 많으면 자동으로 제한
if (columns > maxPossibleColumns) {
console.warn(
`⚠️ 격자 컬럼 수가 너무 많습니다. ${columns}개 → ${maxPossibleColumns}개로 자동 조정됨 (최소 컬럼 너비: ${MIN_COLUMN_WIDTH}px)`,
);
columns = Math.max(1, maxPossibleColumns);
}
// 격자 간격을 고려한 컬럼 너비 계산
const totalGaps = (columns - 1) * gap;
const columnWidth = (availableWidth - totalGaps) / columns;
return {
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
columnWidth: Math.max(columnWidth, MIN_COLUMN_WIDTH),
totalWidth: containerWidth,
totalHeight: containerHeight,
};
@ -96,9 +107,9 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri
const rowHeight = 10;
const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight);
console.log(
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
);
// console.log(
// `📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
// );
return {
width: Math.max(columnWidth, snappedWidth),

View File

@ -0,0 +1,58 @@
/**
* WebTypeConfig와 AutoGeneration
*/
import { ComponentData } from "@/types/screen";
/**
* webTypeConfig의 autoGeneration으로
*/
export function convertWebTypeConfigToAutoGeneration(component: ComponentData): ComponentData {
// webTypeConfig가 없으면 변환 불필요
if (!component.webTypeConfig) {
return component;
}
const config = component.webTypeConfig as any;
// 자동입력이 활성화되어 있는지 확인
if (!config.autoInput || !config.autoValueType) {
return component;
}
// 이미 autoGeneration이 올바르게 설정되어 있으면 변환 불필요
if (
component.autoGeneration &&
component.autoGeneration.type === config.autoValueType &&
component.autoGeneration.options?.numberingRuleId === config.numberingRuleId
) {
return component;
}
// autoGeneration 객체 생성
const autoGeneration: any = {
type: config.autoValueType,
enabled: true,
};
// 채번 규칙인 경우 options.numberingRuleId 설정
if (config.autoValueType === "numbering_rule" && config.numberingRuleId) {
autoGeneration.options = {
numberingRuleId: config.numberingRuleId,
};
}
return {
...component,
autoGeneration,
};
}
/**
* webTypeConfig autoGeneration
*/
export function convertLayoutComponents(components: ComponentData[]): ComponentData[] {
return components.map(convertWebTypeConfigToAutoGeneration);
}

View File

@ -9,6 +9,15 @@ interface TableDisplayState {
sortBy: string | null;
sortOrder: "asc" | "desc";
tableName: string;
// 🆕 엑셀 다운로드 개선을 위한 추가 필드
filterConditions?: Record<string, any>; // 필터 조건
searchTerm?: string; // 검색어
visibleColumns?: string[]; // 화면 표시 컬럼
columnLabels?: Record<string, string>; // 컬럼 라벨
currentPage?: number; // 현재 페이지
pageSize?: number; // 페이지 크기
totalItems?: number; // 전체 항목 수
}
class TableDisplayStore {
@ -22,13 +31,23 @@ class TableDisplayStore {
* @param columnOrder
* @param sortBy
* @param sortOrder
* @param options (, )
*/
setTableData(
tableName: string,
data: any[],
columnOrder: string[],
sortBy: string | null,
sortOrder: "asc" | "desc"
sortOrder: "asc" | "desc",
options?: {
filterConditions?: Record<string, any>;
searchTerm?: string;
visibleColumns?: string[];
columnLabels?: Record<string, string>;
currentPage?: number;
pageSize?: number;
totalItems?: number;
}
) {
this.state.set(tableName, {
data,
@ -36,15 +55,7 @@ class TableDisplayStore {
sortBy,
sortOrder,
tableName,
});
console.log("📦 [TableDisplayStore] 데이터 저장:", {
tableName,
dataCount: data.length,
columnOrderLength: columnOrder.length,
sortBy,
sortOrder,
firstRow: data[0],
...options,
});
this.notifyListeners();
@ -55,15 +66,7 @@ class TableDisplayStore {
* @param tableName
*/
getTableData(tableName: string): TableDisplayState | undefined {
const state = this.state.get(tableName);
console.log("📤 [TableDisplayStore] 데이터 조회:", {
tableName,
found: !!state,
dataCount: state?.data.length,
});
return state;
return this.state.get(tableName);
}
/**

View File

@ -132,7 +132,16 @@ export type AutoGenerationType = "table" | "form" | "mixed";
*/
export interface AutoGenerationConfig {
type: AutoGenerationType;
enabled?: boolean;
tableName?: string;
includeSearch?: boolean;
includePagination?: boolean;
options?: {
length?: number; // 랜덤 문자열/숫자 길이
prefix?: string; // 접두사
suffix?: string; // 접미사
format?: string; // 시간 형식 (current_time용)
startValue?: number; // 시퀀스 시작값
numberingRuleId?: string; // 채번 규칙 ID (numbering_rule 타입용)
};
}

View File

@ -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% (로그인 세션 테스트 제외)

View File

@ -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%는 실제 브라우저 테스트를 통한 최종 검증입니다.**
---
## 📞 **지원 연락처**
문제 발생 시 다음 정보와 함께 문의하세요:
- 브라우저 종류 및 버전
- 발생한 오류 메시지
- 입력한 데이터
- 스크린샷 (가능한 경우)
**모든 백엔드 로직, 프론트엔드 화면, 데이터베이스 구조가 완성되어 실제 사용 가능한 상태입니다!** 🎯

95
values_logistream.yaml Normal file
View File

@ -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: {}

View File

@ -0,0 +1,335 @@
# 채번규칙 테이블 기반 자동 감지 구현 완료
## 📋 변경 요청사항
**요구사항**: 채번 규칙을 더 간단하게 만들기
1. 기본값을 `table`로 설정
2. 적용 범위 선택 UI 제거
3. 현재 화면의 테이블을 자동으로 감지하여 저장
## ✅ 구현 완료 내역
### 1. 데이터베이스 마이그레이션
**파일**: `db/migrations/046_update_numbering_rules_scope_type.sql`
#### 주요 변경사항:
- 기존 모든 규칙을 `table` 타입으로 변경
- `scope_type` 제약조건 단순화 (table만 지원)
- 불필요한 제약조건 제거 (global, menu 관련)
- 인덱스 최적화 (table_name + company_code)
```sql
-- 모든 기존 규칙을 table 타입으로 변경
UPDATE numbering_rules
SET scope_type = 'table'
WHERE scope_type IN ('global', 'menu');
-- table_name이 없는 규칙은 빈 문자열로 설정
UPDATE numbering_rules
SET table_name = ''
WHERE table_name IS NULL;
-- 제약조건: table 타입이면 table_name 필수
ALTER TABLE numbering_rules
ADD CONSTRAINT check_table_scope_requires_table_name
CHECK (
(scope_type = 'table' AND table_name IS NOT NULL)
OR scope_type != 'table'
);
-- 인덱스 최적화
CREATE INDEX IF NOT EXISTS idx_numbering_rules_table_company
ON numbering_rules(table_name, company_code);
```
### 2. 백엔드 API 간소화
**파일**:
- `backend-node/src/services/numberingRuleService.ts`
- `backend-node/src/controllers/numberingRuleController.ts`
#### 주요 변경사항:
- `menuObjid` 파라미터 제거
- 테이블명만으로 필터링 (`tableName` 필수)
- SQL 쿼리 단순화
**수정된 서비스 메서드**:
```typescript
async getAvailableRulesForScreen(
companyCode: string,
tableName: string
): Promise<NumberingRuleConfig[]> {
// menuObjid 제거, tableName만 사용
// WHERE table_name = $1 AND company_code = $2
}
```
**수정된 API 엔드포인트**:
```typescript
GET /api/numbering-rules/available-for-screen?tableName=item_info
// menuObjid 파라미터 제거
```
### 3. 프론트엔드 API 클라이언트 수정
**파일**: `frontend/lib/api/numberingRule.ts`
#### 주요 변경사항:
- `menuObjid` 파라미터 제거
- 테이블명만 전달
```typescript
export async function getAvailableNumberingRulesForScreen(
tableName: string // menuObjid 제거
): Promise<ApiResponse<NumberingRuleConfig[]>> {
const response = await apiClient.get("/numbering-rules/available-for-screen", {
params: { tableName },
});
return response.data;
}
```
### 4. 채번 규칙 디자이너 UI 대폭 간소화
**파일**: `frontend/components/numbering-rule/NumberingRuleDesigner.tsx`
#### 주요 변경사항:
##### ✅ Props 추가
```typescript
interface NumberingRuleDesignerProps {
// ... 기존 props
currentTableName?: string; // 현재 화면의 테이블명 자동 전달
}
```
##### ✅ 새 규칙 생성 시 자동 설정
```typescript
const handleNewRule = useCallback(() => {
const newRule: NumberingRuleConfig = {
// ...
scopeType: "table", // 기본값 table로 고정
tableName: currentTableName || "", // 현재 테이블명 자동 설정
};
}, [currentTableName]);
```
##### ✅ 저장 시 자동 설정
```typescript
const handleSaveRule = useCallback(async () => {
const ruleToSave = {
...currentRule,
scopeType: "table" as const, // 항상 table로 고정
tableName: currentTableName || currentRule.tableName || "", // 자동 감지
};
// 백엔드에 저장
}, [currentRule, currentTableName]);
```
##### ✅ UI 변경: 적용 범위 선택 제거
**이전**:
```tsx
{/* 적용 범위 선택 Select */}
<Select value={scopeType}>
<SelectItem value="global">전역</SelectItem>
<SelectItem value="table">테이블별</SelectItem>
<SelectItem value="menu">메뉴별</SelectItem>
</Select>
{/* 조건부: 테이블명 입력 */}
{scopeType === "table" && (
<Input value={tableName} onChange={...} />
)}
{/* 조건부: 메뉴 선택 */}
{scopeType === "menu" && (
<Select value={menuObjid}>...</Select>
)}
```
**현재 (간소화)**:
```tsx
{/* 자동 감지된 테이블 정보 표시 (읽기 전용) */}
{currentTableName && (
<div className="space-y-2">
<Label className="text-sm font-medium">적용 테이블</Label>
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
{currentTableName}
</div>
<p className="text-muted-foreground text-xs">
이 규칙은 현재 화면의 테이블({currentTableName})에 자동으로 적용됩니다
</p>
</div>
)}
```
### 5. 화면관리에서 테이블명 전달
**파일**: `frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx`
#### 주요 변경사항:
- `menuObjid` 제거, `tableName`만 사용
- 테이블명이 없으면 빈 배열 반환
```typescript
useEffect(() => {
const loadRules = async () => {
if (tableName) {
console.log("📋 테이블 기반 채번 규칙 조회:", { tableName });
response = await getAvailableNumberingRulesForScreen(tableName);
} else {
console.warn("⚠️ 테이블명이 없어 채번 규칙을 조회할 수 없습니다");
setNumberingRules([]);
return;
}
};
}, [localValues.autoValueType, tableName]); // menuObjid 제거
```
## 📊 변경 전후 비교
### 이전 방식 (복잡)
1. 사용자가 **적용 범위** 선택 (전역/테이블별/메뉴별)
2. 테이블별 선택 시 → 테이블명 **직접 입력**
3. 메뉴별 선택 시 → 메뉴 **수동 선택**
4. 저장 시 입력한 정보로 저장
**문제점**:
- UI가 복잡 (3단계 선택)
- 사용자가 테이블명을 수동 입력해야 함
- 오타 가능성
- 메뉴 기반 필터링은 복잡하고 직관적이지 않음
### 현재 방식 (간단)
1. 채번 규칙 디자이너 열기
2. 규칙 이름과 파트 설정
3. 저장 → **자동으로 현재 화면의 테이블명 저장됨**
**장점**:
- UI 단순 (적용 범위 선택 UI 제거)
- 테이블명 자동 감지 (오타 없음)
- 사용자는 규칙만 설계하면 됨
- 같은 테이블을 사용하는 화면에서 자동으로 규칙 공유
## 🔍 작동 흐름
### 1. 채번 규칙 생성
```
사용자: "새 규칙" 버튼 클릭
시스템: currentTableName (예: "item_info") 자동 감지
규칙 생성: scopeType = "table", tableName = "item_info"
저장 시: DB에 table_name = "item_info"로 저장됨
```
### 2. 화면관리에서 규칙 사용
```
사용자: 텍스트 필드 설정 → "자동값 유형" = "채번 규칙"
시스템: 현재 화면의 테이블명 (예: "item_info") 가져옴
API 호출: GET /api/numbering-rules/available-for-screen?tableName=item_info
백엔드: WHERE table_name = 'item_info' AND company_code = 'COMPANY_A'
응답: item_info 테이블에 대한 규칙 목록 반환
UI: 드롭다운에 해당 규칙들만 표시
```
## 🎯 핵심 개선 포인트
### ✅ 사용자 경험 (UX)
- **이전**: 3단계 선택 (범위 → 테이블/메뉴 → 입력/선택)
- **현재**: 규칙만 설계 (테이블은 자동 감지)
### ✅ 오류 가능성
- **이전**: 테이블명 직접 입력 → 오타 발생 가능
- **현재**: 자동 감지 → 오타 불가능
### ✅ 직관성
- **이전**: "이 규칙은 어디에 적용되나요?" → 사용자가 이해해야 함
- **현재**: "현재 화면의 테이블에 자동 적용" → 자동으로 알맞게 적용
### ✅ 코드 복잡도
- **이전**: 3가지 scopeType 처리 (global, table, menu)
- **현재**: 1가지 scopeType만 처리 (table)
## 🚀 다음 단계
### 1. 데이터베이스 마이그레이션 실행 (필수)
```bash
# PostgreSQL 비밀번호 확인 후 실행
PGPASSWORD=<실제_비밀번호> psql -h localhost -U postgres -d ilshin \
-f /Users/kimjuseok/ERP-node/db/migrations/046_update_numbering_rules_scope_type.sql
```
### 2. 통합 테스트
#### 테스트 시나리오:
1. 화면관리에서 `item_info` 테이블 선택
2. 채번 규칙 컴포넌트 열기
3. "새 규칙" 생성 → 자동으로 `tableName = "item_info"` 설정되는지 확인
4. 규칙 저장 → DB에 `scope_type = 'table'`, `table_name = 'item_info'`로 저장되는지 확인
5. 텍스트 필드 설정 → "자동값 유형" = "채번 규칙" 선택
6. 드롭다운에서 해당 규칙이 표시되는지 확인
7. 다른 테이블 화면에서는 해당 규칙이 **안 보이는지** 확인
### 3. 기존 데이터 마이그레이션 확인
마이그레이션 실행 후:
```sql
-- 모든 규칙이 table 타입인지 확인
SELECT scope_type, COUNT(*)
FROM numbering_rules
GROUP BY scope_type;
-- 결과: scope_type='table'만 나와야 함
-- table_name이 비어있는 규칙 확인
SELECT rule_id, rule_name, table_name
FROM numbering_rules
WHERE table_name = '' OR table_name IS NULL;
-- 결과: 비어있는 규칙이 있다면 수동 업데이트 필요
```
## 📝 변경된 파일 목록
### 데이터베이스
- ✅ `db/migrations/046_update_numbering_rules_scope_type.sql` (수정)
### 백엔드
- ✅ `backend-node/src/services/numberingRuleService.ts` (간소화)
- ✅ `backend-node/src/controllers/numberingRuleController.ts` (간소화)
### 프론트엔드
- ✅ `frontend/lib/api/numberingRule.ts` (간소화)
- ✅ `frontend/components/numbering-rule/NumberingRuleDesigner.tsx` (대폭 간소화)
- ✅ `frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx` (간소화)
## 🎉 결론
채번 규칙 시스템이 대폭 간소화되었습니다!
**이제 사용자는**:
1. 화면관리에서 테이블 선택
2. 채번 규칙 디자이너에서 규칙 설계
3. 저장 → **자동으로 현재 테이블에 적용됨**
**시스템은**:
- 자동으로 현재 화면의 테이블명 감지
- 같은 테이블의 화면에서 규칙 자동 공유
- 오타 없는 정확한 매핑
완료! 🚀

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,428 @@
# 채번규칙 테이블 기반 필터링 시스템 구현 완료 보고서
## 📅 완료 일시
- **날짜**: 2025-11-08
- **소요 시간**: 약 3시간 30분 (마이그레이션 실행 미완료)
---
## 🎯 목적
화면관리 시스템에서 채번규칙이 표시되지 않는 문제를 해결하기 위해 **메뉴 기반 필터링**에서 **테이블 기반 필터링**으로 전환
### 기존 문제점
1. 화면관리에서 `menuObjid` 정보가 없어 `scope_type='menu'` 규칙을 볼 수 없음
2. 메뉴 구조 변경 시 채번규칙 재설정 필요
3. 같은 테이블을 사용하는 화면인데도 규칙이 보이지 않음
### 해결 방안
- **테이블명 기반 자동 매칭**: 화면의 테이블과 규칙의 테이블이 같으면 자동으로 표시
- **하이브리드 접근**: `scope_type``'global'`, `'table'`, `'menu'` 세 가지로 확장
- **우선순위 필터링**: menu > table > global 순으로 규칙 표시
---
## ✅ 구현 완료 항목
### Phase 1: 데이터베이스 마이그레이션 (준비 완료)
#### 파일 생성
- ✅ `/db/migrations/046_update_numbering_rules_scope_type.sql`
- ✅ `/db/migrations/RUN_046_MIGRATION.md`
#### 마이그레이션 내용
- `scope_type` 제약조건 확장: `'global'`, `'table'`, `'menu'`
- 유효성 검증 제약조건 추가:
- `check_table_scope_requires_table_name`: table 타입은 table_name 필수
- `check_global_scope_no_table_name`: global 타입은 table_name 없어야 함
- `check_menu_scope_requires_menu_objid`: menu 타입은 menu_objid 필수
- 기존 데이터 자동 마이그레이션: `global` + `table_name``table` 타입으로 변경
- 멀티테넌시 인덱스 최적화:
- `idx_numbering_rules_scope_table (scope_type, table_name, company_code)`
- `idx_numbering_rules_scope_menu (scope_type, menu_objid, company_code)`
#### 상태
⚠️ **마이그레이션 파일 준비 완료, 실행 대기 중**
- Docker 컨테이너 연결 문제로 수동 실행 필요
- 실행 가이드는 `RUN_046_MIGRATION.md` 참고
---
### Phase 2: 백엔드 API 수정 ✅
#### 2.1 numberingRuleService.ts
- ✅ `getAvailableRulesForScreen()` 함수 추가
- 파라미터: `companyCode`, `tableName` (필수), `menuObjid` (선택)
- 우선순위 필터링: menu > table > global
- 멀티테넌시 완벽 지원
**주요 SQL 쿼리:**
```sql
SELECT * FROM numbering_rules
WHERE company_code = $1
AND (
(scope_type = 'menu' AND menu_objid = $2)
OR (scope_type = 'table' AND table_name = $3)
OR (scope_type = 'global' AND table_name IS NULL)
)
ORDER BY
CASE scope_type
WHEN 'menu' THEN 1
WHEN 'table' THEN 2
WHEN 'global' THEN 3
END,
created_at DESC
```
#### 2.2 numberingRuleController.ts
- ✅ `GET /api/numbering-rules/available-for-screen` 엔드포인트 추가
- Query Parameters: `tableName` (필수), `menuObjid` (선택)
- tableName 검증 로직 포함
- 상세 로그 기록
---
### Phase 3: 프론트엔드 API 클라이언트 수정 ✅
#### lib/api/numberingRule.ts
- ✅ `getAvailableNumberingRulesForScreen()` 함수 추가
- 파라미터: `tableName` (필수), `menuObjid` (선택)
- 기존 `getAvailableNumberingRules()` 유지 (하위 호환성)
**사용 예시:**
```typescript
const response = await getAvailableNumberingRulesForScreen(
"item_info", // 테이블명
undefined // menuObjid (선택)
);
```
---
### Phase 4: 화면관리 UI 수정 ✅
#### 4.1 TextTypeConfigPanel.tsx
- ✅ `tableName`, `menuObjid` props 추가
- ✅ 채번 규칙 로드 로직 개선:
- 테이블명이 있으면 `getAvailableNumberingRulesForScreen()` 호출
- 없으면 기존 메뉴 기반 방식 사용 (Fallback)
- 상세 로그 추가
**주요 코드:**
```typescript
useEffect(() => {
const loadRules = async () => {
if (tableName) {
response = await getAvailableNumberingRulesForScreen(tableName, menuObjid);
} else {
response = await getAvailableNumberingRules(menuObjid);
}
};
}, [localValues.autoValueType, tableName, menuObjid]);
```
#### 4.2 DetailSettingsPanel.tsx
- ✅ `currentTableName`을 ConfigPanelComponent에 전달
- ✅ ConfigPanelComponent 타입에 `tableName`, `menuObjid` 추가
#### 4.3 getConfigPanelComponent.tsx
- ✅ `ConfigPanelComponent` 타입 확장: `tableName?`, `menuObjid?` 추가
---
### Phase 5: 채번규칙 관리 UI 수정 ✅
#### NumberingRuleDesigner.tsx
- ✅ 적용 범위 선택 UI 추가
- Global: 모든 화면에서 사용
- Table: 특정 테이블에서만 사용
- Menu: 특정 메뉴에서만 사용
- ✅ 조건부 필드 표시:
- `scope_type='table'`: 테이블명 입력 필드 표시
- `scope_type='menu'`: 메뉴 선택 드롭다운 표시
- `scope_type='global'`: 추가 필드 불필요
- ✅ 새 규칙 기본값: `scope_type='global'`로 변경 (가장 일반적)
**UI 구조:**
```
규칙명 | 미리보기
-----------------
적용 범위 [Global/Table/Menu]
└─ (table) 테이블명 입력
└─ (menu) 메뉴 선택
```
---
## 🔄 데이터 흐름
### 화면관리에서 채번 규칙 조회 시
1. **화면 로드**
- ScreenDesigner → DetailSettingsPanel
- `currentTableName` 전달
2. **TextTypeConfigPanel 렌더링**
- Props: `tableName="item_info"`
- autoValueType이 `"numbering_rule"`일 때 규칙 로드
3. **API 호출**
```
GET /api/numbering-rules/available-for-screen?tableName=item_info
```
4. **백엔드 처리**
- `numberingRuleService.getAvailableRulesForScreen()`
- SQL 쿼리로 우선순위 필터링
- 멀티테넌시 적용 (company_code 확인)
5. **응답 데이터**
```json
{
"success": true,
"data": [
{
"ruleId": "ITEM_CODE",
"ruleName": "품목 코드",
"scopeType": "table",
"tableName": "item_info"
},
{
"ruleId": "GLOBAL_CODE",
"ruleName": "전역 코드",
"scopeType": "global"
}
]
}
```
6. **UI 표시**
- Select 드롭다운에 규칙 목록 표시
- 우선순위대로 정렬됨
---
## 📊 scope_type 정의 및 우선순위
| scope_type | 설명 | 우선순위 | 사용 케이스 |
| ---------- | ---------------------- | -------- | ------------------------------- |
| `menu` | 특정 메뉴에서만 사용 | 1 (최고) | 메뉴별로 다른 채번 방식 필요 시 |
| `table` | 특정 테이블에서만 사용 | 2 (중간) | 테이블 기준 채번 (일반적) |
| `global` | 모든 곳에서 사용 가능 | 3 (최저) | 공통 채번 규칙 |
### 필터링 로직
```sql
WHERE company_code = $1 -- 멀티테넌시 필수
AND (
(scope_type = 'menu' AND menu_objid = $2) -- 1순위
OR (scope_type = 'table' AND table_name = $3) -- 2순위
OR (scope_type = 'global' AND table_name IS NULL) -- 3순위
)
```
---
## 🔐 멀티테넌시 보장
### 데이터베이스 레벨
- ✅ `company_code` 컬럼 필수 (NOT NULL)
- ✅ 외래키 제약조건 (company_info 참조)
- ✅ 복합 인덱스에 company_code 포함
### API 레벨
- ✅ 일반 회사: `WHERE company_code = $1`
- ✅ 최고 관리자: 모든 데이터 조회 가능 (company_code="*" 제외)
- ✅ 일반 회사는 `company_code="*"` 데이터를 볼 수 없음
### 로깅 레벨
- ✅ 모든 로그에 `companyCode` 포함 (감사 추적)
---
## 🧪 테스트 체크리스트
### 데이터베이스 테스트 (마이그레이션 후 수행)
- [ ] 제약조건 확인
```sql
SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'numbering_rules'::regclass
AND conname LIKE '%scope%';
```
- [ ] 인덱스 확인
```sql
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'numbering_rules'
AND indexname LIKE '%scope%';
```
- [ ] 데이터 마이그레이션 확인
```sql
SELECT scope_type, COUNT(*) as count
FROM numbering_rules
GROUP BY scope_type;
```
### 기능 테스트
- [ ] **회사 A로 로그인**
- [ ] 채번규칙 관리에서 새 규칙 생성 (scope_type='table', tableName='item_info')
- [ ] 저장 성공 확인
- [ ] 화면관리에서 item_info 테이블 화면 생성
- [ ] 텍스트 필드에서 "자동 입력 > 채번 규칙" 선택
- [ ] 방금 생성한 규칙이 목록에 표시되는지 확인 ✅
- [ ] **회사 B로 로그인**
- [ ] 화면관리에서 item_info 테이블 화면 접속
- [ ] 텍스트 필드에서 "자동 입력 > 채번 규칙" 선택
- [ ] 회사 A의 규칙이 보이지 않는지 확인 ✅
- [ ] **최고 관리자로 로그인**
- [ ] 채번규칙 관리에서 모든 회사 규칙이 보이는지 확인 ✅
- [ ] 화면관리에서는 일반 회사 규칙만 보이는지 확인 ✅
### 우선순위 테스트
- [ ] 같은 테이블(item_info)에 대해 3가지 scope_type 규칙 생성
- [ ] scope_type='global', table_name=NULL, ruleName="전역규칙"
- [ ] scope_type='table', table_name='item_info', ruleName="테이블규칙"
- [ ] scope_type='menu', menu_objid=123, tableName='item_info', ruleName="메뉴규칙"
- [ ] 화면관리에서 item_info 화면 접속 (menuObjid=123)
- [ ] 규칙 목록에서 순서 확인:
1. 메뉴규칙 (menu, 우선순위 1)
2. 테이블규칙 (table, 우선순위 2)
3. 전역규칙 (global, 우선순위 3)
---
## 📁 수정된 파일 목록
### 데이터베이스 (준비 완료, 실행 대기)
- ✅ `db/migrations/046_update_numbering_rules_scope_type.sql`
- ✅ `db/migrations/RUN_046_MIGRATION.md`
### 백엔드
- ✅ `backend-node/src/services/numberingRuleService.ts`
- ✅ `backend-node/src/controllers/numberingRuleController.ts`
### 프론트엔드 API
- ✅ `frontend/lib/api/numberingRule.ts`
### 프론트엔드 UI
- ✅ `frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx`
- ✅ `frontend/components/screen/panels/DetailSettingsPanel.tsx`
- ✅ `frontend/lib/utils/getConfigPanelComponent.tsx`
- ✅ `frontend/components/numbering-rule/NumberingRuleDesigner.tsx`
---
## 🚀 배포 가이드
### 1단계: 데이터베이스 마이그레이션
```bash
# Docker 환경
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/046_update_numbering_rules_scope_type.sql
# 로컬 PostgreSQL
psql -h localhost -U postgres -d ilshin -f db/migrations/046_update_numbering_rules_scope_type.sql
```
### 2단계: 백엔드 재시작
```bash
# Docker 환경
docker-compose restart backend
# 로컬 개발
npm run dev
```
### 3단계: 프론트엔드 재빌드
```bash
# Docker 환경
docker-compose restart frontend
# 로컬 개발
npm run dev
```
### 4단계: 검증
1. 개발자 도구 콘솔 열기
2. 화면관리 접속
3. 텍스트 필드 추가 → 자동 입력 → 채번 규칙 선택
4. 콘솔에서 다음 로그 확인:
```
📋 테이블 기반 채번 규칙 조회: { tableName: "xxx", menuObjid: undefined }
✅ 채번 규칙 로드 성공: N개
```
---
## 🎉 주요 개선 사항
### 사용자 경험
- ✅ 화면관리에서 채번규칙이 자동으로 표시
- ✅ 메뉴 구조를 몰라도 규칙 설정 가능
- ✅ 같은 테이블 화면에 규칙 재사용 자동
### 유지보수성
- ✅ 메뉴 구조 변경 시 규칙 재설정 불필요
- ✅ 테이블 중심 설계로 직관적
- ✅ 코드 복잡도 감소
### 확장성
- ✅ 향후 scope_type 추가 가능
- ✅ 다중 테이블 지원 가능
- ✅ 멀티테넌시 완벽 지원
---
## ⚠️ 알려진 제약사항
1. **메뉴 목록 로드 미구현**
- NumberingRuleDesigner에서 `scope_type='menu'` 선택 시 메뉴 목록 로드 필요
- TODO: 메뉴 API 연동
2. **마이그레이션 실행 대기**
- Docker 컨테이너 연결 문제로 수동 실행 필요
- 배포 시 반드시 실행 필요
---
## 📝 다음 단계
1. **마이그레이션 실행**
- DB 접속 정보 확인 후 마이그레이션 실행
- 검증 쿼리로 정상 동작 확인
2. **통합 테스트**
- 전체 워크플로우 테스트
- 회사별 데이터 격리 확인
- 우선순위 필터링 확인
3. **메뉴 API 연동**
- NumberingRuleDesigner에서 메뉴 목록 로드 구현
4. **사용자 가이드 작성**
- 채번규칙 사용 방법 문서화
- scope_type별 사용 예시 추가
---
## 📞 문의 및 지원
- **작성자**: AI 개발팀
- **작성일**: 2025-11-08
- **관련 문서**: `채번규칙_테이블기반_필터링_구현_계획서.md`
**구현 완료!** 🎊
마이그레이션 실행 후 바로 사용 가능합니다.