
필터 기능까지 붙이고 나면, 이제 할 일 앱이 얼핏 그럴듯해 보이기 시작합니다. 그런데 실제로 조금만 써보면 금방 아쉬운 지점이 보입니다. 잘못 입력한 항목을 다시 쓰려면 지우고 새로 추가해야 하고, 문구를 조금만 바꾸고 싶어도 삭제 후 재입력을 해야 합니다. 이건 작은 불편 같지만, 실제로는 꽤 자주 걸리는 부분입니다.
그래서 이번 편에서는 수정 기능을 붙여보겠습니다. 다만 이번에도 범위를 크게 벌리지 않겠습니다. 한 번에 여러 항목을 수정하는 방식까지 넣기 시작하면 흐름이 금방 커집니다. 초보자에게 지금 필요한 건 "수정 기능이란 게 이런 구조로 붙는구나"를 이해하는 것이지, 모든 경우를 다 처리하는 것이 아니기 때문입니다.
이번 글에서는 한 번에 하나의 항목만 수정할 수 있게 만들겠습니다. 수정 버튼을 누르면 그 항목이 입력 상태로 바뀌고, 저장과 취소 중 하나를 선택할 수 있게 만들겠습니다. 이렇게 해야 상태가 너무 복잡해지지 않고, JavaScript에서 무엇을 따로 기억해야 하는지도 비교적 또렷하게 보입니다.
| 겉으로 보이는 변화 | 실제로 추가되는 것 | 가장 먼저 봐야 할 지점 |
|---|---|---|
| 각 항목에 수정 버튼이 생긴다 | editingTodoId, editingText 같은 편집 상태가 추가된다 | 지금 어느 항목을 수정 중인지 따로 기억하는지 본다 |
| 저장과 취소 버튼이 보인다 | 편집용 입력창과 저장 흐름이 따로 생긴다 | 기존 목록 한 줄과 편집 중 한 줄이 어떻게 다르게 렌더링되는지 본다 |
| 한 항목을 수정하는 동안 다른 버튼이 잠깐 막힌다 | 동시에 여러 흐름이 섞이지 않도록 상호작용 잠금이 생긴다 | 단순한 UX를 위해 무엇을 일부러 막아두는지 본다 |
이번 글의 핵심 한 줄
수정 기능의 핵심은 버튼 하나를 더 넣는 데 있지 않습니다. 지금 어느 항목을 수정 중인지 기억하고, 그 상태에 따라 화면 한 줄의 구조를 바꾸는 것에 더 가깝습니다.
왜 지금 수정 기능을 붙이는가

지금 단계에서 수정 기능은 꽤 좋은 다음 단계입니다. 이유는 단순합니다. 새 항목 추가, 완료 체크, 삭제, 전체 삭제, 필터까지 해봤다면 이제는 기존 데이터를 바꾸는 흐름을 익혀볼 차례이기 때문입니다. 추가는 새 항목을 넣는 동작이고, 삭제는 항목을 없애는 동작입니다. 수정은 그 중간에 있습니다. 이미 있는 값을 읽어와서, 바꿔서, 다시 저장해야 합니다.
이 과정에는 생각보다 많은 감각이 들어 있습니다. 지금 어떤 항목을 수정 중인지 기억해야 하고, 화면도 그 상태에 맞게 바뀌어야 하고, 저장했을 때는 목록과 저장 데이터가 같이 바뀌어야 합니다. 겉으로는 버튼 하나 더 생기는 것처럼 보여도, 실제로는 상태 관리 연습에 꽤 잘 맞는 기능입니다.
- 기존 데이터를 불러와 수정하는 흐름을 익힐 수 있습니다.
- 현재 수정 중인 상태를 따로 기억하는 방식을 배울 수 있습니다.
- 취소와 저장처럼 분기되는 동작을 다루는 연습이 됩니다.
- 기존 기능을 유지한 채 새로운 기능을 추가하는 감각을 익히기 좋습니다.
핵심 포인트
수정 기능은 단순히 버튼 하나를 더 넣는 일이 아니라, 현재 상태를 따로 기억하고 화면에 반영하는 연습에 가깝습니다.
이번 편에서는 어디까지 만들 것인가
이번 편도 범위를 좁게 잡겠습니다. 수정 기능이라고 해도 여러 방식이 있을 수 있지만, 처음에는 아래 정도만 만들면 충분합니다.
- 각 할 일 항목에 수정 버튼을 추가합니다.
- 수정 버튼을 누르면 그 항목이 입력 상태로 바뀝니다.
- 저장을 누르면 수정한 내용이 반영됩니다.
- 취소를 누르면 원래 값으로 돌아갑니다.
- 수정 내용이 비어 있으면 저장되지 않게 막습니다.
- 수정 중에는 다른 항목 버튼과 필터 버튼을 잠깐 비활성화합니다.
반대로 이번 편에서는 아래 내용은 일부러 넣지 않겠습니다.
- 여러 항목을 동시에 수정하는 기능
- 수정 중인 내용을 따로 임시 저장하는 기능
- 키보드 단축키를 많이 넣는 복잡한 편집 흐름
- 완료 상태까지 한 화면에서 동시에 바꾸는 복합 편집
이렇게 범위를 줄이는 이유는 분명합니다. 지금은 수정 기능의 핵심 구조를 이해하는 게 먼저이기 때문입니다. 범위가 넓어지면 "왜 이렇게 동작하는지"보다 "어떻게든 되게 만드는 것"에만 매달리기 쉽습니다.
10분 실습: 수정 기능이 왜 상태 기능인지 먼저 느껴보기

