---
description: 화면 컴포넌트 개발 시 필수 가이드 - V2 컴포넌트, 엔티티 조인, 폼 데이터, 다국어 지원
alwaysApply: false
---

# 화면 컴포넌트 개발 가이드

새로운 화면 컴포넌트를 개발할 때 반드시 따라야 하는 핵심 원칙과 패턴입니다.

---

## 목차

1. [V2 컴포넌트 규칙 (최우선)](#1-v2-컴포넌트-규칙-최우선)
2. [V2 + Zod 레이아웃 저장/로드 시스템 (핵심)](#2-v2--zod-레이아웃-저장로드-시스템-핵심)
3. [표준 Props 인터페이스](#3-표준-props-인터페이스)
4. [멀티테넌시 (company_code)](#4-멀티테넌시-company_code)
5. [디자인 모드 vs 인터랙티브 모드](#5-디자인-모드-vs-인터랙티브-모드)
6. [로딩 및 에러 처리](#6-로딩-및-에러-처리)
7. [테이블 컬럼 기반 입력 위젯](#7-테이블-컬럼-기반-입력-위젯)
8. [컴포넌트별 테이블 설정](#8-컴포넌트별-테이블-설정)
9. [엔티티 조인 컬럼 활용](#9-엔티티-조인-컬럼-활용)
10. [폼 데이터 관리](#10-폼-데이터-관리)
11. [다국어 지원](#11-다국어-지원)
12. [저장 버튼 및 플로우 연동](#12-저장-버튼-및-플로우-연동)
13. [표준 코드 스타일 가이드](#13-표준-코드-스타일-가이드)
14. [성능 최적화](#14-성능-최적화)
15. [체크리스트](#15-체크리스트)

---

## 1. V2 컴포넌트 규칙 (최우선)

### 핵심 원칙

**화면관리 시스템에서는 반드시 V2 컴포넌트만 사용하고 수정합니다.**

- 원본 컴포넌트(v2 접두사 없는 것)는 더 이상 사용하지 않음
- 모든 수정/개발은 V2 폴더에서 진행

### V2 컴포넌트 목록 (18개)

| 컴포넌트 ID | 이름 | 용도 |
|-------------|------|------|
| `v2-button-primary` | 기본 버튼 | 저장, 삭제, 조회 등 액션 |
| `v2-text-display` | 텍스트 표시 | 읽기 전용 텍스트 |
| `v2-divider-line` | 구분선 | 섹션 구분 |
| `v2-table-list` | 테이블 리스트 | 데이터 목록 조회 |
| `v2-table-search-widget` | 검색 필터 | 테이블 검색 조건 |
| `v2-card-display` | 카드 디스플레이 | 카드 형태 표시 |
| `v2-split-panel-layout` | 분할 패널 | 좌우/상하 분할 |
| `v2-numbering-rule` | 채번 규칙 | 자동 채번 생성 |
| `v2-tabs-widget` | 탭 위젯 | 탭 레이아웃 |
| `v2-repeater` | 통합 리피터 | 행 단위 입력/저장 |
| `v2-rack-structure` | 렉 구조 | 창고 렉 위치 생성 |
| `v2-section-paper` | 섹션 페이퍼 | 섹션 컨테이너 |
| `v2-section-card` | 섹션 카드 | 카드 컨테이너 |
| `v2-repeat-screen-modal` | 반복 화면 모달 | 반복 팝업 |
| `v2-location-swap-selector` | 위치 선택 | 출발지/도착지 |
| `v2-pivot-grid` | 피벗 그리드 | 피벗 테이블 |
| `v2-aggregation-widget` | 집계 위젯 | 데이터 집계 |
| `v2-repeat-container` | 리피터 컨테이너 | 반복 컨테이너 |

### 파일 경로

```
frontend/lib/registry/components/
├── v2-button-primary/      ← V2 컴포넌트 (수정 대상)
├── v2-table-list/          ← V2 컴포넌트 (수정 대상)
├── button-primary/         ← 원본 (수정 금지)
├── table-list/             ← 원본 (수정 금지)
└── ...
```

### 수정/개발 시 규칙

1. **버그 수정/기능 추가**: V2 폴더의 파일만 수정
2. **새 컴포넌트 생성**: `v2-` 접두사로 폴더 및 ID 생성
3. **원본 폴더는 절대 수정하지 않음**

### Definition 네이밍 규칙

```typescript
// V2 접두사 사용
export const V2TableListDefinition = createComponentDefinition({
  id: "v2-table-list",
  name: "테이블 리스트",
  // ...
});
```

---

## 2. V2 + Zod 레이아웃 저장/로드 시스템 (핵심)

### 핵심 원칙

**컴포넌트 코드 수정 시 모든 화면에 자동 반영되도록 V2 + Zod 기반 저장/로드 방식을 사용합니다.**

```
저장: component_url + overrides (차이값만)
로드: Zod 기본값 + overrides 병합
```

### 기존 방식 vs V2 방식

| 항목 | 기존 (문제점) | V2 (해결) |
|-----|-------------|----------|
| 저장 | 전체 설정 "박제" | url + overrides (차이값만) |
| 코드 수정 반영 | 안 됨 | 자동 반영 |
| 테이블 | `screen_layouts` (다중 레코드) | `screen_layouts_v2` (1 레코드) |

### 저장 구조 (screen_layouts_v2)

```json
{
  "version": "2.0",
  "components": [
    {
      "id": "comp_xxx",
      "url": "@/lib/registry/components/v2-select",
      "position": { "x": 100, "y": 50 },
      "size": { "width": 180, "height": 30 },
      "displayOrder": 0,
      "overrides": {
        "tableName": "warehouse_info",
        "columnName": "warehouse_code",
        "label": "창고코드",
        "webType": "select"
      }
    }
  ]
}
```

### 핵심 필드: overrides에 반드시 포함되어야 하는 속성

컴포넌트가 테이블 컬럼에서 드래그되어 생성된 경우, 다음 속성들이 `overrides`에 저장됩니다:

| 속성 | 설명 | 예시 |
|-----|------|-----|
| `tableName` | 연결된 테이블명 | `"warehouse_info"` |
| `columnName` | 연결된 컬럼명 | `"warehouse_code"` |
| `label` | 표시 라벨 | `"창고코드"` |
| `required` | 필수 입력 여부 | `true` / `false` |
| `readonly` | 읽기 전용 여부 | `true` / `false` |
| `inputType` | 입력 타입 | `"text"`, `"select"`, `"date"` 등 |
| `webType` | 웹 타입 | `"text"`, `"select"`, `"date"` 등 |
| `codeCategory` | 코드 카테고리 (코드 타입인 경우) | `"WAREHOUSE_TYPE"` |

### 저장 로직 (convertLegacyToV2)

```typescript
// frontend/lib/utils/layoutV2Converter.ts

export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
  const components = legacyLayout.components.map((comp, index) => {
    const componentType = comp.componentType || comp.widgetType || comp.type;
    const url = getComponentUrl(componentType);
    const defaults = getDefaultsByUrl(url);
    
    // 상위 레벨 속성들도 overrides에 포함 (중요!)
    const topLevelProps: Record<string, any> = {};
    if (comp.tableName) topLevelProps.tableName = comp.tableName;
    if (comp.columnName) topLevelProps.columnName = comp.columnName;
    if (comp.label) topLevelProps.label = comp.label;
    if (comp.required !== undefined) topLevelProps.required = comp.required;
    if (comp.readonly !== undefined) topLevelProps.readonly = comp.readonly;
    
    // componentConfig에서 차이값만 추출
    const configOverrides = extractCustomConfig(comp.componentConfig || {}, defaults);
    
    // 병합
    const overrides = { ...topLevelProps, ...configOverrides };

    return {
      id: comp.id,
      url: url,
      position: comp.position,
      size: comp.size,
      displayOrder: index,
      overrides: overrides,
    };
  });

  return { version: "2.0", components };
}
```

### 로드 로직 (convertV2ToLegacy)

```typescript
// frontend/lib/utils/layoutV2Converter.ts

export function convertV2ToLegacy(v2Layout: LayoutV2): LegacyLayoutData {
  const components = v2Layout.components.map((comp) => {
    const componentType = getComponentTypeFromUrl(comp.url);
    const defaults = getDefaultsByUrl(comp.url);
    const mergedConfig = mergeComponentConfig(defaults, comp.overrides);
    
    // overrides에서 상위 레벨 속성들 복원
    const overrides = comp.overrides || {};

    return {
      id: comp.id,
      componentType: componentType,
      position: comp.position,
      size: comp.size,
      componentConfig: mergedConfig,
      // 상위 레벨 속성 복원 (중요!)
      tableName: overrides.tableName,
      columnName: overrides.columnName,
      label: overrides.label || "",
      required: overrides.required,
      readonly: overrides.readonly,
    };
  });

  return { components };
}
```

### Zod 스키마 구조

```typescript
// frontend/lib/schemas/componentConfig.ts

// 컴포넌트별 overrides 스키마
export const v2SelectOverridesSchema = z.object({
  mode: z.enum(["dropdown", "combobox", "radio", "checkbox"]).default("dropdown"),
  source: z.enum(["static", "code", "entity", "db", "distinct"]).default("distinct"),
  multiple: z.boolean().default(false),
  searchable: z.boolean().default(true),
  placeholder: z.string().default("선택하세요"),
}).passthrough();  // 정의되지 않은 필드도 통과 (tableName, columnName 등)

// 스키마 레지스트리
export const componentOverridesSchemaRegistry: Record<string, z.ZodType<any>> = {
  "v2-select": v2SelectOverridesSchema,
  "v2-input": v2InputOverridesSchema,
  "v2-table-list": v2TableListOverridesSchema,
  // ...
};

// 기본값 레지스트리
export const componentDefaultsRegistry: Record<string, any> = {
  "v2-select": {
    mode: "dropdown",
    source: "distinct",  // 기본: 테이블 컬럼에서 자동 로드
    multiple: false,
    searchable: true,
  },
  // ...
};
```

### v2-select 자동 옵션 로드

`webType`이 `"select"`인 컬럼을 드래그하면:

1. **저장 시**: `tableName`, `columnName`이 `overrides`에 저장됨
2. **로드 시**: `source`가 `"distinct"`이면 자동으로 `/entity/{tableName}/distinct/{columnName}` API 호출
3. **결과**: 해당 컬럼의 고유 값들이 옵션으로 표시됨

```typescript
// DynamicComponentRenderer.tsx

case "v2-select":
  return (
    <V2Select
      {...commonProps}
      config={{
        mode: config.mode || "dropdown",
        source: config.source || "distinct",  // 기본: 테이블에서 자동 로드
        // ...
      }}
    />
  );
```

### 관련 파일

| 파일 | 역할 |
|------|------|
| `frontend/lib/schemas/componentConfig.ts` | Zod 스키마 및 기본값 레지스트리 |
| `frontend/lib/utils/layoutV2Converter.ts` | V2 ↔ Legacy 변환 유틸리티 |
| `frontend/lib/api/screen.ts` | `getLayoutV2`, `saveLayoutV2` API |
| `backend-node/src/services/screenManagementService.ts` | 백엔드 저장/로드 로직 |

### 새 컴포넌트 추가 시 체크리스트

1. [ ] `componentConfig.ts`에 Zod 스키마 추가 (`.passthrough()` 필수)
2. [ ] `componentOverridesSchemaRegistry`에 등록
3. [ ] `componentDefaultsRegistry`에 기본값 등록
4. [ ] 테이블 컬럼 드래그 시 `tableName`, `columnName` 저장 확인

---

## 3. 표준 Props 인터페이스

### 컴포넌트가 받아야 하는 표준 Props

모든 화면 컴포넌트는 다음 Props를 지원해야 합니다:

```typescript
interface StandardComponentProps {
  // 필수
  component: ComponentData;          // 컴포넌트 설정 데이터
  isDesignMode?: boolean;            // 디자인 모드 여부 (기본: false)
  isSelected?: boolean;              // 선택 상태 (디자인 모드용)
  isPreview?: boolean;               // 미리보기 모드

  // 폼 데이터 관련
  formData?: Record<string, any>;    // 현재 폼 데이터
  onFormDataChange?: (fieldName: string, value: any) => void;

  // 선택 관련 (테이블/리스트 컴포넌트)
  selectedRows?: any[];              // 선택된 행 ID 목록
  selectedRowsData?: any[];          // 선택된 행 전체 데이터
  onSelectedRowsChange?: (rows: any[], data: any[]) => void;

  // 사용자 정보 (멀티테넌시용)
  userId?: string;
  userName?: string;
  companyCode?: string;              // 회사 코드 (멀티테넌시 필수)

  // 화면 정보
  screenId?: number;
  tableName?: string;                // 화면 메인 테이블
  menuObjid?: number;

  // 새로고침 제어
  refreshKey?: number;               // 변경 시 데이터 새로고침
  onRefresh?: () => void;

  // 기타
  className?: string;
  style?: React.CSSProperties;
}
```

### Props 사용 예시

```typescript
export const MyComponent: React.FC<StandardComponentProps> = ({
  component,
  isDesignMode = false,
  formData = {},
  onFormDataChange,
  companyCode,
  refreshKey,
}) => {
  // 컴포넌트 구현
};
```

---

## 4. 멀티테넌시 (company_code)

### 핵심 원칙

**모든 데이터 조회/저장 API 호출 시 `autoFilter`를 사용하여 회사별 데이터 격리를 적용합니다.**

### API 호출 시 autoFilter 패턴 (필수)

```typescript
// 데이터 조회 시
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
  page: 1,
  size: 100,
  // 멀티테넌시: company_code 자동 필터링
  autoFilter: {
    enabled: true,
    filterColumn: "company_code",
    userField: "companyCode",
  },
});

// entityJoinApi 사용 시
const response = await entityJoinApi.getTableDataWithJoins(tableName, {
  page: 1,
  size: 100,
  enableEntityJoin: true,
  companyCodeOverride: companyCode,  // 프리뷰용 회사 코드 오버라이드
});
```

### 데이터 저장 시

```typescript
// 저장 시에는 백엔드에서 자동으로 company_code 추가
// 프론트엔드에서 명시적으로 company_code를 전달할 필요 없음
const response = await apiClient.post(`/table-management/tables/${tableName}/add`, {
  ...formData,
  // company_code는 백엔드에서 세션 정보로 자동 추가
});
```

### 주의사항

1. **모든 테이블 조회에 autoFilter 적용** (예외: 시스템 설정 테이블)
2. **company_code = "*"는 최고 관리자 전용** (일반 사용자에게 노출 금지)
3. **JOIN 시에도 company_code 조건 확인** (entityJoinApi가 자동 처리)

---

## 5. 디자인 모드 vs 인터랙티브 모드

### 모드 구분

| 모드 | 설명 | API 호출 | 이벤트 처리 |
|------|------|---------|------------|
| 디자인 모드 (`isDesignMode=true`) | 화면 편집기에서 레이아웃 설계 시 | 스킵 | 비활성화 |
| 인터랙티브 모드 (`isDesignMode=false`) | 실제 화면 실행 시 | 정상 실행 | 활성화 |
| 미리보기 모드 (`isPreview=true`) | 편집기 내 미리보기 | 제한적 실행 | 활성화 |

### 디자인 모드 처리 패턴

```typescript
export const MyComponent: React.FC<Props> = ({ isDesignMode = false }) => {
  const [data, setData] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);

  // 1. 디자인 모드에서 API 호출 스킵
  useEffect(() => {
    if (isDesignMode) return;
    fetchData();
  }, [isDesignMode]);

  // 2. 디자인 모드에서 더미 데이터 표시
  if (isDesignMode) {
    return (
      <div className="border-2 border-dashed border-muted p-4">
        <p className="text-sm text-muted-foreground">
          테이블 리스트 (3개 컬럼)
        </p>
      </div>
    );
  }

  // 3. 인터랙티브 모드에서 실제 데이터 표시
  return (
    <Table>
      {data.map((row) => (
        <TableRow key={row.id}>...</TableRow>
      ))}
    </Table>
  );
};
```

### 이벤트 핸들러 비활성화

```typescript
const handleClick = useCallback(() => {
  if (isDesignMode) return; // 디자인 모드에서 클릭 무시
  
  // 실제 액션 수행
  executeAction();
}, [isDesignMode]);
```

---

## 6. 로딩 및 에러 처리

### 로딩 상태 관리

```typescript
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const fetchData = useCallback(async () => {
  if (isDesignMode) return;
  
  setLoading(true);
  setError(null);
  
  try {
    const response = await apiClient.get(`/api/data`);
    if (response.data.success) {
      setData(response.data.data);
    }
  } catch (err: any) {
    setError(err.message || "데이터 조회 실패");
  } finally {
    setLoading(false);
  }
}, [isDesignMode]);
```

### 로딩 UI 표시

```tsx
if (loading) {
  return (
    <div className="flex items-center justify-center p-4">
      <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
      <span className="ml-2 text-sm text-muted-foreground">로딩 중...</span>
    </div>
  );
}

if (error) {
  return (
    <div className="p-4 text-sm text-destructive">
      {error}
    </div>
  );
}
```

### 토스트 알림 패턴

```typescript
import { toast } from "sonner";

// 성공
toast.success("저장되었습니다.");

// 에러
toast.error("저장 중 오류가 발생했습니다.");

// 로딩 (장시간 작업)
const toastId = toast.loading("저장 중...");
// 완료 후
toast.dismiss(toastId);
toast.success("저장 완료");
```

### DB 에러 유형별 처리

```typescript
const handleApiError = (error: any): string => {
  const responseData = error?.response?.data;
  
  if (!responseData?.error) {
    return error.message || "오류가 발생했습니다.";
  }

  const errorMsg = responseData.error;

  // 중복 키 에러
  if (errorMsg.includes("duplicate key")) {
    return "이미 존재하는 값입니다. 다른 값을 입력해주세요.";
  }
  
  // NOT NULL 제약조건 에러
  if (errorMsg.includes("null value")) {
    const match = errorMsg.match(/column "(\w+)"/);
    const columnName = match ? match[1] : "필수";
    return `${columnName} 필드는 필수 입력 항목입니다.`;
  }
  
  // 외래키 제약조건 에러
  if (errorMsg.includes("foreign key")) {
    return "참조하는 데이터가 존재하지 않습니다.";
  }

  return responseData.message || errorMsg;
};

// 사용
try {
  await apiClient.post("/api/save", data);
  toast.success("저장되었습니다.");
} catch (error) {
  toast.error(handleApiError(error));
}
```

### 조용히 처리해야 하는 액션

다음 액션은 성공/실패 토스트를 표시하지 않습니다:

```typescript
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];

if (!silentActions.includes(actionType)) {
  toast.success("처리 완료");
}
```

---

## 7. 테이블 컬럼 기반 입력 위젯

### 드래그 방식으로 입력 폼 생성

좌측 패널에서 테이블 컬럼을 드래그하면 **테이블 타입관리에서 설정된 `inputType`**에 따라 자동으로 적절한 입력 위젯이 생성됩니다.

```
┌─────────────────────┐         ┌─────────────────────┐
│ 좌측 테이블 패널    │  드래그  │ 캔버스              │
├─────────────────────┤  ───▶   ├─────────────────────┤
│   창고코드 (text)   │         │ [창고코드] [____]   │
│   층 (category)     │         │ [층]      [▼]      │
│   열 (number)       │         │ [열]      [____]   │
└─────────────────────┘         └─────────────────────┘
```

### inputType → 위젯 타입 매핑

| inputType | 생성 위젯 | 설명 |
|-----------|----------|------|
| `text`, `textarea` | V2Input | 텍스트 입력 |
| `number` | V2Input | 숫자 입력 |
| `date`, `datetime` | V2Date | 날짜/시간 선택 |
| `code`, `category`, `entity` | V2Select | 선택박스 |
| `checkbox`, `radio` | 체크박스/라디오 | 선택 |
| `image`, `file` | V2Media | 파일 업로드 |

### 핵심 V2 컴포넌트 (3개)

| 컴포넌트 | 담당 inputType | 파일 경로 |
|----------|---------------|-----------|
| `V2Input` | text, textarea, number, password | `components/v2/V2Input.tsx` |
| `V2Select` | code, category, entity, select | `components/v2/V2Select.tsx` |
| `V2Date` | date, datetime, time, daterange | `components/v2/V2Date.tsx` |

### 컴포넌트 패널에서 직접 드래그 가능한 컴포넌트

| 컴포넌트 ID | 이름 | 설명 |
|-------------|------|------|
| `v2-repeater` | 리피터 그리드 | 행 단위 데이터 추가/수정/삭제 |
| `v2-table-list` | 테이블 리스트 | 데이터 목록 조회/필터/정렬 |
| `v2-table-search-widget` | 검색 필터 | 테이블 검색 조건 입력 |
| `v2-button-primary` | 버튼 | 저장, 삭제, 조회 등 액션 |
| `v2-split-panel-layout` | 분할 패널 | 좌우/상하 분할 레이아웃 |
| `v2-tabs-widget` | 탭 위젯 | 탭으로 화면 분리 |

### 입력 폼 필수 설정 (중요)

입력 폼 컴포넌트 개발 시 **반드시** 다음 설정을 지원해야 합니다:

#### 필수 항목 설정 (`required`)

```typescript
interface InputWidgetConfig {
  required?: boolean;        // 필수 입력 여부
  requiredMessage?: string;  // 에러 메시지 (기본: "필수 입력 항목입니다")
}
```

- 우측 속성 패널에서 **"필수 입력"** 체크박스 제공
- 필수 필드는 라벨 옆에 **빨간색 `*`** 표시
- 테이블 타입관리의 `isNullable = 'NO'`인 경우 기본값 `required: true`

#### 숨김 설정 (`hidden`)

```typescript
interface InputWidgetConfig {
  hidden?: boolean;          // 화면에서 숨김 여부
  hiddenOnNew?: boolean;     // 신규 모드에서만 숨김
  hiddenOnEdit?: boolean;    // 수정 모드에서만 숨김
}
```

#### 저장 시 필수 항목 검증 (필수 구현)

```typescript
// buttonActions.ts - 저장 액션
const validateRequiredFields = (
  formData: Record<string, any>,
  allComponents: ComponentData[],
): { isValid: boolean; errors: string[] } => {
  const errors: string[] = [];
  
  allComponents.forEach((comp) => {
    if (comp.type === "widget" && comp.required) {
      const value = formData[comp.columnName];
      const isEmpty = value === null || value === undefined || value === "" ||
                      (Array.isArray(value) && value.length === 0);
      
      if (isEmpty) {
        const label = comp.label || comp.columnName;
        errors.push(`${label}은(는) 필수 입력 항목입니다.`);
      }
    }
  });
  
  return { isValid: errors.length === 0, errors };
};

// 저장 전 검증
const handleSave = async (context: ButtonActionContext) => {
  const validation = validateRequiredFields(context.formData, context.allComponents);
  
  if (!validation.isValid) {
    toast.error(validation.errors.join("\n"));
    return { success: false, errors: validation.errors };
  }
  
  return await saveFormData(context.formData);
};
```

---

## 8. 컴포넌트별 테이블 설정

### 핵심 원칙

**하나의 화면에서 여러 테이블을 다룰 수 있습니다.**

| 예시: 입고 화면 | 테이블 | 용도 |
|----------------|--------|------|
| 메인 폼 | `receiving_mng` | 입고 마스터 정보 |
| 조회 리스트 | `purchase_order_detail` | 발주 상세 조회 (읽기 전용) |
| 입력 리피터 | `receiving_detail` | 입고 상세 입력/저장 |

### 컴포넌트 설정 패턴

#### 조회용 (테이블 리스트)

```typescript
interface TableListConfig {
  customTableName?: string;  // 사용할 테이블명
  useCustomTable?: boolean;  // true: customTableName 사용
  isReadOnly?: boolean;      // true: 조회만
}
```

#### 저장용 (리피터)

```typescript
interface V2RepeaterConfig {
  mainTableName?: string;           // 저장할 테이블명
  useCustomTable?: boolean;         // true: mainTableName 사용
  foreignKeyColumn?: string;        // FK 컬럼 (예: receiving_id)
  foreignKeySourceColumn?: string;  // PK 컬럼 (예: id)
}
```

### 테이블 선택 UI 표준 (Combobox 그룹별)

```tsx
<Command>
  <CommandInput placeholder="테이블 검색..." />
  <CommandList>
    {/* 그룹 1: 기본 (화면 테이블) */}
    <CommandGroup heading="기본">
      <CommandItem value={screenTableName}>
        <Database className="mr-2 h-3 w-3 text-blue-500" />
        {screenTableName}
      </CommandItem>
    </CommandGroup>

    {/* 그룹 2: 연관 테이블 (FK 자동 설정) */}
    <CommandGroup heading="연관 테이블 (FK 자동 설정)">
      {relatedTables.map((table) => (
        <CommandItem key={table.tableName} value={table.tableName}>
          <Link2 className="mr-2 h-3 w-3 text-green-500" />
          {table.tableName}
          <span className="ml-auto text-xs text-muted-foreground">
            FK: {table.foreignKeyColumn}
          </span>
        </CommandItem>
      ))}
    </CommandGroup>

    {/* 그룹 3: 전체 테이블 */}
    <CommandGroup heading="전체 테이블">
      {allTables.map((table) => (
        <CommandItem key={table.tableName} value={table.tableName}>
          {table.displayName || table.tableName}
        </CommandItem>
      ))}
    </CommandGroup>
  </CommandList>
</Command>
```

### 연관 테이블 선택 시 FK 자동 설정

```typescript
const handleTableSelect = (tableName: string) => {
  const relation = relatedTables.find(r => r.tableName === tableName);
  
  if (relation) {
    // 연관 테이블: FK/PK 자동 설정
    updateConfig({
      useCustomTable: true,
      mainTableName: tableName,
      foreignKeyColumn: relation.foreignKeyColumn,
      foreignKeySourceColumn: relation.referenceColumn,
    });
  }
};
```

### 연관 테이블 조회 API

```typescript
const response = await apiClient.get(
  `/api/table-management/columns/${currentTableName}/referenced-by`
);

// 응답
{
  success: true,
  data: [
    {
      tableName: "receiving_detail",
      columnName: "receiving_id",
      referenceColumn: "id",
    }
  ]
}
```

---

## 9. 엔티티 조인 컬럼 활용

### 핵심 원칙

**테이블 타입관리의 엔티티 관계를 불러와서 조인된 컬럼들을 모두 사용 가능하게 합니다.**

### API 사용법

```typescript
import { entityJoinApi } from "@/lib/api/entityJoin";

const result = await entityJoinApi.getEntityJoinColumns(tableName);

// 응답 구조
{
  tableName: string;
  joinTables: Array<{
    tableName: string;
    currentDisplayColumn: string;
    availableColumns: Array<{
      columnName: string;
      columnLabel: string;
      dataType: string;
    }>;
  }>;
  availableColumns: Array<{
    tableName: string;
    columnName: string;
    columnLabel: string;
    joinAlias: string;       // 예: item_code_item_name
    suggestedLabel: string;  // 예: 품목명
  }>;
}
```

### 설정 패널에 엔티티 조인 컬럼 섹션 추가 (필수)

```typescript
// 상태 정의
const [entityJoinColumns, setEntityJoinColumns] = useState<{
  availableColumns: any[];
  joinTables: any[];
}>({ availableColumns: [], joinTables: [] });

// 엔티티 조인 컬럼 로드
useEffect(() => {
  const fetchEntityJoinColumns = async () => {
    const tableName = config.selectedTable || screenTableName;
    if (!tableName) return;

    const result = await entityJoinApi.getEntityJoinColumns(tableName);
    setEntityJoinColumns({
      availableColumns: result.availableColumns || [],
      joinTables: result.joinTables || [],
    });
  };

  fetchEntityJoinColumns();
}, [config.selectedTable, screenTableName]);
```

### 엔티티 조인 컬럼 추가 함수

```typescript
const addEntityJoinColumn = (tableName: string, column: any) => {
  // "테이블명.컬럼명" 형식으로 저장
  const fullColumnName = `${tableName}.${column.columnName}`;

  const newColumn = {
    columnName: fullColumnName,
    displayName: column.columnLabel,
    isEntityJoin: true,
    entityJoinTable: tableName,
    entityJoinColumn: column.columnName,
  };

  onChange({
    ...config,
    columns: [...(config.columns || []), newColumn],
  });
};
```

### 셀 값 추출 헬퍼

```typescript
const getEntityJoinValue = (item: any, columnName: string): any => {
  // 직접 매칭
  if (item[columnName] !== undefined) return item[columnName];

  // "테이블명.컬럼명" 형식
  if (columnName.includes(".")) {
    const [tableName, fieldName] = columnName.split(".");
    const inferredSourceColumn = tableName.replace("_info", "_code");
    const exactKey = `${inferredSourceColumn}_${fieldName}`;
    
    if (item[exactKey] !== undefined) return item[exactKey];
    if (item[fieldName] !== undefined) return item[fieldName];
  }

  return undefined;
};
```

---

## 10. 폼 데이터 관리

### 통합 폼 시스템 (V2FormContext)

```typescript
import { useFormCompatibility } from "@/hooks/useFormCompatibility";

const MyComponent = ({ onFormDataChange, formData }) => {
  const { getValue, setValue, submit } = useFormCompatibility({
    legacyOnFormDataChange: onFormDataChange,
  });

  // 값 읽기
  const currentValue = getValue("fieldName");

  // 값 설정
  const handleChange = (value: any) => {
    setValue("fieldName", value);
  };

  // 저장
  const handleSave = async () => {
    const result = await submit({ tableName: "my_table", mode: "insert" });
  };
};
```

### onChange 핸들러 패턴

```typescript
const handleChange = useCallback((value: any) => {
  // 1. V2FormContext
  v2Context?.setValue(fieldName, value);

  // 2. ScreenContext
  screenContext?.updateFormData?.(fieldName, value);

  // 3. 레거시 콜백
  onFormDataChange?.(fieldName, value);
}, [fieldName, v2Context, screenContext, onFormDataChange]);
```

---

## 11. 다국어 지원

### 타입 정의 시 다국어 필드 추가

```typescript
interface MyComponentConfig {
  title?: string;
  titleLangKeyId?: number;
  titleLangKey?: string;

  columns?: Array<{
    name: string;
    label: string;
    langKeyId?: number;
    langKey?: string;
  }>;
}
```

### 라벨 추출 로직 등록

파일: `frontend/lib/utils/multilangLabelExtractor.ts`

```typescript
// extractMultilangLabels 함수에 추가
if (comp.componentType === "my-new-component") {
  const config = comp.componentConfig;

  if (config?.title) {
    addLabel({
      id: `${comp.id}_title`,
      label: config.title,
      langKeyId: config.titleLangKeyId,
      langKey: config.titleLangKey,
    });
  }

  config?.columns?.forEach((col, index) => {
    addLabel({
      id: `${comp.id}_col_${index}`,
      label: col.label,
      langKeyId: col.langKeyId,
      langKey: col.langKey,
    });
  });
}
```

### 번역 표시 로직

```typescript
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";

const MyComponent = ({ component }) => {
  const { getTranslatedText } = useScreenMultiLang();
  const config = component.componentConfig;

  const displayTitle = config?.titleLangKey
    ? getTranslatedText(config.titleLangKey, config.title || "")
    : config?.title || "";

  return <h2>{displayTitle}</h2>;
};
```


---

## 12. 저장 버튼 및 플로우 연동

### beforeFormSave 이벤트 처리 (필수)

저장 버튼 클릭 시 `beforeFormSave` 이벤트가 발생하며, 각 컴포넌트는 자신의 데이터를 제공해야 합니다:

```typescript
useEffect(() => {
  const handleSaveRequest = (event: CustomEvent) => {
    const componentKey = columnName || component?.id;
    if (event.detail && componentKey) {
      event.detail.formData[componentKey] = currentValue;
    }
  };
  
  window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
  return () => window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
}, [currentValue, columnName, component?.id]);
```

### 배열 데이터 처리 (리피터, 테이블)

```typescript
useEffect(() => {
  const handleSaveRequest = (event: CustomEvent) => {
    const componentKey = columnName || component?.id || "repeater_data";
    
    // 메타데이터 필드(_로 시작) 제외
    const filteredData = localValue.map((item: any) => {
      const filtered: Record<string, any> = {};
      Object.keys(item).forEach((key) => {
        if (!key.startsWith("_")) {
          filtered[key] = item[key];
        }
      });
      return filtered;
    });
    
    if (event.detail) {
      event.detail.formData[componentKey] = filteredData;
    }
  };
  
  window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
  return () => window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
}, [localValue, columnName, component?.id]);
```

### 플로우(제어관리)와의 연동

```typescript
import { useFlowContext } from "@/contexts/FlowContext";

const MyFormComponent = ({ formData, onFormDataChange }) => {
  const flowContext = useFlowContext();
  
  // 스텝에서 선택된 데이터로 폼 초기화
  useEffect(() => {
    if (flowContext?.selectedData?.length > 0) {
      const firstItem = flowContext.selectedData[0];
      Object.keys(firstItem).forEach((key) => {
        onFormDataChange?.(key, firstItem[key]);
      });
    }
  }, [flowContext?.selectedData]);
};
```

### 저장 버튼 액션 타입

| 액션 타입 | 설명 | 플로우 연동 |
|----------|------|------------|
| `save` | 단순 저장 | X |
| `saveAndClose` | 저장 후 모달/화면 닫기 | X |
| `saveAndMove` | 저장 후 다음 스텝으로 이동 | O |
| `saveAndRefresh` | 저장 후 화면 새로고침 | X |

### 주의사항

1. **컴포넌트 독립성**: 플로우 없이도 단독으로 작동해야 함
2. **옵셔널 처리**: `flowContext`가 없을 수 있으므로 항상 옵셔널 체이닝 사용
3. **데이터 키 일관성**: `columnName`이 있으면 우선 사용, 없으면 `component.id` 사용
4. **메타데이터 제외**: 저장 시 `_`로 시작하는 메타데이터 필드는 제외



---

## 13. 표준 코드 스타일 가이드

**`v2-repeater`** 컴포넌트를 표준으로 삼아 동일한 구조로 작성합니다.

### 핵심 원칙: 느슨한 결합도 (Loose Coupling)

**컴포넌트 간 직접 참조를 피하고, 이벤트 기반 통신을 사용합니다.**

```
┌─────────────┐    이벤트     ┌─────────────┐    이벤트     ┌─────────────┐
│  저장 버튼   │ ──────────▶ │   리피터    │ ◀────────── │  플로우     │
│             │ beforeFormSave │             │ flowStepChange │             │
└─────────────┘              └─────────────┘              └─────────────┘
                                    │
                                    ▼
            ┌─────────────────────────────────────────────────┐
            │            Window Event Bus                      │
            │  beforeFormSave, repeaterSave, flowStepChange   │
            └─────────────────────────────────────────────────┘
```

### 파일 구조 표준

```
frontend/lib/registry/components/v2-[component-name]/
├── index.ts              # V2ComponentDefinition (V2 접두사)
├── [Name]Renderer.tsx    # 렌더러 (자동 등록)
├── [Name]Component.tsx   # 메인 컴포넌트
├── [Name]ConfigPanel.tsx # 설정 패널
└── types.ts              # 타입 정의 (선택적)
```

### index.ts 표준 구조

```typescript
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { MyComponentConfigPanel } from "./MyComponentConfigPanel";
import { MyComponent } from "./MyComponent";

export const V2MyComponentDefinition = createComponentDefinition({
  id: "v2-my-component",           // 반드시 v2- 접두사
  name: "마이 컴포넌트",
  category: ComponentCategory.INPUT,
  component: MyComponent,
  configPanel: MyComponentConfigPanel,
  defaultProps: { config: {} },
  events: ["onDataChange", "onRowClick"],
});

export default V2MyComponentDefinition;
```

### 메인 컴포넌트 표준 구조

```typescript
"use client";

import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { cn } from "@/lib/utils";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";

interface MyComponentProps {
  config: MyComponentConfig;
  parentId?: string | number;
  onDataChange?: (data: any[]) => void;
}

export const MyComponent: React.FC<MyComponentProps> = ({
  config: propConfig,
  parentId,
  onDataChange,
}) => {
  // 1. 설정 병합 (useMemo)
  const config = useMemo(() => ({
    ...DEFAULT_CONFIG,
    ...propConfig,
  }), [propConfig]);

  // 2. 상태 정의
  const [data, setData] = useState<any[]>([]);
  const dataRef = useRef(data);
  dataRef.current = data;

  // 3. 이벤트 리스너 등록 (느슨한 결합)
  useEffect(() => {
    const handleSaveEvent = async (event: CustomEvent) => {
      const { masterRecordId } = event.detail || {};
      await saveData(dataRef.current, masterRecordId);
    };

    // V2 이벤트 버스 구독
    const unsubscribe = v2EventBus.subscribe(
      V2_EVENTS.FORM.REPEATER_SAVE,
      handleSaveEvent
    );

    // 레거시 이벤트 지원
    window.addEventListener("repeaterSave", handleSaveEvent as any);
    
    return () => {
      unsubscribe();
      window.removeEventListener("repeaterSave", handleSaveEvent as any);
    };
  }, [config]);

  // 4. beforeFormSave 이벤트 리스너
  useEffect(() => {
    const handleBeforeFormSave = (event: CustomEvent) => {
      const fieldName = config.fieldName || config.columnName;
      if (event.detail && fieldName) {
        // 메타데이터 제외 후 데이터 제공
        const cleanData = dataRef.current.map(item => {
          const clean: Record<string, any> = {};
          Object.keys(item).forEach(key => {
            if (!key.startsWith("_")) clean[key] = item[key];
          });
          return clean;
        });
        event.detail.formData[fieldName] = cleanData;
      }
    };

    const unsubscribe = v2EventBus.subscribe(
      V2_EVENTS.FORM.BEFORE_SAVE,
      handleBeforeFormSave
    );
    window.addEventListener("beforeFormSave", handleBeforeFormSave as any);
    
    return () => {
      unsubscribe();
      window.removeEventListener("beforeFormSave", handleBeforeFormSave as any);
    };
  }, [config.fieldName, config.columnName]);

  // 5. 렌더링
  return (
    <V2ErrorBoundary componentName="MyComponent">
      <div className={cn("my-component")}>
        {/* 컴포넌트 내용 */}
      </div>
    </V2ErrorBoundary>
  );
};
```

### 설정 패널 표준 구조

```typescript
"use client";

import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Database, Link2, ChevronsUpDown, Check } from "lucide-react";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";

interface MyComponentConfigPanelProps {
  config: MyComponentConfig;
  onChange: (config: MyComponentConfig) => void;
  screenTableName?: string;
}

export const MyComponentConfigPanel: React.FC<MyComponentConfigPanelProps> = ({
  config: propConfig,
  onChange,
  screenTableName,
}) => {
  // 1. config 안전하게 초기화 (useMemo)
  const config = useMemo(() => ({
    ...DEFAULT_CONFIG,
    ...propConfig,
  }), [propConfig]);

  // 2. 상태 정의
  const [allTables, setAllTables] = useState<any[]>([]);
  const [relatedTables, setRelatedTables] = useState<any[]>([]);
  const [tableComboboxOpen, setTableComboboxOpen] = useState(false);

  // 3. 데이터 로딩
  useEffect(() => {
    const loadTables = async () => {
      const response = await tableManagementApi.getTableList();
      if (response.success) setAllTables(response.data);
    };
    loadTables();
  }, []);

  useEffect(() => {
    if (!screenTableName) return;
    const loadRelatedTables = async () => {
      const response = await tableManagementApi.getReferencedByTables(screenTableName);
      if (response.success) setRelatedTables(response.data);
    };
    loadRelatedTables();
  }, [screenTableName]);

  // 4. 핸들러
  const handleChange = useCallback(<K extends keyof MyComponentConfig>(
    key: K, value: MyComponentConfig[K]
  ) => {
    onChange({ ...config, [key]: value });
  }, [config, onChange]);

  const handleTableSelect = useCallback((tableName: string) => {
    const relation = relatedTables.find(r => r.tableName === tableName);
    
    if (relation) {
      onChange({
        ...config,
        useCustomTable: true,
        mainTableName: tableName,
        foreignKeyColumn: relation.foreignKeyColumn,
        foreignKeySourceColumn: relation.referenceColumn,
      });
    } else {
      onChange({
        ...config,
        useCustomTable: tableName !== screenTableName,
        mainTableName: tableName !== screenTableName ? tableName : undefined,
      });
    }
    setTableComboboxOpen(false);
  }, [config, onChange, screenTableName, relatedTables]);

  // 5. 렌더링
  return (
    <div className="space-y-4 p-4">
      {/* 테이블 선택 (그룹별 Combobox) */}
      <div className="space-y-2">
        <Label className="text-xs font-medium">저장 테이블</Label>
        <Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
          <PopoverTrigger asChild>
            <Button variant="outline" className="w-full justify-between">
              {config.mainTableName || screenTableName || "테이블 선택..."}
              <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
            </Button>
          </PopoverTrigger>
          <PopoverContent className="w-full p-0">
            <Command>
              <CommandInput placeholder="테이블 검색..." />
              <CommandList>
                {/* 기본 */}
                {screenTableName && (
                  <CommandGroup heading="기본">
                    <CommandItem onSelect={() => handleTableSelect(screenTableName)}>
                      <Database className="mr-2 h-3 w-3 text-blue-500" />
                      {screenTableName}
                    </CommandItem>
                  </CommandGroup>
                )}
                
                {/* 연관 테이블 */}
                {relatedTables.length > 0 && (
                  <CommandGroup heading="연관 테이블 (FK 자동 설정)">
                    {relatedTables.map((table) => (
                      <CommandItem key={table.tableName} onSelect={() => handleTableSelect(table.tableName)}>
                        <Link2 className="mr-2 h-3 w-3 text-green-500" />
                        {table.tableName}
                        <span className="ml-auto text-xs text-muted-foreground">
                          FK: {table.foreignKeyColumn}
                        </span>
                      </CommandItem>
                    ))}
                  </CommandGroup>
                )}
                
                {/* 전체 */}
                <CommandGroup heading="전체 테이블">
                  {allTables.filter(t => 
                    t.tableName !== screenTableName && 
                    !relatedTables.some(r => r.tableName === t.tableName)
                  ).map((table) => (
                    <CommandItem key={table.tableName} onSelect={() => handleTableSelect(table.tableName)}>
                      {table.displayName || table.tableName}
                    </CommandItem>
                  ))}
                </CommandGroup>
              </CommandList>
            </Command>
          </PopoverContent>
        </Popover>
      </div>

      {/* 필수/숨김 설정 */}
      <div className="space-y-2">
        <Label className="text-xs font-medium">필드 옵션</Label>
        <div className="flex items-center space-x-2">
          <Checkbox
            checked={config.required || false}
            onCheckedChange={(checked) => handleChange("required", checked as boolean)}
          />
          <Label className="text-xs">필수 입력</Label>
        </div>
        <div className="flex items-center space-x-2">
          <Checkbox
            checked={config.hidden || false}
            onCheckedChange={(checked) => handleChange("hidden", checked as boolean)}
          />
          <Label className="text-xs">숨김</Label>
        </div>
      </div>
    </div>
  );
};
```

### 이벤트 패턴

#### 이벤트 구독 (소비자)

```typescript
useEffect(() => {
  const handleEvent = (event: CustomEvent) => { /* 처리 */ };

  // V2 이벤트 버스 (권장)
  const unsubscribe = v2EventBus.subscribe(V2_EVENTS.FORM.BEFORE_SAVE, handleEvent);

  // 레거시 지원
  window.addEventListener("beforeFormSave", handleEvent as any);

  // Cleanup 필수!
  return () => {
    unsubscribe();
    window.removeEventListener("beforeFormSave", handleEvent as any);
  };
}, [dependencies]);
```

#### 이벤트 발행 (생산자)

```typescript
// V2 이벤트 버스
v2EventBus.emit(V2_EVENTS.FORM.REPEATER_SAVE, {
  tableName: "my_table",
  masterRecordId: savedId,
});

// 레거시 이벤트
window.dispatchEvent(new CustomEvent("repeaterSave", {
  detail: { tableName: "my_table", masterRecordId: savedId }
}));
```

### 옵셔널 체이닝 필수 사용

```typescript
// 컨텍스트가 없을 수 있음
const flowContext = useFlowContext?.();
const currentStepId = flowContext?.currentStepId;

// 콜백이 없을 수 있음
onDataChange?.(newData);

// 중첩 속성 접근
const tableName = config?.dataSource?.tableName;
```

---

## 14. 성능 최적화

### useMemo로 계산 비용 줄이기

```typescript
// 설정 병합 (매 렌더링마다 새 객체 생성 방지)
const config = useMemo(() => ({
  ...DEFAULT_CONFIG,
  ...propConfig,
}), [propConfig]);

// 필터링된 데이터 (데이터나 필터가 변경될 때만 재계산)
const filteredData = useMemo(() => {
  return data.filter(item => item.status === selectedStatus);
}, [data, selectedStatus]);

// 컬럼 정의 (설정이 변경될 때만 재생성)
const columns = useMemo(() => {
  return config.columns?.map(col => ({
    ...col,
    render: (value: any) => formatValue(value, col.type),
  }));
}, [config.columns]);
```

### useCallback으로 핸들러 안정화

```typescript
// 자식 컴포넌트에 전달되는 콜백 안정화
const handleRowClick = useCallback((row: any) => {
  setSelectedRow(row);
  onRowClick?.(row);
}, [onRowClick]);

const handleChange = useCallback((value: any) => {
  onFormDataChange?.(fieldName, value);
}, [fieldName, onFormDataChange]);

// 의존성이 많은 핸들러는 useRef 활용
const dataRef = useRef(data);
dataRef.current = data;

const handleSave = useCallback(async () => {
  // dataRef.current로 최신 값 참조 (의존성 배열에 data 불필요)
  await saveData(dataRef.current);
}, []);
```

### useRef로 이벤트 핸들러 최적화

```typescript
// 이벤트 핸들러에서 최신 상태 참조
const [data, setData] = useState<any[]>([]);
const dataRef = useRef(data);

// 상태 변경 시 ref도 업데이트
useEffect(() => {
  dataRef.current = data;
}, [data]);

// 이벤트 핸들러에서 ref 사용 (의존성 배열 최소화)
useEffect(() => {
  const handleSaveEvent = (event: CustomEvent) => {
    // data 대신 dataRef.current 사용
    event.detail.formData[fieldName] = dataRef.current;
  };

  window.addEventListener("beforeFormSave", handleSaveEvent as any);
  return () => window.removeEventListener("beforeFormSave", handleSaveEvent as any);
}, [fieldName]); // data 의존성 제거 → 리스너 재등록 방지
```

### React.memo로 불필요한 리렌더링 방지

```typescript
// 자주 리렌더링되는 자식 컴포넌트
const TableRow = React.memo(({ row, onClick }: TableRowProps) => {
  return (
    <tr onClick={() => onClick(row)}>
      {/* 셀 렌더링 */}
    </tr>
  );
});

// 커스텀 비교 함수 (필요 시)
const TableRow = React.memo(
  ({ row, onClick }: TableRowProps) => { /* ... */ },
  (prevProps, nextProps) => prevProps.row.id === nextProps.row.id
);
```

### 대량 데이터 처리 패턴

```typescript
// 1. 페이지네이션 필수 (전체 데이터 로드 금지)
const fetchData = async (page: number, size: number) => {
  const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
    page,
    size,  // 기본값 50~100
  });
};

// 2. 가상화 (1000개 이상 행 표시 시)
import { useVirtualizer } from "@tanstack/react-virtual";

const rowVirtualizer = useVirtualizer({
  count: data.length,
  getScrollElement: () => containerRef.current,
  estimateSize: () => 40, // 행 높이
});

// 3. 디바운싱 (검색/필터 입력)
import { useDebouncedCallback } from "use-debounce";

const debouncedSearch = useDebouncedCallback((value: string) => {
  setSearchTerm(value);
  fetchData(1, pageSize);
}, 300);
```

### 렌더링 최적화 패턴

```typescript
// 조건부 렌더링 최적화
const MyComponent = ({ isVisible, data }) => {
  // 숨겨진 컴포넌트는 early return
  if (!isVisible) return null;

  // 데이터 없으면 빈 상태만 표시
  if (!data?.length) {
    return <EmptyState />;
  }

  return <DataTable data={data} />;
};

// 무거운 컴포넌트 지연 로드
const HeavyChart = React.lazy(() => import("./HeavyChart"));

const Dashboard = () => (
  <Suspense fallback={<Skeleton />}>
    <HeavyChart />
  </Suspense>
);
```

### 피해야 할 패턴

```typescript
// ❌ 렌더링마다 새 객체/배열 생성
<ChildComponent style={{ color: "red" }} />
<ChildComponent options={[1, 2, 3]} />

// ✅ useMemo 또는 상수로 분리
const style = useMemo(() => ({ color: "red" }), []);
const options = useMemo(() => [1, 2, 3], []);
<ChildComponent style={style} options={options} />

// ❌ 인라인 함수로 핸들러 전달
<ChildComponent onClick={() => handleClick(id)} />

// ✅ useCallback으로 안정화
const handleItemClick = useCallback(() => handleClick(id), [id]);
<ChildComponent onClick={handleItemClick} />

// ❌ 불필요한 상태 업데이트
useEffect(() => {
  setDerivedValue(data.map(x => x.value));  // 매번 새 배열 생성
}, [data]);

// ✅ useMemo로 파생 값 계산
const derivedValue = useMemo(() => data.map(x => x.value), [data]);
```

---

## 15. 체크리스트

### V2 컴포넌트 규칙

- [ ] V2 폴더(`v2-*/`)에서 작업
- [ ] 컴포넌트 ID에 `v2-` 접두사 사용
- [ ] Definition 이름에 `V2` 접두사 사용
- [ ] 원본 폴더 수정하지 않음

### V2 + Zod 레이아웃 시스템

- [ ] `componentConfig.ts`에 Zod 스키마 추가 (`.passthrough()` 필수)
- [ ] `componentOverridesSchemaRegistry`에 컴포넌트 등록
- [ ] `componentDefaultsRegistry`에 기본값 등록
- [ ] 테이블 컬럼 드래그 시 `tableName`, `columnName` 저장 확인
- [ ] `convertLegacyToV2`에서 상위 레벨 속성 포함 확인
- [ ] `convertV2ToLegacy`에서 상위 레벨 속성 복원 확인
- [ ] v2-select는 `source: "distinct"` 기본값 확인

### 표준 Props

- [ ] `component`, `isDesignMode` props 지원
- [ ] `formData`, `onFormDataChange` props 지원
- [ ] `companyCode` props 지원 (멀티테넌시)
- [ ] `refreshKey`, `onRefresh` props 지원

### 멀티테넌시

- [ ] 모든 데이터 조회 API에 `autoFilter` 적용
- [ ] `company_code` 필터링 확인
- [ ] 최고 관리자(`*`) 데이터 일반 사용자에게 노출 안함

### 디자인/인터랙티브 모드

- [ ] `isDesignMode` 체크하여 API 호출 스킵
- [ ] 디자인 모드에서 더미 UI 표시
- [ ] 디자인 모드에서 이벤트 핸들러 비활성화

### 로딩 및 에러 처리

- [ ] `loading` 상태 관리
- [ ] `error` 상태 관리
- [ ] 로딩 중 UI 표시 (Spinner/Skeleton)
- [ ] 에러 메시지 토스트 표시
- [ ] DB 에러 유형별 메시지 처리

### 입력 폼 필수 설정

- [ ] `required` 속성 지원
- [ ] `hidden` 속성 지원
- [ ] 필수 필드에 빨간색 `*` 표시
- [ ] 속성 패널에 "필수 입력", "숨김" 체크박스 제공
- [ ] 저장 시 필수 항목 검증 로직 구현

### 테이블 설정

- [ ] `useCustomTable`, `mainTableName` 설정 지원
- [ ] 연관 테이블 선택 시 FK/PK 자동 설정
- [ ] 테이블 선택 UI는 Combobox 그룹별 표시

### 엔티티 조인

- [ ] `entityJoinApi.getEntityJoinColumns()` 호출
- [ ] 설정 패널에 "엔티티 조인 컬럼" 섹션 추가
- [ ] 컬럼명 `tableName.columnName` 형식으로 저장

### 폼 데이터

- [ ] `useFormCompatibility` 훅 사용
- [ ] 리피터 컴포넌트는 `beforeFormSave` 이벤트 처리

### 다국어 지원

- [ ] 타입에 `langKeyId`, `langKey` 필드 추가
- [ ] `extractMultilangLabels` 함수에 추출 로직 추가
- [ ] `applyMultilangMappings` 함수에 매핑 로직 추가
- [ ] `collectLangKeys` 함수에 수집 로직 추가

### 저장 버튼 연동

- [ ] `beforeFormSave` 이벤트에서 데이터 제공
- [ ] 배열 데이터는 메타데이터(`_` 접두사) 제외
- [ ] 이벤트 리스너 cleanup 처리

### 플로우 연동

- [ ] `FlowContext` 있을 때 `selectedData`로 초기화
- [ ] 플로우 없이도 단독 작동 가능 (옵셔널 체이닝)

### 코드 스타일

- [ ] `v2-repeater` 구조 참고
- [ ] 느슨한 결합도 유지 (이벤트 기반 통신)

### 성능 최적화

- [ ] `useMemo`로 설정/데이터 병합
- [ ] `useCallback`으로 핸들러 안정화
- [ ] `useRef`로 이벤트 핸들러에서 최신 값 참조
- [ ] 렌더링마다 새 객체/배열 생성 방지
- [ ] 인라인 함수 콜백 방지 (자식 컴포넌트 리렌더링 유발)
- [ ] 대량 데이터는 페이지네이션 필수

---

## 관련 파일 목록

### 핵심 파일

| 파일 | 역할 |
|------|------|
| `components/v2/V2Input.tsx` | text, number 입력 |
| `components/v2/V2Select.tsx` | code, entity 선택 |
| `components/v2/V2Date.tsx` | date, datetime 선택 |
| `lib/registry/components/v2-*/` | V2 컴포넌트 폴더 |
| `lib/api/entityJoin.ts` | 엔티티 조인 API |
| `hooks/useFormCompatibility.ts` | 폼 호환성 브릿지 |
| `lib/utils/multilangLabelExtractor.ts` | 다국어 라벨 추출/매핑 |
| `lib/utils/buttonActions.ts` | 저장 버튼 액션 처리 |
| `contexts/FlowContext.tsx` | 플로우 컨텍스트 |

### 참고 컴포넌트

| 컴포넌트 | 경로 | 참고 사항 |
|----------|------|-----------|
| `v2-repeater` | `lib/registry/components/v2-repeater/` | **표준 참조 컴포넌트** |
| `v2-table-list` | `lib/registry/components/v2-table-list/` | 조회 컴포넌트 참조 |
| `v2-table-search-widget` | `lib/registry/components/v2-table-search-widget/` | 검색 필터 참조 |
