포스트

유니티 개발자를 위한 언리얼 C++ #6

상속과 다형성 - virtual의 진짜 의미

유니티 개발자를 위한 언리얼 C++ #6
TL;DR — 핵심 요약
  • C++에서 virtual은 '런타임에 실제 타입의 함수를 호출하겠다'는 선언이다 - 없으면 부모 타입의 함수가 호출된다
  • override 키워드는 C#과 달리 C++에서는 선택사항이지만, 반드시 붙여라 - 오타로 새 함수를 만드는 실수를 컴파일러가 잡아준다
  • 가상 소멸자(virtual ~ClassName)는 상속되는 클래스에 필수다 - 없으면 부모 포인터로 delete할 때 자식 소멸자가 호출되지 않아 메모리가 누수된다
  • 언리얼에서 Super::는 C#의 base.와 같다 - GENERATED_BODY() 매크로가 Super를 자동으로 typedef해준다
Visitors

이 코드, 읽을 수 있나요?

언리얼 프로젝트에서 캐릭터의 데미지 처리 코드를 열면 이런 게 나옵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// DamageableCharacter.h
UCLASS()
class MYGAME_API ADamageableCharacter : public ACharacter
{
    GENERATED_BODY()

public:
    ADamageableCharacter();

    virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent,
        AController* EventInstigator, AActor* DamageCauser) override;

protected:
    virtual void BeginPlay() override;
    virtual void OnDeath();

    UPROPERTY(EditDefaultsOnly)
    float MaxHealth = 100.f;

    float CurrentHealth;
};

// EnemyCharacter.h
UCLASS()
class MYGAME_API AEnemyCharacter : public ADamageableCharacter
{
    GENERATED_BODY()

public:
    AEnemyCharacter();

protected:
    void OnDeath() override;
    virtual void DropLoot();
};

유니티 개발자라면 이런 의문이 듭니다:

  • virtual float TakeDamage(...) override;virtualoverride가 동시에? C#에서는 둘 중 하나만 쓰는데?
  • virtual void OnDeath();= 0이 없는 virtual? 추상 메서드 아닌 건가?
  • void OnDeath() override; — 자식에서는 virtual을 안 붙여도 되나?
  • Super::BeginPlay() — 이건 어디서 오는 거지? base.와 같은 건가?

이번 강에서 C++의 상속/다형성 메커니즘을 완전히 정리합니다.


서론 - C#의 virtual과 C++의 virtual은 다르다

C#에서 상속은 편합니다. virtual을 붙이면 자식이 override할 수 있고, 안 붙여도 new로 숨길 수 있습니다. 대부분의 메서드는 virtual 없이도 잘 동작합니다.

C++에서는 virtual 하나로 완전히 다른 동작이 됩니다. virtual이 없으면 부모 포인터로 호출할 때 항상 부모의 함수가 실행됩니다. 자식의 함수가 아닙니다. 이것이 “정적 바인딩”과 “동적 바인딩”의 차이인데, 언리얼 코드를 읽으려면 이 차이를 반드시 이해해야 합니다.

flowchart TB
    subgraph CSharp["C# (Unity)"]
        direction TB
        CS1["virtual → 자식이 override 가능"]
        CS2["override → 부모 구현을 교체"]
        CS3["base.Method() → 부모 호출"]
        CS4["abstract → 순수 가상 (구현 없음)"]
        CS5["sealed → 더 이상 override 금지"]
    end

    subgraph CPP["C++ (Unreal)"]
        direction TB
        CP1["virtual → 동적 바인딩 활성화!"]
        CP2["override → 오타 방지 (선택이지만 필수급)"]
        CP3["Super::Method() → 부모 호출"]
        CP4["= 0 → 순수 가상 함수"]
        CP5["final → override/상속 금지"]
    end

1. 상속 기본 - C#과 거의 같지만 다른 점

1-1. 상속 문법 비교

1
2
3
4
5
// C++ — : public 부모클래스
class AEnemy : public ACharacter
{
    // ...
};
1
2
3
4
5
// C# — : 부모클래스
class Enemy : Character
{
    // ...
}
항목C#C++
상속 문법: BaseClass: public BaseClass
상속 접근 지정자없음 (항상 public)public / protected / private
다중 상속❌ 클래스는 단일 상속만✅ 다중 상속 가능
인터페이스 다중 구현✅ (순수 가상 클래스로 구현)
부모 호출base.Method()Super::Method() (언리얼) / Base::Method() (순정 C++)

