# 카테고리 메뉴별 컬럼 분리 전략 ## 1. 문제 정의 ### 상황 같은 테이블(`item_info`)의 같은 컬럼(`status`)을 서로 다른 메뉴에서 다른 카테고리 값으로 사용하고 싶은 경우 **예시**: ``` 기준정보 > 품목정보 (menu_objid=103) - status 컬럼: "정상", "대기", "품절" 영업관리 > 판매품목정보 (menu_objid=203) - status 컬럼: "판매중", "판매중지", "품절" ``` ### 현재 문제점 - `table_column_category_values` 테이블 구조: - `table_name` + `column_name` + `menu_objid` 조합으로 카테고리 값 저장 - 같은 테이블, 같은 컬럼, 다른 메뉴 = 서로 다른 카테고리 값 사용 가능 - **하지만 실제 DB 컬럼은 하나뿐!** --- ## 2. 해결 방안 비교 ### 방안 A: 가상 컬럼 분리 (Virtual Column Mapping) ⭐ **추천** **개념**: 물리적으로는 같은 `status` 컬럼이지만, 메뉴별로 **논리적으로 다른 컬럼명**을 사용 #### 장점 - ✅ 데이터베이스 스키마 변경 불필요 - ✅ 기존 데이터 마이그레이션 불필요 - ✅ 메뉴별 완전히 독립적인 카테고리 관리 - ✅ 유연한 확장 가능 #### 단점 - ⚠️ 컬럼 매핑 관리 필요 (논리명 → 물리명) - ⚠️ UI에서 가상 컬럼 개념 이해 필요 #### 구현 방식 **데이터베이스**: ```sql -- table_column_category_values 테이블 사용 -- column_name을 "논리적 컬럼명"으로 저장 -- 기준정보 > 품목정보 INSERT INTO table_column_category_values (table_name, column_name, value_code, value_label, menu_objid) VALUES ('item_info', 'status_stock', 'NORMAL', '정상', 103), ('item_info', 'status_stock', 'PENDING', '대기', 103), ('item_info', 'status_stock', 'OUT_OF_STOCK', '품절', 103); -- 영업관리 > 판매품목정보 INSERT INTO table_column_category_values (table_name, column_name, value_code, value_label, menu_objid) VALUES ('item_info', 'status_sales', 'ON_SALE', '판매중', 203), ('item_info', 'status_sales', 'DISCONTINUED', '판매중지', 203), ('item_info', 'status_sales', 'OUT_OF_STOCK', '품절', 203); ``` **컬럼 매핑 테이블** (새로 생성): ```sql CREATE TABLE category_column_mapping ( mapping_id SERIAL PRIMARY KEY, table_name VARCHAR(100) NOT NULL, logical_column_name VARCHAR(100) NOT NULL, -- status_stock, status_sales physical_column_name VARCHAR(100) NOT NULL, -- status (실제 DB 컬럼) menu_objid NUMERIC NOT NULL, company_code VARCHAR(20) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(table_name, logical_column_name, menu_objid, company_code) ); -- 예시 데이터 INSERT INTO category_column_mapping (table_name, logical_column_name, physical_column_name, menu_objid, company_code) VALUES ('item_info', 'status_stock', 'status', 103, 'COMPANY_A'), ('item_info', 'status_sales', 'status', 203, 'COMPANY_A'); ``` **프론트엔드 UI**: ```typescript // 테이블 타입 관리에서 카테고리 컬럼 추가 시 function AddCategoryColumn({ tableName, menuObjid }: Props) { const [logicalColumnName, setLogicalColumnName] = useState(""); const [physicalColumnName, setPhysicalColumnName] = useState(""); return ( 카테고리 컬럼 추가
{/* 실제 DB 컬럼 선택 */}
{/* 논리적 컬럼명 입력 */}
setLogicalColumnName(e.target.value)} placeholder="예: status_stock, status_sales" />

이 메뉴에서만 사용할 고유한 이름을 입력하세요

