할 일 앱을 조금만 써보면 금방 느끼는 순간이 있습니다. 항목을 추가하고, 체크하고, 수정하고, 삭제하는 것까지는 되는데 정작 순서를 바꾸는 기능이 없으면 생각보다 답답하다는 점입니다. 가장 급한 일을 위로 올리고 싶을 때도 있고, 방금 추가한 항목을 아래로 내리고 싶을 때도 있는데 지금 구조에서는 그게 안 됩니다.

이번 편에서는 이 불편을 직접 해결해보겠습니다. 다만 이번에도 범위를 크게 벌리지 않겠습니다. 처음부터 드래그 앤 드롭으로 가면 겉보기는 멋있을 수 있어도, 초보자 기준에서는 상태를 따라가기가 확실히 더 어렵습니다. 그래서 이번에는 "위로", "아래로" 버튼으로 순서를 바꾸는 방식으로 가겠습니다. 이쪽이 훨씬 단순하고, 배열 안에서 실제로 무슨 일이 일어나는지 읽기에도 좋습니다.

중요한 점도 하나 있습니다. 이번 편에서는 전체 보기에서만 순서 변경이 되게 만들겠습니다.

진행 중 보기나 완료 보기 상태에서 순서를 바꾸면, 숨겨진 항목과 섞여서 움직이기 때문에 초보자 입장에서는 결과가 헷갈리기 쉽습니다. 이번 단계에서는 사용자가 덜 헷갈리는 쪽이 맞습니다.

이번 글에서 먼저 잡아둘 핵심
겉으로 보이는 변화 실제로 추가되는 것 가장 먼저 봐야 할 지점
각 항목에 위로, 아래로 버튼이 생긴다 현재 항목의 인덱스를 찾고 위치를 바꾸는 로직이 생긴다 배열 안 순서가 화면 순서와 연결된다는 점을 먼저 본다
맨 위와 맨 아래 버튼은 비활성화된다 더 이상 이동할 수 없는 상태를 코드와 UI가 같이 판단한다 버튼이 언제 잠겨야 하는지 기준이 분명한지 본다
진행 중, 완료 보기에서는 이동이 막힌다 전체 보기에서만 수동 순서 변경을 허용한다 필터된 목록과 실제 순서를 일부러 분리해서 보는 이유를 이해한다

이번 글의 핵심 한 줄
이번 기능의 핵심은 버튼 두 개를 더 넣는 게 아니라, 배열 안 순서를 바꾸면 화면 순서도 같이 바뀐다는 흐름을 직접 다뤄보는 데 있습니다.


왜 지금 순서 변경 기능이 필요한가

지금까지 만든 할 일 앱은 이미 꽤 많은 기능을 갖고 있습니다. 추가, 완료 체크, 삭제, 전체 삭제, 필터, 수정, 저장 유지까지 들어갔으니 작은 앱으로는 제법 실용적인 편입니다. 그런데 실제로 써보면 기능 개수보다 먼저 눈에 들어오는 건 내가 원하는 순서대로 정리할 수 있느냐입니다.

예를 들어 급한 일을 위로 올리고 싶을 때가 있습니다. 또는 이미 끝낸 일을 아래쪽으로 보내고 싶을 때도 있습니다. 지금까지는 이걸 하려면 삭제 후 다시 추가하거나, 텍스트를 복사해서 다시 만드는 식으로 돌아가야 했습니다. 그건 기능이 없는 앱에서 흔히 생기는 불편입니다.

  • 할 일의 우선순위를 손으로 정리할 수 있습니다.
  • 같은 목록이라도 더 쓰기 편한 상태로 바꿀 수 있습니다.
  • 배열 안의 항목 순서가 실제 화면 순서와 연결된다는 감각을 익힐 수 있습니다.
  • 기존 기능을 유지한 채 새로운 동작 하나를 더 얹는 연습이 됩니다.

핵심 포인트
이번 기능의 핵심은 "버튼을 하나 더 만든다"가 아니라, 배열 안의 순서를 바꾸면 화면 순서도 같이 바뀐다는 흐름을 몸으로 익히는 데 있습니다.


이번 편에서는 어디까지 만들 것인가

