feat(테이블타입): 컬럼 단건 DROP 기능 — ColumnGrid ⋯ 메뉴에 "컬럼 삭제" 추가

- DdlService.dropColumn: ALTER TABLE ... DROP COLUMN (CASCADE 미사용 → FK 참조 시 Postgres 거부, DBeaver 동일)
- 시스템 테이블 / 예약 컬럼(id/created_date/updated_date/company_code/writer) 보호
- 같은 트랜잭션에서 table_type_columns / column_labels 메타 청소 + ddl_execution_log 기록
- DdlController: DELETE /api/ddl/tables/{table}/columns/{column} (SUPER_ADMIN 전용)
- ddlApi.dropColumn 헬퍼
- ColumnGrid: ... 버튼을 DropdownMenu 로 교체, "컬럼 삭제" destructive 메뉴 아이템
- page.tsx: 컬럼 삭제 확인 다이얼로그 + 핸들러, FK 거부 시 토스트로 안내

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 14:49:07 +09:00
parent b25a6324f8
commit f73e468f66
5 changed files with 246 additions and 14 deletions
@@ -91,6 +91,32 @@ public class DdlController {
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
}
/**
* DELETE /api/ddl/tables/{tableName}/columns/{columnName} - 컬럼 삭제
*/
@DeleteMapping("/tables/{tableName}/columns/{columnName}")
public ResponseEntity<ApiResponse<?>> dropColumn(
@PathVariable String tableName,
@PathVariable String columnName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
Map<String, Object> result = ddlService.dropColumn(tableName, columnName, companyCode, userId);
if (Boolean.TRUE.equals(result.get("success"))) {
return ResponseEntity.ok(ApiResponse.success(Map.of(
"table_name", result.get("table_name"),
"column_name", result.get("column_name"),
"executed_query", result.get("executed_query")
), (String) result.get("message")));
}
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
}
/**
* DELETE /api/ddl/tables/{tableName} - 테이블 삭제
*/
@@ -226,6 +226,79 @@ public class DdlService extends BaseService {
}
}
// ─────────────────────────────────────────────────────────────────────────
// DROP COLUMN (DBeaver 방식: FK 등 위반은 Postgres 가 던지는 에러를 그대로 노출)
// ─────────────────────────────────────────────────────────────────────────
public Map<String, Object> dropColumn(String tableName, String columnName,
String companyCode, String userId) {
// 1. 시스템 테이블 보호
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
String errorMsg = "'" + tableName + "'은 시스템 테이블이므로 컬럼을 삭제할 수 없습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
"SYSTEM_TABLE_PROTECTED", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "SYSTEM_TABLE_PROTECTED");
}
// 2. 예약 컬럼 보호 (id / created_date / updated_date / company_code / writer)
if (RESERVED_COLUMNS.contains(columnName.toLowerCase()) || "writer".equalsIgnoreCase(columnName)) {
String errorMsg = "'" + columnName + "'은 시스템 예약 컬럼이므로 삭제할 수 없습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
"RESERVED_COLUMN_PROTECTED", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "RESERVED_COLUMN_PROTECTED");
}
// 3. 테이블/컬럼 존재 여부
if (!tableExists(tableName)) {
String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "TABLE_NOT_FOUND", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_NOT_FOUND");
}
if (!columnExists(tableName, columnName)) {
String errorMsg = "컬럼 '" + columnName + "'이 존재하지 않습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "COLUMN_NOT_FOUND", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "COLUMN_NOT_FOUND");
}
// 4. DDL 실행 — CASCADE 안 붙임 → FK 참조 있으면 Postgres 가 거부 (DBeaver 와 동일)
String ddlQuery = "ALTER TABLE \"" + sanitize(tableName) + "\" DROP COLUMN \"" + sanitize(columnName) + "\"";
try {
transactionTemplate.execute(status -> {
jdbcTemplate.execute(ddlQuery);
// 컬럼 메타 청소
jdbcTemplate.update(
"DELETE FROM table_type_columns WHERE table_name = ? AND column_name = ?",
tableName, columnName);
jdbcTemplate.update(
"DELETE FROM column_labels WHERE table_name = ? AND column_name = ?",
tableName, columnName);
return null;
});
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, ddlQuery, true, null);
log.info("컬럼 삭제 성공: {}.{}, 사용자: {}", tableName, columnName, userId);
return Map.of(
"success", true,
"message", "컬럼 '" + columnName + "'이 성공적으로 삭제되었습니다.",
"table_name", tableName,
"column_name", columnName,
"executed_query", ddlQuery
);
} catch (Exception e) {
String rawMsg = e.getMessage() != null ? e.getMessage() : "";
String guidance = rawMsg.toLowerCase().contains("depend") || rawMsg.toLowerCase().contains("foreign key")
? " (다른 테이블에서 외래키로 참조 중인 컬럼은 삭제할 수 없습니다)"
: "";
String errorMsg = "컬럼 삭제 실패: " + rawMsg + guidance;
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
"FAILED: " + rawMsg, false, errorMsg);
log.error("컬럼 삭제 실패: {}.{}, 사용자: {}, 오류: {}", tableName, columnName, userId, rawMsg, e);
return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED");
}
}
// ─────────────────────────────────────────────────────────────────────────
// VALIDATE
// ─────────────────────────────────────────────────────────────────────────
@@ -120,6 +120,9 @@ export default function TableManagementPage() {
// 테이블 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tableToDelete, setTableToDelete] = useState<string>("");
const [deleteColumnDialogOpen, setDeleteColumnDialogOpen] = useState(false);
const [columnToDelete, setColumnToDelete] = useState<string>("");
const [isDeletingColumn, setIsDeletingColumn] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// PK/인덱스 관리 상태
@@ -984,7 +987,20 @@ export default function TableManagementPage() {
(table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()),
);
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
const q = searchTerm.trim().toLowerCase();
// 검색 매치 강도: 0=정확, 1=시작, 2=포함 — 낮을수록 위
const matchScore = (t: typeof tables[number]) => {
if (!q) return 0;
const tn = (t.table_name ?? "").toLowerCase();
const dn = (t.display_name ?? "").toLowerCase();
if (tn === q || dn === q) return 0;
if (tn.startsWith(q) || dn.startsWith(q)) return 1;
return 2;
};
return filtered.sort((a, b) => {
const sa = matchScore(a);
const sb = matchScore(b);
if (sa !== sb) return sa - sb;
const nameA = a.display_name || a.table_name;
const nameB = b.display_name || b.table_name;
const aKo = isKorean(nameA);
@@ -1188,6 +1204,37 @@ export default function TableManagementPage() {
setDeleteDialogOpen(true);
};
// 컬럼 삭제 (DBeaver 방식 — FK 참조 있으면 Postgres 가 거부)
const handleDeleteColumnClick = (columnName: string) => {
setColumnToDelete(columnName);
setDeleteColumnDialogOpen(true);
};
const handleDeleteColumn = async () => {
if (!selectedTable || !columnToDelete) return;
setIsDeletingColumn(true);
try {
const result = await ddlApi.dropColumn(selectedTable, columnToDelete);
if (result.success) {
toast.success(`컬럼 '${columnToDelete}'이 삭제되었습니다.`);
if (selectedColumn === columnToDelete) setSelectedColumn(null);
await loadColumnTypes(selectedTable);
} else {
showErrorToast("컬럼 삭제에 실패했습니다", result.message, {
guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.",
});
}
} catch (error) {
showErrorToast("컬럼 삭제에 실패했습니다", error, {
guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.",
});
} finally {
setIsDeletingColumn(false);
setDeleteColumnDialogOpen(false);
setColumnToDelete("");
}
};
// 테이블 삭제 실행
const handleDeleteTable = async () => {
if (!tableToDelete) return;
@@ -1678,6 +1725,7 @@ export default function TableManagementPage() {
onIndexToggle={(columnName, checked) =>
handleIndexToggle(columnName, "index", checked)
}
onDeleteColumn={handleDeleteColumnClick}
tables={tables}
referenceTableColumns={referenceTableColumns}
/>
@@ -1863,6 +1911,62 @@ export default function TableManagementPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 컬럼 삭제 확인 다이얼로그 */}
<Dialog open={deleteColumnDialogOpen} onOpenChange={setDeleteColumnDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
? .
</DialogDescription>
</DialogHeader>
{columnToDelete && (
<div className="space-y-3 sm:space-y-4">
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<p className="text-destructive text-sm font-semibold"></p>
<p className="text-destructive/80 mt-1.5 text-sm">
<span className="font-mono font-bold">{selectedTable}.{columnToDelete}</span>
.
</p>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setDeleteColumnDialogOpen(false);
setColumnToDelete("");
}}
disabled={isDeletingColumn}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDeleteColumn}
disabled={isDeletingColumn}
className="h-8 flex-1 gap-2 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isDeletingColumn ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
@@ -1,9 +1,15 @@
"use client";
import React, { useMemo } from "react";
import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react";
import { MoreHorizontal, Database, Layers, FileStack, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo, TableInfo } from "./types";
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
@@ -24,6 +30,7 @@ export interface ColumnGridProps {
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
onPkToggle?: (columnName: string, checked: boolean) => void;
onIndexToggle?: (columnName: string, checked: boolean) => void;
onDeleteColumn?: (columnName: string) => void;
/** 호버 시 한글 라벨 표시용 (Badge title) */
tables?: TableInfo[];
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
@@ -57,6 +64,7 @@ export function ColumnGrid({
getColumnIndexState: externalGetIndexState,
onPkToggle,
onIndexToggle,
onDeleteColumn,
tables,
referenceTableColumns,
}: ColumnGridProps) {
@@ -286,19 +294,32 @@ export function ColumnGrid({
</div>
<div className="flex items-center justify-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onSelectColumn(column.column_name);
}}
aria-label="상세 설정"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
aria-label="컬럼 액션 메뉴"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(e) => {
e.preventDefault();
onDeleteColumn?.(column.column_name);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
+8
View File
@@ -37,6 +37,14 @@ export const ddlApi = {
return response.data;
},
/**
* 컬럼 삭제 (ALTER TABLE ... DROP COLUMN)
*/
dropColumn: async (tableName: string, columnName: string): Promise<DDLExecutionResult> => {
const response = await apiClient.delete(`/ddl/tables/${tableName}/columns/${columnName}`);
return response.data;
},
/**
* 테이블 생성 사전 검증 (실제 생성하지 않고 검증만)
*/