# πŸ“Š ν…Œμ΄λΈ” 동적 생성 κΈ°λŠ₯ 개발 κ³„νšμ„œ ## πŸ“‹ ν”„λ‘œμ νŠΈ κ°œμš” ### 🎯 λͺ©μ  ν˜„μž¬ ν…Œμ΄λΈ” νƒ€μž… 관리 μ‹œμŠ€ν…œμ— **μ‹€μ œ λ°μ΄ν„°λ² μ΄μŠ€ ν…Œμ΄λΈ”κ³Ό μ»¬λŸΌμ„ 생성**ν•˜λŠ” κΈ°λŠ₯을 μΆ”κ°€ν•˜μ—¬, 졜고 κ΄€λ¦¬μžκ°€ λ™μ μœΌλ‘œ μƒˆλ‘œμš΄ ν…Œμ΄λΈ”μ„ μƒμ„±ν•˜κ³  κΈ°μ‘΄ ν…Œμ΄λΈ”μ— μ»¬λŸΌμ„ μΆ”κ°€ν•  수 μžˆλŠ” μ‹œμŠ€ν…œμ„ κ΅¬μΆ•ν•©λ‹ˆλ‹€. ### πŸ” 핡심 λ³΄μ•ˆ μš”κ΅¬μ‚¬ν•­ - **졜고 κ΄€λ¦¬μž μ „μš©**: νšŒμ‚¬μ½”λ“œκ°€ `*`인 μ‚¬μš©μžλ§Œ 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 { // κΆŒν•œ 검증 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 { // μœ μ‚¬ν•œ ꡬ쑰둜 κ΅¬ν˜„ } } ``` #### 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 = { '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([ { 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 ( πŸ†• μƒˆ ν…Œμ΄λΈ” 생성 졜고 κ΄€λ¦¬μžλ§Œ μƒˆλ‘œμš΄ ν…Œμ΄λΈ”μ„ 생성할 수 μžˆμŠ΅λ‹ˆλ‹€.
{/* ν…Œμ΄λΈ” κΈ°λ³Έ 정보 */}
setTableName(e.target.value)} placeholder="예: customer_info" pattern="^[a-zA-Z_][a-zA-Z0-9_]*$" />

영문자, 숫자, μ–Έλ”μŠ€μ½”μ–΄λ§Œ μ‚¬μš© κ°€λŠ₯

setDescription(e.target.value)} placeholder="ν…Œμ΄λΈ” μ„€λͺ…" />
{/* 컬럼 μ •μ˜ ν…Œμ΄λΈ” */}
{/* κΈ°λ³Έ 컬럼 μ•ˆλ‚΄ */} μžλ™ μΆ”κ°€ 컬럼 λ‹€μŒ μ»¬λŸΌλ“€μ΄ μžλ™μœΌλ‘œ μΆ”κ°€λ©λ‹ˆλ‹€: id(PK), created_date, updated_date , company_code
); } ``` #### 4.2 컬럼 μ •μ˜ ν…Œμ΄λΈ” μ»΄ν¬λ„ŒνŠΈ ```tsx // frontend/components/admin/ColumnDefinitionTable.tsx export function ColumnDefinitionTable({ columns, onChange, }: ColumnDefinitionTableProps) { const updateColumn = ( index: number, updates: Partial ) => { const newColumns = [...columns]; newColumns[index] = { ...newColumns[index], ...updates }; onChange(newColumns); }; const removeColumn = (index: number) => { const newColumns = columns.filter((_, i) => i !== index); onChange(newColumns); }; return (
컬럼λͺ… 라벨 μ›Ήνƒ€μž… ν•„μˆ˜ 길이 μ„€λͺ… {columns.map((column, index) => ( updateColumn(index, { name: e.target.value }) } placeholder="column_name" pattern="^[a-zA-Z_][a-zA-Z0-9_]*$" /> updateColumn(index, { label: e.target.value }) } placeholder="컬럼 라벨" /> updateColumn(index, { nullable: !checked }) } /> updateColumn(index, { length: e.target.value ? parseInt(e.target.value) : undefined, }) } placeholder="길이" disabled={!["text", "code"].includes(column.webType)} /> updateColumn(index, { description: e.target.value }) } placeholder="컬럼 μ„€λͺ…" /> ))}
); } ``` #### 4.3 컬럼 μΆ”κ°€ λͺ¨λ‹¬ ```tsx // frontend/components/admin/AddColumnModal.tsx export function AddColumnModal({ isOpen, onClose, tableName, onSuccess, }: AddColumnModalProps) { const [column, setColumn] = useState({ 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 ( βž• 컬럼 μΆ”κ°€ - {tableName}
setColumn({ ...column, name: e.target.value })} placeholder="column_name" pattern="^[a-zA-Z_][a-zA-Z0-9_]*$" />
setColumn({ ...column, label: e.target.value }) } placeholder="컬럼 라벨" />
setColumn({ ...column, length: e.target.value ? parseInt(e.target.value) : undefined, }) } disabled={!["text", "code"].includes(column.webType)} />
setColumn({ ...column, nullable: !checked }) } />