코드를 보기 전에 아주 짧게 흐름을 먼저 상상해보는 편이 좋습니다. 아래처럼 항목 하나가 있다고 가정해보겠습니다.
[ ] JavaScript 복습하기 [수정] [삭제]
여기서 수정 버튼을 누르면, 그냥 텍스트만 바뀌는 게 아닙니다. 화면 한 줄의 모양 자체가 바뀌어야 합니다.
[입력창: JavaScript 복습하기] [저장] [취소]
즉, 수정 기능은 "값을 하나 바꾸는 기능"이라기보다, 현재 이 항목이 평소 상태인지, 수정 중 상태인지에 따라 한 줄 전체를 다르게 그리는 기능에 가깝습니다. 이 감각이 먼저 잡히면 JavaScript 코드가 훨씬 덜 낯설어집니다.
| 겉으로 보이는 변화 | 뒤에서 필요한 상태 |
|---|---|
| 수정 버튼을 누르면 입력창이 나타난다 | 지금 수정 중인 항목 번호가 필요하다 |
| 입력 중 내용이 바뀐다 | 현재 수정 중인 문자열을 따로 기억해야 한다 |
| 저장과 취소가 분기된다 | 편집 모드 종료 규칙과 저장 규칙이 필요하다 |
즉, 이번 기능의 핵심은 텍스트 필드 하나를 더 만드는 데 있지 않습니다. editingTodoId와 editingText 같은 상태가 있어야 이 한 줄의 모양을 바꿀 수 있다는 점을 먼저 잡고 들어가면 훨씬 편합니다.
핵심 포인트
수정 기능은 "글자를 고친다"보다 "지금 수정 중인 항목을 따로 기억한다"는 쪽이 먼저입니다.
HTML은 구조를 유지하고 목록 한 줄만 다르게 그린다
이번 기능은 의외로 HTML 최상단 구조를 많이 바꾸지 않습니다. 수정 입력창과 저장, 취소 버튼은 목록을 그릴 때 JavaScript가 각 항목 안에 동적으로 넣어줄 것이기 때문입니다. 그래서 이번 편에서는 HTML 전체 구조를 유지하면서도, 코드가 한눈에 읽히도록 정렬을 분명하게 맞춘 상태로 보는 게 더 중요합니다.
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">Edit 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,
.cancel-button,
.filter-button {
border-radius: 14px;
padding: 12px 14px;
font-weight: 700;
cursor: pointer;
background: #ffffff;
color: #111827;
border: 1px solid #d1d5db;
}
.save-button {
border: none;
border-radius: 14px;
padding: 12px 14px;
font-weight: 700;
cursor: pointer;
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,
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 {
flex: 1 1 auto;
}
}
이번 CSS에서 가장 중요한 부분은 네 군데입니다.
- todo-item editing : 수정 중인 카드가 일반 카드와 구분되게 만듭니다.
- edit-input : 항목 수정용 입력창이 일반 입력창과 섞이지 않게 잡아줍니다.
- todo-actions : 수정, 저장, 취소, 삭제 버튼을 한 묶음으로 다룹니다.
- save-button : 저장 버튼은 더 중요한 동작처럼 보이게 구분합니다.
즉, 이번 CSS는 새로운 화면을 만드는 작업이 아니라 수정 상태와 일반 상태를 다르게 읽히게 만드는 작업에 가깝습니다. 이런 구분이 있어야 사용자도 지금 "그냥 보고 있는지", "실제로 편집 중인지"를 헷갈리지 않습니다.
핵심 포인트
수정 기능의 CSS는 예쁘게 꾸미는 게 아니라, 지금 이 항목이 편집 중이라는 걸 눈으로 바로 알 수 있게 만드는 것이 핵심입니다.
JavaScript 수정: 수정 상태, 저장, 취소 흐름 붙이기
이번 편의 핵심은 JavaScript입니다. 수정 기능은 결국 지금 어느 항목을 수정 중인지를 따로 기억하고, 그 상태에 맞게 목록 한 줄의 모양과 동작을 바꾸는 작업이기 때문입니다.
| 이름 | 역할 | 왜 중요한가 |
|---|---|---|
| editingTodoId | 지금 어떤 항목을 수정 중인지 기억합니다 | 수정 기능의 핵심 상태입니다 |
| editingText | 입력 중인 초안 문자열을 따로 기억합니다 | 입력창 안의 현재 내용을 저장과 취소 흐름에 연결합니다 |
| createReadonlyItem / createEditingItem | 같은 항목도 상태에 따라 다른 구조로 렌더링합니다 | 목록 한 줄의 모양이 바뀌는 핵심 지점입니다 |
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 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 createReadonlyItem(todo) {
const item = document.createElement("li");
const left = document.createElement("div");
const actions = document.createElement("div");
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(
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();
이번 코드에서 가장 먼저 봐야 할 건 세 가지입니다.
- editingTodoId : 지금 어느 항목을 수정 중인지 기억합니다.
- editingText : 입력창 안의 현재 문자열을 따로 들고 있습니다.
- createReadonlyItem과 createEditingItem : 같은 할 일 항목이라도 일반 보기와 수정 보기일 때 화면을 다르게 만듭니다.
즉, 이번 기능의 핵심은 "수정 버튼을 눌렀다"가 아닙니다. 실제 핵심은 지금 어떤 항목이 수정 중인지 상태를 따로 기억하고, 그 상태에 따라 한 줄의 렌더링 방식을 바꾸는 것입니다. 이 감각이 잡히면 이후 기능도 훨씬 수월해집니다.

또 하나 중요한 점은 한 번에 하나만 수정하도록 일부러 제한했다는 점입니다. 여러 항목을 동시에 수정하게 만들 수도 있지만, 지금 단계에서는 그게 별로 좋은 선택이 아닙니다. 상태가 급격히 복잡해지고, 저장과 취소가 섞이기 시작하면 어디가 꼬였는지 읽기 어려워지기 때문입니다.
그리고 이번 버전에서는 수정 중일 때 다른 조작을 잠깐 막았습니다. 이건 기능 부족이 아니라, 초보자 기준에서 더 단순한 흐름을 주기 위한 선택입니다. 저장과 취소가 끝난 뒤 다른 조작으로 넘어가는 쪽이 훨씬 덜 헷갈립니다.
핵심 정리
이번 편의 JavaScript 핵심은 "수정 버튼 추가"가 아니라, 수정 중인 항목을 따로 기억하고 그 상태에 맞는 화면을 그린다는 점입니다.
직접 눌러보며 테스트하기
이번 기능은 화면 변화와 상태 변화가 같이 일어나기 때문에, 직접 눌러보는 확인 과정이 더 중요합니다. 이번 편에서는 아래 순서로 점검해보는 편이 좋습니다.
- 수정 버튼을 누르면 해당 항목만 입력창으로 바뀌는가
- 입력창에 기존 텍스트가 제대로 들어오는가
- 저장을 누르면 수정 내용이 반영되는가
- 취소를 누르면 원래 목록 상태로 돌아가는가
- 빈 문자열로 저장하려고 하면 막히는가
- 수정 중에는 다른 수정 버튼, 삭제 버튼, 필터 버튼이 잠깐 비활성화되는가
- 완료 체크, 전체 삭제, 새로고침 후 유지가 수정 기능 추가 뒤에도 그대로 동작하는가
특히 이번 편에서는 "입력창이 뜨는가"보다 저장과 취소 뒤 상태가 어떻게 정리되는가를 꼭 봐야 합니다. 수정 기능은 화면을 바꾸는 것보다 상태를 닫는 타이밍이 더 중요해지는 경우가 많기 때문입니다.
핵심 포인트
수정 기능은 시작보다 저장 뒤와 취소 뒤에 상태가 어떻게 정리되는지를 직접 눌러보는 게 더 중요합니다.
Codex에게는 이렇게 나눠서 요청하면 된다
수정 기능도 Codex에게 맡길 수 있습니다. 다만 이번 기능은 HTML보다 JavaScript 쪽 로직이 크게 바뀌기 때문에, 요청 범위를 더 분명하게 자르는 편이 좋습니다. "수정 기능 넣어줘"라고 한 줄로 던지면 AI가 예상보다 넓게 흔들 가능성이 큽니다.
전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.
지금 할 일 앱은 추가, 완료 체크, 삭제, 전체 삭제,
필터, 저장 기능까지 정상 동작하는 상태야.
이번에는 각 항목에 수정 기능만 추가하고 싶어.
한 번에 하나의 항목만 수정할 수 있게 하고,
저장과 취소 흐름이 분명하게 보이게 해줘.
기존 기능은 유지하고 구조도 크게 뒤집지 말아줘.
먼저 어떤 파일을 왜 바꿀지부터 설명해줘.
JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.
@script.js
지금 목록 렌더링 구조는 유지하고,
수정 버튼을 누르면 해당 항목만 입력창으로 바뀌게 해줘.
저장과 취소 버튼도 필요하고,
수정 내용이 비어 있으면 저장되지 않게 막아줘.
한 번에 하나만 수정할 수 있게 하고,
수정 중인 항목을 어떤 변수로 관리할지도 먼저 설명해줘.
CSS만 정리하고 싶다면 이렇게 더 작게 요청할 수 있습니다.
@style.css
수정 모드일 때 항목 카드가 일반 상태와 구분되게 해줘.
수정 입력창, 저장 버튼, 취소 버튼이 한눈에 보이게 하고,
모바일에서도 버튼이 너무 답답하지 않게 정리해줘.
전체 디자인을 갈아엎지 말고 필요한 부분만 추가해줘.
이런 식으로 나누면 AI가 기능 추가와 전면 개편을 섞을 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 것도 화려한 문장보다 수정 범위를 정확하게 제한하는 습관에 더 가까운 경우가 많습니다.
핵심 포인트
Codex에게 수정 기능을 맡길 때는 "수정 기능 넣어줘"보다 한 번에 하나만, 저장과 취소 포함, 기존 기능 유지처럼 경계를 같이 주는 편이 훨씬 안정적입니다.
마무리
이번 편에서 추가한 기능은 아주 크지 않습니다. 각 항목에 수정 버튼이 생기고, 저장과 취소 흐름이 들어갔을 뿐입니다. 그런데 바로 이런 기능이 프로젝트를 "조금 더 실제로 쓸 만한 도구"처럼 보이게 만듭니다.
초보자에게 지금 중요한 건 새로운 문법을 많이 아는 것이 아닙니다. 이미 있는 데이터를 어떻게 불러와서, 상태를 따로 기억하고, 화면에 다시 반영하는지 끝까지 따라가는 경험이 더 중요합니다. 수정 기능은 그 연습으로 꽤 잘 맞는 기능입니다.
여기까지 직접 해보면, 이제 할 일 앱은 단순히 적고 지우는 수준을 조금 넘어서기 시작합니다. 한 번 입력한 내용을 나중에 다시 손보고, 그 손본 결과를 저장하고, 다시 화면에 반영하는 흐름까지 들어오기 때문입니다. 그 차이가 생각보다 큽니다.
'바이브코딩 > 실습' 카테고리의 다른 글
| 바이브코딩 15편: 할 일 앱에 검색 기능 붙이기 (0) | 2026.04.03 |
|---|---|
| 바이브코딩 14편: 할 일 순서를 위아래로 바꾸기 (0) | 2026.04.01 |
| 바이브코딩 12편: 할 일 앱에 전체·진행 중·완료 필터 붙이기 (0) | 2026.03.27 |
| 바이브코딩 11편: 기능은 그대로 두고 할 일 앱 구조만 다듬기 (0) | 2026.03.25 |
| 바이브코딩 10편: 배포한 할 일 앱을 작게 개선해보기 (0) | 2026.03.23 |
