JavaScript 투두리스트 만들기: 상태와 이벤트 위임으로 완성하기

2025.12.10·수정 2026.09.12·약 27분·작성: 해비·블로그 소개

주요 포인트 한눈에 보기

프론트엔드 코딩테스트에서 바닐라 자바스크립트로 투두리스트를 구현했던 경험을 정리합니다. 시험 당시에는 appendChild로 문제를 해결했지만, 이후 DOM 조작을 다시 공부하며 insertAdjacentElement라는 다른 선택지를 알게 되었고, 같은 문제를 두 가지 방식으로 복기한 뒤, 배열 상태·이벤트 위임·필터별 화면 렌더링까지 완성합니다.

코딩테스트에서 만난 투두리스트 문제

프론트엔드 코딩테스트에서 바닐라 자바스크립트로 투두리스트를 구현하는 문제가 출제되었습니다. 프레임워크나 라이브러리는 사용할 수 없었고, 입력값을 받아 할 일을 목록에 추가하고 삭제하는 기본적인 기능을 구현하는 형태였습니다.

시험 당시 접근: appendChild로 구현

JavaScript 투두리스트 만들기: DOM 조작과 이벤트 처리 익히기 전반부 흐름 정리

시험 당시에는 투두리스트라는 문제를 보고 자연스럽게 appendChild를 떠올렸습니다. 새로운 할 일을 리스트에 추가하는 동작은 대부분 이 방식으로 해결해왔기 때문에, 별다른 고민 없이 접근했습니다.

const list = document.querySelector('.todo-list');

function addTodo(text) {
  const li = document.createElement('li');
  li.textContent = text;
  list.appendChild(li);
}

이 코드는 기본 예제에 해당합니다. li 요소를 생성한 뒤 부모 요소의 마지막 자식으로 추가합니다. 구현이 단순하고 가독성이 좋아 빠르게 결과를 만들어야 하는 코딩테스트 상황에서는 안정적인 선택이었습니다.

다만 메커니즘 관점에서 보면, appendChild는 항상 마지막 위치에만 요소를 추가합니다. 중간에 삽입하거나 특정 기준 요소 앞뒤에 넣어야 하는 상황에서는 구조를 다시 생각해야 합니다.

실수 포인트는 DOM 위치를 깊이 고민하지 않고 무조건 appendChild를 사용하는 습관입니다. 삽입 위치 요구가 달라지면 적절한 API를 다시 선택하면 됩니다. 특정 메서드 하나가 모든 상황에서 우월한 것은 아닙니다.

구현 이후 복기하며 느낀 아쉬움과 추가 학습

문제를 풀고 나서는 묘한 아쉬움이 남았습니다. DOM을 직접 다루는 문제였는데, 너무 한 가지 방식만 떠올린 것은 아닐지 고민이 됐습니다. 항상 맨 뒤에 추가하는 방식 외에 다른 선택지는 없었을까 하는 생각도 들었습니다.

코딩테스트가 끝난 뒤 DOM 조작 관련 메서드들을 다시 정리해보기로 했습니다. 평소 자주 사용하던 방식 외에도 어떤 선택지들이 있는지 공식 문서와 예제들을 다시 살펴보며 복기하는 시간을 가졌습니다.

그 과정에서 insertAdjacentElement를 다시 보게 되었습니다. 특정 요소를 기준으로 beforebegin, afterbegin, beforeend, afterend 위치에 요소를 삽입할 수 있다는 점이 인상적이었습니다. 단순히 뒤에 붙이는 방식이 아니라, DOM 구조를 의도적으로 제어할 수 있는 방법이라는 점에서 이 문제를 다시 바라보게 되었습니다.

insertAdjacentElement로 다시 풀어본 투두리스트

JavaScript 투두리스트 만들기: DOM 조작과 이벤트 처리 익히기 후반부 흐름 정리

DOM 조작을 다시 공부한 뒤, 같은 문제를 insertAdjacentElement를 사용해 다시 풀어보았습니다. 이 메서드는 기준 요소를 중심으로 삽입 위치를 명확하게 지정할 수 있다는 점이 핵심입니다.

const list = document.querySelector('.todo-list');

