지난 편에서 체크리스트 앱에 실제 API 저장을 붙였다면, 이제부터는 조금 더 현실적인 문제가 보이기 시작합니다. 목록을 불러오는 중에 실패하면 어떻게 할지, 저장이 잠깐 막혔을 때 사용자에게 무엇을 보여줄지, 그리고 어떤 동작은 화면을 먼저 바꿔도 되고 어떤 동작은 기다려야 하는지를 더 분명하게 정리해야 합니다.

특히 서버 저장 버전은 localStorage 버전과 달리 "실패했을 때 어디로 돌아갈지"를 같이 설명할 수 있어야 합니다. 그냥 fetch만 붙였다고 끝나는 게 아니라, 실패 메시지, 다시 시도, 저장 중 잠금, 그리고 어떤 동작은 먼저 반영했다가 되돌리는 구조까지 같이 붙어야 비로소 앱이 안정적으로 느껴집니다.

이번 글에서는 지난 편의 API 저장 최소 버전을 기준으로, 초기 로드 실패 시 다시 불러오기, 체크 완료는 먼저 반영했다가 실패 시 롤백, 추가와 삭제는 여전히 보수적으로 확정하는 흐름까지 붙여보겠습니다. 이 단계까지 오면 이제 체크리스트 앱도 "저장되는 화면"에서 "실패까지 설명하는 화면"으로 한 단계 올라오게 됩니다.

이번 글에서 먼저 잡아둘 핵심
겉으로 보이는 변화 실제로 추가되는 것 가장 먼저 봐야 할 지점
초기 로드가 실패해도 화면이 멈추지 않는다 loadingError와 다시 불러오기 버튼이 생긴다 목록을 못 읽었을 때 사용자에게 다시 시도할 길을 남기는지 본다
체크 완료는 눌렀을 때 먼저 바뀐다 이전 상태를 백업하고 실패 시 롤백하는 구조가 붙는다 빠르게 보이게 만드는 것보다 어디로 되돌릴지가 먼저 있는지 본다
추가와 삭제는 여전히 조심스럽게 처리한다 응답 성공 후 확정하는 보수적 구조를 유지한다 모든 동작에 같은 저장 방식을 억지로 쓰지 않는지 본다

이번 글의 핵심 한 줄
이번 단계의 핵심은 화면을 무조건 빨리 바꾸는 게 아니라, 실패했을 때 어디까지 되돌릴지까지 같이 설명할 수 있는 저장 구조를 만드는 데 있습니다.


목차

  1. 왜 이제는 실패와 재시도까지 붙여봐야 하는가
  2. 이번 편에서는 어디까지 만들 것인가
  3. HTML 수정: 상태 박스와 다시 불러오기 버튼 추가
  4. CSS 수정: 저장 중 항목과 재시도 버튼이 눈에 띄게 보이기
  5. JavaScript 수정: 로드 재시도, 체크 완료 롤백, 보수적인 추가와 삭제
  6. Codex에게는 이렇게 나눠서 요청하면 된다
  7. 직접 눌러보며 확인할 것
  8. 마무리

왜 이제는 실패와 재시도까지 붙여봐야 하는가

서버 저장 최소 버전이 돌아가기 시작하면, 겉으로는 꽤 그럴듯해 보입니다. 페이지를 열면 목록을 읽어오고, 새 항목도 저장되고, 체크와 삭제도 API를 타고 움직입니다. 그런데 실제로 조금만 더 써보면 금방 빈틈이 보입니다. 서버가 잠깐 죽어 있거나, 응답이 느리거나, 저장이 실패했을 때 화면이 너무 쉽게 멈춰버리기 때문입니다.

이 지점에서 localStorage 버전과 서버 버전의 차이가 더 분명해집니다. localStorage에서는 보통 "저장 = 바로 끝남"이라는 감각으로도 버틸 수 있었는데, 서버 저장은 그렇지 않습니다. 요청이 실패할 수도 있고, 불러오기가 실패할 수도 있고, 어떤 동작은 먼저 바꿨다가 다시 되돌려야 더 자연스러울 수도 있습니다.

  • 초기 로드 실패 시 다시 시도할 수 있어야 합니다.
  • 저장 중인 항목과 그렇지 않은 항목이 구분되면 더 읽기 쉬워집니다.
  • 모든 동작을 같은 방식으로 저장하지 않아도 된다는 감각이 중요해집니다.
  • 빠르게 보이는 구조보다, 실패 시 설명 가능한 구조가 더 중요해집니다.