이번 편도 범위를 분명하게 잘라두는 편이 좋습니다. 순서 변경 기능이라고 해서 모든 상황을 한 번에 다 처리하려 하면 금방 복잡해집니다. 그래서 이번에는 아래 정도까지만 만들겠습니다.

  • 각 할 일 항목에 위로, 아래로 버튼을 추가합니다.
  • 맨 위 항목의 "위로" 버튼은 비활성화합니다.
  • 맨 아래 항목의 "아래로" 버튼은 비활성화합니다.
  • 순서 변경은 전체 보기에서만 가능합니다.
  • 수정 중일 때는 순서 변경 버튼도 잠깐 비활성화합니다.
  • 이동 후에는 저장까지 함께 반영되게 만듭니다.

반대로 이번 편에서는 아래 내용은 일부러 넣지 않겠습니다.

  • 드래그 앤 드롭 정렬
  • 진행 중 보기나 완료 보기에서의 순서 재정렬
  • 키보드 방향키로 이동하는 복잡한 편집 흐름
  • 순서 변경 기록을 따로 되돌리는 기능

이렇게 해야 지금 단계에서 필요한 핵심이 또렷해집니다. 이번 편에서 가장 중요한 건 "순서 변경 기능 완성"이 아니라, 현재 배열에서 항목 위치를 바꾸고 그 결과를 다시 그리는 방식을 이해하는 것입니다.


10분 실습: 순서 변경이 왜 결국 배열 문제인지 먼저 느껴보기

코드를 보기 전에 아주 짧게 상상 실험부터 해보는 편이 좋습니다. 아래처럼 세 개의 항목이 있다고 가정해보겠습니다.

[0] 장보기
[1] 메일 답장
[2] 운동하기

여기서 "메일 답장"을 위로 올린다면, 실제로는 화면을 움직이는 게 아니라 배열 안의 위치가 이렇게 바뀌는 겁니다.

[0] 메일 답장
[1] 장보기
[2] 운동하기

즉, 순서 변경 기능은 "화면에서 카드가 움직인다"처럼 느껴지지만, 실제로는 배열 안에서 항목 하나를 꺼내 다른 위치에 다시 넣는 작업에 더 가깝습니다. 이 감각이 먼저 잡히면 JavaScript가 훨씬 덜 낯설게 보입니다.

순서 변경에서 실제로 일어나는 일
겉으로 보이는 동작 실제로 필요한 계산
위로 버튼을 누른다 현재 인덱스에서 1을 뺀 위치로 옮긴다
아래로 버튼을 누른다 현재 인덱스에서 1을 더한 위치로 옮긴다
맨 위 항목은 더 못 올라간다 대상 인덱스가 0보다 작아지면 막는다
맨 아래 항목은 더 못 내려간다 대상 인덱스가 배열 길이를 넘으면 막는다

즉, 이번 기능의 핵심은 애니메이션이 아니라 현재 항목의 위치를 찾고, 다음 위치를 계산하고, 배열을 다시 정리하는 것입니다. 이 구조를 먼저 이해하고 들어가면 코드가 훨씬 단순하게 보입니다.

핵심 포인트
순서 변경 기능은 결국 배열 안에서 항목을 어디로 옮길지 계산하는 문제라고 보면 훨씬 이해하기 쉽습니다.


HTML은 구조를 그대로 두고 목록 한 줄 동작만 바꾼다

이번 기능은 의외로 HTML 최상단 구조를 크게 건드리지 않습니다. 입력창, 요약 영역, 필터 버튼, 메시지, 목록 영역은 그대로 두고, 실제 변화는 목록 한 줄을 그릴 때 일어납니다. 즉, "위로", "아래로" 버튼은 정적인 HTML에 박아두기보다 JavaScript가 각 항목을 만들 때 같이 붙여주는 쪽이 더 자연스럽습니다.

index.html은 아래처럼 유지하면 됩니다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta
    name="viewport"
    content="width=device-width, initial-scale=1.0"
  >
  <title>오늘 할 일 앱</title>
  <link rel="stylesheet" href="style.css">
  <script defer src="script.js"></script>
