해당 포스팅은 Stuart Butler , Tom Oliver의 Game Development Patterns with Unreal Engine 5를 바탕으로 작성한 내용입니다.
언리얼 엔진과 객체지향 프로그래밍
언리얼 엔진은 기본적으로 C++ 기반의 객체지향 구조 위에서 동작한다. 특히 UObject 기반의 주요 클래스들은 본질적으로 객체지향 철학을 기반으로 설계되어 있으며, 이를 어떻게 이해하고 적용하느냐에 따라 프로젝트의 구조와 유지보수성이 크게 달라진다.
그중에서도 SOLID 원칙은 언리얼 프로젝트를 보다 견고하고 확장이 편하도록 만들어준다. 대규모 프로젝트일수록 “해도 돌아가긴 하지만 유지보수가 불가능한 코드”가 늘어나기 쉬운데, 이를 방지하는 데 SOLID는 매우 효과적이다.
SOLID 원칙을 언리얼 엔진 개발 관점에서 하나씩 살펴보도록 하자.
SOLID 원칙
SOLID 원칙은 기본적으로 다음과 같다.
- S : Single Responsibility(단일 책임)
- O : Open-Closed(개방-폐쇄)
- L : Likov Substitution(리스코프 치환)
- I : Interface Segregation(인터페이스 분리)
- D : Dependency Inversion(의존성 역전)
S : Single Responsibility(단일 책임)
“하나의 클래스는 오직 하나의 책임만 가져야 한다.”
즉, 클래스가 여러 이유로 변경되면 안 된다.
무언가를 관리하는 클래스가 많을수록, 변경할 때 고려해야 하는 점이 늘어나고 버그가 발생하기 쉽다. 언리얼 엔진에서도 이 원칙이 매우 중요하다. 특히 Actor 클래스를 만들다 보면 수십~수백 줄의 기능이 한 클래스, 한 함수에 몰아 넣어지는 경우가 흔하다.
❌ Single Responsibility가 적절히 지켜지지 않은 예시
// Death 처리에 과도한 책임이 몰린 예시
void AEnemy::HandleDeath()
{
// 1. 애니메이션 재생
PlayDeathAnimation();
// 2. 사운드 재생
UGameplayStatics::PlaySoundAtLocation(this, DeathSound, GetActorLocation());
// 3. 아이템 드랍
DropLootItems();
// 4. UI 비활성화 (HPBar 등)
if (HpBarWidget)
{
HpBarWidget->SetVisibility(ESlateVisibility::Hidden);
}
// 5. AI Behavior Tree 정지
if (AIController)
{
AIController->StopMovement();
AIController->BrainComponent->StopLogic("Dead");
}
// 6. 물리 충돌 비활성화
SetActorEnableCollision(false);
// 7. 일정 시간 뒤 Destroy
SetLifeSpan(3.0f);
}위와 같은 예시에서는 HandleDeath() 안에
- 애니메이션 처리
- 사운드 재생
- 아이템 드랍
- UI 처리
- AI 정지
- 충돌 처리
- Destroy 관리
등 Death와 관련된 모든 책임이 하나의 함수에 몰려 있다.
이렇게 되면 한 기능이라도 수정하려고 할 때 함수 전체에 영향을 주게 되고, Death 로직이 커질수록 관리가 매우 어려워진다. 특히 “레어 몬스터는 아이템 드랍을 다르게 해야 한다”, “보스는 Death 카메라 연출이 추가로 있다” 같은 요구가 생기면 이 함수는 점점 거대해지며 Single Responsibility를 위반하게 된다.
✔️ 올바른 예시 — Death 기능을 책임별로 분리한 구조
void AEnemy::HandleDeath()
{
DeathAnimationComp->PlayDeathAnimation();
DeathSoundComp->PlayDeathSound();
LootDropComp->DropItems();
UIComp->HideEnemyUI();
AIComp->StopAI();
CollisionComp->DisableCollision();
LifeSpanComp->ScheduleDestroy();
// Actor는 “Death 발생”만 알리고, 구체 로직은 각 컴포넌트가 처리함
}Actor는 단지 “죽었다”는 이벤트를 컴포넌트에 전달한다. 각 기능의 책임은 독립적으로 나뉘어져 있어 Death 로직이 커져도 Actor는 수정할 필요가 없다.
O : Open-Closed(개방-폐쇄)
객체 또는 엔티티는 확장에는 개방되어 있어야 하지만, 수정에는 닫혀 있어야 한다.
즉 기존 코드를 변경하지 않고도 새로운 기능을 추가할 수 있는 구조를 만드는 것이 핵심이다.
게임 개발에서는 기능이 자주 변경되고 확장되기 때문에 OCP는 특히 중요하다. 초기 구조가 OCP를 고려하지 않고 만들어졌다면, 나중에 기능을 추가할 때 “강제로 기존 코드를 뜯어고쳐야 하는 구조”가 되어 버리기 쉽다.
❌ Open-Closed가 적절히 지켜지지 않은 예시
// 예: Enemy 타입에 따라 다른 공격 방식을 처리하는 코드
void AEnemy::Attack()
{
if (EnemyType == EEnemyType::Melee)
{
PerformMeleeAttack();
}
else if (EnemyType == EEnemyType::Ranged)
{
PerformRangedAttack();
}
else if (EnemyType == EEnemyType::Magic)
{
PerformMagicAttack();
}
}위와 같은 상황에서는
- 새로운 공격 타입이 추가될 때마다 Attack()을 계속 수정해야 함
- 조건문이 계속 늘어나며 유지보수 악화
- Enemy 클래스를 직접 수정해야 하므로 위험성이 증가
즉, 수정이 필요하기 때문에 Open-Closed를 위반하는 구조이다.
✔️ 올바른 예시 — Strategy 패턴 활용
// Base Class
UCLASS()
class UAttackStrategy : public UObject
{
GENERATED_BODY()
public:
virtual void ExecuteAttack(AEnemy* Owner) {}
};// 근접 공격 클래스
class UMeleeAttackStrategy : public UAttackStrategy
{
public:
virtual void ExecuteAttack(AEnemy* Owner) override;
};// 원거리 공격 클래스
class URangedAttackStrategy : public UAttackStrategy
{
public:
virtual void ExecuteAttack(AEnemy* Owner) override;
};Enemy 클래스는 전략만 호출한다.
void AEnemy::Attack()
{
if (AttackStrategy)
{
AttackStrategy->ExecuteAttack(this);
}
}장점
- 새로운 공격 방식 추가 시 UAttackStrategy 상속받아 구현만 하면 됨
- Enemy 코드는 절대 수정할 필요 없음 → 수정에는 닫혀 있음
- 기능 확장(공격 방식 추가)은 용이 → 확장에 열려 있음
L : Likov Substitution(리스코프 치환)
“자식 클래스는 부모 클래스로서 완전히 대체 가능해야 한다.”
즉, 상속 구조에서 부모 타입을 사용하는 곳에 자식 타입을 넣어도 의도한 대로 정상 동작해야 한다는 의미이다.
상속의 목적은 공통 기능을 재사용하거나 확장하기 위한 것인데, 자식이 부모의 규칙을 깨버리면 전체 시스템이 예측 불가능해지고 버그가 발생한다.
❌ Likov Substitution이 적절히 지켜지지 않은 예시
UCLASS()
class AEnemy : public ACharacter
{
GENERATED_BODY()
public:
virtual void Move()
{
UE_LOG(LogTemp, Warning, TEXT("Enemy is moving normally"));
}
};UCLASS()
class ABossEnemy : public AEnemy
{
GENERATED_BODY()
public:
virtual void Move() override
{
// 이동 기능을 아예 제거하거나 무시함
// 아무것도 하지 않음
}
};위와 같은 상황에서는
- Move()가 부모에서는 반드시 “움직이는 행동”을 의미해야 하는데
자식에서 “아무 행동도 하지 않는 것”으로 바뀌어버림.
- AEnemy 타입으로 받아서 Move()를 호출했는데 보스는 움직이지 않아
→ 상속 기반 다형성이 깨짐
- 부모 객체를 기대하고 만든 모든 시스템에서 예측 불가한 동작을 초래
즉, 부모 타입으로서 대체 가능하지 않기 때문에 Likov Substitution 위반이다.
✔️ 언리얼에서 LSP를 지키기 위한 설계 원칙
1. “동작 자체가 바뀔 가능성”이 있는 경우 → 상속보다 구성(Composition)을 사용
2. 상속보다 Interface로 다형성을 설계
I : Interface Segregation(인터페이스 분리)
“필요하지 않은 기능을 억지로 구현하게 만들지 말자.”
“인터페이스는 ‘딱 필요한 기능만’ 모아두는 것이 좋다.”
즉, 너무 많은 기능을 한 인터페이스에 몰아넣지 말고 클라이언트(구현체)가 실제로 필요한 기능만 포함한 작은 인터페이스들로 쪼개야 한다는 원칙.
❌ Interface Segregation이 적절히 지켜지지 않은 예시
//InteractiveInterface에 모든 상호작용 관련 기능을 한 번에 넣어버리는 경우:
UINTERFACE(MinimalAPI)
class UInteractive : public UInterface
{
GENERATED_BODY()
};
class IInteractive
{
GENERATED_BODY()
public:
virtual void OnInteract(AActor* Instigator) = 0;
virtual void OnPickup(AActor* Instigator) = 0;
virtual void OnOpenUI(AActor* Instigator) = 0;
};위와 같은 상황에는
- 문만 열리는 Door도 OnPickup()과 OnOpenUI()를 구현해야 함
- 아이템도 OnOpenUI()처럼 필요 없는 기능을 억지로 구현
- UI 오브젝트도 OnPickup() 같은 현실과 맞지 않는 기능 구현
결국 인터페이스가 너무 비대해지고, 구현체가 필요 없는 함수 의존이 발생한다.
✔️ 언리얼에서 ISP를 지키기 위한 설계 원칙
기능을 작게 나누어진 인터페이스로 분리한다.
D : Dependency Inversion(의존성 역전)
“상위 모듈은 하위 구현에 의존하면 안 되고, 둘 다 추상화(인터페이스)에 의존해야 한다.”
즉, 구체적인 클래스가 아니라 ‘역할(인터페이스)’에 의존하라는 원칙.
간단히 말해, 클래스에 대한 레퍼런스를 주입할 때는 필요한 기능을 갖춘 부모 중 최상위 추상 부모에 주입해야 한다. 쉬운 예로 PlayerController를 둘 수 있다. 특정한 폰 서브클래스에 참조하는 것이 아니라 폰이라는 개념을 참조하는 것이다. 물론 더 효율적인 방법 중 하나는 필요한 기능에 대한 인터페이스를 참조하는 것이다. 이 방식은 cast 체인을 사용하여 타입을 필터링하지 않아도 된다.
❌ Dependency Inversion이 적절히 지켜지지 않은 예시
// Player → Enemy 라는 구체 타입에 직접 의존하는 경우
class Player
{
public:
void AttackEnemy(Enemy* Target) // 직접 Enemy에 의존
{
Target->TakeDamage(20);
}
};위와 같은 상황에는
- Player는 Enemy라는 "구체적인 구현"을 직접 알고 있음
- 만약 Enemy 말고 Boss, Robot, NPC 등 다양한 공격 대상이 생기면?
→ Player에 코드가 계속 수정되어야 함 (확장성 ↓)
- 모듈 간 결합도가 매우 높아짐
✔️올바른 구조
Player와 Enemy 모두 "공격 받을 수 있는 대상"이라는 추상화에 의존
// 1) 인터페이스 정의
UINTERFACE()
class UDamageable : public UInterface
{
GENERATED_BODY()
};
class IDamageable
{
GENERATED_BODY()
public:
virtual void TakeDamage(int Damage) = 0;
};// 2) Enemy가 인터페이스를 구현
class AEnemy : public AActor, public IDamageable
{
public:
virtual void TakeDamage(int Damage) override;
};// 3) Player는 인터페이스에만 의존
class APlayer : public AActor
{
public:
void Attack(IDamageable* Target)
{
Target->TakeDamage(20); // Enemy인지 Boss인지 모름 (의존성 역전 성공)
}
};장점
- Player는 더 이상 Enemy라는 구체 객체를 몰라도 됨
- 새로운 공격 대상(Animal, Robot, Boss 등) 추가 시 Player 수정 ❌
- 상위 모듈(Player)과 하위 모듈(Enemy)의 방향이 “인터페이스”로 모임