핵심 포인트
서버 저장 버전이 "진짜로 쓸 만한 구조"가 되려면 성공만 보는 게 아니라 실패와 다시 시도, 되돌리기까지 같이 보여줄 수 있어야 합니다.


이번 편에서는 어디까지 만들 것인가

이번 편도 범위를 분명하게 잘라두겠습니다. "실패 처리"를 붙인다고 해서 수정, 검색, 정렬, 우선순위, 마감일까지 전부 새 구조로 갈아엎지 않겠습니다. 지금 단계에서는 아래 네 가지만 붙이면 충분합니다.

  • 초기 로드 실패 시 다시 불러오기 버튼을 둡니다.
  • 체크 완료는 화면을 먼저 바꾸고, 실패하면 원래 상태로 되돌립니다.
  • 추가는 여전히 응답 성공 후에만 확정합니다.
  • 삭제도 여전히 응답 성공 후에만 목록에서 제거합니다.

반대로 이번 편에서는 아래 내용은 일부러 넣지 않겠습니다.

  • 수정 저장까지 낙관적으로 먼저 반영하는 구조
  • 삭제한 항목을 일정 시간 안에 되돌리는 기능
  • 동시에 여러 요청을 고급스럽게 병합하는 구조
  • AbortController로 이전 요청을 취소하는 구조

이렇게 해야 이번 단계에서 진짜 봐야 할 게 흐려지지 않습니다. 지금 중요한 건 "서버 저장이 고급스러워 보이느냐"가 아니라, 어떤 동작은 먼저 바꾸고 어떤 동작은 기다리는 기준을 세우는 것에 있습니다.


HTML 수정: 상태 박스와 다시 불러오기 버튼 추가

이번 기능은 HTML 전체를 다시 만드는 작업이 아닙니다. 지난 편의 입력 폼과 목록 구조는 그대로 두고, 상태를 보여주는 영역에 다시 불러오기 버튼만 붙이면 충분합니다. 즉, 저장 구조는 많이 달라지지만 화면 구조는 아주 조금만 바뀝니다.

index.html은 아래처럼 두면 됩니다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta
    name="viewport"
    content="width=device-width, initial-scale=1.0"
  >
  <title>오늘 할 일 앱 API 버전</title>
  <link rel="stylesheet" href="style.css">
  <script defer src="script.js"></script>
</head>
<body>
  <main class="app">
    <section class="panel">
      <header class="panel-header">
        <p class="eyebrow">Retry And Rollback Practice</p>
        <h1>오늘 할 일</h1>
        <p class="description">
          서버 저장 버전에 재시도와 롤백 흐름을 붙여서 더 안정적으로 다듬는 단계입니다.
        </p>
      </header>

      <section class="composer">
        <form id="todoForm" class="todo-form">
          <label for="todoInput">할 일 입력</label>
          <div class="input-row">
            <input
              id="todoInput"
              type="text"
              placeholder="예: 롤백 흐름 점검하기"
            >
            <select
              id="priorityInput"
              class="priority-select"
              aria-label="우선순위 선택"
            >
              <option value="high">높음</option>
              <option
                value="medium"
                selected
              >
                보통
              </option>
              <option value="low">낮음</option>
            </select>
            <input
              id="dueDateInput"
              type="date"
              class="due-date-input"
              aria-label="마감일 선택"
            >
            <button
              id="submitButton"
              type="submit"
              class="primary-button"
            >
              추가
            </button>
          </div>
        </form>
      </section>

      <section
        class="status-panel"
        aria-live="polite"
      >
        <p
          id="statusText"
          class="status-text"
        >
          서버와 연결할 준비가 되었습니다.
        </p>
        <button
          id="retryLoadButton"
          type="button"
          class="secondary-button"
          hidden
        >
          다시 불러오기
        </button>
      </section>

      <section
        class="list-section"
        aria-label="할 일 목록"
      >
        <ul
          id="todoList"
          class="todo-list"
        ></ul>
      </section>
    </section>
  </main>
</body>
</html>

이번 HTML 수정에서 핵심은 retryLoadButton입니다. 이 버튼 하나가 들어오면서 앱은 이제 단순히 "실패했다"를 보여주는 수준을 넘어서, 사용자가 바로 다음 행동을 취할 수 있는 구조로 바뀝니다.