</head>
<body>
  <main class="app">
    <section class="panel">
      <div class="panel-header">
        <p class="eyebrow">Order Practice</p>
        <h1>오늘 할 일</h1>
        <p class="description">
          할 일 순서를 위아래로 직접 정리할 수 있게 만드는 단계입니다.
        </p>
      </div>

      <div class="composer">
        <form id="todoForm" class="todo-form">
          <label for="todoInput">할 일 입력</label>
          <div class="input-row">
            <input
              id="todoInput"
              type="text"
              placeholder="예: 우선순위 다시 정리하기"
            >
            <button
              id="submitButton"
              type="submit"
              class="primary-button"
            >
              추가
            </button>
          </div>
        </form>
      </div>

      <div class="toolbar">
        <p
          id="summaryText"
          class="summary"
        >
          전체 0개 · 남은 할 일 0개
        </p>
        <button
          id="clearAllButton"
          type="button"
          class="secondary-button"
        >
          전체 삭제
        </button>
      </div>

      <div class="filter-section">
        <div
          class="filter-group"
          role="group"
          aria-label="필터 선택"
        >
          <button
            type="button"
            class="filter-button is-active"
            data-filter="all"
            aria-pressed="true"
          >
            전체
          </button>
          <button
            type="button"
            class="filter-button"
            data-filter="active"
            aria-pressed="false"
          >
            진행 중
          </button>
          <button
            type="button"
            class="filter-button"
            data-filter="completed"
            aria-pressed="false"
          >
            완료
          </button>
        </div>
      </div>

      <p
        id="message"
        class="message"
      >
        할 일을 입력하고 추가 버튼을 눌러보세요.
      </p>

      <div class="list-section">
        <ul
          id="todoList"
          class="todo-list"
        ></ul>
      </div>
    </section>
  </main>
</body>
</html>

이번 HTML에서 중요한 건 새 태그가 많이 생겼느냐가 아닙니다. 오히려 지금 구조를 유지한 채 목록 한 줄의 구성만 JavaScript에서 바꾸게 할 수 있느냐가 더 중요합니다. 초보자 기준에서는 이 감각이 꽤 큽니다. 매번 HTML 전체를 뒤집는 방식보다, 필요한 부위만 동적으로 바꾸는 쪽이 훨씬 읽기 쉽기 때문입니다.

핵심 포인트
이번 편에서는 HTML 전체를 뜯는 것보다, 목록 한 줄 안에 어떤 버튼이 추가될지 JavaScript 쪽에서 관리하는 구조를 이해하는 편이 더 중요합니다.


CSS 수정: 이동 버튼과 비활성 상태를 분명하게 보이기

순서 변경 기능은 버튼을 누르는 것 자체도 중요하지만, 지금 이 버튼이 눌릴 수 있는 상태인지가 같이 보여야 훨씬 덜 헷갈립니다. 그래서 이번 CSS는 화려하게 꾸미는 것보다, 이동 버튼과 비활성 상태를 명확하게 읽히게 만드는 쪽이 핵심입니다.

style.css는 아래처럼 정리해보겠습니다.

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.panel-header {
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0;
  line-height: 1.7;
  color: #374151;
}

.composer {
  margin-top: 24px;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row {
  display: flex;
  gap: 12px;
}

input[type="text"] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  font: inherit;
}

button {
  font: inherit;
}

