ERP-node/docs/DDD1542/CATEGORY_TREE_CONTROLLER_AN...

21 KiB

CategoryTreeController 로직 분석 보고서

분석일: 2026-01-26 | 대상 파일: backend-node/src/controllers/categoryTreeController.ts
검증일: 2026-01-26 | TypeScript 컴파일 검증 완료


0. 검증 결과 요약

TypeScript 컴파일 에러 (실제 확인됨)

$ tsc --noEmit src/controllers/categoryTreeController.ts

src/controllers/categoryTreeController.ts(139,15): error TS2339: Property 'targetCompanyCode' does not exist on type 'CreateCategoryValueInput'.
src/controllers/categoryTreeController.ts(140,27): error TS2339: Property 'targetCompanyCode' does not exist on type 'CreateCategoryValueInput'.
src/controllers/categoryTreeController.ts(143,34): error TS2339: Property 'targetCompanyCode' does not exist on type 'CreateCategoryValueInput'.

결론: targetCompanyCode 타입 정의 누락 문제가 실제로 존재함


1. 시스템 개요

1.1 아키텍처 다이어그램

flowchart TB
    subgraph Frontend["프론트엔드"]
        UI[카테고리 관리 UI]
    end
    
    subgraph Backend["백엔드"]
        subgraph Controllers["컨트롤러"]
            CTC[categoryTreeController.ts]
        end
        
        subgraph Services["서비스"]
            CTS[categoryTreeService.ts]
            TCVS[tableCategoryValueService.ts]
        end
        
        subgraph Database["데이터베이스"]
            CVT[(category_values_test)]
            TCCV[(table_column_category_values)]
            TTC[(table_type_columns)]
        end
    end
    
    UI --> |"/api/category-tree/*"| CTC
    CTC --> CTS
    CTS --> CVT
    TCVS --> TCCV
    TCVS --> TTC
    
    style CTC fill:#ff6b6b,stroke:#c92a2a
    style CVT fill:#4ecdc4,stroke:#087f5b
    style TCCV fill:#4ecdc4,stroke:#087f5b

1.2 관련 파일 목록

파일 역할 사용 테이블
categoryTreeController.ts 카테고리 트리 API 라우트 -
categoryTreeService.ts 카테고리 트리 비즈니스 로직 category_values_test
tableCategoryValueService.ts 테이블별 카테고리 값 관리 table_column_category_values
categoryTreeRoutes.ts 라우트 re-export -

2. 발견된 문제점 요약

pie title 문제점 심각도 분류
    "🔴 Critical (즉시 수정)" : 3
    "🟠 Major (수정 권장)" : 2
    "🟡 Minor (검토 필요)" : 2
심각도 문제 영향도 검증
🔴 Critical 라우트 순서 충돌 GET 라우트 2개 호출 불가 이론적 분석
🔴 Critical 타입 정의 불일치 TypeScript 컴파일 에러 tsc 검증됨
🔴 Critical 멀티테넌시 규칙 위반 보안 문제 - 데이터 노출 .cursorrules 규칙 확인
🟠 Major 하위 항목 삭제 미구현 데이터 정합성 주석 vs 구현 비교
🟠 Major 카테고리 시스템 이원화 유지보수 복잡도 코드 분석
🟡 Minor 인덱스 비효율 쿼리 성능 저하 쿼리 패턴 분석
🟡 Minor PUT/DELETE 오버라이드 누락 기능 제한 의도적 설계 가능

3. 🔴 Critical: 라우트 순서 충돌

3.1 문제 설명

Express 라우터는 정의 순서대로 매칭합니다. 현재 라우트 순서에서 일부 GET 라우트가 절대 호출되지 않습니다.

3.2 현재 라우트 순서 (문제)

flowchart LR
    subgraph Order["현재 정의 순서"]
        R1["Line 24<br/>GET /test/all-category-keys"]
        R2["Line 48<br/>GET /test/:tableName/:columnName<br/>⚠️ 너무 일찍 정의"]
        R3["Line 73<br/>GET /test/:tableName/:columnName/flat"]
        R4["Line 98<br/>GET /test/value/:valueId<br/>❌ 가려짐"]
        R5["Line 130<br/>POST /test/value"]
        R6["Line 174<br/>PUT /test/value/:valueId"]
        R7["Line 208<br/>DELETE /test/value/:valueId"]
        R8["Line 240<br/>GET /test/columns/:tableName<br/>❌ 가려짐"]
    end
    
    R1 --> R2 --> R3 --> R4 --> R5 --> R6 --> R7 --> R8
    
    style R2 fill:#fff3bf,stroke:#f59f00
    style R4 fill:#ffe3e3,stroke:#c92a2a
    style R8 fill:#ffe3e3,stroke:#c92a2a