작아 보이지만 꽤 중요한 변화입니다. 서버 저장 버전이 되는 순간부터는 실패를 보여주는 것만으로는 부족하고, 사용자가 다시 시도할 길을 열어두는 쪽이 훨씬 자연스럽기 때문입니다.


CSS 수정: 저장 중 항목과 재시도 버튼이 눈에 띄게 보이기

이번 CSS의 핵심은 화려한 디자인이 아닙니다. 저장 중인 항목이 지금 잠깐 서버와 통신 중이라는 게 보이게 하고, 다시 불러오기 버튼이 필요할 때만 자연스럽게 눈에 들어오게 만드는 것이 더 중요합니다.

style.css는 아래처럼 두면 됩니다.

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.panel-header {
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0;
  line-height: 1.7;
  color: #374151;
}

.composer {
  margin-top: 24px;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row {
  display: flex;
  gap: 12px;
}

input[type="text"],
input[type="date"],
select {
  font: inherit;
}

input[type="text"] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
}

.priority-select,
.due-date-input {
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  background: #ffffff;
  color: #111827;
}

.priority-select {
  min-width: 108px;
}

.due-date-input {
  min-width: 150px;
}

.primary-button,
.secondary-button,
.delete-button {
  font: inherit;
  font-weight: 700;
  border-radius: 14px;
  cursor: pointer;
}

