906 lines
26 KiB
Markdown
906 lines
26 KiB
Markdown
|
|
# 카테고리 메뉴별 컬럼 분리 전략
|
||
|
|
|
||
|
|
## 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 (
|
||
|
|
<Dialog>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>카테고리 컬럼 추가</DialogTitle>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* 실제 DB 컬럼 선택 */}
|
||
|
|
<div>
|
||
|
|
<Label>실제 컬럼 (물리적)</Label>
|
||
|
|
<Select value={physicalColumnName} onValueChange={setPhysicalColumnName}>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="컬럼 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="status">status</SelectItem>
|
||
|
|
<SelectItem value="category_type">category_type</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 논리적 컬럼명 입력 */}
|
||
|
|
<div>
|
||
|
|
<Label>논리적 컬럼명 (메뉴별 식별용)</Label>
|
||
|
|
<Input
|
||
|
|
value={logicalColumnName}
|
||
|
|
onChange={(e) => setLogicalColumnName(e.target.value)}
|
||
|
|
placeholder="예: status_stock, status_sales"
|
||
|
|
/>
|
||
|
|
<p className="text-xs text-muted-foreground mt-1">
|
||
|
|
이 메뉴에서만 사용할 고유한 이름을 입력하세요
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 적용할 메뉴 표시 (읽기 전용) */}
|
||
|
|
<div>
|
||
|
|
<Label>적용 메뉴</Label>
|
||
|
|
<Input value={currentMenuName} disabled />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button onClick={handleSave}>저장</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**데이터 저장 시 매핑 적용**:
|
||
|
|
```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<string, string> = {};
|
||
|
|
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<string, string> = {};
|
||
|
|
mappingResult.rows.forEach((row) => {
|
||
|
|
mapping[row.logical_column_name] = row.physical_column_name;
|
||
|
|
});
|
||
|
|
|
||
|
|
// 2. 논리적 컬럼명 → 물리적 컬럼명 변환
|
||
|
|
const physicalData: Record<string, any> = {};
|
||
|
|
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<string[]>([]);
|
||
|
|
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 (
|
||
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button variant="outline" size="sm">
|
||
|
|
카테고리 컬럼 추가
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
|
||
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle className="text-base sm:text-lg">
|
||
|
|
카테고리 컬럼 추가
|
||
|
|
</DialogTitle>
|
||
|
|
<DialogDescription className="text-xs sm:text-sm">
|
||
|
|
같은 물리적 컬럼을 여러 메뉴에서 다른 카테고리로 사용할 수 있습니다
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="space-y-3 sm:space-y-4">
|
||
|
|
{/* 적용 메뉴 (읽기 전용) */}
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs sm:text-sm">적용 메뉴</Label>
|
||
|
|
<Input
|
||
|
|
value={menuName}
|
||
|
|
disabled
|
||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 실제 컬럼 선택 */}
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs sm:text-sm">
|
||
|
|
실제 컬럼 (물리적) *
|
||
|
|
</Label>
|
||
|
|
<Select value={physicalColumnName} onValueChange={setPhysicalColumnName}>
|
||
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||
|
|
<SelectValue placeholder="컬럼 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{physicalColumns.map((col) => (
|
||
|
|
<SelectItem key={col} value={col} className="text-xs sm:text-sm">
|
||
|
|
{col}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||
|
|
테이블의 실제 컬럼명
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 논리적 컬럼명 입력 */}
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs sm:text-sm">
|
||
|
|
논리적 컬럼명 (메뉴별 식별용) *
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
value={logicalColumnName}
|
||
|
|
onChange={(e) => setLogicalColumnName(e.target.value)}
|
||
|
|
placeholder="예: status_stock, status_sales"
|
||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||
|
|
이 메뉴에서만 사용할 고유한 이름을 입력하세요
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 설명 (선택사항) */}
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs sm:text-sm">설명</Label>
|
||
|
|
<Textarea
|
||
|
|
value={description}
|
||
|
|
onChange={(e) => setDescription(e.target.value)}
|
||
|
|
placeholder="이 컬럼의 용도를 설명하세요 (선택사항)"
|
||
|
|
className="text-xs sm:text-sm"
|
||
|
|
rows={2}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => setOpen(false)}
|
||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
onClick={handleSave}
|
||
|
|
disabled={!logicalColumnName || !physicalColumnName}
|
||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
추가
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.2 카테고리 값 추가 시 논리적 컬럼 사용
|
||
|
|
|
||
|
|
**파일**: `frontend/components/admin/table-type-management/CategoryValueEditor.tsx`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export function CategoryValueEditor({
|
||
|
|
tableName,
|
||
|
|
menuObjid,
|
||
|
|
onSuccess,
|
||
|
|
}: Props) {
|
||
|
|
const [logicalColumns, setLogicalColumns] = useState<Array<{
|
||
|
|
logical_column_name: string;
|
||
|
|
physical_column_name: string;
|
||
|
|
description: string;
|
||
|
|
}>>([]);
|
||
|
|
const [selectedLogicalColumn, setSelectedLogicalColumn] = useState("");
|
||
|
|
const [valueCode, setValueCode] = useState("");
|
||
|
|
const [valueLabel, setValueLabel] = useState("");
|
||
|
|
|
||
|
|
// 논리적 컬럼 목록 조회
|
||
|
|
useEffect(() => {
|
||
|
|
async function loadLogicalColumns() {
|
||
|
|
const response = await apiClient.get(
|
||
|
|
`/api/categories/logical-columns/${tableName}/${menuObjid}`
|
||
|
|
);
|
||
|
|
if (response.data.success) {
|
||
|
|
setLogicalColumns(response.data.data);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
loadLogicalColumns();
|
||
|
|
}, [tableName, menuObjid]);
|
||
|
|
|
||
|
|
const handleSave = async () => {
|
||
|
|
await apiClient.post("/api/categories/values", {
|
||
|
|
tableName,
|
||
|
|
columnName: selectedLogicalColumn, // 논리적 컬럼명 저장
|
||
|
|
valueCode,
|
||
|
|
valueLabel,
|
||
|
|
menuObjid,
|
||
|
|
});
|
||
|
|
|
||
|
|
toast.success("카테고리 값이 추가되었습니다");
|
||
|
|
onSuccess();
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* 논리적 컬럼 선택 */}
|
||
|
|
<div>
|
||
|
|
<Label>컬럼 선택</Label>
|
||
|
|
<Select value={selectedLogicalColumn} onValueChange={setSelectedLogicalColumn}>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="카테고리 컬럼 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{logicalColumns.map((col) => (
|
||
|
|
<SelectItem key={col.logical_column_name} value={col.logical_column_name}>
|
||
|
|
<div className="flex flex-col">
|
||
|
|
<span className="font-medium">{col.logical_column_name}</span>
|
||
|
|
<span className="text-xs text-gray-500">
|
||
|
|
실제 컬럼: {col.physical_column_name}
|
||
|
|
</span>
|
||
|
|
{col.description && (
|
||
|
|
<span className="text-xs text-gray-400">{col.description}</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 카테고리 값 입력 */}
|
||
|
|
<div>
|
||
|
|
<Label>코드</Label>
|
||
|
|
<Input value={valueCode} onChange={(e) => setValueCode(e.target.value)} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label>라벨</Label>
|
||
|
|
<Input value={valueLabel} onChange={(e) => setValueLabel(e.target.value)} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Button onClick={handleSave}>저장</Button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.3 데이터 저장 시 매핑 적용
|
||
|
|
|
||
|
|
**파일**: `frontend/components/screen/InteractiveScreenViewer.tsx`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async function saveFormData(formData: Record<string, any>) {
|
||
|
|
const menuObjid = screenConfig.menuObjid;
|
||
|
|
const tableName = screenConfig.tableName;
|
||
|
|
|
||
|
|
// 백엔드에서 자동으로 논리명 → 물리명 변환
|
||
|
|
const response = await apiClient.post(`/api/data/${tableName}`, {
|
||
|
|
menuObjid,
|
||
|
|
data: formData, // 논리적 컬럼명으로 전달
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.data.success) {
|
||
|
|
toast.success("저장되었습니다");
|
||
|
|
} else {
|
||
|
|
toast.error("저장 실패");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. 사용 예시
|
||
|
|
|
||
|
|
### 예시 1: 품목 상태 컬럼 분리
|
||
|
|
|
||
|
|
**상황**: `item_info.status` 컬럼을 두 메뉴에서 다르게 사용
|
||
|
|
|
||
|
|
#### 1단계: 논리적 컬럼 생성
|
||
|
|
|
||
|
|
```
|
||
|
|
기준정보 > 품목정보 (menu_objid=103)
|
||
|
|
- 논리적 컬럼명: status_stock
|
||
|
|
- 물리적 컬럼명: status
|
||
|
|
- 카테고리 값: 정상, 대기, 품절
|
||
|
|
|
||
|
|
영업관리 > 판매품목정보 (menu_objid=203)
|
||
|
|
- 논리적 컬럼명: status_sales
|
||
|
|
- 물리적 컬럼명: status
|
||
|
|
- 카테고리 값: 판매중, 판매중지, 품절
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 2단계: 데이터 입력
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 기준정보 > 품목정보에서 입력
|
||
|
|
{
|
||
|
|
item_name: "키보드",
|
||
|
|
status_stock: "정상", // 논리적 컬럼명
|
||
|
|
}
|
||
|
|
|
||
|
|
// DB에 저장될 때
|
||
|
|
{
|
||
|
|
item_name: "키보드",
|
||
|
|
status: "정상", // 물리적 컬럼명으로 자동 변환
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 영업관리 > 판매품목정보에서 입력
|
||
|
|
{
|
||
|
|
item_name: "마우스",
|
||
|
|
status_sales: "판매중", // 논리적 컬럼명
|
||
|
|
}
|
||
|
|
|
||
|
|
// DB에 저장될 때
|
||
|
|
{
|
||
|
|
item_name: "마우스",
|
||
|
|
status: "판매중", // 물리적 컬럼명으로 자동 변환
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3단계: 데이터 조회
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 기준정보 > 품목정보에서 조회
|
||
|
|
SELECT
|
||
|
|
item_id,
|
||
|
|
item_name,
|
||
|
|
status -- 물리적 컬럼
|
||
|
|
FROM item_info
|
||
|
|
WHERE company_code = 'COMPANY_A';
|
||
|
|
|
||
|
|
// 프론트엔드에서 표시할 때 논리적 컬럼명으로 매핑
|
||
|
|
{
|
||
|
|
item_name: "키보드",
|
||
|
|
status_stock: "정상", // UI에서는 논리명 사용
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. 체크리스트
|
||
|
|
|
||
|
|
### 개발 단계
|
||
|
|
- [ ] `category_column_mapping` 테이블 생성
|
||
|
|
- [ ] 백엔드: 컬럼 매핑 조회 API
|
||
|
|
- [ ] 백엔드: 컬럼 매핑 생성 API
|
||
|
|
- [ ] 백엔드: 데이터 저장 시 논리명 → 물리명 변환 로직
|
||
|
|
- [ ] 프론트엔드: 논리적 컬럼 추가 UI
|
||
|
|
- [ ] 프론트엔드: 카테고리 값 추가 시 논리적 컬럼 선택
|
||
|
|
- [ ] 프론트엔드: 데이터 저장 시 논리적 컬럼명 사용
|
||
|
|
|
||
|
|
### 테스트 단계
|
||
|
|
- [ ] 같은 물리적 컬럼에 여러 논리적 컬럼 생성
|
||
|
|
- [ ] 메뉴별로 다른 카테고리 값 표시
|
||
|
|
- [ ] 데이터 저장 시 올바른 물리적 컬럼에 저장
|
||
|
|
- [ ] 데이터 조회 시 논리적 컬럼명으로 매핑
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. 장점 요약
|
||
|
|
|
||
|
|
### 데이터베이스
|
||
|
|
- ✅ 스키마 변경 최소화
|
||
|
|
- ✅ 기존 데이터 마이그레이션 불필요
|
||
|
|
- ✅ 유연한 컬럼 관리
|
||
|
|
|
||
|
|
### UI/UX
|
||
|
|
- ✅ 메뉴별 맞춤형 카테고리 관리
|
||
|
|
- ✅ 직관적인 논리적 컬럼명 사용
|
||
|
|
- ✅ 관리자가 쉽게 설정 가능
|
||
|
|
|
||
|
|
### 개발
|
||
|
|
- ✅ 백엔드에서 자동 매핑 처리
|
||
|
|
- ✅ 프론트엔드는 논리적 컬럼명만 사용
|
||
|
|
- ✅ 확장 가능한 아키텍처
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. 결론
|
||
|
|
|
||
|
|
**같은 물리적 컬럼을 메뉴별로 다른 카테고리로 사용하려면 "가상 컬럼 분리 (Virtual Column Mapping)" 방식이 최적입니다.**
|
||
|
|
|
||
|
|
- 논리적 컬럼명으로 메뉴별 카테고리 독립성 확보
|
||
|
|
- 물리적 컬럼명으로 실제 데이터 저장
|
||
|
|
- 매핑 테이블로 유연한 관리
|
||
|
|
|
||
|
|
이 방식은 데이터베이스 변경을 최소화하면서도 메뉴별로 완전히 독립적인 카테고리 관리를 가능하게 합니다.
|
||
|
|
|