ERP-node/채번규칙_테이블기반_필터링_구현_계획서.md

30 KiB

채번규칙 테이블 기반 필터링 구현 계획서

📋 프로젝트 개요

목적

현재 메뉴 기반 채번규칙 필터링 방식을 테이블 기반 필터링으로 전환하여 더 직관적이고 유지보수하기 쉬운 시스템 구축

현재 문제점

  1. 화면관리에서 menuObjid 정보가 없어 scope_type='menu' 규칙을 볼 수 없음
  2. 메뉴 구조 변경 시 채번규칙 재설정 필요
  3. 같은 테이블을 사용하는 화면들에 동일한 규칙을 반복 설정해야 함
  4. 메뉴 계층 구조를 이해해야 규칙 설정 가능 (복잡도 높음)

해결 방안

  • 테이블명 기반 자동 매칭: 화면의 테이블과 규칙의 테이블이 같으면 자동으로 표시
  • 하이브리드 접근: scope_type을 'global', 'table', 'menu' 세 가지로 확장
  • 우선순위 시스템: menu > table > global 순으로 구체적인 규칙 우선 적용

🎯 목표

기능 목표

  • 같은 테이블을 사용하는 화면에서 채번규칙 자동 표시
  • 세 가지 scope_type 지원 (global, table, menu)
  • 우선순위 기반 규칙 선택
  • 기존 규칙 자동 마이그레이션

비기능 목표

  • 기존 기능 100% 호환성 유지
  • 성능 저하 없음 (인덱스 최적화)
  • 멀티테넌시 보안 유지
  • 롤백 가능한 마이그레이션

📐 시스템 설계

scope_type 정의

scope_type 설명 우선순위 사용 케이스
menu 특정 메뉴에서만 사용 1 (최고) 메뉴별로 다른 채번 방식 필요 시
table 특정 테이블에서만 사용 2 (중간) 테이블 기준 채번 (일반적)
global 모든 곳에서 사용 가능 3 (최저) 공통 채번 규칙

필터링 로직 (우선순위)

WHERE company_code = $1
  AND (
    -- 1순위: 메뉴별 규칙 (가장 구체적)
    (scope_type = 'menu' AND menu_objid = $3)

    -- 2순위: 테이블별 규칙 (일반적)
    OR (scope_type = 'table' AND table_name = $2)

    -- 3순위: 전역 규칙 (가장 일반적, table_name 제약 없음)
    OR (scope_type = 'global' AND table_name IS NULL)
  )
ORDER BY
  CASE scope_type
    WHEN 'menu' THEN 1
    WHEN 'table' THEN 2
    WHEN 'global' THEN 3
  END,
  created_at DESC

데이터베이스 스키마 변경

numbering_rules 테이블

변경 전:

scope_type VARCHAR(20) -- 값: 'global' 또는 'menu'

변경 후:

scope_type VARCHAR(20) -- 값: 'global', 'table', 'menu'
CHECK (scope_type IN ('global', 'table', 'menu'))

추가 제약조건:

-- table 타입은 반드시 table_name이 있어야 함
CHECK (
  (scope_type = 'table' AND table_name IS NOT NULL)
  OR scope_type != 'table'
)

-- global 타입은 table_name이 없어야 함
CHECK (
  (scope_type = 'global' AND table_name IS NULL)
  OR scope_type != 'global'
)

-- menu 타입은 반드시 menu_objid가 있어야 함
CHECK (
  (scope_type = 'menu' AND menu_objid IS NOT NULL)
  OR scope_type != 'menu'
)

🔧 구현 단계

Phase 1: 데이터베이스 마이그레이션 (30분)

1.1 마이그레이션 파일 생성

  • 파일: db/migrations/046_update_numbering_rules_scope_type.sql
  • 내용:
    1. scope_type 제약조건 확장
    2. 유효성 검증 제약조건 추가
    3. 기존 데이터 마이그레이션 (global → table)
    4. 인덱스 최적화

1.2 데이터 마이그레이션 로직

