--- 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 = {}; 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> = { "v2-select": v2SelectOverridesSchema, "v2-input": v2InputOverridesSchema, "v2-table-list": v2TableListOverridesSchema, // ... }; // 기본값 레지스트리 export const componentDefaultsRegistry: Record = { "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 ( ); ``` ### 관련 파일 | 파일 | 역할 | |------|------| | `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; // 현재 폼 데이터 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 = ({ 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 = ({ isDesignMode = false }) => { const [data, setData] = useState([]); const [loading, setLoading] = useState(false); // 1. 디자인 모드에서 API 호출 스킵 useEffect(() => { if (isDesignMode) return; fetchData(); }, [isDesignMode]); // 2. 디자인 모드에서 더미 데이터 표시 if (isDesignMode) { return (

테이블 리스트 (3개 컬럼)

); } // 3. 인터랙티브 모드에서 실제 데이터 표시 return ( {data.map((row) => ( ... ))}
); }; ``` ### 이벤트 핸들러 비활성화 ```typescript const handleClick = useCallback(() => { if (isDesignMode) return; // 디자인 모드에서 클릭 무시 // 실제 액션 수행 executeAction(); }, [isDesignMode]); ``` --- ## 6. 로딩 및 에러 처리 ### 로딩 상태 관리 ```typescript const [loading, setLoading] = useState(false); const [error, setError] = useState(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 (
로딩 중...
); } if (error) { return (
{error}
); } ``` ### 토스트 알림 패턴 ```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, 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 {/* 그룹 1: 기본 (화면 테이블) */} {screenTableName} {/* 그룹 2: 연관 테이블 (FK 자동 설정) */} {relatedTables.map((table) => ( {table.tableName} FK: {table.foreignKeyColumn} ))} {/* 그룹 3: 전체 테이블 */} {allTables.map((table) => ( {table.displayName || table.tableName} ))} ``` ### 연관 테이블 선택 시 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

{displayTitle}

; }; ``` --- ## 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 = {}; 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 = ({ config: propConfig, parentId, onDataChange, }) => { // 1. 설정 병합 (useMemo) const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...propConfig, }), [propConfig]); // 2. 상태 정의 const [data, setData] = useState([]); 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 = {}; 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 (
{/* 컴포넌트 내용 */}
); }; ``` ### 설정 패널 표준 구조 ```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 = ({ config: propConfig, onChange, screenTableName, }) => { // 1. config 안전하게 초기화 (useMemo) const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...propConfig, }), [propConfig]); // 2. 상태 정의 const [allTables, setAllTables] = useState([]); const [relatedTables, setRelatedTables] = useState([]); 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(( 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 (
{/* 테이블 선택 (그룹별 Combobox) */}
{/* 기본 */} {screenTableName && ( handleTableSelect(screenTableName)}> {screenTableName} )} {/* 연관 테이블 */} {relatedTables.length > 0 && ( {relatedTables.map((table) => ( handleTableSelect(table.tableName)}> {table.tableName} FK: {table.foreignKeyColumn} ))} )} {/* 전체 */} {allTables.filter(t => t.tableName !== screenTableName && !relatedTables.some(r => r.tableName === t.tableName) ).map((table) => ( handleTableSelect(table.tableName)}> {table.displayName || table.tableName} ))}
{/* 필수/숨김 설정 */}
handleChange("required", checked as boolean)} />
handleChange("hidden", checked as boolean)} />
); }; ``` ### 이벤트 패턴 #### 이벤트 구독 (소비자) ```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([]); 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 ( onClick(row)}> {/* 셀 렌더링 */} ); }); // 커스텀 비교 함수 (필요 시) 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 ; } return ; }; // 무거운 컴포넌트 지연 로드 const HeavyChart = React.lazy(() => import("./HeavyChart")); const Dashboard = () => ( }> ); ``` ### 피해야 할 패턴 ```typescript // ❌ 렌더링마다 새 객체/배열 생성 // ✅ useMemo 또는 상수로 분리 const style = useMemo(() => ({ color: "red" }), []); const options = useMemo(() => [1, 2, 3], []); // ❌ 인라인 함수로 핸들러 전달 handleClick(id)} /> // ✅ useCallback으로 안정화 const handleItemClick = useCallback(() => handleClick(id), [id]); // ❌ 불필요한 상태 업데이트 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/` | 검색 필터 참조 |