파이썬 파일 원자적 저장: tempfile과 os.replace 순서

파이썬 자동화 결과를 최종 경로에 바로 쓰면 실행 중단이나 예외 뒤에 부분 파일이 남을 수 있습니다. 기존 결과를 지키려면 같은 디렉터리의 임시 파일에 내용을 완성하고 검증한 뒤 os.replace로 교체하는 순서를 사용합니다. flush()와 fsync()는 이 교체 전에 버퍼와 운영체제 수준의 기록을 다루는 단계입니다.

왜 최종 파일에 바로 쓰면 위험할까요?

open("result.json", "w")처럼 최종 경로를 먼저 열면 기존 파일이 잘릴 수 있습니다. 이후 JSON 생성이나 변환 과정에서 예외가 발생하면 새 내용은 완성되지 않았는데 파일 이름은 그대로 남습니다. 다음 작업이 파일의 존재만 보고 읽으면 불완전한 결과를 정상 파일로 오인할 수 있습니다.

운영 관점에서는 “파일이 있다”와 “작업이 성공했다”를 구분해야 합니다. 저장 중간에 실패해도 마지막으로 성공한 결과를 보존하려면 최종 경로를 작업 공간으로 쓰지 않는 편이 안전합니다.

안전한 저장 순서

흐름은 다음과 같습니다.

  1. 최종 파일과 같은 디렉터리에 임시 파일을 만듭니다.
  2. 임시 파일에 새 내용을 처음부터 씁니다.
  3. flush()와 필요할 때 os.fsync()를 호출합니다.
  4. 파일을 다시 읽거나 형식 검사 등 애플리케이션 검증을 합니다.
  5. 검증을 통과한 임시 파일만 os.replace()로 최종 경로에 교체합니다.
  6. 예외가 나면 임시 파일을 정리하고 기존 최종 파일은 건드리지 않습니다.

같은 디렉터리를 지정하는 이유는 임시 파일과 최종 파일이 같은 파일시스템에 있도록 하기 위해서입니다. os.replace()는 서로 다른 파일시스템에 있는 경로 사이에서는 실패할 수 있습니다.

Python 예제

from __future__ import annotations

import json
import os
import tempfile
from pathlib import Path
from typing import Any


def save_json_atomically(path: Path, data: Any) -> None:
    path = path.resolve()
    temp_name: str | None = None

    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            dir=path.parent,
            prefix=f".{path.name}.",
            suffix=".tmp",
            delete=False,
        ) as temp_file:
            temp_name = temp_file.name
            json.dump(data, temp_file, ensure_ascii=False, indent=2)
            temp_file.write("\n")
            temp_file.flush()
            os.fsync(temp_file.fileno())

        # 교체 전에 임시 파일 자체가 읽히는지 확인합니다.
        with open(temp_name, "r", encoding="utf-8") as check_file:
            json.load(check_file)

        os.replace(temp_name, path)
        temp_name = None
    finally:
        if temp_name is not None:
            try:
                os.unlink(temp_name)
            except FileNotFoundError:
                pass

dir=path.parent가 핵심입니다. 임시 파일을 시스템 기본 임시 디렉터리에 만들면 최종 경로와 파일시스템이 달라질 수 있습니다. delete=False는 파일을 닫은 뒤 이름으로 다시 열고 검증하기 위한 선택입니다. Windows에서는 열린 임시 파일을 다시 여는 방식이 공유 모드의 영향을 받을 수 있으므로, 이 예제처럼 먼저 닫은 뒤 검증과 교체를 진행하는 편이 다루기 쉽습니다.

flush()와 fsync()는 같은 기능이 아닙니다

flush()는 Python 파일 객체가 가진 쓰기 버퍼를 비우는 동작입니다. 이것만으로 저장장치에 데이터가 내구성 있게 기록되었다고 단정할 수는 없습니다. os.fsync(temp_file.fileno())는 파일 디스크립터를 통해 운영체제에 기록을 요청하는 단계입니다.

모든 자동화에 fsync()가 필요한 것은 아닙니다. 작업 중단이나 전원 장애 뒤에도 파일 내용의 내구성을 중요하게 본다면 고려하고, 단순 캐시나 재생성 가능한 중간 결과라면 비용과 처리 시간을 함께 판단해야 합니다. 공식 문서가 보장하는 범위와 실제 저장장치의 장애 대응 범위는 구분해서 보셔야 합니다.

os.replace() 전에 꼭 검증할 것

원자적 교체는 “교체 순간에 독자가 이전 파일 또는 새 파일을 보게 하는” 경계를 만드는 데 유용하지만, 새 내용이 올바른지 판단해 주지는 않습니다. 잘못 생성된 JSON이나 누락된 레코드도 검증 없이 교체하면 정상 경로를 오염시킬 수 있습니다.

검증은 결과 형식에 맞춰 정합니다.

  • JSON이면 다시 파싱하고 필수 키와 자료형을 확인합니다.
  • CSV이면 헤더와 행 구조, 빈 결과 허용 여부를 확인합니다.
  • 변환 파일이면 입력 건수와 결과 건수처럼 업무 규칙에 필요한 조건을 확인합니다.

검증 규칙은 파일 형식만으로 결정되지 않습니다. 실제 후속 작업이 무엇을 믿고 읽는지부터 정하는 것이 순서입니다.

예외와 임시 파일 정리

프로세스가 임시 파일을 쓰는 중 종료되면 불완전한 임시 파일이 남을 수 있습니다. 따라서 다음 실행에서 임시 파일을 발견했다고 곧바로 최종 파일로 교체하거나 이어 쓰면 안 됩니다. 임시 파일 이름에 작업을 구분할 접두사와 접미사를 두고, 정리 작업도 나이·소유 작업·실행 상태를 확인한 뒤 제한적으로 수행하는 편이 안전합니다.

finally에서 임시 파일을 지우는 코드는 정상적인 예외 처리에 도움이 됩니다. 다만 프로세스가 강제 종료되면 finally가 실행되지 않을 수 있으므로, 별도의 정리 정책은 남아 있는 파일을 다시 검증하지 않고 무조건 사용하지 않는 방향으로 설계해야 합니다.

마무리: 어느 수준까지 적용할까요?

예약 작업이나 배치가 기존 결과를 보존해야 한다면 같은 디렉터리의 임시 파일, 완전한 쓰기, 결과 검증, os.replace()를 한 묶음으로 적용하는 것이 출발점입니다. 전원 장애 뒤의 내구성까지 중요하면 flush() 뒤 fsync()를 추가하고, 그렇지 않다면 처리 비용과 재생성 가능성을 함께 판단합니다.

다음으로 확인할 질문은 결과 파일 자체가 비어 있을 때 원인이 저장 단계인지 입력 단계인지, JSON·CSV별 검증 규칙을 어디에 둘지, 작업 중단 뒤 남은 임시 파일을 어떤 기준으로 정리할지입니다. 이 세 가지를 정해 두면 원자적 교체가 데이터 검증을 대신하는 것으로 오해하는 일도 줄어듭니다.

참고 자료:

Leave a Comment