체크리스트 앱에 검색창이나 필터 버튼을 붙이기 시작하면, 분위기가 조금 달라집니다. 저장도 되고, 수정도 되고, 완료 체크도 되는데 막상 검색을 붙인 뒤부터 이상한 일이 생깁니다. “장”이라고 검색했더니 목록이 줄어드는 것까지는 괜찮은데, 검색어를 지웠는데도 원래 목록이 돌아오지 않거나, 완료 항목만 보기 버튼을 눌렀다가 다시 전체 보기로 돌아왔는데 몇 개가 사라진 것처럼 보이는 경우가 있습니다.

이 문제는 생각보다 단순한 곳에서 시작됩니다. 지금 화면에 보여주는 목록앱이 실제로 가지고 있는 전체 목록을 같은 것으로 취급해버리는 순간부터 꼬이기 시작합니다. 처음에는 둘이 비슷해 보이기 때문에 차이가 잘 안 보입니다. 하지만 검색과 필터가 붙는 순간부터는 이 둘을 분리해서 생각해야 합니다.

이번 글에서는 왜 검색과 필터에서 갑자기 데이터가 사라진 것처럼 느껴지는지, 원본 데이터와 화면용 데이터는 무엇이 다른지, 그리고 초보자가 가장 많이 실수하는 패턴이 무엇인지 차근차근 정리해보겠습니다. 이 구분이 잡히면 이후 정렬, 페이징, 카테고리 필터 같은 기능도 훨씬 덜 흔들립니다.

이번 글에서 먼저 잡아둘 핵심
겉으로 보이는 문제 실제로 일어나는 일 가장 먼저 봐야 할 방향
검색 후 목록이 줄었는데 원래 상태로 안 돌아온다 보여줄 목록이 아니라 원본 목록 자체를 줄여버렸다 검색은 상태만 바꾸고 원본 배열은 그대로 두는지 확인한다
완료만 보기 버튼을 눌렀더니 다른 항목이 사라진 것 같다 필터 결과를 원본 데이터처럼 다뤘다 필터는 보여주는 조건인지, 실제 삭제인지 먼저 구분한다
검색 상태에서 삭제했더니 엉뚱한 항목이 지워진다 화면 순서나 index를 기준으로 삭제 대상을 찾고 있다 삭제와 수정은 여전히 id 기준으로 처리하는지 본다
검색 결과를 저장했더니 일부만 남는다 원본이 아니라 화면용 목록을 저장해버렸다 저장은 항상 전체 데이터 기준으로 하는지 확인한다

이번 글의 핵심 한 줄
검색과 필터는 데이터를 줄이는 기능이 아니라, 이미 있는 데이터 중 지금 무엇을 보여줄지 고르는 기능으로 이해하는 편이 정확하다.


왜 검색과 필터를 붙이면 갑자기 꼬일까

검색과 필터가 어려운 이유는 기능이 화려해서가 아닙니다. 오히려 반대에 가깝습니다. 겉보기에는 너무 단순해서, “보이는 목록만 줄이면 되겠지”라고 생각하기 쉽기 때문입니다. 그런데 이때 초보자가 자주 놓치는 것이 있습니다. 보이는 목록을 줄이는 것실제 데이터를 바꾸는 것은 전혀 다른 일이라는 점입니다.

예를 들어 할 일 전체 목록이 10개 있다고 해보겠습니다. 검색창에 특정 단어를 입력했더니 3개만 보였습니다. 여기서 사용자가 기대하는 건 “지금은 3개만 보이지만, 실제로는 10개가 그대로 있고 검색 조건 때문에 일부만 보인다”는 상태입니다. 그런데 코드를 잘못 짜면 10개 목록 자체가 3개로 줄어들어 버릴 수 있습니다. 그 순간부터 검색어를 지워도 나머지 7개는 돌아오지 않습니다.

필터도 마찬가지입니다. 완료된 항목만 보기 버튼을 눌렀을 때, 실제로 원하는 것은 완료된 항목만 화면에 보이게 하는 것입니다. 하지만 코드를 잘못 짜면 미완료 항목을 진짜로 삭제해버린 것과 비슷한 결과가 나올 수 있습니다. 그래서 검색과 필터는 보기에는 가벼워 보여도, 데이터 흐름을 처음으로 제대로 구분해야 하는 기능이 됩니다.

