초고교급 희망

[8강] 언리얼 C++ 설계 2 - 컴포지션 본문

Game/Unreal

[8강] 언리얼 C++ 설계 2 - 컴포지션

연모링 2023. 12. 27. 01:37
728x90

인프런에서 이득우의 언리얼 프로그래밍 Part 1을 수강하고 작성한 글 입니다.

 

강의 내용

언리얼 C++만의 컴포지션 기법을 사용해 복잡한 언리얼 오브젝트를 효과적으로 생성하는 방법에 대해 알아보겠습니다. 

 

강의 목표

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

 

컴포지션(Composition)

  • 객체지향 프로그래밍의 설계는 크게 상속과 컴포지션의 활용으로 요약할 수 있다.
  • 상속: 성질이 같은 부모 클래스와 자식 클래스의 관계를 의미하는 Is-A 관계
    • 이것만 의존해서는 설계와 유지보수가 어려움.)
  • 컴포지션: 성질이 다른 두 객체에서 어떤 객체가 다른 객체를 소유하는 Has-A 관계
    • 복합적인 기능을 가진 거대한 클래스를 효과적으로 설계하는데 유용하게 사용

 

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

 

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

예시)

기획자가 캐릭터 디자인을 가져왔다. 기획자가 가져온 게임에 보여질 실물을 그대로 구현하기보다는 해당 캐릭터의 기획 의도를 파악해서 보다 추상적인 상위 개념을 기획하고 이를 목표로 설계해야 한다.

 

소프트웨어는 기획 변경이 굉장히 잦기 때문에, 향후 기획 변경으로 인한 유지보수를 생각해서 특정 기능(예시에서는 출입증)은 부모 클래스에서 구현하는 것이 아니고 컴포지션으로 분리하는 것이 바람직하다.

그렇다고 컴포지션으로 구현만 하면 모든 것이 해결될까?

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

 

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

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

  • 하나의 언리얼 오브젝트에는 항상 클래스 기본 오브젝트 CDO가 있다.
  • 방법 1: CDO에 미리 언리얼 오브젝트를 생성해 조합한다. (필수적 포함)
  • 방법 2 : CDO에 빈 포인터만 넣고 런타임에서 언리얼 오브젝트를 생성해 조합한다.
  • 내가 소유한 언리얼 오브젝트를 Subobject라고 한다.
  • 나를 소유한 언리얼 오브젝트를 Outer라고 한다.

 

언리얼 C++의 열거형 타입

UENUM()
enum class ECardType :uint8
{
	Student = 1 UMETA(DisplayName = "For Student"),
	Teacher UMETA(DisplayName = "For Teacher"),
	Staff UMETA(DisplayName = "For Staff"),
	Invalid
};

 

UENUM() 이라는 매크로를 지정해주면 이제 이 객체에 대한 정보를 언리얼 엔진이 파악해서 우리가 유용한 정보들을 가져올 수 있다.

UMETA 매크로를 이용해서 필드마다 메타 정보를 집어 넣을 수 있다.

예를들어서 DisplayName 이라는 메타 정보를 열거형의 각 데이터마다 넣으면, 이것을 코드에서 사용할 수 있다.

 

예시: Person 객체가 Card를 가지도록 기능 확장

 

class UNREALCOMPOSITION_API UPerson : public UObject
{
	GENERATED_BODY()
	
public:
	UPerson();

	FORCEINLINE FString& GetName() { return Name; }
	FORCEINLINE void SetName(const FString& InName) { Name = InName; }

protected:
	UPROPERTY()
	FString Name;

	UPROPERTY()
	class UCard* card;
};

 

class UCard* card;

컴포지션 관계에 있을 때, 선언은 전방선언을 진행하는 것이 좋다.

헤더를 포함하지 않고 보통 오브젝트는 포인터로 관리하기 때문에

정확한 구현부는 알 수 없지만 어쨌든 포인터 크기를 가진다.

전방선언을 통해서 의존성을 최대한 없앨 수가 있다.

 

하지만 UE4까지의 정석이며, UE5부터는 다른 방식의 선언을 권장하고 있다.

UPROPERTY 변수에 원시 포인터가 있었던 선언들을 TObjectPtr을 사용해서 변경하라는 것이다.

선택사항이긴 하지만 포인터로 선언된 것들을 TObjectPtr라고 하는 템플릿 클래스로 감싸서 선언해 주라고 하고있다.

선언에 대해서만 TObjectPtr를 사용하고 구현부에서는 그냥 포인터를 사용해도 된다.

UPROPERTY()
TObjectPtr<class UCard> card;

그래서 UE5에서는 이렇게 변경하는 것이 좋다.

 

실습 

// Fill out your copyright notice in the Description page of Project Settings.

#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("==================="));
}

 

실행 결과

728x90