Skip to Content
WebJavaScriptWeb APIs08. fetch로 JSON 서버 동기화

이번 편의 결과물: 로컬 json-server를 실행하고, notes-app이 시작할 때 서버에서 메모를 받아오고 추가·수정·삭제도 서버에 반영합니다. 같은 서버를 보는 다른 탭에서도 새로고침하면 같은 목록이 보입니다. · 다루는 개념: fetch GET/POST/PATCH/DELETE, json-server 실행, 응답 상태 처리, 로딩 상태 표시

이 편에서 만드는 파일

notes-app/ ├── db.json (+ json-server용 시드 데이터) └── src/ ├── api.js (+ fetch로 json-server CRUD) ├── notes.js (~ setNotes 추가) └── main.js (~ hydrate·서버 동기화 호출 추가)

개념 정리

json-server

json-server는 JSON 파일 하나로 REST API 서버를 즉석에서 띄워 주는 도구입니다. db.json{ "notes": [...] }처럼 키를 두면, 그 키 이름으로 /notes 자원이 자동 생성됩니다.

요청동작
GET /notes전체 메모 목록 조회
POST /notes새 메모 생성(요청 본문을 그대로 저장하고 id 자동 부여)
PATCH /notes/:id메모 일부 필드 수정
DELETE /notes/:id메모 삭제

fetch 응답 상태 확인

fetch는 서버가 404나 500을 응답해도 예외를 던지지 않습니다. 오직 네트워크 자체가 끊겼을 때만 예외가 발생합니다. 그래서 응답을 받은 뒤 response.ok(상태 코드 200~299 여부)를 직접 확인해야 합니다.

const response = await fetch(url); if (!response.ok) { throw new Error(`요청 실패 (${response.status})`); }

로딩 상태

네트워크 요청은 즉시 끝나지 않습니다. 요청이 진행되는 동안 화면에 아무 안내가 없으면 사용자는 앱이 멈췄다고 오해합니다. 요청 시작 시 “불러오는 중” 표시를 하고, 끝나면 결과로 교체합니다.

실습

1. db.json 준비, json-server 실행

notes-app 폴더 바로 아래에 db.json을 만듭니다.

// db.json { "notes": [ { "id": "n1", "title": "Vite 프로젝트 만들기", "body": "npm create vite@latest notes-app -- --template vanilla 로 시작한다.", "tags": ["setup"], "pinned": true, "updatedAt": "2026-09-15T09:00:00+09:00" }, { "id": "n2", "title": "이벤트 위임 메모", "body": "목록 전체에 리스너 하나를 달고 event.target.closest 로 버튼을 찾는다.", "tags": ["dom", "events"], "pinned": false, "updatedAt": "2026-09-15T11:20:00+09:00" }, { "id": "n3", "title": "fetch 취소", "body": "AbortController 의 signal 을 fetch 옵션으로 넘기고 abort() 로 취소한다.", "tags": ["fetch"], "pinned": false, "updatedAt": "2026-09-16T08:00:00+09:00" }, { "id": "n4", "title": "IndexedDB 는 트랜잭션 단위", "body": "objectStore 접근은 transaction 안에서만 가능하다.", "tags": ["storage"], "pinned": false, "updatedAt": "2026-09-16T09:30:00+09:00" } ] }

새 터미널을 열어 json-server를 실행합니다(npm run dev를 실행 중인 터미널은 그대로 둡니다).

npx json-server db.json --port 3001

2. src/api.js 만들기

// src/api.js const BASE_URL = 'http://localhost:3001'; export async function fetchNotes() { const response = await fetch(`${BASE_URL}/notes`); if (!response.ok) throw new Error(`목록을 불러오지 못했습니다 (${response.status})`); return response.json(); } export async function createNoteOnServer(note) { const response = await fetch(`${BASE_URL}/notes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(note), }); if (!response.ok) throw new Error(`메모를 저장하지 못했습니다 (${response.status})`); return response.json(); } export async function updateNoteOnServer(id, patch) { const response = await fetch(`${BASE_URL}/notes/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); if (!response.ok) throw new Error(`메모를 수정하지 못했습니다 (${response.status})`); return response.json(); } export async function deleteNoteOnServer(id) { const response = await fetch(`${BASE_URL}/notes/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error(`메모를 삭제하지 못했습니다 (${response.status})`); }

src/notes.js에 서버에서 받은 배열로 상태를 통째로 교체하는 setNotes를 추가합니다.

// src/notes.js의 export 목록에 추가 export function setNotes(newNotes) { notes = newNotes; saveNotes(notes); }

src/main.js를 열어 시작 시 서버에서 목록을 받아오고, 추가·수정·삭제마다 서버에도 반영하도록 바꿉니다.