기능 사용자가 기대하는 것 잘못 만들었을 때 생기는 일
검색 전체 목록은 그대로 두고, 검색어에 맞는 항목만 잠깐 보여준다 검색 결과만 남고 나머지 데이터가 실제로 사라진 것처럼 된다
완료 필터 완료된 항목만 골라서 보여준다 미완료 항목을 숨긴 것이 아니라, 실제 목록에서 없앤 것처럼 처리된다
미완료 필터 해야 할 일만 모아서 본다 완료된 항목을 되돌릴 수 없거나 전체 보기에서 사라져 보인다

중요한 포인트
검색과 필터는 삭제와 다르다. 삭제는 데이터를 없애는 기능이고, 검색과 필터는 보여주는 범위를 바꾸는 기능이다.


원본 데이터와 화면용 데이터는 무엇이 다른가

이제 용어를 조금 분명하게 잡아보겠습니다. 이번 글에서 말하는 원본 데이터는 앱이 실제로 가지고 있는 전체 목록입니다. 저장할 때도 이 데이터를 저장하고, 수정이나 삭제도 이 데이터를 기준으로 일어납니다. 반면 화면용 데이터는 지금 검색어와 필터 조건을 적용했을 때 화면에 보여줄 목록입니다.

둘은 이름만 다를 뿐 아니라 역할이 완전히 다릅니다. 원본 데이터는 “실제 사실”에 가깝고, 화면용 데이터는 “지금 보여주는 결과”에 가깝습니다. 원본은 지켜야 하고, 화면용 목록은 그때그때 다시 계산해도 됩니다.

원본 데이터와 화면용 데이터의 차이
구분 어디에 쓰는가 바뀌는 방식
원본 데이터 앱이 실제로 가진 전체 목록 저장, 수정, 삭제, 완료 상태 변경 사용자가 실제 행동을 했을 때만 바뀐다
화면용 데이터 현재 조건에 맞아 화면에 보일 목록 목록 출력, 비어 있음 안내, 검색 결과 표시 검색어나 필터가 바뀔 때마다 다시 계산된다

예를 들어 전체 할 일 목록이 아래처럼 있다고 해보겠습니다.

todos = [

{ id: 1, text: '장보기', done: false },
{ id: 2, text: '운동하기', done: true },
{ id: 3, text: '장난감 정리', done: false },
{ id: 4, text: '메일 보내기', done: false }
]

여기서 검색어가 이고, 필터가 미완료만 보기라면 화면에 보여줄 목록은 2개 정도로 줄 수 있습니다. 하지만 원본 데이터는 여전히 4개입니다. 이 차이를 구분해야 나중에 검색을 지웠을 때 다시 전체가 자연스럽게 돌아옵니다.

검색어가 장이고, 미완료만 보기일 때의 예시
항목 원본 데이터에 존재하는가 현재 화면에 보여야 하는가
장보기
운동하기 아니오
장난감 정리
메일 보내기 아니오

쉽게 말하면
원본 데이터는 창고이고, 화면용 데이터는 지금 진열대에 꺼내 놓은 물건에 가깝다. 진열대를 바꾼다고 창고 안 물건이 사라지면 안 된다.


초보자가 자주 하는 잘못된 접근

이제 실제로 많이 보이는 실수를 보겠습니다. 가장 흔한 패턴은 필터나 검색 결과를 원본 배열에 바로 덮어쓰는 방식입니다. 처음엔 꽤 그럴듯해 보입니다. 화면에 보여줄 목록이 줄어들었으니, 그 결과를 그대로 다시 배열에 넣어버리면 될 것 같기 때문입니다.

예를 들어 아래처럼 쓰기 쉽습니다.

function showActiveOnly() {

todos = todos.filter(todo => !todo.done);
renderTodos();
}

이 코드는 겉보기에는 미완료 항목만 잘 보여줄 수도 있습니다. 하지만 동시에 완료된 항목을 원본 목록에서 떼어내 버립니다. 그 상태로 저장까지 해버리면, 완료된 항목은 숨겨진 것이 아니라 정말로 사라진 것과 비슷한 상태가 됩니다.

검색도 비슷합니다.

function searchTodos(keyword) {

todos = todos.filter(todo => todo.text.includes(keyword));
renderTodos();
}

이 방식은 검색 결과를 보여주는 것처럼 보이지만, 실제로는 검색 조건에 맞지 않는 항목을 원본에서 제외해버립니다. 그래서 검색어를 지웠을 때도 되돌릴 수 있는 전체 목록이 남아 있지 않습니다.

