주차권(선착순) 시스템을 개발하던 중 동시 신청 상황에서 데드락이 발생하는 문제를 만났다. 선착순 시스템 특성상 데이터 유실은 치명적이기 때문에, 이 문제를 해결하는 과정을 기록해보려 한다.
데드락이 발생한 관련 테이블은 다음과 같다:
registration.sector_id → sector.id기본적으로 신청자가 신청을 완료하면 Sector(주차 구역 정보)의 잔여 여석을 -1 하는 플로우다.
신청자 A와 신청자 B가 동시에 신청 요청을 보냈을 때 데드락이 발생했다.
대기열 큐를 도입한 서비스 특성상 롤백된 데이터가 유실될 수 있는 상황이 발생했는데...
물론 Redis Sorted Set과 peek-pop 구조로 되어있어 데이터 유실 방지 아키텍처로 구성되어있어 실제 유실되진 않는다.
데이터 유실 방지 아키텍처 관련해선 시간이 되면 나중에 포스팅 하려 한다.
1️⃣ T1 (트랜잭션 1): INSERT 문 실행 → sector 테이블에 공유락(Shared Lock) 획득
2️⃣ T2 (트랜잭션 2): INSERT 문 실행 → sector 테이블에 공유락(Shared Lock) 획득
3️⃣ T1: UPDATE 문 실행 시도 → sector 테이블에 배타락(Exclusive Lock) 대기
4️⃣ T2: UPDATE 문 실행 시도 → sector 테이블에 배타락(Exclusive Lock) 대기
-- T1, T2가 동시에 실행하는 로직
START TRANSACTION;
-- 1. INSERT 시 FK 제약조건 검사를 위해 sector 테이블에 공유락 획득
INSERT INTO registration (user_id, sector_id, ...)
VALUES (?, ?, ...);
-- 2. sector 테이블의 잔여 자리 수를 감소시키기 위한 UPDATE
-- 이때 배타락이 필요하지만 이미 다른 트랜잭션이 공유락을 보유중
UPDATE sector
SET remaining_spots = remaining_spots - 1
WHERE id = ?;
COMMIT;
핵심 원인 3가지:
START TRANSACTION;
-- 🔑 먼저 배타락을 획득하여 다른 트랜잭션의 접근 차단
SELECT * FROM sector WHERE id = ? FOR UPDATE;
-- 이후 INSERT 실행 (이미 배타락이 있으므로 추가 락 불필요)
INSERT INTO registration (user_id, sector_id, ...)
VALUES (?, ?, ...);
-- UPDATE 실행 (이미 배타락 보유 상태)
UPDATE sector
SET remaining_spots = remaining_spots - 1
WHERE id = ?;
COMMIT;
먼저 배타락을 획득함으로써 INSERT, UPDATE 시 추가적인 락 획득 시도를 하지 않도록 한다.
✅ T1: SELECT FOR UPDATE (sector에 배타락) → INSERT → UPDATE
⏳ T2: SELECT FOR UPDATE 시도 → T1의 배타락 때문에 대기 → T1 완료 후 진행
결과: 한 번에 하나씩만 처리되어 충돌 없음
짧은 시간 안에 트래픽이 몰리는 시스템 특성상 비동기 작업이 중요하긴 하지만, 소중한 데이터의 일관성을 지키는 것이 더 중요하다고 생각한다.
특히 선착순 시스템에서는 공정성이 가장 중요한 요소이기 때문에, 약간의 성능을 포기하더라도 안정성을 택하는 것이 옳은 선택이라고 생각한다.
내가 먼저 신청했는데 내가 떨어지면 눈물 날 듯 ㅜㅜㅜ..
데드락 문제는 동시성이 높은 시스템에서 언제든 발생할 수 있는 문제다. 특히 FK 제약조건이 있는 테이블에서 INSERT와 UPDATE가 동시에 일어나는 경우에는 더욱 주의해야 한다.
이번 경험을 통해 락의 종류와 획득 순서가 얼마나 중요한지 다시 한번 깨달았다. 앞으로도 이런 동시성 이슈들을 미리 예측하고 대응할 수 있는 개발자가 되고 싶다.
