본문으로 건너뛰기

저장과 오늘의 한마디

자바스크립트 목차할 일 앱 만들기레슨 76 / 76

목차

마지막 단계에서는 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();

4단계 중 1단계. 앱이 시작되면 먼저 todos = load()와 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();

확인 문제

  1. loadQuote()가 서버에서 명언을 받아 오는 동안 네트워크가 느려 2초가 걸린다면, 그동안 할 일 목록(render())은 빈 화면으로 기다리나요, 아니면 바로 그려지나요?
정답

render();를 먼저 동기적으로 실행한 뒤 비동기 함수 loadQuote();를 불렀으므로, 할 일 목록은 기다리지 않고 즉시 그려지고 명언만 2초 뒤에 바뀝니다.

  1. 고전 스크립트인 app.js에서 loadQuote라는 async 함수를 만들지 않고 파일 맨 아래에 바로 const res = await fetch(url);⁠를 적으면 어떤 오류가 나나요?
정답

고전 스크립트는 맨 위(최상위) await를 허용하지 않으므로 SyntaxError가 나서 app.js 전체가 실행되지 않습니다.

다음 걸음

자바스크립트 과목의 열세 단원을 모두 마쳤습니다! 여기서 배운 여섯 가지 장면은 다음 과목인 **타입스크립트(TypeScript)**에서 실행 전에 실수를 잡아 주는 타입 규칙으로 곧장 이어집니다.

또한 상태 배열을 바꿀 때마다 render()를 직접 부르던 흐름은 React 같은 UI 프레임워크로, fetch의 반대편에서 데이터를 돌려주는 서버는 Node 백엔드로, 완성한 index.html · style.css · app.js 세 파일을 인터넷 주소로 올리는 일은 정적 사이트 **배포(deploy)**로 이어집니다.

끝까지 내려오면 저절로 표시됩니다. 이 기기에만 저장합니다.

자바스크립트 목차로