지난 글에서는 서버 응답을 기다리지 않고 화면을 먼저 바꾸는 "낙관적 업데이트"를 다뤘습니다. 여기까지 오면 바로 다음 문제가 보입니다. 화면은 빨라졌는데, 가끔 저장 결과가 이상하게 되돌아가는 경우입니다. 분명 마지막에 "주말 장보기"로 바꿨는데, 잠시 뒤 예전 값인 "장보기"로 다시 돌아가는 식입니다. 처음 보면 꽤 당황스럽습니다.

이 문제는 코드 한 줄이 틀려서라기보다, 여러 요청이 겹쳐 있을 때 어느 응답이 최종값이 되어야 하는지 규칙이 없어서 생기는 경우가 많습니다. 특히 자동 저장, 연속 수정, 빠른 토글, 낙관적 업데이트가 붙기 시작하면 같은 항목에 대한 요청이 짧은 시간 안에 여러 번 나갈 수 있습니다. 그리고 네트워크 응답은 보낸 순서대로 꼭 돌아오지 않습니다.

이번 글에서는 왜 먼저 보낸 요청보다 나중에 도착한 응답이 더 위험할 수 있는지, "race condition"을 입문자 기준으로 어떻게 이해하면 되는지, 그리고 가장 단순한 첫 방어선으로 requestId를 두는 방식이 왜 유용한지 차근차근 정리해보겠습니다.

이번 글에서 먼저 잡아둘 핵심
겉으로 보이는 문제 실제로 일어나는 일 가장 먼저 봐야 할 방향
마지막에 수정한 값이 다시 예전 값으로 돌아간다 늦게 도착한 오래된 응답이 최신 화면을 덮어썼다 "지금 가장 최신 요청이 무엇인지"를 따로 추적하는지 본다
저장 중 잠금을 넣었는데도 꼬인다 isSaving은 중복 클릭을 막아도, 겹친 응답 순서까지 해결하지는 못한다 요청 순서를 판단하는 별도 기준이 있는지 본다
자동 저장을 붙였더니 더 자주 이상해진다 짧은 시간에 여러 요청이 연속으로 나가면서 응답 도착 순서가 엇갈린다 자동 저장일수록 requestId나 취소 전략이 더 중요해진다
낙관적 업데이트는 빨라졌는데 실패 시 설명이 어렵다 최신 요청인지 오래된 요청인지 구분 없이 rollback이 섞였다 rollback도 최신 요청 기준으로만 하도록 설계하는지 본다

이번 글의 핵심 한 줄
비동기 저장에서는 "무엇을 보냈는가"만큼이나 "어떤 응답이 마지막으로 도착했는가"가 중요하다. 그래서 최신 요청을 구분하는 기준이 꼭 필요해진다.


목차

  1. 왜 먼저 보낸 요청보다 늦게 온 응답이 더 위험할까
  2. "race condition"을 체크리스트 앱으로 이해해보자
  3. isSaving만으로는 왜 충분하지 않을까
  4. 가장 쉬운 첫 방어: requestId로 최신 요청만 유효하게 만들기
  5. "취소"와 "무시"는 무엇이 다를까
  6. 초보자가 자주 하는 실수 6가지
  7. 마무리

왜 먼저 보낸 요청보다 늦게 온 응답이 더 위험할까

동기 저장에 익숙할 때는 "먼저 보낸 요청이 먼저 끝나겠지"라고 자연스럽게 생각하기 쉽습니다. 하지만 네트워크 요청은 그렇게 단순하지 않습니다. 먼저 보냈어도 늦게 도착할 수 있고, 나중에 보낸 요청이 더 빨리 돌아올 수도 있습니다. 이게 문제가 되는 이유는, 앱이 응답 도착 순서를 그대로 믿으면 오래된 값이 최신 값을 덮어쓸 수 있기 때문입니다.

예를 들어 사용자가 같은 항목을 아주 짧은 시간 안에 두 번 수정했다고 해보겠습니다. 첫 번째 저장 요청은 "장보기"를 "주말 장보기"로 바꾸는 요청이고, 두 번째 저장 요청은 다시 "주말 장보기"를 "토요일 장보기"로 바꾸는 요청입니다. 사용자는 마지막에 "토요일 장보기"를 입력했으니 그게 최종이라고 느낍니다. 그런데 응답은 다르게 돌아올 수 있습니다.

