DEV Community

Cover image for 웹 데이터를 위한 타입 추론 시스템 구축하기
circobit
circobit

Posted on

웹 데이터를 위한 타입 추론 시스템 구축하기

웹 테이블은 문자열입니다. 모든 것이 문자열입니다. 하지만 JSON이나 SQL로 내보낼 때는 이렇게 변환되어야 합니다:

  • "1,234.56"1234.56 (숫자)
  • "2024-03-15" → 날짜 타입
  • "Yes"true (불리언)
  • "N/A"null

신뢰할 수 있는 타입 추론 시스템을 구축한다는 것은 실제 데이터의 혼란을 처리한다는 의미입니다. HTML Table Exporter를 개발하면서 이 문제에 접근한 방법을 공유합니다.

문제: 모호성의 바다

다음 값들을 살펴보세요:

해석 가능성
"1,234" 숫자 1234 (미국식) 또는 1.234 (유럽식)
"01/02/03" 2003년 1월 2일 (미국) 또는 2003년 2월 1일 (유럽) 또는 2001-02-03
"1" 정수 1 또는 불리언 true
"N/A" 문자열 또는 null
"0" 정수 0 또는 불리언 false

타입 추론은 파싱이 아닙니다—모호한 증거에서 의도를 추측하는 것입니다.

아키텍처: 컬럼 레벨 추론

셀 단위 타입 추론은 신뢰할 수 없습니다. 단독 값 "1"은 무엇이든 될 수 있습니다. 하지만 ["1", "2", "3", "4"]를 포함하는 컬럼은 분명히 정수입니다.

제 접근법:

  1. 컬럼에서 값 샘플링 (최대 100개)
  2. null이 아닌 각 값의 타입 추론
  3. 집계하여 컬럼 타입 결정
  4. 90% 임계값 적용—90% 이상의 값이 타입과 일치하면 해당 타입 사용
const DATA_TYPES = {
  STRING: "string",
  INTEGER: "integer", 
  NUMBER: "number",
  BOOLEAN: "boolean",
  DATE: "date",
  NULL: "null",
};

function inferColumnType(columnValues, maxSamples = 100) {
  const nonNullValues = columnValues.filter(v => v != null && v !== "");

  if (nonNullValues.length === 0) {
    return { type: DATA_TYPES.STRING, confidence: 0 };
  }

  const sample = nonNullValues.slice(0, maxSamples);

  // 각 타입의 출현 횟수 카운트
  const typeCounts = {
    [DATA_TYPES.INTEGER]: 0,
    [DATA_TYPES.NUMBER]: 0,
    [DATA_TYPES.BOOLEAN]: 0,
    [DATA_TYPES.DATE]: 0,
    [DATA_TYPES.STRING]: 0,
  };

  for (const val of sample) {
    const type = inferValueType(val);
    if (type === DATA_TYPES.INTEGER) {
      // INTEGER는 NUMBER의 부분집합
      typeCounts[DATA_TYPES.INTEGER]++;
      typeCounts[DATA_TYPES.NUMBER]++;
    } else if (type !== DATA_TYPES.NULL) {
      typeCounts[type]++;
    }
  }

  const total = sample.length;
  const threshold = 0.9;

  // 우선순위 순서로 타입 확인
  if (typeCounts[DATA_TYPES.BOOLEAN] >= total * threshold) {
    return { type: DATA_TYPES.BOOLEAN, confidence: typeCounts[DATA_TYPES.BOOLEAN] / total };
  }
  if (typeCounts[DATA_TYPES.DATE] >= total * threshold) {
    return { type: DATA_TYPES.DATE, confidence: typeCounts[DATA_TYPES.DATE] / total };
  }
  if (typeCounts[DATA_TYPES.INTEGER] >= total * threshold) {
    return { type: DATA_TYPES.INTEGER, confidence: typeCounts[DATA_TYPES.INTEGER] / total };
  }
  if (typeCounts[DATA_TYPES.NUMBER] >= total * threshold) {
    return { type: DATA_TYPES.NUMBER, confidence: typeCounts[DATA_TYPES.NUMBER] / total };
  }

  return { type: DATA_TYPES.STRING, confidence: 1 };
}
Enter fullscreen mode Exit fullscreen mode

왜 90%인가? 실제 데이터에는 노이즈가 있습니다. 100개 정수 컬럼에 "N/A" 하나가 있을 수 있습니다. 100% 일치를 요구하면 너무 엄격합니다.

값 레벨 타입 감지

inferValueType 함수는 개별 값을 처리합니다:

