스크립트·파일 자동화

헤더를 유지하면서 큰 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개씩 담긴 분할 파일 2개만 생성되고, 비어 있는 세 번째 파일은 생기지 않아야 합니다. 헤더만 있는 입력은 빈 출력 폴더를 남겨야 합니다. 완전히 빈 입력은 거부되어야 합니다. 이러한 예상 동작은 코드를 검토한 것이며, 여기서 실행한 결과는 아닙니다.

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

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

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

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

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

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

실행·검증 기록

2026-09-20 · 예제 수동 검토 · 대상: Python 3.12 · 표준 라이브러리: csv, pathlib · 실행하지 않음

  • 따옴표로 감싼 여러 줄 메모를 하나의 필드로 취급하여 열 4개와 데이터 레코드 7개를 수동으로 확인했습니다.
  • 레코드를 3개, 3개, 1개로 나누는 구성을 수동으로 확인했습니다.
  • 각 묶음의 합계가 28, 22, 10이며 전체 합계는 60임을 수동으로 계산했습니다.
  • 출력 파일명, 배타적 생성, 헤더 반복, 순차 비교 로직을 코드로 검토했습니다.
  • 예제와 코드를 바탕으로 예상 콘솔 출력을 도출했습니다.
검증 한계
  • 이 응답의 작성자는 코드를 실행하지 않았으며, Python 실행 환경이나 파일 시스템 동작을 시험하지 않았습니다.
  • 경계 조건 입력, 잘못된 형식의 파일, 기존 출력과의 충돌은 코드 검토로만 살펴봤습니다.
  • 대용량 파일의 성능, 메모리 사용량, 실행 중 원본 변경, 쓰기 중단 후 복구는 시험하지 않았습니다.

사이트 전체 작성·검증 원칙

참고 출처

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