발전하는 춘배

[원고, C++/OOP] 로그라이크 게임 만들어보기 8 - 아이템과 인벤토리 1 : 설계, 추상 클래스, 상속, 순환 참조, 전방 선언 본문

.원고

[원고, C++/OOP] 로그라이크 게임 만들어보기 8 - 아이템과 인벤토리 1 : 설계, 추상 클래스, 상속, 순환 참조, 전방 선언

춘배0 2026. 2. 19. 22:38

카메라까지 구현하고 나니 뭘 해야 할지 순간 막막해졌다.

생각해보니 지금은 게임 플레이가 불가능하다. 맵과 몬스터는 나오는데 아이템이 없으니 체력 회복도 불가능해서 몬스터들을 다 잡을 수가 없다.

 

1. 구상

대충 아이템 클래스랑 인벤토리 클래스를 만들고 이렇게 해본다.

- 플레이어는 인벤토리를 가지고 있다.

- 인벤토리는 (0개 이상의) 아이템들을 가지고 있다.

- 인벤토리는 해당 아이템을 몇 개 가지고 있는지 수량 정보도 가지고 있다.

- 인벤토리는 아이템을 추가, 제거(버리기), 사용하는 기능을 가지고 있다.

- 아이템은 데이터다. 공통 정보로는 이름, 종류, 설명이 있다. 종류로는 무기, 의상, 소비아이템 정도가 있다.

-- 무기 아이템은 착용 시 공격력을 올려주므로 "공격력" 스탯이 있다. "착용하기"와 "착용 해제하기" 기능을 가지고 있다.

-- 의상 아이템은 착용 시 방어력을 올려주므로 "방어력" 스탯이 있다. "착용하기"와 "착용 해제하기" 기능을 가지고 있다.

-- 소비 아이템은 사용 시 체력을 회복해주므로 "체력회복량" 스탯이 있다. "사용하기" 기능을 가지고 있다.

음 일단 끄적여봤다. 무기랑 의상은 "장비 아이템"이라는 상위클래스로 뽑는다고 치자.

 

고민 1.

플레이어.인벤토리.아이템1.사용()

플레이어.인벤토리.사용(아이템1)

중 무엇이 더 좋을까?

고민 2.

사용()과 착용()을 따로 만들어야 할까?

고민 3.

아이템을 JSON 기반 데이터 아이템으로 만들려면 어떻게 해야 할까?

 

해결 1.

GPT형님의 도움을 받았다.

전자를 말로 풀어 쓰면, "아이템이 자신의 행동을 가진다"이고,

후자를 말로 풀어 쓰면, "인벤토리가 아이템 사용을 처리한다"이다.

곰곰히 생각해보면 둘 다 맞는 말이다. 그래서 이렇게 한다.

인벤토리는 use(int slotNum)를 가지고, 여기에서 해당 슬롯의 아이템 유무 판별, 사용, 아이템 개수 감소와 같은 로직을 수행한다.

아이템은 use(Entity* player)를 가지고, 여기에서 플레이어에게 효과를 적용시킨다. 참고로 아이템의 use()함수에서 플레이어의 스탯에 직접 접근하는 것은 캡슐화 위반이므로 player에게 heal(), equip()과 같은 API를 만들어주어야 한다.

해결 2.

사용()과 착용()을 따로 만들고자 하는 심리가 왜 생겼는지 생각해본다. 두 개가 뭔가 다르니까 분리해야겠다는 생각이 들었을 것이다. 뭐가 다른가? 사용()은 보유한 소비 아이템의 개수를 감소시키고 플레이어에게 해당 아이템의 효과를 부여한다. 착용()은 해당 장비를 착용 상태를 만들어 플레이어에게 해당 아이템의 효과를 부여한다.

로직이 조금 다르긴 하다. 이런 경우 분리하는 게 확장성에 있어 훨씬 좋다.

다만, 사용()으로 두 기능을 모두 통합하면 내부에서 '해당 아이템이 장비템이면 이거하고, 해당 아이템이 소비템이면 이거하고'의 분기를 처리해야 하지만 호출 자체는 플레이어.인벤토리.사용(아이템)으로 간단해진다.

두 기능을 분리하면 인벤토리 레벨에서, 해당 슬롯의 아이템이 장비면 -> 플레이어.인벤토리.착용(장비템), 소비템이면 -> 플레이어.인벤토리.사용(소비템), 처럼 여기서 분기를 처리하지만 내부 구현은 따로따로 할 수 있어 의미가 명학하고 확장성이 좋다.

나는 분리하는 방법을 선택하기로 한다. 얼마나 이 프로젝트를 크게 키울 지는 모르겠지만 확장성을 위해 사용()과 착용()을 분리한다.

해결 3.

