21 KiB
21 KiB
Flow 기반 반응형 레이아웃 설계서
작성일: 2026-01-30 목표: 진정한 반응형 구현 (PC/태블릿/모바일 전체 대응)
1. 핵심 결론
1.1 현재 방식 vs 반응형 표준
| 항목 | 현재 시스템 | 웹 표준 (2025) |
|---|---|---|
| 배치 방식 | position: absolute |
Flexbox / CSS Grid |
| 좌표 | 픽셀 고정 (x, y) | Flow 기반 (순서) |
| 화면 축소 시 | 그대로 (잘림) | 자동 재배치 |
| 용도 | 툴팁, 오버레이 | 전체 레이아웃 |
결론:
position: absolute는 전체 레이아웃에 사용하면 안 됨 (웹 표준)
1.2 구현 방향
절대 좌표 (x, y 픽셀)
↓ 변환
Flow 기반 배치 (Flexbox + Grid)
↓ 결과
화면 크기에 따라 자동 재배치
2. 실제 화면 데이터 분석
2.1 분석 대상
총 레이아웃: 1,250개
총 컴포넌트: 5,236개
분석 샘플: 6개 화면 (23, 20, 18, 16, 18, 5개 컴포넌트)
2.2 화면 68 (수주 목록) - 가로 배치 패턴
y=88: [분리] [저장] [수정] [삭제] ← 같은 행에 버튼 4개
x=1277 x=1436 x=1594 x=1753
y=128: [────────── 테이블 ──────────]
x=8, width=1904
변환 후:
<div class="flex flex-wrap justify-end gap-2"> <!-- Row 1 -->
<button>분리</button>
<button>저장</button>
<button>수정</button>
<button>삭제</button>
</div>
<div class="w-full"> <!-- Row 2 -->
<Table />
</div>
반응형 동작:
1920px: [분리] [저장] [수정] [삭제] ← 가로 배치
1280px: [분리] [저장] [수정] [삭제] ← 가로 배치 (공간 충분)
768px: [분리] [저장] ← 줄바꿈 발생
[수정] [삭제]
375px: [분리] ← 세로 배치
[저장]
[수정]
[삭제]
2.3 화면 119 (장치 관리) - 2열 폼 패턴
y=80: [장치 코드 ] [시리얼넘버 ]
x=136, w=256 x=408, w=256
y=160: [제조사 ]
x=136, w=528
y=240: [품번 ] [모델명 ]
x=136, w=256 x=408, w=256
y=320: [구매일 ] [상태 ]
y=400: [공급사 ] [구매 가격 ]
y=480: [계약 번호 ] [공급사 전화 ]
... (2열 반복)
y=840: [저장]
x=544
변환 후:
<div class="grid grid-cols-2 gap-4"> <!-- 2열 그리드 -->
<Input label="장치 코드" />
<Input label="시리얼넘버" />
</div>
<div class="w-full"> <!-- 전체 너비 -->
<Input label="제조사" />
</div>
<div class="grid grid-cols-2 gap-4">
<Input label="품번" />
<Select label="모델명" />
</div>
<!-- ... 반복 ... -->
<div class="flex justify-center">
<Button>저장</Button>
</div>
반응형 동작:
1920px: [장치 코드] [시리얼넘버] ← 2열
1280px: [장치 코드] [시리얼넘버] ← 2열
768px: [장치 코드] ← 1열
[시리얼넘버]
375px: [장치 코드] ← 1열
[시리얼넘버]
2.4 화면 4103 (수주 등록) - 섹션 기반 패턴
y=20: [섹션: 옵션 설정 ]
y=35: [입력방식▼] [판매유형▼] [단가방식▼] [☑ 단가수정]
y=110: [섹션: 거래처 정보 ]
y=190: [거래처 * ] [담당자 ] [납품처 ] [납품장소 ]
y=260: [섹션: 추가된 품목 ]
y=360: [리피터 테이블 ]
y=570: [섹션: 무역 정보 ]
y=690: [인코텀즈▼] [결제조건▼] [통화▼ ]
y=740: [선적항 ] [도착항 ] [HS Code ]
y=890: [섹션: 추가 정보 ]
y=935: [메모 ]
y=1080: [저장]
변환 후:
<Card title="옵션 설정">
<div class="flex flex-wrap gap-4">
<Select label="입력방식" />
<Select label="판매유형" />
<Select label="단가방식" />
<Checkbox label="단가수정 허용" />
</div>
</Card>
<Card title="거래처 정보">
<div class="grid grid-cols-4 gap-4"> <!-- 4열 그리드 -->
<Select label="거래처 *" />
<Input label="담당자" />
<Input label="납품처" />
<Input label="납품장소" class="col-span-2" />
</div>
</Card>
<!-- ... 섹션 반복 ... -->
<div class="flex justify-end">
<Button>저장</Button>
</div>
반응형 동작:
1920px: [입력방식] [판매유형] [단가방식] [단가수정] ← 4열
1280px: [입력방식] [판매유형] [단가방식] ← 3열
[단가수정]
768px: [입력방식] [판매유형] ← 2열
[단가방식] [단가수정]
375px: [입력방식] ← 1열
[판매유형]
[단가방식]
[단가수정]
3. 변환 규칙
3.1 Row 그룹화 알고리즘
const ROW_THRESHOLD = 40; // px
function groupByRows(components: Component[]): Row[] {
// 1. y 좌표로 정렬
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
const rows: Row[] = [];
let currentRow: Component[] = [];
let currentY = -Infinity;
for (const comp of sorted) {
if (comp.position.y - currentY > ROW_THRESHOLD) {
// 새로운 Row 시작
if (currentRow.length > 0) {
rows.push({
y: currentY,
components: currentRow.sort((a, b) => a.position.x - b.position.x)
});
}
currentRow = [comp];
currentY = comp.position.y;
} else {
// 같은 Row에 추가
currentRow.push(comp);
}
}
// 마지막 Row 추가
if (currentRow.length > 0) {
rows.push({
y: currentY,
components: currentRow.sort((a, b) => a.position.x - b.position.x)
});
}
return rows;
}
3.2 화면 68 적용 예시
입력:
[
{ "id": "comp_1899", "position": { "x": 1277, "y": 88 }, "text": "분리" },
{ "id": "comp_1898", "position": { "x": 1436, "y": 88 }, "text": "저장" },
{ "id": "comp_1897", "position": { "x": 1594, "y": 88 }, "text": "수정" },
{ "id": "comp_1896", "position": { "x": 1753, "y": 88 }, "text": "삭제" },
{ "id": "comp_1895", "position": { "x": 8, "y": 128 }, "type": "table" }
]
변환 결과:
{
"rows": [
{
"y": 88,
"justify": "end",
"components": ["comp_1899", "comp_1898", "comp_1897", "comp_1896"]
},
{
"y": 128,
"justify": "start",
"components": ["comp_1895"]
}
]
}
3.3 정렬 방향 결정
function determineJustify(row: Row, screenWidth: number): string {
const firstX = row.components[0].position.x;
const lastComp = row.components[row.components.length - 1];
const lastEnd = lastComp.position.x + lastComp.size.width;
// 왼쪽 여백 vs 오른쪽 여백 비교
const leftMargin = firstX;
const rightMargin = screenWidth - lastEnd;
if (leftMargin > rightMargin * 2) {
return "end"; // 오른쪽 정렬
} else if (rightMargin > leftMargin * 2) {
return "start"; // 왼쪽 정렬
} else {
return "center"; // 중앙 정렬
}
}
// 화면 68 버튼 그룹:
// leftMargin = 1277, rightMargin = 1920 - 1912 = 8
// → "end" (오른쪽 정렬)
4. 렌더링 구현
4.1 새로운 FlowLayout 컴포넌트
// frontend/lib/registry/layouts/flow/FlowLayout.tsx
interface FlowLayoutProps {
layout: LayoutData;
renderer: DynamicComponentRenderer;
}
export function FlowLayout({ layout, renderer }: FlowLayoutProps) {
// 1. Row 그룹화
const rows = useMemo(() => {
return groupByRows(layout.components);
}, [layout.components]);
return (
<div className="flex flex-col gap-4 w-full">
{rows.map((row, index) => (
<FlowRow
key={index}
row={row}
renderer={renderer}
/>
))}
</div>
);
}
function FlowRow({ row, renderer }: { row: Row; renderer: any }) {
const justify = determineJustify(row, 1920);
const justifyClass = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
}[justify];
return (
<div className={`flex flex-wrap gap-2 ${justifyClass}`}>
{row.components.map((comp) => (
<div
key={comp.id}
style={{
minWidth: comp.size.width,
// width는 고정하지 않음 (flex로 자동 조정)
}}
>
{renderer.renderChild(comp)}
</div>
))}
</div>
);
}
4.2 기존 코드 수정 위치
현재 (RealtimePreviewDynamic.tsx 라인 524-536):
const baseStyle = {
left: `${adjustedPositionX}px`, // ❌ 절대 좌표
top: `${position.y}px`, // ❌ 절대 좌표
position: "absolute", // ❌ 절대 위치
};
변경 후:
// FlowLayout 사용 시 position 관련 스타일 제거
const baseStyle = isFlowMode ? {
// position, left, top 없음
minWidth: size.width,
height: size.height,
} : {
left: `${adjustedPositionX}px`,
top: `${position.y}px`,
position: "absolute",
};
5. 가상 시뮬레이션
5.1 시나리오 1: 화면 68 (버튼 4개 + 테이블)
렌더링 결과 (1920px):
┌────────────────────────────────────────────────────────────────────────┐
│ [분리] [저장] [수정] [삭제] │
│ flex-wrap, justify-end │
├────────────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 테이블 (w-full) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
✅ 정상: 버튼 오른쪽 정렬, 테이블 전체 너비
렌더링 결과 (1280px):
┌─────────────────────────────────────────────┐
│ [분리] [저장] [수정] [삭제] │
│ flex-wrap, justify-end │
├─────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────┐ │
│ │ 테이블 (w-full) │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
✅ 정상: 버튼 크기 유지, 테이블 너비 조정
렌더링 결과 (768px):
┌──────────────────────────┐
│ [분리] [저장] │
│ [수정] [삭제] │ ← 자동 줄바꿈!
├──────────────────────────┤
│ ┌──────────────────────┐ │
│ │ 테이블 (w-full) │ │
│ └──────────────────────┘ │
└──────────────────────────┘
✅ 정상: 버튼 줄바꿈, 테이블 너비 조정
렌더링 결과 (375px):
┌─────────────┐
│ [분리] │
│ [저장] │
│ [수정] │
│ [삭제] │ ← 세로 배치
├─────────────┤
│ ┌─────────┐ │
│ │ 테이블 │ │ (가로 스크롤)
│ └─────────┘ │
└─────────────┘
✅ 정상: 버튼 세로 배치, 테이블 가로 스크롤
5.2 시나리오 2: 화면 119 (2열 폼)
렌더링 결과 (1920px):
┌────────────────────────────────────────────────────────────────────────┐
│ [장치 코드 ] [시리얼넘버 ] │
│ grid-cols-2 │
├────────────────────────────────────────────────────────────────────────┤
│ [제조사 ] │
│ col-span-2 (전체 너비) │
├────────────────────────────────────────────────────────────────────────┤
│ [품번 ] [모델명▼ ] │
│ ... │
└────────────────────────────────────────────────────────────────────────┘
✅ 정상: 2열 그리드
렌더링 결과 (768px):
┌──────────────────────────┐
│ [장치 코드 ] │
│ [시리얼넘버 ] │ ← 1열로 변경
├──────────────────────────┤
│ [제조사 ] │
├──────────────────────────┤
│ [품번 ] │
│ [모델명▼ ] │
│ ... │
└──────────────────────────┘
✅ 정상: 1열 그리드
5.3 시나리오 3: 분할 패널
현재 SplitPanelLayout 동작:
좌측 60% | 우측 40% ← 이미 퍼센트 기반
변경 후 (768px 이하):
┌────────────────────┐
│ 좌측 100% │
├────────────────────┤
│ 우측 100% │
└────────────────────┘
← 세로 배치로 전환
구현:
// SplitPanelLayoutComponent.tsx
const isMobile = useMediaQuery("(max-width: 768px)");
return (
<div className={isMobile ? "flex-col" : "flex-row"}>
<div className={isMobile ? "w-full" : "w-[60%]"}>
{/* 좌측 패널 */}
</div>
<div className={isMobile ? "w-full" : "w-[40%]"}>
{/* 우측 패널 */}
</div>
</div>
);
6. 엣지 케이스 검증
6.1 겹치는 컴포넌트
현재 데이터 (화면 74):
{ "id": "comp_2606", "position": { "x": 161, "y": 400 } }, // 분할 패널
{ "id": "comp_fkk75q08", "position": { "x": 161, "y": 400 } } // 라디오 버튼
문제: 같은 위치에 두 컴포넌트 → z-index로 겹쳐서 표시
해결:
- z-index가 높은 컴포넌트 우선
- 또는 parent-child 관계면 중첩 처리
function resolveOverlaps(row: Row): Row {
// z-index로 정렬하여 높은 것만 표시
// 또는 parentId 확인하여 중첩 처리
}
6.2 조건부 표시 컴포넌트
현재 데이터 (화면 4103):
{
"id": "section-customer-info",
"conditionalConfig": {
"field": "input_method",
"value": "customer_first",
"action": "show"
}
}
동작: 조건에 따라 show/hide Flow 레이아웃에서: 숨겨지면 공간도 사라짐 (flex 자동 조정)
✅ 문제없음
6.3 테이블 + 버튼 조합
패턴:
[버튼 그룹] ← flex-wrap, justify-end
[테이블] ← w-full
테이블 가로 스크롤:
- 테이블 내부는 가로 스크롤 지원
- 외부 컨테이너는 w-full
✅ 문제없음
6.4 섹션 카드 내부 컴포넌트
현재: 섹션 카드와 내부 컴포넌트가 별도로 저장됨
변환 시:
- 섹션 카드의 y 범위 파악
- 해당 y 범위 내 컴포넌트들을 섹션 자식으로 그룹화
- 섹션 내부에서 다시 Row 그룹화
function groupWithinSection(
section: Component,
allComponents: Component[]
): Component[] {
const sectionTop = section.position.y;
const sectionBottom = section.position.y + section.size.height;
return allComponents.filter(comp => {
return comp.id !== section.id &&
comp.position.y >= sectionTop &&
comp.position.y < sectionBottom;
});
}
7. 호환성 검증
7.1 기존 기능 호환
| 기능 | 호환 여부 | 설명 |
|---|---|---|
| 디자인 모드 | ⚠️ 수정 필요 | 드래그 앤 드롭 로직 수정 |
| 미리보기 | ✅ 호환 | Flow 레이아웃으로 렌더링 |
| 조건부 표시 | ✅ 호환 | flex로 자동 조정 |
| 분할 패널 | ⚠️ 수정 필요 | 반응형 전환 로직 추가 |
| 테이블 | ✅ 호환 | w-full 적용 |
| 모달 | ✅ 호환 | 모달 내부도 Flow 적용 |
7.2 디자인 모드 수정
현재: 드래그하면 x, y 픽셀 저장 변경 후: 드래그하면 x, y 픽셀 저장 (동일) → 렌더링 시 변환
저장: 픽셀 좌표 (기존 유지)
렌더링: Flow 기반으로 변환
장점: DB 마이그레이션 불필요
8. 구현 계획
Phase 1: 핵심 변환 로직 (1일)
groupByRows()함수 구현determineJustify()함수 구현FlowLayout컴포넌트 생성
Phase 2: 렌더링 적용 (1일)
DynamicComponentRenderer에 Flow 모드 추가RealtimePreviewDynamic수정- 기존 absolute 스타일 조건부 적용
Phase 3: 특수 케이스 처리 (1일)
- 섹션 카드 내부 그룹화
- 겹치는 컴포넌트 처리
- 분할 패널 반응형 전환
Phase 4: 테스트 (1일)
- 화면 68 (버튼 + 테이블) 테스트
- 화면 119 (2열 폼) 테스트
- 화면 4103 (복잡한 폼) 테스트
- PC 1920px → 1280px 테스트
- 태블릿 768px 테스트
- 모바일 375px 테스트
9. 예상 이슈
9.1 디자이너 의도 손실
문제: 디자이너가 의도적으로 배치한 위치가 변경될 수 있음
해결:
- 기본 Flow 레이아웃 적용
- 필요시
flexOrder속성으로 순서 조정 가능 - 또는
fixedPosition: true옵션으로 절대 좌표 유지
9.2 복잡한 레이아웃
문제: 일부 화면은 자유 배치가 필요할 수 있음
해결:
- 화면별
layoutMode설정"flow": Flow 기반 (기본값)"absolute": 기존 절대 좌표
9.3 성능
문제: 매 렌더링마다 Row 그룹화 계산
해결:
useMemo로 캐싱- 컴포넌트 목록 변경 시에만 재계산
10. 최종 체크리스트
구현 전
- 현재 동작하는 화면 스크린샷 (비교용)
- 테스트 화면 목록 확정 (68, 119, 4103)
구현 중
groupByRows()구현determineJustify()구현FlowLayout컴포넌트 생성DynamicComponentRenderer수정RealtimePreviewDynamic수정
테스트
- 1920px 테스트
- 1280px 테스트
- 768px 테스트
- 375px 테스트
- 디자인 모드 테스트
- 분할 패널 테스트
- 조건부 표시 테스트
11. 결론
11.1 구현 가능 여부
✅ 가능
- 기존 데이터 구조 유지 (DB 변경 없음)
- 렌더링 레벨에서만 변환
- 모든 화면 패턴 분석 완료
- 엣지 케이스 해결책 확보
11.2 핵심 변경 사항
Before: position: absolute + left/top 픽셀
After: Flexbox + flex-wrap + justify-*
11.3 예상 효과
| 화면 크기 | Before | After |
|---|---|---|
| 1920px | 정상 | 정상 |
| 1280px | 버튼 잘림 | 자동 조정 |
| 768px | 레이아웃 깨짐 | 자동 재배치 |
| 375px | 사용 불가 | 자동 세로 배치 |