이번 편의 결과물: 로컬 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 30012. 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 3001npm run dev확인
- 페이지를 열면 잠깐 “불러오는 중…”이 보인 뒤
db.json의 메모 4개가 나타납니다. - 새 메모를 추가하고
db.json파일을 열어 보면 새 메모가 실제로 추가되어 있습니다(json-server가 파일에 반영합니다). - 같은 주소를 새 탭에서 열면 방금 추가한 메모까지 똑같이 보입니다.
- json-server를 끈 상태로 새로고침하면 “불러오는 중…” 뒤 콘솔에 오류가 찍히고,
localStorage에 남은 마지막 목록이 대신 보입니다.
직접 해보기
fetchNotes실패 시listEl에 “서버에 연결하지 못했습니다. 저장된 메모를 표시합니다.” 문구를 잠깐 보여준 뒤render()로 교체해 봅니다.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 fetch | json-server를 실행하지 않았거나 포트가 다름 | npx json-server db.json --port 3001이 실행 중인지, 포트 번호가 api.js와 같은지 확인한다 |
| 메모 추가는 되는데 새로고침하면 사라짐 | createNoteOnServer 실패를 무시하고 addNote만 호출, 실제로는 로컬 상태만 바뀜 | db.json과 json-server 실행 상태를 확인하고, 실패 시 사용자에게 알린다 |