-- 기존 규칙 중 table_name이 있는 것은 'table' 타입으로 변경
UPDATE numbering_rules
SET scope_type = 'table'
WHERE scope_type = 'global'
  AND table_name IS NOT NULL;

-- 기존 규칙 중 table_name이 없는 것은 'global' 유지
-- (변경 불필요)

1.3 롤백 계획

  • 마이그레이션 실패 시 자동 롤백 (트랜잭션)
  • 수동 롤백 스크립트 제공

Phase 2: 백엔드 API 수정 (1시간)

2.1 numberingRuleService.ts 수정

변경할 함수:

getAvailableRulesForScreen (신규 함수)
async getAvailableRulesForScreen(
  companyCode: string,
  tableName: string,
  menuObjid?: number
): Promise<NumberingRuleConfig[]> {
  try {
    logger.info("화면용 채번 규칙 조회", {
      companyCode,
      tableName,
      menuObjid,
    });

    const pool = getPool();

    // 멀티테넌시: 최고 관리자 vs 일반 회사
    let query: string;
    let params: any[];

    if (companyCode === "*") {
      // 최고 관리자: 모든 회사의 규칙 조회 가능
      // 하지만 일반적으로는 일반 회사들의 규칙을 조회하므로
      // company_code != '*' 조건 추가 (최고 관리자 전용 규칙 제외)
      query = `
        SELECT
          rule_id AS "ruleId",
          rule_name AS "ruleName",
          description,
          separator,
          reset_period AS "resetPeriod",
          current_sequence AS "currentSequence",
          table_name AS "tableName",
          column_name AS "columnName",
          company_code AS "companyCode",
          menu_objid AS "menuObjid",
          scope_type AS "scopeType",
          created_at AS "createdAt",
          updated_at AS "updatedAt",
          created_by AS "createdBy"
        FROM numbering_rules
        WHERE company_code != '*'
          AND (
            (scope_type = 'menu' AND menu_objid = $1)
            OR (scope_type = 'table' AND table_name = $2)
            OR (scope_type = 'global' AND table_name IS NULL)
          )
        ORDER BY
          CASE scope_type
            WHEN 'menu' THEN 1
            WHEN 'table' THEN 2
            WHEN 'global' THEN 3
          END,
          created_at DESC
      `;
      params = [menuObjid, tableName];
      logger.info("최고 관리자: 일반 회사 채번 규칙 조회 (company_code != '*')");
    } else {
      // 일반 회사: 자신의 규칙만 조회
      query = `
        SELECT
          rule_id AS "ruleId",
          rule_name AS "ruleName",
          description,
          separator,
          reset_period AS "resetPeriod",
          current_sequence AS "currentSequence",
          table_name AS "tableName",
          column_name AS "columnName",
          company_code AS "companyCode",
          menu_objid AS "menuObjid",
          scope_type AS "scopeType",
          created_at AS "createdAt",
          updated_at AS "updatedAt",
          created_by AS "createdBy"
        FROM numbering_rules
        WHERE company_code = $1
          AND (
            (scope_type = 'menu' AND menu_objid = $2)
            OR (scope_type = 'table' AND table_name = $3)
            OR (scope_type = 'global' AND table_name IS NULL)
          )
        ORDER BY
          CASE scope_type
            WHEN 'menu' THEN 1
            WHEN 'table' THEN 2
            WHEN 'global' THEN 3
          END,
          created_at DESC
      `;
      params = [companyCode, menuObjid, tableName];
    }

    const result = await pool.query(query, params);

    // 각 규칙의 파트 정보 로드
    for (const rule of result.rows) {
      const partsQuery = `
        SELECT
          id,
          part_order AS "order",
          part_type AS "partType",
          generation_method AS "generationMethod",
          auto_config AS "autoConfig",
          manual_config AS "manualConfig"
        FROM numbering_rule_parts
        WHERE rule_id = $1
          AND company_code = $2
        ORDER BY part_order
      `;

      const partsResult = await pool.query(partsQuery, [
        rule.ruleId,
        companyCode === "*" ? rule.companyCode : companyCode,
      ]);

      rule.parts = partsResult.rows;
    }

    logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, {
      companyCode,
      tableName,
    });

    return result.rows;
  } catch (error: any) {
    logger.error("화면용 채번 규칙 조회 실패", error);
    throw error;
  }
}
getAvailableRulesForMenu (기존 함수 유지)
  • 채번규칙 관리 화면에서 사용
  • 변경 없음 (하위 호환성)

