헤더를 유지하면서 큰 CSV를 작은 파일로 나누기
CSV를 데이터 레코드 수에 따라 나누고, 각 출력 파일에 헤더를 넣으면서 원본은 그대로 보존합니다. 7개 레코드로 구성된 합성 예제를 이용해 분할 경계를 확인하고, 모든 레코드가 원래 순서대로 유지되는지 검증합니다.
원본을 변경하지 않고 폴더를 날짜가 포함된 ZIP 파일로 백업한 뒤, 모든 원본 파일이 동일한 내용으로 보관되었는지 확인합니다. 작은 합성 폴더를 이용해 예상 경로, 파일 크기, 전체 크기를 손으로 확인할 수 있습니다.
이런 분께 맞아요ZIP 파일이 생성되었다는 사실만 믿지 않고, 백업 후 실제 보관 내용을 검증하려는 사람을 위한 안내입니다.
이 스크립트는 하나의 원본 폴더에 있는 일반 파일을 보관하며, 하위 폴더의 파일도 포함합니다. ZIP 내부 경로는 원본 폴더를 기준으로 한 상대 경로로 저장합니다. 따라서 reports/week1.txt와 같은 파일은 절대 경로가 아니라 reports/week1.txt로 보관됩니다.
보관 파일명에는 현재 로컬 달력 날짜가 YYYY-MM-DD 형식으로 포함됩니다. 예제를 2026-09-20에 실행한다면 예상 파일명은 backup_demo_2026-09-20.zip입니다. 같은 날짜에 같은 대상 위치로 다시 실행하면 이전 백업을 교체하지 않고 중단합니다.
아래 데이터는 이 글을 위해 작성한 합성 데이터입니다. 준비용 스크립트를 create_backup_demo.py로 저장하세요. 정확한 바이트 내용을 가진 파일 4개를 만들며, 그중 하나가 아니라 두 개가 reports 하위 폴더에 들어갑니다.
from pathlib import Path
SOURCE = Path("outputs") / "backup_demo"
FILES = {
"notes.txt": b"alpha\n",
"reports/week1.txt": b"beta\n",
"reports/week2.txt": b"gamma\n",
"settings.ini": b"mode=test\n",
}
SOURCE.parent.mkdir(parents=True, exist_ok=True)
SOURCE.mkdir() # Stop if the synthetic source already exists.
for relative_name, content in FILES.items():
target = SOURCE / relative_name
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("xb") as stream:
stream.write(content)
python create_backup_demo.py| 상대 경로 | 정확한 내용 | 바이트 |
|---|---|---|
| notes.txt | alpha와 줄바꿈 | 6 |
| reports/week1.txt | beta와 줄바꿈 | 5 |
| reports/week2.txt | gamma와 줄바꿈 | 6 |
| settings.ini | mode=test와 줄바꿈 | 10 |
원본에는 일반 파일 4개가 있으며 압축 전 내용은 총 27바이트입니다. 계산식은 6 + 5 + 6 + 10입니다. 최종 ZIP 파일 자체의 크기는 이 합계만으로 예측할 수 없습니다. ZIP 압축 결과와 보관 메타데이터에 따른 추가 크기가 있기 때문입니다.
python dated_zip_backup.py스크립트는 원본이 존재하는지, 그리고 백업 디렉터리가 원본 디렉터리 안쪽에 있지 않은지 확인합니다. 백업의 상위 폴더는 이미 있어도 됩니다. 하지만 날짜가 붙은 ZIP 파일 자체는 존재하지 않아야 합니다.
스크립트는 먼저 원본 파일을 정렬된 목록으로 만듭니다. ZIP 파일은 기존 파일이 있으면 실패하는 배타적 생성 방식으로 만들고, 각 파일을 ZIP_DEFLATED 방식으로 저장합니다. 작성이 끝나면 ZIP을 다시 열어 검증합니다. 검증 과정에서는 의도한 멤버 목록, testzip을 이용한 CRC 무결성, 원본과 ZIP 멤버의 SHA-256 해시를 비교합니다.
from datetime import date
import hashlib
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
SOURCE = Path("outputs") / "backup_demo"
BACKUP_DIR = Path("outputs") / "backups"
CHUNK_SIZE = 1024 * 1024
def hash_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(CHUNK_SIZE):
digest.update(chunk)
return digest.hexdigest()
def hash_member(archive: ZipFile, member_name: str) -> str:
digest = hashlib.sha256()
with archive.open(member_name, "r") as stream:
while chunk := stream.read(CHUNK_SIZE):
digest.update(chunk)
return digest.hexdigest()
def main() -> None:
source_root = SOURCE.resolve(strict=True)
if not source_root.is_dir():
raise ValueError("SOURCE must be a directory.")
backup_root = BACKUP_DIR.resolve()
if backup_root.is_relative_to(source_root):
raise ValueError("BACKUP_DIR must be outside SOURCE.")
files = sorted(
path for path in source_root.rglob("*")
if path.is_file() and not path.is_symlink()
)
if not files:
raise ValueError("SOURCE contains no regular files to back up.")
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
archive_name = f"{source_root.name}_{date.today().isoformat()}.zip"
archive_path = BACKUP_DIR / archive_name
if archive_path.exists():
raise FileExistsError(f"Backup already exists: {archive_path}")
expected = {}
with archive_path.open("xb") as raw:
with ZipFile(raw, "w", compression=ZIP_DEFLATED) as archive:
for path in files:
relative = path.relative_to(source_root).as_posix()
expected[relative] = (path.stat().st_size, hash_file(path))
archive.write(path, arcname=relative)
with ZipFile(archive_path, "r") as archive:
bad_member = archive.testzip()
if bad_member is not None:
raise RuntimeError(f"CRC check failed: {bad_member}")
members = [info for info in archive.infolist() if not info.is_dir()]
actual_names = [info.filename for info in members]
expected_names = list(expected)
if actual_names != expected_names:
raise RuntimeError("Archive member list does not match the source list.")
for info in members:
expected_size, expected_hash = expected[info.filename]
if info.file_size != expected_size:
raise RuntimeError(f"Size mismatch: {info.filename}")
if hash_member(archive, info.filename) != expected_hash:
raise RuntimeError(f"Hash mismatch: {info.filename}")
total_bytes = sum(size for size, _ in expected.values())
print(f"Verified {len(expected)} files totaling {total_bytes} bytes.")
print(f"Archive: {archive_path.as_posix()}")
if __name__ == "__main__":
main()
코드는 심볼릭 링크를 건너뛰고 일반 파일만 보관합니다. 예상 해시값은 각 원본 파일을 ZIP에 추가하기 직전에 계산합니다. 실행 중 내용이 바뀌지 않는 일반적인 로컬 폴더라면, 보관 파일을 생성한 뒤 내용을 비교하는 데 유용한 검증 방식입니다.
스크립트를 2026-09-20에 실행한다면 예상 ZIP 경로는 outputs/backups/backup_demo_2026-09-20.zip입니다. ZIP 안에는 정확히 다음 4개 멤버 경로가 있어야 합니다.
| ZIP 내부 멤버 | 압축 전 바이트 |
|---|---|
| notes.txt | 6 |
| reports/week1.txt | 5 |
| reports/week2.txt | 6 |
| settings.ini | 10 |
reports 하위 폴더는 파일 멤버의 경로 자체로 표현됩니다. 파일들이 이미 상대 경로를 포함하므로 reports라는 별도 디렉터리 멤버가 반드시 필요하지는 않습니다.
아래 예상 콘솔 출력은 2026-09-20에 실행한다고 가정하고 수동으로 도출한 값입니다. 실제 실행에서 가져온 로그가 아닙니다.
Verified 4 files totaling 27 bytes.
Archive: outputs/backups/backup_demo_2026-09-20.zipZIP 파일 자체의 바이트 크기를 주된 검증 기준으로 사용하지 마세요. 압축 때문에 정상적인 ZIP도 데이터와 메타데이터에 따라 압축 전 원본 합계보다 작거나 클 수 있습니다.
| 증상 | 확인할 사항 |
|---|---|
| FileNotFoundError | SOURCE와 터미널의 작업 디렉터리를 확인하세요. 이 예제에서는 합성 데이터 준비용 스크립트를 먼저 실행해야 합니다. |
| FileExistsError | 같은 날짜가 붙은 백업 파일이 이미 존재합니다. 덮어쓰지 말고 기존 백업을 확인한 뒤 다른 목적지나 의도한 다른 백업 날짜를 사용하세요. |
| PermissionError | 스크립트가 모든 원본 파일을 읽고 BACKUP_DIR 안에 새 파일을 만들 수 있는지 확인하세요. |
| CRC check failed | 해당 ZIP을 실패한 백업으로 취급하세요. 백업으로 의존하지 말고 저장장치나 쓰기 오류를 확인한 뒤 새 ZIP을 만드세요. |
| 멤버 목록 불일치 | 실행 중 원본이 변경되었거나, ZIP 내용이 처음 작성한 파일 목록과 일치하지 않을 수 있습니다. |
| 해시 또는 크기 불일치 | 검증 실패로 취급하세요. 원본을 그대로 유지한 상태에서 원인을 조사한 뒤 새 ZIP을 만드세요. |
ZIP 파일을 만든 뒤 예외가 발생하면 일부만 작성되었거나 검증되지 않은 ZIP이 백업 폴더에 남을 수 있습니다. 스크립트는 이를 자동 삭제하지 않습니다. 실패 원인을 조사할 때 필요한 정보를 자동 정리가 숨길 수 있기 때문입니다.
이 방식은 파일을 복사해 ZIP으로 보관하는 작업이며, 트랜잭션 방식의 파일 시스템 스냅샷이 아닙니다. 다른 응용 프로그램이 해시 계산이나 ZIP 저장 중 파일을 수정하면 결과가 일관되지 않을 수 있습니다. 중요한 데이터라면 원본에 대한 쓰기를 중지하거나 스냅샷 기능이 있는 백업 시스템을 사용해야 합니다.
이 예제는 파일 시스템의 모든 속성을 보존하지 않습니다. 권한, 소유권, 접근 제어 목록, 확장 속성, 대체 데이터 스트림, 심볼릭 링크, 응용 프로그램별 메타데이터는 다른 백업 도구가 필요할 수 있습니다. ZIP은 이동 가능한 파일 모음을 만드는 데 유용하지만 파일 시스템 전체를 그대로 복제하는 형식은 아닙니다.
검증에 성공한 ZIP도 여전히 하나의 사본일 뿐입니다. 실제 백업 계획에서는 여러 사본, 저장장치 고장, 실수로 인한 삭제, 랜섬웨어, 보존 기간, 암호화, 복구 시험 여부를 고려해야 합니다. 여기서의 검증은 이번 실행에서 관찰한 원본 내용과 ZIP이 일치했다는 점을 확인하는 것이며, 완전한 재해 복구 전략을 의미하지 않습니다.
2026-09-20 · 예제 수동 검토 · 대상: Python 3.12 · 표준 라이브러리: datetime, hashlib, pathlib, zipfile · 실행하지 않음
설명과 예제는 직접 작성했습니다. 관련 동작과 개념은 아래 공식 자료에서 확인할 수 있습니다.