11 KiB
11 KiB
디지털 트윈 동적 계층 구조 마이그레이션 가이드
개요
기존 구조: Area(구역) → Location(위치) 고정 2단계
신규 구조: 동적 N-Level 계층 (영역 → 하위 영역 → ... → 자재)
1. 데이터베이스 마이그레이션
실행 방법
# PostgreSQL 컨테이너 접속
docker exec -it pms-db psql -U postgres -d erp
# 마이그레이션 실행
\i db/migrations/042_refactor_digital_twin_hierarchy.sql
변경 사항
digital_twin_layout테이블에hierarchy_configJSONB 컬럼 추가- 기존 테이블 매핑 컬럼들 제거 (warehouse_table_name, area_table_name 등)
digital_twin_objects테이블에 계층 관련 컬럼 추가:hierarchy_level: 계층 레벨 (1, 2, 3, ...)parent_key: 부모 객체의 외부 DB 키external_key: 자신의 외부 DB 키
2. 백엔드 API 변경 사항
신규 API 엔드포인트
전체 계층 데이터 조회
POST /api/digital-twin/data/hierarchy
Request Body:
{
"externalDbConnectionId": 15,
"hierarchyConfig": "{...}" // JSON 문자열
}
Response:
{
"success": true,
"data": {
"warehouse": [...],
"levels": [
{ "level": 1, "name": "구역", "data": [...] },
{ "level": 2, "name": "위치", "data": [...] }
],
"materials": [
{ "location_key": "LOC001", "count": 150 }
]
}
}
특정 부모의 하위 데이터 조회
POST /api/digital-twin/data/children
Request Body:
{
"externalDbConnectionId": 15,
"hierarchyConfig": "{...}",
"parentLevel": 1,
"parentKey": "AREA001"
}
Response:
{
"success": true,
"data": [...] // 다음 레벨 데이터
}
레거시 API (호환성 유지)
/api/digital-twin/data/warehouses(GET)/api/digital-twin/data/areas(GET)/api/digital-twin/data/locations(GET)/api/digital-twin/data/materials(GET)/api/digital-twin/data/material-counts(POST로 변경)
3. 프론트엔드 변경 사항
새로운 컴포넌트
HierarchyConfigPanel.tsx
동적 계층 구조 설정 UI
사용 방법:
import HierarchyConfigPanel from "./HierarchyConfigPanel";
<HierarchyConfigPanel
externalDbConnectionId={selectedDbConnection}
hierarchyConfig={hierarchyConfig}
onHierarchyConfigChange={setHierarchyConfig}
availableTables={availableTables}
onLoadTables={loadTablesFromDb}
onLoadColumns={loadColumnsFromTable}
/>
계층 구조 설정 예시
{
"warehouse": {
"tableName": "MWARMA",
"keyColumn": "WAREKEY",
"nameColumn": "WARENAME"
},
"levels": [
{
"level": 1,
"name": "구역",
"tableName": "MAREMA",
"keyColumn": "AREAKEY",
"nameColumn": "AREANAME",
"parentKeyColumn": "WAREKEY",
"objectTypes": ["area"]
},
{
"level": 2,
"name": "위치",
"tableName": "MLOCMA",
"keyColumn": "LOCAKEY",
"nameColumn": "LOCANAME",
"parentKeyColumn": "AREAKEY",
"typeColumn": "LOCTYPE",
"objectTypes": ["location-bed", "location-stp"]
}
],
"material": {
"tableName": "WSTKKY",
"keyColumn": "STKKEY",
"locationKeyColumn": "LOCAKEY",
"layerColumn": "LOLAYER",
"quantityColumn": "STKQUAN"
}
}
4. 공간적 종속성 (Spatial Containment)
새로운 유틸리티: spatialContainment.ts
주요 함수
1. 포함 여부 확인
import { isContainedIn } from "./spatialContainment";
const isValid = isContainedIn(childObject, parentObject);
// 자식 객체가 부모 객체 내부에 있는지 AABB로 검증
2. 유효한 부모 찾기
import { findValidParent } from "./spatialContainment";
const parent = findValidParent(draggedChild, allObjects, hierarchyLevels);
// 드래그 중인 자식 객체를 포함하는 부모 객체 자동 감지
3. 검증
import { validateSpatialContainment } from "./spatialContainment";
const result = validateSpatialContainment(child, allObjects);
if (!result.valid) {
alert("하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다!");
}
4. 그룹 이동 (부모 이동 시 자식도 함께)
import { updateChildrenPositions, getAllDescendants } from "./spatialContainment";
// 부모 객체 이동 시
const updatedChildren = updateChildrenPositions(
parentObject,
oldPosition,
newPosition,
allObjects
);
// 모든 하위 자손(재귀) 가져오기
const descendants = getAllDescendants(parentId, allObjects);
5. DigitalTwinEditor 통합 방법
Step 1: HierarchyConfigPanel 추가
// DigitalTwinEditor.tsx
import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel";
const [hierarchyConfig, setHierarchyConfig] = useState<HierarchyConfig | null>(null);
// 좌측 사이드바에 추가
<HierarchyConfigPanel
externalDbConnectionId={selectedDbConnection}
hierarchyConfig={hierarchyConfig}
onHierarchyConfigChange={setHierarchyConfig}
availableTables={availableTables}
onLoadTables={loadTables}
onLoadColumns={loadColumns}
/>
Step 2: 계층 데이터 로드
import { getHierarchyData, getChildrenData } from "@/lib/api/digitalTwin";
const loadHierarchyData = async () => {
if (!selectedDbConnection || !hierarchyConfig) return;
const response = await getHierarchyData(selectedDbConnection, hierarchyConfig);
if (response.success && response.data) {
// 창고 데이터
setWarehouses(response.data.warehouse);
// 각 레벨 데이터
response.data.levels.forEach((level) => {
if (level.level === 1) {
setAvailableAreas(level.data);
} else if (level.level === 2) {
setAvailableLocations(level.data);
}
// ... 추가 레벨
});
// 자재 개수
setMaterialCounts(response.data.materials);
}
};
Step 3: Yard3DCanvas에서 검증
// Yard3DCanvas.tsx 또는 DigitalTwinEditor.tsx
import { validateSpatialContainment } from "./spatialContainment";
const handleObjectDrop = (droppedObject: PlacedObject) => {
const result = validateSpatialContainment(
{
id: droppedObject.id,
position: droppedObject.position,
size: droppedObject.size,
hierarchyLevel: droppedObject.hierarchyLevel || 1,
parentId: droppedObject.parentId,
},
placedObjects.map((obj) => ({
id: obj.id,
position: obj.position,
size: obj.size,
hierarchyLevel: obj.hierarchyLevel || 1,
parentId: obj.parentId,
}))
);
if (!result.valid) {
toast({
variant: "destructive",
title: "배치 오류",
description: "하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다.",
});
return; // 배치 취소
}
// 유효하면 부모 ID 업데이트
droppedObject.parentId = result.parent?.id;
// 상태 업데이트
setPlacedObjects([...placedObjects, droppedObject]);
};
Step 4: 그룹 이동 구현
import { updateChildrenPositions, getAllDescendants } from "./spatialContainment";
const handleObjectMove = (
movedObject: PlacedObject,
oldPosition: { x: number; y: number; z: number },
newPosition: { x: number; y: number; z: number }
) => {
// 이동한 객체 업데이트
const updatedObjects = placedObjects.map((obj) =>
obj.id === movedObject.id
? { ...obj, position: newPosition }
: obj
);
// 모든 하위 자손 가져오기
const descendants = getAllDescendants(
movedObject.id,
placedObjects.map((obj) => ({
id: obj.id,
position: obj.position,
size: obj.size,
hierarchyLevel: obj.hierarchyLevel || 1,
parentId: obj.parentId,
}))
);
// 하위 자손들도 같이 이동
const delta = {
x: newPosition.x - oldPosition.x,
y: newPosition.y - oldPosition.y,
z: newPosition.z - oldPosition.z,
};
descendants.forEach((descendant) => {
const index = updatedObjects.findIndex((obj) => obj.id === descendant.id);
if (index !== -1) {
updatedObjects[index].position = {
x: updatedObjects[index].position.x + delta.x,
y: updatedObjects[index].position.y + delta.y,
z: updatedObjects[index].position.z + delta.z,
};
}
});
setPlacedObjects(updatedObjects);
};
6. 테스트 시나리오
테스트 1: 계층 구조 설정
- 외부 DB 선택
- 창고 테이블 선택 및 컬럼 매핑
- 레벨 추가 (레벨 1: 구역, 레벨 2: 위치)
- 각 레벨의 테이블 및 컬럼 매핑
- 자재 테이블 설정
- "저장" 클릭하여
hierarchy_config저장
테스트 2: 데이터 로드
- 계층 구조 설정 완료 후
- 창고 선택
- 각 레벨 데이터가 좌측 패널에 표시되는지 확인
- 자재 개수가 올바르게 표시되는지 확인
테스트 3: 3D 배치 및 공간적 종속성
- 레벨 1 (구역) 객체를 3D 캔버스에 드래그앤드롭
- 레벨 2 (위치) 객체를 레벨 1 객체 내부에 드래그앤드롭 → 성공
- 레벨 2 객체를 레벨 1 객체 외부에 드롭 → 오류 메시지 표시
테스트 4: 그룹 이동
- 레벨 1 객체를 이동
- 해당 레벨 1 객체의 모든 하위 객체(레벨 2, 3, ...)도 같이 이동하는지 확인
- 부모-자식 관계가 유지되는지 확인
테스트 5: 레이아웃 저장/로드
- 위 단계를 완료한 후 "저장" 클릭
- 페이지 새로고침
- 레이아웃을 다시 로드하여 계층 구조 및 객체 위치가 복원되는지 확인
7. 마이그레이션 체크리스트
- DB 마이그레이션 실행 (042_refactor_digital_twin_hierarchy.sql)
- 백엔드 API 테스트 (Postman/cURL)
HierarchyConfigPanel컴포넌트 통합spatialContainment.ts유틸리티 통합DigitalTwinEditor에서 계층 데이터 로드 구현Yard3DCanvas에서 공간적 종속성 검증 구현- 그룹 이동 기능 구현
- 모든 테스트 시나리오 통과
- 레거시 API와의 호환성 확인
8. 주의사항
- 기존 레이아웃 데이터: 마이그레이션 전 기존 레이아웃이 있다면 백업 필요
- 컬럼 매핑 검증: 외부 DB 테이블의 컬럼명이 변경될 수 있으므로 auto-mapping 로직 필수
- 성능: N-Level이 3단계 이상 깊어지면 재귀 쿼리 성능 모니터링 필요
- 권한: 외부 DB에 대한 읽기 권한 확인
9. 향후 개선 사항
- 드래그 중 실시간 검증: 드래그하는 동안 부모 영역 하이라이트
- 시각적 피드백: 유효한 배치 위치를 그리드에 색상으로 표시
- 계층 구조 시각화: 좌측 패널에 트리 구조로 표시
- Undo/Redo: 객체 배치 실행 취소 기능
- 스냅 가이드: 부모 영역 테두리에 스냅 가이드라인 표시
작성일: 2025-11-20
작성자: AI Assistant