스크립트·파일 자동화

헤더를 유지하면서 큰 CSV를 작은 파일로 나누기

CSV를 데이터 레코드 수에 따라 나누고, 각 출력 파일에 헤더를 넣으면서 원본은 그대로 보존합니다. 7개 레코드로 구성된 합성 예제를 이용해 분할 경계를 확인하고, 모든 레코드가 원래 순서대로 유지되는지 검증합니다.

목차 보기

이런 분께CSV 내용을 직접 편집하지 않고 내보낸 파일을 여러 개의 작은 파일로 나누려는 사람을 위한 안내입니다.

준비사항
  • Python 3.12와 해당 버전을 실행하는 터미널 명령이 준비되어 있어야 합니다.
  • UTF-8 파일을 저장하고 따옴표 안의 여러 줄 텍스트를 유지할 수 있는 텍스트 편집기가 필요합니다.
  • 입력 파일을 읽을 권한이 있고 출력 파일을 저장할 디스크 공간이 충분한 작업 폴더가 필요합니다.
  • Python 표준 라이브러리인 csv와 pathlib만 사용합니다.

01무엇을 한 행으로 셀지 정하기

이 스크립트는 지정한 최대 데이터 레코드 수에 맞춰 번호가 붙은 CSV 파일을 만듭니다. 각 파일은 동일한 헤더로 시작합니다. 헤더는 분할 기준 개수에 포함하지 않습니다. 기준이 3이고 데이터 레코드가 7개라면, 각 파일에는 데이터 레코드가 3개, 3개, 1개씩 들어갑니다.

CSV 레코드가 반드시 텍스트의 물리적인 한 줄과 일치하는 것은 아닙니다. 따옴표로 감싼 필드에는 줄바꿈이 들어갈 수 있습니다. csv 모듈은 CSV의 따옴표 처리 규칙에 따라 레코드를 읽으므로, 여러 줄로 된 메모가 별도 행으로 분리되지 않고 해당 레코드에 유지됩니다.

02합성 입력 데이터 만들기

아래 데이터는 이 글을 위해 작성한 합성 데이터입니다. 작업 폴더에 sample.csv라는 이름으로 저장하세요. 따옴표 안의 메모에 있는 줄바꿈도 그대로 복사해야 합니다. 단순히 텍스트를 줄 단위로 나누면 잘못 처리되는 상황을 의도적으로 포함했습니다.

csv
record_id,team,units,note
R001,Support,12,"starter, pack"
R002,Ops,7,repeat
R003,Support,9,"two
lines"
R004,Sales,5,normal
R005,Ops,11,normal
R006,Sales,6,normal
R007,Support,10,normal

열은 4개이고 데이터 레코드는 7개입니다. starter, pack의 쉼표는 하나의 필드 안에 포함됩니다. R003의 메모는 물리적으로 2줄이지만 하나의 필드입니다. units 값의 합계는 60입니다. 계산식은 12 + 7 + 9 + 5 + 11 + 6 + 10입니다.

03작업 폴더 준비하기

  1. sample.csv와 새 스크립트인 split_large_csv.py를 같은 작업 폴더에 둡니다.
  2. 다음 절의 전체 Python 코드를 스크립트에 붙여 넣습니다.
  3. 이 예제에서는 ROWS_PER_FILE을 3으로 유지합니다. 이 값은 양의 정수여야 합니다.
  4. 작업 폴더에서 터미널을 열고 아래 명령을 실행합니다.
text
python split_large_csv.py

상대 경로의 기준은 터미널의 현재 작업 디렉터리이며, 자동으로 스크립트 위치를 기준으로 삼는 것은 아닙니다. 상위 outputs 폴더는 이미 있어도 되지만, 실행을 시작할 때 outputs/sample_parts는 없어야 합니다.

04레코드를 나누고 저장된 파일 검증하기

분할 과정에서는 레코드를 차례로 읽고, 각 분할 파일을 기존 파일이 있으면 실패하는 배타적 생성 모드로 엽니다. 저장 후에는 원본과 분할 파일을 다시 읽어 헤더, 필드 값, 레코드 순서, 개수를 비교합니다. 이 비교가 끝나야 성공 메시지가 표시됩니다.

python
import csv
from pathlib import Path

SOURCE = Path("sample.csv")
OUTPUT_DIR = Path("outputs") / "sample_parts"
ROWS_PER_FILE = 3


def part_path(number: int) -> Path:
    return OUTPUT_DIR / f"part_{number:04d}.csv"


