프론트엔드에서 만난 Race Condition
Firestore Transaction으로 해결하기
문제가 없었는데 있었어요
실시간 밴픽 시뮬레이션을 만들면서 예상치 못한 버그를 만났다. 타이머가 0초가 되면 다음 단계로 넘어가는 함수를 호출하게 되는데, 문제는 접속한 클라이언트 수만큼 호출된다는 것이다. 2명이 접속하면 2단계가 건너뛰어지고, 3명이면 3단계가 건너뛰어진다.
문제 상황
밴픽 시뮬레이션의 구조
이 프로젝트는 LoL 밴픽을 시뮬레이션하는 웹앱이다. 블루팀과 레드팀이 각각 URL에 접속해서, 30초 타이머 안에 챔피언을 밴/픽한다. 핵심 데이터는 Firestore 문서 하나에 저장된다.
타이머가 0초가 되면 goToNextStep을 호출해서 currentStep을 1 증가시킨다.
단계가 여러 개 건너뛰어진다
블루팀 URL에 2명이 접속한 상황을 생각해보자.
타이머 컴포넌트(BanPickTimer)는 각 클라이언트에서 독립적으로 돌아간다. 타이머가 0초가 되면 모든 클라이언트가 거의 동시에 goToNextStep을 호출한다.
클라이언트 A: goToNextStep(3) → currentStep = 3 + 1 = 4
클라이언트 B: goToNextStep(3) → currentStep = 4 + 1 = 5 ← 문제!
클라이언트 B 입장에서는 본인이 호출할 때 이미 A가 step을 올린 뒤이므로, 의도치 않게 한 단계가 더 넘어간다. 이게 바로 Race Condition이다.
Race Condition이란?
Race Condition(경쟁 상태)은 여러 주체가 공유 자원에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 현상.
해결: Firestore runTransaction
Transaction이란?
데이터베이스에서 트랜잭션은 여러 데이터 연산을 하나의 원자적 단위로 묶어, 전부 성공하거나 전부 실패하도록 보장하는 처리 단위이다.
Firestore의 runTransaction은 낙관적 동시성 제어(Optimistic Concurrency Control) 방식으로 동작한다.
- 문서를 읽는다 (
transaction.get) - 읽은 값을 기반으로 새 값을 계산한다
- 문서를 업데이트한다 (
transaction.update) - 만약 1~3 사이에 다른 누군가가 같은 문서를 수정했다면 → 자동으로 1부터 다시 시도한다
핵심은 충돌을 감지하면 자동으로 재시도한다는 점이다. 덕분에 직접 잠금(lock)을 관리하지 않아도 데이터 정합성이 보장된다.
수정 전: 단순 updateDoc
// 문제가 있던 코드
const goToNextStep = async () => {
const docSnap = await getDoc(docRef);
const data = docSnap.data();
const currentStep = data.sets[currentSet].currentStep;
// 읽기와 쓰기 사이에 다른 클라이언트가 끼어들 수 있다!
await updateDoc(docRef, {
[`sets.${currentSet}.currentStep`]: currentStep + 1,
[`sets.${currentSet}.startedAt`]: serverTimestamp(),
});
};
getDoc으로 읽고 updateDoc으로 쓰는 사이에 다른 클라이언트가 같은 작업을 수행할 수 있다. 이 시간 간격이 Race Condition의 원인이다. 서버에서 읽은 값에 +1을 하니까 언뜻 안전해 보이지만, 읽기와 쓰기가 원자적이지 않기 때문에 그 사이에 다른 클라이언트가 끼어들 수 있다.
수정 후: runTransaction
const goToNextStep = async (localStep: number) => {
await runTransaction(db, async (transaction) => {
const docSnap = await transaction.get(docRef);
if (!docSnap.exists()) return;
const data = docSnap.data();
const currentSet = data.currentSet;
const firestoreStep = data.sets?.[currentSet]?.currentStep;
// 핵심: 클라이언트가 알고 있던 단계와 서버의 단계가 다르면 아무것도 하지 않음
if (firestoreStep !== localStep) return;
transaction.update(docRef, {
[`sets.${currentSet}.currentStep`]: firestoreStep + 1,
[`sets.${currentSet}.startedAt`]: serverTimestamp(),
});
});
};
두 가지 방어 장치가 동시에 작동한다:
1. 가드 조건: firestoreStep !== localStep
클라이언트 A가 이미 step을 올렸다면, 클라이언트 B가 트랜잭션을 실행할 때 서버의 firestoreStep은 이미 4이다. 하지만 B가 전달한 localStep은 여전히 3이므로, 조건에 걸려서 업데이트를 하지 않고 종료한다.
2. 트랜잭션 재시도
만약 A와 B가 정말 동시에 transaction.get을 호출해서 둘 다 같은 값을 읽었더라도, Firestore가 충돌을 감지하고 뒤늦은 쪽의 트랜잭션을 자동으로 재시도한다. 재시도 시에는 이미 업데이트된 값을 읽게 되므로 가드 조건에 걸린다.
클라이언트 A
| 순서 | 동작 | 결과 |
|---|---|---|
| 1 | transaction.get() | step: 3 |
| 2 | localStep(3) === firestoreStep(3) | 일치 → 업데이트 진행 |
| 3 | update: step → 4 | 커밋 성공 |
클라이언트 B
| 순서 | 동작 | 결과 |
|---|---|---|
| 1 | transaction.get() | step: 3 |
| 2 | localStep(3) === firestoreStep(3) | 일치 → 업데이트 진행 |
| 3 | update: step → 4 | A가 이미 커밋해서 문서가 변경됨 → 충돌 감지 → 재시도 |
| 4 | transaction.get() (재시도) | step: 4 |
| 5 | localStep(3) !== firestoreStep(4) | 불일치 → 아무것도 안 함 |
마무리
프론트엔드를 공부하면서 싱글 클라이언트 환경만 생각하고 있었다. 멀티 클라이언트가 같은 데이터를 동시에 건드릴 때 생기는 문제는 생각치도 못했다.
역시나 또 느끼는건 경험과 실력은 비례한다는 것이다. 다양한 경험을 통해 상황에 맞는 대응책을 떠올릴 수 있으니.