18 KiB
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. 참고 자료
- COMPONENT_LAYOUT_V2_ARCHITECTURE.md - V2 아키텍처
- Tailwind CSS Grid - 그리드 시스템
- shadcn/ui - 컴포넌트 라이브러리