체크리스트 앱에 수정 기능을 처음 붙일 때는 보통 이렇게 생각합니다. 입력창 하나 띄우고, 글자를 바꾸고, 저장 버튼만 누르면 끝날 것 같다는 식입니다. 실제로 초반에는 그렇게 보여도 이상하지 않습니다. 그런데 막상 붙여보면 금방 어색한 장면이 나옵니다. 수정 중인데 검색 결과가 바로 바뀌거나, 아직 저장도 안 했는데 목록 글자가 먼저 바뀌어 있거나, 취소를 눌렀는데도 원래 값으로 안 돌아가는 경우입니다.
이 문제는 대개 수정 기능이 어려워서 생기는 게 아닙니다. 더 정확히 말하면, 입력창 안에서 잠깐 바뀌는 값과 앱이 실제로 가지고 있는 데이터를 같은 것으로 다뤄서 생기는 경우가 많습니다. 사용자는 보통 입력 중에는 아직 확정하지 않았다고 느낍니다. 그래서 저장을 누르기 전까지는 원래 값이 남아 있기를 기대합니다. 그런데 코드가 입력 중인 값을 곧바로 실제 데이터에 덮어쓰기 시작하면, 저장과 취소의 의미가 흐려집니다.
이번 글에서는 왜 수정 중인 입력값을 실제 데이터와 바로 섞으면 안 되는지, 임시 편집값을 따로 들고 간다는 것이 무슨 뜻인지, 그리고 입문자 기준에서는 편집 시작, 저장, 취소 흐름을 어떻게 나누는 편이 가장 덜 흔들리는지 차근차근 정리해보겠습니다.
| 겉으로 보이는 문제 | 실제로 일어나는 일 | 가장 먼저 봐야 할 방향 |
|---|---|---|
| 입력 중인데 목록 글자가 바로 바뀐다 | 실제 데이터의 text 값을 입력 중에 바로 덮어쓰고 있다 | 임시 편집값을 따로 두는지 먼저 본다 |
| 취소를 눌렀는데 원래 값으로 안 돌아간다 | 취소할 대상이 이미 실제 데이터에 반영돼 버렸다 | 저장 전까지는 todos를 건드리지 않는 구조인지 본다 |
| 수정 중인데 검색, 정렬, 체크가 같이 흔들린다 | 확정 전 값이 실제 목록 값처럼 흘러들어가고 있다 | 임시 입력값과 실제 목록 값을 분리한다 |
| 다른 항목 편집을 열었더니 이전 값이 남는다 | editingId와 editDraft 정리가 분명하지 않다 | 편집 상태를 한곳에서 시작하고 끝내는 흐름을 만든다 |
이번 글의 핵심 한 줄
수정 입력값은 저장되기 전까지 실제 데이터가 아니라 임시 초안에 가깝다. 이 둘을 분리해야 저장과 취소가 제대로 의미를 가진다.
목차
- 왜 수정 입력값을 실제 데이터와 바로 섞으면 안 될까
- 실제 데이터와 임시 편집값은 무엇이 다른가
- 가장 단순한 편집 상태 구조
- 편집 시작, 저장, 취소는 어떻게 나눌까
- 렌더링은 현재 편집 모드를 어떻게 보여줘야 할까
- 초보자가 자주 하는 실수 6가지
- 마무리
왜 수정 입력값을 실제 데이터와 바로 섞으면 안 될까
입문자가 수정 기능을 붙일 때 가장 자주 떠올리는 방식은 입력할 때마다 todos 안의 text 값을 바로 바꾸는 것입니다. 눈으로 보기에는 간단합니다. 입력창 값이 바뀌면 실제 목록도 바로 따라 바뀌니까요. 그런데 이 방식은 겉보기에는 편해도, 저장과 취소 흐름을 거의 무너뜨리기 쉽습니다.
예를 들어 사용자가 "장보기"를 "주말 장보기"로 바꾸는 중이라고 해보겠습니다. 아직 다 입력하지도 않았는데 목록의 실제 text가 한 글자씩 바뀌기 시작하면, 앱 입장에서는 이미 수정이 끝난 것과 비슷한 상태가 됩니다. 이때 취소를 누르면 어떤 값을 되돌려야 하는지도 애매해집니다. 원래 값은 "장보기"였는데, 코드 입장에서는 이미 여러 번 바뀐 text를 진짜 데이터로 받아들였기 때문입니다.
더 문제는 이 값이 다른 기능으로 흘러갈 수 있다는 점입니다. 검색은 실제 목록 text를 기준으로 돌고, 정렬도 text나 다른 상태를 기준으로 돌 수 있고, 렌더링 역시 todos를 읽어 화면을 다시 그립니다. 아직 확정되지 않은 입력 중간값이 실제 데이터처럼 퍼져나가면, 사용자는 저장도 안 했는데 앱 전체가 먼저 흔들린다고 느끼게 됩니다.
function handleEditInput(todoId, value) {
todos = todos.map(todo =>
todo.id === todoId
? { ...todo, text: value }
: todo
);
renderTodos();
}
이 코드는 얼핏 편해 보이지만, 입력 중간값을 바로 실제 데이터에 넣는 구조입니다. 이런 방식에서는 아래 같은 문제가 생기기 쉽습니다.
| 문제 | 왜 생기나 |
|---|---|
| 취소가 애매해진다 | 원래 값이 이미 덮여서 복구 기준이 흐려진다 |
| 검색 결과가 입력 중간에 바뀐다 | 검색 대상이 임시값까지 포함한 실제 데이터가 돼 버린다 |
| 저장 버튼 의미가 흐려진다 | 실제 반영은 이미 끝났는데 저장만 형식적으로 남게 된다 |
| 다른 기능과 충돌이 커진다 | 수정 전 값과 수정 중 값의 경계가 없어지기 때문이다 |
핵심 포인트
수정 입력은 "지금 타이핑 중인 값"이고, todos의 text는 "저장된 실제 값"이다. 이 둘을 바로 합치면 취소와 저장의 의미가 무너진다.
실제 데이터와 임시 편집값은 무엇이 다른가
이제 구분을 조금 더 분명하게 보겠습니다. todos 안에 있는 text는 현재 앱이 실제로 가진 데이터입니다. 검색도 이 값을 보고, 정렬도 이 값을 기반으로 할 수 있고, 새로고침해도 남아 있어야 하는 쪽입니다. 반면 수정 입력창 안에서 타이핑 중인 값은 아직 확정되지 않았습니다. 이 값은 사용자가 저장을 누르기 전까지는 임시 편집값으로 보는 편이 맞습니다.
이 둘을 표로 정리하면 차이가 더 또렷합니다.
| 구분 | 실제 데이터 | 임시 편집값 |
|---|---|---|
| 대표 예시 | todo.text | editDraft |
| 언제 바뀌나 | 저장을 확정했을 때 | 입력할 때마다 바뀔 수 있다 |
| 새로고침 후 유지 | 예 | 보통 아니오 |
| 취소 가능성 | 이미 확정된 값 | 언제든 버릴 수 있는 값 |
| 검색, 정렬 기준으로 바로 써도 되는가 | 예 | 처음엔 보통 아니오 |
이 구분이 생기면 수정 기능이 갑자기 덜 모호해집니다. 사용자가 타이핑하는 동안 바뀌는 것은 editDraft이고, 실제 목록의 todo.text는 아직 그대로입니다. 그래서 취소를 누르면 editDraft만 지우면 되고, 저장을 누르면 그때 비로소 todo.text에 반영하면 됩니다.
쉽게 말하면
실제 데이터는 "확정본"이고, editDraft는 "작업 중 초안"에 가깝다. 초안을 쓰는 동안 확정본이 바로 바뀌면 저장과 취소가 모두 애매해진다.
가장 단순한 편집 상태 구조
입문자에게 가장 무난한 첫 구조는 편집 중인 항목을 하나만 허용하고, 그 항목의 초안값도 하나만 들고 가는 방식입니다. 이때 필요한 값은 생각보다 많지 않습니다.
let uiState = {
editingId: null,
editDraft: "",
isSaving: false
};
이 구조에서 editingId는 지금 어떤 항목을 수정 중인지 설명합니다. editDraft는 그 항목의 입력창 안에서 타이핑 중인 값입니다. isSaving은 저장 중에 버튼을 연속으로 누르지 않게 막는 용도로 쓸 수 있습니다.
| 상태 이름 | 무슨 뜻인가 | 왜 필요한가 |
|---|---|---|
| editingId | 현재 수정 중인 항목의 id | 한 화면에서 어떤 항목을 입력 모드로 바꿀지 판단하기 위해 |
| editDraft | 입력창 안의 현재 임시값 | 실제 데이터와 분리해서 수정 초안을 들고 가기 위해 |
| isSaving | 저장 처리 중인지 | 중복 저장이나 연속 클릭을 막기 위해 |
처음에는 editDraft를 todo 객체 안에 넣고 싶어질 수 있습니다. 예를 들어 todo.draftText 같은 식입니다. 하지만 입문 단계에서는 그렇게까지 섞지 않는 편이 더 읽기 쉽습니다. 왜냐하면 초안은 영구 데이터가 아니라, 지금 화면에서 잠깐 쓰는 값이기 때문입니다. 그래서 uiState 쪽에 두는 편이 훨씬 덜 헷갈립니다.
첫 구조는 단순할수록 좋다
처음에는 여러 항목 동시 편집보다 "편집 대상 하나 + 임시 초안 하나" 구조가 훨씬 안정적이고 이해하기 쉽다.
편집 시작, 저장, 취소는 어떻게 나눌까
수정 기능은 사실상 세 단계로 나눠서 보면 됩니다. 편집 시작, 저장, 취소. 이 세 단계가 섞이기 시작하면 코드가 금방 애매해집니다. 반대로 이 셋의 책임을 분명하게 나누면 흐름이 꽤 또렷해집니다.
1. 편집 시작
편집 시작 단계에서는 실제 데이터를 바꾸지 않습니다. 지금 수정하려는 항목의 id를 기록하고, 현재 text 값을 editDraft에 복사해 넣습니다. 즉, 원본을 기준으로 초안을 하나 만드는 단계입니다.
function startEdit(todoId) {
const current = todos.find(todo => todo.id === todoId);
if (!current) return;
if (uiState.isSaving) return;
uiState.editingId = todoId;
uiState.editDraft = current.text;
renderTodos();
}
2. 입력 중
입력 중에는 editDraft만 바뀝니다. todos는 그대로 둡니다. 여기서 실제 데이터까지 바꾸기 시작하면 다시 앞 섹션의 문제가 돌아옵니다.
function updateEditDraft(value) {
uiState.editDraft = value;
}
3. 저장
저장 버튼을 눌렀을 때 비로소 todo.text에 editDraft를 반영합니다. 이때 trim 처리나 빈값 검사 같은 것도 같이 넣을 수 있습니다. 그리고 저장이 끝나면 editingId와 editDraft를 비워서 편집 모드를 종료합니다.
function saveEdit() {
const todoId = uiState.editingId;
const nextText = uiState.editDraft.trim();
if (todoId === null) return;
if (!nextText) return;
if (uiState.isSaving) return;
uiState.isSaving = true;
todos = todos.map(todo =>
todo.id === todoId
? { ...todo, text: nextText }
: todo
);
saveTodos();
uiState.editingId = null;
uiState.editDraft = "";
uiState.isSaving = false;
renderTodos();
}
4. 취소
취소는 오히려 더 단순합니다. 실제 데이터는 건드리지 않았기 때문에, editDraft와 editingId만 비우면 됩니다. 이게 바로 임시 편집값 구조의 가장 큰 장점입니다.
function cancelEdit() {
uiState.editingId = null;
uiState.editDraft = "";
renderTodos();
}
| 단계 | 실제로 바뀌는 것 | 왜 분리하면 좋은가 |
|---|---|---|
| 편집 시작 | editingId, editDraft | 원본을 유지한 채 편집 모드만 시작할 수 있다 |
| 입력 중 | editDraft | 실제 목록 값을 흔들지 않는다 |
| 저장 | todos, uiState | 확정 시점이 분명해진다 |
| 취소 | uiState만 정리 | 원래 값이 그대로 남아 있으므로 되돌리기가 쉽다 |
핵심 정리
수정 기능에서 저장은 "초안을 실제 데이터로 옮기는 순간"이고, 취소는 "초안을 버리는 순간"이다. 이 구분이 분명해야 흐름이 덜 흔들린다.
렌더링은 현재 편집 모드를 어떻게 보여줘야 할까
임시 편집값 구조를 만들었으면, 화면도 그 구조를 따라가야 합니다. 즉, editingId에 해당하는 항목만 입력창으로 바뀌고, 그 입력창의 값은 todo.text가 아니라 editDraft를 보여줘야 합니다. 이 차이를 놓치면 타이핑할 때 글자가 되돌아가거나, 저장 전 값과 실제 값이 섞여 보이기 쉽습니다.
가장 단순한 렌더링 기준은 아래와 같습니다.
- editingId와 같은 항목
입력창과 저장, 취소 버튼을 보여준다 - 나머지 항목
평소 텍스트와 버튼을 보여준다 - 수정 중일 때 다른 편집 버튼
처음 버전에서는 비활성화하거나 숨겨도 괜찮다
function renderTodoItem(todo) {
const isEditing = uiState.editingId === todo.id;
if (isEditing) {
return {
mode: "editing",
inputValue: uiState.editDraft
};
}
return {
mode: "view",
text: todo.text
};
}
중요한 건 입력창 값을 uiState.editDraft에서 읽는다는 점입니다. 만약 여기서 todo.text를 그대로 입력창에 넣으면, 사용자가 타이핑해도 다음 렌더링 순간 다시 원래 값이 들어가는 식의 어색한 현상이 생길 수 있습니다. 반대로 모든 항목의 text를 editDraft 기준으로 보여주면 아직 저장하지 않은 값이 목록 전체에 실제값처럼 번질 수 있습니다.
| 상황 | 무엇을 보여줘야 하나 |
|---|---|
| 수정 중인 항목 입력창 | uiState.editDraft |
| 수정 중이 아닌 다른 항목 텍스트 | todo.text |
| 저장 후 | todos에 반영된 새 text |
| 취소 후 | 원래 todo.text |
핵심 포인트
편집 중인 입력창은 초안을 보여주고, 평소 목록은 실제 데이터를 보여줘야 한다. 렌더링이 이 구분을 놓치면 입력감이 바로 이상해진다.
초보자가 자주 하는 실수 6가지
임시 편집값 구조를 붙일 때도 비슷한 실수들이 반복됩니다. 대부분은 실제 데이터와 editDraft의 경계를 다시 흐리게 만드는 쪽에서 나옵니다.
| 실수 | 겉으로 보이는 증상 | 실제로 문제인 부분 | 어떻게 보는 게 좋은가 |
|---|---|---|---|
| 입력할 때마다 todo.text를 직접 바꾼다 | 취소가 의미 없어지고 저장 전에도 목록이 바뀐다 | 임시 초안과 실제 데이터를 분리하지 않았다 | 입력 중에는 editDraft만 바꾸는 편이 낫다 |
| startEdit에서 editDraft를 현재 text로 채우지 않는다 | 입력창이 비어 있거나 이전 항목 값이 남아 있다 | 편집 시작 시 초기화가 빠졌다 | 편집 시작은 원본 text를 초안으로 복사하는 단계라고 본다 |
| 취소 후 editDraft를 비우지 않는다 | 다음 항목 편집 때 이전 값이 섞여 보인다 | 편집 종료 상태 정리가 빠졌다 | cancel은 editingId와 editDraft를 함께 정리하는 편이 좋다 |
| 입력창 값에 todo.text를 그대로 바인딩한다 | 타이핑 중에 값이 되돌아가거나 깜빡인다 | 렌더링 기준이 초안이 아니라 실제 데이터다 | 수정 중인 입력창은 editDraft를 보여줘야 한다 |
| 수정 중인데 다른 항목 편집도 바로 연다 | 어느 입력값이 어느 항목용인지 곧바로 헷갈린다 | 한 번에 하나의 편집 흐름만 두는 규칙이 없다 | 첫 버전은 editingId 하나만 허용하는 편이 낫다 |
| 저장 뒤에도 editingId를 안 비운다 | 저장했는데도 계속 편집 모드처럼 남아 있다 | 확정 후 상태 정리가 빠졌다 | 저장은 데이터 반영과 모드 종료까지 한 묶음으로 본다 |
특히 네 번째 실수는 처음엔 잘 안 보이지만 체감이 큽니다. 입력창은 현재 사용자가 만드는 초안을 보여줘야 하는데, 여기서 실제 데이터 값을 그대로 읽어오면 매 렌더링 때마다 입력값이 원래 값으로 되돌아간 것처럼 보일 수 있습니다. 수정 기능이 어색하게 느껴질 때는 편집 상태나 저장 함수뿐 아니라 입력창이 어떤 값을 기준으로 렌더링되는지도 꼭 같이 봐야 합니다.
실전에서 가장 많이 흔들리는 부분
수정 기능이 이상하게 느껴질 때는 저장 함수만 볼 게 아니라, "입력 중에는 무엇을 바꾸고 있고 화면은 무엇을 보여주는가"를 같이 봐야 한다.
'바이브코딩 > 이론' 카테고리의 다른 글
| 21. 저장 버튼을 눌렀는데 왜 두 번 들어갈까: 바이브코딩 저장 흐름 나누는 법 (1) | 2026.04.21 |
|---|---|
| 20. 검증은 어디서 해야 할까: 체크리스트 앱에서 빈값, 공백, 중복 다루기 (0) | 2026.04.16 |
| 18. 드래그 중에 수정 버튼 누르면 왜 꼬일까: 바이브코딩 상태 충돌 정리 (0) | 2026.04.09 |
| 17. 입력은 달라도 바뀌는 건 같다: 마우스와 터치를 하나의 재정렬 로직으로 묶는 법 (0) | 2026.04.07 |
| 16. 데스크톱에선 되는데 모바일에선 왜 안 될까: 터치 기반 재정렬 입문 (0) | 2026.04.02 |
