PostgreSQL 트랜잭션 격리 수준으로 게임 경매장 데이터 경합(Data Contention)을 방지하는 법

PostgreSQL 트랜잭션 격리 수준으로 게임 경매장 데이터 경합(Data Contention)을 방지하는 법

동시 입찰과 구매 요청이 몰리는 게임 경매장에서 이중 판매, 최고 입찰 역전, 잔액 오류를 막는 PostgreSQL 트랜잭션 격리 수준과 행 잠금, 조건부 갱신, SERIALIZABLE 재시도 전략을 SQL 예제로 설명합니다.

TL;DR

데이터 경합(Data Contention)은 여러 사용자나 프로세스가 동일한 데이터에 동시에 접근하여 수정하려고 경쟁하는 상황을 말합니다.

게임 경매장의 데이터 경합은 여러 요청이 같은 판매 글과 지갑 잔액을 동시에 읽고 갱신할 때 발생한다. PostgreSQL에서는 구매 확정처럼 한 번만 성공해야 하는 작업을 READ COMMITTED 트랜잭션과 SELECT ... FOR UPDATE로 직렬화하고 복잡한 입찰 규칙은 SERIALIZABLE과 재시도로 보호하는 방식이 실용적이다.

게임 경매장에서는 왜 데이터 경합이 발생하는가?

경매장 API는 짧은 시간에 같은 판매 글을 대상으로 여러 요청을 처리한다. 대표적인 오류는 다음과 같다.

  • 두 구매 요청이 모두 ACTIVE 상태를 읽은 뒤 각각 판매 완료로 갱신해 아이템이 이중 판매된다.
  • 두 입찰 요청이 같은 현재 최고가를 읽어 더 낮거나 같은 가격의 입찰을 통과시킨다.
  • 구매자 잔액을 애플리케이션에서 먼저 확인한 뒤 차감해 잔액이 음수가 되거나 같은 재화를 중복 차감한다.
  • 낙찰 처리와 입찰 처리의 순서가 뒤섞여 종료된 경매에 입찰이 반영된다.

예를 들어 현재 최고가가 1,000 골드일 때 요청 A와 B가 동시에 이를 읽고 각각 1,100 골드와 1,050 골드를 검증하면 늦게 커밋한 B가 더 낮은 가격으로 최고 입찰을 덮어쓸 수 있다. 이는 전형적인 갱신 손실(lost update)이다.

동시 구매 요청이 같은 경매 판매 글을 읽어 이중 판매로 이어지는 상황

PostgreSQL 트랜잭션 격리 수준은 무엇을 막는가?

PostgreSQL은 MVCC(Multi-Version Concurrency Control)로 읽기와 쓰기를 처리한다. 격리 수준은 다른 트랜잭션의 변경을 어느 시점까지 볼지와 충돌을 어떻게 처리할지를 정한다.

격리 수준PostgreSQL 동작 요약경매장 적용 판단
READ COMMITTEDSQL 문마다 새 스냅샷을 사용한다. 기본값이다.행 잠금 또는 조건부 갱신과 함께 구매 확정에 적합하다.
REPEATABLE READ트랜잭션 시작 시점의 일관된 스냅샷을 유지한다.긴 조회 일관성에는 유용하지만 경매 쓰기 규칙의 만능 해법은 아니다.
SERIALIZABLE직렬 실행과 같은 결과가 되도록 감시하고 충돌 시 40001 오류로 중단한다.복잡한 입찰·정산 규칙에 적합하며 재시도가 필수다.
READ UNCOMMITTEDPostgreSQL에서는 READ COMMITTED처럼 동작한다.별도 선택 이점이 없다.

PostgreSQL의 REPEATABLE READ는 트랜잭션 내 재조회 결과를 고정하지만 모든 비즈니스 규칙을 자동으로 직렬화하지는 않는다. 여러 행이나 집계 결과를 바탕으로 입찰 가능 여부를 결정한다면 SERIALIZABLE 또는 명시적 잠금 전략을 검토해야 한다.

이중 판매는 어떻게 막을까? FOR UPDATE로 판매 글을 잠근다

구매 확정은 판매 글 한 건, 구매자 지갑, 판매자 지갑을 함께 변경하는 작업이다. 기본 격리 수준인 READ COMMITTED에서도 대상 행을 FOR UPDATE로 잠그면 같은 판매 글을 구매하려는 후속 요청은 먼저 온 트랜잭션이 끝날 때까지 기다린다.

Step 1. 트랜잭션 안에서 판매 글을 잠근다

BEGIN;

SELECT id, seller_id, item_instance_id, buyout_price, status
FROM auction_listings
WHERE id = $1
FOR UPDATE;

조회 결과가 없거나 status <> 'ACTIVE'이면 ROLLBACK하고 이미 판매되었거나 취소된 글로 처리한다. 상태 확인을 잠금 이후에 수행해야 한다.

Step 2. 지갑 행을 정해진 순서로 잠근다

