ERP-node/docs/DDD1542/MULTI_SELECT_ARRAY_SERIALIZ...

7.4 KiB

다중 선택(Multi-Select) 배열 직렬화 문제 해결 보고서

문제 요약

증상: 다중 선택 컴포넌트(TagboxSelect, 체크박스 등)로 선택한 값이 DB에 저장될 때 손상되거나 null로 저장됨

영향받는 기능:

  • 품목정보의 division (구분) 필드
  • 모든 다중 선택 카테고리 필드

손상된 데이터 예시:

{"{\"{\\\"CAT_ML7SR2T9_IM7H\\\",\\\"CAT_ML8ZFQFU_EE5Z\\\"}\"}",...}

정상 데이터 예시:

CAT_ML7SR2T9_IM7H,CAT_ML8ZFQFU_EE5Z,CAT_ML8ZFVEL_1TOR

문제 원인 분석

1. PostgreSQL의 배열 자동 변환

Node.js의 node-pg 라이브러리는 JavaScript 배열을 PostgreSQL 배열 리터럴({...})로 자동 변환합니다.

// JavaScript
["CAT_1", "CAT_2", "CAT_3"]

// PostgreSQL로 자동 변환됨
{"CAT_1","CAT_2","CAT_3"}

하지만 우리 시스템은 커스텀 테이블에서 쉼표 구분 문자열을 기대합니다:

CAT_1,CAT_2,CAT_3

2. 여러 저장 경로의 존재

코드를 분석한 결과, 저장 로직이 여러 경로로 나뉘어 있었습니다:

경로 파일 설명
1 buttonActions.ts 기본 저장 로직 (INSERT/UPDATE)
2 EditModal.tsx 모달 내 직접 저장 (CREATE/UPDATE)
3 nodeFlowExecutionService.ts 백엔드 노드 플로우 저장

3. 왜 초기 수정이 실패했는가?

시도 1: buttonActions.ts에 배열 변환 추가

// buttonActions.ts (라인 1002-1025)
if (isUpdate) {
  for (const key of Object.keys(formData)) {
    if (Array.isArray(value)) {
      formData[key] = value.join(",");
    }
  }
}

실패 이유: EditModalonSave 콜백을 제공하면, buttonActions.ts는 이 콜백을 바로 호출하고 내부 저장 로직을 건너뜀

// buttonActions.ts (라인 545-552)
if (onSave) {
  await onSave();  // 바로 여기서 EditModal.handleSave()가 호출됨
  return true;     // 아래 배열 변환 로직에 도달하지 않음!
}

시도 2: nodeFlowExecutionService.tsnormalizeValueForDB 추가

부분 성공: INSERT에서는 동작했으나, EditModal의 UPDATE 경로는 여전히 문제


최종 해결 방법

핵심 수정: EditModal.tsx에 직접 배열 변환 추가

EditModal이 직접 dynamicFormApi.updateFormDataPartial을 호출하므로, 저장 직전에 배열을 변환해야 했습니다.

수정 위치 1: UPDATE 경로 (라인 957-1002)

// EditModal.tsx - UPDATE 모드
Object.keys(formData).forEach((key) => {
  if (formData[key] !== originalData[key]) {
    let value = formData[key];
    
    if (Array.isArray(value)) {
      // 리피터 데이터 제외
      const isRepeaterData = value.length > 0 && 
        typeof value[0] === "object" && 
        ("_targetTable" in value[0] || "_isNewItem" in value[0]);
      
      if (!isRepeaterData) {
        // 🔧 손상된 값 필터링
        const isValidValue = (v: any): boolean => {
          if (typeof v === "number") return true;
          if (typeof v !== "string") return false;
          if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) 
            return false;
          return true;
        };
        
        // 유효한 값만 쉼표로 연결
        const validValues = value.filter(isValidValue);
        value = validValues.join(",");
      }
    }
    
    changedData[key] = value;
  }
});

수정 위치 2: CREATE 경로 (라인 855-875)

