CodeGym /행동 /Go SELF /temp file + rename 패턴: 파일의 원자적 업데이트

temp file + rename 패턴: 파일의 원자적 업데이트

Go SELF
레벨 42, 레슨 1
사용 가능

1. 왜 우리에게는 프로토콜이 필요하고, «그냥 파일을 쓰는 것»만으로는 충분하지 않은가

우리가 처음으로 데이터를 파일에 저장하기 시작하면, 손은 자연스럽게 os.WriteFile(...) 쪽으로 갑니다. 이것은 정상입니다. 간결하고, 읽기 쉽고, 거의 마법처럼 느껴집니다. 문제는 기록의 신뢰성이 «어떤 함수를 호출하느냐»가 아니라 «장애가 발생했을 때 결과가 어떤 속성을 보장해야 하느냐»에 있기 때문입니다. 그리고 바로 여기서부터 어른의 대화가 시작됩니다.

전형적인 시나리오를 상상해 보세요. 우리 프로그램이 하나의 파일에 데이터(예: 작업 목록)를 저장합니다. 우리는 그 파일을 새 버전으로 다시 씁니다. 그런데 갑자기 불쾌한 일이 생깁니다. 프로그램이 충돌하거나, 컴퓨터가 꺼지거나, 디스크가 «재채기»를 하거나, 공간이 부족해지거나, 권한이 사라지거나, 백신이 «도와주려»고 합니다. 대상 파일에 바로 쓰고 있었다면, 아주 쉽게 «반쯤 된 버전»을 얻게 됩니다. 파일은 이미 잘려 있는데 새 버전은 일부만 기록된 상태가 되는 것입니다.

여기서 중요한 점은 실패가 Write에서만 나타나는 것이 아니라는 것입니다. 버퍼와 파일 시스템의 세계에서는 오류가 나중에 나타날 수도 있습니다. 버퍼를 마무리하는 단계(Flush), Sync, 심지어 Close에서도 오류가 드러날 수 있습니다. 그리고 defer는 우리가 좋아하는 도구이지만, 함수가 끝날 때 지연된 닫기를 실행해 줄 뿐, 특히 Close의 오류를 직접 확인해야 하는 경우 그 오류를 올바르게 처리해 준다고 보장하지는 않습니다.

2. 원자적 업데이트의 아이디어

«기존 파일을 위에서 덮어쓰는» 대신, 우리는 신중한 문서 편집자처럼 행동합니다. 먼저 별도의 종이에 새 깨끗한 버전을 인쇄하고, 그것이 온전한지 확인한 뒤, 마지막 순간에 그 종이를 옛것 대신 책상 위에 놓는 것입니다. 파일 세계에서 이 «마지막 순간»은 이름 변경 (rename)입니다.

temp file + rename 패턴의 핵심은 작업을 두 단계로 나눈다는 데 있습니다. 긴 단계는 모든 데이터를 임시 파일에 쓰는 것입니다. 짧은 단계는 결과를 «공개»하는 것으로, 임시 파일의 이름을 대상 이름으로 바꾸는 것입니다. 긴 단계에서 실패가 일어나면 대상 파일은 아예 갱신되지 않았거나, 옛 파일이 그대로 남아 있습니다. 공개가 성공한 뒤 실패가 일어나면 이미 새 버전이 존재합니다.

여기서 핵심 단어는 원자성입니다. 실용적인 의미에서 이것은 이렇게 말하는 것과 같습니다. «어느 한 순간에 대상 파일은 옛 버전이거나 새 버전이어야 하며, “반쯤 된” 상태는 사용자에게 제공하지 않는다.» 이것은 마법도 아니고, 모든 OS와 모든 디스크의 모든 모드에서의 «절대적 보장»도 아니지만, 직접 덮어쓰는 것에 비해 매우 강력한 개선입니다.

왜 임시 파일은 가까이 있어야 하는가

이제 초보자들이 자주 놓치고 나중에 이해하기 어려운 오류를 만나게 되는 중요한 세부 사항이 있습니다. 원자적 공개를 위한 임시 파일은 대상 파일이 있는 같은 디렉터리에 만들어야 합니다. 시스템 temp에 만들거나 /tmp 어딘가에 만드는 것이 아니라, 정확히 옆에 두어야 합니다.

이유는 간단합니다. 같은 디렉터리 안에서의 rename은 보통 예측 가능한 이름 변경 작업이며, 흔히 원자적입니다. 하지만 서로 다른 디렉터리나 서로 다른 파일 시스템 간의 이름 변경은 «복사 + 삭제»로 바뀔 수 있고, 그러면 신뢰성 수준과 실패 영역이 완전히 달라집니다.

