여러 부서나 쇼핑몰에서 받은 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로 저장했습니다. |
1. 단순 병합보다 먼저 해결해야 했던 문제
CSV는 확장자가 같아도 내부 형식이 같다는 보장이 없었습니다. 특히 Windows에서 만들어진 한글 CSV는 CP949인 경우가 있고, 다른 프로그램에서 내보낸 파일은 UTF-8 또는 UTF-8-SIG일 수 있었습니다. 인코딩을 하나로 고정해서 읽으면 일부 파일에서 한글이 깨지거나 UnicodeDecodeError가 발생했습니다.

그래서 파일을 선택하자마자 합치지 않고, 먼저 각 파일의 인코딩·구분자·데이터 행·열 수를 분석하도록 순서를 나눴습니다. 사용자가 결과를 확인하기 전에는 원본 파일을 수정하지 않습니다.
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로 판별되었습니다. 인코딩이 서로 달라도 한글 상품명이 정상적으로 표시되었습니다.

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)

5. 문제가 있는 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)

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("저장된 데이터 값 또는 순서가 다릅니다.")

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