Python으로 여러 CSV 파일 합치기 — UTF-8·CP949 한글 깨짐과 헤더·중복 행까지 직접 해결

여러 부서나 쇼핑몰에서 받은 CSV 파일을 하나로 합치려고 하면 단순히 행을 이어 붙이는 것만으로 끝나지 않았습니다. 실제로 테스트해 보니 UTF-8-SIG, UTF-8, CP949가 섞여 있었고, 같은 열을 사용하면서도 헤더 순서가 다른 파일이 있었습니다. 빈 행과 완전히 같은 중복 행도 포함되어 있었습니다.

이번에는 이런 파일을 한꺼번에 분석하고, 문제가 없는 데이터만 미리 확인한 뒤 안전하게 저장하는 Windows용 CSV 병합·정리 도구를 Python과 tkinter로 직접 제작했습니다. 저장 후에는 결과 파일을 다시 열어 인코딩·구분자·열 수·행 수·데이터 값까지 재검증하도록 만들었습니다.

이번 테스트에서 확인한 결과

검증 항목준비한 테스트 데이터실제 결과
문자 인코딩UTF-8-SIG, UTF-8, CP949세 형식을 각각 판별해 정상적으로 읽었습니다.
헤더 순서열 이름은 같지만 순서가 다른 파일기준 헤더 순서로 다시 정렬했습니다.
빈 행내용이 없는 행 1개분석 과정에서 제외했습니다.
중복 데이터완전히 같은 행 2개선택 옵션에 따라 제거했습니다.
잘못된 헤더누락·추가 열, 중복 열 이름오류 사유를 표시하고 병합을 차단했습니다.
최종 저장입력 데이터 8행중복 2행을 제거해 6행을 UTF-8-SIG로 저장했습니다.
직접 만든 테스트 CSV로 확인한 결과입니다.

1. 단순 병합보다 먼저 해결해야 했던 문제

CSV는 확장자가 같아도 내부 형식이 같다는 보장이 없었습니다. 특히 Windows에서 만들어진 한글 CSV는 CP949인 경우가 있고, 다른 프로그램에서 내보낸 파일은 UTF-8 또는 UTF-8-SIG일 수 있었습니다. 인코딩을 하나로 고정해서 읽으면 일부 파일에서 한글이 깨지거나 UnicodeDecodeError가 발생했습니다.

Python tkinter로 제작한 CSV 병합 정리 도구의 초기 화면
파일을 선택하기 전의 CSV 병합·정리 도구입니다. 분석, 미리보기, 저장, 저장 파일 재검증을 한 화면에서 진행하도록 구성했습니다.

그래서 파일을 선택하자마자 합치지 않고, 먼저 각 파일의 인코딩·구분자·데이터 행·열 수를 분석하도록 순서를 나눴습니다. 사용자가 결과를 확인하기 전에는 원본 파일을 수정하지 않습니다.

2. UTF-8-SIG와 CP949를 순서대로 판별했습니다

인코딩 판별에서 먼저 확인한 것은 UTF-8 BOM이었습니다. BOM이 있으면 utf-8-sig로 읽고, 그렇지 않으면 UTF-8과 CP949를 차례로 시도했습니다. 모든 예외를 무시하는 방식은 잘못 읽은 데이터를 정상으로 오인할 수 있어 사용하지 않았습니다.

def decode_csv_bytes(path: Path) -> tuple[str, str]:
    raw = path.read_bytes()

    if raw.startswith(codecs.BOM_UTF8):
        return raw.decode("utf-8-sig"), "UTF-8-SIG"

    for label, encoding in SUPPORTED_ENCODINGS:
        try:
            return raw.decode(encoding), label
        except UnicodeDecodeError:
            continue

    raise CsvReadError("UTF-8 또는 CP949로 읽을 수 없습니다.")

실제 분석에서는 첫 번째 파일이 UTF-8-SIG, 두 번째 파일이 CP949, 세 번째 파일이 UTF-8로 판별되었습니다. 인코딩이 서로 달라도 한글 상품명이 정상적으로 표시되었습니다.