.primary-button {
  border: none;
  padding: 14px 18px;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button {
  padding: 12px 14px;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.primary-button:disabled,
.secondary-button:disabled,
.delete-button:disabled,
input:disabled,
select:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

.status-panel {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.status-text {
  margin: 0;
  line-height: 1.6;
  color: #4b5563;
}

.list-section {
  margin-top: 24px;
}

.todo-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-item.is-pending {
  opacity: 0.72;
  border-style: dashed;
}

.todo-left {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  min-width: 0;
  flex: 1;
}

.todo-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

.todo-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.priority-badge,
.due-date-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 700;
}

.priority-badge--high {
  background: #fee2e2;
  color: #b91c1c;
}

.priority-badge--medium {
  background: #fef3c7;
  color: #92400e;
}

.priority-badge--low {
  background: #dcfce7;
  color: #166534;
}

.due-date-badge {
  background: #e5e7eb;
  color: #374151;
}

.todo-text {
  line-height: 1.6;
  word-break: break-word;
}

.todo-text.is-done {
  text-decoration: line-through;
  color: #9ca3af;
}

.todo-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row {
    flex-direction: column;
  }

  .status-panel {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-actions {
    width: 100%;
  }

  .delete-button {
    flex: 1 1 auto;
  }

  .priority-select,
  .due-date-input {
    width: 100%;
  }
}

이번 CSS에서 가장 중요한 부분은 todo-item is-pending입니다. 저장 중인 항목이 어떤 것인지 살짝 흐려 보이게만 해도, 사용자는 지금 이 카드가 잠깐 대기 중이라는 걸 훨씬 쉽게 받아들입니다.

즉, 이번 CSS는 예쁘게 꾸미는 작업이 아니라, 서버 저장에서 생기는 "잠깐 기다리는 상태"를 화면에서도 납득 가능하게 만드는 작업에 가깝습니다.


JavaScript 수정: 로드 재시도, 체크 완료 롤백, 보수적인 추가와 삭제

이번 편의 핵심은 JavaScript입니다. 서버 저장 버전에서 "실패까지 설명하는 구조"가 실제로 어떻게 생기는지 바로 여기서 갈립니다. 이번에는 일부러 저장 방식을 두 가지로 나눠서 보겠습니다. 체크 완료는 먼저 바꾸고 실패 시 되돌리고, 추가와 삭제는 성공 후 확정하는 방식입니다.

이번 JavaScript에서 먼저 봐야 할 상태와 함수
이름 역할 왜 중요한가
loadingError 초기 로드 실패 메시지를 따로 가진다 다시 불러오기 버튼이 언제 보여야 하는지 판단하는 기준이 된다
savingTodoIds 지금 저장 중인 항목만 따로 기억한다 목록 전체를 잠그지 않고, 해당 항목만 대기 상태처럼 보이게 할 수 있다
handleToggle 완료 체크를 먼저 반영한 뒤 실패하면 되돌린다 이번 글에서 롤백 구조가 가장 잘 보이는 지점이다
retryLoadButton 초기 로드 실패 뒤 바로 다시 시도할 수 있게 한다 실패 화면이 막다른 길처럼 보이지 않게 만든다

script.js는 아래처럼 정리하면 됩니다.

const API_URL = "/todos";

const PRIORITY_TYPES = {
  HIGH: "high",
  MEDIUM: "medium",
  LOW: "low"
};

const PRIORITY_LABELS = {
  high: "높음",
  medium: "보통",
  low: "낮음"
};

const elements = {
  form: document.querySelector("#todoForm"),
  input: document.querySelector("#todoInput"),
  priorityInput: document.querySelector("#priorityInput"),
  dueDateInput: document.querySelector("#dueDateInput"),
  submitButton: document.querySelector("#submitButton"),
  statusText: document.querySelector("#statusText"),
  retryLoadButton: document.querySelector("#retryLoadButton"),
  list: document.querySelector("#todoList")
};

const uiState = {
  isLoading: false,
  isCreating: false,
  loadingError: "",
  saveError: "",
  savingTodoIds: [],
  notice: ""
};

let todos = [];

function normalizePriority(value) {
  const allowedValues = Object.values(PRIORITY_TYPES);

  return allowedValues.includes(value)
    ? value
    : PRIORITY_TYPES.MEDIUM;
}

function normalizeDueDate(value) {
  const nextValue = String(value || "").trim();

  if (nextValue === "") {
    return "";
  }

  const datePattern = /^\d{4}-\d{2}-\d{2}$/;

  return datePattern.test(nextValue) ? nextValue : "";
}

function normalizeTodo(todo) {
  return {
    id: String(todo.id),
    text: String(todo.text || ""),
    done: Boolean(todo.done),
    priority: normalizePriority(todo.priority),
    dueDate: normalizeDueDate(todo.dueDate)
  };
}

function getPriorityLabel(priority) {
  return PRIORITY_LABELS[priority] || "보통";
}

function formatDueDateLabel(dueDate) {
  if (!dueDate) {
    return "";
  }

  return "마감 " + dueDate.replace(/-/g, ".");
}

function isTodoSaving(todoId) {
  return uiState.savingTodoIds.includes(String(todoId));
}

function startTodoSaving(todoId) {
  const nextId = String(todoId);

  if (!uiState.savingTodoIds.includes(nextId)) {
    uiState.savingTodoIds.push(nextId);
  }
}

function finishTodoSaving(todoId) {
  const nextId = String(todoId);

  uiState.savingTodoIds = uiState.savingTodoIds.filter(function (id) {
    return id !== nextId;
  });
}

function clearTransientMessages() {
  uiState.loadingError = "";
  uiState.saveError = "";
  uiState.notice = "";
}

function setFormDisabled(disabled) {
  elements.input.disabled = disabled;
  elements.priorityInput.disabled = disabled;
  elements.dueDateInput.disabled = disabled;
  elements.submitButton.disabled = disabled;
}

function renderStatus() {
  if (uiState.loadingError) {
    elements.statusText.textContent = uiState.loadingError;
    elements.retryLoadButton.hidden = false;
    return;
  }

  elements.retryLoadButton.hidden = true;

  if (uiState.isLoading) {
    elements.statusText.textContent = "서버에서 목록을 불러오는 중입니다.";
    return;
  }

  if (uiState.isCreating) {
    elements.statusText.textContent = "새 할 일을 서버에 저장 중입니다.";
    return;
  }

  if (uiState.savingTodoIds.length > 0) {
    elements.statusText.textContent = "일부 항목을 서버와 동기화하는 중입니다.";
    return;
  }

  if (uiState.saveError) {
    elements.statusText.textContent = uiState.saveError;
    return;
  }

  if (uiState.notice) {
    elements.statusText.textContent = uiState.notice;
    return;
  }

  if (todos.length === 0) {
    elements.statusText.textContent = "아직 등록된 할 일이 없습니다.";
    return;
  }

  elements.statusText.textContent = "서버와 연결된 목록을 보고 있습니다.";
}

function createPriorityBadge(todo) {
  const badge = document.createElement("span");

  badge.className =
    "priority-badge priority-badge--" + todo.priority;
  badge.textContent = getPriorityLabel(todo.priority);

  return badge;
}

function createDueDateBadge(todo) {
  if (!todo.dueDate) {
    return null;
  }

  const badge = document.createElement("span");

  badge.className = "due-date-badge";
  badge.textContent = formatDueDateLabel(todo.dueDate);

  return badge;
}

function createTodoText(todo) {
  const text = document.createElement("span");

  text.className = "todo-text";
  text.textContent = todo.text;

  if (todo.done) {
    text.classList.add("is-done");
  }

  return text;
}

function createTodoItem(todo) {
  const item = document.createElement("li");
  const left = document.createElement("div");
  const content = document.createElement("div");
  const meta = document.createElement("div");
  const actions = document.createElement("div");
  const checkbox = document.createElement("input");
  const deleteButton = document.createElement("button");
  const dueDateBadge = createDueDateBadge(todo);
  const isPending = isTodoSaving(todo.id);

  item.className = "todo-item";
  left.className = "todo-left";
  content.className = "todo-content";
  meta.className = "todo-meta";
  actions.className = "todo-actions";

  if (isPending) {
    item.classList.add("is-pending");
  }

  checkbox.type = "checkbox";
  checkbox.checked = todo.done;
  checkbox.disabled = uiState.isLoading || uiState.isCreating || isPending;

  checkbox.addEventListener("change", function () {
    handleToggle(todo, checkbox.checked);
  });

  deleteButton.type = "button";
  deleteButton.className = "delete-button";
  deleteButton.textContent = "삭제";
  deleteButton.disabled = uiState.isLoading || uiState.isCreating || isPending;

  deleteButton.addEventListener("click", function () {
    handleDelete(todo.id);
  });

  meta.append(createPriorityBadge(todo));

  if (dueDateBadge) {
    meta.append(dueDateBadge);
  }

  content.append(
    meta,
    createTodoText(todo)
  );

  actions.append(deleteButton);
  left.append(checkbox, content);
  item.append(left, actions);

  return item;
}

function renderTodos() {
  elements.list.innerHTML = "";

  todos.forEach(function (todo) {
    elements.list.appendChild(
      createTodoItem(todo)
    );
  });

  renderStatus();
}

async function loadTodosFromServer() {
  uiState.isLoading = true;
  uiState.loadingError = "";
  uiState.notice = "";
  renderStatus();

  try {
    const response = await fetch(API_URL);

    if (!response.ok) {
      throw new Error("목록을 불러오지 못했습니다.");
    }

    const data = await response.json();

    todos = data.map(function (todo) {
      return normalizeTodo(todo);
    });
  } catch (error) {
    uiState.loadingError = "목록을 불러오지 못했습니다. 다시 불러오기를 눌러보세요.";
  } finally {
    uiState.isLoading = false;
    renderTodos();
  }
}

async function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();
  const priority = elements.priorityInput.value;
  const dueDate = elements.dueDateInput.value;

  if (!text) {
    uiState.saveError = "할 일을 먼저 입력해보세요.";
    uiState.notice = "";
    renderStatus();
    elements.input.focus();
    return;
  }

  uiState.isCreating = true;
  uiState.saveError = "";
  uiState.notice = "";
  setFormDisabled(true);
  renderTodos();

  try {
    const response = await fetch(API_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        text: text,
        done: false,
        priority: normalizePriority(priority),
        dueDate: normalizeDueDate(dueDate)
      })
    });

    if (!response.ok) {
      throw new Error("저장 실패");
    }

    const createdTodo = normalizeTodo(await response.json());

    todos = [createdTodo].concat(todos);
    elements.form.reset();
    elements.priorityInput.value = PRIORITY_TYPES.MEDIUM;
    uiState.notice = "새 할 일을 서버에 저장했습니다.";
  } catch (error) {
    uiState.saveError = "저장에 실패했습니다. 다시 시도해 주세요.";
  } finally {
    uiState.isCreating = false;
    setFormDisabled(false);
    renderTodos();
    elements.input.focus();
  }
}

