체크리스트 앱을 처음 만들 때는 대개 이런 순서로 기능이 붙습니다. 항목 추가, 삭제, 완료 체크, 저장, 정렬, 재정렬. 여기까지 오면 화면은 꽤 그럴듯합니다. 그런데 바로 그 지점에서 새로운 문제가 생깁니다. 기능 하나하나는 잘 되는데, 같이 놓는 순간 서로 충돌하기 시작하는 것입니다.

예를 들어 드래그로 순서를 바꾸는 중에 수정 버튼이 눌리거나, 수정 입력창이 열린 상태에서 완료 체크가 바뀌거나, 삭제 확인창이 떠 있는데 또 다른 항목 정렬이 시작되는 식입니다. 처음에는 이런 일이 전부 “작은 버그”처럼 보입니다. 하지만 조금 더 정확히 보면, 이건 개별 기능 문제가 아니라 상태를 어떻게 관리할지에 대한 규칙이 아직 없기 때문인 경우가 많습니다.

이번 글에서는 재정렬, 수정, 완료 체크, 삭제 버튼이 한 화면에 함께 있을 때 왜 갑자기 코드가 흔들리기 시작하는지, 무엇을 먼저 잠가야 하고 무엇은 같이 둬도 되는지, 그리고 초보자 기준에서는 어떤 순서로 규칙을 세우는 편이 가장 덜 꼬이는지 차근차근 정리해보겠습니다.

이번 글에서 먼저 잡아둘 핵심
겉으로 보이는 문제 실제로 일어나는 일 가장 먼저 봐야 할 방향
드래그 중에 수정 버튼이 같이 눌린다 정렬 모드와 수정 모드를 동시에 허용하고 있다 한 번에 하나의 주요 모드만 열어두는 규칙부터 세운다
수정 중인데 완료 체크도 바뀌고 삭제도 된다 입력 중 상태를 다른 클릭 동작이 침범하고 있다 무엇을 잠글지 우선순위를 먼저 정한다
코드를 고칠 때마다 여기저기 같이 흔들린다 상태가 여러 군데 흩어져 있고 기준이 하나가 아니다 화면 전체 상태를 한곳에 모은다
어떤 버튼은 눌려야 하고 어떤 버튼은 막혀야 하는지 헷갈린다 현재 모드를 기준으로 UI를 다시 그리는 규칙이 없다 렌더링을 상태 기준으로 다시 정리한다

이번 글의 핵심 한 줄
기능이 많아질수록 중요한 건 기능 개수보다 상태 규칙이다. 무엇이 동시에 가능하고, 무엇은 잠깐 막혀야 하는지 먼저 정해야 코드가 덜 흔들린다.


목차

  1. 기능이 늘수록 왜 서로 충돌할까
  2. 먼저 구분해야 할 두 종류의 상태
  3. 처음엔 한 번에 하나의 주요 모드만 허용하는 편이 낫다
  4. 화면 전체 상태를 한곳에 모으는 법
  5. 이벤트 핸들러는 실행 전에 먼저 물어봐야 한다
  6. 렌더링은 현재 상태를 기준으로 다시 보여줘야 한다
  7. 초보자가 자주 하는 실수 6가지

기능이 늘수록 왜 서로 충돌할까

기능이 하나씩만 있을 때는 거의 문제가 없습니다. 삭제만 있으면 삭제만 생각하면 되고, 수정만 있으면 수정 흐름만 따라가면 됩니다. 그런데 재정렬, 수정, 완료 체크, 삭제 확인창 같은 기능이 한 화면에 함께 놓이면 이야기가 달라집니다. 이제는 각각의 기능이 혼자서 잘 되느냐 보다 다른 기능이 이미 열려 있을 때도 잘 되느냐가 더 중요해집니다.

여기서 초보자가 자주 놓치는 건, 화면은 하나인데 행동 후보는 여러 개라는 점입니다. 사용자는 같은 항목에서 체크도 하고 싶고, 수정도 하고 싶고, 순서도 바꾸고 싶습니다. 그런데 앱은 그 모든 동작을 매 순간 동시에 허용할 필요가 없습니다. 오히려 처음 버전에서는 같이 열어두는 순간 설명이 어려워지는 조합이 많습니다.

