여기까지 왔다면 지금 할 일 앱은 꽤 많은 걸 할 수 있는 상태입니다. 추가, 수정, 삭제, 검색, 정렬, 우선순위, 마감일까지 들어갔으니 작은 프로젝트치고는 제법 손에 익는 도구가 되었을 겁니다. 그런데 이쯤에서 또 한 번 흐름이 크게 바뀌는 지점이 옵니다. 바로 localStorage 중심 앱에서, API를 통한 서버 저장 감각으로 넘어가기 시작하는 구간입니다.

초보자 입장에서는 이 단계가 꽤 낯섭니다. 지금까지는 저장 버튼을 누르면 거의 바로 끝난다는 감각이 강했는데, 서버 저장이 들어오는 순간부터는 결과가 바로 확정되지 않을 수 있기 때문입니다. 잠깐 기다려야 하고, 실패할 수도 있고, 화면을 언제 바꿔야 하는지도 다시 생각해야 합니다.

이번 글에서는 실제 서버를 완전히 배우는 게 목적이 아닙니다. 그보다 더 중요한 건, 지금까지 만든 할 일 앱 기준으로 왜 localStorage 저장과 서버 저장이 체감상 다르게 느껴지는지를 실제로 손으로 붙여보는 데 있습니다. 그래서 이번 편은 현재 앱 전체를 한 번에 서버 버전으로 갈아엎지 않고, 서버 저장 감각이 가장 잘 드러나는 최소 흐름만 먼저 연결해보겠습니다.

이번 글에서 먼저 잡아둘 핵심
겉으로 보이는 변화 실제로 추가되는 것 가장 먼저 봐야 할 지점
앱이 서버에서 목록을 읽어온다 GET 요청과 로딩 상태가 생긴다 화면을 그리기 전에 먼저 서버 응답을 기다리는 구조를 본다
추가, 체크, 삭제가 localStorage 대신 API로 간다 POST, PATCH, DELETE 요청이 생긴다 요청 후 성공과 실패를 나눠서 처리하는 구조를 본다
앱은 단순한데 저장은 갑자기 조심스러워진다 isLoading, isSaving 같은 상태가 중요해진다 저장 중 잠금과 실패 메시지를 같이 보는 감각을 익힌다

이번 글의 핵심 한 줄
이번 단계의 핵심은 기능을 많이 옮기는 게 아니라, 서버에서 불러오고 저장하는 최소 흐름을 먼저 손으로 익히는 데 있습니다.


왜 앱 전체를 한 번에 서버 버전으로 갈아엎지 않는가

지금 할 일 앱에는 이미 검색, 필터, 수정, 순서 변경, 우선순위, 마감일까지 들어가 있습니다. 이걸 한 번에 서버 저장 버전으로 옮기면 보기에는 멋있을 수 있습니다. 하지만 초보자 기준에서는 오히려 핵심이 흐려질 가능성이 큽니다. 무엇이 저장 구조 때문이고, 무엇이 기존 기능 때문인지 구분이 어려워지기 때문입니다.

그래서 이번 편에서는 범위를 일부러 줄입니다. 지금까지 만든 앱의 감각은 유지하되, 서버 저장에서 반드시 봐야 하는 동작만 먼저 떼어보겠습니다.

  • 서버에서 목록을 불러옵니다.
  • 새 항목을 서버에 저장합니다.
  • 완료 체크를 서버에 반영합니다.
  • 삭제를 서버에 반영합니다.

즉, 이번 편은 "현재 앱 전체를 서버 앱으로 완성"하는 글이 아니라, 서버 저장 감각을 이해하기 위한 최소 API 실습 버전을 만드는 글이라고 보는 편이 맞습니다. 이 정도만 해도 localStorage와 서버 저장의 차이는 훨씬 선명하게 느껴집니다.

핵심 포인트
서버 저장을 처음 붙일 때는 앱 전체를 다 옮기기보다, 저장 감각이 가장 잘 드러나는 핵심 동작만 먼저 떼어보는 편이 훨씬 낫습니다.


