인게임 전리품 거래 시스템 설계: 파티 전리품 공유부터 시간 제한 거래까지

파티 전리품을 안전하게 공유하고 획득 후 일정 시간 동안만 거래할 수 있게 만드는 서버 권위형 설계를 정리합니다. 상태 모델, 동시성 제어, 만료 처리, 감사 로그까지 구현 관점에서 살펴봅니다.

✓ 복사 완료!
챕터인게임 경제 & 보상 체계
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
인게임 전리품 거래 시스템 설계: 파티 전리품 공유부터 시간 제한 거래까지

문제를 둘로 나누기

파티 전리품 공유와 시간 제한 거래는 비슷해 보이지만 책임이 다릅니다.

  • 전리품 분배는 아이템을 누가 소유할지 결정하는 과정입니다.
  • 시간 제한 거래는 이미 소유자가 정해진 아이템을 특정 조건에서 다시 이전할 수 있게 하는 과정입니다.

두 기능을 하나의 Tradeable 플래그로 처리하면 문제가 생깁니다. 예를 들어 파티원이 보스를 처치한 뒤 아이템을 아직 수령하지 않았는데 거래 가능 상태가 되거나 거래 시간이 끝났는데도 클라이언트 캐시에 남은 버튼으로 거래를 시도할 수 있습니다. 따라서 서버에서는 소유권과 거래 권한을 별도 상태로 관리해야 합니다.

파티 전리품 소유권과 시간 제한 거래 권한의 분리

핵심 원칙: 서버가 진실의 원천이다

인벤토리, 파티 구성, 처치 시각, 거래 가능 대상은 모두 서버가 판정해야 합니다. 클라이언트는 화면 표시와 요청 전송만 담당합니다.

특히 다음 값은 클라이언트가 보내는 값을 신뢰하면 안 됩니다.

  • 아이템 인스턴스 ID
  • 거래 상대의 캐릭터 ID
  • 파티 참여 여부
  • 획득 시각과 남은 거래 시간
  • 아이템 잠금 여부

클라이언트가 남은 시간이 10초입니다라고 보내더라도 서버는 자신의 만료 시각과 현재 시간을 비교해야 합니다. 시간 제한 기능은 UI 타이머가 아니라 서버 규칙입니다.

권장 데이터 모델

스택 가능한 재화와 개별 옵션을 가진 장비는 모델을 분리하는 편이 좋습니다. 시간 제한 거래는 보통 개별 아이템 인스턴스에 붙습니다.

using PlayerId = uint64_t;
using ItemInstanceId = uint64_t;
using UnixSeconds = int64_t;

struct LootTradePolicy
{
    // 거래 가능한 대상 파티원의 스냅샷
    std::vector<PlayerId> eligibleRecipients;
    UnixSeconds expiresAt = 0;
    bool consumed = false;
};

struct ItemInstance
{
    ItemInstanceId id;
    uint32_t itemTemplateId;
    PlayerId ownerId;
    uint32_t quantity = 1;

    bool isBound = false;
    bool isLocked = false;
    std::optional<LootTradePolicy> lootTrade;
    uint64_t version = 0;
};

여기서 eligibleRecipients는 현재 파티 목록이 아니라 획득 시점의 자격 스냅샷입니다. 현재 파티만 조회하면 획득 뒤에 파티에 들어온 플레이어가 거래 대상이 되거나 나간 플레이어의 권한이 의도치 않게 사라질 수 있습니다.

게임 정책에 따라 예외를 둘 수는 있습니다. 예를 들어 거래 시점에도 같은 파티여야 한다면 스냅샷 검사와 현재 파티 검사를 함께 적용합니다. 중요한 점은 정책을 데이터와 검증 코드에 명시하는 것입니다.

전리품 생성과 분배 흐름

보스 처치 이벤트가 들어오면 서버는 참여자와 기여도 규칙을 바탕으로 전리품 권한을 만듭니다. 이후 개인 인벤토리에 즉시 넣을지, 공동 전리품 창에서 선택하게 할지는 게임에 따라 달라집니다.

공동 전리품 창을 사용하는 경우에는 아이템 자체를 먼저 특정 플레이어에게 주지 말고 LootEntry라는 별도 엔터티로 보관하는 방식이 안전합니다.

flowchart TD
    A[보스 처치 확정] --> B[파티 및 자격 대상 스냅샷 생성]
    B --> C[전리품 항목 생성]
    C --> D{분배 방식}
    D -->|개인 획득| E[소유자 인벤토리에 아이템 생성]
    D -->|주사위 또는 선택| F[공동 전리품 항목 생성]
    F --> G[선택 마감과 승자 확정]
    G --> E
    E --> H[시간 제한 거래 정책 부여]

분배 방식이 무엇이든, 최종 소유자가 확정된 뒤에만 거래 정책을 붙이는 편이 단순합니다. 이때 획득 시각과 만료 시각은 서버 시간으로 기록합니다.

void GrantPartyLoot(ItemInstance& item,
                    PlayerId winner,
                    const std::vector<PlayerId>& eligiblePlayers,
                    UnixSeconds now)
{
    item.ownerId = winner;

    LootTradePolicy policy;
    policy.eligibleRecipients = eligiblePlayers;
    policy.expiresAt = now + 2 * 60 * 60; // 예: 2시간
    item.lootTrade = std::move(policy);
}

거래 요청 검증 순서

거래 승인 과정은 가능하면 하나의 트랜잭션으로 처리해야 합니다. 인벤토리에서 아이템을 빼는 작업, 새 소유자에게 넣는 작업, 거래 권한 소모 기록이 따로 커밋되면 장애나 재시도 상황에서 복제 또는 유실이 발생할 수 있습니다.

권장 검증 순서는 다음과 같습니다.

  1. 요청한 플레이어가 아이템의 현재 소유자인지 확인합니다.
  2. 아이템이 존재하고 잠금, 장착, 파기 대기 같은 이전 금지 상태가 아닌지 확인합니다.
  3. 귀속 아이템인지 확인합니다.
  4. 시간 제한 거래 정책이 있고 아직 만료되지 않았는지 확인합니다.
  5. 대상 플레이어가 자격 스냅샷에 있는지 확인합니다.
  6. 필요하다면 대상이 현재 같은 파티 또는 같은 인스턴스에 있는지 확인합니다.
  7. 대상 인벤토리에 공간이 있는지 확인합니다.
  8. 낙관적 버전 검사 또는 행 잠금으로 동시 요청을 막습니다.
  9. 소유권을 이전하고 거래 권한을 소모합니다.
TradeResult TransferLootTrade(PlayerId sender,
                              PlayerId recipient,
                              ItemInstanceId itemId,
                              UnixSeconds now)
{
    return database.RunTransaction([&]() -> TradeResult
    {
        ItemInstance item = inventoryRepo.LoadForUpdate(itemId);

        if (item.ownerId != sender)
            return TradeResult::NotOwner;
        if (item.isBound || item.isLocked)
            return TradeResult::ItemUnavailable;
        if (!item.lootTrade.has_value())
            return TradeResult::NotTradeable;

        const LootTradePolicy& policy = *item.lootTrade;
        if (policy.consumed || now >= policy.expiresAt)
            return TradeResult::Expired;
        if (!Contains(policy.eligibleRecipients, recipient))
            return TradeResult::RecipientNotEligible;
        if (!inventoryRepo.HasSpace(recipient, item))
            return TradeResult::RecipientInventoryFull;

        item.ownerId = recipient;
        item.lootTrade->consumed = true;
        item.version++;

        inventoryRepo.Save(item);
        auditRepo.AppendLootTransfer(sender, recipient, itemId, now);
        return TradeResult::Success;
    });
}

LoadForUpdate는 관계형 데이터베이스의 행 잠금처럼 같은 아이템에 대한 동시 거래를 직렬화하는 역할을 합니다. 저장소 구조상 행 잠금을 쓰기 어렵다면 version 값을 조건으로 업데이트하는 낙관적 잠금을 사용할 수 있습니다. 어느 방식이든 아이템 하나가 두 번 이전되는 상황은 반드시 막아야 합니다.

만료는 지우기보다 판정으로 처리하기

거래 시간이 끝났다고 해서 모든 아이템의 거래 정책을 즉시 삭제할 필요는 없습니다. 요청을 받을 때 now >= expiresAt으로 판정하면 만료 규칙은 정확하게 동작합니다.

백그라운드 작업은 다음 용도로 두는 것이 좋습니다.

  • 만료된 거래 권한 데이터를 정리해 저장 공간을 줄이기
  • 클라이언트에 표시할 거래 가능 목록 캐시 갱신하기
  • 운영 지표를 집계하기

즉시 삭제 작업만으로 만료를 보장하려 하면 작업 지연이나 서버 재시작 중에 빈틈이 생깁니다. 실제 권한 검증은 항상 요청 경로에서 수행해야 합니다.

흔한 악용과 대응

파티 탈퇴와 재초대 악용

획득 순간의 자격 대상만 허용한다면 탈퇴와 재초대로 새 자격을 얻을 수 없습니다. 반대로 현재 파티 조건도 요구한다면 거래 시점에 파티 구성까지 재검증합니다. 어느 정책이든 획득 뒤에 새로 합류한 플레이어가 권한을 얻지 못하게 해야 합니다.

우편과 거래소 우회

시간 제한 거래가 붙은 아이템은 일반 거래 외의 이전 수단도 동일하게 막아야 합니다. 우편, 경매장 등록, 길드 창고, 계정 창고, 분해 재료 이동 같은 경로를 목록으로 관리하고 공통 CanTransfer 검증을 거치게 만드세요.

복수 요청과 재전송

모바일 네트워크에서는 같은 요청이 재전송될 수 있습니다. 거래 요청에 고유한 요청 ID를 넣고 이미 성공한 요청의 결과를 재사용하는 멱등성 처리를 적용하면 중복 처리를 줄일 수 있습니다. 다만 멱등성은 동시성 제어를 대체하지 않습니다. 데이터베이스 트랜잭션과 아이템 잠금이 먼저입니다.

UI는 서버 결과를 빠르게 반영한다

클라이언트는 거래 가능 남은 시간을 계산해 보여 줄 수 있지만 화면의 시간은 참고용입니다. 거래 창을 열거나 확정 버튼을 누를 때 서버에서 최신 상태를 받아야 합니다.

성공하면 송신자와 수신자의 인벤토리 델타를 함께 전송합니다. 실패하면 Expired, RecipientNotEligible, RecipientInventoryFull처럼 원인을 구분한 오류 코드를 보내야 플레이어가 상황을 이해할 수 있습니다. 단순히 거래에 실패했습니다만 보여 주면 지원 문의와 재시도가 늘어납니다.

테스트 체크리스트

구현을 마무리하기 전에 다음 경우를 자동화 테스트로 확인하세요.

  • 만료 직전과 만료 직후 요청이 서로 다른 결과를 내는가
  • 동일 아이템에 대한 두 거래 요청 중 정확히 하나만 성공하는가
  • 수신자 인벤토리가 가득 찼을 때 송신자 아이템이 그대로 남는가
  • 파티에 나중에 들어온 플레이어가 대상이 될 수 없는가
  • 귀속, 잠금, 장착 상태의 아이템이 모든 이전 경로에서 차단되는가
  • 서버 재시작 뒤에도 만료 시각과 자격 스냅샷이 유지되는가
  • 성공 및 실패 거래가 운영 감사 로그에 남는가

전리품 공유 기능의 신뢰도는 화려한 UI보다 소유권 이전의 정확성에서 결정됩니다. 획득 시점의 자격을 스냅샷으로 남기고 거래 요청마다 서버에서 만료와 상태를 검증하며 단일 트랜잭션으로 소유권을 옮기면 대부분의 복제와 우회 문제를 구조적으로 줄일 수 있습니다.