async function handleToggle(todo, nextDone) {
  if (uiState.isLoading || uiState.isCreating || isTodoSaving(todo.id)) {
    return;
  }

  const previousTodos = todos;
  const nextTodos = todos.map(function (currentTodo) {
    if (currentTodo.id === todo.id) {
      return {
        ...currentTodo,
        done: nextDone
      };
    }

    return currentTodo;
  });

  todos = nextTodos;
  uiState.saveError = "";
  uiState.notice = "";
  startTodoSaving(todo.id);
  renderTodos();

  try {
    const response = await fetch(API_URL + "/" + todo.id, {
      method: "PATCH",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        done: nextDone
      })
    });

    if (!response.ok) {
      throw new Error("완료 상태 저장 실패");
    }

    const savedTodo = normalizeTodo(await response.json());

    todos = todos.map(function (currentTodo) {
      if (currentTodo.id === savedTodo.id) {
        return savedTodo;
      }

      return currentTodo;
    });

    uiState.notice = "완료 상태를 서버에 반영했습니다.";
  } catch (error) {
    todos = previousTodos;
    uiState.saveError = "완료 상태 저장에 실패해서 원래 상태로 되돌렸습니다.";
  } finally {
    finishTodoSaving(todo.id);
    renderTodos();
  }
}

