ERP-node/.cursor/rules/component-development-guide...

1789 lines
53 KiB
Plaintext

---
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/` | 검색 필터 참조 |