노션에서 글을 쓰다가 저장 버튼을 찾은 적 있으신가요?
아마 거의 없을 겁니다. 내용을 입력하고 다른 페이지로 이동했다가 돌아와도 방금 쓴 내용이 남아 있을 거라고 생각합니다. 구글 문서도 마찬가지입니다. 구글은 공식 도움말에서 “입력하는 동안 변경 사항을 자동으로 저장하므로 저장 버튼이 필요 없다”고 설명합니다.
자동 저장은 사용자가 언제 저장할지 판단하고 버튼을 누르는 일을 편집 흐름에서 빼줍니다. 문서, 메모, 프로젝트 카드처럼 내용을 자주 조금씩 고치는 화면에서는 꽤 필요한 기능입니다. 모바일에서 긴 글을 쓰거나 여러 페이지를 오갈 때는 더 그렇습니다.
다만 사용자가 저장을 신경 쓰지 않는 만큼 서비스가 더 잘 챙겨야 합니다. 오류를 바로 보여 주는 수동 저장과 달리, 자동 저장은 실패를 놓치기 쉽습니다. 한참 뒤 다시 들어왔을 때 내용이 사라져 있으면 사용자는 어느 시점에 실패했는지도 알 수 없습니다.
프론트엔드 면접에서 다음 질문을 받았다고 해보겠습니다.
사용자가 입력한 내용을 자동으로 저장하는 기능을 어떻게 구현하시겠어요?
가장 먼저 떠오르는 답은 debounce입니다.
const save = debounce((value) => {
api.save(value)
}, 700)
좋은 출발입니다. 하지만 면접관이 몇 가지만 더 물으면 금방 복잡해집니다.
이 글에서는 이 꼬리 질문에 하나씩 답해보겠습니다.
코드보다 먼저 제품 조건을 확인하는 편이 좋습니다. “자동 저장”이라는 이름은 같아도 필요한 수준이 다릅니다.
검색 필터 한두 개와 장문의 문서는 데이터 유실 비용이 다릅니다. 프로필 이름처럼 저장 버튼을 눌러도 자연스러운 값이 있고, 문서 본문처럼 입력할 때마다 초안을 지키는 편이 자연스러운 값도 있습니다.
한 브라우저에서만 수정한다면 요청 순서만 관리해도 충분할 수 있습니다. 여러 탭, 여러 사용자, 외부 API가 같은 데이터를 수정한다면 서버 버전과 충돌 정책이 필요합니다.
온라인에서 잠깐 실패한 요청만 재시도할지, 브라우저를 닫았다가 다시 열어도 로컬 초안을 복구할지 정합니다. 후자라면 메모리 상태만으로는 부족합니다.
블로그 글을 예로 들면 초안은 자동 저장해도 좋지만 발행은 명시적 버튼이 필요합니다. 자동 저장은 편집 중인 초안을 지키고, 발행 버튼은 외부에 공개하는 시점을 확정합니다.
면접에서는 이 조건을 먼저 질문하는 것만으로도 구현 범위를 생각하고 있다는 인상을 줄 수 있습니다.
자동 저장에 debounce를 쓰는 이유는 단순합니다. 사용자가 글자 하나를 입력할 때마다 API를 호출하면 요청이 너무 많아집니다. 마지막 입력 후 일정 시간 기다렸다가 최신 값을 한 번 보냅니다.
보통 500~1,000ms 정도에서 시작해 입력 성격과 서버 비용에 맞춰 조정할 수 있습니다.
const saveLater = debounce(saveDraft, 700)
function onChange(next: string) {
setDraft(next)
saveLater(next)
}
여기까지만 구현하면 쉬지 않고 계속 입력하는 동안 저장이 계속 밀릴 수 있습니다. trailing debounce는 마지막 호출 이후 700ms의 빈 구간이 생겨야 실행되기 때문입니다.
그래서 maxWait를 함께 둡니다.
const saveLater = debounce(saveDraft, 700, {
maxWait: 10_000,
})
마지막 입력 후 700ms가 지나면 저장하고, 계속 입력하더라도 늦어도 10초마다 한 번은 저장합니다. Lodash의 _.debounce도 cancel, flush, maxWait를 제공합니다.
직접 구현한다면 타이머를 두 개 둘 수 있습니다.
idle timer : 마지막 입력 후 700ms
max timer : 첫 dirty 상태부터 최대 10초
둘 중 하나가 먼저 끝나면 저장하고 두 타이머를 초기화합니다.
debounce는 여기까지 맡습니다. 요청 수를 줄이고 저장 지연의 상한을 정합니다. 요청 순서와 충돌은 다음 단계에서 처리합니다.
자동 저장 화면에는 최소 세 가지 값이 필요합니다.
base : 마지막으로 서버가 확정한 값
draft : 사용자가 현재 편집하는 값
dirty : draft와 base가 다른지
입력하면 draft를 바로 바꿉니다. 그래서 네트워크가 느려도 글자는 즉시 보입니다. 서버가 성공 응답을 주기 전에는 base를 바꾸지 않습니다.
function onChange(next: string) {
draft = next
dirty = draft !== base
scheduleSave()
}
서버 요청 전에 base까지 draft로 덮으면 실패 후 비교할 기준이 사라집니다.
서버 실제 값: A
base를 미리 A'로 변경
저장 실패
draft와 base는 둘 다 A'
→ 바뀐 것이 없다고 판단해 재시도하지 못함
화면에는 draft를 보여 주되, base는 마지막 서버 응답으로 남겨야 실패 뒤에도 dirty 상태를 계산할 수 있습니다.
React의 useOptimistic도 확정된 값과 요청 중에 보여 줄 값을 구분합니다. TanStack Query의 Optimistic Updates 역시 진행 중인 refetch가 낙관적 상태를 덮지 않게 막고, 실패에 대비해 이전 값을 보관합니다.
낙관적 UI만으로 서버 정합성까지 해결하지는 않습니다. 다만 base와 draft를 분리하면 다음 알고리즘을 적용할 기준이 생깁니다.
isSaving 하나만으로는 자동 저장 상태를 설명하기 어렵습니다. 저장 중에 새 입력이 들어올 수 있고, 서버 요청은 끝났지만 최신 draft는 아직 저장하지 못했을 수도 있습니다.
저는 다음 상태부터 잡는 편입니다.
synced : draft와 서버 값이 같음
dirty : 저장할 로컬 변경이 있음
saving : 서버 요청이 진행 중
error : 저장 실패, draft는 로컬에 남아 있음
offline : 네트워크가 없어 로컬에만 저장함
상태 전이는 대략 이렇습니다.
입력
synced ─────────────→ dirty
↑ │
│ debounce
│ ↓
└──── 저장 성공 ── saving
│ ↑
저장 중 입력 │ │ 재시도
↓ │
dirty / error
저장 중 새 입력이 들어오면 화면은 계속 draft를 보여 줍니다. 현재 요청이 끝난 뒤 dirty가 남아 있으면 다음 저장을 시작합니다.
상태 머신을 꼭 라이브러리로 구현할 필요는 없습니다. 다만 가능한 상태와 전이를 먼저 적어두면 isSaving=false인데 저장하지 않은 입력이 남는 식의 빈틈을 찾기 쉽습니다.
debounce를 통과한 요청도 서로 겹칠 수 있습니다.
요청 A: “면접 준비” 저장 ───────────────→ 늦게 완료
요청 B: “면접 준비 완료” 저장 ───→ 먼저 완료
최종 화면: “면접 준비 완료” → “면접 준비”
응답 순서가 바뀌면 이전 값이 최신 값을 덮습니다. 화면에서 A 응답만 무시해도 서버가 이미 A를 마지막으로 적용했을 수 있습니다.
한 문서의 자동 저장 요청은 하나만 진행하도록 만들 수 있습니다. 흔히 single-flight라고 부르는 형태입니다. 저장 중 새 입력은 최신 draft 하나로 합칩니다. 중간의 모든 글자 상태를 서버에 남길 필요는 없으므로 coalescing queue가 잘 맞습니다.
let savePromise: Promise<void> | null = null
async function drainSaveQueue() {
if (savePromise) {
await savePromise
if (dirty) await drainSaveQueue()
return
}
savePromise = saveLoop().finally(() => {
savePromise = null
})
await savePromise
if (dirty) await drainSaveQueue()
}
async function saveLoop() {
while (dirty) {
const sentDraft = draft
const saved = await api.save({
content: sentDraft,
baseRevision: base.revision,
})
base = saved
// 요청 중 입력이 없었다면 서버 응답으로 확정한다.
if (draft === sentDraft) draft = saved.content
dirty = draft !== base.content
}
}
예를 들어 A, B, C를 빠르게 입력했다고 해보겠습니다.
A 저장 시작
저장 중 B 입력
저장 중 C 입력
A 저장 완료
최신 C만 다음 요청으로 저장
B는 화면에는 즉시 보였지만 별도 서버 버전으로 남기지 않습니다. 사용자가 원하는 최신 상태 C는 보존합니다. 요청 수와 순서 문제를 함께 줄일 수 있습니다.
클라이언트 요청을 직렬화해도 다른 탭이나 다른 사용자의 요청까지 한 줄로 세울 수는 없습니다. 서버에서 현재 버전을 확인합니다.
각 문서에 증가하는 revision을 둡니다.
id: note-1
content: 면접 준비
revision: 7
클라이언트는 자신이 편집을 시작한 revision을 저장 요청에 함께 보냅니다.
{
"content": "면접 준비 완료",
"baseRevision": 7
}
서버는 revision이 여전히 7일 때만 수정합니다.
UPDATE note
SET
content = ?,
revision = revision + 1
WHERE id = ?
AND revision = 7;
수정된 행이 1개라면 성공입니다. 0개라면 누군가 먼저 수정해 revision이 바뀐 겁니다.
이 패턴을 Optimistic Concurrency Control, 낙관적 동시성 제어라고 부릅니다. DB 관점에서는 compare-and-swap, CAS와 비슷한 형태입니다. 충돌이 자주 나지 않을 거라고 보고 먼저 작업하되, 저장할 때 버전이 같은지 확인합니다.
HTTP의 If-Match도 같은 문제를 해결합니다.
PUT /notes/1
If-Match: "revision-7"
서버의 ETag가 다르면 요청을 적용하지 않고 412 Precondition Failed를 반환합니다. 정수 revision을 요청 본문에 넣든 ETag를 헤더에 넣든 목적은 같습니다. 오래된 화면을 보고 보낸 요청이 최신 내용을 조용히 덮지 못하게 합니다.
updatedAt만 비교하는 것보다 증가하는 revision이 다루기 편합니다. 시간 정밀도와 서버 시계를 신경 쓰지 않고 숫자의 크기로 응답 순서까지 판단할 수 있습니다.
revision 충돌을 찾았다면 다음 행동도 미리 정합니다. 모든 필드에 같은 정책을 쓸 필요는 없습니다.
| 데이터 | 가능한 정책 |
|---|---|
| 토글·상태·짧은 제목 | latest-write-wins 또는 충돌 안내 |
| 긴 텍스트 | base/local/remote 3-way merge |
| 게시·결제·권한 변경 | 자동 병합하지 않고 사용자가 다시 확인 |
| 실시간 공동 문서 | OT 또는 CRDT 검토 |
긴 텍스트에는 세 값이 필요합니다.
base : 사용자가 처음 본 본문
local : 사용자가 수정한 본문
remote : 서버의 최신 본문
서로 다른 위치를 수정했다면 양쪽 변경을 합칠 수 있습니다.
base : 면접 준비
local : 프론트 면접 준비
remote : 면접 준비\n질문 목록 추가
merged : 프론트 면접 준비\n질문 목록 추가
base에서 local로 바뀐 diff를 만들고, 그 patch를 remote 위에 다시 적용합니다. 이를 3-way merge 또는 rebase로 설명할 수 있습니다.
const localPatch = diff(base, local)
const merged = applyPatch(remote, localPatch)
같은 문장을 양쪽에서 완전히 다르게 고쳤다면 자동 병합이 틀릴 수 있습니다. 이 경우에는 다음 중 하나를 제품 성격에 맞게 고릅니다.
ProseMirror의 협업 편집 가이드도 중앙 서버가 문서 버전을 관리하고, 버전이 어긋난 클라이언트가 로컬 변경을 최신 문서 위에 rebase해 다시 보내는 구조를 설명합니다.
실시간으로 여러 사람이 같은 문장을 편집한다면 이보다 더 정교한 모델이 필요합니다. OT와 CRDT는 변경 연산을 추적해 여러 클라이언트의 결과가 같은 문서로 수렴하도록 돕습니다. 자동 저장이 필요하다는 이유만으로 처음부터 CRDT를 넣을 필요는 없습니다. 동시 편집 빈도와 충돌 비용을 보고 선택할 수 있습니다.
저장 요청만 버전을 확인하면 끝일까요? 조회도 늦게 도착할 수 있습니다.
GET revision 7 시작 ─────────────────→ 늦게 도착
SAVE revision 8 ───→ 먼저 완료
화면: revision 8 → revision 7
캐시를 사용하지 않아도 생길 수 있는 문제입니다. GET을 시작한 시점에는 revision 7이 최신이었지만 응답이 돌아올 때는 오래된 값이 됐습니다.
화면에 서버 응답을 적용할 때 단조 증가하는 revision을 확인합니다.
function applyServerValue(incoming: Note) {
if (incoming.revision <= base.revision) return
if (status === 'dirty' || status === 'saving') return
base = incoming
draft = incoming.content
}
요청 번호를 함께 쓸 수도 있습니다.
const requestId = ++latestRequestId
const result = await fetchNote()
if (requestId !== latestRequestId) return
요청 번호는 같은 클라이언트 안의 응답 순서를 판별합니다. revision은 다른 탭과 다른 사용자까지 포함한 서버 데이터의 순서를 판별합니다. 협업 가능성이 있다면 revision 쪽이 더 강한 기준입니다.
사용자가 마지막 글자를 입력하고 700ms 안에 다른 페이지로 이동할 수 있습니다. 이때는 debounce 대기분을 즉시 실행합니다.
await saveLater.flush()
await drainSaveQueue()
첫 줄은 아직 시작하지 않은 저장을 시작합니다. 두 번째 줄은 이미 진행 중인 저장과 그사이에 생긴 추가 입력까지 기다립니다.
다음 시점에 flush를 고려할 수 있습니다.
Ctrl+S를 눌렀을 때브라우저를 닫는 순간에 네트워크 요청 하나를 보내 모든 것을 해결하려고 하면 불안정합니다. MDN은 beforeunload가 모바일을 포함한 여러 상황에서 실행되지 않을 수 있다고 설명합니다. 앱 상태 저장에는 visibilitychange를 더 신뢰할 만한 신호로 권합니다.
beforeunload는 dirty 상태일 때 경고를 띄우는 마지막 안전장치로 쓰고, 실제 초안은 사용자가 페이지에 머무는 동안 주기적으로 저장하는 편이 낫습니다.
네트워크 timeout은 저장 실패와 같은 말이 아닙니다.
클라이언트 → 서버 저장 성공 → 응답이 돌아오다 끊김
클라이언트는 실패로 보지만 서버에는 이미 반영됐습니다. 같은 요청을 바로 다시 보내면 중복 생성이나 중복 알림 같은 부작용이 생길 수 있습니다.
본문을 특정 값으로 바꾸는 PUT·PATCH는 같은 요청을 반복해도 결과가 같도록 설계하기 비교적 쉽습니다. 새 초안 버전을 생성하거나 알림·이벤트를 함께 만든다면 idempotency key를 둘 수 있습니다.
Idempotency-Key: 63df...e912
같은 키로 재시도하면 서버가 처음 처리한 결과를 다시 반환합니다. Stripe의 멱등 요청 문서도 연결 오류 뒤 같은 작업을 안전하게 재시도하기 위해 이 방식을 사용합니다.
재시도 간격은 exponential backoff와 jitter를 함께 고려합니다.
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay)
const withJitter = Math.random() * delay
여러 클라이언트가 같은 간격으로 동시에 재시도하면 장애 중인 서버에 다시 요청이 몰립니다. jitter는 재시도 시간을 조금씩 흩어 줍니다. AWS의 Timeouts, retries and backoff with jitter도 timeout 뒤 이미 부작용이 생겼을 수 있으므로 API를 멱등하게 만들고, backoff와 jitter로 재시도 부하를 조절하라고 설명합니다.
모든 오류를 재시도하지는 않습니다.
재시도에 실패해도 draft는 지우지 않습니다.
메모리의 draft는 탭을 닫거나 브라우저가 죽으면 사라집니다. 오프라인 편집까지 지원한다면 로컬 저장소에 초안을 남깁니다.
작은 텍스트 하나라면 localStorage로 시작할 수 있습니다. 문서가 크거나 첨부 파일·여러 초안을 다룬다면 IndexedDB를 고려할 수 있습니다. IndexedDB는 구조화된 데이터를 비동기로 저장하고 오프라인에서도 읽을 수 있습니다.
로컬 레코드에는 본문만 넣지 말고 동기화 기준도 함께 둡니다.
type LocalDraft = {
resourceId: string
content: string
baseRevision: number
updatedAt: number
}
온라인으로 돌아오면 서버 최신 revision을 읽습니다. baseRevision이 같으면 바로 저장하고, 다르면 3-way merge나 충돌 UI로 보냅니다. 서버 저장을 확인한 뒤 로컬 초안을 지웁니다.
여러 탭에서 같은 문서를 열 수 있다면 BroadcastChannel로 새 revision을 알릴 수도 있습니다. 다만 최종 기준은 여전히 서버 revision으로 두는 편이 안전합니다.
자동 저장의 목표는 저장 버튼을 계속 누르는 수고를 줄이는 데 있습니다. 그렇다고 저장 상태까지 숨길 필요는 없습니다.
노션이나 구글 문서를 떠올리면 작은 상태 문구가 있습니다. Microsoft 365도 자동 저장 중 파일 제목 영역에 상태를 보여 주며, 자동 저장된 내용을 되돌릴 수 있도록 버전 기록을 제공합니다.
화면에는 다음 정도를 보여 줄 수 있습니다.
dirty → 저장 대기 중
saving → 저장 중…
synced → 방금 저장됨 / 마지막 저장 시각
offline → 오프라인에서 보관 중
error → 저장하지 못함 · 다시 시도
매번 성공 토스트를 띄우면 입력을 방해합니다. 헤더나 제목 근처의 작은 상태 문구면 충분한 경우가 많습니다. 실패는 눈에 띄게 보여 주고, 로컬 draft를 보존했다는 사실과 재시도 방법을 함께 알립니다.
Nielsen Norman Group의 시스템 상태 가시성 원칙도 사용자가 이전 행동의 결과와 다음 행동을 판단할 수 있도록 적절한 피드백을 제공하라고 설명합니다.
자동 저장은 실수로 수정한 내용도 바로 저장합니다. 그래서 문서 성격에 따라 undo, 버전 기록, “이전 버전 복원”이 함께 필요할 수 있습니다. Microsoft의 AutoSave 안내도 자동 저장이 원본을 계속 바꾸는 상황에서 Version History로 이전 버전을 복원하는 방법을 안내합니다.
메모나 프로젝트 카드처럼 여러 화면에서 자주 수정하는 기능이라면 다음 구성이 현실적입니다.
입력
↓
draft 즉시 갱신
↓
700ms debounce + 10초 maxWait
↓
single-flight 저장 큐
↓
revision을 포함한 조건부 UPDATE
↓
성공: base와 revision 갱신
충돌: 최신본 조회 후 3-way merge
실패: draft 유지 + backoff 재시도
화면을 다시 조회할 때는 현재 revision보다 낮은 응답을 버립니다. 탭이 다시 보일 때 원격 revision을 확인하고, 로컬이 dirty라면 화면을 바로 덮지 않고 다음 저장에서 merge합니다.
오프라인 요구가 있다면 IndexedDB를 추가합니다. 실시간 공동 편집 요구가 생기면 그때 OT나 CRDT를 검토합니다. 저장과 발행의 의미가 다르면 초안만 자동 저장하고 발행 버튼은 남깁니다.
처음부터 모든 기능을 넣기보다 제품의 데이터 유실 비용과 동시 수정 가능성에 맞춰 단계를 올릴 수 있습니다.
저라면 다음 순서로 답하겠습니다.
입력값은 서버의 마지막 확정본과 로컬 draft로 분리하겠습니다. 입력할 때 draft를 바로 갱신하고 500~1,000ms debounce와 maxWait로 저장을 예약합니다. 같은 문서에는 하나의 저장 요청만 보내고, 저장 중 추가 입력은 최신 값으로 합쳐 다음 요청에서 저장하겠습니다.
서버에는 revision을 두고
expectedRevision이 맞을 때만 수정하는 낙관적 동시성 제어를 적용하겠습니다. 충돌하면 최신본을 가져와 긴 텍스트는 base/local/remote 3-way merge를 시도하고, 병합하기 어려우면 로컬 draft를 보존한 채 사용자에게 선택권을 주겠습니다.페이지 이동과
visibilitychange에서 pending 저장을 flush하되beforeunload만 믿지는 않겠습니다. 일시적인 실패는 멱등성을 확보한 뒤 exponential backoff와 jitter로 재시도하고, 오프라인 요구가 있으면 IndexedDB에 초안을 저장하겠습니다. UI에는 저장 대기·저장 중·완료·오류 상태를 보여 주겠습니다.
여기에 제품 조건에 따라 “실시간 협업이 필요한가”, “초안과 발행이 분리되는가”를 질문하면 답의 범위를 더 정확히 잡을 수 있습니다.
자동 저장은 정상 요청 한 번보다 순서가 꼬였을 때를 테스트하는 편이 중요합니다.
| 상황 | 확인할 것 |
|---|---|
| 계속 입력함 | maxWait 안에 중간 저장이 실행되는가 |
| 저장 중 추가 입력 | 첫 응답이 최신 draft를 지우지 않는가 |
| 느린 이전 응답 | 낮은 revision을 화면에 적용하지 않는가 |
| 다른 탭에서 수정 | revision 충돌을 찾고 merge하는가 |
| timeout 뒤 재시도 | 같은 작업을 두 번 만들지 않는가 |
| 페이지 즉시 이동 | debounce와 진행 중 저장을 flush하는가 |
| 브라우저 종료 | 로컬 draft 또는 마지막 주기 저장이 남는가 |
| 오프라인 후 복귀 | baseRevision을 확인하고 동기화하는가 |
| 서버 검증 실패 | draft를 유지하고 오류를 설명하는가 |
| 충돌 병합 실패 | 로컬과 원격 중 하나를 조용히 버리지 않는가 |
MSW나 Playwright에서 응답 지연과 순서를 의도적으로 바꾸면 이런 상황을 재현할 수 있습니다. 단위 테스트에서는 상태 머신과 merge 함수를 검증하고, 통합 테스트에서는 화면 이동·오프라인·다중 탭을 확인할 수 있습니다.
노션이나 구글 문서에서 저장 버튼을 찾지 않는 경험은 편합니다. 사용자는 글을 쓰고 이동하는 일에만 집중합니다. 이 편리함 뒤에서는 저장 지연, 요청 순서, 충돌, 재시도, 화면 종료를 계속 관리합니다.
면접에서 debounce를 먼저 말해도 좋습니다. 이어서 maxWait, single-flight, revision, merge, 멱등성, 복구 UX까지 설명하면 자동 저장을 하나의 비동기 시스템으로 보고 있다는 점을 보여 줄 수 있습니다.
사용자가 원하는 결과는 단순합니다. 다시 열었을 때 방금 쓴 내용이 그대로 있으면 됩니다. 구현은 그 약속을 지키는 쪽으로 하나씩 더하면 됩니다.
추신: 위 내용은 컴윗 서비스의 자동 기능저장을 개선하면서 코덱스가 작성한 글입니다.
좋은 글 감사합니다~