// EditModal.tsx - CREATE 모드
Object.entries(dataToSave).forEach(([key, value]) => {
  if (!Array.isArray(value)) {
    masterDataToSave[key] = value;
  } else {
    const isRepeaterData = /* 리피터 체크 */;
    
    if (isRepeaterData) {
      // 리피터 데이터는 제외 (별도 저장)
    } else {
      // 다중 선택 배열 → 쉼표 구분 문자열
      const validValues = value.filter(isValidValue);
      masterDataToSave[key] = validValues.join(",");
    }
  }
});

수정 위치 3: 그룹 UPDATE 경로 (라인 630-650)

그룹 품목 수정 시에도 동일한 로직 적용


손상된 데이터 필터링

기존에 손상된 데이터가 배열에 포함될 수 있어서, 변환 전 필터링이 필요했습니다:

const isValidValue = (v: any): boolean => {
  // 숫자는 유효
  if (typeof v === "number" && !isNaN(v)) return true;
  // 문자열이 아니면 무효
  if (typeof v !== "string") return false;
  // 빈 값 무효
  if (!v || v.trim() === "") return false;
  // PostgreSQL 배열 형식 감지 → 무효
  if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) 
    return false;
  return true;
};

필터링 예시:

입력 배열: ['{"CAT_1","CAT_2"}', 'CAT_ML7SR2T9_IM7H', 'CAT_ML8ZFQFU_EE5Z']
              ↑ 손상됨 (필터링)      ↑ 유효                ↑ 유효

출력: 'CAT_ML7SR2T9_IM7H,CAT_ML8ZFQFU_EE5Z'

수정된 파일 목록

파일 수정 내용
frontend/components/screen/EditModal.tsx CREATE/UPDATE/그룹UPDATE 경로에 배열→문자열 변환 + 손상값 필터링
frontend/lib/utils/buttonActions.ts INSERT 경로에 배열→문자열 변환 (이미 수정됨)
frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx handleChange에서 배열→문자열 변환
backend-node/src/services/nodeFlowExecutionService.ts normalizeValueForDB 헬퍼 추가

교훈 및 향후 주의사항

1. 저장 경로 파악의 중요성

프론트엔드에서 저장 로직이 여러 경로로 분기될 수 있으므로, 모든 경로를 추적해야 합니다.

사용자 저장 버튼 클릭
    ↓
ButtonPrimaryComponent
    ↓
buttonActions.handleSave()
    ↓
┌─────────────────────────────────────┐
│ onSave 콜백이 있으면?               │
│   → EditModal.handleSave() 직접 호출│  ← 이 경로를 놓침!
│ onSave 콜백이 없으면?               │
│   → buttonActions 내부 저장 로직    │
└─────────────────────────────────────┘

2. 로그 기반 디버깅

로그가 어디까지 찍히고 어디서 안 찍히는지를 통해 코드 경로를 추적:

[예상한 로그]
buttonActions.ts:512 🔍 [handleSave] 진입
buttonActions.ts:1021 🔧 배열→문자열 변환  ← 이게 안 나옴!

[실제 로그]
buttonActions.ts:512 🔍 [handleSave] 진입
dynamicForm.ts:140 🔄 폼 데이터 부분 업데이트  ← 바로 여기로 점프!

3. 리피터 데이터 vs 다중 선택 구분

배열이라고 모두 쉼표 문자열로 변환하면 안 됩니다:

타입 예시 처리 방법
다중 선택 ["CAT_1", "CAT_2"] 쉼표 문자열로 변환
리피터 데이터 [{id: 1, _targetTable: "..."}] 별도 테이블에 저장, 마스터에서 제외

확인된 정상 동작

EditModal.tsx:1002 🔧 [EditModal UPDATE] 배열→문자열 변환: division 
  {original: 3, valid: 3, converted: 'CAT_ML7SR2T9_IM7H,CAT_ML8ZFQFU_EE5Z,CAT_ML8ZFVEL_1TOR'}

dynamicForm.ts:153 ✅ 폼 데이터 부분 업데이트 성공

작성일

2026-02-05

작성자

AI Assistant (Claude)