사용자가 입력한 내용을 자동으로 저장해 주는 Auto Save는 이탈, 새로고침, 네트워크 이슈 등으로 인한 데이터 유실을 막아 UX를 크게 개선합니다. 이 글에서는 실무에서 바로 적용 가능한 디바운스 기반 Auto Save 패턴, 재사용 가능한 커스텀 훅, 요청 경합 처리, 오프라인 임시 저장까지 단계별로 정리합니다.
1. 기본 원리: 디바운스 기반 자동 저장
Auto Save는 보통 입력 변경을 감지한 뒤 잠깐 대기했다가 저장합니다. 연속 입력 동안 서버 호출을 남발하지 않도록 디바운스를 사용합니다. 핵심은 타이머를 설정하고, 새로운 입력이 오면 기존 타이머를 취소하는 것입니다.
import { useEffect, useRef, useState } from "react";
function useDebouncedAutoSave(value, onSave, delay = 1000) {
const [status, setStatus] = useState("idle"); // idle | saving | saved | error
const [error, setError] = useState(null);
const [lastSavedAt, setLastSavedAt] = useState(null);
const timerRef = useRef(null);
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
try {
setStatus("saving");
await onSave(value);
setStatus("saved");
setLastSavedAt(new Date());
} catch (e) {
setStatus("error");
setError(e);
}
}, delay);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [value, delay, onSave]);
return { status, error, lastSavedAt };
}
위 훅은 기본적인 동작을 제공합니다. 하지만 실무에서는 중복 요청, 최신값 보장, 오프라인 대비가 필요합니다. 다음 절에서 보강합니다.
2. 재사용 가능한 훅: useAutoSave
아래 훅은 다음을 지원합니다. 첫째, 값이 실제로 달라졌을 때만 저장합니다. 둘째, 이전 저장 요청을 AbortController로 취소해 최신 입력만 저장합니다. 셋째, 비활성화 옵션과 에러 상태를 제공합니다.
import { useEffect, useMemo, useRef, useState } from "react";
export function useAutoSave({
value,
onSave, // async (val, { signal }) => Promise
delay = 1000,
enabled = true,
}) {
const [status, setStatus] = useState("idle"); // idle | dirty | saving | saved | error
const [error, setError] = useState(null);
const [lastSavedAt, setLastSavedAt] = useState(null);
const abortRef = useRef(null);
const prevSigRef = useRef(null);
const signature = useMemo(() => {
try {
return typeof value === "string" || typeof value === "number"
? String(value)
: JSON.stringify(value);
} catch {
return String(value);
}
}, [value]);
useEffect(() => {
if (!enabled) return;
if (prevSigRef.current === null) {
// 최초 마운트에서는 저장하지 않음
prevSigRef.current = signature;
return;
}
if (prevSigRef.current === signature) return;
setStatus("dirty");
const timer = setTimeout(async () => {
// 이전 요청 취소로 최신값만 저장
if (abortRef.current) abortRef.current.abort();
const ac = new AbortController();
abortRef.current = ac;
try {
setStatus("saving");
await onSave(value, { signal: ac.signal });
prevSigRef.current = signature;
setStatus("saved");
setLastSavedAt(new Date());
setError(null);
} catch (e) {
if (e?.name === "AbortError") return; // 최신 요청만 유지
setStatus("error");
setError(e);
}
}, delay);
return () => clearTimeout(timer);
}, [signature, delay, enabled, onSave, value]);
const abort = () => abortRef.current?.abort();
return { status, error, lastSavedAt, abort };
}
주의할 점은 onSave가 참조 동일성을 유지하도록 useCallback으로 감싸는 것입니다. 그렇지 않으면 의도치 않게 효과가 자주 재실행됩니다.
3. 예제: 메모 편집기 자동 저장
아래는 텍스트 영역에서 입력을 받고, 서버에 자동 저장하는 예제입니다. 최신값만 저장되며, 저장 상태를 화면에 표시합니다.
import React, { useCallback, useEffect, useState } from "react";
import { useAutoSave } from "./useAutoSave";
export default function NoteEditor({ noteId }) {
const [content, setContent] = useState("");
// 초기 데이터 로드
useEffect(() => {
let mounted = true;
(async () => {
const res = await fetch(`/api/notes/${noteId}`);
const data = await res.json();
if (mounted) setContent(data.content || "");
})();
return () => {
mounted = false;
};
}, [noteId]);
const saveNote = useCallback(async (val, { signal }) => {
const res = await fetch(`/api/notes/${noteId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: val }),
signal,
});
if (!res.ok) throw new Error("Failed to save");
}, [noteId]);
const { status, lastSavedAt, error } = useAutoSave({
value: content,
onSave: saveNote,
delay: 1000,
enabled: true,
});
return (
<div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={10}
style={{ width: "100%" }}
placeholder="메모를 입력하면 자동 저장됩니다."
/>
<p>상태: {status} {lastSavedAt ? `(마지막 저장: ${new Date(lastSavedAt).toLocaleTimeString()})` : ""}</p>
{error ? <p>에러: {String(error.message || error)}</p> : null}
</div>
);
}
네트워크가 느릴 때도 마지막 입력만 서버로 전송되므로 불필요한 중복 저장을 막습니다.
4. 요청 경합과 최신값 보장: AbortController
사용자가 빠르게 입력할 때 이전 저장 요청이 아직 진행 중일 수 있습니다. AbortController로 진행 중인 요청을 취소해 최신 요청만 유지해야 데이터 경합을 줄일 수 있습니다. 위 useAutoSave 훅은 이를 기본 제공합니다. 서버 측에서도 멱등성 보장을 위해 동일 리소스에 대한 PUT 요청을 안전하게 처리하도록 구현하는 것이 좋습니다.
// onSave 내부 예시 (이미 위에서 사용)
const saveNote = async (val, { signal }) => {
const res = await fetch(`/api/notes/${noteId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: val }),
signal,
});
if (!res.ok) throw new Error("Failed to save");
};
React 18의 StrictMode에서 개발 모드로 효과가 두 번 실행될 수 있으므로 onSave는 같은 페이로드로 여러 번 호출되어도 문제가 없도록 설계하는 것이 안전합니다.
5. 오프라인과 새로고침 대비: localStorage 임시 저장
네트워크가 끊기거나 사용자가 탭을 닫는 경우를 대비해 localStorage에 초안(드래프트)을 보관합니다. 온라인이 되면 서버와 동기화하고 성공 시 초안을 삭제합니다.
import React, { useCallback, useEffect, useState } from "react";
import { useAutoSave } from "./useAutoSave";
const draftKey = (id) => `draft:note:${id}`;
export default function NoteEditorWithDraft({ noteId }) {
const [content, setContent] = useState("");
// 1) 초기 로드: 서버 데이터 > 로컬 드래프트
useEffect(() => {
let mounted = true;
(async () => {
try {
const res = await fetch(`/api/notes/${noteId}`);
const data = await res.json();
const serverContent = data.content || "";
const localDraft = localStorage.getItem(draftKey(noteId));
const initial = localDraft ?? serverContent;
if (mounted) setContent(initial);
} catch {
const localDraft = localStorage.getItem(draftKey(noteId));
if (mounted) setContent(localDraft ?? "");
}
})();
return () => { mounted = false; };
}, [noteId]);
// 2) 입력 변경 시 드래프트 저장
useEffect(() => {
localStorage.setItem(draftKey(noteId), content);
}, [noteId, content]);
// 3) 서버 저장 함수
const saveNote = useCallback(async (val, { signal }) => {
if (!navigator.onLine) {
// 오프라인이면 서버 저장 건너뛰고 드래프트만 유지
return;
}
const res = await fetch(`/api/notes/${noteId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: val }),
signal,
});
if (!res.ok) throw new Error("Failed to save");
}, [noteId]);
const { status, lastSavedAt, error } = useAutoSave({
value: content,
onSave: saveNote,
delay: 1000,
enabled: true,
});
// 4) 온라인 전환 시 동기화
useEffect(() => {
const onOnline = async () => {
const draft = localStorage.getItem(draftKey(noteId));
if (draft != null) {
try {
await saveNote(draft, { signal: undefined });
localStorage.removeItem(draftKey(noteId));
} catch {}
}
};
window.addEventListener("online", onOnline);
return () => window.removeEventListener("online", onOnline);
}, [noteId, saveNote]);
// 5) 저장 성공 시 드래프트 삭제
useEffect(() => {
if (status === "saved") {
localStorage.removeItem(draftKey(noteId));
}
}, [status, noteId]);
return (
<div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={10}
style={{ width: "100%" }}
/>
<p>상태: {status} {lastSavedAt ? `(마지막 저장: ${new Date(lastSavedAt).toLocaleTimeString()})` : ""}</p>
{error ? <p>에러: {String(error.message || error)}</p> : null}
</div>
);
}
임시 저장은 사용자의 안심감을 높이고 이탈 시에도 데이터를 보호합니다. 단, 민감한 데이터는 브라우저 저장소 사용 시 보안 정책을 점검해야 합니다.
6. 성능과 UX 체크리스트
첫째, delay는 입력 패턴에 맞춰 800ms~1500ms 사이로 조정합니다. 너무 짧으면 서버 과부하, 너무 길면 저장 지연으로 불안감을 줄 수 있습니다.
둘째, 저장 상태를 즉시 피드백합니다. saving, saved, error 상태를 텍스트 또는 아이콘으로 명확히 보여 주세요.
셋째, 큰 폼은 필드 단위로 저장하거나 변경된 필드만 패치합니다. onSave에서 최소 페이로드만 전송하면 비용을 줄일 수 있습니다.
넷째, 네트워크 에러 시 지수 백오프 재시도를 고려하되, 사용자의 추가 입력이 발생하면 즉시 최신값 기준으로 재시도하도록 합니다.
다섯째, 서버는 동일 리소스에 대한 여러 저장 요청이 와도 최신 타임스탬프의 데이터만 반영하도록 설계합니다.
7. 지수 백오프 예시
간단한 재시도 래퍼를 추가해 일시적 오류를 흡수할 수 있습니다.
async function withRetry(fn, { retries = 3, baseDelay = 500 } = {}) {
let attempt = 0;
let lastErr;
while (attempt <= retries) {
try {
return await fn();
} catch (e) {
lastErr = e;
if (e?.name === "AbortError") throw e;
const delay = baseDelay * Math.pow(2, attempt);
await new Promise((r) => setTimeout(r, delay));
attempt += 1;
}
}
throw lastErr;
}
// 사용 예시
const saveNote = useCallback(async (val, { signal }) => {
return withRetry(async () => {
const res = await fetch(`/api/notes/${noteId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: val }),
signal,
});
if (!res.ok) throw new Error("Failed to save");
});
}, [noteId]);
8. 마무리
Auto Save는 단순한 디바운스를 넘어 최신값 보장, 요청 취소, 오프라인 드래프트, 명확한 상태 표시까지 포함해야 합니다. 위의 useAutoSave 훅과 드래프트 패턴을 조합하면 대부분의 폼에서 안전하고 확장 가능한 자동 저장을 빠르게 구축할 수 있습니다. 프로젝트에 맞게 delay, 저장 단위, 에러 처리 정책을 조정해 적용하시기 바랍니다.
'React' 카테고리의 다른 글
| React에서 브라우저 풀스크린 API 제어하기 (0) | 2026.06.22 |
|---|---|
| React 앱에서 지연 로딩(Lazy Loading) 비디오 구현하기 (0) | 2026.06.19 |
| React에서 클라이언트 사이드 데이터 암호화/복호화 처리하기 (0) | 2026.06.19 |
| React 앱에서 Device Orientation API 활용하기 (0) | 2026.06.18 |
| React에서 스크롤 기반 Parallax 효과 구현하기 (0) | 2026.06.18 |