그래서 기본 레시피는 항상 이렇게 시작합니다. dir := filepath.Dir(targetPath)를 가져오고, 그곳에 temp를 만듭니다. 생성에는 os.CreateTemp(dir, pattern)를 사용하는 것이 편리합니다. 이 함수는 고유한 이름을 자동으로 선택하고 파일을 쓰기 모드로 엽니다.

작은 예제 — «옆에 temp를 만들고 이름을 저장하기»만:


package main

import (
	"fmt"
	"os"
	"path/filepath"
)

func main() {
	target := "data/tasks.txt"
	dir := filepath.Dir(target)

	tmp, err := os.CreateTemp(dir, ".tmp-*")
	if err != nil {
		fmt.Println("create temp:", err)
		return
	}
	fmt.Println("temp file:", tmp.Name()) // temp file: data/.tmp-123456...
	_ = tmp.Close()
}

3. 투명한 단계 프로토콜: 순서와 실패 지점

신뢰성은 하나의 «올바른 호출»에서 나오는 것이 아니라, 규율에서 나옵니다. 우리는 프로토콜을 고정하고 언제나 똑같이 실행합니다. 이것을 «if err != nil»이 200줄이나 되는 장황한 코드로 만들지 않으려면, 단계 표를 눈앞에 두는 편이 좋습니다.

아래는 우리가 구현할 temp file + rename의 기본 프로토콜입니다. 이것은 정확히 단계의 순서입니다. 순서를 바꾸면 «원자적 공개»의 의미를 쉽게 잃을 수 있습니다.

단계 작업 무엇이 잘못될 수 있는가 최종적으로 무엇이 남아야 하는가
1 대상 옆에 temp 생성 권한 없음, 공간 부족 대상 파일은 건드리지 않음
2 temp에 데이터 쓰기 쓰기 오류, 부분 기록 대상 파일은 건드리지 않음
3 (버퍼가 있으면) Flush() 버퍼 비우기 오류 대상 파일은 건드리지 않음
4 Sync() OS가 저장하지 못함 대상 파일은 건드리지 않음
5 오류를 확인하는 Close() 닫는 시점에 오류가 드러남 대상 파일은 건드리지 않음
6 Chmod(temp, perm) 모드를 변경할 권한 없음 대상 파일은 건드리지 않음
7 Rename(temp → target) rename 문제 대상은 옛 버전이거나 새 버전
8 오류가 있으면 temp 삭제 쓰레기가 지워지지 않음 적어도 대상은 망가뜨리지 않음

Flush()에 대한 중요한 상기 사항: bufio.Writer는 데이터를 메모리에 쌓아 두고 마지막까지 «조용할» 수 있습니다. Go에서 흔한 패턴은 여러 번 쓰고, 오류는 Flush에서 확인하는 것입니다.

4. Go에서의 구현: 거대한 함수 하나보다 작은 함수들

이제 코드를 읽기 쉽게 구성해 보겠습니다. 5~10줄짜리 작은 함수들, 명확한 이름들, 그리고 단계별로 프로토콜을 실행하는 하나의 «지휘자» 함수입니다. 이 경우 분해는 학문적 아름다움이 아니라, 단계를 헷갈리지 않고 «이중 Close»를 하지 않기 위한 방법입니다.

대상 파일 옆에 temp 만들기

«옆에 temp 만들기»를 별도 함수로 분리하겠습니다:

package storage

import (
	"fmt"
	"os"
	"path/filepath"
)

func createTempNear(targetPath string) (*os.File, string, error) {
	dir := filepath.Dir(targetPath)

	f, err := os.CreateTemp(dir, ".tmp-*")
	if err != nil {
		return nil, "", fmt.Errorf("create temp in %s: %w", dir, err)
	}
	return f, f.Name(), nil
}

주의하세요. 우리는 *os.File과 tmpName을 모두 반환합니다. 이름은 Chmod, Rename, Remove에 필요합니다.

모든 바이트를 정확하게 쓰기

우리는 Write(data)로 파일에 쓰고, 기대한 만큼 정확히 기록되었는지 확인합니다. 대부분의 일상적인 작업에서는 n을 확인하지 않아도 되지만, 신뢰할 수 있는 기록 프로토콜에서는 엄격한 편이 좋습니다.

package storage

import "fmt"

func writeAll(f interface {
	Write([]byte) (int, error)
}, data []byte) error {
	n, err := f.Write(data)
	if err != nil {
		return fmt.Errorf("write: %w", err)
	}
	if n != len(data) {
		return fmt.Errorf("write: partial write %d/%d", n, len(data))
	}
	return nil
}

마무리: Sync와 Close

