JS 8.1 · 8부. 종합 프로젝트

할 일 관리 앱

DOM 과 배열, 저장소를 모아 하나의 앱을 만듭니다.

예상 학습 시간 25분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 실전
요구사항

무엇을 만드나

할 일을 적어 넣고, 마친 것을 표시하고, 지우는 화면을 만듭니다. 새로 고쳐도 남습니다.

화면에 있을 것

  • 입력 칸과 추가 단추
  • 할 일 목록 — 항목마다 글자와 지우기 단추
  • 남은 개수 표시

지켜야 할 규칙

  • 빈칸만 넣으면 더하지 않습니다.
  • 글자를 누르면 마친 표시가 켜지고 꺼집니다.
  • 지우기를 누르면 그 항목만 사라집니다.
  • 바뀔 때마다 저장하고, 열 때 불러옵니다.

4부(DOM · 이벤트 · 위임) · 3부(배열) · 7.4(저장소)를 함께 사용합니다.

단계별 구현

1단계 — 데이터를 먼저 정합니다

화면부터 만들면 나중에 헝클어집니다. 무엇을 담을지부터 정하고, 화면은 그것을 그리는 일로 둡니다.

script.js
// 할 일 하나는 이렇게 생겼습니다
let todos = [
	{ id: 1, text: "연필 사기", done: false },
	{ id: 2, text: "책 읽기", done: true }
];

console.log(todos.length);
console.log(todos.filter(todo => !todo.done).length);
console.log(todos[0].text);
출력
2 1 연필 사기

id 를 두는 까닭이 있습니다. 글자가 같은 할 일이 둘일 수 있어, 지울 때 무엇을 지울지 가릴 것이 필요합니다.

단계별 구현

2단계 — 데이터를 화면으로

그리는 일을 함수 하나에 모읍니다. 데이터가 바뀔 때마다 이 함수만 다시 부르면 됩니다(4.6).

script.js
const list = document.querySelector("#list");
const count = document.querySelector("#count");

let todos = [
	{ id: 1, text: "연필 사기", done: false },
	{ id: 2, text: "책 읽기", done: true }
];

function render() {
	list.replaceChildren();

	todos.forEach(todo => {
		const li = document.createElement("li");
		li.dataset.id = todo.id;
		if (todo.done) li.classList.add("done");

		const text = document.createElement("span");
		text.className = "text";
		text.textContent = todo.text;

		const del = document.createElement("button");
		del.textContent = "지우기";

		li.append(text, del);
		list.append(li);
	});

	count.textContent = `남은 일 ${todos.filter(t => !t.done).length}개`;
}

render();

console.log(list.children.length);
console.log(count.textContent);
출력
2 남은 일 1개

li.dataset.id 에 번호를 붙여 두었습니다. 나중에 누른 항목이 어느 데이터인지 찾을 때 씁니다(4.3).

단계별 구현

3단계 — 더하기

script.js
const input = document.querySelector("#todo");
const add = document.querySelector("#add");
const list = document.querySelector("#list");

let todos = [];
let nextId = 1;

function render() {
	list.replaceChildren();

	todos.forEach(todo => {
		const li = document.createElement("li");
		li.textContent = todo.text;
		list.append(li);
	});
}

add.addEventListener("click", () => {
	const text = input.value.trim();
	if (text === "") return;

	todos.push({ id: nextId++, text: text, done: false });
	input.value = "";

	render();
});

// 코드로 두 번 눌러 봅니다
input.value = "연필 사기";
add.click();

input.value = "   ";
add.click();

console.log(todos.length);
console.log(list.children.length);
출력
1 1

빈칸만 넣은 두 번째는 더해지지 않았습니다. trim 뒤에 빈 글자면 먼저 끝냅니다(7.3 의 조기 반환).

단계별 구현

4단계 — 토글과 지우기

항목은 계속 늘어나므로 목록 하나에만 이벤트를 붙입니다(4.7 의 위임).

script.js
const list = document.querySelector("#list");

let todos = [
	{ id: 1, text: "연필 사기", done: false },
	{ id: 2, text: "책 읽기", done: false }
];

function render() {
	list.replaceChildren();

	todos.forEach(todo => {
		const li = document.createElement("li");
		li.dataset.id = todo.id;
		if (todo.done) li.classList.add("done");

		const text = document.createElement("span");
		text.className = "text";
		text.textContent = todo.text;

		const del = document.createElement("button");
		del.textContent = "지우기";

		li.append(text, del);
		list.append(li);
	});
}

list.addEventListener("click", event => {
	const li = event.target.closest("li");
	if (!li) return;

	const id = Number(li.dataset.id);

	if (event.target.closest("button")) {
		todos = todos.filter(todo => todo.id !== id);
	} else if (event.target.closest(".text")) {
		const found = todos.find(todo => todo.id === id);
		found.done = !found.done;
	}

	render();
});

render();

// 첫 항목의 글자를 누르고, 둘째 항목을 지웁니다
list.querySelector(".text").click();
list.querySelectorAll("button")[1].click();

