본문으로 건너뛰기

상태를 화면으로

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

목차

13단원에서는 네 레슨에 걸쳐 **오늘 할 일 앱(app.js)**을 처음부터 끝까지 완성합니다. 첫 단계인 이 레슨에서는 할 일 배열 todos 하나를 기준으로 화면의 목록(#todo-list), 빈 안내(#empty), 남은 개수(#count) 세 곳을 한꺼번에 그리는 render() 함수를 만듭니다.

작업 폴더에 아래 index.html과 style.css를 준비하고 같은 폴더에 빈 app.js 파일을 만듭니다(index.html 받기 · style.css 받기). index.html 맨 아래 </body> 바로 위에는 <script src="app.js" defer> 태그를 넣어 둡니다.

완성 index.html 펼치기 (defer 스크립트 포함)
<!-- index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta
    name="viewport"
    content="width=device-width"
  />
  <title>오늘 할 일</title>
  <link
    rel="stylesheet"
    href="style.css"
  />
</head>
<body>
  <main class="app">
    <h1>오늘 할 일</h1>

    <p id="quote" class="quote">
      오늘도 한 걸음씩.
    </p>

    <form id="todo-form">
      <label
        class="sr-only"
        for="todo-input"
      >
        할 일
      </label>
      <input
        id="todo-input"
        type="text"
        placeholder="예: 장보기"
        autocomplete="off"
        maxlength="100"
      />
      <button type="submit">
        추가
      </button>
    </form>

    <div
      id="filters"
      class="filters"
    >
      <button
        type="button"
        data-filter="all"
        class="active"
      >
        전체
      </button>
      <button
        type="button"
        data-filter="active"
      >
        진행 중
      </button>
      <button
        type="button"
        data-filter="done"
      >
        완료
      </button>
    </div>

    <ul id="todo-list"></ul>

    <p
      id="empty"
      class="empty"
      hidden
    >
      할 일이 없습니다.
    </p>

    <footer class="footer">
      <p
        id="count"
        class="count"
      >
        전체 0개 중 0개 남음
      </p>
      <button
        type="button"
        id="clear-done"
        class="clear"
      >
        완료한 일 지우기
      </button>
    </footer>
  </main>
  <script
    src="app.js"
    defer
  ></script>
</body>
</html>
결과 화면탭: 오늘 할 일
완성 style.css 펼치기 — 이 파일은 읽지 않아도 됩니다
/* style.css */
* {
  box-sizing: border-box;
}

:root {
  --bg: #f5f5f7;
  --surface: #ffffff;
  --text: #1d1d1f;
  --muted: #6e6e73;
  --line: #d2d2d7;
  --accent: #005bb5;
  --danger: #d70015;
  color-scheme: light dark;
}

@media (
  prefers-color-scheme: dark
) {
  :root {
    --bg: #121820;
    --surface: #1d2633;
    --text: #f1f4f8;
    --muted: #a3adb8;
    --line: #3a4552;
    --accent: #9ac7ff;
    --danger: #ff6b6b;
  }
}

body {
  margin: 0;
  padding: 40px 16px;
  background: var(--bg);
  color: var(--text);
  font-family:
    system-ui,
    sans-serif;
  line-height: 1.6;
}

.app {
  max-width: 520px;
  margin: 0 auto;
  padding: 24px;
  background: var(--surface);
  border-radius: 12px;
  box-shadow:
    0 1px 3px rgb(0 0 0 / 0.1);
}

h1 {
  margin: 0 0 8px;
  font-size: 1.5rem;
}

.quote {
  margin: 0 0 20px;
  padding: 12px;
  background: var(--bg);
  border-radius: 8px;
  color: var(--muted);
  font-size: 0.875rem;
}

#todo-form {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}

#todo-input {
  flex: 1;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface);
  color: var(--text);
  font-size: 0.9375rem;
  font-family: inherit;
}

button {
  padding: 10px 16px;
  border: none;
  border-radius: 8px;
  background: var(--accent);
  color: var(--surface);
  font-size: 0.9375rem;
  font-family: inherit;
  cursor: pointer;
}

.filters {
  display: flex;
  gap: 6px;
  margin-bottom: 16px;
}

.filters button {
  padding: 6px 12px;
  background: var(--bg);
  color: var(--text);
  font-size: 0.875rem;
}

.filters button.active {
  background: var(--text);
  color: var(--surface);
}

#todo-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

#todo-list li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 4px;
  border-bottom:
    1px solid var(--line);
}

#todo-list li:last-child {
  border-bottom: none;
}

#todo-list input {
  width: 18px;
  height: 18px;
  cursor: pointer;
}

.text {
  flex: 1;
  cursor: pointer;
  overflow-wrap: anywhere;
}

.delete {
  padding: 4px 10px;
  background: transparent;
  color: var(--muted);
  font-size: 1.125rem;
  line-height: 1;
}

.done .text {
  color: var(--muted);
  text-decoration: line-through;
}

.empty {
  padding: 32px 0;
  color: var(--muted);
  text-align: center;
}

.footer {
  display: flex;
  align-items: center;
  justify-content:
    space-between;
  margin-top: 16px;
  padding-top: 16px;
  border-top:
    1px solid var(--line);
}

.count {
  margin: 0;
  color: var(--muted);
  font-size: 0.875rem;
}