def split_csv() -> tuple[int, int]:
    if type(ROWS_PER_FILE) is not int or ROWS_PER_FILE < 1:
        raise ValueError("ROWS_PER_FILE must be a positive integer.")

    with SOURCE.open("r", encoding="utf-8-sig", newline="") as source:
        reader = csv.reader(source, strict=True)
        header = next(reader, None)
        if not header or any(not name.strip() for name in header):
            raise ValueError("Missing header or blank column name.")
        if len(set(header)) != len(header):
            raise ValueError("Duplicate column names.")

        OUTPUT_DIR.parent.mkdir(parents=True, exist_ok=True)
        OUTPUT_DIR.mkdir()  # Refuse to reuse an existing output path.
        total = 0
        parts = 0
        target = None
        try:
            for record_no, row in enumerate(reader, start=1):
                if len(row) != len(header):
                    raise ValueError(
                        f"Data record {record_no}: wrong number of fields."
                    )
                if total % ROWS_PER_FILE == 0:
                    if target is not None:
                        target.close()
                    parts += 1
                    target = part_path(parts).open(
                        "x", encoding="utf-8", newline=""
                    )
                    writer = csv.writer(target)
                    writer.writerow(header)
                writer.writerow(row)
                total += 1
        finally:
            if target is not None:
                target.close()

    return total, parts


def verify_csv(expected_rows: int, part_count: int) -> None:
    with SOURCE.open("r", encoding="utf-8-sig", newline="") as source:
        original = csv.reader(source, strict=True)
        header = next(original, None)
        seen = 0
        for number in range(1, part_count + 1):
            with part_path(number).open(
                "r", encoding="utf-8", newline=""
            ) as part:
                rows = csv.reader(part, strict=True)
                if next(rows, None) != header:
                    raise ValueError(f"Header mismatch in part {number}.")
                count = 0
                for row in rows:
                    if row != next(original, None):
                        raise ValueError(f"Data mismatch in part {number}.")
                    count += 1
                required = min(
                    ROWS_PER_FILE,
                    expected_rows - (number - 1) * ROWS_PER_FILE,
                )
                if count != required:
                    raise ValueError(f"Row count mismatch in part {number}.")
                seen += count
        if seen != expected_rows or next(original, None) is not None:
            raise ValueError("Overall row count mismatch.")


if __name__ == "__main__":
    total, parts = split_csv()
    verify_csv(total, parts)
    print(f"Verified {total} data records in {parts} files.")
    print(f"Output folder: {OUTPUT_DIR.as_posix()}")

입력은 파일 앞에 바이트 순서 표식이 있는 UTF-8과 없는 UTF-8을 모두 읽을 수 있습니다. 출력은 해당 표식 없이 UTF-8로 저장합니다. newline 인수를 설정하면 csv 모듈이 레코드 끝과 필드 안의 줄바꿈을 처리합니다.

05예상 결과를 손으로 확인하기

생성될 것으로 예상되는 파일은 아래와 같습니다. units 합계는 수동 교차 확인용이며, 분할 스크립트가 계산하는 값은 아닙니다. 모든 파일의 헤더는 record_id,team,units,note여야 합니다.

outputs/sample_parts 안의 파일레코드 ID데이터 레코드 수units 합계
part_0001.csvR001, R002, R003328
part_0002.csvR004, R005, R006322
part_0003.csvR007110

예상 콘솔 출력은 아래와 같습니다. 예제와 코드를 바탕으로 손으로 도출한 내용이며, 실제 실행에서 수집한 로그가 아닙니다.

text
Verified 7 data records in 3 files.
Output folder: outputs/sample_parts

28 + 22 + 10이 원본 합계인 60과 같은지 확인하세요. 첫 번째 분할 파일을 텍스트 편집기로 열어 따옴표 안의 쉼표와 여러 줄 메모를 살펴보세요. 화면에 보이는 줄 수만 세면 데이터 레코드 수를 잘못 계산하게 됩니다.

06더 큰 입력에 적용하기 전에 확인하기

  1. 3개 파일 모두 동일한 4개 헤더 필드를 갖고 있으며, 각 파일에 헤더가 한 번만 있는지 확인합니다.
  2. 번호 순서대로 파일을 읽습니다. R001부터 R007까지 누락이나 중복 없이 각각 한 번씩 나와야 합니다.
  3. 스크립트에 검증 성공 메시지가 표시되는지 확인합니다. 검증에서는 개수뿐 아니라 필드 문자열도 비교하므로, 개수만 같다고 충분한 것은 아닙니다.
  4. 대상 폴더를 바꾸지 않고 스크립트를 다시 실행합니다. 다른 분할 파일을 쓰기 전에 FileExistsError로 중단되어야 합니다.

별도 폴더에서 테스트하세요. 레코드 6개는 3개씩 두 파일로 나뉘고, 헤더만 있으면 분할 파일이 생성되지 않으며, 완전히 빈 파일은 거부됩니다. 경계 조건 테스트와 실행 결과는 아래 실행·검증 기록에서 확인할 수 있습니다.

