ERP-node/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCH...

26 KiB

반응형 그리드 시스템 아키텍처

최종 업데이트: 2026-01-30


1. 개요

1.1 현재 문제

컴포넌트 위치/크기가 픽셀 단위로 고정되어 반응형 미지원

// 현재 DB 저장 방식 (screen_layouts_v2.layout_data)
{
  "position": { "x": 1753, "y": 88 },
  "size": { "width": 158, "height": 40 }
}
화면 크기 결과
1920px (디자인 기준) 정상
1280px (노트북) 오른쪽 버튼 잘림
768px (태블릿) 레이아웃 완전히 깨짐
375px (모바일) 사용 불가

1.2 목표

목표 설명
PC 대응 1280px ~ 1920px
태블릿 대응 768px ~ 1024px
모바일 대응 320px ~ 767px

1.3 해결 방향

현재: 픽셀 좌표 → position: absolute → 고정 레이아웃
변경: 그리드 셀 번호 → CSS Grid + ResizeObserver → 반응형 레이아웃

2. 현재 시스템 분석

2.1 데이터 현황

총 레이아웃: 1,250개
총 컴포넌트: 5,236개
회사 수: 14개
테이블 크기: 약 3MB

2.2 컴포넌트 타입별 분포

컴포넌트 수량 shadcn 사용
v2-input 1,914 @/components/ui/input
v2-button-primary 1,549 @/components/ui/button
v2-table-search-widget 355 shadcn 기반
v2-select 327 @/components/ui/select
v2-table-list 285 @/components/ui/table
v2-media 181 shadcn 기반
v2-date 132 @/components/ui/calendar
v2-split-panel-layout 131 shadcn 기반 (반응형 필요)
v2-tabs-widget 75 shadcn 기반
기타 287 shadcn 기반
합계 5,236 전부 shadcn

2.3 현재 렌더링 방식

// frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx (라인 234-248)
{components.map((child) => (
  <div
    style={{
      position: "absolute",       // 절대 위치
      left: child.position.x,     // 픽셀 고정
      top: child.position.y,      // 픽셀 고정
      width: child.size.width,    // 픽셀 고정
      height: child.size.height,  // 픽셀 고정
    }}
  >
    {renderer.renderChild(child)}
  </div>
))}

2.4 핵심 발견

✅ 이미 있는 것:
- 12컬럼 그리드 설정 (gridSettings.columns: 12)
- 그리드 스냅 기능 (snapToGrid: true)
- shadcn/ui 기반 컴포넌트 (전체)

❌ 없는 것:
- 그리드 셀 번호 저장 (현재 픽셀 저장)
- 반응형 브레이크포인트 설정
- CSS Grid 기반 렌더링
- 분할 패널 반응형 처리

2.5 레이아웃 시스템 구조

현재 시스템에는 두 가지 레벨의 레이아웃이 존재합니다:

2.5.1 화면 레이아웃 (screen_layouts_v2)

화면 전체의 컴포넌트 배치를 담당합니다.

// DB 구조
{
  "version": "2.0",
  "components": [
    { "id": "comp_1", "position": { "x": 100, "y": 50 }, ... },
    { "id": "comp_2", "position": { "x": 500, "y": 50 }, ... },
    { "id": "GridLayout_1", "position": { "x": 100, "y": 200 }, ... }
  ]
}

현재: absolute 포지션으로 컴포넌트 배치 → 반응형 불가

2.5.2 컴포넌트 레이아웃 (GridLayout, FlexboxLayout 등)

개별 레이아웃 컴포넌트 내부의 zone 배치를 담당합니다.

컴포넌트 위치 내부 구조 CSS Grid 사용
GridLayout layouts/grid/ zones 배열 이미 사용
FlexboxLayout layouts/flexbox/ zones 배열 absolute
SplitLayout layouts/split/ left/right flex
TabsLayout layouts/ tabs 배열 탭 구조
CardLayout layouts/card-layout/ zones 배열 flex
AccordionLayout layouts/accordion/ items 배열 아코디언

2.5.3 구조 다이어그램

