유료 크레딧 차감을 안전하게 — SELECT FOR UPDATE와 단일 트랜잭션
동시에 들어온 요청이 같은 잔액을 읽으면 크레딧이 이중으로 차감되거나 잔액이 음수가 될 수 있어요. 행 잠금과 단일 트랜잭션으로 막는 패턴, 그리고 결제 웹훅의 멱등 처리까지 정리했어요.
크레딧이나 포인트처럼 잔액을 깎는 기능은 동시성에서 사고가 나기 쉬워요. 두 요청이 거의 동시에 들어와 같은 잔액을 읽으면, 둘 다 "잔액 충분"이라고 판단하고 각자 차감해서 이중 차감이나 음수 잔액이 생길 수 있어요.
읽고-쓰는 사이의 틈
문제는 "잔액을 읽는 시점"과 "차감해서 쓰는 시점" 사이의 틈이에요. 그 틈에 다른 요청이 끼어들면 둘 다 옛날 잔액을 기준으로 판단해요.
요청 A: 잔액 10 읽음 → (틈) → 10 - 3 = 7로 씀
요청 B: 잔액 10 읽음 → (틈) → 10 - 3 = 7로 씀
결과: 두 번 차감했으니 4가 남아야 하는데 7이 남음 (한 번의 차감이 사라짐)행을 잠그고 한 트랜잭션에서
해결은 그 행을 잠근 채로 읽고, 같은 트랜잭션 안에서 차감까지 끝내는 거예요. PostgreSQL에서는 SELECT ... FOR UPDATE로 해당 행에 잠금을 걸 수 있어요. 잠금을 잡은 트랜잭션이 끝날 때까지 다른 요청은 그 행을 기다려요.
BEGIN;
SELECT balance FROM credits WHERE user_id = $1 FOR UPDATE;
-- 잔액 확인 후
UPDATE credits SET balance = balance - $2 WHERE user_id = $1;
COMMIT;읽기와 쓰기가 한 트랜잭션에 묶여 있고 행이 잠겨 있어서, 끼어드는 요청이 옛날 값을 보지 못해요.
결제 웹훅은 멱등하게
차감이 결제 웹훅으로 들어온다면 한 가지가 더 필요해요. 결제 제공자는 같은 이벤트를 두 번 보낼 수 있어서, 같은 이벤트로 두 번 차감되지 않게 막아야 해요. 이벤트 식별자를 유니크 제약이 걸린 곳에 먼저 기록해두고, 이미 처리한 이벤트면 다시 처리하지 않는 식이에요. 이렇게 하면 같은 알림이 여러 번 와도 결과가 한 번 처리한 것과 같아져요.
정리
핵심은 두 가지예요. 잔액 변경은 행 잠금 + 단일 트랜잭션으로 원자적으로, 그리고 외부에서 오는 이벤트는 멱등하게요. 돈과 직접 닿는 코드라 여기서는 빠르게보다 정확하게가 먼저라고 생각해요.