function inferValueType(value) {
  if (value == null || value === "") {
    return DATA_TYPES.NULL;
  }

  const str = String(value).trim();
  if (str === "") return DATA_TYPES.NULL;

  // 불리언 확인
  const lowerStr = str.toLowerCase();
  if (["true", "false", "yes", "no", "", "si"].includes(lowerStr)) {
    return DATA_TYPES.BOOLEAN;
  }

  // 날짜 확인 (ISO 형식 우선)
  if (/^\d{4}-\d{2}-\d{2}/.test(str)) {
    return DATA_TYPES.DATE;
  }
  if (/^\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4}$/.test(str)) {
    return DATA_TYPES.DATE;
  }

  // 정수 확인 (엄격)
  if (/^-?\d+$/.test(str)) {
    return DATA_TYPES.INTEGER;
  }

  // 숫자 확인 (소수점 및 구분자 포함)
  const cleanedForNumber = str
    .replace(/[$€£¥%\s]/g, "")
    .replace(/,/g, ".");

  if (/^-?\d+(\.\d+)?$/.test(cleanedForNumber)) {
    return DATA_TYPES.NUMBER;
  }

  return DATA_TYPES.STRING;
}
Enter fullscreen mode Exit fullscreen mode

숫자 정규화의 난제

유럽식 vs 미국식 숫자 형식이 가장 까다로운 모호성입니다:

미국식 해석 유럽식 해석
"1,234" 1234 1.234
"1.234" 1.234 1234
"1,234.56" 1234.56 유효하지 않음
"1.234,56" 유효하지 않음 1234.56

형식 감지를 위한 휴리스틱:

function normalizeNumber(value) {
  if (value == null) return value;

  let str = String(value).trim();

  // 통화 기호 및 공백 제거
  str = str.replace(/[$€£¥\s]/g, "");

  // 퍼센트 처리
  const isPercent = str.endsWith("%");
  if (isPercent) str = str.slice(0, -1);

  // 구분자 분석으로 형식 감지
  const commaCount = (str.match(/,/g) || []).length;
  const dotCount = (str.match(/\./g) || []).length;
  const lastComma = str.lastIndexOf(",");
  const lastDot = str.lastIndexOf(".");

  let normalized;

  if (commaCount === 0 && dotCount === 0) {
    // 순수 정수: "1234"
    normalized = str;
  } else if (commaCount === 0 && dotCount === 1) {
    // "1.234" (유럽식 천 단위) 또는 "1.23" (소수)
    // 휴리스틱: 점 뒤 3자리 = 천 단위 구분자
    const afterDot = str.slice(lastDot + 1);
    if (afterDot.length === 3 && /^\d+$/.test(afterDot)) {
      normalized = str.replace(".", "");
    } else {
      normalized = str;
    }
  } else if (commaCount === 1 && dotCount === 0) {
    // "1,234" (미국식 천 단위) 또는 "1,23" (유럽식 소수)
    const afterComma = str.slice(lastComma + 1);
    if (afterComma.length === 3 && /^\d+$/.test(afterComma)) {
      normalized = str.replace(",", "");
    } else {
      normalized = str.replace(",", ".");
    }
  } else if (lastDot > lastComma) {
    // "1,234.56" - 미국식
    normalized = str.replace(/,/g, "");
  } else if (lastComma > lastDot) {
    // "1.234,56" - 유럽식
    normalized = str.replace(/\./g, "").replace(",", ".");
  } else {
    return value;
  }

  const num = parseFloat(normalized);
  if (Number.isNaN(num)) return value;

  return isPercent ? num / 100 : num;
}
Enter fullscreen mode Exit fullscreen mode

핵심 인사이트: 구분자의 위치와 개수가 대부분의 모호성을 해결합니다. 마지막 구분자가 보통 소수점 구분자입니다.

불리언 감지 (주의사항 포함)

명확한 불리언: "true", "false", "yes", "no"

하지만 "0""1"은 어떨까요?

// 문제: 세율을 불리언으로 변환
// 컬럼: [0, 0.05, 0.1, 0.2]  -> [false, 0.05, 0.1, 0.2] 
Enter fullscreen mode Exit fullscreen mode

제 해결책: 순수 숫자 값은 불리언으로 변환하지 않습니다.