2.2 numberingRuleController.ts 수정

신규 엔드포인트 추가:

// GET /api/numbering-rules/available-for-screen?tableName=xxx&menuObjid=xxx
router.get(
  "/available-for-screen",
  authMiddleware,
  async (req: Request, res: Response) => {
    try {
      const companyCode = req.user!.companyCode;
      const { tableName, menuObjid } = req.query;

      if (!tableName) {
        return res.status(400).json({
          success: false,
          message: "tableName is required",
        });
      }

      const rules = await numberingRuleService.getAvailableRulesForScreen(
        companyCode,
        tableName as string,
        menuObjid ? parseInt(menuObjid as string) : undefined
      );

      return res.json({
        success: true,
        data: rules,
      });
    } catch (error: any) {
      logger.error("화면용 채번 규칙 조회 실패", error);
      return res.status(500).json({
        success: false,
        message: error.message,
      });
    }
  }
);

Phase 3: 프론트엔드 API 클라이언트 수정 (30분)

3.1 lib/api/numberingRule.ts 수정

신규 함수 추가:

/**
 * 화면용 채번 규칙 조회 (테이블 기반)
 * @param tableName 화면의 테이블명 (필수)
 * @param menuObjid 현재 메뉴의 objid (선택)
 * @returns 사용 가능한 채번 규칙 목록
 */
export async function getAvailableNumberingRulesForScreen(
  tableName: string,
  menuObjid?: number
): Promise<ApiResponse<NumberingRuleConfig[]>> {
  try {
    const params: any = { tableName };
    if (menuObjid) {
      params.menuObjid = menuObjid;
    }

    const response = await apiClient.get(
      "/numbering-rules/available-for-screen",
      {
        params,
      }
    );
    return response.data;
  } catch (error: any) {
    return {
      success: false,
      error: error.message || "화면용 규칙 조회 실패",
    };
  }
}

기존 함수 유지:

// getAvailableNumberingRules (메뉴 기반) - 하위 호환성
// 채번규칙 관리 컴포넌트에서 계속 사용

Phase 4: 화면관리 UI 수정 (30분)

4.1 TextTypeConfigPanel.tsx 수정

변경 전:

const response = await getAvailableNumberingRules();

변경 후:

const loadRules = async () => {
  setLoadingRules(true);
  try {
    // 화면의 테이블명 가져오기
    const screenTableName = getScreenTableName(); // 구현 필요

    if (!screenTableName) {
      logger.warn("화면 테이블명을 찾을 수 없습니다");
      setNumberingRules([]);
      return;
    }

    // 테이블 기반 규칙 조회
    const response = await getAvailableNumberingRulesForScreen(
      screenTableName,
      undefined // menuObjid (향후 확장 가능)
    );

    if (response.success && response.data) {
      setNumberingRules(response.data);
      logger.info(`채번 규칙 ${response.data.length}개 로드 완료`, {
        tableName: screenTableName,
      });
    }
  } catch (error) {
    console.error("채번 규칙 목록 로드 실패:", error);
    setNumberingRules([]);
  } finally {
    setLoadingRules(false);
  }
};

화면 테이블명 가져오기:

// ScreenDesigner에서 props로 전달받거나 Context 사용
const getScreenTableName = (): string | undefined => {
  // 방법 1: Props로 전달받기 (권장)
  return props.screenTableName;

  // 방법 2: Context에서 가져오기
  // const { selectedScreen } = useScreenContext();
  // return selectedScreen?.tableName;

  // 방법 3: 상위 컴포넌트에서 찾기
  // return component.tableName || selectedScreen?.tableName;
};

