마지막 단계에서는 localStorage로 할 일 목록을 저장·복원해 새로고침해도 날아가지 않게 만들고, **fetch로 오늘의 한마디(#quote)**를 받아 와 할 일 앱을 완성합니다.
앞 레슨에서 만든 app.js 펼치기
// app.js (13-3까지)
'use strict';
let todos = [
{
id: 1,
text: '우유 사기',
done: false,
},
{
id: 2,
text: '복습하기',
done: true,
},
];todos.push({
id: 3,
text: '산책하기',
done: false,
});
let nextId = 4;
let filter = 'all';
const listEl = document
.querySelector(
'#todo-list',
);
const emptyEl = document
.querySelector('#empty');
const countEl = document
.querySelector('#count');const formEl = document
.querySelector(
'#todo-form',
);
const inputEl = document
.querySelector(
'#todo-input',
);
const filtersEl = document
.querySelector('#filters');
const clearEl = document
.querySelector(
'#clear-done',
);function getVis(list, f) {
if (f === 'active') {
return list.filter(
(t) => !t.done,
);
}
if (f === 'done') {
return list.filter(
(t) => t.done,
);
}
return list;
}function makeRow(t) {
const li = document
.createElement('li');
li.dataset.id =
String(t.id);
li.classList.toggle(
'done',
t.done,
);
const box = document
.createElement('input');
box.type = 'checkbox';
box.checked = t.done; const span = document
.createElement('span');
span.className = 'text';
span.textContent = t.text;
const btn = document
.createElement('button');
btn.type = 'button';
btn.className = 'delete';
btn.textContent = '×';
li.append(box, span, btn);
return li;
}function render() {
const visible = getVis(
todos,
filter,
);
listEl.textContent = '';
for (const t of visible) {
const r = makeRow(t);
listEl.append(r);
}
emptyEl.hidden =
visible.length > 0; const btns = filtersEl
.querySelectorAll(
'button',
);
for (const b of btns) {
const on =
b.dataset.filter ===
filter;
b.classList.toggle(
'active',
on,
);
} const left = todos.filter(
(t) => !t.done,
).length;
countEl.textContent =
`전체 ${todos.length}개` +
` 중 ${left}개 남음`;
}formEl.addEventListener(
'submit',
(event) => {
event.preventDefault();
const text =
inputEl.value.trim();
if (text === '') return;
const item = {
id: nextId++,
text,
done: false,
}; todos.push(item);
inputEl.value = '';
render();
},
);listEl.addEventListener(
'click',
(event) => {
const li = event.target
.closest('li');
if (!li) return;
const id = Number(
li.dataset.id,
);
const del = event.target
.closest('.delete');
if (del) {
todos = todos.filter(
(t) => t.id !== id,
); } else {
const item =
todos.find(
(t) => t.id === id,
);
if (item) {
item.done =
!item.done;
}
}
render();
},
);filtersEl.addEventListener(
'click',
(event) => {
const btn = event.target
.closest('button');
if (!btn) return;
filter =
btn.dataset.filter;
render();
},
);clearEl.addEventListener(
'click',
() => {
todos = todos.filter(
(t) => !t.done,
);
render();
},
);
render();1save()와 load()로 새로고침 대비하기
12단원 마지막 레슨에서 배운 localStorage를 연결합니다. 처음 방문해 저장된 값이 없을(raw === null) 때만 기본 세 줄을 보여 주고, 한 번이라도 저장된 뒤에는 저장된 배열을 꺼내 씁니다.
const INITIAL = [
{
id: 1,
text: '우유 사기',
done: false,
},
{
id: 2,
text: '복습하기',
done: true,
},
];
이어서 세 번째 기본 항목과 load(), save() 함수를 만듭니다.
INITIAL.push({
id: 3,
text: '산책하기',
done: false,
});
function load() {
const raw = localStorage
.getItem('todos');
if (raw === null) {
return INITIAL;
}
try {
return JSON.parse(raw);
} catch {
return [];
}
}
function save() {
localStorage.setItem(
'todos',
JSON.stringify(todos),
);
}
앱을 켤 때 let todos = load();로 불러오고, 새로 추가할 할 일의 nextId가 기존 번호와 겹치지 않도록 가장 큰 id에 1을 더한 값으로 잡습니다.
function getNextId(todos) {
let max = 0;
for (const t of todos) {
if (t.id > max) {
max = t.id;
}
}
return max + 1;
}
이어서 빈 배열과 두 항목 배열로 확인합니다.
const sample = [
{ id: 2 },
{ id: 5 },
];
console.log(getNextId([]));
console.log(getNextId(sample));
1
6그리고 todos 배열이 바뀌는 세 리스너(폼 submit, #todo-list 클릭, #clear-done 클릭)마다 render() 바로 앞에 save(); 한 줄을 넣습니다(필터 단추 클릭은 todos가 바뀌지 않으므로 save()를 부르지 않습니다).
2오늘의 한마디 받아 오기: async function loadQuote()
상단 #quote 문단에는 기본 문구 '오늘도 한 걸음씩.'이 적혀 있습니다. 앱이 켜질 때 dummyjson.com의 /quotes/random 주소로 fetch 요청을 보내 명언을 받아 #quote를 바꾸고, 인터넷이 끊겼거나 오류가 나면 catch에서 조용히 넘어가 기본 문구가 그대로 남게 합니다.
12단원 2레슨에서 배웠듯 <script src="app.js" defer>는 고전 스크립트라 맨 위에서 바로 await를 쓸 수 없으므로, 반드시 async function loadQuote()로 감싸서 호출합니다.
const quoteEl = document
.querySelector('#quote');
async function loadQuote() {
try {
const url =
'https://dummyjson.com' +
'/quotes/random';
const res =
await fetch(url);
if (!res.ok) return;
이어서 응답 본문 JSON을 꺼내 #quote에 넣습니다.
const data =
await res.json();
if (data.quote) {
quoteEl.textContent =
data.quote;
}
} catch {
// 실패 시 기본 문구 유지
}
}
loadQuote();
- 연결 시: 받아 온 명언 표시
- 실패 시: '오늘도 한 걸음씩.'render()를 먼저 불러 할 일 목록을 즉시 화면에 그린 뒤loadQuote()를 부르므로, 명언 응답이 늦게 와도 할 일 목록은 기다리지 않고 바로 쓸 수 있습니다.
3완성한 app.js 전체 코드
네 레슨을 모두 합친 완성 app.js입니다.
완성 app.js 전체 보기
// app.js — 완성본
'use strict';
const INITIAL = [
{
id: 1,
text: '우유 사기',
done: false,
},
{
id: 2,
text: '복습하기',
done: true,
},
];INITIAL.push({
id: 3,
text: '산책하기',
done: false,
});
function load() {
const raw = localStorage
.getItem('todos');
if (raw === null) {
return INITIAL;
}
try {
return JSON.parse(raw);
} catch { return [];
}
}
function save() {
localStorage.setItem(
'todos',
JSON.stringify(todos),
);
}function getNextId(list) {
let max = 0;
for (const t of list) {
if (t.id > max) {
max = t.id;
}
}
return max + 1;
}
let todos = load();
let nextId =
getNextId(todos);
let filter = 'all';const quoteEl = document
.querySelector('#quote');
const listEl = document
.querySelector(
'#todo-list',
);
const emptyEl = document
.querySelector('#empty');
const countEl = document
.querySelector('#count');const formEl = document
.querySelector(
'#todo-form',
);
const inputEl = document
.querySelector(
'#todo-input',
);
const filtersEl = document
.querySelector('#filters');
const clearEl = document
.querySelector(
'#clear-done',
);function getVis(list, f) {
if (f === 'active') {
return list.filter(
(t) => !t.done,
);
}
if (f === 'done') {
return list.filter(
(t) => t.done,
);
}
return list;
}function makeRow(t) {
const li = document
.createElement('li');
li.dataset.id =
String(t.id);
li.classList.toggle(
'done',
t.done,
);
const box = document
.createElement('input');
box.type = 'checkbox';
box.checked = t.done; const span = document
.createElement('span');
span.className = 'text';
span.textContent = t.text;
const btn = document
.createElement('button');
btn.type = 'button';
btn.className = 'delete';
btn.textContent = '×';
li.append(box, span, btn);
return li;
}function render() {
const visible = getVis(
todos,
filter,
);
listEl.textContent = '';
for (const t of visible) {
const r = makeRow(t);
listEl.append(r);
}
emptyEl.hidden =
visible.length > 0; const btns = filtersEl
.querySelectorAll(
'button',
);
for (const b of btns) {
const on =
b.dataset.filter ===
filter;
b.classList.toggle(
'active',
on,
);
} const left = todos.filter(
(t) => !t.done,
).length;
countEl.textContent =
`전체 ${todos.length}개` +
` 중 ${left}개 남음`;
}formEl.addEventListener(
'submit',
(event) => {
event.preventDefault();
const text =
inputEl.value.trim();
if (text === '') return;
const item = {
id: nextId++,
text,
done: false,
}; todos.push(item);
inputEl.value = '';
save();
render();
},
);listEl.addEventListener(
'click',
(event) => {
const li = event.target
.closest('li');
if (!li) return;
const id = Number(
li.dataset.id,
);
const del = event.target
.closest('.delete');
if (del) {
todos = todos.filter(
(t) => t.id !== id,
); } else {
const item =
todos.find(
(t) => t.id === id,
);
if (item) {
item.done =
!item.done;
}
}
save();
render();
},
);filtersEl.addEventListener(
'click',
(event) => {
const btn = event.target
.closest('button');
if (!btn) return;
filter =
btn.dataset.filter;
render();
},
);clearEl.addEventListener(
'click',
() => {
todos = todos.filter(
(t) => !t.done,
);
save();
render();
},
);async function loadQuote() {
try {
const url =
'https://dummyjson' +
'.com/quotes/random';
const res =
await fetch(url);
if (!res.ok) return;
const data =
await res.json();
if (data.quote) {
quoteEl.textContent =
data.quote;
} } catch {
// 기본 문구 유지
}
}
render();
loadQuote();확인 문제
loadQuote()가 서버에서 명언을 받아 오는 동안 네트워크가 느려 2초가 걸린다면, 그동안 할 일 목록(render())은 빈 화면으로 기다리나요, 아니면 바로 그려지나요?
정답
render();를 먼저 동기적으로 실행한 뒤 비동기 함수 loadQuote();를 불렀으므로, 할 일 목록은 기다리지 않고 즉시 그려지고 명언만 2초 뒤에 바뀝니다.
- 고전 스크립트인
app.js에서loadQuote라는async함수를 만들지 않고 파일 맨 아래에 바로const res = await fetch(url);를 적으면 어떤 오류가 나나요?
정답
고전 스크립트는 맨 위(최상위) await를 허용하지 않으므로 SyntaxError가 나서 app.js 전체가 실행되지 않습니다.
다음 걸음
자바스크립트 과목의 열세 단원을 모두 마쳤습니다! 여기서 배운 여섯 가지 장면은 다음 과목인 **타입스크립트(TypeScript)**에서 실행 전에 실수를 잡아 주는 타입 규칙으로 곧장 이어집니다.
- 객체: 이름 붙은 칸 묶음에서 없는 속성을 읽으면
undefined가 나오던 일 → 타입 검사기가 하는 일에서 실행 전에 오타를 잡는 법 - 글자를 수로: Number와 NaN에서
'2' + '3'이'23'이 되던 일 → 타입은 실행 전에 지워진다에서 타입 표기가 값을 바꾸지 않고 지켜 주는 원리 - 세기: 메뉴별로 모으기에서 빈 객체
{}에 실행 중에 키를 늘려 가던 일 → 키가 정해지지 않은 객체 — 인덱스 시그니처에서 동적 키 객체에 타입을 붙이는 법 - JSON: 객체와 글 사이에서
JSON.parse로 꺼낸 값을 믿고 쓰던 일 → 바깥 값 확인하기에서unknown과 타입 가드로 안전하게 검증하는 법 - 요소 찾기: querySelector에서 못 찾은
null이나input.value때문에 실행 중 오류가 나던 일 → DOM 요소의 타입에서Element | null을instanceof로 좁히는 법 - async와 await에서
Promise안의 값을 꺼내 쓰던 일 → Promise와 async의 타입에서 비동기 반환값을Promise<T>로 적는 법
또한 상태 배열을 바꿀 때마다 render()를 직접 부르던 흐름은 React 같은 UI 프레임워크로, fetch의 반대편에서 데이터를 돌려주는 서버는 Node 백엔드로, 완성한 index.html · style.css · app.js 세 파일을 인터넷 주소로 올리는 일은 정적 사이트 **배포(deploy)**로 이어집니다.