1-2. 상속 접근 지정자 — C#에 없는 개념

1
2
3
class AEnemy : public ACharacter      // 부모의 public → public, protected → protected
class AEnemy : protected ACharacter   // 부모의 public → protected
class AEnemy : private ACharacter     // 부모의 public/protected → private

실무에서는 99.9% public 상속을 사용합니다. 언리얼에서는 public 외에 다른 상속 접근 지정자를 볼 일이 거의 없습니다. “이런 게 있구나” 정도만 알면 됩니다.

💬 잠깐, 이건 알고 가자

Q. C#에서 상속 접근 지정자가 없는 이유는?

C#은 설계 철학이 다릅니다. C#에서는 public 상속만 허용하고, 접근 제한이 필요하면 인터페이스를 명시적으로 구현합니다. C++의 protected/private 상속은 “is-a”보다 “is-implemented-in-terms-of” 관계를 표현하는데, 실무에서는 컴포지션으로 대체하는 게 낫습니다.


2. virtual과 override - 핵심 중의 핵심

2-1. virtual이 없으면 어떻게 되나?

이것이 C#과 가장 큰 차이입니다. C#에서는 virtual 없이도 자식 타입으로 호출하면 자식 함수가 실행됩니다. C++에서는 아닙니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// virtual 없는 경우 — 정적 바인딩
class ABaseWeapon
{
public:
    void Fire()  // virtual 없음!
    {
        UE_LOG(LogTemp, Display, TEXT("BaseWeapon::Fire()"));
    }
};

class ARifle : public ABaseWeapon
{
public:
    void Fire()  // 같은 이름의 함수 정의 (오버라이드가 아님!)
    {
        UE_LOG(LogTemp, Display, TEXT("Rifle::Fire()"));
    }
};

// 테스트
ARifle* Rifle = new ARifle();
Rifle->Fire();              // "Rifle::Fire()" ← 자식 타입이니까 자식 함수

ABaseWeapon* Weapon = Rifle; // 부모 포인터에 자식 객체 대입
Weapon->Fire();              // "BaseWeapon::Fire()" ← ❌ 부모 함수가 호출됨!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// virtual 있는 경우 — 동적 바인딩
class ABaseWeapon
{
public:
    virtual void Fire()  // virtual 있음!
    {
        UE_LOG(LogTemp, Display, TEXT("BaseWeapon::Fire()"));
    }
};

class ARifle : public ABaseWeapon
{
public:
    void Fire() override  // 오버라이드
    {
        UE_LOG(LogTemp, Display, TEXT("Rifle::Fire()"));
    }
};

// 테스트
ABaseWeapon* Weapon = new ARifle();
Weapon->Fire();  // "Rifle::Fire()" ← ✅ 실제 타입(ARifle)의 함수 호출!
flowchart LR
    subgraph Static["virtual 없음 (정적 바인딩)"]
        direction TB
        S1["컴파일 시점에 결정"]
        S2["포인터/참조 타입의 함수 호출"]
        S3["부모* ptr → 부모::Fire()"]
    end

    subgraph Dynamic["virtual 있음 (동적 바인딩)"]
        direction TB
        D1["런타임에 결정"]
        D2["실제 객체 타입의 함수 호출"]
        D3["부모* ptr → 자식::Fire()"]
    end

C#과 비교하면:

상황C#C++ (virtual 없음)C++ (virtual 있음)
부모 타입으로 호출자식 함수 (virtual 시)부모 함수!자식 함수
자식 타입으로 호출자식 함수자식 함수자식 함수

2-2. override 키워드

C++에서 override는 C++11에 추가된 선택사항 키워드입니다. 안 써도 컴파일됩니다. 하지만 반드시 써야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ABaseWeapon
{
public:
    virtual void Fire();
    virtual void Reload();
};

class ARifle : public ABaseWeapon
{
public:
    // ❌ override 없이 — 오타가 있어도 컴파일됨!
    void Fier()           // ⚠️ 오타! 새로운 함수가 되어버림 (경고 없음!)
    {
    }