.primary-button {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button,
.inline-button,
.save-button,
.cancel-button,
.filter-button,
.move-up-button,
.move-down-button {
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
}

.secondary-button,
.delete-button,
.inline-button,
.cancel-button,
.filter-button,
.move-up-button,
.move-down-button {
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.save-button {
  border: none;
  background: #111827;
  color: #ffffff;
}

.primary-button:disabled,
.secondary-button:disabled,
.delete-button:disabled,
.inline-button:disabled,
.cancel-button:disabled,
.save-button:disabled,
.filter-button:disabled,
.move-up-button:disabled,
.move-down-button:disabled,
input:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.toolbar {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.summary {
  margin: 0;
  color: #111827;
  font-weight: 700;
  line-height: 1.6;
}

.filter-section {
  margin-top: 16px;
}

.filter-group {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.filter-button {
  border-radius: 999px;
}

.filter-button.is-active {
  background: #111827;
  color: #ffffff;
  border-color: #111827;
}

.message {
  margin: 14px 0 0;
  line-height: 1.6;
  color: #6b7280;
}

.list-section {
  margin-top: 24px;
}

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

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-item.editing {
  border-color: #111827;
  background: #ffffff;
}

.todo-left {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
  flex: 1;
}

.todo-left span {
  line-height: 1.6;
  word-break: break-word;
}

.todo-edit {
  flex: 1;
}

.edit-input {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid #111827;
  border-radius: 12px;
  font: inherit;
}

.todo-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  justify-content: flex-end;
}

.todo-item.done span {
  text-decoration: line-through;
  color: #9ca3af;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row {
    flex-direction: column;
  }

  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }

  .filter-group {
    width: 100%;
  }

  .filter-button {
    flex: 1 1 auto;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-actions {
    width: 100%;
  }

  .inline-button,
  .save-button,
  .cancel-button,
  .delete-button,
  .move-up-button,
  .move-down-button {
    flex: 1 1 auto;
  }
}

이번 CSS에서 특히 중요한 건 세 가지입니다.

  • move-up-buttonmove-down-button이 다른 보조 버튼과 자연스럽게 섞이도록 맞췄습니다.
  • 맨 위나 맨 아래에서 버튼이 비활성화되면 바로 눈에 띄게 opacity를 낮췄습니다.
  • 모바일에서는 버튼이 많아질 수 있으니 한 줄에 억지로 넣지 않고 자연스럽게 줄바꿈되도록 잡았습니다.

즉, 이번 CSS는 새로운 디자인을 만드는 작업이 아닙니다. 순서 변경 기능이 들어왔을 때 화면이 답답해지지 않도록, 그리고 사용할 수 없는 버튼이 명확하게 보이도록 정리하는 작업에 더 가깝습니다.

핵심 포인트
이동 버튼 UI의 핵심은 "예쁘게 보이기"보다 지금 눌릴 수 있는지 없는지가 분명하게 보이기입니다.


JavaScript 수정: 위로, 아래로 이동 흐름 붙이기

이번 편의 핵심은 JavaScript입니다. 순서를 바꾸는 기능은 결국 배열 안에 있는 항목의 위치를 바꾸고, 그 바뀐 배열을 다시 화면에 그려주는 흐름이기 때문입니다. 여기서 중요한 건 새로운 문법보다, 지금 어떤 항목이 몇 번째에 있는지를 읽고 바꾸는 감각입니다.

이번 JavaScript에서 먼저 봐야 할 것
이름 역할 왜 중요한가
findTodoIndex 지금 항목이 배열에서 몇 번째인지 찾습니다 순서 변경 계산의 출발점입니다
canReorderTodos 순서 변경이 가능한 상황인지 판단합니다 필터 중이거나 수정 중일 때는 일부러 막기 위해서입니다
moveTodo 실제로 배열 안 항목 위치를 바꿉니다 이번 기능의 가장 핵심 함수입니다
createMoveUpButton / createMoveDownButton 이동 버튼 생성과 비활성 기준을 함께 다룹니다 UI와 로직이 따로 놀지 않게 해줍니다

script.js는 아래처럼 정리해보겠습니다.

const STORAGE_KEY = "vibe-todos";

const FILTER_TYPES = {
  ALL: "all",
  ACTIVE: "active",
  COMPLETED: "completed"
};

const elements = {
  form: document.querySelector("#todoForm"),
  input: document.querySelector("#todoInput"),
  submitButton: document.querySelector("#submitButton"),
  list: document.querySelector("#todoList"),
  message: document.querySelector("#message"),
  summary: document.querySelector("#summaryText"),
  clearAllButton: document.querySelector("#clearAllButton"),
  filterButtons: Array.from(
    document.querySelectorAll("[data-filter]")
  )
};

let todos = loadTodos();
let currentFilter = FILTER_TYPES.ALL;
let editingTodoId = null;
let editingText = "";

function loadTodos() {
  try {
    const savedValue = localStorage.getItem(STORAGE_KEY);

    return savedValue ? JSON.parse(savedValue) : [];
  } catch (error) {
    return [];
  }
}

function saveTodos() {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify(todos)
  );
}

function setMessage(text) {
  elements.message.textContent = text;
}

function getRemainingCount() {
  return todos.filter(function (todo) {
    return !todo.done;
  }).length;
}

function getFilteredTodos() {
  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return todos.filter(function (todo) {
      return !todo.done;
    });
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return todos.filter(function (todo) {
      return todo.done;
    });
  }

  return todos;
}