먼저 보낸 요청이 늦게 돌아오면 생길 수 있는 상황
순서 무슨 일이 일어났나 사용자가 기대하는 값
1 요청 A: "주말 장보기" 저장 요청 전송 아직 진행 중
2 요청 B: "토요일 장보기" 저장 요청 전송 최종적으로는 이 값이 남아야 한다
3 응답 B가 먼저 도착해 화면이 "토요일 장보기"가 된다 여기까지는 자연스럽다
4 응답 A가 나중에 도착해 다시 "주말 장보기"로 덮어쓴다 사용자 눈에는 갑자기 예전 값으로 돌아간 버그처럼 보인다

즉, 문제는 요청이 실패했는가가 아니라 오래된 응답을 최신 값처럼 반영했는가에 있습니다. 그래서 비동기 저장에서는 "성공했으니 반영한다"만으로는 부족하고, "이 응답이 아직도 최신 요청에 해당하는가"까지 같이 봐야 합니다.

핵심 포인트
비동기 요청에서는 성공 응답도 항상 반영하면 안 될 수 있다. 오래된 성공 응답은 오히려 현재 상태를 망가뜨릴 수 있다.


"race condition"을 체크리스트 앱으로 이해해보자

"race condition"이라는 말이 처음 나오면 꽤 어렵게 느껴질 수 있습니다. 하지만 지금 단계에서는 아주 단순하게 이해해도 충분합니다. 여러 작업이 동시에 달리고 있을 때, 누가 먼저 끝나느냐에 따라 최종 결과가 달라지는 상황 정도로 받아들이면 됩니다.

체크리스트 앱에서는 이게 저장 요청끼리 겹칠 때 자주 드러납니다. 예를 들어 자동 저장이 켜져 있거나, 사용자가 아주 빠르게 같은 항목을 여러 번 바꾸거나, 완료 체크를 연속으로 누르는 상황이 그렇습니다. 각각의 요청은 모두 정당하게 보이지만, 응답이 뒤섞여 돌아오면 최종 결과가 사용자의 마지막 의도와 다를 수 있습니다.

체크리스트 앱에서 race condition이 잘 보이는 순간들
상황 왜 겹치기 쉬운가
자동 저장 중 빠르게 여러 글자를 수정한다 같은 항목에 대한 요청이 짧은 시간 안에 여러 번 나간다
완료 체크를 빠르게 on, off 한다 마지막 상태보다 먼저 보낸 요청이 늦게 와서 덮어쓸 수 있다
낙관적 업데이트로 화면은 먼저 바뀐다 성공 응답이 늦게 섞이면 현재 UI와 과거 응답이 충돌할 수 있다

즉, race condition은 꼭 거대한 시스템에서만 생기는 문제가 아닙니다. 작은 체크리스트 앱에서도 비동기 요청이 겹치기 시작하면 충분히 등장합니다. 그래서 이 개념을 너무 거창하게 보기보다, "마지막 사용자 의도를 누가 지켜줄 것인가"라는 질문으로 바꿔서 보는 편이 훨씬 이해가 쉽습니다.

쉽게 말하면
race condition은 코드가 어려워서 생기는 문제가 아니라, 동시에 달리는 요청들 사이에서 "누가 최종 우선권을 가지는가"가 아직 정해지지 않았을 때 생기는 문제에 가깝다.


isSaving만으로는 왜 충분하지 않을까

입문자 입장에서는 이런 반응이 자연스럽습니다. "그럼 저장 중에는 버튼을 막으면 되지 않나?" 실제로 isSaving은 매우 중요합니다. 중복 클릭을 막고, 같은 저장 버튼이 연속으로 눌리는 문제를 줄이는 데 큰 도움이 됩니다. 하지만 race condition까지 해결해주지는 않습니다.

왜냐하면 isSaving은 주로 지금 새 행동을 막을 것인가를 다루는 상태이지, 돌아온 응답이 최신 것인가를 판단하는 상태는 아니기 때문입니다. 특히 자동 저장이나 입력 중 저장처럼 사용자가 계속 값을 바꾸는 구조에서는 단순히 버튼 잠금만으로 해결이 안 됩니다.

isSaving이 해주는 일과 못 해주는 일
구분 도움이 되는가 이유
저장 버튼 연속 클릭 막기 저장 중에는 다시 저장 행동을 잠글 수 있다
오래된 응답 무시하기 아니오 응답 자체가 최신 요청인지 아닌지를 구분해주지 않는다
자동 저장 여러 번 겹칠 때 최신 값 지키기 보통 부족하다 마지막 요청 기준을 따로 가져야 한다