    // ✅ override 있으면 — 오타를 컴파일러가 잡아줌
    void Fier() override  // ❌ 컴파일 에러! "Fier는 부모에 없습니다"
    {
    }

    void Fire() override  // ✅ 올바른 오버라이드
    {
    }
};
키워드C#C++필수 여부
virtual선택 (자식이 override 가능하게)선택 (동적 바인딩 활성화)C++에서 더 중요
override필수 (override 시 반드시)선택 (C++11, 하지만 강력 권장)둘 다 쓰는 게 좋음
abstract / = 0abstract= 0구현 없는 함수
sealed / finalsealedfinal추가 override 금지

💬 잠깐, 이건 알고 가자

Q. C++에서 virtualoverride를 동시에 쓸 수 있나요?

네! 언리얼 코드에서 자주 볼 수 있는 패턴입니다:

1
virtual void BeginPlay() override;  // "이 함수는 가상이고, 부모를 오버라이드한다"

virtual은 “이 함수도 자식이 다시 오버라이드할 수 있다”는 의미이고, override는 “부모의 가상 함수를 재정의한다”는 의미입니다. 실제로 C++에서 한 번 virtual이면 자식에서 virtual을 안 써도 가상 함수로 유지됩니다. 하지만 의도를 명확하게 하기 위해 언리얼에서는 둘 다 쓰는 것이 관례입니다.

Q. 자식에서 virtual을 안 붙여도 되나요?

네, 기술적으로는 한 번 virtual로 선언된 함수는 모든 자식에서 자동으로 가상 함수입니다. 하지만 언리얼 코딩 표준에서는 자식이 더 오버라이드될 수 있다면 virtual을 명시하고, 최종 구현이면 override만 쓰거나 final을 붙이는 것을 권장합니다.


3. 순수 가상 함수 - C#의 abstract

C#의 abstract 메서드와 같습니다. 구현 없이 선언만 하고, 자식이 반드시 구현해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// C++ — 순수 가상 함수 (= 0)
class ABaseWeapon
{
public:
    virtual void Fire() = 0;          // 순수 가상 함수 — 구현 없음
    virtual void Reload() = 0;
    virtual FString GetName() const = 0;
};

// ABaseWeapon 직접 생성 불가!
// ABaseWeapon* Weapon = new ABaseWeapon();  // ❌ 컴파일 에러!

class ARifle : public ABaseWeapon
{
public:
    void Fire() override { /* 라이플 발사 로직 */ }
    void Reload() override { /* 장전 로직 */ }
    FString GetName() const override { return TEXT("Rifle"); }
};

ARifle* Rifle = new ARifle();  // ✅ 모든 순수 가상 함수를 구현했으므로 생성 가능
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// C# — abstract
abstract class BaseWeapon
{
    public abstract void Fire();
    public abstract void Reload();
    public abstract string GetName();
}

class Rifle : BaseWeapon
{
    public override void Fire() { /* 발사 */ }
    public override void Reload() { /* 장전 */ }
    public override string GetName() => "Rifle";
}
항목C#C++
추상 메서드 선언abstract void Method();virtual void Method() = 0;
추상 클래스 표시abstract class순수 가상 함수가 1개라도 있으면 자동으로 추상
인스턴스 생성불가불가
클래스 키워드abstract class 필수별도 키워드 없음 (= 0이면 자동)

4. 가상 소멸자 - 반드시 알아야 하는 규칙

상속되는 클래스의 소멸자에는 반드시 virtual을 붙여야 합니다. C#에서는 GC가 있어서 걱정할 필요 없지만, C++에서는 이 규칙을 어기면 메모리가 누수됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class ABaseWeapon
{
public:
    ~ABaseWeapon()  // ❌ virtual 없음!
    {
        UE_LOG(LogTemp, Display, TEXT("BaseWeapon 소멸"));
    }
};

class ARifle : public ABaseWeapon
{
public:
    ARifle() { BulletBuffer = new uint8[1024]; }

    ~ARifle()
    {
        delete[] BulletBuffer;  // 이게 호출되지 않으면 메모리 누수!
        UE_LOG(LogTemp, Display, TEXT("Rifle 소멸"));
    }

private:
    uint8* BulletBuffer;
};

