크레딧이 두 번 차감되는 버그, SELECT FOR UPDATE로 잡는 법
동시 요청에 크레딧이 두 번 빠지는 race condition의 원인과 SELECT FOR UPDATE 락, 단일 트랜잭션으로 잡는 과정을 정리했어요.
혹시 크레딧 잔액은 1개인데 차감 로그가 2건 찍힌 적 없나요? 저도 횟수 제한이 있는 유료 기능의 카운트 로직을 만들다가 같은 상황을 만났어요.
왜 두 번 빠질까요
원인은 "읽고 나서 쓰는" 코드 구조예요. 잔액을 SELECT로 읽고, 앱 코드에서 검사하고, UPDATE로 빼는 3단계 사이에 다른 요청이 끼어들 수 있어요.
요청 A와 B가 거의 동시에 들어오면 둘 다 "잔액 1"을 읽어요. 둘 다 검사를 통과하고, 둘 다 차감을 실행해요. 잔액이 음수가 되거나 로그만 2건 남는 거죠.
직접 재현해 보니 어렵지 않았어요. 같은 API에 동시 요청 10개를 쏘는 스크립트를 돌려 보니 첫 실행에서 바로 중복 차감이 나왔어요.
SELECT FOR UPDATE로 줄 세우기
해결 원리는 단순해요. 잔액을 읽는 순간부터 그 행에 락을 걸어서, 다른 요청이 읽기 자체를 기다리게 만드는 거예요.
BEGIN;
SELECT balance FROM credits
WHERE user_id = $1
FOR UPDATE; -- 이 행에 락
-- 잔액 검사 후
UPDATE credits SET balance = balance - 1
WHERE user_id = $1;
COMMIT;핵심은 두 가지예요. FOR UPDATE로 행 락을 걸고, 읽기부터 차감까지 반드시 한 트랜잭션 안에서 끝내기. 락만 걸고 트랜잭션을 쪼개면 락이 풀린 틈에 또 끼어들어요.
적용해 봤더니 두 번째 요청은 첫 요청의 COMMIT까지 기다렸다가 갱신된 잔액 0을 읽고, 검사에서 걸러졌어요.
락 없이 한 줄로 끝나는 경우도 있어요
검사 조건이 단순하면 UPDATE 한 문장으로도 돼요.
UPDATE credits SET balance = balance - 1
WHERE user_id = $1 AND balance >= 1;영향받은 행이 0이면 잔액 부족으로 응답하면 돼요. 다만 차감 전 잔액으로 분기하거나 로그 테이블에 같이 기록해야 한다면, FOR UPDATE와 단일 트랜잭션 조합이 안전해요.
- 행 락으로 줄 세우기
- 읽기부터 차감까지 한 트랜잭션
- 조건부 UPDATE 한 줄 대안
검증까지 해야 끝이에요
저는 플랜엘(plan-l)의 월 3건 무료 카운트처럼 횟수 제한이 걸린 기능을 만들 때 이 패턴을 기본으로 깔아요. 동시 요청 10개를 쏘는 테스트를 함께 두면, 나중에 코드를 고쳐도 회귀를 바로 잡아낼 수 있어요.
테스트해 보면 락 대기 때문에 응답이 수십 ms 느려지긴 해요. 그래도 크레딧이 걸린 경로라면 싼 비용이라고 생각해요. 차감 로직은 빠른 코드보다 한 번만 실행되는 코드가 먼저예요.
관련 글
댓글
아직 댓글이 없어요. 첫 댓글을 남겨주세요.