function updateComposerState() {
  const isEditing = editingTodoId !== null;

  elements.input.disabled = isEditing;
  elements.submitButton.disabled = isEditing;
}

function updateSummary() {
  const totalCount = todos.length;
  const remainingCount = getRemainingCount();

  elements.summary.textContent =
    "전체 " +
    totalCount +
    "개 · 남은 할 일 " +
    remainingCount +
    "개";

  elements.clearAllButton.disabled =
    totalCount === 0 || editingTodoId !== null;
}

function updateFilterButtons() {
  const isEditing = editingTodoId !== null;

  elements.filterButtons.forEach(function (button) {
    const isActive =
      button.dataset.filter === currentFilter;

    button.classList.toggle("is-active", isActive);
    button.setAttribute(
      "aria-pressed",
      String(isActive)
    );

    button.disabled = isEditing;
  });
}

function getDefaultMessage() {
  if (todos.length === 0) {
    return "아직 등록된 할 일이 없습니다.";
  }

  if (editingTodoId !== null) {
    return "수정 중에는 저장 또는 취소를 먼저 선택해보세요.";
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return "진행 중인 할 일만 보고 있습니다. 순서 변경은 전체 보기에서 할 수 있습니다.";
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return "완료한 할 일만 보고 있습니다. 순서 변경은 전체 보기에서 할 수 있습니다.";
  }

  return "위로와 아래로 버튼으로 할 일 순서를 바꿔보세요.";
}

function getEmptyFilterMessage() {
  if (todos.length === 0) {
    return "아직 등록된 할 일이 없습니다.";
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return "진행 중인 할 일이 없습니다.";
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return "완료한 할 일이 없습니다.";
  }

  return "아직 등록된 할 일이 없습니다.";
}

function focusEditingInput() {
  const input = document.querySelector(
    "[data-edit-input=\"true\"]"
  );

  if (input) {
    input.focus();
  }
}

function startEditing(todo) {
  editingTodoId = todo.id;
  editingText = todo.text;

  renderTodos("할 일 수정 모드를 시작했습니다.");
}

function cancelEditing(messageText) {
  editingTodoId = null;
  editingText = "";

  renderTodos(messageText || "수정을 취소했습니다.");
}

function saveEditedTodo(todoId) {
  const nextText = editingText.trim();

  if (!nextText) {
    setMessage("수정 내용은 비워둘 수 없습니다.");
    focusEditingInput();
    return;
  }

  todos = todos.map(function (todo) {
    if (todo.id === todoId) {
      return {
        id: todo.id,
        text: nextText,
        done: todo.done
      };
    }

    return todo;
  });

  editingTodoId = null;
  editingText = "";
  saveTodos();
  renderTodos("할 일을 수정했습니다.");
}

function findTodoIndex(todoId) {
  return todos.findIndex(function (todo) {
    return todo.id === todoId;
  });
}

function canReorderTodos() {
  return (
    currentFilter === FILTER_TYPES.ALL &&
    editingTodoId === null
  );
}

function moveTodo(todoId, direction) {
  if (!canReorderTodos()) {
    setMessage("순서 변경은 전체 보기에서만 할 수 있습니다.");
    return;
  }

  const currentIndex = findTodoIndex(todoId);

  if (currentIndex === -1) {
    return;
  }

  const targetIndex =
    direction === "up"
      ? currentIndex - 1
      : currentIndex + 1;

  if (
    targetIndex < 0 ||
    targetIndex >= todos.length
  ) {
    return;
  }

  const movedTodo = todos.splice(currentIndex, 1)[0];

  todos.splice(targetIndex, 0, movedTodo);

  saveTodos();
  renderTodos(
    direction === "up"
      ? "할 일을 위로 옮겼습니다."
      : "할 일을 아래로 옮겼습니다."
  );
}

function createCheckbox(todo, disabled) {
  const checkbox = document.createElement("input");

  checkbox.type = "checkbox";
  checkbox.checked = todo.done;
  checkbox.disabled = Boolean(disabled);

  if (!disabled) {
    checkbox.addEventListener("change", function () {
      todo.done = checkbox.checked;
      saveTodos();
      renderTodos("할 일 상태를 업데이트했습니다.");
    });
  }

  return checkbox;
}

