같아 보이는 CSV 열 이름의 유니코드 충돌 찾기

CSV의 열 이름이 둘 다 ’수량’처럼 보여도 저장된 문자의 조합은 다를 수 있습니다. 원래 이름과 NFC로 비교한 이름을 열 위치별로 나란히 기록하고, 서로 다른 두 원시 이름이 같은 이름으로 모이면 인수를 멈추세요. 한 열을 고르거나 원본의 이름을 자동으로 바꾸면 어느 값이 어느 열의 자료였는지 놓칠 수 있습니다.

이 글에서는 상품 이동 자료의 id와 수량 열을 확인하는 상황을 가정합니다. 실제 업무 자료 대신 네 가상 CSV를 사용합니다. 완성형 한글과 분해된 한글이 따로 있는 두 파일에서는 고유 대응을 확인하고, 두 표기가 한 파일에 함께 있는 경우에는 충돌 한 쌍을 찾습니다. 폭 없는 문자가 붙은 이름은 NFC 후에도 남으므로 별도 미일치로 보류합니다. 결과는 진단 JSON이며, 원본 CSV의 머리글과 값은 변경하지 않습니다.

실습 안전 안내: 현재 본문 코드와 공개 ZIP의 코드는 입력 크기를 1,000,000바이트 이하로 제한하지만, 열 수·충돌쌍 수·보고서 크기는 별도로 제한하지 않습니다. 입력 한도 안에서도 충돌쌍과 메모리 사용량이 크게 늘 수 있습니다. 보완 첨부가 공개되기 전까지는 ZIP에 동봉된 네 가상 CSV로만 실습하고, 업무 파일이나 열이 많은 CSV의 진단은 보류하세요. 보완본은 로컬 검수를 마쳤지만 아직 공개 첨부에 적용되지 않았습니다.

1 한글이 같아 보여도 문자 코드는 다를 수 있습니다

이 실습에서 완성형 수량은 코드 포인트 두 개로 저장됩니다.

수량: U+C218 U+B7C9

같은 문자열을 NFD로 분해하면 다섯 개가 됩니다. 화면에서는 비슷하게 조합되어 보일 수 있습니다.

수량: U+1109 U+116E U+1105 U+1163 U+11BC

NFD는 정준 분해, NFC는 정준 분해 후 가능한 문자를 다시 결합하는 형식입니다. Python unicodedata 공식 문서가 이 차이를 설명합니다. 이 예제에서는 unicodedata.normalize("NFC", 이름)을 비교용 값에만 적용합니다. 보고서에는 원시 이름도 그대로 남깁니다.

이름이 NFC에서 같아지는 관계를 정준 동등성이라고 합니다. 보기 비슷한 모든 글자가 같은 문자는 아닙니다. NFC는 대소문자를 맞추거나 공백과 제로폭 문자를 지우는 규칙이 아닙니다. 전각 영문을 일반 영문으로 바꾸는 NFKC도 이번 예제에서 사용하지 않습니다.

2 네 가상 입력을 순서대로 확인합니다

ZIP을 새 실습 폴더에 풀고 fixtures의 네 파일을 텍스트 편집기로 보기만 합니다. 한글을 눈으로 다시 입력하면 저장 조합이 달라질 수 있으므로, 실습에는 제공한 파일을 그대로 사용하세요. 아래 둘째 파일의 한글은 실제 NFD 문자열입니다.

fixtures/nfc.csv는 완성형 이름 하나가 있습니다.

id,수량
001,2
002,3

fixtures/nfd.csv는 수량 이름만 NFD로 저장했습니다.

id,수량
001,2
002,3

fixtures/collision.csv에는 완성형과 NFD 이름이 함께 있습니다. 위치 2와 3의 이름이 NFC에서 둘 다 수량이 됩니다. 두 열의 값도 2·20과 3·30으로 다르게 둬, 어느 열을 골라도 된다고 생각하지 않게 했습니다.

id,수량,수량
001,2,20
002,3,30