이번 편에서는 mock API로 간다

지금 단계에서는 실제 백엔드 서버를 처음부터 직접 만드는 쪽보다, mock API를 먼저 붙여보는 편이 훨씬 낫습니다. 중요한 건 서버 프레임워크를 배우는 게 아니라, 브라우저 앱이 API와 데이터를 주고받는 감각을 익히는 것이기 때문입니다.

이번 편에서는 가장 가볍게 쓸 수 있는 방식으로, JSON Server를 써보겠습니다. 이 도구는 데이터 파일 하나로 GET, POST, PATCH, DELETE 요청을 바로 흉내 낼 수 있어서 초보자 실습에 잘 맞습니다.

폴더 구조는 아래처럼 두면 충분합니다.

todo-api/
  db.json
  public/
    index.html
    style.css
    script.js

여기서 중요한 건 두 가지입니다.

  • db.json이 API 데이터 역할을 합니다.
  • public 안의 HTML, CSS, JavaScript 파일은 브라우저에서 열 앱 역할을 합니다.

즉, 지금 단계에서는 서버를 따로 구축하는 게 아니라, 정적 파일과 REST API를 한 번에 가볍게 띄우는 구조를 먼저 경험하는 것이 핵심입니다.

핵심 포인트
실제 서버를 처음 붙일 때는 기술을 다 배우는 것보다, 앱이 API 주소와 실제로 대화하는 감각을 먼저 붙여보는 것이 훨씬 중요합니다.


10분 실습: db.json 만들고 서버 띄우기

먼저 데이터 파일부터 만들어보겠습니다. 지금까지의 할 일 앱 흐름을 이어가기 위해 text, done, priority, dueDate를 같이 들고 가는 편이 좋습니다.

{
  "todos": [
    {
      "id": "1",
      "text": "API 저장 흐름 이해하기",
      "done": false,
      "priority": "medium",
      "dueDate": ""
    },
    {
      "id": "2",
      "text": "서버에서 목록 불러오기 테스트",
      "done": true,
      "priority": "high",
      "dueDate": "2026-03-25"
    }
  ]
}

그다음 아래 명령으로 서버를 시작할 수 있습니다.

npx json-server db.json

이 단계에서 봐야 할 건 아주 단순합니다.

  • 로컬 서버가 하나 열린다
  • API 주소가 생긴다
  • public 안의 파일도 같이 열 수 있다

즉, 이번 실습은 아직 운영 서버를 다루는 게 아니라, 브라우저 앱이 실제 HTTP 요청을 보내고 응답을 받는 첫 감각을 잡는 단계입니다.

핵심 포인트
지금 단계에서는 서버가 복잡할 필요가 없습니다. GET, POST, PATCH, DELETE가 실제로 오가는지만 느껴져도 충분합니다.


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">
      <p class="eyebrow">API Save Practice</p>
      <h1>오늘 할 일</h1>
      <p class="description">
        localStorage 대신 실제 API를 통해 목록을 불러오고 저장하는 단계입니다.
      </p>

      <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>

      <p
        id="statusText"
        class="status-text"
      >
        서버와 연결할 준비가 되었습니다.
      </p>

      <p
        id="summaryText"
        class="summary"
      >
        전체 0개 · 남은 할 일 0개
      </p>

      <ul
        id="todoList"
        class="todo-list"
      ></ul>
    </section>
  </main>
</body>
</html>

이번 HTML 수정에서 핵심은 statusText입니다. 서버 저장이 들어오면 이제 화면은 단순히 "목록이 있느냐 없느냐"만 보여주면 부족합니다. 지금 불러오는 중인지, 저장 중인지, 실패했는지를 같이 보여줘야 합니다.

초보자에게는 이게 꽤 중요합니다. localStorage 버전에서는 없던 문구가 왜 필요한지, 바로 여기서부터 저장 감각이 달라지기 시작하기 때문입니다.

핵심 포인트
서버 저장 버전에서는 입력창보다도, 지금 무슨 상태인지 보여주는 자리가 훨씬 중요해집니다.