의도 초보자가 흔히 쓰는 방식 왜 문제가 되는가
미완료만 보기 원본 배열을 미완료 항목만 남기도록 다시 만든다 완료 항목을 숨긴 것이 아니라 목록에서 떼어낸 셈이 된다
검색 결과 보기 검색어에 맞는 항목만 원본 배열에 남긴다 검색을 지워도 원래 전체 목록을 되살릴 기준이 없어진다
검색 상태에서 삭제 보이는 목록의 순서를 기준으로 지운다 필터나 검색이 걸린 상태에서는 index가 쉽게 흔들린다

여기서 꼭 기억할 점
필터 결과는 계산해서 쓰는 값이지, 원본을 대체하는 값으로 다루면 안 된다.


안전한 흐름: 원본은 두고 보여줄 목록만 계산하기

그렇다면 더 안전한 방식은 무엇일까요. 핵심은 의외로 단순합니다. 원본 배열은 그대로 둔 채, 현재 검색어와 필터 조건을 바탕으로 지금 보여줄 목록만 따로 계산하면 됩니다. 이때 검색어와 필터 상태는 데이터 목록과 별도로 들고 있어야 합니다.

예를 들면 아래처럼 생각할 수 있습니다.

let todos = [];

let currentFilter = 'all';
let searchKeyword = '';

여기서 todos는 원본 데이터입니다. currentFilter와 searchKeyword는 화면을 어떻게 보여줄지 결정하는 상태입니다. 즉, 이 둘은 목록 그 자체가 아니라 목록을 바라보는 조건에 가깝습니다.

그다음에는 화면에 그리기 직전에 보여줄 목록만 계산합니다.

function getVisibleTodos() {

const keyword = searchKeyword.trim().toLowerCase();

return todos.filter(todo => {
const matchFilter =
currentFilter === 'all'
? true
: currentFilter === 'active'
? !todo.done
: todo.done;

const matchKeyword = todo.text.toLowerCase().includes(keyword);

return matchFilter && matchKeyword;

});
}

그리고 렌더링에서는 원본 todos가 아니라 getVisibleTodos가 돌려준 결과를 사용합니다.

function renderTodos() {

const visibleTodos = getVisibleTodos();

todoList.innerHTML = '';

visibleTodos.forEach(todo => {
// 화면에 그리기
});
}
이 구조가 좋은 이유
구성 요소 역할 왜 안정적인가
todos 실제 전체 데이터 검색어를 지우거나 필터를 바꿔도 전체 기준이 남아 있다
currentFilter 현재 필터 상태 데이터를 줄이지 않고 보여주는 방식만 바꾼다
searchKeyword 현재 검색어 원본을 건드리지 않고 화면용 목록만 다시 계산하게 해준다
getVisibleTodos 지금 보여줄 목록 계산 검색과 필터 로직이 한곳에 모여 있어 수정하기 쉽다

핵심 감각
원본은 저장을 위한 기준이고, 화면용 목록은 현재 조건을 적용한 결과다. 결과를 기준으로 저장하면 꼬이고, 기준을 기준으로 저장하면 안정된다.


검색과 필터를 함께 붙일 때 생각할 순서

이제 실제로 기능을 붙이는 순서를 정리해보겠습니다. 입문자에게는 코드 한 줄보다 흐름이 더 중요합니다. 검색과 필터를 함께 붙일 때는 아래 순서로 생각하는 편이 덜 꼬입니다.

  1. 원본 목록은 따로 둔다.
    수정, 삭제, 완료 체크, 저장은 전부 원본 목록 기준으로 처리합니다.
  2. 검색어와 필터 상태를 따로 둔다.
    검색어와 필터는 목록 그 자체가 아니라, 목록을 어떻게 보여줄지 정하는 조건입니다.
  3. 보여줄 목록은 그때그때 계산한다.
    render를 할 때마다 현재 검색어와 필터 상태를 바탕으로 visible 목록을 구합니다.
  4. 삭제와 수정은 보이는 순서가 아니라 id로 처리한다.
    검색 결과 화면에서는 일부만 보이기 때문에 index를 믿기 어렵습니다.
  5. 저장은 항상 원본 목록을 기준으로 한다.
    검색 결과만 저장하거나 필터된 목록만 저장하면 나중에 전체 복구가 어렵습니다.

예를 들어 검색창 입력은 아래처럼 처리할 수 있습니다.

function handleSearchInput(value) {

searchKeyword = value;
renderTodos();
}

