언리얼 C++ 만의 Composition
기법을 사용해 복잡한 언리얼 오브젝트를 효과적으로 생성하기
객체 지향 언어들이 추구하는 원칙의 뼈대를 이루는 SOLID
principle이다. S는 Single Responsibility Principle
, O는 Open Closed Principle
, L은 Liskov Substitution Principle
, I는 Interface Segregation Principle
, D는 Dependency Inversion Principle
을 의미한다. 중요한 원칙이므로 앞글자를 따서 이름까지 외우자!
이번 글에서 다룰 Composition
도 이 SOLID 원칙에 기반하여 탄생한 객체 지향 구조이다.
Composition
의 활용으로 요약해 볼 수 있다.Is-A
관계만으로는 설계와 유지보수가 어려움.Composition
은 객체 지향 설계에서 Has-A
관계를 구현하는 설계 방법모든 직업을 가지는 존재는 사람일 수 밖에 없지만, 이 사람들이 모두 다 출입증을 소지하지 않을 수 있다.
위 그림에서, Student, Staff, Teacher는 Is-A 관계라서 다 Person 클래스를 상속받지만, Card는 Has-A 관계이기 때문에 Composition의 개념으로 접근하여야 한다.
설명에 앞서 용어 정리부터 하자면, 언리얼 Composition에서 자신이 소유하고 있는 언리얼 오브젝트를 SubObject
라 하고, SubObject 입장에서 자신을 소유하고 있는 언리얼 오브젝트를 Outer
라 한다.
방법 1 : 컴파일 타임에 미리 CDO와 언리얼 오브젝트를 생성해 조합(필수적 포함)
우리가 계층 구조의 pawn을 만들때 자주 사용했던 CreateDefaultSubobject()
가 바로 이 방법이다. CDO 단에서부터 완성된 object를 SubObject
로 넣고, 오브젝트를 NewObject()
로 생성하게 되면 계층적으로 완성된 object가 탄생한다. Pawn 밑에 Camera, Camera Arm 등등을 CreateDefaultSubobject()
을 통해 붙였던 것을 다시 상기시켜보자.
붙이고자 하는 CDO
를 붙이는 대상의 생성자에서 CreateDefaultSubobject()
를 호출한다.
방법 2 : CDO에 빈 포인터만 넣고 런타임에서 언리얼 오브젝트를 생성해 조합(선택적 포함)
이 오브젝트를 NewObject()
를 통해 생성하면 빈 SubObject
를 가진 Object가 생성되게 되고, 나중에 필요하면 SubObject
에 대해 NewObject()
로 동적 생성한다.
3가지 클래스를 만들것이다. Has-A 관계에서 SubObject에 해당되는 Card 클래스, Card 클래스를 가지는 Person 클래스, Person 클래스를 상속 받는 Student 클래스.
// Card.h
UENUM()
enum class ECard Type : uint8
{
Student = 1 UMETA(DisplayName = "For Student"),
Teacher UMETA(DisplayName = "For Student"),
Staff UMETA(DisplayName = "For Student"),
Invalid UMETA(DisplayName = "For Student")
}
Card 클래스에서는 enum class 변수 타입을 잡아준다. 기본적으로 열거형의 기본 타입으로는 8byte으로 설정.
UENUM()
매크로를 통해 언리얼 reflection 시스템에 이 열거형을 등록시키고 enum class의 각 field에 UMETA()
를 넣어 각 열거형 값에 메타데이터를 추가한다.
// Card.h
UPROPERTY()
ECardType CardType;
타입 선언을 했으면 변수 선언을 위와 같이 한다.
// Card.cpp
CardType = ECardType::Invalid;
cpp 파일에서는 기본 enum class 값을 잡아준다.
Person 클래스가 Card를 가지도록 설정해보자.
방법1을 사용할 것임.
// Person.h
// 언리얼 4 스타일 선언
UPROPERTY()
class UCard* Card;
// Person.h
// 언리얼 5에서의 새로운 선언 표준
UPROPERTY()
TObjectptr<class UCard> Card
Person 헤더에서 Card 변수를 선언한다. 언리얼 4까지는 원시 포인터 형태로 변수를 선언했다면, 언리얼 5에서는 TObjectptr
를 통해 선언부를 작성하라고 권한다.
Composition 관계시 헤더에서는 전방 선언을 해 주는 것이 좋다. 전방 선언을 하면 헤더에서는 아직 include 하지 않아도 되므로, 의존성을 최대한 없앨 수 있다.
// Person.h에서 Getter & Setter
FORCEINLINE class UCard* GetCard() const {return Card;}
FORCEINLINE void SetCard(class UCard* InCard) {Card = InCard;}
Getter와 Setter 또한 만들어준다. 전방 선언을 하였음으로 class UCard*
가 꾸준히 나타나는 것을 확인할 수 있다.
// Person.cpp
UPerson::UPerson()
{
// 이제는 UCard의 파일을 include 해준다.
//
Card = CreateDefaultSubobject<UCard>();
}
이 부분이 핵심이다. 이전에 언리얼에서 Composition을 하는 2가지 방법이 있다고 했다. 우리는 방법 1을 사용할 것이고 이는 CDO
가 만들어질때 CreateDefaultSubobject
API를 통해 SubObject
를 같이 생성해 버리는 방법이다. CDO
부분에 이렇게 작업을 해 놓으면, 나중에 UPerson을 생성할때 Card 또한 함께 생성되게 된다.
// Student.cpp
UStudent::UStudent()
{
// Student는 Person을 상속한다.
// Person의 생성자가 돌고 나서 Student의 생성자가 돌게 된다.
//
Card -> SetCardType(ECardType::Student);
}
Person 클래스를 상속한 Student 클래스. Person 헤더에서 만든 Setter를 통해 Card의 enum 타입을 결정해준다.
Composition
을 구현하는 독특한 패턴이 있다. Has-A
관계에 사용되는 용어SubObject
Outer