개별 기능은 단순해도 같이 놓으면 달라지는 이유
기능 하나만 볼 때 여러 기능이 같이 있을 때 생기는 질문
삭제는 항목 하나를 지우면 된다 수정 중인 항목도 삭제를 허용할 것인가
완료 체크는 done 값만 바꾸면 된다 재정렬 중에도 체크를 눌러도 되는가
수정은 입력창을 열고 저장하면 된다 수정 중에 다른 항목 편집을 또 열 수 있는가
재정렬은 order만 바꾸면 된다 수정 입력창이 열린 상태에서 순서 이동을 허용할 것인가

즉, 상태 충돌은 갑자기 생기는 예외가 아니라 기능이 늘어나면 자연스럽게 생기는 단계입니다. 그래서 중요한 건 “모든 걸 동시에 되게 한다”가 아니라, 처음엔 어떤 조합을 일부러 막을지 결정하는 것입니다.

핵심 포인트
한 기능만 볼 때는 정답이 쉬운데, 여러 기능이 만나는 순간부터는 “무엇을 허용하고 무엇을 잠글까”가 핵심이 된다.


먼저 구분해야 할 두 종류의 상태

입문자가 상태 관리에서 가장 먼저 헷갈리는 부분은, 서로 다른 종류의 상태를 한 덩어리로 보는 것입니다. 예를 들어 완료 여부인 done과 수정 중인지 아닌지인 editingId는 모두  상태 라고 부를 수 있지만, 성격은 꽤 다릅니다.

가장 먼저 구분하면 좋은 것은 아래 두 가지입니다.

처음부터 구분해두면 좋은 두 가지 상태
구분 예시 왜 중요한가
데이터 상태 text, done, order 저장 대상이고, 앱을 다시 열어도 유지되어야 한다
화면 상호작용 상태 isSorting, editingId, confirmDeleteId, isSaving 지금 화면이 어떤 모드에 있는지를 설명한다

이 구분이 필요한 이유는 단순합니다. done은 항목의 실제 상태라서 저장해야 합니다. 반면 editingId는 지금 어느 항목이 수정 중인지에 대한 임시 정보라서, 화면을 닫으면 굳이 남아 있을 필요가 없습니다. 둘 다 상태지만, 같은 방식으로 다루면 오히려 헷갈리기 쉽습니다.

특히 재정렬, 수정, 삭제 확인은 대개 화면 상호작용 상태에 가깝습니다. 지금 무엇을 하고 있는지 설명하는 값이지, 항목의 영구적인 데이터는 아닙니다. 반대로 완료 체크나 순서 값은 실제 데이터입니다. 이 구분이 안 되면 “왜 저장해야 하지?”, “왜 저장하면 안 되지?” 같은 판단이 흐려집니다.

쉽게 말하면
done은 항목이 가진 사실이고, 수정 중인지 정렬 중인지는 지금 화면이 어떤 모드인지에 가깝다. 둘을 같은 층위로 보면 곧바로 꼬이기 시작한다.


처음엔 한 번에 하나의 주요 모드만 허용하는 편이 낫다

초보자에게 가장 현실적인 첫 규칙은 이것입니다. 수정, 재정렬, 삭제 확인 같은 주요 모드는 처음엔 한 번에 하나만 열어둔다. 이 원칙 하나만 있어도 상태 충돌이 크게 줄어듭니다.

물론 이론적으로는 더 복잡한 조합도 만들 수 있습니다. 예를 들어 한 항목을 수정하면서 다른 항목을 재정렬할 수도 있고, 삭제 확인창이 떠 있는 상태에서 다른 체크를 눌러도 되게 만들 수도 있습니다. 하지만 입문자 단계에서는 이런 조합이 주는 이점보다, 그걸 설명하고 디버깅해야 하는 비용이 더 큽니다.

처음엔 막는 편이 좋은 조합
조합 처음엔 막는 편이 좋은 이유
재정렬 중 + 수정 시작 입력창이 열리면서 순서 이동 흐름을 동시에 추적해야 해 갑자기 복잡해진다
수정 중 + 다른 항목 삭제 확인 입력값 보존과 삭제 대상 판단이 동시에 섞인다
삭제 확인 중 + 재정렬 시작 무엇이 현재 우선인지 UI가 불분명해진다
저장 중 + 다른 동작 연속 실행 중복 저장이나 상태 꼬임이 생길 수 있다