async function handleDelete(todoId) {
  if (uiState.isLoading || uiState.isCreating || isTodoSaving(todoId)) {
    return;
  }

  uiState.saveError = "";
  uiState.notice = "";
  startTodoSaving(todoId);
  renderTodos();

  try {
    const response = await fetch(API_URL + "/" + todoId, {
      method: "DELETE"
    });

    if (!response.ok) {
      throw new Error("삭제 실패");
    }

    todos = todos.filter(function (todo) {
      return todo.id !== String(todoId);
    });

    uiState.notice = "항목을 서버에서 삭제했습니다.";
  } catch (error) {
    uiState.saveError = "삭제에 실패했습니다. 다시 시도해 주세요.";
  } finally {
    finishTodoSaving(todoId);
    renderTodos();
  }
}

function bindEvents() {
  elements.form.addEventListener(
    "submit",
    handleSubmit
  );

  elements.retryLoadButton.addEventListener(
    "click",
    loadTodosFromServer
  );
}

bindEvents();
loadTodosFromServer();

이번 코드에서 가장 먼저 봐야 할 건 세 가지입니다.

  • loadingErrorretryLoadButton입니다. 초기 로드 실패를 그냥 메시지로만 끝내지 않고, 바로 다시 시도할 길을 같이 둡니다.
  • savingTodoIds입니다. 전체 목록을 잠그지 않고, 지금 저장 중인 항목만 흐리게 보여줄 수 있게 해줍니다.
  • handleToggle입니다. 이번 글에서 롤백 구조가 가장 잘 드러나는 지점입니다. 이전 상태를 백업하고, 먼저 반영하고, 실패하면 원래 상태로 되돌립니다.

중요한 건 이번에도 모든 저장을 같은 방식으로 만들지 않았다는 점입니다. 완료 체크는 화면을 먼저 바꿔도 상대적으로 위험이 작고, 실패했을 때도 되돌리기 비교적 쉽습니다. 반대로 삭제는 먼저 지워버렸다가 실패하면 사용자가 훨씬 크게 당황할 수 있어서, 이번 글에서는 여전히 응답 성공 후에만 제거하는 방식으로 두었습니다.

핵심 정리
이번 편의 JavaScript는 모든 동작을 빠르게 보이게 만드는 게 아니라, 어떤 동작은 먼저 바꾸고 어떤 동작은 기다리는 편이 더 안전한지 구분하는 구조를 만드는 데 있습니다.


Codex에게는 이렇게 나눠서 요청하면 된다

이 단계도 Codex에게 맡길 수 있습니다. 다만 이번 기능은 "서버 저장이 된다"를 넘어서 "실패와 롤백까지 설명한다"는 쪽으로 가기 때문에, 범위를 더 조심스럽게 잘라야 합니다. "실패 처리 붙여줘"라고만 던지면 AI가 삭제까지 낙관적으로 바꿔버리거나, 전체 구조를 크게 흔들 가능성이 큽니다.

전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.

지금 체크리스트 앱은 실제 API 저장 최소 버전까지 붙은 상태야.
이번에는 서버 저장 버전에 실패, 재시도, 롤백 흐름을 추가하고 싶어.
초기 로드 실패 시 다시 불러오기 버튼이 필요하고,
체크 완료는 먼저 반영했다가 실패 시 되돌리는 구조로 가고 싶어.
추가와 삭제는 아직 응답 성공 후 확정 방식으로 유지할 거야.
어떤 상태와 함수가 더 필요해지는지 먼저 설명해줘.

JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.

