lifecarelog
백엔드

크레딧이 두 번 차감되는 버그, SELECT FOR UPDATE로 잡는 법

동시 요청에 크레딧이 두 번 빠지는 race condition의 원인과 SELECT FOR UPDATE 락, 단일 트랜잭션으로 잡는 과정을 정리했어요.

4분 읽기

혹시 크레딧 잔액은 1개인데 차감 로그가 2건 찍힌 적 없나요? 저도 횟수 제한이 있는 유료 기능의 카운트 로직을 만들다가 같은 상황을 만났어요.

왜 두 번 빠질까요

원인은 "읽고 나서 쓰는" 코드 구조예요. 잔액을 SELECT로 읽고, 앱 코드에서 검사하고, UPDATE로 빼는 3단계 사이에 다른 요청이 끼어들 수 있어요.

요청 A와 B가 거의 동시에 들어오면 둘 다 "잔액 1"을 읽어요. 둘 다 검사를 통과하고, 둘 다 차감을 실행해요. 잔액이 음수가 되거나 로그만 2건 남는 거죠.

직접 재현해 보니 어렵지 않았어요. 같은 API에 동시 요청 10개를 쏘는 스크립트를 돌려 보니 첫 실행에서 바로 중복 차감이 나왔어요.

요청A 잔액 읽기요청B 잔액 읽기둘 다 검사 통과차감 2번 실행

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 느려지긴 해요. 그래도 크레딧이 걸린 경로라면 싼 비용이라고 생각해요. 차감 로직은 빠른 코드보다 한 번만 실행되는 코드가 먼저예요.

#race-condition#postgresql#트랜잭션

라이프케어로그 서비스가 궁금하신가요?

AI 기반 건강·일정·재활 관리 앱을 직접 써보세요.

서비스 살펴보기

관련 글

댓글

아직 댓글이 없어요. 첫 댓글을 남겨주세요.