체크리스트 앱을 만들다 보면 저장과 삭제까지는 비교적 빠르게 붙는 경우가 많습니다. 항목도 남고, 새로고침해도 그대로고, 삭제 버튼도 어느 정도 돌아갑니다. 그런데 그다음에 완료 체크를 붙이려는 순간부터 묘하게 복잡해집니다. 체크를 했는데 새로고침하면 풀려 있거나, 화면에서는 완료처럼 보이는데 실제 데이터는 안 바뀌어 있거나, 완료한 항목만 따로 보고 싶어졌는데 구조가 갑자기 꼬이는 식입니다.

이 시점에서 필요한 건 새로운 마법 같은 기능이 아닙니다. 오히려 더 기본적인 감각에 가깝습니다. “이 항목은 끝났는가, 아직 아닌가”를 데이터 안에서 어떻게 기록할지, 그리고 그 값을 기준으로 화면을 어떻게 다시 그릴지를 이해해야 합니다. 여기서 자주 등장하는 값이 바로 done입니다.

이번 글에서는 done이 정확히 어떤 역할을 하는지, 왜 단순히 체크 모양만 바꾸는 것으로는 부족한지, 완료 상태가 바뀔 때 데이터와 화면이 어떤 순서로 움직여야 하는지, 그리고 완료/미완료 필터를 붙이기 전에 어떤 구조가 먼저 갖춰져야 하는지까지 차근차근 정리해보겠습니다.

이번 글에서 먼저 잡아둘 핵심

상황 겉으로 보이는 문제 실제로 필요한 감각
체크는 되는데 새로고침하면 풀린다 화면만 바뀌고 저장값은 그대로다 상태 변경 후 저장까지 이어져야 한다
완료한 항목을 따로 보여주고 싶다 목록이 점점 복잡해진다 데이터와 렌더링을 분리해서 봐야 한다
체크 상태가 자꾸 엉뚱한 항목에 붙는다 화면 기준으로만 처리하고 있다 id를 기준으로 done 값을 바꿔야 한다

이번 글의 핵심 한 줄
완료 체크는 디자인 효과가 아니라 데이터 상태 변경이고, 그 상태를 기준으로 화면을 다시 그리는 것이 핵심이다.


왜 체크 기능부터 갑자기 복잡해질까

입문자 눈에는 체크 기능이 단순해 보입니다. 박스 하나 누르면 끝난 일인지 아닌지만 바뀌면 될 것 같기 때문입니다. 실제로 화면만 보면 맞는 말입니다. 문제는 그 변화가 단순히 보이는 모양의 변화가 아니라, 항목의 상태 자체가 바뀌는 일이라는 점입니다.

예를 들어 삭제는 항목 하나를 없애는 일이라 비교적 분명합니다. 수정도 글자만 바꾸면 된다는 감각이 있습니다. 하지만 완료 체크는 조금 다릅니다. 항목은 그대로 남아 있는데, 그 항목의 속성 하나만 달라집니다. 그리고 그 변화가 저장돼야 하고, 나중에 완료 항목만 따로 보여주고 싶을 수도 있습니다. 즉, 작은 기능처럼 보여도 생각보다 많은 곳과 연결됩니다.

 

기능 겉보기 동작 데이터에서 실제로 일어나는 일
추가 항목 하나가 생긴다 배열 끝에 새 객체 하나가 들어간다
삭제 항목 하나가 사라진다 배열에서 특정 객체 하나를 제거한다
수정 글자가 바뀐다 특정 객체의 text 값이 바뀐다
완료 체크 모양은 거의 그대로인데 상태만 바뀐다 특정 객체의 done 값이 바뀐다

즉, 체크 기능이 어렵게 느껴지는 건 기능이 대단해서가 아니라, 보이는 변화보다 안쪽의 상태 변화가 더 중요한 기능이기 때문입니다. 이 감각이 잡히면 이후에 완료 항목 숨기기, 완료 개수 세기, 완료된 항목 회색 처리 같은 기능도 훨씬 덜 막막해집니다.

중요한 포인트
체크 기능은 “체크박스를 그리는 일”이 아니라 “항목 상태를 기록하는 일”로 받아들이는 편이 훨씬 정확하다.


done 값은 정확히 무엇인가

done은 말 그대로 “이 할 일이 끝났는가”를 나타내는 값입니다. 보통 체크리스트 예시에서는 아래처럼 객체 안에 함께 들어 있습니다.

{

id: 1710000000001,
text: '장보기',
done: false
}