function createTodoText(todo) {
  const text = document.createElement("span");

  text.textContent = todo.text;

  return text;
}

function createActionButton(
  label,
  className,
  handler,
  disabled
) {
  const button = document.createElement("button");

  button.type = "button";
  button.className = className;
  button.textContent = label;
  button.disabled = Boolean(disabled);

  if (!disabled) {
    button.addEventListener("click", handler);
  }

  return button;
}

function createDeleteButton(todoId, disabled) {
  return createActionButton(
    "삭제",
    "delete-button",
    function () {
      todos = todos.filter(function (currentTodo) {
        return currentTodo.id !== todoId;
      });

      saveTodos();
      renderTodos("항목을 삭제했습니다.");
    },
    disabled
  );
}

function createEditButton(todo, disabled) {
  return createActionButton(
    "수정",
    "inline-button",
    function () {
      startEditing(todo);
    },
    disabled
  );
}

function createMoveUpButton(todoId, todoIndex, disabled) {
  return createActionButton(
    "위로",
    "move-up-button",
    function () {
      moveTodo(todoId, "up");
    },
    disabled || !canReorderTodos() || todoIndex === 0
  );
}

function createMoveDownButton(todoId, todoIndex, disabled) {
  return createActionButton(
    "아래로",
    "move-down-button",
    function () {
      moveTodo(todoId, "down");
    },
    disabled || !canReorderTodos() || todoIndex === todos.length - 1
  );
}

function createReadonlyItem(todo) {
  const item = document.createElement("li");
  const left = document.createElement("div");
  const actions = document.createElement("div");
  const todoIndex = findTodoIndex(todo.id);
  const isLocked = editingTodoId !== null;

  item.className = "todo-item";
  left.className = "todo-left";
  actions.className = "todo-actions";

  if (todo.done) {
    item.classList.add("done");
  }

  left.append(
    createCheckbox(todo, isLocked),
    createTodoText(todo)
  );

  actions.append(
    createMoveUpButton(todo.id, todoIndex, isLocked),
    createMoveDownButton(todo.id, todoIndex, isLocked),
    createEditButton(todo, isLocked),
    createDeleteButton(todo.id, isLocked)
  );

  item.append(left, actions);

  return item;
}

function createEditInput(todoId) {
  const input = document.createElement("input");

  input.type = "text";
  input.className = "edit-input";
  input.value = editingText;
  input.setAttribute("data-edit-input", "true");

  input.addEventListener("input", function () {
    editingText = input.value;
  });

  input.addEventListener("keydown", function (event) {
    if (event.key === "Enter") {
      event.preventDefault();
      saveEditedTodo(todoId);
    }

    if (event.key === "Escape") {
      event.preventDefault();
      cancelEditing();
    }
  });

  return input;
}

function createEditingItem(todo) {
  const item = document.createElement("li");
  const editArea = document.createElement("div");
  const actions = document.createElement("div");

  item.className = "todo-item editing";
  editArea.className = "todo-edit";
  actions.className = "todo-actions";

  editArea.append(
    createEditInput(todo.id)
  );

  actions.append(
    createActionButton(
      "저장",
      "save-button",
      function () {
        saveEditedTodo(todo.id);
      }
    ),
    createActionButton(
      "취소",
      "cancel-button",
      function () {
        cancelEditing();
      }
    )
  );

  item.append(editArea, actions);

  return item;
}

function renderTodos(messageText) {
  const visibleTodos = getFilteredTodos();

  elements.list.innerHTML = "";
  updateComposerState();
  updateSummary();
  updateFilterButtons();

  if (visibleTodos.length === 0) {
    setMessage(messageText || getEmptyFilterMessage());
    return;
  }

  visibleTodos.forEach(function (todo) {
    if (todo.id === editingTodoId) {
      elements.list.appendChild(
        createEditingItem(todo)
      );
      return;
    }

    elements.list.appendChild(
      createReadonlyItem(todo)
    );
  });

  setMessage(messageText || getDefaultMessage());

  if (editingTodoId !== null) {
    focusEditingInput();
  }
}

