feat: Add PK and index management APIs for table management

- Implemented new API endpoints for managing primary keys and indexes in the table management system.
- Added functionality to retrieve table constraints, set primary keys, toggle indexes, and manage NOT NULL constraints.
- Enhanced the frontend to support PK and index management, including loading constraints and handling user interactions for toggling indexes and setting primary keys.
- Improved error handling and logging for better debugging and user feedback during these operations.
This commit is contained in:
kjs 2026-02-11 16:07:44 +09:00
parent 2bbb5d7013
commit e065835c4d
3 changed files with 529 additions and 142 deletions

View File

@ -2447,3 +2447,260 @@ export async function getReferencedByTables(
res.status(500).json(response);
}
}
// ========================================
// PK / 인덱스 관리 API
// ========================================
/**
* PK/
* GET /api/table-management/tables/:tableName/constraints
*/
export async function getTableConstraints(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
if (!tableName) {
res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
return;
}
// PK 조회
const pkResult = await query<any>(
`SELECT tc.conname AS constraint_name,
array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_constraint tc
JOIN pg_class c ON tc.conrelid = c.oid
JOIN pg_namespace ns ON c.relnamespace = ns.oid
CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'
GROUP BY tc.conname`,
[tableName]
);
// array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환
const parseColumns = (cols: any): string[] => {
if (Array.isArray(cols)) return cols;
if (typeof cols === "string") {
// PostgreSQL 배열 형식: {col1,col2}
return cols.replace(/[{}]/g, "").split(",").filter(Boolean);
}
return [];
};
const primaryKey = pkResult.length > 0
? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) }
: { name: "", columns: [] };
// 인덱스 조회 (PK 인덱스 제외)
const indexResult = await query<any>(
`SELECT i.relname AS index_name,
ix.indisunique AS is_unique,
array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_index ix
JOIN pg_class t ON ix.indrelid = t.oid
JOIN pg_class i ON ix.indexrelid = i.oid
JOIN pg_namespace ns ON t.relnamespace = ns.oid
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE ns.nspname = 'public' AND t.relname = $1
AND ix.indisprimary = false
GROUP BY i.relname, ix.indisunique
ORDER BY i.relname`,
[tableName]
);
const indexes = indexResult.map((row: any) => ({
name: row.index_name,
columns: parseColumns(row.columns),
isUnique: row.is_unique,
}));
logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}`);
res.status(200).json({
success: true,
data: { primaryKey, indexes },
});
} catch (error) {
logger.error("제약조건 조회 오류:", error);
res.status(500).json({
success: false,
message: "제약조건 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* PK
* PUT /api/table-management/tables/:tableName/primary-key
*/
export async function setTablePrimaryKey(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { columns } = req.body;
if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) {
res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." });
return;
}
logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`);
// 기존 PK 제약조건 이름 조회
const existingPk = await query<any>(
`SELECT conname FROM pg_constraint tc
JOIN pg_class c ON tc.conrelid = c.oid
JOIN pg_namespace ns ON c.relnamespace = ns.oid
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`,
[tableName]
);
// 기존 PK 삭제
if (existingPk.length > 0) {
const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`;
logger.info(`기존 PK 삭제: ${dropSql}`);
await query(dropSql);
}
// 새 PK 추가
const colList = columns.map((c: string) => `"${c}"`).join(", ");
const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`;
logger.info(`새 PK 추가: ${addSql}`);
await query(addSql);
res.status(200).json({
success: true,
message: `PK가 설정되었습니다: ${columns.join(", ")}`,
});
} catch (error) {
logger.error("PK 설정 오류:", error);
res.status(500).json({
success: false,
message: "PK 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* (/)
* POST /api/table-management/tables/:tableName/indexes
*/
export async function toggleTableIndex(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { columnName, indexType, action } = req.body;
if (!tableName || !columnName || !indexType || !action) {
res.status(400).json({
success: false,
message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.",
});
return;
}
const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`;
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
if (action === "create") {
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`;
logger.info(`인덱스 생성: ${sql}`);
await query(sql);
} else if (action === "drop") {
const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`;
logger.info(`인덱스 삭제: ${sql}`);
await query(sql);
} else {
res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." });
return;
}
res.status(200).json({
success: true,
message: action === "create"
? `인덱스가 생성되었습니다: ${indexName}`
: `인덱스가 삭제되었습니다: ${indexName}`,
});
} catch (error: any) {
logger.error("인덱스 토글 오류:", error);
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내
const errorMsg = error.message?.includes("duplicate key")
? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요."
: "인덱스 설정 중 오류가 발생했습니다.";
res.status(500).json({
success: false,
message: errorMsg,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* NOT NULL
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
*/
export async function toggleColumnNullable(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { nullable } = req.body;
if (!tableName || !columnName || typeof nullable !== "boolean") {
res.status(400).json({
success: false,
message: "tableName, columnName, nullable(boolean)이 필요합니다.",
});
return;
}
if (nullable) {
// NOT NULL 해제
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`;
logger.info(`NOT NULL 해제: ${sql}`);
await query(sql);
} else {
// NOT NULL 설정
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`;
logger.info(`NOT NULL 설정: ${sql}`);
await query(sql);
}
res.status(200).json({
success: true,
message: nullable
? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.`
: `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`,
});
} catch (error: any) {
logger.error("NOT NULL 토글 오류:", error);
// NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내
const errorMsg = error.message?.includes("contains null values")
? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요."
: "NOT NULL 설정 중 오류가 발생했습니다.";
res.status(500).json({
success: false,
message: errorMsg,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}

View File

@ -28,6 +28,10 @@ import {
multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
getTableConstraints, // 🆕 PK/인덱스 상태 조회
setTablePrimaryKey, // 🆕 PK 설정
toggleTableIndex, // 🆕 인덱스 토글
toggleColumnNullable, // 🆕 NOT NULL 토글
} from "../controllers/tableManagementController";
const router = express.Router();
@ -133,6 +137,30 @@ router.put("/tables/:tableName/columns/batch", updateAllColumnSettings);
*/
router.get("/tables/:tableName/schema", getTableSchema);
/**
* PK/
* GET /api/table-management/tables/:tableName/constraints
*/
router.get("/tables/:tableName/constraints", getTableConstraints);
/**
* PK ( PK DROP )
* PUT /api/table-management/tables/:tableName/primary-key
*/
router.put("/tables/:tableName/primary-key", setTablePrimaryKey);
/**
* (/)
* POST /api/table-management/tables/:tableName/indexes
*/
router.post("/tables/:tableName/indexes", toggleTableIndex);
/**
* NOT NULL
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
*/
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
/**
*
* GET /api/table-management/tables/:tableName/exists

View File

@ -145,6 +145,14 @@ export default function TableManagementPage() {
const [tableToDelete, setTableToDelete] = useState<string>("");
const [isDeleting, setIsDeleting] = useState(false);
// PK/인덱스 관리 상태
const [constraints, setConstraints] = useState<{
primaryKey: { name: string; columns: string[] };
indexes: Array<{ name: string; columns: string[]; isUnique: boolean }>;
}>({ primaryKey: { name: "", columns: [] }, indexes: [] });
const [pkDialogOpen, setPkDialogOpen] = useState(false);
const [pendingPkColumns, setPendingPkColumns] = useState<string[]>([]);
// 선택된 테이블 목록 (체크박스)
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set());
@ -397,6 +405,19 @@ export default function TableManagementPage() {
}
}, []);
// PK/인덱스 제약조건 로드
const loadConstraints = useCallback(async (tableName: string) => {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`);
if (response.data.success) {
setConstraints(response.data.data);
}
} catch (error) {
console.error("제약조건 로드 실패:", error);
setConstraints({ primaryKey: { name: "", columns: [] }, indexes: [] });
}
}, []);
// 테이블 선택
const handleTableSelect = useCallback(
(tableName: string) => {
@ -410,8 +431,9 @@ export default function TableManagementPage() {
setTableDescription(tableInfo?.description || "");
loadColumnTypes(tableName, 1, pageSize);
loadConstraints(tableName);
},
[loadColumnTypes, pageSize, tables],
[loadColumnTypes, loadConstraints, pageSize, tables],
);
// 입력 타입 변경
@ -1000,6 +1022,123 @@ export default function TableManagementPage() {
}
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
// PK 체크박스 변경 핸들러
const handlePkToggle = useCallback(
(columnName: string, checked: boolean) => {
const currentPkCols = [...constraints.primaryKey.columns];
let newPkCols: string[];
if (checked) {
newPkCols = [...currentPkCols, columnName];
} else {
newPkCols = currentPkCols.filter((c) => c !== columnName);
}
// PK 변경은 확인 다이얼로그 표시
setPendingPkColumns(newPkCols);
setPkDialogOpen(true);
},
[constraints.primaryKey.columns],
);
// PK 변경 확인
const handlePkConfirm = async () => {
if (!selectedTable) return;
try {
if (pendingPkColumns.length === 0) {
toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다.");
setPkDialogOpen(false);
return;
}
const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, {
columns: pendingPkColumns,
});
if (response.data.success) {
toast.success(response.data.message);
await loadConstraints(selectedTable);
} else {
toast.error(response.data.message || "PK 설정 실패");
}
} catch (error: any) {
toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다.");
} finally {
setPkDialogOpen(false);
}
};
// 인덱스 토글 핸들러
const handleIndexToggle = useCallback(
async (columnName: string, indexType: "index" | "unique", checked: boolean) => {
if (!selectedTable) return;
const action = checked ? "create" : "drop";
try {
const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, {
columnName,
indexType,
action,
});
if (response.data.success) {
toast.success(response.data.message);
await loadConstraints(selectedTable);
} else {
toast.error(response.data.message || "인덱스 설정 실패");
}
} catch (error: any) {
toast.error(error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다.");
}
},
[selectedTable, loadConstraints],
);
// 컬럼별 인덱스 상태 헬퍼
const getColumnIndexState = useCallback(
(columnName: string) => {
const isPk = constraints.primaryKey.columns.includes(columnName);
const hasIndex = constraints.indexes.some(
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
const hasUnique = constraints.indexes.some(
(idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
return { isPk, hasIndex, hasUnique };
},
[constraints],
);
// NOT NULL 토글 핸들러
const handleNullableToggle = useCallback(
async (columnName: string, currentIsNullable: string) => {
if (!selectedTable) return;
// isNullable이 "YES"면 nullable, "NO"면 NOT NULL
// 체크박스 체크 = NOT NULL 설정 (nullable: false)
// 체크박스 해제 = NOT NULL 해제 (nullable: true)
const isCurrentlyNotNull = currentIsNullable === "NO";
const newNullable = isCurrentlyNotNull; // NOT NULL이면 해제, NULL이면 설정
try {
const response = await apiClient.put(
`/table-management/tables/${selectedTable}/columns/${columnName}/nullable`,
{ nullable: newNullable },
);
if (response.data.success) {
toast.success(response.data.message);
// 컬럼 상태 로컬 업데이트
setColumns((prev) =>
prev.map((col) =>
col.columnName === columnName
? { ...col, isNullable: newNullable ? "YES" : "NO" }
: col,
),
);
} else {
toast.error(response.data.message || "NOT NULL 설정 실패");
}
} catch (error: any) {
toast.error(
error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.",
);
}
},
[selectedTable],
);
// 테이블 삭제 확인
const handleDeleteTableClick = (tableName: string) => {
setTableToDelete(tableName);
@ -1391,12 +1530,16 @@ export default function TableManagementPage() {
{/* 컬럼 헤더 (고정) */}
<div
className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold"
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
>
<div className="pr-4"></div>
<div className="px-4"></div>
<div className="pr-4"></div>
<div className="px-4"></div>
<div className="pr-6"> </div>
<div className="pl-4"></div>
<div className="text-center text-xs">Primary</div>
<div className="text-center text-xs">NotNull</div>
<div className="text-center text-xs">Index</div>
<div className="text-center text-xs">Unique</div>
</div>
{/* 컬럼 리스트 (스크롤 영역) */}
@ -1410,16 +1553,15 @@ export default function TableManagementPage() {
}
}}
>
{columns.map((column, index) => (
{columns.map((column, index) => {
const idxState = getColumnIndexState(column.columnName);
return (
<div
key={column.columnName}
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
>
<div className="pt-1 pr-4">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="px-4">
<div className="pr-4">
<Input
value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
@ -1427,6 +1569,9 @@ export default function TableManagementPage() {
className="h-8 text-xs"
/>
</div>
<div className="px-4 pt-1">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="pr-6">
<div className="space-y-3">
{/* 입력 타입 선택 */}
@ -1689,141 +1834,11 @@ export default function TableManagementPage() {
</div>
)}
{/* 표시 컬럼 - 검색 가능한 Combobox */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" && (
<div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> </label>
<Popover
open={entityComboboxOpen[column.columnName]?.displayColumn || false}
onOpenChange={(open) =>
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
displayColumn: open,
},
}))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={
entityComboboxOpen[column.columnName]?.displayColumn || false
}
className="bg-background h-8 w-full justify-between text-xs"
disabled={
!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0
}
>
{!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0 ? (
<span className="flex items-center gap-2">
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
...
</span>
) : column.displayColumn && column.displayColumn !== "none" ? (
column.displayColumn
) : (
"컬럼 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
handleDetailSettingsChange(
column.columnName,
"entity_display_column",
"none",
);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
displayColumn: false,
},
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.displayColumn === "none" || !column.displayColumn
? "opacity-100"
: "opacity-0",
)}
/>
-- --
</CommandItem>
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
<CommandItem
key={refCol.columnName}
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
onSelect={() => {
handleDetailSettingsChange(
column.columnName,
"entity_display_column",
refCol.columnName,
);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
displayColumn: false,
},
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.displayColumn === refCol.columnName
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{refCol.columnName}</span>
{refCol.columnLabel && (
<span className="text-muted-foreground text-[10px]">
{refCol.columnLabel}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 설정 완료 표시 */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" &&
column.displayColumn &&
column.displayColumn !== "none" && (
column.referenceColumn !== "none" && (
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs">
<Check className="h-3 w-3" />
<span className="truncate"> </span>
@ -1953,8 +1968,49 @@ export default function TableManagementPage() {
className="h-8 w-full text-xs"
/>
</div>
{/* PK 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={idxState.isPk}
onCheckedChange={(checked) =>
handlePkToggle(column.columnName, checked as boolean)
}
aria-label={`${column.columnName} PK 설정`}
/>
</div>
))}
{/* NN (NOT NULL) 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={column.isNullable === "NO"}
onCheckedChange={() =>
handleNullableToggle(column.columnName, column.isNullable)
}
aria-label={`${column.columnName} NOT NULL 설정`}
/>
</div>
{/* IDX 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={idxState.hasIndex}
onCheckedChange={(checked) =>
handleIndexToggle(column.columnName, "index", checked as boolean)
}
aria-label={`${column.columnName} 인덱스 설정`}
/>
</div>
{/* UQ 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={idxState.hasUnique}
onCheckedChange={(checked) =>
handleIndexToggle(column.columnName, "unique", checked as boolean)
}
aria-label={`${column.columnName} 유니크 설정`}
/>
</div>
</div>
);
})}
{/* 로딩 표시 */}
{columnsLoading && (
@ -2120,6 +2176,52 @@ export default function TableManagementPage() {
</>
)}
{/* PK 변경 확인 다이얼로그 */}
<Dialog open={pkDialogOpen} onOpenChange={setPkDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">PK </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
PK를 .
<br /> .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div className="rounded-lg border p-4">
<p className="text-sm font-medium"> PK :</p>
{pendingPkColumns.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{pendingPkColumns.map((col) => (
<Badge key={col} variant="secondary" className="font-mono text-xs">
{col}
</Badge>
))}
</div>
) : (
<p className="text-destructive mt-2 text-sm">PK가 </p>
)}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setPkDialogOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handlePkConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>