지난 글에서는 낙관적 생성에서 임시 항목을 먼저 화면에 보여주고, 서버 응답이 오면 그 항목을 진짜 항목으로 교체하는 흐름을 다뤘습니다. 여기까지 오면 거의 바로 다음 문제가 보입니다. 그럼 실패했을 때는 어떻게 할까? 가장 단순한 첫 버전에서는 임시 항목을 그냥 목록에서 제거해도 됩니다. 실제로 많은 예제가 그렇게 시작합니다.
문제는 사용감입니다. 사용자는 분명 방금 입력했고, 화면에도 잠깐 보였던 항목이 네트워크 실패 한 번으로 완전히 사라지면 꽤 불안하게 느낄 수 있습니다. 특히 문장이 길거나, 여러 개를 연속으로 추가하던 중이거나, 네트워크가 불안정한 환경이라면 더 그렇습니다. 그래서 어느 시점부터는 "실패했으면 그냥 없앤다"보다 "실패한 항목을 남겨두고 다시 시도할 수 있게 만든다"는 선택지가 훨씬 현실적으로 느껴집니다.
이번 글에서는 왜 실패한 임시 항목을 바로 지우는 방식이 어떤 상황에서는 충분하지만 어떤 상황에서는 아쉬워지는지, retry 가능한 임시 항목은 어떤 상태를 들고 있어야 하는지, 그리고 입문자라면 처음에 어떤 규칙부터 세워두는 편이 가장 덜 흔들리는지를 차근차근 정리해보겠습니다.
| 겉으로 보이는 문제 | 실제로 일어나는 일 | 가장 먼저 봐야 할 방향 |
|---|---|---|
| 추가한 항목이 실패와 함께 사라져서 허탈하다 | 실패 시 임시 항목을 무조건 제거하는 규칙만 있다 | 실패 항목을 남길지 지울지 기준부터 정한다 |
| 재시도 버튼을 붙였는데 상태가 더 복잡해졌다 | pending, failed, retrying 상태를 구분하지 않았다 | 임시 항목의 생애주기를 상태로 나눠 본다 |
| 같은 실패 항목이 재시도 후 두 개처럼 보인다 | 기존 failed 항목을 교체하지 않고 새로 append 했다 | 재시도 성공 시에도 replace가 핵심인지 본다 |
| 실패 항목이 일반 항목처럼 보여서 더 헷갈린다 | failed 상태를 화면에서 구분해 보여주지 않는다 | 항목 단위 syncStatus와 UI 표시 기준을 같이 정한다 |
이번 글의 핵심 한 줄
낙관적 생성에서 실패한 항목을 어떻게 처리할지는 기술보다 사용자 신뢰와 더 가까운 문제다. 그래서 "지울지", "남길지", "다시 시도하게 할지"의 기준이 먼저 필요하다.
목차
- 왜 실패한 임시 항목 처리가 중요한가
- 바로 제거와 실패 상태 유지 중 무엇을 고를까
- retry 가능한 임시 항목은 어떤 상태를 가져야 할까
- 재시도 흐름은 어떻게 움직일까
- failed 항목 UI는 어떻게 보여줘야 할까
- 초보자가 자주 하는 실수 6가지
- 마무리
왜 실패한 임시 항목 처리가 중요한가
생성 실패는 수정 실패와 느낌이 조금 다릅니다. 수정은 원래 있던 항목이 그대로 남아 있기 때문에, 실패해도 사용자는 "바뀌지 않았구나" 정도로 받아들이는 경우가 많습니다. 하지만 생성은 막 생겨난 항목이 대상입니다. 사용자는 방금 무언가를 만들었다고 느끼는데, 실패와 함께 그 항목이 흔적도 없이 사라지면 체감이 훨씬 큽니다.
특히 자동 저장이나 낙관적 추가가 붙은 앱에서는 이 감각이 더 강해집니다. 화면에는 분명 보였고, 사용자는 이미 "내가 추가했다"고 느꼈는데, 네트워크 문제나 서버 오류 한 번으로 그 항목이 사라지면 앱 전체에 대한 신뢰도도 같이 흔들릴 수 있습니다.
| 상황 | 사용자가 느끼는 체감 |
|---|---|
| 수정 실패 | 원래 값이 남아 있어서 "저장이 안 됐구나"로 이해하기 쉽다 |
| 생성 실패 후 즉시 제거 | "내가 방금 만든 것이 사라졌다"는 감각이 생기기 쉽다 |
그래서 실패한 임시 항목 처리는 단순한 예외 처리라기보다, 사용자가 앱을 얼마나 믿을 수 있게 보이게 만들 것인가와도 연결됩니다. 작은 개인용 도구에서는 실패 시 조용히 제거해도 큰 문제가 없을 수 있지만, 입력 노력이 조금만 커지면 이 방식은 금방 거칠게 느껴집니다.
핵심 포인트
생성 실패는 "새 값이 반영되지 않았다"보다 "막 만든 것이 사라졌다"처럼 느껴지기 쉬워서, 실패 후 처리 방식이 사용자 신뢰에 더 크게 영향을 줄 수 있다.
바로 제거와 실패 상태 유지 중 무엇을 고를까
여기서 가장 먼저 해야 할 선택은 단순합니다. 실패한 임시 항목을 바로 제거할 것인가, 아니면 실패 상태로 남겨둘 것인가. 둘 다 가능하고, 둘 다 나름의 장단점이 있습니다. 중요한 건 앱 성격에 맞는 기준을 먼저 세우는 것입니다.
| 방식 | 장점 | 아쉬운 점 |
|---|---|---|
| 실패 시 바로 제거 | 구조가 단순하고 목록이 깔끔하다 | 사용자 입력이 한순간에 사라진 것처럼 느껴질 수 있다 |
| 실패 상태로 유지 | 입력값을 잃지 않고 재시도 버튼을 줄 수 있다 | pending, failed 같은 상태와 UI 규칙이 더 필요하다 |
입문자에게는 아래 같은 기준이 도움이 됩니다.
- 바로 제거가 비교적 맞는 경우
입력값이 매우 짧고, 다시 입력해도 부담이 작고, 네트워크 실패가 드문 단순한 개인 도구 - 실패 상태 유지가 더 맞는 경우
입력 노력이 조금이라도 있고, 사용자가 같은 내용을 다시 쓰게 만드는 것이 거칠게 느껴질 수 있는 경우
즉, 실패 상태 유지는 기술적으로 더 무거워 보이지만, 사용자가 적어도 "내 입력을 앱이 기억하고 있다"는 느낌을 받을 수 있다는 장점이 있습니다. 반대로 바로 제거는 구현은 쉬워도 사용감이 더 거칠 수 있습니다.
현실적인 기준
입력값을 다시 만드는 비용이 작으면 제거도 괜찮지만, 사용자가 "다시 써야 한다"고 느끼는 순간부터는 실패 상태 유지와 retry가 훨씬 자연스러워질 수 있다.
retry 가능한 임시 항목은 어떤 상태를 가져야 할까
실패한 항목을 남겨두기로 했다면, 이제는 상태를 더 분명하게 나눠야 합니다. 이전 글에서 tmp id가 임시 항목을 추적하는 연결 고리라고 했는데, retry가 붙으면 id만으로는 부족합니다. 지금 이 항목이 저장 중인지, 실패 상태인지, 이미 서버와 동기화됐는지를 같이 설명할 값이 필요해집니다.
입문자에게 가장 단순한 첫 구조는 아래 정도면 충분한 경우가 많습니다.
function createTempTodo(text) {
return {
id: "tmp-" + Date.now() + "-" + Math.random().toString(16).slice(2),
text,
done: false,
order: getNextOrder(),
syncStatus: "pending",
errorMessage: "",
retryCount: 0
};
}
| 필드 | 역할 | 왜 필요한가 |
|---|---|---|
| id | 임시 항목 추적용 tmp id | 성공 시 어느 항목을 교체할지 찾기 위해 |
| syncStatus | "pending", "failed", "synced" 같은 상태 | 현재 항목이 어떤 단계인지 UI가 알 수 있게 하기 위해 |
| errorMessage | 실패 안내 문구 | 사용자에게 왜 멈췄는지 설명하기 위해 |
| retryCount | 재시도 횟수 | 지금이 첫 실패인지 반복 실패인지 구분하는 데 도움을 줄 수 있다 |
여기서 syncStatus는 특히 중요합니다. 화면에서 일반 항목과 pending 항목, failed 항목을 구분해 보여주는 기준이 되기 때문입니다. 입문자에게는 pending과 failed만 있어도 충분하지만, 나중에는 retrying 같은 상태를 더 둘 수도 있습니다. 다만 첫 버전에서는 너무 잘게 쪼개기보다 pending -> failed -> 성공 시 교체 정도만 분명해도 좋습니다.
핵심 정리
retry가 들어오는 순간 임시 항목은 단순한 "가짜 항목"이 아니라, 저장 상태를 가진 항목이 된다. 그래서 syncStatus 같은 명확한 단계 값이 필요해진다.
재시도 흐름은 어떻게 움직일까
이제 실제 retry 흐름을 보겠습니다. 핵심은 어렵지 않습니다. failed 항목을 다시 pending으로 바꾸고, 같은 text로 다시 요청을 보내고, 성공하면 tmp 항목을 서버 항목으로 교체하고, 실패하면 다시 failed로 돌린다는 흐름입니다.
입문자에게는 아래처럼 단계로 나눠 보는 편이 가장 이해하기 쉽습니다.
- 사용자가 retry 버튼을 누른다.
- 해당 tmp 항목의 syncStatus를 "failed"에서 "pending"으로 바꾼다.
- errorMessage를 비우고 retryCount를 올린다.
- 같은 text로 서버 생성 요청을 다시 보낸다.
- 성공하면 tmp 항목을 서버 응답 항목으로 교체한다.
- 실패하면 다시 "failed" 상태로 되돌린다.
async function retryCreateTodo(tempId) {
const target = todos.find(todo => todo.id === tempId);
if (!target) return;
if (target.syncStatus !== "failed") return;
todos = todos.map(todo =>
todo.id === tempId
? {
...todo,
syncStatus: "pending",
errorMessage: "",
retryCount: todo.retryCount + 1
}
: todo
);
renderTodos();
try {
const savedTodo = await createTodoOnServer(target.text);
todos = todos.map(todo =>
todo.id === tempId
? savedTodo
: todo
);
} catch (error) {
todos = todos.map(todo =>
todo.id === tempId
? {
...todo,
syncStatus: "failed",
errorMessage: "저장에 실패했습니다. 다시 시도해 주세요."
}
: todo
);
} finally {
renderTodos();
}
}
이 코드에서 가장 중요한 점은 두 가지입니다. 첫째, retry도 새로운 append가 아니라 같은 tmp 항목을 기준으로 상태를 바꾸고 마지막에 교체한다는 점입니다. 둘째, 실패한 뒤에도 입력값은 사라지지 않고 같은 항목 안에 남아 있기 때문에, 사용자는 다시 입력할 필요 없이 retry만 누르면 됩니다.
| 단계 | 핵심 동작 |
|---|---|
| retry 시작 | failed -> pending |
| 성공 | tmp 항목 -> 서버 항목 교체 |
| 실패 | pending -> failed |
이 구조가 중요한 이유는 retry가 단순한 버튼 하나가 아니라, 실패한 항목의 생애주기를 한 번 더 돌리는 것이기 때문입니다. 이 감각이 잡히면 retry 버튼이 왜 별도의 상태 설계를 요구하는지도 훨씬 이해하기 쉬워집니다.
핵심 포인트
retry는 새로운 생성이 아니라, 실패 상태였던 같은 tmp 항목을 다시 pending 흐름에 올려놓는 일에 가깝다.
failed 항목 UI는 어떻게 보여줘야 할까
retry 구조를 만들었다면 화면에서도 이 항목이 일반 항목과 다르게 보이는 편이 좋습니다. 그렇지 않으면 사용자는 왜 이 항목만 저장이 안 됐는지, 지금 누를 수 있는 행동이 무엇인지 알기 어렵습니다. 특히 failed 항목은 "이미 목록에 있지만 아직 완전히 저장된 것은 아닌 상태"라는 점이 중요합니다.
| 보여주면 좋은 것 | 왜 필요한가 |
|---|---|
| "저장 실패" 같은 짧은 배지 | 일반 항목과 상태를 구분해 보여줄 수 있다 |
| retry 버튼 | 사용자가 다음 행동을 바로 알 수 있다 |
| 삭제 또는 닫기 버튼 | 사용자가 직접 이 임시 항목을 정리할 수 있다 |
| pending 중 표시 | retry 후 다시 저장 중인지 알 수 있다 |
입문자에게는 처음부터 너무 많은 동작을 허용하지 않는 편이 좋습니다. 예를 들면 failed 항목에서는 우선 retry와 제거만 열어두고, 수정이나 드래그 재정렬은 잠깐 막아두는 편이 훨씬 단순합니다. 이렇게 해야 "지금 이 항목은 확정된 일반 항목이 아니라 실패 상태의 임시 항목"이라는 규칙이 화면에서도 분명해집니다.
예를 들어 렌더링 기준은 이렇게 잡을 수 있습니다.
function getTodoUiMode(todo) {
if (todo.syncStatus === "pending") {
return "pending";
}
if (todo.syncStatus === "failed") {
return "failed";
}
return "normal";
}
그리고 failed 모드에서는 일반 액션 일부를 막고 retry 쪽을 더 분명하게 보여주는 식입니다. 이런 UI는 기술적으로 복잡하기보다, 현재 이 항목이 어떤 종류의 항목인지 사용자에게 설명하는 역할에 더 가깝습니다.
실전 팁
실패한 임시 항목은 "조용히 남아 있는 일반 항목"처럼 보이게 두기보다, retry가 필요하다는 점이 한눈에 보이게 만드는 편이 훨씬 덜 헷갈린다.
초보자가 자주 하는 실수 6가지
retry 구조를 붙이기 시작하면 기능은 친절해지지만, 동시에 자주 나오는 실수도 꽤 분명합니다. 대부분은 임시 항목과 최종 항목의 경계를 다시 흐리게 볼 때 생깁니다.
| 실수 | 겉으로 보이는 증상 | 실제로 문제인 부분 | 어떻게 보는 게 좋은가 |
|---|---|---|---|
| 실패 시 무조건 제거만 한다 | 사용자가 방금 쓴 입력을 잃어버린 느낌을 받는다 | 실패 후 유지라는 선택지를 고려하지 않았다 | 입력 노력이 조금이라도 크면 failed 유지도 고려하는 편이 낫다 |
| retry도 append처럼 처리한다 | 재시도 성공 후 같은 항목이 또 생긴다 | 실패 항목을 재사용하지 않고 새 생성처럼 다뤘다 | retry는 같은 tmp 항목을 다시 pending으로 올리는 흐름으로 본다 |
| 성공 후 syncStatus를 안 지운다 | 이미 저장된 항목이 계속 실패 또는 대기 항목처럼 보인다 | 교체 단계가 완전히 끝나지 않았다 | 성공 시 tmp 항목을 서버 항목으로 완전히 교체하는 편이 좋다 |
| failed 항목도 일반 항목처럼 편집, 재정렬, 체크를 다 허용한다 | 구조 설명이 급격히 어려워진다 | 임시 항목 규칙이 없다 | 첫 버전은 retry와 제거 위주로 제한하는 편이 낫다 |
| retryCount 같은 상태를 늘렸는데 어디서 쓰는지 기준이 없다 | 상태만 복잡해지고 UI는 그대로다 | 상태와 화면 규칙이 연결되지 않았다 | 처음에는 syncStatus와 errorMessage 정도만으로도 충분하다 |
| retry 도중 또 retry를 허용한다 | 같은 tmp 항목에 요청이 겹쳐 더 꼬인다 | pending 상태에서의 잠금 규칙이 없다 | pending 항목은 retry 버튼을 비활성화하는 편이 더 안전하다 |
특히 마지막 실수는 retry를 넣기 시작하면 자주 보입니다. 사용자는 불안해서 버튼을 여러 번 누를 수 있고, 그 순간 같은 tmp 항목에 여러 생성 요청이 겹치기 쉽습니다. 그래서 failed -> pending으로 바뀌는 순간에는 다시 retry를 잠깐 잠그는 편이 훨씬 안정적입니다.
실전에서 가장 많이 흔들리는 부분
retry 기능이 이상해질 때는 서버 요청보다 "같은 tmp 항목이 지금 pending인지 failed인지"를 UI와 코드가 똑같이 보고 있는지부터 다시 확인하는 편이 훨씬 빠르다.
'바이브코딩 > 이론' 카테고리의 다른 글
| 30. 인터넷이 끊겨도 앱은 어떻게 버텨야 할까: 큐와 동기화 입문 (0) | 2026.05.21 |
|---|---|
| 28. 새 항목은 보이는데 왜 수정과 삭제가 꼬일까: 임시 id와 진짜 id 연결하기 (0) | 2026.05.14 |
| 27. 저장은 성공했는데 왜 화면 값이 또 바뀔까: 서버 응답 반영 기준 (1) | 2026.05.12 |
| 26. 지금 저장됐는지 어떻게 보여줄까: "저장 중", "저장됨", "실패" 상태 설계하기 (0) | 2026.05.07 |
| 25. 입력할 때마다 저장하면 왜 더 불편할까: 바이브코딩 debounce 입문 (0) | 2026.05.05 |