defer와는 친구이지만, 중요한 기록에서는 한 가지 미묘한 점이 있습니다. 우리는 Close() 오류를 프로토콜의 일부로 직접 잡아내는 것이 중요합니다. «나중에 defer에서, 이미 다른 오류를 반환한 뒤에» 처리하는 것이 아닙니다. 따라서 Close는 명시적으로 호출하고 오류를 확인합니다. 동시에 defer는 조기 종료 시 정리용 안전장치로 남길 수 있습니다. 함수가 끝난 뒤 지연된 호출을 실행하는 메커니즘으로서의 defer, 그리고 LIFO 순서는 바로 이것이 cleanup에 편리한 이유입니다.

package storage

import (
	"fmt"
	"os"
)

func finalizeTempFile(f *os.File) error {
	if err := f.Sync(); err != nil {
		return fmt.Errorf("sync temp: %w", err)
	}
	if err := f.Close(); err != nil {
		return fmt.Errorf("close temp: %w", err)
	}
	return nil
}

공개: chmod와 rename

대상 파일은 기대한 perm으로 이미 나타나도록, 권한은 공개 전에 설정하는 편이 좋습니다.

package storage

import (
	"fmt"
	"os"
)

func publishTemp(tmpName, targetPath string, perm os.FileMode) error {
	if err := os.Chmod(tmpName, perm); err != nil {
		return fmt.Errorf("chmod temp: %w", err)
	}
	if err := os.Rename(tmpName, targetPath); err != nil {
		return fmt.Errorf("rename temp -> target: %w", err)
	}
	return nil
}

5. AtomicWriteFile을 조립하기: 이중 Close와 쓰레기 없이

이제 핵심 함수입니다. 여기서 흔히 발을 헛디디는 부분은 «defer Close + Close를 수동으로 또 호출하기»입니다. 우리는 이렇게 하겠습니다. defer는 조건부 안전장치로만 쓰고, 실제 Close()는 명시적으로 한 번만 호출합니다. 기술적으로는 «아직 닫아야 하는지/아직 지워야 하는지»를 나타내는 두 플래그로 보입니다.

네, 조금 더 장황합니다. 하지만 동작은 예측 가능하고, 가장 중요한 것은 Close()를 실수로 두 번 호출하지 않고, Close() 오류를 잃지 않으며, 조기 종료 시 임시 파일 삭제를 잊지 않는다는 점입니다.

package storage

import (
	"fmt"
	"os"
)

func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
	tmp, tmpName, err := createTempNear(path)
	if err != nil {
		return err
	}

	needClose := true
	needRemove := true
	defer func() {
		if needClose {
			_ = tmp.Close() // cleanup: 여기서는 더 이상 오류를 위로 올리지 않음
		}
		if needRemove {
			_ = os.Remove(tmpName)
		}
	}()

	if err := writeAll(tmp, data); err != nil {
		return fmt.Errorf("atomic write: %w", err)
	}

	if err := finalizeTempFile(tmp); err != nil {
		return fmt.Errorf("atomic write: %w", err)
	}
	needClose = false // Close는 정확히 한 번만 수행됨

	if err := publishTemp(tmpName, path, perm); err != nil {
		return fmt.Errorf("atomic write: %w", err)
	}
	needRemove = false // temp가 대상 파일이 됨

	return nil
}

6. 학습용 앱에 통합하기: 작업을 신뢰성 있게 저장하기

이것이 이론만으로 끝나지 않도록, 간단한 학습용 앱에 연결해 보겠습니다. 작업 목록(mini-todo)입니다. 아직 JSON은 사용하지 않겠습니다(그건 나중의 별도 주제입니다). 대신 가장 단순한 텍스트 형식을 사용하겠습니다. 한 줄에 하나의 작업, 맨 앞에는 0 또는 1(미완료 / 완료), 그다음 공백과 텍스트입니다.

먼저 데이터 모델입니다(최소한만, 메서드 없이 — 너무 앞서가지 않기 위해서입니다):

package model

type Task struct {
	Title string
	Done  bool
}

이제 텍스트로 직렬화합니다. 문자열을 조합하는 데 편리한 strings.Builder를 사용하고, 그다음 []byte로 바꿔서 기록합니다.

package storage

import (
	"strconv"
	"strings"

	"example.com/todo/model"
)

func encodeTasks(tasks []model.Task) []byte {
	var b strings.Builder

	for _, t := range tasks {
		done := "0"
		if t.Done {
			done = "1"
		}
		b.WriteString(done + " " + t.Title + "\n")
	}

	return []byte(b.String())
}

마지막으로, 우리의 원자적 프로토콜을 사용하는 «작업 저장» 함수입니다:

