언리얼 C++만의 컴포지션 기법을 사용해 복잡한 언리얼 오브젝트를 효과적으로 생성하자

  • 언리얼 C++ 컴포지션 기법을 사용해 오브젝트의 포함 관계를 설계하는 방법의 학습
  • 언리얼 C++이 제공하는 확장 열거형 타입의 선언과 활용 방법의 학습 

 

 

인프런 이득우님의 '언리얼 프로그래밍 Part1 - 언리얼 C++의 이해' 강의를 참고하였습니다. 
😎 [이득우의 언리얼 프로그래밍 Part1 - 언리얼 C++의 이해] 강의 들으러 가기!

 

 

목차

     

     


     

     

    언리얼 오브젝트의 컴포지션


     

    컴포지션 ( Composition )

     

    객체 지향 프로그래밍의 설계는 상속과 컴포지션으로 나눌 수 있다.

    • 상속이란 같은 성질을 가진 부모, 자식 객체 간의 Is-A 관계만 의존해서는 설계와 유지보수가 어려움.
    • 컴포지션은 객체 지향 설계에서 Has-A 관계를 구현하는 설계 방법
    • 컴포지션의 활용
      • 복합적인 기능을 거대한 클래스를 효과적으로 설계하는데 유용하게 사용할 수 있음.


     

     

    모던 객체 설계 기법과 컴포지션

     

    S O L I D  
    Single Responsibility Principle
    단일 책임 원칙
    하나의 객체는 하나의 의무(기능)만 가지도록 설계한다.
    Open-Closed Principle
    개방 폐쇄 원칙
    기존 구현된 코드를  변경하지 않으면서 새로운 기능을 추가할 수 있도록 설계한다.
    Liskov Substitution Principle
    리스코프 치환 원칙
    자식 객체를 부모 객체로 변경해도 작동에 문제가 없을 정도로 상속을 단순하게 사용한다.
    Interface Segregation Design
    인터페이스 분리 원칙
    객체가 구현해야 할 기능이 많다면 여러 개의 단순한 인터페이스로 분리해 설계한다.
    Dependency Injection Principle
    의존성 역전의 원칙
    구현된 실물보다 구축해야 할 추상적 개념에 의존한다.

     

    모던 객체 설계 기법의 설계 핵심은 상속을 단순화하고,
    단순한 기능을 가진 다수의 객체를 조합해 복잡한 객체를 구성하는데 있음.


     

     

    언리얼 엔진에서의 컴포지션 구현 방법

     

    • 하나의 언리얼 오브젝트(UObject이하 계층)에는 항상 클래스 기본 오브젝트 CDO가 1:1로 매칭 되어있다.
    • 언리얼 오브젝트간의 컴포지션은 어떻게 구현할 것인가?
    • 언리얼 오브젝트에 다른 언리얼 오브젝트를 조합할 때 다음의 선택지가 존재한다.
      • 방법 1: CDO에 미리 언리얼 오브젝트를 생성해 조합한다. CreateDefaultSubObject() 를 사용한다.
        • 필수적 포함: 언제나 조합되어있어야 한다
        • ex. 캐릭터의 Mesh. (항상 필요한 컴포넌트 초기화)
      • 방법 2: CDO에 빈 포인터만 넣고 런타임에서 언리얼 오브젝트를 생성해 조합한다. NewObject() 를 사용한다.
        • 선택적 포함: 내가 필요할 때 조합하겠다 ⇒ 런타임에서 동적 생성하겠다.
        • ex. 특정 상황에서만 필요한 오브젝트 생성
    • 언리얼 오브젝트를 생성할 때 컴포지션 정보를 자동으로 구축할 수 있다.
      • 내가 소유한 언리얼 오브젝트를 SubObject라고 한다.
      • 나를 소유한 언리얼 오브젝트를 Outer라고 한다.

     

     

    SubObject필수적 포함인지, 아니면 선택적 포함인지 파악하고 사용하자!

     


     

     

     

     

    예시


     

     

    컴포지션 설계 예시

     

    • 학교 구성원 시스템의 설계 예시
      • 학교 구성원을 위해 출입증을 만들기로 한다.
      • 출입증은 Person에서 구현해 상속시킬 것인가? 아니면 컴포지션으로 분리할 것인가?
    • Person에서 직접 구현해서 상속시키는 경우의 문제
      • 새로운 형태의 구성원이 등장한다면(예를 들어 출입증이 없는 외부 연수생) Peson을 수정할 것인가?
      • 상위 클래스 Person을 수정하면, 하위 클래스들의 동작은 문제 없음을 보장할 수 있는가?
    • 따라서 설계적으로 출입증은 컴포지션으로 분리하는 것이 바람직하다.
    • 그렇다면 컴포지션으로만 포함시키면 모든 것이 해결되는가?

    모던 객체 지향 언어가 제공하는 고급 기법을 활용해야 한다.


     

     

    예제를 위한 클래스 다이어그램

     

    학교 구성원임을 증명하는 출입증 카드의 부여

      • 학생, 교사, 직원 모두가 상시 지니고 있음.
      • 향후 확장성을 고려해 컴포지션으로 구현함.


     

     

    예제 코드

     

    Card.h

    #pragma once
    #include "CoreMinimal.h"
    #include "UObject/NoExportTypes.h"
    #include "Card.generated.h"
    
    enum class ECardType : uint8 // 1바이트 크기의 uint8 타입으로 지정
    {
    	Student = 1	UMETA(DisplayName="For Student"),
    	Teacher		UMETA(DisplayName = "For Teacher"),
    	Staff		UMETA(DisplayName = "For Staff"),
    	Invalid 
    };
    
    UCLASS()
    class UNREALCOMPOSITION_API UCard : public UObject
    {
    	GENERATED_BODY()
    public:
    	UCard();
    	ECardType GetCardType() const { return CardType; }
    	void SetCardType(ECardType InCardType) { CardType = InCardType; }
    
    private:
    	UPROPERTY()
    	ECardType CardType;
        
    	UPROPERTY()
    	uint32 Id;	
    };

     

    Card.cpp

    #include "Card.h"
    
    UCard::UCard(){
    	CardType = ECardType::Invalid;
    	Id = 0;
    }

     

     

     

    Person.h

    #pragma once
    #include "CoreMinimal.h"
    #include "UObject/NoExportTypes.h"
    #include "Person.generated.h"
    
    UCLASS()
    class UNREALCOMPOSITION_API UPerson : public UObject
    {
    	GENERATED_BODY()	
    public:
    	UPerson();
    	FORCEINLINE const FString& GetName() const { return Name; }
    	FORCEINLINE void SetName(const FString& InName) { Name = InName; }
        
    	FORCEINLINE class UCard* GetCard() const { return Card; }
    	FORCEINLINE void SetCard(class UCard* InCard) { Card = InCard; }
    protected:
    	UPROPERTY()
    	FString Name;
    
    	UPROPERTY()
    	TObjectPtr<class UCard> Card; // 컴포지션 관계에 있을때는 헤더가 아닌 전방선언(class OO)을 하는게 좋다. 이렇게하면 헤더를 선언하는것보다 의존성을 줄일 수 있다.
    };

    UPROPERTY()
    TObjectPtr<class UCard> Card;

    • 컴포지션 관계에 있을때는 헤더가 아닌 전방선언(class UCard)을 하는게 좋다.
    • 이렇게 전방선언을 사용하면 헤더를 선언하는것보다 의존성을 줄일 수 있다.

     

     

    Person.cpp

    #include "Person.h"
    #include "Card.h"
    
    UPerson::UPerson(){
    	Name = TEXT("홍길동");
    	Card = CreateDefaultSubobject<UCard>(TEXT("NAME_Card"));
    }

     

     

    Teacher.h

    #pragma once
    #include "CoreMinimal.h"
    #include "Person.h"
    #include "LessonInterface.h"
    #include "Teacher.generated.h"
    
    UCLASS()
    class UNREALCOMPOSITION_API UTeacher : public UPerson, public ILessonInterface
    {
    	GENERATED_BODY()
    public:
    	UTeacher();
    	virtual void DoLesson() override;
    };

     

    Teacher.cpp

    #include "Teacher.h"
    #include "Card.h"
    
    UTeacher::UTeacher(){
    	Name = TEXT("이선생");
    	Card->SetCardType(ECardType::Teacher);
    }
    
    void UTeacher::DoLesson(){
    	ILessonInterface::DoLesson();
    	UE_LOG(LogTemp, Log, TEXT("%s님은 가르칩니다."), *Name);
    }

     

     

    MyGameInstance.h

    #pragma once
    #include "CoreMinimal.h"
    #include "Engine/GameInstance.h"
    #include "MyGameInstance.generated.h"
    
    UCLASS()
    class UNREALCOMPOSITION_API UMyGameInstance : public UGameInstance
    {
    	GENERATED_BODY()
    public:
    	UMyGameInstance();
    	virtual void Init() override;
        
    private:
    	UPROPERTY()
    	FString SchoolName;
    };

     

    MyGameInstance.cpp

    #include "MyGameInstance.h"
    #include "Student.h"
    #include "Teacher.h"
    #include "Staff.h"
    #include "Card.h"
    
    UMyGameInstance::UMyGameInstance(){
    	SchoolName = TEXT("기본학교");
    }
    
    void UMyGameInstance::Init()
    {
    	Super::Init();
    
    	UE_LOG(LogTemp, Log, TEXT("============================"));
    	TArray<UPerson*> Persons = { NewObject<UStudent>(), NewObject<UTeacher>(), NewObject<UStaff>() };
    	for (const auto Person : Persons)
    	{
    		const UCard* OwnCard =Person->GetCard();
    		check(OwnCard);
    		ECardType CardType = OwnCard->GetCardType();
    		//UE_LOG(LogTemp, Log, TEXT("%s님이 소유한 카드 종류 %d"), *Person->GetName(), CardType);
    
    		const UEnum* CardEnumType = FindObject<UEnum>(nullptr, TEXT("/Script/UnrealComposition.ECardType"));
    		if (CardEnumType)
    		{
    			FString CardMetaData = CardEnumType->GetDisplayNameTextByValue((int64)CardType).ToString();
    			UE_LOG(LogTemp, Log, TEXT("%s님이 소유한 카드 종류 %s"), *Person->GetName(), *CardMetaData);
    		}
    	}
    
    	UE_LOG(LogTemp, Log, TEXT("============================"));
    }

     

     

    실행화면

     


     

     

     

    참고사항 & 정리


     

     

    언리얼 공식 가이드

     

    UPROPERTY 변수 사용시 TObjectPtr이라고 하는 템플릿 클래스로 감싸서 사용해라. 

    구현부에는 원시 포인터를 사용해도 상관없다.

     

    이유는?

    • 언리얼 엔진이 더 이상 32비트를 지원하지 않아 64비트 포인터인 TObjectPtr 사용을 권장하고 있다.

     

     

    https://dev.epicgames.com/documentation/ko-kr/unreal-engine/unreal-engine-5-migration-guide?application_version=5.0

     

    언리얼 엔진 5 마이그레이션 가이드

    언리얼 엔진 4 프로젝트를 언리얼 엔진 5로 이주하는 방법 및 요구사항입니다.

    dev.epicgames.com

     

    https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Core/Delegates/TObjectPtr?application_version=5.3

     

    TObjectPtr

    [TObjectPtr](API\Runtime\Core\Delegates\TObjectPtr) is a type of pointer to a [UObject](API\Runtime\CoreUObject\UObject\UObject) that is meant to functi...

    dev.epicgames.com


     

     

    정리:  컴포지션을 활용한 언리얼 오브젝트 설계

     

    • 언리얼 C++ 은 컴포지션을 구현하는 독특한 패턴이 있다.
    • 클래스 기본 객체를 생성하는 생성자 코드를 사용해 복잡한 언리얼 오브젝트를 구축 후 한 번에 생성할 수 있음.
    • 언리얼 C++ 컴포지션의 Has-A 관계에 사용되는 용어
      • 내가 소유한 하위 오브젝트는 SubObject
      • 나를 소유한 상위 오브젝트는 Outer
    • 언리얼 C++이 제공하는 확장 열거형을 사용해 다양한 메타 정보를 넣고 활용할 수 있다.

     

    언리얼 C++의 컴포지션 기법은 
    게임의 복잡한 객체를 설계하고 생성할 때 유용하게 사용된다.