ERP-node/docs/DDD1542/FLOW_BASED_RESPONSIVE_DESIG...

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 섹션 카드 내부 컴포넌트

현재: 섹션 카드와 내부 컴포넌트가 별도로 저장됨

변환 시:

  1. 섹션 카드의 y 범위 파악
  2. 해당 y 범위 내 컴포넌트들을 섹션 자식으로 그룹화
  3. 섹션 내부에서 다시 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일)

  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 사용 불가 자동 세로 배치