
이번에도 역시 학교 동아리에서 진행하는 프로젝트 개발을 하면서 고민하고 찾아낸 새로운 방법이다. 노션과 같이 앱 내부에 에디터가 존재하고 이 에디터에서 작성한 파일들은 로컬 DB와 서버 DB 두 곳에 모두 저장되어야한다. 로컬 DB를 두는 이유는 오프라인 상태에서 작업을 가능을 위해서, 서버 DB는 작업 변경 내용을 서로 다른 디바이스 간에서 동기화를 가능하게 하기 위해서이다. 그렇다면 "언제 DB에 저장하는 작업을 실행할 것인가"가 수동 저장 그리고 자동 저장 다양한 방면에서 더 좋은 사용자 경험을 위해 고민할 필요가 있었다.
가장 원초적인 방식은 수동 저장이다. 이것은 우리가 자주 사용하는 MS Office의 저장방식과 동일하다. 앱을 닫기 전에 변경 사항이 있으면 유저에게 저장을 할 것인지 물어보는 팝업과 버튼을 보여주거나, 아니면 유저가 Ctrl(cmd) + s와 같은 단축키를 통해 수동으로 저장하는 방식이 있다. 가장 직관적인 저장 방식이지만 유저가 직접 해야한다는 비용이 있다. 따라서 더 편리하고 저렴한 비용의 저장방식을 사용자들은 기대했다. 즉, 수동 저장 없이 노션과 같이 무언가를 적어두었다면 종이에 적어둔 것처럼 다시 앱을 열었을 때 내가 적어둔 내용을 그대로 볼 수 있어야했다.
물론 수동 저장 방식은 사용자가 반드시 의식적으로 확인해야하는 경우 채택된다. (예를 들면 사용자가 비밀번호를 변경할 때 해당 비밀번호는 자동저장되지 않고, 유저가 확인 버튼을 눌러야 저장된다)
누구나 생각해볼 수 있는 방법이라고 생각한다. 사용자의 키 이벤트 하나 하나에 CRUD가 발생하고, 프론트엔드와 백엔드를 지속적으로 동기화할 수 있다. 어떻게 보면 데이터 손실을 가장 최소화할 수 있는 방식이다. 그렇지만 그러면 API를 호출이 엄청 많이 발생할 것이고, 앱도 느려지며, 서버 비용도 엄청 많이 나올 것이다.
그렇다면 대부분의 실무에서는 어떤 방식을 사용할까? Notion이나 Google Docs와 같이 문서편집 어플리케이션은 다양한 상황(예를 들자면 빠른 종료, 네트워크 이슈, 앱 크래시 등)을 어떻게 커버를 할까? 여러 글들을 찾아보면서 좋은 방법들을 찾을 수 있었다.
To save or to autosave: Autosaving patterns in modern web applications
StackExchange: Editor's autosave UX
Implementing Auto-Save Functionality on a Form - A Detailed Guide
이 방법은 특정한 작업 주기 상수 N(N > 0)초을 정의하고, N의 시간이 흐를 때마다 저장을 하는 것이다. 이 방법의 장단점은 다음과 같다.
장점
단점
이 방법은 상수 N(N > 0)초을 정의하고, 특정 작업이 발생 후 N의 시간이 흐를 때마다 저장을 하는 것이다. 이러한 작업을 디바운싱이라고 한다. 이 방법의 장단점은 다음과 같다.
장점
단점
현재 입력하는데 사용되는 필드의 포커스가 해제되거나 변경됐을 때 저장을 하는 것이다. 이 방법의 장단점은 다음과 같다.
장점
단점
유저가 글을 빠르게 붙여넣고, 매우 빠르게 앱을 닫는 경우까지 고려를 하고 자동 저장을 설계하기에는 너무 복잡하였기 때문에 이 경우는 배제를 하고 옵션2와 옵션3을 합쳐서 다음과 같은 자동 저장 방식을 구현하기로 하였다.
useEffect(() => {
// 다른 코드 생략...
// 1.5초의 시간 후 자동 저장
saveTimeoutRef.current = setTimeout(async () => {
try {
let saved = false;
// 아래 ID 조건을 사용하는 이유는 다이나믹 라우트를 사용하여 params가 null일 때는 새로운 파일을 생성하는 것으로, params.id가 있을 경우 해당 id에 맞는 파일을 수정하는 것으로 판단합니다.
// ID가 있을 경우 업데이트
if (currentNoteId) {
await noteRepo.updateNoteById(currentNoteId, markdown);
queryClient.invalidateQueries({ queryKey: ["notes"] });
saved = true;
}
// ID가 없을 경우 생성
else {
if (markdown.trim().length > 0 && !isInitializingRef.current) {
const newNote = await noteRepo.create(markdown);
setCurrentNoteId(newNote.id);
lastEditedNoteIdRef.current = newNote.id;
queryClient.invalidateQueries({ queryKey: ["notes"] });
saved = true;
}
}
// 저장 상태 UI 업데이트...
if (saved) {
setSaveStatus("saved");
isDirtyRef.current = false;
if (savedTimeoutRef.current) {
clearTimeout(savedTimeoutRef.current);
}
savedTimeoutRef.current = setTimeout(() => {
setSaveStatus(null);
}, 1500);
} else {
setSaveStatus(null);
}
} catch (error) {
console.error("Failed to save note:", error);
setSaveStatus(null);
}
}, 2000);
});
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === "hidden") {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
void flushSave();
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, [currentNoteId]);
우리의 앱이 서버 DB만 사용하거나, 로컬 DB만을 사용하거나 한쪽의 DB만 사용한다면 괜찮지만 양쪽의 DB를 모두 사용하고 동기화를 목표로 하고 있기 때문에 위의 방법으로도 해결할 수 없는 문제점이있었다.
1. 사용자가 오프라인에서 작업 중이라면, 로컬 DB에 저장하는 동시에 서버 DB에 저장하여 동기화할 수 없다.
2. 사용자가 온라인에서 작업 중이었지만 백엔드 DB에 저장하기 위해 API를 호출했을 때 갑작스러운 네트워크 에러가 발생한다면 데이터 불일치가 발생할 수 있다.
이 패턴은 분산 시스템에서 발생하는 이중 쓰기 작업 문제를 해결하기 위해 설계된 패턴이다. 이중 쓰기 작업은 애플리케이션이 서로 다른 두 시스템에 데이터를 쓸 때 발생한다.
[참고] AWS: 트랜잭션 아웃박스 패턴
구매 시스템을 예로 들자면, 주문 서비스는 새로운 주문을 DB에 저장하고, 동시에 메시지 브로커를 통해 주문 생성 이벤트를 발행한다. 만약 DB 저장은 성공했지만 이벤트 발행이 실패한다면, 주문 데이터는 존재하지만 결제·재고·알림 서비스는 이를 인지하지 못하는 상태가 된다. 이 경우 오류 로그조차 남지 않아 시스템은 조용히 불일치 상태에 빠지게 된다. 따라서 주문 서비스는 DB에 주문 저장과 주문 저장 이벤트 발행 이 두개의 작업이 같이 성공해야 의미가 있다. 이에 이를 하나의 논리적 작업으로 다루기 위해 트랜잭션 아웃박스 패턴이 사용된다.
따라서 주문 생성이라는 작업은 DB 저장과 이벤트 발행이 모두 성공해야 의미가 있으며, 이를 하나의 논리적 작업으로 다루기 위해 트랜잭션 아웃박스 패턴이 사용된다.
이 개념을 현재 개발 중인 애플리케이션에 적용하면, 에디터에서의 수정은 로컬 DB에 반영되는 동시에 백엔드 API를 통해 서버 DB에도 반영되어야 한다. 이는 전형적인 분산 시스템은 아니지만, 오프라인 환경과 네트워크 불확실성으로 인해 본질적으로 동일한 이중 쓰기 문제를 갖는다.
즉, 로컬 DB 수정과 서버 DB 반영이 모두 완료되어야 “저장이 완료되었다”고 말할 수 있으며, 이 중 하나라도 실패하면 시스템은 불완전한 상태가 된다. 이를 해결하기 위해 변경 내용을 즉시 서버로 보내지 않고, “변경됨”이라는 의도를 로컬 Outbox에 데이터로 저장해 두었다가, 네트워크가 복구되면 해당 변경을 재시도함으로써 최종적 일관성을 보장할 수 있다.
이러한 구조에서는 “변경됨”이라는 데이터가 존재하는 한 시스템은 아직 처리되지 않은 작업이 있음을 인지할 수 있으며, 별도의 분산 트랜잭션이나 강한 롤백 메커니즘 없이도 안정적인 동기화가 가능하다.

