경매장 락킹 메커니즘으로 동시 구매 레이스 컨디션 막기

같은 경매 등록 상품에 여러 구매 요청이 동시에 도착할 때 발생하는 데이터 경합을 재현하고 트랜잭션 락과 원자적 갱신으로 한 명만 구매에 성공하도록 만드는 방법을 설명합니다.

✓ 복사 완료!
챕터인게임 경제 & 보상 체계
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
경매장 락킹 메커니즘으로 동시 구매 레이스 컨디션 막기

문제: 한 개의 등록 상품을 두 명이 동시에 산다면

경매장에 등록된 상품은 보통 판매중 또는 판매완료 상태를 가집니다. 구매 요청 처리 코드가 단순히 상태를 조회한 뒤 판매 완료로 바꾸면 두 요청이 모두 판매중을 읽는 순간 문제가 시작됩니다. 두 플레이어 모두 결제를 마치고 아이템을 받는 이중 판매가 발생할 수 있습니다.

이 문제는 서버가 한 대인지 여러 대인지와 무관합니다. 요청 처리 스레드가 겹치거나 여러 게임 서버가 같은 데이터베이스를 바라보면 언제든 발생합니다. 메모리의 mutex만으로는 서버 프로세스 밖의 요청까지 보호할 수 없으므로 최종 상태를 보장하는 저장소 기준의 동시성 제어가 필요합니다.

flowchart TD
    A[구매자 A: 판매중 조회] --> C[결제 및 지급 처리]
    B[구매자 B: 판매중 조회] --> D[결제 및 지급 처리]
    C --> E[판매완료 갱신]
    D --> F[판매완료 갱신]
    E --> G[둘 다 성공할 수 있음]
    F --> G

동일한 경매 등록 상품에 동시에 도착한 두 구매 요청과 하나만 성공해야 하는 처리 흐름

구매 처리의 불변 조건을 먼저 정한다

구현 전에 반드시 지켜야 할 조건을 데이터 관점에서 적어 두는 편이 좋습니다.

  • 하나의 listing_id는 한 번만 판매 완료될 수 있다.
  • 판매자는 자기 등록 상품을 구매할 수 없거나 허용한다면 별도의 정책을 적용한다.
  • 구매 성공 시 재화 차감, 판매 대금 적립, 아이템 소유권 이전, 경매 상태 변경이 함께 확정된다.
  • 실패한 요청은 재화나 아이템 상태를 남기지 않는다.
  • 네트워크 재시도로 같은 요청이 다시 와도 구매 결과가 중복 적용되지 않는다.

핵심은 판매중인지 확인과 판매 완료로 변경을 분리된 작업으로 남겨 두지 않는 것입니다. 둘은 하나의 트랜잭션 안에서 보호해야 합니다.

비관적 락: 행을 잠근 뒤 처리하기

관계형 데이터베이스라면 대상 경매 행을 SELECT ... FOR UPDATE로 읽는 방식이 직관적입니다. PostgreSQL에서는 이 절이 반환된 행에 잠금을 걸고 충돌하는 수정·잠금 요청은 현재 트랜잭션이 끝날 때까지 기다립니다. 먼저 락을 잡은 요청만 상태를 검사하고 구매를 진행하며 뒤따른 요청은 락이 풀릴 때까지 기다립니다. (PostgreSQL locking clause 문서)

다음 예시는 PostgreSQL 계열 문법을 기준으로 한 의사 SQL입니다. 실제 지원 범위와 격리 수준은 사용하는 DBMS 문서를 확인해야 합니다.

BEGIN;

SELECT listing_id, seller_id, item_instance_id, price, status
FROM auction_listing
WHERE listing_id = :listingId
FOR UPDATE;

-- 애플리케이션에서 결과가 없거나 status <> 'ON_SALE'이면 ROLLBACK
-- 구매자와 판매자 계정 행도 일관된 순서로 잠근다.

SELECT account_id, gold
FROM account
WHERE account_id IN (:buyerId, :sellerId)
ORDER BY account_id
FOR UPDATE;

-- 잔액, 자기 구매, 아이템 소유 상태를 검증한다.
UPDATE account SET gold = gold - :price
WHERE account_id = :buyerId;

UPDATE account SET gold = gold + :price
WHERE account_id = :sellerId;