3.3 요청 매칭 시뮬레이션

sequenceDiagram
    participant Client as 클라이언트
    participant Express as Express Router
    participant R2 as Line 48<br/>/:tableName/:columnName
    participant R4 as Line 98<br/>/value/:valueId
    participant R8 as Line 240<br/>/columns/:tableName
    
    Note over Client,Express: 요청: GET /test/value/123
    Client->>Express: GET /test/value/123
    Express->>R2: 패턴 매칭 시도
    Note over R2: tableName="value"<br/>columnName="123"<br/>✅ 매칭됨!
    R2-->>Express: 처리 완료
    Note over R4: ❌ 검사되지 않음
    
    Note over Client,Express: 요청: GET /test/columns/users
    Client->>Express: GET /test/columns/users
    Express->>R2: 패턴 매칭 시도
    Note over R2: tableName="columns"<br/>columnName="users"<br/>✅ 매칭됨!
    R2-->>Express: 처리 완료
    Note over R8: ❌ 검사되지 않음

3.4 영향받는 라우트

라인 경로 HTTP 상태 원인
98 /test/value/:valueId GET 호출 불가 Line 48에 의해 가려짐
240 /test/columns/:tableName GET 호출 불가 Line 48에 의해 가려짐

3.5 PUT/DELETE는 왜 문제없는가?

flowchart TB
    subgraph Methods["HTTP 메서드별 라우트 분리"]
        subgraph GET["GET 메서드"]
            G1["Line 24: /test/all-category-keys"]
            G2["Line 48: /test/:tableName/:columnName ⚠️"]
            G3["Line 73: /test/:tableName/:columnName/flat"]
            G4["Line 98: /test/value/:valueId ❌"]
            G5["Line 240: /test/columns/:tableName ❌"]
        end
        
        subgraph POST["POST 메서드"]
            P1["Line 130: /test/value"]
        end
        
        subgraph PUT["PUT 메서드"]
            U1["Line 174: /test/value/:valueId ✅"]
        end
        
        subgraph DELETE["DELETE 메서드"]
            D1["Line 208: /test/value/:valueId ✅"]
        end
    end
    
    Note1[Express는 같은 HTTP 메서드 내에서만<br/>순서대로 매칭함]
    
    style G2 fill:#fff3bf
    style G4 fill:#ffe3e3
    style G5 fill:#ffe3e3
    style U1 fill:#d3f9d8
    style D1 fill:#d3f9d8

결론: PUT /test/value/:valueId와 DELETE /test/value/:valueId는 GET 라우트와 HTTP 메서드가 다르므로 충돌하지 않습니다.

3.6 수정 방안

// ✅ 올바른 순서 (더 구체적인 경로 먼저)

// 1. 리터럴 경로 (가장 먼저)
router.get("/test/all-category-keys", ...);

// 2. 부분 리터럴 경로 (리터럴 + 파라미터)
router.get("/test/value/:valueId", ...);        // "value"가 고정
router.get("/test/columns/:tableName", ...);    // "columns"가 고정

// 3. 더 긴 동적 경로
router.get("/test/:tableName/:columnName/flat", ...);  // 4세그먼트

// 4. 가장 일반적인 동적 경로 (마지막에)
router.get("/test/:tableName/:columnName", ...);  // 3세그먼트

4. 🔴 Critical: 타입 정의 불일치

4.1 문제 설명

컨트롤러에서 input.targetCompanyCode를 사용하지만, 인터페이스에 해당 필드가 없습니다.

4.2 코드 비교

flowchart LR
    subgraph Interface["CreateCategoryValueInput 인터페이스"]
        I1[tableName: string]
        I2[columnName: string]
        I3[valueCode: string]
        I4[valueLabel: string]
        I5[valueOrder?: number]
        I6[parentValueId?: number]
        I7[description?: string]
        I8[color?: string]
        I9[icon?: string]
        I10[isActive?: boolean]
        I11[isDefault?: boolean]
        Missing["❌ targetCompanyCode 없음"]
    end
    
    subgraph Controller["컨트롤러 (Line 139)"]
        C1["input.targetCompanyCode 사용"]
    end
    
    Controller -.-> |"타입 불일치"| Missing
    
    style Missing fill:#ffe3e3,stroke:#c92a2a

4.3 문제 코드

인터페이스 정의 (categoryTreeService.ts Line 34-46):