즉, isSaving은 여전히 필요하지만 그것만으로는 충분하지 않습니다. 저장 중 잠금은 "지금 또 눌러도 되는가"를 보는 기준이고, race condition 방어는 "이 응답을 지금 반영해도 되는가"를 보는 기준입니다. 둘은 서로 다른 질문입니다.

핵심 정리
isSaving은 행동 잠금이고, race condition 방어는 응답 우선순위 문제다. 둘 다 필요하지만 같은 역할은 아니다.


가장 쉬운 첫 방어: requestId로 최신 요청만 유효하게 만들기

입문자가 가장 먼저 이해하기 좋은 방법은 각 저장 요청에 번호를 붙이는 방식입니다. 보통 requestId, saveId, sequence 같은 이름을 씁니다. 핵심은 간단합니다. 요청을 보낼 때마다 숫자를 하나씩 올리고, 응답이 돌아왔을 때 그 숫자가 아직 최신인지 확인하는 것입니다.

예를 들면 아래처럼 볼 수 있습니다.

let saveState = {

latestRequestId: 0
};

저장 요청을 보낼 때는 현재 값을 하나 올립니다.

function getNextRequestId() {

saveState.latestRequestId += 1;
return saveState.latestRequestId;
}

그리고 요청마다 자기 requestId를 들고 갑니다.

async function saveEditSafely(nextTodos) {

const requestId = getNextRequestId();

uiState.isSaving = true;
uiState.saveError = "";

try {
const savedTodos = await saveTodosToServer(nextTodos);

if (requestId !== saveState.latestRequestId) {
  return;
}

todos = savedTodos;

} catch (error) {
if (requestId !== saveState.latestRequestId) {
return;
}

uiState.saveError = "저장 중 문제가 생겼습니다.";

} finally {
if (requestId === saveState.latestRequestId) {
uiState.isSaving = false;
renderTodos();
}
}
}

이 구조의 핵심은 아주 분명합니다. 늦게 온 응답이라도 그 requestId가 최신이 아니면 그냥 무시합니다. 즉, "성공한 응답이냐 아니냐"보다 먼저 "아직도 이 응답이 최신 요청에 해당하느냐"를 묻는 셈입니다.

requestId 방식이 하는 일
단계 무슨 의미인가
요청 A 보냄, requestId = 1 현재 기준 최신 요청은 1이다
요청 B 보냄, requestId = 2 이제 최신 요청 기준은 2로 바뀐다
응답 A가 늦게 옴 1 !== 2 이므로 오래된 응답이라 무시한다
응답 B가 옴 2 === 2 이므로 현재 최신 요청으로 인정하고 반영한다

입문자 입장에서 이 방식이 좋은 이유는 복잡한 취소 기능 없이도 "최신 요청이 이긴다"는 규칙을 분명하게 만들 수 있기 때문입니다. 아직 네트워크 요청을 완전히 취소하지는 못해도, 적어도 오래된 응답이 최신 화면을 다시 덮어쓰는 일은 크게 줄일 수 있습니다.

가장 쉬운 첫 방어선
요청마다 번호를 붙이고, 응답이 돌아왔을 때 그 번호가 아직 최신인지 확인하는 방식은 race condition을 처음 이해하기에 가장 단순하고 효과적인 출발점이다.


"취소"와 "무시"는 무엇이 다를까

여기서 한 가지를 더 구분해두면 좋습니다. 오래된 요청을 무시하는 것과, 아예 이전 요청을 취소하는 것은 같은 말이 아닙니다. 처음에는 둘 다 "예전 요청을 더 이상 안 쓰게 한다"처럼 보여서 섞이기 쉬운데, 역할은 분명히 다릅니다.

"무시"와 "취소"는 다르다
구분 무시 취소
응답이 와도 현재 화면에는 반영하지 않는다 애초에 이전 요청을 멈추려 시도한다
구현 난도 비교적 낮은 편 조금 더 높은 편
입문자 첫 단계로 적합한가 상황에 따라 나중 단계로 보는 편이 무난하다

즉, requestId 방식은 "무시"에 가깝습니다. 예전 요청이 실제 네트워크에서 계속 끝까지 돌더라도, 화면에는 최신 요청 기준으로만 반영하는 방식입니다. 반면 "취소"는 이전 요청 자체를 가능한 한 멈추게 하는 쪽입니다. 브라우저 환경에서는 예를 들어 AbortController 같은 도구를 쓰는 방식이 여기에 가깝습니다.

