서버 변경 경쟁 상태: 늦게 끝난 요청이 최신 값을 덮지 않게 하려면
서울 다음 부산 배송지를 저장했지만 느린 서울 응답이 마지막에 도착해 되돌아가는 실패를 재현하고, 직렬 실행과 서버 버전 조건의 책임을 나눈다.
목차
표준·구현·측정·해석 표시는 무엇인가요?
- 표준웹 표준이나 언어 명세가 정한 동작
- 구현특정 기술이나 브라우저가 실제로 구현한 동작
- 측정명시한 환경에서 직접 실행해 관찰한 결과
- 해석앞선 근거에서 도출한 설계 판단
- 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용
30초 요약
비동기 서버 변경은 호출한 순서대로 끝난다고 보장할 수 없다. 같은 값을 바꾸는 요청을 겹쳐 보내면 느린 이전 요청이 빠른 최신 요청 뒤에 완료되어 화면과 서버 원본을 되돌릴 수 있다. 모든 변경을 순서대로 적용해야 한다면 같은 범위를 직렬화하고, 여러 클라이언트가 함께 바꾸는 원본은 서버가 버전 조건으로 오래된 쓰기를 거절해야 한다.
이 글을 관통하는 상황: 서울 다음 부산을 눌렀는데 서울이 남는다
사용자가 기본 배송지를 서울로 저장한 직후 부산으로 바꾼다고 하자. 서울 요청은 1.5초, 부산 요청은 0.3초 뒤 완료되도록 만든 가짜 서버로 완료 순서를 고정한다. 서버 함수는 요청이 끝나는 시점에 원본을 바꾼다.
useMutation은 서버 변경 함수를 실행하고 성공·실패 상태를 다루는 TanStack Query Hook이다.
import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query'
import { useState } from 'react'
const queryClient = new QueryClient()
let serverAddress = '기본 배송지 없음'
async function saveAddress({ name, delay }) {
await new Promise((resolve) => setTimeout(resolve, delay))
serverAddress = name
return { name }
}
function AddressSettings() {
const [confirmedAddress, setConfirmedAddress] = useState(serverAddress)
const [events, setEvents] = useState([])
const [isRunning, setIsRunning] = useState(false)
const saveAddressChange = useMutation({
mutationFn: saveAddress,
onSuccess: ({ name }) => {
setConfirmedAddress(name)
setEvents((current) => [...current, `${name} 완료`])
},
})
async function reproduceRace() {
serverAddress = '기본 배송지 없음'
setConfirmedAddress(serverAddress)
setEvents([])
setIsRunning(true)
try {
const slowSeoul = saveAddressChange.mutateAsync({
name: '서울',
delay: 1500,
})
const fastBusan = saveAddressChange.mutateAsync({
name: '부산',
delay: 300,
})
await Promise.all([slowSeoul, fastBusan])
} finally {
setIsRunning(false)
}
}
return (
<main>
<button disabled={isRunning} onClick={reproduceRace}>
서울 다음 부산 저장 재현
</button>
<p>화면 확인값: {confirmedAddress}</p>
<p>가짜 서버 원본: {serverAddress}</p>
<ol>
{events.map((event, index) => <li key={index}>{event}</li>)}
</ol>
</main>
)
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AddressSettings />
</QueryClientProvider>
)
}버튼을 누르면 완료 목록은 부산 완료 → 서울 완료가 된다. 화면 확인값과 가짜 서버 원본도 마지막에
서울로 돌아간다. 호출은 서울 → 부산이었지만 완료는 부산 → 서울이었고, 완료 callback이 올 때마다
무조건 확인값을 교체했기 때문이다.
호출 순서와 완료 순서는 다르다
요청 로그에서 시작 시각만 보거나 mutate 호출 코드를 위에서 아래로 읽는 것으로는 최종 순서를
증명할 수 없다. 각 작업의 의도 식별자, 시작·완료 시각, 적용된 서버 버전과 최종 원본을 함께 봐야 한다.
같은 클라이언트에서 모든 변경을 순서대로 실행한다
배송지 변경처럼 중간 작업도 모두 서버에 적용해야 하고 마지막 사용자 의도가 최종값이어야 한다면, 같은 자원을 바꾸는 작업을 직렬 실행할 수 있다.
앞 예제의 useMutation 설정을 다음 블록으로 바꾼 뒤 브라우저를 전체 새로고침한다.
const saveAddressChange = useMutation({
mutationFn: saveAddress,
scope: { id: 'default-shipping-address' },
onSuccess: ({ name }) => {
setConfirmedAddress(name)
setEvents((current) => [...current, `${name} 완료`])
},
})같은 재현 버튼을 누르면 서울이 끝난 뒤 부산이 시작된다. 완료 목록은 서울 완료 → 부산 완료, 화면과
가짜 서버 최종값은 부산이다. 이 방식은 모든 작업을 보존하지만, 느린 앞 작업만큼 최신 변경의 시작도
늦어진다.
모든 문제에 직렬 실행이 답은 아니다. 장바구니의 서로 다른 상품 수량처럼 자원이 독립적이면 서로 다른 scope로 병렬 실행할 수 있다. 입력 중간값이 필요 없고 마지막 값만 중요하면, 요청을 시작하기 전 짧게 모아 마지막 입력만 보내는 방식도 검토한다. 먼저 “모든 작업을 적용해야 하는가, 마지막 의도만 필요한가, 작업 순서가 결과를 바꾸는가”를 정한다.
늦은 응답 무시는 서버 원본을 보호하지 않는다
클라이언트에 증가하는 요청 번호를 두고 이전 번호의 onSuccess를 무시하면 화면이 서울로 돌아가는
것은 막을 수 있다. 하지만 첫 예제에서 느린 서울 함수는 이미 serverAddress = '서울'을 실행했다.
화면만 부산으로 남겨도 다음 Query가 서버를 다시 읽으면 서울이 나타난다.
따라서 “가장 최근 의도만 화면에 반영”하는 클라이언트 규칙과 “오래된 변경은 서버 원본에 적용하지 않음”이라는 서버 규칙을 분리한다. 요청 취소 신호를 보냈다는 사실도 서버가 이미 완료한 변경을 자동으로 되돌리지는 않는다.
클라이언트 직렬화와 서버 동시성 보호를 나눈다
같은 서버 원본을 여러 클라이언트가 바꿀 수 있다면 서버가 현재 버전을 비교해야 한다. 여기서는
클라이언트가 이전에 읽은 단일 strong ETag를 보내는 패턴으로 범위를 좁힌다. ETag는 서버
자원의 특정 버전을 나타내는 식별값이다. strong ETag는 W/ 접두사가 없는 강한 태그이며,
If-Match 비교에서는 강한 비교 규칙을 사용한다. If-Match는 “내가 읽은 버전이 아직 현재
버전일 때만 바꿔 달라”는 요청 조건이다.
API가 HTTP ETag를 쓰지 않아도 같은 원리는 요청의 version과 데이터베이스 조건부 갱신으로 구현할
수 있다.
예를 들어 클라이언트가 버전 7을 읽었다면 변경 요청에도 7을 보낸다. 서버는 현재 버전이 7일 때만 값을 바꾸고 8로 올린다. 이미 다른 요청이 8로 바꿨다면 버전 7 요청은 충돌로 거절하고, 클라이언트는 최신 값을 다시 읽어 사용자에게 재시도나 병합을 요구한다. 이렇게 해야 늦은 요청이 새 값을 조용히 덮지 않는다.
Web과 React Native가 공유하는 경계
경쟁 상태의 원인은 DOM이나 네이티브 UI가 아니라 겹친 비동기 작업의 순서다. 따라서 Web과 React Native 모두 같은 작업 식별, 직렬화와 서버 버전 계약이 필요하다. Native에서는 앱이 배경으로 간 동안 오프라인 작업이 재개될 수 있으므로 시작 시각만으로 최신 의도를 추정하지 않는다. 재시도·오프라인 저장은 별도 전달 계약으로 다룬다.
문제를 만났을 때의 조사 순서
- 어떤 서버 자원을 바꾸는 작업들이 동시에 진행될 수 있는지 적는다.
- 사용자 행동 순서, 요청 시작 순서와 서버 적용·응답 완료 순서를 각각 기록한다.
- 늦은 결과가 화면만 덮었는지 서버 원본도 덮었는지 다시 읽어 구분한다.
- 모든 작업 적용, 마지막 의도만 적용, 서로 독립 중 어떤 업무 계약인지 정한다.
- 같은 클라이언트 순서와 여러 클라이언트 서버 버전 보호를 각각 검증한다.
한 번 더 예측하고 확인한다
- 병렬 예제에서 부산 지연을 2초, 서울 지연을 0.3초로 바꾸면 최종값을 예측한다.
- 같은 지연을 직렬 scope에 적용하면 완료 목록과 최종값을 예측한다.
- 늦은 서울 응답을 화면에서만 무시한 뒤 서버를 다시 읽으면 무엇이 보일지 설명한다.
- 다른 기기가 같은 배송지를 바꿀 때 클라이언트 scope가 왜 보호하지 못하는지 설명한다.
앞의 가짜 서버 실행은 현재 QueryClient 안의 직렬화와 최종 부산 값까지만 증명한다. 서버 버전
거절은 실제 API 통합 검증이 필요하다. 같은 초기 ETag를 가진 두 변경을 보내 한 요청만 적용되는지,
오래된 요청은 대표적으로 412로 거절되는지, 마지막 GET이 성공한 변경의 값을 반환하는지 확인한다.
문제 서울 다음 부산을 저장했는데 마지막에 서울로 돌아감
잘못된 판단 호출 순서와 응답 완료 순서는 항상 같음
관찰 부산 완료 뒤 느린 서울 완료가 화면과 서버 원본을 덮음
원인 같은 자원의 서버 변경 작업을 순서 계약 없이 병렬 실행함
행동 클라이언트 작업 계약에 따라 직렬화하고 서버는 버전 조건 검사
검증 로컬 실험은 마지막 의도 확인, 서버 버전 거절은 실제 API 통합 검증PR에서 바로 확인할 항목
- 같은 서버 자원을 바꾸는 요청이 겹칠 수 있는가?
- 호출 순서가 아니라 각 요청의 완료·서버 적용 순서를 관찰했는가?
- 모든 작업 보존, 마지막 의도만 보존, 독립 병렬 중 계약이 명확한가?
- 클라이언트 화면에서 늦은 응답을 무시하는 것만으로 끝내지 않았는가?
- 같은 클라이언트의 직렬화와 여러 클라이언트의 서버 버전 검사를 구분했는가?
- Web과 Native의 재시도·오프라인 재개에서도 같은 작업 식별자가 유지되는가?
기억할 질문은 하나다.
겹친 변경 중 무엇을 어떤 순서로 적용해야 하며, 그 순서를 누가 보장하는가?
Active recall
기억에서 꺼내 보기
답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.
01서울 저장을 먼저 호출하고 부산 저장을 나중에 호출하면 서버에도 항상 부산이 남는가?
정답
아니다. 두 비동기 요청이 겹치면 느린 서울 요청이 나중에 끝나 부산 결과를 덮을 수 있다.
관련 설명 다시 읽기왜 그런가
사용자 행동 순서, 요청 시작 순서와 완료 순서는 서로 다른 증거다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
02화면에서 이전 요청의 늦은 응답만 무시하면 경쟁 상태가 모두 해결되는가?
정답
아니다. 화면은 부산으로 남아도 서버에서 느린 서울 쓰기가 이미 부산을 덮었을 수 있다.
관련 설명 다시 읽기왜 그런가
클라이언트 표시 보호와 서버 원본의 동시 변경 보호는 별도 계약이다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
03같은 scope.id로 직렬화하면 여러 기기에서 보내는 변경도 한 줄로 정렬되는가?
정답
아니다. 같은 QueryClient에서 같은 scope를 쓰는 작업의 시작만 직렬화한다.
관련 설명 다시 읽기왜 그런가
다른 탭, 앱 프로세스와 기기는 같은 클라이언트 큐를 공유하지 않으므로 서버 조건이 필요하다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
출처와 검증 범위
아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.
- MutationsTanStack · 공식 문서 · 확인 2026-08-09
- useMutationTanStack · 공식 문서 · 확인 2026-08-09
- RFC 9110 HTTP Semantics — If-MatchRFC Editor · 표준 명세 · 확인 2026-08-09
- Promise.all()MDN Web Docs · 공식 문서 · 확인 2026-08-09