// src/main.js import { getNotes, addNote, removeNote, updateNote, setNotes } from './notes.js'; import { renderList } from './render.js'; import { fetchNotes, createNoteOnServer, updateNoteOnServer, deleteNoteOnServer, } from './api.js'; const listEl = document.querySelector('#note-list'); const form = document.querySelector('#note-form'); const titleInput = form.elements.title; const titleError = form.querySelector('[data-error-for="title"]'); let editingId = null; function render() { renderList(getNotes(), listEl, { editingId }); } async function hydrate() { listEl.innerHTML = '<li class="note-status">불러오는 중...</li>'; try { const serverNotes = await fetchNotes(); setNotes(serverNotes); } catch (error) { console.error(error); } render(); } titleInput.addEventListener('input', () => { titleInput.setCustomValidity(''); }); form.addEventListener('submit', async (event) => { event.preventDefault(); if (titleInput.value.trim().length === 0) { titleInput.setCustomValidity('제목을 입력하세요.'); } else if (titleInput.value.trim().length < 2) { titleInput.setCustomValidity('제목은 2자 이상 입력하세요.'); } else { titleInput.setCustomValidity(''); } if (!form.checkValidity()) { titleError.textContent = titleInput.validationMessage; return; } titleError.textContent = ''; const formData = new FormData(form); const draft = { title: formData.get('title').trim(), body: formData.get('body').trim(), tags: [], pinned: false, }; try { const saved = await createNoteOnServer(draft); addNote(saved); } catch (error) { console.error(error); addNote(draft); } form.reset(); render(); }); listEl.addEventListener('click', async (event) => { const actionEl = event.target.closest('[data-action]'); if (!actionEl) return; const card = actionEl.closest('.note-card'); if (!card) return; const id = card.dataset.id; const action = actionEl.dataset.action; if (action === 'delete') { try { await deleteNoteOnServer(id); } catch (error) { console.error(error); } removeNote(id); render(); } else if (action === 'edit') { editingId = id; render(); } else if (action === 'cancel') { editingId = null; render(); } else if (action === 'save') { const title = card.querySelector('.edit-title').value.trim(); const body = card.querySelector('.edit-body').value.trim(); try { await updateNoteOnServer(id, { title, body }); } catch (error) { console.error(error); } updateNote(id, { title, body }); editingId = null; render(); } }); hydrate();

hydrate는 서버 요청이 실패해도 catch에서 콘솔에만 기록하고 넘어갑니다. 07편에서 만든 localStorage 저장분이 이미 notes.js의 초기값으로 남아 있으므로, 서버 없이 실행해도 마지막으로 저장된 목록이 그대로 보입니다.

3. 실행

두 터미널에서 각각 실행합니다.

npx json-server db.json --port 3001
npm run dev

확인

  • 페이지를 열면 잠깐 “불러오는 중…”이 보인 뒤 db.json의 메모 4개가 나타납니다.
  • 새 메모를 추가하고 db.json 파일을 열어 보면 새 메모가 실제로 추가되어 있습니다(json-server가 파일에 반영합니다).
  • 같은 주소를 새 탭에서 열면 방금 추가한 메모까지 똑같이 보입니다.
  • json-server를 끈 상태로 새로고침하면 “불러오는 중…” 뒤 콘솔에 오류가 찍히고, localStorage에 남은 마지막 목록이 대신 보입니다.

직접 해보기

  1. fetchNotes 실패 시 listEl에 “서버에 연결하지 못했습니다. 저장된 메모를 표시합니다.” 문구를 잠깐 보여준 뒤 render()로 교체해 봅니다.
  2. createNoteOnServer가 실패했을 때 사용자에게 “오프라인 상태로 저장했습니다” 안내를 추가해 봅니다.

정답 보기

async function hydrate() { listEl.innerHTML = '<li class="note-status">불러오는 중...</li>'; try { const serverNotes = await fetchNotes(); setNotes(serverNotes); } catch (error) { console.error(error); listEl.innerHTML = '<li class="note-status">서버에 연결하지 못했습니다. 저장된 메모를 표시합니다.</li>'; } render(); }
try { const saved = await createNoteOnServer(draft); addNote(saved); } catch (error) { console.error(error); addNote(draft); window.alert('오프라인 상태로 저장했습니다.'); }

자주 하는 실수

증상원인고치는 법
404 응답인데도 .then이 정상 실행됨fetch는 HTTP 오류 상태에서도 예외를 던지지 않음응답을 받은 직후 response.ok를 확인해 직접 예외를 던진다
Failed to fetchjson-server를 실행하지 않았거나 포트가 다름npx json-server db.json --port 3001이 실행 중인지, 포트 번호가 api.js와 같은지 확인한다
메모 추가는 되는데 새로고침하면 사라짐createNoteOnServer 실패를 무시하고 addNote만 호출, 실제로는 로컬 상태만 바뀜db.json과 json-server 실행 상태를 확인하고, 실패 시 사용자에게 알린다

확인 문제

문제 14지선다
fetch가 404 응답을 받았을 때의 동작으로 옳은 것은?
문제 24지선다
json-server에서 db.json의 notes 키가 하는 역할은?
문제 34지선다
hydrate 함수가 시작할 때 listEl에 로딩 문구를 넣는 이유는?
문제 44지선다
notes.js의 setNotes 함수가 필요한 이유는?

참고 자료

Last updated on