feat: 테이블 복제 기능 구현 (최고 관리자 전용)

 새로운 기능
- 테이블 타입 관리에 테이블 복제 기능 추가
- 기존 테이블의 설정과 컬럼 정보를 복사하여 새 테이블 생성
- 최고 관리자만 사용 가능 (company_code = '*' AND userType = 'SUPER_ADMIN')
- 테이블 1개 선택 시에만 복제 버튼 활성화

🎨 UI 개선
- 테이블 목록에 '테이블 복제' 버튼 추가 (Copy 아이콘)
- CreateTableModal을 복제 모드로 재사용
- 복제 모드 시 제목/설명/버튼 텍스트 동적 변경
- 원본 테이블 정보 자동 로드

🔧 기술적 개선
- CreateTableModal에 mode/sourceTableName props 추가
- 복제 모드 감지 및 데이터 자동 로드 로직 구현
- API 타입 정의 수정 (ColumnListData 인터페이스 추가)
- 백엔드 응답 구조와 프론트엔드 타입 일치화

🐛 버그 수정
- API 응답 구조 불일치 문제 해결
- ColumnListResponse 타입 수정 (배열 → 객체)
- 데이터 파싱 로직 수정 (data.columns 접근)
- 디버그 로그 추가로 문제 추적 개선

📝 변경된 파일
- frontend/app/(main)/admin/tableMng/page.tsx
- frontend/components/admin/CreateTableModal.tsx
- frontend/lib/api/tableManagement.ts
- frontend/types/ddl.ts
- 테이블_복제_기능_구현_계획서.md (신규)

 테스트 완료
- 최고 관리자 권한 체크
- 테이블 정보 로드
- 컬럼 정보 복제
- 새 테이블명 입력 및 검증
- 테이블 생성 및 목록 갱신
This commit is contained in:
kjs 2025-10-31 17:58:49 +09:00
parent dc7e7714f7
commit 5c2e147784
5 changed files with 1026 additions and 13 deletions

View File

