Supabase Postgres에서 유한 재고 추첨을 원자적으로 처리하기

Supabase Postgres에서 유한 재고 추첨을 원자적으로 처리하기

한눈에 보기

한정 재고 추첨에서 잔액 확인, 상품 선택, 재고 차감, 결과 기록을 하나의 트랜잭션으로 묶는 방법을 다룬다. 동시성 제어와 request_id 기반 멱등성이 핵심이다.

예시 코드 안내

본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.

재고가 있는 랜덤 상품 추첨은 단순히 Math.random()으로 상품 하나를 고르는 문제가 아니다. 동시에 두 요청이 들어와도 재고가 음수가 되지 않아야 하고, 포인트는 한 번만 차감되어야 하며, 당첨 결과는 반드시 기록으로 남아야 한다.

즉, 다음 네 작업이 하나의 원자적인 단위가 되어야 한다.

  1. 사용자의 잔액을 확인한다.
  2. 남은 재고 중 하나를 가중치에 따라 선택한다.
  3. 잔액과 재고를 차감한다.
  4. 당첨 기록과 사용자 보관함을 생성한다.

이 중 하나라도 실패하면 전체가 실패해야 한다.

목차

API에서 순서대로 처리하면 왜 위험한가

가장 직관적인 코드는 다음과 같다.

const wallet = await getWallet(userId);
const prize = await selectPrize(boxId);

await decreasePoint(wallet.id, price);
await decreaseStock(prize.id);
await createInventory(userId, prize.id);

요청이 하나일 때는 잘 동작한다. 하지만 두 요청이 같은 재고 1개를 동시에 읽으면 둘 다 자신이 구매할 수 있다고 판단할 수 있다. 또한 포인트를 차감한 뒤 보관함 생성이 실패하면 사용자는 돈만 잃게 된다.

어플리케이션 코드의 await 순서는 DB 트랜잭션을 보장하지 않는다.

핵심 규칙을 DB 함수로 옮겼다

Supabase에서는 Postgres 함수를 RPC로 호출할 수 있다. 재고 선택과 차감, 결과 생성을 하나의 DB 함수에 두면 트랜잭션 경계가 명확해진다.

create or replace function draw_prize(
  p_user_id uuid,
  p_box_id uuid,
  p_request_id uuid
) returns jsonb
language plpgsql
as $$
declare
  v_prize_id uuid;
begin
  -- 잔액과 대상 레코드를 트랜잭션 안에서 확인한다.
  -- 선택된 재고를 잠그고 조건부로 차감한다.
  -- 잔액 차감과 당첨 기록을 같이 생성한다.

  return jsonb_build_object('prize_id', v_prize_id);
end;
$$;

API Route의 책임은 인증, 입력 검증, RPC 호출, 응답 변환으로 줄어든다.

“선택”과 “재고 확보”를 같이 보장해야 한다

트랜잭션을 쓴다고 자동으로 동시성 문제가 사라지는 것은 아니다. 재고를 읽은 뒤 다른 요청이 같은 행을 수정할 수 있다면 여전히 race condition이 발생한다.

대표적인 방법은 다음과 같다.

update prizes
set remaining_quantity = remaining_quantity - 1
where id = v_prize_id
  and remaining_quantity > 0
returning id into v_prize_id;

갱신이 성공했는지를 반드시 검증해야 한다.

가중치 추첨의 기준은 “남은 확률”이다

유한 재고 추첨에서 표시할 확률은 초기 확률과 다를 수 있다. 어떤 상품의 재고가 소진되면 다음 추첨의 모집단에서 제외되어야 한다.

상품 i의 현재 가중치를 w_i, 남은 재고를 q_i라고 하면 실용적인 가중치를 다음처럼 둘 수 있다.

effective_weight_i = w_i × q_i

전체 effective weight 구간에서 난수를 하나 뽑고 누적 구간을 찾는다. 단, 확률 산정 규칙은 사업 정책에 따라 달라질 수 있으므로 코드와 사용자 표시가 반드시 일치해야 한다.

재시도와 중복 결제를 막는 request ID

네트워크 장애로 응답을 받지 못한 클라이언트는 같은 요청을 다시 보낼 수 있다. request_id에 unique constraint를 두면 같은 요청을 두 번 실행하지 않고 처음 결과를 다시 돌려줄 수 있다.

이를 빠뜨리면 DB 트랜잭션은 각 요청의 정합성을 보장해도 같은 사용자 의도가 두 번 실행되는 것은 막지 못한다.

트랜잭션만으로는 부족하다

트랜잭션은 한 번의 실행을 원자적으로 만들지만, 네트워크 재시도로 생긴 두 번의 정상 실행까지 하나로 합쳐 주지는 않는다. 사용자 의도를 식별하는 멱등성 키가 별도로 필요하다.

결론

유한 재고 추첨은 랜덤 함수보다 트랜잭션 설계가 더 중요하다.

이 구조를 잡아두면 API 서버가 여러 대로 늘어나도 핵심 정합성은 DB가 보장한다.

관련 노트