fixtures/zero_width.csv의 두 번째 이름 앞에는 U+200B가 한 개 있습니다. 화면에서 보이지 않을 수 있습니다. 이 파일의 원시 이름은 코드 포인트 U+200B U+C218 U+B7C9이며 NFC 후에도 같습니다.

id,​수량
001,2
002,3

U+200B는 ZERO WIDTH SPACE입니다. 보고서에서 raw_repr에 \u200b가 있고 코드 포인트의 첫 항목이 U+200B인지 확인합니다. 이 글의 보류는 이 문자를 무조건 지워도 된다는 판단이 아닙니다.

3 열 위치와 두 이름을 보존하는 전체 코드

다음은 ZIP의 code/inspect_headers.py와 동일한 전체 코드입니다. Python 표준 라이브러리만 사용합니다. 파일을 지정해 읽고 첫 CSV 레코드의 각 필드를 목록으로 받습니다. Python csv 공식 문서는 reader가 행을 문자열 목록으로 반환하는 동작을 설명합니다. 이 예제는 첫 레코드만 열 이름 진단에 사용합니다.

각 열마다 위치, 원시 이름, NFC 이름, 각각의 repr()와 코드 포인트를 기록합니다. NFC 이름을 기준으로 위치를 모아 충돌하는 모든 쌍을 보고하므로 첫 열이나 마지막 열을 선택하지 않습니다.

"""Report Unicode NFC header correspondence; never rewrite a CSV."""
import argparse
import csv
import hashlib
import io
import json
import sys
import unicodedata
from itertools import combinations
from pathlib import Path

REFERENCE_NFC = ("id", "수량")
MAX_BYTES = 1_000_000


def codepoints(text):
    return [f"U+{ord(char):04X}" for char in text]


def inspect(source):
    with Path(source).open("rb") as handle:
        raw_bytes = handle.read(MAX_BYTES + 1)
    if len(raw_bytes) > MAX_BYTES:
        raise ValueError("실습 입력 한도 1,000,000바이트를 초과했습니다")
    try:
        text = raw_bytes.decode("utf-8-sig", errors="strict")
    except UnicodeDecodeError as error:
        raise ValueError("UTF-8 해석 실패") from error
    try:
        header = next(csv.reader(io.StringIO(text, newline=""), strict=True), None)
    except csv.Error as error:
        raise ValueError("CSV 머리글 구문 오류: " + str(error)) from error
    if header is None or not header:
        raise ValueError("빈 CSV 또는 첫 레코드가 비어 있습니다")

    columns = []
    groups = {}
    for position, raw_name in enumerate(header, 1):
        nfc_name = unicodedata.normalize("NFC", raw_name)
        columns.append({
            "position": position, "raw": raw_name, "nfc": nfc_name,
            "raw_repr": repr(raw_name), "nfc_repr": repr(nfc_name),
            "raw_codepoints": codepoints(raw_name),
            "nfc_codepoints": codepoints(nfc_name),
            "changed_for_comparison": raw_name != nfc_name,
        })
        groups.setdefault(nfc_name, []).append(position)
    collisions = []
    for nfc_name, positions in groups.items():
        for left, right in combinations(positions, 2):
            collisions.append({
                "nfc": nfc_name, "positions": [left, right],
                "raw_names_differ": header[left - 1] != header[right - 1],
            })
    unmatched = [item["position"] for item in columns
                 if item["nfc"] not in REFERENCE_NFC]
    if collisions:
        status = "REJECT_COLLISION"
    elif unmatched:
        status = "HOLD_UNMATCHED"
    else:
        status = "UNIQUE_MAPPING"
    return {
        "normalization": "NFC", "unicode_database": unicodedata.unidata_version,
        "reference_nfc": list(REFERENCE_NFC), "status": status,
        "columns": columns, "collisions": collisions,
        "collision_pair_count": len(collisions),
        "unmatched_positions": unmatched,
        "source_sha256": hashlib.sha256(raw_bytes).hexdigest(),
        "header_changes_written": 0, "data_changes_written": 0,
    }


