지난 글에서는 서버가 돌려준 응답을 어떤 기준으로 클라이언트 상태에 반영할지 정리했습니다. 여기까지 오면 바로 다음으로 자주 나오는 문제가 있습니다. 바로 "생성"입니다. 수정은 기존 항목이 이미 있으니 id도 있고, 어느 항목을 바꿀지 기준도 분명합니다. 그런데 새 항목을 만들 때는 상황이 조금 다릅니다. 아직 서버가 준 진짜 id가 없는데, 화면에는 먼저 보여주고 싶을 때가 생기기 때문입니다.
특히 낙관적 업데이트를 붙이기 시작하면 이 문제가 더 잘 보입니다. 사용자가 추가 버튼을 누르는 즉시 목록에 항목이 보이게 만들고 싶은데, 그 순간 서버 응답은 아직 오지 않았습니다. 그러면 화면에는 뭔가 "임시 항목"이 먼저 생겨야 합니다. 그리고 나중에 서버가 진짜 id와 최종 저장본을 돌려주면, 그 임시 항목을 제대로 갈아끼워야 합니다.
이 단계에서 구조가 조금만 흐리면 금방 이상한 일이 생깁니다. 항목이 두 개로 보이거나, 화면에는 보이는데 수정이나 삭제가 실패하거나, 정렬이 이상하게 흔들리거나, 저장 성공 후에도 tmp id가 남아서 다음 요청이 꼬이는 식입니다. 이번 글에서는 왜 생성이 수정보다 더 까다로운지, 임시 id가 정확히 무슨 역할을 하는지, 그리고 초보자 기준에서는 어떤 방식으로 임시 항목을 서버가 준 진짜 항목으로 바꾸는 편이 가장 덜 흔들리는지를 차근차근 정리해보겠습니다.
| 겉으로 보이는 문제 | 실제로 일어나는 일 | 가장 먼저 봐야 할 방향 |
|---|---|---|
| 새 항목이 두 개로 보인다 | 임시 항목을 "교체"하지 않고 서버 응답 항목을 그냥 "추가"했다 | 생성 성공 시 append가 아니라 replace가 필요한지 본다 |
| 생성은 됐는데 수정이나 삭제가 실패한다 | 화면에 남아 있는 id가 서버가 아는 진짜 id가 아니다 | 서버 응답에서 받은 최종 id로 갈아끼우는지 확인한다 |
| 항목은 보이는데 뭔가 불안하다 | 아직 저장 확정 전인 임시 항목을 일반 항목과 같은 것으로 보고 있다 | pending 상태를 구분해서 보여주는 편이 좋은지 본다 |
| 실패했을 때 목록이 더 이상해진다 | 임시 항목을 제거할지, 에러 상태로 남길지 규칙이 없다 | 첫 버전에서는 실패 시 어떤 복구를 할지 먼저 정한다 |
이번 글의 핵심 한 줄
낙관적 생성에서는 화면에 먼저 뜬 항목이 최종 저장본이 아닐 수 있다. 그래서 "임시 항목을 서버가 확정한 항목으로 교체하는 단계"가 꼭 필요해진다.
목차
- 왜 생성은 수정보다 더 까다로울까
- 임시 id는 정확히 왜 필요한가
- 서버 id로 바꾸지 않으면 어떤 일이 생기나
- 가장 쉬운 두 가지 흐름: 응답 후 추가 vs 낙관적 추가
- 낙관적 생성의 핵심: 임시 항목을 서버 항목으로 "교체"하기
- 초보자가 자주 하는 실수 6가지
- 마무리
왜 생성은 수정보다 더 까다로울까
수정은 기존 항목이 이미 있다는 점에서 출발합니다. id도 있고, text도 있고, done도 있고, order도 있습니다. 그래서 사용자가 수정 버튼을 누르면 "어느 항목을 바꿀지"가 비교적 분명합니다. 서버 저장으로 넘어가도 같은 id를 기준으로 응답을 덮어쓰거나 교체하면 됩니다.
반면 생성은 조금 다릅니다. 서버에 요청을 보내기 전까지는 아직 그 항목이 "진짜로 존재한다"고 보기 어렵기 때문입니다. 그런데 사용자 경험을 좋게 만들려면 요청 직후 화면에 바로 보이게 하고 싶을 수 있습니다. 이때부터 임시 항목이라는 개념이 등장합니다.
| 구분 | 수정 | 생성 |
|---|---|---|
| 시작점 | 이미 존재하는 항목이 있다 | 아직 서버에 존재하지 않는 항목이다 |
| id | 기존 id를 그대로 쓴다 | 최종 id는 서버가 줄 수 있다 |
| 낙관적 UI | 기존 항목을 바로 바꿔 보여주기 쉽다 | 임시 항목을 먼저 만들어 보여줘야 할 수 있다 |
| 성공 후 처리 | 기존 항목을 최신 응답으로 교체하거나 병합한다 | 임시 항목을 진짜 항목으로 바꿔 끼워야 한다 |
즉, 생성이 더 까다로운 이유는 "항목 하나 더 넣기" 때문이 아니라, 존재가 아직 확정되지 않은 항목을 먼저 화면에 보여줄 수 있기 때문입니다. 이 차이를 이해하면 왜 임시 id와 교체 단계가 필요한지도 훨씬 또렷해집니다.
핵심 포인트
수정은 기존 항목을 다듬는 문제이고, 생성은 "아직 확정되지 않은 항목을 잠깐 보여줄 것인가"까지 함께 다루는 문제다.
임시 id는 정확히 왜 필요한가
입문자가 임시 id를 처음 보면 이런 생각을 하기 쉽습니다. "어차피 나중에 서버가 진짜 id를 줄 텐데, 굳이 지금도 id가 있어야 하나?" 그런데 낙관적 생성에서는 꽤 자주 필요합니다. 화면에 뜬 임시 항목도 일단은 목록 안의 한 줄이기 때문입니다. 렌더링할 때도 구분이 필요하고, 나중에 서버 응답이 왔을 때 "어느 임시 항목을 진짜 항목으로 바꿀지" 찾는 기준도 필요합니다.
즉, 임시 id는 "서버가 아는 최종 id"라기보다 클라이언트가 잠깐 들고 가는 추적용 꼬리표에 가깝습니다. 낙관적 UI에서는 이 꼬리표가 있어야 나중에 응답과 임시 항목을 연결할 수 있습니다.
function createTempTodo(text) {
return {
id: "tmp-" + Date.now() + "-" + Math.random().toString(16).slice(2),
text,
done: false,
order: getNextOrder(),
isPending: true
};
}
여기서 tmp- 같은 접두사를 붙이는 이유도 단순합니다. 서버 id와 눈으로도 구분하기 쉽고, 숫자 id와 우연히 섞여서 헷갈릴 가능성을 줄일 수 있기 때문입니다. 입문자 단계에서는 이런 단순한 구분이 꽤 도움이 됩니다.
| 이유 | 왜 필요한가 |
|---|---|
| 렌더링 구분 | 목록 안에서 항목 하나를 식별할 수 있어야 한다 |
| 응답 연결 | 나중에 서버 응답이 오면 어느 임시 항목을 교체할지 찾아야 한다 |
| 임시 상태 표시 | 아직 저장 중인 항목인지 화면에서 구분해 보여줄 수 있다 |
핵심 정리
임시 id는 서버를 위한 값이 아니라, 서버 응답이 오기 전까지 클라이언트가 임시 항목을 잃어버리지 않기 위한 연결 고리다.
서버 id로 바꾸지 않으면 어떤 일이 생기나
이제 가장 자주 터지는 문제를 보겠습니다. 임시 항목을 서버 응답 항목으로 제대로 바꾸지 않으면, 겉보기에는 추가가 된 것 같은데 그 다음 단계에서 계속 어색해집니다. 왜냐하면 화면은 tmp id를 가진 항목을 보고 있는데, 서버는 진짜 id를 가진 항목을 저장해두고 있기 때문입니다.
| 문제 | 왜 생기나 |
|---|---|
| 같은 항목이 두 개로 보인다 | 임시 항목은 그대로 두고 서버 응답 항목을 따로 append 했다 |
| 수정이나 삭제가 실패한다 | 클라이언트는 tmp id를 보내고, 서버는 그 id를 모른다 |
| 정렬이나 선택 상태가 이상해진다 | 같은 항목처럼 보이는데 실제 식별자는 서로 다르다 |
| pending 표시가 안 사라진다 | 임시 항목을 최종 항목으로 교체하는 단계가 빠졌다 |
이 문제를 가장 많이 만드는 코드는 보통 이런 식입니다.
todos = [...todos, tempTodo];
renderTodos();
const savedTodo = await createTodoOnServer(result.value);
todos = [...todos, savedTodo];
renderTodos();
겉보기에는 문제 없어 보이지만, 실제로는 tempTodo도 남고 savedTodo도 따로 들어갑니다. 즉, "하나를 바꿔 끼운다"가 아니라 "하나를 더 추가한다"가 되어버립니다. 그래서 생성 성공 뒤에는 append보다 replace가 훨씬 더 중요해집니다.
핵심 포인트
낙관적 생성에서 가장 중요한 동사는 append가 아니라 replace에 가깝다. 임시 항목을 남겨두고 서버 항목을 더하는 순간부터 모든 게 조금씩 어긋나기 시작한다.
가장 쉬운 두 가지 흐름: 응답 후 추가 vs 낙관적 추가
여기서 초보자에게 가장 도움이 되는 비교가 하나 있습니다. 생성은 크게 두 가지 흐름으로 만들 수 있습니다. 첫 번째는 응답이 온 뒤에 목록에 추가하는 방식입니다. 두 번째는 임시 항목을 먼저 보여주고 나중에 교체하는 방식입니다.
| 방식 | 장점 | 주의할 점 |
|---|---|---|
| 응답 후 추가 | 구조가 단순하고 tmp id가 필요 없다 | 사용자 눈에는 반응이 느리게 느껴질 수 있다 |
| 낙관적 추가 | 즉시 반응해서 빠르게 느껴진다 | 임시 항목, 교체, 실패 복구 규칙이 필요하다 |
입문자에게는 이 비교가 특히 중요합니다. 왜냐하면 "빠르게 보이게 만들고 싶은 욕심" 때문에 바로 낙관적 추가로 가기 쉽기 때문입니다. 하지만 구조를 처음 잡는 단계에서는 응답 후 추가부터 먼저 안정화해도 충분히 의미가 있습니다. 그 다음에 낙관적 추가를 붙이면 왜 tmp id가 필요해지는지도 더 쉽게 이해됩니다.
예를 들어 응답 후 추가는 아주 단순하게 볼 수 있습니다.
async function addTodoAfterResponse(rawText) {
const result = validateTodoText(rawText);
if (!result.ok) {
uiState.formError = "내용을 입력해 주세요.";
renderTodos();
return;
}
uiState.isSaving = true;
uiState.formError = "";
renderTodos();
try {
const savedTodo = await createTodoOnServer(result.value);
todos = [...todos, savedTodo];
} catch (error) {
uiState.formError = "추가 저장에 실패했습니다.";
} finally {
uiState.isSaving = false;
renderTodos();
}
}
입문자에게 가장 현실적인 기준
생성 흐름이 아직 헷갈린다면 응답 후 추가부터 먼저 만들고, 사용감이 너무 느리다고 느껴질 때 그다음 단계로 낙관적 추가를 붙이는 편이 훨씬 덜 흔들린다.
낙관적 생성의 핵심: 임시 항목을 서버 항목으로 "교체"하기
이제 낙관적 추가의 가장 중요한 흐름을 보겠습니다. 핵심은 단순합니다. 임시 항목을 먼저 넣고, 성공 시에는 서버가 준 항목으로 그 자리를 교체하고, 실패 시에는 미리 정한 규칙대로 정리한다.
가장 단순한 첫 버전 코드는 아래처럼 볼 수 있습니다.
async function addTodoOptimistically(rawText) {
const result = validateTodoText(rawText);
if (!result.ok) {
uiState.formError = "내용을 입력해 주세요.";
renderTodos();
return;
}
const tempTodo = createTempTodo(result.value);
todos = [...todos, tempTodo];
uiState.formError = "";
renderTodos();
try {
const savedTodo = await createTodoOnServer(result.value);
todos = todos.map(todo =>
todo.id === tempTodo.id
? savedTodo
: todo
);
} catch (error) {
todos = todos.filter(todo => todo.id !== tempTodo.id);
uiState.formError = "추가 저장에 실패했습니다.";
} finally {
renderTodos();
}
}
이 코드에서 꼭 눈여겨볼 부분은 두 군데입니다.
- 성공 시에는 map으로 교체한다
tmp id를 가진 항목을 서버가 준 savedTodo로 바꿉니다. - 실패 시에는 remove 규칙을 쓴다
첫 버전에서는 임시 항목을 제거하고 에러만 보여줘도 충분한 경우가 많습니다.
여기서 더 발전된 버전으로 가면 실패한 임시 항목을 바로 지우지 않고 "다시 시도" 상태로 남겨둘 수도 있습니다. 하지만 입문자 기준에서는 우선 교체 흐름이 분명한 편이 더 중요합니다. 생성 성공 후 tmp id가 남지 않고, 서버가 준 진짜 id로 바뀌는 것. 이 감각이 먼저 잡혀야 이후 재시도나 오프라인 큐 같은 개념도 덜 흔들립니다.
| 단계 | 무슨 일이 일어나나 |
|---|---|
| 1 | 입력값 검증 후 tempTodo를 만든다 |
| 2 | 목록에 tempTodo를 먼저 넣고 화면에 보여준다 |
| 3 | 서버 요청을 보낸다 |
| 4 | 성공 시 tempTodo를 savedTodo로 교체한다 |
| 5 | 실패 시 tempTodo를 제거하거나 실패 상태로 정리한다 |
핵심 정리
낙관적 생성에서 가장 중요한 순간은 "서버가 성공 응답을 보냈을 때"가 아니라, "그 응답을 임시 항목 자리에 정확히 교체하는 순간"에 가깝다.
초보자가 자주 하는 실수 6가지
임시 id와 서버 id를 연결하는 단계에서는 비슷한 실수가 반복해서 나옵니다. 대부분은 임시 항목과 최종 항목의 경계를 흐리게 볼 때 생깁니다.
| 실수 | 겉으로 보이는 증상 | 실제로 문제인 부분 | 어떻게 보는 게 좋은가 |
|---|---|---|---|
| tmp id 없이 낙관적 생성부터 시도한다 | 응답이 왔을 때 어느 항목을 바꿔야 하는지 애매하다 | 임시 항목을 추적할 연결 고리가 없다 | 낙관적 생성이라면 tmp id 같은 추적 기준부터 둔다 |
| 성공 후 append만 하고 replace를 안 한다 | 같은 항목이 두 개처럼 보인다 | 임시 항목이 남아 있다 | 생성 성공 시에는 교체가 핵심이라고 보는 편이 좋다 |
| tmp id를 숫자로 아무렇게나 만든다 | 서버 숫자 id와 구분이 흐려진다 | 임시와 최종을 눈으로 구분하기 어렵다 | tmp- 접두사처럼 명확한 구분을 두는 편이 낫다 |
| 성공했는데도 isPending 같은 상태를 안 지운다 | 이미 저장된 항목이 계속 임시 항목처럼 보인다 | 서버 응답으로 최종 항목 전환이 완전히 끝나지 않았다 | 성공 시에는 임시 상태가 같이 정리되어야 한다 |
| 실패 시 임시 항목 정리 규칙이 없다 | 화면에 반쯤 저장된 것 같은 항목이 남는다 | 성공과 실패 후 처리 경계가 없다 | 첫 버전은 제거 또는 실패 표시 중 하나를 먼저 고른다 |
| pending 항목도 일반 항목처럼 수정, 삭제, 정렬을 다 허용한다 | 아직 서버가 확정하지 않은 항목에 여러 동작이 겹친다 | 임시 상태 규칙이 없다 | 첫 버전에서는 pending 항목의 동작을 일부 잠그는 편이 훨씬 안전하다 |
특히 마지막 실수는 생각보다 자주 나옵니다. 아직 서버 응답도 안 왔는데 사용자가 그 임시 항목을 다시 수정하거나 끌어서 순서를 바꾸기 시작하면, 다음 단계 설명이 훨씬 어려워집니다. 그래서 입문자에게는 pending 상태의 항목은 처음엔 일부 동작을 잠깐 막아두는 편이 더 낫습니다.
실전에서 가장 많이 흔들리는 부분
낙관적 생성이 이상할 때는 서버 요청보다도 "임시 항목을 언제 만들고, 언제 최종 항목으로 교체하고, 실패 시 어떻게 치울지"의 경계가 흐린 경우가 더 많다.
'바이브코딩 > 이론' 카테고리의 다른 글
| 30. 인터넷이 끊겨도 앱은 어떻게 버텨야 할까: 큐와 동기화 입문 (1) | 2026.05.21 |
|---|---|
| 29. 저장 실패한 항목을 왜 바로 없애면 아쉬울까: retry 가능한 임시 항목 설계 (0) | 2026.05.19 |
| 27. 저장은 성공했는데 왜 화면 값이 또 바뀔까: 서버 응답 반영 기준 (1) | 2026.05.12 |
| 26. 지금 저장됐는지 어떻게 보여줄까: "저장 중", "저장됨", "실패" 상태 설계하기 (0) | 2026.05.07 |
| 25. 입력할 때마다 저장하면 왜 더 불편할까: 바이브코딩 debounce 입문 (0) | 2026.05.05 |