CSS는 로딩 상태와 저장 상태가 눈에 띄면 충분하다

이번 CSS의 핵심은 화려한 디자인이 아닙니다. 입력 폼이 너무 답답해 보이지 않게 정리하고, 저장 중이나 로딩 중에 버튼이 잠긴 상태가 바로 보이게 만드는 것이 더 중요합니다. 서버 저장이 들어오면 "상태 잠금"도 UI의 중요한 일부가 됩니다.

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);
}

.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 0 24px;
  line-height: 1.7;
  color: #374151;
}

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,
.delete-button {
  font: inherit;
  font-weight: 700;
  border-radius: 14px;
  cursor: pointer;
}

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

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

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

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

.summary {
  margin: 14px 0 0;
  line-height: 1.6;
  font-weight: 700;
  color: #111827;
}

.todo-list {
  list-style: none;
  margin: 24px 0 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-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;
  }

  .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에서 중요한 건 아주 단순합니다. 저장 중에는 실제로 버튼이 눌릴 것처럼 보이지 않아야 하고, 입력창도 동시에 여러 번 조작하는 느낌을 주지 않는 쪽이 더 낫습니다. 서버 저장으로 넘어오면 "상태 잠금"이 사용성에도 직접 영향을 주기 시작합니다.

핵심 포인트
서버 저장 버전의 CSS 핵심은 꾸미기보다 지금 잠깐 기다려야 하는 상태인지 눈에 보이게 만드는 것입니다.


JavaScript 수정: 서버에서 불러오기, 추가하기, 체크 반영, 삭제하기

이번 편의 핵심은 JavaScript입니다. 여기서부터 localStorage 감각과 API 저장 감각이 실제로 갈리기 시작합니다. 이번 예시에서는 최대한 단순하게 가겠습니다. 서버 응답이 성공했을 때만 화면을 최종 확정하고, 실패하면 에러 문구를 보여주는 가장 보수적인 첫 구조로 가겠습니다.

이번 JavaScript에서 먼저 봐야 할 것
이름 역할 왜 중요한가
API_URL todos 리소스와 통신할 주소 기준입니다 앱이 실제로 어디와 대화하는지 보이게 합니다
uiState isLoading, isSaving, error 같은 상태를 따로 들고 있습니다 서버 저장에서는 이 값들이 UI와 직접 연결되기 때문입니다
loadTodosFromServer 앱 시작 시 서버에서 목록을 불러옵니다 서버 저장 감각의 출발점이 되는 함수입니다
handleSubmit, handleToggle, handleDelete 추가, 완료 체크, 삭제를 각각 API 요청으로 보냅니다 저장 기능이 실제로 서버와 연결되는 핵심 지점입니다

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"),
  summaryText: document.querySelector("#summaryText"),
  list: document.querySelector("#todoList")
};

const uiState = {
  isLoading: false,
  isSaving: false,
  error: ""
};

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 setStatus(text) {
  elements.statusText.textContent = text;
}

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

function updateSummary() {
  const totalCount = todos.length;
  const remainingCount = todos.filter(function (todo) {
    return !todo.done;
  }).length;

  elements.summaryText.textContent =
    "전체 " +
    totalCount +
    "개 · 남은 할 일 " +
    remainingCount +
    "개";
}

function renderStatus() {
  if (uiState.isLoading) {
    setStatus("서버에서 목록을 불러오는 중입니다.");
    return;
  }

  if (uiState.isSaving) {
    setStatus("서버에 저장 중입니다.");
    return;
  }

  if (uiState.error) {
    setStatus(uiState.error);
    return;
  }

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

  setStatus("서버와 연결된 목록을 보고 있습니다.");
}

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 createTodoContent(todo) {
  const content = document.createElement("div");
  const meta = document.createElement("div");
  const dueDateBadge = createDueDateBadge(todo);

  content.className = "todo-content";
  meta.className = "todo-meta";

  meta.append(createPriorityBadge(todo));

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

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

  return content;
}