def write_report(source, target):
    source, target = Path(source), Path(target)
    if source.resolve() == target.resolve():
        raise ValueError("입력과 보고서는 서로 다른 경로여야 합니다")
    if target.suffix.lower() != ".json":
        raise ValueError("보고서는 새 .json 파일로 지정하세요")
    report = inspect(source)
    rendered = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
    target.parent.mkdir(parents=True, exist_ok=True)
    try:
        with target.open("x", encoding="utf-8", newline="\n") as handle:
            handle.write(rendered)
    except FileExistsError as error:
        raise ValueError("기존 보고서 보존: 새 --report 이름을 지정하세요") from error
    return report


def main():
    parser = argparse.ArgumentParser(description="CSV 열 이름의 NFC 대응·충돌 진단")
    parser.add_argument("--input", required=True)
    parser.add_argument("--report", required=True)
    args = parser.parse_args()
    try:
        report = write_report(args.input, args.report)
    except (ValueError, OSError) as error:
        print("오류: " + str(error), file=sys.stderr)
        return 4
    status = report["status"]
    if status == "REJECT_COLLISION":
        message = f"거부: NFC 충돌 {report['collision_pair_count']}쌍"
        exit_code = 2
    elif status == "HOLD_UNMATCHED":
        message = f"보류: NFC 미일치 {len(report['unmatched_positions'])}열"
        exit_code = 3
    else:
        message = f"NFC 고유 대응: {len(report['columns'])}열"
        exit_code = 0
    print(f"{message} / 원본 변경 0 / {args.report}")
    return exit_code


if __name__ == "__main__":
    raise SystemExit(main())

REFERENCE_NFC는 비교 대상으로 정한 이름 id와 수량입니다. 각 원시 이름이 이 이름들에 대응하는지 확인하되, 필수 열 유무나 데이터 레코드의 너비·수량 내용까지 검사하지는 않습니다. UNIQUE_MAPPING은 읽은 열 이름의 NFC 대응에 충돌과 미일치가 없다는 뜻입니다. 파일 전체 검수나 업무 제출 승인을 뜻하지 않습니다. 보고서의 source_sha256은 이번 실행에서 읽은 바이트 스냅샷의 해시입니다. 파일을 잠그거나 실행 이후의 변경을 감시하지 않습니다.

UTF-8을 엄격하게 해석하며 파일 맨 앞의 BOM 한 개는 utf-8-sig로 건너뜁니다. Python codecs 공식 문서에 설명된 입력 해독 동작입니다. BOM을 NFC가 지운 것으로 해석하지 않습니다. 두 번째 BOM이 열 이름에 남으면 U+FEFF로 보고되어 미일치로 보류됩니다.

실습 입력은 1,000,000바이트 이하로 제한합니다. 빈 첫 레코드나 해석할 수 없는 UTF-8·CSV 머리글은 진단 자체를 중단합니다. 첫 레코드를 읽을 수 있는 충돌·미일치 사례에는 진단 JSON을 남깁니다.

4 두 고유 대응 파일을 실행합니다

터미널 작업 위치를 ZIP을 푼 실습 폴더로 정하고 다음 명령을 각각 실행합니다. python을 사용하는 Python 3 환경 기준이며 컴퓨터에서 실행 명령이 python3라면 첫 단어만 바꿉니다.

python code/inspect_headers.py --input fixtures/nfc.csv --report reports/nfc.json
python code/inspect_headers.py --input fixtures/nfd.csv --report reports/nfd.json

각 명령의 기대 콘솔 출력입니다. 종료 코드는 둘 다 0입니다.

NFC 고유 대응: 2열 / 원본 변경 0 / reports/nfc.json
NFC 고유 대응: 2열 / 원본 변경 0 / reports/nfd.json

reports/nfd.json에서 위치 2의 항목을 확인합니다. 다음은 해당 열의 실제 기대 내용만 발췌한 것입니다. 전체 비교용 보고서는 ZIP의 expected/nfd.json에 있습니다.

{
  "position": 2,
  "raw": "수량",
  "nfc": "수량",
  "raw_repr": "'수량'",
  "nfc_repr": "'수량'",
  "raw_codepoints": [
    "U+1109",
    "U+116E",
    "U+1105",
    "U+1163",
    "U+11BC"
  ],
  "nfc_codepoints": [
    "U+C218",
    "U+B7C9"
  ],
  "changed_for_comparison": true
}