function applyBooleanNormalization(rows, booleanConfig) {
  const trueValues = new Set(booleanConfig.true.map(v => v.toLowerCase()));
  const falseValues = new Set(booleanConfig.false.map(v => v.toLowerCase()));

  return rows.map((row, rowIndex) => {
    if (rowIndex === 0) return row; // 헤더 스킵

    return row.map(cell => {
      if (cell == null) return cell;

      const cellStr = String(cell).toLowerCase().trim();

      // 순수 숫자면 스킵 (숫자 컬럼에서 0 -> false 방지)
      if (/^-?\d+(\.\d+)?$/.test(cellStr)) {
        return cell;
      }

      if (trueValues.has(cellStr)) return "true";
      if (falseValues.has(cellStr)) return "false";

      return cell;
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Null 값 처리

웹 데이터는 "값 없음"을 다양하게 표현합니다:

  • "" (빈 문자열)
  • "N/A", "n/a", "NA"
  • "-", "--"
  • "null", "NULL"
  • "none", "None"
  • "." (정부 데이터에서 발견)

설정 가능한 null 감지:

function applyNullValues(rows, nullPatterns) {
  const nullSet = new Set(nullPatterns.map(v => v.toLowerCase().trim()));

  return rows.map((row, rowIndex) => {
    if (rowIndex === 0) return row; // 헤더 스킵

    return row.map(cell => {
      if (cell == null) return null;

      const cellStr = String(cell).toLowerCase().trim();
      return nullSet.has(cellStr) ? null : cell;
    });
  });
}

// 사용법
applyNullValues(rows, ["N/A", "n/a", "-", "--", "null", "none", "."]);
Enter fullscreen mode Exit fullscreen mode

파이프라인

전체 타입 추론 및 정제 파이프라인:

function cleanTable(tableInfo, config) {
  let rows = cloneRows(tableInfo.rows);

  // 1. 모든 문자열 트림
  if (config.trimStrings) {
    rows = applyTrimStrings(rows);
  }

  // 2. null 패턴을 실제 null로 변환
  if (config.nullValues?.length) {
    rows = applyNullValues(rows, config.nullValues);
  }

  // 3. 불리언 정규화 (숫자보다 먼저, 0->false 이슈 방지)
  if (config.booleans) {
    rows = applyBooleanNormalization(rows, config.booleans);
  }

  // 4. 숫자 정규화 (EU/US 형식 처리)
  if (config.normalizeNumbers) {
    rows = applyNumberNormalization(rows);
  }

  // 5. 날짜 정규화 (선택적, 형식별)
  if (config.normalizeDates) {
    rows = applyDateNormalization(rows, config.dateFormat);
  }

  return { ...tableInfo, rows };
}
Enter fullscreen mode Exit fullscreen mode

엣지 케이스 테스트

타입 추론에는 많은 엣지 케이스가 있습니다. 테스트 스위트를 유지합니다:

// 숫자 정규화 테스트
assertEqual(normalizeNumber("1,234.56"), 1234.56);    // 미국식
assertEqual(normalizeNumber("1.234,56"), 1234.56);    // 유럽식
assertEqual(normalizeNumber("€1.234,56"), 1234.56);   // 통화 포함
assertEqual(normalizeNumber("45,5%"), 0.455);         // 퍼센트
assertEqual(normalizeNumber("1.234"), 1234);          // 유럽식 천 단위
assertEqual(normalizeNumber("1.23"), 1.23);           // 소수

// 불리언 테스트
assertEqual(inferValueType("Yes"), DATA_TYPES.BOOLEAN);
assertEqual(inferValueType("0"), DATA_TYPES.INTEGER);  // 불리언 아님
assertEqual(inferValueType("0.5"), DATA_TYPES.NUMBER); // 불리언 아님
Enter fullscreen mode Exit fullscreen mode

내보내기 통합

SQL로 내보낼 때 추론된 타입 사용:

function inferSqlColumnTypes(rows) {
  const header = rows[0];
  const dataRows = rows.slice(1);

  return header.map((_, colIndex) => {
    const columnValues = dataRows.map(row => row[colIndex]);
    const { type } = inferColumnType(columnValues);

    switch (type) {
      case DATA_TYPES.INTEGER: return "INTEGER";
      case DATA_TYPES.NUMBER: return "REAL";
      case DATA_TYPES.BOOLEAN: return "BOOLEAN";
      case DATA_TYPES.DATE: return "DATE";
      default: return "TEXT";
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

배운 교훈

  1. 컬럼 컨텍스트가 셀 분석을 이깁니다. 단일 "1"은 모호하지만, 정수 컬럼은 아닙니다.

  2. 변환에 보수적이 되세요. 잘못된 변환으로 데이터를 손상시키는 것보다 문자열로 두는 것이 낫습니다.

  3. 설정 가능하게 만드세요. 도메인마다 null 값, 불리언 표현, 숫자 형식이 다릅니다.

  4. 실제 데이터로 테스트하세요. 합성 테스트로는 실제 웹 테이블의 혼돈을 놓칩니다.

이 시스템이 HTML Table Exporter의 데이터 정제를 구동합니다. 이런 문제들의 실용적 예시는 웹사이트에서 테이블을 Excel로 복사하기에서 적절한 타입 추론이 깨진 서식을 어떻게 수정하는지 확인할 수 있습니다.

PRO 버전에서는 내보내기 프로필별로 이 규칙들을 설정할 수 있습니다. gauchogrid.com/ko/html-table-exporter에서 자세히 알아보거나 Chrome 웹 스토어에서 사용해보세요.


다른 도메인에서 타입 추론을 구축 중이신가요? 엣지 케이스를 들려주세요.

Top comments (0)