ERP-node/docs/카테고리_메뉴별_컬럼_분리_전략.md

26 KiB

카테고리 메뉴별 컬럼 분리 전략

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에서 가상 컬럼 개념 이해 필요

구현 방식

데이터베이스:

-- 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);

컬럼 매핑 테이블 (새로 생성):

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:

// 테이블 타입 관리에서 카테고리 컬럼 추가 시
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>
  );
}

데이터 저장 시 매핑 적용:

// 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 실행 필요
  • 유연성 부족

구현 방식

데이터베이스 스키마 변경:

-- 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 컬럼 매핑 테이블 생성

-- 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 기존 카테고리 컬럼 마이그레이션 (선택사항)

-- 기존에 직접 물리적 컬럼명을 사용하던 경우 매핑 생성
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

/**
 * 메뉴별 컬럼 매핑 조회
 * 
 * @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],
  });
}

라우트 등록:

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

/**
 * 데이터 저장 시 논리적 컬럼명 → 물리적 컬럼명 변환
 */
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

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

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

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단계: 데이터 입력

// 기준정보 > 품목정보에서 입력
{
  item_name: "키보드",
  status_stock: "정상", // 논리적 컬럼명
}

// DB에 저장될 때
{
  item_name: "키보드",
  status: "정상", // 물리적 컬럼명으로 자동 변환
}
// 영업관리 > 판매품목정보에서 입력
{
  item_name: "마우스",
  status_sales: "판매중", // 논리적 컬럼명
}

// DB에 저장될 때
{
  item_name: "마우스",
  status: "판매중", // 물리적 컬럼명으로 자동 변환
}

3단계: 데이터 조회

// 기준정보 > 품목정보에서 조회
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)" 방식이 최적입니다.

  • 논리적 컬럼명으로 메뉴별 카테고리 독립성 확보
  • 물리적 컬럼명으로 실제 데이터 저장
  • 매핑 테이블로 유연한 관리

이 방식은 데이터베이스 변경을 최소화하면서도 메뉴별로 완전히 독립적인 카테고리 관리를 가능하게 합니다.