raw는 다섯 코드 포인트의 원래 이름이고 nfc는 비교용 수량입니다. changed_for_comparison: true는 메모리 안의 비교용 문자열이 달라졌다는 뜻입니다. 파일의 머리글을 변경했다는 뜻이 아닙니다. 보고서의 header_changes_written과 data_changes_written은 모두 0이며, 원본 파일의 이름과 값은 그대로 남습니다.

nfc.csv도 UNIQUE_MAPPING이지만 위치 2의 원시 코드 포인트가 이미 U+C218, U+B7C9이므로 비교용 변화는 없습니다. 두 보고서를 대조하면 정상적으로 고유 대응이 되는 두 저장 방식을 구별할 수 있습니다.

5 충돌과 보류를 다른 결과로 읽습니다

나머지 두 명령을 각각 실행합니다.

python code/inspect_headers.py --input fixtures/collision.csv --report reports/collision.json
python code/inspect_headers.py --input fixtures/zero_width.csv --report reports/zero_width.json

기대 콘솔 출력입니다.

거부: NFC 충돌 1쌍 / 원본 변경 0 / reports/collision.json
보류: NFC 미일치 1열 / 원본 변경 0 / reports/zero_width.json

충돌의 종료 코드는 2, 미일치 보류는 3입니다. 두 경우 모두 JSON 보고서는 생기지만, 열 이름을 바꾼 CSV는 생기지 않습니다. 보고서가 있다는 이유로 자료를 인수하지 않습니다.

collision.json의 status는 REJECT_COLLISION, collision_pair_count는 1입니다. collisions에는 다음 항목이 있습니다.

[
  {
    "nfc": "수량",
    "positions": [
      2,
      3
    ],
    "raw_names_differ": true
  }
]

열 위치는 첫 열을 1로 셉니다. 위치 2와 3의 원시 이름은 다르지만 NFC 이름은 같습니다. 이 상태에서 NFC 이름을 사전 키로 쓰면 두 열을 고유하게 구분할 수 없습니다. 위치 2의 값 2와 위치 3의 값 20 중 하나를 선택하거나 두 값을 더하지 않습니다.

zero_width.json의 status는 HOLD_UNMATCHED, unmatched_positions는 [2]입니다. 위치 2의 원시·NFC 코드 포인트는 모두 U+200B, U+C218, U+B7C9입니다. 기준 이름 수량과는 다른 상태로 남습니다. collision_pair_count는 0이므로 NFC 충돌과 구분해서 읽습니다.

서로 다른 NFC·NFD 단독 CSV는 각각 고유 대응 두 개, 같은 CSV의 열 2·3은 NFC 충돌 한 쌍, U+200B가 붙은 수량은 미일치 보류인 세 판정 도해
설명용 도해: NFC·NFD 단독 파일은 각각 고유 대응 2개, 두 이름 공존은 충돌 1쌍, U+200B 미일치는 보류 1개입니다. +는 코드포인트 조합 표시입니다. 진단 JSON만 남기고 원본 CSV는 바꾸지 않습니다.

6 발견한 차이를 원본 담당자에게 확인합니다

충돌을 발견했다면 위치 2와 3의 원시 repr, 원시 코드 포인트, 공통 NFC 이름을 함께 전달할 수 있는 검토 기록으로 남깁니다. 두 열이 같은 의미를 중복 내보낸 것인지, 서로 다른 업무 항목인데 이름만 비슷한 것인지 담당자에게 확인합니다. 원본을 보관하고 의미와 열 이름을 확정한 새 사본을 받아 다시 진단합니다. 프로그램이 한 열을 버리거나 뒤에 숫자를 붙여 해결하지 않습니다.

NFD 단독 파일은 고유 대응을 확인할 수 있습니다. 후속 프로그램이 원시 이름을 요구하는지 NFC 이름을 요구하는지 확인하고, 사용할 대응표와 변환 규칙을 따로 합의합니다. 이 진단에서 대응을 찾았다는 사실만으로 원본 열명 변경 권한을 얻는 것은 아닙니다.