// 문제 상황
ABaseWeapon* Weapon = new ARifle();
delete Weapon;  // ❌ ~ABaseWeapon()만 호출됨! ~ARifle()은 호출 안 됨!
                // → BulletBuffer 메모리 누수!
1
2
3
4
5
6
7
8
9
10
11
12
class ABaseWeapon
{
public:
    virtual ~ABaseWeapon()  // ✅ virtual 소멸자!
    {
        UE_LOG(LogTemp, Display, TEXT("BaseWeapon 소멸"));
    }
};

// 이제 안전
ABaseWeapon* Weapon = new ARifle();
delete Weapon;  // ✅ ~ARifle() 호출 → ~ABaseWeapon() 호출 (자식 → 부모 순)
flowchart TB
    subgraph NoVirtual["virtual 소멸자 없을 때"]
        direction TB
        N1["delete BaseWeapon*"]
        N2["~BaseWeapon() 호출"]
        N3["~Rifle() 건너뜀 ❌"]
        N4["BulletBuffer 메모리 누수!"]
        N1 --> N2 --> N3 --> N4
    end

    subgraph WithVirtual["virtual 소멸자 있을 때"]
        direction TB
        V1["delete BaseWeapon*"]
        V2["~Rifle() 호출 ✅"]
        V3["BulletBuffer 해제"]
        V4["~BaseWeapon() 호출"]
        V1 --> V2 --> V3 --> V4
    end

규칙: 하나라도 virtual 함수가 있는 클래스는 소멸자도 virtual로 만들어라.

C#에서는 이 걱정이 전혀 없습니다. GC가 모든 객체를 타입에 관계없이 수거하니까요. C++만의 중요한 규칙입니다.

상황소멸자결과
부모 포인터로 delete + 비가상 소멸자~Base()자식 소멸자 ❌ → 누수
부모 포인터로 delete + 가상 소멸자virtual ~Base()자식 → 부모 순서로 ✅
자식 타입으로 delete어느 쪽이든정상 호출

💬 잠깐, 이건 알고 가자

Q. 언리얼에서 virtual ~AMyActor()를 직접 쓰나요?

거의 안 씁니다. UObject 계열 클래스는 GC가 관리하므로 소멸자를 직접 쓸 일이 없습니다. AActor, UActorComponent 등은 UObject를 상속하는데, 이들의 소멸자는 이미 virtual입니다. 개발자가 따로 신경 쓸 필요가 없습니다.

하지만 F 접두사 클래스(일반 C++ 클래스)에서 상속이 필요하면 직접 virtual 소멸자를 써야 합니다.


5. VTable - virtual 뒤에 숨겨진 메커니즘

C#에서는 런타임이 메서드 호출을 알아서 처리합니다. C++에서는 VTable(가상 함수 테이블)이라는 메커니즘이 사용됩니다. 코드에서 직접 볼 일은 없지만, 왜 virtual에 비용이 있는지 이해할 수 있습니다.

flowchart TB
    subgraph VTable["VTable 동작 원리"]
        direction TB

        subgraph Objects["객체 메모리"]
            O1["ARifle 객체\n─────────\nvptr → VTable_ARifle\nAmmo = 30\nDamage = 10.0"]
            O2["AShotgun 객체\n─────────\nvptr → VTable_AShotgun\nAmmo = 8\nDamage = 50.0"]
        end

        subgraph Tables["VTable (읽기 전용)"]
            T1["VTable_ARifle\n─────────\nFire → ARifle::Fire\nReload → ARifle::Reload\n~Dtor → ARifle::~ARifle"]
            T2["VTable_AShotgun\n─────────\nFire → AShotgun::Fire\nReload → ABaseWeapon::Reload\n~Dtor → AShotgun::~AShotgun"]
        end

        O1 -.-> T1
        O2 -.-> T2
    end

동작 과정:

  1. virtual 함수가 있는 클래스의 객체에는 vptr(가상 함수 포인터)이 숨겨져 있습니다
  2. vptr은 해당 클래스의 VTable(가상 함수 테이블)을 가리킵니다
  3. virtual 함수를 호출하면 vptr → VTable → 실제 함수 순으로 찾아갑니다
  4. 이 과정이 런타임에 일어나므로 “동적 바인딩”이라고 합니다

