기록

로컬 JSON 저장의 동시 쓰기 손상

TL;DR

  • 같은 파일에 fs.writeFile()을 동시에 호출하면 JSON 파일이 손상될 수 있다.
  • 손상은 “쓰다 만 파일”이 아니라 “짧은 JSON 뒤에 이전 긴 JSON의 잔여 바이트가 남는 형태”로 나타날 수 있다.
  • 임시 파일과 rename은 부분 쓰기를 막지만, 여러 쓰기의 순서를 정하지는 않는다.
  • 읽기·수정·쓰기를 같은 경로 기준 큐에 넣어야 project.json의 일관성을 지킬 수 있다.

Electron 앱에서 서버가 아니라 로컬 파일이 원본 데이터 역할을 했다. 프로젝트 폴더 안에는 project.json이 있고, 이 파일에 파일 트리, 제목, 체크리스트, 태그, 목표 설정, 마지막 수정 시각이 들어간다. 장애는 단순했다.

JSON.parse(project.json) 실패

앱이 직접 쓴 파일을 앱이 다시 읽지 못하는 상태였다. 서버 데이터라면 다시 요청하면 되지만, 로컬 파일 기반 앱에서는 그 파일이 원본이다. 따라서 문제는 저장 실패가 아니라 데이터 원본 손상이다.

손상 형태

처음 의심하기 쉬운 형태는 부분 쓰기다. 프로세스가 쓰는 중간에 죽으면 JSON 끝이 잘린 채 남을 수 있다.

{
  "id": "project-1",
  "tree":

하지만 실제로 더 중요한 형태는 잔여 바이트다.

{"title":"short"}"oldField":"previous-long-value", ... }

앞부분은 새 JSON이고, 뒤쪽에는 이전 파일의 나머지가 남아 있다. 이 상태에서는 짧은 JSON이 끝나는 위치에서 JSON.parse가 실패한다. 이 모양은 짧은 내용이 긴 파일의 앞부분만 덮었을 때 생긴다. 파일 끝이 잘린 것이 아니라, 이전 파일의 뒤쪽이 남은 것이다.

fs.writeFile의 동시 호출

Node.js의 fs.writeFile은 기본적으로 파일을 쓰기 모드로 연다. 같은 파일에 대해 여러 writeFile을 기다리지 않고 호출하면 안전하지 않다. 문제는 자바스크립트 스레드가 여러 개라는 뜻이 아니다. main process가 하나여도, await 없이 시작된 파일 쓰기 작업은 OS 레벨에서 겹칠 수 있다. 최소 모양은 이렇다.

const big = JSON.stringify({ note: "A".repeat(200_000) });
const small = JSON.stringify({ note: "B".repeat(50_000) });

await Promise.all([ fs.writeFile(file, big, "utf-8"), fs.writeFile(file, small, "utf-8"), ]);

const raw = await fs.readFile(file, "utf-8");
JSON.parse(raw);

가능한 결과는 세 가지다.

결과 의미
big 전체 긴 쓰기가 마지막에 이김
small 전체 짧은 쓰기가 마지막에 이기고 파일 길이도 짧아짐
small + big 잔여 앞은 짧은 쓰기, 뒤는 긴 쓰기 잔여

세 번째가 JSON 손상이다. 파일 길이는 긴 쪽에 가까운데, 앞부분은 짧은 JSON이라 중간에 닫힌 객체 뒤로 이전 바이트가 이어진다.

같은 파일을 쓰는 여러 기능

project.json은 하나의 설정 파일처럼 보이지만 실제로는 여러 기능의 공유 자원이었다.

기능 project.json에서 바꾸는 것
문서 저장 updatedAt, 문서 메타데이터
새 문서·폴더 생성 tree 노드
드래그앤드롭 tree 전체
제목 변경 title, path
체크리스트 checklist
태그·라벨 tags, labels
목표 설정 trackerSettings

대부분의 코드는 파일을 읽고, 객체 일부를 수정하고, 다시 전체 JSON을 쓴다.

const project = await readProject(projectPath);
const nextProject = {
  ...project,
  checklist: nextChecklist,
};
await writeProject(projectPath, nextProject);

이 패턴은 단일 호출에서는 자연스럽다. 하지만 여러 기능이 동시에 같은 파일을 읽고 쓰면 경쟁 조건이 생긴다.

A: project.json 읽기
B: project.json 읽기
A: checklist 수정 후 쓰기
B: title 수정 후 쓰기

이 경우 파일이 깨지지 않아도 A의 변경이 사라질 수 있다. 읽기·수정·쓰기 전체가 하나의 트랜잭션처럼 보호되어야 한다.

debounce의 범위

자동 저장에는 debounce가 들어가 있었다.

const handleDebouncedSave = useCallback(
  debounce((payload) => saveTemplate(payload), 1000),
  [],
);

문제는 debounce의 소유권이다. 에디터 훅이 여러 인스턴스에서 실행되면 debounce 함수도 여러 개 생긴다. 필드 A와 필드 B가 각각 자기 타이머를 갖고 있고, 두 타이머가 비슷한 시점에 만료되면 저장 요청은 동시에 출발한다. debounce는 한 호출 지점의 빈도를 줄인다. 여러 호출 지점이 같은 파일을 쓰는 순서를 정하지는 않는다. 공유 자원을 보호하려면 호출 지점이 아니라 자원 기준으로 줄을 세워야 한다.

원자적 쓰기의 역할

부분 쓰기 문제를 줄이기 위해 임시 파일에 먼저 쓰고 rename으로 교체할 수 있다.

const tempPath = `${filePath}.temp`;