제로폭 이름은 raw_repr의 \u200b와 첫 코드 포인트 U+200B부터 확인합니다. 내보내기 과정에서 붙었는지, 의도한 문자였는지 확인한 뒤 원본 담당자가 새 사본을 만들도록 합니다. NFC를 반복 적용해도 없어지지 않습니다. 문자를 삭제할 정책이 필요하면 이번 NFC 비교와 분리해서 정하고, 기존 파일을 덮지 않습니다.

공백을 붙인 " 수량 ", 대문자 ID, 전각 id도 이 코드가 자동으로 고치지 않습니다. 각 이름을 그대로 보고합니다. 보이지 않는 문자 종류를 셀 안에서 확인하는 실습은 엑셀 CLEAN 함수의 문자 진단을 함께 참고할 수 있습니다.

같은 보고서 이름으로 다시 실행하면 기존 보고서를 덮지 않고 오류 종료 코드 4로 멈춥니다. --report reports/nfd-2.json처럼 새 이름을 사용하세요. 권한이나 저장 장치 문제로 쓰는 도중 실패한 보고서는 불완전할 수 있으므로 완료 결과로 사용하지 않고 보관한 뒤 새 경로에서 다시 실행합니다.

7 원본 불변과 비교 규칙을 다시 시험합니다

실습 폴더에서 다음 두 명령을 실행합니다.

python -m unittest discover -s tests -v
python tests/verify_examples.py

첫 명령의 기대 결과는 20개 테스트 통과와 OK입니다. 둘째 명령의 기대 메시지는 다음과 같습니다.

고유 대응 2개 / NFC 충돌 1쌍 / 미일치 보류 1개 / 변경 CSV 0개 / 원본 SHA-256 일치

네 핵심 사례 외에 양끝 공백 보존, 대소문자 보존, 전각 문자 보존, BOM 해독, 따옴표가 있는 머리글, 무효 UTF-8, 기존 보고서 보호 등을 검사합니다. 값에 앞자리 0·공백·문자를 넣은 파일도 원본 바이트가 유지되는지 대조합니다. 머리글만 있는 파일은 열 이름 진단이 가능하지만 데이터가 있다는 뜻은 아닙니다.

Python 3.12.14에서 20개 테스트를 실제 실행해 모두 통과했고 건너뜀은 0개였습니다. 네 가상 파일에서는 고유 대응 2개, NFC 충돌 1쌍, 미일치 보류 1개와 진단 JSON 4개를 확인했습니다. 원본 네 파일의 SHA-256은 실행 전후 같고 변경된 CSV 출력은 0개였습니다. 실행 환경의 Unicode 데이터베이스 버전은 15.0.0이며 ZIP의 test_results에 환경·명령·보고서를 기록했습니다.

테스트는 새 임시 실습 폴더의 가상 자료만 사용하고 끝나면 그 폴더를 정리합니다. 사용자 폴더를 검색하거나 업무 파일에 접근하지 않습니다. 보고서의 unicode_database는 Python 버전에 따라 다를 수 있으므로 다른 환경에서는 이 버전과 이름·코드 포인트·판정을 함께 대조합니다.

실습 자료와 공식 참고 자료

실습 ZIP 다운로드

실습 ZIP에는 본문과 같은 코드, 네 가상 CSV, 네 비교용 진단 JSON, README, 테스트와 실행 기록이 들어 있습니다. 원시 이름을 다시 입력하기보다 제공한 파일로 재현하고, 충돌이 생긴 두 위치와 보류된 문자를 보고서에서 확인하세요.

실습 입력·기대 결과 XLSX 다운로드

XLSX의 실행안내와 원시CSV와진단 시트에서 원시 열 이름·코드 포인트와 네 파일의 기대 판정을 확인할 수 있습니다. 저장된 문자 조합을 진단하는 Python 실습은 ZIP의 원본 CSV와 코드로 진행하세요.

공식 문서 조회일은 2026-10-05입니다.

작성자 소개