package storage

import (
	"os"

	"example.com/todo/model"
)

func SaveTasks(path string, tasks []model.Task) error {
	data := encodeTasks(tasks)
	return AtomicWriteFile(path, data, 0644)
}

만약 bufio.Writer로 쓰고 싶다면(예를 들어 모든 것을 메모리에 모으고 싶지 않다면), 이 생각을 기억하세요. bufio.Writer는 오류를 쌓아 두었다가 Flush()에서 알려줄 수 있습니다. 따라서 스트리밍 버전의 프로토콜에서는 Flush() 단계가 마무리의 필수 일부가 됩니다.

도식: 원자적 공개가 시간 속에서 어떻게 보이는가

머릿속에 그림을 고정하려면, 사용자(또는 프로그램의 다른 부분)가 시간에 따라 대상 파일을 «관찰»한다고 상상하면 도움이 됩니다.

flowchart TD
    A[옛 target.txt가 있음] --> B[temp를 가까이에 생성: .tmp-123]
    B --> C[temp에 데이터 기록]
    C --> D[temp에 대해 Flush/Sync/Close]
    D --> E[.tmp-123 -> target.txt Rename]
    E --> F[이제 target.txt = 새 버전]

핵심 의미는 다음과 같습니다. Rename 단계 전까지는 대상 파일이 우리의 쓰기 시도로 인해 «망가지면 안 됩니다». Rename 이후에는 갱신된 것으로 간주됩니다.

7. temp+rename 구현 시 흔한 실수

실수 #1: temp를 대상 파일 옆이 아니라 «시스템 temp 어딘가»에 만드는 것.
사람들은 습관적으로 이렇게 합니다. «임시 파일이니까 /tmp에 두면 되겠지.» 하지만 그러면 Rename이 파일 시스템 간 이동이 되어 «원자적 공개»의 의미를 잃을 수 있습니다. 올바른 규칙은 단순합니다. os.CreateTemp(filepath.Dir(target), pattern).

실수 #2: defer tmp.Close()를 해 놓고, 나중에 또 tmp.Close()를 수동으로 호출하는 것.
이것은 가장 흔한 소형 재난 중 하나입니다. 겉보기에는 «별일 아닌 것» 같지만, 예측 불가능한 동작을 얻거나(더 나쁘게는) 닫기/버퍼 비우기 오류라는 중요한 오류를 잃을 수 있습니다. 하나의 전략만 선택하는 것이 좋습니다. 신뢰할 수 있는 기록 프로토콜에서는 Close()를 명시적으로, 확인하면서 수행하고, defer는 성공적인 닫기 이후 플래그로 비활성화하는 조건부 안전장치로만 남겨 둡니다. defer의 의미와 LIFO 실행 순서는 이 기법을 작동하게 해 주지만, 동시에 규율을 요구합니다.

실수 #3: Close보다 먼저 Rename을 호출하는 것.
직관적으로는 «지금 이름부터 바꾸고 나중에 닫으면 되겠지»라고 느껴집니다. 그러나 실제로는 프로토콜을 흐리게 만듭니다. 아직 최종화되지 않은 파일을 공개하는 셈이기 때문입니다. 올바른 순서는 먼저 기록을 마무리하고(Flush/Sync/Close), 그다음에 공개하는 것입니다.

실수 #4: «Write가 안 떨어졌으면 — 다 저장된 것»이라고 생각하는 것.
bufio.Writer에서는 특히 교묘합니다. 기록은 버퍼에만 들어갔을 수 있고, 오류는 Flush에서 드러날 수 있습니다. «오류는 지연될 수 있다»는 생각은 Go의 버퍼화된 writer에서 표준적입니다.

실수 #5: 오류가 났을 때 temp를 삭제하지 않는 것.
보장된 cleanup이 없으면(보통 defer를 통해), 디렉터리는 시간이 지나면서 «임시 파일의 묘지»로 변합니다. 더 불쾌한 것은 어떤 파일이 실제로 최신인지 헷갈리기 시작한다는 점입니다. 데이터가 중요하더라도 쓰레기를 조용히 남겨두는 것은 나쁜 정책입니다. 지우거나, 아니면 명시적으로 로그를 남기고/신호를 보내야 합니다.

1
과제
Go SELF, 레벨 42, 레슨 1
잠금
옆의 tmp
옆의 tmp
1
과제
Go SELF, 레벨 42, 레슨 1
잠금
무손실 기록
무손실 기록
1
과제
Go SELF, 레벨 42, 레슨 1
잠금
원자적 교체
원자적 교체
1
과제
Go SELF, 레벨 42, 레슨 1
잠금
일괄 게시
일괄 게시
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION