# 템플릿 저장 모델 v2 — 자유배치 유지, 저장만 semantic 화

> 2026-04-20 / gbpark
> 자유배치 편집은 그대로 두고, DB 저장 모델을 px 절대좌표 + 추측렌더에서
> % 정규좌표 + role + responsivePolicy 로 갈아엎는다.
> 목표 = 런타임 추측 제거.

---

## 0. 왜 바꾸는가

현재 `components/dash/TemplateRenderer.tsx` 는 저장된 절대 px 좌표에서
row/group/overlay/action 을 **추론**한다:

- top band clustering (tolerance 기반)
- `FIXED_SMALL_IDS` 하드코딩 (button/divider/input)
- `FULL_WIDTH_IDS` 하드코딩 (table/container/…)
- centerY 범위 기반 overlay 흡수
- action line clamp (main 과 겹칠 때만)
- rowHeight / overlayFullBottomPx / effectiveRowHeight / marginTop — 직교하지 않는
  6개 이상의 계산이 누적

케이스별로 clamp 를 붙이며 증상은 잡고 있지만, 다음 템플릿에서 바로 흔들린다:
- 버튼이 카드 **옆** 에 있어야 하는 경우
- 카드보다 더 **낮게 내려간 companion** 이 있는 경우
- 테이블 band **안쪽** 에 의도된 overlay 가 있는 경우

근본 원인은 **의도 정보가 저장되지 않아서** 렌더러가 좌표만 보고 추측한다는 것.

## 1. 저장 모델 v2 스키마

### 1-1. 블록 스키마

```ts
export interface BlockV2 {
  id: string;
  componentId: string;          // unified id: 'table' | 'stats' | 'button' | ...

  // 정규화된 좌표 (기준 캔버스 대비 %)
  xPct: number;                 // 0.0 ~ 1.0
  yPct: number;                 // 0.0 ~ 1.0
  wPct: number;                 // 0.0 ~ 1.0
  hPct: number;                 // 0.0 ~ 1.0

  // 디자이너 의도 — 런타임이 이걸 그대로 렌더
  role: BlockRole;
  responsivePolicy: ResponsivePolicy;

  // 앵커 (선택 — 런타임 추측 금지, 디자이너가 명시)
  anchor?: BlockAnchor;

  // 컴포넌트 설정 (현재 그대로 유지)
  config: Record<string, any>;
}

export type BlockRole =
  | 'main'        // 페이지의 주 콘텐츠 (카드/테이블/폼)
  | 'action'      // main 아래/위 별도 액션 라인 (저장/수정/새로고침 바)
  | 'companion'   // main 과 같은 라인에 붙는 동료 (카드 옆 인디케이터 등)
  | 'overlay';    // main 위에 띄우는 floating (플로팅 버튼, 토스트 앵커 등)

export type ResponsivePolicy =
  | 'fixed'       // 원본 w/h 유지. 카드가 좁아져도 자기 크기 유지
  | 'scroll'      // 부모 폭 채우고 내부 overflow (테이블/컨테이너)
  | 'reflow'      // auto-fit grid 로 내부 열 재배치 (stats 그리드)
  | 'wrap';       // flex-wrap (버튼 그룹 등)

export type BlockAnchor =
  | 'top-left'    | 'top-center'    | 'top-right'
  | 'center-left' | 'center'        | 'center-right'
  | 'bottom-left' | 'bottom-center' | 'bottom-right';
```

### 1-2. 뷰(View) 스키마

```ts
export interface ViewV2 {
  blocks: BlockV2[];
  canvas: {
    baseWidth: number;          // 정규화 기준 폭 (예: 1920)
    baseHeight: number;         // 정규화 기준 높이 (예: 1080)
    aspectPolicy: 'preserve' | 'free'; // 카드 폭 축소 시 세로도 비례 축소할지
  };
}

export interface TemplateV2 {
  templateId: string;
  name: string;
  category: string;
  primaryTable?: string;
  fields: FieldConfig[];
  views: {
    list?: ViewV2;
    create?: ViewV2;
    edit?: ViewV2;
  };
  connections: Connection[];
  status: 'draft' | 'published';
  version: 2;                   // v1 (현재) 과 구분
}
```

### 1-3. DB 저장 포맷

`templates` 테이블 구조는 유지. `VIEWS` jsonb 컬럼 안에서 version 필드로 v1/v2 구분.

```json
{
  "version": 2,
  "canvas": { "baseWidth": 1920, "baseHeight": 1080, "aspectPolicy": "preserve" },
  "list": {
    "blocks": [
      {
        "id": "stats_1", "componentId": "stats",
        "xPct": 0.05, "yPct": 0.1, "wPct": 0.28, "hPct": 0.22,
        "role": "main", "responsivePolicy": "reflow",
        "config": { /* ... */ }
      },
      {
        "id": "btn_save", "componentId": "button",
        "xPct": 0.78, "yPct": 0.14, "wPct": 0.1, "hPct": 0.05,
        "role": "action", "responsivePolicy": "fixed",
        "anchor": "top-right",
        "config": { "text": "저장", "variant": "primary" }
      }
    ]
  }
}
```