여기서 id는 이 항목이 누구인지 구분해주는 번호표이고, text는 사용자가 보는 실제 내용입니다. 그리고 done은 이 항목이 완료된 상태인지 아닌지를 나타냅니다. 아직 끝나지 않았다면 false, 완료됐다면 true가 들어갑니다.

필드 의미 왜 필요한가
id 항목을 구분하는 고유 값 삭제, 수정, 체크 변경 시 정확한 대상을 찾기 위해
text 할 일 내용 사용자가 화면에서 읽는 정보
done 완료 여부 체크 상태, 스타일, 필터, 개수 계산 등에 필요

이 값을 객체 안에 함께 넣는 이유는 단순합니다. 체크 여부는 화면의 장식이 아니라 항목이 가진 정보 중 하나이기 때문입니다. 예를 들어 장보기라는 항목이 있다고 해서 항상 미완료인 것은 아닙니다. 같은 항목이라도 어떤 날은 끝났고 어떤 날은 아닐 수 있습니다. 따라서 완료 여부는 따로 기억되어야 하는 데이터입니다.

이 개념이 생기면 여러 가지가 가능해집니다.

  • 완료된 항목만 따로 모아서 보기
  • 미완료 항목 개수만 세기
  • 완료된 항목에 취소선 넣기
  • 완료된 항목을 아래로 보내기
  • 완료/미완료 기준으로 필터 버튼 만들기

즉, done은 체크박스와 연결된 작은 값처럼 보이지만, 실제로는 이후 기능 확장의 중심이 되는 경우가 많습니다.


문자열이 아니라 true/false로 다루는 이유

초보자가 처음 상태값을 다룰 때 자주 하는 고민이 있습니다. 완료 여부를 '완료', '미완료'처럼 글자로 넣어도 되지 않나 하는 생각입니다. 아주 작은 예제에서는 그렇게 해도 돌아갈 수 있습니다. 하지만 보통은 참과 거짓을 나타내는 truefalse로 다루는 편이 훨씬 안정적입니다.

방식 예시 처음엔 왜 쉬워 보이나 나중에 왜 불편해지나
문자열 '완료', '미완료' 사람 눈에는 바로 읽힌다 오타, 비교 조건 증가, 언어 변경 시 수정이 많아진다
true / false true, false 처음엔 조금 추상적으로 보인다 조건문, 필터, 토글 처리에 훨씬 간단하고 안정적이다

예를 들어 완료되지 않은 항목만 보고 싶다면 아래처럼 쓸 수 있습니다.

const activeTodos = todos.filter(todo => !todo.done);

완료된 항목만 보고 싶다면 이렇게 됩니다.

const completedTodos = todos.filter(todo => todo.done);

이처럼 true, false는 조건을 다루기에 아주 자연스럽습니다. 반대로 문자열로 처리하면 todo.status === '완료' 같은 비교를 계속 써야 하고, 오타나 표현 변경에도 민감해집니다. 나중에 언어를 바꾸거나 버튼 문구를 바꿀 때도 데이터 구조까지 흔들릴 수 있습니다.

입문자 기준으로 이해하면
보여주는 말은 화면에서 처리하고, 상태를 기록하는 값은 데이터 안에서 단순하게 유지하는 편이 훨씬 덜 꼬인다.


완료 상태를 바꾸는 기본 흐름

완료 체크 기능의 핵심 흐름은 의외로 단순합니다. 대상을 찾고, done 값을 바꾸고, 저장하고, 다시 그린다. 결국 이 네 단계입니다. 여기서 중요한 건 “체크 모양을 바꾸는 것”보다 “데이터 안의 값이 실제로 바뀌는 것”입니다.

예를 들어 특정 id를 가진 항목의 완료 상태를 뒤집는 함수는 아래처럼 만들 수 있습니다.

function toggleTodo(id) {

todos = todos.map(todo =>
todo.id === id
? { ...todo, done: !todo.done }
: todo
);

saveTodos();
renderTodos();
}

이 코드에서 중요한 부분은 세 군데입니다.

  1. todo.id === id
    어느 항목을 바꿀지 정확히 찾는 부분입니다. 앞선 글에서 다룬 id가 여기서 다시 중요해집니다.
  2. done: !todo.done
    현재 값이 falsetrue로, truefalse로 뒤집습니다. 체크/체크 해제를 한 번에 처리하는 방식입니다.
  3. saveTodos(); renderTodos();
    데이터를 바꾼 뒤 저장하고 화면을 다시 그립니다. 이 순서가 빠지면 체크 상태가 유지되지 않거나 화면과 저장값이 어긋날 수 있습니다.