export interface CreateCategoryValueInput {
  tableName: string;
  columnName: string;
  valueCode: string;
  valueLabel: string;
  valueOrder?: number;
  parentValueId?: number | null;
  description?: string;
  color?: string;
  icon?: string;
  isActive?: boolean;
  isDefault?: boolean;
  // ❌ targetCompanyCode 필드 없음!
}

컨트롤러 사용 (categoryTreeController.ts Line 136-145):

// 🔧 최고 관리자가 특정 회사를 선택한 경우, targetCompanyCode 우선 사용
let companyCode = userCompanyCode;
if (input.targetCompanyCode && userCompanyCode === "*") {  // ⚠️ 타입 에러 가능
  companyCode = input.targetCompanyCode;
  logger.info("🔓 최고 관리자 회사 코드 오버라이드", {
    originalCompanyCode: userCompanyCode,
    targetCompanyCode: input.targetCompanyCode,
  });
}

4.4 영향

  1. TypeScript 컴파일 시 에러 또는 경고 발생 가능
  2. 런타임에 input.targetCompanyCode가 항상 undefined
  3. 최고 관리자의 회사 오버라이드 기능이 작동하지 않음

4.5 수정 방안

// categoryTreeService.ts - 인터페이스 수정
export interface CreateCategoryValueInput {
  tableName: string;
  columnName: string;
  valueCode: string;
  valueLabel: string;
  valueOrder?: number;
  parentValueId?: number | null;
  description?: string;
  color?: string;
  icon?: string;
  isActive?: boolean;
  isDefault?: boolean;
  targetCompanyCode?: string;  // ✅ 추가
}

5. 🔴 Critical: 멀티테넌시 규칙 위반 (심각도 상향)

5.1 규칙 위반 설명

.cursorrules 파일에 명시된 프로젝트 규칙:

중요: company_code = "*"최고 관리자 전용 데이터를 의미합니다.

  • 잘못된 이해: company_code = "*" = 모든 회사가 공유하는 공통 데이터
  • 올바른 이해: company_code = "*" = 최고 관리자만 관리하는 전용 데이터

핵심: 일반 회사 사용자는 company_code = "*" 데이터를 볼 수 없습니다!

현재 상태: 서비스 코드에서 일반 회사도 company_code = '*' 데이터를 조회할 수 있음 → 보안 위반

5.2 문제 쿼리 패턴

flowchart TB
    subgraph Current["현재 구현 (문제)"]
        Q1["WHERE (company_code = $1 OR company_code = '*')"]
        
        subgraph Result1["일반 회사 'COMPANY_A' 조회 시"]
            R1A["✅ COMPANY_A 데이터"]
            R1B["⚠️ * 데이터도 조회됨 (규칙 위반)"]
        end
    end
    
    subgraph Expected["올바른 구현"]
        Q2["if (companyCode === '*')<br/>  전체 조회<br/>else<br/>  WHERE company_code = $1"]
        
        subgraph Result2["일반 회사 'COMPANY_A' 조회 시"]
            R2A["✅ COMPANY_A 데이터만"]
        end
    end
    
    style R1B fill:#ffe3e3,stroke:#c92a2a
    style R2A fill:#d3f9d8,stroke:#087f5b

5.3 영향받는 함수 목록

서비스 함수 라인 문제 쿼리
categoryTreeService.ts getCategoryTree 93 WHERE (company_code = $1 OR company_code = '*')
categoryTreeService.ts getCategoryList 146 WHERE (company_code = $1 OR company_code = '*')
categoryTreeService.ts getCategoryValue 188 WHERE (company_code = $1 OR company_code = '*')
categoryTreeService.ts updateCategoryValue 352 WHERE (company_code = $1 OR company_code = '*')
categoryTreeService.ts deleteCategoryValue 415 WHERE (company_code = $1 OR company_code = '*')
categoryTreeService.ts updateChildrenPaths 443 WHERE (company_code = $1 OR company_code = '*')
categoryTreeService.ts getCategoryColumns 498 WHERE (company_code = $2 OR company_code = '*')
categoryTreeService.ts getAllCategoryKeys 530 WHERE cv.company_code = $1 OR cv.company_code = '*'

5.4 수정 방안

// ✅ 올바른 멀티테넌시 패턴 (tableCategoryValueService.ts 참고)

async getCategoryTree(companyCode: string, tableName: string, columnName: string) {
  let query: string;
  let params: any[];

  if (companyCode === "*") {
    // 최고 관리자: 모든 데이터 조회
    query = `
      SELECT * FROM category_values_test
      WHERE table_name = $1 AND column_name = $2
      ORDER BY depth ASC, value_order ASC
    `;
    params = [tableName, columnName];
  } else {
    // 일반 회사: 자신의 데이터만 조회 (company_code = '*' 제외)
    query = `
      SELECT * FROM category_values_test
      WHERE table_name = $1 AND column_name = $2
        AND company_code = $3
      ORDER BY depth ASC, value_order ASC
    `;
    params = [tableName, columnName, companyCode];
  }

  return await pool.query(query, params);
}

