[개발 깨알 상식_Tips] / Windows에서 NamedTemporaryFile을 다시 열 때 나는 PermissionError.md

Windows에서 NamedTemporaryFile을 다시 열 때 나는 PermissionError

조회

2026년 8월 3일 | 개발 깨알 상식_Tips


이미지 전처리 함수에 파일 객체 대신 경로를 넘겨야 해서 NamedTemporaryFiletmp.name을 전달했다. WSL과 Linux CI에서는 잘 읽혔는데 Windows runner에서만 PermissionError 또는 WinError 32가 났다. 파일 기록 뒤 flush()까지 호출했으니 처음에는 백신이나 느린 디스크를 의심했다. 실제 원인은 내용이 아니라 아직 열린 임시 파일 핸들과 Windows의 삭제 공유 조건이었다.

NamedTemporaryFile을 열린 채 경로로 다시 사용할 때 POSIX와 Windows의 결과가 갈리고, Windows에서는 delete_on_close 또는 명시적 정리로 해결하는 흐름
Figure 1. 같은 임시 파일 경로라도 POSIX의 재열기와 Windows의 삭제 공유 조건은 다르다. flush가 아니라 핸들 수명과 삭제 시점을 바꿔야 한다.

파일 내용이 아니라 열린 핸들이 충돌한다

NamedTemporaryFile은 파일시스템에서 보이는 이름을 보장한다. 그래서 tmp.name을 Pillow, ffmpeg, 압축 라이브러리처럼 경로 입력만 받는 도구에 넘기기 쉽다. 다만 이름이 보인다는 것과 다른 핸들로 언제든 다시 열 수 있다는 것은 별개의 보장이다. POSIX에서는 임시 파일이 열린 상태여도 그 이름으로 다시 여는 경로가 일반적으로 동작한다. Windows에서는 기본값인 delete=True, delete_on_close=True로 연 핸들이 삭제 권한을 잡고 있어, 추가 open()이 삭제 공유 조건을 맞추지 못하면 재열기가 막힐 수 있다.

이 차이가 까다로운 이유는 코드가 틀린 위치와 실패 위치가 떨어져 있기 때문이다. 임시 파일 생성과 쓰기는 성공하고, 그 이름을 받은 두 번째 라이브러리에서만 예외가 난다. Linux에서만 개발하면 같은 코드가 계속 통과하므로 경로 오타나 파일 생성 실패로 보기 쉽다. 한 프로세스 안에서도 파일 객체 두 개는 서로 다른 OS 핸들이라는 점을 먼저 확인해야 한다.

flush는 공유 모드와 삭제 시점을 바꾸지 않는다

tmp.flush()는 Python 버퍼의 바이트를 운영체제 쪽으로 밀어낸다. 두 번째 reader가 최신 내용을 볼 수 있게 하는 데는 필요하지만, 첫 번째 핸들을 닫거나 Windows의 공유 플래그를 변경하지는 않는다. fsync()도 저장장치 반영 범위를 강화할 뿐 재열기 권한 문제의 해법은 아니다. 그래서 flush를 한 번 더 호출하거나 짧게 sleep을 넣어도 같은 PermissionError가 남는다.

with tempfile.NamedTemporaryFile(suffix=".png") as tmp:
    tmp.write(image_bytes)
    tmp.flush()

    # POSIX에서는 통과할 수 있지만 Windows에서는 실패할 수 있다.
    result = read_image_by_path(tmp.name)

이 재현에서 확인할 것은 파일 크기보다 핸들 수명이다. consumer가 파일 객체를 받을 수 있다면 tmp 자체를 넘기는 것이 가장 단순하다. 경로만 받는 API라면 원본 핸들을 언제 닫고, 최종 삭제를 누가 책임질지 코드에 드러내야 한다.

Python 3.12 이상은 delete_on_close를 분리한다

Python 3.12에 추가된 delete_on_close=False는 파일 객체를 닫는 시점과 임시 이름을 지우는 시점을 분리한다. delete=True는 유지하므로 context가 정상 종료될 때 정리는 계속 맡길 수 있다. Windows에서 일반 open()으로 같은 이름을 다시 써야 할 때 이 선택지가 잘 맞는다.

with tempfile.NamedTemporaryFile(
    mode="wb",
    suffix=".png",
    delete=True,
    delete_on_close=False,
) as tmp:
    tmp.write(image_bytes)
    tmp.flush()

    # consumer는 내부에서 연 핸들을 반환 전에 닫아야 한다.
    result = read_image_by_path(tmp.name)

