JavaScript 투두리스트에서 상태와 이벤트 위임으로 완료·삭제를 어떻게 처리하나요?
투두리스트는 작업 데이터를 바꾼 뒤 그 상태로 목록과 개수를 다시 그립니다. 부모에 클릭 리스너를 한 번 등록하고 항목 id·동작을 읽으면 새로 그린 버튼도 완료·삭제 처리를 할 수 있습니다.
공식 문서 참고: MDN: 이벤트 버블링과 부모 요소의 이벤트 위임 · MDN: data-* 속성과 dataset 문자열 값 · OWASP: XSS 문맥별 인코딩과 안전한 DOM 출력
독립 실습: 상태와 이벤트 위임
JavaScript 투두리스트 만들기: 상태와 이벤트 위임으로 완성하기 · 독립 실습
작업을 추가하고 완료 전환·삭제하여 목록과 개수 변화를 확인하세요.
예제 크게 보기 (새 탭)독자 질문: 목록 항목을 새로 그릴 때 리스너를 다시 붙이지 않고 완료·삭제를 어떻게 처리하는가?
index.html과 app.js를 같은 폴더에 저장하고 브라우저에서 여세요. 추가 패키지는 없습니다.
index.html 전체
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>작업 목록</title><script src="./app.js" defer></script></head>
<body>
<form id="task-form"><label for="task-title">작업 이름</label><input id="task-title" required><button>추가</button></form>
<p id="summary" aria-live="polite"></p>
<ul id="task-list"></ul>
</body>
</html>
app.js 전체
let tasks = [{ id: '1', title: 'JavaScript 공부', completed: false }];
let nextId = 2;
const form = document.querySelector('#task-form');
const input = document.querySelector('#task-title');
const list = document.querySelector('#task-list');
const summary = document.querySelector('#summary');
function render() {
list.replaceChildren();
for (const task of tasks) {
const item = document.createElement('li');
item.dataset.taskId = task.id;
const label = document.createElement('span');
label.textContent = task.title;
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.dataset.action = 'toggle';
toggle.textContent = task.completed ? '미완료로 변경' : '완료';
const remove = document.createElement('button');
remove.type = 'button';
remove.dataset.action = 'delete';
remove.textContent = '삭제';
item.append(label, toggle, remove);
list.append(item);
}
summary.textContent = `전체 ${tasks.length}개 · 완료 ${tasks.filter((task) => task.completed).length}개`;
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const title = input.value.trim();
if (!title) return;
tasks = [...tasks, { id: String(nextId++), title, completed: false }];
input.value = '';
render();
});
list.addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
const item = button?.closest('[data-task-id]');
if (!item || !list.contains(item)) return;
if (button.dataset.action === 'toggle') {
tasks = tasks.map((task) => task.id === item.dataset.taskId ? { ...task, completed: !task.completed } : task);
} else if (button.dataset.action === 'delete') {
tasks = tasks.filter((task) => task.id !== item.dataset.taskId);
}
render();
});
render();
예상 결과: 처음에는 전체 1개 · 완료 0개입니다. ‘문제 풀기’를 추가하면 전체 2개가 되고 첫 항목 완료 버튼을 누르면 완료 1개가 됩니다. 새 항목 삭제 후 전체 1개가 됩니다.
직접 풀어볼 문제
완료한 항목을 다시 미완료로 바꿔 완료 수가 0으로 돌아오는지 확인하세요. 목록의 빈 곳을 클릭해도 오류가 없어야 합니다.
검증 상태: 독립 파일의 실제 브라우저 클릭·키보드 조작은 미실행·미확인입니다. 다음 회차는 URL의 status·q 복원을 다룹니다.
상태가 먼저이고 DOM은 결과입니다
다음과 같은 흐름을 사용합니다.
사용자 이벤트
→ store 상태 변경
→ 현재 상태 읽기
→ renderTasks()
→ DOM 갱신
DOM에서 체크된 항목을 다시 읽어 상태를 추측하는 방식으로 만들지 않습니다.