6. 🟠 Major: 하위 항목 삭제 미구현

6.1 문제 설명

주석에는 "하위 항목도 함께 삭제"라고 되어 있지만, 실제 구현에서는 단일 레코드만 삭제합니다.

6.2 코드 분석

flowchart TB
    subgraph Comment["주석 (Line 407)"]
        C1["카테고리 값 삭제 (하위 항목도 함께 삭제)"]
    end
    
    subgraph Implementation["실제 구현 (Line 413-416)"]
        I1["DELETE FROM category_values_test<br/>WHERE ... AND value_id = $2"]
        I2["단일 레코드만 삭제"]
    end
    
    Comment -.-> |"불일치"| Implementation
    
    style Comment fill:#e7f5ff,stroke:#1971c2
    style Implementation fill:#ffe3e3,stroke:#c92a2a

6.3 예상 문제 시나리오

flowchart TB
    subgraph Before["삭제 전"]
        P["대분류 (value_id=1)"]
        C1["중분류 A (parent_value_id=1)"]
        C2["중분류 B (parent_value_id=1)"]
        C3["소분류 X (parent_value_id=C1)"]
        
        P --> C1
        P --> C2
        C1 --> C3
    end
    
    subgraph After["'대분류' 삭제 후"]
        C1o["중분류 A ⚠️ 고아"]
        C2o["중분류 B ⚠️ 고아"]
        C3o["소분류 X ⚠️ 고아"]
        
        Orphan["parent_value_id가 존재하지 않는<br/>부모를 가리킴"]
    end
    
    Before --> |"DELETE"| After
    
    style C1o fill:#ffe3e3
    style C2o fill:#ffe3e3
    style C3o fill:#ffe3e3

6.4 수정 방안