아이템은 기본적으로 데이터이므로 파일로 따로 빼서 로드하는 방식을 사용하기로 한다. 타일 데이터를 따로 관리했더니 새로운 타일 도입이 너무 편했던 경험을 했더니 그러고 싶어졌다. 이 때 어떻게 아이템의 '기능'을 데이터화시킬 수 있는가?

이런 식으로 저장해본다.

{
  "name": "Small Potion",
  "type": "consumable",
  "description" : "heals +20 health to the player"
  "effects": [
    { "type": "heal", "value": 20 }
  ]
}

effect 필드에 타입과 밸류를 지정하여 기능을 데이터화시킨다.

사용은 이렇게 하면 될 것이다.

Item::use(Entity* player) {

effects에 대한 반복문:

 switch (effects[i]. type)

  heal인 경우: player.heal(20)

  뭐인 경우: ~~

}

이런 느낌?

 

2. Inventory 클래스 구현

일단 인벤토리 클래스. 위에서 거의 명확하게 정의를 해놨기 때문에 쉽게 짤 수 있었다.

내부 구현은 Item 종류와 수량의 pair를 저장하는 vector로 했다.

class Inventory {
  private:
    std::vector<std::pair<Item, int>> items;
    int capacity;

  public:
    Inventory(int capacity);
    bool addItem(const Item& item, int quantity);
    bool removeItem(int slotNum, int quantity);
    bool useItem(int slotNum);
    void moveItem(int fromSlot, int toSlot);
    void displayInventory() const;

    // GETTERS
    int getCapacity() const;
    const std::vector<std::pair<Item, int>>& getItems() const;
    int getItemQuantity(const Item& item) const;
    int getItemQuantity(int slotNum) const;
    const Item& getItem(int slotNum) const;

    // SETTERS
    void setCapacity(int newCapacity);
};

 

3. 추상 클래스

다음으로 Item 

생각해보면 직접 Item 객체로 만들어지는 아이템은 없다. 다 이걸 상속받은 다른 클래스의 객체일 것이므로 "추상 클래스"로 만든다.

https://gloomystudy.tistory.com/41

 

[C++] 추상 클래스 (abstract class), 인터페이스(interface)

순수 가상 함수 (pure virtual function) - 함수의 구현부가 없고, 선언부가 =0 으로 끝나는 가상함수 추상 클래스 (abstract class) - 순수 가상 함수가 한 개 이상 있는 클래스 - 객체를 생성할 수 없다. - 포

gloomystudy.tistory.com

추상 클래스란 ???

- 순수 가상 함수가 한 개 이상 있는 클래스

- 객체 생성이 불가능

그럼 순수 가상 함수란 ???

- 함수의 선언부가 =0 으로 끝나는 가상함수 (virtual void use() = 0; 요런거)

왜 쓰냐면

파생 클래스들의 공통 인터페이스를 정의할 수 있음

파생 클래스에게 특정 멤버 함수 구현(override)을 강제할 수 있음

다형성을 사용할 수 있음

 

정리해보면..

c++에서 추상클래스는 virtaul 함수 = 0; 으로 만들 수 있고, 이렇게 하면 해당 클래스의 객체 생성이 불가능하다.

한편 추상 클래스는 가상 소멸자 virtual ~Item() {} 을 넣어놔야 안전하다.

 

그럼 여기서 문제.

나는 사용()과 착용()을 분리하기로 했다.

그리고 아이템 파생 클래스의 위계는 대충 다음과 같다.

Item
├ ConsumableItem
└ EquipmentItem
  ├ WeaponItem
  └ ArmorItem

그러면 virtual use()와 virtaul equip()은 어디에 만들어놓아야 할까?

다형성을 이용한다 생각해보자.

Item에 이 둘 다 넣는 건 뭔가 아닌 것 같다는 느낌이 빡 오므로 배제한다.

1. 그럼 ConsumableItem에 use()를 넣고 (이러면 얘는 virtual일 필요가 없다)

EquipmentItem에 virtual equip()을 넣으면 될까?

2. 아니면 Item에 virtual use()를 넣고, ConsumableItem에선 use()를 구현해주고,

EquipmentItem에서는 virtaul equip()를 선언하고, use() 구현에선 내부에서 equip()을 호출하며, WeaponItem과 ArmorItem에서 equip()을 구현해주는 방법도 있다.

 

둘 중 뭐가 좋을까요 GPT선생님

1번의 가장 큰 문제는 "공통 인터페이스가 사라진다"는 것이다. 이러면 아래와 같은 상황이 생긴다

Item* item = inventory.get(slot);

여기서 item을 사용하려면.. use()를 호출할 지 equip()을 호출할 지 item의 타입 검사를 해서 정해줘야 하는데 호출단에서 그렇게 하는 건 다형성 설계를 깨는 방법이다.

따라서 2번 설계가 적절하다.

 

