5초 전까지만 해도 잘 작동하던 설정 파일이 이제는 깨진 JSON 조각 347바이트가 되어버렸습니다. CLI 도구는 실행되지 않습니다. 업데이트가 예상보다 오래 걸려 단순히 Ctrl-C를 누른 사용자는, 요청하지도 않은 에러 스택 트레이스를 마주하게 됩니다. 쓰기 작업 전에 존재했던 2KB의 유효한 설정은 사라지고, 그 자리는 반쯤 인쇄된 영수증과 다를 바 없는 디지털 쓰레기로 대체되었습니다.
이는 writeFile이 원자적(atomic)이지 않기 때문에 발생합니다. writeFile은 기존 경로를 열고, 내용을 비우고(truncate), Node에서 커널의 페이지 캐시로 데이터를 스트리밍한 다음, 최종적으로 파일 디스크립터를 닫습니다. 내용을 비우는 순간, 기존 내용은 이미 사라집니다. 내용을 비우는 시점과 최종 close 사이의 모든 시간은 취약한 구간(window of vulnerability)이 됩니다. 이 구간에서 SIGINT가 발생하거나, 정전이 되거나, 노트북 덮개가 닫히면 파일 시스템에는 잘려 나간 엉망진창인 파일만 남게 됩니다. Node가 Promise를 resolved 상태로 보고하더라도, 운영체제는 여전히 메모리에서 쓰기 작업을 버퍼링하고 있을 수 있습니다. 편의 메서드들은 이 간극을 숨겨줄 뿐, 없애주지는 않습니다.
해결책은 제자리에서 쓰는 것이 아닙니다. 쓰는 행위와 게시(publishing)하는 행위를 분리하는 것입니다.
형제 파일에 쓰고, 교체하기
신뢰할 수 있는 이 패턴은 5단계로 구성됩니다. 각 단계는 복잡하지 않지만, 이를 통해 실패 구간을 전체 스트리밍 쓰기 작업에서 단일 파일 시스템 메타데이터 작업으로 축소할 수 있습니다.
첫째, 메모리 내에서 전체 페이로드를 직렬화(serialize)합니다. 임시 파일을 생성하기 전에 이 작업을 수행하십시오. 누군가 순환 참조 객체(circular object)를 전달하여 JSON.stringify에서 예외가 발생한다면, 디스크를 건드리기 전에 해당 예외가 상위로 전달되도록 해야 합니다.
둘째, 직렬화된 데이터를 대상 디렉토리와 동일한 위치에 있는 임시 파일에 씁니다. 두 개의 동시 실행 작업이 충돌하지 않도록 무작위 이름을 사용하십시오. 임시 파일을 동일한 디렉토리에 유지하는 것이 중요한 이유는 rename이 단일 파일 시스템 내에서만 원자적이기 때문입니다. 만약 임시 파일이 다른 파티션에 있다면, 운영체제는 복사 후 삭제(copy-and-delete) 방식을 사용하게 되며, 이는 자체적인 실패 모드를 유발하고 더 이상 원자적이지 않게 됩니다.
셋째, 커널에 해당 임시 파일을 물리적 저장소로 플러시(flush)하도록 요청합니다. 파일 핸들의 sync 메서드로 제공되는 Node의 fsync는 버퍼가 실제 물리 장치에 기록될 때까지 블로킹(block)됩니다. 이 작업은 느리지만, 설정 파일 쓰기는 드물게 발생하므로 이 정도의 밀리초(ms)를 투자할 가치가 충분합니다.
넷째, 임시 파일의 이름을 원래 경로로 변경(rename)합니다. POSIX 시스템과 Windows 모두에서 이 지점이 커밋(commit) 포인트입니다. 원래 경로를 여는 읽기 작업은 완전한 이전 파일 또는 완전한 새 파일 중 하나만을 보게 됩니다. 읽기 작업이 경로를 열었을 때 반쯤 쓰여진 버퍼를 관찰하게 되는 순간은 존재하지 않습니다.
다섯째, 부모 디렉토리를 동기화(sync)합니다. 이는 미묘한 예외 케이스를 방지하기 위함입니다. rename은 디렉토리 엔트리를 업데이트하지만, 디렉토리 메타데이터 자체는 커널의 페이지 캐시에 남아 있을 수 있습니다. rename 성공 직후 갑작스러운 전원 차단이 발생하면, 새로운 inode 참조가 영구적으로 기록되지 않은 상태로 파일 시스템이 남을 수 있습니다. 디렉토리를 동기화하면 해당 메타데이터 업데이트를 디스크로 강제하여 트랜잭션을 완료(seal)할 수 있습니다.
Node.js 구현 예시
Node.js 표준 라이브러리만을 사용하여 이 패턴을 실제로 구현하면 다음과 같습니다:
import { open, rename, rm } from "node:fs/promises";
import { dirname, basename, join } from "node:path";
import { randomUUID } from "node:crypto";
export async function writeJsonAtomic(path, value) {
const directory = dirname(path);
const temporary = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
const body = `${JSON.stringify(value, null, 2)}\n`;
let handle;
try {
handle = await open(temporary, "wx", 0o600);
await handle.writeFile(body, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await rename(temporary, path);
const directoryHandle = await open(directory, "r");
try {
await directoryHandle.sync();
} finally {
await directoryHandle.close();
}
} catch (error) {
if (handle) await handle.close().catch(() => {});
await rm(temporary, { force: true }).catch(() => {});
throw error;
}
}
이 코드에서 주의 깊게 살펴볼 몇 가지 세부 사항이 있습니다.
wx 플래그는 "쓰기를 수행하되, 파일이 이미 존재하면 실패하라"는 의미입니다. 이는 UUID 충돌이나 이전 프로세스가 비정상 종료되면서 남긴 임시 파일로부터 보호하는 역할을 합니다. 만약 누군가 임시 파일이 있어야 할 자리에 악성 파일을 놓아두었다면, 기존 내용을 덮어쓰는 대신 즉시 오류를 인지할 수 있습니다.
0o600 권한 마스크는 소유자만 읽고 쓸 수 있도록 임시 파일을 생성합니다. 설정 파일에는 종종 비밀 정보, API 토큰 또는 비공개 저장소 URL이 포함됩니다. 파일이 준비되는 동안 시스템의 다른 사용자가 임시 파일을 엿보게 할 이유는 전혀 없습니다.
파일과 디렉토리에 대해 별도로 sync를 호출하는 점에 주목하십시오. 많은 개발자가 디렉토리 동기화가 불필요하다고 느껴 이를 생략하곤 합니다. 하지만 그렇지 않습니다. Ext4, APFS, NTFS는 모두 디렉토리 업데이트를 다르게 처리하지만, 성능을 위해 메타데이터 쓰기를 일괄 처리(batching)하는 공통적인 습성이 있습니다. 전원 차단 상황에서도 살아남고 싶다면, 디렉토리 동기화가 마지막 봉인(seal)이 됩니다.
catch 블록의 정리 작업은 의도적으로 방어적으로 설계되었습니다. 파일 핸들이 열린 후 어떤 예외가 발생하더라도, 코드는 핸들을 닫고 임시 파일을 삭제하려고 시도하며, 이때 발생하는 2차 에러는 무시하여 원래의 예외가 깨끗하게 전파되도록 합니다. 정리 과정에서 발생한 권한 에러가 실제 실패를 일으킨 진짜 버그를 가려버리는 상황을 방지하기 위함입니다.
이 패턴이 도움이 되지 않는 경우
원자적 파일 교체는 데이터가 깨진 채로 써지는 torn write를 방지합니다. 하지만 업데이트 손실(lost updates)을 방지하지는 못합니다. 만약 두 개의 CLI 인스턴스가 동시에 동일한 설정을 읽고, 둘 다 메모리에서 수정을 수행한 뒤, 각각 새로운 임시 파일을 쓰고 rename을 실행한다면, 두 번째 rename이 승리하게 됩니다. 첫 번째 프로세스는 두 번째 프로세스의 변경 사항을 관찰하지 못했기 때문입니다. 애플리케이션에 따라, 이는 한 사용자가 한 터미널에서 설정을 추가하고 다른 사용자가 다른 터미널에서 이를 삭제했을 때, 최종 파일에 마지막 작성자의 변경 사항만 반영되는 결과를 초래할 수 있습니다.
도구가 동시 수정(concurrent mutation)을 지원해야 한다면, 원자적 쓰기 위에 별도의 조정 메커니즘이 필요합니다. 간단한 경우에는 advisory lock 파일이 효과적입니다. 설정 파일 내부에 버전 벡터(version vectors)나 단조 증가하는 리비전 번호(monotonic revision number)를 두어 충돌을 감지하면, 두 번째 작성자가 재시도할 수 있도록 도울 수 있습니다. 하지만 이러한 방식은 복잡성을 더하며, 버그는 바로 그 복잡성 속에서 숨어듭니다.
이것이 바로 경계가 중요한 이유입니다. 하나의 프로세스가 가끔 업데이트하는 단일 JSON 블롭은 원자적 파일 쓰기에 적합한 대상입니다. 하지만 여러 레코드를 관리하거나, 스키마를 강제하거나, 동시 수정에 대해 걱정해야 하는 시점이 온다면, 이미 파일 시스템의 한계를 넘어선 것입니다. SQLite가 존재하는 이유가 바로 이것입니다. SQLite는 단일 호스트 로컬 파일 내에서 원자적 트랜잭션, 롤백 저널, 그리고 동시 읽기/쓰기에 대한 적절한 처리를 모두 제공합니다. 영리한 파일 프로토콜은 데이터베이스가 아니며, 그렇지 않은 척하며 유지보수 비용을 낭비해서는 안 됩니다.
핵심 요약
다음에 CLI 도구 내부에서 writeFile을 사용하려 한다면 잠시 멈추십시오. 어려운 부분은 직렬화가 아닙니다. 내구성(durability)이 어려운 부분입니다. 설정 파일은 스트리밍하기에는 너무 작고, 내용을 잘라내기(truncate)에는 너무 중요합니다. 전체 페이로드를 숨겨진 형제 파일에 쓰고, flush한 뒤, rename으로 커밋하고, 디렉토리에 이를 알리십시오. 사용자가 Ctrl-C를 누르거나, 전원 코드를 뽑거나, 노트북 덮개를 닫아버릴 수도 있습니다. 컴퓨터가 다시 켜졌을 때, 파일은 이전 상태이거나 새로운 상태 중 하나일 것입니다. 그 중간 상태는 존재하지 않을 것입니다.
