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

18 KiB

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

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

1. 개요

1.1 문제 정의

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

// 현재 저장 방식 (screen_layouts_v2.layout_data)
{
  "position": { "x": 1753, "y": 88 },
  "size": { "width": 158, "height": 40 }
}

발생 문제:

  • 1920px 기준 설계 → 1280px 화면에서 버튼이 화면 밖으로 나감
  • 모바일/태블릿에서 레이아웃 완전히 깨짐
  • 화면 축소해도 컴포넌트 위치/크기 그대로

1.2 목표

목표 설명
PC 대응 1280px ~ 1920px 화면에서 정상 동작
태블릿 대응 768px ~ 1024px 화면에서 레이아웃 재배치
모바일 대응 320px ~ 767px 화면에서 세로 스택
shadcn/Tailwind 활용 반응형 브레이크포인트 시스템 사용

1.3 핵심 원칙

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

2. 현재 시스템 분석

2.1 기존 그리드 설정 (이미 존재)

// frontend/components/screen/ScreenDesigner.tsx
gridSettings: {
  columns: 12,        // ✅ 이미 12컬럼 그리드 있음
  gap: 16,            // ✅ 간격 설정 있음
  padding: 0,
  snapToGrid: true,   // ✅ 스냅 기능 있음
  showGrid: false,
  gridColor: "#d1d5db",
  gridOpacity: 0.5,
}

2.2 현재 저장 방식

// 드래그 후 저장되는 데이터
{
  "id": "comp_1896",
  "url": "@/lib/registry/components/v2-button-primary",
  "position": { "x": 1753.33, "y": 88, "z": 1 },  // 픽셀 좌표
  "size": { "width": 158.67, "height": 40 },      // 픽셀 크기
  "overrides": { ... }
}

2.3 현재 렌더링 방식

// frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx (라인 234-248)
<div style={{
  position: "absolute",
  left: child.position?.x || 0,      // 픽셀 절대 위치
  top: child.position?.y || 0,
  width: child.size?.width || "auto",
  height: child.size?.height || "auto",
  zIndex: child.position?.z || 1,
}}>

2.4 문제점 요약

현재 문제
12컬럼 그리드 있음 스냅용으로만 사용, 저장은 픽셀
position: 픽셀 좌표 화면 크기 변해도 위치 고정
size: 픽셀 크기 화면 작아지면 넘침
absolute 포지션 반응형 불가

3. 신규 데이터 구조

3.1 layout_data 구조 변경

현재 구조:

{
  "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": { ... }
  }]
}

변경 후 구조:

{
  "version": "3.0",
  "layoutMode": "grid",
  "components": [{
    "id": "comp_xxx",
    "url": "@/lib/registry/components/v2-button-primary",
    "grid": {
      "col": 11,
      "row": 2,
      "colSpan": 2,
      "rowSpan": 1
    },
    "responsive": {
      "sm": { "col": 1, "colSpan": 12 },
      "md": { "col": 7, "colSpan": 6 },
      "lg": { "col": 11, "colSpan": 2 }
    },
    "overrides": { ... }
  }],
  "gridSettings": {
    "columns": 12,
    "rowHeight": 80,
    "gap": 16
  }
}

3.2 필드 설명

필드 타입 설명
version string "3.0" (반응형 그리드 버전)
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) 설정

3.3 반응형 브레이크포인트

브레이크포인트 화면 크기 기본 동작
sm < 768px 모든 컴포넌트 12컬럼 (세로 스택)
md 768px ~ 1024px 컬럼 수 2배로 확장
lg > 1024px 원본 그리드 위치 유지

4. 변환 로직

4.1 픽셀 → 그리드 변환 함수

// frontend/lib/utils/gridConverter.ts

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

interface PixelPosition {
  x: number;
  y: number;
}

interface PixelSize {
  width: number;
  height: number;
}

interface GridPosition {
  col: number;
  row: number;
  colSpan: number;
  rowSpan: number;
}

interface ResponsiveConfig {
  sm: { col: number; colSpan: number };
  md: { col: number; colSpan: number };
  lg: { col: number; colSpan: number };
}

/**
 * 픽셀 좌표를 그리드 셀 번호로 변환
 */
