1091 lines
31 KiB
Markdown
1091 lines
31 KiB
Markdown
# 📊 테이블 동적 생성 기능 개발 계획서
|
||
|
||
## 📋 프로젝트 개요
|
||
|
||
### 🎯 목적
|
||
|
||
현재 테이블 타입 관리 시스템에 **실제 데이터베이스 테이블과 컬럼을 생성**하는 기능을 추가하여, 최고 관리자가 동적으로 새로운 테이블을 생성하고 기존 테이블에 컬럼을 추가할 수 있는 시스템을 구축합니다.
|
||
|
||
### 🔐 핵심 보안 요구사항
|
||
|
||
- **최고 관리자 전용**: 회사코드가 `*`인 사용자만 DDL 실행 가능
|
||
- **시스템 테이블 보호**: 핵심 시스템 테이블 수정 금지
|
||
- **SQL 인젝션 방지**: 모든 입력값에 대한 엄격한 검증
|
||
- **트랜잭션 안전성**: DDL 실행과 메타데이터 저장의 원자성 보장
|
||
|
||
---
|
||
|
||
## 🔍 현재 시스템 분석
|
||
|
||
### ✅ 기존 기능
|
||
|
||
- **테이블 조회**: `information_schema`를 통한 기존 테이블 스캔
|
||
- **컬럼 관리**: 컬럼별 웹타입 설정 및 메타데이터 관리
|
||
- **데이터 CRUD**: 기존 테이블의 데이터 조작
|
||
- **권한 관리**: 회사별 데이터 접근 제어
|
||
|
||
### ❌ 누락 기능
|
||
|
||
- **실제 테이블 생성**: DDL `CREATE TABLE` 실행
|
||
- **컬럼 추가**: DDL `ALTER TABLE ADD COLUMN` 실행
|
||
- **스키마 변경**: 데이터베이스 구조 변경
|
||
|
||
---
|
||
|
||
## 🚀 개발 단계별 계획
|
||
|
||
### 📦 Phase 1: 권한 시스템 강화 (2일)
|
||
|
||
#### 1.1 슈퍼관리자 미들웨어 구현
|
||
|
||
```typescript
|
||
// backend-node/src/middleware/superAdminMiddleware.ts
|
||
export const requireSuperAdmin = (
|
||
req: AuthenticatedRequest,
|
||
res: Response,
|
||
next: NextFunction
|
||
): void => {
|
||
if (!req.user || req.user.companyCode !== "*") {
|
||
return res.status(403).json({
|
||
success: false,
|
||
error: {
|
||
code: "SUPER_ADMIN_REQUIRED",
|
||
details: "최고 관리자 권한이 필요합니다.",
|
||
},
|
||
});
|
||
}
|
||
next();
|
||
};
|
||
```
|
||
|
||
#### 1.2 권한 검증 로직 확장
|
||
|
||
- 사용자 세션 유효성 확인
|
||
- DDL 실행 권한 이중 검증
|
||
- 로그 기록 및 감사 추적
|
||
|
||
### 📦 Phase 2: DDL 실행 서비스 구현 (3일)
|
||
|
||
#### 2.1 DDL 실행 서비스 클래스
|
||
|
||
```typescript
|
||
// backend-node/src/services/ddlExecutionService.ts
|
||
export class DDLExecutionService {
|
||
/**
|
||
* 새 테이블 생성
|
||
*/
|
||
async createTable(
|
||
tableName: string,
|
||
columns: CreateColumnDefinition[],
|
||
userCompanyCode: string
|
||
): Promise<void> {
|
||
// 권한 검증
|
||
this.validateSuperAdminPermission(userCompanyCode);
|
||
|
||
// 테이블명 검증
|
||
this.validateTableName(tableName);
|
||
|
||
// DDL 쿼리 생성 및 실행
|
||
const ddlQuery = this.generateCreateTableQuery(tableName, columns);
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
// 1. 테이블 생성
|
||
await tx.$executeRawUnsafe(ddlQuery);
|
||
|
||
// 2. 메타데이터 저장
|
||
await this.saveTableMetadata(tx, tableName, columns);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 기존 테이블에 컬럼 추가
|
||
*/
|
||
async addColumn(
|
||
tableName: string,
|
||
column: CreateColumnDefinition,
|
||
userCompanyCode: string
|
||
): Promise<void> {
|
||
// 유사한 구조로 구현
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.2 DDL 쿼리 생성기
|
||
|
||
- **CREATE TABLE**: 기본 컬럼(id, created_date, updated_date, company_code) 자동 포함
|
||
- **ALTER TABLE**: 안전한 컬럼 추가
|
||
- **타입 매핑**: 웹타입을 PostgreSQL 타입으로 변환
|
||
|
||
```typescript
|
||
private mapWebTypeToPostgresType(webType: string, length?: number): string {
|
||
const typeMap: Record<string, string> = {
|
||
'text': length ? `varchar(${length})` : 'text',
|
||
'number': 'integer',
|
||
'decimal': 'numeric(10,2)',
|
||
'date': 'date',
|
||
'datetime': 'timestamp',
|
||
'boolean': 'boolean',
|
||
'code': 'varchar(100)',
|
||
'entity': 'integer',
|
||
'file': 'text',
|
||
'email': 'varchar(255)'
|
||
};
|
||
return typeMap[webType] || 'text';
|
||
}
|
||
```
|
||
|
||
### 📦 Phase 3: API 엔드포인트 구현 (2일)
|
||
|
||
#### 3.1 DDL 컨트롤러
|
||
|
||
```typescript
|
||
// backend-node/src/controllers/ddlController.ts
|
||
export class DDLController {
|
||
/**
|
||
* POST /api/ddl/tables - 새 테이블 생성
|
||
*/
|
||
static async createTable(req: AuthenticatedRequest, res: Response) {
|
||
try {
|
||
const { tableName, columns, description } = req.body;
|
||
const userCompanyCode = req.user?.companyCode;
|
||
|
||
const ddlService = new DDLExecutionService();
|
||
await ddlService.createTable(tableName, columns, userCompanyCode);
|
||
|
||
res.json({
|
||
success: true,
|
||
message: `테이블 '${tableName}'이 성공적으로 생성되었습니다.`,
|
||
data: { tableName },
|
||
});
|
||
} catch (error) {
|
||
logger.error("테이블 생성 실패:", error);
|
||
res.status(400).json({
|
||
success: false,
|
||
error: { code: "TABLE_CREATION_FAILED", details: error.message },
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* POST /api/ddl/tables/:tableName/columns - 컬럼 추가
|
||
*/
|
||
static async addColumn(req: AuthenticatedRequest, res: Response) {
|
||
// 컬럼 추가 로직
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.2 라우팅 설정
|
||
|
||
```typescript
|
||
// backend-node/src/routes/ddlRoutes.ts
|
||
import { requireSuperAdmin } from "../middleware/superAdminMiddleware";
|
||
|
||
const router = express.Router();
|
||
|
||
router.post("/tables", requireSuperAdmin, DDLController.createTable);
|
||
router.post(
|
||
"/tables/:tableName/columns",
|
||
requireSuperAdmin,
|
||
DDLController.addColumn
|
||
);
|
||
|
||
export default router;
|
||
```
|
||
|
||
### 📦 Phase 4: 프론트엔드 UI 구현 (3일)
|
||
|
||
#### 4.1 테이블 생성 모달
|
||
|
||
```tsx
|
||
// frontend/components/admin/CreateTableModal.tsx
|
||
export function CreateTableModal({ isOpen, onClose, onSuccess }: Props) {
|
||
const [tableName, setTableName] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const [columns, setColumns] = useState<CreateColumnDefinition[]>([
|
||
{ name: "name", label: "이름", webType: "text", nullable: false },
|
||
]);
|
||
|
||
const handleCreateTable = async () => {
|
||
try {
|
||
await apiClient.post("/ddl/tables", {
|
||
tableName,
|
||
description,
|
||
columns,
|
||
});
|
||
|
||
toast.success(`테이블 '${tableName}'이 생성되었습니다.`);
|
||
onSuccess();
|
||
onClose();
|
||
} catch (error) {
|
||
toast.error("테이블 생성에 실패했습니다.");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||
<DialogContent className="max-w-4xl">
|
||
<DialogHeader>
|
||
<DialogTitle>🆕 새 테이블 생성</DialogTitle>
|
||
<DialogDescription>
|
||
최고 관리자만 새로운 테이블을 생성할 수 있습니다.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-6">
|
||
{/* 테이블 기본 정보 */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<Label htmlFor="tableName">테이블명 *</Label>
|
||
<Input
|
||
id="tableName"
|
||
value={tableName}
|
||
onChange={(e) => setTableName(e.target.value)}
|
||
placeholder="예: customer_info"
|
||
pattern="^[a-zA-Z_][a-zA-Z0-9_]*$"
|
||
/>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
영문자, 숫자, 언더스코어만 사용 가능
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="description">설명</Label>
|
||
<Input
|
||
id="description"
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder="테이블 설명"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 컬럼 정의 테이블 */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<Label>컬럼 정의 *</Label>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() =>
|
||
setColumns([
|
||
...columns,
|
||
{
|
||
name: "",
|
||
label: "",
|
||
webType: "text",
|
||
nullable: true,
|
||
order: columns.length + 1,
|
||
},
|
||
])
|
||
}
|
||
>
|
||
+ 컬럼 추가
|
||
</Button>
|
||
</div>
|
||
|
||
<ColumnDefinitionTable columns={columns} onChange={setColumns} />
|
||
</div>
|
||
|
||
{/* 기본 컬럼 안내 */}
|
||
<Alert>
|
||
<InfoIcon className="h-4 w-4" />
|
||
<AlertTitle>자동 추가 컬럼</AlertTitle>
|
||
<AlertDescription>
|
||
다음 컬럼들이 자동으로 추가됩니다:
|
||
<code>id</code>(PK), <code>created_date</code>,<code>
|
||
updated_date
|
||
</code>, <code>company_code</code>
|
||
</AlertDescription>
|
||
</Alert>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={onClose}>
|
||
취소
|
||
</Button>
|
||
<Button
|
||
onClick={handleCreateTable}
|
||
disabled={!tableName || columns.length === 0}
|
||
>
|
||
테이블 생성
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 4.2 컬럼 정의 테이블 컴포넌트
|
||
|
||
```tsx
|
||
// frontend/components/admin/ColumnDefinitionTable.tsx
|
||
export function ColumnDefinitionTable({
|
||
columns,
|
||
onChange,
|
||
}: ColumnDefinitionTableProps) {
|
||
const updateColumn = (
|
||
index: number,
|
||
updates: Partial<CreateColumnDefinition>
|
||
) => {
|
||
const newColumns = [...columns];
|
||
newColumns[index] = { ...newColumns[index], ...updates };
|
||
onChange(newColumns);
|
||
};
|
||
|
||
const removeColumn = (index: number) => {
|
||
const newColumns = columns.filter((_, i) => i !== index);
|
||
onChange(newColumns);
|
||
};
|
||
|
||
return (
|
||
<div className="border rounded-lg">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-[150px]">컬럼명</TableHead>
|
||
<TableHead className="w-[150px]">라벨</TableHead>
|
||
<TableHead className="w-[120px]">웹타입</TableHead>
|
||
<TableHead className="w-[100px]">필수</TableHead>
|
||
<TableHead className="w-[100px]">길이</TableHead>
|
||
<TableHead>설명</TableHead>
|
||
<TableHead className="w-[50px]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{columns.map((column, index) => (
|
||
<TableRow key={index}>
|
||
<TableCell>
|
||
<Input
|
||
value={column.name}
|
||
onChange={(e) =>
|
||
updateColumn(index, { name: e.target.value })
|
||
}
|
||
placeholder="column_name"
|
||
pattern="^[a-zA-Z_][a-zA-Z0-9_]*$"
|
||
/>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Input
|
||
value={column.label || ""}
|
||
onChange={(e) =>
|
||
updateColumn(index, { label: e.target.value })
|
||
}
|
||
placeholder="컬럼 라벨"
|
||
/>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Select
|
||
value={column.webType}
|
||
onValueChange={(value) =>
|
||
updateColumn(index, { webType: value })
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="text">텍스트</SelectItem>
|
||
<SelectItem value="number">숫자</SelectItem>
|
||
<SelectItem value="decimal">소수</SelectItem>
|
||
<SelectItem value="date">날짜</SelectItem>
|
||
<SelectItem value="datetime">날짜시간</SelectItem>
|
||
<SelectItem value="boolean">불린</SelectItem>
|
||
<SelectItem value="code">코드</SelectItem>
|
||
<SelectItem value="entity">엔티티</SelectItem>
|
||
<SelectItem value="file">파일</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Checkbox
|
||
checked={!column.nullable}
|
||
onCheckedChange={(checked) =>
|
||
updateColumn(index, { nullable: !checked })
|
||
}
|
||
/>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Input
|
||
type="number"
|
||
value={column.length || ""}
|
||
onChange={(e) =>
|
||
updateColumn(index, {
|
||
length: e.target.value
|
||
? parseInt(e.target.value)
|
||
: undefined,
|
||
})
|
||
}
|
||
placeholder="길이"
|
||
disabled={!["text", "code"].includes(column.webType)}
|
||
/>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Input
|
||
value={column.description || ""}
|
||
onChange={(e) =>
|
||
updateColumn(index, { description: e.target.value })
|
||
}
|
||
placeholder="컬럼 설명"
|
||
/>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => removeColumn(index)}
|
||
disabled={columns.length === 1}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 4.3 컬럼 추가 모달
|
||
|
||
```tsx
|
||
// frontend/components/admin/AddColumnModal.tsx
|
||
export function AddColumnModal({
|
||
isOpen,
|
||
onClose,
|
||
tableName,
|
||
onSuccess,
|
||
}: AddColumnModalProps) {
|
||
const [column, setColumn] = useState<CreateColumnDefinition>({
|
||
name: "",
|
||
label: "",
|
||
webType: "text",
|
||
nullable: true,
|
||
order: 0,
|
||
});
|
||
|
||
const handleAddColumn = async () => {
|
||
try {
|
||
await apiClient.post(`/ddl/tables/${tableName}/columns`, { column });
|
||
|
||
toast.success(`컬럼 '${column.name}'이 추가되었습니다.`);
|
||
onSuccess();
|
||
onClose();
|
||
} catch (error) {
|
||
toast.error("컬럼 추가에 실패했습니다.");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>➕ 컬럼 추가 - {tableName}</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<Label>컬럼명 *</Label>
|
||
<Input
|
||
value={column.name}
|
||
onChange={(e) => setColumn({ ...column, name: e.target.value })}
|
||
placeholder="column_name"
|
||
pattern="^[a-zA-Z_][a-zA-Z0-9_]*$"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>라벨</Label>
|
||
<Input
|
||
value={column.label || ""}
|
||
onChange={(e) =>
|
||
setColumn({ ...column, label: e.target.value })
|
||
}
|
||
placeholder="컬럼 라벨"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<Label>웹타입 *</Label>
|
||
<Select
|
||
value={column.webType}
|
||
onValueChange={(value) =>
|
||
setColumn({ ...column, webType: value })
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="text">텍스트</SelectItem>
|
||
<SelectItem value="number">숫자</SelectItem>
|
||
<SelectItem value="date">날짜</SelectItem>
|
||
<SelectItem value="boolean">불린</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label>길이</Label>
|
||
<Input
|
||
type="number"
|
||
value={column.length || ""}
|
||
onChange={(e) =>
|
||
setColumn({
|
||
...column,
|
||
length: e.target.value
|
||
? parseInt(e.target.value)
|
||
: undefined,
|
||
})
|
||
}
|
||
disabled={!["text", "code"].includes(column.webType)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-center space-x-2">
|
||
<Checkbox
|
||
id="nullable"
|
||
checked={!column.nullable}
|
||
onCheckedChange={(checked) =>
|
||
setColumn({ ...column, nullable: !checked })
|
||
}
|
||
/>
|
||
<Label htmlFor="nullable">필수 입력</Label>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label>설명</Label>
|
||
<Textarea
|
||
value={column.description || ""}
|
||
onChange={(e) =>
|
||
setColumn({ ...column, description: e.target.value })
|
||
}
|
||
placeholder="컬럼 설명"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={onClose}>
|
||
취소
|
||
</Button>
|
||
<Button onClick={handleAddColumn} disabled={!column.name}>
|
||
컬럼 추가
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 4.4 테이블 관리 페이지 확장
|
||
|
||
```tsx
|
||
// frontend/app/(main)/admin/tableMng/page.tsx (기존 페이지 확장)
|
||
export default function TableManagementPage() {
|
||
const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
|
||
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
|
||
const { user } = useAuth();
|
||
|
||
// 최고 관리자 여부 확인
|
||
const isSuperAdmin = user?.companyCode === "*";
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold">테이블 타입 관리</h1>
|
||
|
||
{isSuperAdmin && (
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={() => setCreateTableModalOpen(true)}
|
||
className="bg-green-600 hover:bg-green-700"
|
||
>
|
||
<Plus className="h-4 w-4 mr-2" />새 테이블 생성
|
||
</Button>
|
||
|
||
{selectedTable && (
|
||
<Button
|
||
onClick={() => setAddColumnModalOpen(true)}
|
||
variant="outline"
|
||
>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
컬럼 추가
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 기존 테이블 목록 및 컬럼 관리 UI */}
|
||
|
||
{/* 새 테이블 생성 모달 */}
|
||
<CreateTableModal
|
||
isOpen={createTableModalOpen}
|
||
onClose={() => setCreateTableModalOpen(false)}
|
||
onSuccess={() => {
|
||
loadTables();
|
||
setCreateTableModalOpen(false);
|
||
}}
|
||
/>
|
||
|
||
{/* 컬럼 추가 모달 */}
|
||
<AddColumnModal
|
||
isOpen={addColumnModalOpen}
|
||
onClose={() => setAddColumnModalOpen(false)}
|
||
tableName={selectedTable || ""}
|
||
onSuccess={() => {
|
||
loadColumns(selectedTable!);
|
||
setAddColumnModalOpen(false);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 📦 Phase 5: 안전성 검증 시스템 (2일)
|
||
|
||
#### 5.1 DDL 안전성 검증기
|
||
|
||
```typescript
|
||
// backend-node/src/services/ddlSafetyValidator.ts
|
||
export class DDLSafetyValidator {
|
||
/**
|
||
* 테이블 생성 전 검증
|
||
*/
|
||
static validateTableCreation(
|
||
tableName: string,
|
||
columns: CreateColumnDefinition[]
|
||
): ValidationResult {
|
||
const errors: string[] = [];
|
||
|
||
// 1. 테이블명 검증
|
||
if (!this.isValidTableName(tableName)) {
|
||
errors.push(
|
||
"유효하지 않은 테이블명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다."
|
||
);
|
||
}
|
||
|
||
// 2. 시스템 테이블 보호
|
||
if (this.isSystemTable(tableName)) {
|
||
errors.push(`'${tableName}'은 시스템 테이블명으로 사용할 수 없습니다.`);
|
||
}
|
||
|
||
// 3. 예약어 검증
|
||
if (this.isReservedWord(tableName)) {
|
||
errors.push(`'${tableName}'은 예약어이므로 사용할 수 없습니다.`);
|
||
}
|
||
|
||
// 4. 길이 검증
|
||
if (tableName.length > 63) {
|
||
errors.push("테이블명은 63자를 초과할 수 없습니다.");
|
||
}
|
||
|
||
// 5. 컬럼 검증
|
||
if (columns.length === 0) {
|
||
errors.push("최소 1개의 컬럼이 필요합니다.");
|
||
}
|
||
|
||
// 6. 컬럼명 중복 검증
|
||
const columnNames = columns.map((col) => col.name.toLowerCase());
|
||
const duplicates = columnNames.filter(
|
||
(name, index) => columnNames.indexOf(name) !== index
|
||
);
|
||
if (duplicates.length > 0) {
|
||
errors.push(`중복된 컬럼명: ${duplicates.join(", ")}`);
|
||
}
|
||
|
||
// 7. 컬럼명 검증
|
||
for (const column of columns) {
|
||
if (!this.isValidColumnName(column.name)) {
|
||
errors.push(`유효하지 않은 컬럼명: ${column.name}`);
|
||
}
|
||
|
||
if (this.isReservedColumnName(column.name)) {
|
||
errors.push(`'${column.name}'은 예약된 컬럼명입니다.`);
|
||
}
|
||
}
|
||
|
||
return {
|
||
isValid: errors.length === 0,
|
||
errors,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 컬럼 추가 전 검증
|
||
*/
|
||
static validateColumnAddition(
|
||
tableName: string,
|
||
column: CreateColumnDefinition
|
||
): ValidationResult {
|
||
const errors: string[] = [];
|
||
|
||
// 컬럼명 검증
|
||
if (!this.isValidColumnName(column.name)) {
|
||
errors.push("유효하지 않은 컬럼명입니다.");
|
||
}
|
||
|
||
// 예약된 컬럼명 검증
|
||
if (this.isReservedColumnName(column.name)) {
|
||
errors.push(`'${column.name}'은 예약된 컬럼명입니다.`);
|
||
}
|
||
|
||
return {
|
||
isValid: errors.length === 0,
|
||
errors,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 테이블명 유효성 검증
|
||
*/
|
||
private static isValidTableName(tableName: string): boolean {
|
||
const tableNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||
return tableNameRegex.test(tableName);
|
||
}
|
||
|
||
/**
|
||
* 컬럼명 유효성 검증
|
||
*/
|
||
private static isValidColumnName(columnName: string): boolean {
|
||
const columnNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||
return columnNameRegex.test(columnName) && columnName.length <= 63;
|
||
}
|
||
|
||
/**
|
||
* 시스템 테이블 확인
|
||
*/
|
||
private static isSystemTable(tableName: string): boolean {
|
||
const systemTables = [
|
||
"user_info",
|
||
"company_mng",
|
||
"menu_info",
|
||
"auth_group",
|
||
"table_labels",
|
||
"column_labels",
|
||
"screen_definitions",
|
||
"screen_layouts",
|
||
"common_code",
|
||
"multi_lang_key_master",
|
||
"multi_lang_text",
|
||
"button_action_standards",
|
||
];
|
||
return systemTables.includes(tableName.toLowerCase());
|
||
}
|
||
|
||
/**
|
||
* 예약어 확인
|
||
*/
|
||
private static isReservedWord(word: string): boolean {
|
||
const reservedWords = [
|
||
"user",
|
||
"order",
|
||
"group",
|
||
"table",
|
||
"column",
|
||
"index",
|
||
"select",
|
||
"insert",
|
||
"update",
|
||
"delete",
|
||
"from",
|
||
"where",
|
||
"join",
|
||
"on",
|
||
"as",
|
||
"and",
|
||
"or",
|
||
"not",
|
||
"null",
|
||
"true",
|
||
"false",
|
||
];
|
||
return reservedWords.includes(word.toLowerCase());
|
||
}
|
||
|
||
/**
|
||
* 예약된 컬럼명 확인
|
||
*/
|
||
private static isReservedColumnName(columnName: string): boolean {
|
||
const reservedColumns = [
|
||
"id",
|
||
"created_date",
|
||
"updated_date",
|
||
"company_code",
|
||
];
|
||
return reservedColumns.includes(columnName.toLowerCase());
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 5.2 DDL 실행 로깅
|
||
|
||
```typescript
|
||
// backend-node/src/services/ddlAuditLogger.ts
|
||
export class DDLAuditLogger {
|
||
/**
|
||
* DDL 실행 로그 기록
|
||
*/
|
||
static async logDDLExecution(
|
||
userId: string,
|
||
companyCode: string,
|
||
ddlType: "CREATE_TABLE" | "ADD_COLUMN",
|
||
tableName: string,
|
||
ddlQuery: string,
|
||
success: boolean,
|
||
error?: string
|
||
): Promise<void> {
|
||
try {
|
||
await prisma.ddl_execution_log.create({
|
||
data: {
|
||
user_id: userId,
|
||
company_code: companyCode,
|
||
ddl_type: ddlType,
|
||
table_name: tableName,
|
||
ddl_query: ddlQuery,
|
||
success: success,
|
||
error_message: error,
|
||
executed_at: new Date(),
|
||
},
|
||
});
|
||
} catch (logError) {
|
||
logger.error("DDL 실행 로그 기록 실패:", logError);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 📦 Phase 6: 통합 테스트 및 검증 (2일)
|
||
|
||
#### 6.1 테스트 시나리오
|
||
|
||
```typescript
|
||
// backend-node/src/test/ddl-execution.test.ts
|
||
describe("DDL 실행 테스트", () => {
|
||
test("최고 관리자 - 테이블 생성 성공", async () => {
|
||
const response = await request(app)
|
||
.post("/api/ddl/tables")
|
||
.set("Authorization", `Bearer ${superAdminToken}`)
|
||
.send({
|
||
tableName: "test_table",
|
||
columns: [
|
||
{ name: "name", webType: "text", nullable: false },
|
||
{ name: "email", webType: "email", nullable: true },
|
||
],
|
||
});
|
||
|
||
expect(response.status).toBe(200);
|
||
expect(response.body.success).toBe(true);
|
||
});
|
||
|
||
test("일반 사용자 - 테이블 생성 권한 거부", async () => {
|
||
const response = await request(app)
|
||
.post("/api/ddl/tables")
|
||
.set("Authorization", `Bearer ${normalUserToken}`)
|
||
.send({
|
||
tableName: "test_table",
|
||
columns: [{ name: "name", webType: "text" }],
|
||
});
|
||
|
||
expect(response.status).toBe(403);
|
||
expect(response.body.error.code).toBe("SUPER_ADMIN_REQUIRED");
|
||
});
|
||
|
||
test("유효하지 않은 테이블명 - 생성 실패", async () => {
|
||
const response = await request(app)
|
||
.post("/api/ddl/tables")
|
||
.set("Authorization", `Bearer ${superAdminToken}`)
|
||
.send({
|
||
tableName: "123invalid",
|
||
columns: [{ name: "name", webType: "text" }],
|
||
});
|
||
|
||
expect(response.status).toBe(400);
|
||
expect(response.body.error.code).toBe("VALIDATION_FAILED");
|
||
});
|
||
|
||
test("시스템 테이블명 사용 - 생성 실패", async () => {
|
||
const response = await request(app)
|
||
.post("/api/ddl/tables")
|
||
.set("Authorization", `Bearer ${superAdminToken}`)
|
||
.send({
|
||
tableName: "user_info",
|
||
columns: [{ name: "name", webType: "text" }],
|
||
});
|
||
|
||
expect(response.status).toBe(400);
|
||
expect(response.body.error.details).toContain("시스템 테이블명");
|
||
});
|
||
});
|
||
```
|
||
|
||
#### 6.2 통합 테스트 체크리스트
|
||
|
||
- [ ] **권한 테스트**: 최고 관리자만 DDL 실행 가능
|
||
- [ ] **테이블 생성 테스트**: 다양한 웹타입으로 테이블 생성
|
||
- [ ] **컬럼 추가 테스트**: 기존 테이블에 컬럼 추가
|
||
- [ ] **메타데이터 동기화**: DDL 실행 후 메타데이터 정확성
|
||
- [ ] **오류 처리 테스트**: 잘못된 입력값 처리
|
||
- [ ] **트랜잭션 테스트**: 실패 시 롤백 확인
|
||
- [ ] **로깅 테스트**: DDL 실행 로그 기록 확인
|
||
|
||
---
|
||
|
||
## 🔒 보안 및 안전성 고려사항
|
||
|
||
### 🛡️ 보안 검증
|
||
|
||
1. **다층 권한 검증**
|
||
|
||
- 미들웨어 레벨 권한 체크
|
||
- 서비스 레벨 추가 검증
|
||
- 사용자 세션 유효성 확인
|
||
|
||
2. **SQL 인젝션 방지**
|
||
|
||
- 테이블명/컬럼명 정규식 검증
|
||
- 화이트리스트 기반 검증
|
||
- 파라미터 바인딩 사용
|
||
|
||
3. **시스템 보호**
|
||
- 핵심 시스템 테이블 수정 금지
|
||
- 예약어 사용 금지
|
||
- 테이블명/컬럼명 길이 제한
|
||
|
||
### 🔄 트랜잭션 안전성
|
||
|
||
- DDL 실행과 메타데이터 저장을 하나의 트랜잭션으로 처리
|
||
- 실패 시 자동 롤백
|
||
- 상세한 로그 기록 및 모니터링
|
||
|
||
### 📊 모니터링 및 감사
|
||
|
||
- 모든 DDL 실행 로그 기록
|
||
- 실행 시간 및 성공/실패 추적
|
||
- 정기적인 시스템 상태 점검
|
||
|
||
---
|
||
|
||
## 📅 개발 일정
|
||
|
||
| Phase | 작업 내용 | 소요 기간 | 담당자 |
|
||
| ------- | -------------------- | --------- | --------------- |
|
||
| Phase 1 | 권한 시스템 강화 | 2일 | Backend 개발자 |
|
||
| Phase 2 | DDL 실행 서비스 구현 | 3일 | Backend 개발자 |
|
||
| Phase 3 | API 엔드포인트 구현 | 2일 | Backend 개발자 |
|
||
| Phase 4 | 프론트엔드 UI 구현 | 3일 | Frontend 개발자 |
|
||
| Phase 5 | 안전성 검증 시스템 | 2일 | Backend 개발자 |
|
||
| Phase 6 | 통합 테스트 및 검증 | 2일 | 전체 팀 |
|
||
|
||
**총 개발 기간: 14일 (약 3주)**
|
||
|
||
---
|
||
|
||
## 📂 파일 구조 변경사항
|
||
|
||
### 백엔드 추가 파일
|
||
|
||
```
|
||
backend-node/src/
|
||
├── middleware/
|
||
│ └── superAdminMiddleware.ts # 최고 관리자 권한 미들웨어
|
||
├── services/
|
||
│ ├── ddlExecutionService.ts # DDL 실행 서비스
|
||
│ ├── ddlSafetyValidator.ts # DDL 안전성 검증기
|
||
│ └── ddlAuditLogger.ts # DDL 실행 로깅
|
||
├── controllers/
|
||
│ └── ddlController.ts # DDL 실행 컨트롤러
|
||
├── routes/
|
||
│ └── ddlRoutes.ts # DDL API 라우터
|
||
├── types/
|
||
│ └── ddl.ts # DDL 관련 타입 정의
|
||
└── test/
|
||
└── ddl-execution.test.ts # DDL 실행 테스트
|
||
```
|
||
|
||
### 프론트엔드 추가 파일
|
||
|
||
```
|
||
frontend/
|
||
├── components/admin/
|
||
│ ├── CreateTableModal.tsx # 테이블 생성 모달
|
||
│ ├── AddColumnModal.tsx # 컬럼 추가 모달
|
||
│ └── ColumnDefinitionTable.tsx # 컬럼 정의 테이블
|
||
├── lib/api/
|
||
│ └── ddl.ts # DDL API 클라이언트
|
||
└── types/
|
||
└── ddl.ts # DDL 관련 타입 정의
|
||
```
|
||
|
||
### 데이터베이스 스키마 추가
|
||
|
||
```sql
|
||
-- DDL 실행 로그 테이블
|
||
CREATE TABLE ddl_execution_log (
|
||
id serial PRIMARY KEY,
|
||
user_id varchar(100) NOT NULL,
|
||
company_code varchar(50) NOT NULL,
|
||
ddl_type varchar(50) NOT NULL,
|
||
table_name varchar(100) NOT NULL,
|
||
ddl_query text NOT NULL,
|
||
success boolean NOT NULL,
|
||
error_message text,
|
||
executed_at timestamp DEFAULT now()
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## 🎯 성공 기준
|
||
|
||
### ✅ 기능적 요구사항
|
||
|
||
- [ ] 최고 관리자만 테이블/컬럼 생성 가능
|
||
- [ ] PostgreSQL 테이블 실제 생성 및 컬럼 추가
|
||
- [ ] 메타데이터 자동 동기화
|
||
- [ ] 사용자 친화적인 UI 제공
|
||
|
||
### ✅ 비기능적 요구사항
|
||
|
||
- [ ] 모든 DDL 실행 로깅
|
||
- [ ] SQL 인젝션 방지
|
||
- [ ] 트랜잭션 안전성 보장
|
||
- [ ] 시스템 테이블 보호
|
||
|
||
### ✅ 품질 기준
|
||
|
||
- [ ] 코드 커버리지 90% 이상
|
||
- [ ] 모든 테스트 케이스 통과
|
||
- [ ] 보안 검증 완료
|
||
- [ ] 성능 테스트 통과
|
||
|
||
---
|
||
|
||
## 📝 참고사항
|
||
|
||
### 🔗 관련 문서
|
||
|
||
- [현재 테이블 타입 관리 시스템](<frontend/app/(main)/admin/tableMng/page.tsx>)
|
||
- [기존 권한 시스템](backend-node/src/middleware/authMiddleware.ts)
|
||
- [DDL 실행 가이드](docs/NodeJS_Refactoring_Rules.md)
|
||
|
||
### ⚠️ 주의사항
|
||
|
||
1. **시스템 테이블 보호**: 핵심 시스템 테이블은 절대 수정하지 않음
|
||
2. **백업 필수**: DDL 실행 전 데이터베이스 백업 권장
|
||
3. **점진적 배포**: 개발환경 → 스테이징 → 운영환경 순차 배포
|
||
4. **롤백 계획**: 문제 발생 시 즉시 롤백할 수 있는 계획 수립
|
||
|
||
이 계획서를 통해 안전하고 강력한 테이블 동적 생성 기능을 체계적으로 개발할 수 있습니다. 🚀
|