function addTodo(text) {
  const li = document.createElement('li');
  li.textContent = text;
  list.insertAdjacentElement('beforeend', li);
}

동작 결과만 놓고 보면 appendChild와 동일합니다. 하지만 메커니즘 관점에서는 “list의 끝(beforeend)에 추가한다”는 의도가 코드에 그대로 드러납니다.

insertAdjacentElement는 beforebegin, afterbegin, beforeend, afterend 네 가지 위치를 지원합니다. 이 구조 덕분에 DOM 트리를 더 입체적으로 다룰 수 있게 됩니다.

실수 포인트는 위치 문자열을 암기하려다 헷갈리는 것입니다. 실제로는 문서를 참고하며 사용해도 무방하고, 핵심은 DOM 기준점과 삽입 위치를 분리해서 사고하는 데 있습니다.

appendChild와 insertAdjacentElement 비교

두 방식은 결과만 보면 거의 동일하게 느껴질 수 있지만, DOM을 어떤 관점에서 다루고 있는지에 따라 사고 흐름이 완전히 달라집니다. 이 섹션에서는 코드가 실제로 DOM의 어느 위치에 들어가는지를 기준으로 차이를 정리합니다.

먼저 HTML 구조를 간단히 가정해보겠습니다.

<ul class="todo-list">
  <li>기존 할 일</li>
</ul>

위 상태에서 새로운 할 일을 추가한다고 가정합니다.

appendChild를 사용하는 경우, 새로운 요소는 항상 부모 요소의 마지막 자식으로 들어갑니다.

const list = document.querySelector('.todo-list');
const li = document.createElement('li');
li.textContent = '새로운 할 일';

list.appendChild(li);

DOM 구조는 아래처럼 변경됩니다.

<ul class="todo-list">
  <li>기존 할 일</li>
  <li>새로운 할 일</li>
</ul>

반면 insertAdjacentElement는 기준 요소와 위치를 명시합니다. 같은 결과를 만들더라도, 코드의 의미는 조금 달라집니다.

const list = document.querySelector('.todo-list');
const li = document.createElement('li');
li.textContent = '새로운 할 일';

list.insertAdjacentElement('beforeend', li);

beforeend는 “기준 요소의 내부, 맨 끝”을 의미합니다. 따라서 결과 DOM은 appendChild와 동일합니다.

차이가 드러나는 지점은 위치 옵션을 바꿀 때입니다.

list.insertAdjacentElement('afterbegin', li);

이 경우 새로운 할 일은 맨 앞에 추가됩니다.

<ul class="todo-list">
  <li>새로운 할 일</li>
  <li>기존 할 일</li>
</ul>

정리하면 appendChild는 “무조건 마지막에 추가”하는 방식이고, insertAdjacentElement는 “어디에 추가할 것인지”를 코드 레벨에서 명확히 드러내는 방식입니다.

요구사항에 맞는 위치에 안전하게 넣는지가 기준입니다. API 선택만으로 평가 결과를 단정할 수 없습니다.

정리하며 느낀 점

시험 당시에는 appendChild만 떠올랐지만, 이후 다시 공부하면서 insertAdjacentElement라는 선택지를 알게 되었습니다. 같은 문제라도 다시 풀어보니 보이지 않던 부분들이 보였고, 코딩테스트 이후의 복기 과정 자체가 좋은 학습이 되었다고 느꼈습니다.

FAQ

Q. 투두리스트 문제에서 appendChild로 풀면 감점이 되나요?
요구사항만 충족한다면 기능적으로는 문제가 없습니다. 실제 채점 기준은 시험마다 다르므로 API 이름만으로 감점을 단정할 수 없습니다.

Q. insertAdjacentElement를 꼭 사용해야 하나요?
반드시 사용해야 하는 것은 아니지만, DOM 구조를 더 명확하게 제어할 수 있는 선택지로 알고 있는 것은 도움이 됩니다.

Q. 코딩테스트 이후 복기는 어떻게 하는 게 좋을까요?
시험에서 사용한 방식 외에도 다른 접근이 가능한지 다시 풀어보는 과정이 큰 도움이 됩니다.

같이 읽으면 좋은 글

관련 글

배열이 기준인 투두리스트로 확장하기