07자주 발생하는 오류 확인하기

증상확인할 사항
FileNotFoundErrorSOURCE와 터미널의 작업 디렉터리를 확인하세요. 파일명이 sample.csv.txt가 아니라 sample.csv인지 확인하세요.
FileExistsError기존 대상 폴더를 검토한 뒤 outputs 아래의 새 폴더를 선택하세요. 이미 존재하면 빈 폴더라도 사용할 수 없습니다.
UnicodeDecodeError원본 인코딩을 확인하세요. 디코딩 오류를 무시하지 말고, 올바르게 디코딩되는 사본을 확보하거나 만든 뒤 분할하세요.
헤더 누락 또는 중복비어 있지 않은 열 이름을 사용하세요. 정확히 같은 열 이름은 중복으로 거부하며, 그 외의 헤더 텍스트는 그대로 유지합니다.
필드 수 불일치 또는 csv.Error구분자, 따옴표, 빈 레코드를 확인하세요. 이 스크립트는 쉼표 구분자와 표준 큰따옴표 처리 규칙을 사용하는 입력을 대상으로 합니다.
저장 또는 검증 중 실패해당 대상 폴더는 미완성 결과로 취급하세요. 오류를 검토하고, 수정 후에는 새 대상 폴더로 다시 실행하세요.

처리 도중 뒤늦게 오류가 발생하면 일부 파일이 남을 수 있습니다. 스크립트는 현재 파일을 닫지만, 폴더를 실행 전 상태로 되돌리거나 파일을 자동 삭제하지 않습니다.

08규모를 늘리기 전에 한계 이해하기

본인 컴퓨터에서 예제가 정상적으로 처리되면 SOURCE를 바꾸고, outputs 아래에 새로운 OUTPUT_DIR을 지정한 뒤 ROWS_PER_FILE을 늘리세요. 레코드 수 제한은 바이트 단위의 파일 크기 제한이 아닙니다. 필드가 길면 레코드 수가 같은 묶음이라도 디스크 사용량은 크게 달라질 수 있습니다.

이 스크립트는 파싱된 필드 문자열을 보존하지만 원본 바이트, 따옴표 표기 방식, 레코드 끝의 줄바꿈 형식까지 보존하지는 않습니다. 전체 데이터를 한꺼번에 불러오지는 않지만, 매우 큰 필드는 여전히 메모리가 필요하고 CSV 파서의 필드 크기 제한을 넘을 수 있습니다. 검증 과정에서는 원본과 출력을 추가로 읽습니다. 실행 내내 원본을 변경하지 마세요. 이 작업은 파일을 잠가 생성하는 스냅샷이나 트랜잭션 방식의 백업이 아닙니다.

실행·검증 기록

ChatGPT Python tool · Python 3.13.5 · standard library csv/pathlib

  • 주 실행에서 정확히 'Verified 7 data records in 3 files.'와 'Output folder: outputs/sample_parts'가 출력되었다.
  • part_0001.csv, part_0002.csv, part_0003.csv가 생성되었으며 각각 데이터 레코드 수는 3개, 3개, 1개였다.
  • part_0001에는 R001-R003이 포함되었고, 'starter, pack'은 하나의 필드로, 'two\nlines'는 하나의 여러 줄 필드로 유지되었다. units 합계는 28이었다.
  • part_0002에는 R004-R006이 포함되었고 units 합계는 22였다. part_0003에는 R007이 포함되었고 units 합계는 10이었다. 전체 units 합계는 60이었다.
  • 모든 분할 파일에 동일한 4개 필드의 헤더가 반복되었으며, 원본 7개 레코드는 원래 순서대로 각각 한 번씩 모두 보존되었다.
  • 성공적인 실행 전후로 원본 sample.csv의 바이트와 텍스트는 변경되지 않았다.
  • outputs/sample_parts가 이미 존재하는 상태에서 두 번째로 실행하면 다른 분할 파일을 만들기 전에 FileExistsError로 중단되었다.
  • 6개 레코드 입력에서는 각각 3개 레코드를 가진 정확히 2개의 분할 파일이 생성되었다.
  • 헤더만 있는 입력은 0개 레코드와 0개 분할 파일로 정상 처리되었으며 출력 디렉터리는 비어 있었다.
  • 완전히 빈 입력은 ValueError: 'Missing header or blank column name.'으로 거부되었다.
검증 범위의 한계
  • 대용량 파일 성능, 비정상적으로 큰 CSV 필드, 처리 중 원본 파일 변경, 빈 파일 외의 손상 입력 변형, 디스크 쓰기 실패, 중단된 쓰기의 복구는 스트레스 테스트하지 않았다.

참고 출처

설명과 예제는 직접 작성했습니다. 관련 동작과 개념은 아래 공식 자료에서 확인할 수 있습니다.