Compare commits
No commits in common. "main" and "feat/dashboard" have entirely different histories.
main
...
feat/dashb
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="output" path="WebContent/WEB-INF/classes"/>
|
||||
</classpath>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"Framelink Figma MCP": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,394 +0,0 @@
|
|||
# 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. **철저한 마무리** ✨
|
||||
- 로그 제거, 테스트, 명확한 설명
|
||||
|
||||
---
|
||||
|
||||
## 11. 화면관리 시스템 위젯 개발 가이드
|
||||
|
||||
### 위젯 크기 설정의 핵심 원칙
|
||||
|
||||
화면관리 시스템에서 위젯을 개발할 때, **크기 제어는 상위 컨테이너(`RealtimePreviewDynamic`)가 담당**합니다.
|
||||
|
||||
#### ✅ 올바른 크기 설정 패턴
|
||||
|
||||
```tsx
|
||||
// 위젯 컴포넌트 내부
|
||||
export function YourWidget({ component }: YourWidgetProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-between gap-2"
|
||||
style={{
|
||||
padding: component.style?.padding || "0.75rem",
|
||||
backgroundColor: component.style?.backgroundColor,
|
||||
// ❌ width, height, minHeight 등 크기 관련 속성은 제거!
|
||||
}}
|
||||
>
|
||||
{/* 위젯 내용 */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 크기 설정 패턴
|
||||
|
||||
```tsx
|
||||
// 이렇게 하면 안 됩니다!
|
||||
<div
|
||||
style={{
|
||||
width: component.style?.width || "100%", // ❌ 상위에서 이미 제어함
|
||||
height: component.style?.height || "80px", // ❌ 상위에서 이미 제어함
|
||||
minHeight: "80px", // ❌ 내부 컨텐츠가 줄어듦
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
### 이유
|
||||
|
||||
1. **`RealtimePreviewDynamic`**이 `baseStyle`로 이미 크기를 제어:
|
||||
|
||||
```tsx
|
||||
const baseStyle = {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: getWidth(), // size.width 사용
|
||||
height: getHeight(), // size.height 사용
|
||||
};
|
||||
```
|
||||
|
||||
2. 위젯 내부에서 크기를 다시 설정하면:
|
||||
- 중복 설정으로 인한 충돌
|
||||
- 내부 컨텐츠가 설정한 크기보다 작게 표시됨
|
||||
- 편집기에서 설정한 크기와 실제 렌더링 크기 불일치
|
||||
|
||||
### 위젯이 관리해야 할 스타일
|
||||
|
||||
위젯 컴포넌트는 **위젯 고유의 스타일**만 관리합니다:
|
||||
|
||||
- ✅ `padding`: 내부 여백
|
||||
- ✅ `backgroundColor`: 배경색
|
||||
- ✅ `border`, `borderRadius`: 테두리
|
||||
- ✅ `gap`: 자식 요소 간격
|
||||
- ✅ `flexDirection`, `alignItems`: 레이아웃 방향
|
||||
|
||||
### 위젯 등록 시 defaultSize
|
||||
|
||||
```tsx
|
||||
ComponentRegistry.registerComponent({
|
||||
id: "your-widget",
|
||||
name: "위젯 이름",
|
||||
category: "utility",
|
||||
defaultSize: { width: 1200, height: 80 }, // 픽셀 단위 (필수)
|
||||
component: YourWidget,
|
||||
defaultProps: {
|
||||
style: {
|
||||
padding: "0.75rem",
|
||||
// width, height는 defaultSize로 제어되므로 여기 불필요
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 레이아웃 구조
|
||||
|
||||
```tsx
|
||||
// 전체 높이를 차지하고 내부 요소를 정렬
|
||||
<div className="flex h-full w-full items-center justify-between gap-2">
|
||||
{/* 왼쪽 컨텐츠 */}
|
||||
<div className="flex items-center gap-3">{/* ... */}</div>
|
||||
|
||||
{/* 오른쪽 버튼들 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* flex-shrink-0으로 버튼이 줄어들지 않도록 보장 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 체크리스트
|
||||
|
||||
위젯 개발 시 다음을 확인하세요:
|
||||
|
||||
- [ ] 위젯 루트 요소에 `h-full w-full` 클래스 사용
|
||||
- [ ] `width`, `height`, `minHeight` 인라인 스타일 **제거**
|
||||
- [ ] `padding`, `backgroundColor` 등 위젯 고유 스타일만 관리
|
||||
- [ ] `defaultSize`에 적절한 기본 크기 설정
|
||||
- [ ] 양끝 정렬이 필요하면 `justify-between` 사용
|
||||
- [ ] 줄어들면 안 되는 요소에 `flex-shrink-0` 적용
|
||||
|
||||
---
|
||||
|
||||
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 아키텍처 가이드
|
||||
|
||||
## 전체 아키텍처
|
||||
|
||||
이 애플리케이션은 전형적인 Spring MVC 3-tier 아키텍처를 따릅니다:
|
||||
|
||||
- **Presentation Layer**: JSP + jQuery (Frontend)
|
||||
- **Business Layer**: Spring Controllers + Services (Backend Logic)
|
||||
- **Data Access Layer**: MyBatis + PostgreSQL (Database)
|
||||
|
||||
## 패키지 구조
|
||||
|
||||
```
|
||||
src/com/pms/
|
||||
├── controller/ # Spring MVC Controllers (@Controller)
|
||||
├── service/ # Business Logic (@Service)
|
||||
├── mapper/ # MyBatis XML Mappers
|
||||
├── common/ # 공통 유틸리티 및 설정
|
||||
├── salesmgmt/ # 영업관리 모듈
|
||||
└── ions/ # 특수 모듈
|
||||
```
|
||||
|
||||
## 주요 컴포넌트
|
||||
|
||||
### Controllers
|
||||
|
||||
모든 컨트롤러는 [BaseService](mdc:src/com/pms/common/service/BaseService.java)를 상속받습니다.
|
||||
|
||||
- URL 패턴: `*.do` (예: `/admin/menuMngList.do`)
|
||||
- 주요 컨트롤러: [AdminController](mdc:src/com/pms/controller/AdminController.java)
|
||||
|
||||
### Services
|
||||
|
||||
비즈니스 로직을 처리하는 서비스 계층입니다.
|
||||
|
||||
- 예시: [AdminService](mdc:src/com/pms/service/AdminService.java)
|
||||
- MyBatis SqlSession을 직접 사용하여 데이터베이스 접근
|
||||
|
||||
### MyBatis Mappers
|
||||
|
||||
SQL 쿼리를 XML로 정의합니다.
|
||||
|
||||
- 위치: `src/com/pms/mapper/`
|
||||
- 예시: [admin.xml](mdc:src/com/pms/mapper/admin.xml)
|
||||
|
||||
### JSP Views
|
||||
|
||||
JSP 뷰 파일들은 `WebContent/WEB-INF/view/` 디렉토리에 위치합니다.
|
||||
|
||||
- InternalResourceViewResolver 사용
|
||||
- prefix: `/WEB-INF/view`, suffix: `.jsp`
|
||||
|
||||
## 데이터베이스 설정
|
||||
|
||||
- JNDI DataSource 사용: `plm`
|
||||
- PostgreSQL 연결
|
||||
- 초기 데이터: [ilshin.pgsql](mdc:db/ilshin.pgsql)
|
||||
|
||||
## 설정 파일 위치
|
||||
|
||||
- Spring 설정: [dispatcher-servlet.xml](mdc:WebContent/WEB-INF/dispatcher-servlet.xml)
|
||||
- 로깅 설정: [log4j.xml](mdc:WebContent/WEB-INF/log4j.xml)
|
||||
- 웹 설정: [web.xml](mdc:WebContent/WEB-INF/web.xml)
|
||||
|
|
@ -3,143 +3,205 @@ description:
|
|||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 데이터베이스 가이드
|
||||
|
||||
## 데이터베이스 설정
|
||||
|
||||
### PostgreSQL 연결
|
||||
- **드라이버**: `pg` (node-postgres)
|
||||
- **설정 파일**: `backend-node/src/config/database.ts`
|
||||
- **환경 변수**: `backend-node/.env` (DATABASE_URL)
|
||||
- **JNDI 리소스명**: `plm`
|
||||
- **드라이버**: `org.postgresql.Driver`
|
||||
- **설정 파일**: [context.xml](mdc:tomcat-conf/context.xml)
|
||||
|
||||
### 연결 풀 사용
|
||||
```typescript
|
||||
import { Pool } from 'pg';
|
||||
### 초기 데이터
|
||||
- **스키마 파일**: [ilshin.pgsql](mdc:db/ilshin.pgsql)
|
||||
- **역할 설정**: [00-create-roles.sh](mdc:db/00-create-roles.sh)
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
});
|
||||
```
|
||||
## MyBatis 설정
|
||||
|
||||
## 쿼리 패턴
|
||||
|
||||
### 기본 CRUD
|
||||
```typescript
|
||||
// SELECT
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM example_table WHERE company_code = $1 ORDER BY created_at DESC',
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
// INSERT
|
||||
const result = await pool.query(
|
||||
'INSERT INTO example_table (company_code, name) VALUES ($1, $2) RETURNING *',
|
||||
[companyCode, name]
|
||||
);
|
||||
|
||||
// UPDATE
|
||||
const result = await pool.query(
|
||||
'UPDATE example_table SET name = $1, updated_at = NOW() WHERE id = $2 AND company_code = $3 RETURNING *',
|
||||
[name, id, companyCode]
|
||||
);
|
||||
|
||||
// DELETE
|
||||
const result = await pool.query(
|
||||
'DELETE FROM example_table WHERE id = $1 AND company_code = $2 RETURNING id',
|
||||
[id, companyCode]
|
||||
);
|
||||
### SqlSession 사용 패턴
|
||||
```java
|
||||
public List<Map<String, Object>> getData(Map<String, Object> paramMap) {
|
||||
SqlSession sqlSession = SqlMapConfig.getInstance().getSqlSession();
|
||||
try {
|
||||
return sqlSession.selectList("namespace.queryId", paramMap);
|
||||
} finally {
|
||||
sqlSession.close(); // 반드시 리소스 해제
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 트랜잭션 처리
|
||||
```typescript
|
||||
const client = await pool.connect();
|
||||
```java
|
||||
public void saveData(Map<String, Object> paramMap) {
|
||||
SqlSession sqlSession = SqlMapConfig.getInstance().getSqlSession(false); // autoCommit=false
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('INSERT INTO ...', [params]);
|
||||
await client.query('UPDATE ...', [params]);
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
sqlSession.insert("namespace.insertQuery", paramMap);
|
||||
sqlSession.update("namespace.updateQuery", paramMap);
|
||||
sqlSession.commit(); // 명시적 커밋
|
||||
} catch (Exception e) {
|
||||
sqlSession.rollback(); // 오류 시 롤백
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
sqlSession.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 동적 조건 처리
|
||||
```typescript
|
||||
const conditions: string[] = ['company_code = $1'];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
## 매퍼 XML 작성 가이드
|
||||
|
||||
if (searchText) {
|
||||
conditions.push(`name ILIKE $${paramIndex}`);
|
||||
params.push(`%${searchText}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
### 기본 구조
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="admin">
|
||||
<!-- 쿼리 정의 -->
|
||||
</mapper>
|
||||
```
|
||||
|
||||
if (status) {
|
||||
conditions.push(`status = $${paramIndex}`);
|
||||
params.push(status);
|
||||
paramIndex++;
|
||||
}
|
||||
### 파라미터 바인딩
|
||||
```xml
|
||||
<!-- 안전한 파라미터 바인딩 (권장) -->
|
||||
<select id="selectUser" parameterType="map" resultType="map">
|
||||
SELECT * FROM users
|
||||
WHERE user_id = #{userId}
|
||||
AND status = #{status}
|
||||
</select>
|
||||
|
||||
const query = `SELECT * FROM example_table WHERE ${conditions.join(' AND ')} ORDER BY created_at DESC`;
|
||||
const result = await pool.query(query, params);
|
||||
<!-- 동적 조건 처리 -->
|
||||
<select id="selectUserList" parameterType="map" resultType="map">
|
||||
SELECT * FROM users
|
||||
WHERE 1=1
|
||||
<if test="userName != null and userName != ''">
|
||||
AND user_name LIKE '%' || #{userName} || '%'
|
||||
</if>
|
||||
<if test="deptCode != null and deptCode != ''">
|
||||
AND dept_code = #{deptCode}
|
||||
</if>
|
||||
</select>
|
||||
```
|
||||
|
||||
### PostgreSQL 특화 문법
|
||||
```xml
|
||||
<!-- 시퀀스 사용 -->
|
||||
<insert id="insertData" parameterType="map">
|
||||
INSERT INTO table_name (id, name, reg_date)
|
||||
VALUES (nextval('seq_table'), #{name}, now())
|
||||
</insert>
|
||||
|
||||
<!-- 숫자 타입 캐스팅 -->
|
||||
<update id="updateData" parameterType="map">
|
||||
UPDATE table_name
|
||||
SET status = #{status}
|
||||
WHERE id = #{id}::numeric
|
||||
</update>
|
||||
|
||||
<!-- 재귀 쿼리 (메뉴 트리 구조) -->
|
||||
<select id="selectMenuTree" resultType="map">
|
||||
WITH RECURSIVE menu_tree AS (
|
||||
SELECT * FROM menu_info WHERE parent_id = 0
|
||||
UNION ALL
|
||||
SELECT m.* FROM menu_info m
|
||||
JOIN menu_tree mt ON m.parent_id = mt.id
|
||||
)
|
||||
SELECT * FROM menu_tree ORDER BY path
|
||||
</select>
|
||||
```
|
||||
|
||||
## 주요 테이블 구조
|
||||
|
||||
### 메뉴 관리
|
||||
- **menu_info**: 메뉴 정보
|
||||
- **menu_auth_group**: 메뉴 권한 그룹
|
||||
- **auth_group**: 권한 그룹 정보
|
||||
- **MENU_INFO**: 메뉴 정보
|
||||
- **MENU_AUTH_GROUP**: 메뉴 권한 그룹
|
||||
- **AUTH_GROUP**: 권한 그룹 정보
|
||||
|
||||
### 사용자 관리
|
||||
- **user_info**: 사용자 정보
|
||||
- **dept_info**: 부서 정보
|
||||
- **user_auth**: 사용자 권한
|
||||
- **USER_INFO**: 사용자 정보
|
||||
- **DEPT_INFO**: 부서 정보
|
||||
- **USER_AUTH**: 사용자 권한
|
||||
|
||||
### 코드 관리
|
||||
- **code_info**: 공통 코드
|
||||
- **code_category**: 코드 카테고리
|
||||
- **CODE_INFO**: 공통 코드
|
||||
- **CODE_CATEGORY**: 코드 카테고리
|
||||
|
||||
## 마이그레이션
|
||||
## 데이터베이스 개발 모범 사례
|
||||
|
||||
마이그레이션 파일은 `db/migrations/` 디렉토리에 순번으로 관리:
|
||||
### 1. 파라미터 검증
|
||||
```xml
|
||||
<select id="selectData" parameterType="map" resultType="map">
|
||||
SELECT * FROM table_name
|
||||
WHERE 1=1
|
||||
<if test="id != null and id != ''">
|
||||
AND id = #{id}::numeric
|
||||
</if>
|
||||
</select>
|
||||
```
|
||||
db/migrations/
|
||||
├── 001_initial_schema.sql
|
||||
├── 002_add_company_code.sql
|
||||
├── ...
|
||||
└── 1021_create_numbering_audit_log.sql
|
||||
|
||||
### 2. 페이징 처리
|
||||
```xml
|
||||
<select id="selectListWithPaging" parameterType="map" resultType="map">
|
||||
SELECT * FROM (
|
||||
SELECT *, ROW_NUMBER() OVER (ORDER BY reg_date DESC) as rnum
|
||||
FROM table_name
|
||||
WHERE 1=1
|
||||
<!-- 검색 조건 -->
|
||||
) t
|
||||
WHERE rnum BETWEEN #{startRow}::numeric AND #{endRow}::numeric
|
||||
</select>
|
||||
```
|
||||
|
||||
### 3. 대소문자 처리
|
||||
```xml
|
||||
<!-- PostgreSQL은 대소문자를 구분하므로 주의 -->
|
||||
<select id="selectData" resultType="map">
|
||||
SELECT
|
||||
user_id as "userId", -- 카멜케이스 변환
|
||||
user_name as "userName",
|
||||
UPPER(status) as "status"
|
||||
FROM user_info
|
||||
</select>
|
||||
```
|
||||
|
||||
### 4. NULL 처리
|
||||
```xml
|
||||
<select id="selectData" resultType="map">
|
||||
SELECT
|
||||
COALESCE(description, '') as description,
|
||||
CASE WHEN status = 'Y' THEN '활성' ELSE '비활성' END as statusName
|
||||
FROM table_name
|
||||
</select>
|
||||
```
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
### 인덱스 활용
|
||||
```sql
|
||||
CREATE INDEX idx_example_company_code ON example_table(company_code);
|
||||
-- 자주 검색되는 컬럼에 인덱스 생성
|
||||
CREATE INDEX idx_user_dept ON user_info(dept_code);
|
||||
CREATE INDEX idx_menu_parent ON menu_info(parent_id);
|
||||
```
|
||||
|
||||
### 페이징 처리
|
||||
```typescript
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE company_code = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`;
|
||||
const result = await pool.query(query, [companyCode, limit, offset]);
|
||||
### 쿼리 최적화
|
||||
```xml
|
||||
<!-- EXISTS 사용으로 성능 개선 -->
|
||||
<select id="selectUserWithAuth" resultType="map">
|
||||
SELECT u.* FROM user_info u
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM user_auth ua
|
||||
WHERE ua.user_id = u.user_id
|
||||
AND ua.auth_code = #{authCode}
|
||||
)
|
||||
</select>
|
||||
```
|
||||
|
||||
### 슬로우 쿼리 확인
|
||||
```sql
|
||||
SELECT query, mean_time, calls
|
||||
FROM pg_stat_statements
|
||||
ORDER BY mean_time DESC;
|
||||
### 배치 처리
|
||||
```xml
|
||||
<!-- 대량 데이터 삽입 시 배치 사용 -->
|
||||
<insert id="insertBatch" parameterType="list">
|
||||
INSERT INTO table_name (col1, col2, col3)
|
||||
VALUES
|
||||
<foreach collection="list" item="item" separator=",">
|
||||
(#{item.col1}, #{item.col2}, #{item.col3})
|
||||
</foreach>
|
||||
</insert>
|
||||
```
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# 개발 가이드
|
||||
|
||||
## 개발 환경 설정
|
||||
|
||||
### Docker 개발 환경
|
||||
```bash
|
||||
# 개발 환경 실행
|
||||
docker-compose -f docker-compose.dev.yml up --build -d
|
||||
|
||||
# 운영 환경 실행
|
||||
docker-compose -f docker-compose.prod.yml up --build -d
|
||||
```
|
||||
|
||||
### 로컬 개발 환경
|
||||
1. Java 7 JDK 설치
|
||||
2. Eclipse IDE 설정
|
||||
3. Tomcat 7.0 설정
|
||||
4. PostgreSQL 데이터베이스 설정
|
||||
|
||||
## 코딩 컨벤션
|
||||
|
||||
### Controller 개발
|
||||
```java
|
||||
@Controller
|
||||
public class ExampleController extends BaseService {
|
||||
|
||||
@Autowired
|
||||
ExampleService exampleService;
|
||||
|
||||
@RequestMapping("/example/list.do")
|
||||
public String getList(HttpServletRequest request,
|
||||
@RequestParam Map<String, Object> paramMap) {
|
||||
// 비즈니스 로직은 Service에서 처리
|
||||
List<Map<String, Object>> list = exampleService.getList(request, paramMap);
|
||||
request.setAttribute("list", list);
|
||||
return "/example/list";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service 개발
|
||||
```java
|
||||
@Service
|
||||
public class ExampleService extends BaseService {
|
||||
|
||||
public List<Map<String, Object>> getList(HttpServletRequest request,
|
||||
Map<String, Object> paramMap) {
|
||||
SqlSession sqlSession = SqlMapConfig.getInstance().getSqlSession();
|
||||
try {
|
||||
return sqlSession.selectList("example.selectList", paramMap);
|
||||
} finally {
|
||||
sqlSession.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MyBatis Mapper 개발
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="example">
|
||||
<select id="selectList" parameterType="map" resultType="map">
|
||||
SELECT * FROM example_table
|
||||
WHERE 1=1
|
||||
<if test="searchText != null and searchText != ''">
|
||||
AND name LIKE '%' || #{searchText} || '%'
|
||||
</if>
|
||||
</select>
|
||||
</mapper>
|
||||
```
|
||||
|
||||
## 주요 유틸리티
|
||||
|
||||
### 공통 유틸리티
|
||||
- [CommonUtils](mdc:src/com/pms/common/utils/CommonUtils.java) - 공통 유틸리티 메서드
|
||||
- [Constants](mdc:src/com/pms/common/utils/Constants.java) - 상수 정의
|
||||
- [Message](mdc:src/com/pms/common/Message.java) - 메시지 처리
|
||||
|
||||
### 파일 관련
|
||||
- [FileRenameClass](mdc:src/com/pms/common/FileRenameClass.java) - 파일명 변경
|
||||
- 파일 업로드/다운로드 처리
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
### JSP 개발
|
||||
- 위치: `WebContent/WEB-INF/view/`
|
||||
- 공통 초기화: [init_jqGrid.jsp](mdc:WebContent/init_jqGrid.jsp)
|
||||
- 스타일시트: [all.css](mdc:WebContent/css/all.css)
|
||||
|
||||
### JavaScript 라이브러리
|
||||
- jQuery 1.11.3/2.1.4
|
||||
- jqGrid 4.7.1 - 데이터 그리드
|
||||
- Tabulator - 테이블 컴포넌트
|
||||
- rMateChart - 차트 라이브러리
|
||||
|
||||
## 데이터베이스 개발
|
||||
|
||||
### 연결 설정
|
||||
- JNDI 리소스명: `plm`
|
||||
- 드라이버: PostgreSQL
|
||||
- 컨텍스트 설정: [context.xml](mdc:tomcat-conf/context.xml)
|
||||
|
||||
### 스키마 관리
|
||||
- 초기 스키마: [ilshin.pgsql](mdc:db/ilshin.pgsql)
|
||||
- 역할 설정: [00-create-roles.sh](mdc:db/00-create-roles.sh)
|
||||
|
||||
## 빌드 및 배포
|
||||
- Eclipse 기반 빌드 (Maven/Gradle 미사용)
|
||||
- 컴파일된 클래스: `WebContent/WEB-INF/classes/`
|
||||
- 라이브러리: `WebContent/WEB-INF/lib/`
|
||||
- WAR 파일로 Tomcat 배포
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Next.js 마이그레이션 가이드
|
||||
|
||||
## 마이그레이션 개요
|
||||
현재 JSP/jQuery 기반 프론트엔드를 Next.js로 전환하는 작업이 계획되어 있습니다.
|
||||
자세한 내용은 [TODO.md](mdc:TODO.md)를 참조하세요.
|
||||
|
||||
## 현재 프론트엔드 분석
|
||||
|
||||
### JSP 뷰 구조
|
||||
```
|
||||
WebContent/WEB-INF/view/
|
||||
├── admin/ # 관리자 화면
|
||||
├── approval/ # 승인 관리
|
||||
├── common/ # 공통 컴포넌트
|
||||
├── dashboard/ # 대시보드
|
||||
├── main/ # 메인 화면
|
||||
└── ... # 기타 모듈별 화면
|
||||
```
|
||||
|
||||
### 주요 JavaScript 라이브러리
|
||||
- **jQuery**: 1.11.3/2.1.4 - DOM 조작 및 AJAX
|
||||
- **jqGrid**: 4.7.1 - 데이터 그리드 (교체 필요)
|
||||
- **Tabulator**: 테이블 컴포넌트
|
||||
- **rMateChart**: 차트 라이브러리 (교체 필요)
|
||||
- **CKEditor**: 텍스트 에디터
|
||||
|
||||
### CSS 프레임워크
|
||||
- [all.css](mdc:WebContent/css/all.css) - 메인 스타일시트
|
||||
- jQuery UI 테마 적용
|
||||
- 반응형 디자인 미적용 (데스크톱 중심)
|
||||
|
||||
## API 설계 가이드
|
||||
|
||||
### RESTful API 변환
|
||||
현재 Spring MVC는 JSP 뷰를 반환하는 구조입니다:
|
||||
```java
|
||||
@RequestMapping("/admin/menuMngList.do")
|
||||
public String getMenuList(HttpServletRequest request, @RequestParam Map<String, Object> paramMap) {
|
||||
// 데이터 조회
|
||||
List<Map<String, Object>> menuList = adminService.getMenuList(request, paramMap);
|
||||
request.setAttribute("menuList", menuList);
|
||||
return "/admin/menu/menuMngList"; // JSP 뷰 반환
|
||||
}
|
||||
```
|
||||
|
||||
Next.js 연동을 위해 JSON API로 변환 필요:
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class AdminApiController {
|
||||
|
||||
@GetMapping("/admin/menus")
|
||||
@ResponseBody
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMenuList(
|
||||
@RequestParam Map<String, Object> paramMap) {
|
||||
List<Map<String, Object>> menuList = adminService.getMenuList(null, paramMap);
|
||||
return ResponseEntity.ok(ApiResponse.success(menuList));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API 응답 표준화
|
||||
```java
|
||||
public class ApiResponse<T> {
|
||||
private boolean success;
|
||||
private T data;
|
||||
private String message;
|
||||
private String errorCode;
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(true, data, null, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(String message, String errorCode) {
|
||||
return new ApiResponse<>(false, null, message, errorCode);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 컴포넌트 매핑 가이드
|
||||
|
||||
### 데이터 그리드 교체
|
||||
**현재**: jqGrid 4.7.1
|
||||
```javascript
|
||||
$("#grid").jqGrid({
|
||||
url: 'menuMngList.do',
|
||||
datatype: 'json',
|
||||
colModel: [
|
||||
{name: 'menuName', label: '메뉴명'},
|
||||
{name: 'url', label: 'URL'}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
**변환 후**: TanStack Table 또는 AG Grid
|
||||
```tsx
|
||||
import { useTable } from '@tanstack/react-table';
|
||||
|
||||
const MenuTable = () => {
|
||||
const columns = [
|
||||
{ accessorKey: 'menuName', header: '메뉴명' },
|
||||
{ accessorKey: 'url', header: 'URL' }
|
||||
];
|
||||
|
||||
const table = useTable({ data, columns });
|
||||
// 테이블 렌더링
|
||||
};
|
||||
```
|
||||
|
||||
### 차트 라이브러리 교체
|
||||
**현재**: rMateChart
|
||||
**변환 후**: Recharts 또는 Chart.js
|
||||
|
||||
### 폼 처리 교체
|
||||
**현재**: jQuery 기반 폼 처리
|
||||
**변환 후**: react-hook-form 사용
|
||||
|
||||
## 인증/인가 처리
|
||||
|
||||
### 현재 세션 기반 인증
|
||||
```java
|
||||
// 세션에서 사용자 정보 조회
|
||||
PersonBean person = (PersonBean)request.getSession().getAttribute(Constants.PERSON_BEAN);
|
||||
```
|
||||
|
||||
### Next.js 연동 방안
|
||||
1. **세션 유지**: 쿠키 기반 세션 ID 전달
|
||||
2. **JWT 토큰**: 새로운 토큰 기반 인증 도입
|
||||
3. **하이브리드**: 기존 세션 + API 토큰
|
||||
|
||||
## 개발 단계별 접근
|
||||
|
||||
### Phase 1: API 개발
|
||||
1. 기존 Controller 분석
|
||||
2. @RestController 신규 생성
|
||||
3. 기존 Service 재사용
|
||||
4. CORS 설정 추가
|
||||
|
||||
### Phase 2: Next.js 기본 구조
|
||||
1. Next.js 프로젝트 생성
|
||||
2. 기본 레이아웃 구현
|
||||
3. 라우팅 구조 설계
|
||||
4. 공통 컴포넌트 개발
|
||||
|
||||
### Phase 3: 화면별 마이그레이션
|
||||
1. 관리자 화면부터 시작
|
||||
2. 주요 업무 화면 순차 전환
|
||||
3. 대시보드 및 리포트 화면
|
||||
|
||||
### Phase 4: 테스트 및 최적화
|
||||
1. 기능 테스트
|
||||
2. 성능 최적화
|
||||
3. 사용자 테스트
|
||||
4. 점진적 배포
|
||||
|
||||
## 주의사항
|
||||
|
||||
### 데이터 호환성
|
||||
- 기존 데이터베이스 스키마 유지
|
||||
- API 응답 형식 표준화
|
||||
- 날짜/시간 형식 통일
|
||||
|
||||
### 사용자 경험
|
||||
- 기존 업무 프로세스 유지
|
||||
- 화면 전환 시 혼란 최소화
|
||||
- 점진적 마이그레이션 고려
|
||||
|
||||
### 성능 고려사항
|
||||
- API 응답 속도 최적화
|
||||
- 클라이언트 사이드 캐싱
|
||||
- 이미지 및 정적 자원 최적화
|
||||
|
|
@ -1,844 +0,0 @@
|
|||
---
|
||||
priority: critical
|
||||
applies_to: all
|
||||
check_frequency: always
|
||||
enforcement: mandatory
|
||||
---
|
||||
|
||||
# 멀티테넌시(Multi-Tenancy) 필수 구현 가이드
|
||||
|
||||
**🚨 최우선 보안 규칙: 이 문서의 모든 규칙은 예외 없이 반드시 준수해야 합니다.**
|
||||
|
||||
**⚠️ AI 에이전트는 모든 코드 작성/수정 후 반드시 이 체크리스트를 확인해야 합니다.**
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**모든 비즈니스 데이터는 회사별(company_code)로 완벽하게 격리되어야 합니다.**
|
||||
|
||||
이 시스템은 멀티테넌트 아키텍처를 사용하며, 각 회사(tenant)는 자신의 데이터만 접근할 수 있어야 합니다.
|
||||
다른 회사의 데이터에 접근하는 것은 **치명적인 보안 취약점**입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터베이스 스키마 요구사항
|
||||
|
||||
### 1.1 company_code 컬럼 필수
|
||||
|
||||
**모든 비즈니스 테이블은 `company_code` 컬럼을 반드시 포함해야 합니다.**
|
||||
|
||||
```sql
|
||||
CREATE TABLE example_table (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_code VARCHAR(20) NOT NULL, -- ✅ 필수!
|
||||
name VARCHAR(100),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- 외래키 제약조건 (필수)
|
||||
CONSTRAINT fk_company FOREIGN KEY (company_code)
|
||||
REFERENCES company_mng(company_code)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- 성능을 위한 인덱스 (필수)
|
||||
CREATE INDEX idx_example_company_code ON example_table(company_code);
|
||||
|
||||
-- 복합 유니크 제약조건 (중복 방지)
|
||||
CREATE UNIQUE INDEX idx_example_unique
|
||||
ON example_table(name, company_code); -- 회사별로 고유해야 하는 경우
|
||||
```
|
||||
|
||||
### 1.2 예외 테이블 (company_code 불필요)
|
||||
|
||||
**⚠️ 유일한 예외: `company_mng` 테이블만 `company_code`가 없습니다.**
|
||||
|
||||
이 테이블은 회사 정보를 저장하는 마스터 테이블이므로 예외입니다.
|
||||
|
||||
**모든 다른 테이블은 예외 없이 `company_code`가 필수입니다:**
|
||||
|
||||
- ✅ `user_info` → `company_code` 필수 (사용자는 특정 회사 소속)
|
||||
- ✅ `menu_info` → `company_code` 필수 (회사별 메뉴 설정 가능)
|
||||
- ✅ `system_config` → `company_code` 필수 (회사별 시스템 설정)
|
||||
- ✅ `audit_log` → `company_code` 필수 (회사별 감사 로그)
|
||||
- ✅ 모든 비즈니스 테이블 → `company_code` 필수
|
||||
|
||||
**새로운 테이블 생성 시 체크리스트:**
|
||||
|
||||
- [ ] `company_mng` 테이블인가? → `company_code` 불필요 (유일한 예외)
|
||||
- [ ] 그 외 모든 테이블 → `company_code` 필수 (예외 없음)
|
||||
- [ ] `company_code` 없이 테이블을 만들려고 하는가? → 다시 생각하세요!
|
||||
|
||||
---
|
||||
|
||||
## 2. 백엔드 API 구현 필수 사항
|
||||
|
||||
### 2.1 모든 데이터 조회 시 필터링
|
||||
|
||||
**절대 원칙: 모든 SELECT 쿼리는 company_code 필터링을 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode; // 인증된 사용자의 회사 코드
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사 데이터 조회 가능
|
||||
query = `
|
||||
SELECT * FROM example_table
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [];
|
||||
logger.info("최고 관리자 전체 데이터 조회");
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 데이터만 조회
|
||||
query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE company_code = $1
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [companyCode];
|
||||
logger.info("회사별 데이터 조회", { companyCode });
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법 - 절대 사용 금지
|
||||
|
||||
```typescript
|
||||
// 🚨 치명적 보안 취약점: company_code 필터링 없음
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const query = `SELECT * FROM example_table`; // 모든 회사 데이터 노출!
|
||||
const result = await pool.query(query);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 데이터 생성 (INSERT)
|
||||
|
||||
**모든 INSERT 쿼리는 company_code를 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function createData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode; // 서버에서 확정
|
||||
const { name, description } = req.body;
|
||||
|
||||
const query = `
|
||||
INSERT INTO example_table (company_code, name, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, name, description]);
|
||||
|
||||
logger.info("데이터 생성", {
|
||||
companyCode,
|
||||
id: result.rows[0].id,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 클라이언트 입력 사용 금지
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 클라이언트가 임의의 회사 코드 지정 가능
|
||||
async function createData(req: Request, res: Response) {
|
||||
const { companyCode, name } = req.body; // 사용자가 다른 회사 코드 전달 가능!
|
||||
const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`;
|
||||
await pool.query(query, [companyCode, name]);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 데이터 수정 (UPDATE)
|
||||
|
||||
**WHERE 절에 company_code를 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function updateData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 데이터 수정 가능
|
||||
query = `
|
||||
UPDATE example_table
|
||||
SET name = $1, description = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
`;
|
||||
params = [name, description, id];
|
||||
} else {
|
||||
// 일반 회사: 자신의 데이터만 수정 가능
|
||||
query = `
|
||||
UPDATE example_table
|
||||
SET name = $1, description = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND company_code = $4
|
||||
RETURNING *
|
||||
`;
|
||||
params = [name, description, id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터를 찾을 수 없거나 권한이 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 수정", { companyCode, id });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 다른 회사의 같은 ID 데이터도 수정됨
|
||||
const query = `
|
||||
UPDATE example_table
|
||||
SET name = $1, description = $2
|
||||
WHERE id = $3
|
||||
`;
|
||||
```
|
||||
|
||||
### 2.4 데이터 삭제 (DELETE)
|
||||
|
||||
**WHERE 절에 company_code를 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function deleteData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 데이터 삭제 가능
|
||||
query = `DELETE FROM example_table WHERE id = $1 RETURNING id`;
|
||||
params = [id];
|
||||
} else {
|
||||
// 일반 회사: 자신의 데이터만 삭제 가능
|
||||
query = `
|
||||
DELETE FROM example_table
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id
|
||||
`;
|
||||
params = [id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터를 찾을 수 없거나 권한이 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 삭제", { companyCode, id });
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. company_code = "\*" 의 의미
|
||||
|
||||
### 3.1 최고 관리자 전용 데이터
|
||||
|
||||
**중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다.
|
||||
|
||||
- ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터
|
||||
- ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터
|
||||
|
||||
### 3.2 데이터 격리 원칙
|
||||
|
||||
**회사별 데이터 접근 규칙:**
|
||||
|
||||
| 사용자 유형 | company_code | 접근 가능한 데이터 |
|
||||
| ----------- | ------------ | ---------------------------------------------- |
|
||||
| 회사 A | `COMPANY_A` | `company_code = 'COMPANY_A'` 데이터만 |
|
||||
| 회사 B | `COMPANY_B` | `company_code = 'COMPANY_B'` 데이터만 |
|
||||
| 최고 관리자 | `*` | 모든 회사 데이터 + `company_code = '*'` 데이터 |
|
||||
|
||||
**핵심**:
|
||||
|
||||
- 일반 회사는 `company_code = "*"` 데이터를 **절대 볼 수 없음**
|
||||
- 일반 회사는 다른 회사의 데이터를 **절대 볼 수 없음**
|
||||
- 최고 관리자만 모든 데이터에 접근 가능
|
||||
|
||||
---
|
||||
|
||||
## 4. 복잡한 쿼리에서의 멀티테넌시
|
||||
|
||||
### 4.1 JOIN 쿼리
|
||||
|
||||
**모든 JOIN된 테이블에도 company_code 필터링을 적용해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
SELECT
|
||||
a.*,
|
||||
b.name as category_name,
|
||||
c.name as user_name
|
||||
FROM example_table a
|
||||
LEFT JOIN category_table b
|
||||
ON a.category_id = b.id
|
||||
AND a.company_code = b.company_code -- ✅ JOIN 조건에도 company_code 필수
|
||||
LEFT JOIN user_info c
|
||||
ON a.user_id = c.user_id
|
||||
AND a.company_code = c.company_code
|
||||
WHERE a.company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: JOIN에서 다른 회사 데이터와 섞임
|
||||
const query = `
|
||||
SELECT
|
||||
a.*,
|
||||
b.name as category_name
|
||||
FROM example_table a
|
||||
LEFT JOIN category_table b ON a.category_id = b.id -- company_code 없음!
|
||||
WHERE a.company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 4.2 서브쿼리
|
||||
|
||||
**모든 서브쿼리에도 company_code 필터링을 적용해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table
|
||||
WHERE active = true AND company_code = $1 -- ✅
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 서브쿼리에서 company_code 누락
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table WHERE active = true -- company_code 없음!
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 4.3 집계 함수 (COUNT, SUM 등)
|
||||
|
||||
**집계 함수도 company_code로 필터링해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM example_table
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 모든 회사의 총합 반환
|
||||
const query = `SELECT COUNT(*) as total FROM example_table`;
|
||||
```
|
||||
|
||||
### 4.4 EXISTS 서브쿼리
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table a
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM related_table b
|
||||
WHERE b.example_id = a.id
|
||||
AND b.company_code = a.company_code -- ✅ 필수
|
||||
)
|
||||
AND a.company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 자동 필터 시스템 (autoFilter)
|
||||
|
||||
### 5.1 백엔드 구현 (이미 완료)
|
||||
|
||||
백엔드에는 `autoFilter` 기능이 구현되어 있습니다:
|
||||
|
||||
```typescript
|
||||
// tableManagementController.ts
|
||||
let enhancedSearch = { ...search };
|
||||
if (autoFilter?.enabled && req.user) {
|
||||
const filterColumn = autoFilter.filterColumn || "company_code";
|
||||
const userField = autoFilter.userField || "companyCode";
|
||||
const userValue = (req.user as any)[userField];
|
||||
|
||||
if (userValue) {
|
||||
enhancedSearch[filterColumn] = userValue;
|
||||
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||
filterColumn,
|
||||
userValue,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 프론트엔드 사용 (필수)
|
||||
|
||||
**모든 테이블 데이터 API 호출 시 `autoFilter`를 반드시 전달해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/screen.ts
|
||||
const requestBody = {
|
||||
...params,
|
||||
autoFilter: {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
userField: "companyCode",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
requestBody
|
||||
);
|
||||
```
|
||||
|
||||
#### Entity 조인 API
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/entityJoin.ts
|
||||
const autoFilter = {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
userField: "companyCode",
|
||||
};
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/table-management/tables/${tableName}/data-with-joins`,
|
||||
{
|
||||
params: {
|
||||
...params,
|
||||
autoFilter: JSON.stringify(autoFilter),
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 서비스 계층 패턴
|
||||
|
||||
### 6.1 표준 서비스 함수 패턴
|
||||
|
||||
**서비스 함수는 항상 companyCode를 첫 번째 파라미터로 받아야 합니다.**
|
||||
|
||||
```typescript
|
||||
class ExampleService {
|
||||
async findAll(companyCode: string, filters?: any) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `SELECT * FROM example_table`;
|
||||
params = [];
|
||||
} else {
|
||||
query = `SELECT * FROM example_table WHERE company_code = $1`;
|
||||
params = [companyCode];
|
||||
}
|
||||
|
||||
return await pool.query(query, params);
|
||||
}
|
||||
|
||||
async findById(companyCode: string, id: number) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `SELECT * FROM example_table WHERE id = $1`;
|
||||
params = [id];
|
||||
} else {
|
||||
query = `SELECT * FROM example_table WHERE id = $1 AND company_code = $2`;
|
||||
params = [id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async create(companyCode: string, data: any) {
|
||||
const query = `
|
||||
INSERT INTO example_table (company_code, name, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await pool.query(query, [
|
||||
companyCode,
|
||||
data.name,
|
||||
data.description,
|
||||
]);
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
// 컨트롤러에서 사용
|
||||
const exampleService = new ExampleService();
|
||||
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const data = await exampleService.findAll(companyCode, req.query);
|
||||
return res.json({ success: true, data });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 마이그레이션 체크리스트
|
||||
|
||||
### 7.1 새로운 테이블 생성 시
|
||||
|
||||
- [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가
|
||||
- [ ] `company_mng` 테이블에 대한 외래키 제약조건 추가
|
||||
- [ ] `company_code`에 인덱스 생성
|
||||
- [ ] 복합 유니크 제약조건에 `company_code` 포함
|
||||
- [ ] 샘플 데이터에 올바른 `company_code` 값 포함
|
||||
|
||||
### 7.2 기존 테이블 마이그레이션 시
|
||||
|
||||
```sql
|
||||
-- 1. company_code 컬럼 추가
|
||||
ALTER TABLE example_table ADD COLUMN company_code VARCHAR(20);
|
||||
|
||||
-- 2. 기존 데이터를 모든 회사별로 복제
|
||||
INSERT INTO example_table (company_code, name, description, created_at)
|
||||
SELECT ci.company_code, et.name, et.description, et.created_at
|
||||
FROM (SELECT * FROM example_table WHERE company_code IS NULL) et
|
||||
CROSS JOIN company_mng ci
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM example_table et2
|
||||
WHERE et2.name = et.name
|
||||
AND et2.company_code = ci.company_code
|
||||
);
|
||||
|
||||
-- 3. NULL 데이터 삭제
|
||||
DELETE FROM example_table WHERE company_code IS NULL;
|
||||
|
||||
-- 4. NOT NULL 제약조건
|
||||
ALTER TABLE example_table ALTER COLUMN company_code SET NOT NULL;
|
||||
|
||||
-- 5. 인덱스 및 외래키
|
||||
CREATE INDEX idx_example_company ON example_table(company_code);
|
||||
ALTER TABLE example_table
|
||||
ADD CONSTRAINT fk_example_company
|
||||
FOREIGN KEY (company_code) REFERENCES company_mng(company_code)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 체크리스트
|
||||
|
||||
### 8.1 필수 테스트 시나리오
|
||||
|
||||
**모든 새로운 API는 다음 테스트를 통과해야 합니다:**
|
||||
|
||||
- [ ] **회사 A 테스트**: 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인
|
||||
- [ ] **회사 B 테스트**: 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인
|
||||
- [ ] **격리 테스트**: 회사 A로 로그인하여 회사 B 데이터에 접근 불가능한지 확인
|
||||
- [ ] **최고 관리자 테스트**: 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인
|
||||
- [ ] **수정 권한 테스트**: 회사 A가 회사 B의 데이터를 수정할 수 없는지 확인
|
||||
- [ ] **삭제 권한 테스트**: 회사 A가 회사 B의 데이터를 삭제할 수 없는지 확인
|
||||
|
||||
### 8.2 SQL 인젝션 테스트
|
||||
|
||||
```typescript
|
||||
// company_code를 URL 파라미터로 전달하려는 시도 차단
|
||||
// ❌ 이런 요청을 받아서는 안 됨
|
||||
GET /api/data?company_code=COMPANY_B
|
||||
|
||||
// ✅ company_code는 항상 req.user에서 가져와야 함
|
||||
const companyCode = req.user!.companyCode;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 감사 로그 (Audit Log)
|
||||
|
||||
### 9.1 모든 중요 작업에 로깅
|
||||
|
||||
```typescript
|
||||
logger.info("데이터 생성", {
|
||||
companyCode: req.user!.companyCode,
|
||||
userId: req.user!.userId,
|
||||
tableName: "example_table",
|
||||
action: "INSERT",
|
||||
recordId: result.rows[0].id,
|
||||
});
|
||||
|
||||
logger.warn("권한 없는 접근 시도", {
|
||||
companyCode: req.user!.companyCode,
|
||||
userId: req.user!.userId,
|
||||
attemptedRecordId: req.params.id,
|
||||
message: "다른 회사의 데이터 접근 시도",
|
||||
});
|
||||
```
|
||||
|
||||
### 9.2 감사 로그 테이블 구조
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
table_name VARCHAR(100),
|
||||
record_id VARCHAR(100),
|
||||
old_value JSONB,
|
||||
new_value JSONB,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_company ON audit_log(company_code);
|
||||
CREATE INDEX idx_audit_action ON audit_log(action, created_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 보안 체크리스트 (코드 리뷰 시 필수)
|
||||
|
||||
### 10.1 백엔드 API 체크리스트
|
||||
|
||||
- [ ] 모든 SELECT 쿼리에 `WHERE company_code = $1` 포함 (최고 관리자 예외)
|
||||
- [ ] 모든 INSERT 쿼리에 `company_code` 컬럼 포함
|
||||
- [ ] 모든 UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건 포함
|
||||
- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건 포함
|
||||
- [ ] 서브쿼리에 `company_code` 필터링 포함
|
||||
- [ ] 집계 함수에 `company_code` 필터링 포함
|
||||
- [ ] `req.user.companyCode` 사용 (클라이언트 입력 사용 금지)
|
||||
- [ ] 최고 관리자(`company_code = "*"`) 예외 처리
|
||||
- [ ] 로그에 `companyCode` 정보 포함
|
||||
- [ ] 권한 없음 시 404 또는 403 반환
|
||||
|
||||
### 10.2 프론트엔드 체크리스트
|
||||
|
||||
- [ ] 모든 테이블 데이터 API 호출 시 `autoFilter` 전달
|
||||
- [ ] `company_code`를 직접 전달하지 않음 (백엔드에서 자동 처리)
|
||||
- [ ] 에러 발생 시 적절한 메시지 표시
|
||||
|
||||
### 10.3 데이터베이스 체크리스트
|
||||
|
||||
- [ ] 모든 비즈니스 테이블에 `company_code` 컬럼 존재
|
||||
- [ ] `company_code`에 NOT NULL 제약조건 적용
|
||||
- [ ] `company_code`에 인덱스 생성
|
||||
- [ ] 외래키 제약조건으로 `company_mng` 참조
|
||||
- [ ] 복합 유니크 제약조건에 `company_code` 포함
|
||||
|
||||
---
|
||||
|
||||
## 11. 일반적인 실수와 해결방법
|
||||
|
||||
### 실수 1: 서브쿼리에서 company_code 누락
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table WHERE active = true
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table
|
||||
WHERE active = true AND company_code = $1
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 실수 2: COUNT/SUM 집계 함수
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법 - 모든 회사의 총합
|
||||
const query = `SELECT COUNT(*) as total FROM example_table`;
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM example_table
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 실수 3: autoFilter 누락
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
page,
|
||||
size,
|
||||
search,
|
||||
}
|
||||
);
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
page,
|
||||
size,
|
||||
search,
|
||||
autoFilter: {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
userField: "companyCode",
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 참고 자료
|
||||
|
||||
### 완료된 구현 예시
|
||||
|
||||
- **테이블 데이터 API**: `backend-node/src/controllers/tableManagementController.ts` (getTableData)
|
||||
- **Entity 조인 API**: `backend-node/src/controllers/entityJoinController.ts` (getTableDataWithJoins)
|
||||
- **카테고리 값 API**: `backend-node/src/services/tableCategoryValueService.ts` (getCategoryValues)
|
||||
- **프론트엔드 API**: `frontend/lib/api/screen.ts` (getTableData)
|
||||
- **프론트엔드 Entity 조인**: `frontend/lib/api/entityJoin.ts` (getTableDataWithJoins)
|
||||
|
||||
### 마이그레이션 스크립트
|
||||
|
||||
- `db/migrations/044_simple_version.sql` - table_type_columns에 company_code 추가
|
||||
- `db/migrations/045_add_company_code_to_category_values.sql` - 카테고리 값 테이블 마이그레이션
|
||||
|
||||
---
|
||||
|
||||
## 요약: 절대 잊지 말아야 할 핵심 규칙
|
||||
|
||||
### 데이터베이스
|
||||
|
||||
1. **모든 테이블에 `company_code` 필수** (`company_mng` 제외)
|
||||
2. **인덱스와 외래키 필수**
|
||||
3. **복합 유니크 제약조건에 `company_code` 포함**
|
||||
|
||||
### 백엔드 API
|
||||
|
||||
1. **모든 SELECT 쿼리**: `WHERE company_code = $1` (최고 관리자 제외)
|
||||
2. **모든 INSERT 쿼리**: `company_code` 컬럼 포함
|
||||
3. **모든 UPDATE/DELETE 쿼리**: WHERE 절에 `company_code` 조건 포함
|
||||
4. **JOIN/서브쿼리/집계**: 모두 `company_code` 필터링 필수
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
1. **모든 테이블 데이터 API 호출**: `autoFilter` 전달 필수
|
||||
2. **`company_code`를 직접 전달 금지**: 백엔드에서 자동 처리
|
||||
|
||||
---
|
||||
|
||||
**🚨 멀티테넌시는 보안의 핵심입니다. 예외 없이 모든 규칙을 준수하세요!**
|
||||
|
||||
**⚠️ company_code = "\*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**
|
||||
|
||||
**✅ 모든 테이블에 company_code 필수! (company_mng 제외)**
|
||||
|
||||
---
|
||||
|
||||
## 🤖 AI 에이전트 필수 체크리스트
|
||||
|
||||
**모든 코드 작성/수정 완료 후 반드시 다음을 확인하세요:**
|
||||
|
||||
### 데이터베이스 마이그레이션을 작성했다면:
|
||||
|
||||
- [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가했는가?
|
||||
- [ ] `company_code`에 인덱스를 생성했는가?
|
||||
- [ ] `company_mng` 테이블에 대한 외래키를 추가했는가?
|
||||
- [ ] 복합 유니크 제약조건에 `company_code`를 포함했는가?
|
||||
- [ ] 기존 데이터를 모든 회사별로 복제했는가?
|
||||
|
||||
### 백엔드 API를 작성/수정했다면:
|
||||
|
||||
- [ ] SELECT 쿼리에 `WHERE company_code = $1` 조건이 있는가? (최고 관리자 제외)
|
||||
- [ ] INSERT 쿼리에 `company_code` 컬럼이 포함되어 있는가?
|
||||
- [ ] UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건이 있는가?
|
||||
- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건이 있는가?
|
||||
- [ ] 서브쿼리에 `company_code` 필터링이 있는가?
|
||||
- [ ] 집계 함수(COUNT, SUM 등)에 `company_code` 필터링이 있는가?
|
||||
- [ ] `req.user.companyCode`를 사용하고 있는가? (클라이언트 입력 사용 금지)
|
||||
- [ ] 최고 관리자(`company_code = "*"`) 예외 처리를 했는가?
|
||||
- [ ] 로그에 `companyCode` 정보를 포함했는가?
|
||||
- [ ] 권한 없음 시 적절한 HTTP 상태 코드(404/403)를 반환하는가?
|
||||
|
||||
### 프론트엔드 API 호출을 작성/수정했다면:
|
||||
|
||||
- [ ] `autoFilter` 옵션을 전달하고 있는가?
|
||||
- [ ] `autoFilter.enabled = true`로 설정했는가?
|
||||
- [ ] `autoFilter.filterColumn = "company_code"`로 설정했는가?
|
||||
- [ ] `autoFilter.userField = "companyCode"`로 설정했는가?
|
||||
- [ ] `company_code`를 직접 전달하지 않았는가? (백엔드 자동 처리)
|
||||
|
||||
### 테스트를 수행했다면:
|
||||
|
||||
- [ ] 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인했는가?
|
||||
- [ ] 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인했는가?
|
||||
- [ ] 회사 A가 회사 B 데이터에 접근할 수 없는지 확인했는가?
|
||||
- [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인했는가?
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 위 체크리스트 중 하나라도 "아니오"가 있다면, 코드를 다시 검토하세요!**
|
||||
|
||||
**🚨 멀티테넌시 위반은 치명적인 보안 취약점입니다!**
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# 네비게이션 가이드
|
||||
|
||||
## 프로젝트 구조 이해
|
||||
|
||||
### 루트 디렉토리
|
||||
```
|
||||
plm-ilshin/
|
||||
├── src/ # Java 소스 코드
|
||||
├── WebContent/ # 웹 리소스 (JSP, CSS, JS, 이미지)
|
||||
├── db/ # 데이터베이스 스크립트
|
||||
├── tomcat-conf/ # Tomcat 설정
|
||||
├── docker-compose.*.yml # Docker 설정
|
||||
└── 문서/ # 프로젝트 문서
|
||||
```
|
||||
|
||||
### 주요 소스 디렉토리
|
||||
```
|
||||
src/com/pms/
|
||||
├── controller/ # 웹 컨트롤러 (@Controller)
|
||||
├── service/ # 비즈니스 로직 (@Service)
|
||||
├── mapper/ # MyBatis SQL 매퍼 (XML)
|
||||
├── common/ # 공통 컴포넌트
|
||||
├── salesmgmt/ # 영업관리 모듈
|
||||
└── ions/ # 특수 기능 모듈
|
||||
```
|
||||
|
||||
### 웹 리소스 구조
|
||||
```
|
||||
WebContent/
|
||||
├── WEB-INF/
|
||||
│ ├── view/ # JSP 뷰 파일
|
||||
│ ├── lib/ # JAR 라이브러리
|
||||
│ ├── classes/ # 컴파일된 클래스
|
||||
│ └── *.xml # 설정 파일
|
||||
├── css/ # 스타일시트
|
||||
├── js/ # JavaScript 파일
|
||||
├── images/ # 이미지 리소스
|
||||
└── template/ # 템플릿 파일
|
||||
```
|
||||
|
||||
## 주요 파일 찾기
|
||||
|
||||
### 컨트롤러 찾기
|
||||
특정 URL에 대한 컨트롤러를 찾을 때:
|
||||
1. URL 패턴 확인 (예: `/admin/menuMngList.do`)
|
||||
2. `src/com/pms/controller/` 에서 해당 `@RequestMapping` 검색
|
||||
3. 주요 컨트롤러들:
|
||||
- [AdminController.java](mdc:src/com/pms/controller/AdminController.java) - 관리자 기능
|
||||
- [ApprovalController.java](mdc:src/com/pms/controller/ApprovalController.java) - 승인 관리
|
||||
- [AsController.java](mdc:src/com/pms/controller/AsController.java) - AS 관리
|
||||
|
||||
### 서비스 찾기
|
||||
비즈니스 로직을 찾을 때:
|
||||
1. 컨트롤러에서 `@Autowired` 된 서비스 확인
|
||||
2. `src/com/pms/service/` 디렉토리에서 해당 서비스 파일 찾기
|
||||
3. 주요 서비스들:
|
||||
- [AdminService.java](mdc:src/com/pms/service/AdminService.java) - 관리자 서비스
|
||||
- [ApprovalService.java](mdc:src/com/pms/service/ApprovalService.java) - 승인 서비스
|
||||
|
||||
### SQL 쿼리 찾기
|
||||
데이터베이스 쿼리를 찾을 때:
|
||||
1. 서비스 코드에서 `sqlSession.selectList("namespace.queryId")` 확인
|
||||
2. `src/com/pms/mapper/` 에서 해당 namespace XML 파일 찾기
|
||||
3. XML 파일 내에서 queryId로 검색
|
||||
|
||||
### JSP 뷰 찾기
|
||||
화면을 찾을 때:
|
||||
1. 컨트롤러 메서드의 return 값 확인 (예: `"/admin/menu/menuMngList"`)
|
||||
2. `WebContent/WEB-INF/view/` + return 값 + `.jsp` 경로로 파일 찾기
|
||||
|
||||
## 모듈별 주요 기능
|
||||
|
||||
### 관리자 모듈 (`/admin/*`)
|
||||
- 메뉴 관리: [AdminController.java](mdc:src/com/pms/controller/AdminController.java)
|
||||
- 사용자 관리, 권한 관리
|
||||
- 코드 관리, 카테고리 관리
|
||||
- 시스템 로그 관리
|
||||
|
||||
### 영업 관리 (`/salesmgmt/*`)
|
||||
- 위치: `src/com/pms/salesmgmt/`
|
||||
- 영업 관련 컨트롤러, 서비스, 매퍼 분리
|
||||
|
||||
### 공통 기능 (`/common/*`)
|
||||
- 공통 유틸리티: [CommonUtils](mdc:src/com/pms/common/utils/CommonUtils.java)
|
||||
- 메시지 처리: [Message](mdc:src/com/pms/common/Message.java)
|
||||
- 파일 처리: [FileRenameClass](mdc:src/com/pms/common/FileRenameClass.java)
|
||||
|
||||
## 개발 시 주의사항
|
||||
|
||||
### 파일 수정 시
|
||||
1. Java 파일 수정 → Eclipse에서 자동 컴파일 → `WebContent/WEB-INF/classes/`에 반영
|
||||
2. JSP/CSS/JS 수정 → 바로 반영 (서버 재시작 불필요)
|
||||
3. XML 설정 파일 수정 → 서버 재시작 필요
|
||||
|
||||
### 데이터베이스 관련
|
||||
1. 스키마 변경 시 [ilshin.pgsql](mdc:db/ilshin.pgsql) 업데이트
|
||||
2. 새로운 쿼리 추가 시 해당 mapper XML 파일에 추가
|
||||
3. 트랜잭션 처리는 서비스 레벨에서 관리
|
||||
|
|
@ -8,69 +8,30 @@ alwaysApply: true
|
|||
|
||||
## 프로젝트 정보
|
||||
|
||||
이 프로젝트는 WACE ERP/PLM 솔루션입니다.
|
||||
Node.js + Next.js 기반의 풀스택 웹 애플리케이션으로, 멀티테넌시를 지원합니다.
|
||||
이 프로젝트는 제품 수명 주기 관리(PLM - Product Lifecycle Management) 솔루션입니다.
|
||||
Spring Framework 기반의 Java 웹 애플리케이션으로, 제품 개발부터 폐기까지의 전체 생명주기를 관리합니다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **Backend**: Node.js 20+, Express 4, TypeScript
|
||||
- **Frontend**: Next.js (App Router, Turbopack), React, shadcn/ui, Tailwind CSS
|
||||
- **Database**: PostgreSQL (pg 드라이버 직접 사용)
|
||||
- **인증**: JWT (jsonwebtoken)
|
||||
- **빌드**: npm, TypeScript (tsc)
|
||||
- **개발도구**: nodemon (백엔드 핫리로드), Turbopack (프론트엔드)
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
ERP-node/
|
||||
├── backend-node/ # Express + TypeScript 백엔드
|
||||
│ ├── src/
|
||||
│ │ ├── app.ts # 엔트리포인트
|
||||
│ │ ├── controllers/ # API 컨트롤러
|
||||
│ │ ├── services/ # 비즈니스 로직
|
||||
│ │ ├── middleware/ # 인증, 에러처리 미들웨어
|
||||
│ │ ├── routes/ # 라우터
|
||||
│ │ └── config/ # DB 연결 등 설정
|
||||
│ └── package.json
|
||||
├── frontend/ # Next.js 프론트엔드
|
||||
│ ├── app/ # App Router 페이지
|
||||
│ ├── components/ # React 컴포넌트 (shadcn/ui)
|
||||
│ ├── lib/ # 유틸리티, API 클라이언트
|
||||
│ ├── hooks/ # Custom React Hooks
|
||||
│ └── package.json
|
||||
├── db/ # 데이터베이스 마이그레이션 SQL
|
||||
│ └── migrations/ # 순차 마이그레이션 파일
|
||||
├── docker/ # Docker 설정 (dev/prod/deploy)
|
||||
├── scripts/ # 개발/배포 스크립트
|
||||
└── docs/ # 프로젝트 문서
|
||||
```
|
||||
- **Backend**: Java 7, Spring Framework 3.2.4, MyBatis 3.2.3
|
||||
- **Frontend**: JSP, jQuery 1.11.3/2.1.4, jqGrid 4.7.1
|
||||
- **Database**: PostgreSQL
|
||||
- **WAS**: Apache Tomcat 7.0
|
||||
- **Build**: Eclipse IDE 기반 (Maven/Gradle 미사용)
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- 사용자 및 권한 관리 (멀티테넌시)
|
||||
- 메뉴 및 화면 관리
|
||||
- 플로우(워크플로우) 관리
|
||||
- BOM 관리
|
||||
- 문서/파일 관리
|
||||
- 화면 디자이너 (동적 화면 생성)
|
||||
- 메일 연동
|
||||
- 외부 DB 연결
|
||||
|
||||
## 개발 환경
|
||||
|
||||
```bash
|
||||
# 백엔드 (nodemon으로 자동 재시작)
|
||||
cd backend-node && npm run dev
|
||||
|
||||
# 프론트엔드 (Turbopack)
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
- 제품 정보 관리
|
||||
- BOM (Bill of Materials) 관리
|
||||
- 설계 변경 관리 (ECO/ECR)
|
||||
- 문서 관리 및 버전 제어
|
||||
- 프로젝트/일정 관리
|
||||
- 사용자 및 권한 관리
|
||||
- 워크플로우 관리
|
||||
|
||||
## 주요 설정 파일
|
||||
|
||||
- `backend-node/.env` - 백엔드 환경 변수 (DB, JWT 등)
|
||||
- `frontend/.env.local` - 프론트엔드 환경 변수
|
||||
- `docker/` - Docker Compose 설정 (dev/prod)
|
||||
- `Dockerfile` - 프로덕션 멀티스테이지 빌드
|
||||
- `Jenkinsfile` - CI/CD 파이프라인
|
||||
- [web.xml](mdc:WebContent/WEB-INF/web.xml) - 웹 애플리케이션 배포 설정
|
||||
- [dispatcher-servlet.xml](mdc:WebContent/WEB-INF/dispatcher-servlet.xml) - Spring MVC 설정
|
||||
- [docker-compose.dev.yml](mdc:docker-compose.dev.yml) - 개발환경 Docker 설정
|
||||
- [docker-compose.prod.yml](mdc:docker-compose.prod.yml) - 운영환경 Docker 설정
|
||||
|
|
|
|||
|
|
@ -3,124 +3,246 @@ description:
|
|||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 보안 가이드
|
||||
|
||||
## 인증 및 인가
|
||||
|
||||
### JWT 기반 인증
|
||||
현재 시스템은 JWT 토큰 기반 인증을 사용합니다:
|
||||
### 세션 기반 인증
|
||||
현재 시스템은 세션 기반 인증을 사용합니다:
|
||||
|
||||
```typescript
|
||||
// 토큰 생성 (로그인 시)
|
||||
const token = jwt.sign(
|
||||
{ userId, companyCode, role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
|
||||
);
|
||||
```java
|
||||
// 사용자 인증 정보 저장
|
||||
PersonBean person = new PersonBean();
|
||||
person.setUserId(userId);
|
||||
person.setUserName(userName);
|
||||
request.getSession().setAttribute(Constants.PERSON_BEAN, person);
|
||||
|
||||
// 토큰 검증 (미들웨어)
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = decoded;
|
||||
// 인증 정보 조회
|
||||
PersonBean person = (PersonBean)request.getSession().getAttribute(Constants.PERSON_BEAN);
|
||||
if (person == null) {
|
||||
// 로그인 페이지로 리다이렉트
|
||||
}
|
||||
```
|
||||
|
||||
### 권한 관리 구조
|
||||
- **auth_group**: 권한 그룹 정의
|
||||
- **menu_auth_group**: 메뉴별 권한 그룹 매핑
|
||||
- **user_auth**: 사용자별 권한 할당
|
||||
- **AUTH_GROUP**: 권한 그룹 정의
|
||||
- **MENU_AUTH_GROUP**: 메뉴별 권한 그룹 매핑
|
||||
- **USER_AUTH**: 사용자별 권한 할당
|
||||
|
||||
### 인증 미들웨어
|
||||
`backend-node/src/middleware/authMiddleware.ts` 참조
|
||||
### 메뉴 접근 권한 체크
|
||||
```java
|
||||
public HashMap<String, Object> checkUserMenuAuth(HttpServletRequest request, Map<String, Object> paramMap) {
|
||||
PersonBean person = (PersonBean)request.getSession().getAttribute(Constants.PERSON_BEAN);
|
||||
String userId = person.getUserId();
|
||||
|
||||
paramMap.put("userId", userId);
|
||||
paramMap.put("menuUrl", request.getRequestURI());
|
||||
|
||||
// 권한 체크 쿼리 실행
|
||||
return sqlSession.selectOne("admin.checkUserMenuAuth", paramMap);
|
||||
}
|
||||
```
|
||||
|
||||
## 데이터 보안
|
||||
|
||||
### SQL 인젝션 방지
|
||||
**올바른 방법** - 파라미터 바인딩 사용:
|
||||
```typescript
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM user_info WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
```xml
|
||||
<select id="selectUser" parameterType="map" resultType="map">
|
||||
SELECT * FROM user_info
|
||||
WHERE user_id = #{userId} <!-- 안전한 파라미터 바인딩 -->
|
||||
</select>
|
||||
```
|
||||
|
||||
**위험한 방법** - 직접 문자열 삽입:
|
||||
```typescript
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM user_info WHERE user_id = '${userId}'`
|
||||
);
|
||||
**위험한 방법** - 직접 문자열 치환:
|
||||
```xml
|
||||
<select id="selectUser" parameterType="map" resultType="map">
|
||||
SELECT * FROM user_info
|
||||
WHERE user_id = '${userId}' <!-- SQL 인젝션 위험 -->
|
||||
</select>
|
||||
```
|
||||
|
||||
### 패스워드 보안
|
||||
```typescript
|
||||
import bcrypt from 'bcryptjs';
|
||||
```java
|
||||
// 패스워드 암호화 (EncryptUtil 사용)
|
||||
String encryptedPassword = EncryptUtil.encrypt(plainPassword);
|
||||
|
||||
// 해싱
|
||||
const hashedPassword = await bcrypt.hash(plainPassword, 10);
|
||||
|
||||
// 검증
|
||||
const isValid = await bcrypt.compare(plainPassword, hashedPassword);
|
||||
// 패스워드 검증
|
||||
boolean isValid = EncryptUtil.matches(plainPassword, encryptedPassword);
|
||||
```
|
||||
|
||||
### 입력값 검증
|
||||
```typescript
|
||||
import Joi from 'joi';
|
||||
|
||||
const schema = Joi.object({
|
||||
userId: Joi.string().required().max(50),
|
||||
email: Joi.string().email().required(),
|
||||
});
|
||||
|
||||
const { error, value } = schema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({ success: false, message: error.message });
|
||||
```java
|
||||
// CommonUtils를 사용한 입력값 정제
|
||||
String safeInput = CommonUtils.checkNull(request.getParameter("input"));
|
||||
if (StringUtils.isEmpty(safeInput)) {
|
||||
throw new IllegalArgumentException("필수 입력값이 누락되었습니다.");
|
||||
}
|
||||
```
|
||||
|
||||
## 보안 미들웨어
|
||||
## 세션 보안
|
||||
|
||||
### Helmet (보안 헤더)
|
||||
```typescript
|
||||
import helmet from 'helmet';
|
||||
app.use(helmet());
|
||||
### 세션 설정
|
||||
[web.xml](mdc:WebContent/WEB-INF/web.xml)에서 세션 타임아웃 설정:
|
||||
```xml
|
||||
<session-config>
|
||||
<session-timeout>1440</session-timeout> <!-- 24시간 -->
|
||||
</session-config>
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
```typescript
|
||||
import rateLimit from 'express-rate-limit';
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
});
|
||||
app.use('/api/', limiter);
|
||||
### 세션 무효화
|
||||
```java
|
||||
// 로그아웃 시 세션 무효화
|
||||
@RequestMapping("/logout.do")
|
||||
public String logout(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session != null) {
|
||||
session.invalidate();
|
||||
}
|
||||
return "redirect:/login.do";
|
||||
}
|
||||
```
|
||||
|
||||
### CORS 설정
|
||||
```typescript
|
||||
import cors from 'cors';
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN,
|
||||
credentials: true,
|
||||
}));
|
||||
## 파일 업로드 보안
|
||||
|
||||
### 파일 확장자 검증
|
||||
```java
|
||||
public boolean isAllowedFileType(String fileName) {
|
||||
String[] allowedExtensions = {".jpg", ".jpeg", ".png", ".gif", ".pdf", ".doc", ".docx", ".xls", ".xlsx"};
|
||||
String extension = fileName.toLowerCase().substring(fileName.lastIndexOf("."));
|
||||
return Arrays.asList(allowedExtensions).contains(extension);
|
||||
}
|
||||
```
|
||||
|
||||
### 파일 크기 제한
|
||||
```java
|
||||
// web.xml에서 파일 업로드 크기 제한
|
||||
<multipart-config>
|
||||
<max-file-size>10485760</max-file-size> <!-- 10MB -->
|
||||
<max-request-size>52428800</max-request-size> <!-- 50MB -->
|
||||
</multipart-config>
|
||||
```
|
||||
|
||||
### 안전한 파일명 생성
|
||||
```java
|
||||
// FileRenameClass 사용하여 안전한 파일명 생성
|
||||
String safeFileName = FileRenameClass.rename(originalFileName);
|
||||
```
|
||||
|
||||
## XSS 방지
|
||||
|
||||
### 출력값 이스케이프
|
||||
JSP에서 사용자 입력값 출력 시:
|
||||
```jsp
|
||||
<!-- 안전한 출력 -->
|
||||
<c:out value="${userInput}" escapeXml="true"/>
|
||||
|
||||
<!-- 위험한 출력 -->
|
||||
${userInput} <!-- XSS 공격 가능 -->
|
||||
```
|
||||
|
||||
### JavaScript에서 데이터 처리
|
||||
```javascript
|
||||
// 안전한 방법
|
||||
var safeData = $('<div>').text(userInput).html();
|
||||
|
||||
// 위험한 방법
|
||||
var dangerousData = userInput; // XSS 공격 가능
|
||||
```
|
||||
|
||||
## CSRF 방지
|
||||
|
||||
### 토큰 기반 CSRF 방지
|
||||
```jsp
|
||||
<!-- 폼에 CSRF 토큰 포함 -->
|
||||
<form method="post" action="save.do">
|
||||
<input type="hidden" name="csrfToken" value="${csrfToken}"/>
|
||||
<!-- 기타 입력 필드 -->
|
||||
</form>
|
||||
```
|
||||
|
||||
```java
|
||||
// 컨트롤러에서 CSRF 토큰 검증
|
||||
@RequestMapping("/save.do")
|
||||
public String save(HttpServletRequest request, @RequestParam Map<String, Object> paramMap) {
|
||||
String sessionToken = (String)request.getSession().getAttribute("csrfToken");
|
||||
String requestToken = (String)paramMap.get("csrfToken");
|
||||
|
||||
if (!sessionToken.equals(requestToken)) {
|
||||
throw new SecurityException("CSRF 토큰이 일치하지 않습니다.");
|
||||
}
|
||||
|
||||
// 정상 처리
|
||||
}
|
||||
```
|
||||
|
||||
## 로깅 및 감사
|
||||
|
||||
### 보안 이벤트 로깅
|
||||
```java
|
||||
private static final Logger securityLogger = LoggerFactory.getLogger("SECURITY");
|
||||
|
||||
public void logSecurityEvent(String event, String userId, String details) {
|
||||
securityLogger.info("Security Event: {} | User: {} | Details: {}", event, userId, details);
|
||||
}
|
||||
|
||||
// 사용 예시
|
||||
logSecurityEvent("LOGIN_SUCCESS", userId, request.getRemoteAddr());
|
||||
logSecurityEvent("ACCESS_DENIED", userId, request.getRequestURI());
|
||||
```
|
||||
|
||||
### 민감 정보 마스킹
|
||||
```java
|
||||
public String maskSensitiveData(String data) {
|
||||
if (data == null || data.length() < 4) {
|
||||
return "****";
|
||||
}
|
||||
return data.substring(0, 2) + "****" + data.substring(data.length() - 2);
|
||||
}
|
||||
```
|
||||
|
||||
## 환경별 보안 설정
|
||||
|
||||
### 개발 환경
|
||||
- 상세한 오류 메시지 표시
|
||||
- 디버그 모드 활성화
|
||||
- 보안 제약 완화
|
||||
|
||||
### 운영 환경
|
||||
- 일반적인 오류 메시지만 표시
|
||||
- 디버그 모드 비활성화
|
||||
- 엄격한 보안 정책 적용
|
||||
|
||||
```java
|
||||
// 환경별 설정 예시
|
||||
if (Constants.IS_PRODUCTION) {
|
||||
// 운영 환경 설정
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근이 거부되었습니다.");
|
||||
} else {
|
||||
// 개발 환경 설정
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "권한 부족: " + detailedMessage);
|
||||
}
|
||||
```
|
||||
|
||||
## 보안 체크리스트
|
||||
|
||||
### 코드 레벨
|
||||
- [ ] SQL 인젝션 방지 ($1 파라미터 바인딩 사용)
|
||||
- [ ] XSS 방지 (React 자동 이스케이프 활용)
|
||||
- [ ] 입력값 검증 (Joi 스키마)
|
||||
- [ ] 패스워드 bcrypt 해싱
|
||||
- [ ] JWT 토큰 만료 설정
|
||||
- [ ] SQL 인젝션 방지 (#{} 파라미터 바인딩 사용)
|
||||
- [ ] XSS 방지 (출력값 이스케이프)
|
||||
- [ ] CSRF 방지 (토큰 검증)
|
||||
- [ ] 입력값 검증 및 정제
|
||||
- [ ] 패스워드 암호화 저장
|
||||
|
||||
### 설정 레벨
|
||||
- [ ] Helmet 보안 헤더
|
||||
- [ ] Rate Limiting 적용
|
||||
- [ ] CORS 적절한 설정
|
||||
- [ ] 세션 타임아웃 설정
|
||||
- [ ] 파일 업로드 제한
|
||||
- [ ] 오류 페이지 설정
|
||||
- [ ] HTTPS 사용 (운영 환경)
|
||||
- [ ] 환경 변수로 시크릿 관리
|
||||
- [ ] 보안 헤더 설정
|
||||
|
||||
### 운영 레벨
|
||||
- [ ] winston 로깅 모니터링
|
||||
- [ ] 정기적인 보안 점검
|
||||
- [ ] 로그 모니터링
|
||||
- [ ] 권한 정기 검토
|
||||
- [ ] 패스워드 정책 적용
|
||||
- [ ] 백업 데이터 암호화
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
# TableListComponent 개발 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
`TableListComponent`는 ERP 시스템의 핵심 데이터 그리드 컴포넌트입니다. DevExpress DataGrid 스타일의 고급 기능들을 구현하고 있습니다.
|
||||
|
||||
**파일 위치**: `frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 핵심 기능 목록
|
||||
|
||||
### 1. 인라인 편집 (Inline Editing)
|
||||
|
||||
- 셀 더블클릭 또는 F2 키로 편집 모드 진입
|
||||
- 직접 타이핑으로도 편집 모드 진입 가능
|
||||
- Enter로 저장, Escape로 취소
|
||||
- **컬럼별 편집 가능 여부 설정** (`editable` 속성)
|
||||
|
||||
```typescript
|
||||
// ColumnConfig에서 editable 속성 사용
|
||||
interface ColumnConfig {
|
||||
editable?: boolean; // false면 해당 컬럼 인라인 편집 불가
|
||||
}
|
||||
```
|
||||
|
||||
**편집 불가 컬럼 체크 필수 위치**:
|
||||
1. `handleCellDoubleClick` - 더블클릭 편집
|
||||
2. `onKeyDown` F2 케이스 - 키보드 편집
|
||||
3. `onKeyDown` default 케이스 - 직접 타이핑 편집
|
||||
4. 컨텍스트 메뉴 "셀 편집" 옵션
|
||||
|
||||
### 2. 배치 편집 (Batch Editing)
|
||||
|
||||
- 여러 셀 수정 후 일괄 저장/취소
|
||||
- `pendingChanges` Map으로 변경사항 추적
|
||||
- 저장 전 유효성 검증
|
||||
|
||||
### 3. 데이터 유효성 검증 (Validation)
|
||||
|
||||
```typescript
|
||||
type ValidationRule = {
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: RegExp;
|
||||
customMessage?: string;
|
||||
validate?: (value: any, row: any) => string | null;
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 컬럼 헤더 필터 (Header Filter)
|
||||
|
||||
- 각 컬럼 헤더에 필터 아이콘
|
||||
- 고유값 목록에서 다중 선택 필터링
|
||||
- `headerFilters` Map으로 필터 상태 관리
|
||||
|
||||
### 5. 필터 빌더 (Filter Builder)
|
||||
|
||||
```typescript
|
||||
interface FilterCondition {
|
||||
id: string;
|
||||
column: string;
|
||||
operator: "equals" | "notEquals" | "contains" | "notContains" |
|
||||
"startsWith" | "endsWith" | "greaterThan" | "lessThan" |
|
||||
"greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FilterGroup {
|
||||
id: string;
|
||||
logic: "AND" | "OR";
|
||||
conditions: FilterCondition[];
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 검색 패널 (Search Panel)
|
||||
|
||||
- 전체 데이터 검색
|
||||
- 검색어 하이라이팅
|
||||
- `searchHighlights` Map으로 하이라이트 위치 관리
|
||||
|
||||
### 7. 엑셀 내보내기 (Excel Export)
|
||||
|
||||
- `xlsx` 라이브러리 사용
|
||||
- 현재 표시 데이터 또는 전체 데이터 내보내기
|
||||
|
||||
```typescript
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// 사용 예시
|
||||
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
|
||||
XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`);
|
||||
```
|
||||
|
||||
### 8. 클립보드 복사 (Copy to Clipboard)
|
||||
|
||||
- 선택된 행 또는 전체 데이터 복사
|
||||
- 탭 구분자로 엑셀 붙여넣기 호환
|
||||
|
||||
### 9. 컨텍스트 메뉴 (Context Menu)
|
||||
|
||||
- 우클릭으로 메뉴 표시
|
||||
- 셀 편집, 행 복사, 행 삭제 등 옵션
|
||||
- 편집 불가 컬럼은 "(잠김)" 표시
|
||||
|
||||
### 10. 키보드 네비게이션
|
||||
|
||||
| 키 | 동작 |
|
||||
|---|---|
|
||||
| Arrow Keys | 셀 이동 |
|
||||
| Tab | 다음 셀 |
|
||||
| Shift+Tab | 이전 셀 |
|
||||
| F2 | 편집 모드 |
|
||||
| Enter | 저장 후 아래로 이동 |
|
||||
| Escape | 편집 취소 |
|
||||
| Ctrl+C | 복사 |
|
||||
| Delete | 셀 값 삭제 |
|
||||
|
||||
### 11. 컬럼 리사이징
|
||||
|
||||
- 컬럼 헤더 경계 드래그로 너비 조절
|
||||
- `columnWidths` 상태로 관리
|
||||
- localStorage에 저장
|
||||
|
||||
### 12. 컬럼 순서 변경
|
||||
|
||||
- 드래그 앤 드롭으로 컬럼 순서 변경
|
||||
- `columnOrder` 상태로 관리
|
||||
- localStorage에 저장
|
||||
|
||||
### 13. 상태 영속성 (State Persistence)
|
||||
|
||||
```typescript
|
||||
// localStorage 키 패턴
|
||||
const stateKey = `tableState_${tableName}_${userId}`;
|
||||
|
||||
// 저장되는 상태
|
||||
interface TableState {
|
||||
columnWidths: Record<string, number>;
|
||||
columnOrder: string[];
|
||||
sortBy: string;
|
||||
sortOrder: "asc" | "desc";
|
||||
frozenColumns: string[];
|
||||
columnVisibility: Record<string, boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
### 14. 그룹화 및 그룹 소계
|
||||
|
||||
```typescript
|
||||
interface GroupedData {
|
||||
groupKey: string;
|
||||
groupValues: Record<string, any>;
|
||||
items: any[];
|
||||
count: number;
|
||||
summary?: Record<string, { sum: number; avg: number; count: number }>;
|
||||
}
|
||||
```
|
||||
|
||||
### 15. 총계 요약 (Total Summary)
|
||||
|
||||
- 숫자 컬럼의 합계, 평균, 개수 표시
|
||||
- 테이블 하단에 요약 행 렌더링
|
||||
|
||||
---
|
||||
|
||||
## 캐싱 전략
|
||||
|
||||
```typescript
|
||||
// 테이블 컬럼 캐시
|
||||
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
|
||||
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||
|
||||
// API 호출 디바운싱
|
||||
const debouncedApiCall = <T extends any[], R>(
|
||||
key: string,
|
||||
fn: (...args: T) => Promise<R>,
|
||||
delay: number = 300
|
||||
) => { ... };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 필수 Import
|
||||
|
||||
```typescript
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { TableListConfig, ColumnConfig } from "./types";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { codeCache } from "@/lib/caching/codeCache";
|
||||
import * as XLSX from "xlsx";
|
||||
import { toast } from "sonner";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주요 상태 (State)
|
||||
|
||||
```typescript
|
||||
// 데이터 관련
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [filteredData, setFilteredData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 편집 관련
|
||||
const [editingCell, setEditingCell] = useState<{
|
||||
rowIndex: number;
|
||||
colIndex: number;
|
||||
columnName: string;
|
||||
originalValue: any;
|
||||
} | null>(null);
|
||||
const [editingValue, setEditingValue] = useState<string>("");
|
||||
const [pendingChanges, setPendingChanges] = useState<Map<string, Map<string, any>>>(new Map());
|
||||
const [validationErrors, setValidationErrors] = useState<Map<string, Map<string, string>>>(new Map());
|
||||
|
||||
// 필터 관련
|
||||
const [headerFilters, setHeaderFilters] = useState<Map<string, Set<string>>>(new Map());
|
||||
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
|
||||
const [globalSearchText, setGlobalSearchText] = useState("");
|
||||
const [searchHighlights, setSearchHighlights] = useState<Map<string, number[]>>(new Map());
|
||||
|
||||
// 컬럼 관련
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({});
|
||||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
||||
|
||||
// 선택 관련
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
||||
|
||||
// 정렬 관련
|
||||
const [sortBy, setSortBy] = useState<string>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 편집 불가 컬럼 구현 체크리스트
|
||||
|
||||
새로운 편집 진입점을 추가할 때 반드시 다음을 확인하세요:
|
||||
|
||||
- [ ] `column.editable === false` 체크 추가
|
||||
- [ ] 편집 불가 시 `toast.warning()` 메시지 표시
|
||||
- [ ] `return` 또는 `break`로 편집 모드 진입 방지
|
||||
|
||||
```typescript
|
||||
// 표준 편집 불가 체크 패턴
|
||||
const column = visibleColumns.find((col) => col.columnName === columnName);
|
||||
if (column?.editable === false) {
|
||||
toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 시각적 표시
|
||||
|
||||
### 편집 불가 컬럼 표시
|
||||
|
||||
```tsx
|
||||
// 헤더에 잠금 아이콘
|
||||
{column.editable === false && (
|
||||
<Lock className="ml-1 h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
// 셀 배경색
|
||||
className={cn(
|
||||
column.editable === false && "bg-gray-50 dark:bg-gray-900/30"
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
1. **useMemo 사용**: `visibleColumns`, `filteredData`, `paginatedData` 등 계산 비용이 큰 값
|
||||
2. **useCallback 사용**: 이벤트 핸들러 함수들
|
||||
3. **디바운싱**: API 호출, 검색, 필터링
|
||||
4. **캐싱**: 테이블 컬럼 정보, 코드 데이터
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **visibleColumns 정의 순서**: `columnOrder`, `columnVisibility` 상태 이후에 정의해야 함
|
||||
2. **editInputRef 타입 체크**: `select()` 호출 전 `instanceof HTMLInputElement` 확인
|
||||
3. **localStorage 키**: `tableName`과 `userId`를 조합하여 고유하게 생성
|
||||
4. **멀티테넌시**: 모든 API 호출에 `company_code` 필터링 적용 (백엔드에서 자동 처리)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `frontend/lib/registry/components/table-list/types.ts` - 타입 정의
|
||||
- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - 설정 패널
|
||||
- `frontend/components/common/TableOptionsModal.tsx` - 옵션 모달
|
||||
- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - 스티키 헤더 테이블
|
||||
|
|
@ -1,592 +0,0 @@
|
|||
# 테이블 타입 관리 SQL 작성 가이드
|
||||
|
||||
테이블 타입 관리에서 테이블 생성 시 적용되는 컬럼, 타입, 메타데이터 등록 로직을 기반으로 한 SQL 작성 가이드입니다.
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일**: 날짜 타입 외 모든 컬럼은 `VARCHAR(500)`
|
||||
2. **날짜/시간 컬럼만 `TIMESTAMP` 사용**: `created_date`, `updated_date` 등
|
||||
3. **기본 컬럼 5개 자동 포함**: 모든 테이블에 id, created_date, updated_date, writer, company_code 필수
|
||||
4. **3개 메타데이터 테이블 등록 필수**: `table_labels`, `column_labels`, `table_type_columns`
|
||||
|
||||
---
|
||||
|
||||
## 1. 테이블 생성 DDL 템플릿
|
||||
|
||||
### 기본 구조
|
||||
|
||||
```sql
|
||||
CREATE TABLE "테이블명" (
|
||||
-- 시스템 기본 컬럼 (자동 포함)
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),b
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500),
|
||||
|
||||
-- 사용자 정의 컬럼 (모두 VARCHAR(500))
|
||||
"컬럼1" varchar(500),
|
||||
"컬럼2" varchar(500),
|
||||
"컬럼3" varchar(500)
|
||||
);
|
||||
```
|
||||
|
||||
### 예시: 고객 테이블 생성
|
||||
|
||||
```sql
|
||||
CREATE TABLE "customer_info" (
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500),
|
||||
|
||||
"customer_name" varchar(500),
|
||||
"customer_code" varchar(500),
|
||||
"phone" varchar(500),
|
||||
"email" varchar(500),
|
||||
"address" varchar(500),
|
||||
"status" varchar(500),
|
||||
"registration_date" varchar(500)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 메타데이터 테이블 등록
|
||||
|
||||
테이블 생성 시 반드시 아래 3개 테이블에 메타데이터를 등록해야 합니다.
|
||||
|
||||
### 2.1 table_labels (테이블 메타데이터)
|
||||
|
||||
```sql
|
||||
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||
VALUES ('테이블명', '테이블 라벨', '테이블 설명', now(), now())
|
||||
ON CONFLICT (table_name)
|
||||
DO UPDATE SET
|
||||
table_label = EXCLUDED.table_label,
|
||||
description = EXCLUDED.description,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
### 2.2 table_type_columns (컬럼 타입 정보)
|
||||
|
||||
**필수 컬럼**: `table_name`, `column_name`, `company_code`, `input_type`, `display_order`
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼 등록 (display_order: -5 ~ -1)
|
||||
INSERT INTO table_type_columns (
|
||||
table_name, column_name, company_code, input_type, detail_settings,
|
||||
is_nullable, display_order, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
|
||||
('테이블명', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
|
||||
('테이블명', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
|
||||
('테이블명', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
|
||||
('테이블명', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET
|
||||
input_type = EXCLUDED.input_type,
|
||||
display_order = EXCLUDED.display_order,
|
||||
updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작)
|
||||
INSERT INTO table_type_columns (
|
||||
table_name, column_name, company_code, input_type, detail_settings,
|
||||
is_nullable, display_order, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', '컬럼1', '*', 'text', '{}', 'Y', 0, now(), now()),
|
||||
('테이블명', '컬럼2', '*', 'number', '{}', 'Y', 1, now(), now()),
|
||||
('테이블명', '컬럼3', '*', 'code', '{"codeCategory":"카테고리코드"}', 'Y', 2, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
display_order = EXCLUDED.display_order,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
### 2.3 column_labels (레거시 호환용 - 필수)
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼 등록
|
||||
INSERT INTO column_labels (
|
||||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
description, display_order, is_visible, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, now(), now()),
|
||||
('테이블명', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
|
||||
('테이블명', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
|
||||
('테이블명', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
|
||||
('테이블명', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, 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,
|
||||
description = EXCLUDED.description,
|
||||
display_order = EXCLUDED.display_order,
|
||||
is_visible = EXCLUDED.is_visible,
|
||||
updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼 등록
|
||||
INSERT INTO column_labels (
|
||||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
description, display_order, is_visible, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', '컬럼1', '컬럼1 라벨', 'text', '{}', '컬럼1 설명', 0, true, now(), now()),
|
||||
('테이블명', '컬럼2', '컬럼2 라벨', 'number', '{}', '컬럼2 설명', 1, true, 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,
|
||||
description = EXCLUDED.description,
|
||||
display_order = EXCLUDED.display_order,
|
||||
is_visible = EXCLUDED.is_visible,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Input Type 정의
|
||||
|
||||
### 지원되는 Input Type 목록
|
||||
|
||||
| input_type | 설명 | DB 저장 타입 | UI 컴포넌트 |
|
||||
| ---------- | ------------- | ------------ | -------------------- |
|
||||
| `text` | 텍스트 입력 | VARCHAR(500) | Input |
|
||||
| `number` | 숫자 입력 | VARCHAR(500) | Input (type=number) |
|
||||
| `date` | 날짜/시간 | VARCHAR(500) | DatePicker |
|
||||
| `code` | 공통코드 선택 | VARCHAR(500) | Select (코드 목록) |
|
||||
| `entity` | 엔티티 참조 | VARCHAR(500) | Select (테이블 참조) |
|
||||
| `select` | 선택 목록 | VARCHAR(500) | Select |
|
||||
| `checkbox` | 체크박스 | VARCHAR(500) | Checkbox |
|
||||
| `radio` | 라디오 버튼 | VARCHAR(500) | RadioGroup |
|
||||
| `textarea` | 긴 텍스트 | VARCHAR(500) | Textarea |
|
||||
| `file` | 파일 업로드 | VARCHAR(500) | FileUpload |
|
||||
|
||||
### WebType → InputType 변환 규칙
|
||||
|
||||
```
|
||||
text, textarea, email, tel, url, password → text
|
||||
number, decimal → number
|
||||
date, datetime, time → date
|
||||
select, dropdown → select
|
||||
checkbox, boolean → checkbox
|
||||
radio → radio
|
||||
code → code
|
||||
entity → entity
|
||||
file → text
|
||||
button → text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Detail Settings 설정
|
||||
|
||||
### 4.1 Code 타입 (공통코드 참조)
|
||||
|
||||
```json
|
||||
{
|
||||
"codeCategory": "코드_카테고리_ID"
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
|
||||
VALUES (..., 'code', '{"codeCategory":"STATUS_CODE"}', ...);
|
||||
```
|
||||
|
||||
### 4.2 Entity 타입 (테이블 참조)
|
||||
|
||||
```json
|
||||
{
|
||||
"referenceTable": "참조_테이블명",
|
||||
"referenceColumn": "참조_컬럼명(보통 id)",
|
||||
"displayColumn": "표시할_컬럼명"
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
|
||||
VALUES (..., 'entity', '{"referenceTable":"user_info","referenceColumn":"id","displayColumn":"user_name"}', ...);
|
||||
```
|
||||
|
||||
### 4.3 Select 타입 (정적 옵션)
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{ "label": "옵션1", "value": "value1" },
|
||||
{ "label": "옵션2", "value": "value2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 전체 예시: 주문 테이블 생성
|
||||
|
||||
### Step 1: DDL 실행
|
||||
|
||||
```sql
|
||||
CREATE TABLE "order_info" (
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500),
|
||||
|
||||
"order_no" varchar(500),
|
||||
"order_date" varchar(500),
|
||||
"customer_id" varchar(500),
|
||||
"total_amount" varchar(500),
|
||||
"status" varchar(500),
|
||||
"notes" varchar(500)
|
||||
);
|
||||
```
|
||||
|
||||
### Step 2: table_labels 등록
|
||||
|
||||
```sql
|
||||
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||
VALUES ('order_info', '주문 정보', '주문 관리 테이블', now(), now())
|
||||
ON CONFLICT (table_name)
|
||||
DO UPDATE SET
|
||||
table_label = EXCLUDED.table_label,
|
||||
description = EXCLUDED.description,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
### Step 3: table_type_columns 등록
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼
|
||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
|
||||
('order_info', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
|
||||
('order_info', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
|
||||
('order_info', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
|
||||
('order_info', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼
|
||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'order_no', '*', 'text', '{}', 'Y', 0, now(), now()),
|
||||
('order_info', 'order_date', '*', 'date', '{}', 'Y', 1, now(), now()),
|
||||
('order_info', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'Y', 2, now(), now()),
|
||||
('order_info', 'total_amount', '*', 'number', '{}', 'Y', 3, now(), now()),
|
||||
('order_info', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 4, now(), now()),
|
||||
('order_info', 'notes', '*', 'textarea', '{}', 'Y', 5, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
```
|
||||
|
||||
### Step 4: column_labels 등록 (레거시 호환)
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼
|
||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'id', 'ID', 'text', '{}', '기본키', -5, true, now(), now()),
|
||||
('order_info', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
|
||||
('order_info', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
|
||||
('order_info', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
|
||||
('order_info', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
|
||||
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼
|
||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'order_no', '주문번호', 'text', '{}', '주문 식별 번호', 0, true, now(), now()),
|
||||
('order_info', 'order_date', '주문일자', 'date', '{}', '주문 발생 일자', 1, true, now(), now()),
|
||||
('order_info', 'customer_id', '고객', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '주문 고객', 2, true, now(), now()),
|
||||
('order_info', 'total_amount', '총금액', 'number', '{}', '주문 총 금액', 3, true, now(), now()),
|
||||
('order_info', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '주문 상태', 4, true, now(), now()),
|
||||
('order_info', 'notes', '비고', 'textarea', '{}', '추가 메모', 5, true, 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, description = EXCLUDED.description, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 컬럼 추가 시
|
||||
|
||||
### DDL
|
||||
|
||||
```sql
|
||||
ALTER TABLE "테이블명" ADD COLUMN "새컬럼명" varchar(500);
|
||||
```
|
||||
|
||||
### 메타데이터 등록
|
||||
|
||||
```sql
|
||||
-- table_type_columns
|
||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||
VALUES ('테이블명', '새컬럼명', '*', 'text', '{}', 'Y', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM table_type_columns WHERE table_name = '테이블명'), now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
|
||||
-- column_labels
|
||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||
VALUES ('테이블명', '새컬럼명', '새컬럼 라벨', 'text', '{}', '새컬럼 설명', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM column_labels WHERE table_name = '테이블명'), true, now(), now())
|
||||
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 로그 테이블 생성 (선택사항)
|
||||
|
||||
변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다.
|
||||
|
||||
### 7.1 로그 테이블 DDL 템플릿
|
||||
|
||||
```sql
|
||||
-- 로그 테이블 생성
|
||||
CREATE TABLE 테이블명_log (
|
||||
log_id SERIAL PRIMARY KEY,
|
||||
operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE
|
||||
original_id VARCHAR(100), -- 원본 테이블 PK 값
|
||||
changed_column VARCHAR(100), -- 변경된 컬럼명
|
||||
old_value TEXT, -- 변경 전 값
|
||||
new_value TEXT, -- 변경 후 값
|
||||
changed_by VARCHAR(50), -- 변경자 ID
|
||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
|
||||
ip_address VARCHAR(50), -- 변경 요청 IP
|
||||
user_agent TEXT, -- User Agent
|
||||
full_row_before JSONB, -- 변경 전 전체 행
|
||||
full_row_after JSONB -- 변경 후 전체 행
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id);
|
||||
CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at);
|
||||
CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type);
|
||||
|
||||
-- 코멘트 추가
|
||||
COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력';
|
||||
```
|
||||
|
||||
### 7.2 트리거 함수 DDL 템플릿
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_column_name TEXT;
|
||||
v_old_value TEXT;
|
||||
v_new_value TEXT;
|
||||
v_user_id VARCHAR(50);
|
||||
v_ip_address VARCHAR(50);
|
||||
BEGIN
|
||||
v_user_id := current_setting('app.user_id', TRUE);
|
||||
v_ip_address := current_setting('app.ip_address', TRUE);
|
||||
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after)
|
||||
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
|
||||
RETURN NEW;
|
||||
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
FOR v_column_name IN
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '테이블명'
|
||||
AND table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
||||
INTO v_old_value, v_new_value
|
||||
USING OLD, NEW;
|
||||
|
||||
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||
INSERT INTO 테이블명_log (
|
||||
operation_type, original_id, changed_column, old_value, new_value,
|
||||
changed_by, ip_address, full_row_before, full_row_after
|
||||
)
|
||||
VALUES (
|
||||
'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value,
|
||||
v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
|
||||
ELSIF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before)
|
||||
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
### 7.3 트리거 DDL 템플릿
|
||||
|
||||
```sql
|
||||
CREATE TRIGGER 테이블명_audit_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON 테이블명
|
||||
FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func();
|
||||
```
|
||||
|
||||
### 7.4 로그 설정 등록
|
||||
|
||||
```sql
|
||||
INSERT INTO table_log_config (
|
||||
original_table_name, log_table_name, trigger_name,
|
||||
trigger_function_name, is_active, created_by, created_at
|
||||
) VALUES (
|
||||
'테이블명', '테이블명_log', '테이블명_audit_trigger',
|
||||
'테이블명_log_trigger_func', 'Y', '생성자ID', now()
|
||||
);
|
||||
```
|
||||
|
||||
### 7.5 table_labels에 use_log_table 플래그 설정
|
||||
|
||||
```sql
|
||||
UPDATE table_labels
|
||||
SET use_log_table = 'Y', updated_date = now()
|
||||
WHERE table_name = '테이블명';
|
||||
```
|
||||
|
||||
### 7.6 전체 예시: order_info 로그 테이블 생성
|
||||
|
||||
```sql
|
||||
-- Step 1: 로그 테이블 생성
|
||||
CREATE TABLE order_info_log (
|
||||
log_id SERIAL PRIMARY KEY,
|
||||
operation_type VARCHAR(10) NOT NULL,
|
||||
original_id VARCHAR(100),
|
||||
changed_column VARCHAR(100),
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by VARCHAR(50),
|
||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(50),
|
||||
user_agent TEXT,
|
||||
full_row_before JSONB,
|
||||
full_row_after JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id);
|
||||
CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at);
|
||||
CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type);
|
||||
|
||||
COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력';
|
||||
|
||||
-- Step 2: 트리거 함수 생성
|
||||
CREATE OR REPLACE FUNCTION order_info_log_trigger_func()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_column_name TEXT;
|
||||
v_old_value TEXT;
|
||||
v_new_value TEXT;
|
||||
v_user_id VARCHAR(50);
|
||||
v_ip_address VARCHAR(50);
|
||||
BEGIN
|
||||
v_user_id := current_setting('app.user_id', TRUE);
|
||||
v_ip_address := current_setting('app.ip_address', TRUE);
|
||||
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after)
|
||||
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
|
||||
RETURN NEW;
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
FOR v_column_name IN
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'order_info' AND table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
||||
INTO v_old_value, v_new_value USING OLD, NEW;
|
||||
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||
INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
|
||||
VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
ELSIF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before)
|
||||
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Step 3: 트리거 생성
|
||||
CREATE TRIGGER order_info_audit_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON order_info
|
||||
FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func();
|
||||
|
||||
-- Step 4: 로그 설정 등록
|
||||
INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at)
|
||||
VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now());
|
||||
|
||||
-- Step 5: table_labels 플래그 업데이트
|
||||
UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info';
|
||||
```
|
||||
|
||||
### 7.7 로그 테이블 삭제
|
||||
|
||||
```sql
|
||||
-- 트리거 삭제
|
||||
DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명;
|
||||
|
||||
-- 트리거 함수 삭제
|
||||
DROP FUNCTION IF EXISTS 테이블명_log_trigger_func();
|
||||
|
||||
-- 로그 테이블 삭제
|
||||
DROP TABLE IF EXISTS 테이블명_log;
|
||||
|
||||
-- 로그 설정 삭제
|
||||
DELETE FROM table_log_config WHERE original_table_name = '테이블명';
|
||||
|
||||
-- table_labels 플래그 업데이트
|
||||
UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 체크리스트
|
||||
|
||||
### 테이블 생성/수정 시 반드시 확인할 사항:
|
||||
|
||||
- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code)
|
||||
- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용
|
||||
- [ ] `table_labels`에 테이블 메타데이터 등록
|
||||
- [ ] `table_type_columns`에 모든 컬럼 등록 (company_code = '\*')
|
||||
- [ ] `column_labels`에 모든 컬럼 등록 (레거시 호환)
|
||||
- [ ] 기본 컬럼 display_order: -5 ~ -1
|
||||
- [ ] 사용자 정의 컬럼 display_order: 0부터 순차
|
||||
- [ ] code/entity 타입은 detail_settings에 참조 정보 포함
|
||||
- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리
|
||||
|
||||
### 로그 테이블 생성 시 확인할 사항 (선택):
|
||||
|
||||
- [ ] 로그 테이블 생성 (`테이블명_log`)
|
||||
- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type)
|
||||
- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`)
|
||||
- [ ] 트리거 생성 (`테이블명_audit_trigger`)
|
||||
- [ ] `table_log_config`에 로그 설정 등록
|
||||
- [ ] `table_labels.use_log_table = 'Y'` 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 9. 금지 사항
|
||||
|
||||
1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지
|
||||
2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용
|
||||
3. **기본 컬럼 누락 금지**: id, created_date, updated_date, writer, company_code 필수
|
||||
4. **메타데이터 미등록 금지**: 3개 테이블 모두 등록 필수
|
||||
5. **web_type 사용 금지**: 레거시 컬럼이므로 `input_type` 사용
|
||||
|
||||
---
|
||||
|
||||
## 참조 파일
|
||||
|
||||
- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스
|
||||
- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스
|
||||
- `backend-node/src/types/ddl.ts`: DDL 타입 정의
|
||||
- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러
|
||||
- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# 트러블슈팅 가이드
|
||||
|
||||
## 일반적인 문제 해결
|
||||
|
||||
### 애플리케이션 시작 오류
|
||||
|
||||
#### 데이터베이스 연결 실패
|
||||
```
|
||||
원인: JNDI DataSource 설정 문제
|
||||
해결:
|
||||
1. tomcat-conf/context.xml 확인
|
||||
2. PostgreSQL 서비스 상태 확인
|
||||
3. 데이터베이스 접속 정보 확인
|
||||
```
|
||||
|
||||
#### 클래스 로딩 오류
|
||||
```
|
||||
원인: 컴파일되지 않은 Java 파일
|
||||
해결:
|
||||
1. Eclipse에서 프로젝트 Clean & Build
|
||||
2. WebContent/WEB-INF/classes/ 디렉토리 확인
|
||||
3. 누락된 라이브러리 확인 (WebContent/WEB-INF/lib/)
|
||||
```
|
||||
|
||||
### 런타임 오류
|
||||
|
||||
#### 404 오류 (페이지를 찾을 수 없음)
|
||||
```
|
||||
원인: URL 매핑 문제
|
||||
해결:
|
||||
1. @RequestMapping 어노테이션 확인
|
||||
2. JSP 파일 경로 확인 (/WEB-INF/view/)
|
||||
3. web.xml의 servlet-mapping 확인 (*.do 패턴)
|
||||
```
|
||||
|
||||
#### 500 오류 (서버 내부 오류)
|
||||
```
|
||||
원인: Java 코드 실행 오류
|
||||
해결:
|
||||
1. 로그 파일 확인 (log4j 설정)
|
||||
2. MyBatis SQL 오류 확인
|
||||
3. NullPointerException 체크
|
||||
4. 데이터베이스 트랜잭션 오류 확인
|
||||
```
|
||||
|
||||
### 데이터베이스 관련 문제
|
||||
|
||||
#### SQL 실행 오류
|
||||
```
|
||||
원인: MyBatis 매퍼 설정 문제
|
||||
해결:
|
||||
1. mapper XML 파일의 SQL 문법 확인
|
||||
2. parameterType과 resultType 확인
|
||||
3. 테이블/컬럼명 대소문자 확인 (PostgreSQL)
|
||||
```
|
||||
|
||||
#### 트랜잭션 문제
|
||||
```
|
||||
원인: SqlSession 관리 문제
|
||||
해결:
|
||||
1. SqlSession.close() 호출 확인
|
||||
2. try-finally 블록에서 리소스 정리
|
||||
3. 트랜잭션 커밋/롤백 처리
|
||||
```
|
||||
|
||||
### 프론트엔드 문제
|
||||
|
||||
#### JavaScript 오류
|
||||
```
|
||||
원인: jQuery/jqGrid 라이브러리 문제
|
||||
해결:
|
||||
1. 브라우저 개발자 도구 콘솔 확인
|
||||
2. JavaScript 파일 로딩 순서 확인
|
||||
3. jQuery 버전 호환성 확인
|
||||
```
|
||||
|
||||
#### CSS 스타일 문제
|
||||
```
|
||||
원인: CSS 파일 로딩 또는 경로 문제
|
||||
해결:
|
||||
1. CSS 파일 경로 확인
|
||||
2. 브라우저 캐시 클리어
|
||||
3. all.css 파일 확인
|
||||
```
|
||||
|
||||
## 개발 환경 문제
|
||||
|
||||
### Docker 환경
|
||||
```bash
|
||||
# 컨테이너 로그 확인
|
||||
docker-compose logs app
|
||||
|
||||
# 컨테이너 내부 접속
|
||||
docker-compose exec app bash
|
||||
|
||||
# 데이터베이스 접속 확인
|
||||
docker-compose exec db psql -U postgres -d ilshin
|
||||
```
|
||||
|
||||
### Eclipse 환경
|
||||
```
|
||||
문제: 프로젝트 인식 오류
|
||||
해결:
|
||||
1. .project 파일 확인
|
||||
2. .classpath 파일 확인
|
||||
3. Project Properties > Java Build Path 확인
|
||||
4. Server Runtime 설정 확인 (Tomcat 7.0)
|
||||
```
|
||||
|
||||
## 로그 분석
|
||||
|
||||
### 주요 로그 위치
|
||||
- 애플리케이션 로그: log4j 설정에 따름
|
||||
- Tomcat 로그: `$CATALINA_HOME/logs/`
|
||||
- Docker 로그: `docker-compose logs`
|
||||
|
||||
### 로그 설정 파일
|
||||
- [log4j.xml](mdc:WebContent/WEB-INF/log4j.xml) - 로깅 설정
|
||||
- 로그 레벨 조정으로 상세 정보 확인 가능
|
||||
|
||||
## 성능 문제
|
||||
|
||||
### 데이터베이스 성능
|
||||
```sql
|
||||
-- 슬로우 쿼리 확인
|
||||
SELECT query, mean_time, calls
|
||||
FROM pg_stat_statements
|
||||
ORDER BY mean_time DESC;
|
||||
|
||||
-- 인덱스 사용률 확인
|
||||
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch
|
||||
FROM pg_stat_user_indexes;
|
||||
```
|
||||
|
||||
### 메모리 문제
|
||||
```
|
||||
원인: 메모리 누수 또는 부족
|
||||
해결:
|
||||
1. JVM 힙 메모리 설정 확인
|
||||
2. SqlSession 리소스 정리 확인
|
||||
3. 대량 데이터 처리 시 페이징 적용
|
||||
```
|
||||
|
||||
## 보안 관련
|
||||
|
||||
### 권한 문제
|
||||
```
|
||||
원인: 사용자 권한 설정 오류
|
||||
해결:
|
||||
1. MENU_AUTH_GROUP 테이블 확인
|
||||
2. 사용자 세션 정보 확인
|
||||
3. Spring Security 설정 확인 (있는 경우)
|
||||
```
|
||||
|
||||
### SQL 인젝션 방지
|
||||
```
|
||||
주의사항:
|
||||
1. MyBatis #{} 파라미터 바인딩 사용
|
||||
2. ${} 직접 문자열 치환 지양
|
||||
3. 사용자 입력값 검증
|
||||
```
|
||||
61
.cursorrules
|
|
@ -1,66 +1,5 @@
|
|||
# Cursor Rules for ERP-node Project
|
||||
|
||||
## 🚨 비즈니스 로직 요청 양식 검증 (필수)
|
||||
|
||||
**사용자가 화면 개발 또는 비즈니스 로직 구현을 요청할 때, 아래 양식을 따르지 않으면 반드시 다음과 같이 응답하세요:**
|
||||
|
||||
```
|
||||
안녕하세요. Oh My Master! 양식을 못 알아 듣겠습니다.
|
||||
다시 한번 작성해주십쇼.
|
||||
=== 비즈니스 로직 요청서 ===
|
||||
|
||||
【화면 정보】
|
||||
- 화면명:
|
||||
- 회사코드:
|
||||
- 메뉴ID (있으면):
|
||||
|
||||
【테이블 정보】
|
||||
- 메인 테이블:
|
||||
- 디테일 테이블 (있으면):
|
||||
- 관계 FK (있으면):
|
||||
|
||||
【버튼 목록】
|
||||
버튼1:
|
||||
- 버튼명:
|
||||
- 동작 유형: (저장/삭제/수정/조회/기타)
|
||||
- 조건 (있으면):
|
||||
- 대상 테이블:
|
||||
- 추가 동작 (있으면):
|
||||
|
||||
【추가 요구사항】
|
||||
-
|
||||
```
|
||||
|
||||
**양식 미준수 판단 기준:**
|
||||
1. "화면 만들어줘" 같이 테이블명/버튼 정보 없이 요청
|
||||
2. "저장하면 저장해줘" 같이 구체적인 테이블/로직 설명 없음
|
||||
3. "이전이랑 비슷하게" 같이 모호한 참조
|
||||
4. 버튼별 조건/동작이 명시되지 않음
|
||||
|
||||
**양식 미준수 시 절대 작업 진행하지 말고, 위 양식을 보여주며 다시 작성하라고 요청하세요.**
|
||||
|
||||
**상세 가이드**: [화면개발_표준_가이드.md](docs/screen-implementation-guide/화면개발_표준_가이드.md)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 최우선 보안 규칙: 멀티테넌시
|
||||
|
||||
**모든 코드 작성/수정 완료 후 반드시 다음 파일을 확인하세요:**
|
||||
- [멀티테넌시 필수 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc)
|
||||
|
||||
**AI 에이전트는 다음 상황에서 반드시 멀티테넌시 체크리스트를 확인해야 합니다:**
|
||||
1. 데이터베이스 마이그레이션 작성 시
|
||||
2. 백엔드 API (SELECT/INSERT/UPDATE/DELETE) 작성/수정 시
|
||||
3. 프론트엔드 데이터 API 호출 작성/수정 시
|
||||
4. 테스트 완료 시
|
||||
|
||||
**핵심 원칙:**
|
||||
- ✅ 모든 테이블에 `company_code` 필수 (company_mng 제외)
|
||||
- ✅ 모든 쿼리에 `company_code` 필터링 필수
|
||||
- ✅ 프론트엔드 API 호출 시 `autoFilter` 전달 필수
|
||||
|
||||
---
|
||||
|
||||
## shadcn/ui 웹 스타일 가이드라인
|
||||
|
||||
모든 프론트엔드 개발 시 다음 shadcn/ui 기반 스타일 가이드라인을 준수해야 합니다.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
# PLM ILSHIN 개발환경 설정
|
||||
|
||||
# 애플리케이션 환경
|
||||
NODE_ENV=development
|
||||
|
||||
# 데이터베이스 설정
|
||||
DB_URL=jdbc:postgresql://39.117.244.52:11132/plm
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=ph0909!!
|
||||
|
||||
# PostgreSQL 환경 변수 (내부 DB 사용 시)
|
||||
POSTGRES_DB=plm
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=ph0909!!
|
||||
|
||||
# 애플리케이션 포트
|
||||
APP_PORT=8090
|
||||
|
||||
# JVM 옵션
|
||||
JAVA_OPTS="-Xms512m -Xmx1024m -XX:PermSize=256m -XX:MaxPermSize=512m"
|
||||
|
||||
# 로그 레벨
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
# 개발 모드 플래그
|
||||
DEBUG=true
|
||||
|
|
@ -1,12 +1,5 @@
|
|||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
bower_components
|
||||
|
||||
# Package manager logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
|
@ -17,125 +10,138 @@ pids
|
|||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
lib-cov
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
build/Release
|
||||
|
||||
# Cache
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
.cache/
|
||||
.parcel-cache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
*.cache
|
||||
cache/
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
|
||||
# Storybook
|
||||
.out
|
||||
.storybook-out
|
||||
|
||||
# REPL history
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Package archives
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# Temporary
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
log/
|
||||
*.log
|
||||
|
||||
# ===== 환경 변수 및 민감 정보 =====
|
||||
|
||||
# 환경 변수
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
.env.local
|
||||
.env.production
|
||||
.env.docker
|
||||
backend-node/.env
|
||||
backend-node/.env.local
|
||||
backend-node/.env.development
|
||||
backend-node/.env.production
|
||||
backend-node/.env.test
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
frontend/.env.development
|
||||
frontend/.env.production
|
||||
frontend/.env.test
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
docker-compose.prod.yml
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# 설정 파일
|
||||
configs/
|
||||
settings/
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# 키/인증서
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
*.crt
|
||||
*.cert
|
||||
secrets/
|
||||
secrets.json
|
||||
secrets.yaml
|
||||
secrets.yml
|
||||
api-keys.json
|
||||
tokens.json
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# 데이터베이스 덤프
|
||||
*.sql
|
||||
*.dump
|
||||
db/dump/
|
||||
db/backup/
|
||||
# Build cache
|
||||
.cache/
|
||||
|
||||
# 백업
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
backup/
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
|
||||
# ===== IDE =====
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.user
|
||||
|
||||
# OS
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
|
|
@ -144,7 +150,127 @@ backup/
|
|||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# ===== 업로드/미디어 =====
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# ===== 민감한 정보 보호 =====
|
||||
|
||||
# 데이터베이스 연결 정보
|
||||
backend-node/.env
|
||||
backend-node/.env.local
|
||||
backend-node/.env.development
|
||||
backend-node/.env.production
|
||||
backend-node/.env.test
|
||||
|
||||
# 백엔드 환경 변수
|
||||
backend/.env
|
||||
backend/.env.local
|
||||
backend/.env.development
|
||||
backend/.env.production
|
||||
backend/.env.test
|
||||
|
||||
# 프론트엔드 환경 변수
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
frontend/.env.development
|
||||
frontend/.env.production
|
||||
frontend/.env.test
|
||||
|
||||
# Docker 관련 민감 정보
|
||||
docker-compose.override.yml
|
||||
docker-compose.prod.yml
|
||||
.env.docker
|
||||
|
||||
# 설정 파일들
|
||||
configs/
|
||||
settings/
|
||||
*.config.js
|
||||
*.config.ts
|
||||
*.config.json
|
||||
|
||||
# 로그 파일들
|
||||
*.log
|
||||
logs/
|
||||
log/
|
||||
|
||||
# 임시 파일들
|
||||
*.tmp
|
||||
*.temp
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
# 백업 파일들
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
backup/
|
||||
|
||||
# 키 파일들
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
*.crt
|
||||
*.cert
|
||||
|
||||
# API 키 및 토큰
|
||||
secrets/
|
||||
secrets.json
|
||||
secrets.yaml
|
||||
secrets.yml
|
||||
api-keys.json
|
||||
tokens.json
|
||||
|
||||
# 데이터베이스 덤프 파일
|
||||
*.sql
|
||||
*.dump
|
||||
*.backup
|
||||
db/dump/
|
||||
db/backup/
|
||||
|
||||
# 캐시 파일들
|
||||
.cache/
|
||||
cache/
|
||||
*.cache
|
||||
|
||||
# 사용자별 설정
|
||||
.vscode/settings.json
|
||||
.idea/workspace.xml
|
||||
*.user
|
||||
|
||||
# ===== Gradle 관련 파일들 (레거시 Java 프로젝트) =====
|
||||
# Gradle 캐시 및 빌드 파일들
|
||||
.gradle/
|
||||
*/.gradle/
|
||||
gradle/
|
||||
gradlew
|
||||
gradlew.bat
|
||||
gradle.properties
|
||||
build/
|
||||
*/build/
|
||||
|
||||
# Gradle Wrapper
|
||||
gradle-wrapper.jar
|
||||
gradle-wrapper.properties
|
||||
|
||||
# IntelliJ IDEA 관련 (Gradle 프로젝트)
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
out/
|
||||
|
||||
# Eclipse 관련 (Gradle 프로젝트)
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
bin/
|
||||
|
||||
# 업로드된 파일들 제외
|
||||
backend-node/uploads/
|
||||
uploads/
|
||||
*.jpg
|
||||
|
|
@ -160,20 +286,4 @@ uploads/
|
|||
*.hwp
|
||||
*.hwpx
|
||||
|
||||
# ===== 기타 =====
|
||||
claude.md
|
||||
|
||||
# Agent Pipeline 로컬 파일
|
||||
_local/
|
||||
.agent-pipeline/
|
||||
.codeguard-baseline.json
|
||||
scripts/browser-test-*.js
|
||||
|
||||
# AI 에이전트 테스트 산출물
|
||||
*-test-screenshots/
|
||||
*-screenshots/
|
||||
*-test.mjs
|
||||
|
||||
# 개인 작업 문서
|
||||
popdocs/
|
||||
.cursor/rules/popdocs-safety.mdc
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
|
@ -0,0 +1,47 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>ilshin</name>
|
||||
<comment></comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.wst.jsdt.core.javascriptValidator</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.jdt.core.javabuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.wst.common.project.facet.core.builder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.wst.validation.validationbuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.eclipse.jem.workbench.JavaEMFNature</nature>
|
||||
<nature>org.eclipse.wst.common.modulecore.ModuleCoreNature</nature>
|
||||
<nature>org.eclipse.wst.common.project.facet.core.nature</nature>
|
||||
<nature>org.eclipse.jdt.core.javanature</nature>
|
||||
<nature>org.eclipse.wst.jsdt.core.jsNature</nature>
|
||||
</natures>
|
||||
<filteredResources>
|
||||
<filter>
|
||||
<id>1746619144814</id>
|
||||
<name></name>
|
||||
<type>30</type>
|
||||
<matcher>
|
||||
<id>org.eclipse.core.resources.regexFilterMatcher</id>
|
||||
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
|
||||
</matcher>
|
||||
</filter>
|
||||
</filteredResources>
|
||||
</projectDescription>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry excluding="**/*.min.js|**/node_modules/*|**/bower_components/*" kind="src" path="WebContent"/>
|
||||
<classpathentry kind="con" path="org.eclipse.wst.jsdt.launching.JRE_CONTAINER"/>
|
||||
<classpathentry kind="con" path="org.eclipse.wst.jsdt.launching.WebProject">
|
||||
<attributes>
|
||||
<attribute name="hide" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="con" path="org.eclipse.wst.jsdt.launching.baseBrowserLibrary"/>
|
||||
<classpathentry kind="output" path=""/>
|
||||
</classpath>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
eclipse.preferences.version=1
|
||||
encoding//WebContent/WEB-INF/view/materMgmt/materOrderDown1.jsp=UTF-8
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
eclipse.preferences.version=1
|
||||
org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
|
||||
org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate
|
||||
org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
|
||||
org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
|
||||
org.eclipse.jdt.core.compiler.compliance=1.7
|
||||
org.eclipse.jdt.core.compiler.debug.lineNumber=generate
|
||||
org.eclipse.jdt.core.compiler.debug.localVariable=generate
|
||||
org.eclipse.jdt.core.compiler.debug.sourceFile=generate
|
||||
org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
|
||||
org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
|
||||
org.eclipse.jdt.core.compiler.source=1.7
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?><project-modules id="moduleCoreId" project-version="1.5.0">
|
||||
<wb-module deploy-name="plm">
|
||||
<wb-resource deploy-path="/" source-path="/WebContent" tag="defaultRootSource"/>
|
||||
<wb-resource deploy-path="/WEB-INF/classes" source-path="/src"/>
|
||||
<property name="context-root" value="plm"/>
|
||||
<property name="java-output-path" value="/plm/build/classes"/>
|
||||
</wb-module>
|
||||
</project-modules>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<faceted-project>
|
||||
<runtime name="ilshin"/>
|
||||
<fixed facet="wst.jsdt.web"/>
|
||||
<fixed facet="jst.web"/>
|
||||
<fixed facet="java"/>
|
||||
<installed facet="java" version="1.7"/>
|
||||
<installed facet="jst.web" version="3.0"/>
|
||||
<installed facet="wst.jsdt.web" version="1.0"/>
|
||||
</faceted-project>
|
||||
|
|
@ -0,0 +1 @@
|
|||
org.eclipse.wst.jsdt.launching.baseBrowserLibrary
|
||||
|
|
@ -0,0 +1 @@
|
|||
Window
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
DELEGATES_PREFERENCE=delegateValidatorList
|
||||
USER_BUILD_PREFERENCE=enabledBuildValidatorListorg.eclipse.jst.j2ee.internal.classpathdep.ClasspathDependencyValidator;
|
||||
USER_MANUAL_PREFERENCE=enabledManualValidatorListorg.eclipse.jst.j2ee.internal.classpathdep.ClasspathDependencyValidator;
|
||||
USER_PREFERENCE=overrideGlobalPreferencestruedisableAllValidationtrueversion1.2.700.v201508251749
|
||||
eclipse.preferences.version=1
|
||||
override=true
|
||||
suspend=true
|
||||
vf.version=3
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
# 카드 컴포넌트 기능 확장 계획
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
테이블 리스트 컴포넌트의 고급 기능들(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일)
|
||||
**✨ 품질**: 테이블 리스트 대비 동등하거나 우수한 기능 수준
|
||||
|
||||
### 🔥 주요 성과
|
||||
|
||||
이제 사용자들은 **테이블 리스트**와 **카드 디스플레이** 중에서 자유롭게 선택하여 동일한 데이터를 서로 다른 형태로 시각화할 수 있습니다. 두 컴포넌트 모두 완전히 동일한 고급 기능을 제공합니다!
|
||||
|
|
@ -377,7 +377,7 @@ docker-compose -f docker-compose.backend.mac.yml up --build -d
|
|||
|
||||
- Node.js 백엔드: `backend-node/` 디렉토리
|
||||
- Next.js 프론트엔드: `frontend/` 디렉토리
|
||||
- 데이터베이스: PostgreSQL (pg 드라이버)
|
||||
- 데이터베이스: PostgreSQL (JNDI 설정)
|
||||
|
||||
---
|
||||
|
||||
106
Dockerfile
|
|
@ -1,106 +0,0 @@
|
|||
# ==========================
|
||||
# 멀티 스테이지 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"]
|
||||
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
# 외부 커넥션 관리 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
|
||||
**상태**: 완료 ✅
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
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
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,733 @@
|
|||
# 🔐 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일
|
||||
**담당자**: 백엔드 개발팀
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
# 🗂️ 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()` 단순 교체 작업이 주요 작업
|
||||
|
|
@ -0,0 +1,736 @@
|
|||
# 📊 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)
|
||||
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
# 📝 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()` 함수로 교체 완료
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
# 🔌 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)
|
||||
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
# 🎮 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)
|
||||
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||
**특이사항**: 복잡한 비즈니스 로직이 포함되어 있어 신중한 테스트 필요
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
# 🔧 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 실행의 특성상 신중한 테스트 필요
|
||||
|
|
@ -0,0 +1,566 @@
|
|||
# 🖥️ 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)
|
||||
**상태**: ✅ **완료**
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
# 📋 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 조건 포함
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
# 📋 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 호출 포함
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
# 📋 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, 조인 유효성 검증, 순환 참조 방지 포함
|
||||
|
|
@ -0,0 +1,456 @@
|
|||
# 📋 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 방지)
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: 보안 크리티컬, 비밀번호 암호화, 세션 관리 포함
|
||||
**⚠️ 주의**: 이 서비스는 보안에 매우 중요하므로 신중한 테스트 필수!
|
||||
|
|
@ -0,0 +1,515 @@
|
|||
# 📋 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 연동, 스케줄링, 트랜잭션 처리 포함
|
||||
**⚠️ 주의**: 배치 시스템의 핵심 기능이므로 신중한 테스트 필수!
|
||||
|
|
@ -0,0 +1,540 @@
|
|||
# 📋 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 인젝션 방지가 매우 중요!
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# 📋 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로 전환이 완료되어 있어 추가 작업이 필요하지 않습니다.
|
||||
|
||||
**상태**: ✅ **완료**
|
||||
**특이사항**: 캐싱 로직으로 성능에 중요한 서비스
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
# 📋 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 실행 시 각별한 주의 필요
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
# 🎨 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 쿼리 포함
|
||||
|
||||
|
|
@ -0,0 +1,484 @@
|
|||
# 🗂️ 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 포함
|
||||
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
# 📋 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 쿼리 포함
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
# 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줄)
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
# 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 상태 코드 반환
|
||||
- 사용자 친화적 에러 메시지
|
||||
|
|
@ -0,0 +1,546 @@
|
|||
# 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% 완료)
|
||||
|
|
@ -0,0 +1,759 @@
|
|||
# 외부 커넥션 관리 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
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
# 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`
|
||||
|
|
@ -0,0 +1,998 @@
|
|||
# 반응형 레이아웃 시스템 구현 계획서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 목표
|
||||
|
||||
화면 디자이너는 절대 위치 기반으로 유지하되, 실제 화면 표시는 반응형으로 동작하도록 전환
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
- ✅ 화면 디자이너의 절대 위치 기반 드래그앤드롭은 그대로 유지
|
||||
- ✅ 실제 화면 표시만 반응형으로 전환
|
||||
- ✅ 데이터 마이그레이션 불필요 (신규 화면부터 적용)
|
||||
- ✅ 기존 화면은 불러올 때 스마트 기본값 자동 생성
|
||||
|
||||
---
|
||||
|
||||
## 🎯 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부터 순차적으로 구현을 시작합니다.
|
||||
337
PLAN.MD
|
|
@ -1,337 +0,0 @@
|
|||
# 현재 구현 계획: pop-card-list 입력 필드/계산 필드 구조 개편
|
||||
|
||||
> **작성일**: 2026-02-24
|
||||
> **상태**: 계획 완료, 코딩 대기
|
||||
> **목적**: 입력 필드 설정 단순화 + 본문 필드에 계산식 통합 + 기존 계산 필드 섹션 제거
|
||||
|
||||
---
|
||||
|
||||
## 1. 변경 개요
|
||||
|
||||
### 배경
|
||||
- 기존: "입력 필드", "계산 필드", "담기 버튼" 3개가 별도 섹션으로 분리
|
||||
- 문제: 계산 필드가 본문 필드와 동일한 위치에 표시되어야 하는데 별도 영역에 있음
|
||||
- 문제: 입력 필드의 min/max 고정값은 비실용적 (실제로는 DB 컬럼 기준 제한이 필요)
|
||||
- 문제: step, columnName, sourceColumns, resultColumn 등 죽은 코드 존재
|
||||
|
||||
### 목표
|
||||
1. **본문 필드에 계산식 지원 추가** - 필드별로 "DB 컬럼" 또는 "계산식" 선택
|
||||
2. **입력 필드 설정 단순화** - 고정 min/max 제거, 제한 기준 컬럼 방식으로 변경
|
||||
3. **기존 "계산 필드" 섹션 제거** - 본문 필드에 통합되므로 불필요
|
||||
4. **죽은 코드 정리**
|
||||
|
||||
---
|
||||
|
||||
## 2. 수정 대상 파일 (3개)
|
||||
|
||||
### 파일 A: `frontend/lib/registry/pop-components/types.ts`
|
||||
|
||||
#### 변경 A-1: CardFieldBinding 타입 확장
|
||||
|
||||
**현재 코드** (라인 367~372):
|
||||
```typescript
|
||||
export interface CardFieldBinding {
|
||||
id: string;
|
||||
columnName: string;
|
||||
label: string;
|
||||
textColor?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**변경 코드**:
|
||||
```typescript
|
||||
export interface CardFieldBinding {
|
||||
id: string;
|
||||
label: string;
|
||||
textColor?: string;
|
||||
valueType: "column" | "formula"; // 값 유형: DB 컬럼 또는 계산식
|
||||
columnName?: string; // valueType === "column"일 때 사용
|
||||
formula?: string; // valueType === "formula"일 때 사용 (예: "$input - received_qty")
|
||||
unit?: string; // 계산식일 때 단위 표시 (예: "EA")
|
||||
}
|
||||
```
|
||||
|
||||
**주의**: `columnName`이 required에서 optional로 변경됨. 기존 저장 데이터와의 하위 호환 필요.
|
||||
|
||||
#### 변경 A-2: CardInputFieldConfig 단순화
|
||||
|
||||
**현재 코드** (라인 443~453):
|
||||
```typescript
|
||||
export interface CardInputFieldConfig {
|
||||
enabled: boolean;
|
||||
columnName?: string;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
defaultValue?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
maxColumn?: string;
|
||||
step?: number;
|
||||
}
|
||||
```
|
||||
|
||||
**변경 코드**:
|
||||
```typescript
|
||||
export interface CardInputFieldConfig {
|
||||
enabled: boolean;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값)
|
||||
saveTable?: string; // 저장 대상 테이블
|
||||
saveColumn?: string; // 저장 대상 컬럼
|
||||
showPackageUnit?: boolean; // 포장등록 버튼 표시 여부
|
||||
}
|
||||
```
|
||||
|
||||
**제거 항목**:
|
||||
- `columnName` -> `saveTable` + `saveColumn`으로 대체 (명확한 네이밍)
|
||||
- `defaultValue` -> 제거 (제한 기준 컬럼 값으로 대체)
|
||||
- `min` -> 제거 (항상 0)
|
||||
- `max` -> 제거 (`limitColumn`으로 대체)
|
||||
- `maxColumn` -> `limitColumn`으로 이름 변경
|
||||
- `step` -> 제거 (키패드 방식에서 미사용)
|
||||
|
||||
#### 변경 A-3: CardCalculatedFieldConfig 제거
|
||||
|
||||
**삭제**: `CardCalculatedFieldConfig` 인터페이스 전체 (라인 457~464)
|
||||
**삭제**: `PopCardListConfig`에서 `calculatedField?: CardCalculatedFieldConfig;` 제거
|
||||
|
||||
---
|
||||
|
||||
### 파일 B: `frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx`
|
||||
|
||||
#### 변경 B-1: 본문 필드 편집기(FieldEditor)에 값 유형 선택 추가
|
||||
|
||||
**현재**: 필드 편집 시 라벨, 컬럼, 텍스트색상만 설정 가능
|
||||
|
||||
**변경**: 값 유형 라디오("DB 컬럼" / "계산식") 추가
|
||||
- "DB 컬럼" 선택 시: 기존 컬럼 Select 표시
|
||||
- "계산식" 선택 시: 수식 입력란 + 사용 가능한 컬럼/변수 칩 목록 표시
|
||||
- 사용 가능한 변수: DB 컬럼명들 + `$input` (입력 필드 활성화 시)
|
||||
|
||||
**하위 호환**: 기존 저장 데이터에 `valueType`이 없으면 `"column"`으로 기본 처리
|
||||
|
||||
#### 변경 B-2: 입력 필드 설정 섹션 개편
|
||||
|
||||
**현재 설정 항목**: 라벨, 단위, 기본값, 최소/최대, 최대값 컬럼, 저장 컬럼
|
||||
|
||||
**변경 설정 항목**:
|
||||
```
|
||||
라벨 [입고 수량 ]
|
||||
단위 [EA ]
|
||||
제한 기준 컬럼 [ order_qty v ]
|
||||
저장 대상 테이블 [ 선택 v ]
|
||||
저장 대상 컬럼 [ 선택 v ]
|
||||
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
포장등록 버튼 [on/off]
|
||||
```
|
||||
|
||||
#### 변경 B-3: "계산 필드" 섹션 제거
|
||||
|
||||
**삭제**: `CalculatedFieldSettingsSection` 함수 전체
|
||||
**삭제**: 카드 템플릿 탭에서 "계산 필드" CollapsibleSection 제거
|
||||
|
||||
#### 변경 B-4: import 정리
|
||||
|
||||
**삭제**: `CardCalculatedFieldConfig` import
|
||||
**추가**: 없음 (기존 import 재사용)
|
||||
|
||||
---
|
||||
|
||||
### 파일 C: `frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx`
|
||||
|
||||
#### 변경 C-1: FieldRow에서 계산식 필드 지원
|
||||
|
||||
**현재**: `const value = row[field.columnName]` 로 DB 값만 표시
|
||||
|
||||
**변경**:
|
||||
```typescript
|
||||
function FieldRow({ field, row, scaled, inputValue }: {
|
||||
field: CardFieldBinding;
|
||||
row: RowData;
|
||||
scaled: ScaledConfig;
|
||||
inputValue?: number; // 입력 필드 값 (계산식에서 $input으로 참조)
|
||||
}) {
|
||||
const value = field.valueType === "formula" && field.formula
|
||||
? evaluateFormula(field.formula, row, inputValue ?? 0)
|
||||
: row[field.columnName ?? ""];
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**주의**: `inputValue`를 FieldRow까지 전달해야 하므로 CardItem -> FieldRow 경로에 prop 추가 필요
|
||||
|
||||
#### 변경 C-2: 계산식 필드 실시간 갱신
|
||||
|
||||
**현재**: 별도 `calculatedValue` useMemo가 `[calculatedField, row, inputValue]`에 반응
|
||||
|
||||
**변경**: FieldRow가 `inputValue` prop을 받으므로, `inputValue`가 변경될 때 계산식 필드가 자동으로 리렌더링됨. 별도 useMemo 불필요.
|
||||
|
||||
#### 변경 C-3: 기존 calculatedField 관련 코드 제거
|
||||
|
||||
**삭제 대상**:
|
||||
- `calculatedField` prop 전달 (CardItem)
|
||||
- `calculatedValue` useMemo
|
||||
- 계산 필드 렌더링 블록 (`{calculatedField?.enabled && calculatedValue !== null && (...)}`
|
||||
|
||||
#### 변경 C-4: 입력 필드 로직 단순화
|
||||
|
||||
**변경 대상**:
|
||||
- `effectiveMax`: `limitColumn` 사용, 미설정 시 999999 폴백
|
||||
- `defaultValue` 자동 초기화 로직 제거 (불필요)
|
||||
- `NumberInputModal`에 포장등록 on/off 전달
|
||||
|
||||
#### 변경 C-5: NumberInputModal에 포장등록 on/off 전달
|
||||
|
||||
**현재**: 포장등록 버튼 항상 표시
|
||||
**변경**: `showPackageUnit` prop 추가, false이면 포장등록 버튼 숨김
|
||||
|
||||
---
|
||||
|
||||
### 파일 D: `frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx`
|
||||
|
||||
#### 변경 D-1: showPackageUnit prop 추가
|
||||
|
||||
**현재 props**: open, onOpenChange, unit, initialValue, initialPackageUnit, min, maxValue, onConfirm
|
||||
|
||||
**추가 prop**: `showPackageUnit?: boolean` (기본값 true)
|
||||
|
||||
**변경**: `showPackageUnit === false`이면 포장등록 버튼 숨김
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 순서 (의존성 기반)
|
||||
|
||||
| 순서 | 작업 | 파일 | 의존성 | 상태 |
|
||||
|------|------|------|--------|------|
|
||||
| 1 | A-1: CardFieldBinding 타입 확장 | types.ts | 없음 | [ ] |
|
||||
| 2 | A-2: CardInputFieldConfig 단순화 | types.ts | 없음 | [ ] |
|
||||
| 3 | A-3: CardCalculatedFieldConfig 제거 | types.ts | 없음 | [ ] |
|
||||
| 4 | B-1: FieldEditor에 값 유형 선택 추가 | PopCardListConfig.tsx | 순서 1 | [ ] |
|
||||
| 5 | B-2: 입력 필드 설정 섹션 개편 | PopCardListConfig.tsx | 순서 2 | [ ] |
|
||||
| 6 | B-3: 계산 필드 섹션 제거 | PopCardListConfig.tsx | 순서 3 | [ ] |
|
||||
| 7 | B-4: import 정리 | PopCardListConfig.tsx | 순서 6 | [ ] |
|
||||
| 8 | D-1: NumberInputModal showPackageUnit 추가 | NumberInputModal.tsx | 없음 | [ ] |
|
||||
| 9 | C-1: FieldRow 계산식 지원 | PopCardListComponent.tsx | 순서 1 | [ ] |
|
||||
| 10 | C-3: calculatedField 관련 코드 제거 | PopCardListComponent.tsx | 순서 9 | [ ] |
|
||||
| 11 | C-4: 입력 필드 로직 단순화 | PopCardListComponent.tsx | 순서 2, 8 | [ ] |
|
||||
| 12 | 린트 검사 | 전체 | 순서 1~11 | [ ] |
|
||||
|
||||
순서 1, 2, 3은 독립이므로 병렬 가능.
|
||||
순서 8은 독립이므로 병렬 가능.
|
||||
|
||||
---
|
||||
|
||||
## 4. 사전 충돌 검사 결과
|
||||
|
||||
### 새로 추가할 식별자 목록
|
||||
|
||||
| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 |
|
||||
|--------|------|-----------|-----------|-----------|
|
||||
| `valueType` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
|
||||
| `formula` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 (기존 CardCalculatedFieldConfig.formula와 다른 인터페이스) |
|
||||
| `limitColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
|
||||
| `saveTable` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
|
||||
| `saveColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
|
||||
| `showPackageUnit` | CardInputFieldConfig 속성 / NumberInputModal prop | types.ts, NumberInputModal.tsx | PopCardListComponent.tsx | 충돌 없음 |
|
||||
|
||||
### 기존 타입/함수 재사용 목록
|
||||
|
||||
| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 |
|
||||
|------------|-----------|------------------------|
|
||||
| `evaluateFormula()` | PopCardListComponent.tsx 라인 1026 | C-1: FieldRow에서 호출 (기존 함수 그대로 재사용) |
|
||||
| `CardFieldBinding` | types.ts 라인 367 | A-1에서 수정, B-1/C-1에서 사용 |
|
||||
| `CardInputFieldConfig` | types.ts 라인 443 | A-2에서 수정, B-2/C-4에서 사용 |
|
||||
| `GroupedColumnSelect` | PopCardListConfig.tsx | B-1: 계산식 모드에서 컬럼 칩 표시에 재사용 가능 |
|
||||
|
||||
**사용처 있는데 정의 누락된 항목: 없음**
|
||||
|
||||
---
|
||||
|
||||
## 5. 에러 함정 경고
|
||||
|
||||
### 함정 1: 기존 저장 데이터 하위 호환
|
||||
기존에 저장된 `CardFieldBinding`에는 `valueType`이 없고 `columnName`이 필수였음.
|
||||
**반드시** 런타임에서 `field.valueType || "column"` 폴백 처리해야 함.
|
||||
Config UI에서도 `valueType` 미존재 시 `"column"` 기본값 적용 필요.
|
||||
|
||||
### 함정 2: CardInputFieldConfig 하위 호환
|
||||
기존 `maxColumn`이 `limitColumn`으로 이름 변경됨.
|
||||
기존 저장 데이터의 `maxColumn`을 `limitColumn`으로 읽어야 함.
|
||||
런타임: `inputField?.limitColumn || (inputField as any)?.maxColumn` 폴백 필요.
|
||||
|
||||
### 함정 3: evaluateFormula의 inputValue 전달
|
||||
FieldRow에 `inputValue`를 전달하려면, CardItem -> body.fields.map -> FieldRow 경로에서 `inputValue` prop을 추가해야 함.
|
||||
입력 필드가 비활성화된 경우 `inputValue`는 0으로 전달.
|
||||
|
||||
### 함정 4: calculatedField 제거 시 기존 데이터
|
||||
기존 config에 `calculatedField` 데이터가 남아 있을 수 있음.
|
||||
타입에서 제거하더라도 런타임 에러는 나지 않음 (unknown 속성은 무시됨).
|
||||
다만 이전에 계산 필드로 설정한 내용은 사라짐 - 마이그레이션 없이 제거.
|
||||
|
||||
### 함정 5: columnName optional 변경
|
||||
`CardFieldBinding.columnName`이 optional이 됨.
|
||||
기존에 `row[field.columnName]`으로 직접 접근하던 코드 전부 수정 필요.
|
||||
`field.columnName ?? ""` 또는 valueType 분기 처리.
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 방법
|
||||
|
||||
### 시나리오 1: 기존 본문 필드 (하위 호환)
|
||||
1. 기존 저장된 카드리스트 열기
|
||||
2. 본문 필드에 기존 DB 컬럼 필드가 정상 표시되는지 확인
|
||||
3. 설정 패널에서 기존 필드가 "DB 컬럼" 유형으로 표시되는지 확인
|
||||
|
||||
### 시나리오 2: 계산식 본문 필드 추가
|
||||
1. 본문 필드 추가 -> 값 유형 "계산식" 선택
|
||||
2. 수식: `order_qty - received_qty` 입력
|
||||
3. 카드에서 계산 결과가 정상 표시되는지 확인
|
||||
|
||||
### 시나리오 3: $input 참조 계산식
|
||||
1. 입력 필드 활성화
|
||||
2. 본문 필드 추가 -> 값 유형 "계산식" -> 수식: `$input - received_qty`
|
||||
3. 키패드에서 수량 입력 시 계산 결과가 실시간 갱신되는지 확인
|
||||
|
||||
### 시나리오 4: 제한 기준 컬럼
|
||||
1. 입력 필드 -> 제한 기준 컬럼: `order_qty`
|
||||
2. order_qty=1000인 카드에서 키패드 열기
|
||||
3. MAX 버튼 클릭 시 1000이 입력되고, 1001 이상 입력 불가 확인
|
||||
|
||||
### 시나리오 5: 포장등록 on/off
|
||||
1. 입력 필드 -> 포장등록 버튼: off
|
||||
2. 키패드 모달에서 포장등록 버튼이 숨겨지는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 이전 완료 계획 (아카이브)
|
||||
|
||||
<details>
|
||||
<summary>pop-dashboard 4가지 아이템 모드 완성 (완료)</summary>
|
||||
|
||||
- [x] groupBy UI 추가
|
||||
- [x] xAxisColumn 입력 UI 추가
|
||||
- [x] 통계카드 카테고리 설정 UI 추가
|
||||
- [x] 차트 xAxisColumn 자동 보정 로직
|
||||
- [x] 통계카드 카테고리별 필터 적용
|
||||
- [x] SQL 빌더 방어 로직
|
||||
- [x] refreshInterval 최소값 강제
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
|
||||
|
||||
- [x] overflow-hidden 제거
|
||||
- [x] overflow-auto 공통 적용
|
||||
- [x] 일반 모드 min-h-full 추가
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>POP 뷰어 실제 컴포넌트 렌더링 (완료)</summary>
|
||||
|
||||
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
|
||||
- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
|
||||
|
||||
</details>
|
||||
1041
POPUPDATE.md
696
POPUPDATE_2.md
|
|
@ -1,696 +0,0 @@
|
|||
# POP 컴포넌트 정의서 v8.0
|
||||
|
||||
## POP 헌법 (공통 규칙)
|
||||
|
||||
### 제1조. 컴포넌트의 정의
|
||||
|
||||
- 컴포넌트란 디자이너가 그리드에 배치하는 것이다
|
||||
- 그리드에 배치하지 않는 것은 컴포넌트가 아니다
|
||||
|
||||
### 제2조. 컴포넌트의 독립성
|
||||
|
||||
- 모든 컴포넌트는 독립적으로 동작한다
|
||||
- 컴포넌트는 다른 컴포넌트의 존재를 직접 알지 못한다 (이벤트 버스로만 통신)
|
||||
|
||||
### 제3조. 데이터의 자유
|
||||
|
||||
- 모든 컴포넌트는 자신의 테이블 + 외부 테이블을 자유롭게 조인할 수 있다
|
||||
- 컬럼별로 read/write/readwrite/hidden을 개별 설정할 수 있다
|
||||
- 보유 데이터 중 원하는 컬럼만 골라서 저장할 수 있다
|
||||
|
||||
### 제4조. 통신의 규칙
|
||||
|
||||
- 컴포넌트 간 통신은 반드시 이벤트 버스(usePopEvent)를 통한다
|
||||
- 컴포넌트가 다른 컴포넌트를 직접 참조하거나 호출하지 않는다
|
||||
- 이벤트는 화면 단위로 격리된다 (다른 POP 화면의 이벤트를 받지 않는다)
|
||||
- 같은 화면 안에서는 이벤트를 통해 자유롭게 데이터를 주고받을 수 있다
|
||||
|
||||
### 제5조. 역할의 분리
|
||||
|
||||
- 조회용 입력(pop-search)과 저장용 입력(pop-field)은 다른 컴포넌트다
|
||||
- 이동/실행(pop-icon)과 값 선택 후 반환(pop-lookup)은 다른 컴포넌트다
|
||||
- 자주 쓰는 패턴은 하나로 합치되, 흐름 자체는 강제하고 보이는 방식만 옵션으로 제공한다
|
||||
|
||||
### 제6조. 시스템 설정도 컴포넌트다
|
||||
|
||||
- 프로필, 테마, 대시보드 보이기/숨기기 같은 시스템 설정도 컴포넌트(pop-system)로 만든다
|
||||
- 디자이너가 pop-system을 배치하지 않으면 해당 화면에 설정 기능이 없다
|
||||
- 이를 통해 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정한다
|
||||
|
||||
### 제7조. 디자이너의 권한
|
||||
|
||||
- 디자이너는 컴포넌트를 배치하고 설정한다
|
||||
- 디자이너는 사용자에게 커스텀을 허용할지 말지 결정한다 (userConfigurable)
|
||||
- 디자이너가 "사용자 커스텀 허용 = OFF"로 설정하면, 사용자는 변경할 수 없다
|
||||
- 컴포넌트의 옵션 설정(어떻게 저장하고 어떻게 조회하는지 등)은 디자이너가 결정한다
|
||||
|
||||
### 제8조. 컴포넌트의 구성
|
||||
|
||||
- 모든 컴포넌트는 3개 파일로 구성된다: 실제 컴포넌트, 디자인 미리보기, 설정 패널
|
||||
- 모든 컴포넌트는 레지스트리에 등록해야 디자이너에 나타난다
|
||||
- 모든 컴포넌트 인스턴스는 userConfigurable, displayName 공통 속성을 가진다
|
||||
|
||||
### 제9조. 모달 화면의 설계
|
||||
|
||||
- 모달은 인라인(컴포넌트 설정만으로 구성)과 외부 참조(별도 POP 화면 연결) 두 가지 방식이 있다
|
||||
- 단순한 목록 선택은 인라인 모달을 사용한다 (설정만으로 완결)
|
||||
- 복잡한 검색/필터가 필요하거나 여러 곳에서 재사용하는 모달은 별도 POP 화면을 만들어 참조한다
|
||||
- 모달 안의 화면도 동일한 POP 컴포넌트 시스템으로 구성된다 (같은 그리드, 같은 컴포넌트)
|
||||
- 모달 화면의 layout_data는 기존 screen_layouts_pop 테이블에 저장한다 (DB 변경 불필요)
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
- 그리드 시스템 (v5.2): 완성
|
||||
- 컴포넌트 레지스트리: 완성 (PopComponentRegistry.ts)
|
||||
- 구현 완료: `pop-text` 1개 (pop-text.tsx)
|
||||
- 기존 `components-spec.md`는 v4 기준이라 갱신 필요
|
||||
|
||||
## 아키텍처 개요
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph designer [디자이너]
|
||||
Palette[컴포넌트 팔레트]
|
||||
Grid[CSS Grid 캔버스]
|
||||
ConfigPanel[속성 설정 패널]
|
||||
end
|
||||
|
||||
subgraph registry [레지스트리]
|
||||
Registry[PopComponentRegistry]
|
||||
end
|
||||
|
||||
subgraph infra [공통 인프라]
|
||||
DataSource[useDataSource 훅]
|
||||
EventBus[usePopEvent 훅]
|
||||
ActionRunner[usePopAction 훅]
|
||||
end
|
||||
|
||||
subgraph components [9개 컴포넌트]
|
||||
Text[pop-text - 완성]
|
||||
Dashboard[pop-dashboard]
|
||||
Table[pop-table]
|
||||
Button[pop-button]
|
||||
Icon[pop-icon]
|
||||
Search[pop-search]
|
||||
Field[pop-field]
|
||||
Lookup[pop-lookup]
|
||||
System[pop-system]
|
||||
end
|
||||
|
||||
subgraph backend [기존 백엔드 API]
|
||||
DataAPI[dataApi - 동적 CRUD]
|
||||
DashAPI[dashboardApi - 통계 쿼리]
|
||||
CodeAPI[commonCodeApi - 공통코드]
|
||||
NumberAPI[numberingRuleApi - 채번]
|
||||
end
|
||||
|
||||
Palette --> Grid
|
||||
Grid --> ConfigPanel
|
||||
ConfigPanel --> Registry
|
||||
|
||||
Registry --> components
|
||||
components --> infra
|
||||
infra --> backend
|
||||
EventBus -.->|컴포넌트 간 통신| components
|
||||
System -.->|보이기/숨기기 제어| components
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 공통 인프라 (모든 컴포넌트가 공유)
|
||||
|
||||
### 핵심 원칙: 모든 컴포넌트는 데이터를 자유롭게 다룬다
|
||||
|
||||
1. **데이터 전달**: 모든 컴포넌트는 자신이 보유한 데이터를 다른 컴포넌트에 전달/수신 가능
|
||||
2. **테이블 조인**: 자신의 테이블 + 외부 테이블 자유롭게 조인하여 데이터 구성
|
||||
3. **컬럼별 CRUD 제어**: 컬럼 단위로 "조회만" / "저장 대상" / "숨김"을 개별 설정 가능
|
||||
4. **선택적 저장**: 보유 데이터 중 원하는 컬럼만 골라서 저장/수정/삭제 가능
|
||||
|
||||
### 공통 인스턴스 속성 (모든 컴포넌트 배치 시 설정 가능)
|
||||
|
||||
디자이너가 컴포넌트를 그리드에 배치할 때 설정하는 공통 속성:
|
||||
|
||||
- `userConfigurable`: boolean - 사용자가 이 컴포넌트를 숨길 수 있는지 (개인 설정 패널에 노출)
|
||||
- `displayName`: string - 개인 설정 패널에 보여줄 이름 (예: "금일 생산실적")
|
||||
|
||||
### 1. DataSourceConfig (데이터 소스 설정 타입)
|
||||
|
||||
모든 데이터 연동 컴포넌트가 사용하는 표준 설정 구조:
|
||||
|
||||
- `tableName`: 대상 테이블
|
||||
- `columns`: 컬럼 바인딩 목록 (ColumnBinding 배열)
|
||||
- `filters`: 필터 조건 배열
|
||||
- `sort`: 정렬 설정
|
||||
- `aggregation`: 집계 함수 (count, sum, avg, min, max)
|
||||
- `joins`: 테이블 조인 설정 (JoinConfig 배열)
|
||||
- `refreshInterval`: 자동 새로고침 주기 (초)
|
||||
- `limit`: 조회 건수 제한
|
||||
|
||||
### 1-1. ColumnBinding (컬럼별 읽기/쓰기 제어)
|
||||
|
||||
각 컬럼이 컴포넌트에서 어떤 역할을 하는지 개별 설정:
|
||||
|
||||
- `columnName`: 컬럼명
|
||||
- `sourceTable`: 소속 테이블 (조인된 외부 테이블 포함)
|
||||
- `mode`: "read" | "write" | "readwrite" | "hidden"
|
||||
- read: 조회만 (화면에 표시하되 저장 안 함)
|
||||
- write: 저장 대상 (사용자 입력 -> DB 저장)
|
||||
- readwrite: 조회 + 저장 모두
|
||||
- hidden: 내부 참조용 (화면에 안 보이지만 다른 컴포넌트에 전달 가능)
|
||||
- `label`: 화면 표시 라벨
|
||||
- `defaultValue`: 기본값
|
||||
|
||||
예시: 발주 품목 카드에서 5개 컬럼 중 3개만 저장
|
||||
|
||||
```
|
||||
columns: [
|
||||
{ columnName: "item_code", sourceTable: "order_items", mode: "read" },
|
||||
{ columnName: "item_name", sourceTable: "item_info", mode: "read" },
|
||||
{ columnName: "inbound_qty", sourceTable: "order_items", mode: "readwrite" },
|
||||
{ columnName: "warehouse", sourceTable: "order_items", mode: "write" },
|
||||
{ columnName: "memo", sourceTable: "order_items", mode: "write" },
|
||||
]
|
||||
```
|
||||
|
||||
### 1-2. JoinConfig (테이블 조인 설정)
|
||||
|
||||
외부 테이블과 자유롭게 조인:
|
||||
|
||||
- `targetTable`: 조인할 외부 테이블명
|
||||
- `joinType`: "inner" | "left" | "right"
|
||||
- `on`: 조인 조건 { sourceColumn, targetColumn }
|
||||
- `columns`: 가져올 컬럼 목록
|
||||
|
||||
### 2. useDataSource 훅
|
||||
|
||||
DataSourceConfig를 받아서 기존 API를 호출하고 결과를 반환:
|
||||
|
||||
- 로딩/에러/데이터 상태 관리
|
||||
- 자동 새로고침 타이머
|
||||
- 필터 변경 시 자동 재조회
|
||||
- 기존 `dataApi`, `dashboardApi` 활용
|
||||
- **CRUD 함수 제공**: save(data), update(id, data), delete(id)
|
||||
- ColumnBinding의 mode가 "write" 또는 "readwrite"인 컬럼만 저장 대상에 포함
|
||||
- "read" 컬럼은 저장 시 자동 제외
|
||||
|
||||
### 3. usePopEvent 훅 (이벤트 버스 - 데이터 전달 포함)
|
||||
|
||||
컴포넌트 간 통신 (단순 이벤트 + 데이터 페이로드):
|
||||
|
||||
- `publish(eventName, payload)`: 이벤트 발행
|
||||
- `subscribe(eventName, callback)`: 이벤트 구독
|
||||
- `getSharedData(key)`: 공유 데이터 직접 읽기
|
||||
- `setSharedData(key, value)`: 공유 데이터 직접 쓰기
|
||||
- 화면 단위 스코프 (다른 POP 화면과 격리)
|
||||
|
||||
### 4. PopActionConfig (액션 설정 타입)
|
||||
|
||||
모든 컴포넌트가 사용할 수 있는 액션 표준 구조:
|
||||
|
||||
- `type`: "navigate" | "modal" | "save" | "delete" | "api" | "event" | "refresh"
|
||||
- `navigate`: { screenId, url }
|
||||
- `modal`: { mode, title, screenId, inlineConfig, modalSize }
|
||||
- mode: "inline" (설정만으로 구성) | "screen-ref" (별도 화면 참조)
|
||||
- title: 모달 제목
|
||||
- screenId: mode가 "screen-ref"일 때 참조할 POP 화면 ID
|
||||
- inlineConfig: mode가 "inline"일 때 사용할 DataSourceConfig + 표시 설정
|
||||
- modalSize: { width, height } 모달 크기
|
||||
- `save`: { targetColumns }
|
||||
- `delete`: { confirmMessage }
|
||||
- `api`: { method, endpoint, body }
|
||||
- `event`: { eventName, payload }
|
||||
- `refresh`: { targetComponents }
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 정의 (9개)
|
||||
|
||||
### 1. pop-text (완성)
|
||||
|
||||
- **한 줄 정의**: 보여주기만 함
|
||||
- **카테고리**: display
|
||||
- **역할**: 정적 표시 전용 (이벤트 없음)
|
||||
- **서브타입**: text, datetime, image, title
|
||||
- **데이터**: 없음 (정적 콘텐츠)
|
||||
- **이벤트**: 발행 없음, 수신 없음
|
||||
- **설정**: 내용, 폰트 크기/굵기, 좌우/상하 정렬, 이미지 URL/맞춤/크기, 날짜 포맷 빌더
|
||||
|
||||
### 2. pop-dashboard (신규 - 2026-02-09 토의 결과 반영)
|
||||
|
||||
- **한 줄 정의**: 여러 집계 아이템을 묶어서 다양한 방식으로 보여줌
|
||||
- **카테고리**: display
|
||||
- **역할**: 숫자 데이터를 집계/계산하여 시각화. 하나의 컴포넌트 안에 여러 집계 아이템을 담는 컨테이너
|
||||
- **구조**: 1개 pop-dashboard = 여러 DashboardItem의 묶음. 각 아이템은 독립적으로 데이터 소스/서브타입/보이기숨기기 설정 가능
|
||||
- **서브타입** (아이템별로 선택, 한 묶음에 혼합 가능):
|
||||
- kpi-card: 숫자 + 단위 + 라벨 + 증감 표시
|
||||
- chart: 막대/원형/라인 차트
|
||||
- gauge: 게이지 (목표 대비 달성률)
|
||||
- stat-card: 통계 카드 (건수 + 대기 + 링크)
|
||||
- **표시 모드** (디자이너가 선택):
|
||||
- arrows: 좌우 버튼으로 아이템 넘기기
|
||||
- auto-slide: 전광판처럼 자동 전환 (터치 시 멈춤, 일정 시간 후 재개)
|
||||
- grid: 컴포넌트 영역 내부를 행/열로 쪼개서 여러 아이템 동시 표시 (디자이너가 각 아이템 위치 직접 지정)
|
||||
- scroll: 좌우 또는 상하 스와이프
|
||||
- **데이터**: 각 아이템별 독립 DataSourceConfig (조인/집계 자유)
|
||||
- **계산식 지원**: "생산량/총재고량", "출고량/현재고량" 같은 복합 표현 가능
|
||||
- 값 A, B를 각각 다른 테이블/집계로 설정
|
||||
- 표시 형태: 분수(1,234/5,678), 퍼센트(21.7%), 비율(1,234:5,678)
|
||||
- **CRUD**: 주로 읽기. 목표값 수정 등 필요 시 write 컬럼으로 저장 가능
|
||||
- **이벤트**:
|
||||
- 수신: filter_changed, data_ready
|
||||
- 발행: kpi_clicked (아이템 클릭 시 상세 데이터 전달)
|
||||
- **설정**: 데이터 소스(드롭다운 기반 쉬운 집계), 집계 함수, 계산식, 라벨, 단위, 색상 구간, 차트 타입, 새로고침 주기, 목표값, 표시 모드, 아이템별 보이기/숨기기
|
||||
- **보이기/숨기기**: 각 아이템별로 pop-system에서 개별 on/off 가능 (userConfigurable)
|
||||
- **기존 POP 대시보드 폐기**: `frontend/components/pop/dashboard/` 폴더 전체를 이 컴포넌트로 대체 예정 (Phase 1~3 완료 후)
|
||||
|
||||
#### pop-dashboard 데이터 구조
|
||||
|
||||
```
|
||||
PopDashboardConfig {
|
||||
items: DashboardItem[] // 아이템 목록 (각각 독립 설정)
|
||||
displayMode: "arrows" | "auto-slide" | "grid" | "scroll"
|
||||
autoSlideInterval: number // 자동 슬라이드 간격(초)
|
||||
gridLayout: { columns: number, rows: number } // 행열 그리드 설정
|
||||
showIndicator: boolean // 페이지 인디케이터 표시
|
||||
gap: number // 아이템 간 간격
|
||||
}
|
||||
|
||||
DashboardItem {
|
||||
id: string
|
||||
label: string // pop-system에서 보이기/숨기기용 이름
|
||||
visible: boolean // 보이기/숨기기
|
||||
subType: "kpi-card" | "chart" | "gauge" | "stat-card"
|
||||
dataSource: DataSourceConfig // 각 아이템별 독립 데이터 소스
|
||||
|
||||
// 행열 그리드 모드에서의 위치 (디자이너가 직접 지정)
|
||||
gridPosition: { col: number, row: number, colSpan: number, rowSpan: number }
|
||||
|
||||
// 계산식 (선택사항)
|
||||
formula?: {
|
||||
enabled: boolean
|
||||
values: [
|
||||
{ id: "A", dataSource: DataSourceConfig, label: "생산량" },
|
||||
{ id: "B", dataSource: DataSourceConfig, label: "총재고량" },
|
||||
]
|
||||
expression: string // "A / B", "A + B", "A / B * 100"
|
||||
displayFormat: "value" | "fraction" | "percent" | "ratio"
|
||||
}
|
||||
|
||||
// 서브타입별 설정
|
||||
kpiConfig?: { unit, colorRanges, showTrend, trendPeriod }
|
||||
chartConfig?: { chartType, xAxis, yAxis, colors }
|
||||
gaugeConfig?: { min, max, target, colorRanges }
|
||||
statConfig?: { categories, showLink }
|
||||
}
|
||||
```
|
||||
|
||||
#### 설정 패널 흐름 (드롭다운 기반 쉬운 집계)
|
||||
|
||||
```
|
||||
1. [+ 아이템 추가] 버튼 클릭
|
||||
2. 서브타입 선택: kpi-card / chart / gauge / stat-card
|
||||
3. 데이터 모드 선택: [단일 집계] 또는 [계산식]
|
||||
|
||||
[단일 집계]
|
||||
- 테이블 선택 (table-schema API로 목록)
|
||||
- 조인할 테이블 추가 (선택사항)
|
||||
- 컬럼 선택 → 집계 함수 선택 (합계/건수/평균/최소/최대)
|
||||
- 필터 조건 추가
|
||||
|
||||
[계산식] (예: 생산량/총재고량)
|
||||
- 값 A: 테이블 -> 컬럼 -> 집계함수
|
||||
- 값 B: 테이블 -> 컬럼 -> 집계함수 (다른 테이블도 가능)
|
||||
- 계산식: A / B
|
||||
- 표시 형태: 분수 / 퍼센트 / 비율
|
||||
|
||||
4. 라벨, 단위, 색상 등 외형 설정
|
||||
5. 행열 그리드 위치 설정 (grid 모드일 때)
|
||||
```
|
||||
|
||||
### 3. pop-table (신규 - 가장 복잡)
|
||||
|
||||
- **한 줄 정의**: 데이터 목록을 보여주고 편집함
|
||||
- **카테고리**: display
|
||||
- **역할**: 데이터 목록 표시 + 편집 (카드형/테이블형)
|
||||
- **서브타입**:
|
||||
- card-list: 카드 형태
|
||||
- table-list: 테이블 형태 (행/열 장부)
|
||||
- **데이터**: DataSourceConfig (조인/컬럼별 읽기쓰기 자유)
|
||||
- **CRUD**: useDataSource의 save/update/delete 사용. write/readwrite 컬럼만 자동 추출
|
||||
- **카드 템플릿** (card-list 전용): 카드 내부 미니 그리드로 요소 배치, 요소별 데이터 바인딩
|
||||
- **이벤트**:
|
||||
- 수신: filter_changed, refresh, data_ready
|
||||
- 발행: row_selected, row_action, save_complete, delete_complete
|
||||
- **설정**: 데이터 소스, 표시 모드, 카드 템플릿, 컬럼 정의, 행 선택 방식, 페이징, 정렬, 인라인 편집 여부
|
||||
|
||||
### 4. pop-button (신규)
|
||||
|
||||
- **한 줄 정의**: 누르면 액션 실행 (저장, 삭제 등)
|
||||
- **카테고리**: action
|
||||
- **역할**: 액션 실행 (저장, 삭제, API 호출, 모달 열기 등)
|
||||
- **데이터**: 이벤트로 수신한 데이터를 액션에 활용
|
||||
- **CRUD**: 버튼 클릭 시 수신 데이터 기반으로 save/update/delete 실행
|
||||
- **이벤트**:
|
||||
- 수신: data_ready, row_selected
|
||||
- 발행: save_complete, delete_complete 등
|
||||
- **설정**: 라벨, 아이콘, 크기, 스타일, 액션 설정(PopActionConfig), 확인 다이얼로그, 로딩 상태
|
||||
|
||||
### 5. pop-icon (신규)
|
||||
|
||||
- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음)
|
||||
- **카테고리**: action
|
||||
- **역할**: 네비게이션 (화면 이동, URL 이동)
|
||||
- **데이터**: 없음
|
||||
- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행)
|
||||
- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시
|
||||
- **pop-lookup과의 차이**: pop-icon은 이동/실행만 함. 값을 선택해서 돌려주지 않음
|
||||
|
||||
### 6. pop-search (신규)
|
||||
|
||||
- **한 줄 정의**: 조건을 입력해서 다른 컴포넌트를 조회/필터링
|
||||
- **카테고리**: input
|
||||
- **역할**: 다른 컴포넌트에 필터 조건 전달 + 자체 데이터 조회
|
||||
- **서브타입**:
|
||||
- text-search: 텍스트 검색
|
||||
- date-range: 날짜 범위
|
||||
- select-filter: 드롭다운 선택 (공통코드 연동)
|
||||
- combo-filter: 복합 필터 (여러 조건 조합)
|
||||
- **실행 방식**: auto(값 변경 즉시) 또는 button(검색 버튼 클릭 시)
|
||||
- **데이터**: 공통코드/카테고리 API로 선택 항목 조회
|
||||
- **이벤트**:
|
||||
- 수신: 없음
|
||||
- 발행: filter_changed (필터 값 변경 시)
|
||||
- **설정**: 필터 타입, 대상 컬럼, 공통코드 연결, 플레이스홀더, 실행 방식(auto/button), 발행할 이벤트 이름
|
||||
- **pop-field와의 차이**: pop-search 입력값은 조회용(DB에 안 들어감). pop-field 입력값은 저장용(DB에 들어감)
|
||||
|
||||
### 7. pop-field (신규)
|
||||
|
||||
- **한 줄 정의**: 저장할 값을 입력
|
||||
- **카테고리**: input
|
||||
- **역할**: 단일 데이터 입력 (폼 필드) - 입력한 값이 DB에 저장되는 것이 목적
|
||||
- **서브타입**:
|
||||
- text: 텍스트 입력
|
||||
- number: 숫자 입력 (수량, 금액)
|
||||
- date: 날짜 선택
|
||||
- select: 드롭다운 선택
|
||||
- numpad: 큰 숫자패드 (현장용)
|
||||
- **데이터**: DataSourceConfig (선택적)
|
||||
- select 옵션을 DB에서 조회 가능
|
||||
- ColumnBinding으로 입력값의 저장 대상 테이블/컬럼 지정
|
||||
- **CRUD**: 자체 저장은 보통 하지 않음. value_changed 이벤트로 pop-button 등에 전달
|
||||
- **이벤트**:
|
||||
- 수신: set_value (외부에서 값 설정)
|
||||
- 발행: value_changed (값 + 컬럼명 + 모드 정보)
|
||||
- **설정**: 입력 타입, 라벨, 플레이스홀더, 필수 여부, 유효성 검증, 최소/최대값, 단위 표시, 바인딩 컬럼
|
||||
|
||||
### 8. pop-lookup (신규)
|
||||
|
||||
- **한 줄 정의**: 모달에서 값을 골라서 반환
|
||||
- **카테고리**: input
|
||||
- **역할**: 필드를 클릭하면 모달이 열리고, 목록에서 선택하면 값이 반환되는 컴포넌트
|
||||
- **서브타입 (모달 안 표시 방식)**:
|
||||
- card: 카드형 목록
|
||||
- table: 테이블형 목록
|
||||
- icon-grid: 아이콘 그리드 (참조 화면의 거래처 선택처럼)
|
||||
- **동작 흐름**: 필드 클릭 -> 모달 열림 -> 목록에서 선택 -> 모달 닫힘 -> 필드에 값 표시 + 이벤트 발행
|
||||
- **데이터**: DataSourceConfig (모달 안 목록의 데이터 소스)
|
||||
- **이벤트**:
|
||||
- 수신: set_value (외부에서 값 초기화)
|
||||
- 발행: value_selected (선택한 레코드 전체 데이터 전달), filter_changed (선택 값을 필터로 전달)
|
||||
- **설정**: 라벨, 플레이스홀더, 데이터 소스, 모달 표시 방식(card/table/icon-grid), 표시 컬럼(모달 목록에 보여줄 컬럼), 반환 컬럼(선택 시 돌려줄 값), 발행할 이벤트 이름
|
||||
- **pop-icon과의 차이**: pop-icon은 이동/실행만 하고 값이 안 돌아옴. pop-lookup은 값을 골라서 돌려줌
|
||||
- **pop-search와의 차이**: pop-search는 텍스트/날짜/드롭다운으로 필터링. pop-lookup은 모달을 열어서 목록에서 선택
|
||||
|
||||
#### pop-lookup 모달 화면 설계 방식
|
||||
|
||||
pop-lookup이 열리는 모달의 내부 화면은 **두 가지 방식** 중 선택할 수 있다:
|
||||
|
||||
**방식 A: 인라인 모달 (기본)**
|
||||
- pop-lookup 컴포넌트의 설정 패널에서 직접 모달 내부 화면을 구성
|
||||
- DataSourceConfig + 표시 컬럼 + 검색 필터 설정만으로 동작
|
||||
- 별도 화면 생성 없이 컴포넌트 설정만으로 완결
|
||||
- 적합한 경우: 단순 목록 선택 (거래처 목록, 품목 목록 등)
|
||||
|
||||
**방식 B: 외부 화면 참조 (고급)**
|
||||
- 별도의 POP 화면(screen_id)을 모달로 연결
|
||||
- 모달 안에서 검색/필터/테이블 등 복잡한 화면을 디자이너로 자유롭게 구성
|
||||
- 여러 pop-lookup에서 같은 모달 화면을 재사용 가능
|
||||
- 적합한 경우: 복잡한 검색/필터가 필요한 선택 화면, 여러 화면에서 공유하는 모달
|
||||
|
||||
**설정 구조:**
|
||||
|
||||
```
|
||||
modalConfig: {
|
||||
mode: "inline" | "screen-ref"
|
||||
|
||||
// mode = "inline"일 때 사용
|
||||
dataSource: DataSourceConfig
|
||||
displayColumns: ColumnBinding[]
|
||||
searchFilter: { enabled: boolean, targetColumns: string[] }
|
||||
modalSize: { width: number, height: number }
|
||||
|
||||
// mode = "screen-ref"일 때 사용
|
||||
screenId: number // 참조할 POP 화면 ID
|
||||
returnMapping: { // 모달 화면에서 선택된 값을 어떻게 매핑할지
|
||||
sourceColumn: string // 모달 화면에서 반환하는 컬럼
|
||||
targetField: string // pop-lookup 필드에 표시할 값
|
||||
}[]
|
||||
modalSize: { width: number, height: number }
|
||||
}
|
||||
```
|
||||
|
||||
**기존 시스템과의 호환성 (검증 완료):**
|
||||
|
||||
| 항목 | 현재 상태 | pop-lookup 지원 여부 |
|
||||
|------|-----------|---------------------|
|
||||
| DB: layout_data JSONB | 유연한 JSON 구조 | modalConfig를 layout_data에 저장 가능 (스키마 변경 불필요) |
|
||||
| DB: screen_layouts_pop 테이블 | screen_id + company_code 기반 | 모달 화면도 별도 screen_id로 저장 가능 |
|
||||
| 프론트: TabsWidget | screenId로 외부 화면 참조 지원 | 같은 패턴으로 모달에서 외부 화면 로드 가능 |
|
||||
| 프론트: detectLinkedModals API | 연결된 모달 화면 감지 기능 있음 | 화면 간 참조 관계 추적에 활용 가능 |
|
||||
| 백엔드: saveLayoutPop/getLayoutPop | POP 전용 저장/조회 API 있음 | 모달 화면도 동일 API로 저장/조회 가능 |
|
||||
| 레이어 시스템 | layer_id 기반 다중 레이어 지원 | 모달 내부 레이아웃을 레이어로 관리 가능 |
|
||||
|
||||
**DB 마이그레이션 불필요**: layout_data가 JSONB이므로 modalConfig를 컴포넌트 overrides에 포함하면 됨.
|
||||
**백엔드 변경 불필요**: 기존 saveLayoutPop/getLayoutPop API가 그대로 사용 가능.
|
||||
**프론트엔드 참고 패턴**: TabsWidget의 screenId 참조 방식을 그대로 차용.
|
||||
|
||||
### 9. pop-system (신규)
|
||||
|
||||
- **한 줄 정의**: 시스템 설정을 하나로 통합한 컴포넌트 (프로필, 테마, 보이기/숨기기)
|
||||
- **카테고리**: system
|
||||
- **역할**: 사용자 개인 설정 기능을 제공하는 통합 컴포넌트
|
||||
- **내부 포함 기능**:
|
||||
- 프로필 표시 (사용자명, 부서)
|
||||
- 테마 선택 (기본/다크/블루/그린)
|
||||
- 대시보드 보이기/숨기기 체크박스 (같은 화면의 userConfigurable=true 컴포넌트를 자동 수집)
|
||||
- 하단 메뉴 보이기/숨기기
|
||||
- 드래그앤드롭으로 순서 변경
|
||||
- **디자이너가 설정하는 것**: 크기(그리드에서 차지하는 영역), 내부 라벨/아이콘 크기와 위치
|
||||
- **사용자가 하는 것**: 체크박스로 컴포넌트 보이기/숨기기, 테마 선택, 순서 변경
|
||||
- **데이터**: 같은 화면의 layout_data에서 컴포넌트 목록을 자동 수집
|
||||
- **저장**: 사용자별 설정을 localStorage에 저장 (데스크탑 패턴 따름)
|
||||
- **이벤트**:
|
||||
- 수신: 없음
|
||||
- 발행: visibility_changed (컴포넌트 보이기/숨기기 변경 시), theme_changed (테마 변경 시)
|
||||
- **설정**: 내부 라벨 크기, 아이콘 크기, 위치 정도만
|
||||
- **특이사항**:
|
||||
- 디자이너가 이 컴포넌트를 배치하지 않으면 해당 화면에 개인 설정 기능이 없다
|
||||
- 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정하는 구조
|
||||
- 메인 홈에는 배치, 업무 화면(입고 등)에는 안 배치하는 식으로 사용
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 간 통신 예시
|
||||
|
||||
### 예시 1: 검색 -> 필터 연동
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Search as pop-search
|
||||
participant Dashboard as pop-dashboard
|
||||
participant Table as pop-table
|
||||
|
||||
Note over Search: 사용자가 창고 WH01 선택
|
||||
Search->>Dashboard: filter_changed
|
||||
Search->>Table: filter_changed
|
||||
Note over Dashboard: DataSource 재조회
|
||||
Note over Table: DataSource 재조회
|
||||
```
|
||||
|
||||
### 예시 2: 데이터 전달 + 선택적 저장
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Table as pop-table
|
||||
participant Field as pop-field
|
||||
participant Button as pop-button
|
||||
|
||||
Note over Table: 사용자가 발주 행 선택
|
||||
Table->>Field: row_selected
|
||||
Table->>Button: row_selected
|
||||
Note over Field: 사용자가 qty를 500으로 입력
|
||||
Field->>Button: value_changed
|
||||
Note over Button: 사용자가 저장 클릭
|
||||
Note over Button: write/readwrite 컬럼만 추출하여 저장
|
||||
Button->>Table: save_complete
|
||||
Note over Table: 데이터 새로고침
|
||||
```
|
||||
|
||||
### 예시 3: pop-lookup 거래처 선택 -> 품목 조회
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Lookup as pop-lookup
|
||||
participant Table as pop-table
|
||||
|
||||
Note over Lookup: 사용자가 거래처 필드 클릭
|
||||
Note over Lookup: 모달 열림 - 거래처 목록 표시
|
||||
Note over Lookup: 사용자가 대한금속 선택
|
||||
Note over Lookup: 모달 닫힘 - 필드에 대한금속 표시
|
||||
Lookup->>Table: filter_changed { company: "대한금속" }
|
||||
Note over Table: company=대한금속 필터로 재조회
|
||||
Note over Table: 발주 품목 3건 표시
|
||||
```
|
||||
|
||||
### 예시 4: pop-lookup 인라인 모달 vs 외부 화면 참조
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 사용자
|
||||
participant Lookup as pop-lookup (거래처)
|
||||
participant Modal as 모달
|
||||
|
||||
Note over User,Modal: [방식 A: 인라인 모달]
|
||||
User->>Lookup: 거래처 필드 클릭
|
||||
Lookup->>Modal: 인라인 모달 열림 (DataSourceConfig 기반)
|
||||
Note over Modal: supplier 테이블에서 목록 조회
|
||||
Note over Modal: 테이블형 목록 표시
|
||||
User->>Modal: "대한금속" 선택
|
||||
Modal->>Lookup: value_selected { supplier_code: "DH001", name: "대한금속" }
|
||||
Note over Lookup: 필드에 "대한금속" 표시
|
||||
|
||||
Note over User,Modal: [방식 B: 외부 화면 참조]
|
||||
User->>Lookup: 거래처 필드 클릭
|
||||
Lookup->>Modal: 모달 열림 (screenId=42 화면 로드)
|
||||
Note over Modal: 별도 POP 화면 렌더링
|
||||
Note over Modal: pop-search(검색) + pop-table(목록) 등 배치된 컴포넌트 동작
|
||||
User->>Modal: 검색 후 "대한금속" 선택
|
||||
Modal->>Lookup: returnMapping 기반으로 값 반환
|
||||
Note over Lookup: 필드에 "대한금속" 표시
|
||||
```
|
||||
|
||||
### 예시 5: 컬럼별 읽기/쓰기 분리 동작
|
||||
|
||||
5개 컬럼이 있는 발주 화면:
|
||||
|
||||
- item_code (read) -> 화면에 표시, 저장 안 함
|
||||
- item_name (read, 조인) -> item_info 테이블에서 가져옴, 저장 안 함
|
||||
- inbound_qty (readwrite) -> 화면에 표시 + 사용자 수정 + 저장
|
||||
- warehouse (write) -> 사용자 입력 + 저장
|
||||
- memo (write) -> 사용자 입력 + 저장
|
||||
|
||||
저장 API 호출 시: `{ inbound_qty: 500, warehouse: "WH01", memo: "긴급" }` 만 전달
|
||||
조회 API 호출 시: 5개 컬럼 전부 + 조인된 item_name까지 조회
|
||||
|
||||
---
|
||||
|
||||
## 구현 우선순위
|
||||
|
||||
- Phase 0 (공통 인프라): ColumnBinding, JoinConfig, DataSourceConfig 타입, useDataSource 훅 (CRUD 포함), usePopEvent 훅 (데이터 전달 포함), PopActionConfig 타입
|
||||
- Phase 1 (기본 표시): pop-dashboard (4개 서브타입 전부 + 멀티 아이템 컨테이너 + 4개 표시 모드 + 계산식)
|
||||
- Phase 2 (기본 액션): pop-button, pop-icon
|
||||
- Phase 3 (데이터 목록): pop-table (테이블형부터, 카드형은 후순위)
|
||||
- Phase 4 (입력/연동): pop-search, pop-field, pop-lookup
|
||||
- Phase 5 (고도화): pop-table 카드 템플릿
|
||||
- Phase 6 (시스템): pop-system (프로필, 테마, 대시보드 보이기/숨기기 통합)
|
||||
|
||||
### Phase 1 상세 변경 (2026-02-09 토의 결정)
|
||||
|
||||
기존 계획에서 "KPI 카드 우선"이었으나, 토의 결과 **4개 서브타입 전부를 Phase 1에서 구현**으로 변경:
|
||||
- kpi-card, chart, gauge, stat-card 모두 Phase 1
|
||||
- 멀티 아이템 컨테이너 (arrows, auto-slide, grid, scroll)
|
||||
- 계산식 지원 (formula)
|
||||
- 드롭다운 기반 쉬운 집계 설정
|
||||
- 기존 `frontend/components/pop/dashboard/` 폴더는 Phase 1 완료 후 폐기/삭제
|
||||
|
||||
### 백엔드 API 현황 (호환성 점검 완료)
|
||||
|
||||
기존 백엔드에 이미 구현되어 있어 새로 만들 필요 없는 API:
|
||||
|
||||
| API | 용도 | 비고 |
|
||||
|-----|------|------|
|
||||
| `dataApi.getTableData()` | 동적 테이블 조회 | 페이징, 검색, 정렬, 필터 |
|
||||
| `dataApi.getJoinedData()` | 2개 테이블 조인 | Entity 조인, 필터링, 중복제거 |
|
||||
| `entityJoinApi.getTableDataWithJoins()` | Entity 조인 전용 | ID->이름 자동 변환 |
|
||||
| `dataApi.createRecord/updateRecord/deleteRecord()` | 동적 CRUD | - |
|
||||
| `dataApi.upsertGroupedRecords()` | 그룹 UPSERT | - |
|
||||
| `dashboardApi.executeQuery()` | SELECT SQL 직접 실행 | 집계/복합조인용 |
|
||||
| `dashboardApi.getTableSchema()` | 테이블/컬럼 목록 | 설정 패널 드롭다운용 |
|
||||
|
||||
**백엔드 신규 개발 불필요** - 기존 API만으로 모든 데이터 연동 가능
|
||||
|
||||
### useDataSource의 API 선택 전략
|
||||
|
||||
```
|
||||
단순 조회 (조인/집계 없음) -> dataApi.getTableData() 또는 entityJoinApi
|
||||
2개 테이블 조인 -> dataApi.getJoinedData()
|
||||
3개+ 테이블 조인 또는 집계 -> DataSourceConfig를 SQL로 변환 -> dashboardApi.executeQuery()
|
||||
CRUD -> dataApi.createRecord/updateRecord/deleteRecord()
|
||||
```
|
||||
|
||||
### POP 전용 훅 분리 (2026-02-09 결정)
|
||||
|
||||
데스크탑과의 완전 분리를 위해 POP 전용 훅은 별도 폴더:
|
||||
- `frontend/hooks/pop/usePopEvent.ts` (POP 전용)
|
||||
- `frontend/hooks/pop/useDataSource.ts` (POP 전용)
|
||||
|
||||
## 기존 시스템 호환성 검증 결과 (v8.0 추가)
|
||||
|
||||
v8.0에서 추가된 모달 설계 방식에 대해 기존 시스템과의 호환성을 검증한 결과:
|
||||
|
||||
### DB 스키마 (변경 불필요)
|
||||
|
||||
| 테이블 | 현재 구조 | 호환성 |
|
||||
|--------|-----------|--------|
|
||||
| screen_layouts_v2 | layout_data JSONB + screen_id + company_code + layer_id | modalConfig를 컴포넌트 overrides에 포함하면 됨 |
|
||||
| screen_layouts_pop | 동일 구조 (POP 전용) | 모달 화면도 별도 screen_id로 저장 가능 |
|
||||
|
||||
- layout_data가 JSONB 타입이므로 어떤 JSON 구조든 저장 가능
|
||||
- 모달 화면을 별도 screen_id로 만들어도 기존 UNIQUE(screen_id, company_code, layer_id) 제약조건과 충돌 없음
|
||||
- DB 마이그레이션 불필요
|
||||
|
||||
### 백엔드 API (변경 불필요)
|
||||
|
||||
| API | 엔드포인트 | 호환성 |
|
||||
|-----|-----------|--------|
|
||||
| POP 레이아웃 저장 | POST /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 저장 |
|
||||
| POP 레이아웃 조회 | GET /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 조회 |
|
||||
| 연결 모달 감지 | detectLinkedModals(screenId) | 화면 간 참조 관계 추적에 활용 |
|
||||
|
||||
### 프론트엔드 (참고 패턴 존재)
|
||||
|
||||
| 기존 기능 | 위치 | 활용 방안 |
|
||||
|-----------|------|-----------|
|
||||
| TabsWidget screenId 참조 | frontend/components/screen/widgets/TabsWidget.tsx | 같은 패턴으로 모달에서 외부 화면 로드 |
|
||||
| TabsConfigPanel | frontend/components/screen/config-panels/TabsConfigPanel.tsx | pop-lookup 설정 패널의 모달 화면 선택 UI 참조 |
|
||||
| ScreenDesigner 탭 내부 컴포넌트 | frontend/components/screen/ScreenDesigner.tsx | 모달 내부 컴포넌트 편집 패턴 참조 |
|
||||
|
||||
### 결론
|
||||
|
||||
- DB 마이그레이션: 불필요
|
||||
- 백엔드 변경: 불필요
|
||||
- 프론트엔드: pop-lookup 컴포넌트 구현 시 기존 TabsWidget의 screenId 참조 패턴을 그대로 차용
|
||||
- 새로운 API: 불필요 (기존 saveLayoutPop/getLayoutPop로 충분)
|
||||
|
||||
## 참고 파일
|
||||
|
||||
- 레지스트리: `frontend/lib/registry/PopComponentRegistry.ts`
|
||||
- 기존 텍스트 컴포넌트: `frontend/lib/registry/pop-components/pop-text.tsx`
|
||||
- 공통 스타일 타입: `frontend/lib/registry/pop-components/types.ts`
|
||||
- POP 타입 정의: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||
- 기존 스펙 (v4): `popdocs/components-spec.md`
|
||||
- 탭 위젯 (모달 참조 패턴): `frontend/components/screen/widgets/TabsWidget.tsx`
|
||||
- POP 레이아웃 API: `frontend/lib/api/screen.ts` (saveLayoutPop, getLayoutPop)
|
||||
- 백엔드 화면관리: `backend-node/src/controllers/screenManagementController.ts`
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
# PLM 윈도우 개발환경 가이드
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 1. 전체 환경 시작
|
||||
```cmd
|
||||
start-windows.bat
|
||||
```
|
||||
|
||||
### 2. 환경 중지
|
||||
```cmd
|
||||
stop-windows.bat
|
||||
```
|
||||
|
||||
### 3. 빌드만 실행
|
||||
```cmd
|
||||
build-windows.bat
|
||||
```
|
||||
|
||||
## 📋 사전 요구사항
|
||||
|
||||
### 필수 소프트웨어
|
||||
- **Docker Desktop for Windows** (WSL2 백엔드 사용)
|
||||
- **Java Development Kit (JDK) 7 이상**
|
||||
- **Git for Windows**
|
||||
|
||||
### Docker Desktop 설정
|
||||
1. Docker Desktop 설치
|
||||
2. **Settings > General**에서 "Use WSL 2 based engine" 체크
|
||||
3. **Settings > Resources > WSL Integration**에서 WSL 배포판 활성화
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
```
|
||||
new_ph/
|
||||
├── start-windows.bat # 🎯 메인 시작 스크립트
|
||||
├── stop-windows.bat # ⏹️ 중지 스크립트
|
||||
├── build-windows.bat # 🔨 Java 빌드 스크립트
|
||||
├── docker-compose.win.yml # 🐳 윈도우용 Docker Compose
|
||||
├── Dockerfile.win # 🐳 윈도우용 Dockerfile
|
||||
├── config.windows.env # ⚙️ 환경 변수 설정
|
||||
└── README-WINDOWS.md # 📖 이 파일
|
||||
```
|
||||
|
||||
## ⚙️ 환경 설정
|
||||
|
||||
### config.windows.env 파일 수정
|
||||
```env
|
||||
# 데이터베이스 설정
|
||||
DB_PASSWORD=your_password_here
|
||||
|
||||
# 포트 설정 (충돌 시 변경)
|
||||
TOMCAT_PORT=9090
|
||||
|
||||
# 메모리 설정
|
||||
TOMCAT_MEMORY_MIN=512m
|
||||
TOMCAT_MEMORY_MAX=1024m
|
||||
```
|
||||
|
||||
## 🐳 Docker 서비스
|
||||
|
||||
### 애플리케이션 서비스
|
||||
- **컨테이너명**: plm-windows
|
||||
- **포트**: 9090 → 8080
|
||||
- **접속 URL**: http://localhost:9090
|
||||
|
||||
### 데이터베이스 서비스
|
||||
- **컨테이너명**: plm-postgres-win
|
||||
- **포트**: 5432 → 5432
|
||||
- **데이터베이스**: plm
|
||||
- **사용자**: postgres
|
||||
- **패스워드**: ph0909!!
|
||||
|
||||
## 🔧 주요 명령어
|
||||
|
||||
### Docker 관리
|
||||
```cmd
|
||||
# 컨테이너 상태 확인
|
||||
docker-compose -f docker-compose.win.yml ps
|
||||
|
||||
# 로그 확인
|
||||
docker-compose -f docker-compose.win.yml logs -f
|
||||
|
||||
# 특정 서비스 로그
|
||||
docker-compose -f docker-compose.win.yml logs -f plm-app
|
||||
docker-compose -f docker-compose.win.yml logs -f plm-db
|
||||
|
||||
# 컨테이너 재시작
|
||||
docker-compose -f docker-compose.win.yml restart plm-app
|
||||
```
|
||||
|
||||
### 개발 작업
|
||||
```cmd
|
||||
# 빌드만 실행
|
||||
build-windows.bat
|
||||
|
||||
# 컨테이너 재빌드
|
||||
docker-compose -f docker-compose.win.yml up --build -d
|
||||
|
||||
# 데이터베이스 리셋
|
||||
docker-compose -f docker-compose.win.yml down -v
|
||||
docker-compose -f docker-compose.win.yml up -d
|
||||
```
|
||||
|
||||
## 🐛 문제 해결
|
||||
|
||||
### Docker Desktop 실행 안됨
|
||||
```cmd
|
||||
# Windows 서비스 확인
|
||||
sc query com.docker.service
|
||||
|
||||
# WSL2 상태 확인
|
||||
wsl --status
|
||||
|
||||
# Docker Desktop 재시작
|
||||
taskkill /f /im "Docker Desktop.exe"
|
||||
start "" "C:\Program Files\Docker\Docker\Docker Desktop.exe"
|
||||
```
|
||||
|
||||
### Java 컴파일 오류
|
||||
```cmd
|
||||
# Java 버전 확인
|
||||
java -version
|
||||
javac -version
|
||||
|
||||
# 클래스패스 문제 시 수동 빌드
|
||||
javac -cp "WebContent\WEB-INF\lib\*" -d WebContent\WEB-INF\classes src\com\pms\**\*.java
|
||||
```
|
||||
|
||||
### 포트 충돌
|
||||
```cmd
|
||||
# 포트 사용 확인
|
||||
netstat -ano | findstr :9090
|
||||
|
||||
# 프로세스 종료
|
||||
taskkill /PID <PID번호> /F
|
||||
```
|
||||
|
||||
### 볼륨 권한 문제
|
||||
```cmd
|
||||
# Docker 볼륨 정리
|
||||
docker volume prune -f
|
||||
|
||||
# WSL2 재시작
|
||||
wsl --shutdown
|
||||
```
|
||||
|
||||
## 📊 모니터링
|
||||
|
||||
### 리소스 사용량
|
||||
```cmd
|
||||
# Docker 시스템 정보
|
||||
docker system df
|
||||
|
||||
# 컨테이너 리소스 사용량
|
||||
docker stats
|
||||
|
||||
# 로그 크기 확인
|
||||
dir logs /s
|
||||
```
|
||||
|
||||
### 헬스체크
|
||||
```cmd
|
||||
# 애플리케이션 상태
|
||||
curl http://localhost:9090
|
||||
|
||||
# 데이터베이스 연결 테스트
|
||||
docker exec plm-postgres-win psql -U postgres -d plm -c "SELECT version();"
|
||||
```
|
||||
|
||||
## 🔄 업데이트
|
||||
|
||||
### 코드 변경 후
|
||||
1. `build-windows.bat` 실행
|
||||
2. `docker-compose -f docker-compose.win.yml restart plm-app`
|
||||
|
||||
### Docker 이미지 업데이트
|
||||
```cmd
|
||||
docker-compose -f docker-compose.win.yml down
|
||||
docker-compose -f docker-compose.win.yml pull
|
||||
docker-compose -f docker-compose.win.yml up --build -d
|
||||
```
|
||||
|
||||
## 📞 지원
|
||||
|
||||
문제가 발생하면 다음을 확인하세요:
|
||||
|
||||
1. **로그 파일**: `logs/` 디렉토리
|
||||
2. **Docker 로그**: `docker-compose -f docker-compose.win.yml logs`
|
||||
3. **시스템 요구사항**: Docker Desktop, WSL2, Java JDK
|
||||
4. **네트워크**: 방화벽, 포트 충돌
|
||||
|
||||
---
|
||||
|
||||
**🎉 즐거운 개발 되세요!**
|
||||
523
README.md
|
|
@ -1,175 +1,472 @@
|
|||
# WACE 솔루션 (ERP/PLM)
|
||||
# WACE 솔루션 (PLM)
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
본 프로젝트는 WACE ERP/PLM(Product Lifecycle Management) 솔루션입니다.
|
||||
Node.js + Next.js 기반 풀스택 웹 애플리케이션으로, 멀티테넌시를 지원합니다.
|
||||
본 프로젝트는 제품 수명 주기 관리(PLM - Product Lifecycle Management) 솔루션입니다.
|
||||
**기존 JSP 기반 프론트엔드를 Next.js 14로 완전 전환**하여 현대적이고 사용자 친화적인 웹 애플리케이션을 제공합니다.
|
||||
|
||||
## 주요 특징
|
||||
## 🚀 주요 특징
|
||||
|
||||
- **모던 프론트엔드**: Next.js (App Router) + TypeScript + shadcn/ui
|
||||
- **Node.js 백엔드**: Express + TypeScript + PostgreSQL
|
||||
- **모던 프론트엔드**: Next.js 14 + TypeScript + shadcn/ui
|
||||
- **반응형 디자인**: 데스크톱, 태블릿, 모바일 모든 기기 지원
|
||||
- **멀티테넌시**: 회사별 데이터 격리 (company_code 기반)
|
||||
- **안정적인 백엔드**: 검증된 Java Spring + MyBatis 기반 API
|
||||
- **Docker 기반 배포**: 개발/운영 환경 일관성 보장
|
||||
- **타입 안전성**: TypeScript로 런타임 에러 방지
|
||||
|
||||
## 기술 스택
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
### Frontend
|
||||
### Frontend (Next.js 14)
|
||||
|
||||
- **프레임워크**: Next.js (App Router, Turbopack)
|
||||
- **프레임워크**: Next.js 14 (App Router)
|
||||
- **언어**: TypeScript
|
||||
- **UI 라이브러리**: shadcn/ui + Radix UI
|
||||
- **스타일링**: Tailwind CSS
|
||||
- **상태 관리**: TanStack Query + React Context
|
||||
- **폼 처리**: React Hook Form + Zod
|
||||
- **상태 관리**: React Context + Hooks
|
||||
- **아이콘**: Lucide React
|
||||
|
||||
### Backend
|
||||
### Backend (기존 유지)
|
||||
|
||||
- **런타임**: Node.js 20+
|
||||
- **프레임워크**: Express 4
|
||||
- **언어**: TypeScript
|
||||
- **데이터베이스**: PostgreSQL (pg 드라이버)
|
||||
- **인증**: JWT (jsonwebtoken) + bcryptjs
|
||||
- **로깅**: Winston
|
||||
- **언어**: Java 7
|
||||
- **프레임워크**: Spring Framework 3.2.4
|
||||
- **ORM**: MyBatis 3.2.3
|
||||
- **데이터베이스**: PostgreSQL
|
||||
- **WAS**: Apache Tomcat 7.0
|
||||
|
||||
### 개발 도구
|
||||
|
||||
- **컨테이너화**: Docker + Docker Compose
|
||||
- **코드 품질**: ESLint + Prettier
|
||||
- **테스트**: Jest + Supertest
|
||||
- **백엔드 핫리로드**: nodemon
|
||||
- **CI/CD**: Jenkins
|
||||
- **코드 품질**: ESLint + TypeScript
|
||||
- **패키지 관리**: npm
|
||||
|
||||
## 프로젝트 구조
|
||||
## 📁 프로젝트 구조
|
||||
|
||||
```
|
||||
ERP-node/
|
||||
├── backend-node/ # Express + TypeScript 백엔드
|
||||
│ ├── src/
|
||||
│ │ ├── app.ts # 엔트리포인트
|
||||
│ │ ├── controllers/ # API 컨트롤러
|
||||
│ │ ├── services/ # 비즈니스 로직
|
||||
│ │ ├── middleware/ # 인증, 에러처리 미들웨어
|
||||
│ │ ├── routes/ # 라우터
|
||||
│ │ └── config/ # DB 연결 등 설정
|
||||
│ └── package.json
|
||||
new_ph/
|
||||
├── frontend/ # Next.js 프론트엔드
|
||||
│ ├── app/ # App Router 페이지
|
||||
│ ├── components/ # React 컴포넌트
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── (auth)/ # 인증 관련 페이지
|
||||
│ │ │ └── login/ # 로그인 페이지
|
||||
│ │ ├── dashboard/ # 대시보드
|
||||
│ │ └── layout.tsx # 루트 레이아웃
|
||||
│ ├── components/ # 재사용 컴포넌트
|
||||
│ │ ├── ui/ # shadcn/ui 기본 컴포넌트
|
||||
│ │ ├── admin/ # 관리자 컴포넌트
|
||||
│ │ ├── screen/ # 화면 디자이너
|
||||
│ │ └── v2/ # V2 컴포넌트
|
||||
│ ├── lib/ # 유틸리티, API 클라이언트
|
||||
│ ├── hooks/ # Custom React Hooks
|
||||
│ └── package.json
|
||||
├── db/ # 데이터베이스
|
||||
│ └── migrations/ # 순차 마이그레이션 SQL
|
||||
├── docker/ # Docker 설정 (dev/prod/deploy)
|
||||
├── scripts/ # 개발/배포 스크립트
|
||||
├── docs/ # 프로젝트 문서
|
||||
├── Dockerfile # 프로덕션 멀티스테이지 빌드
|
||||
├── Jenkinsfile # CI/CD 파이프라인
|
||||
└── .cursorrules # AI 개발 가이드
|
||||
│ │ └── layout/ # 레이아웃 컴포넌트
|
||||
│ ├── lib/ # 유틸리티 함수
|
||||
│ └── types/ # TypeScript 타입 정의
|
||||
├── src/ # Java 백엔드 소스
|
||||
│ └── com/pms/ # 패키지 구조
|
||||
├── WebContent/ # 레거시 JSP (사용 중단)
|
||||
├── db/ # 데이터베이스 스크립트
|
||||
└── docker-compose.win.yml # Windows 환경 설정
|
||||
```
|
||||
|
||||
## 빠른 시작
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 1. 필수 요구사항
|
||||
|
||||
- **Node.js**: 20.10+
|
||||
- **PostgreSQL**: 데이터베이스 서버
|
||||
- **npm**: 10.0+
|
||||
- **Docker Desktop** (Windows/Mac) 또는 **Docker + Docker Compose** (Linux)
|
||||
- **Git** (소스 코드 관리)
|
||||
|
||||
### 2. 개발 환경 실행
|
||||
### 2. Windows 환경에서 실행
|
||||
|
||||
#### 자동 실행 (권장)
|
||||
|
||||
```bash
|
||||
# 백엔드 (nodemon으로 자동 재시작)
|
||||
cd backend-node && npm install && npm run dev
|
||||
# 프로젝트 시작
|
||||
./run-windows.bat
|
||||
|
||||
# 프론트엔드 (Turbopack)
|
||||
cd frontend && npm install && npm run dev
|
||||
# 서비스 상태 확인
|
||||
./status-windows.bat
|
||||
|
||||
# 서비스 중지
|
||||
./stop-windows.bat
|
||||
```
|
||||
|
||||
### 3. Docker 환경 실행
|
||||
#### 수동 실행
|
||||
|
||||
```bash
|
||||
# 백엔드 + 프론트엔드 (개발)
|
||||
docker-compose -f docker-compose.backend.win.yml up -d
|
||||
docker-compose -f docker-compose.frontend.win.yml up -d
|
||||
# Docker 컨테이너 실행
|
||||
docker-compose -f docker-compose.win.yml up --build -d
|
||||
|
||||
# 프로덕션 배포
|
||||
docker-compose -f docker/deploy/docker-compose.yml up -d
|
||||
# 로그 확인
|
||||
docker-compose -f docker-compose.win.yml logs -f
|
||||
```
|
||||
|
||||
### 4. 서비스 접속
|
||||
### 3. 서비스 접속
|
||||
|
||||
| 서비스 | URL | 설명 |
|
||||
| -------------- | --------------------- | ------------------------------ |
|
||||
| **프론트엔드** | http://localhost:9771 | Next.js 사용자 인터페이스 |
|
||||
| **백엔드 API** | http://localhost:8080 | Express REST API |
|
||||
| **프론트엔드** | http://localhost:9771 | Next.js 기반 사용자 인터페이스 |
|
||||
| **백엔드 API** | http://localhost:9090 | Spring 기반 REST API |
|
||||
|
||||
## 주요 기능
|
||||
> **주의**: 기존 JSP 화면(`localhost:9090`)은 더 이상 사용하지 않습니다.
|
||||
> 모든 사용자는 **Next.js 프론트엔드(`localhost:9771`)**를 사용해주세요.
|
||||
|
||||
### 1. 사용자 및 권한 관리
|
||||
- 사용자 계정 관리 (CRUD)
|
||||
- 역할 기반 접근 제어 (RBAC)
|
||||
- 부서/조직 관리
|
||||
- 멀티테넌시 (회사별 데이터 격리)
|
||||
## 🎨 UI/UX 디자인 시스템
|
||||
|
||||
### 2. 메뉴 및 화면 관리
|
||||
- 동적 메뉴 구성
|
||||
- 화면 디자이너 (드래그앤드롭)
|
||||
- V2 컴포넌트 시스템
|
||||
### shadcn/ui 컴포넌트 라이브러리
|
||||
|
||||
### 3. 플로우(워크플로우) 관리
|
||||
- 비즈니스 프로세스 정의
|
||||
- 데이터 흐름 관리
|
||||
- 감사 로그
|
||||
- **일관된 디자인**: 전체 애플리케이션에서 통일된 UI 컴포넌트
|
||||
- **접근성**: WCAG 가이드라인 준수
|
||||
- **커스터마이징**: Tailwind CSS로 쉬운 스타일 변경
|
||||
- **다크모드**: 자동 테마 전환 지원
|
||||
|
||||
### 4. 제품/BOM 관리
|
||||
- BOM 구성 및 버전 관리
|
||||
- 제품 정보 관리
|
||||
### 공통 스타일 가이드
|
||||
|
||||
### 5. 기타
|
||||
- 파일/문서 관리
|
||||
- 메일 연동
|
||||
- 외부 DB 연결
|
||||
- 번호 채번 규칙
|
||||
```typescript
|
||||
// 색상 팔레트
|
||||
const colors = {
|
||||
primary: "hsl(222.2 47.4% 11.2%)", // 네이비 블루
|
||||
secondary: "hsl(210 40% 96%)", // 연한 그레이
|
||||
accent: "hsl(210 40% 98%)", // 거의 화이트
|
||||
destructive: "hsl(0 62.8% 30.6%)", // 레드
|
||||
muted: "hsl(210 40% 96%)", // 음소거된 그레이
|
||||
};
|
||||
|
||||
## 환경 변수
|
||||
|
||||
```bash
|
||||
# backend-node/.env
|
||||
DATABASE_URL=postgresql://postgres:password@localhost:5432/dbname
|
||||
JWT_SECRET=your-jwt-secret
|
||||
JWT_EXPIRES_IN=24h
|
||||
PORT=8080
|
||||
CORS_ORIGIN=http://localhost:9771
|
||||
|
||||
# frontend/.env.local
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
||||
// 타이포그래피
|
||||
const typography = {
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
fontSize: {
|
||||
xs: "0.75rem", // 12px
|
||||
sm: "0.875rem", // 14px
|
||||
base: "1rem", // 16px
|
||||
lg: "1.125rem", // 18px
|
||||
xl: "1.25rem", // 20px
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 배포
|
||||
## 🔧 개발 가이드
|
||||
|
||||
### 프로덕션 빌드
|
||||
### 컴포넌트 개발 원칙
|
||||
|
||||
```bash
|
||||
# 멀티스테이지 Docker 빌드 (백엔드 + 프론트엔드)
|
||||
docker build -t wace-solution .
|
||||
#### 1. 재사용 가능한 컴포넌트
|
||||
|
||||
```typescript
|
||||
// components/ui/button.tsx
|
||||
interface ButtonProps {
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = "default",
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button className={cn(buttonVariants({ variant, size }))} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### CI/CD
|
||||
#### 2. 폼 컴포넌트
|
||||
|
||||
Jenkins 파이프라인 (`Jenkinsfile`)으로 자동 빌드 및 배포가 설정되어 있습니다.
|
||||
```typescript
|
||||
// React Hook Form + Zod 사용
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
|
||||
## 코드 컨벤션
|
||||
const loginSchema = z.object({
|
||||
userId: z.string().min(1, "사용자 ID를 입력해주세요"),
|
||||
password: z.string().min(1, "비밀번호를 입력해주세요"),
|
||||
});
|
||||
|
||||
export function LoginForm() {
|
||||
const form = useForm<z.infer<typeof loginSchema>>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
// 폼 처리 로직
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. API 연동
|
||||
|
||||
```typescript
|
||||
// lib/api.ts
|
||||
class ApiClient {
|
||||
private baseURL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:9090";
|
||||
|
||||
async login(credentials: LoginCredentials): Promise<LoginResponse> {
|
||||
const response = await fetch(`${this.baseURL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(credentials),
|
||||
credentials: "include", // 세션 쿠키 포함
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("로그인 실패");
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 스타일링 가이드
|
||||
|
||||
#### 1. Tailwind CSS 클래스
|
||||
|
||||
```typescript
|
||||
// 일반적인 레이아웃
|
||||
<div className="flex items-center justify-center min-h-screen bg-slate-50">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
{/* 컨텐츠 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 카드 컴포넌트
|
||||
<div className="bg-white rounded-lg shadow-lg border border-slate-200 p-6">
|
||||
{/* 카드 내용 */}
|
||||
</div>
|
||||
|
||||
// 반응형 그리드
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 그리드 아이템 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 2. CSS 변수 활용
|
||||
|
||||
```css
|
||||
/* globals.css */
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
}
|
||||
```
|
||||
|
||||
## 🔐 인증 시스템
|
||||
|
||||
### 세션 기반 인증 (기존 백엔드 호환)
|
||||
|
||||
```typescript
|
||||
// 로그인 프로세스
|
||||
1. 사용자가 로그인 폼 제출
|
||||
2. Next.js에서 백엔드 API 호출
|
||||
3. 백엔드에서 세션 생성 (기존 로직 사용)
|
||||
4. 프론트엔드에서 인증 상태 관리
|
||||
5. 보호된 라우트 접근 제어
|
||||
```
|
||||
|
||||
### 라우트 보호
|
||||
|
||||
```typescript
|
||||
// middleware.ts
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// 공개 페이지
|
||||
const publicPaths = ["/login", "/"];
|
||||
if (publicPaths.includes(pathname)) return NextResponse.next();
|
||||
|
||||
// 인증 확인
|
||||
const sessionCookie = request.cookies.get("JSESSIONID");
|
||||
if (!sessionCookie) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 주요 기능
|
||||
|
||||
### 1. 대시보드
|
||||
|
||||
- **프로젝트 현황**: 진행 중인 프로젝트 상태 모니터링
|
||||
- **작업 요약**: 개인별 할당된 작업 목록
|
||||
- **알림 센터**: 중요한 업데이트 및 알림
|
||||
- **차트 및 그래프**: 프로젝트 진척도 시각화
|
||||
|
||||
### 2. 프로젝트 관리
|
||||
|
||||
- **프로젝트 생성/수정**: 새 프로젝트 등록 및 정보 관리
|
||||
- **팀 구성**: 프로젝트 멤버 할당 및 역할 관리
|
||||
- **마일스톤**: 주요 일정 및 목표 설정
|
||||
- **진행 상황 추적**: 실시간 프로젝트 진척도 모니터링
|
||||
|
||||
### 3. 제품 관리
|
||||
|
||||
- **제품 카탈로그**: 제품 정보 및 사양 관리
|
||||
- **BOM 관리**: Bill of Materials 구성 및 버전 관리
|
||||
- **설계 변경**: ECO/ECR 프로세스 관리
|
||||
- **문서 관리**: 기술 문서 및 도면 버전 제어
|
||||
|
||||
### 4. 사용자 관리
|
||||
|
||||
- **사용자 계정**: 계정 생성/수정/비활성화
|
||||
- **권한 관리**: 역할 기반 접근 제어 (RBAC)
|
||||
- **부서 관리**: 조직 구조 및 부서별 권한 설정
|
||||
- **감사 로그**: 사용자 활동 추적 및 보안 모니터링
|
||||
|
||||
## 🚢 배포 가이드
|
||||
|
||||
### 개발 환경
|
||||
|
||||
```bash
|
||||
# 프론트엔드 개발 서버
|
||||
cd frontend && npm run dev
|
||||
|
||||
# 백엔드 (Docker)
|
||||
docker-compose -f docker-compose.win.yml up -d plm-app
|
||||
```
|
||||
|
||||
### 운영 환경
|
||||
|
||||
```bash
|
||||
# 전체 서비스 배포
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 무중단 배포 (blue-green)
|
||||
./deploy-production.sh
|
||||
```
|
||||
|
||||
### 환경 변수 설정
|
||||
|
||||
```bash
|
||||
# .env.local (Next.js)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:9090
|
||||
NEXT_PUBLIC_APP_ENV=development
|
||||
|
||||
# docker-compose.win.yml (백엔드)
|
||||
DB_URL=jdbc:postgresql://db:5432/plm
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=secure_password
|
||||
```
|
||||
|
||||
## 🔧 문제 해결
|
||||
|
||||
### 자주 발생하는 문제
|
||||
|
||||
#### 1. 로그인 화면이 업데이트되지 않는 경우
|
||||
|
||||
```bash
|
||||
# 브라우저 캐시 클리어 후 다음 확인:
|
||||
# - Next.js 서버 재시작
|
||||
npm run dev
|
||||
|
||||
# - 올바른 URL 접속 확인
|
||||
# 올바름: http://localhost:9771/login
|
||||
# 잘못됨: http://localhost:9090/login.jsp
|
||||
```
|
||||
|
||||
#### 2. Docker 컨테이너 실행 오류
|
||||
|
||||
```bash
|
||||
# 포트 충돌 확인
|
||||
netstat -ano | findstr :9771
|
||||
netstat -ano | findstr :9090
|
||||
|
||||
# Docker 시스템 정리
|
||||
docker system prune -a
|
||||
docker-compose -f docker-compose.win.yml down --volumes
|
||||
```
|
||||
|
||||
#### 3. API 연결 오류
|
||||
|
||||
```bash
|
||||
# 백엔드 컨테이너 로그 확인
|
||||
docker-compose -f docker-compose.win.yml logs plm-app
|
||||
|
||||
# 네트워크 연결 확인
|
||||
curl http://localhost:9090/api/health
|
||||
```
|
||||
|
||||
### 개발자 도구
|
||||
|
||||
#### 브라우저 개발자 도구
|
||||
|
||||
- **Console**: JavaScript 오류 확인
|
||||
- **Network**: API 요청/응답 모니터링
|
||||
- **Application**: 세션 쿠키 확인
|
||||
|
||||
#### 로그 확인
|
||||
|
||||
```bash
|
||||
# Next.js 개발 서버 로그
|
||||
npm run dev
|
||||
|
||||
# 백엔드 애플리케이션 로그
|
||||
docker-compose -f docker-compose.win.yml logs -f plm-app
|
||||
|
||||
# 데이터베이스 로그
|
||||
docker-compose -f docker-compose.win.yml logs -f db
|
||||
```
|
||||
|
||||
## 📈 성능 최적화
|
||||
|
||||
### Next.js 최적화
|
||||
|
||||
- **이미지 최적화**: Next.js Image 컴포넌트 사용
|
||||
- **코드 분할**: 동적 임포트로 번들 크기 최소화
|
||||
- **서버 사이드 렌더링**: 초기 로딩 속도 개선
|
||||
- **정적 생성**: 변경되지 않는 페이지 사전 생성
|
||||
|
||||
### 백엔드 최적화
|
||||
|
||||
- **데이터베이스 인덱스**: 자주 조회되는 필드 인덱싱
|
||||
- **쿼리 최적화**: N+1 문제 해결
|
||||
- **캐싱**: Redis를 통한 세션 및 데이터 캐싱
|
||||
- **리소스 최적화**: JVM 메모리 튜닝
|
||||
|
||||
## 🤝 기여 가이드
|
||||
|
||||
### 코드 컨벤션
|
||||
|
||||
- **TypeScript**: 엄격한 타입 정의 사용
|
||||
- **ESLint + Prettier**: 일관된 코드 스타일
|
||||
- **shadcn/ui**: UI 컴포넌트 표준
|
||||
- **API 클라이언트**: `frontend/lib/api/` 전용 클라이언트 사용 (fetch 직접 사용 금지)
|
||||
- **멀티테넌시**: 모든 쿼리에 company_code 필터링 필수
|
||||
- **ESLint**: 코드 품질 유지.
|
||||
- **Prettier**: 일관된 코드 포맷팅
|
||||
- **커밋 메시지**: Conventional Commits 규칙 준수
|
||||
|
||||
### 브랜치 전략
|
||||
|
||||
```bash
|
||||
main # 운영 환경 배포 브랜치
|
||||
develop # 개발 환경 통합 브랜치
|
||||
feature/* # 기능 개발 브랜치
|
||||
hotfix/* # 긴급 수정 브랜치
|
||||
```
|
||||
|
||||
### Pull Request 프로세스
|
||||
|
||||
1. 기능 브랜치에서 개발
|
||||
2. 테스트 코드 작성
|
||||
3. PR 생성 및 코드 리뷰
|
||||
4. 승인 후 develop 브랜치에 병합
|
||||
|
||||
## 📞 지원 및 문의
|
||||
|
||||
- **개발팀 문의**: 내부 Slack 채널 `#plm-support`
|
||||
- **버그 리포트**: GitHub Issues
|
||||
- **기능 요청**: Product Owner와 협의
|
||||
- **긴급 상황**: 개발팀 직접 연락
|
||||
|
||||
---
|
||||
|
||||
## 📝 변경 이력
|
||||
|
||||
### v2.0.0 (2025년 1월)
|
||||
|
||||
- ✅ JSP → Next.js 14 완전 전환
|
||||
- ✅ shadcn/ui 디자인 시스템 도입
|
||||
- ✅ TypeScript 타입 안전성 강화
|
||||
- ✅ 반응형 디자인 적용
|
||||
- ✅ WACE 브랜딩 적용
|
||||
|
||||
### v1.x (레거시)
|
||||
|
||||
- ❌ JSP + jQuery 기반 (사용 중단)
|
||||
- ❌ 데스크톱 전용 UI
|
||||
- ❌ 제한적인 확장성
|
||||
|
||||
**🎉 현재 버전 2.0.0에서는 완전히 새로운 사용자 경험을 제공합니다!**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
<Context docBase="ilshin" path="/" reloadable="true" source="org.eclipse.jst.jee.server:ilshin">
|
||||
<Resource auth="Container" driverClassName="org.postgresql.Driver" maxActive="100" maxIdle="10" maxWait="-1" name="plm" password="admin0909!!" type="javax.sql.DataSource" url="jdbc:postgresql://211.224.136.4:5432/ilshin" username="postgres"/>
|
||||
</Context>
|
||||
46
STATUS.md
|
|
@ -1,46 +0,0 @@
|
|||
# 프로젝트 상태 추적
|
||||
|
||||
> **최종 업데이트**: 2026-02-11
|
||||
|
||||
---
|
||||
|
||||
## 현재 진행 중
|
||||
|
||||
### pop-dashboard 스타일 정리
|
||||
**상태**: 코딩 완료, 브라우저 확인 대기
|
||||
**계획서**: [popdocs/PLAN.md](./popdocs/PLAN.md)
|
||||
**내용**: 글자 크기 커스텀 제거 + 라벨 정렬만 유지 + stale closure 수정
|
||||
|
||||
---
|
||||
|
||||
## 다음 작업
|
||||
|
||||
| 순서 | 작업 | 상태 |
|
||||
|------|------|------|
|
||||
| 1 | pop-card-list 입력 필드/계산 필드 구조 개편 (PLAN.MD 참고) | [ ] 코딩 대기 |
|
||||
| 2 | pop-card-list 담기 버튼 독립화 (보류) | [ ] 대기 |
|
||||
| 3 | pop-card-list 반응형 표시 런타임 적용 | [ ] 대기 |
|
||||
|
||||
---
|
||||
|
||||
## 완료된 작업 (최근)
|
||||
|
||||
| 날짜 | 작업 | 비고 |
|
||||
|------|------|------|
|
||||
| 2026-02-11 | 대시보드 스타일 정리 | FONT_SIZE_PX/글자 크기 Select 삭제, ItemStyleConfig -> labelAlign만, stale closure 수정 |
|
||||
| 2026-02-10 | 디자이너 캔버스 UX 개선 | 헤더 제거, 실제 데이터 렌더링, 컴포넌트 목록 |
|
||||
| 2026-02-10 | 차트/게이지/네비게이션/정렬 디자인 개선 | CartesianGrid, abbreviateNumber, 오버레이 화살표/인디케이터 |
|
||||
| 2026-02-10 | 대시보드 4가지 아이템 모드 완성 | groupBy UI, xAxisColumn, 통계카드 카테고리, 필터 버그 수정 |
|
||||
| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 |
|
||||
| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent |
|
||||
| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 |
|
||||
|
||||
---
|
||||
|
||||
## 알려진 이슈
|
||||
|
||||
| # | 이슈 | 심각도 | 상태 |
|
||||
|---|------|--------|------|
|
||||
| 1 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 |
|
||||
| 2 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 |
|
||||
| 3 | 기존 저장 데이터의 `itemStyle.align`이 `labelAlign`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 |
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
# 🎨 제어관리 - 데이터 연결 설정 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부터 순차적으로 구현**을 시작하겠습니다.
|
||||
|
||||
**첫 번째 작업**: 좌우 분할 레이아웃과 연결 타입 선택 컴포넌트 구현
|
||||
|
||||
구현을 시작하시겠어요? 🚀
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
# 작업 이력 관리 시스템 설치 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
작업 이력 관리 시스템이 추가되었습니다. 입고/출고/이송/정비 작업을 관리하고 통계를 확인할 수 있습니다.
|
||||
|
||||
## 🚀 설치 방법
|
||||
|
||||
### 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` - 테이블 생성 스크립트
|
||||
|
||||
## 🎉 완료!
|
||||
|
||||
작업 이력 관리 시스템이 성공적으로 설치되었습니다!
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Manifest-Version: 1.0
|
||||
Class-Path:
|
||||
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="ko" xml:lang="ko">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<title>네이버 :: Smart Editor 2 ™</title>
|
||||
<script type="text/javascript" src="./js/HuskyEZCreator.js" charset="utf-8"></script>
|
||||
</head>
|
||||
<body>
|
||||
<form action="sample.php" method="post">
|
||||
<textarea name="ir1" id="ir1" rows="10" cols="100" style="width:766px; height:412px; display:none;"></textarea>
|
||||
<!--textarea name="ir1" id="ir1" rows="10" cols="100" style="width:100%; height:412px; min-width:610px; display:none;"></textarea-->
|
||||
<p>
|
||||
<input type="button" onclick="pasteHTML();" value="본문에 내용 넣기" />
|
||||
<input type="button" onclick="showHTML();" value="본문 내용 가져오기" />
|
||||
<input type="button" onclick="submitContents(this);" value="서버로 내용 전송" />
|
||||
<input type="button" onclick="setDefaultFont();" value="기본 폰트 지정하기 (궁서_24)" />
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<script type="text/javascript">
|
||||
var oEditors = [];
|
||||
|
||||
// 추가 글꼴 목록
|
||||
//var aAdditionalFontSet = [["MS UI Gothic", "MS UI Gothic"], ["Comic Sans MS", "Comic Sans MS"],["TEST","TEST"]];
|
||||
|
||||
nhn.husky.EZCreator.createInIFrame({
|
||||
oAppRef: oEditors,
|
||||
elPlaceHolder: "ir1",
|
||||
sSkinURI: "SmartEditor2Skin.html",
|
||||
htParams : {
|
||||
bUseToolbar : true, // 툴바 사용 여부 (true:사용/ false:사용하지 않음)
|
||||
bUseVerticalResizer : true, // 입력창 크기 조절바 사용 여부 (true:사용/ false:사용하지 않음)
|
||||
bUseModeChanger : true, // 모드 탭(Editor | HTML | TEXT) 사용 여부 (true:사용/ false:사용하지 않음)
|
||||
//aAdditionalFontList : aAdditionalFontSet, // 추가 글꼴 목록
|
||||
fOnBeforeUnload : function(){
|
||||
//alert("완료!");
|
||||
}
|
||||
}, //boolean
|
||||
fOnAppLoad : function(){
|
||||
//예제 코드
|
||||
//oEditors.getById["ir1"].exec("PASTE_HTML", ["로딩이 완료된 후에 본문에 삽입되는 text입니다."]);
|
||||
},
|
||||
fCreator: "createSEditor2"
|
||||
});
|
||||
|
||||
function pasteHTML() {
|
||||
var sHTML = "<span style='color:#FF0000;'>이미지도 같은 방식으로 삽입합니다.<\/span>";
|
||||
oEditors.getById["ir1"].exec("PASTE_HTML", [sHTML]);
|
||||
}
|
||||
|
||||
function showHTML() {
|
||||
var sHTML = oEditors.getById["ir1"].getIR();
|
||||
alert(sHTML);
|
||||
}
|
||||
|
||||
function submitContents(elClickedObj) {
|
||||
oEditors.getById["ir1"].exec("UPDATE_CONTENTS_FIELD", []); // 에디터의 내용이 textarea에 적용됩니다.
|
||||
|
||||
// 에디터의 내용에 대한 값 검증은 이곳에서 document.getElementById("ir1").value를 이용해서 처리하면 됩니다.
|
||||
|
||||
try {
|
||||
elClickedObj.form.submit();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function setDefaultFont() {
|
||||
var sDefaultFont = '궁서';
|
||||
var nFontSize = 24;
|
||||
oEditors.getById["ir1"].setDefaultFont(sDefaultFont, nFontSize);
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
@charset "UTF-8";
|
||||
/* NHN Web Standardization Team (http://html.nhndesign.com/) HHJ 090226 */
|
||||
/* COMMON */
|
||||
body,#smart_editor2,#smart_editor2 p,#smart_editor2 h1,#smart_editor2 h2,#smart_editor2 h3,#smart_editor2 h4,#smart_editor2 h5,#smart_editor2 h6,#smart_editor2 ul,#smart_editor2 ol,#smart_editor2 li,#smart_editor2 dl,#smart_editor2 dt,#smart_editor2 dd,#smart_editor2 table,#smart_editor2 th,#smart_editor2 td,#smart_editor2 form,#smart_editor2 fieldset,#smart_editor2 legend,#smart_editor2 input,#smart_editor2 textarea,#smart_editor2 button,#smart_editor2 select{margin:0;padding:0}
|
||||
#smart_editor2,#smart_editor2 h1,#smart_editor2 h2,#smart_editor2 h3,#smart_editor2 h4,#smart_editor2 h5,#smart_editor2 h6,#smart_editor2 input,#smart_editor2 textarea,#smart_editor2 select,#smart_editor2 table,#smart_editor2 button{font-family:'돋움',Dotum,Helvetica,sans-serif;font-size:12px;color:#666}
|
||||
#smart_editor2 span,#smart_editor2 em{font-size:12px}
|
||||
#smart_editor2 em,#smart_editor2 address{font-style:normal}
|
||||
#smart_editor2 img,#smart_editor2 fieldset{border:0}
|
||||
#smart_editor2 hr{display:none}
|
||||
#smart_editor2 ol,#smart_editor2 ul{list-style:none}
|
||||
#smart_editor2 button{border:0;background:none;font-size:11px;vertical-align:top;cursor:pointer}
|
||||
#smart_editor2 button span,#smart_editor2 button em{visibility:hidden;overflow:hidden;position:absolute;top:0;font-size:0;line-height:0}
|
||||
#smart_editor2 legend,#smart_editor2 .blind{visibility:hidden;overflow:hidden;position:absolute;width:0;height:0;font-size:0;line-height:0}
|
||||
#smart_editor2 .input_ty1{height:14px;margin:0;padding:4px 2px 0 4px;border:1px solid #c7c7c7;font-size:11px;color:#666}
|
||||
#smart_editor2 a:link,#smart_editor2 a:visited,#smart_editor2 a:active,#smart_editor2 a:focus{color:#666;text-decoration:none}
|
||||
#smart_editor2 a:hover{color:#666;text-decoration:underline}
|
||||
/* LAYOUT */
|
||||
#smart_editor2 .se2_header{margin:10px 0 29px 0}
|
||||
#smart_editor2 .se2_bi{float:left;width:93px;height:20px;margin:0;padding:0;background:url("../img/ko_KR/btn_set.png?130306") -343px -358px no-repeat;font-size:0;line-height:0;text-indent:-10000px;vertical-align:middle}
|
||||
#smart_editor2 .se2_allhelp{display:inline-block;width:18px;height:18px;padding:0;background:url("../img/ko_KR/btn_set.png?130306") -437px -358px no-repeat;font-size:0;line-height:0;text-indent:-10000px;vertical-align:middle}
|
||||
#smart_editor2 #smart_editor2_content{border:1px solid #b5b5b5}
|
||||
#smart_editor2 .se2_tool{overflow:visible;position:relative;z-index:25}
|
||||
/* EDITINGAREA */
|
||||
#smart_editor2 .se2_input_area{position:relative;z-index:22;height:400px;margin:0;padding:0;*zoom:1}
|
||||
#smart_editor2 .se2_input_wysiwyg,#smart_editor2 .se2_input_syntax{display:block;overflow:auto;width:100%;height:100%;margin:0;*margin:-1px 0 0 0;border:0}
|
||||
/* EDITINGMODE */
|
||||
#smart_editor2 .se2_conversion_mode{position:relative;height:15px;padding-top:1px;border-top:1px solid #b5b5b5;background:url("../img/icon_set.gif") 0 -896px repeat-x}
|
||||
#smart_editor2 .se2_inputarea_controller{display:block;clear:both;position:relative;width:100%;height:15px;text-align:center;cursor:n-resize}
|
||||
#smart_editor2 .se2_inputarea_controller span,#smart_editor2 .controller_on span{background:url("../img/ico_extend.png") no-repeat}
|
||||
#smart_editor2 .se2_inputarea_controller span{position:static;display:inline-block;visibility:visible;overflow:hidden;height:15px;padding-left:11px;background-position:0 2px;color:#888;font-size:11px;letter-spacing:-1px;line-height:16px;white-space:nowrap}
|
||||
* + html #smart_editor2 .se2_inputarea_controller span{line-height:14px}
|
||||
#smart_editor2 .controller_on span{background-position:0 -21px;color:#249c04}
|
||||
#smart_editor2 .ly_controller{display:block;position:absolute;bottom:2px;left:50%;width:287px;margin-left:-148px;padding:8px 0 7px 9px;border:1px solid #827f7c;background:#fffdef}
|
||||
#smart_editor2 .ly_controller p{color:#666;font-size:11px;letter-spacing:-1px;line-height:11px}
|
||||
#smart_editor2 .ly_controller .bt_clse,#smart_editor2 .ly_controller .ic_arr{position:absolute;background:url("../img/ico_extend.png") no-repeat}
|
||||
#smart_editor2 .ly_controller .bt_clse{top:5px;right:4px;width:14px;height:15px;background-position:1px -43px}
|
||||
#smart_editor2 .ly_controller .ic_arr{top:25px;left:50%;width:10px;height:6px;margin-left:-5px;background-position:0 -65px}
|
||||
#smart_editor2 .se2_converter{float:left;position:absolute;top:-1px;right:3px;z-index:20}
|
||||
#smart_editor2 .se2_converter li{float:left}
|
||||
#smart_editor2 .se2_converter .se2_to_editor{width:59px;height:15px;background:url("../img/ko_KR/btn_set.png?130306") 0 -85px no-repeat;vertical-align:top}
|
||||
#smart_editor2 .se2_converter .se2_to_html{width:59px;height:15px;background:url("../img/ko_KR/btn_set.png?130306") -59px -70px no-repeat;vertical-align:top}
|
||||
#smart_editor2 .se2_converter .se2_to_text{width:60px;height:15px;background:url("../img/ko_KR/btn_set.png?130306") -417px -466px no-repeat;vertical-align:top}
|
||||
#smart_editor2 .se2_converter .active .se2_to_editor{width:59px;height:15px;background:url("../img/ko_KR/btn_set.png?130306") 0 -70px no-repeat;vertical-align:top}
|
||||
#smart_editor2 .se2_converter .active .se2_to_html{width:59px;height:15px;background:url("../img/ko_KR/btn_set.png?130306") -59px -85px no-repeat;vertical-align:top}
|
||||
#smart_editor2 .se2_converter .active .se2_to_text{width:60px;height:15px;background:url("../img/ko_KR/btn_set.png?130306") -417px -481px no-repeat;vertical-align:top}
|
||||
/* EDITINGAREA_HTMLSRC */
|
||||
#smart_editor2 .off .ico_btn,#smart_editor2 .off .se2_more,#smart_editor2 .off .se2_more2,#smart_editor2 .off .se2_font_family,#smart_editor2 .off .se2_font_size,#smart_editor2 .off .se2_bold,#smart_editor2 .off .se2_underline,#smart_editor2 .off .se2_italic,#smart_editor2 .off .se2_tdel,#smart_editor2 .off .se2_fcolor,#smart_editor2 .off .se2_fcolor_more,#smart_editor2 .off .se2_bgcolor,#smart_editor2 .off .se2_bgcolor_more,#smart_editor2 .off .se2_left,#smart_editor2 .off .se2_center,#smart_editor2 .off .se2_right,#smart_editor2 .off .se2_justify,#smart_editor2 .off .se2_ol,#smart_editor2 .off .se2_ul,#smart_editor2 .off .se2_indent,#smart_editor2 .off .se2_outdent,#smart_editor2 .off .se2_lineheight,#smart_editor2 .off .se2_del_style,#smart_editor2 .off .se2_blockquote,#smart_editor2 .off .se2_summary,#smart_editor2 .off .se2_footnote,#smart_editor2 .off .se2_url,#smart_editor2 .off .se2_emoticon,#smart_editor2 .off .se2_character,#smart_editor2 .off .se2_table,#smart_editor2 .off .se2_find,#smart_editor2 .off .se2_spelling,#smart_editor2 .off .se2_sup,#smart_editor2 .off .se2_sub,#smart_editor2 .off .se2_text_tool_more,#smart_editor2 .off .se2_new,#smart_editor2 .off .selected_color,#smart_editor2 .off .se2_lineSticker{-ms-filter:alpha(opacity=50);opacity:.5;cursor:default;filter:alpha(opacity=50)}
|
||||
/* LAYER */
|
||||
#smart_editor2 .se2_text_tool .se2_layer{display:none;float:left;position:absolute;top:20px;left:0;z-index:50;margin:0;padding:0;border:1px solid #bcbbbb;background:#fafafa}
|
||||
#smart_editor2 .se2_text_tool li.active{z-index:50}
|
||||
#smart_editor2 .se2_text_tool .active .se2_layer{display:block}
|
||||
#smart_editor2 .se2_text_tool .active li .se2_layer{display:none}
|
||||
#smart_editor2 .se2_text_tool .active .active .se2_layer{display:block}
|
||||
#smart_editor2 .se2_text_tool .se2_layer .se2_in_layer{float:left;margin:0;padding:0;border:1px solid #fff;background:#fafafa}
|
||||
/* TEXT_TOOLBAR */
|
||||
#smart_editor2 .se2_text_tool{position:relative;clear:both;z-index:30;padding:4px 0 4px 3px;background:#f4f4f4 url("../img/bg_text_tool.gif") 0 0 repeat-x;border-bottom:1px solid #b5b5b5;*zoom:1}
|
||||
#smart_editor2 .se2_text_tool:after{content:"";display:block;clear:both}
|
||||
#smart_editor2 .se2_text_tool ul{float:left;display:inline;margin-right:3px;padding-left:1px;white-space:nowrap}
|
||||
#smart_editor2 .se2_text_tool li{_display:inline;float:left;position:relative;z-index:30}
|
||||
#smart_editor2 .se2_text_tool button,#smart_editor2 .se2_multy .se2_icon{width:21px;height:21px;background:url("../img/ko_KR/text_tool_set.png?140317") no-repeat;vertical-align:top}
|
||||
#smart_editor2 .se2_text_tool .se2_font_type{position:relative}
|
||||
#smart_editor2 .se2_text_tool .se2_font_type li{margin-left:3px}
|
||||
#smart_editor2 .se2_text_tool .se2_font_type button{text-align:left}
|
||||
#smart_editor2 .se2_text_tool .se2_font_type button.se2_font_family span,#smart_editor2 .se2_text_tool .se2_font_type button.se2_font_size span{display:inline-block;visibility:visible;position:static;width:52px;height:20px;padding:0 0 0 6px;font-size:12px;line-height:20px;*line-height:22px;color:#333;*zoom:1}
|
||||
#smart_editor2 .se2_text_tool .se2_multy{position:absolute;top:0;right:0;padding-left:0;margin-right:0;white-space:nowrap;border-left:1px solid #e0dedf}
|
||||
#smart_editor2 .se2_text_tool .se2_multy .se2_mn{float:left;white-space:nowrap}
|
||||
#smart_editor2 .se2_text_tool .se2_multy button{background-image:none;width:47px}
|
||||
#smart_editor2 .se2_text_tool .se2_multy .se2_icon{display:inline-block;visibility:visible;overflow:visible;position:static;width:16px;height:29px;margin:-1px 2px 0 -1px;background-position:0 -132px;line-height:30px;vertical-align:top}
|
||||
#smart_editor2 .se2_text_tool .se2_multy button,#smart_editor2 .se2_text_tool .se2_multy button span{height:29px;line-height:29px}
|
||||
#smart_editor2 .se2_text_tool .se2_map .se2_icon{background-position:-29px -132px}
|
||||
#smart_editor2 .se2_text_tool button span.se2_mntxt{display:inline-block;visibility:visible;overflow:visible;_overflow-y:hidden;position:relative;*margin-right:-1px;width:auto;height:29px;font-weight:normal;font-size:11px;line-height:30px;*line-height:29px;_line-height:30px;color:#444;letter-spacing:-1px;vertical-align:top}
|
||||
#smart_editor2 .se2_text_tool .se2_multy .se2_photo{margin-right:1px}
|
||||
#smart_editor2 .se2_text_tool .se2_multy .hover .ico_btn{background:#e8e8e8}
|
||||
#smart_editor2 .se2_text_tool .se2_multy .se2_mn.hover{background:#e0dedf}
|
||||
/* TEXT_TOOLBAR : ROUNDING */
|
||||
#smart_editor2 ul li.first_child button span.tool_bg,#smart_editor2 ul li.last_child button span.tool_bg,#smart_editor2 ul li.single_child button span.tool_bg{visibility:visible;height:21px}
|
||||
#smart_editor2 ul li.first_child button span.tool_bg{left:-1px;width:3px;background:url("../img/bg_button_left.gif?20121228") no-repeat}
|
||||
#smart_editor2 ul li.last_child button span.tool_bg{right:0px;_right:-1px;width:2px;background:url("../img/bg_button_right.gif") no-repeat}
|
||||
#smart_editor2 ul li.single_child{padding-right:1px}
|
||||
#smart_editor2 ul li.single_child button span.tool_bg{left:0;background:url("../img/bg_button.gif?20121228") no-repeat;width:22px}
|
||||
#smart_editor2 div.se2_text_tool ul li.hover button span.tool_bg{background-position:0 -21px}
|
||||
#smart_editor2 div.se2_text_tool ul li.active button span.tool_bg,#smart_editor2 div.se2_text_tool ul li.active li.active button span.tool_bg{background-position:0 -42px}
|
||||
#smart_editor2 div.se2_text_tool ul li.active li button span.tool_bg{background-position:0 0}
|
||||
/* TEXT_TOOLBAR : SUB_MENU */
|
||||
#smart_editor2 .se2_sub_text_tool{display:none;position:absolute;top:20px;left:0;z-index:40;width:auto;height:29px;padding:0 4px 0 0;border:1px solid #b5b5b5;border-top:1px solid #9a9a9a;background:#f4f4f4}
|
||||
#smart_editor2 .active .se2_sub_text_tool{display:block}
|
||||
#smart_editor2 .se2_sub_text_tool ul{float:left;height:25px;margin:0;padding:4px 0 0 4px}
|
||||
/* TEXT_TOOLBAR : SUB_MENU_SIZE */
|
||||
#smart_editor2 .se2_sub_step1{width:88px}
|
||||
#smart_editor2 .se2_sub_step2{width:199px}
|
||||
#smart_editor2 .se2_sub_step2_1{width:178px}
|
||||
/* TEXT_TOOLBAR : BUTTON */
|
||||
#smart_editor2 .se2_text_tool .se2_font_family{width:70px;height:21px;background-position:0 -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_font_family{background-position:0 -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_font_family{background-position:0 -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_font_size{width:45px;height:21px;background-position:-70px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_font_size{background-position:-70px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_font_size{background-position:-70px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_bold{background-position:-115px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_bold{background-position:-115px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_bold{background-position:-115px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_underline{background-position:-136px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_underline{background-position:-136px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_underline{background-position:-136px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_italic{background-position:-157px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_italic{background-position:-157px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_italic{background-position:-157px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_tdel{background-position:-178px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_tdel{background-position:-178px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_tdel{background-position:-178px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_fcolor{position:relative;background-position:-199px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_fcolor{background-position:-199px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_fcolor{background-position:-199px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_fcolor_more{background-position:-220px -10px;width:10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_fcolor_more{background-position:-220px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_fcolor_more{background-position:-220px -103px}
|
||||
#smart_editor2 .se2_text_tool .selected_color{position:absolute;top:14px;left:5px;width:11px;height:3px;font-size:0}
|
||||
#smart_editor2 .se2_text_tool .se2_ol,#smart_editor2 .se2_text_tool .active .se2_sub_text_tool .se2_ol{background-position:-345px -10px}
|
||||
#smart_editor2 .se2_text_tool .se2_ul,#smart_editor2 .se2_text_tool .active .se2_sub_text_tool .se2_ul{background-position:-366px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_ol,#smart_editor2 .se2_text_tool .active .se2_sub_text_tool .hover .se2_ol{background-position:-345px -72px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_ul,#smart_editor2 .se2_text_tool .active .se2_sub_text_tool .hover .se2_ul{background-position:-366px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_ol,#smart_editor2 .se2_text_tool .active .active .se2_ol{background-position:-345px -103px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_ul,#smart_editor2 .se2_text_tool .active .active .se2_ul{background-position:-366px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_indent,#smart_editor2 .se2_text_tool .active .se2_sub_text_tool .se2_indent{background-position:-408px -10px}
|
||||
#smart_editor2 .se2_text_tool .se2_outdent,#smart_editor2 .se2_text_tool .active .se2_sub_text_tool .se2_outdent{background-position:-387px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_indent,#smart_editor2 .se2_text_tool .active .se2_sub_text_tool .hover .se2_indent{background-position:-408px -72px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_outdent,#smart_editor2 .se2_text_tool .active .se2_sub_text_tool .hover .se2_outdent{background-position:-387px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_indent,#smart_editor2 .se2_text_tool .active .active .se2_indent{background-position:-408px -103px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_outdent,#smart_editor2 .se2_text_tool .active .active .se2_outdent{background-position:-387px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_lineheight{background-position:-429px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_lineheight{background-position:-429px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_lineheight{background-position:-429px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_url{background-position:-513px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_url{background-position:-513px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_url{background-position:-513px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_bgcolor{position:relative;background-position:-230px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_bgcolor{background-position:-230px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_bgcolor{background-position:-230px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_bgcolor_more{background-position:-251px -10px;width:10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_bgcolor_more{background-position:-251px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_bgcolor_more{background-position:-251px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_left{background-position:-261px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_left{background-position:-261px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_left{background-position:-261px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_center{background-position:-282px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_center{background-position:-282px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_center{background-position:-282px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_right{background-position:-303px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_right{background-position:-303px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_right{background-position:-303px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_justify{background-position:-324px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_justify{background-position:-324px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_justify{background-position:-324px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_blockquote{background-position:-471px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_blockquote{background-position:-471px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_blockquote{background-position:-471px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_character{background-position:-555px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_character{background-position:-555px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_character{background-position:-555px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_table{background-position:-576px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_table{background-position:-576px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_table{background-position:-576px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_find{background-position:-597px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_find{background-position:-597px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_find{background-position:-597px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_sup{background-position:-660px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_sup{background-position:-660px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_sup{background-position:-660px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_sub{background-position:-681px -10px}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_sub{background-position:-681px -72px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_sub{background-position:-681px -103px}
|
||||
#smart_editor2 .se2_text_tool .se2_text_tool_more{background-position:0 -41px;width:13px}
|
||||
#smart_editor2 .se2_text_tool .se2_text_tool_more span.tool_bg{background:none}
|
||||
#smart_editor2 .se2_text_tool .hover .se2_text_tool_more{background-position:-13px -41px}
|
||||
#smart_editor2 .se2_text_tool .active .se2_text_tool_more{background-position:-26px -41px}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
@charset "UTF-8";
|
||||
/* NHN Web Standardization Team (http://html.nhndesign.com/) HHJ 090226 */
|
||||
/* COMMON */
|
||||
body,.se2_inputarea{margin:0;padding:0;font-family:'돋움',Dotum,Helvetica,Sans-serif;font-size:12px;line-height:1.5}
|
||||
/* body,.se2_inputarea,.se2_inputarea th,.se2_inputarea td{margin:0;padding:0;font-family:'돋움',Dotum,Helvetica,Sans-serif;font-size:12px;line-height:1.5;color:#666} */
|
||||
.se2_inputarea p,.se2_inputarea br{margin:0;padding:0}
|
||||
.se2_inputarea{margin:15px;word-wrap:break-word;*word-wrap:normal;*word-break:break-all}
|
||||
.se2_inputarea td{word-break:break-all}
|
||||
.se2_inputarea_890{width:741px;margin:20px 0 10px 64px}
|
||||
.se2_inputarea_698{width:548px;margin:20px 0 10px 64px}
|
||||
/* TEXT_TOOLBAR : QUOTE */
|
||||
.se2_quote1{margin:0 0 30px 20px;padding:0 8px;border-left:2px solid #ccc;color:#888}
|
||||
.se2_quote2{margin:0 0 30px 13px;padding:0 8px 0 16px;background:url("../img/bg_quote2.gif") 0 3px no-repeat;color:#888}
|
||||
.se2_quote3{margin:0 0 30px;padding:12px 10px 11px;border:1px dashed #ccc;color:#888}
|
||||
.se2_quote4{margin:0 0 30px;padding:12px 10px 11px;border:1px dashed #66b246;color:#888}
|
||||
.se2_quote5{margin:0 0 30px;padding:12px 10px 11px;border:1px dashed #ccc;background:#fafafa;color:#888}
|
||||
.se2_quote6{margin:0 0 30px;padding:12px 10px 11px;border:1px solid #e5e5e5;color:#888}
|
||||
.se2_quote7{margin:0 0 30px;padding:12px 10px 11px;border:1px solid #66b246;color:#888}
|
||||
.se2_quote8{margin:0 0 30px;padding:12px 10px 11px;border:1px solid #e5e5e5;background:#fafafa;color:#888}
|
||||
.se2_quote9{margin:0 0 30px;padding:12px 10px 11px;border:2px solid #e5e5e5;color:#888}
|
||||
.se2_quote10{margin:0 0 30px;padding:12px 10px 11px;border:2px solid #e5e5e5;background:#fafafa;color:#888}
|
||||
|
|
@ -0,0 +1,462 @@
|
|||
@charset "UTF-8";
|
||||
/* NHN Web Standardization Team (http://html.nhndesign.com/) HHJ 090226 */
|
||||
/* TEXT_TOOLBAR : FONTNAME */
|
||||
#smart_editor2 .se2_tool .se2_l_font_fam{width:202px;margin:0;padding:0}
|
||||
#smart_editor2 .se2_tool .se2_l_font_fam li{display:block;width:202px;height:21px;margin:0;padding:0;color:#333;cursor:pointer}
|
||||
#smart_editor2 .se2_l_font_fam .hover,#smart_editor2 .se2_l_font_fam .active{background:#ebebeb}
|
||||
#smart_editor2 .se2_l_font_fam button{width:200px;height:21px;margin:0;padding:2px 0 2px 0px;background:none;text-align:left}
|
||||
#smart_editor2 .se2_l_font_fam button span{display:block;visibility:visible;overflow:visible;position:relative;top:auto;left:auto;width:auto;height:auto;margin:0 0 0 4px;padding:0;font-size:12px;line-height:normal;color:#333}
|
||||
#smart_editor2 .se2_l_font_fam button span span{display:inline;visibility:visible;overflow:visible;width:auto;height:auto;margin:0 0 0 4px;font-family:Verdana;font-size:12px;line-height:14px;color:#888}
|
||||
#smart_editor2 .se2_l_font_fam button span em{visibility:visible;overflow:auto;position:static;width:auto;height:auto;margin-right:-4px;font-size:12px;color:#888}
|
||||
#smart_editor2 .se2_l_font_fam .se2_division{width:162px;height:2px !important;margin:1px 0 1px 0px;border:0;background:url("../img/bg_line1.gif") 0 0 repeat-x;font-size:0;cursor:default}
|
||||
/* TEXT_TOOLBAR : FONTSIZE */
|
||||
#smart_editor2 .se2_tool .se2_l_font_size{width:302px;margin:0;padding:0}
|
||||
#smart_editor2 .se2_tool .se2_l_font_size li{width:302px;margin:0;padding:0;color:#333;cursor:pointer}
|
||||
#smart_editor2 .se2_l_font_size .hover,#smart_editor2 .se2_l_font_size .active{background:#ebebeb}
|
||||
#smart_editor2 .se2_l_font_size button{width:300px;height:auto;margin:0;padding:2px 0 1px 0px;*padding:4px 0 1px 0px;background:none;text-align:left}
|
||||
#smart_editor2 .se2_l_font_size button span{display:block;visibility:visible;overflow:visible;position:relative;top:auto;left:auto;width:auto;height:auto;margin:0 0 0 4px;padding:0;line-height:normal;color:#373737;letter-spacing:0px}
|
||||
#smart_editor2 .se2_l_font_size button span span{display:inline;margin:0 0 0 5px;padding:0}
|
||||
#smart_editor2 .se2_l_font_size span em{visibility:visible;overflow:auto;position:static;width:auto;height:auto;color:#888}
|
||||
/* TEXT_TOOLBAR : FONTCOLOR */
|
||||
#smart_editor2 .se2_palette{float:left;position:relative;width:225px;margin:0;padding:11px 0 10px 0}
|
||||
#smart_editor2 .se2_palette .se2_pick_color{_display:inline;float:left;clear:both;width:205px;margin:0 0 0 11px;padding:0}
|
||||
#smart_editor2 .se2_palette .se2_pick_color li{float:left;width:12px;height:12px;margin:0;padding:0}
|
||||
#smart_editor2 .se2_palette .se2_pick_color li button{width:11px;height:11px;border:0}
|
||||
#smart_editor2 .se2_palette .se2_pick_color li button span{display:block;visibility:visible;overflow:visible;position:absolute;top:1px;left:1px;width:11px;height:11px}
|
||||
#smart_editor2 .se2_palette .se2_pick_color li button span span{visibility:hidden;overflow:hidden;position:absolute;top:0;left:0;width:0;height:0}
|
||||
#smart_editor2 .se2_palette .se2_pick_color .hover button,#smart_editor2 .se2_palette .se2_pick_color .active button{width:11px;height:11px;border:1px solid #666}
|
||||
#smart_editor2 .se2_palette .se2_pick_color .hover span,#smart_editor2 .se2_palette .se2_pick_color .active span{width:7px;height:7px;border:1px solid #fff}
|
||||
#smart_editor2 .se2_palette .se2_view_more{_display:inline;float:left;width:46px;height:23px;margin:1px 0 0 1px;background:url("../img/ko_KR/btn_set.png?130306") 0 -47px no-repeat}
|
||||
#smart_editor2 .se2_palette .se2_view_more2{_display:inline;float:left;width:46px;height:23px;margin:1px 0 0 1px;background:url("../img/ko_KR/btn_set.png?130306") 0 -24px no-repeat}
|
||||
#smart_editor2 .se2_palette h4{_display:inline;float:left;width:203px;margin:9px 0 0 11px;padding:10px 0 4px 0;background:url("../img/bg_line1.gif") repeat-x;font-weight:normal;font-size:12px;line-height:14px;color:#333;letter-spacing:-1px}
|
||||
#smart_editor2 .se2_palette2{float:left;_float:none;width:214px;margin:9px 0 0 0;padding:11px 0 0 11px;background:url("../img/bg_line1.gif") repeat-x}
|
||||
#smart_editor2 .se2_palette2 .se2_color_set{float:left}
|
||||
#smart_editor2 .se2_palette2 .se2_selected_color{_display:inline;float:left;width:83px;height:18px;margin:0;border:1px solid #c7c7c7;background:#fff}
|
||||
#smart_editor2 .se2_palette2 .se2_selected_color span{_display:inline;float:left;width:79px;height:14px;margin:2px}
|
||||
#smart_editor2 .se2_palette2 .input_ty1{_display:inline;float:left;width:67px;height:16px;margin:0 3px 0 3px;padding:2px 2px 0 4px;font-family:tahoma;font-size:11px}
|
||||
#smart_editor2 .se2_palette2 button.se2_btn_insert{float:left;width:35px;height:21px;margin-left:2px;padding:0;background:url("../img/ko_KR/btn_set.png?130306") -80px 0 no-repeat}
|
||||
#smart_editor2 .se2_gradation1{float:left;_float:none;width:201px;height:128px;margin:4px 0 0 0;border:1px solid #c7c7c7;cursor:crosshair}
|
||||
#smart_editor2 .se2_gradation2{float:left;_float:none;width:201px;height:10px;margin:4px 0 1px 0;border:1px solid #c7c7c7;cursor:crosshair}
|
||||
/* TEXT_TOOLBAR : BGCOLOR */
|
||||
#smart_editor2 .se2_palette_bgcolor{width:225px;margin:11px 0 0;padding:0}
|
||||
#smart_editor2 .se2_palette_bgcolor .se2_background{width:205px;margin:0 11px 0 11px}
|
||||
#smart_editor2 .se2_palette_bgcolor .se2_background li{width:68px;height:20px}
|
||||
#smart_editor2 .se2_palette_bgcolor .se2_background button{width:67px;height:19px;border:0}
|
||||
#smart_editor2 .se2_palette_bgcolor .se2_background span{left:0;display:block;visibility:visible;overflow:visible;width:65px;height:17px;padding:0}
|
||||
#smart_editor2 .se2_palette_bgcolor .se2_background span span{display:block;visibility:visible;overflow:visible;width:64px;height:16px;padding:3px 0 0 3px;font-size:11px;line-height:14px;text-align:left}
|
||||
#smart_editor2 .se2_palette_bgcolor .se2_background .hover span{width:65px;height:17px;border:1px solid #666}
|
||||
#smart_editor2 .se2_palette_bgcolor .se2_background .hover span span{width:62px;height:14px;padding:1px 0 0 1px;border:1px solid #fff}
|
||||
/* TEXT_TOOLBAR : LINEHEIGHT */
|
||||
#smart_editor2 .se2_l_line_height{width:107px;margin:0;padding:0}
|
||||
#smart_editor2 .se2_l_line_height li{width:107px;margin:0;padding:0;border-top:0;border-bottom:0;color:#333;cursor:pointer}
|
||||
#smart_editor2 .se2_l_line_height .hover{background:#ebebeb}
|
||||
#smart_editor2 .se2_l_line_height button{width:105px;height:19px;margin:0;padding:3px 0 2px 0px;background:none;text-align:left}
|
||||
#smart_editor2 .se2_l_line_height button span{visibility:visible;overflow:visible;position:relative;width:auto;height:auto;margin:0;padding:0 0 0 15px;font-size:12px;line-height:normal;color:#373737}
|
||||
#smart_editor2 .se2_l_line_height li button.active span{background:url("../img/icon_set.gif") 5px -30px no-repeat}
|
||||
#smart_editor2 .se2_l_line_height_user{clear:both;width:83px;margin:5px 0 0 12px;padding:10px 0 0 0;_padding:11px 0 0 0;background:url("../img/bg_line1.gif") repeat-x}
|
||||
#smart_editor2 .se2_l_line_height_user h3{margin:0 0 4px 0;_margin:0 0 2px -1px;padding:0;line-height:14px;color:#000;letter-spacing:-1px}
|
||||
#smart_editor2 .se2_l_line_height_user .bx_input{display:block;position:relative;width:83px}
|
||||
#smart_editor2 .se2_l_line_height_user .btn_up{position:absolute;top:2px;*top:3px;left:68px;width:13px;height:8px;background:url("../img/ko_KR/btn_set.png?130306") -86px -54px no-repeat}
|
||||
#smart_editor2 .se2_l_line_height_user .btn_down{position:absolute;top:10px;*top:11px;left:68px;width:13px;height:8px;background:url("../img/ko_KR/btn_set.png?130306") -86px -62px no-repeat}
|
||||
#smart_editor2 .se2_l_line_height_user .btn_area{margin:5px 0 10px 0}
|
||||
#smart_editor2 .se2_tool .btn_area .se2_btn_apply3{width:41px;height:24px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat}
|
||||
#smart_editor2 .se2_tool .btn_area .se2_btn_cancel3{width:39px;height:24px;margin-left:3px;background:url("../img/ko_KR/btn_set.png?130306") -41px 0 no-repeat}
|
||||
/* TEXT_TOOLBAR : QUOTE */
|
||||
#smart_editor2 .se2_quote{width:425px;height:56px}
|
||||
#smart_editor2 .se2_quote ul{_display:inline;float:left;margin:11px 0 0 9px;padding:0}
|
||||
#smart_editor2 .se2_quote li{_display:inline;float:left;margin:0 0 0 2px;padding:0}
|
||||
#smart_editor2 .se2_quote button{width:34px;height:34px;margin:0;padding:0;background:url("../img/ko_KR/btn_set.png?130306") no-repeat;cursor:pointer}
|
||||
#smart_editor2 .se2_quote button span{left:0;display:block;visibility:visible;overflow:visible;width:32px;height:32px;margin:0;padding:0;border:1px solid #c7c7c7}
|
||||
#smart_editor2 .se2_quote button span span{visibility:hidden;overflow:hidden;position:absolute;top:0;left:0;width:0;height:0;margin:0;padding:0}
|
||||
#smart_editor2 .se2_quote .se2_quote1{background-position:1px -375px}
|
||||
#smart_editor2 .se2_quote .se2_quote2{background-position:-32px -375px}
|
||||
#smart_editor2 .se2_quote .se2_quote3{background-position:-65px -375px}
|
||||
#smart_editor2 .se2_quote .se2_quote4{background-position:-98px -375px}
|
||||
#smart_editor2 .se2_quote .se2_quote5{background-position:-131px -375px}
|
||||
#smart_editor2 .se2_quote .se2_quote6{background-position:-164px -375px}
|
||||
#smart_editor2 .se2_quote .se2_quote7{background-position:-197px -375px}
|
||||
#smart_editor2 .se2_quote .se2_quote8{background-position:-230px -375px}
|
||||
#smart_editor2 .se2_quote .se2_quote9{background-position:-263px -375px}
|
||||
#smart_editor2 .se2_quote .se2_quote10{background-position:-296px -375px}
|
||||
#smart_editor2 .se2_quote .hover button span,#smart_editor2 .se2_quote .active button span{width:30px;height:30px;margin:0;padding:0;border:2px solid #44b525}
|
||||
#smart_editor2 .se2_quote .hover button span span,#smart_editor2 .se2_quote .active button span span{visibility:hidden;overflow:hidden;position:absolute;top:0;left:0;width:0;height:0;margin:0;padding:0}
|
||||
#smart_editor2 .se2_quote .se2_cancel2{float:left;width:40px;height:35px;margin:11px 0 0 5px;background:url("../img/ko_KR/btn_set.png?130306") -46px -24px no-repeat}
|
||||
#smart_editor2 .se2_quote .se2_cancel2 span{visibility:hidden;overflow:hidden;position:absolute;top:0;left:0;width:0;height:0;margin:0;padding:0}
|
||||
/* TEXT_TOOLBAR : HYPERLINK */
|
||||
#smart_editor2 .se2_url2{width:281px;padding:11px 11px 6px 11px;color:#666}
|
||||
#smart_editor2 .se2_url2 .input_ty1{display:block;width:185px;height:16px;margin:0 5px 5px 0;*margin:-1px 5px 5px 0;padding:5px 2px 0 4px}
|
||||
#smart_editor2 .se2_url2 .se2_url_new{width:15px;height:15px;margin:-1px 3px 1px -1px;*margin:-2px 3px 2px -1px;vertical-align:middle}
|
||||
#smart_editor2 .se2_url2 label{font-size:11px;line-height:14px;vertical-align:middle}
|
||||
#smart_editor2 .se2_url2 .se2_apply{position:absolute;top:13px;right:51px;width:41px;height:24px;margin:-1px 3px 1px 0;background:url("../img/ko_KR/btn_set.png?130306") no-repeat}
|
||||
#smart_editor2 .se2_url2 .se2_cancel{position:absolute;top:13px;right:9px;width:39px;height:24px;margin:-1px 3px 1px 0;background:url("../img/ko_KR/btn_set.png?130306") -41px 0 no-repeat}
|
||||
/* TEXT_TOOLBAR : SCHARACTER */
|
||||
#smart_editor2 .se2_bx_character{width:469px;height:272px;margin:0;padding:0;background:url("../img/ko_KR/bx_set_110302.gif") 9px -1230px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .se2_char_tab{_display:inline;float:left;position:relative;width:443px;margin:11px 10px 200px 11px;padding:0 0 0 1px}
|
||||
#smart_editor2 .se2_bx_character .se2_char_tab li{position:static;margin:0 0 0 -1px;padding:0}
|
||||
#smart_editor2 .se2_bx_character .se2_char1{width:76px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") 0 -204px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .se2_char2{width:86px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -75px -204px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .se2_char3{width:68px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -160px -204px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .se2_char4{width:55px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -227px -204px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .se2_char5{width:97px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -281px -204px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .se2_char6{width:66px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -377px -204px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .active .se2_char1{width:76px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") 0 -230px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .active .se2_char2{width:86px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -75px -230px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .active .se2_char3{width:68px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -160px -230px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .active .se2_char4{width:55px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -227px -230px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .active .se2_char5{width:97px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -281px -230px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .active .se2_char6{width:66px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -377px -230px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .se2_s_character{display:none;position:absolute;top:26px;left:0;width:448px;height:194px;margin:0;padding:0}
|
||||
#smart_editor2 .se2_bx_character .active .se2_s_character{display:block}
|
||||
#smart_editor2 .se2_bx_character .se2_s_character ul{float:left;width:422px;height:172px;margin:0;padding:9px 0 0 11px}
|
||||
#smart_editor2 .se2_bx_character .se2_s_character li{_display:inline;float:left;position:relative;width:20px;height:18px;margin:0 0 1px 1px;background:#fff}
|
||||
#smart_editor2 .se2_bx_character .se2_s_character button{width:20px;height:18px;margin:0;padding:2px;background:none}
|
||||
#smart_editor2 .se2_bx_character .se2_s_character .hover,#smart_editor2 .se2_bx_character .se2_s_character .active{background:url("../img/ko_KR/btn_set.png?130306") -446px -274px no-repeat}
|
||||
#smart_editor2 .se2_bx_character .se2_s_character button span{left:0;display:block;visibility:visible;overflow:visible;width:14px;height:16px;margin:3px 0 0 3px;border:0;background:none;font-size:12px;line-height:normal}
|
||||
#smart_editor2 .se2_apply_character{clear:both;position:relative;padding:0 0 0 11px}
|
||||
#smart_editor2 .se2_apply_character label{margin:0 3px 0 0;font-size:12px;color:#666;letter-spacing:-1px}
|
||||
#smart_editor2 .se2_apply_character .input_ty1{width:283px;height:17px;margin:-1px 5px 1px 0;padding:4px 0 0 5px;font-size:12px;color:#666;letter-spacing:0;vertical-align:middle}
|
||||
#smart_editor2 .se2_apply_character .se2_confirm{width:41px;height:24px;margin-right:3px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat;vertical-align:middle}
|
||||
#smart_editor2 .se2_apply_character .se2_cancel{width:39px;height:24px;background:url("../img/ko_KR/btn_set.png?130306") -41px 0 no-repeat;vertical-align:middle}
|
||||
/* TEXT_TOOLBAR : TABLECREATOR */
|
||||
#smart_editor2 .se2_table_set{position:relative;width:166px;margin:3px 11px 0 11px;padding:8px 0 0 0}
|
||||
#smart_editor2 .se2_table_set .se2_cell_num{float:left;width:73px}
|
||||
#smart_editor2 .se2_table_set .se2_cell_num dt{float:left;clear:both;width:17px;height:23px;margin:0;padding:0}
|
||||
#smart_editor2 .se2_table_set .se2_cell_num dt label{display:block;margin:5px 0 0 0;font-size:11px;color:#666}
|
||||
#smart_editor2 .se2_table_set .se2_cell_num dd{float:left;position:relative;width:54px;height:23px;margin:0;padding:0}
|
||||
#smart_editor2 .se2_table_set .se2_cell_num .input_ty2{display:block;width:32px;height:16px;*margin:-1px 0 0 0;padding:2px 19px 0 0px;border:1px solid #c7c7c7;font-family:tahoma,verdana,times New Roman;font-size:11px;color:#666;text-align:right;*direction:rtl}
|
||||
#smart_editor2 .se2_table_set .se2_cell_num .input_ty2::-ms-clear{display:none}
|
||||
#smart_editor2 .se2_table_set .se2_pre_table{float:right;width:91px;height:43px;background:#c7c7c7;border-spacing:1px}
|
||||
#smart_editor2 .se2_table_set .se2_pre_table tr{background:#fff}
|
||||
#smart_editor2 .se2_table_set .se2_pre_table td{font-size:0;line-height:0}
|
||||
#smart_editor2 .se2_table_set .se2_add{position:absolute;top:2px;right:3px;width:13px;height:8px;background:url("../img/ko_KR/btn_set.png?130306") -86px -54px no-repeat}
|
||||
#smart_editor2 .se2_table_set .se2_del{position:absolute;top:10px;right:3px;width:13px;height:8px;background:url("../img/ko_KR/btn_set.png?130306") -86px -62px no-repeat}
|
||||
/* TEXT_TOOLBAR : TABLEEDITOR */
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1{float:left;width:166px;margin:7px 0 0 0;padding:10px 0 5px;background:url("../img/bg_line1.gif") repeat-x}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1 dt{width:166px;margin:0 0 6px 0}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1 dd{width:166px}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1 dt input{width:15px;height:15px;margin:-1px 3px 1px 0;_margin:-2px 3px 2px 0;vertical-align:middle}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1 dt label{font-weight:bold;font-size:11px;color:#666;letter-spacing:-1px;vertical-align:middle}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_1{float:left;position:relative;z-index:59;width:166px;margin:1px 0 0 0}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_2{z-index:54;margin:0}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_3{z-index:53;margin:0}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_4{z-index:52;margin:0}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_1 dt{_display:inline;float:left;clear:both;width:66px;height:22px;margin:1px 0 0 18px}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_1 dt label{display:block;margin:4px 0 0 0;font-weight:normal;font-size:11px;color:#666;letter-spacing:-1px}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_1 dd{float:left;position:relative;width:82px;height:23px}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_1 .input_ty1{width:72px;height:16px;*margin:-1px 0 0 0;padding:2px 2px 0 6px;font-family:tahoma,verdana,times New Roman;font-size:11px;color:#666}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_1 .input_ty3{float:left;width:49px;height:16px;margin:0 3px 0 0;padding:2px 4px 0 4px;border:1px solid #c7c7c7;font-family:tahoma,verdana,times New Roman;font-size:11px;color:#666}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_1 .se2_add{top:2px;right:2px}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_1 .se2_del{top:10px;right:2px}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper1_1 .se2_color_set .input_ty1{_display:inline;float:left;width:67px;height:16px;margin:0 3px 0 3px;padding:2px 2px 0 4px;font-family:tahoma,verdana,times New Roman;font-size:11px}
|
||||
#smart_editor2 .se2_select_ty1{position:relative;width:80px;height:18px;border:1px solid #c7c7c7;background:#fff;font-size:11px;line-height:14px;text-align:left}
|
||||
#smart_editor2 .se2_select_ty1 span{float:left;width:54px;height:18px;margin:0 0 0 5px;font-size:11px;line-height:14px;color:#666}
|
||||
#smart_editor2 .se2_select_ty1 .se2_b_style0{position:relative;top:3px;left:-3px;white-space:nowrap}
|
||||
#smart_editor2 .se2_select_ty1 .se2_b_style1{height:15px;margin:3px 0 0 4px;font-size:11px;line-height:14px;color:#666;letter-spacing:-1px}
|
||||
#smart_editor2 .se2_select_ty1 .se2_b_style2{background:url("../img/bg_set.gif") 0 -50px repeat-x}
|
||||
#smart_editor2 .se2_select_ty1 .se2_b_style3{background:url("../img/bg_set.gif") 0 -68px repeat-x}
|
||||
#smart_editor2 .se2_select_ty1 .se2_b_style4{background:url("../img/bg_set.gif") 0 -85px repeat-x}
|
||||
#smart_editor2 .se2_select_ty1 .se2_b_style5{background:url("../img/bg_set.gif") 0 -103px repeat-x}
|
||||
#smart_editor2 .se2_select_ty1 .se2_b_style6{background:url("../img/bg_set.gif") 0 -121px repeat-x}
|
||||
#smart_editor2 .se2_select_ty1 .se2_b_style7{background:url("../img/bg_set.gif") 0 -139px repeat-x}
|
||||
#smart_editor2 .se2_select_ty1 .se2_view_more{position:absolute;top:1px;right:1px;width:13px;height:16px;background:url("../img/ko_KR/btn_set.png?130306") -112px -54px no-repeat}
|
||||
#smart_editor2 .se2_select_ty1 .se2_view_more2{position:absolute;top:1px;right:1px;width:13px;height:16px;background:url("../img/ko_KR/btn_set.png?130306") -99px -54px no-repeat}
|
||||
/* TEXT_TOOLBAR : TABLEEDITOR > BORDER */
|
||||
#smart_editor2 .se2_table_set .se2_b_t_b1{border-top:1px solid #b1b1b1}
|
||||
#smart_editor2 .se2_layer_b_style{position:absolute;top:20px;right:0px;width:80px;padding-bottom:1px;border:1px solid #c7c7c7;border-top:1px solid #a8a8a8;background:#fff}
|
||||
#smart_editor2 .se2_layer_b_style ul{width:80px;margin:0;padding:1px 0 0 0}
|
||||
#smart_editor2 .se2_layer_b_style li{width:80px;height:18px;margin:0;padding:0}
|
||||
#smart_editor2 .se2_layer_b_style .hover,#smart_editor2 .se2_layer_b_style .active{background:#ebebeb}
|
||||
#smart_editor2 .se2_layer_b_style button{width:80px;height:18px;background:none}
|
||||
#smart_editor2 .se2_layer_b_style button span{left:0;display:block;visibility:visible;overflow:visible;width:71px;height:18px;margin:0 0 0 5px;font-size:11px;line-height:15px;text-align:left}
|
||||
#smart_editor2 .se2_layer_b_style button span span{visibility:hidden;overflow:hidden;position:absolute;top:0;left:0;width:0;height:0}
|
||||
#smart_editor2 .se2_layer_b_style .se2_b_style1 span{margin:3px 0 0 4px;font-size:11px;line-height:14px;color:#666;letter-spacing:-1px}
|
||||
#smart_editor2 .se2_layer_b_style .se2_b_style2 span{background:url("../img/bg_set.gif") 0 -50px repeat-x}
|
||||
#smart_editor2 .se2_layer_b_style .se2_b_style3 span{background:url("../img/bg_set.gif") 0 -68px repeat-x}
|
||||
#smart_editor2 .se2_layer_b_style .se2_b_style4 span{background:url("../img/bg_set.gif") 0 -86px repeat-x}
|
||||
#smart_editor2 .se2_layer_b_style .se2_b_style5 span{background:url("../img/bg_set.gif") 0 -103px repeat-x}
|
||||
#smart_editor2 .se2_layer_b_style .se2_b_style6 span{background:url("../img/bg_set.gif") 0 -121px repeat-x}
|
||||
#smart_editor2 .se2_layer_b_style .se2_b_style7 span{background:url("../img/bg_set.gif") 0 -139px repeat-x}
|
||||
/* TEXT_TOOLBAR : TABLEEDITOR > COLOR */
|
||||
#smart_editor2 .se2_pre_color{float:left;width:18px;height:18px;border:1px solid #c7c7c7}
|
||||
#smart_editor2 .se2_pre_color button{float:left;width:14px;height:14px;margin:2px 0 0 2px;padding:0}
|
||||
#smart_editor2 .se2_pre_color button span{overflow:hidden;position:absolute;top:-10000px;left:-10000px;z-index:-100;width:0;height:0}
|
||||
/* TEXT_TOOLBAR : TABLEEDITOR > DIMMED */
|
||||
#smart_editor2 .se2_table_set .se2_t_dim1{clear:both;position:absolute;top:71px;left:16px;z-index:60;width:157px;height:118px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_table_set .se2_t_dim2{position:absolute;top:116px;left:16px;z-index:55;width:157px;height:45px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_table_set .se2_t_dim3{clear:both;position:absolute;top:192px;left:16px;z-index:51;width:157px;height:39px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
/* TEXT_TOOLBAR : TABLEEDITOR > STYLE PREVIEW */
|
||||
#smart_editor2 .se2_table_set .se2_t_proper2{float:left;position:relative;z-index:50;width:166px;margin:2px 0 0 0}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper2 dt{float:left;width:84px;height:33px;margin:4px 0 0 0}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper2 dt input{width:15px;height:15px;margin:-1px 3px 1px 0;_margin:-2px 3px 2px 0;vertical-align:middle}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper2 dt label{font-weight:bold;font-size:11px;color:#666;letter-spacing:-1px;vertical-align:middle}
|
||||
#smart_editor2 .se2_table_set .se2_t_proper2 dd{float:left;width:66px;height:33px}
|
||||
#smart_editor2 .se2_select_ty2{position:relative;width:65px;height:31px;border:1px solid #c7c7c7;background:#fff;font-size:11px;line-height:14px;text-align:left}
|
||||
#smart_editor2 .se2_select_ty2 span{float:left;width:45px;height:25px;margin:3px 0 0 3px;background:url("../img/ko_KR/btn_set.png?130306") repeat-x}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style1{background-position:0 -410px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style2{background-position:-46px -410px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style3{background-position:-92px -410px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style4{background-position:-138px -410px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style5{background-position:-184px -410px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style6{background-position:-230px -410px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style7{background-position:-276px -410px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style8{background-position:-322px -410px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style9{background-position:0 -436px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style10{background-position:-46px -436px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style11{background-position:-92px -436px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style12{background-position:-138px -436px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style13{background-position:-184px -436px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style14{background-position:-230px -436px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style15{background-position:-276px -436px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_t_style16{background-position:-322px -436px}
|
||||
#smart_editor2 .se2_select_ty2 .se2_view_more{position:absolute;top:1px;right:1px;_right:0px;width:13px !important;height:29px !important;background:url("../img/ko_KR/btn_set.png?130306") -353px -48px no-repeat !important}
|
||||
#smart_editor2 .se2_select_ty2 .se2_view_more2{position:absolute;top:1px;right:1px;_right:0px;width:13px !important;height:29px !important;background:url("../img/ko_KR/btn_set.png?130306") -340px -48px no-repeat !important}
|
||||
#smart_editor2 .se2_select_ty2 .se2_view_more span{display:none}
|
||||
/* TEXT_TOOLBAR : TABLEEDITOR > STYLE */
|
||||
#smart_editor2 .se2_layer_t_style{position:absolute;top:33px;right:15px;width:208px;border:1px solid #c7c7c7;border-top:1px solid #a8a8a8;background:#fff}
|
||||
#smart_editor2 .se2_layer_t_style ul{width:204px;height:126px;margin:1px 2px;padding:1px 0 0 0;background:#fff}
|
||||
#smart_editor2 .se2_layer_t_style li{_display:inline;float:left;width:45px;height:25px;margin:1px;padding:1px;border:1px solid #fff}
|
||||
#smart_editor2 .se2_layer_t_style .hover,#smart_editor2 .se2_layer_t_style .active{border:1px solid #666;background:#fff}
|
||||
#smart_editor2 .se2_layer_t_style button{width:45px;height:25px;background:url("../img/ko_KR/btn_set.png?130306") repeat-x !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style1{background-position:0 -410px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style2{background-position:-46px -410px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style3{background-position:-92px -410px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style4{background-position:-138px -410px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style5{background-position:-184px -410px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style6{background-position:-230px -410px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style7{background-position:-276px -410px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style8{background-position:-322px -410px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style9{background-position:0 -436px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style10{background-position:-46px -436px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style11{background-position:-92px -436px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style12{background-position:-138px -436px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style13{background-position:-184px -436px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style14{background-position:-230px -436px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style15{background-position:-276px -436px !important}
|
||||
#smart_editor2 .se2_layer_t_style .se2_t_style16{background-position:-322px -436px !important}
|
||||
#smart_editor2 .se2_table_set .se2_btn_area{float:left;width:166px;margin:6px 0 0 0;padding:12px 0 8px 0;background:url("../img/bg_line1.gif") repeat-x;text-align:center}
|
||||
#smart_editor2 .se2_table_set button.se2_apply{width:41px;height:24px;margin-right:3px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat}
|
||||
#smart_editor2 .se2_table_set button.se2_cancel{width:39px;height:24px;background:url("../img/ko_KR/btn_set.png?130306") -41px 0 no-repeat}
|
||||
#smart_editor2 .se2_table_set .se2_rd{width:14px;height:14px;vertical-align:middle}
|
||||
#smart_editor2 .se2_table_set .se2_celltit{font-size:11px;font-size:11px;color:#666;letter-spacing:-1px}
|
||||
#smart_editor2 .se2_table_set dt label.se2_celltit{display:inline}
|
||||
/* TEXT_TOOLBAR : FINDREPLACE */
|
||||
#smart_editor2 .se2_bx_find_revise{position:relative;width:255px;margin:0;padding:0}
|
||||
#smart_editor2 .se2_bx_find_revise .se2_close{position:absolute;top:5px;right:8px;width:20px;height:20px;background:url("../img/ko_KR/btn_set.png?130306") -151px -1px no-repeat}
|
||||
#smart_editor2 .se2_bx_find_revise h3{margin:0;padding:10px 0 13px 10px;background:url("../img/bg_find_h3.gif") 0 -1px repeat-x;font-size:12px;line-height:14px;letter-spacing:-1px}
|
||||
#smart_editor2 .se2_bx_find_revise ul{position:relative;margin:8px 0 0 0;padding:0 0 0 12px}
|
||||
#smart_editor2 .se2_bx_find_revise ul li{_display:inline;float:left;position:static;margin:0 0 0 -1px;padding:0}
|
||||
#smart_editor2 .se2_bx_find_revise .se2_tabfind{width:117px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") 0 -100px no-repeat}
|
||||
#smart_editor2 .se2_bx_find_revise .se2_tabrevise{width:117px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -116px -100px no-repeat}
|
||||
#smart_editor2 .se2_bx_find_revise .active .se2_tabfind{width:117px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") 0 -126px no-repeat}
|
||||
#smart_editor2 .se2_bx_find_revise .active .se2_tabrevise{width:117px;height:26px;background:url("../img/ko_KR/btn_set.png?130306") -116px -126px no-repeat}
|
||||
#smart_editor2 .se2_bx_find_revise .se2_in_bx_find dl{_display:inline;float:left;width:223px;margin:0 0 0 9px;padding:7px 0 13px 14px;background:url("../img/ko_KR/bx_set_110302.gif") -289px -1518px no-repeat}
|
||||
#smart_editor2 .se2_bx_find_revise .se2_in_bx_revise dl{_display:inline;float:left;width:223px;margin:0 0 0 9px;padding:7px 0 13px 14px;background:url("../img/ko_KR/bx_set_110302.gif") -289px -1619px no-repeat}
|
||||
#smart_editor2 .se2_bx_find_revise dt{_display:inline;float:left;clear:both;width:47px;margin:1px 0 2px 0}
|
||||
#smart_editor2 .se2_bx_find_revise dd{float:left;margin:0 0 2px 0}
|
||||
#smart_editor2 .se2_bx_find_revise label{float:left;padding:5px 0 0 0;font-size:11px;color:#666;letter-spacing:-2px}
|
||||
#smart_editor2 .se2_bx_find_revise input{float:left;width:155px;height:12px;margin:1px 0 0 0;padding:3px 2px 3px 4px;font-size:12px;color:#666}
|
||||
#smart_editor2 .se2_bx_find_revise .se2_find_btns{float:left;clear:both;width:255px;padding:8px 0 10px 0;text-align:center}
|
||||
#smart_editor2 .se2_bx_find_revise .se2_find_next{width:65px;height:24px;margin:0 3px 0 0;background:url("../img/ko_KR/btn_set.png?130306") -180px -48px no-repeat}
|
||||
#smart_editor2 .se2_bx_find_revise .se2_find_next2{width:61px;height:24px;margin:0 3px 0 0;background:url("../img/ko_KR/btn_set.png?130306") -180px -24px no-repeat}
|
||||
#smart_editor2 .se2_bx_find_revise .se2_revise1{width:54px;height:24px;margin:0 3px 0 0;background:url("../img/ko_KR/btn_set.png?130306") -245px -48px no-repeat}
|
||||
#smart_editor2 .se2_bx_find_revise .se2_revise2{width:70px;height:24px;margin:0 3px 0 0;background:url("../img/ko_KR/btn_set.png?130306") -245px -24px no-repeat}
|
||||
#smart_editor2 .se2_bx_find_revise .se2_cancel{width:39px;height:24px;background:url("../img/ko_KR/btn_set.png?130306") -41px 0 no-repeat}
|
||||
/* TEXT_TOOLBAR : QUICKEDITOR_TABLE */
|
||||
#smart_editor2 .se2_qmax{position:absolute;width:18px;height:18px;background:url("../img/ko_KR/btn_set.png?130306") -339px -169px no-repeat}
|
||||
#smart_editor2 .se2_qeditor{position:absolute;top:0;left:0;width:183px;margin:0;padding:0;border:1px solid #c7c7c7;border-right:1px solid #ababab;border-bottom:1px solid #ababab;background:#fafafa}
|
||||
#smart_editor2 .se2_qeditor label,#smart_editor2 .se2_qeditor span,#smart_editor2 .se2_qeditor dt{font-size:11px;color:#666;letter-spacing:-1px}
|
||||
#smart_editor2 .se2_qbar{position:relative;width:183px;height:11px;background:url("../img/ko_KR/bx_set_110302.gif") 0 -731px no-repeat}
|
||||
#smart_editor2 .se2_qbar .se2_qmini{position:absolute;top:-1px;right:0;*right:-1px;_right:-3px;width:18px;height:14px;background:url("../img/ko_KR/btn_set.png?130306") -315px -170px no-repeat}
|
||||
#smart_editor2 .se2_qbar .se2_qmini button{width:20px;height:14px;margin-top:-1px}
|
||||
#smart_editor2 .se2_qeditor .se2_qbody0{float:left;border:1px solid #fefefe}
|
||||
#smart_editor2 .se2_qeditor .se2_qbody{position:relative;z-index:90;width:174px;padding:4px 0 0 7px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe1{overflow:hidden;width:174px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe1 dt{float:left;width:22px;height:18px;padding:4px 0 0 0}
|
||||
#smart_editor2 .se2_qeditor .se2_qe1 dd{float:left;width:65px;height:22px}
|
||||
#smart_editor2 .se2_qeditor .se2_addrow{width:28px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -385px -49px}
|
||||
#smart_editor2 .se2_qeditor .se2_addcol{width:29px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -413px -49px}
|
||||
#smart_editor2 .se2_qeditor .se2_seprow{width:28px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -385px -68px}
|
||||
#smart_editor2 .se2_qeditor .se2_sepcol{width:29px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -413px -68px}
|
||||
#smart_editor2 .se2_qeditor .se2_delrow{width:28px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -385px -106px}
|
||||
#smart_editor2 .se2_qeditor .se2_delcol{width:29px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -413px -106px}
|
||||
#smart_editor2 .se2_qeditor .se2_merrow{width:57px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -385px -125px}
|
||||
#smart_editor2 .se2_qeditor .se2_mercol{width:57px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -413px -125px}
|
||||
#smart_editor2 .se2_qeditor .se2_seprow_off{width:28px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -385px -87px}
|
||||
#smart_editor2 .se2_qeditor .se2_sepcol_off{width:29px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -413px -87px}
|
||||
#smart_editor2 .se2_qeditor .se2_merrow_off{width:57px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -385px -144px}
|
||||
#smart_editor2 .se2_qeditor .se2_mercol_off{width:57px;height:19px;background:url("../img/ko_KR/btn_set.png?130306") no-repeat -413px -144px}
|
||||
/* TEXT_TOOLBAR : QUICKEDITOR_TABLE > CELL_BACKGROUND */
|
||||
#smart_editor2 .se2_qeditor .se2_qe2{_display:inline;float:left;position:relative;z-index:100;width:165px;margin:2px 0 0 1px;padding:7px 0 0 0;background:url("../img/bg_line1.gif") repeat-x;zoom:1}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_1 dt{float:left;width:62px;padding:3px 0 0 0}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_1 dt input{width:15px;height:15px;margin:-1px 1px 1px -1px;vertical-align:middle}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_1 dd{float:left;position:relative;zoom:1}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_3{padding:7px 0 6px 0}
|
||||
/* My글양식 없을때 */
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2{position:relative;_position:absolute}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 dt{float:left;width:50px;padding:3px 0 0 13px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 dt input{width:15px;height:15px;margin:-1px 2px 1px -1px;vertical-align:middle}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 dd{float:left}
|
||||
/* TEXT_TOOLBAR : QUICKEDITOR_TABLE > STYLE */
|
||||
#smart_editor2 .se2_table_set .se2_qbody .se2_t_proper2{float:left;*float:none;position:static;width:166px;margin:5px 0 0 1px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe3 dt{float:left;width:62px;padding:0}
|
||||
#smart_editor2 .se2_qeditor .se2_qe3 dt label{font-weight:normal}
|
||||
#smart_editor2 .se2_qeditor .se2_qe3 dt input{width:15px;height:15px;margin:-1px 1px 1px -1px;vertical-align:middle}
|
||||
#smart_editor2 .se2_qeditor .se2_qe3 dd .se2_qe3_table{position:relative}
|
||||
/* TEXT_TOOLBAR : QUICKEDITOR_TABLE > CELL_BACKGROUND PREWVIEW */
|
||||
#smart_editor2 .se2_qeditor .se2_pre_color{float:left;width:18px;height:18px;border:1px solid #c7c7c7}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_color button{float:left;width:14px;height:14px;margin:2px 0 0 2px;padding:0}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_color button span{overflow:hidden;position:absolute;top:-10000px;left:-10000px;z-index:-100;width:0;height:0}
|
||||
/* TEXT_TOOLBAR : QUICKEDITOR_TABLE > CELL_BACKGROUND LAYER */
|
||||
#smart_editor2 .se2_qeditor .se2_layer{float:left;clear:both;position:absolute;top:20px;left:0;margin:0;padding:0;border:1px solid #c7c7c7;border-top:1px solid #9a9a9a;background:#fafafa}
|
||||
#smart_editor2 .se2_qeditor .se2_layer .se2_in_layer{float:left;margin:0;padding:0;border:1px solid #fff;background:#fafafa}
|
||||
#smart_editor2 .se2_qeditor .se2_layer button{vertical-align:top}
|
||||
#smart_editor2 .se2_qeditor .se2_layer .se2_pick_color li{position:relative}
|
||||
/* TEXT_TOOLBAR : QUICKEDITOR_TABLE > CELL_BACKGROUND IMAGE */
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg{float:left;width:14px;height:14px;padding:2px;border:1px solid #c7c7c7}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 button{width:16px;height:16px;background:url("../img/ko_KR/btn_set.png?130306") 0 -261px no-repeat}
|
||||
/* TEXT_TOOLBAR : QUICKEDITOR_TABLE > CELL_BACKGROUND IMAGE LAYER */
|
||||
#smart_editor2 .se2_cellimg_set{_display:inline;float:left;width:136px;margin:4px 3px 0 4px;padding-bottom:4px}
|
||||
#smart_editor2 .se2_cellimg_set li{_display:inline;float:left;width:16px;height:16px;margin:0 1px 1px 0}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg0{background-position:-255px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg1{background-position:0 -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg2{background-position:-17px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg3{background-position:-34px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg4{background-position:-51px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg5{background-position:-68px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg6{background-position:-85px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg7{background-position:-102px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg8{background-position:-119px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg9{background-position:-136px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg10{background-position:-153px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg11{background-position:-170px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg12{background-position:-187px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg13{background-position:-204px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg14{background-position:-221px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg15{background-position:-238px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg16{background-position:-255px -261px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg17{background-position:0 -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg18{background-position:-17px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg19{background-position:-34px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg20{background-position:-51px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg21{background-position:-68px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg22{background-position:-85px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg23{background-position:-102px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg24{background-position:-119px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg25{background-position:-136px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg26{background-position:-153px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg27{background-position:-170px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg28{background-position:-187px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg29{background-position:-204px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg30{background-position:-221px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_qe2_2 .se2_cellimg31{background-position:-238px -278px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg button{width:14px;height:14px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg1{background-position:-1px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg2{background-position:-18px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg3{background-position:-35px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg4{background-position:-52px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg5{background-position:-69px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg6{background-position:-86px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg7{background-position:-103px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg8{background-position:-120px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg9{background-position:-137px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg10{background-position:-154px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg11{background-position:-171px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg12{background-position:-188px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg13{background-position:-205px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg14{background-position:-222px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg15{background-position:-239px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg16{background-position:-256px -262px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg17{background-position:-1px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg18{background-position:-18px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg19{background-position:-35px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg20{background-position:-52px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg21{background-position:-69px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg22{background-position:-86px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg23{background-position:-103px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg24{background-position:-120px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg25{background-position:-137px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg26{background-position:-154px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg27{background-position:-171px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg28{background-position:-188px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg29{background-position:-205px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg30{background-position:-222px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg31{background-position:-239px -279px}
|
||||
#smart_editor2 .se2_qeditor .se2_pre_bgimg .se2_cellimg32{background-position:-256px -279px}
|
||||
/* TEXT_TOOLBAR : QUICKEDITOR_TABLE > MY REVIEW */
|
||||
#smart_editor2 .se2_btn_area{_display:inline;float:left;clear:both;width:166px;margin:5px 0 0 1px;padding:7px 0 6px 0;background:url("../img/bg_line1.gif") repeat-x;text-align:center}
|
||||
#smart_editor2 .se2_btn_area .se2_btn_save{width:97px;height:21px;background:url("../img/ko_KR/btn_set.png?130306") -369px -163px no-repeat}
|
||||
/* TEXT_TOOLBAR : QUICKEDITOR_IMAGE */
|
||||
#smart_editor2 .se2_qe10{width:166px;margin:0;*margin:-2px 0 0 0}
|
||||
#smart_editor2 .se2_qe10 label{margin:0 1px 0 0;vertical-align:middle}
|
||||
#smart_editor2 .se2_qe10 .se2_sheight{margin-left:4px}
|
||||
#smart_editor2 .se2_qe10 .input_ty1{width:30px;height:13px;margin:0 0 1px 1px;padding:3px 4px 0 1px;font-size:11px;letter-spacing:0;text-align:right;vertical-align:middle}
|
||||
#smart_editor2 .se2_qe10 .se2_sreset{width:41px;height:19px;margin-left:3px;background:url("../img/ko_KR/btn_set.png?130306") -401px -184px no-repeat;vertical-align:middle}
|
||||
#smart_editor2 .se2_qe10_1{margin-top:4px;padding:10px 0 3px;background:url("../img/bg_line1.gif") repeat-x}
|
||||
#smart_editor2 .se2_qe10_1 input{width:15px;height:15px;margin:-1px 3px 1px -1px;vertical-align:middle}
|
||||
#smart_editor2 .se2_qe11{float:left;width:166px;margin:4px 0 0 0;padding:7px 0 2px 0;background:url("../img/bg_line1.gif") repeat-x}
|
||||
#smart_editor2 .se2_qe11_1{float:left;width:99px}
|
||||
#smart_editor2 .se2_qe11_1 dt{float:left;width:56px;height:15px;padding:5px 0 0 0}
|
||||
#smart_editor2 .se2_qe11_1 dd{float:left;position:relative;width:38px;height:20px}
|
||||
#smart_editor2 .se2_qe11_1 .input_ty1{display:block;width:29px;height:15px;margin:0;*margin:-1px 0 1px 0;padding:3px 1px 0 5px;font-size:11px;letter-spacing:0;text-align:left}
|
||||
#smart_editor2 .se2_qe11_1 .se2_add{position:absolute;top:2px;right:3px;width:13px;height:8px;background:url("../img/ko_KR/btn_set.png?130306") -86px -54px no-repeat}
|
||||
#smart_editor2 .se2_qe11_1 .se2_del{position:absolute;top:10px;right:3px;width:13px;height:8px;background:url("../img/ko_KR/btn_set.png?130306") -86px -62px no-repeat}
|
||||
#smart_editor2 .se2_qe11_2{float:left;width:67px}
|
||||
#smart_editor2 .se2_qe11_2 dt{float:left;width:47px;margin:5px 0 0 0}
|
||||
#smart_editor2 .se2_qe11_2 dd{float:left;position:relative;width:20px}
|
||||
#smart_editor2 .se2_qe12{float:left;width:166px;margin:3px 0 0 0;padding:7px 0 0 0;background:url("../img/bg_line1.gif") repeat-x}
|
||||
#smart_editor2 .se2_qe12 dt{float:left;margin:5px 4px 0 0}
|
||||
#smart_editor2 .se2_qe12 dd{float:left;padding:0 0 6px 0}
|
||||
#smart_editor2 .se2_qe12 .se2_align0{float:left;width:19px;height:21px;background:url("../img/ko_KR/btn_set.png?130306") -276px -121px no-repeat}
|
||||
#smart_editor2 .se2_qe12 .se2_align1{float:left;width:19px;height:21px;background:url("../img/ko_KR/btn_set.png?130306") -295px -121px no-repeat}
|
||||
#smart_editor2 .se2_qe12 .se2_align2{float:left;width:20px;height:21px;background:url("../img/ko_KR/btn_set.png?130306") -314px -121px no-repeat}
|
||||
#smart_editor2 .se2_qe13{position:relative;z-index:10;zoom:1}
|
||||
#smart_editor2 .se2_qe13 dt{float:left;width:62px;padding:3px 0 0}
|
||||
#smart_editor2 .se2_qe13 dt input{width:15px;height:15px;margin:-1px 1px 1px -1px;vertical-align:middle;zoom:1}
|
||||
#smart_editor2 .se2_qe13 dt .se2_qdim2{width:32px}
|
||||
#smart_editor2 .se2_qe13 dd .se2_select_ty1{width:38px}
|
||||
#smart_editor2 .se2_qe13 dd .se2_select_ty1 span{width:15px}
|
||||
#smart_editor2 .se2_qe13 dd .input_ty1{width:20px}
|
||||
#smart_editor2 .se2_qe13 dd .se2_palette2 .input_ty1{width:67px}
|
||||
#smart_editor2 .se2_qe13 .se2_add{*top:3px}
|
||||
#smart_editor2 .se2_qe13 .se2_del{*top:11px}
|
||||
#smart_editor2 .se2_qe13 .se2_layer_b_style{right:-2px;_right:0}
|
||||
#smart_editor2 .se2_qe13 .se2_layer_b_style li span{width:auto;margin:0 4px 0 5px;padding-top:2px}
|
||||
#smart_editor2 .se2_qe13 dd{_display:inline;float:left;position:relative;width:29px;margin-right:5px;_margin-right:3px;zoom:1}
|
||||
#smart_editor2 .se2_qe13 dd .se2_palette h4{margin-top:9px;font-family:dotum;font-size:12px}
|
||||
#smart_editor2 .se2_qe13 dd.dd_type{width:38px}
|
||||
#smart_editor2 .se2_qe13 dd.dd_type2{width:37px;margin-right:3px}
|
||||
#smart_editor2 .se2_qe13 dd.dd_type2 .input_ty1{width:29px}
|
||||
#smart_editor2 .se2_qe13 dd.dd_type2 button{right:2px;_right:1px}
|
||||
#smart_editor2 .se2_qe13 dd.dd_type3{width:20px;margin:0}
|
||||
#smart_editor2 .se2_qe13_v1{_display:inline;float:left;margin:2px 0 1px}
|
||||
#smart_editor2 .se2_qe13_v1 dt{padding:4px 0 0 1px}
|
||||
#smart_editor2 .se2_qe13_v2{_display:inline;float:left;position:relative;z-index:100;width:165px;margin:4px 0 0 1px;zoom:1}
|
||||
#smart_editor2 .se2_qe13_v2 dd{width:18px;margin:0}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim1{clear:both;position:absolute;top:25px;left:115px;width:60px;height:23px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim2{clear:both;position:absolute;top:55px;left:24px;z-index:110;width:70px;height:22px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim3{clear:both;position:absolute;top:55px;left:118px;z-index:110;width:56px;height:22px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim4{clear:both;position:absolute;top:81px;left:23px;z-index:35;width:116px;height:35px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim5{clear:both;position:absolute;top:31px;left:106px;width:68px;height:26px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim6c{clear:both;position:absolute;top:25px;left:28px;width:29px;height:23px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim6r{clear:both;position:absolute;top:25px;left:57px;width:29px;height:23px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_highedit{float:right;width:56px;height:21px;margin:-27px 8px 0 0;background:url("../img/ko_KR/btn_set.png?130306") -329px -142px no-repeat}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim7{clear:both;position:absolute;top:55px;left:24px;z-index:110;width:150px;height:48px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim8{clear:both;position:absolute;top:105px;left:24px;z-index:110;width:150px;height:37px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim9{clear:both;position:absolute;top:55px;left:111px;z-index:110;width:65px;height:24px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim10{clear:both;position:absolute;top:55px;left:100px;z-index:110;width:77px;height:24px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
#smart_editor2 .se2_qeditor .se2_qdim11{clear:both;position:absolute;top:55px;left:65px;z-index:110;width:115px;height:24px;background:#fafafa;opacity:0.5;filter:alpha(opacity=50)}
|
||||
/* HELP : ACCESSIBILITY */
|
||||
#smart_editor2 .se2_accessibility{z-index:90}
|
||||
#smart_editor2 .se2_accessibility .se2_in_layer{width:568px;padding:0 10px;background:#fafafa;border:1px solid #bcbbbb}
|
||||
#smart_editor2 .se2_accessibility h3{margin:0 -10px;padding:6px 0 12px 0;background:url("../img/bg_find_h3.gif") repeat-x;font-size:12px;line-height:14px;letter-spacing:-1px}
|
||||
#smart_editor2 .se2_accessibility h3 strong{display:inline-block;padding:4px 0 3px 11px;color:#333;letter-spacing:0}
|
||||
#smart_editor2 .se2_accessibility .se2_close{position:absolute;top:10px;right:12px;width:13px;height:12px;background:url("../img/ko_KR/btn_set.png?130306") -155px -5px no-repeat}
|
||||
#smart_editor2 .se2_accessibility .box_help{padding:0 2px;margin-top:8px;background:url("../img/bg_help.gif") 0 100% no-repeat}
|
||||
#smart_editor2 .se2_accessibility .box_help div{overflow:hidden;padding:20px 21px 24px;border-top:1px solid #d0d0d0;color:#333}
|
||||
#smart_editor2 .se2_accessibility .box_help strong{display:block;margin-bottom:2px}
|
||||
#smart_editor2 .se2_accessibility .box_help p{margin-bottom:28px;line-height:1.5}
|
||||
#smart_editor2 .se2_accessibility .box_help ul{width:150%;margin-top:10px}
|
||||
#smart_editor2 .se2_accessibility .box_help li{position:relative;float:left;width:252px;padding:5px 0 5px 9px;margin-right:40px;background:url("../img/ko_KR/btn_set.png?130306") -475px -51px no-repeat;border-right:1px solid #f0f0f0;*zoom:1;line-height:1}
|
||||
#smart_editor2 .se2_accessibility .box_help li span{position:absolute;top:4px;left:138px;line-height:1.2}
|
||||
#smart_editor2 .se2_accessibility .se2_btns{padding:9px 0 10px;text-align:center}
|
||||
#smart_editor2 .se2_accessibility .se2_btns .se2_close2{width:39px;height:24px;background:url("../img/ko_KR/btn_set.png?130306") -235px -120px no-repeat}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
@charset "UTF-8";
|
||||
/* NHN Web Standardization Team (http://html.nhndesign.com/) HHJ 090226 */
|
||||
/* COMMON */
|
||||
.se2_outputarea,.se2_outputarea th,.se2_outputarea td{margin:0;padding:0;color:#666;font-size:12px;font-family:'돋움',Dotum,'굴림',Gulim,Helvetica,Sans-serif;line-height:1.5}
|
||||
.se2_outputarea p{margin:0;padding:0}
|
||||
.se2_outputarea a:hover{text-decoration:underline}
|
||||
.se2_outputarea a:link{color:#0000ff}
|
||||
.se2_outputarea ul{margin:0 0 0 40px;padding:0}
|
||||
.se2_outputarea ul li{margin:0;list-style-type:disc;padding:0}
|
||||
.se2_outputarea ul ul li{list-style-type:circle}
|
||||
.se2_outputarea ul ul ul li{list-style-type:square}
|
||||
.se2_outputarea img,.se2_outputarea fieldset{border:0}
|
||||
|
After Width: | Height: | Size: 115 B |
|
After Width: | Height: | Size: 526 B |
|
After Width: | Height: | Size: 331 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 159 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 103 B |
|
After Width: | Height: | Size: 43 B |
|
After Width: | Height: | Size: 56 B |
|
After Width: | Height: | Size: 941 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 104 B |
|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 155 B |
|
After Width: | Height: | Size: 270 B |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 1.4 MiB |