반대로 같이 둬도 되는 것도 있습니다. 예를 들어 done은 데이터 상태라서, 수정 모드가 아니라면 평소처럼 토글할 수 있습니다. 검색어 입력이나 필터 선택도 경우에 따라 같이 둘 수 있습니다. 다만 재정렬과 사용자 순서 변경처럼 화면 구조를 직접 흔드는 동작은 처음엔 더 보수적으로 잠그는 편이 좋습니다.

이 규칙은 사용자 경험에도 도움이 됩니다. 지금 내가 편집 중 인지, 정렬 중 인지, 삭제를 확인 중 인지가 한눈에 분명해지기 때문입니다. 모드가 너무 많이 겹치면 사용자도 헷갈리고, 개발자도 헷갈립니다.

입문자에게 가장 현실적인 원칙
모든 조합을 다 허용하려 하지 말고, 먼저 “한 번에 하나의 주요 모드”만 열어두는 구조부터 안정화하는 편이 훨씬 낫다.


화면 전체 상태를 한곳에 모으는 법

이제 실제로 상태를 어디에 둘지 보겠습니다. 초보자 단계에서 가장 많이 흔들리는 구조는 상태가 여러 함수 안에 제각각 흩어져 있는 경우입니다. 어느 함수에서는 editingId를 따로 기억하고, 어느 함수에서는 isSorting만 따로 들고 있고, 삭제 확인 대상은 또 다른 변수에 있는 식입니다. 이렇게 되면 기능이 하나 늘어날 때마다 “지금 무엇이 이미 열려 있지?”를 추적하기가 어려워집니다.

그래서 화면 상호작용 상태는 가능하면 한군데 모아두는 편이 좋습니다. 예를 들면 아래처럼 볼 수 있습니다.

let uiState = {

isSorting: false,
draggedId: null,
targetId: null,
position: null,
editingId: null,
confirmDeleteId: null,
isSaving: false
};

이 구조가 좋은 이유는 눈에 보입니다. 지금 정렬 중인지, 어떤 항목이 수정 중인지, 어떤 항목이 삭제 확인 상태인지가 전부 한곳에 있습니다. 그래서 이벤트 핸들러가 동작하기 전에 “지금 이 행동을 받아도 되는가”를 묻기가 훨씬 쉬워집니다.

한곳에 모아두면 보기 쉬운 화면 상태
상태 이름 무슨 뜻인가 왜 따로 필요할까
isSorting 지금 재정렬 흐름 안에 있는지 정렬 중에는 다른 클릭 동작을 잠글 수 있다
editingId 수정 중인 항목의 id 같은 화면에서 편집 입력창을 하나만 열어두기 쉽다
confirmDeleteId 삭제 확인을 받고 있는 항목의 id 삭제 확인 UI를 다른 동작과 분리해서 볼 수 있다
isSaving 저장 처리 중인지 중복 클릭이나 연속 저장을 막는 기준이 된다

중요한 건 이 상태 객체가 “모든 걸 다 넣는 저장소”가 아니라는 점입니다. text, done, order 같은 실제 데이터는 여전히 todos 안에 있어야 합니다. uiState는 지금 화면이 어떤 모드인지 설명하는 역할만 맡기면 됩니다. 이 선을 지켜야 나중에 읽을 때도 덜 헷갈립니다.

핵심 정리
데이터는 todos에 두고, 화면 상호작용 상태는 uiState 같은 한곳에 모아두는 편이 훨씬 읽기 쉽고 디버깅도 편하다.


이벤트 핸들러는 실행 전에 먼저 물어봐야 한다

상태를 한곳에 모았다면, 이제 각 핸들러는 바로 동작하기 전에 먼저 질문할 수 있습니다. 지금 이 행동을 받아도 되는가? 이 질문이 없으면 버튼은 눌리는 대로 반응하고, 모드는 겹치고, 결국 상태 충돌이 생깁니다.

초보자에게는 아래처럼 “가능한가”를 묻는 작은 함수부터 두는 방식이 꽤 도움이 됩니다.

function canStartSorting() {

if (uiState.isSaving) return false;
if (uiState.editingId !== null) return false;
if (uiState.confirmDeleteId !== null) return false;
return true;
}

function canStartEditing(todoId) {
if (uiState.isSaving) return false;
if (uiState.isSorting) return false;
if (uiState.confirmDeleteId !== null) return false;
if (uiState.editingId !== null && uiState.editingId !== todoId) return false;
return true;
}