export function pixelToGrid(
  position: PixelPosition,
  size: PixelSize
): GridPosition {
  // 컬럼 계산 (1-based)
  const col = Math.max(1, Math.min(12, Math.round(position.x / COLUMN_WIDTH) + 1));
  
  // 행 계산 (1-based)
  const row = Math.max(1, Math.round(position.y / ROW_HEIGHT) + 1);
  
  // 컬럼 스팬 계산
  const colSpan = Math.max(1, Math.min(12 - col + 1, Math.round(size.width / COLUMN_WIDTH)));
  
  // 행 스팬 계산
  const rowSpan = Math.max(1, Math.round(size.height / ROW_HEIGHT));
  
  return { col, row, colSpan, rowSpan };
}

/**
 * 그리드 셀 번호를 픽셀 좌표로 변환 (디자인 모드용)
 */
export function gridToPixel(
  grid: GridPosition
): { position: PixelPosition; size: PixelSize } {
  return {
    position: {
      x: (grid.col - 1) * COLUMN_WIDTH,
      y: (grid.row - 1) * ROW_HEIGHT,
    },
    size: {
      width: grid.colSpan * COLUMN_WIDTH,
      height: grid.rowSpan * ROW_HEIGHT,
    },
  };
}

/**
 * 기본 반응형 설정 생성
 */
export function getDefaultResponsive(
  grid: GridPosition
): ResponsiveConfig {
  return {
    // 모바일: 전체 너비, 원래 순서대로 스택
    sm: { 
      col: 1, 
      colSpan: 12 
    },
    // 태블릿: 컬럼 스팬 2배 (최대 12)
    md: { 
      col: Math.max(1, Math.round((grid.col - 1) / 2) + 1),
      colSpan: Math.min(grid.colSpan * 2, 12) 
    },
    // 데스크톱: 원본 유지
    lg: { 
      col: grid.col, 
      colSpan: grid.colSpan 
    },
  };
}

4.2 Tailwind 클래스 생성 함수

// frontend/lib/utils/gridClassGenerator.ts

/**
 * 그리드 위치/크기를 Tailwind 클래스로 변환
 */
export function generateGridClasses(
  grid: GridPosition,
  responsive: ResponsiveConfig
): string {
  const classes: string[] = [];
  
  // 모바일 (기본)
  classes.push(`col-start-${responsive.sm.col}`);
  classes.push(`col-span-${responsive.sm.colSpan}`);
  
  // 태블릿
  classes.push(`md:col-start-${responsive.md.col}`);
  classes.push(`md:col-span-${responsive.md.colSpan}`);
  
  // 데스크톱
  classes.push(`lg:col-start-${responsive.lg.col}`);
  classes.push(`lg:col-span-${responsive.lg.colSpan}`);
  
  return classes.join(' ');
}

주의: Tailwind는 빌드 타임에 클래스를 결정하므로, 동적 클래스 생성 시 safelist 설정 필요

// tailwind.config.js
module.exports = {
  safelist: [
    // 그리드 컬럼 시작
    { pattern: /col-start-(1|2|3|4|5|6|7|8|9|10|11|12)/ },
    { pattern: /md:col-start-(1|2|3|4|5|6|7|8|9|10|11|12)/ },
    { pattern: /lg:col-start-(1|2|3|4|5|6|7|8|9|10|11|12)/ },
    // 그리드 컬럼 스팬
    { pattern: /col-span-(1|2|3|4|5|6|7|8|9|10|11|12)/ },
    { pattern: /md:col-span-(1|2|3|4|5|6|7|8|9|10|11|12)/ },
    { pattern: /lg:col-span-(1|2|3|4|5|6|7|8|9|10|11|12)/ },
  ],
}

5. 렌더링 컴포넌트 수정

5.1 ResponsiveGridLayout 컴포넌트

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

import { cn } from "@/lib/utils";
import { generateGridClasses } from "@/lib/utils/gridClassGenerator";

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