## 2. 런타임 렌더 모델 (추측 제거)

### 2-1. 한 가지 원칙

**렌더러는 블록의 `role` 과 `responsivePolicy` 를 보고만 결정한다.**
- componentId 기반 특수처리 금지 (FIXED_SMALL_IDS, FULL_WIDTH_IDS 삭제)
- top/center/bbox 기반 clustering 금지
- overlap 판정 금지
- action line clamp 금지

### 2-2. role 별 렌더 전략 (고정)

| role | 렌더 방식 |
|---|---|
| `main` | row flex 에 참여. 같은 y 밴드(명시된 y 범위 겹침)끼리 묶여 가로 배치 |
| `action` | main band 다음 라인. 원본 y 순서는 유지하되 **항상 main 아래** 로 배치 |
| `companion` | 같은 band 의 main 과 **가로로 공존** (flex row child). overlap 아님 |
| `overlay` | `position: absolute`. 원본 x/y% 그대로 띄움 (main 위에 얹힘) |

band 분할 규칙도 단순화:
- role 이 `main` 인 블록들만 band clustering (같은 행 = yPct 근접)
- `companion` 은 가장 가까운 main band 에 붙임 (디자이너가 명시한 band id 선택지 제공)
- `action` 은 자기가 가리키는 main band id 기준으로 그 아래 라인 생성
- `overlay` 는 band 무관, 자기 xPct/yPct 로 절대 배치

선택지: 블록에 `bandId?: string` 을 추가해 band 를 명시하면 clustering 조차 필요 없음.
디자이너에서 "이 버튼은 어느 카드 밴드의 action?" 을 명시적으로 연결.

### 2-3. responsivePolicy 별 렌더 (role 과 직교)

| policy | 구현 |
|---|---|
| `fixed` | `width: wPct%; height: hPct%` 또는 원본 px. flex-shrink 0 |
| `scroll` | 내부 overflow:auto. 카드 폭 축소 시 wrapper 만 좁아지고 내부 스크롤 |
| `reflow` | `display:grid; grid-template-columns: repeat(auto-fit, minmax(Xpx, 1fr))` |
| `wrap` | `display:flex; flex-wrap: wrap; gap: Y` |

각 policy 의 파라미터(minItemWidth, gap 등) 는 블록 config 에 명시.

## 3. 디자이너 측 변경

### 3-1. 편집 UX (변경 없음)

- 자유배치 드래그/리사이즈 그대로
- Toolbar 에 "역할" 드롭다운 추가: main / action / companion / overlay
- "반응 정책" 드롭다운 추가: fixed / scroll / reflow / wrap
- 선택적 "앵커" 드롭다운
- 선택적 "band 연결" 드롭다운 (companion/action 용)

### 3-2. 저장 시 normalize

현재 `templateAdapter.ts` 의 `saveTemplate` 에 추가:
```ts
function normalize(block: BlockRaw, canvas: { w: number; h: number }): BlockV2 {
  return {
    id: block.id,
    componentId: block.componentId,
    xPct: block.pos.left / canvas.w,
    yPct: block.pos.top / canvas.h,
    wPct: block.pos.width / canvas.w,
    hPct: block.pos.height / canvas.h,
    role: block.role ?? inferRoleOnSave(block), // 저장 시점 1회 추론, 이후는 사용자가 수정
    responsivePolicy: block.responsivePolicy ?? inferPolicyOnSave(block),
    anchor: block.anchor,
    config: block.config,
  };
}
```

### 3-3. 로드 시 de-normalize (편집 화면)

```ts
function denormalize(b: BlockV2, canvas: { w: number; h: number }): BlockRaw {
  return {
    id: b.id,
    componentId: b.componentId,
    pos: {
      left: b.xPct * canvas.w,
      top: b.yPct * canvas.h,
      width: b.wPct * canvas.w,
      height: b.hPct * canvas.h,
    },
    role: b.role,
    responsivePolicy: b.responsivePolicy,
    anchor: b.anchor,
    config: b.config,
  };
}
```

디자이너는 내부적으로 px 로 편집, 저장/로드 경계에서만 % 변환.

## 4. 마이그레이션 경로

### 4-1. 기존 v1 → v2 자동 변환

런타임에서 version 필드 확인:
- `version === 2` → v2 렌더러
- 없음/`version === 1` → **v1 → v2 즉석 변환** 후 v2 렌더러로 통합