UPDATE item_instance
SET owner_id = :buyerId
WHERE item_instance_id = :itemInstanceId
  AND owner_id = :sellerId;

UPDATE auction_listing
SET status = 'SOLD', buyer_id = :buyerId, sold_at = CURRENT_TIMESTAMP
WHERE listing_id = :listingId
  AND status = 'ON_SALE';

COMMIT;

뒤 요청은 첫 요청이 커밋한 뒤 행을 다시 읽습니다. 그 시점에는 상태가 이미 SOLD이므로 정상적으로 구매 실패를 반환할 수 있습니다. 어떤 단계에서 검증에 실패하거나 갱신 행 수가 기대와 다르면 ROLLBACK해야 합니다.

락을 오래 잡지 않는다

락을 잡은 트랜잭션 안에서 외부 결제 API 호출, 로그 전송 대기, 다른 서버 RPC 호출처럼 오래 걸릴 수 있는 작업을 수행하면 대기열과 데드락 가능성이 커집니다. 게임 재화처럼 서버가 직접 관리하는 가치라면 검증과 상태 변경을 짧은 DB 트랜잭션으로 끝내는 구성이 안전합니다.

외부 결제가 필요한 상품이라면 보통 결제 승인과 아이템 지급을 한 트랜잭션으로 묶을 수 없습니다. 이 경우 주문 상태를 별도로 두고 멱등 키와 보상 처리 또는 아웃박스 패턴을 설계해야 합니다. 단순히 DB 락 시간을 늘려 해결하려 하면 장애 상황에서 더 어려운 문제가 생깁니다.

원자적 조건부 갱신: 짧은 구매 경쟁에 적합한 방법

경매 상태 변경만 경쟁의 핵심이라면 조건부 UPDATE 한 번으로 승자를 정할 수도 있습니다.

UPDATE auction_listing
SET status = 'SOLD', buyer_id = :buyerId, sold_at = CURRENT_TIMESTAMP
WHERE listing_id = :listingId
  AND status = 'ON_SALE';

영향받은 행 수가 1이면 이 요청이 판매권을 확보한 것입니다. 0이면 이미 팔렸거나 존재하지 않는 등록입니다. 다만 이 쿼리만으로 구매 처리를 끝내면 안 됩니다. 재화와 아이템 이전도 같은 트랜잭션에서 처리해야 하며 순서와 실패 시 롤백 정책을 명확히 해야 합니다.

특히 아이템 이동은 소유자 조건을 포함한 갱신으로 보호하는 것이 좋습니다.

UPDATE item_instance
SET owner_id = :buyerId
WHERE item_instance_id = :itemInstanceId
  AND owner_id = :sellerId;

영향 행 수가 1이 아니라면 아이템이 이미 이동했거나 데이터가 기대한 상태가 아닙니다. 이때는 거래 전체를 롤백하고 조사 가능한 오류를 남겨야 합니다.

데드락을 줄이는 규칙

경매 구매는 경매 행, 구매자 계정, 판매자 계정, 아이템 행처럼 여러 레코드를 다루기 쉽습니다. 서로 다른 요청이 이 행들을 다른 순서로 잠그면 교착 상태가 발생할 수 있습니다.

가장 실용적인 규칙은 모든 거래가 같은 종류의 행을 같은 순서로 잠그게 하는 것입니다. 이 글의 예시에서는 경매 행을 먼저 잠근 뒤, 구매자와 판매자 계정을 작은 account_id부터 잠그고, 마지막으로 아이템 행을 갱신합니다. 다른 거래 경로도 이 순서를 지켜야 합니다. 데이터베이스는 데드락을 감지하면 보통 한 트랜잭션을 중단시키므로 서버는 이 오류를 일시적 실패로 분류해 제한된 횟수만 재시도해야 합니다.

경매 행과 계정 행을 정해진 순서로 잠가 데드락 가능성을 낮추는 예시

C++ 서버 코드에서 확인할 점

DB 접근 계층이 트랜잭션 객체를 제공한다면 구매 서비스는 다음처럼 한 경계 안에 작업을 모읍니다. 아래 코드는 구조를 보여 주는 의사 코드이며 실제 예외 처리와 쿼리 바인딩 방식은 사용하는 라이브러리에 맞춰야 합니다.