export function ResponsiveGridLayout({
  layout,
  isDesignMode,
  renderer,
}: ResponsiveGridLayoutProps) {
  const { gridSettings, components } = layout;
  
  return (
    <div
      className={cn(
        "grid grid-cols-12",
        `gap-${gridSettings?.gap || 4}`,
        isDesignMode && "min-h-[600px] border border-dashed"
      )}
      style={{
        gridAutoRows: `${gridSettings?.rowHeight || 80}px`,
      }}
    >
      {components
        .sort((a, b) => (a.grid?.row || 0) - (b.grid?.row || 0))
        .map((component) => {
          const gridClasses = generateGridClasses(
            component.grid,
            component.responsive
          );
          
          return (
            <div
              key={component.id}
              className={cn(
                gridClasses,
                `row-span-${component.grid?.rowSpan || 1}`,
                isDesignMode && "border border-blue-200 hover:border-blue-400"
              )}
            >
              {renderer.renderChild(component)}
            </div>
          );
        })}
    </div>
  );
}

5.2 렌더링 결과 예시

데스크톱 (lg: 1024px+):

┌─────────────────────────────────────────────────────────┐
│ [분리] [저장] [수정] [삭제]                              │ ← 버튼들 오른쪽 정렬
├─────────────────────────────────────────────────────────┤
│                                                          │
│                    테이블 컴포넌트                        │
│                                                          │
└─────────────────────────────────────────────────────────┘

태블릿 (md: 768px ~ 1024px):

┌───────────────────────────────┐
│ [분리] [저장] [수정] [삭제]    │ ← 버튼들 2개씩
├───────────────────────────────┤
│                                │
│       테이블 컴포넌트          │
│                                │
└───────────────────────────────┘

모바일 (sm: < 768px):

┌─────────────────┐
│ [분리]          │
│ [저장]          │
│ [수정]          │ ← 세로 스택
│ [삭제]          │
├─────────────────┤
│ 테이블 컴포넌트  │
│ (스크롤)        │
└─────────────────┘

6. 마이그레이션 계획

6.1 데이터 마이그레이션 스크립트

-- 기존 데이터를 V3 구조로 변환하는 함수
CREATE OR REPLACE FUNCTION migrate_layout_to_v3(layout_data JSONB)
RETURNS JSONB AS $$
DECLARE
  result JSONB;
  component JSONB;
  new_components JSONB := '[]'::JSONB;
  grid_col INT;
  grid_row INT;
  col_span INT;
  row_span INT;
BEGIN
  -- 각 컴포넌트 변환
  FOR component IN SELECT * FROM jsonb_array_elements(layout_data->'components')
  LOOP
    -- 픽셀 → 그리드 변환 (160px = 1컬럼, 80px = 1행)
    grid_col := GREATEST(1, LEAST(12, ROUND((component->'position'->>'x')::NUMERIC / 160) + 1));
    grid_row := GREATEST(1, ROUND((component->'position'->>'y')::NUMERIC / 80) + 1);
    col_span := GREATEST(1, LEAST(13 - grid_col, ROUND((component->'size'->>'width')::NUMERIC / 160)));
    row_span := GREATEST(1, ROUND((component->'size'->>'height')::NUMERIC / 80));
    
    -- 새 컴포넌트 구조 생성
    component := component || jsonb_build_object(
      'grid', jsonb_build_object(
        'col', grid_col,
        'row', grid_row,
        'colSpan', col_span,
        'rowSpan', row_span
      ),
      'responsive', jsonb_build_object(
        'sm', jsonb_build_object('col', 1, 'colSpan', 12),
        'md', jsonb_build_object('col', GREATEST(1, ROUND(grid_col / 2.0)), 'colSpan', LEAST(col_span * 2, 12)),
        'lg', jsonb_build_object('col', grid_col, 'colSpan', col_span)
      )
    );
    
    -- position, size 필드 제거 (선택사항 - 호환성 위해 유지 가능)
    -- component := component - 'position' - 'size';
    
    new_components := new_components || component;
  END LOOP;
  
  -- 결과 생성
  result := jsonb_build_object(
    'version', '3.0',
    'layoutMode', 'grid',
    'components', new_components,
    'gridSettings', COALESCE(layout_data->'gridSettings', '{"columns": 12, "rowHeight": 80, "gap": 16}'::JSONB)
  );
  
  RETURN result;
END;
$$ LANGUAGE plpgsql;

-- 마이그레이션 실행
UPDATE screen_layouts_v2
SET layout_data = migrate_layout_to_v3(layout_data)
WHERE (layout_data->>'version') = '2.0';

6.2 백워드 호환성

V2 ↔ V3 호환을 위한 변환 레이어:

// frontend/lib/utils/layoutVersionConverter.ts

