실시간 좌석 예약 시스템에서 Race Condition을 해결한 방법 (그리고 잠을 못 잤다)
Source: Dev.to
문제
두 명의 사용자, 한 좌석 – 완전한 혼란.
사용자 A와 사용자 B가 거의 같은 순간에 좌석을 조회했으며, 둘 다 사용 가능한 것으로 보고 예약을 진행했습니다. 데이터베이스가 두 번 업데이트되어 같은 좌석에 두 명의 소유자가 생겼습니다.
실패한 접근 방식
간단한 읽기‑후‑쓰기 검사
먼저 if 문을 이용한 직관적인 방법을 시도했습니다: 좌석 상태를 읽고, 새로운 예약을 기록합니다. 실제 동시성 상황에서는 바로 깨졌습니다. 읽기‑후‑쓰기 순서는 원자적이지 않 않기 때문입니다. 두 연산 사이의 아주 작은 틈이 바로 레이스 컨디션이 발생하는 지점입니다.
클라이언트‑측 플래그
다음으로 잠금을 표시하는 클라이언트‑측 플래그를 추가했습니다. 이것도 실패했습니다—Socket.io 이벤트는 순서가 뒤바뀔 수 있고, UI 상태는 신뢰할 수 있는 진실의 원천이 아니기 때문입니다.
해결책
읽기와 쓰기를 하나의 원자적 MongoDB 작업으로 합치고, 이를 Socket.io 브로드캐스트와 결합해 모든 연결된 클라이언트가 즉시 잠금을 확인하도록 합니다.
// reserveSeat.js
async function reserveSeat(tripId, seatNo, userId) {
// Atomically find the seat if it is still free and mark it as taken
const seat = await Seats.findOneAndUpdate(
{ tripId, seatNo, taken: false }, // query condition (the guard)
{ $set: { taken: true, userId } }, // update (atomic at document level)
{ returnDocument: 'after' } // return the updated document
);
if (!seat) {
throw new Error("Seat already taken!");
}
// Broadcast the lock to all travelers
io.emit('seatLocked', {
seatNo,
userId,
message: `Seat ${seatNo} secured for User ${userId}`
});
}핵심 단계
- 쿼리 조건이 가드 역할 – 아직 비어 있는 좌석만 매치합니다.
findOneAndUpdate는 문서 수준에서 원자적 – 읽기와 쓰기가 하나의 작업으로 수행됩니다.- null 체크가 오류 경계 – 반환된 문서가 없으면 좌석이 이미 잡힌 것입니다.
- Socket.io가 즉시 잠금을 브로드캐스트 – 모든 클라이언트가 실시간으로 업데이트를 받습니다.
가장 큰 교훈
수정이 단순히 MongoDB에만 국한된 것이 아니라 사고 방식의 전환이라는 점입니다: 동시 시스템에서는 읽기 후 쓰기를 절대 신뢰하지 말라. 가드 로직을 데이터베이스 작업 자체에 넣어 원자적으로 만들세요.
여러분의 차례
프로젝트에서 레이스 컨디션을 겪어본 적이 있나요? 어떻게 해결했는지 공유해 주세요.