2026년 6월 29일 | 개발 공부
배치 작업이 결과 파일을 바로 덮어쓰면 실패가 조용히 이상한 모양으로 남는다. 예외가 터지면 차라리 낫다. 더 성가신 쪽은 프로세스가 중간에 죽었는데 파일 이름은 정상 결과처럼 남아 있고, 다음 단계가 그 파일을 그대로 읽는 경우다. 나는 이런 문제를 볼 때 파서 오류보다 저장 순서를 먼저 의심하는 편이다. 내용이 틀린 파일과 아직 쓰는 중이던 파일은 처리 방식이 완전히 다르기 때문이다.
바로 덮어쓰기가 위험한 순간
작은 스크립트에서는 `open(path, "w")`가 편하다. 파일을 열고, 기존 내용을 비우고, 새 내용을 쓰면 끝난다. 문제는 그 사이에 프로세스가 멈출 수 있다는 점이다. 네트워크 파일시스템이 잠깐 끊기거나, 컨테이너가 재시작되거나, 디스크가 가득 차거나, 작업자가 Ctrl+C를 누르는 것만으로도 결과 파일은 이미 비워진 상태일 수 있다.
특히 JSONL, CSV, 캐시 manifest, 모델 평가 결과처럼 다음 단계가 자동으로 이어 읽는 파일은 더 조심해야 한다. 마지막 줄 하나가 잘린 파일은 스키마 오류처럼 보이고, 헤더만 남은 CSV는 데이터가 0건인 정상 결과처럼 보인다. 이때 재시도 로직이 붙어 있으면 잘못된 파일을 기준으로 다시 계산하면서 원인을 더 멀리 밀어낸다.
임시 파일이 먼저 받아야 할 책임
내가 기본값으로 두는 방식은 단순하다. 최종 파일을 바로 열지 않고, 같은 디렉터리에 임시 파일을 만든다. 거기에 끝까지 쓴 다음 flush와 fsync로 OS 버퍼까지 밀어 넣고, 필요한 최소 검증을 통과했을 때만 최종 이름으로 바꾼다. 같은 파일시스템 안의 rename 또는 replace는 대부분의 로컬 환경에서 원자적으로 처리되기 때문에, 읽는 쪽은 이전 파일이나 새 파일 중 하나만 보게 된다.
from pathlib import Path
import os
import tempfile
final = Path("result.jsonl")
fd, tmp_name = tempfile.mkstemp(prefix=final.name + ".", suffix=".tmp", dir=final.parent or Path("."))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
for row in rows:
f.write(serialize(row) + "\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp_name, final)
except Exception:
Path(tmp_name).unlink(missing_ok=True)
raise
여기서 핵심은 `tmp`라는 이름을 붙이는 데 있지 않다. 최종 이름을 마지막 순간까지 비워 두는 것이 핵심이다. 임시 파일은 실패해도 버려질 수 있어야 하고, 최종 파일은 성공한 결과라는 의미를 가져야 한다. 이 경계를 흐리면 로그에는 성공과 실패가 섞인 한 덩어리 파일만 남는다.
성공 로그에 남길 최소 단서
저장 순서를 바꿨다면 로그도 같이 바꿔야 한다. 나는 보통 final path, tmp path, record count, byte count, elapsed time 정도를 남긴다. 데이터가 정렬된 입력이면 첫 ID와 마지막 ID도 같이 적는다. 해시까지 항상 붙일 필요는 없지만, 배포 산출물이나 평가 결과처럼 나중에 비교할 파일이면 checksum을 한 줄 더 남기는 쪽이 편했다.
이 단서들은 멋진 관측성 도구가 없어도 바로 도움이 된다. 파일이 작게 남았을 때 "쓰기 중 끊겼는지", "검증을 통과한 빈 결과인지", "아예 이전 파일이 유지된 것인지"를 빠르게 나눌 수 있다. 재처리할 때도 무작정 전체 파이프라인을 다시 돌리기보다, 마지막 성공 교체 직전의 record count부터 볼 수 있다.
원자적 교체가 모든 저장소에서 같지는 않다
주의할 점도 있다. 로컬 디스크에서 안전한 패턴이 곧바로 S3 같은 오브젝트 스토리지나 일부 네트워크 마운트에서 같은 의미를 갖지는 않는다. 오브젝트 스토리지는 rename 개념이 복사와 삭제로 풀릴 수 있고, reader가 목록 갱신 타이밍을 다르게 볼 수도 있다. 이런 환경에서는 임시 키, 세대 번호, manifest pointer, 완료 marker를 따로 설계하는 편이 낫다.
데이터베이스라면 파일 rename보다 트랜잭션을 먼저 써야 한다. SQLite도 WAL, checkpoint, backup API처럼 이미 만들어진 경계가 있다. 그러니 이 팁을 모든 저장 작업에 기계적으로 붙이기보다, 파일 하나가 성공 여부를 대표하는 배치 산출물에 우선 적용하는 쪽이 맞다.
또 하나 놓치기 쉬운 부분은 임시 파일의 위치다. 최종 파일이 있는 디렉터리와 다른 디렉터리에 tmp를 만들면 마지막 교체가 파일시스템 경계를 넘을 수 있다. 그러면 원자적 교체가 아니라 복사에 가까운 동작이 섞일 수 있고, 권한이나 디스크 여유 공간도 따로 맞춰야 한다. 그래서 가능하면 최종 파일 옆에 같은 prefix로 임시 파일을 만든다. 나중에 청소할 때도 남은 tmp를 찾기 쉽다.
작게 붙일 수 있는 검증
교체 직전 검증은 복잡할 필요가 없다. JSONL이면 마지막 줄이 개행으로 끝났는지, 각 줄이 JSON으로 열리는지, 예상 record count와 크게 어긋나지 않는지만 봐도 충분할 때가 많다. CSV라면 헤더 컬럼 수와 본문 첫 몇 줄의 컬럼 수를 맞춰 본다. 모델 평가 결과라면 metric key가 빠지지 않았는지 확인한다. 이 검증은 품질 평가가 아니라 절반만 쓴 파일을 최종 산출물로 승격하지 않기 위한 문턱이다.
검증이 실패했을 때는 최종 파일을 건드리지 않는 쪽이 중요하다. 실패한 tmp는 보통 지워도 되지만, 원인 분석이 필요한 배치라면 `.bad`나 날짜가 붙은 이름으로 잠깐 남길 수 있다. 다만 그 파일이 다음 파이프라인에서 자동으로 읽히지 않게 확장자와 위치를 분리해야 한다. 실패 자료를 남기려다 또 다른 입력 후보를 만드는 실수를 꽤 쉽게 한다.
내가 붙이는 작은 기준
결과 파일을 만드는 스크립트를 볼 때 나는 세 가지를 묻는다. 첫째, 실패 중간 산출물이 최종 파일명으로 보일 수 있는가. 둘째, reader가 실행되는 동안 old와 new가 섞여 보일 수 있는가. 셋째, 로그만 보고 마지막 성공 교체를 다시 찾을 수 있는가. 이 세 질문에 답이 흐리면 저장 코드를 조금 고친다.
이건 거창한 인프라 패턴이라기보다 작은 파일 쓰기 습관에 가깝다. 그래도 한 번 몸에 붙여 두면 파싱 오류를 볼 때 불필요한 가설을 많이 줄여 준다. 데이터가 왜 깨졌는지 보기 전에, 그 데이터가 끝까지 쓰인 결과인지부터 확인할 수 있기 때문이다.
'[개발 공부]' 카테고리의 다른 글
| Reliability diagram: 과신 구간을 눈으로 보는 표 (0) | 2026.06.30 |
|---|---|
| Expected Calibration Error: 확률 구간별 과신 (0) | 2026.06.30 |
| Brier score: 확률 예측의 거리 (0) | 2026.06.29 |
| ICC: 반복 측정값의 신뢰도 (1) | 2026.06.26 |
| Bland-Altman Plot: 두 측정값의 합의 범위 (0) | 2026.06.26 |