console.log(todos.length);
console.log(todos[0].done);
출력
1 true

데이터를 고치고 다시 그립니다. 화면을 직접 고치지 않습니다 — 데이터와 화면이 어긋날 일이 없어집니다.

dataset.id 는 글자이므로 Number 로 바꿔 비교합니다(4.3).

단계별 구현

5단계 — 저장하고 불러오기

script.js
const KEY = "todos";

function save(todos) {
	localStorage.setItem(KEY, JSON.stringify(todos));
}

function load() {
	const raw = localStorage.getItem(KEY);
	if (raw === null) return [];

	try {
		return JSON.parse(raw);
	} catch {
		return [];
	}
}

console.log(load());

save([{ id: 1, text: "연필 사기", done: false }]);

console.log(load().length);
console.log(load()[0].text);
출력
[] 1 연필 사기

처음에는 아무것도 없으므로 빈 배열입니다. 망가진 값이 들어 있어도 try...catch 로 빈 배열이 됩니다(7.4).

이 화면에서는 다시 실행하면 비어 있습니다. 실제 페이지에서는 새로 고쳐도 남습니다.

완성 코드

모두 합치기

실행한 뒤 미리보기 탭에서 직접 더하고, 누르고, 지워 보세요.

script.js
const KEY = "todos";

const input = document.querySelector("#todo");
const add = document.querySelector("#add");
const list = document.querySelector("#list");
const count = document.querySelector("#count");

function load() {
	const raw = localStorage.getItem(KEY);
	if (raw === null) return [];

	try { return JSON.parse(raw); } catch { return []; }
}

let todos = load();
let nextId = todos.reduce((max, t) => Math.max(max, t.id), 0) + 1;

function save() {
	localStorage.setItem(KEY, JSON.stringify(todos));
}

function render() {
	list.replaceChildren();

	todos.forEach(todo => {
		const li = document.createElement("li");
		li.dataset.id = todo.id;
		if (todo.done) li.classList.add("done");

		const text = document.createElement("span");
		text.className = "text";
		text.textContent = todo.text;

		const del = document.createElement("button");
		del.textContent = "지우기";

		li.append(text, del);
		list.append(li);
	});

	count.textContent = `남은 일 ${todos.filter(t => !t.done).length}개`;
}

function update() {
	save();
	render();
}

add.addEventListener("click", () => {
	const text = input.value.trim();
	if (text === "") return;

	todos.push({ id: nextId++, text: text, done: false });
	input.value = "";

	update();
});

list.addEventListener("click", event => {
	const li = event.target.closest("li");
	if (!li) return;

	const id = Number(li.dataset.id);

	if (event.target.closest("button")) {
		todos = todos.filter(todo => todo.id !== id);
	} else if (event.target.closest(".text")) {
		const found = todos.find(todo => todo.id === id);
		found.done = !found.done;
	}

	update();
});

render();

// 확인용으로 하나 넣어 둡니다
input.value = "연필 사기";
add.click();

console.log(todos.length);
console.log(count.textContent);
출력
1 남은 일 1개

update 하나가 저장과 그리기를 함께 합니다. 두 곳에서 따로 부르면 한쪽을 빠뜨립니다(7.3).

nextId 는 저장된 것 가운데 가장 큰 번호의 다음 값입니다. 1 부터 다시 세면 이미 있는 항목과 번호가 부딪힙니다.

확장 과제

더 해 볼 것

1. 엔터로 더하기 난이도 하

입력 칸에서 엔터를 눌러도 더해지게 하세요.

입력 칸에 keydown 을 걸고 event.key === "Enter" 일 때 추가 단추와 같은 일을 합니다(4.5). 더하는 코드를 함수로 빼 두면 두 곳에서 부를 수 있습니다.
2. 걸러 보기 난이도 중

전체 · 남은 것 · 마친 것을 고르는 단추 셋을 두고, 고른 것만 보이게 하세요.

지금 무엇을 고르고 있는지 담을 변수를 하나 두고, render 안에서 그리기 전에 filter 를 겁니다(3.3). 데이터는 그대로 두고 그리는 것만 거릅니다.
3. 마감일 붙이기 난이도 상

할 일에 마감일을 붙이고, 오늘이 지난 것은 눈에 띄게 하세요.

날짜는 "2026-09-07" 같은 글자로 담고, 견줄 때만 new Date() 로 바꿉니다. JSON 에는 날짜가 그대로 담기지 않습니다(3.8).
요약

이 프로젝트에서 배운 것

  • 화면보다 데이터를 먼저 정합니다.
  • 그리는 일을 함수 하나에 모으고, 바뀔 때마다 그것만 다시 부릅니다.
  • 화면을 직접 고치지 않고 데이터를 고친 뒤 다시 그립니다.
  • 늘어나는 항목은 부모에 이벤트를 한 번만 붙입니다.
  • 저장과 그리기를 한 함수로 묶으면 한쪽을 빠뜨리지 않습니다.