(뭐가 문제인 지는 gpt의 도움 없이는 아직 정확히 말로 설명하지는 못하겠지만 그래도 느낌상 1번은 아닌 것 같다는 생각을 했고, 2번 접근방식을 떠올려 낸 나 자신을 칭찬한다. 실력이 늘고 있다. 문제점을 직관으로 느끼는 게 아니라 말로 설명할 수 있도록 더 공부하면 될 듯)

 

이제 구현해보자

일단 아이템 클래스.

class Item {
  private:
    std::string name;
    ItemType type;
    std::string description;

  public:
    Item(const std::string& name, ItemType type, const std::string& description);
    virtual ~Item() {}

    // GETTERS
    std::string getName() const;
    ItemType getType() const;
    std::string getDescription() const;

    virtual bool use(Entity& target) = 0;
};

참고로 use의 target은 의미상 nullptr일 수 없으므로 포인터가 아닌 레퍼런스로 구현했다.

 

다음으로 하위 클래스들

struct Effect {
  std::string name;
  int value;
};

class ConsumableItem : public Item {
private:
  std::vector<Effect> effects;

public:
  ConsumableItem(const std::string& name, const std::string& description, const std::vector<Effect>& effects);

  // GETTERS
  std::vector<Effect> getEffects() const;

  bool use(Entity& target) override;
};

...

bool ConsumableItem::use(Entity& target) {
  for (const auto& effect : effects) {
    if (effect.name == "heal") {
      target.heal(effect.value);
      std::cout << "Consumed " << getName() << " | HP +" << effect.value << ".\n";
      return true;
    }
  }
  return false;
}
class EquipmentItem : public Item {
private:
  bool equipped = false;
  virtual bool equip(Entity &target) = 0;
  virtual bool unequip(Entity &target) = 0;

public:
  EquipmentItem(const std::string &name, const ItemType type, const std::string &description);
  virtual ~EquipmentItem () {}
  bool use(Entity &target) override;
};

...
bool EquipmentItem::use(Entity &target) {
  if (equipped)
    return unequip(target);
  else
    return equip(target);
}

 

다음으로 장비아이템의 하위클래스들(무기, 갑옷)

class WeaponItem : public EquipmentItem {
private:
  int damage;

public:
  WeaponItem(const std::string &name, const std::string &description, int damage);
  bool equip(Entity &target) override;
  bool unequip(Entity &target) override;
  int getDamage() const;
};

...
bool WeaponItem::equip(Entity& target) {
  target.setDamage(target.getDamage() + damage);
  return true;
}
bool WeaponItem::unequip(Entity& target) {
  target.setDamage(target.getDamage() - damage);
  return true;
}
class ArmorItem : public EquipmentItem {
private:
  int defense;

public:
  ArmorItem(const std::string &name, const std::string &description, int defense);
  bool equip(Entity &target) override;
  bool unequip(Entity &target) override;
  int getDefense() const;
};

...
bool ArmorItem::equip(Entity& target) {
  target.setDefense(target.getDefense() + defense);
  return true;
}
bool ArmorItem::unequip(Entity& target) {
  target.setDefense(target.getDefense() - defense);
  return true;
}

 

4. 장착 정책의 책임

음 사실 무기와 갑옷은 중복 착용이 안 된다.

즉, 이미 euipped = true; 인 장비가 따로 있으면 그 장비의 착용을 해제하고 새로운 장비를 착용시켜야 한다.

이 로직은 어디에 짜 넣어야 할까?

 

엔티티 클래스에, 현재 착용 중인 무기와 갑옷 아이템 정보를 저장시켜 놓고,

EquipmentItem::use에서 해당 로직을 구현하는 게 좋아 보인다.

 

확신이 들지 않을 땐 GPT형님을 또 불러본다.

아뿔싸 오답이었다.

아니 내가 맞고 GPT형님이 틀릴 수도 있지 않은가? 설명을 더 읽어본다.

 

EquipmentItem에서 장비 교체 로직을 구현한다고 가정해보자.

그러면 내부에..

if (player.getEquippedWeapon()) player.getEquippedWeapon()->unequip();

같은 로직이 들어가게 되는데 이건 OOP적으로 문제가 있다.

1. 한낱 "아이템" 따위가 "플레이어"의 내부를 조작한다

2. "장착" 로직이 "아이템" 클래스에 있다는 것도 문제다

3. "플레이어"의 장착 슬롯이 늘어나는데 "아이템" 클래스의 코드를 수정해야 한다

이런 문제들이 있다.

 

그러므로 장착 정책은 엔티티의 책임이라는 결론에 도달한다.

앞서 내가 얻은 깨달음을 바탕으로 이 결론을 다시 도출해보자.

1. 알고 있는 것

아이템은 엔티티에 대해 모른다.

아이템은 자신의 효과를 안다.

엔티티(플레이어)는 자신이 장착한 아이템을 안다.

 