순서 무엇을 하는가 왜 필요한가
1 id로 대상 항목을 찾는다 엉뚱한 항목의 상태가 바뀌는 것을 막는다
2 done 값을 뒤집는다 완료와 미완료 사이를 오갈 수 있게 한다
3 바뀐 데이터를 저장한다 새로고침 후에도 상태가 유지되게 한다
4 화면을 다시 그린다 체크 상태가 시각적으로 반영되게 한다

결국 체크 기능은 버튼 클릭에 반응하는 UI처럼 보여도, 안에서는 수정 기능과 꽤 비슷합니다. 특정 항목을 찾아서 그 항목의 값 하나를 바꾸는 일이기 때문입니다. 차이라면 text 대신 done을 바꾼다는 점 정도입니다.


렌더링 구조가 중요해지는 이유

여기서부터는 많은 초보자가 슬슬 막히기 시작합니다. 체크는 분명 됐는데 화면이 어색하거나, 취소선을 넣었더니 다시 그릴 때 풀려 버리거나, 저장은 됐는데 체크박스 표시와 실제 데이터가 서로 다르게 보이는 경우가 생깁니다. 이때 중요한 개념이 렌더링입니다.

렌더링은 쉽게 말해 지금 데이터 상태를 기준으로 화면을 다시 만드는 과정입니다. 체크리스트에서는 이 개념이 특히 중요합니다. 완료 여부가 바뀌면 화면에서도 그 차이가 보여야 하기 때문입니다. 예를 들어 완료된 항목은 취소선을 넣고, 미완료 항목은 일반 글씨로 두고 싶다면 화면은 항상 todo.done 값을 보고 그려져야 합니다.

function renderTodos() {

todoList.innerHTML = '';

todos.forEach(todo => {
const li = document.createElement('li');

if (todo.done) {
  li.style.textDecoration = 'line-through';
  li.style.opacity = '0.6';
}

const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = todo.done;

checkbox.addEventListener('change', () => {
  toggleTodo(todo.id);
});

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

li.appendChild(checkbox);
li.appendChild(span);
todoList.appendChild(li);

});
}

이 코드에서 핵심은 화면이 먼저가 아니라는 점입니다. 화면은 항상 데이터의 결과여야 합니다. 다시 말해, 아래 순서로 생각하는 편이 좋습니다.

체크박스 모양을 바꾼다

→ 상태가 바뀌는 것이 아니다

done 값을 바꾼다
→ 그 결과를 기준으로 화면을 다시 그린다
→ 체크박스 모양과 스타일이 함께 바뀐다

입문자는 종종 이 순서를 거꾸로 생각합니다. 예를 들어 클릭했을 때 체크박스 모양이나 글자 스타일만 먼저 바꾸고, 실제 데이터는 안 건드리는 식입니다. 그 순간에는 된 것처럼 보여도, 저장이나 새로고침, 필터를 붙이기 시작하면 바로 흔들립니다.

렌더링을 쉽게 이해하면
화면은 정답을 들고 있는 곳이 아니라, 데이터가 현재 어떤 상태인지 보여주는 결과판에 가깝다.


완료/미완료 나누기와 필터는 어떻게 붙을까

done 값이 제대로 관리되기 시작하면 그다음으로 욕심나는 기능이 보통 필터입니다. 전체 보기, 미완료만 보기, 완료만 보기 같은 기능은 체크리스트 앱에서 거의 자연스럽게 따라옵니다. 그런데 이 기능도 사실은 새로운 마법이 아니라, 이미 있는 done 값을 기준으로 어떤 항목을 화면에 보여줄지 고르는 일에 가깝습니다.

예를 들어 현재 필터 상태를 문자열 하나로 들고 있다고 해보겠습니다.

let currentFilter = 'all';

그다음 렌더링 전에 보여줄 목록만 한 번 추려낼 수 있습니다.

function getFilteredTodos() {

if (currentFilter === 'active') {
return todos.filter(todo => !todo.done);
}

if (currentFilter === 'completed') {
return todos.filter(todo => todo.done);
}

return todos;
}

그리고 렌더링에서는 원래 전체 todos 대신 필터된 목록을 그리면 됩니다.

const visibleTodos = getFilteredTodos();

