오늘의 할 일 |
프로젝트 회고 할 일 |
|
|
프로젝트 진행 시 담당했던 파트 코드 분석
시스템 아키텍처: 확장성에 초점을 둔 감자가 생각났어요

1. 아이템 및 인벤토리(필수 구현 과제)
중요 포인트
☞ 아이템 및 인벤토리 시스템은 메모리 관리의 효율성과 출력 로직의 유연성을 최우선으로 설계하였다.
아이템 객체 포인터와 수량을 하나의 구초제로 관리하여 불필요한 중복 객체 생성을 방지하고 메모리 점유율을 최소화했으며, std::function 기반의 콜백 구조를 활용해 데이터 관리와 화면 출력 로직을 완벽히 분리하여 높은 확장성을 확보했다.
✅ 체크리스트
1. 아이템 사용 조건 자율 - 아이템은 사용 순간 사라지고, 효과는 바로 캐릭터에게 반영
2. 아이템:
2-1. HP 포션: 체력 + 50
2-2. 공격력 포션: 공격력 + 10 → 전투 중에만 효과 발휘/전투 종료 후 원래 상태로
3. 전투 중 아이템 랜덤 사용 → 전투 구현 담당자분이 적용해주심
아이템 시스템은 기본 클래스를 두고 이를 상속받는 특수 아이템을 구현하고 있다. (특수 아이템은 다른 분께서 작업하셨다)
이 부분에서는 Item 클래스에 대해서만 간단히 언급하고 인벤토리에 관해 자세히 설명할 예정이다.
Item(Base Class): Item.h
- 모든 아이템이 공통으로 가지는 속성을 가짐
- ItemType 열거형을 통해 HP 포션, 공격력 포션, 몬스터 신체 일부, 무기를 쉽게 분류
ItemType에 따른 효과 적용을 볼 수 있는 Item.cpp 일부:
/*
* @brief 아이템 효과 플레이어에게 적용하는 메서드
* @param target 플레이어
*/
void Item::Use(Character* target)
{
if (!target) { return; }
switch (type)
{
case ItemType::HP_POTION:
target->SetHp(target->GetHp() + target->GetMaxHp()*value/100);
break;
case ItemType::ATTACK_BUFF: {
int currentAttack = target->GetAttack();
int bonusAttack = std::round(currentAttack * 4 / 100.0f);
IPCManager::GetInstance().SendLog("[아이템 사용됨] 현재 공격력: " + std::to_string(currentAttack)+ " | 계산된 증가량: " + std::to_string(bonusAttack)+ " | 결과: " + std::to_string(currentAttack + bonusAttack));
target->SetAttack(currentAttack + bonusAttack);
break;
}
default:
break;
}
}
아이템 및 인벤토리 파트에서 가장 신경 써서 구현한 부분은 인벤토리이다.
인벤토리는 특정 타입에 종속되지 않도록 템플릿 방식으로 구현하였는데, 덕분에 상점 시스템을 구현할 때도 인벤토리 시스템을 활용하여 쉽게 구현할 수 있었다.
인벤토리는 Inventory<Item> 형태로 Player.h 선언하여 사용하였고,
InventorySlot<T> 구조체를 사용하여 아이템 객체 포인터와 개수를 쌍으로 관리했다.
여기서 개수를 객체 포인터와 함께 관리한 이유는 AddItem 시 추가하려는 아이템이 인벤토리에 존재하는지 먼저 확인하여, 동일한 아이템이 있을 경우 개수만 증가시켜 불필요한 메모리를 추가적으로 할당하지 않도록 하기 위해서이다.
Inventory.h의 AddItem 코드:
/*
* @brief 아이템 추가 메서드
* @param newItem 새로 추가할 아이템
* @param amount 추가할 아이템 수량
*/
void AddItem(T* newItem, int amount = 1)
{
if (newItem == nullptr || amount <= 0) { return; }
auto* slot = GetItemSlot(newItem->GetName());
// 기존에 있던 아이템일 경우
if (slot)
{
slot->count += amount;
delete newItem;
return;
}
// 기존에 없던 아이템일 경우
slots.push_back(InventorySlot<T>(newItem, amount));
}
인벤토리를 구현할 때 아이템 목록 출력 함수를 어떻게 작성해야 확장성이 있을지 고민을 많이 했었다.
인벤토리 템플릿을 상점 시스템에서도 사용할 것이기 때문에, 사용자 인벤토리 목록 출력과 상점 재고 목록 출력을 동일한 함수를 써야할지, 각각 구분하여 함수를 구현해야할지 결정하는 부분이 제일 어려웠던 거 같다.
하나의 함수를 사용하면서 각각의 기능에 따라 출력문을 작성하고 싶어서 찾아보니
std::function 콜백을 인자로 받아 출력 방식을 외부에서 결정할 수 있도록 설계하는 방법이 있길래 출력 함수는 이 방법을 채택하여 구현하였다.(사용자 인벤토리 목록을 출력하는 State는 다른 분이 작성해주셨는데,, 이 방식으로 출력을 안하신 거 같다.. 속상구만)
이 방법은 아이템을 순회하는 반복문은 Inventory가 담당하고, 가져온 아이템을 화면에 어떻게 출력할 것인가(포맷팅)는 호출하는 쪽에서 결정하는 방식이다.
코드를 작성하기에 앞서 이 방식이 가지는 장점을 작성해보면 다음과 같다.
- 재사용성이 높다.
- SRP 준수하였다.
- 함수를 따로 작성할 필요 없이 람다식을 넘겨줌으로써 가독성을 높였다.
PrintAllItems 함수 관련 코드:
// Inventory.h 코드 일부
/*
* @brief 인벤토리 출력 메서드
* @param formatter 아이템과 수량을 어떻게 출력할지 정의하는 함수
*/
void PrintAllItems(std::function<void(const T*, int, int)> formatter) const
{
// 인벤 비어있는 경우
if (slots.empty())
{
formatter(nullptr, 0, -1);
return;
}
int index = 0;
for (const auto& slot : slots)
{
formatter(slot.item, slot.count, index++);
}
}
// Shop.cpp 코드 일부
/*
* @brief 상점 재고 출력 메서드
*/
void Shop::ShowStock() const
{
Renderer::DisplayUI(UIPart::CenterLeft, 0, "[상점 재고 목록]");
stock.PrintAllItems([](const Item* item, int count, int index)
{
if (index == -1)
{
Renderer::DisplayUI(UIPart::CenterLeft, 1, "상점에 재고가 없습니다.");
return;
}
std::string info = item->GetName() + " | 가격: " + std::to_string(item->GetPrice()) + "G | 재고: " + std::to_string(count) + "개";
Renderer::DisplayUI(UIPart::CenterLeft, index + 1, info);
});
}
2. 상점 시스템(도전 구현 과제)
Inventory<Item> 템플릿 클래스를 상점의 재고로 재활용하여 중복되는 코드를 줄이고, 아이템 추가/삭제/중첩에 대한 검증 로직을 가져와 시스템의 안정성을 높였다.
✅ 체크리스트
1. 전투 후 플레이어에게 상점 입장 여부 물어보기
2. 상점:
2-1. 재화명: 골드 → G
2-2. 아이템 구매 시: 인벤토리 즉시 추가 / 가격은 고정값으로 유동적으로 변할 수 없음
2-3. 아이템 판매 시: 아이템 →골드로 전환 / 인벤토리 즉시 제거 / 판매 가격은 구입 원가의 60%
3. 아이템 목록은 자유롭게 제작 가능
상점 시스템의 주요 기능은 크게 재고 관리, 구매 로직, 판매 로직 세 가지였다.
세 가지 기능 중 가장 먼저 재고 관리에 관해 설명하고자 한다.
상점은 자체적으로 Inventory<Item> stock 멤버 변수를 가지고 있어, 마치 하나의 상인 캐릭터가 인벤토리를 들고 있는 것과 같은 구조이다. 초기화 시점에 기본 재고를 stock.AddItem()으로 등록하고, 재고 목록 출력 시 ShowStock() 메서드를 호출하여 상점 전용 UI로 재고를 렌더링한다.
각각의 함수는 인벤토리 템플릿에서 이미 다룬 내용이기 때문에 바로 구매 로직에 대한 설명으로 넘어가려 한다.
상점에서 아이템을 구매하기 위해서는 아래 세 가지를 확인해야 한다.
1. 구매하고자 하는 아이템이 상점에 존재하는가
2. 구매하려는 수량만큼 아이템 재고가 있는가
3. 플레이어의 소지금이 총 구매 금액보다 많은가
위 세 가지 조건을 모두 만족한다면 플레이어 인벤토리에 아이템을 추가하고, 상점의 stock에서는 수량을 1 감소시키면 된다.
아이템 구매 관련 메서드:
/*
* @brief 아이템 구매 메서드
* @param player 아이템을 구매하는 플레이어
* @param itemName 구매할 아이템 이름
* @return 아이템 구매 성공 여부(true - 성공 / false - 실패)
*/
bool Shop::BuyItem(Player* player, const std::string& itemName)
{
// 상점 재고에서 아이템 찾기
auto* slot = stock.GetItemSlot(itemName);
// 재고 확인
if (!slot || slot->count <= 0) return false;
Item* shopItem = slot->item;
int price = shopItem->GetPrice();
// 플레이어 잔고 확인
if (player->GetMoney() < price) return false;
// 구매 진행
player->SetMoney(player->GetMoney() - price);
player->GetInventory().AddItem(new Item(*shopItem), 1);
stock.UseItem(nullptr, itemName, 1);
return true;
}
마지막으로 판매 로직은 플레이어의 인벤토리에서 판매하고자 한 아이템이 있는지를 검증하면 되므로 구매 로직에 비해 비교적 간단하다.
만약 사용자 인벤토리에 아이템이 있다면 아이템 종류에 따라 정확한 판매 금액을 계산하여 플레이어에게 지급한다.
(일반 아이템: 구매 금액의 60%, 몬스터 부위: 100%)
이후 플레이어의 인벤토리에서 해당 아이템을 삭제하고, 삭제된 아이템은 깊은 복사를 수행해 다시 상점의 재고에 추가한다.
아이템 판매 관련 메서드:
// Shop.cpp 코드 일부
/*
* @brief 아이템 판매 메서드
* @param player 아이템을 판매하는 플레이어
* @param itemName 판매할 아이템 이름
* @param amount 판매할 아이템 수량
* @return 아이템 판매 성공 여부(true - 성공 / false - 실패)
*/
bool Shop::SellItem(Player* player, const std::string& itemName, int amount)
{
// 플레이어 인벤토리에 해당 아이템이 실제로 있는지 확인
auto* slot = player->GetInventory().GetItemSlot(itemName);
if (!slot || slot->count < amount) return false;
// 판매 금액
Item* itemToSell = slot->item;
int sellPrice = slot->item->GetSellPrice() * amount;
// 판매 진행
Item* newItemForStock = new Item(*itemToSell);
player->GetInventory().UseItem(nullptr, itemName, amount);
player->SetMoney(player->GetMoney() + sellPrice);
stock.AddItem(newItemForStock, amount);
return true;
}
//Item.cpp 코드 일부
/*
* @brief 아이템 판매 금액 반환 메서드
* @return MONSTER_PART 타입이면 금액 그대로, 다른 다입일 경우 구매 금액의 60%
*/
int Item::GetSellPrice() const
{
if (type == ItemType::MONSTER_PART) return price;
return static_cast<int>(price * 0.6);
}
이렇게 구현한 각 기능들은 ShopBuyState와 ShopSellState 등으로 상태 클래스를 나누어 관리하였고, 사용자의 행동에 따른 결과가 반영되도록 하였다.
3. 아이템 거래 시스템(추가 구현)
거래 시스템의 핵심은 네트워크 환경에서의 데이터 무결성 확보이다.
아이템 거래에서는 방장 중심의 ID 부여 - 승인 - 동기화 프로세스를 통해 아이템 복제 가능성을 차단하였고,
#pragma pack을 통한 메모리 정렬 최적화와 리슨 서버 모델에 최적화된 로컬 동기화 로직은 멀티플레이 TRPG의 안정적인 거래 시스템을 구축하는 기반이 되었다.
(네트워크 패킷 전송 시 구조체 크기 불일치를 방지하기 위해 #pragma pack(push, 1)을 사용하여 바이트 정렬을 강제함으로써, 서로 다른 환경 간의 데이터 오염 차단)
아이템 거래 시스템은 단순한 아이템 이동을 넘어, 양측의 합의 하에 아이템이 교환되는 1:1 거래 시나리오를 네트워크 기반으로 구현한 것이다.
거래의 생명주기는 TradeManager를 통해 관리되었고, 다음과 같이 진행된다.
먼저, 특정 클라이언트가 다른 클라이언트에게 거래를 신청하면, Pkt_TradeRequest 패킷이 전달되고 방장은 이 패킷에 고유한 tradeId를 부여한 후 Pending(0) 상태로 목록에 등록한다.
이렇게 새로운 거래가 발생할 경우 방장은 Pkt_TradeSync를 브로드캐스트하여 모든 접속자가 실시간으로 거래 목록을 확인할 수 있게 하는데, 이를 동기화한다고 한다.
마지막으로 거래 수신자가 수락(1) 또는 거절(2)을 응답하면 방장은 최종 상태를 확정한 후 다시 동기화를 진행한다.
이러한 과정 속에서 꼭 체크해야할 포인트는 아이템 이동의 무결성이다.
아이템이 중복 지급되거나 복제되는 상황을 방지하기 위해
오직 status가 0dptj 1로 변하는 시점에만 실제 인벤토리 수정 로직이 동작하도록 설계하였고
플레이어가 신청자인지 혹은 수신자인지를 판별하여
주는 아이템은 인벤토리에서 제거하고 받는 아이템은 생성하는 교차 로직을 적용하였다.
거래 생명주기 관련 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::GetInstance().BroadcastTradeSync(syncPkt);
IPCManager::GetInstance().SendLog("[서버] 새로운 거래 신청 등록 (ID: " + std::to_string(newInfo.tradeId) + ")");
}
// [공용] 목록 동기화 (서버가 보낸 정보를 내 로컬 리스트에 반영)
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);
}
}
void TradeManager::Server_HandleResponse(uint32_t tradeId, uint8_t response)
{
if (!Client::isServer) return; // 방장만 실행
TradeInfo * trade = GetTradeById(tradeId);
if (!trade || trade->status != 0) return; // 이미 처리된 거래면 무시
TradeInfo updatedInfo = *trade;
updatedInfo.status = response; // 1: 수락, 2: 거절
if (response == 1)
{ // [수락됨]
IPCManager::GetInstance().SendLog("[서버] 거래 성사! (ID: " + std::to_string(tradeId) + ")");
}
else
{
// [거절됨]
IPCManager::GetInstance().SendLog("[서버] 거래 거절됨. (ID: " + std::to_string(tradeId) + ")");
}
Pkt_TradeSync syncPkt;
syncPkt.info = updatedInfo;
NetworkManager::GetInstance().BroadcastTradeSync(syncPkt);
SyncTrade(updatedInfo);
}
// 실제로 인벤토리를 건드리는 함수
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) + "'님과의 거래 성사! 아이템이 교환되었습니다.");
}
}
프로젝트 진행 시 담당했던 파트 코드 분석 결과 및 회고
처음 인벤토리를 구현할 땐 그저 데이터를 잘 저장하고 꺼내는 데 집중했었다. 하지만 std::function 콜백을 활용해 데이터 보관 로직과 UI 출력 로직을 분리해 보니, 하나의 템플릿이 어떻게 상점 시스템의 재고 관리로 재사용될 수 있는지 깨닫게 되었다. 데이터를 쥐고 있는 쪽과 화면에 뿌려주는 쪽의 역할을 철저히 분리한 덕분에, 코드를 중복해서 작성하지 않아도 상점의 구매/판매 로직이 자연스럽게 맞물려 돌아가는 것을 보며 설계의 중요성을 체감했다.
그리고 가장 도전하기 두려웠던 거래 시스템에서는 리슨 서버 모델의 까다로움을 배웠다. 아이템과 골드가 1:1로 오가는 과정에서 복제 버그를 막으려면 패킷의 흐름과 상태 변화(Pending -> Accepted)를 완벽하게 통제해야 했는데, 방장(서버)이 ID를 부여하고 싱크를 맞추는 과정, 그리고 #pragma pack으로 메모리 정렬 문제까지 해결해 가면서, 단일 플레이 환경에서는 겪어보지 못할 데이터 동기화의 어려움과 이를 방어하기 위한 네트워크 로직 설계 방식을 깊이 배울 수 있었다.
실제로 작동하는 모습을 보니 너무 너무 신기하고 어렵지만 도전할만한 가치가 있었다는 생각을 했다!
단순한 TextRPG이기 때문에 새로운 기술적 도전이나 배움이 적을 것이라 섣불리 짐작하기도 했는데, 남의 코드를 뜯어보고, 상태 패턴을 통해 컨텐츠 간의 결합도를 낮추며, 멀티플레이 동기화 문제를 직접 몸으로 부딪쳐 해결해 보니 "어떤 프로젝트든 도움이 안 되는 프로젝트는 없다"는 것을 뼈저리게 느낄 수 있었던 경험이었다.
Git Repository: https://github.com/NBC-TRPG6/TRPG6
'내배캠 > CH2 팀프로젝트(감자가생각났어요)' 카테고리의 다른 글
| [TIL: 2026.06.04] TRPG 프로젝트 트러블 슈팅(3) (0) | 2026.06.03 |
|---|---|
| [TIL: 2026.06.02] TRPG 프로젝트 분석 및 회고(1) (1) | 2026.06.02 |
| [TIL: 2026.05.29] TRPG 팀프로젝트 종료 회고록 (0) | 2026.06.01 |
| [TIL: 2026.05.28] C++ TRPG 프로젝트: 서버 검증 기반 1:1 아이템 거래 시스템 구현 (0) | 2026.06.01 |
| [TIL: 2026.05.22] C++ TRPG 인벤토리 구조 리팩토링 및 렌더러 기반 상점 UI 구축 트러블슈팅 (0) | 2026.06.01 |