PurchaseResult BuyListing(Db& db, AccountId buyerId, ListingId listingId,
                          RequestId requestId) {
    auto tx = db.BeginTransaction();

    if (auto saved = tx.FindPurchaseByRequestId(requestId)) {
        tx.Commit();
        return saved->ToResult();
    }

    auto listing = tx.SelectListingForUpdate(listingId);
    // 같은 requestId의 선행 요청이 이 경매 행 잠금을 기다리는 동안
    // 커밋되었을 수 있으므로, 잠금을 얻은 뒤 멱등 결과를 다시 확인한다.
    if (auto saved = tx.FindPurchaseByRequestId(requestId)) {
        tx.Commit();
        return saved->ToResult();
    }
    if (!listing || listing->status != ListingStatus::OnSale) {
        tx.Rollback();
        return PurchaseResult::AlreadySold;
    }

    auto accounts = tx.SelectAccountsForUpdateOrdered(buyerId, listing->sellerId);
    if (!accounts.HasEnoughGold(buyerId, listing->price)) {
        tx.Rollback();
        return PurchaseResult::InsufficientGold;
    }

    if (!tx.TransferGold(buyerId, listing->sellerId, listing->price) ||
        !tx.TransferItem(listing->itemInstanceId, listing->sellerId, buyerId) ||
        !tx.MarkListingSold(listingId, buyerId)) {
        tx.Rollback();
        return PurchaseResult::Conflict;
    }

    tx.SavePurchaseResult(requestId, buyerId, listingId);
    tx.Commit();
    return PurchaseResult::Success;
}

requestId는 클라이언트가 재전송한 동일 구매 요청을 구분하는 멱등 키입니다. 이 값에 고유 제약을 두고 성공 결과를 저장하면 응답 유실 뒤의 재시도가 재화를 다시 차감하는 일을 막을 수 있습니다. 단, 같은 상품을 새로 사려는 별도 요청과 단순 재시도를 구분할 수 있도록 클라이언트와 서버의 요청 생성 규칙을 함께 설계해야 합니다.

위 코드의 잠금 후 재조회는 각 문장이 새 커밋을 볼 수 있는 격리 수준(예: PostgreSQL의 READ COMMITTED)을 전제로 합니다. 스냅샷이 트랜잭션 동안 고정되는 격리 수준이나 다른 DBMS에서는 고유 제약 위반·직렬화 실패를 포함한 충돌 처리를 추가하고 전체 트랜잭션을 다시 실행해야 합니다.

테스트는 동시에 시작시켜야 한다

단위 테스트에서 구매 함수를 순서대로 두 번 호출하면 경합을 재현할 수 없습니다. 같은 listing_id를 대상으로 여러 작업을 동시에 시작하고 결과를 검증해야 합니다.

  • 동시에 2개 이상 구매 요청을 보낸다.
  • 성공 결과가 정확히 1개인지 확인한다.
  • 구매자 잔액, 판매자 잔액, 아이템 소유자, 경매 상태를 함께 검증한다.
  • 구매 성공 직후 응답을 잃은 상황을 흉내 내고 같은 requestId로 재시도한다.
  • 서로 다른 상품을 교차 구매해 데드락 재시도 경로도 확인한다.

운영 환경에서는 락 대기 시간, 데드락 횟수, 조건부 갱신 실패율을 관찰하면 병목과 비정상 요청을 찾는 데 도움이 됩니다. 실패율이 높다고 무조건 락을 없애기보다는 인기 상품에 요청이 몰리는 정상적인 경쟁인지 재시도 폭주나 클라이언트 버그인지 먼저 구분해야 합니다.

정리

경매장 동시 구매 문제는 상태 조회와 상태 변경 사이에 다른 요청이 끼어들 수 있기 때문에 발생합니다. 해결의 중심은 경매 상태, 재화, 아이템 소유권 변경을 짧은 트랜잭션으로 묶고 행 락 또는 조건부 갱신으로 단 하나의 요청만 판매권을 얻도록 하는 것입니다.

여기에 일관된 락 순서, 갱신 행 수 검증, 멱등 키, 동시성 테스트를 더하면 단순한 판매중 플래그를 실제 서비스에서 견딜 수 있는 거래 처리로 발전시킬 수 있습니다.