작업 항목에 id 연결하기
<li data-task-id="12">
...
</li>
data-*는 DOM 요소와 JavaScript 데이터를 연결할 때 사용할 수 있습니다.
이벤트 위임
작업이 추가될 때마다 버튼마다 새 이벤트 리스너를 등록할 필요는 없습니다.
taskList.addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
if (!button) return;
});
클릭 이벤트가 부모 요소까지 올라오는 버블링을 이용합니다.
액션과 id 읽기
const item = button.closest('[data-task-id]');
const id = item.dataset.taskId;
const action = button.dataset.action;
이제 toggle, delete처럼 액션에 따라 store 함수를 호출할 수 있습니다.
HTML 문자열을 만들 때 주의
사용자 입력을 innerHTML 문자열에 그대로 넣으면 의도하지 않은 HTML이 해석될 수 있습니다. 이 실습에서는 작업 제목을 textContent로 넣어 텍스트로 처리합니다.
자주 발생하는 오류
렌더링할 때 이벤트 리스너를 계속 추가합니다
renderTasks() 내부에서 매번 각 버튼에 listener를 붙이면 중복 실행 가능성이 커집니다. 부모 목록에 한 번만 이벤트 위임을 등록합니다.
id가 문자열이라는 점을 잊습니다
dataset에서 읽은 값은 문자열입니다. store의 id가 숫자라면 Number()로 맞춥니다.
DOM을 직접 바꾼 뒤 store를 바꾸지 않습니다
삭제 버튼에서 <li>만 제거하면 다시 렌더링했을 때 되살아납니다. 먼저 store를 변경하고 다시 렌더링합니다.
학습 완료 체크리스트
- 상태 → 렌더링 흐름을 설명할 수 있습니다.
- 이벤트 위임을 사용할 수 있습니다.
data-*로 id와 액션을 전달할 수 있습니다.- CRUD 후 화면을 다시 그릴 수 있습니다.
- 필터와 검색 조건을 상태 함수에 전달할 수 있습니다.
독립 실습은 여기서 마칠 수 있습니다. 아래 선택 실습은 TaskFlow 프로젝트 파일로 진행합니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 JavaScript로 화면에 동작을 붙입니다.
작업 관리 데모 section에 필터·검색 컨트롤을 추가하고 다음 JavaScript 파일을 적용합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
JavaScript 투두리스트 만들기: 상태와 이벤트 위임으로 완성하기 · 단계별 TaskFlow
작업 추가·완료 전환·삭제와 현재 단계의 필터/출력을 확인하세요.
예제 크게 보기 (새 탭)변경 설명과 부분 예제를 읽은 뒤, 시작·완료 파일과 적용 순서에서 필요한 선언·import·화면 연결을 포함한 전체 파일을 확인하세요. 이번 단계의 변경 이유와 적용 위치도 함께 확인합니다.
시작본과 이번 변경
JavaScript DOM 폼 만들기: 입력 검증과 접근성 처리의 완료 체크포인트를 시작본으로 사용합니다.
index.html의 작업 폼 뒤에 필터·검색 컨트롤을 추가하고 task-store.js의 filterTasks만 교체합니다. task-view.js와 app.js는 제시한 전체 파일을 적용하세요. 추가→완료→삭제 후 요약 수와 필터·검색 결과를 함께 확인합니다.
연속 프로젝트 선수 조건
- DOM 선택과 form submit 처리
addTask,toggleTask,deleteTask,filterTasksjavascript-step-07-dom-form체크포인트
이전 체크포인트
javascript-step-07-dom-form
필터·검색 UI 추가
작업 폼 아래에 다음 컨트롤을 추가합니다.
<div class="task-controls">
<label for="task-status">상태</label>
<select id="task-status">
<option value="all">전체</option>
<option value="pending">미완료</option>
<option value="completed">완료</option>
</select>
<label for="task-search">검색</label>
<input id="task-search" type="search" />
</div>
filterTasks() 확장
export function filterTasks(status = 'all', query = '') {
const normalizedQuery = query.trim().toLowerCase();
return tasks.filter((task) => {
const matchesStatus =
status === 'all' ||
(status === 'completed' && task.completed) ||
(status === 'pending' && !task.completed);
const matchesQuery =
!normalizedQuery ||
task.title.toLowerCase().includes(normalizedQuery);
return matchesStatus && matchesQuery;
});
}
이번 회차 변경 코드와 전체본 연결
작업 관리 데모 section에 필터·검색 컨트롤을 추가하고 다음 JavaScript 파일을 적용합니다.
전체 파일·코드 보기
js/task-store.js 핵심 변경
기존 함수는 유지하고 filterTasks()만 검색 인수를 추가합니다.
export function filterTasks(status = 'all', query = '') {
const normalizedQuery = query.trim().toLowerCase();
return tasks.filter((task) => {
const matchesStatus =
status === 'all' ||
(status === 'completed' && task.completed) ||
(status === 'pending' && !task.completed);
const matchesQuery =
!normalizedQuery ||
task.title.toLowerCase().includes(normalizedQuery);
return matchesStatus && matchesQuery;
});
}
js/task-view.js 전체 코드
export function bindTaskForm(onAdd) {
const form = document.querySelector('#task-form');
const titleInput = document.querySelector('#task-title');
const message = document.querySelector('#task-message');
if (!form || !titleInput || !message) {
throw new Error('TaskFlow 작업 폼 요소를 찾을 수 없습니다.');
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const title = titleInput.value.trim();
if (!title) {
titleInput.setAttribute('aria-invalid', 'true');
message.textContent = '작업 이름을 입력해 주세요.';
titleInput.focus();
return;
}
onAdd(title);
titleInput.value = '';
titleInput.setAttribute('aria-invalid', 'false');
message.textContent = '작업을 추가했습니다.';
titleInput.focus();
});
}
export function bindTaskControls(onChange) {
const status = document.querySelector('#task-status');
const search = document.querySelector('#task-search');
if (!status || !search) {
throw new Error('TaskFlow 필터 요소를 찾을 수 없습니다.');
}
status.addEventListener('change', onChange);
search.addEventListener('input', onChange);
}
export function getTaskControls() {
return {
status: document.querySelector('#task-status').value,
query: document.querySelector('#task-search').value,
};
}
export function bindTaskList(onAction) {
const list = document.querySelector('#task-list');
if (!list) {
throw new Error('task-list를 찾을 수 없습니다.');
}
list.addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
const item = button?.closest('[data-task-id]');
if (!button || !item) return;
onAction({
id: item.dataset.taskId,
action: button.dataset.action,
});
});
}
export function renderTasks(tasks, summary) {
const list = document.querySelector('#task-list');
const summaryElement = document.querySelector('#task-summary');
list.replaceChildren();
summaryElement.textContent =
`전체 ${summary.total} · 완료 ${summary.completed} · 미완료 ${summary.pending}`;
if (tasks.length === 0) {
const empty = document.createElement('li');
empty.textContent = '조건에 맞는 작업이 없습니다.';
list.append(empty);
return;
}
tasks.forEach((task) => {
const item = document.createElement('li');
item.dataset.taskId = String(task.id);
const title = document.createElement('span');
title.textContent = task.title;
const toggleButton = document.createElement('button');
toggleButton.type = 'button';
toggleButton.dataset.action = 'toggle';
toggleButton.textContent = task.completed ? '미완료로 변경' : '완료';
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.dataset.action = 'delete';
deleteButton.textContent = '삭제';
item.append(title, toggleButton, deleteButton);
list.append(item);
});
}
js/app.js 전체 코드
import {
addTask,
deleteTask,
filterTasks,
getTaskSummary,
toggleTask,
} from './task-store.js';
import {
bindTaskControls,
bindTaskForm,
bindTaskList,
getTaskControls,
renderTasks,
} from './task-view.js';
function render() {
const { status, query } = getTaskControls();
const tasks = filterTasks(status, query);
const summary = getTaskSummary();
renderTasks(tasks, summary);
}
bindTaskForm((title) => {
addTask(title);
render();
});
bindTaskControls(render);
bindTaskList(({ id, action }) => {
if (action === 'toggle') toggleTask(id);
if (action === 'delete') deleteTask(id);
render();
});
render();
중간부터 시작하는 전체 파일과 적용 순서
시작 ZIP은 직전 프로젝트 단계 javascript-step-07-dom-form의 실제 완료 파일입니다. 프로젝트 순서를 이어가는 기준이며 사이트의 기술 읽기 순서와는 구분합니다.
- 시작 ZIP을 새 폴더에 풉니다. 이 폴더가 작업 루트이며 기존 체크포인트 파일을 별도로 찾을 필요가 없습니다.
- 폼 다음에 필터·검색 컨트롤을 넣고 store의 filterTasks를 교체합니다. view와 app은 전체 교체합니다.
- 아래 변경 파일을 같은 상대 경로에 추가하거나 전체 교체합니다. 표시하지 않은 파일과 images는 시작본 그대로 유지합니다. package-lock.json은 완료 ZIP에 있는 파일을 함께 적용합니다.
- 작업 루트에서 명령을 실행하고 예상 결과를 확인합니다. 완료 ZIP은 직접 적용한 결과를 비교하는 기준입니다.
추가·교체 파일
style.css— 전체 교체 (2026-10-01 디자인 보완)index.html— 전체 교체CHANGES.md— 전체 교체README.md— 전체 교체js/app.js— 전체 교체js/task-store.js— 전체 교체js/task-view.js— 전체 교체
실행과 확인
npm start
npm test
개발 서버는 실행 상태로 두고 다른 터미널에서 검사 명령을 실행하세요. Node.js 24.18.0에서 검증했습니다. JavaScript는 http://127.0.0.1:4173, TypeScript는 Vite 실행 출력의 주소로 접속합니다.
예상 결과: 초기 3개, 추가·완료·삭제 및 상태/검색 필터가 목록과 요약에 반영됩니다. 제목의 HTML 문자는 텍스트로 표시됩니다.
실제 검증: JavaScript 모듈·DOM·경계값 실행 검사 통과. 시작 ZIP에 이 글의 변경 파일만 적용한 결과와 완료 ZIP의 모든 파일 내용이 일치했습니다. 브라우저 화면 배치·실제 기기·외부 CDN 가용성은 이 검사에 포함하지 않았습니다.
이 절의 전체 파일은 ZIP과 일치합니다. 위쪽 원문의 짧은 예제나 생략 표시가 있는 부분 코드는 개념·변경 위치 설명용입니다. 서버 저장과 인증은 제공하지 않으며 JSON fixture는 로컬 읽기 실습용입니다.
디자인 보완 완료본 (2026-10-01): 작업 입력·필터·목록 간격과 모바일 배치를 정리했습니다. 아래 style.css 전체 파일을 교체하면 실행 화면과 같은 디자인이 됩니다. JavaScript 기능은 동일합니다. 다음 단계의 기존 ZIP에는 이 스타일이 포함되지 않으므로 유지하려면 style.css를 함께 가져가세요.
복사할 변경 파일 전체 코드
style.css — 전체 파일
:root {
color: #171717;
background: #f5f5f5;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
Arial,
sans-serif;
line-height: 1.6;
color: #171717;
background: #f5f5f5;
}
.page {
width: min(
100% - 32px,
960px
);
margin-inline: auto;
}
.site-header,
.site-footer {
padding-block: 20px;
}
main {
padding-block: 48px;
}
section + section {
margin-top: 64px;
}
.card {
padding: 24px;
border: 1px solid #d7d7d7;
background: #fff;
}
form {
display: grid;
gap: 16px;
}
.form-field {
display: grid;
gap: 8px;
}
input,
textarea {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid #b8b8b8;
background: #fff;
}
button {
min-height: 44px;
padding: 10px 16px;
border: 1px solid #171717;
background: #171717;
color: #fff;
}
a { color: inherit; }
input, textarea, button { font: inherit; }
h1 { font-size: 32px; font-weight: 700; }
h2 { font-size: 24px; }
h3 { font-size: 20px; }
.site-header__inner {
display: flex;
flex-direction: column;
gap: 12px;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 16px;
}
@media (min-width: 48rem) {
.site-header__inner {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.feature-list {
flex-direction: row;
}
.feature-list > * {
flex: 1;
}
}
.site-header {
position: sticky;
top: 0;
z-index: 10;
background: #f5f5f5;
border: 1px solid #ddd;
}
.feature-card {
position: relative;
}
.feature-card__badge {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 8px;
border: 1px solid #171717;
background: #fff;
font-size: 12px;
}
.help-button {
position: fixed;
right: 16px;
bottom: 16px;
min-width: 44px;
min-height: 44px;
padding: 10px 14px;
border: 1px solid #171717;
background: #171717;
color: #fff;
text-decoration: none;
}
.faq {
display: grid;
gap: 12px;
}
.faq details {
border: 1px solid #d7d7d7;
background: #fff;
}
.faq summary {
padding: 16px;
cursor: pointer;
font-weight: 700;
}
.faq details p {
margin: 0;
padding: 0 16px 16px;
}
.faq summary:focus-visible {
outline: 2px solid #171717;
outline-offset: 2px;
}
.feature-grid,
.pricing-grid {
display: grid;
grid-template-columns:
repeat(
auto-fit,
minmax(
min(100%, 220px),
1fr
)
);
gap: 16px;
}
.task-preview {
display: flex;
align-items: flex-start;
gap: 12px;
}
.task-preview__content {
min-width: 0;
flex: 1;
}
.task-preview__content p {
overflow-wrap: anywhere;
}
.settings-layout {
display: grid;
grid-template-columns:
180px
minmax(0, 1fr);
gap: 24px;
}
.table-scroll {
overflow-x: auto;
}
.pricing-table {
width: 100%;
min-width: 640px;
border-collapse: collapse;
background: #fff;
}
.pricing-table caption {
margin-bottom: 12px;
text-align: left;
font-weight: 700;
}
.pricing-table th,
.pricing-table td {
padding: 12px;
border: 1px solid #d7d7d7;
text-align: left;
}
img {
display: block;
max-width: 100%;
}
.hero__image {
width: 100%;
height: auto;
}
.feature-card__image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.service-layout {
display: grid;
gap: 24px;
}
@media (
min-width: 64rem
) {
.service-layout {
grid-template-columns:
minmax(0, 1fr)
280px;
}
}
.feature-slot {
container-type:
inline-size;
}
.feature-card {
display: grid;
gap: 16px;
}
@container (
min-width: 30rem
) {
.feature-card {
grid-template-columns:
180px
minmax(0, 1fr);
align-items: center;
}
}
.service-layout > * { min-width: 0; }
:root {
--z-header: 10;
--z-floating: 20;
}
.site-header {
position: sticky;
top: 0;
z-index:
var(--z-header);
}
.help-button {
position: fixed;
right: 16px;
bottom: 16px;
z-index:
var(--z-floating);
}
:focus-visible {
outline: 2px solid #171717;
outline-offset: 3px;
}
.skip-link {
position: absolute;
left: 16px;
top: -100px;
z-index: 100;
padding: 10px 12px;
background: #171717;
color: #fff;
}
.skip-link:focus {
top: 16px;
}
section[id], main[id] { scroll-margin-top: 8rem; }
body { padding-bottom: 5rem; }
.hero { display: grid; gap: 24px; }
@media (min-width: 48rem) { .hero { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; } }
.button-link { display: inline-block; padding: 10px 16px; border: 1px solid #171717; background: #171717; color: #fff; }
.task-demo {
padding: 24px;
border: 1px solid #d7d7d7;
background: #fff;
}
.task-form {
display: grid;
gap: 12px;
max-width: 560px;
}
.task-list {
margin: 24px 0 0;
padding: 0;
list-style: none;
}
/* TaskFlow step 08: task form, filters and list layout. */
.task-demo {
padding: clamp(20px, 4vw, 40px);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.task-demo h2 {
margin: 0 0 8px;
font-size: 28px;
line-height: 1.35;
font-weight: 700;
letter-spacing: -0.035em;
}
.task-demo > p:first-of-type {
margin: 0 0 28px;
color: #595959;
font-size: 14px;
}
.task-demo .task-form {
max-width: none;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 12px;
}
.task-demo label {
font-size: 14px;
font-weight: 500;
color: #424242;
}
.task-demo input,
.task-demo select,
.task-demo button {
min-width: 0;
min-height: 46px;
margin: 0;
padding: 10px 14px;
border: 1px solid #b8b8b8;
border-radius: 0;
font: inherit;
font-size: 15px;
line-height: 1.5;
}
.task-demo input,
.task-demo select { background: #fff; color: #171717; }
.task-demo button { cursor: pointer; font-weight: 500; }
.task-demo .task-form button {
padding-inline: 24px;
border-color: #171717;
background: #171717;
color: #fff;
}
.task-demo .task-controls {
display: grid;
grid-template-columns: auto 140px auto minmax(0, 1fr);
align-items: center;
gap: 12px;
margin-top: 20px;
padding: 16px;
background: #f5f5f5;
}
.task-demo #task-message {
min-height: 24px;
margin: 12px 0 8px;
font-size: 14px;
color: #595959;
}
.task-demo #task-summary {
margin-top: 20px;
color: #424242;
font-size: 14px;
font-weight: 500;
}
.task-demo .task-list {
display: grid;
gap: 10px;
margin: 14px 0 0;
}
.task-demo .task-list > li {
padding: 14px 16px;
border: 1px solid #dedede;
background: #fff;
overflow-wrap: anywhere;
}
.task-demo .task-list > li[data-task-id] {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px 60px;
align-items: center;
gap: 10px;
}
.task-demo .task-list > li > span {
min-width: 0;
font-size: 16px;
font-weight: 500;
}
.task-demo .task-list button {
padding-inline: 10px;
border-color: #d1d1d1;
background: #f6f6f6;
color: #242424;
font-size: 14px;
}
.task-demo .task-list button[data-action="delete"] { background: #fff; color: #595959; }
.task-demo :focus-visible { outline: 2px solid #171717; outline-offset: 3px; }
@media (max-width: 600px) {
.task-demo .task-form { grid-template-columns: minmax(0, 1fr); }
.task-demo .task-controls { grid-template-columns: auto minmax(0, 1fr); }
.task-demo .task-list > li[data-task-id] { grid-template-columns: minmax(0, 1fr) auto; }
.task-demo .task-list > li > span { grid-column: 1 / -1; }
.task-demo h2 { font-size: 24px; }
}
README.md — 전체 파일
# javascript-step-08-todo-core — 실행 가능한 전체 파일
Node.js 24.18.0에서 검증했습니다. ZIP에는 node_modules와 빌드 결과를 넣지 않았습니다.
이 폴더에서 명령을 실행합니다. npm ci는 TypeScript 단계부터 필요합니다.
```sh
npm start
npm test
```
JavaScript의 주소는 http://127.0.0.1:4173, Vite의 주소는 실행 출력에서 확인합니다.
브라우저에서 HTML 파일을 직접 여는 file:// 방식은 ES Module/fetch 실행을 보장하지 않습니다.
## 이번 단계
폼 다음에 필터·검색 컨트롤을 넣고 store의 filterTasks를 교체합니다. view와 app은 전체 교체합니다.
## 예상 결과와 확인
초기 3개, 추가·완료·삭제 및 상태/검색 필터가 목록과 요약에 반영됩니다. 제목의 HTML 문자는 텍스트로 표시됩니다.
## 실제 제공 범위
- 앞 회차 완료 ZIP이 이번 회차의 시작 ZIP입니다. 변경 파일을 전체 교체/추가하면 이번 완료 ZIP과 같아집니다.
- 원문 설명용 부분 코드를 순서 없이 이어 붙이지 말고 본문의 보강 전체 파일을 사용합니다.
- JSON fixture는 읽기 전용입니다. 서버 저장·로그인·실제 외부 API는 제공하지 않습니다.
- DOM/React 동작 검증은 jsdom입니다. 실제 브라우저 배치·기기·외부 CDN 가용성은 별도 검증 범위입니다.
- TypeScript 학습의 Task 계약은 id/title/completed입니다. 기존 JavaScript 전체 기능은 typed 화면 도입 이후 javascript.html에 보존합니다.
## 2026-10-01 디자인 보완
작업 관리 영역의 입력창·필터·목록 간격과 모바일 배치를 정리했습니다. JavaScript 기능과 시작 체크포인트는 동일합니다. 시작본에 이 글의 변경 파일과 style.css 전체 파일을 적용하세요. 다음 단계의 기존 ZIP에는 이 표시 스타일이 포함되지 않으므로 유지하려면 style.css를 함께 가져가세요.
CHANGES.md — 전체 파일
# javascript-step-08-todo-core
폼 다음에 필터·검색 컨트롤을 넣고 store의 filterTasks를 교체합니다. view와 app은 전체 교체합니다.
확인: 초기 3개, 추가·완료·삭제 및 상태/검색 필터가 목록과 요약에 반영됩니다. 제목의 HTML 문자는 텍스트로 표시됩니다.
## 2026-10-01 디자인 보완
작업 관리 영역의 입력창·필터·목록 간격과 모바일 배치를 정리했습니다. JavaScript 기능과 시작 체크포인트는 동일합니다. 시작본에 이 글의 변경 파일과 style.css 전체 파일을 적용하세요. 다음 단계의 기존 ZIP에는 이 표시 스타일이 포함되지 않으므로 유지하려면 style.css를 함께 가져가세요.
index.html — 전체 파일
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>
TaskFlow - 간단한 작업 정리 서비스
</title>
<link href="./style.css" rel="stylesheet"/>
<meta content="TaskFlow의 주요 기능과 요금제, 문의 방법을 확인할 수 있는 작업 관리 서비스 소개 페이지입니다." name="description"/>
</head>
<body>
<a class="skip-link" href="#main">
본문으로 바로가기
</a>
<header class="site-header">
<div class="page site-header__inner">
<a class="brand" href="#home">
TaskFlow
</a>
<nav aria-label="주요 메뉴" class="nav">
<a href="#features">
기능
</a>
<a href="#pricing">
요금제
</a>
<a href="#contact">
문의
</a>
<a href="#faq">
FAQ
</a>
</nav>
</div>
</header>
<main class="page" id="main" tabindex="-1">
<div class="service-layout">
<div>
<section class="hero" id="home">
<div>
<h1>
해야 할 일을 한눈에 정리하세요
</h1>
<p>
TaskFlow는 오늘의 작업을 빠르게 정리하는 서비스입니다.
</p>
<a class="button-link" href="#contact">
시작 문의하기
</a>
</div>
<img alt="TaskFlow 서비스 소개 페이지의 기능·문의 화면" class="hero__image" height="800" src="./images/hero.webp" width="1200"/>
</section>
<section id="features">
<h2>
주요 기능
</h2>
<div class="feature-grid">
<article class="card feature-card">
<span class="feature-card__badge">
NEW
</span>
<h3>
빠른 작업 추가
</h3>
<p>
해야 할 일을 바로 기록할 수 있습니다.
</p>
<img alt="TaskFlow 기능 카드 세 개" class="feature-card__image" height="450" loading="lazy" src="./images/feature-board.webp" width="800"/>
</article>
<article class="card">
<h3>
간단한 분류
</h3>
<p>
해야 할 일을 구분합니다.
</p>
</article>
<article class="card">
<h3>
진행 확인
</h3>
<p>
완료 여부를 확인합니다.
</p>
</article>
<article class="card task-preview">
<div class="task-preview__content">
<h3>
아주 긴 작업 제목과 URL
</h3>
<p>
https://example.com/very-long-task-url-without-normal-break-points-123456789
</p>
</div>
<a href="#contact">
문의
</a>
</article>
</div>
</section>
<section class="faq" id="faq">
<h2>
자주 묻는 질문
</h2>
<details>
<summary>
TaskFlow는 어떤 서비스인가요?
</summary>
<p>
해야 할 일을 빠르게 정리하는 작업 관리 서비스 예제입니다.
</p>
</details>
<details>
<summary>
모바일에서도 볼 수 있나요?
</summary>
<p>
작은 화면부터 설계한 반응형 레이아웃을 사용합니다.
</p>
</details>
<details>
<summary>
문의는 어디서 하나요?
</summary>
<p>
아래 문의 폼을 이용할 수 있습니다.
</p>
</details>
</section>
<section id="pricing">
<h2>
요금제
</h2>
<div class="pricing-grid">
<article class="card">
<h3>
Basic
</h3>
<p>
개인 학습용
</p>
</article>
<article class="card">
<h3>
Pro
</h3>
<p>
더 많은 프로젝트
</p>
</article>
</div>
<div aria-label="요금제 비교표 가로 스크롤" class="table-scroll" role="region" tabindex="0">
<table class="pricing-table">
<caption>
TaskFlow 요금제 기능 비교
</caption>
<thead>
<tr>
<th scope="col">
항목
</th>
<th scope="col">
Basic
</th>
<th scope="col">
Pro
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">
프로젝트
</th>
<td>
3개
</td>
<td>
무제한
</td>
</tr>
<tr>
<th scope="row">
작업 보관
</th>
<td>
30일
</td>
<td>
무제한
</td>
</tr>
<tr>
<th scope="row">
팀 공유
</th>
<td>
없음
</td>
<td>
지원
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="contact">
<h2>
문의하기
</h2>
<form>
<div class="form-field">
<label for="name">
이름
</label>
<input autocomplete="name" id="name" name="name" type="text"/>
</div>
<div class="form-field">
<label for="email">
이메일
</label>
<input autocomplete="email" id="email" name="email" type="email"/>
</div>
<div class="form-field">
<label for="message">
문의 내용
</label>
<textarea id="message" name="message" rows="5"></textarea>
</div>
<button type="submit">
문의 보내기
</button>
</form>
</section>
<div class="feature-slot">
<article class="card feature-card">
<img alt="TaskFlow 기능 카드 세 개" class="feature-card__image" height="450" loading="lazy" src="./images/feature-board.webp" width="800"/>
<div>
<h3>
작업 카드
</h3>
<p>
넓은 공간에서는 이미지와 설명을 나란히 배치합니다.
</p>
</div>
</article>
</div>
</div>
<aside aria-label="기능 미리보기">
<div class="feature-slot">
<article class="card feature-card">
<img alt="TaskFlow 기능 카드 세 개" class="feature-card__image" height="450" loading="lazy" src="./images/feature-board.webp" width="800"/>
<div>
<h3>
작업 카드
</h3>
<p>
넓은 공간에서는 이미지와 설명을 나란히 배치합니다.
</p>
</div>
</article>
</div>
</aside>
</div>
<section
class="task-demo"
id="task-demo"
aria-labelledby="task-demo-title"
>
<h2 id="task-demo-title">작업 관리 데모</h2>
<p>JavaScript 과정에서 이 영역을 단계적으로 동작하게 만듭니다.</p>
<form class="task-form" id="task-form" novalidate>
<div class="form-field">
<label for="task-title">작업 이름</label>
<input
id="task-title"
name="title"
type="text"
maxlength="80"
required
aria-describedby="task-message"
/>
</div>
<button type="submit">작업 추가</button>
</form><div class="task-controls">
<label for="task-status">상태</label>
<select id="task-status">
<option value="all">전체</option>
<option value="pending">미완료</option>
<option value="completed">완료</option>
</select>
<label for="task-search">검색</label>
<input id="task-search" type="search" />
</div>
<p id="task-message" aria-live="polite"></p>
<div id="task-summary" aria-live="polite"></div>
<ul class="task-list" id="task-list"></ul>
</section>
</main>
<footer class="site-footer">
<div class="page">
<p>
© TaskFlow
</p>
</div>
</footer>
<a class="help-button" href="#contact">
문의
</a>
<script type="module" src="./js/app.js"></script></body>
</html>
js/app.js — 전체 파일
import {
addTask,
deleteTask,
filterTasks,
getTaskSummary,
toggleTask,
} from './task-store.js';
import {
bindTaskControls,
bindTaskForm,
bindTaskList,
getTaskControls,
renderTasks,
} from './task-view.js';
function render() {
const { status, query } = getTaskControls();
const tasks = filterTasks(status, query);
const summary = getTaskSummary();
renderTasks(tasks, summary);
}
bindTaskForm((title) => {
addTask(title);
render();
});
bindTaskControls(render);
bindTaskList(({ id, action }) => {
if (action === 'toggle') toggleTask(id);
if (action === 'delete') deleteTask(id);
render();
});
render();
js/task-store.js — 전체 파일
let tasks = [
{ id: '1', title: 'JavaScript 과정 정리', completed: false },
{ id: '2', title: 'TaskFlow 화면 확인', completed: true },
{ id: '3', title: '다음 글 읽기', completed: false },
];
export function getTasks() {
return [...tasks];
}
export function addTask(title) {
const task = {
id: crypto.randomUUID(),
title,
completed: false,
};
tasks = [...tasks, task];
return task;
}
export function toggleTask(id) {
let updatedTask = null;
tasks = tasks.map((task) => {
if (task.id !== id) return task;
updatedTask = {
...task,
completed: !task.completed,
};
return updatedTask;
});
return updatedTask;
}
export function deleteTask(id) {
tasks = tasks.filter((task) => task.id !== id);
}
export function filterTasks(status = 'all', query = '') {
const normalizedQuery = query.trim().toLowerCase();
return tasks.filter((task) => {
const matchesStatus =
status === 'all' ||
(status === 'completed' && task.completed) ||
(status === 'pending' && !task.completed);
const matchesQuery =
!normalizedQuery ||
task.title.toLowerCase().includes(normalizedQuery);
return matchesStatus && matchesQuery;
});
}
export function getTaskSummary() {
return tasks.reduce(
(summary, task) => {
summary.total += 1;
if (task.completed) {
summary.completed += 1;
} else {
summary.pending += 1;
}
return summary;
},
{ total: 0, completed: 0, pending: 0 },
);
}
js/task-view.js — 전체 파일
export function bindTaskForm(onAdd) {
const form = document.querySelector('#task-form');
const titleInput = document.querySelector('#task-title');
const message = document.querySelector('#task-message');
if (!form || !titleInput || !message) {
throw new Error('TaskFlow 작업 폼 요소를 찾을 수 없습니다.');
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const title = titleInput.value.trim();
if (!title) {
titleInput.setAttribute('aria-invalid', 'true');
message.textContent = '작업 이름을 입력해 주세요.';
titleInput.focus();
return;
}
onAdd(title);
titleInput.value = '';
titleInput.setAttribute('aria-invalid', 'false');
message.textContent = '작업을 추가했습니다.';
titleInput.focus();
});
}
export function bindTaskControls(onChange) {
const status = document.querySelector('#task-status');
const search = document.querySelector('#task-search');
if (!status || !search) {
throw new Error('TaskFlow 필터 요소를 찾을 수 없습니다.');
}
status.addEventListener('change', onChange);
search.addEventListener('input', onChange);
}
export function getTaskControls() {
return {
status: document.querySelector('#task-status').value,
query: document.querySelector('#task-search').value,
};
}
export function bindTaskList(onAction) {
const list = document.querySelector('#task-list');
if (!list) {
throw new Error('task-list를 찾을 수 없습니다.');
}
list.addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
const item = button?.closest('[data-task-id]');
if (!button || !item) return;
onAction({
id: item.dataset.taskId,
action: button.dataset.action,
});
});
}
export function renderTasks(tasks, summary) {
const list = document.querySelector('#task-list');
const summaryElement = document.querySelector('#task-summary');
list.replaceChildren();
summaryElement.textContent =
`전체 ${summary.total} · 완료 ${summary.completed} · 미완료 ${summary.pending}`;
if (tasks.length === 0) {
const empty = document.createElement('li');
empty.textContent = '조건에 맞는 작업이 없습니다.';
list.append(empty);
return;
}
tasks.forEach((task) => {
const item = document.createElement('li');
item.dataset.taskId = String(task.id);
const title = document.createElement('span');
title.textContent = task.title;
const toggleButton = document.createElement('button');
toggleButton.type = 'button';
toggleButton.dataset.action = 'toggle';
toggleButton.textContent = task.completed ? '미완료로 변경' : '완료';
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.dataset.action = 'delete';
deleteButton.textContent = '삭제';
item.append(title, toggleButton, deleteButton);
list.append(item);
});
}
완료 체크포인트
javascript-step-08-todo-core
다음 학습
다음 글에서는 현재 선택한 상태 필터와 검색어를 URL에 저장해 새로고침하거나 링크를 공유해도 같은 화면이 열리도록 만듭니다.
다음 글: JavaScript URLSearchParams 사용법: URL 파라미터 읽고 수정하기
글 소개·학습 준비·전체 목차

JavaScript · 8 / 15 · 필수 완성 실습 · 상태와 이벤트 위임
작업 배열에 추가·완료·삭제를 반영한 뒤 목록과 요약을 다시 렌더링하고, 부모 목록에 한 번 등록한 클릭 리스너에서 액션과 항목 id를 읽으면 투두리스트를 구성할 수 있습니다.
핵심은 DOM 자체를 상태로 사용하지 않는 것입니다. 작업 데이터는 store가 소유하고 화면은 그 상태를 읽어 렌더링합니다.
이번 글에서 배우는 내용
- 상태와 DOM 분리
renderTasks()data-*속성- 이벤트 버블링
- 이벤트 위임
closest()- 추가·완료·삭제
- 필터와 검색
- 빈 목록 상태
먼저 알아야 할 내용
HTML form·button·ul과 JavaScript 배열·map·filter, DOM 선택·submit 이벤트를 알면 됩니다. 아래 독립 파일은 상태와 렌더 함수를 모두 포함합니다.
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 15개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.