export function normalizeLayout(layout: any): NormalizedLayout {
  const version = layout.version || "2.0";
  
  if (version === "2.0") {
    // V2 → V3 변환 (렌더링 시)
    return {
      ...layout,
      version: "3.0",
      layoutMode: "grid",
      components: layout.components.map((comp: any) => ({
        ...comp,
        grid: pixelToGrid(comp.position, comp.size),
        responsive: getDefaultResponsive(pixelToGrid(comp.position, comp.size)),
      })),
    };
  }
  
  return layout; // V3는 그대로
}

7. 디자인 모드 수정

7.1 그리드 편집 UI

디자인 모드에서 그리드 셀 선택 방식 추가:

// 기존: 픽셀 좌표 입력
<Input
  label="X 좌표"
  value={position.x}
  onChange={(x) => updatePosition({ x })}
/>

// 변경: 그리드 셀 선택
<div className="grid grid-cols-12 gap-1 p-2 bg-gray-50 rounded">
  {Array.from({ length: 12 }).map((_, col) => (
    <div
      key={col}
      className={cn(
        "h-8 border cursor-pointer hover:bg-blue-100",
        selected.col === col + 1 && "bg-blue-300"
      )}
      onClick={() => setGridCol(col + 1)}
    />
  ))}
</div>

<div className="flex gap-4">
  <Select label="컬럼 시작" value={grid.col} options={[1,2,3,4,5,6,7,8,9,10,11,12]} />
  <Select label="컬럼 스팬" value={grid.colSpan} options={[1,2,3,4,5,6,7,8,9,10,11,12]} />
</div>

7.2 반응형 미리보기

// 화면 크기 미리보기 버튼
<div className="flex gap-2">
  <Button onClick={() => setPreviewMode("sm")} icon={<Smartphone />}>
    모바일
  </Button>
  <Button onClick={() => setPreviewMode("md")} icon={<Tablet />}>
    태블릿
  </Button>
  <Button onClick={() => setPreviewMode("lg")} icon={<Monitor />}>
    데스크톱
  </Button>
</div>

// 미리보기 컨테이너
<div className={cn(
  "mx-auto transition-all",
  previewMode === "sm" && "max-w-[375px]",
  previewMode === "md" && "max-w-[768px]",
  previewMode === "lg" && "max-w-full"
)}>
  <ResponsiveGridLayout layout={layout} />
</div>

8. 작업 목록

Phase 1: 핵심 유틸리티 (1일)

작업 파일 상태
그리드 변환 함수 lib/utils/gridConverter.ts
클래스 생성 함수 lib/utils/gridClassGenerator.ts
Tailwind safelist 설정 tailwind.config.js

Phase 2: 렌더링 수정 (1일)

작업 파일 상태
ResponsiveGridLayout 생성 lib/registry/layouts/responsive-grid/
레이아웃 버전 분기 처리 lib/registry/DynamicComponentRenderer.tsx

Phase 3: 저장 로직 수정 (1일)

작업 파일 상태
저장 시 그리드 변환 components/screen/ScreenDesigner.tsx
V3 레이아웃 변환기 lib/utils/layoutV3Converter.ts

Phase 4: 디자인 모드 UI (1일)

작업 파일 상태
그리드 셀 편집 UI components/screen/panels/V2PropertiesPanel.tsx
반응형 미리보기 components/screen/ScreenDesigner.tsx

Phase 5: 마이그레이션 (0.5일)

작업 파일 상태
마이그레이션 스크립트 db/migrations/xxx_migrate_to_v3.sql
백워드 호환성 테스트 -

9. 예상 일정

단계 기간 완료 기준
Phase 1 1일 유틸리티 함수 테스트 통과
Phase 2 1일 그리드 렌더링 정상 동작
Phase 3 1일 저장/로드 정상 동작
Phase 4 1일 디자인 모드 UI 완성
Phase 5 0.5일 기존 데이터 마이그레이션 완료
테스트 0.5일 모든 화면 반응형 테스트
합계 5일

10. 리스크 및 대응

리스크 영향 대응 방안
기존 레이아웃 깨짐 높음 position/size 필드 유지하여 폴백
Tailwind 동적 클래스 중간 safelist로 모든 클래스 사전 정의
디자인 모드 혼란 낮음 그리드 가이드라인 시각화

11. 참고 자료