18 KiB
18 KiB
화면관리 시스템 설계문서
1. 개요
1.1 목적
ERP 시스템에서 사용자가 직관적인 드래그앤드롭 인터페이스를 통해 동적으로 화면을 설계하고 관리할 수 있는 시스템
1.2 주요 기능
- 드래그앤드롭 기반 화면 설계
- 실시간 미리보기 및 속성 편집
- 다양한 위젯 타입 지원
- 데이터베이스 테이블/컬럼과의 연동
- 템플릿 기반 빠른 화면 생성
- 스타일 및 레이아웃 커스터마이징
2. 시스템 아키텍처
2.1 전체 구조
화면 편집기 (ScreenDesigner)
├── 템플릿 패널 (TemplatesPanel)
├── 테이블 패널 (TablesPanel)
├── 속성 편집 패널 (PropertiesPanel)
├── 스타일 편집 패널 (StyleEditor)
├── 상세설정 패널 (DetailSettingsPanel)
├── 격자 설정 패널 (GridPanel)
└── 캔버스 영역 (RealtimePreview)
할당된 화면 (InteractiveScreenViewer)
├── 라벨 렌더링
├── 위젯 렌더링
└── 폼 데이터 관리
2.2 데이터 흐름
사용자 입력 → 컴포넌트 상태 → 레이아웃 데이터 → API 저장/불러오기 → 할당된 화면 렌더링
3. 컴포넌트 구조
3.1 컴포넌트 타입
type ComponentType = "container" | "widget" | "group" | "datatable";
interface BaseComponent {
id: string;
type: ComponentType;
position: { x: number; y: number; z?: number };
size: { width: number; height: number };
parentId?: string;
label?: string;
required?: boolean;
readonly?: boolean;
style?: ComponentStyle;
}
interface WidgetComponent extends BaseComponent {
type: "widget";
widgetType: WebType;
placeholder?: string;
columnName?: string;
webTypeConfig?: WebTypeConfig;
}
3.2 지원하는 웹 타입
- 텍스트 입력: text, email, tel
- 숫자 입력: number, decimal
- 날짜/시간: date, datetime
- 선택: select, dropdown, radio
- 체크박스: checkbox, boolean
- 텍스트 영역: textarea
- 파일: file
- 코드: code
- 엔티티: entity
- 버튼: button
4. 주요 기능 상세
4.1 드래그앤드롭 시스템
템플릿 드래그
- 사전 정의된 템플릿을 캔버스에 드롭
- 컨테이너와 자식 컴포넌트 관계 자동 설정
- 격자 스냅 및 자동 크기 조정
컬럼 드래그
- 데이터베이스 테이블의 컬럼을 위젯으로 변환
- 컬럼 타입에 따른 자동 웹타입 매핑
- 폼 컨테이너에 드롭 시 자동 부모-자식 관계 설정
다중 컴포넌트 드래그
- Ctrl/Cmd + 클릭으로 다중 선택
- 선택된 모든 컴포넌트 동시 이동
- 실시간 미리보기 제공
4.2 속성 편집 시스템
실시간 속성 편집 패턴
// 로컬 상태 기반 즉시 반영
const [localInputs, setLocalInputs] = useState({
title: component.title || "",
placeholder: component.placeholder || "",
});
// 입력과 동시에 업데이트
<Input
value={localInputs.title}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs(prev => ({ ...prev, title: newValue }));
onUpdateProperty("title", newValue);
}}
/>
컴포넌트별 개별 상태 관리
// 동적 ID 기반 상태 관리
const [localColumnInputs, setLocalColumnInputs] = useState<Record<string, string>>({});
// 컴포넌트 변경 시 기존 값 보존하면서 새 항목만 추가
useEffect(() => {
setLocalColumnInputs((prev) => {
const newInputs = { ...prev };
component.columns?.forEach((col) => {
if (!(col.id in newInputs)) {
newInputs[col.id] = col.label;
}
});
return newInputs;
});
}, [component.columns]);
4.3 스타일 시스템
스타일 적용 계층
- 컴포넌트 기본 스타일:
component.style - 웹타입 설정 스타일:
webTypeConfig에서 정의 - 라벨 스타일: 별도 관리 (
labelColor,labelFontSize등)
스타일 패널 구성
- 여백: margin, padding, gap
- 테두리: borderWidth, borderStyle, borderColor, borderRadius
- 배경: backgroundColor, backgroundImage
- 텍스트: color, fontSize, fontWeight, textAlign
4.4 템플릿 시스템
데이터 테이블 템플릿
{
id: "data-table",
name: "데이터 테이블",
category: "table",
components: [
{
id: "table-container",
type: "datatable",
searchFilters: [],
columns: [
{ id: "col1", label: "컬럼 1", visible: true, sortable: true },
{ id: "col2", label: "컬럼 2", visible: true, sortable: false }
],
pagination: { enabled: true, pageSize: 10 },
actions: {
create: { enabled: true, label: "추가" },
edit: { enabled: true, label: "수정" },
delete: { enabled: true, label: "삭제" }
}
}
]
}
입력 폼 템플릿
{
id: "input-form",
name: "입력 폼",
category: "form",
components: [
{
id: "form-container",
type: "container",
style: { backgroundColor: "#f8f9fa", borderRadius: "8px" },
children: [
{
id: "save-button",
type: "widget",
widgetType: "button",
parentId: "form-container",
position: { x: 0, y: 0 },
style: { position: "absolute", bottom: "24px", right: "104px" }
},
{
id: "cancel-button",
type: "widget",
widgetType: "button",
parentId: "form-container",
position: { x: 0, y: 0 },
style: { position: "absolute", bottom: "24px", right: "24px" }
}
]
}
]
}
범용 버튼 템플릿
{
id: "universal-button",
name: "버튼",
category: "button",
components: [
{
id: "button",
type: "widget",
widgetType: "button",
webTypeConfig: {
actionType: "save",
variant: "default",
size: "sm"
}
}
]
}
4.5 웹타입별 상세 설정
버튼 설정 (ButtonConfigPanel)
interface ButtonTypeConfig {
actionType: ButtonActionType;
variant: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
size: "default" | "sm" | "lg" | "icon";
icon?: string;
confirmMessage?: string;
popupTitle?: string;
popupContent?: string;
popupSize?: "sm" | "md" | "lg";
navigateUrl?: string;
navigateTarget?: "_self" | "_blank";
customAction?: string;
backgroundColor?: string;
textColor?: string;
borderColor?: string;
}
type ButtonActionType =
| "save"
| "cancel"
| "delete"
| "edit"
| "add"
| "search"
| "reset"
| "submit"
| "close"
| "popup"
| "navigate"
| "custom";
텍스트 설정 (TextTypeConfig)
interface TextTypeConfig {
format: "none" | "korean" | "english" | "alphanumeric" | "numeric" | "email" | "phone" | "url";
minLength?: number;
maxLength?: number;
pattern?: string;
placeholder?: string;
multiline?: boolean;
}
숫자 설정 (NumberTypeConfig)
interface NumberTypeConfig {
min?: number;
max?: number;
step?: number;
format?: "integer" | "decimal" | "currency" | "percentage";
decimalPlaces?: number;
thousandSeparator?: boolean;
}
날짜 설정 (DateTypeConfig)
interface DateTypeConfig {
format: "YYYY-MM-DD" | "YYYY-MM-DD HH:mm" | "YYYY-MM-DD HH:mm:ss";
showTime: boolean;
minDate?: string;
maxDate?: string;
defaultValue?: string;
}
선택박스 설정 (SelectTypeConfig)
interface SelectTypeConfig {
options: Array<{ label: string; value: string }>;
multiple?: boolean;
searchable?: boolean;
placeholder?: string;
}
엔티티 설정 (EntityTypeConfig)
interface EntityTypeConfig {
entityName: string;
displayField: string;
valueField: string;
filters: Array<{ field: string; operator: string; value: any }>;
multiple: boolean;
searchable: boolean;
allowClear: boolean;
placeholder?: string;
displayFormat?: string;
defaultValue?: any;
}
4.6 격자 시스템
격자 설정
interface GridSettings {
columns: number; // 격자 컬럼 수
gap: number; // 격자 간격 (px)
padding: number; // 캔버스 패딩 (px)
snapToGrid: boolean; // 격자 스냅 활성화
showGrid: boolean; // 격자 표시 여부
gridColor: string; // 격자 선 색상
gridOpacity: number; // 격자 투명도 (0-1)
}
격자 스냅 로직
const snapToGrid = (value: number, gridSize: number): number => {
return Math.round(value / gridSize) * gridSize;
};
const snapSizeToGrid = (size: number, gridSize: number): number => {
return Math.max(gridSize, Math.round(size / gridSize) * gridSize);
};
5. 렌더링 시스템
5.1 편집기 렌더링 (ScreenDesigner)
컴포넌트 위치 계산
// 절대 위치 래퍼 div
<div
className="absolute"
style={{
left: `${displayComponent.position.x}px`,
top: `${displayComponent.position.y}px`,
width: displayComponent.style?.width || `${displayComponent.size.width}px`,
height: displayComponent.style?.height || `${displayComponent.size.height}px`,
zIndex: displayComponent.position.z || 1,
}}
>
<RealtimePreview
component={displayComponent}
position={{ x: 0, y: 0 }} // 래퍼 div 내에서는 상대 위치
/>
</div>
자식 컴포넌트 상대 위치
// 자식 컴포넌트는 부모 기준 상대 위치로 계산
const relativePosition = {
x: child.position.x - parent.position.x,
y: child.position.y - parent.position.y,
};
5.2 할당된 화면 렌더링 (InteractiveScreenViewer)
라벨 외부 분리 렌더링
// 라벨을 컴포넌트 외부에 별도 렌더링 (높이에 영향 없음)
{shouldShowLabel && (
<div
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y - 25}px`, // 컴포넌트 위쪽에 배치
zIndex: (component.position.z || 1) + 1,
...labelStyle,
}}
>
{labelText}
{component.required && <span style={{ color: "#f97316" }}>*</span>}
</div>
)}
// 실제 컴포넌트 (라벨 높이에 영향받지 않음)
<div style={{ height: component.style?.height || `${component.size.height}px` }}>
<InteractiveScreenViewer component={component} hideLabel={true} />
</div>
스타일 적용 시스템
const applyStyles = (element: React.ReactElement) => {
if (!comp.style) return element;
return React.cloneElement(element, {
style: {
...element.props.style, // 기존 스타일 유지
...comp.style, // 컴포넌트 스타일 적용
width: "100%", // 부모 컨테이너에 맞춤
height: "100%",
minHeight: "100%", // 강제 높이 적용
maxHeight: "100%",
boxSizing: "border-box",
},
});
};
위젯별 렌더링
switch (widgetType) {
case "text":
case "email":
case "tel":
return applyStyles(
<Input
type={inputType}
placeholder={finalPlaceholder}
value={currentValue}
onChange={handleInputChange}
className="w-full"
style={{ height: "100%" }}
/>
);
case "button":
const config = widget.webTypeConfig as ButtonTypeConfig;
return (
<Button
onClick={handleButtonClick}
size={config?.size || "sm"}
variant={config?.variant || "default"}
className="w-full"
style={{
...comp.style,
height: "100%",
backgroundColor: config?.backgroundColor,
color: config?.textColor,
borderColor: config?.borderColor,
}}
>
{label || "버튼"}
</Button>
);
case "entity":
return (
<Select>
<SelectTrigger
className="w-full"
style={{
...comp.style,
height: "100%",
}}
>
<SelectValue placeholder={finalPlaceholder} />
</SelectTrigger>
<SelectContent>
{options.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
6. 상태 관리
6.1 레이아웃 상태
interface LayoutData {
components: ComponentData[];
gridSettings: GridSettings;
}
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: defaultGridSettings,
});
6.2 선택 상태
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
const [selectedComponents, setSelectedComponents] = useState<ComponentData[]>([]);
6.3 드래그 상태
interface DragState {
isDragging: boolean;
draggedComponents: ComponentData[];
startPosition: { x: number; y: number };
currentPosition: { x: number; y: number };
}
6.4 패널 상태
interface PanelState {
isOpen: boolean;
position: { x: number; y: number };
size: { width: number; height: number };
}
const [panelStates, setPanelStates] = useState<Record<string, PanelState>>({
templates: { isOpen: true, position: { x: 0, y: 0 }, size: { width: 300, height: 400 } },
properties: { isOpen: false, position: { x: 0, y: 0 }, size: { width: 360, height: 600 } },
styles: { isOpen: false, position: { x: 0, y: 0 }, size: { width: 360, height: 400 } },
// ...
});
7. API 연동
7.1 화면 정보 API
// 화면 목록 조회
GET /api/screens
Response: {
screens: Array<{
id: number;
name: string;
description: string;
createdAt: string;
updatedAt: string;
}>
}
// 화면 상세 조회
GET /api/screens/:id
Response: {
id: number;
name: string;
description: string;
layout: LayoutData;
}
// 화면 저장
POST /api/screens/:id/layout
Request: {
layout: LayoutData
}
7.2 테이블 정보 API
// 테이블 목록 조회
GET / api / tables;
Response: {
tables: Array<{
id: string;
name: string;
description: string;
columns: Array<{
id: string;
name: string;
type: string;
nullable: boolean;
primaryKey: boolean;
}>;
}>;
}
8. 성능 최적화
8.1 렌더링 최적화
useCallback으로 이벤트 핸들러 메모이제이션useMemo로 계산 비용이 큰 값 캐싱- 컴포넌트 분할을 통한 불필요한 리렌더링 방지
8.2 상태 최적화
- 로컬 상태 기반 즉시 반영으로 UI 응답성 향상
- 디바운싱을 통한 과도한 API 호출 방지
- 컴포넌트별 개별 상태 관리로 전역 상태 오염 방지
9. 개발 가이드
9.1 새로운 웹타입 추가
- 타입 정의 추가
// types/screen.ts
type WebType = "text" | "number" | "date" | "새로운타입";
interface 새로운타입TypeConfig {
// 설정 속성들
}
type WebTypeConfig = TextTypeConfig | NumberTypeConfig | 새로운타입TypeConfig;
- 설정 패널 생성
// panels/webtype-configs/새로운타입TypeConfigPanel.tsx
export const 새로운타입ConfigPanel: React.FC<Props> = ({ component, onUpdateComponent }) => {
// 설정 UI 구현
};
- 렌더링 로직 추가
// RealtimePreview.tsx, InteractiveScreenViewer.tsx
case "새로운타입":
return renderNewWidget(component);
- DetailSettingsPanel에 연결
case "새로운타입":
return <새로운타입ConfigPanel component={widget} onUpdateComponent={handleUpdate} />;
9.2 새로운 템플릿 추가
- TemplatesPanel에 템플릿 정의 추가
const templates: TemplateComponent[] = [
{
id: "새로운-템플릿",
name: "새로운 템플릿",
category: "카테고리",
icon: <IconComponent />,
defaultSize: { width: 400, height: 300 },
components: [
// 템플릿 구성 컴포넌트들
]
}
];
- 필요한 경우 특별한 렌더링 로직 추가
9.3 코딩 컨벤션
실시간 속성 편집 패턴 (필수)
// 1. 로컬 상태 정의
const [localInputs, setLocalInputs] = useState({
title: component.title || "",
});
// 2. 컴포넌트 변경 시 동기화
useEffect(() => {
setLocalInputs({
title: component.title || "",
});
}, [component.title]);
// 3. 실시간 입력 처리
<Input
value={localInputs.title}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs(prev => ({ ...prev, title: newValue }));
onUpdateProperty("title", newValue);
}}
/>
10. 테스트 전략
10.1 단위 테스트
- 유틸리티 함수 (격자 계산, 스타일 적용 등)
- 컴포넌트 상태 관리 로직
- 데이터 변환 함수
10.2 통합 테스트
- 드래그앤드롭 시나리오
- 속성 편집 플로우
- API 연동 테스트
10.3 E2E 테스트
- 화면 생성부터 렌더링까지 전체 플로우
- 복잡한 사용자 시나리오
11. 향후 개선 계획
11.1 단기 계획
- 웹타입별 상세 설정 완성 (Date, Number, Select, Radio, File, Code, Entity)
- 조건부 표시 기능 (특정 조건에 따른 컴포넌트 표시/숨김)
- 계산 필드 기능 (다른 필드 값을 기반으로 한 자동 계산)
11.2 중장기 계획
- 컴포넌트 간 데이터 바인딩
- 워크플로우 연동
- 다국어 지원
- 반응형 디자인
- 컴포넌트 라이브러리 확장
12. 트러블슈팅
12.1 일반적인 문제
높이가 적용되지 않는 문제
- 원인: Tailwind CSS의
h-full클래스가 인라인 스타일을 무시 - 해결:
className="w-full"+style={{ height: "100%" }}사용
라벨이 컴포넌트 높이에 포함되는 문제
- 원인: 라벨과 위젯이 같은 컨테이너 내에 위치
- 해결: 라벨을 외부에 별도 렌더링하여 높이에서 제외
스타일이 할당된 화면에서 적용되지 않는 문제
- 원인:
applyStyles함수 미사용 또는 잘못된 스타일 병합 - 해결: 모든 위젯에서 일관된 스타일 적용 로직 사용
다중 드래그 시 성능 문제
- 원인: 과도한 리렌더링
- 해결:
useCallback,useMemo적극 활용
12.2 디버깅 팁
- 브라우저 개발자 도구의 React DevTools 활용
- 콘솔 로그를 통한 상태 추적
- 컴포넌트 트리 구조 시각화
본 문서는 지속적으로 업데이트되며, 새로운 기능 추가 시 해당 섹션을 업데이트해야 합니다.