여기에도 조건이 하나 남는다. 두 번째 도구가 연 핸들을 context 밖까지 붙잡고 있으면, context 종료 시 실행되는 os.unlink()가 다시 PermissionError를 낼 수 있다. 지연 로딩 이미지 객체, 메모리 매핑, 백그라운드 프로세스는 함수가 반환돼도 핸들을 유지할 수 있다. 필요하면 명시적인 close(), context manager, subprocess 종료 대기를 사용해 추가 핸들이 먼저 닫혔다는 사실을 고정한다.

혼합 버전은 delete=False와 finally가 안전하다

Python 3.11 이하가 섞인 서비스나 외부 프로그램에 경로를 넘기는 배치에서는 delete=False가 더 예측 가능하다. 임시 파일을 쓰는 context부터 끝내 원본 핸들을 닫고, 그다음 consumer를 실행한다. 자동 삭제를 포기한 만큼 finally에서 직접 지우는 책임을 함께 가져가야 한다.

from pathlib import Path
from tempfile import NamedTemporaryFile

path = None
try:
    with NamedTemporaryFile(mode="wb", suffix=".png", delete=False) as tmp:
        tmp.write(image_bytes)
        path = Path(tmp.name)

    result = read_image_by_path(path)
finally:
    if path is not None:
        path.unlink(missing_ok=True)

삭제 코드를 성공 경로 마지막 줄에만 두면 consumer 예외와 timeout에서 파일이 쌓인다. 반대로 consumer가 비동기로 파일을 읽는다면 호출 직후 삭제해서는 안 된다. 프로세스의 완료나 라이브러리 객체의 close를 기다린 뒤 지워야 한다. 여러 파일을 함께 넘기는 작업이라면 개별 파일을 추적하기보다 TemporaryDirectory 하나에 묶고 작업 종료 후 디렉터리 단위로 정리하는 편이 덜 헷갈린다.

회귀 테스트는 Windows runner에서 파일 소거까지 본다

Linux 테스트 하나로는 이 문제를 재현할 수 없다. 최소한 Windows runner에서 “경로로 다시 읽기”를 실제로 실행해야 한다. 성공 값만 검사하지 않고 consumer가 끝난 뒤 임시 경로가 사라졌는지도 본다. 실패 경로에서는 consumer가 예외를 내도 cleanup이 동작하는지, 추가 핸들을 일부러 오래 잡았을 때 기대한 오류가 나는지도 분리하면 수명주기 계약이 선명해진다.

  • 같은 바이트가 파일 객체 경로와 파일명 경로에서 모두 읽히는지 확인한다.
  • 정상 완료 뒤 임시 경로가 존재하지 않는지 확인한다.
  • consumer 예외와 timeout 뒤에도 명시적 cleanup이 실행되는지 확인한다.
  • Windows와 POSIX job을 모두 두어 플랫폼별 경계를 회귀 테스트로 남긴다.

임시 파일 이름을 직접 만들기 위해 tempfile.mktemp()로 우회하는 것은 피한다. 이름을 얻은 시점과 실제 생성 사이에 경쟁 조건이 생길 수 있다. 보안상 안전하게 파일을 만든 뒤 수명만 직접 관리하려면 NamedTemporaryFile(delete=False)나 저수준 mkstemp()를 쓰고, 후자는 반환된 file descriptor까지 반드시 닫는다.

선택 기준은 consumer의 입력 형태다

나는 이 문제를 만나면 먼저 consumer가 파일 객체를 받을 수 있는지 본다. 받을 수 있으면 경로 재열기 자체를 없앤다. 경로가 꼭 필요하고 실행 환경이 Python 3.12 이상으로 고정돼 있으면 delete_on_close=False와 context 종료 전 핸들 close를 묶는다. 버전이 섞였거나 외부 프로세스가 파일을 읽는다면 원본 핸들을 먼저 닫는 delete=False 경로와 finally cleanup을 택한다.

Python의 tempfile 문서가 강조하는 것도 플랫폼별 재열기 조건과 삭제 시점의 분리다. 이번 오류에서 고칠 대상은 대기 시간이나 flush 횟수가 아니었다. 누가 파일을 열고, 어느 핸들이 먼저 닫히며, 마지막 삭제를 누가 책임지는지를 코드 순서로 만드는 것이었다.

댓글

홈으로 돌아가기

검색 결과

"" 검색 결과입니다.