2. 생명 주기

엔티티가 없어지면 그 엔티티가 장착하고 있었던 아이템도 없어진다.

아이템이 없어진다고 엔티티가 없어지진 않는다.

 

그러므로 엔티티가 아이템의 장착 정책을 구현해야 한다.

아이템은 자신의 효과를 그대로 엔티티에 적용/해제하는 일까지만 한다.

 

이런 결론에 따라 위에서 작성한 EquipmentItem::use()를 수정해준다.

bool Entity::equip(EquipmentItem *equipmentItem) {
  if (equipmentItem->getType() == ItemType::WEAPON) {
    if (equippedWeapon) {
      equippedWeapon->unequip(*this);
    }

    equippedWeapon = static_cast<WeaponItem *>(equipmentItem);
    return equippedWeapon->equip(*this);
      
  } else if (equipmentItem->getType() == ItemType::ARMOR) {
    if (equippedArmor)
      equippedArmor->unequip(*this);

    equippedArmor = static_cast<ArmorItem *>(equipmentItem);
    return equippedArmor->equip(*this);
  }
}
bool EquipmentItem::use(Entity &target) {
  return target.equip(this);
}

 

5. 컴파일 에러 해결

일단 여기까지 컴파일해본다. 각종 에러가 쏟아져서 우선 이걸 해결하고 다음에 이어서 더 해보기로 한다.

하나. 순환 참조 전방 선언

분명히 Item.h에서 #include "Entity.h"를 해주었는데 알 수 없다고 뜬다.

이유는 순환 참조가 발생했기 때문이다.

Item.h는 Entity.h를 include하고 있고,

Entity.h는 장착된 장비템을 저장해놓기 위해 ArmorItem과 WeaponItem을 include하고 있는데 결과적으론 Item.h를 참조하고 있다.

이를 해결하기 위해서는 Item.h에서 Entity를 include하는 것이 아니라,

forward declaration; 전방 선언을 해주면 된다.

https://boycoding.tistory.com/143#google_vignette

 

C++ 01.09 - 전방 선언과 정의 (forward declarations and definitions)

01.09 - 전방 선언과 정의 (forward declarations and definitions) add.cpp 라는 샘플 프로그램을 보자. #include int main() { std::cout

boycoding.tistory.com

Item 클래스의 헤더에서는 Entity& target에서만 Entity를 참조하고 있다.

레퍼런스( Entity& )는 완전한 정의가 없어도(=내부 구조를 몰라도) 주소만 알면 사용 가능하기 때문에, 전방 선언을 통해 해결이 가능하다.

#include "Entity.h" 대신

class Entity; 만 선언해주어 이런 게 있다는 것만 알려주면 된다.

 

둘. 인벤토리 클래스 구현 수정 - 추상 클래스는 포인터로 저장해야 함

뭔가 무섭게 생긴 오류메시지가 쏟아졌다.

Inventory.cpp에서 <Item, int>의 pair를 생성하려 하는데 Item은 추상 클래스이므로 객체 생성이 불가하다.

아이템은 다형성을 위한 추상 클래스이므로, 값(객체)를 저장하려 하면 안 되고, 포인터를 저장해야 한다.

이왕 바꾸는 김에, 인벤토리 내부 구현도 <Item, 수량>의 pair를 저장하는 대신 struct로 저장하게 바꿔주기로 한다.

struct InventorySlot {
  std::unique_ptr<Item> item;
  int quantity;
};

class Inventory {
  private:
    std::vector<InventorySlot> items;
    int capacity;
    
  ...
    
}

이에 맞게 메서드들도 수정해준다.

bool Inventory::addItem(const Item &item, int quantity) {
  if (getItemQuantity(item) == 0) {
    if (items.size() >= capacity)
      return false;

    InventorySlot newSlot;
    newSlot.item = item.clone();
    newSlot.quantity = quantity;
    items.push_back(std::move(newSlot));
    return true;
    
  } else {
    for (auto &slot : items) {
      if (slot.item->getName() == item.getName()) {
        slot.quantity += quantity;
        return true;
      }
    }
  }
  return false;
}

여기서 볼만한 건 const Item&의 소유권은 다른 곳 어딘가에 있으므로, 해당 아이템을 "슬롯에 저장한다"고 했을 때, 거기에 저장되는 아이템은 "그 아이템" 그 자체라고 보기 어렵고, 복사본을 저장해야 맞다. 그래서 Item에 virtual 메서드 clone()을 선언해주고, 파생 클래스들에서 

std::unique_ptr<Item> ConsumableItem::clone() const {
    return std::make_unique<ConsumableItem>(*this);
}

이런 느낌으로 구현해준다.

 

컴파일 성공.

다음엔 아이템 데이터들을 만들어보고, 게임에 로드해서 실제로 사용해보는 테스트를 해보자.

반응형