입문자에게는 보통 무시 전략부터 이해하는 편이 더 낫습니다. 왜냐하면 지금 단계에서 중요한 건 네트워크를 정교하게 제어하는 것보다, 오래된 응답이 최신 화면을 덮어쓰지 못하게 하는 규칙을 먼저 갖추는 것이기 때문입니다.

핵심 정리
처음에는 "이전 요청을 완벽히 없애는 것"보다 "예전 응답이 지금 화면을 망치지 못하게 하는 것"부터 분명히 해도 충분하다.


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

race condition을 처음 다룰 때 자주 나오는 실수는 꽤 비슷합니다. 대부분은 "응답은 다 성공이니까 다 반영해도 되겠지"라는 직관에서 시작됩니다. 하지만 비동기 저장에서는 그 직관이 자주 틀립니다.

실수 겉으로 보이는 증상 실제로 문제인 부분 어떻게 보는 게 좋은가
응답이 성공이면 무조건 todos에 반영한다 예전 응답이 최신 상태를 덮어쓴다 응답이 최신 요청인지 확인하지 않았다 requestId 같은 기준으로 최신 여부를 먼저 본다
isSaving만 있으면 충분하다고 생각한다 자동 저장이나 겹친 수정에서는 여전히 꼬인다 행동 잠금과 응답 우선순위 문제를 섞어 봤다 isSaving과 requestId는 서로 다른 역할로 본다
rollback도 오래된 요청 기준으로 실행한다 현재 화면이 갑자기 예전 snapshot으로 돌아간다 실패 처리에도 최신 요청 여부 검사가 없다 복구도 최신 요청에 대해서만 실행하는 편이 낫다
snapshot을 같은 참조로만 잡아둔다 복구했는데도 이미 바뀐 값이 남아 있다 백업이 실제 복사가 아니라 같은 객체를 가리켰다 배열과 객체를 분명하게 복사해두는 편이 좋다
응답 순서를 의식하지 않고 테스트한다 평소엔 되다가 가끔만 이상해 보여 원인 추적이 어렵다 겹친 요청 시나리오를 일부러 만들어보지 않았다 빠른 연속 수정이나 자동 저장 상황을 직접 테스트해본다
모든 기능에 같은 낙관적 전략을 곧바로 적용한다 토글은 괜찮은데 삭제나 생성에서 복구가 갑자기 어려워진다 기능별 복구 난도 차이를 무시했다 단순한 동작부터 단계적으로 적용하는 편이 낫다

특히 세 번째 실수는 지난 글의 낙관적 업데이트와 이번 글의 race condition이 연결되는 지점이라 더 중요합니다. 오래된 요청의 실패 응답이 나중에 도착했는데, 그걸 기준으로 rollback을 실행해 버리면 현재 최신 화면까지 같이 망가질 수 있습니다. 그래서 성공 응답만 최신 여부를 확인하는 것이 아니라, 실패 처리와 복구도 같은 기준으로 묶어야 합니다.

실전에서 가장 많이 흔들리는 부분
race condition은 성공 응답만의 문제가 아니다. 실패 처리와 rollback도 "이 요청이 아직 최신인가"를 같이 보지 않으면 오히려 더 큰 혼란을 만들 수 있다.


마무리

비동기 저장이 겹치기 시작하면 앱은 더 이상 "저장했다, 안 했다" 두 가지만으로 설명되지 않습니다. 이제는 어떤 요청이 최신인지, 어떤 응답을 믿어야 하는지, 실패했을 때 어느 상태로 돌아갈지까지 같이 봐야 합니다. 처음엔 낯설게 느껴질 수 있지만, 이 기준이 생기는 순간부터 저장 구조가 한층 더 안정적으로 보이기 시작합니다.

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

첫째, race condition은 거대한 시스템에서만 생기는 문제가 아니라 작은 체크리스트 앱에서도 충분히 생길 수 있다는 점.

둘째, isSaving은 필요하지만 그것만으로는 응답 우선순위를 해결해주지 못한다는 점.

셋째, requestId처럼 최신 요청을 구분하는 기준을 두면 오래된 응답이 현재 화면을 덮어쓰는 문제를 크게 줄일 수 있다는 점입니다.

 

여기까지 오면 이제 저장 구조는 단순한 성공과 실패를 넘어서, "어떤 응답이 최종 진실인가"까지 설명할 수 있는 단계로 올라옵니다. 다음 글에서는 이 흐름을 이어서, 입력할 때마다 바로 저장 요청을 보내면 왜 금방 불편해지는지, 그리고 자동 저장에서 자주 나오는 "debounce"를 초보자 기준에서 쉽게 풀어보겠습니다.