성능 비용:

  • 객체 크기: vptr 하나(8바이트, 64비트) 추가
  • 함수 호출: 간접 참조 1번 추가 (보통 무시할 수준)
  • 인라인 불가: 컴파일러가 virtual 함수를 인라인할 수 없음
1
2
3
4
5
// virtual이 있으면
class AWeapon { virtual void Fire(); };  // sizeof = 멤버 + 8(vptr)

// virtual이 없으면
class FWeaponData { void Fire(); };      // sizeof = 멤버만

💬 잠깐, 이건 알고 가자

Q. VTable 때문에 virtual을 아껴 써야 하나요?

게임 개발에서 virtual 함수의 오버헤드는 거의 무시할 수준입니다. 초당 수만 번 호출되는 저수준 연산(수학 계산, 물리 시뮬레이션)이 아닌 이상, virtual 비용은 신경 쓸 필요 없습니다. 언리얼의 Tick(), BeginPlay() 등이 모두 virtual인 이유도 이 때문입니다.

다만 데이터 지향 설계(DOD)가 필요한 극한 최적화 상황에서는 virtual을 피하기도 합니다. 이건 매우 드문 경우입니다.


6. final - 더 이상의 상속/오버라이드 금지

C#의 sealed와 같은 역할입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// final 클래스 — 더 이상 상속 불가
class APlayerCharacter final : public ACharacter
{
    // ...
};

// class ASuperPlayer : public APlayerCharacter { };  // ❌ 컴파일 에러!

// final 함수 — 더 이상 오버라이드 불가
class ABaseEnemy : public ACharacter
{
public:
    virtual void Attack();
    virtual void Die() final;  // 자식에서 오버라이드 금지
};

class ABossEnemy : public ABaseEnemy
{
public:
    void Attack() override;    // ✅ OK
    // void Die() override;    // ❌ 컴파일 에러! final 함수
};
C#C++의미
sealed classclass Name final클래스 상속 금지
sealed override void Method()void Method() override final함수 오버라이드 금지

7. 인터페이스 - 순수 가상 클래스로 구현

C#에는 interface 키워드가 있지만, C++에는 없습니다. 대신 모든 멤버가 순수 가상 함수인 클래스를 인터페이스처럼 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// C++ 인터페이스 패턴 — 순수 가상 클래스
class IDamageable
{
public:
    virtual ~IDamageable() = default;  // 가상 소멸자
    virtual void TakeDamage(float Damage) = 0;
    virtual float GetHealth() const = 0;
    virtual bool IsDead() const = 0;
};

class IInteractable
{
public:
    virtual ~IInteractable() = default;
    virtual void Interact(AActor* Instigator) = 0;
    virtual FString GetInteractionText() const = 0;
};

// 다중 구현 (C#의 다중 인터페이스 구현과 동일)
class AEnemyActor : public AActor, public IDamageable, public IInteractable
{
public:
    // IDamageable 구현
    void TakeDamage(float Damage) override { /* ... */ }
    float GetHealth() const override { return Health; }
    bool IsDead() const override { return Health <= 0; }