function canToggleDone() {
if (uiState.isSaving) return false;
if (uiState.isSorting) return false;
if (uiState.editingId !== null) return false;
return true;
}

이렇게 해두면 실제 이벤트 핸들러는 훨씬 또렷해집니다.

function handleEditClick(todoId) {

if (!canStartEditing(todoId)) return;

uiState.editingId = todoId;
renderTodos();
}

function handleSortStart(todoId) {
if (!canStartSorting()) return;

uiState.isSorting = true;
uiState.draggedId = todoId;
}

function handleToggleClick(todoId) {
if (!canToggleDone()) return;

toggleTodo(todoId);
}

이 구조의 좋은 점은 왜 지금 이 버튼이 안 눌리는지도 설명하기 쉬워진다는 것입니다. 단순히 우연히 안 되는 것이 아니라, 현재 화면 모드상 막혀 있기 때문입니다. 즉, 버그와 규칙을 구분할 수 있게 됩니다.

핸들러가 먼저 물어보면 생기는 장점
장점 왜 도움이 되나
버튼 동작 기준이 분명해진다 지금 가능한 행동과 불가능한 행동이 코드에 드러난다
중복 조건이 줄어든다 핸들러마다 같은 조건을 길게 반복할 필요가 줄어든다
버그를 찾기 쉬워진다 허용 규칙이 잘못된 건지, 실제 동작 코드가 잘못된 건지 경계를 볼 수 있다

실전 감각으로 정리하면
핸들러는 버튼이 눌렸다고 바로 일하는 곳이 아니라, 지금 이 행동이 허용되는지 먼저 묻고 그다음 움직이는 곳에 가깝다.


렌더링은 현재 상태를 기준으로 다시 보여줘야 한다

상태 규칙을 세웠다면 화면도 그 규칙을 반영해야 합니다. 많은 초보자가 여기서 놓치는 부분이 있습니다. 코드 안에서는 수정 중이면 다른 버튼을 막자고 정해놓고, 실제 화면에서는 여전히 삭제 버튼과 체크 버튼이 그대로 살아 있는 경우입니다. 그러면 사용자는 눌러보고 나서야 안 되는 걸 알게 되고, 왜 안 되는지도 헷갈립니다.

그래서 렌더링은 현재 uiState를 기준으로 다시 보이게 만드는 편이 좋습니다. 예를 들면 이런 식입니다.

  • 정렬 중일 때
    수정 버튼, 삭제 버튼, 완료 체크를 비활성화하거나 흐리게 보이게 한다
  • 수정 중일 때
    해당 항목만 입력창으로 바꾸고, 다른 항목의 편집 시작 버튼은 막는다
  • 삭제 확인 중일 때
    다른 주요 동작 버튼을 잠시 비활성화한다
  • 저장 중일 때
    전체적으로 다시 클릭되지 않게 막는다

간단한 예시는 아래처럼 볼 수 있습니다.

function renderTodoItem(todo) {

const isEditing = uiState.editingId === todo.id;
const disableActions =
uiState.isSorting ||
uiState.confirmDeleteId !== null ||
uiState.isSaving;

const disableToggle =
disableActions || uiState.editingId !== null;

const disableEdit =
disableActions ||
(uiState.editingId !== null && uiState.editingId !== todo.id);

return {
isEditing,
disableToggle,
disableEdit
};
}

실제 HTML 생성 코드는 방식마다 다를 수 있습니다. 중요한 건 결과가 하나입니다. 지금 상태에서 가능한 행동은 열어두고, 지금 상태에서 막혀야 하는 행동은 화면에서도 막혀 보이게 만들기. 이 기준이 있어야 사용자가 덜 헷갈리고, 개발자도 나중에 왜 이런 버튼이 안 눌리는지 설명하기 쉬워집니다.

현재 상태에 따라 UI가 달라져야 하는 예시
현재 상태 화면에서 보이면 좋은 변화
재정렬 중 드래그 관련 표시만 살리고 다른 버튼은 잠시 비활성화
수정 중 입력창과 저장 또는 취소 버튼을 분명하게 보여줌
삭제 확인 중 삭제 대상이 명확히 보이고, 다른 동작은 잠시 덜 강조함
저장 중 중복 클릭을 막을 수 있게 전체 버튼을 잠시 비활성화