{/* 적용할 메뉴 표시 (읽기 전용) */}
); } ``` **데이터 저장 시 매핑 적용**: ```typescript // InteractiveScreenViewer.tsx async function saveData(formData: any) { const companyCode = user.companyCode; const menuObjid = screenConfig.menuObjid; // 논리적 컬럼명 → 물리적 컬럼명 매핑 const mappingResponse = await apiClient.get( `/api/categories/column-mapping/${tableName}/${menuObjid}` ); const columnMapping = mappingResponse.data.data; // { status_sales: "status" } // formData를 물리적 컬럼명으로 변환 const physicalData = {}; for (const [logicalCol, value] of Object.entries(formData)) { const physicalCol = columnMapping[logicalCol] || logicalCol; physicalData[physicalCol] = value; } // 실제 DB 저장 await apiClient.post(`/api/data/${tableName}`, physicalData); } ``` --- ### 방안 B: 물리적 컬럼 분리 (Physical Column Separation) **개념**: 실제로 테이블에 `status_stock`, `status_sales` 같은 별도 컬럼 생성 #### 장점 - ✅ 단순하고 직관적 - ✅ 매핑 로직 불필요 #### 단점 - ❌ 데이터베이스 스키마 변경 필요 - ❌ 기존 데이터 마이그레이션 필요 - ❌ 컬럼 추가마다 DDL 실행 필요 - ❌ 유연성 부족 #### 구현 방식 **데이터베이스 스키마 변경**: ```sql -- item_info 테이블에 컬럼 추가 ALTER TABLE item_info ADD COLUMN status_stock VARCHAR(50), ADD COLUMN status_sales VARCHAR(50); -- 기존 데이터 마이그레이션 UPDATE item_info SET status_stock = status, -- 기본값으로 복사 status_sales = status; ``` **단점이 명확함**: - 메뉴가 추가될 때마다 컬럼 추가 필요 - 테이블 구조가 복잡해짐 - 유지보수 어려움 --- ### 방안 C: 현재 구조 유지 (Same Column, Different Values) **개념**: 같은 `status` 컬럼을 사용하되, 메뉴별로 다른 카테고리 값만 표시 #### 장점 - ✅ 가장 단순한 구조 - ✅ 추가 개발 불필요 #### 단점 - ❌ **데이터 정합성 문제**: 실제 DB에는 하나의 값만 저장 가능 - ❌ 메뉴별로 다른 값을 저장할 수 없음 #### 예시 (문제 발생) ``` item_info 테이블의 실제 데이터: item_id | status --------|-------- 1 | "NORMAL" (기준정보에서 입력) 2 | "ON_SALE" (영업관리에서 입력) → 기준정보에서 item_id=2를 볼 때 "ON_SALE"이 뭔지 모름 (정의되지 않은 값) ``` **결론**: 이 방안은 **불가능**합니다. --- ## 3. 최종 추천 방안 ### 🏆 방안 A: 가상 컬럼 분리 (Virtual Column Mapping) **이유**: 1. 데이터베이스 스키마 변경 없음 2. 메뉴별 완전히 독립적인 카테고리 관리 3. 실제 데이터 저장 시 물리적 컬럼으로 자동 매핑 4. 확장성과 유연성 확보 **핵심 개념**: - **논리적 컬럼명**: UI와 카테고리 설정에서 사용 (`status_stock`, `status_sales`) - **물리적 컬럼명**: 실제 DB 저장 시 사용 (`status`) - **매핑 테이블**: 논리명과 물리명을 연결 --- ## 4. 구현 계획 ### Phase 1: 데이터베이스 스키마 추가 #### 4.1 컬럼 매핑 테이블 생성 ```sql -- db/migrations/054_create_category_column_mapping.sql CREATE TABLE category_column_mapping ( mapping_id SERIAL PRIMARY KEY, table_name VARCHAR(100) NOT NULL, logical_column_name VARCHAR(100) NOT NULL COMMENT '논리적 컬럼명 (UI에서 사용)', physical_column_name VARCHAR(100) NOT NULL COMMENT '물리적 컬럼명 (실제 DB 컬럼)', menu_objid NUMERIC NOT NULL, company_code VARCHAR(20) NOT NULL, description TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), created_by VARCHAR(50), updated_by VARCHAR(50), CONSTRAINT fk_mapping_company FOREIGN KEY (company_code) REFERENCES company_info(company_code), CONSTRAINT fk_mapping_menu FOREIGN KEY (menu_objid) REFERENCES menu_info(objid), CONSTRAINT uk_mapping UNIQUE(table_name, logical_column_name, menu_objid, company_code) ); CREATE INDEX idx_mapping_table_menu ON category_column_mapping(table_name, menu_objid); CREATE INDEX idx_mapping_company ON category_column_mapping(company_code); COMMENT ON TABLE category_column_mapping IS '카테고리 컬럼의 논리명-물리명 매핑'; COMMENT ON COLUMN category_column_mapping.logical_column_name IS '메뉴별 카테고리 컬럼의 논리적 이름 (예: status_stock)'; COMMENT ON COLUMN category_column_mapping.physical_column_name IS '실제 테이블의 물리적 컬럼 이름 (예: status)'; ``` #### 4.2 기존 카테고리 컬럼 마이그레이션 (선택사항) ```sql -- 기존에 직접 물리적 컬럼명을 사용하던 경우 매핑 생성 INSERT INTO category_column_mapping (table_name, logical_column_name, physical_column_name, menu_objid, company_code) SELECT DISTINCT table_name, column_name, -- 기존에는 논리명=물리명 column_name, menu_objid, company_code FROM table_column_category_values WHERE menu_objid IS NOT NULL ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) DO NOTHING; ``` ### Phase 2: 백엔드 API 구현 #### 2.1 컬럼 매핑 API **파일**: `backend-node/src/controllers/categoryController.ts` ```typescript /** * 메뉴별 컬럼 매핑 조회 * * @param tableName - 테이블명 * @param menuObjid - 메뉴 OBJID * @returns { logical_column: physical_column } 매핑 */ export async function getColumnMapping(req: Request, res: Response) { const { tableName, menuObjid } = req.params; const companyCode = req.user!.companyCode; const query = ` SELECT logical_column_name, physical_column_name, description FROM category_column_mapping WHERE table_name = $1 AND menu_objid = $2 AND company_code = $3 `; const result = await pool.query(query, [tableName, menuObjid, companyCode]); // { status_stock: "status", status_sales: "status" } 형태로 변환 const mapping: Record = {}; result.rows.forEach((row) => { mapping[row.logical_column_name] = row.physical_column_name; }); logger.info("컬럼 매핑 조회", { tableName, menuObjid, companyCode, mappingCount: Object.keys(mapping).length, }); return res.json({ success: true, data: mapping, }); } /** * 컬럼 매핑 생성 */ export async function createColumnMapping(req: Request, res: Response) { const companyCode = req.user!.companyCode; const { tableName, logicalColumnName, physicalColumnName, menuObjid, description, } = req.body; // 입력 검증 if (!tableName || !logicalColumnName || !physicalColumnName || !menuObjid) { return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다", }); } // 물리적 컬럼이 실제로 존재하는지 확인 const columnCheckQuery = ` SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2 `; const columnCheck = await pool.query(columnCheckQuery, [tableName, physicalColumnName]); if (columnCheck.rowCount === 0) { return res.status(400).json({ success: false, message: `테이블 ${tableName}에 컬럼 ${physicalColumnName}이(가) 존재하지 않습니다`, }); } // 매핑 저장 const query = ` INSERT INTO category_column_mapping ( table_name, logical_column_name, physical_column_name, menu_objid, company_code, description, created_by, updated_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) DO UPDATE SET physical_column_name = EXCLUDED.physical_column_name, description = EXCLUDED.description, updated_at = NOW(), updated_by = EXCLUDED.updated_by RETURNING * `; const result = await pool.query(query, [ tableName, logicalColumnName, physicalColumnName, menuObjid, companyCode, description || null, req.user!.userId, req.user!.userId, ]); logger.info("컬럼 매핑 생성", { tableName, logicalColumnName, physicalColumnName, menuObjid, companyCode, }); return res.json({ success: true, data: result.rows[0], }); } ``` **라우트 등록**: ```typescript router.get("/api/categories/column-mapping/:tableName/:menuObjid", authenticate, getColumnMapping); router.post("/api/categories/column-mapping", authenticate, createColumnMapping); ``` #### 2.2 데이터 저장 시 매핑 적용 **파일**: `backend-node/src/controllers/dataController.ts` ```typescript /** * 데이터 저장 시 논리적 컬럼명 → 물리적 컬럼명 변환 */ export async function saveData(req: Request, res: Response) { const { tableName } = req.params; const { menuObjid, data } = req.body; const companyCode = req.user!.companyCode; // 1. 컬럼 매핑 조회 const mappingQuery = ` SELECT logical_column_name, physical_column_name FROM category_column_mapping WHERE table_name = $1 AND menu_objid = $2 AND company_code = $3 `; const mappingResult = await pool.query(mappingQuery, [tableName, menuObjid, companyCode]); const mapping: Record = {}; mappingResult.rows.forEach((row) => { mapping[row.logical_column_name] = row.physical_column_name; }); // 2. 논리적 컬럼명 → 물리적 컬럼명 변환 const physicalData: Record = {}; for (const [key, value] of Object.entries(data)) { const physicalColumn = mapping[key] || key; // 매핑 없으면 원래 이름 사용 physicalData[physicalColumn] = value; } // 3. 실제 데이터 저장 const columns = Object.keys(physicalData); const values = Object.values(physicalData); const placeholders = columns.map((_, i) => `$${i + 1}`).join(", "); const insertQuery = ` INSERT INTO ${tableName} (${columns.join(", ")}, company_code) VALUES (${placeholders}, $${columns.length + 1}) RETURNING * `; const result = await pool.query(insertQuery, [...values, companyCode]); logger.info("데이터 저장 (컬럼 매핑 적용)", { tableName, menuObjid, logicalColumns: Object.keys(data), physicalColumns: columns, }); return res.json({ success: true, data: result.rows[0], }); } ``` ### Phase 3: 프론트엔드 UI 구현 #### 3.1 테이블 타입 관리: 논리적 컬럼 추가 **파일**: `frontend/components/admin/table-type-management/AddCategoryColumnDialog.tsx` ```typescript interface AddCategoryColumnDialogProps { tableName: string; menuObjid: number; menuName: string; onSuccess: () => void; } export function AddCategoryColumnDialog({ tableName, menuObjid, menuName, onSuccess, }: AddCategoryColumnDialogProps) { const [open, setOpen] = useState(false); const [physicalColumns, setPhysicalColumns] = useState([]); const [logicalColumnName, setLogicalColumnName] = useState(""); const [physicalColumnName, setPhysicalColumnName] = useState(""); const [description, setDescription] = useState(""); // 테이블의 실제 컬럼 목록 조회 useEffect(() => { if (open) { async function loadColumns() { const response = await apiClient.get(`/api/tables/${tableName}/columns`); if (response.data.success) { setPhysicalColumns(response.data.data.map((col: any) => col.column_name)); } } loadColumns(); } }, [open, tableName]); const handleSave = async () => { // 1. 컬럼 매핑 생성 const mappingResponse = await apiClient.post("/api/categories/column-mapping", { tableName, logicalColumnName, physicalColumnName, menuObjid, description, }); if (!mappingResponse.data.success) { toast.error("컬럼 매핑 생성 실패"); return; } toast.success("논리적 컬럼이 추가되었습니다"); setOpen(false); onSuccess(); }; return ( 카테고리 컬럼 추가 같은 물리적 컬럼을 여러 메뉴에서 다른 카테고리로 사용할 수 있습니다
{/* 적용 메뉴 (읽기 전용) */}
{/* 실제 컬럼 선택 */}

테이블의 실제 컬럼명

{/* 논리적 컬럼명 입력 */}
setLogicalColumnName(e.target.value)} placeholder="예: status_stock, status_sales" className="h-8 text-xs sm:h-10 sm:text-sm" />

이 메뉴에서만 사용할 고유한 이름을 입력하세요

{/* 설명 (선택사항) */}