# 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 ``` **변환 후**: ```html
``` **반응형 동작**: ``` 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 ``` **변환 후**: ```html
``` **반응형 동작**: ``` 1920px: [입력방식] [판매유형] [단가방식] [단가수정] ← 4열 1280px: [입력방식] [판매유형] [단가방식] ← 3열 [단가수정] 768px: [입력방식] [판매유형] ← 2열 [단가방식] [단가수정] 375px: [입력방식] ← 1열 [판매유형] [단가방식] [단가수정] ``` --- ## 3. 변환 규칙 ### 3.1 Row 그룹화 알고리즘 ```typescript 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 적용 예시 **입력**: ```json [ { "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" } ] ``` **변환 결과**: ```json { "rows": [ { "y": 88, "justify": "end", "components": ["comp_1899", "comp_1898", "comp_1897", "comp_1896"] }, { "y": 128, "justify": "start", "components": ["comp_1895"] } ] } ``` ### 3.3 정렬 방향 결정 ```typescript 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 컴포넌트 ```tsx // 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 (
{rows.map((row, index) => ( ))}
); } 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 (
{row.components.map((comp) => (
{renderer.renderChild(comp)}
))}
); } ``` ### 4.2 기존 코드 수정 위치 **현재 (RealtimePreviewDynamic.tsx 라인 524-536)**: ```tsx const baseStyle = { left: `${adjustedPositionX}px`, // ❌ 절대 좌표 top: `${position.y}px`, // ❌ 절대 좌표 position: "absolute", // ❌ 절대 위치 }; ``` **변경 후**: ```tsx // 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% │ └────────────────────┘ ← 세로 배치로 전환 ``` **구현**: ```tsx // SplitPanelLayoutComponent.tsx const isMobile = useMediaQuery("(max-width: 768px)"); return (
{/* 좌측 패널 */}
{/* 우측 패널 */}
); ``` --- ## 6. 엣지 케이스 검증 ### 6.1 겹치는 컴포넌트 **현재 데이터 (화면 74)**: ```json { "id": "comp_2606", "position": { "x": 161, "y": 400 } }, // 분할 패널 { "id": "comp_fkk75q08", "position": { "x": 161, "y": 400 } } // 라디오 버튼 ``` **문제**: 같은 위치에 두 컴포넌트 → z-index로 겹쳐서 표시 **해결**: - z-index가 높은 컴포넌트 우선 - 또는 parent-child 관계면 중첩 처리 ```typescript function resolveOverlaps(row: Row): Row { // z-index로 정렬하여 높은 것만 표시 // 또는 parentId 확인하여 중첩 처리 } ``` ### 6.2 조건부 표시 컴포넌트 **현재 데이터 (화면 4103)**: ```json { "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 섹션 카드 내부 컴포넌트 **현재**: 섹션 카드와 내부 컴포넌트가 별도로 저장됨 **변환 시**: 1. 섹션 카드의 y 범위 파악 2. 해당 y 범위 내 컴포넌트들을 섹션 자식으로 그룹화 3. 섹션 내부에서 다시 Row 그룹화 ```typescript 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일) 1. `groupByRows()` 함수 구현 2. `determineJustify()` 함수 구현 3. `FlowLayout` 컴포넌트 생성 ### Phase 2: 렌더링 적용 (1일) 1. `DynamicComponentRenderer`에 Flow 모드 추가 2. `RealtimePreviewDynamic` 수정 3. 기존 absolute 스타일 조건부 적용 ### Phase 3: 특수 케이스 처리 (1일) 1. 섹션 카드 내부 그룹화 2. 겹치는 컴포넌트 처리 3. 분할 패널 반응형 전환 ### Phase 4: 테스트 (1일) 1. 화면 68 (버튼 + 테이블) 테스트 2. 화면 119 (2열 폼) 테스트 3. 화면 4103 (복잡한 폼) 테스트 4. PC 1920px → 1280px 테스트 5. 태블릿 768px 테스트 6. 모바일 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 | 사용 불가 | **자동 세로 배치** |