두 지갑을 잠글 때는 모든 코드 경로에서 동일한 순서를 사용한다. 예를 들어 항상 작은 player_id부터 잠그면 서로 상대방 지갑을 기다리는 교착 상태 가능성을 줄일 수 있다.

SELECT player_id, gold
FROM player_wallets
WHERE player_id IN ($buyer_id, $seller_id)
ORDER BY player_id
FOR UPDATE;

구매자 잔액은 애플리케이션 메모리에 있는 값이 아니라 잠긴 행의 gold로 검증한다.

Step 3. 상태·잔액·소유권을 같은 트랜잭션에서 변경한다

UPDATE player_wallets
SET gold = gold - $buyout_price
WHERE player_id = $buyer_id
  AND gold >= $buyout_price;

-- 영향 행 수가 0이면 잔액 부족이므로 ROLLBACK

UPDATE player_wallets
SET gold = gold + $buyout_price
WHERE player_id = $seller_id;

UPDATE auction_listings
SET status = 'SOLD',
    buyer_id = $buyer_id,
    sold_at = clock_timestamp()
WHERE id = $listing_id
  AND status = 'ACTIVE';

UPDATE item_instances
SET owner_id = $buyer_id,
    location = 'INVENTORY'
WHERE id = $item_instance_id
  AND owner_id = $seller_id
  AND location = 'AUCTION_ESCROW';

COMMIT;

UPDATE ... WHERE gold >= price처럼 조건을 갱신문 자체에 넣으면 잔액 검증과 차감을 한 SQL 문으로 묶을 수 있다. 다만 판매 글 상태, 소유권, 지갑처럼 여러 불변 조건을 함께 관리해야 하므로 구매 확정 전체는 하나의 트랜잭션으로 유지해야 한다.

flowchart TD
    A[구매 요청] --> B[BEGIN]
    B --> C[판매 글 SELECT FOR UPDATE]
    C --> D{ACTIVE 상태인가?}
    D -- 아니오 --> E[ROLLBACK: 이미 처리됨]
    D -- 예 --> F[지갑 행을 고정 순서로 잠금]
    F --> G{잔액 및 소유권 검증}
    G -- 실패 --> H[ROLLBACK]
    G -- 성공 --> I[차감·지급·소유권 이전·SOLD]
    I --> J[COMMIT]

최고 입찰 갱신에는 어떤 격리 수준을 써야 할까?

최고 입찰 갱신은 단일 판매 글의 현재 가격만 바꾸는 단순한 구조라면 READ COMMITTEDFOR UPDATE로 충분히 구현할 수 있다. 잠긴 판매 글을 읽고 ends_at, status, current_bid를 검증한 뒤 새 최고가와 입찰 이력을 기록한다.

BEGIN;

SELECT id, status, ends_at, current_bid, bidder_id
FROM auction_listings
WHERE id = $1
FOR UPDATE;

-- status = 'ACTIVE', ends_at > clock_timestamp(), new_bid > current_bid를 검증

UPDATE auction_listings
SET current_bid = $new_bid,
    bidder_id = $bidder_id,
    version = version + 1
WHERE id = $listing_id;

INSERT INTO auction_bids (listing_id, bidder_id, amount, created_at)
VALUES ($listing_id, $bidder_id, $new_bid, clock_timestamp());

COMMIT;

반대로 다음처럼 여러 행의 조건을 조합한다면 SERIALIZABLE이 더 명확한 선택이다.

  • 플레이어별 동시 입찰 수 제한
  • 같은 아이템 계열의 일일 거래 한도
  • 길드 자금과 개인 자금을 함께 사용하는 입찰
  • 여러 경매 중 하나만 낙찰될 수 있는 예약 입찰
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- 입찰 가능 한도, 지갑, 판매 글, 기존 예약 입찰을 조회·검증·갱신

COMMIT;

SERIALIZABLE에서 PostgreSQL은 충돌한 트랜잭션을 성공한 것처럼 임의로 합치지 않는다. 대신 SQLSTATE 40001(serialization_failure)을 반환한다. 애플리케이션은 트랜잭션 전체를 처음부터 재시도해야 하며 일부 SQL 문만 다시 실행하면 안 된다.

행 잠금 기반 구매 처리와 SERIALIZABLE 재시도 기반 복합 입찰 처리를 비교한 서버 설계

SERIALIZABLE 재시도는 어떻게 구현할까?

재시도는 일시적 충돌을 정상 제어 흐름으로 취급하는 코드다. 클라이언트 요청을 그대로 여러 번 실행하면 중복 보상이 발생할 수 있으므로 아래 두 가지를 함께 적용한다.

  1. 4000140P01(deadlock_detected)만 제한 횟수 내에서 재시도한다.
  2. 외부 효과는 커밋 뒤에 발행하고 요청에는 멱등성 키를 둔다.
  3. 재시도 사이에는 지수 백오프와 작은 무작위 지연을 둔다.
const retryableSqlStates = new Set(["40001", "40P01"]);