핵심 정리
상태 규칙은 코드 안에만 있으면 부족하다. 사용자가 지금 무엇을 할 수 있고 없는지를 화면에서도 알아볼 수 있어야 한다.


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

상태 충돌을 다루기 시작하면 이전보다 코드가 훨씬 논리적인 것처럼 보이기 시작합니다. 그런데도 자주 나오는 실수는 꽤 비슷합니다. 대부분은 상태 종류를 섞거나, 잠금 규칙을 코드와 화면 중 한쪽에만 적용하는 경우입니다.

실수 겉으로 보이는 증상 실제로 문제인 부분 어떻게 보는 게 좋은가
done과 editingId를 같은 종류 상태처럼 다룬다 무엇은 저장해야 하고 무엇은 임시인지 기준이 흐려진다 데이터 상태와 화면 상호작용 상태를 구분하지 않았다 영구 데이터와 임시 화면 모드를 먼저 나눠 본다
주요 모드를 여러 개 동시에 열어둔다 드래그 중 수정창이 뜨거나 삭제 확인 중 다른 버튼이 눌린다 우선순위 규칙이 없다 처음엔 한 번에 하나의 주요 모드만 허용한다
상태가 여러 함수 안에 흩어져 있다 무엇이 현재 열려 있는지 추적하기 어렵다 화면 상태를 한곳에 모아두지 않았다 uiState 같은 공통 구조로 먼저 모은다
핸들러가 허용 여부를 묻지 않고 바로 실행된다 모드가 겹친 상태에서도 버튼이 섞여 동작한다 행동 가능 조건이 빠져 있다 canStartSorting, canStartEditing 같은 규칙 함수부터 둔다
코드에서는 막았는데 화면은 그대로 열어둔다 사용자는 눌러보고 나서야 안 된다는 걸 알게 된다 렌더링이 현재 상태를 반영하지 않는다 비활성화나 시각적 구분도 같이 보여준다
저장 중에도 연속 클릭을 허용한다 중복 저장, 두 번 삭제, 두 번 편집 시작 같은 문제가 생긴다 isSaving 같은 공통 잠금 상태를 안 봤다 저장 중 상태는 전체 상호작용을 잠시 막는 기준으로 본다

특히 다섯 번째 실수는 생각보다 자주 보입니다. 개발자는 코드 안에서 return 했으니 됐다고 느끼기 쉽지만, 사용자 입장에서는 버튼이 살아 보이면 눌러도 되는 것처럼 보입니다. 그래서 상태 충돌을 줄이는 일은 조건문 몇 줄을 추가하는 것에서 끝나지 않고, 화면 자체가 그 규칙을 설명하게 만드는 일까지 포함됩니다.

실전에서 가장 많이 흔들리는 부분
상태 충돌은 보통 로직 한 줄이 틀려서가 아니라, 지금 허용되는 행동을 코드와 화면이 서로 다르게 말할 때 더 자주 생긴다.


마무리

기능이 늘어난 체크리스트 앱이 갑자기 불안해지는 이유는 대개 기능이 많아서가 아닙니다. 더 정확히 말하면, 기능이 많아졌는데도 여전히 한 기능씩 따로만 보고 있기 때문인 경우가 많습니다. 재정렬, 수정, 완료 체크, 삭제 확인은 각각만 보면 단순하지만, 한 화면 안에서는 서로의 타이밍과 우선순위를 같이 봐야 합니다.

 

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

첫째, 상태에는 데이터 상태와 화면 상호작용 상태가 있고 이 둘을 구분해야 한다는 점.

둘째, 처음에는 한 번에 하나의 주요 모드만 허용하는 편이 훨씬 안정적이라는 점.

셋째, 상태는 한곳에 모으고, 핸들러는 실행 전에 허용 여부를 먼저 묻고, 화면도 그 규칙을 같이 보여줘야 한다는 점입니다.

 

여기까지 오면 이제 체크리스트 앱은 기능 나열 수준을 넘어, 동작 규칙을 가진 화면으로 보이기 시작합니다. 다음 편에서는 이 흐름을 이어서, 수정 중인 입력값을 실제 데이터와 어떻게 분리해서 다뤄야 하는지, 즉 임시 편집값을 따로 들고 가는 이유와 저장 또는 취소 흐름이 왜 중요한지를 본격적으로 다루겠습니다.