┌─────────────────────────────────────────────────────────────────┐
│  screen_layouts_v2 (화면 전체)                                   │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  현재: absolute 포지션 → 반응형 불가                       │   │
│  │  변경: ResponsiveGridLayout (CSS Grid) → 반응형 가능       │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                  │
│  ┌──────────┐  ┌──────────┐  ┌─────────────────────────────┐   │
│  │ v2-button │  │ v2-input │  │ GridLayout (컴포넌트)        │   │
│  │ (shadcn)  │  │ (shadcn) │  │ ┌─────────┬─────────────┐   │   │
│  └──────────┘  └──────────┘  │ │ zone1   │ zone2       │   │   │
│                              │ │ (이미   │ (이미       │   │   │
│                              │ │ CSS Grid│ CSS Grid)   │   │   │
│                              │ └─────────┴─────────────┘   │   │
│                              └─────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

2.6 기존 레이아웃 컴포넌트 호환성

2.6.1 GridLayout (기존 커스텀 그리드)

// frontend/lib/registry/layouts/grid/GridLayout.tsx
// 이미 CSS Grid를 사용하고 있음!

const gridStyle: React.CSSProperties = {
  display: "grid",
  gridTemplateRows: `repeat(${gridConfig.rows}, 1fr)`,
  gridTemplateColumns: `repeat(${gridConfig.columns}, 1fr)`,
  gap: `${gridConfig.gap || 16}px`,
};

호환성: 완전 호환

  • GridLayout은 화면 내 하나의 컴포넌트로 취급됨
  • ResponsiveGridLayout이 GridLayout의 위치만 관리
  • GridLayout 내부는 기존 방식 그대로 동작

2.6.2 FlexboxLayout

// frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx
// zone 내부에서 컴포넌트를 absolute로 배치

{zoneChildren.map((child) => (
  <div style={{
    position: "absolute",
    left: child.position?.x || 0,
    top: child.position?.y || 0,
  }}>
    {renderer.renderChild(child)}
  </div>
))}

호환성: 호환 (내부는 기존 방식 유지)

  • FlexboxLayout 컴포넌트 자체의 위치는 ResponsiveGridLayout이 관리
  • 내부 zone의 컴포넌트 배치는 기존 absolute 방식 유지

2.6.3 SplitPanelLayout (분할 패널)

호환성: ⚠️ 별도 수정 필요

  • 외부 위치: ResponsiveGridLayout이 관리
  • 내부 반응형: 별도 수정 필요 (모바일에서 상하 분할)

2.6.4 호환성 요약

컴포넌트 외부 배치 내부 동작 추가 수정
v2-button, v2-input 등 반응형 shadcn 그대로 불필요
GridLayout 반응형 CSS Grid 그대로 불필요
FlexboxLayout 반응형 ⚠️ absolute 유지 불필요
SplitPanelLayout 반응형 좌우 고정 ⚠️ 내부 반응형 추가
TabsLayout 반응형 탭 그대로 불필요

2.7 동작 방식 비교

변경 전

화면 로드
  ↓
screen_layouts_v2에서 components 조회
  ↓
각 컴포넌트를 position.x, position.y로 absolute 배치
  ↓
GridLayout 컴포넌트도 absolute로 배치됨
  ↓
GridLayout 내부는 CSS Grid로 zone 배치
  ↓
결과: 화면 크기 변해도 모든 컴포넌트 위치 고정

변경 후

화면 로드
  ↓
screen_layouts_v2에서 components 조회
  ↓
layoutMode === "grid" 확인
  ↓
ResponsiveGridLayout으로 렌더링 (CSS Grid)
  ↓
각 컴포넌트를 grid.col, grid.colSpan으로 배치
  ↓
화면 크기 감지 (ResizeObserver)
  ↓
breakpoint에 따라 responsive.sm/md/lg 적용
  ↓
GridLayout 컴포넌트도 반응형으로 배치됨
  ↓
GridLayout 내부는 기존 CSS Grid로 zone 배치 (변경 없음)
  ↓
결과: 화면 크기에 따라 컴포넌트 재배치

3. 기술 결정

3.1 왜 Tailwind 동적 클래스가 아닌 CSS Grid + Inline Style인가?

Tailwind 동적 클래스의 한계:

// ❌ 이건 안 됨 - Tailwind가 빌드 타임에 인식 못함
className={`col-start-${col} md:col-start-${mdCol}`}

// ✅ 이것만 됨 - 정적 클래스
className="col-start-1 md:col-start-3"

Tailwind는 빌드 타임에 클래스를 스캔하므로, 런타임에 동적으로 생성되는 클래스는 인식하지 못합니다.

해결책: CSS Grid + Inline Style + ResizeObserver:

// ✅ 올바른 방법
<div style={{
  display: 'grid',
  gridTemplateColumns: 'repeat(12, 1fr)',
}}>
  <div style={{
    gridColumn: `${col} / span ${colSpan}`,  // 동적 값 가능
  }}>
    {component}
  </div>
</div>

3.2 역할 분담

영역 기술 설명
UI 컴포넌트 shadcn/ui 버튼, 인풋, 테이블 등 (이미 적용됨)
레이아웃 배치 CSS Grid + Inline Style 컴포넌트 위치, 크기, 반응형
반응형 감지 ResizeObserver 화면 크기 감지 및 브레이크포인트 변경
┌─────────────────────────────────────────────────────────┐
│  ResponsiveGridLayout (CSS Grid)                         │
│  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐        │
│  │ shadcn      │ │ shadcn      │ │ shadcn      │        │
│  │ Button      │ │ Input       │ │ Select      │        │
│  └─────────────┘ └─────────────┘ └─────────────┘        │
│  ┌─────────────────────────────────────────────┐        │
│  │ shadcn Table                                 │        │
│  └─────────────────────────────────────────────┘        │
└─────────────────────────────────────────────────────────┘

4. 데이터 구조 변경

4.1 현재 구조 (V2)

{
  "version": "2.0",
  "components": [{
    "id": "comp_xxx",
    "url": "@/lib/registry/components/v2-button-primary",
    "position": { "x": 1753, "y": 88, "z": 1 },
    "size": { "width": 158, "height": 40 },
    "overrides": { ... }
  }]
}

4.2 변경 후 구조 (V2 + 그리드)

{
  "version": "2.0",
  "layoutMode": "grid",
  "components": [{
    "id": "comp_xxx",
    "url": "@/lib/registry/components/v2-button-primary",
    "position": { "x": 1753, "y": 88, "z": 1 },
    "size": { "width": 158, "height": 40 },
    "grid": {
      "col": 11,
      "row": 2,
      "colSpan": 1,
      "rowSpan": 1
    },
    "responsive": {
      "sm": { "col": 1, "colSpan": 12 },
      "md": { "col": 7, "colSpan": 6 },
      "lg": { "col": 11, "colSpan": 1 }
    },
    "overrides": { ... }
  }],
  "gridSettings": {
    "columns": 12,
    "rowHeight": 80,
    "gap": 16
  }
}

4.3 필드 설명

필드 타입 설명
layoutMode string "grid" (반응형 그리드 사용)
grid.col number 시작 컬럼 (1-12)
grid.row number 시작 행 (1부터)
grid.colSpan number 차지하는 컬럼 수
grid.rowSpan number 차지하는 행 수
responsive.sm object 모바일 (< 768px) 설정
responsive.md object 태블릿 (768px ~ 1024px) 설정
responsive.lg object 데스크톱 (> 1024px) 설정

4.4 호환성

  • position, size 필드는 유지 (디자인 모드 + 폴백용)
  • layoutMode가 없으면 기존 방식(absolute) 사용
  • 마이그레이션 후에도 기존 화면 정상 동작

5. 구현 상세

5.1 그리드 변환 유틸리티

// frontend/lib/utils/gridConverter.ts

const DESIGN_WIDTH = 1920;
const COLUMNS = 12;
const COLUMN_WIDTH = DESIGN_WIDTH / COLUMNS; // 160px
const ROW_HEIGHT = 80;

/**
 * 픽셀 좌표를 그리드 셀 번호로 변환
 */
export function pixelToGrid(
  position: { x: number; y: number },
  size: { width: number; height: number }
): GridPosition {
  return {
    col: Math.max(1, Math.min(12, Math.round(position.x / COLUMN_WIDTH) + 1)),
    row: Math.max(1, Math.round(position.y / ROW_HEIGHT) + 1),
    colSpan: Math.max(1, Math.round(size.width / COLUMN_WIDTH)),
    rowSpan: Math.max(1, Math.round(size.height / ROW_HEIGHT)),
  };
}

/**
 * 기본 반응형 설정 생성
 */
export function getDefaultResponsive(grid: GridPosition): ResponsiveConfig {
  return {
    sm: { col: 1, colSpan: 12 },                    // 모바일: 전체 너비
    md: { 
      col: Math.max(1, Math.round(grid.col / 2)),
      colSpan: Math.min(grid.colSpan * 2, 12) 
    },                                               // 태블릿: 2배 확장
    lg: { col: grid.col, colSpan: grid.colSpan },   // 데스크톱: 원본
  };
}

5.2 반응형 그리드 레이아웃 컴포넌트

// frontend/lib/registry/layouts/responsive-grid/ResponsiveGridLayout.tsx

import React, { useRef, useState, useEffect } from "react";

type Breakpoint = "sm" | "md" | "lg";

interface ResponsiveGridLayoutProps {
  layout: LayoutData;
  isDesignMode: boolean;
  renderer: ComponentRenderer;
}

export function ResponsiveGridLayout({
  layout,
  isDesignMode,
  renderer,
}: ResponsiveGridLayoutProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg");

  // 화면 크기 감지
  useEffect(() => {
    if (!containerRef.current) return;

    const observer = new ResizeObserver((entries) => {
      const width = entries[0].contentRect.width;
      if (width < 768) setBreakpoint("sm");
      else if (width < 1024) setBreakpoint("md");
      else setBreakpoint("lg");
    });

    observer.observe(containerRef.current);
    return () => observer.disconnect();
  }, []);

  const gridSettings = layout.gridSettings || { columns: 12, rowHeight: 80, gap: 16 };

  return (
    <div
      ref={containerRef}
      style={{
        display: "grid",
        gridTemplateColumns: `repeat(${gridSettings.columns}, 1fr)`,
        gridAutoRows: `${gridSettings.rowHeight}px`,
        gap: `${gridSettings.gap}px`,
        minHeight: isDesignMode ? "600px" : "auto",
      }}
    >
      {layout.components
        .sort((a, b) => (a.grid?.row || 0) - (b.grid?.row || 0))
        .map((component) => {
          // 반응형 설정 가져오기
          const gridConfig = component.responsive?.[breakpoint] || component.grid;
          const { col, colSpan } = gridConfig;
          const rowSpan = component.grid?.rowSpan || 1;

          return (
            <div
              key={component.id}
              style={{
                gridColumn: `${col} / span ${colSpan}`,
                gridRow: `span ${rowSpan}`,
              }}
            >
              {renderer.renderChild(component)}
            </div>
          );
        })}
    </div>
  );
}

5.3 브레이크포인트 훅

// frontend/lib/registry/layouts/responsive-grid/useBreakpoint.ts

import { useState, useEffect, RefObject } from "react";

type Breakpoint = "sm" | "md" | "lg";

export function useBreakpoint(containerRef: RefObject<HTMLElement>): Breakpoint {
  const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg");

  useEffect(() => {
    if (!containerRef.current) return;

    const observer = new ResizeObserver((entries) => {
      const width = entries[0].contentRect.width;
      if (width < 768) setBreakpoint("sm");
      else if (width < 1024) setBreakpoint("md");
      else setBreakpoint("lg");
    });

    observer.observe(containerRef.current);
    return () => observer.disconnect();
  }, [containerRef]);

  return breakpoint;
}

5.4 분할 패널 반응형 수정

// frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx

// 추가할 코드
const containerRef = useRef<HTMLDivElement>(null);
const [isMobile, setIsMobile] = useState(false);

useEffect(() => {
  if (!containerRef.current) return;

  const observer = new ResizeObserver((entries) => {
    const width = entries[0].contentRect.width;
    setIsMobile(width < 768);
  });

  observer.observe(containerRef.current);
  return () => observer.disconnect();
}, []);

// 렌더링 부분 수정
return (
  <div
    ref={containerRef}
    className={cn(
      "flex h-full",
      isMobile ? "flex-col" : "flex-row"  // 모바일: 상하, 데스크톱: 좌우
    )}
  >
    <div style={{ 
      width: isMobile ? "100%" : `${leftWidth}%`,
      minHeight: isMobile ? "300px" : "auto"
    }}>
      {/* 좌측/상단 패널 */}
    </div>
    <div style={{ 
      width: isMobile ? "100%" : `${100 - leftWidth}%`,
      minHeight: isMobile ? "300px" : "auto"
    }}>
      {/* 우측/하단 패널 */}
    </div>
  </div>
);

6. 렌더링 분기 처리

// frontend/lib/registry/DynamicComponentRenderer.tsx

function renderLayout(layout: LayoutData) {
  // layoutMode에 따라 분기
  if (layout.layoutMode === "grid") {
    return <ResponsiveGridLayout layout={layout} renderer={this} />;
  }
  
  // 기존 방식 (폴백)
  return <FlexboxLayout layout={layout} renderer={this} />;
}

7. 마이그레이션

7.1 백업

-- 마이그레이션 전 백업
CREATE TABLE screen_layouts_v2_backup_20260130 AS 
SELECT * FROM screen_layouts_v2;

7.2 마이그레이션 스크립트

-- grid, responsive 필드 추가
UPDATE screen_layouts_v2
SET layout_data = (
  SELECT jsonb_set(
    jsonb_set(
      layout_data,
      '{layoutMode}',
      '"grid"'
    ),
    '{components}',
    (
      SELECT jsonb_agg(
        comp || jsonb_build_object(
          'grid', jsonb_build_object(
            'col', GREATEST(1, LEAST(12, ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1)),
            'row', GREATEST(1, ROUND((comp->'position'->>'y')::NUMERIC / 80) + 1),
            'colSpan', GREATEST(1, ROUND((comp->'size'->>'width')::NUMERIC / 160)),
            'rowSpan', GREATEST(1, ROUND((comp->'size'->>'height')::NUMERIC / 80))
          ),
          'responsive', jsonb_build_object(
            'sm', jsonb_build_object('col', 1, 'colSpan', 12),
            'md', jsonb_build_object(
              'col', GREATEST(1, ROUND((ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1) / 2.0)),
              'colSpan', LEAST(ROUND((comp->'size'->>'width')::NUMERIC / 160) * 2, 12)
            ),
            'lg', jsonb_build_object(
              'col', GREATEST(1, LEAST(12, ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1)),
              'colSpan', GREATEST(1, ROUND((comp->'size'->>'width')::NUMERIC / 160))
            )
          )
        )
      )
      FROM jsonb_array_elements(layout_data->'components') as comp
    )
  )
);

7.3 롤백

-- 문제 발생 시 롤백
DROP TABLE screen_layouts_v2;
ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2;

8. 동작 흐름

8.1 데스크톱 (> 1024px)

┌────────────────────────────────────────────────────────────┐
│ 1  2  3  4  5  6  7  8  9  10 │ 11    12 │                 │
│                               │ [버튼]   │                 │
├────────────────────────────────────────────────────────────┤
│                                                             │
│                      테이블 (12컬럼)                         │
│                                                             │
└────────────────────────────────────────────────────────────┘

8.2 태블릿 (768px ~ 1024px)

┌─────────────────────────────────────┐
│ 1  2  3  4  5  6 │ 7  8  9 10 11 12 │
│                  │ [버튼]           │
├─────────────────────────────────────┤
│                                      │
│           테이블 (12컬럼)             │
│                                      │
└─────────────────────────────────────┘

8.3 모바일 (< 768px)

┌──────────────────┐
│ [버튼]           │ ← 12컬럼 (전체 너비)
├──────────────────┤
│                   │
│  테이블 (스크롤)   │ ← 12컬럼 (전체 너비)
│                   │
└──────────────────┘

8.4 분할 패널 (반응형)

데스크톱:

┌─────────────────────────┬─────────────────────────┐
│   좌측 패널 (60%)        │   우측 패널 (40%)        │
└─────────────────────────┴─────────────────────────┘

모바일:

┌─────────────────────────┐
│   상단 패널 (이전 좌측)   │
├─────────────────────────┤
│   하단 패널 (이전 우측)   │
└─────────────────────────┘

9. 수정 파일 목록

9.1 새로 생성

파일 설명
lib/utils/gridConverter.ts 픽셀 → 그리드 변환 유틸리티
lib/registry/layouts/responsive-grid/ResponsiveGridLayout.tsx CSS Grid 레이아웃
lib/registry/layouts/responsive-grid/useBreakpoint.ts ResizeObserver 훅
lib/registry/layouts/responsive-grid/index.ts 모듈 export

9.2 수정

파일 수정 내용
lib/registry/DynamicComponentRenderer.tsx layoutMode 분기 추가
components/screen/ScreenDesigner.tsx 저장 시 grid/responsive 생성
v2-split-panel-layout/SplitPanelLayoutComponent.tsx 반응형 처리 추가

9.3 수정 없음

파일 이유
v2-input/* 레이아웃과 무관 (shadcn 그대로)
v2-button-primary/* 레이아웃과 무관 (shadcn 그대로)
v2-table-list/* 레이아웃과 무관 (shadcn 그대로)
v2-select/* 레이아웃과 무관 (shadcn 그대로)
...모든 v2 컴포넌트 수정 불필요

10. 작업 일정

Phase 작업 파일 시간
1 그리드 변환 유틸리티 gridConverter.ts 2시간
1 브레이크포인트 훅 useBreakpoint.ts 1시간
2 ResponsiveGridLayout ResponsiveGridLayout.tsx 4시간
2 렌더링 분기 처리 DynamicComponentRenderer.tsx 1시간
3 저장 로직 수정 ScreenDesigner.tsx 2시간
3 분할 패널 반응형 SplitPanelLayoutComponent.tsx 3시간
4 마이그레이션 스크립트 SQL 2시간
4 마이그레이션 실행 - 1시간
5 테스트 및 버그 수정 - 4시간
합계 약 2.5일

11. 체크리스트

개발 전

  • screen_layouts_v2 백업 완료
  • 개발 환경에서 테스트 데이터 준비

Phase 1: 유틸리티

  • gridConverter.ts 생성
  • useBreakpoint.ts 생성
  • 단위 테스트 작성

Phase 2: 레이아웃

  • ResponsiveGridLayout.tsx 생성
  • DynamicComponentRenderer.tsx 분기 추가
  • 기존 화면 정상 동작 확인

Phase 3: 저장/수정

  • ScreenDesigner.tsx 저장 로직 수정
  • SplitPanelLayoutComponent.tsx 반응형 추가
  • 디자인 모드 테스트

Phase 4: 마이그레이션

  • 마이그레이션 스크립트 테스트 (개발 DB)
  • 운영 DB 백업
  • 마이그레이션 실행
  • 검증

Phase 5: 테스트

  • PC (1920px, 1280px) 테스트
  • 태블릿 (768px, 1024px) 테스트
  • 모바일 (375px, 414px) 테스트
  • 분할 패널 화면 테스트
  • GridLayout 컴포넌트 포함 화면 테스트
  • FlexboxLayout 컴포넌트 포함 화면 테스트
  • TabsLayout 컴포넌트 포함 화면 테스트
  • 중첩 레이아웃 (GridLayout 안에 컴포넌트) 테스트

12. 리스크 및 대응

리스크 영향 대응
마이그레이션 실패 높음 백업 테이블에서 즉시 롤백
기존 화면 깨짐 중간 layoutMode 없으면 기존 방식 사용 (폴백)
디자인 모드 혼란 낮음 position/size 필드 유지
GridLayout 내부 깨짐 낮음 내부는 기존 방식 유지, 외부 배치만 변경
중첩 레이아웃 문제 낮음 각 레이아웃 컴포넌트는 독립적으로 동작

13. 참고