UTF-8-SIG CP949 UTF-8 CSV 파일을 각각 분석한 화면
서로 다른 세 가지 인코딩을 자동으로 판별했습니다. 입력 8행 중 중복 2행을 제거해 결과 6행으로 계산되었습니다.

3. 헤더 위치가 아니라 열 이름을 비교했습니다

두 번째 실수하기 쉬운 부분은 헤더 순서였습니다. 예를 들어 한 파일이 날짜, 상품, 수량, 금액 순서이고 다른 파일이 상품, 금액, 날짜, 수량 순서여도 열 이름이 모두 같다면 병합할 수 있습니다. 반대로 열 개수만 같고 이름이 다르면 합치면 안 됩니다.

# 가장 많이 사용된 헤더 구성을 기준으로 선택합니다.
header_counts = Counter(frozenset(data.header) for data in loaded_files)
most_common_header_set = header_counts.most_common(1)[0][0]
master_header = next(
    data.header for data in loaded_files
    if frozenset(data.header) == most_common_header_set
)

missing = [name for name in master_header if name not in data.header]
extra = [name for name in data.header if name not in master_header]

처음에는 파일명 순서상 첫 번째 파일의 헤더를 무조건 기준으로 삼았습니다. 그런데 오류 확인용 파일 이름이 앞에 오면 잘못된 헤더가 전체 기준이 되는 문제가 생겼습니다. 이를 실제 테스트에서 발견한 뒤, 여러 파일에서 가장 많이 등장한 헤더 구성을 기준으로 선택하도록 수정했습니다. 같은 열 이름을 가진 파일은 기준 순서에 맞춰 행을 다시 구성했습니다.

4. 완전히 같은 행만 중복으로 제거했습니다

중복 판정은 특정 열 하나가 아니라 기준 헤더의 모든 값을 묶어 비교했습니다. 날짜나 상품명만 같은 정상 데이터가 사라지지 않도록 하기 위해서입니다.

seen: set[tuple[str, ...]] = set()

for row in data.rows:
    reordered = {column: row[column] for column in master_header}
    key = tuple(reordered[column] for column in master_header)

    if remove_duplicates and key in seen:
        duplicates += 1
        continue

    seen.add(key)
    merged_rows.append(reordered)
CSV 중복 제거 후 병합 데이터 6행을 미리 보는 화면
저장하기 전에 날짜·상품·수량·금액 4개 열과 최종 6행을 직접 확인했습니다.

5. 문제가 있는 CSV는 조용히 건너뛰지 않았습니다

오류 파일을 정상 파일처럼 합치면 결과를 신뢰하기 어렵습니다. 별도의 오류 예제를 만들어 누락 열, 추가 열, 중복 열 이름을 확인했습니다. 기준에 맞지 않는 파일은 빨간색으로 표시하고 병합 버튼을 실행할 수 없도록 했습니다.

누락 열과 중복 열 이름이 있는 CSV 파일을 차단한 화면
금액 열이 누락되고 비고 열이 추가된 파일, 수량 열 이름이 중복된 파일을 각각 오류로 판별했습니다.

이 과정에서 CSV 구분자 자동 판별도 보완했습니다. 처음에는 세미콜론 CSV 안에 줄바꿈이 포함된 따옴표 셀이 있을 때 자동 판별 결과가 흔들렸습니다. 판별된 구분자가 실제 헤더를 여러 열로 나누는지 다시 검사하고, 필요하면 헤더에 나타난 쉼표·세미콜론·탭의 개수로 재선택하도록 수정했습니다.

6. 결과 파일은 임시 파일을 거쳐 UTF-8-SIG로 저장했습니다

저장 중 오류가 발생했는데 불완전한 CSV가 결과 파일로 남는 상황도 막고 싶었습니다. 같은 폴더에 임시 파일을 먼저 완성한 뒤 최종 파일명으로 교체했습니다. 이미 같은 이름의 결과가 있으면 덮어쓰지 않고 중단하도록 했습니다.