우선 로컬 DB는 Indexed DB를 사용하였다. Electron이 React를 통해서 웹기반으로 돌아가는 Desktop App이지만 local storage를 사용하면 쿼리를 통한 검색이나 인덱스 조회가 불가능하거나, 메우 적은 용량 제한이 있고, 무엇보다 비동기가 아닌 동기식이라 실시간으로 수정이 일어나고, 해당 수정이 UI에 반영되야하는 에디터에서는 부적합하였다. 따라서 Indexed DB(dexie.js: Indexed DB의 Wrapper Library)를 사용하였다.
dexie.js를 통해 Indexed DB에 테이블을 추가할 때 아래와 같이 하면 된다.
// src/types/Outbox.ts
export type OutboxOpType =
| "note.create"
| "note.update"
| "note.move"
| "note.delete";
export type OutboxOp = {
opId: string;
entityId: string;
type: OutboxOpType;
payload: any;
status: "pending" | "processing";
retryCount: number;
nextRetryAt: number;
createdAt: number;
updatedAt: number;
lastError?: string;
};
// src/db/app.db.ts
import Dexie, { Table } from "dexie";
export class ChatDB extends Dexie {
// Table<T, K> T: 테이블 타입, K: 기본키 타입 (T.id의 타입)
// 다른 테이블들 생략...
outbox!: Table<OutboxOp, string>;
constructor() {
// 기존 버전들 생략...
this.version(3).stores({
// 다른 테이블들 생략
// 테이블이름: 키본키(opId), 인덱스(entityId, type...) => 인덱스틑 복합 인덱스 허용
outbox: "opId, entityId, type, status, createdAt, [status+nextRetryAt]",
});
}
}
export const db = new ChatDB();
사실 이 구조는 transaction outbox pattern을 모방한 패턴이라고 봐야한다. 위에서 언급한 AWS 문서를 보면 이벤트 소싱 패턴을 구현해야한다고 한다. 이벤트 소싱의 핵심은 형삭 기억(레거시)이다. 즉 git처럼 과거의 모든 기록이 남아서 감사/추적/복구가 가능하다. 그렇지만 여기서 구현하는 Outbox는 단지 "이 변경 사항을 아직 서버에 요청하지 못했다"를 기억하는 작업 큐일 뿐이다. 따라서 이벤트 소싱과 다르게 과거 이벤트를 수정이나 덮어쓰는 것을 통해 요청을 최적화 할 수 있다.
예를 들면 파일 A를 오프라인에서 수정을 하였다. "동해물과 백두산이"를 입력하고 다음 가사가 생각나지 않아서 몇 초간 생각하는 시간이 흘렀다. 이때 이미 오프라인 상태라 API 호출이 안 되서 outbox에 하나의 큐가 쌓였다. 이때 가사가 생각이 나서 "마르고 닳도록 하느님이 보우하사 우리 나라 만세"를 입력하였다. 이러면 두개의 update outbox큐가 쌓이게 된다. 이것은 비효율적이기 때문에 update가 이렇게 누적될 경우 가장 최근것만 payload를 덮어써서 같은 파일을 수정했다면 한번의 호출로 끝내는 것이 효울적이다.
// src/managers/outboxRepo.ts
import { db } from "@/db/chat.db";
import uuid from "@/utils/uuid";
import type { OutboxOp, OutboxOpType } from "@/types/Outbox";
import type {
NoteCreateDto,
NoteUpdateDto,
} from "@taco_tsinghua/graphnode-sdk";
/**
* Coalesce 기준
* (A) note.delete enqueue 시: 해당 noteId의 기존 pending op(create/update/move)를 전부 제거하고 delete만 남김
* (B) note.create가 pending이면: 이후 update/move는 create payload에 흡수(merge)하고 새 op 만들지 않음
* (C) note.update는 noteId당 1개만 유지: 이미 있으면 payload 덮어쓰기
* (D) note.move도 NoteUpdateDto로 처리하며 noteId당 1개만 유지: 이미 있으면 payload 덮어쓰기
*/
export const outboxRepo = {
async enqueueNoteCreate(noteId: string, payload: NoteCreateDto) {
await enqueueWithCoalesce("note.create", noteId, payload);
},
async enqueueNoteUpdate(noteId: string, payload: NoteUpdateDto) {
await enqueueWithCoalesce("note.update", noteId, payload);
},
async enqueueNoteMove(noteId: string, payload: NoteUpdateDto) {
await enqueueWithCoalesce("note.move", noteId, payload);
},
async enqueueNoteDelete(noteId: string) {
await enqueueWithCoalesce("note.delete", noteId, null);
},
};
async function enqueueWithCoalesce(
type: OutboxOpType,
entityId: string,
payload: any
) {
const now = Date.now();
if (!entityId) {
throw new Error("entityId is required");
}
await db.transaction("rw", db.outbox, async () => {
// (A) delete: 관련 op 정리 후 delete만 남김
if (type === "note.delete") {
const related = await db.outbox
.where("entityId")
.equals(entityId)
.toArray();
const pendingOnly = related.filter((r) => r.status === "pending");
if (pendingOnly.length) {
await db.outbox.bulkDelete(pendingOnly.map((r) => r.opId));
}
await db.outbox.put(
makeOp(entityId, "note.delete", { id: entityId }, now)
);
return;
}
// (B) create가 이미 pending이면: create payload에 update/move를 흡수
const pendingCreate = await db.outbox
.where({
entityId,
type: "note.create" as const,
status: "pending" as const,
})
.first();
if (pendingCreate) {
const merged = mergeIntoCreatePayload(
pendingCreate.payload as NoteCreateDto,
type,
payload
);
await db.outbox.update(pendingCreate.opId, {
payload: merged,
status: "pending",
nextRetryAt: now,
updatedAt: now,
lastError: undefined,
});
return;
}
// (C) update/move는 noteId당 1개로 coalesce
if (type === "note.update" || type === "note.move") {
const existing = await db.outbox
.where({ entityId, type: type as any, status: "pending" as const })
.first();
if (existing) {
await db.outbox.update(existing.opId, {
payload,
status: "pending",
nextRetryAt: now,
updatedAt: now,
lastError: undefined,
});
return;
}
await db.outbox.put(makeOp(entityId, type, payload, now));
return;
}
// (D) create가 없으면 create는 그대로 enqueue
if (type === "note.create") {
await db.outbox.put(makeOp(entityId, "note.create", payload, now));
return;
}
// fallback
await db.outbox.put(makeOp(entityId, type, payload, now));
});
}
function makeOp(
entityId: string,
type: OutboxOpType,
payload: any,
now: number
): OutboxOp {
return {
opId: uuid(),
entityId,
type,
payload,
status: "pending",
retryCount: 0,
nextRetryAt: now,
createdAt: now,
updatedAt: now,
};
}
function mergeIntoCreatePayload(
existing: NoteCreateDto,
incomingType: OutboxOpType,
incomingPayload: any
): NoteCreateDto {
if (incomingType === "note.update" || incomingType === "note.move") {
const u = incomingPayload as NoteUpdateDto;
return {
id: existing.id,
content: u.content ?? existing.content,
title: u.title ?? existing.title,
folderId: u.folderId ?? existing.folderId ?? null,
};
}
return existing;
}
여기서 중요한 것은 db.transaction으로 로컬 DB의 작업과 outbox 큐를 쌓는 작업을 하나의 작업으로 묶어야한다는 것이다. 즉 로컬 DB 작업이 실패했는데 서버 요청 작업 대기열 outbox 큐는 쌓이면 불일치하게 로컬과 서버의 정보가 불일치하게 된다.
// src/managers/noteRepo.ts
import { db } from "@/db/chat.db";
import { Note } from "@/types/Note";
import extractTitleFromMarkdown from "@/utils/extractTitleFromMarkdown";
import uuid from "@/utils/uuid";
import { outboxRepo } from "./outboxRepo";
export const noteRepo = {
async create(content: string, folderId: string | null = null): Promise<Note> {
const newNote: Note = {
id: uuid(),
title: extractTitleFromMarkdown(content),
content,
folderId,
updatedAt: new Date(Date.now()),
createdAt: new Date(Date.now()),
};
// transaction 안에서 실행되는 DB 작업은 전부 성공 또는 전부 실패 (rw = read write, 접근할 테이블 목록 전부 명시)
await db.transaction("rw", db.notes, db.outbox, async () => {
await db.notes.put(newNote);
await outboxRepo.enqueueNoteCreate(newNote.id, {
id: newNote.id,
title: newNote.title,
content: newNote.content,
folderId: newNote.folderId,
});
});
return newNote;
},
쌓여있는 outbox 큐를 소비를 해야하는 유틸 함수가 하나 필요하다.
1. processing인데 60초 이상 처리 중인 outbox는 오류가 있음으로 보고 status를 pending으로 변경한다
2. outbox의 type에 맞는 API를 호출한다(백엔드는 SDK를 구현해서 사용했습니다).
3. API 호출에 성공한 outbox 큐는 제거를 하며, 실패한 큐는 상태를 변경하여 다시 대기열에 반영을 한다.
// src/managers/syncWorker.ts
import { db } from "@/db/chat.db";
import { api } from "@/apiClient";
import type { OutboxOp } from "@/types/Outbox";
let running = false;
export async function syncOnce(limit = 20) {
if (running) return;
running = true;
try {
const now = Date.now();
// 60초 이상 실패한 작업을 pending으로 변경해서 앱 크래시나 강제 종료로 인한 processing 상태 초기화
await db.outbox
.where("status")
.equals("processing")
.and((op) => op.updatedAt < now - 60_000)
.modify({
status: "pending",
nextRetryAt: now,
updatedAt: now,
});
// status가 pending이고 nextRetryAt이 현재 시간보다 작은 작업을 limit개만 가져옴
const ops = await db.outbox
.where("[status+nextRetryAt]")
.between(["pending", 0], ["pending", now])
.limit(limit)
.toArray();
for (const op of ops) {
await processOp(op);
}
} finally {
running = false;
}
}
async function processOp(op: OutboxOp) {
const now = Date.now();
// 현재 작업 상황 업데이트 => 같은 탭에서 중복 실행 방지
await db.outbox.update(op.opId, { status: "processing", updatedAt: now });
try {
switch (op.type) {
case "note.create":
await api.note.createNote(op.payload);
break;
case "note.update":
await api.note.updateNote(op.entityId, op.payload);
break;
case "note.move":
await api.note.updateNote(op.entityId, op.payload);
break;
case "note.delete":
await api.note.deleteNote(op.entityId);
break;
}
// 작업 성공 후 outbox에서 제거
await db.outbox.delete(op.opId);
} catch (e: any) {
// 작업 실패 후 재시도 횟수 증가 및 지연 시간 계산 및 아웃박스 정보 업데이트
const retryCount = (op.retryCount ?? 0) + 1;
const delay = backoffMs(retryCount);
await db.outbox.update(op.opId, {
status: "pending",
retryCount,
nextRetryAt: now + delay,
updatedAt: now,
lastError: String(e?.message ?? e),
});
}
}
function backoffMs(retryCount: number) {
// 1s, 2s, 4s, 8s, 16s, 32s, max 60s (+jitter)
const base = Math.min(60_000, 1000 * 2 ** Math.min(6, retryCount - 1));
const jitter = Math.floor(Math.random() * 300);
return base + jitter;
}
그 다음으로 온라인일 경우 5초마다 주기적으로 outbox 큐를 주기적으로 소비하는 함수를 추가하고, main.tsx에서 호출해주면 된다.
// src/managers/startSyncLoop.ts
import { syncOnce } from "./syncWorker";
let started = false;
let timer: number | null = null;
export function startSyncLoop() {
if (started) return;
started = true;
const handleOnline = () => syncOnce();
window.addEventListener("online", handleOnline);
timer = window.setInterval(() => {
if (navigator.onLine) {
syncOnce();
}
}, 5000);
return () => {
window.removeEventListener("online", handleOnline);
if (timer !== null) {
clearInterval(timer);
timer = null;
}
started = false;
};
}
// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { initI18n } from "./i18n";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { startSyncLoop } from "./managers/startSyncLoop";
startSyncLoop();
const queryClient = new QueryClient();
(async () => {
await initI18n();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);
})().catch((err) => {
console.error("i18n init failed:", err);
});
이 작업이 끝나고 회고하며 블로그를 쓰면서 치명적인 단점이 하나있다는 것을 발견했다. 바로 디바이스 A에서 outbox 큐가 전부 처리 안된 상태(특히 update부분)로 앱을 종료를 하고, 다른 디바이스 B에서 앱을 실행해 동일한 파일을 수정을 하고, 해당 outbox가 모두 소비된 상태에서 다시 디바이스 A에서 최신 내역을 받아올 때 기존 outbox가 B에서 수정한 최신 내역을 모두 덮어써버리는 치명적인 오류가 있다.
즉 Outbox 자체의 문제가 아니라 Conflict Resolution 전략이 필요하다. 충돌 자체는 막을 수 없다. 모든 이슈를 고려하면서 충돌을 막기에는 불가능하기 때문에 git처럼 충돌이 일어났을 경우 유저가 충돌을 직접 해결할 수 있는 방법을 제시해야한다. 아직은 구현하지 않았지만 시나리오는 다음과 같다.
우선 핵심 개념은 서버측 파일에 version(정수) 필드룰 두고, update가 성공할 때마다 서버에서 승격시킨다. 프론트에서는 서버에서 내려준 version을 로컬 DB에 저장해 해당 파일의 변경이 어떤 version을 기준으로 만들어졌는지를 나타내는 baseVersion으로 사용한다.
1. A에서 오프라인에서 파일을 수정하였다. 수정하였을 때 해당 파일의 버전은 5였고, outbox 큐에 그대로 쌓여있다.
2. B에서도 동일한 version의 파일을 수정하였고, 이 outbox 큐는 소비가 되었고 서버에는 동기화된 노트의 version은 6 혹은 그 이상이다.
3. A 디바이스가 온라인 상태로 바뀌었고, 이때 다시 outbox 큐를 소비하기 위해 API를 호출했지만 A에서 작업한 파일의 baseVersion은 5를 기준으로 만들어졌고, 서버의 currentVersion은 7이기 때문에 다르다. 이 경우 백엔드에서는 요청에 reject(409)와 함께 다음과 같은 응답 결과를 준다.
{
"error": "CONFLICT",
"currentVersion": 7,
"serverNote": {
"id": "...",
"version": 7,
"content": "..."
}
}
프론트에서는 git과 같이 충돌을 해결을 할 수 있는 도구를 제공하고, 유저가 수동으로 충돌을 해결하였다면, 서버가 내려준 최신 버전을 기준으로 다시 update 요청을 보낸다. 이 요청이 성공하면 최종 version은 서버에서 승격된다.
서버에서 버전 승격까지 완료된다면 프론트에 승격된 버전과 opId를 응답으로 준다.
프론트에서 응답을 받고, 로컬 DB에 최신 버전 반영을 했으면 opId를 통해 해당 outbox op를 삭제하는 것을 같은 트랜잭션으로 묶어서 outbox 큐 소비를 완료한다.
즉 충돌 자체를 완전히 없애는 것보다는, 충돌을 명확히 감지하고 데이터 손실이 없는 해결 방법을 제공하는 것이 현실적이다.