필터 버튼은 아래처럼 처리할 수 있습니다.

function handleFilterChange(nextFilter) {

currentFilter = nextFilter;
renderTodos();
}

삭제는 여전히 원본 목록을 기준으로 움직여야 합니다.

function deleteTodo(id) {

todos = todos.filter(todo => todo.id !== id);
saveTodos();
renderTodos();
}
행동별로 무엇이 바뀌어야 하는가
사용자 행동 바뀌어야 하는 것 바뀌면 안 되는 것
검색어 입력 searchKeyword 원본 todos
필터 버튼 클릭 currentFilter 원본 todos
항목 삭제 원본 todos 보이는 순서 자체
저장 원본 todos visible 목록만 따로 저장하는 것

이 순서를 몸에 익히면 검색과 필터가 더 이상 별개의 마법 같은 기능으로 안 보입니다. 결국은 조건 상태를 바꾸고, 그 조건에 맞는 화면용 목록을 다시 계산하는 과정으로 정리되기 때문입니다.


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

검색과 필터를 붙일 때는 비슷한 실수가 반복됩니다. 아래 여섯 가지는 특히 자주 보이는 편입니다. 지금 코드가 돌아가긴 하는데 이상하게 목록이 사라지거나 되돌아오지 않는다면, 대부분 여기 안에서 이유가 보입니다.

실수 겉으로 보이는 증상 실제로 문제인 부분 어떻게 보는 게 좋은가
필터 결과를 원본 배열에 덮어쓴다 전체 보기로 돌아와도 일부 항목이 안 보인다 숨긴 것이 아니라 데이터 자체를 줄여버렸다 필터는 계산용 목록만 만들고 원본은 그대로 둔다
검색 결과를 저장해버린다 새로고침 후 전체 데이터가 줄어든다 저장 대상을 visible 목록으로 착각했다 저장은 항상 전체 todos만 기준으로 한다
검색 결과 화면에서 index로 삭제한다 다른 항목이 지워지거나 순서가 바뀌면 꼬인다 보이는 순서와 원본 순서를 같은 것으로 봤다 수정과 삭제는 계속 id 기준으로 처리한다
검색어를 지울 때 원본 복구 코드를 따로 만든다 로직이 불필요하게 복잡해진다 애초에 원본을 건드리지 않으면 복구 코드가 필요 없다 searchKeyword만 비우고 다시 render하면 된다
보여줄 목록이 비었는지, 원본이 비었는지를 섞어 쓴다 검색 결과 없음과 전체 데이터 없음 안내가 헷갈린다 빈 상태 메시지의 기준이 섞여 있다 전체 빈 상태와 검색 결과 없음 상태를 따로 본다
검색과 필터 로직이 여러 군데 흩어져 있다 한쪽만 수정해서 결과가 들쭉날쭉해진다 조건 계산이 한곳에 모여 있지 않다 보여줄 목록 계산 함수로 한 번 모으는 편이 낫다

실전에서 가장 많이 헷갈리는 부분
검색 결과가 비어 있는 것과 전체 데이터가 비어 있는 것은 전혀 다른 상태다. 이 둘을 섞어 쓰면 안내 문구부터 저장 흐름까지 같이 흔들린다.


마무리

검색과 필터는 체크리스트 앱을 한 단계 더 도구답게 만드는 기능입니다. 하지만 그만큼 초보자가 처음으로 원본 데이터와 화면용 데이터를 분리해서 생각해야 하는 구간이기도 합니다. 여기서 이 감각이 생기지 않으면 검색이 삭제처럼 보이고, 필터가 저장을 망가뜨리고, 되돌리기 로직이 불필요하게 길어지기 쉽습니다.

 

이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.

첫째, 검색과 필터는 데이터를 줄이는 기능이 아니라 보여주는 조건을 바꾸는 기능이라는 점.

둘째, 원본 목록은 그대로 두고 검색어와 필터 상태를 따로 들고 있어야 한다는 점.

셋째, 수정과 삭제와 저장은 계속 원본 데이터를 기준으로 처리해야 한다는 점입니다.

 

여기까지 오면 이제 목록 앱 구조가 꽤 단단해집니다. 다음 편에서는 정렬까지 붙기 시작하면 왜 또 다른 종류의 혼란이 생기는지, 특히 배열 정렬이 원본 데이터를 직접 바꾸는 순간 어떤 문제가 생기는지를 정리하겠습니다.