with tempfile.NamedTemporaryFile(
    mode="w",
    encoding="utf-8-sig",
    newline="",
    delete=False,
    dir=output_path.parent,
    suffix=".tmp",
) as temp_file:
    writer = csv.DictWriter(temp_file, fieldnames=master_header)
    writer.writeheader()
    writer.writerows(rows)

temp_path.replace(output_path)
CSV 6행을 UTF-8-SIG로 저장한 완료 화면
단일 EXE에서도 6행이 UTF-8-SIG CSV로 정상 저장되었습니다.

7. 엑셀 없이 저장 결과를 다시 검증했습니다

테스트한 PC에는 Microsoft Excel이 설치되어 있지 않았습니다. 그래서 저장 성공 메시지만 믿지 않고 프로그램이 방금 저장한 파일을 다시 읽도록 기능을 추가했습니다. 재검증에서는 인코딩이 UTF-8-SIG인지, 구분자가 쉼표인지, 헤더와 행 수가 맞는지, 미리보기의 값과 순서까지 같은지 확인했습니다.

saved = read_csv_file(output_path)

if saved.encoding != "UTF-8-SIG":
    issues.append("저장 인코딩이 다릅니다.")
if saved.delimiter != ",":
    issues.append("결과 구분자가 쉼표가 아닙니다.")
if saved.header != preview_header:
    issues.append("저장된 헤더가 미리보기와 다릅니다.")
if saved.rows != preview_rows:
    issues.append("저장된 데이터 값 또는 순서가 다릅니다.")
저장된 CSV를 다시 열어 UTF-8-SIG 4열 6행을 검증한 화면
EXE에서 저장 파일을 다시 열어 UTF-8-SIG, 쉼표, 4열, 6행과 미리보기 데이터 일치를 최종 확인했습니다.

8. 마지막에는 단일 EXE로 실행했습니다

Python이 설치되지 않은 Windows PC에서도 실행할 수 있도록 PyInstaller의 onefile 방식으로 변환했습니다. 이번 글은 CSV 처리 과정이 중심이므로 EXE 제작 과정은 아래 명령으로만 정리했습니다.

py -m PyInstaller --noconfirm --onefile --windowed --clean --name "JKLAB365_CSV_Merger" main.py

dist 폴더에는 약 11.8MB 크기의 JKLAB365_CSV_Merger.exe 단일 파일이 생성되었습니다. EXE에서 다시 파일 분석, 중복 제거, 저장, 저장 파일 재검증까지 실행해 Python 소스 실행 때와 같은 결과가 나오는 것을 확인했습니다.

마무리

이번 프로그램에서 중요했던 부분은 CSV를 빠르게 합치는 기능보다 잘못된 결과를 만들지 않는 과정이었습니다. 서로 다른 한글 인코딩을 구분하고, 열 이름이 같은지 검사하고, 완전히 같은 행만 제거한 뒤, 저장된 파일을 다시 읽어 미리보기와 비교했습니다.

자동 테스트도 함께 작성해 인코딩 혼합, 헤더 순서 변경, 헤더 불일치, 중복 열 이름, 빈 행, 중복 행, 세미콜론과 따옴표 안 줄바꿈, 결과 파일 덮어쓰기 방지, 저장 후 변조 감지를 확인했습니다. 총 9개의 테스트가 모두 통과한 뒤 EXE에서 실제 조작 테스트를 진행했습니다.

전체 코드를 그대로 나열하기보다는 문제가 발생하기 쉬운 부분을 중심으로 정리했습니다. 비슷한 업무 자동화 프로그램을 만들 때도 입력 파일을 바로 변경하지 않고, 분석 → 미리보기 → 실행 → 결과 재검증 순서로 구성하면 실수를 줄이는 데 도움이 됩니다.

참고자료

Leave a Comment