🤔 초기 설계: "공통점이 많으니 Base로 관리하자."
프로젝트 초기, ASoulCharacterBase를 설계할 때 저는 확신에 차 있었습니다.
Player와 Enemy는 공격, 피격, 사망 등 공통 로직이 80% 이상이었기 때문입니다.
- 공통 시스템: 공격, 피격, 방어, 사망
- 공통 컴포넌트: Attribute, Combat, State
- 공통 로직: 데미지 계산, 히트 리액션
// 공통 로직은 Base에!
class ASoulCharacterBase : public ACharacter, public ISoulCombat
{
protected:
// 공통 컴포넌트
UAttributeComponent* AttributeComponent;
UCombatComponent* CombatComponent;
UStateComponent* StateComponent;
public:
// 공통 함수
virtual float TakeDamage(...);
void HitReaction(...);
void DoAttack(...);
};
// 상속 구조
ASoulPlayerCharacter : public ASoulCharacterBase
ASoulEnemy : public ASoulCharacterBase
기대했던 장점
- ✅ 코드 중복 제거
- ✅ 유지보수 용이 (Base만 수정하면 모두에게 적용)
- ✅ 상속의 이점 극대화 (코드 재사용성)
"Base 클래스에서 한 번만 정의하면 모든 자식이 공짜로 기능을 쓴다." 이 강력한 코드 재사용성(Code Reusability) 덕분에 개발 속도는 빨랐고, 유지보수도 쉬워 보였습니다.
하지만 개발이 진행될수록, 이 '편리함'에 점점 의구심을 갖기 시작했습니다.
🚨 문제 인식
문제는 Player와 Enemy의 로직이 미세하게 갈라지기 시작한 시점부터였습니다.
딜레마 1: 버려지는 프로퍼티 (Bloated Base Class)
class ASoulCharacterBase
{
// ❓ Enemy는 평생 쓰지도 않을 메모리 낭비
UCameraComponent* Camera;
UInventoryComponent* Inventory;
TSubclassOf<UCameraShake> HitShake;
};
Enemy 인스턴스가 생성될 때마다 사용하지도 않는 카메라와 인벤토리 포인터를 들고 있는 상황. "Base 클래스가 쓸데없이 비대해지고 있다"는 신호였습니다.
딜레마 2: 로직의 분기 (Spawn of Spaghetti)
TakeDamage는 공통 로직이지만, 세부 동작이 달랐습니다.
Player: 카메라 셰이크 필요Enemy: AI에게 데미지 알림 필요
// 🚨 Enemy 전용 - Player는 빈 구현
virtual void TakeDamageForEnemy(float Damage, AController* EventInstigator,
const FVector& EventLocation, const FVector& HitLocation) {}public:
virtual float TakeDamage(float Damage, const FDamageEvent& DamageEvent,
AController* EventInstigator, AActor* DamageCauser) override
{
// 공통 로직
float FinalDamage = CalculateDamage(Damage);
// 🚨 누군가는 쓰고 누군가는 안 쓰는 코드
ClientStartCameraShake(ECameraShakeType::Hit); // Player만
TakeDamageForEnemy(Damage, EventInstigator, ...); // Enemy만
// 공통 로직
AttributeComponent->TakeDamageAmount(FinalDamage);
return FinalDamage;
}};
```cpp
// 시간이 지날수록 점점 늘어나는 분기점들...
void ASoulCharacterBase::HitReaction(...)
{
PlayHitAnimation(); // 공통
OnPlayerHitEffect(); // Player만 (빈 함수)
OnEnemyHitEffect(); // Enemy만 (빈 함수)
ApplyKnockback(); // 공통
OnPlayerCameraShake(); // Player만 (빈 함수)
OnEnemyUIUpdate(); // Enemy만 (빈 함수)
SetState(SoulGameplayTag::Hit); // 공통
}
공통 로직 (Step 1)
↓
빈 가상 함수 호출 (Player 전용) ← Enemy는 빈 함수 실행
↓
공통 로직 (Step 2)
↓
빈 가상 함수 호출 (Enemy 전용) ← Player는 빈 함수 실행
↓
공통 로직 (Step 3)
↓
빈 가상 함수 호출 (...)
↓
...
갈수록 이러한 늘어나면서 부모 클래스는 점점 관리가 복잡해지는 코드가 되어갔습니다.
💭 고민 : "전방위적인 결합도 증가"
이 불안함은 시스템 간의 연결(Coupling)을 다룰 때 폭발했습니다.
의문 1: AnimNotify가 왜 캐릭터를 알아야 하지?
AnimNotify에서 충돌 판정을 켤 때, Cast<ASoulCharacterBase>를 사용하고 있었습니다.
만약 나중에 "공격 가능한 오브젝트(예: 함정, 포탑)"가 추가된다면? ASoulCharacterBase를 상속받지 않은 그들은 이 노티파이를 쓸 수 없습니다.
불필요한 결합이 발생한 것입니다.
// Before: 구체적인 타입에 의존
void UAnimNotify_ActivateCollision::Notify(USkeletalMeshComponent* MeshComp, ...)
{
if (ASoulCharacterBase* Character = Cast<ASoulCharacterBase>(MeshComp->GetOwner()))
{
Character->ActivateCollision(CollisionType, DamageType);
}
}
// ATrapTower.h - 포탑 추가
class ATrapTower : public AActor // ❌ ASoulCharacterBase를 상속받지 않음
{
// 공격 기능이 필요한데...
// AnimNotify_ActivateCollision을 쓸 수 없다!
};
결과:
- 함정, 포탑 같은 "공격 가능한 오브젝트" 추가 시
ASoulCharacterBase를 상속받지 않으면 기존AnimNotify사용 불가- 새로운
AnimNotify를 또 만들어야 함 → 코드 중복 발생
의문 2: Manager 클래스는 정답인가?
컴포넌트 간의 참조가 복잡해지자(Inventory ↔ UI ↔ Combat), 저는 WidgetManager 같은 관리자 클래스에 의존성을 몰아넣었습니다.
// 복잡한 의존성 그래프
InventoryComponent ↔ InventoryWidget
↕
CombatComponent ↔ StatusWidget
↕
AttributeComponent ↔ StatBarWidget
하지만 이것도 미봉책이었습니다. "AnimNotify에게 함수를 전달하기 위해 또 Manager를 만들어야 하나?"
모든 문제를 Manager로 해결하려다 보니, 또 다른 거대 클래스(God Class)를 만들고 있었습니다.
의문 3: "만약 전투 컨텐츠 이외로 확장이 된다면?"
지금은 전투 게임이지만, 만약 대화만 가능한 NPC가 추가된다면?
그 NPC가 ASoulCharacterBase를 상속받으면 체력, 공격력, 전투 로직까지 억지로 떠안게 됩니다.
그렇다고 상속을 안 받자니, 상호작용 로직을 또 새로 짜야 합니다.
// ANPCCharacter.h - 대화만 가능한 NPC
class ANPCCharacter : public ASoulCharacterBase // ❌ 강제 상속
{
// 🚨 억지로 떠안게 되는 것들
UAttributeComponent* AttributeComponent; // 체력? 필요 없는데...
UCombatComponent* CombatComponent; // 공격? 필요 없는데...
virtual float TakeDamage(...) override { return 0.f; } // 빈 구현 강제
virtual void DoAttack(...) override { } // 빈 구현 강제
// 실제로 필요한 건 이것뿐
void StartDialogue();
};
"상속은 '정체성(Is-A)'을 강제하지만, 게임 개발에서 필요한 건 '기능(Can-Do)'의 확장이 아닐까?"
🔧 문제 해결 접근 방법 : 인터페이스를 통한 "기능 중심 설계"
저는 이 문제를 해결하기 위해 Base 클래스의 해체가 아닌, 인터페이스와의 공존을 선택했습니다.
✅ 전략 1: "정체성"이 아닌 "능력"으로 대화하라
AnimNotify나 외부 시스템은 대상이 ASoulCharacterBase인지 알 필요가 없습니다. 그저 "전투가 가능한가(ISoulCombat)?"만 알면 됩니다.
// ISoulCombat.h - 전투 능력 계약
UINTERFACE(MinimalAPI)
class USoulCombat : public UInterface
{
GENERATED_BODY()
};
class SOUL_API ISoulCombat
{
GENERATED_BODY()
public:
// "나는 전투할 수 있다"는 계약
virtual void ActivateCollision(EWeaponCollisionType CollisionType, TSubclassOf<UDamageType> DamageType) = 0;
virtual void DeactivateCollision(EWeaponCollisionType CollisionType) = 0;
virtual void DoAttack(const FGameplayTag& AttackTypeTag) = 0;
virtual void HitReaction(AActor* Attacker, UDamageType* DamageType, const FVector& HitDirection) = 0;
};
// Before : 구체적인 타입에 의존
void UAnimNotify_ActivateCollision::Notify(USkeletalMeshComponent* MeshComp, ...)
{
// ❌ ASoulCharacterBase만 가능
if (ASoulCharacterBase* Character = Cast<ASoulCharacterBase>(MeshComp->GetOwner()))
{
Character->ActivateCollision(CollisionType, DamageType);
}
}
// After: 인터페이스에 의존
void UAnimNotify_ActivateCollision::Notify(USkeletalMeshComponent* MeshComp, ...)
{
// 이제 포탑이든, 함정이든, 캐릭터든 ISoulCombat만 구현하면 작동한다.
if (ISoulCombat* Combat = Cast<ISoulCombat>(Mesh->GetOwner()))
{
Combat->ActivateCollision();
}
}
// 이제 포탑도 전투 가능!
class ATrapTower : public AActor, public ISoulCombat // ✅ 인터페이스만 구현
{
// ISoulCombat 구현
virtual void ActivateCollision(...) override
{
// 포탑만의 충돌 로직
}
virtual void DoAttack(...) override
{
// 포탑만의 공격 로직
}
// ASoulCharacterBase의 무거운 로직은 필요 없음!
};
┌──────────────────┐
│ AnimNotify │
└────────┬─────────┘
│ Cast<ISoulCombat>
↓
┌──────────────────────┐
│ <<interface>> │
│ ISoulCombat │ ← ✅ 추상 계약에 의존
└──────────────────────┘
△
┌────┼────┬────┬────┐
│ │ │ │ │
Player Enemy 함정 포탑 Boss (자유로운 확장)
이로써 결합도가 획기적으로 낮아졌고, 추후 어떤 오브젝트가 추가되더라도 인터페이스만 구현하면 기존 시스템을 재사용할 수 있게 되었습니다.
✅ 전략 2: Base 클래스는 "구현의 도우미"로 남겨라
Base 클래스를 버린 것이 아닙니다. 공통 로직(데미지 산출, 상태 관리)은 여전히 유효하기 때문입니다.
대신, Base 클래스의 역할을 재정의했습니다.
┌─────────────────────────────┐
│ 1. Interface (계약) │
│ "이 객체는 전투 가능하다" │
└──────────────┬──────────────┘
│
↓
┌─────────────────────────────┐
│ 2. Base (공통 구현) │
│ "계약에 대한 기본 구현" │
└──────────────┬──────────────┘
│
↓
┌─────────────────────────────┐
│ 3. Child (특수화) │
│ "Hook으로 1% 로직만" │
└─────────────────────────────┘
- Interface (ISoulCombat): "이 객체는 공격과 피격이 가능하다"는 계약(Protocol) 정의.
- Base Class (ASoulCharacterBase): 계약에 대한 공통 구현(Default Implementation) 제공.
- Child Class (Player/Enemy): Hook 함수(OnHitReactionEffect)를 통해 자신만의 1% 로직만 오버라이딩.
// ISoulCombat.h
class ISoulCombat
{
public:
virtual void HitReaction(AActor* Attacker, UDamageType* DamageType, const FVector& HitDirection) = 0;
};
// ASoulCharacterBase.h
class ASoulCharacterBase : public ACharacter, public ISoulCombat
{
protected:
// 공통 컴포넌트
UAttributeComponent* AttributeComponent;
UCombatComponent* CombatComponent;
// 🎯 Hook 함수: 자식이 필요하면 구현
virtual void OnHitReactionEffect(AActor* Attacker) {}
public:
// 공통 로직 구현
virtual void HitReaction(AActor* Attacker, UDamageType* DamageType, const FVector& HitDirection) override
{
// ✅ 공통 로직 90%
PlayHitAnimation();
ApplyKnockback(HitDirection);
// 🎯 Hook: 자식만의 1% 로직 호출
OnHitReactionEffect(Attacker);
// ✅ 공통 로직 계속
SetState(SoulGameplayTag::Character_State_Hit);
}
};
// ASoulPlayerCharacter.h
class ASoulPlayerCharacter : public ASoulCharacterBase, public IPlayerCharacter
{
protected:
// Player만의 프로퍼티
UCameraComponent* Camera;
TSubclassOf<UCameraShakeBase> HitCameraShake;
// 🎯 Hook 구현: Player만의 1% 로직
virtual void OnHitReactionEffect(AActor* Attacker) override
{
// 카메라 셰이크 (Player 전용)
ClientStartCameraShake(HitCameraShake);
}
};
// ASoulEnemy.h
class ASoulEnemy : public ASoulCharacterBase, public IEnemy
{
protected:
// Enemy만의 프로퍼티
UWidgetComponent* HealthBar;
// 🎯 Hook 구현: Enemy만의 1% 로직
virtual void OnHitReactionEffect(AActor* Attacker) override
{
// 체력바 표시 (Enemy 전용)
ToggleHealthBarVisibility(true);
}
};
HitReaction() 호출
↓
PlayHitAnimation() // ✅ 공통 (Base)
↓
ApplyKnockback() // ✅ 공통 (Base)
↓
OnHitReactionEffect() // 🎯 Hook 호출
├─ Player: ClientStartCameraShake() // Player만의 1%
└─ Enemy: ToggleHealthBarVisibility() // Enemy만의 1%
↓
SetState() // ✅ 공통 (Base)
Base 클래스의 공통 로직을 살리되, 더 포괄적인 항목의 가상 함수를 만들어 자식에서 재정의하는 방식을 사용했습니다.
문제 인식
이제는 전투 관련 함수이지만, Player만 또는 Enemy만 사용하는 함수에 대한 구현을 올려놓아야 하는 고민이 남았습니다.
// ❌ Fat Interface
class ISoulCombat
{
// 공통
virtual void DoAttack(...) = 0;
virtual void HitReaction(...) = 0;
// Player 전용
virtual void ClientStartCameraShake(...) = 0; // ❌ Enemy도 구현 강제
virtual void AddItem(...) = 0; // ❌ Enemy도 구현 강제
// Enemy 전용
virtual void PerformAttack(...) = 0; // ❌ Player도 구현 강제
virtual void OnTargeted(...) = 0; // ❌ Player도 구현 강제
};
아예 과감히 인터페이스를 세분화(ISP, Interface Segregation Principle)하기로 결정했습니다.
// ISoulCombat.h - 전투 공통
class ISoulCombat
{
virtual void DoAttack(const FGameplayTag& AttackTypeTag) = 0;
virtual void HitReaction(AActor* Attacker, UDamageType* DamageType, const FVector& HitDirection) = 0;
virtual void ActivateCollision(EWeaponCollisionType CollisionType, TSubclassOf<UDamageType> DamageType) = 0;
};
// IPlayerCharacter.h - Player 전용
class IPlayerCharacter
{
virtual void BroadcastStatusMessage(const FString& Message) const = 0;
virtual bool AddItem(const FName ItemID, const int32 Amount = 1, const int32 SlotIndex = -1) = 0;
virtual void ClientStartCameraShake(TSubclassOf<UCameraShakeBase> ShakeClass) const = 0;
};
// IEnemy.h - Enemy 전용
class IEnemy
{
virtual bool PerformAttack(FGameplayTag& AttackTypeTag, FOnMontageEnded& MontageEndedDelegate) = 0;
};
// ISoulTargeting.h - 타게팅 가능 객체
class ISoulTargeting
{
virtual void OnTargeted(bool bTarget) = 0;
virtual bool CanBeTargeted() = 0;
};
이제 Derived 클래스는 Base 클래스와 달리 더 많은 인터페이스를 상속받는 구조가 되었습니다.
```cpp // Player: 필요한 인터페이스만 구현 class ASoulPlayerCharacter : public ASoulCharacterBase, // 공통 구현 public IPlayerCharacter // Player 전용 { // IPlayerCharacter 구현 virtual void ClientStartCameraShake(...) override; virtual bool AddItem(...) override; };// Enemy: 필요한 인터페이스만 구현
class ASoulEnemy : public ASoulCharacterBase, // 공통 구현
public IEnemy, // Enemy 전용
public ISoulTargeting // 타게팅 대상
{
// IEnemy 구현
virtual bool PerformAttack(...) override;
// ISoulTargeting 구현
virtual void OnTargeted(...) override;
virtual bool CanBeTargeted() override;};
```
✅ 결론: 복잡성을 제어하는 힘
이번 리팩토링을 통해 얻은 깨달은 것은 다음과 같습니다.
- 상속의 한계
- "상속은 코드 재사용을 위해 좋지만, 설계를 유연하게 만들지는 못한다"
이유:
- 상속은 부모-자식 간의 결합도가 가장 높은 형태
- 부모의 변경이 자식 전체에 영향
- 자식은 부모의 모든 것을 떠안음 (필요 없어도)
- 인터페이스는 확장성을 위한 투자
- "당장은 코드가 늘어나는 것 같지만, 새로운 컨텐츠가 추가될 때 기존 코드를 수정하지 않고도 확장할 수 있는 길을 열어준다"
- Base와 Interface는 양자택일이 아니다
- "Base로 중복을 줄이고, Interface로 결합도를 낮추는 하이브리드 설계가 실무적인 정답에 가깝다"
'프로젝트 회고' 카테고리의 다른 글
| [Dedicated Server] 네트워크 복제 대역폭 최적화: ```Fast Array Serializer```를 활용한 배열 요소 단위 델타 복제(Delta Replication) (0) | 2025.12.03 |
|---|---|
| PlayerController가 입력을 처리하는게 적절한가? (0) | 2025.12.03 |
| [UE5 액션] [리팩토링] CPU 성능 최적화: Tick 의존성 해소, 이벤트 기반(Event-Driven Architecture)으로의 전환 (0) | 2025.12.02 |
| [DirectX 11] Unity 리소스 메타데이터 파싱을 통한 스프라이트 시트 관리 (0) | 2025.12.02 |
| [UE5 팀 프로젝트] Pull-Request 시행착오와 교훈 (0) | 2025.12.02 |
