Node.js JSON 파일 저장소와 동시성 - 동시 요청에 데이터가 사라지는 이유 (Node.js API 7단원)
이 단원에서 배우는 것
서버를 다시 켜도 메모가 남아 있게 만든다. 3단원의 loadMemos/saveMemos를 요청마다 부르면 될 것 같지만, 요청이 동시에 들어오면 데이터가 사라진다. 스레드가 하나뿐인 Node.js에도 동시성 문제가 있다는 것을 직접 재현하고, 저장소가 쓰기를 한 줄로 세우게 만들어 막는다.
- "읽고 → 고치고 → 쓰기" 사이에
await가 있으면 다른 요청이 끼어든다는 것을 확인한다. Promise사슬로 쓰기 작업을 한 번에 하나씩 실행하는 대기열을 만든다.- 파일 쓰기에 성공했을 때만 메모리를 바꾸는 순서, 동시 저장에서 임시 파일 이름이 부딪치는 문제를 다룬다.
- 앱이 저장소를 주입받게 바꿔, 저장 방식이 바뀌어도 라우팅 코드는 그대로 두는 구조를 만든다.
문제 상황
메모 앱을 스터디원 스무 명이 동시에 쓰기 시작했다. 모임 시작 시각에 모두 "오늘 할 일"을 한꺼번에 올렸는데, 목록에는 한 개만 남았다. 오류 로그는 없다. 요청은 전부 201을 받았다. 코드는 이랬다.
const memos = await loadMemos(file);
memos.push({ id: memos.length + 1, title, done: false });
await saveMemos(file, memos);
이 단원에서 memo-api 폴더에 더하거나 바꾸는 파일은 lib/memo-file.mjs, lib/store.mjs, app.mjs, server.mjs와 실험용 race.mjs, burst.mjs다. lib/http.mjs, lib/memos.mjs, call.mjs는 6단원 그대로다.
완성 코드
사라지는 메모 재현: race.mjs
// race.mjs — 같은 파일에 동시에 20번 추가하면 몇 개가 남을까
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import { loadMemos, saveMemos } from './lib/memo-file.mjs';
import { createMemoStore } from './lib/store.mjs';
const dir = join(import.meta.dirname, 'race-data');
await rm(dir, { recursive: true, force: true });
const titles = Array.from({ length: 20 }, (_, i) => `메모 ${i + 1}`);
// 1) 읽고 → 고치고 → 쓰기를 아무 순서 조절 없이
async function addNaive(file, title) {
const memos = await loadMemos(file);
memos.push({ id: memos.length + 1, title, done: false });
await saveMemos(file, memos);
}
const naiveFile = join(dir, 'naive.json');
await Promise.all(titles.map((t) => addNaive(naiveFile, t)));
const naive = await loadMemos(naiveFile);
console.log(`줄 세우지 않은 추가: 20번 요청 → 파일에 ${naive.length}개`);
// 2) store 가 쓰기를 한 줄로 세운다
const queuedFile = join(dir, 'queued.json');
const store = createMemoStore(queuedFile);
const created = await Promise.all(titles.map((title) => store.create({ title, done: false })));
const queued = await loadMemos(queuedFile);
console.log(`store 로 추가: 20번 요청 → 파일에 ${queued.length}개`);
console.log('받은 id:', created.map((m) => m.id).join(','));
await rm(dir, { recursive: true, force: true });
lib/memo-file.mjs
// lib/memo-file.mjs — 3단원 파일에서 임시 파일 이름만 바꿨다
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { randomUUID } from 'node:crypto';
export async function loadMemos(file) {
let text;
try {
text = await readFile(file, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
try {
return JSON.parse(text);
} catch (err) {
throw new Error(`${file} 의 JSON 이 깨졌습니다: ${err.message}`, { cause: err });
}
}
export async function saveMemos(file, memos) {
await mkdir(dirname(file), { recursive: true });
const temp = `${file}.${randomUUID()}.tmp`; // 동시에 저장해도 임시 파일끼리 부딪치지 않게
await writeFile(temp, JSON.stringify(memos, null, 2) + '\n', 'utf8');
await rename(temp, file);
}
lib/store.mjs
// lib/store.mjs — JSON 파일 하나를 쓰는 메모 저장소. 쓰기는 한 번에 하나씩만
import { loadMemos, saveMemos } from './memo-file.mjs';
export function createMemoStore(file) {
let memos = null; // 처음 필요할 때 파일에서 한 번 읽어 둔다
let queue = Promise.resolve(); // 쓰기 작업이 줄 서는 곳
async function current() {
memos ??= await loadMemos(file);
return memos;
}
// task 를 줄 맨 뒤에 세운다. 앞 작업이 성공하든 실패하든 끝나야 시작한다
function exclusive(task) {
const run = queue.then(task);
queue = run.catch(() => {}); // 한 작업의 실패가 뒤 작업을 막지 않게
return run;
}
// 새 배열을 파일에 먼저 쓰고, 성공했을 때만 메모리의 목록을 바꾼다
async function commit(next) {
await saveMemos(file, next);
memos = next;
}
return {
async list() {
return (await current()).map((m) => ({ ...m })); // 복사본을 준다
},
async get(id) {
const memo = (await current()).find((m) => m.id === id);
return memo ? { ...memo } : null;
},
create(value) {
return exclusive(async () => {
const all = await current();
const memo = { id: Math.max(0, ...all.map((m) => m.id)) + 1, ...value };
await commit([...all, memo]);
return { ...memo };
});
},
update(id, patch) {
return exclusive(async () => {
const all = await current();
const old = all.find((m) => m.id === id);
if (!old) return null;
const memo = { ...old, ...patch, id };
await commit(all.map((m) => (m.id === id ? memo : m)));
return { ...memo };
});
},
remove(id) {
return exclusive(async () => {
const all = await current();
if (!all.some((m) => m.id === id)) return false;
await commit(all.filter((m) => m.id !== id));
return true;
});
},
flush() { // 줄 서 있는 쓰기가 모두 끝나면 이행되는 Promise
return queue;
},
};
}
app.mjs
라우팅 표와 오류 처리는 6단원과 같다. 배열 대신 store를 받고, 처리 함수가 await store...를 부른다.
// app.mjs — 6단원 앱에서 배열 대신 store 를 쓰도록 바꿨다 (라우팅·오류 처리는 그대로)
import { HttpError, sendJson, readJson } from './lib/http.mjs';
import { validateMemo, parseId } from './lib/memos.mjs';
export function createApp({ store, onError = console.error }) {
const notFound = (id) => new HttpError(404, 'MEMO_NOT_FOUND', `${id}번 메모가 없습니다`);
const routes = [
['GET', '/health', () => ({ body: { ok: true } })],
['GET', '/memos', async ({ url }) => {
const q = url.searchParams.get('q');
const all = await store.list();
const items = q ? all.filter((m) => String(m.title ?? '').includes(q)) : all;
return { body: { items, count: items.length } };
}],
['POST', '/memos', async ({ req }) => {
const memo = await store.create(validateMemo(await readJson(req)));
return { status: 201, body: memo, headers: { location: `/memos/${memo.id}` } };
}],
['GET', '/memos/:id', async ({ params }) => {
const id = parseId(params.id);
const memo = await store.get(id);
if (!memo) throw notFound(id);
return { body: memo };
}],
['PATCH', '/memos/:id', async ({ req, params }) => {
const id = parseId(params.id);
const memo = await store.update(id, validateMemo(await readJson(req), { partial: true }));
if (!memo) throw notFound(id);
return { body: memo };
}],
['DELETE', '/memos/:id', async ({ params }) => {
const id = parseId(params.id);
if (!(await store.remove(id))) throw notFound(id);
return { status: 204 };
}],
];
function match(pattern, path) {
const a = pattern.split('/');
const b = path.split('/');
if (a.length !== b.length) return null;
const params = {};
for (let i = 0; i < a.length; i++) {
if (a[i].startsWith(':')) params[a[i].slice(1)] = b[i]; // 숫자 id 만 쓰므로 디코딩하지 않는다
else if (a[i] !== b[i]) return null;
}
return params;
}
async function dispatch(req, res) {
const url = new URL(req.url, 'http://localhost');
const allowed = [];
for (const [method, pattern, handler] of routes) {
const params = match(pattern, url.pathname);
if (!params) continue;
if (method !== req.method) {
allowed.push(method);
continue;
}
const { status = 200, body, headers } = await handler({ req, url, params });
return sendJson(res, status, body, headers);
}
if (allowed.length) {
throw new HttpError(405, 'METHOD_NOT_ALLOWED', `${req.method} 메서드는 이 주소에서 쓸 수 없습니다`,
undefined, { allow: allowed.join(', ') });
}
throw new HttpError(404, 'NOT_FOUND', `${url.pathname} 는 없는 주소입니다`);
}
return async function app(req, res) {
try {
await dispatch(req, res);
} catch (err) {
if (res.headersSent) return res.destroy(err);
if (err instanceof HttpError) {
const { status, code, message, details, headers } = err;
return sendJson(res, status, { error: { code, message, details } }, headers);
}
onError(err, req);
sendJson(res, 500, { error: { code: 'INTERNAL', message: '서버에서 문제가 생겼습니다' } });
}
};
}
server.mjs와 burst.mjs
// server.mjs — 메모를 data/memos.json 에 저장하는 서버
import { createServer } from 'node:http';
import { join } from 'node:path';
import { createApp } from './app.mjs';
import { createMemoStore } from './lib/store.mjs';
const store = createMemoStore(join(import.meta.dirname, 'data', 'memos.json'));
const app = createApp({
store,
onError: (err, req) => console.error(`[500] ${req.method} ${req.url}\n${err.stack}`),
});
createServer(app).listen(3700, '127.0.0.1', () => {
console.log('메모 API 대기 중: http://127.0.0.1:3700');
});
// burst.mjs — 서버에 메모 추가 요청 N개를 한꺼번에 보낸다
const BASE = process.env.API_BASE ?? 'http://127.0.0.1:3700';
const n = Number(process.argv[2] ?? 10);
const responses = await Promise.all(
Array.from({ length: n }, (_, i) =>
fetch(`${BASE}/memos`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: `동시 요청 ${i + 1}` }),
})),
);
const statuses = responses.map((r) => r.status);
const ids = (await Promise.all(responses.map((r) => r.json()))).map((m) => m.id);
console.log('상태 코드:', [...new Set(statuses)].join(','), `(${n}건)`);
console.log('받은 id(정렬):', ids.sort((a, b) => a - b).join(','));
const list = await (await fetch(`${BASE}/memos`)).json();
console.log('GET /memos count:', list.count);
줄별 해설
왜 사라지는가: await 사이의 틈
Node.js는 스레드가 하나라서 두 코드가 "정확히 같은 순간"에 실행되지는 않는다. 하지만 await에서 멈출 때마다 다른 요청의 코드가 실행될 수 있다. 스무 개 요청이 도착하면 이렇게 된다.
- 요청 1이
loadMemos를 시작하고await에서 멈춘다. 요청 2~20도 차례로 같은 곳에서 멈춘다. 아직 아무도 저장하지 않았으므로 스물 모두 빈 목록을 읽는다. - 각자 빈 목록에 자기 메모 하나를 넣어
[자기 메모]를 만든다.id도 모두 1이다. - 스물이 차례로 파일을 덮어쓴다. 마지막에 쓴 요청의 메모 하나만 남는다.
그림 7-1. 갱신 손실이 생기는 순서
이것을 갱신 손실(lost update)이라고 한다. 데이터베이스를 써도 "읽고 → 고치고 → 쓰기"를 따로 하면 똑같이 생긴다. 실행 결과에서 20번 요청해 1개가 남는 것을 확인한다.
한 줄로 세우기: exclusive
function exclusive(task) {
const run = queue.then(task);
queue = run.catch(() => {}); // 한 작업의 실패가 뒤 작업을 막지 않게
return run;
}
queue는 "지금까지 줄 선 작업이 모두 끝나면 이행되는 Promise"다. 새 작업은 queue.then(task)로 그 뒤에 붙는다. 앞 작업이 끝나야 task가 시작되므로, 스무 개 create가 동시에 불려도 실제 "읽고 → 고치고 → 쓰기"는 하나씩 실행된다. queue = run.catch(() => {})는 작업 하나가 실패했을 때 줄 전체가 실패 상태로 굳지 않게 하는 장치다. 실패는 run을 받은 호출자에게 그대로 전달되고, 줄은 다음 작업으로 넘어간다.
읽기(list, get)는 줄을 서지 않는다. 메모리의 배열을 읽기만 하므로 기다릴 필요가 없다.
그림 7-2. 쓰기를 한 줄로 세우는 저장소
파일이 먼저, 메모리는 나중
async function commit(next) {
await saveMemos(file, next);
memos = next;
}
새 배열을 만들어 파일에 먼저 쓰고, 성공했을 때만 메모리의 memos를 바꾼다. 디스크가 가득 차서 쓰기가 실패하면 메모리는 옛 상태 그대로다. 순서를 거꾸로 하면 "화면에는 보이는데 재시작하면 사라지는 메모"가 생긴다. 기존 배열을 push로 고치지 않고 [...all, memo]로 새 배열을 만드는 이유도 같다. 실패했을 때 되돌릴 필요가 없다.
list와 get이 복사본({ ...m })을 주는 것도 같은 생각이다. 받은 쪽이 객체를 고쳐도 저장소 안의 데이터는 바뀌지 않는다. 6단원의 PATCH는 배열 속 객체를 Object.assign으로 직접 고쳤는데, 이제는 반드시 store.update를 거친다.
임시 파일 이름이 부딪치지 않게
const temp = `${file}.${randomUUID()}.tmp`;
3단원은 임시 파일 이름을 memos.json.tmp 하나로 고정했다. race.mjs의 첫 실험처럼 저장이 동시에 일어나면 두 요청이 같은 임시 파일에 쓰고, 먼저 끝난 쪽이 rename으로 가져가 버려 나머지는 ENOENT로 실패할 수 있다. 저장소가 쓰기를 줄 세우면 이 일은 없지만, 파일 함수는 누가 어떻게 부를지 모르므로 스스로 안전하게 만든다. randomUUID는 겹칠 걱정이 없는 무작위 이름을 만든다.
저장소를 주입받는 앱
createApp({ store })는 저장소가 파일인지 메모리인지 모른다. list, get, create, update, remove만 있으면 된다. 나중에 데이터베이스로 옮길 때도 같은 이름의 메서드를 가진 저장소를 만들어 넘기면 라우팅 코드는 바뀌지 않는다. 10단원 테스트는 임시 폴더를 가리키는 저장소를 넘겨 실제 데이터를 건드리지 않는다.
| 메서드 | 줄 서나 | 돌려주는 것 |
|---|---|---|
list() | 아니오 | 복사본 배열 |
get(id) | 아니오 | 복사본 또는 null |
create(value) | 예 | 새 메모 |
update(id, patch) | 예 | 고친 메모 또는 null |
remove(id) | 예 | true / false |
flush() | - | 줄이 빌 때 이행되는 Promise |
6단원 연습 문제의 두 버그도 여기서 고쳤다. 검색은 String(m.title ?? '')로 제목이 없는 데이터에도 죽지 않고, 경로 변수는 디코딩하지 않는다.
실제 실행 결과
memo-api 폴더에서 실험부터 돌렸다.
$ node race.mjs
줄 세우지 않은 추가: 20번 요청 → 파일에 1개
store 로 추가: 20번 요청 → 파일에 20개
받은 id: 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20
줄을 세우지 않은 쪽은 20번 모두 성공했다고 생각했지만 파일에는 1개뿐이다. 오류가 하나도 나지 않았다는 점이 이 버그를 위험하게 만든다. 저장소를 거친 쪽은 20개가 모두 남았고 id도 1부터 20까지 겹치지 않는다.
이제 서버를 띄우고 burst.mjs로 요청 10개를 동시에 보냈다.
$ node burst.mjs 10
상태 코드: 201 (10건)
받은 id(정렬): 1,2,3,4,5,6,7,8,9,10
GET /memos count: 10
서버를 Ctrl+C로 끄고 다시 띄운 뒤 요청을 보냈다. 파일에서 다시 읽었으므로 10번 메모가 그대로 있다. 삭제도 파일에 반영되고, 6단원에서 500이던 %zz는 이제 400이다.
$ node call.mjs "GET /memos/10" "DELETE /memos/10" "GET /memos/%zz"
GET /memos/10 -> 200
{"id":10,"title":"동시 요청 10","done":false}
DELETE /memos/10 -> 204
GET /memos/%zz -> 400
{"error":{"code":"INVALID_ID","message":"id 는 1 이상의 정수여야 합니다"}}
실무에서 자주 틀리는 것
1. "Node는 싱글 스레드라 동시성 문제가 없다"
코드 한 줄 한 줄은 겹치지 않지만, await를 사이에 둔 여러 단계는 다른 요청과 얼마든지 섞인다. "읽은 값을 바탕으로 쓰는" 코드를 보면 그 사이에 await가 있는지 확인한다.
2. 프로세스 여러 개가 같은 파일을 쓴다
이 단원의 대기열은 한 프로세스 안에서만 작동한다. 서버를 두 개 띄우거나 클러스터 모드로 돌리면 각자 자기 줄을 가지므로 다시 갱신 손실이 생긴다. 여러 프로세스가 같은 데이터를 쓰기 시작하면 JSON 파일을 졸업하고 데이터베이스(트랜잭션, 조건부 갱신)로 가야 한다. 이 책의 파일 저장소는 "프로세스 하나" 전제다.
| 항목 | 메모리 배열 | JSON 파일 + 대기열 | 데이터베이스 |
|---|---|---|---|
| 재시작 후 | 사라짐 | 남음 | 남음 |
| 한 프로세스 동시 쓰기 | 안전 | 대기열로 안전 | 트랜잭션 |
| 여러 프로세스 | 각자 따로 | 갱신 손실 | 가능 |
| 설치 | 없음 | 없음 | 필요 |
3. id를 length + 1로 만든다
중간 메모를 지우면 length + 1이 이미 있는 id와 겹친다. 이 책은 최댓값 + 1을 썼지만 이것도 완벽하지는 않다(연습 문제 1번).
4. 메모리에 먼저 반영한다
memos.push(memo); await saveMemos(...) 순서는 저장이 실패해도 메모리에는 남는다. 재시작 전까지는 보이다가 재시작 후 사라지는 데이터는 재현하기 가장 어려운 버그가 된다.
연습 문제
- 메모 3개를 만든 뒤 3번을 지우고 새로 하나 만들면 새 메모의
id는 몇인가? 이것이 왜 문제가 될 수 있는가? 어떻게 고치는가? - 동료가 "서버를 PM2 클러스터 모드로 4개 띄우면 빨라지겠다"고 한다. 이 저장소에서 어떤 일이 생기는가?
commit의 두 줄 순서를 바꿔memos = next;를 먼저 하면, 디스크가 가득 찬 상황에서 사용자는 무엇을 보게 되는가?
정답과 해설
- 3이다. 남은 최댓값이 2라서 2 + 1 = 3이 된다(검증 스크립트로 확인). 누군가 "3번 메모" 주소를 즐겨찾기해 두었거나 다른 시스템이
id3을 기억하고 있으면, 지워진 메모 대신 전혀 다른 메모를 보게 된다. 지운 번호를 다시 쓰지 않으려면 파일에{ "nextId": 4, "items": [...] }처럼 다음 번호를 따로 저장하고 계속 늘리기만 한다. 데이터베이스의 자동 증가 컬럼이 하는 일이 이것이다. - 프로세스 4개가 각자 메모리에 목록을 들고 각자 파일을 덮어쓴다. 한 프로세스가 만든 메모를 다른 프로세스는 모르므로, 요청이 어느 프로세스로 가느냐에 따라 목록이 다르게 보이고, 마지막에 저장한 프로세스의 목록만 파일에 남는다. 대기열은 프로세스 안에서만 줄을 세운다. 여러 프로세스가 필요해지면 저장소를 데이터베이스로 바꿔야 한다.
- 저장이 실패해 사용자는 500을 받지만, 메모리에는 이미 새 메모가 들어가 있어서 바로 목록을 다시 조회하면 그 메모가 보인다. "실패했다면서 저장됐네?" 하고 넘어갔다가 서버가 재시작되면 사라진다. 응답과 실제 상태가 어긋나는 것이 가장 나쁜 결과다.
포트 번호 3700과 파일 경로가 코드에 박혀 있다. 운영 서버에서는 다른 포트와 다른 경로를 써야 한다. 다음 단원에서 환경변수로 설정을 바깥에서 받고, 잘못된 설정은 서버가 뜨기 전에 거절한다.