function addTodo(todoText) {
  todos.unshift({
    id: Date.now(),
    text: todoText,
    done: false
  });

  currentFilter = FILTER_TYPES.ALL;
  saveTodos();
  renderTodos("할 일을 추가했습니다.");
}

function clearAllTodos() {
  if (todos.length === 0 || editingTodoId !== null) {
    return;
  }

  const shouldClear = window.confirm(
    "정말 모든 할 일을 삭제하시겠습니까?"
  );

  if (!shouldClear) {
    setMessage("전체 삭제를 취소했습니다.");
    return;
  }

  todos = [];
  currentFilter = FILTER_TYPES.ALL;
  saveTodos();
  renderTodos("모든 항목을 삭제했습니다.");
}

function setFilter(nextFilter) {
  if (currentFilter === nextFilter || editingTodoId !== null) {
    return;
  }

  currentFilter = nextFilter;
  renderTodos();
}

function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();

  if (!text) {
    setMessage("할 일을 먼저 입력해보세요.");
    elements.input.focus();
    return;
  }

  addTodo(text);
  elements.input.value = "";
  elements.input.focus();
}

function bindFilterEvents() {
  elements.filterButtons.forEach(function (button) {
    button.addEventListener("click", function () {
      setFilter(button.dataset.filter);
    });
  });
}

function bindEvents() {
  elements.form.addEventListener(
    "submit",
    handleSubmit
  );

  elements.clearAllButton.addEventListener(
    "click",
    clearAllTodos
  );

  bindFilterEvents();
}

bindEvents();
renderTodos();

이번 코드에서 가장 먼저 봐야 할 부분은 네 군데입니다.

  • findTodoIndex : 지금 항목이 배열에서 몇 번째인지 찾습니다.
  • canReorderTodos : 전체 보기일 때만 순서 변경이 가능하도록 제한합니다.
  • moveTodo : 실제로 배열 안의 항목 위치를 바꿉니다.
  • createMoveUpButton, createMoveDownButton : 위아래 버튼을 만들고, 눌릴 수 없는 상태도 같이 정합니다.

이번 기능의 핵심은 단순합니다. 버튼을 누르면 항목을 복사해서 새로 만들거나, 텍스트를 바꾸는 게 아닙니다. 배열 안에서 현재 항목을 꺼내 다른 위치에 다시 넣고, 저장한 뒤 목록을 다시 그리는 것입니다. 이 흐름만 잡히면 순서 변경 기능의 대부분은 이해한 셈입니다.

또 하나 중요한 점은 이번 편에서 진행 중 보기와 완료 보기에서는 이동 버튼을 비활성화했다는 점입니다. 이건 구현을 못 해서가 아니라, 지금 단계에서는 이쪽이 더 맞기 때문입니다. 숨겨진 항목이 섞여 있는 상태에서 순서를 움직이면 "왜 안 보이는 항목 때문에 위치가 저렇게 바뀌지?" 같은 혼란이 생기기 쉽습니다. 초보자 실습에서는 이런 혼란을 줄이는 쪽이 맞습니다.

핵심 정리
이번 편의 JavaScript 핵심은 "위아래 버튼을 붙였다"가 아니라, 배열의 실제 순서를 바꾸고 그 결과를 다시 그린다는 점입니다.


직접 눌러보며 테스트하기

이번 기능은 버튼은 단순하지만 상태가 섞여 있기 때문에 직접 눌러보는 확인이 더 중요합니다. 이번 편에서는 아래 순서로 점검해보는 편이 좋습니다.

  • 할 일을 여러 개 추가한 뒤 위로, 아래로 버튼이 실제 순서를 바꾸는가
  • 맨 위 항목에서는 "위로" 버튼이 비활성화되는가
  • 맨 아래 항목에서는 "아래로" 버튼이 비활성화되는가
  • 진행 중 보기와 완료 보기에서는 이동 버튼이 비활성화되는가
  • 수정 버튼을 누른 상태에서는 이동 버튼이 비활성화되는가
  • 순서를 바꾼 뒤 새로고침해도 그 순서가 유지되는가
  • 수정, 삭제, 전체 삭제, 필터가 순서 변경 기능 추가 뒤에도 그대로 동작하는가