변환 로직 (`lib/utils/templateMigrate.ts`):
```ts
function migrateV1toV2(v1View: any): ViewV2 {
  const canvas = v1View.screenResolution ?? { width: 1920, height: 1080 };
  const blocks = extractComponents(v1View).map((c) => {
    const pos = normalizePos(c, canvas);
    return {
      id: c.id,
      componentId: normalizeComponentId(c),
      xPct: pos.left / canvas.width,
      yPct: pos.top / canvas.height,
      wPct: pos.width / canvas.width,
      hPct: pos.height / canvas.height,
      role: inferRoleFromV1(c),           // 마이그레이션용 1회 추론
      responsivePolicy: inferPolicyFromV1(c),
      config: c.config ?? c.overrides ?? {},
    };
  });
  return {
    blocks,
    canvas: {
      baseWidth: canvas.width,
      baseHeight: canvas.height,
      aspectPolicy: 'preserve',
    },
  };
}
```

추론 규칙 (마이그레이션 1회만 — v2 로 저장되면 디자이너가 고칠 수 있음):
- `FULL_WIDTH_IDS` (table/container/search/accordion) → `role: main`, `policy: scroll`
- `stats` → `role: main`, `policy: reflow`
- `button` → 현재 overlay 흡수 판정 적용 (위치 관계 보고 main/action/overlay 추정)
- `title`, `divider` → `role: companion`, `policy: fixed`

### 4-2. 단계적 전환

- v1 템플릿은 건드리지 않는다 (DB 상 jsonb 유지)
- 저장할 때마다 v2 포맷으로 upgrade
- 1개월 후 "v1 템플릿 변환" 배치 스크립트로 남은 것 일괄 변환

### 4-3. 하위 호환

`TemplateRenderer.tsx` 는 version 분기 없이 **입력을 항상 v2 로 보게** 하고,
로드 경로(`pickViewsObj` 근처)에서 **v1 인지 체크 → migrateV1toV2 적용**.
즉 렌더러 본체에는 v1 특수 로직이 없음.

## 5. 구현 Phase

**Phase 1 — 타입 + 런타임 렌더러 v2 (변환 경유)**
- `types/invyone-component.ts` 에 `BlockV2`, `ViewV2`, `TemplateV2`, `BlockRole`, `ResponsivePolicy` 추가
- `lib/utils/templateMigrate.ts` (v1 → v2 변환)
- `components/dash/TemplateRenderer.tsx` 를 v2 스키마만 받는 버전으로 재작성
  - 상단에서 v1 을 받으면 `migrateV1toV2` 돌려 v2 로
  - 내부 렌더는 `role` + `responsivePolicy` 만 참조
- 기존 템플릿이 동일 화면으로 렌더되는지 회귀 확인

**Phase 2 — 디자이너 로드/저장 전환**
- `templateAdapter.ts` 의 `loadTemplateAsLayout`, `saveTemplate` 을 v2 경유로
- 저장 시 px → % normalize, role/policy 없으면 자동 추론 1회
- 편집 화면은 내부적으로 px 유지

**Phase 3 — 디자이너 UI (role/policy/band 선택)**
- 속성 패널(`components/screen/RealtimePreviewDynamic.tsx` 또는 별도)에 드롭다운 추가
- "역할 = main/action/companion/overlay"
- "반응 정책 = fixed/scroll/reflow/wrap"
- "소속 밴드" (companion/action 용)
- 디자이너가 의도를 명시할 수 있게

**Phase 4 — v1 제거**
- 런타임 v1 migrate 경로 삭제
- 남은 v1 템플릿은 배치 변환 후 `TemplateRenderer` 에서 v2 만 받음

**Phase 5 — 규칙 clean up**
- `FIXED_SMALL_IDS`, `FULL_WIDTH_IDS` 상수 제거
- `groupIntoRows` 의 top-tolerance / overlap 로직 제거
- `renderOverlay` 의 action line clamp 제거
- Before/After 로 line count 측정 (예상: 800줄 → 300줄)

## 6. 체크리스트 (작업 착수 전 확인)

- [ ] 위 BlockV2 스키마 필드 OK (추가/제거 필요한 것 있는지)
- [ ] `bandId` 명시 연결 방식을 쓸지 (추천) / y% 근접 clustering 으로 갈지
- [ ] aspectPolicy: preserve (세로 비례) vs free (세로 content size) 기본값
- [ ] anchor 필드가 fixed policy 와 함께 있을 때 우선순위
- [ ] v1 템플릿 migrate 1회 추론 결과가 기존 렌더와 비슷하게 나오는지 (회귀)
- [ ] Phase 1~5 순서 승인

## 7. 의도적으로 하지 않는 것

- ❌ 디자이너 UX 리뉴얼 (자유배치 유지)
- ❌ grid/slot 강제 (C 옵션)
- ❌ 전체 scale transform (B 옵션)
- ❌ 컴포넌트 ID 하드코딩으로 특수처리 (현재 방식)
- ❌ 좌표 기반 overlap/clustering 런타임 추측

이 문서를 바탕으로 Phase 1 먼저 착수하면 됩니다.