async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
  const pool = getPool();
  const client = await pool.connect();

  try {
    await client.query("BEGIN");

    // 1. 재귀적으로 모든 하위 항목 ID 조회
    const descendantsQuery = `
      WITH RECURSIVE descendants AS (
        SELECT value_id FROM category_values_test 
        WHERE value_id = $1 AND (company_code = $2 OR company_code = '*')
        
        UNION ALL
        
        SELECT c.value_id FROM category_values_test c
        JOIN descendants d ON c.parent_value_id = d.value_id
        WHERE c.company_code = $2 OR c.company_code = '*'
      )
      SELECT value_id FROM descendants
    `;
    
    const descendants = await client.query(descendantsQuery, [valueId, companyCode]);
    const idsToDelete = descendants.rows.map(r => r.value_id);

    // 2. 하위 항목 포함 일괄 삭제
    if (idsToDelete.length > 0) {
      await client.query(
        `DELETE FROM category_values_test WHERE value_id = ANY($1::int[])`,
        [idsToDelete]
      );
    }

    await client.query("COMMIT");
    
    logger.info("카테고리 값 및 하위 항목 삭제 완료", { 
      valueId, 
      totalDeleted: idsToDelete.length 
    });
    
    return true;
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

7. 🟠 Major: 카테고리 시스템 이원화

7.1 문제 설명

동일한 목적의 두 개의 카테고리 시스템이 존재합니다.

7.2 시스템 비교

flowchart TB
    subgraph System1["시스템 1: categoryTreeService"]
        S1C[categoryTreeController.ts]
        S1S[categoryTreeService.ts]
        S1T[(category_values_test)]
        
        S1C --> S1S --> S1T
    end
    
    subgraph System2["시스템 2: tableCategoryValueService"]
        S2S[tableCategoryValueService.ts]
        S2T[(table_column_category_values)]
        
        S2S --> S2T
    end
    
    subgraph Usage["사용처"]
        U1[NumberingRuleDesigner.tsx]
        U2[V2Select.tsx]
        U3[screenManagementService.ts]
    end
    
    U1 --> S1T
    U2 --> S1T
    U3 --> S1T
    
    style S1T fill:#4ecdc4,stroke:#087f5b
    style S2T fill:#4ecdc4,stroke:#087f5b

7.3 테이블 비교

속성 category_values_test table_column_category_values
서비스 categoryTreeService tableCategoryValueService
menu_objid 없음 있음
계층 구조 지원 (최대 3단계) 지원
path 컬럼 있음 없음
사용 빈도 높음 (108건) 낮음 (0건 추정)
명칭 "테스트" "정식"

7.4 권장 사항

flowchart LR
    subgraph Current["현재 상태"]
        C1[category_values_test<br/>실제 사용 중]
        C2[table_column_category_values<br/>거의 미사용]
    end
    
    subgraph Recommended["권장 조치"]
        R1["1. 테이블명 정리:<br/>_test 접미사 제거"]
        R2["2. 서비스 통합:<br/>하나의 서비스로"]
        R3["3. 미사용 테이블 정리"]
    end
    
    Current --> Recommended

8. 🟡 Minor: 인덱스 비효율 쿼리

8.1 문제 쿼리

WHERE (company_code = $1 OR company_code = '*')

8.2 문제점

  • OR 조건은 인덱스 최적화를 방해
  • Full Table Scan 발생 가능

8.3 수정 방안

-- 옵션 1: UNION 사용 (권장)
SELECT * FROM category_values_test WHERE company_code = $1
UNION ALL
SELECT * FROM category_values_test WHERE company_code = '*'

-- 옵션 2: IN 연산자 사용
WHERE company_code IN ($1, '*')

-- 옵션 3: 조건별 분기 (가장 권장)
-- 최고 관리자와 일반 사용자 쿼리 분리 (멀티테넌시 규칙 준수와 함께)

9. 🟡 Minor: PUT/DELETE 오버라이드 누락

9.1 문제 설명

POST에서만 targetCompanyCode 오버라이드 로직이 있고, PUT/DELETE에는 없습니다.

9.2 비교 표

메서드 라인 targetCompanyCode 처리
POST /test/value 136-145 있음
PUT /test/value/:valueId 174-201 없음
DELETE /test/value/:valueId 208-233 없음

9.3 영향

  • 최고 관리자가 다른 회사의 카테고리 값을 수정/삭제할 때 제한될 수 있음
  • 단, 의도적 설계일 수 있음 (생성만 회사 지정, 수정/삭제는 기존 레코드의 company_code 사용)

9.4 권장 사항

기능 요구사항 확인 후 결정:

  1. 의도적이라면: 주석으로 의도 명시
  2. 누락이라면: POST와 동일한 로직 추가

10. 수정 계획

10.1 우선순위별 수정 항목

gantt
    title 수정 우선순위
    dateFormat  YYYY-MM-DD
    section 🔴 Critical
    라우트 순서 수정           :crit, a1, 2026-01-26, 1d
    타입 정의 수정             :crit, a2, 2026-01-26, 1d
    멀티테넌시 규칙 준수       :crit, a3, 2026-01-26, 1d
    section 🟠 Major
    하위 항목 삭제 구현        :b1, 2026-01-27, 2d
    section 🟡 Minor
    쿼리 최적화               :c1, 2026-01-29, 1d
    PUT/DELETE 검토           :c2, 2026-01-29, 1d

10.2 수정 체크리스트

🔴 Critical (즉시 수정)

  • 라우트 순서 수정 (Line 48, 98, 240)

    • /test/value/:valueId/test/:tableName/:columnName 앞으로 이동
    • /test/columns/:tableName/test/:tableName/:columnName 앞으로 이동
  • 타입 정의 수정 (categoryTreeService.ts Line 34-46)

    • CreateCategoryValueInputtargetCompanyCode?: string 추가
    • TypeScript 컴파일 에러 해결
  • 멀티테넌시 규칙 준수 (categoryTreeService.ts 모든 쿼리)

    • WHERE (company_code = $1 OR company_code = '*') 패턴 제거
    • 최고 관리자 분기와 일반 사용자 분기 분리
    • 일반 사용자는 company_code = '*' 데이터 조회 불가
    • 영향받는 함수: getCategoryTree, getCategoryList, getCategoryValue, updateCategoryValue, deleteCategoryValue, updateChildrenPaths, getCategoryColumns, getAllCategoryKeys

🟠 Major (수정 권장)

  • 하위 항목 삭제 구현 (deleteCategoryValue 함수)
    • 재귀적 하위 항목 조회 및 삭제 로직 추가
    • 또는 주석 수정 (실제 동작과 일치하도록)

🟡 Minor (검토 필요)

  • PUT/DELETE 오버라이드 검토
    • 필요 시 POST와 동일한 로직 추가
    • 불필요 시 의도 주석 추가

11. 참고 자료

  • 멀티테넌시 가이드: .cursor/rules/multi-tenancy-guide.mdc
  • DB 비효율성 분석: docs/DB_INEFFICIENCY_ANALYSIS.md
  • 보안 가이드: .cursor/rules/security-guide.mdc