    // IInteractable 구현
    void Interact(AActor* Instigator) override { /* ... */ }
    FString GetInteractionText() const override { return TEXT("적 조사하기"); }

private:
    float Health = 100.f;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// C# — 인터페이스
interface IDamageable
{
    void TakeDamage(float damage);
    float GetHealth();
    bool IsDead();
}

interface IInteractable
{
    void Interact(GameObject instigator);
    string GetInteractionText();
}

class EnemyActor : MonoBehaviour, IDamageable, IInteractable
{
    // 구현...
}

언리얼 인터페이스는 조금 특별합니다. 언리얼 리플렉션 시스템과 통합하기 위해 UINTERFACE 매크로를 사용합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 언리얼 인터페이스 선언 (7강에서 자세히 다룸)
UINTERFACE(MinimalAPI)
class UDamageable : public UInterface
{
    GENERATED_BODY()
};

class IDamageable
{
    GENERATED_BODY()

public:
    virtual void TakeDamage(float Damage) = 0;
};

// 사용
UCLASS()
class AEnemy : public AActor, public IDamageable
{
    GENERATED_BODY()

public:
    void TakeDamage(float Damage) override;
};

// 인터페이스 체크
if (OtherActor->GetClass()->ImplementsInterface(UDamageable::StaticClass()))
{
    IDamageable* Damageable = Cast<IDamageable>(OtherActor);
    Damageable->TakeDamage(10.f);
}
항목C#C++ (순정)C++ (언리얼)
키워드interface없음 (순수 가상 클래스)UINTERFACE 매크로
다중 구현
멤버 변수❌ 불가 (C# 8.0 전)가능 (하지만 안 씀)불가 (I 클래스에)
네이밍INameIName (관례)UName + IName (쌍으로)

8. 언리얼 실전 코드 해부 - Super::와 상속 패턴

8-1. Super:: — C#의 base.

언리얼에서 SuperGENERATED_BODY() 매크로가 자동으로 만들어주는 부모 클래스의 typedef입니다.

1
2
3
4
5
6
7
8
9
10
11
12
// ACharacter를 상속하면
class AMyCharacter : public ACharacter
{
    GENERATED_BODY()  // 이 매크로 안에: typedef ACharacter Super;
};

// 그래서 Super::는 ACharacter::와 같음
void AMyCharacter::BeginPlay()
{
    Super::BeginPlay();  // = ACharacter::BeginPlay();
    // C#의 base.BeginPlay()와 동일한 역할
}
1
2
3
4
5
// C# 비교
protected override void Awake()
{
    base.Awake();  // 부모의 Awake 호출
}
C#C++ (언리얼)C++ (순정)
base.Method()Super::Method()ParentClass::Method()

언리얼에서 반드시 Super:: 호출해야 하는 함수들:

  • BeginPlay() — 부모의 초기화 로직 실행
  • Tick() — 보통 호출하지만, 의도적으로 생략할 수도 있음
  • EndPlay() — 부모의 정리 로직 실행
  • TakeDamage() — 부모의 데미지 처리 로직
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ Super:: 깜빡하면 부모 기능 동작 안 함
void AMyCharacter::BeginPlay()
{
    // Super::BeginPlay() 없음!
    CurrentHealth = MaxHealth;
    // 부모(ACharacter)의 BeginPlay 로직이 실행 안 됨 → 버그!
}

// ✅ 올바른 패턴
void AMyCharacter::BeginPlay()
{
    Super::BeginPlay();        // 항상 먼저 부모 호출!
    CurrentHealth = MaxHealth;
}

8-2. 맨 처음 코드 다시 분석

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// DamageableCharacter.h
UCLASS()
class MYGAME_API ADamageableCharacter : public ACharacter  // ① ACharacter 상속
{
    GENERATED_BODY()  // ② Super = ACharacter (자동 typedef)

public:
    ADamageableCharacter();

    // ③ virtual + override: 부모(AActor)의 TakeDamage를 재정의하면서, 자식도 재정의 가능
    virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent,
        AController* EventInstigator, AActor* DamageCauser) override;

protected:
    // ④ virtual + override: ACharacter의 BeginPlay 재정의, 자식도 재정의 가능
    virtual void BeginPlay() override;

    // ⑤ virtual만: 새로운 가상 함수 (부모에 없음, = 0 아님 → 기본 구현 있음)
    virtual void OnDeath();

    UPROPERTY(EditDefaultsOnly)
    float MaxHealth = 100.f;
    float CurrentHealth;
};

// EnemyCharacter.h
UCLASS()
class MYGAME_API AEnemyCharacter : public ADamageableCharacter  // ⑥ 2단계 상속
{
    GENERATED_BODY()  // Super = ADamageableCharacter

public:
    AEnemyCharacter();

protected:
    // ⑦ override만: ADamageableCharacter의 OnDeath 재정의
    //    virtual을 안 쓴 건 "더 이상 자식이 override할 필요 없다"는 의도
    void OnDeath() override;