4.2 ScreenDesigner.tsx 수정

화면 테이블명을 하위 컴포넌트에 전달:

// PropertiesPanel에 screenTableName prop 추가
<PropertiesPanel
  selectedComponent={selectedComponent}
  onUpdateProperty={handleUpdateProperty}
  onUpdateComponent={handleUpdateComponent}
  screenTableName={tables[0]?.tableName} // 추가
/>

// PropertiesPanel에서 TextTypeConfigPanel에 전달
<TextTypeConfigPanel
  config={config}
  onConfigChange={handleConfigChange}
  screenTableName={screenTableName} // 추가
/>

Phase 5: 채번규칙 관리 UI 수정 (30분)

5.1 NumberingRuleDesigner.tsx 수정

scope_type 선택 UI 추가:

<div className="space-y-2">
  <Label className="text-sm font-medium">적용 범위</Label>
  <Select
    value={config.scopeType || "table"}
    onValueChange={(value) => updateConfig("scopeType", value)}
  >
    <SelectTrigger className="h-9 text-sm">
      <SelectValue />
    </SelectTrigger>
    <SelectContent>
      <SelectItem value="global" className="text-sm">
        전역 (모든 화면)
      </SelectItem>
      <SelectItem value="table" className="text-sm">
        테이블별 (같은 테이블 화면)
      </SelectItem>
      <SelectItem value="menu" className="text-sm">
        메뉴별 (특정 메뉴만)
      </SelectItem>
    </SelectContent>
  </Select>
  <p className="text-xs text-muted-foreground">
    {config.scopeType === "global" && "모든 화면에서 사용 가능"}
    {config.scopeType === "table" && "같은 테이블을 사용하는 화면에서만 표시"}
    {config.scopeType === "menu" && "선택한 메뉴에서만 사용 가능"}
  </p>
</div>

조건부 필드 표시:

{
  /* table 타입: 테이블명 필수 */
}
{
  config.scopeType === "table" && (
    <div className="space-y-2">
      <Label className="text-sm font-medium">
        테이블명 <span className="text-destructive">*</span>
      </Label>
      <Input
        value={config.tableName || ""}
        onChange={(e) => updateConfig("tableName", e.target.value)}
        placeholder="예: item_info"
        className="h-9 text-sm"
      />
    </div>
  );
}

