Unreal: 인터페이스(Interface) & 델리게이트(Delegates)
Unreal: 인터페이스(Interface) & 델리게이트(Delegates)
인터페이스(Interface)
- 인터페이스는 특정 클래스의 부모가 무엇인지와 관계없이, 특정한 기능을 수행할 수 있는지를 확인하고 호출하기 위한 약속이다. 이는 클래스가 특정한 기능을 가지고 있고 구현했음을 강제하여 클래스 대신 인터페이스만 알더라도 기능을 호출할 수 있도록 한다.
언어마다 형태는 다르지만, C++는 인터페이스 기능을 별도로 지원하지 않아 추상 클래스를 만들어 다중 상속하는 형식으로 대신한다. 언리얼은 C++을 사용은 하지만 인터페이스 기능을 추가적으로 제공한다. 다만 언리얼 리플렉션에 반영되기 위해 그 구조는 다른 프로그래밍 언어의 인터페이스 사용법과는 약간 다르다. 언리얼의 UObject는 다중상속을 지원하지 않기에 추상 클래스 다중 상속 같은 방법을 사용할 수 없다. 대신 인터페이스 클래스를 별도로 제공하고, 이를 하나의 클래스에서 여러개 상속하여 사용할 수 있다.
선언
- 언리얼의 인터페이스는
UInterface를 상속하는 U 접두사 클래스와, 실제 함수를 선언하는 I 접두사 클래스 두 개로 만든다. U 접두사 클래스는 리플렉션과 Blueprint 노출을 위한 껍데기이고, 실제 C++ 상속과 구현은 I 접두사 클래스를 사용한다.
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
// Interactable.h #pragma once #include "CoreMinimal.h" #include "UObject/Interface.h" #include "Interactable.generated.h" //변경하지 않는 인터페이스 자료형. 언리얼 리플렉션에 등록하기 위해 형식적으로 존재하는 클래스이다. //블루프린트에 노출하기위해선 BlueprintType을, 구현까지 하기 위해선 Blueprintable를 사용해야 한다. //처음 기본으로 생성한다면 MinimalAPI 키워드만 들어가있을 것이다. UINTERFACE(BlueprintType, Blueprintable) class UInteractable : public UInterface { GENERATED_BODY() }; //실제로 구현하는 내용은 여기에 들어간다. class IInteractable { GENERATED_BODY() public: UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interaction") void Interact(AActor* Interactor); //구현할 함수 선언 };
BlueprintNativeEvent는 C++ 기본 구현과 Blueprint 재정의를 모두 허용한다. C++ 구현 함수 이름은Interact()가 아닌Interact_Implementation()이 된다. Blueprint에서만 구현하게 하고 싶다면BlueprintImplementableEvent를 사용한다.
1 2 3 4 5 6 7 8 9 10
// Door.h UCLASS() class ADoor : public AActor, public IInteractable { GENERATED_BODY() public: //인터페이스 상속 함수 구현 virtual void Interact_Implementation(AActor* Interactor) override; };
1 2 3 4 5
// Door.cpp void ADoor::Interact_Implementation(AActor* Interactor) { // 문 열기, 애니메이션 재생 등의 처리 }
- 언리얼의 인터페이스는
호출 방법
- 대상이 인터페이스를 구현하는지 먼저 확인한다.
BlueprintNativeEvent,BlueprintImplementableEvent는 Blueprint 구현도 존재할 수 있으므로IInteractable::Execute_Interact()로 호출하는 편이 안전하다.
1 2 3 4 5 6 7 8 9 10 11 12
//인터페이스 함수 호출 예시 void AMyCharacter::TryInteract(AActor* Target) { if (!IsValid(Target)) return; //인터페이스 구현했는지 확인 if (Target->Implements<UInteractable>()) { //인터페이스에 달린 정적 함수에 해당 객체와 호출자인 이 객체를 함께 넘겨서 대리 실행 IInteractable::Execute_Interact(Target, this); } }
Cast<IInteractable>()는 C++에서 인터페이스를 직접 구현한 객체를 얻을 때 사용할 수 있다. 다만 Blueprint만으로 인터페이스를 구현한 경우까지 처리하려면Implements<UInteractable>()와Execute_호출을 사용해야 한다.
- 대상이 인터페이스를 구현하는지 먼저 확인한다.
Blueprint 구현을 포함한 안전 호출 상세
- UINTERFACE() 매크로에 Blueprintable 키워드를 추가하면 블루프린트에서 인터페이스를 구현할 수 있다.
- C++에서는
Target->Implements<UInteractable>()를 사용할 수 있고, Blueprint Function Library 방식으로는UKismetSystemLibrary::DoesImplementInterface()를 사용할 수 있다. 두 방식 모두 Blueprint 구현을 확인할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
#include "Kismet/KismetSystemLibrary.h" void AMyTorchlight::NotifyFireDetected() { for (const TWeakObjectPtr<AActor>& Item : Items) { AActor* Target = Item.Get(); if (!IsValid(Target)) continue; //UKismetSystemLibrary를 이용한 Interface 구현 여부 검사 if (UKismetSystemLibrary::DoesImplementInterface(Target, UTestMyInterface::StaticClass())) { //인터페이스 함수 실행 ITestMyInterface::Execute_OnFireDetected(Target, 100.0f, FVector::ZeroVector); } } }
Execute_OnFireDetected()같은 함수는 인터페이스 함수가BlueprintNativeEvent또는BlueprintImplementableEvent로 선언되어야 UHT가 생성한다. 이때BlueprintNativeEvent는 호출 시 Blueprint 재정의가 있는지 먼저 확인하고, 없다면 C++의_Implementation()을 호출한다.
1 2 3 4 5 6 7 8 9 10 11 12
class ITestMyInterface { GENERATED_BODY() public: //BlueprintNativeEvent로 선언된 인터페이스 함수 UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category = "Interaction") void OnFireDetected(float Temperature, FVector HitLocation); }; // 구현 클래스의 헤더에선 이렇게 선언 후 실행 virtual void OnFireDetected_Implementation(float Temperature, FVector HitLocation) override;
BlueprintNativeEvent함수 선언 자체에는virtual을 붙일 수 없다. 대신 UHT가 생성하는 호출 경로를 사용하고, C++ 기본 동작은_Implementation()에virtual로 구현한다.OnFireDetected()예시에서 본문을 직접 정의하면 UHT 생성 코드와 충돌한다.Execute_호출은 객체가 유효하고 인터페이스를 구현한다는 전제가 필요하다. 내부 검사만 믿고 잘못된 객체를 넘기면 assert 또는 crash로 이어질 수 있다.- UObject를 받기에 객체의 유효성을 보장 못함.
- Private 선언과 관계 없이 외부에서도 호출 가능함.
- 이미 해제된 메모리거나 nullptr이면 치명적인 버그/크래시 가능.
Execute_OnFireDetected()에도 체크용 로직은 존재하지만 크래시를 낼 수 있기에UKismetSystemLibrary::DoesImplementInterface()혹은Implements<>()로 더블체크 해주도록 하자.
사용 상황
- 상호작용 가능한 문, 아이템, NPC, 레버
- 데미지를 받을 수 있는 액터, 팀 판별이 가능한 액터
- 서로 다른 Actor Component가 같은 기능을 제공해야 하는 경우
- 의존성을 줄이고 싶은 경우. Character가
ADoor,AChest같은 구체적인 타입을 직접 알 필요가 없어진다.
델리게이트(Delegate)
델리게이트는 한 객체에서 발생한 일을 다른 객체에 알리기 위한 콜백 구조다. 인터페이스가 “이 기능을 실행할 수 있는가?”를 묻는 구조라면, 델리게이트는 “이 일이 발생했다”를 구독자에게 알리는 구조다. 체력 변경을 HUD에 반영하거나, 게임 상태 변경에 여러 UI가 반응하게 만들 때 유용하다.
종류
- Single Delegate - 하나의 함수만 연결한다. 반환값을 받을 수 있다.
- Multicast Delegate - 여러 함수를 연결하고 한 번에 호출한다. 반환값은 사용할 수 없다.
- Dynamic Delegate - 리플렉션을 사용한다. Blueprint 연결,
UPROPERTY, 직렬화가 필요한 경우 사용한다. 일반 델리게이트보다 호출 비용이 크다. - UI와 Blueprint에 이벤트를 노출해야 하는 경우에는
Dynamic Multicast Delegate가 자주 쓰인다.
선언과 호출
- 예시) 체력 컴포넌트에서 체력이 변할 때마다 이벤트 발행
Broadcast()가 호출되면 연결된 모든 함수가 실행된다. 구독자가 없어도 Multicast Delegate의Broadcast()는 안전하게 호출할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// HealthComponent.h //델리게이트 타입의 선언, float타입의 NewHealth를 매개변수로 호출될 때 같이 전달한다. DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChanged, float, NewHealth); UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent)) class UHealthComponent : public UActorComponent { GENERATED_BODY() public: //미리 선언한 델리게이트 타입을 이용한 델리게이트 변수 선언 UPROPERTY(BlueprintAssignable, Category = "Health") FOnHealthChanged OnHealthChanged; void SetHealth(float NewHealth); private: float Health = 100.0f; };
1 2 3 4 5 6 7
// HealthComponent.cpp // 델리게이트 호출 예시, Health 값을 전달하며 델리게이트를 호출, 바인드 되어있는 함수들이 실행된다. void UHealthComponent::SetHealth(float NewHealth) { Health = FMath::Clamp(NewHealth, 0.0f, 100.0f); OnHealthChanged.Broadcast(Health); }
- 예시) 체력 컴포넌트에서 체력이 변할 때마다 이벤트 발행
바인딩과 해제
- Dynamic Delegate에 연결하는 함수는
UFUNCTION()이어야 하며, 파라미터 타입과 개수가 델리게이트 선언과 같아야 한다. Widget처럼 수명이 짧은 객체는 제거될 때 바인딩도 해제해야 중복 호출과 유효하지 않은 객체 참조를 막을 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
// MyHealthWidget.h UCLASS() class UMyHealthWidget : public UUserWidget { GENERATED_BODY() public: //바인드할 예시 함수 UFUNCTION() void UpdateHealth(float NewHealth); //바인딩 구현 함수 void BindHealthComponent(UHealthComponent* InHealthComponent); protected: virtual void NativeDestruct() override; private: UPROPERTY() TObjectPtr<UHealthComponent> HealthComponent; };
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
// MyHealthWidget.cpp // 함수를 델리게이트에 바인드한다. void UMyHealthWidget::BindHealthComponent(UHealthComponent* InHealthComponent) { if (HealthComponent) HealthComponent->OnHealthChanged.RemoveDynamic(this, &UMyHealthWidget::UpdateHealth); HealthComponent = InHealthComponent; if (HealthComponent) HealthComponent->OnHealthChanged.AddUniqueDynamic(this, &UMyHealthWidget::UpdateHealth); } //함수를 델리게이트에서 언바인드한다. void UMyHealthWidget::NativeDestruct() { if (HealthComponent) HealthComponent->OnHealthChanged.RemoveDynamic(this, &UMyHealthWidget::UpdateHealth); Super::NativeDestruct(); }
- Dynamic Delegate에 연결하는 함수는
인터페이스와 델리게이트 선택
- Character가 대상에게 직접 “상호작용해라”라고 요청한다면 인터페이스가 맞다.
- 체력이 변했음을 HUD, 사운드, 퀘스트 시스템처럼 여러 곳에 알려야 한다면 Multicast Delegate가 맞다.
- 호출 대상이 하나이고 반드시 응답해야 한다면 일반 함수 또는 Single Delegate도 고려할 수 있다.
- 델리게이트는 호출 순서와 연결 상태에 의존한다. 객체가 파괴되거나 UI가 다시 생성되는 시점에는
RemoveDynamic,RemoveAll등으로 수명을 관리해야 한다.
This post is licensed under CC BY 4.0 by the author.