.clear {
  padding: 6px 12px;
  background: transparent;
  color: var(--muted);
  font-size: 0.875rem;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

5단계 중 1단계. render()가 불리면 #todo-list를 비운 뒤 todos 배열의 세 항목을 <li>로 만들어 차례로 붙입니다.

1상태 배열 todos와 화면 요소 준비

할 일 한 줄은 { id, text, done } 속성을 가진 객체 하나로 나타냅니다. 처음 화면을 확인할 수 있도록 할 일 세 개를 todos 배열에 넣고, 화면에서 자주 쓸 요소들을 querySelector로 찾아 둡니다.

// app.js — 1. 상태와 요소 찾기
'use strict';

let todos = [
  {
    id: 1,
    text: '우유 사기',
    done: false,
  },
  {
    id: 2,
    text: ' 복습하기'.trim(),
    done: true,
  },
];

이어서 세 번째 항목을 넣고 목록(#todo-list), 빈 안내(#empty), 남은 개수(#count) 요소를 찾습니다.

todos.push({
  id: 3,
  text: '산책하기',
  done: false,
});

const listEl = document
  .querySelector('#todo-list');
const emptyEl = document
  .querySelector('#empty');
const countEl = document
  .querySelector('#count');

2한 줄 요소 만들기: makeRow(t)

할 일 객체 t 하나를 받아 <li data-id="..."> 안에 체크박스(<input type="checkbox">), 글자(<span class="text">), 삭제 단추(<button class="delete">)⁠를 조립해 돌려주는 도우미 함수를 만듭니다.

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;

이어서 글자 span과 삭제 단추 btn을 만들어 li에 붙입니다.

  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;
}
  • li.dataset.id = String(t.id): 다음 레슨에서 어느 줄이 눌렸는지 찾을 수 있게 <li data-id="1"> 속성을 심어 둡니다.
  • toggle('done', t.done)⁠과 box.checked = t.done: 완료된 할 일(t.done === true)이면 .done 취소선 클래스와 체크박스 체크 표시를 둘 다 켭니다.

3목록·빈 안내·개수를 한 번에 그리는 render()

상태 배열 todos와 화면은 저절로 맞물려 있지 않습니다. 그래서 render() 함수가 불릴 때마다 1) 목록을 비우고 새로 붙이기 → 2) 빈 안내 숨김/표시 → 3) 남은 개수 문구 계산을 한꺼번에 처리합니다.

function render() {
  listEl.textContent = '';
  for (const t of todos) {
    listEl.append(makeRow(t));
  }
  emptyEl.hidden =
    todos.length > 0;
  const left = todos.filter(
    (t) => !t.done,
  ).length;
  countEl.textContent =
    `전체 ${todos.length}개 중 ` +
    `${left}개 남음`;
}
render();
실행 결과
- [ ] 우유 사기      ×
- [✓] 복습하기       × (취소선)
- [ ] 산책하기       ×
전체 3개 중 2개 남음

emptyEl.hidden과 countEl.textContent 계산이 어떻게 돌아가는지 Node에서 그대로 확인해 봅니다.

const todos = [
  { id: 1, done: false },
  { id: 2, done: true },
  { id: 3, done: false },
];
const hide = todos.length > 0;
const left = todos.filter(
  (t) => !t.done,
).length;
console.log('hidden:', hide);
console.log(
  `전체 ${todos.length}개 중 ` +
    `${left}개 남음`,
);
실행 결과
hidden: true
전체 3개 중 2개 남음
  • 할 일이 1개라도 있으면 todos.length > 0이 true가 되어 emptyEl.hidden = true(빈 안내 숨김)가 되고, 모두 지워져 0개가 되면 false가 되어 '할 일이 없습니다.'가 나타납니다.

자주 틀리는 곳

  1. todos 배열만 만들어 두고 마지막에 render()를 부르지 않는다: 함수를 정의만 해 두면 화면은 처음 HTML 그대로 비어 있습니다. 파일 끝에서 render();를 한 번 불러야 첫 화면이 그려집니다.
  2. li에 .done 클래스만 붙이고 box.checked = t.done을 빼먹는다: .done 클래스는 글자에 취소선만 그어 줄 뿐 체크박스 네모 칸을 체크해 주지 않습니다. 두 속성을 함께 맞춰 줍니다.
  3. emptyEl.hidden 조건을 반대로 적는다: hidden 속성은 값이 true일 때 숨겨집니다. 할 일이 있을 때(todos.length > 0) 숨겨야 하므로 emptyEl.hidden에 todos.length > 0을 대입합니다.

확인 문제

  1. todos 배열에 항목이 3개 있고 그중 done: true인 항목이 2개일 때, todos.filter((t) => !t.done)⁠의 길이(.length)는 얼마인가요?
정답

아직 끝내지 않은(!t.done) 항목의 개수이므로 **1**입니다.

  1. 모든 할 일을 지워서 todos.length가 0이 되었습니다. 이때 emptyEl.hidden에 todos.length > 0을 대입하면 값은 true인가요, false인가요? 화면에는 어떻게 보이나요?
정답

0 > 0은 false이므로 emptyEl.hidden이 false가 되어, 숨겨져 있던 '할 일이 없습니다.' 문단이 화면에 나타납니다.

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

자바스크립트 목차로