{
  /* menu 타입: 메뉴 선택 필수 */
}
{
  config.scopeType === "menu" && (
    <div className="space-y-2">
      <Label className="text-sm font-medium">
        메뉴 <span className="text-destructive">*</span>
      </Label>
      <Select
        value={config.menuObjid?.toString() || ""}
        onValueChange={(value) => updateConfig("menuObjid", parseInt(value))}
      >
        <SelectTrigger className="h-9 text-sm">
          <SelectValue placeholder="메뉴 선택" />
        </SelectTrigger>
        <SelectContent>
          {/* 메뉴 목록 로드 */}
          {menus.map((menu) => (
            <SelectItem key={menu.objid} value={menu.objid.toString()}>
              {menu.menuName}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  );
}

{
  /* global 타입: 추가 설정 불필요 */
}
{
  config.scopeType === "global" && (
    <div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
      <p className="text-sm text-blue-800">
         규칙은 모든 화면에서 사용할  있습니다.
      </p>
    </div>
  );
}

5.2 유효성 검증 추가

const validateRuleConfig = (config: NumberingRuleConfig): string | null => {
  if (config.scopeType === "table" && !config.tableName) {
    return "테이블 타입은 테이블명이 필수입니다.";
  }

  if (config.scopeType === "menu" && !config.menuObjid) {
    return "메뉴 타입은 메뉴 선택이 필수입니다.";
  }

  if (config.scopeType === "global" && config.tableName) {
    return "전역 타입은 테이블명을 지정할 수 없습니다.";
  }

  return null;
};

📝 마이그레이션 파일 작성

파일: db/migrations/046_update_numbering_rules_scope_type.sql

-- =====================================================
-- 마이그레이션 046: 채번규칙 scope_type 확장
-- 목적: 메뉴 기반 → 테이블 기반 필터링 지원
-- 날짜: 2025-11-08
-- =====================================================

BEGIN;

-- 1. 기존 제약조건 제거
ALTER TABLE numbering_rules
DROP CONSTRAINT IF EXISTS check_scope_type;

-- 2. 새로운 scope_type 제약조건 추가 (global, table, menu)
ALTER TABLE numbering_rules
ADD CONSTRAINT check_scope_type
CHECK (scope_type IN ('global', 'table', 'menu'));

-- 3. table 타입 유효성 검증 제약조건
ALTER TABLE numbering_rules
ADD CONSTRAINT check_table_scope_requires_table_name
CHECK (
  (scope_type = 'table' AND table_name IS NOT NULL)
  OR scope_type != 'table'
);

-- 4. global 타입 유효성 검증 제약조건
ALTER TABLE numbering_rules
ADD CONSTRAINT check_global_scope_no_table_name
CHECK (
  (scope_type = 'global' AND table_name IS NULL)
  OR scope_type != 'global'
);

-- 5. menu 타입 유효성 검증 제약조건
ALTER TABLE numbering_rules
ADD CONSTRAINT check_menu_scope_requires_menu_objid
CHECK (
  (scope_type = 'menu' AND menu_objid IS NOT NULL)
  OR scope_type != 'menu'
);

-- 6. 기존 데이터 마이그레이션
-- global 규칙 중 table_name이 있는 것 → table 타입으로 변경
-- 멀티테넌시: 모든 회사의 데이터를 안전하게 변환
UPDATE numbering_rules
SET scope_type = 'table'
WHERE scope_type = 'global'
  AND table_name IS NOT NULL;
-- 주의: company_code 필터 없음 (모든 회사 데이터 마이그레이션)

-- 7. 인덱스 최적화 (멀티테넌시 필수!)
-- 기존 인덱스 제거
DROP INDEX IF EXISTS idx_numbering_rules_table;

-- 새로운 복합 인덱스 생성 (테이블 기반 조회 최적화)
-- company_code 포함으로 회사별 격리 성능 향상
CREATE INDEX IF NOT EXISTS idx_numbering_rules_scope_table
ON numbering_rules(scope_type, table_name, company_code);

-- 메뉴 기반 조회 최적화
-- company_code 포함으로 회사별 격리 성능 향상
CREATE INDEX IF NOT EXISTS idx_numbering_rules_scope_menu
ON numbering_rules(scope_type, menu_objid, company_code);

-- 8. 통계 정보 업데이트
ANALYZE numbering_rules;

COMMIT;

-- =====================================================
-- 롤백 스크립트 (문제 발생 시 실행)
-- =====================================================
/*
BEGIN;

-- 제약조건 제거
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_scope_type;
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_table_scope_requires_table_name;
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_global_scope_no_table_name;
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_menu_scope_requires_menu_objid;

-- 인덱스 제거
DROP INDEX IF EXISTS idx_numbering_rules_scope_table;
DROP INDEX IF EXISTS idx_numbering_rules_scope_menu;

-- 데이터 롤백 (table → global)
UPDATE numbering_rules
SET scope_type = 'global'
WHERE scope_type = 'table';

-- 기존 제약조건 복원
ALTER TABLE numbering_rules
ADD CONSTRAINT check_scope_type
CHECK (scope_type IN ('global', 'menu'));

-- 기존 인덱스 복원
CREATE INDEX IF NOT EXISTS idx_numbering_rules_table
ON numbering_rules(table_name, column_name);

COMMIT;
*/

검증 계획

1. 데이터베이스 검증

1.1 제약조건 확인

-- scope_type 제약조건 확인
SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'numbering_rules'::regclass
  AND conname LIKE '%scope%';

-- 예상 결과:
-- check_scope_type | CHECK (scope_type IN ('global', 'table', 'menu'))
-- check_table_scope_requires_table_name
-- check_global_scope_no_table_name
-- check_menu_scope_requires_menu_objid

1.2 인덱스 확인

-- 인덱스 목록 확인
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'numbering_rules'
ORDER BY indexname;

-- 예상 결과:
-- idx_numbering_rules_scope_table
-- idx_numbering_rules_scope_menu

1.3 데이터 마이그레이션 확인

-- scope_type별 개수
SELECT scope_type, COUNT(*) as count
FROM numbering_rules
GROUP BY scope_type;

-- 테이블명이 있는데 global인 규칙 (없어야 정상)
SELECT rule_id, rule_name, scope_type, table_name
FROM numbering_rules
WHERE scope_type = 'global' AND table_name IS NOT NULL;

2. API 검증

2.1 테이블 기반 조회 테스트

# 특정 테이블의 규칙 조회
curl -X GET "http://localhost:8080/api/numbering-rules/available-for-screen?tableName=item_info" \
  -H "Authorization: Bearer {token}"

# 예상 응답:
# - scope_type='table' && table_name='item_info'
# - scope_type='global' && table_name IS NULL

2.2 우선순위 테스트

-- 테스트 데이터 삽입
INSERT INTO numbering_rules (rule_id, rule_name, scope_type, table_name, company_code)
VALUES
  ('RULE_GLOBAL', '전역규칙', 'global', NULL, 'TEST_CO'),
  ('RULE_TABLE', '테이블규칙', 'table', 'item_info', 'TEST_CO'),
  ('RULE_MENU', '메뉴규칙', 'menu', NULL, 'TEST_CO');

-- API 호출 시 순서 확인 (menu > table > global)

3. 멀티테넌시 검증 (필수!)

3.1 회사별 데이터 격리 확인

-- 회사 A 규칙 생성
INSERT INTO numbering_rules (rule_id, rule_name, scope_type, table_name, company_code)
VALUES ('RULE_A', '회사A규칙', 'table', 'item_info', 'COMPANY_A');

-- 회사 B 규칙 생성
INSERT INTO numbering_rules (rule_id, rule_name, scope_type, table_name, company_code)
VALUES ('RULE_B', '회사B규칙', 'table', 'item_info', 'COMPANY_B');

-- 회사 A로 로그인 → API 호출
-- 예상: RULE_A만 조회, RULE_B는 보이지 않음 ✅

-- 회사 B로 로그인 → API 호출
-- 예상: RULE_B만 조회, RULE_A는 보이지 않음 ✅

3.2 최고 관리자 가시성 제한 확인

-- 최고 관리자 전용 규칙 생성
INSERT INTO numbering_rules (rule_id, rule_name, scope_type, table_name, company_code)
VALUES ('RULE_SUPER', '최고관리자규칙', 'global', NULL, '*');

-- 일반 회사로 로그인 → API 호출
-- 예상: RULE_SUPER는 보이지 않음 ✅ (company_code='*' 제외)

-- 최고 관리자로 로그인 → API 호출
-- 예상: 일반 회사 규칙들만 조회 (RULE_SUPER 제외) ✅

3.3 company_code 필터링 로그 확인

// 백엔드 로그에서 확인
logger.info("화면용 채번 규칙 조회 완료", {
  companyCode: "COMPANY_A", // ✅ 로그에 회사 코드 기록
  tableName: "item_info",
  rowCount: 5,
});

// 최고 관리자 로그
logger.info("최고 관리자: 일반 회사 채번 규칙 조회 (company_code != '*')");

4. UI 검증

4.1 화면관리 테스트

  1. 화면 생성 (테이블: item_info)
  2. 텍스트 필드 추가
  3. 자동 입력 > 채번규칙 선택
  4. 확인사항:
    • table_name='item_info'인 규칙 표시
    • scope_type='global'인 규칙 표시
    • 다른 테이블 규칙은 미표시
    • 다른 회사 규칙은 미표시 (멀티테넌시)

4.2 채번규칙 관리 테스트

  1. 새 규칙 생성
  2. 적용 범위 선택: "테이블별"
  3. 테이블명 입력: item_info
  4. 저장 → 화면관리에서 바로 표시 확인

4.3 우선순위 테스트

  1. 같은 테이블에 대해 3가지 scope_type 규칙 생성
  2. 화면관리에서 조회 시 menu가 최상단에 표시 확인

🚨 예외 처리 및 엣지 케이스

1. 테이블명이 없는 화면

// TextTypeConfigPanel.tsx
if (!screenTableName) {
  logger.warn("화면에 테이블이 지정되지 않았습니다");

  // global 규칙만 조회
  const response = await getAvailableNumberingRules();
  setNumberingRules(response.data || []);
  return;
}

2. 규칙이 하나도 없는 경우

if (numberingRules.length === 0) {
  return (
    <div className="text-center text-sm text-muted-foreground py-4">
      사용 가능한 채번규칙이 없습니다.
      <br />
      채번규칙 관리에서 규칙을 먼저 생성해주세요.
    </div>
  );
}

3. 동일 우선순위에 여러 규칙

-- created_at DESC로 정렬되므로 최신 규칙 우선
ORDER BY
  CASE scope_type
    WHEN 'menu' THEN 1
    WHEN 'table' THEN 2
    WHEN 'global' THEN 3
  END,
  created_at DESC -- 같은 scope_type이면 최신 규칙 우선

4. 최고 관리자 특별 처리

// company_code="*"인 경우 모든 규칙 조회 가능
if (companyCode === "*") {
  // 모든 회사의 규칙 표시 (멀티테넌시 예외)
}

📊 성능 최적화

1. 인덱스 전략

-- 복합 인덱스로 WHERE + ORDER BY 최적화
CREATE INDEX idx_numbering_rules_scope_table
ON numbering_rules(scope_type, table_name, company_code);

CREATE INDEX idx_numbering_rules_scope_menu
ON numbering_rules(scope_type, menu_objid, company_code);

2. 쿼리 플랜 확인

EXPLAIN ANALYZE
SELECT * FROM numbering_rules
WHERE company_code = 'TEST_CO'
  AND (
    (scope_type = 'table' AND table_name = 'item_info')
    OR (scope_type = 'global' AND table_name IS NULL)
  )
ORDER BY
  CASE scope_type
    WHEN 'menu' THEN 1
    WHEN 'table' THEN 2
    WHEN 'global' THEN 3
  END;

-- Index Scan 확인 (Seq Scan이면 인덱스 추가 필요)

3. 캐싱 전략 (향후 고려)

// 자주 조회되는 규칙은 메모리 캐싱
const ruleCache = new Map<string, NumberingRuleConfig[]>();

async function getAvailableRulesWithCache(
  tableName: string
): Promise<NumberingRuleConfig[]> {
  const cacheKey = `rules:${tableName}`;

  if (ruleCache.has(cacheKey)) {
    return ruleCache.get(cacheKey)!;
  }

  const rules = await getAvailableRulesForScreen(tableName);
  ruleCache.set(cacheKey, rules);

  return rules;
}

📅 구현 일정

Phase 작업 내용 예상 시간 담당자
Phase 1 DB 마이그레이션 30분 Backend
Phase 2 백엔드 API 수정 1시간 Backend
Phase 3 프론트 API 클라이언트 30분 Frontend
Phase 4 화면관리 UI 수정 30분 Frontend
Phase 5 채번규칙 UI 수정 30분 Frontend
검증 통합 테스트 1시간 All
총계 4시간 30분

🔄 하위 호환성

기존 기능 유지

  1. getAvailableNumberingRules() 함수 유지 (메뉴 기반)
  2. 기존 scope_type='menu' 규칙 정상 동작
  3. 채번규칙 관리 화면 정상 동작

마이그레이션 영향

  • ⚠️ scope_type='global' + table_name 있는 규칙 → 'table'로 자동 변경
  • 기존 동작 유지 (자동 마이그레이션)
  • 사용자 재설정 불필요

📖 사용자 가이드

규칙 생성 시 권장사항

언제 global을 사용하나요?

  • 회사 전체에서 공통으로 사용하는 채번 규칙
  • 예: "공지사항 번호", "공통 문서 번호"

언제 table을 사용하나요? (권장)

  • 특정 테이블의 데이터에 적용되는 규칙
  • 예: item_info 테이블의 "품목 코드"
  • 대부분의 경우 이 방식 사용

언제 menu를 사용하나요?

  • 같은 테이블이라도 메뉴별로 다른 채번 방식
  • 예: "영업팀 품목 코드" vs "구매팀 품목 코드"

🎉 기대 효과

1. 사용자 경험 개선

  • 화면관리에서 채번규칙이 자동으로 표시
  • 메뉴 구조를 몰라도 규칙 설정 가능
  • 같은 테이블 화면에 규칙 재사용 자동

2. 유지보수성 향상

  • 메뉴 구조 변경 시 규칙 재설정 불필요
  • 테이블 중심 설계로 직관적
  • 코드 복잡도 감소

3. 확장성 확보

  • 향후 scope_type 추가 가능
  • 다중 테이블 지원 가능
  • 조건부 규칙 확장 가능

📞 연락처

  • 작성자: 개발팀
  • 작성일: 2025-11-08
  • 버전: 1.0.0
  • 상태: 계획 수립 완료

다음 단계

  1. 계획서 검토 및 승인
  2. Phase 1 실행 (DB 마이그레이션)
  3. Phase 2 실행 (백엔드 수정)
  4. Phase 3-5 실행 (프론트엔드 수정)
  5. 통합 테스트
  6. 운영 배포

시작 준비 완료! 🚀


🔒 멀티테넌시 보안 최종 확인

완벽하게 적용됨

1. 데이터베이스 레벨

-- ✅ company_code 컬럼 필수 (NOT NULL)
-- ✅ 외래키 제약조건 (company_info 참조)
-- ✅ 복합 인덱스에 company_code 포함
CREATE INDEX idx_numbering_rules_scope_table
ON numbering_rules(scope_type, table_name, company_code);

2. API 레벨

// ✅ 일반 회사: WHERE company_code = $1
WHERE company_code = $1
  AND (scope_type = 'table' AND table_name = $2)

// ✅ 최고 관리자: WHERE company_code != '*'
// (일반 회사 데이터만 조회, 최고 관리자 전용 데이터 제외)
WHERE company_code != '*'
  AND (scope_type = 'table' AND table_name = $2)

// ✅ 파트 조회: WHERE company_code = $2
WHERE rule_id = $1 AND company_code = $2

3. 로깅 레벨

// ✅ 모든 로그에 companyCode 포함 (감사 추적)
logger.info("화면용 채번 규칙 조회 완료", {
  companyCode, // 필수!
  tableName,
  rowCount,
});

4. 검증 레벨

-- ✅ 회사 A 규칙은 회사 B에서 절대 안 보임
-- ✅ company_code='*' 규칙은 일반 회사에서 안 보임
-- ✅ 로그에 회사 코드 기록으로 추적 가능

🛡️ 보안 원칙 준수

  1. 완전한 격리: 회사별 데이터 100% 격리
  2. 최고 관리자 예외: company_code='*' 데이터는 최고 관리자 전용
  3. 감사 추적: 모든 조회에 companyCode 로깅
  4. 성능 최적화: 인덱스에 company_code 포함
  5. 데이터 무결성: 외래키 제약조건으로 보장

⚠️ 주의사항

  • 절대 company_code 필터 누락 금지
  • 클라이언트에서 company_code 전달 금지 (서버에서만 사용)
  • SQL 인젝션 방지 (파라미터 바인딩 필수)
  • 모든 쿼리에 company_code 조건 포함
  • 로그에 companyCode 필수 기록

멀티테넌시가 완벽하게 적용되었습니다! 🔐