visibleTodos.forEach(todo => {
// 화면에 그리기
});
필터 값 실제로 보여줄 목록
all 전체 보기 모든 항목
active 미완료만 보기 donefalse인 항목
completed 완료만 보기 donetrue인 항목

여기서도 중요한 건 필터가 데이터를 지우는 기능이 아니라는 점입니다. 화면에서 잠깐 보이게 하거나 안 보이게 할 뿐입니다. 이 차이를 놓치면 “완료 항목 숨기기” 버튼을 눌렀는데 실제 데이터를 지워버리는 실수가 생길 수 있습니다.

필터를 이해하는 핵심
필터는 데이터를 바꾸는 기능이 아니라, 이미 있는 데이터 중 지금 무엇을 보여줄지 고르는 기능이다.


초보자가 자주 하는 실수 6가지

done 상태를 처음 붙일 때는 아주 비슷한 곳에서 자주 꼬입니다. 코드가 길어 보여도 실제 원인은 반복되는 편입니다. 아래 실수들을 먼저 알고 있으면 시행착오를 꽤 줄일 수 있습니다.

 

실수 겉으로 보이는 증상 실제로 빠진 부분 어떻게 보는 게 좋은가
체크박스 모양만 바꾼다 그 순간엔 체크된 것처럼 보이지만 새로고침하면 풀린다 데이터의 done 값은 안 바꿨다 UI 변경보다 상태값 변경이 먼저여야 한다
상태는 바꿨는데 저장을 안 한다 화면에서는 바뀌지만 다시 열면 예전 상태다 saveTodos() 호출이 빠졌다 상태 변경 후 저장까지 한 묶음으로 본다
저장은 했는데 다시 그리지 않는다 실제 데이터는 바뀌었는데 화면이 그대로다 renderTodos()가 빠졌다 저장은 기록, 렌더링은 반영이라고 나눠서 본다
id 대신 index로 완료 상태를 바꾼다 필터나 정렬 후 엉뚱한 항목이 체크된다 화면 순서를 데이터 정체성으로 착각했다 상태 변경 대상은 id로 찾는 편이 안전하다
문자열로 상태를 관리한다 비교 조건이 늘고 오타에 약해진다 true/false 대신 표시용 문구를 데이터에 넣었다 보여주는 말과 저장하는 값은 분리하는 편이 낫다
필터가 데이터를 지운다고 착각한다 숨기기 기능이 삭제처럼 동작하게 만들기 쉽다 보여주기와 데이터 변경을 섞었다 필터는 보여줄 목록만 바꾸는 기능으로 이해한다

특히 첫 번째 실수는 정말 흔합니다. 체크박스를 클릭했을 때 브라우저가 기본적으로 모양을 바꿔주기 때문에, 마치 기능이 완성된 것처럼 느껴집니다. 하지만 데이터 안의 done 값이 그대로면 그건 실제 완료 처리라고 보기 어렵습니다. 잠깐 보이는 모양만 달라진 상태에 가깝습니다.

실전에서 가장 자주 놓치는 부분
체크 기능은 체크박스가 스스로 해주는 것처럼 보여도, 실제 앱 관점에서는 데이터 상태를 직접 바꾸고 다시 저장하고 다시 그리는 과정까지 포함해야 한다.


마무리

체크 기능은 보기엔 작아 보여도, 바이브코딩에서 꽤 중요한 전환점이 됩니다. 여기서부터는 단순히 목록을 보여주는 수준을 넘어서, 각 항목이 가진 상태를 데이터로 관리해야 하기 때문입니다. 이 흐름을 한 번 이해하면 체크리스트 앱이 조금 더 “진짜 도구”처럼 보이기 시작합니다.

이번 글에서 꼭 가져가면 좋은 건 네 가지입니다. 첫째, 완료 여부는 화면 장식이 아니라 데이터 안의 상태값이라는 점. 둘째, 그 상태는 done처럼 단순한 true/false 값으로 다루는 편이 좋다는 점. 셋째, 상태를 바꾼 뒤에는 저장과 렌더링이 함께 따라와야 한다는 점. 넷째, 필터는 데이터를 지우는 기능이 아니라 보여줄 범위를 고르는 기능이라는 점입니다.

여기까지 오면 이제 할 일 앱 구조가 꽤 단단해집니다. 다음 편에서는 이벤트가 하나둘 늘어나기 시작할 때 코드가 왜 갑자기 지저분해지는지, 그리고 함수 분리와 역할 나누기를 초보자 기준에서 어떻게 이해하면 좋은지를 다루겠습니다.