@script.js
현재 API 저장 최소 버전을 기준으로,
loadingError, retry 버튼,
savingTodoIds,
체크 완료 롤백 구조만 추가해줘.
삭제는 보수적으로 유지하고,
체크 완료만 이전 상태 백업 -> 먼저 반영 -> 실패 시 롤백으로 가고 싶어.
먼저 어떤 상태와 함수부터 추가할지 설명해줘.

HTML과 CSS만 정리하고 싶다면 이렇게 요청할 수 있습니다.

@index.html @style.css
지금 API 저장 버전에 상태 박스와 다시 불러오기 버튼을 추가할 거야.
저장 중인 항목은 살짝 흐려 보이게 하고,
초기 로드 실패 시에는 다시 불러오기 버튼이 자연스럽게 보이게 해줘.
전체 레이아웃은 크게 바꾸지 말아줘.

이런 식으로 나누면 AI가 범위를 크게 흔들 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 방식도 어려운 문장보다, 어떤 동작은 낙관적으로, 어떤 동작은 보수적으로 유지할지를 같이 적는 습관에 더 가깝습니다.

핵심 포인트
Codex에게 이 단계를 맡길 때는 "실패 처리 붙여줘"보다 "체크 완료만 롤백, 삭제는 보수적 유지, 로드 실패는 재시도 버튼 추가"처럼 경계를 같이 주는 편이 훨씬 안정적입니다.


직접 눌러보며 확인할 것

이번 기능은 코드를 읽는 것만큼이나 손으로 눌러보는 확인이 더 중요합니다. 특히 서버를 잠깐 꺼보거나, 네트워크를 일부러 불안정하게 상상하면서 봐야 차이가 더 선명해집니다. 이번 편에서는 아래 순서로 점검해보는 편이 좋습니다.

  • 페이지를 열었을 때 목록을 정상적으로 불러오는가
  • 서버가 꺼진 상태에서 페이지를 열면 다시 불러오기 버튼이 보이는가
  • 다시 불러오기 버튼을 눌렀을 때 재시도가 되는가
  • 체크 완료를 눌렀을 때 화면이 먼저 바뀌는가
  • 체크 저장이 실패하면 원래 상태로 되돌아가는가
  • 추가는 여전히 서버 응답이 성공했을 때만 목록에 들어오는가
  • 삭제는 여전히 서버 응답이 성공했을 때만 목록에서 사라지는가
  • 저장 중인 항목은 잠깐 흐려지고 버튼이 중복으로 눌리지 않게 되는가

기본 동작이 안정적이라면 체크포인트를 남기고 정리하면 됩니다.

git status
git add .
git commit -m "체크리스트 앱 서버 저장 재시도와 롤백 추가"

이번 단계에서 가장 중요한 건 "서버 저장이 좀 더 빨라 보인다"가 아닙니다. 실패했을 때도 어디로 돌아가는지 설명할 수 있는 구조가 생겼는지, 바로 그 점을 확인하는 데 있습니다.


마무리

이번 편에서 한 일은 거창하지 않습니다. 상태 박스 옆에 다시 불러오기 버튼 하나를 붙였고, 체크 완료는 먼저 바뀌었다가 실패하면 되돌아가게 만들었고, 삭제와 추가는 여전히 보수적으로 유지했을 뿐입니다. 그런데 바로 이 차이가 서버 저장 버전을 훨씬 더 실제 서비스에 가까운 쪽으로 움직이게 만듭니다.

중요한 건 여기서 "모든 동작을 한 가지 방식으로 처리하지 않았다"는 점입니다. 완료 체크는 먼저 반영해도 상대적으로 자연스럽지만, 삭제는 여전히 기다리는 편이 낫다는 구분이 생기기 시작하면 저장 구조도 한 단계 더 현실적으로 보이기 시작합니다. 이 감각이 생기면 이제부터는 단순히 API를 붙이는 수준을 넘어서, 어떤 상호작용이 더 빠르게 보여야 하고 어떤 상호작용은 더 보수적으로 다뤄야 하는지까지 생각하게 됩니다.

여기까지 오면 localStorage 중심 시즌과 서버 저장 입문 시즌이 꽤 또렷하게 구분됩니다. 버튼을 누르면 바로 끝나는 저장에서, 요청하고 기다리고 실패하면 되돌리는 저장으로 감각이 바뀌었기 때문입니다. 작은 체크리스트 앱이지만, 바로 이런 차이에서부터 실제 서비스 구조가 시작됩니다.