await fs.writeFile(tempPath, JSON.stringify(data, null, 2), "utf-8");
await fs.rename(tempPath, filePath);

이 패턴은 최종 파일이 반쯤 쓰인 상태로 관측되는 문제를 줄인다. 읽는 쪽은 이전 파일 또는 새 파일 중 하나를 보게 된다. 하지만 이것만으로 동시성 문제가 사라지지는 않는다.

문제 원자적 쓰기 효과
쓰기 중 프로세스 종료 완화
최종 파일 일부만 갱신 완화
두 쓰기 순서 경쟁 해결하지 않음
read-modify-write 유실 해결하지 않음
같은 temp 파일명 충돌 새 문제 가능

특히 temp 파일명을 ${filePath}.temp로 고정하면 동시 호출 두 개가 임시 파일 단계에서 다시 충돌할 수 있다. 원자적 쓰기 함수 자체도 동시 호출을 고려해야 한다.

const tempPath = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;

임시 파일명은 호출마다 고유해야 한다.

경로별 쓰기 큐

핵심 해결책은 파일 경로를 기준으로 큐를 두는 것이다.

const queues = new Map<string, Promise<unknown>>();

function enqueue<T>(filePath: string, operation: () => Promise<T>): Promise<T> { const previous = queues.get(filePath) ?? Promise.resolve(); const next = previous .catch(() => undefined) .then(operation) .finally(() => { if (queues.get(filePath) === next) { queues.delete(filePath); } });

queues.set(filePath, next);
  return next;
}

catch(() => undefined)를 앞에 둔 이유는 앞 작업이 실패해도 뒤 작업이 큐에서 같이 죽지 않게 하기 위해서다. 저장 하나의 실패가 이미 줄 서 있던 저장 전체를 막으면 또 다른 데이터 유실이 된다. 중요한 것은 큐에 넣는 단위다. 쓰기만 큐에 넣으면 부족하다.

// 부족함: 읽기가 큐 밖에 있음
const project = await readProject(projectPath);
await enqueue(projectPath, () => writeProject(projectPath, next(project)));

읽기·수정·쓰기를 한 덩어리로 넣어야 한다.

await enqueue(projectPath, async () => {
  const project = await readProject(projectPath);
  const nextProject = update(project);
  await writeProject(projectPath, nextProject);
});

이렇게 해야 같은 파일에 대한 변경이 순서대로 적용된다.

큐의 한계

메모리 큐는 한 프로세스 안에서만 동작한다.

상황 보장
같은 Electron main process 안의 여러 저장 큐로 직렬화 가능
서로 다른 파일 경로 병렬 처리 가능
앱 인스턴스 두 개 큐가 분리되어 보장 없음
외부 프로세스가 같은 파일 수정 보장 없음

두 개의 앱 인스턴스가 같은 프로젝트 폴더를 열 수 있다면 app.requestSingleInstanceLock() 또는 OS 수준 파일 잠금을 검토해야 한다. 메모리 큐는 프로세스 내부 구조이므로 프로세스 밖의 쓰기를 볼 수 없다.

복구 검증

깨진 JSON을 복구할 때도 기준이 필요하다. JSON.parse가 다시 성공한다고 복구가 성공한 것은 아니다. 예를 들어 기본 구조와 병합하는 복구 코드는 필드를 되살리는 것처럼 보일 수 있다.

const merged = {
  ...defaultStructure,
  ...repairedData,
  id: repairedData.id || projectId,
  tree: repairedData.tree || defaultStructure.tree,
  checklist: repairedData.checklist || [],
};

이 코드는 파싱 가능한 객체를 만들 수는 있다. 하지만 tree가 기본값으로 대체되면 사용자의 문서 구조는 사라진다. 필수 필드 존재 여부만 검사하면 이런 손실을 잡지 못한다. 복구 검증은 구조가 존재하는지만 보지 말고, 보존되어야 하는 데이터의 양과 연결성을 봐야 한다.

function validateRecoveredProject(project: Project) {
  assert(project.id);
  assert(project.tree.root);
  assert(countNodes(project.tree) > 1);
  assert(noDanglingChildren(project.tree));
}

프로젝트마다 노드가 하나뿐인 상태가 유효할 수 있다면, 복구 전 백업과 비교해서 노드 수, 문서 수, checklist 항목 수가 급격히 줄었는지 검사하는 쪽이 낫다. 복구 함수는 원본을 덮기 전에 백업 파일을 남겨야 하고, UI도 백업이 있다는 사실을 사용자에게 알려야 한다. 백업을 남기는 것과 사용자가 되돌릴 수 있는 것은 다른 문제다.

정리

로컬 파일 기반 Electron 앱에서 JSON 손상은 파일 시스템 문제가 아니라 애플리케이션 동시성 문제일 수 있다. 같은 project.json을 여러 기능이 동시에 read-modify-write하면 파일은 깨질 수도 있고, 깨지지 않아도 변경이 유실될 수 있다. 해결 기준은 네 가지다.

  1. 같은 경로의 읽기·수정·쓰기를 큐로 직렬화한다.
  2. 원자적 쓰기는 부분 쓰기 방지용으로 쓰고, 순서 보장은 큐에 맡긴다.
  3. 임시 파일명은 호출마다 고유하게 만든다.
  4. 복구 성공은 파싱 성공이 아니라 사용자 데이터 보존으로 판정한다.

fs.writeFile 한 줄은 간단하지만, 공유 파일을 원본 데이터로 쓰는 순간 그 파일은 데이터베이스에 가까운 책임을 갖는다. 데이터베이스 없이 파일로 간다면 최소한 쓰기 순서와 복구 판정은 애플리케이션이 직접 가져가야 한다.

관련 글