Skip to main content
← 블로그

네트워크가 끊겨도 내 글은 사라지지 않는다

VauDium ·

API 호출 실패로 사용자의 콘텐츠가 증발하는 문제를 AsyncStorage 기반 임시저장으로 해결한 이야기.

네트워크가 끊겨도 내 글은 사라지지 않는다

내가 당했다

지하철에서 오늘 하루 회고를 쓰고 있었습니다. 꽤 길게 썼습니다. “오늘 뭘 잘했고, 뭘 놓쳤고, 내일은 이렇게 해봐야겠다” — 그런 종류의 글. 다 쓰고 확인 버튼을 눌렀습니다. 그리고 화면이 돌아갔습니다.

저장된 줄 알았습니다. 다시 열어보니 빈 화면이었습니다.

지하철이 터널 구간을 지나는 동안 네트워크가 끊겼고, API 호출이 실패했고, 화면은 이미 닫혔고, 제가 쓴 글은 어디에도 남아있지 않았습니다.

앱을 만든 사람이 자기 앱에서 당하는 경험은 좀 특별합니다. 화가 나는 동시에 부끄럽습니다.

문제의 구조

Fecit에서 콘텐츠를 수정하는 흐름은 대부분 이런 패턴입니다:

  1. 화면을 열고 내용을 편집한다
  2. 뒤로가기(또는 확인)를 누른다
  3. beforeRemove 이벤트에서 API를 호출한다
  4. 화면이 닫힌다

여기서 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가 복원될 때, 사용자에게 어떻게 알려줄 것인가? 두 가지 선택지가 있었습니다.

  1. 조용히 복원하고 아무 말도 안 한다
  2. “이전에 저장하지 못한 내용이 복원되었습니다” 같은 알림을 보여준다

2번을 선택했습니다. 사용자가 의도적으로 비운 화면인데 갑자기 내용이 채워져 있으면 당황할 수 있습니다. 작은 토스트 하나면 충분합니다.

24시간 TTL

draft에 만료 시간을 설정한 건 의도적입니다. 임시저장 데이터가 영원히 살아있으면 문제가 됩니다.

  • 일주일 전에 쓰다 만 태스크 내용이 갑자기 복원되면 혼란스럽습니다
  • 이미 다른 기기에서 같은 내용을 다시 작성했을 수도 있습니다
  • AsyncStorage에 오래된 데이터가 쌓이는 것도 좋지 않습니다

24시간이면 “네트워크가 잠깐 끊겼다가 복구된 후 다시 앱을 여는” 시나리오를 충분히 커버합니다.

사용자 경험은 바뀌지 않는다

이 전체 시스템에서 가장 중요한 점은, 네트워크가 정상일 때 사용자가 느끼는 경험이 전혀 달라지지 않는다는 것입니다.

  • 뒤로가기를 누르면 바로 화면이 닫힙니다
  • 생성 버튼을 누르면 바로 생성됩니다
  • 로딩 스피너도 없고, “저장 중” 토스트도 없습니다

안전망은 보이지 않을 때 가장 좋은 안전망입니다. 99%의 경우에는 존재하지 않는 것처럼 작동하고, 나머지 1%에서 사용자의 30분짜리 회고가 사라지는 걸 막아줍니다.

내 글이 사라진 그 순간이 없었다면, 이 기능은 아마 한참 뒤에야 만들었을 겁니다. 혹은 영영 만들지 않았을 수도 있습니다. 직접 당해봐야 우선순위가 올라가는 법이니까요.