function createTodoItem(todo) {
  const item = document.createElement("li");
  const left = document.createElement("div");
  const actions = document.createElement("div");
  const checkbox = document.createElement("input");
  const deleteButton = document.createElement("button");

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

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

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

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

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

  left.append(
    checkbox,
    createTodoContent(todo)
  );

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

  return item;
}

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

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

  updateSummary();
  renderStatus();
}

async function loadTodosFromServer() {
  uiState.isLoading = true;
  uiState.error = "";
  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.error =
      "목록을 불러오지 못했습니다. 서버 상태를 확인해 주세요.";
  } 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.error = "할 일을 먼저 입력해보세요.";
    renderStatus();
    elements.input.focus();
    return;
  }

  uiState.isSaving = true;
  uiState.error = "";
  setFormDisabled(true);
  renderStatus();

  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.error = "";
    setStatus("새 할 일을 서버에 저장했습니다.");
  } catch (error) {
    uiState.error =
      "저장에 실패했습니다. 다시 시도해 주세요.";
  } finally {
    uiState.isSaving = false;
    setFormDisabled(false);
    renderTodos();
    elements.input.focus();
  }
}

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

  uiState.isSaving = true;
  uiState.error = "";
  renderStatus();

  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("상태 저장 실패");
    }

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

      return currentTodo;
    });

    setStatus("완료 상태를 서버에 반영했습니다.");
  } catch (error) {
    uiState.error =
      "완료 상태 저장에 실패했습니다.";
  } finally {
    uiState.isSaving = false;
    renderTodos();
  }
}

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

  uiState.isSaving = true;
  uiState.error = "";
  renderStatus();

  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);
    });

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

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

bindEvents();
loadTodosFromServer();

이번 코드에서 가장 먼저 봐야 할 부분은 네 군데입니다.

  • uiState입니다. localStorage 버전보다 로딩과 저장 중 상태가 훨씬 중요해졌습니다.
  • loadTodosFromServer입니다. 화면이 먼저 열리는 게 아니라, 서버 응답을 받아와서 목록을 채우는 흐름으로 바뀝니다.
  • handleSubmit, handleToggle, handleDelete입니다. 이제 저장 동작마다 fetch 요청과 성공/실패 분기가 들어갑니다.
  • normalizeTodo입니다. 서버에서 온 값도 지금 앱이 기대하는 구조로 한 번 맞춰놓는 습관이 중요해집니다.

이번 단계에서는 일부러 가장 보수적인 흐름을 택했습니다. 완료 체크도, 삭제도, 추가도 먼저 화면을 바꾸지 않고 서버 응답이 성공했을 때만 최종 반영합니다. 이건 느려 보여서가 아니라, 첫 API 버전에서는 이쪽이 훨씬 설명하기 쉽기 때문입니다. 지금은 빠르게 보이는 것보다 언제 확정되는지 분명한 구조가 더 중요합니다.

또 하나 중요한 건 앱 전체를 다 옮기지 않았다는 점입니다. 지금은 검색, 정렬, 수정까지 한 번에 다 서버 저장 버전으로 갈아엎지 않았습니다. 그 대신 목록 불러오기와 저장 흐름을 먼저 손으로 붙여보는 데 집중했습니다. 이쪽이 초보자에게 훨씬 더 낫습니다.

핵심 정리
이번 편의 JavaScript는 기능이 많아진 게 아니라, 저장 대상이 브라우저 안에서 서버로 바뀌면서 로딩, 저장 중, 실패 상태까지 같이 봐야 하는 구조로 바뀐 것입니다.


직접 눌러보며 테스트하기

이번 기능은 코드보다도 손으로 눌러보는 확인이 더 중요합니다. localStorage에서는 잘 되던 감각이 서버 버전에서는 어디서 달라지는지 직접 봐야 하기 때문입니다. 이번 편에서는 아래 순서로 점검해보는 편이 좋습니다.

  • 페이지를 열자마자 서버에서 목록을 불러오는가
  • 새 항목을 추가했을 때 새로고침 뒤에도 남아 있는가
  • 체크 박스를 눌렀을 때 완료 상태가 서버 기준으로 유지되는가
  • 삭제 버튼을 눌렀을 때 항목이 서버에서 실제로 지워지는가
  • 저장 중에는 같은 버튼을 여러 번 누르기 어렵게 잠기는가
  • 서버를 끄고 시도했을 때 실패 메시지가 분명하게 보이는가