async function placeBidWithRetry(input: BidInput) {
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const client = await pool.connect();
    try {
      await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE");
      await placeBidInTransaction(client, input);
      await client.query("COMMIT");
      return;
    } catch (error: any) {
      await client.query("ROLLBACK").catch(() => undefined);
      if (!retryableSqlStates.has(error.code) || attempt === 2) throw error;
      const delayMs = 20 * 2 ** attempt + Math.floor(Math.random() * 20);
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    } finally {
      client.release();
    }
  }
}

입찰 성공 알림, 우편 발송, 분석 이벤트 같은 외부 작업은 트랜잭션 안에서 직접 호출하지 않는다. outbox_events 테이블에 이벤트를 기록하고 커밋된 이벤트만 별도 워커가 발행하면 재시도 중 중복 전송을 피할 수 있다.

낙관적 잠금은 행 잠금의 대안이 될 수 있는가?

낙관적 잠금은 version 열을 조건에 포함해 다른 요청이 먼저 바꿨는지 확인한다. 경합이 낮고 요청을 빠르게 실패시킨 뒤 클라이언트가 다시 조회해도 되는 화면 갱신에는 유용하다.

UPDATE auction_listings
SET current_bid = $new_bid,
    bidder_id = $bidder_id,
    version = version + 1
WHERE id = $listing_id
  AND version = $expected_version
  AND status = 'ACTIVE'
  AND ends_at > clock_timestamp()
  AND current_bid < $new_bid;

영향 행 수가 0이면 충돌 또는 규칙 위반이다. 다만 낙관적 잠금은 지갑 차감, 이전 최고 입찰자의 보증금 반환, 아이템 소유권 변경처럼 다수 행을 원자적으로 처리해야 하는 문제를 대체하지 않는다. 이 경우에는 트랜잭션과 행 잠금을 기본으로 두고 version은 추가 검증 수단으로 쓰는 편이 안전하다.

성능과 운영에서 확인할 항목

  • auction_listings(id)는 기본 키로 유지하고 활성 경매 탐색에는 WHERE status = 'ACTIVE' 조건에 맞춘 부분 인덱스를 검토한다.
  • 트랜잭션 안에서 HTTP 호출, Redis 왕복, 파일 I/O를 수행하지 않는다. 잠금 보유 시간이 길수록 대기열과 교착 상태가 늘어난다.
  • 잠금 대기가 길어지는 요청은 lock_timeout을 짧게 설정해 빠르게 실패시키고 API는 재시도 가능 여부를 명확히 반환한다.
  • pg_stat_activity, pg_locks, 느린 쿼리 로그로 오래 열린 트랜잭션과 잠금 대기를 관찰한다.
  • 게임 서버가 여러 대여도 잠금의 최종 권위는 PostgreSQL에 둔다. 프로세스 내부 mutex만으로는 서버 간 경합을 막을 수 없다.

잠금 경합을 단순화해 볼 때 평균 도착률을 λ\lambda, 한 요청이 잠금을 잡는 평균 시간을 TlockT_{lock}이라고 하면 경합 압력은 대략 다음에 비례한다.

ρ=λ×Tlock\rho = \lambda \times T_{lock}

특정 인기 경매 글의 ρ\rho가 커질수록 요청을 더 빨리 처리하는 것보다 즉시 구매 기능의 제한이나 입찰 배치 정책처럼 핫스팟 자체를 줄이는 제품 정책도 필요해진다.

자주 묻는 질문 (FAQ)

SELECT ... FOR UPDATE만 쓰면 SERIALIZABLE은 필요 없나?

아니다. 잠글 대상을 정확히 알 수 있는 단일 판매 글 구매에는 행 잠금이 효과적이다. 여러 행의 조회 결과나 집계 조건까지 보호해야 한다면 SERIALIZABLE과 재시도가 더 안전하고 이해하기 쉬운 선택이다.

SERIALIZABLE 오류가 나면 해당 SQL만 재실행해도 되나?

안 된다. SQLSTATE 40001이 발생하면 트랜잭션 전체를 롤백하고 처음부터 다시 실행해야 한다. 이전 조회 결과는 더 이상 유효하지 않을 수 있다.

교착 상태는 버그인가?

항상 그렇지는 않다. 서로 다른 순서로 여러 행을 잠그면 정상적인 동시 요청에서도 발생할 수 있다. 모든 경로의 잠금 순서를 통일하고 40P01은 제한적으로 재시도하는 것이 일반적인 대응이다.

정리

경매장 동시성 제어의 출발점은 READ COMMITTED에서 판매 글과 지갑을 FOR UPDATE로 잠그고 상태·잔액·소유권 변경을 하나의 트랜잭션으로 커밋하는 것이다. 복수 경매와 집계 규칙까지 얽힌 입찰은 SERIALIZABLE을 사용하고 40001 재시도, 멱등성 키, 커밋 후 이벤트 발행을 함께 설계하자. 이렇게 하면 중복 구매와 최고 입찰 역전 같은 오류를 데이터베이스 경계에서 일관되게 차단할 수 있다.

#PostgreSQL#트랜잭션 격리 수준#게임 서버#경매장#동시성 제어#데이터베이스 잠금

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs