본문 바로가기
내배캠/CH2 팀프로젝트(감자가생각났어요)

[TIL: 2026.05.28] C++ TRPG 프로젝트: 서버 검증 기반 1:1 아이템 거래 시스템 구현

by 4.41p._.m 2026. 6. 1.

학습 개요

TRPG 프로젝트에서 플레이어 간의 상호작용을 극대화하기 위해 아이템 및 재화 거래 시스템을 개발했다. 거래 시스템을 개발할 때 단순히 데이터를 주고받는 것을 넘어, 네트워크 환경에서 발생할 수 있는 아이템 복사 버그와 데이터 동기화 문제를 해결하는 데 초점을 맞췄다.

 

 

시스템 아키텍처 및 데이터 모델

네트워크 상에서 거래되는 모든 정보는 단일 구조체로 패키징되어 이동해야 한다. 본 프로젝트에서는 컴파일러에 의한 메모리 패딩(Padding)으로 패킷 크기가 어긋나는 것을 막기 위해 #pragma pack(push, 1)을 사용했다.

📌 TradeInfo 구조체 (Packet.h)

거래의 '누가, 누구에게, 무엇을 주고받는지'에 대한 명세서

#pragma pack(push, 1)
struct TradeInfo
{
    uint32_t tradeId;      // 방장(서버)이 부여하는 거래 고유 번호
    char sender[32];       // 거래를 신청한 사람 이름
    char receiver[32];     // 거래를 받을 사람 이름

    // [주는 아이템 정보]
    char itemGiveName[32];
    int itemGiveType;      // 0: HP_POTION, 1: ATTACK_BUFF, 2: MONSTER_PART
    int itemGiveValue;
    int itemGivePrice;
    int itemGiveCount;     // 몇 개 줄 것인지

    // [받고 싶은 아이템 정보]
    char itemReceiveName[32];
    int itemReceiveType;   // 0: HP_POTION, 1: ATTACK_BUFF, 2: MONSTER_PART
    int itemReceiveValue;
    int itemReceivePrice;
    int itemReceiveCount;  // 몇 개 받을 것인지

    // 거래 상태 (0: 대기 중, 1: 수락됨, 2: 거절됨, 3: 취소됨)
    uint8_t status;
};
#pragma pack(pop)

 

 

핵심 통신 프로토콜: 3-Way Handshake 방식

아이템 거래는 민감한 데이터 이동이므로, 클라이언트 간 직접 통신이 아닌 서버(방장) 중심의 검증 통신으로 설계했다.

통신 흐름은 크게 3단계로 이루어진다.

Step 1. 거래 신청 (Client -> Server)

  • 패킷: PKT_C2S_TRADE_REQUEST
  • 동작: 신청자가 UI에 / 구분자로 신청서([받을사람]/[줄아이템]/[받을아이템])를 작성하면, 로컬 인벤토리 검증을 거친 후 서버로 패킷을 전송
// ItemTradeRequestState.cpp 일부
// 유효성 검사
// (1) 줄 아이템이 내 인벤토리에 있는지 확인
auto* giveSlot = player->GetInventory().GetItemSlot(giveItemName);
if (!giveSlot || giveSlot->count <= 0)
{
    Renderer::DisplayUITimed(UIPart::CenterLeft, 10, "해당 아이템을 보유하고 있지 않습니다!", 2.0f);
    return;
}

...

// 3. 모든 검사 통과 시 패킷 생성 및 전송
Pkt_TradeRequest pkt;

// 신청자 정보
strcpy_s(pkt.info.sender, Client::playerName.c_str());
strcpy_s(pkt.info.receiver, receiverName.c_str());

... (아이템 정보 복사) ...

// 패킷 전송
NetworkManager::GetInstance().SendTradeRequest(pkt);

Step 2. 서버의 등록 및 전파 (Server -> All)

  • 서버는 요청을 받으면 Server_HandleRequest를 호출
  • 가장 중요한 점은 서버가 직접 고유한 tradeId를 부여한다는 것
// TradeManager.cpp
// [서버 전용] 방장이 클라이언트의 거래 신청을 받았을 때
void TradeManager::Server_HandleRequest(const TradeInfo & info)
{
    if (!Client::isServer) return;

    TradeInfo newInfo = info;
    newInfo.tradeId = nextTradeId++; // 서버가 고유 ID 부여
    newInfo.status = 0; // Pending

    {
        std::lock_guard<std::mutex> lock(tradeMutex);
        tradeList.push_back(newInfo);
    }

    // 모든 플레이어에게 "새 거래 생겼다"고 전파
    Pkt_TradeSync syncPkt;
    syncPkt.info = newInfo;

    // NetworkManager를 통해 브로드캐스트 (이 함수는 나중에 구현)
    NetworkManager::GetInstance().BroadcastTradeSync(syncPkt);
    IPCManager::GetInstance().SendLog("[서버] 새로운 거래 신청 등록 (ID: " + std::to_string(newInfo.tradeId) + ")");
}

Step 3. 응답 및 최종 동기화 (Client -> Server -> All)

  • 수신자가 제안을 보고 수락하면 PKT_C2S_TRADE_RESPONSE를 서버로 보낸다.
  • 서버는 해당 tradeId의 거래 상태를 1 (수락됨)로 변경하고 다시 BroadcastTradeSync를 호출하여 전원에게 알린다.

 

Troubleshooting: 아이템 복사 방지를 위한 동기화 로직

이 시스템을 개발하며 가장 까다로웠던 부분이 "어느 시점에 아이템을 실제로 인벤토리에서 넣고 뺄 것인가?"였다. 단순히 SYNC 패킷을 받았을 때 아이템을 지급하면, 네트워크 문제로 패킷을 2번 받거나 UI가 리프레시될 때 아이템이 복사되는 최악의 버그가 발생한다.
이를 해결하기 위해 TradeManager::SyncTrade에서 상태 전이(State Transition) 기법을 활용했다.

📌 멱등성을 보장하는 SyncTrade 로직

// TradeManager.cpp
// [공용] 목록 동기화 (서버가 보낸 정보를 내 로컬 리스트에 반영)
void TradeManager::SyncTrade(const TradeInfo & info)
{
    std::lock_guard<std::mutex> lock(tradeMutex);

    // 이미 있는 거래면 업데이트, 없으면 추가
    auto it = std::find_if(tradeList.begin(), tradeList.end(), [&](const TradeInfo& t)
    {
        return t.tradeId == info.tradeId;
    });

    if (it != tradeList.end())
    {
        if (it->status == 0 && info.status == 1)
        {
            ApplyRealItemTrade(info); // 실제 아이템 이동 로직 호출
        }
        * it = info;
    }
    else
    {
        tradeList.push_back(info);
        // 만약 서버에서 처음부터 성공 상태로 보냈다면
        if (info.status == 1) ApplyRealItemTrade(info);
    }
}
  • 해결 원리: 서버로부터 수락됨(status=1) 패킷을 여러 번 받더라도, 첫 번째 수신 시 로컬 리스트의 상태가 1로 변경되므로 두 번째부터는 if (it->status == 0 && info.status == 1) 조건을 절대 만족할 수 없다.

 

실제 아이템 교환 로직 (ApplyRealItemTrade)

실제 아이템 교환 시에는 자신이 신청자인지 수신자인지에 따라 로직이 반대로 적용되도록 분기 처리를 구현했다.

// TradeManager.cpp
void TradeManager::ApplyRealItemTrade(const TradeInfo & info)
{
    Player * myPlayer = GameManager::GetInstance().GetPlayer();
    if (!myPlayer) return;

    // 내가 신청자(A)인 경우: 내 아이템(Give)을 주고 상대 아이템(Receive)을 받음
    if (std::string(info.sender) == Client::playerName)
    {
        myPlayer->GetInventory().UseItem(nullptr, info.itemGiveName, info.itemGiveCount);

        // 아이템 생성 
        Item* newItem = new Item(info.itemReceiveName, (ItemType)info.itemReceiveType, info.itemReceiveValue, info.itemReceivePrice);
        myPlayer->GetInventory().AddItem(newItem, info.itemReceiveCount);

        IPCManager::GetInstance().SendLog("[거래] '" + std::string(info.receiver) + "'님과의 거래 성사! 아이템이 교환되었습니다.");
    }

    // 내가 수락자(B)인 경우: 내 아이템(Receive)을 주고 상대 아이템(Give)을 받음
    else if (std::string(info.receiver) == Client::playerName)
    {
        myPlayer->GetInventory().UseItem(nullptr, info.itemReceiveName, info.itemReceiveCount);

        Item * newItem = new Item(info.itemGiveName, static_cast<ItemType>(info.itemGiveType), info.itemGiveValue, info.itemGivePrice);
        myPlayer->GetInventory().AddItem(newItem, info.itemGiveCount);

        IPCManager::GetInstance().SendLog("[거래] '" + std::string(info.sender) + "'님과의 거래 성사! 아이템이 교환되었습니다.");
    }
}

 

 

 

Troubleshooting: 호스트(방장) 동기화 누락 이슈 해결

  • 이슈 발생: P2P 호스트-클라이언트 구조이다 보니, 방장이 거래를 수락했을 때 클라이언트는 BroadcastTradeSync를 받아 정상적으로 아이템이 들어오지만, 정작 방장 본인의 인벤토리는 변하지 않는 문제가 있었다. 방장은 브로드캐스트 패킷을 보내기만 하고 자신은 수신 처리를 타지 않았기 때문이다.
  • 해결: 서버의 응답 처리 함수 마지막에 자기 자신의 SyncTrade를 직접 호출하도록 로직을 추가
// TradeManager.cpp
void TradeManager::Server_HandleResponse(uint32_t tradeId, uint8_t response)
{
	// ... (상태 변경 및 로깅 로직) ...
    Pkt_TradeSync syncPkt;
    syncPkt.info = updatedInfo;
    NetworkManager::GetInstance().BroadcastTradeSync(syncPkt);

    SyncTrade(updatedInfo);
}

이를 통해 서버 역할을 수행하는 방장도 일반 플레이어와 동일한 사이클로 렌더링되도록 문제를 해결했다.

 

회고

이번 거래 시스템을 구현하면서 가장 크게 배운 점은 네트워크 환경에서는 클라이언트의 상태를 절대 신뢰하면 안 된다는 것이다.

  • 클라이언트 UI에서 입력을 필터링하더라도(예: 인벤토리에 없는 아이템 신청 불가),
  • 결국 최종 승인과 ID 발급은 중앙(서버/방장)에서 처리하고,
  • 상태의 전이(State Transition) 과정에서만 실제 중요한 데이터 조작(아이템 추가/삭제)을 수행해야 무결성이 보장된다는 것을 실감했다.

향후에는 거래 완료 시 파일이나 DB에 로그를 기록하여 혹시 모를 부정 거래를 어드민이 추적할 수 있는 시스템을 추가해보고 싶다.