    // ⑧ 새로운 virtual 함수
    virtual void DropLoot();
};
번호패턴의미
virtual ... override부모 함수 재정의 + 자식도 재정의 가능
virtual void OnDeath()새로운 가상 함수 정의 (기본 구현 있음)
void OnDeath() override부모의 가상 함수를 재정의 (virtual 생략)
virtual void DropLoot()이 클래스에서 시작하는 새 가상 함수

9. 흔한 실수 & 주의사항

실수 1: virtual 없이 오버라이드 시도

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ABaseEnemy : public ACharacter
{
public:
    void OnHit(float Damage)  // ❌ virtual 없음!
    {
        Health -= Damage;
    }
};

class ABossEnemy : public ABaseEnemy
{
public:
    void OnHit(float Damage)  // 오버라이드가 아닌 "숨기기"!
    {
        Health -= Damage * 0.5f;  // 보스는 데미지 50% 감소
    }
};

ABaseEnemy* Enemy = new ABossEnemy();
Enemy->OnHit(100);  // ABaseEnemy::OnHit 호출 → 데미지 감소 안 됨!

해결: 부모 함수에 virtual을 붙이고, 자식에 override를 붙이세요.

실수 2: override 오타를 모르고 넘어감

1
2
3
4
5
6
7
class AMyCharacter : public ACharacter
{
    virtual void BeginPlay() override;  // ✅

    virtual void beginPlay() override;  // ❌ 컴파일 에러 (소문자 b)
    virtual void BeginPlay(int) override;  // ❌ 컴파일 에러 (파라미터 다름)
};

override 키워드가 없었다면 위 두 경우 모두 새로운 함수로 생성되어, 왜 BeginPlay가 호출 안 되는지 한참 디버깅했을 것입니다.

실수 3: 가상 소멸자 누락

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 위험한 코드
class FWeaponBase
{
public:
    ~FWeaponBase() {}  // virtual 없음!
};

class FRifle : public FWeaponBase
{
public:
    ~FRifle() { delete[] BulletData; }
    uint8* BulletData = new uint8[256];
};

FWeaponBase* Weapon = new FRifle();
delete Weapon;  // ~FRifle() 호출 안 됨 → BulletData 누수!

규칙: virtual 함수가 하나라도 있거나, 상속될 예정이면 → virtual ~ClassName()

실수 4: Super:: 호출 누락

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void AMyCharacter::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    // ❌ Super::EndPlay() 안 함!
    // 부모의 정리 로직이 실행되지 않아 리소스 누수 가능

    CleanupWeapon();
}

// ✅ 올바른 패턴
void AMyCharacter::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    CleanupWeapon();
    Super::EndPlay(EndPlayReason);  // 마지막에 부모 호출 (EndPlay는 보통 마지막)
}

패턴:

  • BeginPlay()Super:: 먼저, 내 로직 나중에
  • EndPlay() → 내 정리 먼저, Super:: 나중에
  • Tick() → 상황에 따라 다름 (보통 Super:: 먼저)

정리 - 6강 체크리스트

이 강을 마치면 언리얼 코드에서 다음을 읽을 수 있어야 합니다:

  • virtual void Method()이 “동적 바인딩을 활성화한다”는 것을 안다
  • virtual 없이 함수를 재정의하면 부모 포인터에서 부모 함수가 호출됨을 안다
  • override 키워드가 오타/시그니처 실수를 방지하는 역할임을 안다
  • virtual void Method() = 0;이 C#의 abstract와 같음을 안다
  • virtual ~ClassName()이 왜 상속 클래스에 필수인지 안다
  • VTable이 무엇이고 virtual의 성능 비용이 무시할 수준임을 안다
  • final이 C#의 sealed와 같은 역할임을 안다
  • C++에서 인터페이스가 순수 가상 클래스로 구현됨을 안다
  • Super::Method()가 C#의 base.Method()와 같음을 안다
  • SuperGENERATED_BODY() 매크로에 의해 자동으로 typedef됨을 안다
  • BeginPlay에서는 Super:: 먼저, EndPlay에서는 내 로직 먼저 패턴을 안다
  • virtual ... override를 동시에 쓰는 언리얼 패턴을 읽을 수 있다

다음 강 미리보기

7강: 언리얼 매크로의 마법 - UCLASS, UPROPERTY, UFUNCTION

언리얼 코드에서 가장 많이 보이는 UCLASS(), UPROPERTY(), UFUNCTION(). 이것들은 C++ 표준이 아닌 언리얼만의 매크로입니다. 이 매크로들이 없으면 GC도, 에디터 노출도, 블루프린트 연동도 안 됩니다. GENERATED_BODY()가 뭘 하는지, UPROPERTY(EditAnywhere)UPROPERTY(VisibleAnywhere)의 차이는 뭔지, 리플렉션 시스템이 왜 필요한지 다룹니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.