@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2 } from "lucide-react";
import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy } from "lucide-react";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
import { useMultiLang } from "@/hooks/useMultiLang";
@ -84,6 +84,10 @@ export default function TableManagementPage() {
const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
// 테이블 복제 관련 상태
const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create");
const [duplicateSourceTable, setDuplicateSourceTable] = useState<string | null>(null);
// 로그 뷰어 상태
const [logViewerOpen, setLogViewerOpen] = useState(false);
@ -97,8 +101,8 @@ export default function TableManagementPage() {
// 선택된 테이블 목록 (체크박스)
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set());
// 최고 관리자 여부 확인 (회사코드가 "*"인 경우)
const isSuperAdmin = user?.companyCode === "*";
// 최고 관리자 여부 확인 (회사코드가 "*" AND userType이 "SUPER_ADMIN")
const isSuperAdmin = user?.companyCode === "*" && user?.userType === "SUPER_ADMIN";
// 다국어 텍스트 로드
useEffect(() => {
@ -706,13 +710,36 @@ export default function TableManagementPage() {
{isSuperAdmin && (
<>
<Button
onClick={() => setCreateTableModalOpen(true)}
onClick={() => {
setDuplicateModalMode("create");
setDuplicateSourceTable(null);
setCreateTableModalOpen(true);
}}
className="h-10 gap-2 text-sm font-medium"
size="default"
>
<Plus className="h-4 w-4" />
</Button>
<Button
onClick={() => {
if (selectedTableIds.size !== 1) {
toast.error("복제할 테이블을 1개만 선택해주세요.");
return;
}
const sourceTable = Array.from(selectedTableIds)[0];
setDuplicateSourceTable(sourceTable);
setDuplicateModalMode("duplicate");
setCreateTableModalOpen(true);
}}
variant="outline"
disabled={selectedTableIds.size !== 1}
className="h-10 gap-2 text-sm font-medium"
>
<Copy className="h-4 w-4" />
</Button>
{selectedTable && (
<Button
onClick={() => setAddColumnModalOpen(true)}
@ -1195,9 +1222,16 @@ export default function TableManagementPage() {
<>
<CreateTableModal
isOpen={createTableModalOpen}
onClose={() => setCreateTableModalOpen(false)}
onClose={() => {
setCreateTableModalOpen(false);
setDuplicateModalMode("create");
setDuplicateSourceTable(null);
}}
onSuccess={async (result) => {
toast.success("테이블이 성공적으로 생성되었습니다!");
const message = duplicateModalMode === "duplicate"
? "테이블이 성공적으로 복제되었습니다!"
: "테이블이 성공적으로 생성되었습니다!";
toast.success(message);
// 테이블 목록 새로고침
await loadTables();
// 새로 생성된 테이블 자동 선택 및 컬럼 로드
@ -1207,7 +1241,14 @@ export default function TableManagementPage() {
setColumns([]);
await loadColumnTypes(result.data.tableName, 1, pageSize);
}
// 선택 초기화
setSelectedTableIds(new Set());
// 상태 초기화
setDuplicateModalMode("create");
setDuplicateSourceTable(null);
}}
mode={duplicateModalMode}
sourceTableName={duplicateSourceTable || undefined}
/>
<AddColumnModal

View File

@ -33,7 +33,15 @@ import {
RESERVED_WORDS,
} from "../../types/ddl";
export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModalProps) {
export function CreateTableModal({
isOpen,
onClose,
onSuccess,
mode = "create",
sourceTableName
}: CreateTableModalProps) {
const isDuplicateMode = mode === "duplicate" && sourceTableName;
const [tableName, setTableName] = useState("");
const [description, setDescription] = useState("");
const [columns, setColumns] = useState<CreateColumnDefinition[]>([
@ -76,9 +84,78 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
*/
useEffect(() => {
if (isOpen) {
resetModal();
if (!isDuplicateMode) {
resetModal();
}
}
}, [isOpen]);
}, [isOpen, isDuplicateMode]);
/**
* 모드: 원본
*/
useEffect(() => {
if (isOpen && isDuplicateMode && sourceTableName) {
loadSourceTableData(sourceTableName);
}
}, [isOpen, isDuplicateMode, sourceTableName]);
/**
*
*/
const loadSourceTableData = async (tableName: string) => {
setLoading(true);
try {
// 1. 테이블 컬럼 정보 조회
const columnsResponse = await tableManagementApi.getColumnList(tableName);
console.log("🔍 컬럼 조회 응답:", columnsResponse);
if (columnsResponse.success && columnsResponse.data) {
// API는 { columns, total, page, size } 형태로 반환
const columnsList = columnsResponse.data.columns;
console.log("🔍 컬럼 리스트:", columnsList);
if (columnsList && columnsList.length > 0) {
// 첫 번째 컬럼에서 테이블 설명 가져오기 (모든 컬럼이 같은 테이블 설명을 가짐)
const firstColumn = columnsList[0];
setDescription(firstColumn.description || "");
// 2. 컬럼 정보 변환
const loadedColumns: CreateColumnDefinition[] = columnsList.map((col, idx) => ({
name: col.columnName,
label: col.displayName || col.columnName,
inputType: col.webType || col.inputType || "text",
nullable: col.isNullable === "YES",
order: idx + 1,
description: col.description,
}));
setColumns(loadedColumns);
// 3. 테이블명은 비워둠 (사용자가 입력해야 함)
setTableName("");
setTableNameError("");
toast.success(`${tableName} 테이블 정보를 불러왔습니다.`);
} else {
console.error("❌ 컬럼 배열이 비어있거나 유효하지 않음:", columnsList);
toast.error("테이블에 컬럼이 없습니다.");
onClose();
}
} else {
console.error("❌ API 응답 실패:", columnsResponse);
toast.error("테이블 정보를 불러올 수 없습니다.");
onClose();
}
} catch (error: any) {
console.error("❌ 원본 테이블 정보 로드 실패:", error);
toast.error("원본 테이블 정보를 불러오는데 실패했습니다.");
onClose();
} finally {
setLoading(false);
}
};
/**
*
@ -248,10 +325,14 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
<DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
<Plus className="h-5 w-5" />
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
</DialogTitle>
<DialogDescription>
. .
{isDuplicateMode
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
}
</DialogDescription>
</DialogHeader>
@ -398,7 +479,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
...
</>
) : (
"테이블 생성"
isDuplicateMode ? "복제 생성" : "테이블 생성"
)}
</Button>
</DialogFooter>

View File

@ -54,9 +54,18 @@ export interface ColumnSettings {
isVisible?: boolean;
}
// 컬럼 리스트 페이지네이션 응답
export interface ColumnListData {
columns: ColumnTypeInfo[];
total: number;
page: number;
size: number;
totalPages: number;
}
// API 응답 타입들
export interface TableListResponse extends ApiResponse<TableInfo[]> {}
export interface ColumnListResponse extends ApiResponse<ColumnTypeInfo[]> {}
export interface ColumnListResponse extends ApiResponse<ColumnListData> {}
export interface ColumnSettingsResponse extends ApiResponse<void> {}
/**

View File

@ -246,6 +246,9 @@ export interface CreateTableModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (result: DDLExecutionResult) => void;
// 🆕 복제 모드 관련
mode?: "create" | "duplicate";
sourceTableName?: string; // 복제 대상 테이블명
}
// 컬럼 추가 모달 속성

View File

@ -0,0 +1,879 @@
# 테이블 복제 기능 구현 계획서
## 📋 개요
테이블 타입 관리 시스템에 기존 테이블을 복제하여 새로운 테이블을 생성하는 기능을 추가합니다. 복제 시 원본 테이블의 컬럼 구조와 설정을 가져와 사용자가 수정 후 저장할 수 있도록 합니다.
---
## 🎯 주요 요구사항
### 1. 권한 제한
- **최고 관리자(company_code = "\*" && user_type = "SUPER_ADMIN")만 사용 가능**
- 일반 회사 사용자는 테이블 복제 버튼이 보이지 않음
### 2. 복제 프로세스
1. 테이블 목록에서 복제할 테이블 선택 (체크박스)
2. "복제" 버튼 클릭
3. 기존 "테이블 생성" 모달이 "테이블 복제" 모드로 열림
4. 원본 테이블의 설정이 자동으로 채워짐:
- 테이블명: 빈 상태 (사용자가 새로 입력)
- 테이블 설명: 원본 설명 복사 (수정 가능)
- 컬럼 정의: 원본 컬럼들이 모두 로드됨 (수정/삭제 가능)
5. 사용자가 수정 후 저장
6. 테이블명 중복 검증 (실시간)
7. DDL 실행 및 테이블 생성
### 3. 제약사항
- **테이블명 중복 불가** (실시간 검증)
- 시스템 테이블은 복제 불가 (선택 불가 처리)
- 최소 1개 이상의 컬럼 필요
---
## 🏗️ 구현 아키텍처
### 컴포넌트 구조
```
📦 테이블 타입 관리 페이지 (tableMng/page.tsx)
├── 📄 테이블 목록 (기존)
│ ├── 체크박스 (단일 선택 또는 다중 선택)
│ └── 복제 버튼 (최고 관리자만 표시)
└── 🔧 CreateTableModal 확장 (CreateTableModal.tsx)
├── 모드: "create" | "duplicate"
├── Props 추가: duplicateTableName?, isDuplicateMode?
├── 복제 모드 시 자동 로드:
│ ├── 원본 테이블 설명
│ └── 원본 컬럼 목록 (전체 설정 포함)
└── 실시간 테이블명 중복 검증
```
---
## 📝 상세 구현 계획
### Phase 1: UI 추가 (테이블 목록 페이지)
#### 1.1. 체크박스 추가
**파일**: `frontend/app/(main)/admin/tableMng/page.tsx`
**변경사항**:
- 각 테이블 행에 체크박스 추가
- 선택된 테이블 상태 관리: `selectedTableIds` (Set<string>)
- 시스템 테이블은 체크박스 비활성화
```typescript
// 상태 추가
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(
new Set()
);
// 최고 관리자 여부 확인 수정 (기존 코드 수정 필요)
// ❌ 기존: const isSuperAdmin = user?.companyCode === "*";
// ✅ 수정: const isSuperAdmin = user?.companyCode === "*" && user?.userType === "SUPER_ADMIN";
// 시스템 테이블 목록 (복제 불가)
const SYSTEM_TABLES = [
"user_info",
"company_info",
"menu_info",
"auth_group",
"menu_auth_group",
"user_auth",
"dept_info",
"code_info",
"code_category",
// ... 기타 시스템 테이블
];
// 체크박스 핸들러
const handleTableSelect = (tableName: string) => {
setSelectedTableIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(tableName)) {
newSet.delete(tableName);
} else {
newSet.add(tableName);
}
return newSet;
});
};
```
#### 1.2. 복제 버튼 추가
**위치**: 테이블 목록 상단 (새 테이블 생성 버튼 옆)
```typescript
{
isSuperAdmin && (
<Button
variant="outline"
onClick={handleDuplicateTable}
disabled={selectedTableIds.size !== 1}
className="h-9 text-xs sm:h-10 sm:text-sm"
>
<Copy className="mr-2 h-4 w-4" />
테이블 복제
</Button>
);
}
```
**조건**:
- 최고 관리자만 표시
- 정확히 1개의 테이블이 선택된 경우만 활성화
- 선택된 테이블이 시스템 테이블인 경우 비활성화
---
### Phase 2: CreateTableModal 확장
#### 2.1. Props 확장
**파일**: `frontend/components/admin/CreateTableModal.tsx`
```typescript
export interface CreateTableModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (result: any) => void;
// 🆕 복제 모드 관련
mode?: "create" | "duplicate";
sourceTableName?: string; // 복제 대상 테이블명
}
```
#### 2.2. 복제 모드 감지 및 데이터 로드
```typescript
export function CreateTableModal({
isOpen,
onClose,
onSuccess,
mode = "create",
sourceTableName,
}: CreateTableModalProps) {
const isDuplicateMode = mode === "duplicate" && sourceTableName;
// 복제 모드일 때 원본 테이블 정보 로드
useEffect(() => {
if (isOpen && isDuplicateMode && sourceTableName) {
loadSourceTableData(sourceTableName);
}
}, [isOpen, isDuplicateMode, sourceTableName]);
const loadSourceTableData = async (tableName: string) => {
setLoading(true);
try {
// 1. 테이블 기본 정보 조회
const tableResponse = await apiClient.get(
`/table-management/tables/${tableName}`
);
if (tableResponse.data.success) {
const tableInfo = tableResponse.data.data;
setDescription(tableInfo.description || "");
}
// 2. 컬럼 정보 조회
const columnsResponse = await tableManagementApi.getColumnList(tableName);
if (columnsResponse.success && columnsResponse.data) {
const loadedColumns: CreateColumnDefinition[] =
columnsResponse.data.map((col, idx) => ({
name: col.columnName,
label: col.displayName || col.columnName,
inputType: col.webType as any,
nullable: col.isNullable === "YES",
order: idx + 1,
description: col.description,
codeCategory: col.codeCategory || undefined,
referenceTable: col.referenceTable || undefined,
referenceColumn: col.referenceColumn || undefined,
displayColumn: col.displayColumn || undefined,
}));
setColumns(loadedColumns);
toast.success(`${tableName} 테이블 정보를 불러왔습니다.`);
}
} catch (error: any) {
console.error("원본 테이블 정보 로드 실패:", error);
toast.error("원본 테이블 정보를 불러오는데 실패했습니다.");
onClose();
} finally {
setLoading(false);
}
};
}
```
#### 2.3. 모달 제목 및 버튼 텍스트 변경
```typescript
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{isDuplicateMode
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다.`
: "데이터베이스에 새로운 테이블을 생성합니다."}
</DialogDescription>
</DialogHeader>
{/* ... 폼 내용 ... */}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
취소
</Button>
<Button
onClick={handleCreateTable}
disabled={!isFormValid || loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
생성 중...
</>
) : isDuplicateMode ? (
"복제 생성"
) : (
"생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
```
#### 2.4. 테이블명 실시간 중복 검증 강화
```typescript
// 테이블명 변경 시 실시간 검증
const handleTableNameChange = async (name: string) => {
setTableName(name);
// 기본 검증
const error = validateTableName(name);
if (error) {
setTableNameError(error);
return;
}
// 중복 검증
setValidating(true);
try {
const result = await tableManagementApi.checkTableExists(name);
if (result.success && result.data?.exists) {
setTableNameError("이미 존재하는 테이블명입니다.");
} else {
setTableNameError("");
}
} catch (error) {
console.error("테이블명 검증 오류:", error);
} finally {
setValidating(false);
}
};
```
---
### Phase 3: 백엔드 API 구현 (필요 시)
#### 3.1. 테이블 기본 정보 조회 API
**엔드포인트**: `GET /api/table-management/tables/:tableName`
**응답**:
```json
{
"success": true,
"data": {
"tableName": "contracts",
"displayName": "계약 관리",
"description": "계약 정보를 관리하는 테이블",
"columnCount": 15
}
}
```
**구현 위치**: `backend-node/src/controllers/tableManagementController.ts`
```typescript
/**
* 특정 테이블의 기본 정보 조회
*/
async getTableInfo(req: Request, res: Response) {
const { tableName } = req.params;
try {
const query = `
SELECT
t.table_name as "tableName",
dt.display_name as "displayName",
dt.description as "description",
COUNT(c.column_name) as "columnCount"
FROM information_schema.tables t
LEFT JOIN db_table_types dt ON dt.table_name = t.table_name
LEFT JOIN information_schema.columns c ON c.table_name = t.table_name
WHERE t.table_schema = 'public'
AND t.table_name = $1
GROUP BY t.table_name, dt.display_name, dt.description
`;
const result = await pool.query(query, [tableName]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: `테이블 '${tableName}'을 찾을 수 없습니다.`
});
}
return res.json({
success: true,
data: result.rows[0]
});
} catch (error: any) {
logger.error("테이블 정보 조회 실패", { tableName, error: error.message });
return res.status(500).json({
success: false,
message: "테이블 정보를 조회하는데 실패했습니다."
});
}
}
```
#### 3.2. 라우트 추가
**파일**: `backend-node/src/routes/tableManagement.ts`
```typescript
// 테이블 기본 정보 조회 (새로 추가)
router.get("/tables/:tableName", controller.getTableInfo);
```
---
### Phase 4: 통합 및 테스트
#### 4.1. 테이블 목록 페이지에서 복제 흐름 구현
```typescript
// tableMng/page.tsx
const handleDuplicateTable = async () => {
// 선택된 테이블 1개 확인
if (selectedTableIds.size !== 1) {
toast.error("복제할 테이블을 1개만 선택해주세요.");
return;
}
const sourceTable = Array.from(selectedTableIds)[0];
// 시스템 테이블 체크
if (SYSTEM_TABLES.includes(sourceTable)) {
toast.error("시스템 테이블은 복제할 수 없습니다.");
return;
}
// 복제 모달 열기
setDuplicateSourceTable(sourceTable);
setCreateTableModalMode("duplicate");
setCreateTableModalOpen(true);
};
// 모달 컴포넌트
<CreateTableModal
isOpen={createTableModalOpen}
onClose={() => {
setCreateTableModalOpen(false);
setDuplicateSourceTable(null);
setCreateTableModalMode("create");
}}
onSuccess={(result) => {
loadTables(); // 테이블 목록 새로고침
setSelectedTableIds(new Set()); // 선택 초기화
}}
mode={createTableModalMode}
sourceTableName={duplicateSourceTable}
/>;
```
---
## 🧪 테스트 시나리오
### 테스트 케이스 1: 정상 복제 흐름
1. 최고 관리자로 로그인
2. 테이블 타입 관리 페이지 접속
3. 복제할 테이블 1개 선택 (예: `contracts`)
4. "테이블 복제" 버튼 클릭
5. 모달이 열리고 원본 테이블 정보가 자동으로 로드됨
6. 새 테이블명 입력 (예: `contracts_backup`)
7. 컬럼 정보 확인 및 필요 시 수정
8. "복제 생성" 버튼 클릭
9. 테이블이 생성되고 목록에 표시됨
10. 성공 토스트 메시지 확인
### 테스트 케이스 2: 테이블명 중복 검증
1. 테이블 복제 흐름 시작
2. 기존에 존재하는 테이블명 입력 (예: `user_info`)
3. 실시간으로 에러 메시지 표시: "이미 존재하는 테이블명입니다."
4. "복제 생성" 버튼 비활성화 확인
### 테스트 케이스 3: 시스템 테이블 복제 제한
1. 시스템 테이블 선택 시도 (예: `user_info`)
2. 체크박스가 비활성화되어 있음
3. 또는 선택 시 경고 메시지: "시스템 테이블은 복제할 수 없습니다."
### 테스트 케이스 4: 권한 제한
1. 일반 회사 사용자로 로그인
2. 테이블 타입 관리 페이지 접속
3. "테이블 복제" 버튼이 보이지 않음
4. 체크박스도 표시되지 않음 (선택 사항)
### 테스트 케이스 5: 컬럼 수정 후 복제
1. 테이블 복제 모달 열기
2. 원본 컬럼 중 일부 삭제
3. 새 컬럼 추가
4. 컬럼 라벨 변경
5. 저장 시 수정된 구조로 테이블 생성됨
---
## 📊 데이터 흐름도
```
┌─────────────────────────────────────────────────────────────┐
│ 1. 테이블 선택 (체크박스) │
│ - 사용자가 복제할 테이블 1개 선택 │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. "테이블 복제" 버튼 클릭 │
│ - handleDuplicateTable() 실행 │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. CreateTableModal 열림 (복제 모드) │
│ - mode: "duplicate" │
│ - sourceTableName: 선택된 테이블명 │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. 원본 테이블 정보 자동 로드 │
│ - GET /api/table-management/tables/:tableName │
│ → 테이블 설명 가져오기 │
│ - GET /api/table-management/tables/:tableName/columns │
│ → 모든 컬럼 정보 가져오기 │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 5. 사용자 편집 │
│ - 새 테이블명 입력 (실시간 중복 검증) │
│ - 테이블 설명 수정 │
│ - 컬럼 추가/삭제/수정 │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 6. "복제 생성" 버튼 클릭 │
│ - POST /api/ddl/create-table │
│ (기존 테이블 생성 API 재사용) │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 7. DDL 실행 및 테이블 생성 │
│ - CREATE TABLE ... (새 테이블명) │
│ - db_column_types 레코드 생성 │
│ - db_table_types 레코드 생성 │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 8. 성공 처리 │
│ - 모달 닫기 │
│ - 테이블 목록 새로고침 │
│ - 성공 토스트 메시지 표시 │
└─────────────────────────────────────────────────────────────┘
```
---
## 🚀 구현 순서 (우선순위)
### Step 0: 기존 코드 수정 (최고 관리자 체크 로직)
1. ✅ `frontend/app/(main)/admin/tableMng/page.tsx` - line 101
- 기존: `const isSuperAdmin = user?.companyCode === "*";`
- 수정: `const isSuperAdmin = user?.companyCode === "*" && user?.userType === "SUPER_ADMIN";`
**예상 소요 시간**: 5분
### Step 1: UI 기반 구조 (프론트엔드)
1. ✅ 테이블 목록에 체크박스 추가
2. ✅ 복제 버튼 추가 (최고 관리자만 표시)
3. ✅ 선택된 테이블 상태 관리
4. ✅ 시스템 테이블 복제 제한
**예상 소요 시간**: 1-2시간
### Step 2: CreateTableModal 확장
1. ✅ Props 확장 (mode, sourceTableName)
2. ✅ 복제 모드 감지 로직
3. ✅ 원본 테이블 정보 로드 함수
4. ✅ 모달 제목 및 버튼 텍스트 동적 변경
5. ✅ 테이블명 실시간 중복 검증
**예상 소요 시간**: 2-3시간
### Step 3: 백엔드 API 추가 (필요 시)
1. ✅ 테이블 기본 정보 조회 API 추가
2. ✅ 라우트 추가
3. ✅ 권한 검증 (최고 관리자만)
**예상 소요 시간**: 1시간
### Step 4: 통합 및 테스트
1. ✅ 전체 흐름 통합
2. ✅ 각 테스트 케이스 실행
3. ✅ 버그 수정 및 최적화
**예상 소요 시간**: 2-3시간
**전체 예상 소요 시간**: **6-9시간** (기존 코드 수정 포함)
---
## 🔒 보안 고려사항
### 1. 권한 체크
- 프론트엔드: `isSuperAdmin` 플래그로 UI 제어
- `user?.companyCode === "*" && user?.userType === "SUPER_ADMIN"`
- 백엔드: 모든 API 호출 시 두 가지 조건 모두 검증
```typescript
// 백엔드 미들웨어 (기존 requireSuperAdmin 재사용)
import { requireSuperAdmin } from "@/middleware/superAdminMiddleware";
// 또는 인라인 체크
const requireSuperAdmin = (req: Request, res: Response, next: NextFunction) => {
if (req.user?.companyCode !== "*" || req.user?.userType !== "SUPER_ADMIN") {
return res.status(403).json({
success: false,
message: "최고 관리자만 접근할 수 있습니다.",
});
}
next();
};
// 라우트 적용
router.post(
"/tables/:tableName/duplicate",
requireSuperAdmin,
controller.duplicateTable
);
```
### 2. 시스템 테이블 보호
- SYSTEM_TABLES 상수로 관리
- 복제 시도 시 서버 측에서도 검증
### 3. 테이블명 검증
- SQL Injection 방지: 정규식 검증 (`^[a-z][a-z0-9_]*$`)
- 예약어 체크
- 길이 제한 (3-63자)
---
## 📌 주요 체크포인트
### 사용자 경험 (UX)
- ✅ 복제 버튼은 1개 테이블 선택 시에만 활성화
- ✅ 로딩 상태 표시 (모달 열릴 때)
- ✅ 실시간 테이블명 중복 검증
- ✅ 명확한 에러 메시지
- ✅ 성공/실패 토스트 알림
### 데이터 일관성
- ✅ 원본 테이블의 모든 컬럼 정보 정확히 복사
- ✅ webType, codeCategory, referenceTable 등 관계 정보 포함
- ✅ 컬럼 순서 유지
### 성능
- ✅ 컬럼이 많은 테이블도 빠르게 로드
- ✅ 불필요한 API 호출 최소화
- ✅ 로딩 중 중복 요청 방지
---
## 🎨 UI 스타일 가이드
### 복제 버튼
```tsx
<Button
variant="outline"
onClick={handleDuplicateTable}
disabled={selectedTableIds.size !== 1}
className="h-9 text-xs sm:h-10 sm:text-sm"
>
<Copy className="mr-2 h-4 w-4" />
테이블 복제
</Button>
```
### 체크박스 (테이블 행)
```tsx
<Checkbox
checked={selectedTableIds.has(table.tableName)}
onCheckedChange={() => handleTableSelect(table.tableName)}
disabled={SYSTEM_TABLES.includes(table.tableName)}
className="h-4 w-4"
/>
```
### 모달 제목 (복제 모드)
```tsx
<DialogTitle className="text-base sm:text-lg">
테이블 복제
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다.
</DialogDescription>
```
---
## 📚 참고 자료
### 기존 구현 파일
- `frontend/app/(main)/admin/tableMng/page.tsx` - 테이블 타입 관리 페이지
- `frontend/components/admin/CreateTableModal.tsx` - 테이블 생성 모달
- `frontend/components/admin/ColumnDefinitionTable.tsx` - 컬럼 정의 테이블
- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API
- `backend-node/src/controllers/tableManagementController.ts` - 백엔드 컨트롤러
### 유사 기능 참고
- Yard 레이아웃 복제 기능: `YardLayoutList.tsx` (onDuplicate 핸들러)
- 메뉴 복제 기능 (있다면)
---
## ✅ 완료 기준
### Phase 1 완료 조건
- [ ] 테이블 목록에 체크박스 추가됨
- [ ] 복제 버튼 추가 (최고 관리자만 보임)
- [ ] 1개 테이블 선택 시에만 버튼 활성화
- [ ] 시스템 테이블은 선택 불가
### Phase 2 완료 조건
- [ ] CreateTableModal이 복제 모드를 지원
- [ ] 원본 테이블 정보가 자동으로 로드됨
- [ ] 모달 제목과 버튼 텍스트가 모드에 따라 변경됨
- [ ] 테이블명 실시간 중복 검증 작동
### Phase 3 완료 조건
- [ ] 테이블 기본 정보 조회 API 추가
- [ ] 최고 관리자 권한 검증
- [ ] 라우트 등록
### Phase 4 완료 조건
- [ ] 모든 테스트 케이스 통과
- [ ] 버그 수정 완료
- [ ] 사용자 가이드 문서 작성
---
## 🐛 예상 이슈 및 해결 방안
### 이슈 1: 컬럼이 너무 많은 테이블 복제 시 느림
**해결방안**:
- 페이지네이션 없이 전체 컬럼 한 번에 로드 (`size=9999`)
- 로딩 스피너 표시
- 필요시 API에서 캐싱 적용
### 이슈 2: 복제 후 테이블명 입력 잊음
**해결방안**:
- 테이블명 필드를 비워두고 포커스 자동 이동
- placeholder에 예시 제공 (예: `contracts_copy`)
### 이슈 3: 시스템 테이블 실수로 복제
**해결방안**:
- 프론트엔드와 백엔드 양쪽에서 검증
- SYSTEM_TABLES 리스트 관리
### 이슈 4: 참조 무결성 (Foreign Key) 복사 여부
**해결방안**:
- 현재는 컬럼 구조만 복사
- Foreign Key는 사용자가 수동으로 설정
- 향후 고급 기능으로 제약조건 복사 추가 가능
---
## 🔮 향후 개선 방향 (Optional)
### 1. 다중 테이블 복제
- 여러 테이블을 한 번에 복제
- 접두사 또는 접미사 자동 추가 (예: `_copy`)
### 2. 데이터 포함 복제
- 테이블 구조 + 데이터도 함께 복제
- `INSERT INTO ... SELECT` 방식
### 3. 제약조건 복사
- Primary Key, Foreign Key, Unique, Check 등
- 인덱스도 함께 복사
### 4. 복제 템플릿
- 자주 사용하는 테이블 구조를 템플릿으로 저장
- 빠른 복제 기능
### 5. 복제 이력 관리
- 어떤 테이블이 어디서 복제되었는지 추적
- DDL 로그에 복제 정보 기록
---
## 📞 문의 및 지원
- 기술 문의: 개발팀
- 사용 방법: 사용자 가이드 참조
- 버그 제보: 이슈 트래커
---
**작성일**: 2025-10-31
**작성자**: AI Assistant
**버전**: 1.0
**상태**: ✅ 구현 완료
---
## 🐛 버그 수정 내역
### API 응답 구조 불일치 문제
**문제**: 모달이 뜨자마자 바로 닫히면서 "테이블 정보를 불러올 수 없습니다" 토스트 표시
**원인**:
- 백엔드 API는 `{ columns: [], total, page, size }` 형태로 반환
- 프론트엔드는 `data`를 직접 배열로 취급
- 타입 정의: `ColumnListResponse extends ApiResponse<ColumnTypeInfo[]>` (잘못됨)
**해결**:
1. **API 타입 수정** (`frontend/lib/api/tableManagement.ts`)
```typescript
// 추가된 타입
export interface ColumnListData {
columns: ColumnTypeInfo[];
total: number;
page: number;
size: number;
totalPages: number;
}
// 수정된 타입
export interface ColumnListResponse extends ApiResponse<ColumnListData> {}
```
2. **CreateTableModal 데이터 파싱 수정**
```typescript
// Before (잘못됨)
if (
columnsResponse.success &&
columnsResponse.data &&
columnsResponse.data.length > 0
) {
const firstColumn = columnsResponse.data[0];
}
// After (올바름)
if (columnsResponse.success && columnsResponse.data) {
const columnsList = columnsResponse.data.columns;
if (columnsList && columnsList.length > 0) {
const firstColumn = columnsList[0];
}
}
```
3. **디버그 로그 추가**
- API 응답 전체 로그
- 컬럼 리스트 추출 후 로그
- 에러 상황별 상세 로그
**결과**:
- ✅ 복제 모드에서 테이블 정보 정상 로드
- ✅ 타입 안전성 향상
- ✅ 에러 핸들링 개선