먼저 읽기: DOM 폼과 입력 검증, 배열 메서드. 앞 수업은 작은 DOM 자체가 상태였습니다. 이제 추가·완료·삭제·검색·필터를 모두 연결하므로 todos 배열을 단일 기준으로 삼습니다. DOM을 읽어 완료 수를 계산하거나 삭제된 화면에서 데이터를 추측하지 않습니다.

각 항목은 { id, text, done } 객체입니다. 같은 문구를 두 번 넣어도 id가 다르므로 한 항목만 완료하거나 삭제할 수 있습니다. nextId는 현재 실행 동안 증가하며 배열 인덱스를 식별자로 쓰지 않습니다. 새로고침 뒤 보존하는 저장 기능은 이번 범위에 포함되지 않습니다.

상태 변경과 파생 화면 구분하기

changeTodos는 동작을 받아 새 배열을 반환합니다. toggle은 map으로 목표 id만 새 객체로 바꾸고 delete는 filter로 제외합니다. 원본 객체를 변경하지 않기 때문에 이전 상태와 비교해 무엇이 달라졌는지 확인하기 쉽습니다. visibleTodos는 완료 필터와 검색을 함께 적용한 파생 배열이며 전체 todos를 덮어쓰지 않습니다. 검색을 지우면 숨겨졌던 항목이 다시 보입니다.

render는 visibleTodos로 li를 만들고 replaceChildren으로 목록을 교체합니다. 전체 개수와 완료 개수는 원본 배열, 표시 개수는 파생 배열에서 계산합니다. 따라서 완료 필터에서 추가한 새 미완료 항목은 전체 개수에는 포함되고 화면에는 숨겨지는 것이 정상입니다. 화면 재생성은 포커스도 잃게 하므로 조작한 id의 버튼을 찾아 돌려주고, 항목이 숨겨지거나 삭제되면 입력으로 옮깁니다.

위임에서 target·currentTarget·data-action 읽기

ul에 등록한 리스너는 나중에 다시 그린 버튼의 클릭도 처리합니다. target이 버튼 안 span이어도 closest로 버튼을 찾고 currentTarget.contains로 목록 안인지 검사합니다. data-action은 동작, 가장 가까운 li의 data-id는 대상을 결정합니다. 버튼 문구나 배열 순서가 바뀌어도 이 계약은 유지됩니다. 리스너는 render 안에 등록하지 않아 다시 그릴 때 중복 실행되지 않습니다.

아래 완성 파일은 삽입 API 비교용 짧은 예제를 대체하는 실행 프로젝트입니다. 위 조각들을 같은 스크립트에 모두 붙이면 const list 재선언 오류가 나므로 각 조각은 독립 실험으로 읽으세요. insertAdjacentElement의 beforebegin/afterend는 기준 요소 바깥의 형제 위치입니다. ul 앞뒤에 li를 넣는 것은 목록 구조에 맞지 않으므로 내부 beforeend/afterbegin과 구분해야 합니다.

index.html

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

전체 코드

app.js