기본 동작이 안정적이라면 이번 실습은 성공이라고 봐도 됩니다. 여기서 중요한 건 "API가 붙었다"는 사실보다, 저장과 화면 반영이 같은 타이밍에 끝나지 않는다는 걸 실제로 확인하는 데 있습니다.

핵심 포인트
이번 단계의 테스트는 결과보다도 로딩, 저장 중, 실패가 실제로 어떤 느낌인지 직접 눌러보는 데 의미가 있습니다.


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

이 단계도 Codex에게 맡길 수 있습니다. 다만 지금은 기능 하나를 더 넣는 게 아니라 저장 감각 자체가 달라지는 구간이기 때문에, 범위를 훨씬 조심스럽게 잘라야 합니다. "API 저장 붙여줘"라고만 던지면 AI가 검색, 정렬, 수정까지 한꺼번에 다시 엮어버릴 수 있습니다.

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

지금 localStorage 기반 할 일 앱이 있어.
이번에는 앱 전체를 한 번에 옮기지 말고,
목록 불러오기, 새 항목 추가, 완료 체크, 삭제까지만
실제 API 저장 버전으로 바꾸고 싶어.
정적 파일은 그대로 두고 fetch 기반으로 연결할 거야.
먼저 어떤 파일과 함수가 가장 크게 바뀌는지부터 설명해줘.

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

@script.js
현재 localStorage 저장 흐름을 걷어내고,
GET으로 목록 불러오기,
POST로 추가,
PATCH로 완료 체크,
DELETE로 삭제하는 최소 API 버전으로 바꿔줘.
isLoading, isSaving, error 상태를 따로 두고
성공 후에만 목록을 다시 반영하는 보수적인 구조로 가고 싶어.
먼저 어떤 상태와 함수가 새로 필요한지 설명해줘.

환경 준비 쪽만 묻고 싶다면 이렇게 요청할 수도 있습니다.

가장 가볍게 mock API를 띄우고 싶어.
db.json 하나와 public 폴더 구조로
정적 파일과 REST API를 같이 여는 방식 기준으로,
폴더 구조와 실행 명령만 아주 간단하게 정리해줘.

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

핵심 포인트
Codex에게 이 단계를 맡길 때는 "API 저장 붙여줘"보다 목록 불러오기, 추가, 체크, 삭제까지만처럼 경계를 같이 주는 편이 훨씬 낫습니다.


마무리

이번 편에서 만든 건 거창한 서버 앱이 아닙니다. 여전히 작은 할 일 앱이고, 로컬에서 가볍게 띄운 mock API와 연결했을 뿐입니다. 그런데도 체감은 꽤 달라집니다. 이제는 저장이 브라우저 안에서 바로 끝나는 게 아니라, 요청을 보내고 기다린 뒤 성공하면 확정되는 흐름으로 바뀌었기 때문입니다.

특히 이번 단계는 localStorage 시즌과 서버 저장 시즌 사이를 잇는 첫 구간이라고 볼 수 있습니다. 여기서 중요한 건 기능이 많아지는 게 아니라, 저장 감각이 달라진다는 점입니다. 불러오기, 저장 중 잠금, 실패 문구, 성공 후 반영 같은 흐름이 눈에 들어오기 시작하면 이후 구조도 훨씬 덜 낯설게 느껴집니다.

즉, 이번 편의 핵심은 서버를 다 배웠다는 데 있지 않습니다. 오히려 같은 저장 버튼인데 왜 로딩과 실패 상태가 같이 필요해지는지를 손으로 확인했다는 데 더 가깝습니다. 그 감각이 붙으면, 다음 단계의 서버 저장도 훨씬 덜 막막해집니다.