네트워크가 끊겨도 내 글은 사라지지 않는다
API 호출 실패로 사용자의 콘텐츠가 증발하는 문제를 AsyncStorage 기반 임시저장으로 해결한 이야기.
네트워크가 끊겨도 내 글은 사라지지 않는다
내가 당했다
지하철에서 오늘 하루 회고를 쓰고 있었습니다. 꽤 길게 썼습니다. “오늘 뭘 잘했고, 뭘 놓쳤고, 내일은 이렇게 해봐야겠다” — 그런 종류의 글. 다 쓰고 확인 버튼을 눌렀습니다. 그리고 화면이 돌아갔습니다.
저장된 줄 알았습니다. 다시 열어보니 빈 화면이었습니다.
지하철이 터널 구간을 지나는 동안 네트워크가 끊겼고, API 호출이 실패했고, 화면은 이미 닫혔고, 제가 쓴 글은 어디에도 남아있지 않았습니다.
앱을 만든 사람이 자기 앱에서 당하는 경험은 좀 특별합니다. 화가 나는 동시에 부끄럽습니다.
문제의 구조
Fecit에서 콘텐츠를 수정하는 흐름은 대부분 이런 패턴입니다:
- 화면을 열고 내용을 편집한다
- 뒤로가기(또는 확인)를 누른다
beforeRemove이벤트에서 API를 호출한다- 화면이 닫힌다
여기서 3번과 4번 사이에 문제가 있습니다. beforeRemove에서 API를 호출하되, 그 결과를 기다리지 않습니다. fire-and-forget 패턴입니다. 사용자 입장에서는 뒤로가기를 눌렀는데 로딩 스피너가 돌면서 화면이 안 닫히는 게 더 이상합니다. 그래서 의도적으로 이렇게 설계한 겁니다.
문제는 이 fire-and-forget이 성공을 전제한다는 것입니다. 네트워크가 끊기면? API 타임아웃이 나면? 사용자가 쓴 내용은 메모리에서만 존재하다가 화면이 unmount되면서 사라집니다.
새 태스크를 생성하는 경우도 마찬가지입니다. 내용을 다 채우고 생성 버튼을 누르는데 네트워크 오류가 나면, 사용자는 처음부터 다시 입력해야 합니다.
안전망이라는 발상
해결책을 생각하면서 한 가지 원칙을 세웠습니다: 사용자 경험을 바꾸지 않는다.
로딩 스피너를 추가하거나, “저장 중…” 토스트를 띄우거나, 네트워크 상태를 체크해서 경고를 주는 건 하고 싶지 않았습니다. 기존의 fire-and-forget 패턴이 주는 가벼운 느낌을 유지하고 싶었습니다.
대신, 밑에 안전망을 깔기로 했습니다. 네트워크가 정상이면 아무 일도 일어나지 않습니다. 네트워크가 실패하면, 다음에 같은 화면을 열 때 직전에 쓴 내용이 복원됩니다.
DraftStorage: 임시저장 유틸리티
핵심은 간단한 유틸리티 클래스입니다.
class DraftStorage {
static async save<T>(key: string, draft: T): Promise<void> {
const entry = {
data: draft,
savedAt: Date.now(),
};
await AsyncStorage.setItem(key, JSON.stringify(entry));
}
static async load<T>(key: string): Promise<T | null> {
const raw = await AsyncStorage.getItem(key);
if (!raw) return null;
const entry = JSON.parse(raw);
const age = Date.now() - entry.savedAt;
if (age > 24 * 60 * 60 * 1000) {
await AsyncStorage.removeItem(key);
return null;
}
return entry.data as T;
}
static async clear(key: string): Promise<void> {
await AsyncStorage.removeItem(key);
}
}
저장할 때 타임스탬프를 함께 기록하고, 불러올 때 24시간이 지났으면 자동으로 버립니다. 3일 전에 쓰다 만 초안이 갑자기 복원되는 건 혼란만 줍니다.
키는 화면 타입별로 나눕니다:
// 새로 만들기 — 화면당 하나
const CREATE_TASK_RECORD_KEY = "@draft:create-task-record";
// 기존 항목 편집 — ID별로 구분
const editTaskRecordKey = (id: string) => `@draft:edit-task-record:${id}`;
// 리뷰 편집 — 날짜별로 구분
const editDailyReviewKey = (date: string) => `@draft:edit-daily-review:${date}`;
const editWeeklyReviewKey = (date: string) => `@draft:edit-weekly-review:${date}`;
새로 만들기 vs 편집하기
두 시나리오의 흐름이 약간 다릅니다.
새로 만들기 (CreateTaskRecord 등)
사용자가 내용을 입력한다
→ 생성 버튼을 누른다
→ API를 호출한다
→ 실패하면: 입력한 내용을 draft로 저장한다
→ 다음에 같은 생성 화면을 열면: draft가 있으면 복원하고 알림을 보여준다
→ 생성 성공하면: draft를 삭제한다
생성 화면에서는 API 호출 결과를 기다립니다. 실패했을 때만 draft를 저장합니다. 성공하면 draft가 없으니까 아무 일도 일어나지 않습니다.
기존 항목 편집 (BrowseTaskRecord, edit-daily-review 등)
사용자가 내용을 편집한다
→ 뒤로가기를 누른다 (beforeRemove 트리거)
→ 현재 내용을 draft로 저장한다
→ API를 호출한다
→ 성공하면: draft를 삭제한다
→ 실패하면: draft가 남아있다
→ 다음에 같은 항목을 열면: draft가 있으면 복원한다
편집 화면에서는 API 호출 전에 draft를 저장합니다. 왜냐하면 beforeRemove에서는 화면이 곧 사라지기 때문입니다. 저장부터 하고, 성공하면 지우는 겁니다. 순서가 중요합니다.
update 함수의 반환값 변경
이 구조를 가능하게 만든 작은 변경이 하나 있습니다. 기존 update API 함수들은 대부분 void를 반환했습니다.
// 변경 전
async function updateTaskRecord(id: string, data: UpdateData): Promise<void> {
await authorizedPut(`/task/record/${id}`, data);
}
이걸 boolean으로 바꿨습니다.
// 변경 후
async function updateTaskRecord(id: string, data: UpdateData): Promise<boolean> {
try {
await authorizedPut(`/task/record/${id}`, data);
return true;
} catch {
return false;
}
}
이제 호출하는 쪽에서 성공/실패를 알 수 있고, 성공했을 때만 draft를 지울 수 있습니다.
// beforeRemove 핸들러 안에서
await DraftStorage.save(editTaskRecordKey(id), currentDraft);
const success = await updateTaskRecord(id, updateData);
if (success) {
await DraftStorage.clear(editTaskRecordKey(id));
}
적용 범위
이 패턴을 7개 이상의 화면 타입에 적용했습니다:
| 화면 | 드래프트 키 | 유형 |
|---|---|---|
| CreateTaskRecord | @draft:create-task-record | 새로 만들기 |
| BrowseTaskRecord | @draft:edit-task-record:{id} | 편집 |
| BrowseSubTaskRecord | @draft:edit-subtask-record:{id} | 편집 |
| BrowseTaskTemplate | @draft:edit-task-template:{id} | 편집 |
| BrowseSubTaskTemplate | @draft:edit-subtask-template:{id} | 편집 |
| edit-daily-review | @draft:edit-daily-review:{date} | 편집 |
| edit-weekly-review | @draft:edit-weekly-review:{date} | 편집 |
각 화면마다 타입이 다르기 때문에 draft도 타입을 나눴습니다:
interface CreateTaskRecordDraft {
title: string;
startDate: string;
endDate: string;
memo: string;
subTasks: SubTaskDraft[];
// ...
}
interface EditTaskRecordDraft {
title: string;
memo: string;
state: string;
// ...
}
interface EditReviewDraft {
content: string;
rating: number;
}
interface EditTaskTemplateDraft {
title: string;
description: string;
// ...
}
타입을 나눈 이유는, 생성 화면과 편집 화면에서 저장해야 할 필드가 다르기 때문입니다. 생성 화면에서는 날짜, 서브태스크 구성 같은 걸 다 들고 있어야 하지만, 편집 화면에서는 사용자가 실제로 변경한 필드만 있으면 됩니다.
복원 시점의 UX
draft가 복원될 때, 사용자에게 어떻게 알려줄 것인가? 두 가지 선택지가 있었습니다.
- 조용히 복원하고 아무 말도 안 한다
- “이전에 저장하지 못한 내용이 복원되었습니다” 같은 알림을 보여준다
2번을 선택했습니다. 사용자가 의도적으로 비운 화면인데 갑자기 내용이 채워져 있으면 당황할 수 있습니다. 작은 토스트 하나면 충분합니다.
24시간 TTL
draft에 만료 시간을 설정한 건 의도적입니다. 임시저장 데이터가 영원히 살아있으면 문제가 됩니다.
- 일주일 전에 쓰다 만 태스크 내용이 갑자기 복원되면 혼란스럽습니다
- 이미 다른 기기에서 같은 내용을 다시 작성했을 수도 있습니다
- AsyncStorage에 오래된 데이터가 쌓이는 것도 좋지 않습니다
24시간이면 “네트워크가 잠깐 끊겼다가 복구된 후 다시 앱을 여는” 시나리오를 충분히 커버합니다.
사용자 경험은 바뀌지 않는다
이 전체 시스템에서 가장 중요한 점은, 네트워크가 정상일 때 사용자가 느끼는 경험이 전혀 달라지지 않는다는 것입니다.
- 뒤로가기를 누르면 바로 화면이 닫힙니다
- 생성 버튼을 누르면 바로 생성됩니다
- 로딩 스피너도 없고, “저장 중” 토스트도 없습니다
안전망은 보이지 않을 때 가장 좋은 안전망입니다. 99%의 경우에는 존재하지 않는 것처럼 작동하고, 나머지 1%에서 사용자의 30분짜리 회고가 사라지는 걸 막아줍니다.
내 글이 사라진 그 순간이 없었다면, 이 기능은 아마 한참 뒤에야 만들었을 겁니다. 혹은 영영 만들지 않았을 수도 있습니다. 직접 당해봐야 우선순위가 올라가는 법이니까요.