const form = document.querySelector('#todo-form');
const input = document.querySelector('#todo-input');
const list = document.querySelector('#todo-list');
const filter = document.querySelector('#filter');
const search = document.querySelector('#search');
const status = document.querySelector('#status');
const error = document.querySelector('#error');
let todos = [];
let nextId = 1;
function render(focusId, action) {
  const visible = visibleTodos(todos, filter.value, search.value);
  const nodes = visible.map((todo) => {
    const item = document.createElement('li');
    item.dataset.id = String(todo.id);
    item.classList.toggle('done', todo.done);
    const text = document.createElement('span');
    text.textContent = todo.text;
    const toggle = document.createElement('button');
    toggle.type = 'button';
    toggle.dataset.action = 'toggle';
    toggle.setAttribute('aria-pressed', String(todo.done));
    toggle.setAttribute('aria-label', `${todo.text} 완료`);
    const caption = document.createElement('span');
    caption.textContent = '완료';
    toggle.append(caption);
    const remove = document.createElement('button');
    remove.type = 'button';
    remove.dataset.action = 'delete';
    remove.textContent = '삭제';
    remove.setAttribute('aria-label', `${todo.text} 삭제`);
    item.append(text, toggle, remove);
    return item;
  });
  list.replaceChildren(...nodes);
  document.querySelector('#empty').hidden = visible.length !== 0;
  status.textContent = `전체 ${todos.length}개 / 완료 ${todos.filter((todo) => todo.done).length}개 / 표시 ${visible.length}개`;
  if (focusId !== undefined) {
    const target = list.querySelector(
      `[data-id="${focusId}"] [data-action="${action}"]`,
    );
    (target || input).focus();
  }
}
form.addEventListener('submit', (event) => {
  event.preventDefault();
  const text = input.value.trim();
  if (text.length < 2 || text.length > 40) {
    error.textContent = '할 일을 2~40 길이로 입력해 주세요.';
    input.setAttribute('aria-invalid', 'true');
    input.focus();
    return;
  }
  error.textContent = '';
  input.removeAttribute('aria-invalid');
  todos = changeTodos(todos, { type: 'add', id: nextId++, text });
  input.value = '';
  render();
  input.focus();
});
list.addEventListener('click', (event) => {
  if (!(event.target instanceof Element)) return;
  const button = event.target.closest('button[data-action]');
  if (!button || !event.currentTarget.contains(button)) return;
  const item = button.closest('li[data-id]');
  if (!item || item.parentElement !== list) return;
  const id = Number(item.dataset.id);
  const type = button.dataset.action;
  if (!['toggle', 'delete'].includes(type)) return;
  todos = changeTodos(todos, { type, id });
  render(id, type);
});
filter.addEventListener('change', () => render());
search.addEventListener('input', () => render());
render();

checks.cjs

const assert = require('node:assert/strict');
const { changeTodos, visibleTodos } = require('./logic.js');
const start = [
  { id: 1, text: '우유 사기', done: false },
  { id: 2, text: '우유 사기', done: false },
];
const changed = changeTodos(start, { type: 'toggle', id: 1 });
assert.equal(start[0].done, false);
assert.equal(changed[0].done, true);
assert.equal(changed[1].done, false);
assert.deepEqual(
  visibleTodos(changed, 'done', '우유').map((t) => t.id),
  [1],
);
assert.deepEqual(visibleTodos(changed, 'active', '없는검색'), []);
assert.equal(changeTodos(changed, { type: 'delete', id: 1 })[0].id, 2);
assert.equal(changeTodos(changed, { type: 'add', id: 3, text: '코드 읽기' }).length, 3);
assert.equal(visibleTodos(changed, 'all', ' ').length, 2);
console.log('8 state assertions passed');

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>상태에서 그리는 투두리스트</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main>
      <h1>상태에서 그리는 투두리스트</h1>
      <form id="todo-form" novalidate>
        <label for="todo-input">할 일</label>
        <p id="hint">앞뒤 공백을 뺀 길이 2~40(UTF-16 코드 단위)로 입력하세요.</p>
        <input
          id="todo-input"
          name="todo"
          maxlength="40"
          required
          aria-describedby="hint error"
          autocomplete="off"
        />
        <button type="submit">추가</button>
        <p id="error" role="alert"></p>
      </form>
      <label for="filter">완료 상태</label
      ><select id="filter">
        <option value="all">전체</option>
        <option value="active">미완료</option>
        <option value="done">완료</option></select
      ><label for="search">검색</label><input id="search" type="search" />
      <p id="status" role="status"></p>
      <p id="empty">표시할 할 일이 없습니다.</p>
      <ul id="todo-list"></ul>
    </main>
    <script src="logic.js"></script>
    <script src="app.js"></script>
  </body>
</html>

logic.js

function changeTodos(todos, action) {
  if (action.type === 'add')
    return [...todos, { id: action.id, text: action.text, done: false }];
  if (action.type === 'toggle')
    return todos.map((todo) =>
      todo.id === action.id ? { ...todo, done: !todo.done } : todo,
    );
  if (action.type === 'delete') return todos.filter((todo) => todo.id !== action.id);
  return todos;
}
function visibleTodos(todos, filter, query) {
  const needle = query.trim().toLowerCase();
  return todos.filter(
    (todo) =>
      (filter === 'all' || (filter === 'done' ? todo.done : !todo.done)) &&
      todo.text.toLowerCase().includes(needle),
  );
}
if (typeof module !== 'undefined') module.exports = { changeTodos, visibleTodos };