여기서 중요한 건 "버튼이 눌리느냐"보다 배열 순서와 화면 순서가 정말 같이 움직이는지, 그리고 막아야 할 상황에서는 제대로 막히는지를 보는 것입니다.

핵심 포인트
순서 변경 기능은 성공했을 때보다, 막혀야 할 순간에 제대로 막히는지까지 확인해봐야 구조가 더 분명해집니다.


Codex에게는 이렇게 나눠서 요청하면 된다

순서 변경 기능도 Codex에게 맡길 수 있습니다. 다만 이번 기능은 필터 상태와 목록 렌더링, 버튼 비활성화까지 같이 엮여 있기 때문에 더더욱 범위를 분명하게 잘라야 합니다. "순서 바꾸기 넣어줘"라고만 하면, AI가 드래그 앤 드롭까지 넓게 가거나 필터 로직을 과하게 흔들 수 있습니다.

전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.

지금 할 일 앱은 추가, 수정, 삭제, 전체 삭제,
필터, 저장 기능까지 정상 동작하는 상태야.
이번에는 각 항목에 위로, 아래로 버튼을 붙여서
순서를 바꾸는 기능만 넣고 싶어.
드래그 앤 드롭 말고 버튼 방식으로 가고,
순서 변경은 전체 보기에서만 가능하게 해줘.
기존 기능은 유지하고 어떤 파일을 왜 바꿀지 먼저 설명해줘.

JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.

@script.js
현재 목록 렌더링 구조는 유지하고,
각 항목에 위로, 아래로 버튼을 추가해줘.
배열 안에서 항목 순서를 실제로 바꾸는 방식으로 구현하고,
진행 중 보기와 완료 보기에서는 순서 변경 버튼이 비활성화되게 해줘.
수정 중에도 이동 버튼은 잠깐 막히게 해줘.
새로 생길 함수와 상태 기준부터 먼저 설명해줘.

CSS만 정리하고 싶다면 이렇게 요청할 수 있습니다.

@style.css
위로, 아래로 버튼을 기존 액션 버튼들과 자연스럽게 섞이게 해줘.
맨 위나 맨 아래에서 비활성화된 버튼은
눌릴 수 없는 상태가 바로 보이게 해줘.
모바일에서도 버튼들이 너무 답답하지 않게 줄바꿈되게 정리해줘.

이런 식으로 요청하면 AI가 기능 범위를 넘어가서 구조 전체를 흔들 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 방식도 대단한 표현보다 수정 범위와 제한 조건을 같이 주는 습관에 더 가깝습니다.

핵심 포인트
Codex에게 순서 변경 기능을 맡길 때는 "순서 바꾸기 넣어줘"보다 버튼 방식, 전체 보기만 허용, 기존 기능 유지처럼 경계를 같이 주는 편이 훨씬 낫습니다.


마무리

이번 편에서 추가한 기능은 거창하지 않습니다. 위로, 아래로 버튼이 생겼고, 전체 보기에서만 순서가 바뀌도록 한 정도입니다. 그런데 실제로 앱을 써보면 이 차이가 꽤 큽니다. 이제는 단순히 적고 지우는 수준을 넘어서, 내가 일할 순서에 맞게 목록을 직접 정리할 수 있는 상태가 되었기 때문입니다.

초보자에게 지금 중요한 건 기능 숫자를 무작정 늘리는 일이 아닙니다. 하나의 기능을 넣을 때 기존 구조가 어디까지 영향을 받는지, 그리고 그 상태를 내가 끝까지 읽을 수 있는지가 더 중요합니다. 이번 순서 변경 기능은 그 연습으로 꽤 잘 맞습니다. 직접 몇 번 써보면, 지금 앱에서 어디가 더 편해졌고 어디가 아직 거슬리는지도 훨씬 선명하게 보이기 시작합니다.

여기까지 직접 해보면, 이제 할 일 앱은 단순히 목록을 보여주는 수준을 조금 넘어서기 시작합니다. 같은 데이터라도 내가 손으로 정렬하고, 그 결과를 다시 저장하고, 다음에 다시 열었을 때도 그 순서를 유지하는 흐름이 들어오기 때문입니다. 그 차이가 생각보다 큽니다.