style.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  padding: 2rem 1rem;
  color: #222;
  background: #fff;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
main {
  max-width: 48rem;
  margin: auto;
}
form,
section {
  margin-block: 1.5rem;
}
input,
select,
button {
  font: inherit;
  padding: 0.5rem;
  border: 1px solid #777;
  color: #222;
  background: #fff;
}
button {
  cursor: pointer;
}
:focus-visible {
  outline: 3px solid #333;
  outline-offset: 3px;
}
label {
  display: block;
}
li {
  margin-block: 0.7rem;
  overflow-wrap: anywhere;
}
li button {
  margin-inline-start: 0.5rem;
}
.done span {
  text-decoration: line-through;
}
pre {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  padding: 1rem;
  background: #eee;
}
[aria-invalid='true'] {
  border: 2px solid #222;
}

index.html 전체 코드 보기

style.css

style.css 전체 코드 보기

logic.js

logic.js 전체 코드 보기

app.js

app.js 전체 코드 보기

참고: MDN 삽입 위치 · MDN currentTarget.

실습 파일 내려받기

HTML·CSS·JavaScript 실습 ZIP 다운로드

index.html을 브라우저에서 엽니다. 별도 패키지 설치는 없습니다.

완료 기준

  1. 같은 우유 사기 문구를 두 번 추가한 뒤 첫 항목만 완료: 전체 2/완료 1이며 두 번째는 미완료입니다.
  2. 완료 필터: 완료 1개만 보입니다. 미완료 필터: 다른 1개만 보입니다.
  3. 없는 검색어: 빈 결과 안내가 나오고 전체 수는 2를 유지합니다. 검색어를 비우면 해당 필터 결과가 복원됩니다.
  4. 완료 버튼 안 글자를 눌러도 위임이 처리됩니다. 여러 번 렌더 후 클릭 한 번당 한 번만 토글됩니다.
  5. 완료 필터에서 새 할 일 추가: 전체 수는 증가하지만 새 항목은 숨겨집니다. 전체 필터에서 보입니다.
  6. 삭제나 필터에서 숨겨지는 완료 조작 뒤 입력에 포커스가 돌아옵니다. 남아 있는 토글은 같은 항목 버튼으로 복원됩니다.

이 글이 도움이 되었나요?

조회 중

JavaScript 학습 순서

필수 13개 · 전체 14개

읽음 기록 관리

전체 과정 목차 (14개)
  1. 필수 학습 · JavaScript 조건문과 반복문: 변수 값의 흐름부터 추적하기
  2. 필수 학습 · JavaScript 함수와 객체, import export로 모듈 나누기
  3. 필수 학습 · JavaScript 배열 메서드: map filter forEach reduce 차이
  4. 필수 학습 · JavaScript 객체 참조와 불변 갱신: 중첩 객체를 안전하게 바꾸기
  5. 필수 학습 · JavaScript reduce 사용법: 배열 누적 계산을 이해하는 기준
  6. 필수 학습 · JavaScript DOM 폼 만들기: 입력 검증과 접근성 처리
  7. 필수 학습 · JavaScript 투두리스트 만들기: 상태와 이벤트 위임으로 완성하기 현재 글
  8. 필수 학습 · JavaScript URLSearchParams 사용법: URL 파라미터 읽고 수정하기
  9. 필수 학습 · JavaScript Date UTC KST 차이: 시간대 변환 기준 잡기
  10. 필수 학습 · JavaScript 실행 컨텍스트 기준: 스코프 호이스팅 클로저 연결하기
  11. 필수 학습 · JavaScript fetch 오류 처리: Promise부터 404까지
  12. 필수 학습 · JavaScript 이벤트 루프: Promise와 await 실행 순서 추적하기
  13. 필수 학습 · JavaScript 고급 비동기: AbortController와 최신 요청 경쟁 제어
  14. 선택 참고 · GSAP이 처음일 때 기초 사용법: 설치부터 기본 애니메이션까지

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기