스마트 포인터의 사용법은 일반 포인터와 거의 동일합니다. 하지만 일반 포인터와 스마트 포인터가 지양하는 컨셉은 좀 다릅니다. 일반 포인터가 객체에 접근가능한 주소를 보관하는 개념이라면, 스마트 포인터는 객체의 소유권을 증명하는 개념입니다.
언리얼에서는 이를 T로 시작하는 라이브러리로 스마트 포인터를 제공합니다.
집을 지을 때, 단독 명의로 집을 짓고 싶다면 유니크 포인터 서비스를 이용하면 됩니다. 단독 명의로 선언하면 이 집을 혼자서 독차지할 수 있지만 다른 사람들과 나누어 가질 수는 없습니다.
하지만 혼자 소유한다고 해서 매매가 불가능한 것은 아닙니다. 이미 만들어진 집은 그대로 두고 주인만 바꿔치기할 수 있습니다. 이러한 개념을 C++에서는 무브시멘틱(Move Semantic) 이라고 하는데, 언리얼 엔진에서는 MoveTemp라는 이름으로 API를 제공합니다.
아래는 위에서 언급한 모든 기능을 구현한 코드입니다.
// 1단계
AB_LOG(Warning, TEXT("****** 1단계 ******"));
FHouse* NewHouseAddress = new FHouse();
AB_LOG(Warning, TEXT("집을 새로 지었습니다. 내집크기 : %d"), NewHouseAddress->Size);
if (NewHouseAddress)
{
delete NewHouseAddress;
AB_LOG(Warning, TEXT("내가 직접 집을 철거했습니다. 집크기 : %d"), NewHouseAddress->Size);
NewHouseAddress = nullptr;
}
// 2단계
AB_LOG(Warning, TEXT("****** 2단계 ******"));
NewHouseAddress = new FHouse();
NewHouseAddress->Size = 100;
AB_LOG(Warning, TEXT("집을 다시 지었습니다. 집크기 : %d"), NewHouseAddress->Size);
{
TUniquePtr<FHouse> MyHouseDeed = TUniquePtr<FHouse>(NewHouseAddress);
if (MyHouseDeed.IsValid())
{
AB_LOG(Warning, TEXT("이 집은 제 단독소유 주택입니다. 내집크기 : %d"), MyHouseDeed->Size);
}
//TUniquePtr<FHouse> FriendsHouseDeed = MyHouseDeed; // 컴파일 에러! 단독 소유만 가능
TUniquePtr<FHouse> FriendsHouseDeed = MoveTemp(MyHouseDeed); // 집은 그대로 두고 집주인만 변경
if (!MyHouseDeed.IsValid())
{
AB_LOG(Warning, TEXT("친구에게 집을 팔았습니다. 친구집크기 : %d"), FriendsHouseDeed->Size);
}
}
AB_LOG(Warning, TEXT("집문서가 사라져서 집은 자동으로 철거되었습니다. 집크기 : %d"), NewHouseAddress->Size);
여러분이 제작한 객체를 여러 군데에서 사용하고자 할 경우에는, 즉 공동명의로 집을 소유하고 싶으면 공유 포인터 서비스를 이용하면 됩니다. 공유포인터는 내부적으로 레퍼런스 카운팅 기법을 사용합니다. 누군가 객체를 참조할 때마다 레퍼런스 카운트가 하나씩 올라가고, 해지할 때 하나씩 내려갑니다. 이렇게 관리하다가 레퍼런스 카운트가 0이되면 스마트포인터는 자동으로 해당 객체를 소멸시킵니다.
// 3단계
AB_LOG(Warning, TEXT("****** 3단계 ******"));
NewHouseAddress = new FHouse();
NewHouseAddress->Size = 150.0f;
AB_LOG(Warning, TEXT("집을 또 다시 지었습니다. 집크기 : %d"), NewHouseAddress->Size);
{
TSharedPtr<FHouse> MyHouseDeed = MakeShareable(NewHouseAddress); // 만들어진 집을 차후에 등록
if (MyHouseDeed.IsValid())
{
AB_LOG(Warning, TEXT("공동 소유 가능한 집이 되었습니다. 내집크기 : %d"), MyHouseDeed->Size);
if (MyHouseDeed.IsUnique())
{
AB_LOG(Warning, TEXT("현재는 혼자 소유하고 있습니다. 내집크기 : %d"), MyHouseDeed->Size);
}
}
TSharedPtr<FHouse> FriendsHouseDeed = MyHouseDeed;
if (!FriendsHouseDeed.IsUnique())
{
AB_LOG(Warning, TEXT("친구와 집을 나눠가지게 되었습니다. 친구집크기 : %d"), FriendsHouseDeed->Size);
}
MyHouseDeed.Reset(); // 내가 집 소유권을 포기함
if (FriendsHouseDeed.IsUnique())
{
AB_LOG(Warning, TEXT("이제 친구만 집을 소유하고 있습니다. 친구집크기 : %d"), FriendsHouseDeed->Size);
}
AB_LOG(Warning, TEXT("집은 아직 그대로 있습니다. 집크기 : %d"), NewHouseAddress->Size);
}
AB_LOG(Warning, TEXT("집은 자동 철거되었습니다. 집크기 : %d"), NewHouseAddress->Size);
공유포인터는 가장 많이 사용하는 스마트포인터라고 할 수 있습니다. 하지만 뿌린 집문서가 모두 사라져야 집이 소멸되는 특징이 있기 때문에, 예상치 못하는 상황이 발생할 수 있습니다. 대표적인 문제가 공유 포인터의 순환 참조(Circular Reference) 문제입니다. 이 문제를 직접 확인하고 테스트하기 위해, 집 클래스를 확장해 다른 집 문서를 보관하는 기능을 추가합시다.
class FHouse
{
public:
TSharedPtr<FHouse> OthersDeed;
int32 Size = 10;
};
// 4단계
AB_LOG(Warning, TEXT("****** 4단계 ******"));
NewHouseAddress = new FHouse();
NewHouseAddress->Size = 200.0f;
AB_LOG(Warning, TEXT("집을 한번 더 다시 지었습니다. 첫번째집크기 : %d"), NewHouseAddress->Size);
FHouse* NewHouseAddress2 = new FHouse();
NewHouseAddress2->Size = 250.0f;
AB_LOG(Warning, TEXT("친구도 집을 직접 지었습니다. 두번째집크기 : %d"), NewHouseAddress2->Size);
{
TSharedPtr<FHouse> MyHouseDeed = MakeShareable(NewHouseAddress);
AB_LOG(Warning, TEXT("내 집은 내가 소유합니다. 내집크기 : %d"), MyHouseDeed->Size);
TSharedPtr<FHouse> FriendsHouseDeed = MakeShareable(NewHouseAddress2);
AB_LOG(Warning, TEXT("친구 집은 친구가 소유합니다. 친구집크기 : %d"), FriendsHouseDeed->Size);
MyHouseDeed->OthersDeed = FriendsHouseDeed;
AB_LOG(Warning, TEXT("친구 집을 공동 소유하고 문서를 내 집에 보관합니다. 친구집크기 : %d"), MyHouseDeed->OthersDeed->Size);
FriendsHouseDeed->OthersDeed = MyHouseDeed;
AB_LOG(Warning, TEXT("친구도 내 집을 공동 소유하고 문서를 자기 집에 보관합니다. 내집크기 : %d"), FriendsHouseDeed->OthersDeed->Size);
}
AB_LOG(Warning, TEXT("집문서가 사라져도 내가 지은 집이 자동 철거되지 않습니다. 첫번째집크기 : %d"), NewHouseAddress->Size);
AB_LOG(Warning, TEXT("친구가 지은 집도 자동 철거되지 않습니다. 두번째집크기 : %d"), NewHouseAddress2->Size);
NewHouseAddress->OthersDeed.Reset();
AB_LOG(Warning, TEXT("친구가 지은 집을 수동으로 철거했습니다. 집주소가 남아있어서 다행입니다. 두번째집크기 : %d"), NewHouseAddress2->Size);
AB_LOG(Warning, TEXT("이제서야 내가 지은 집도 자동 철거됩니다. 첫번째집크기 : %d"), NewHouseAddress->Size);
이와 같이 공유포인터에만 모든 것을 의존하면 문제가 발생할 수 있음을 확인할 수 있습니다.
공유 포인터에 의한 문제를 해결하기 위해 등장한 것이 약포인터입니다. 약포인터는 부동산 중개업자로 비유할 수 있습니다. 집에 대한 소유권은 없지만 집에 대해서는 잘 알고 있죠. 위의 문제를 해결하기 위해 한 쪽에는 약포인터로 선언해 필요할 때에만 중개업자를 통해서 이용하도록(?) 구성해봅시다. 그러면 양쪽이 모두 서로의 집을 사용할 수 있으면서, 양쪽 집문서가 소멸되면 자동으로 집도 철거되게 만들 수 있습니다. 먼저 약포인터 멤버 변수를 추가합시다.
class FHouse
{
public:
TSharedPtr<FHouse> OthersDeed;
TWeakPtr<FHouse> AccessHouse;
int32 Size = 10;
};
// 5단계
AB_LOG(Warning, TEXT("****** 5단계 ******"));
NewHouseAddress = new FHouse();
NewHouseAddress->Size = 300.0f;
AB_LOG(Warning, TEXT("이제 마지막으로 집을 짓습니다. 첫번째집크기 : %d"), NewHouseAddress->Size);
NewHouseAddress2 = new FHouse();
NewHouseAddress2->Size = 350.0f;
AB_LOG(Warning, TEXT("친구도 집을 다시 지었습니다. 두번째집크기 : %d"), NewHouseAddress2->Size);
{
TSharedPtr<FHouse> MyHouseDeed = MakeShareable(NewHouseAddress);
AB_LOG(Warning, TEXT("내 집은 내가 소유합니다. 내집크기 : %d"), MyHouseDeed->Size);
TSharedPtr<FHouse> FriendsHouseDeed = MakeShareable(NewHouseAddress2);
AB_LOG(Warning, TEXT("친구 집은 친구가 소유합니다. 친구집크기 : %d"), FriendsHouseDeed->Size);
MyHouseDeed->OthersDeed = FriendsHouseDeed;
AB_LOG(Warning, TEXT("친구 집을 공동 소유하고 문서를 내 집에 보관합니다. 친구집크기 : %d"), MyHouseDeed->OthersDeed->Size);
FriendsHouseDeed->AccessHouse = MyHouseDeed;
AB_LOG(Warning, TEXT("친구가 내 집 정보를 열람합니다. 내집크기 : %d"), FriendsHouseDeed->AccessHouse.Pin()->Size);
}
AB_LOG(Warning, TEXT("내가 지은 집은 자동 철거됩니다. 첫번째집크기 : %d"), NewHouseAddress->Size);
AB_LOG(Warning, TEXT("친구가 지은 집도 자동 철거됩니다. 두번째집크기 : %d"), NewHouseAddress2->Size);
위에서 언급한 스마트 포인터 라이브러리는 일반 C++ 객체를 위한 라이브러리이고, 언리얼 오브젝트에는 사용할 수 없습니다.
언리얼 오브젝트는 언리얼 엔진 가상머신의 가비지 컬렉션(Garbage Collection, 줄여서 GC) 시스템에 의해 자동으로 관리되기 때문입니다. 하나의 언리얼 오브젝트가 GC 시스템에 의해 자동 관리되기 위해서는 변수 선언에 반드시 UPROPERY 매크로가 들어가야 합니다. 그러면 언리얼 엔진에 의해서 필요가 없어질 때 자동으로 회수됩니다.
GC 시스템이 언리얼 오브젝트를 관리하는 방식은 스마트 포인터 삼총사 중 두 번째에 설명한 공유 포인터와 거의 유사합니다. 다만 다른 점은 중앙의 GC 시스템에 의해서 모든 관리되기 때문에, 메모리에서 해지되는 타이밍을 정확히 예측할 수 없다는 점입니다.
따라서 언리얼 오브젝트의 포인터를 소멸할 때에는 BeginConditionalDestroy()라는 함수를 호출해주고 시스템이 해지해줄 때까지 기다리는 수 밖에 없습니다. 참고로 액터의 경우에는 월드의 씬 정보를 업데이트 해야하기 때문에 먼저 DestroyActor() 함수도 추가로 호출해야 합니다.
가비지 컬렉션은 프로젝트 세팅에서 지정된 시간마다 언리얼 오브젝트를 감지해서 제거해줍니다. 이는 프로젝트 세팅의 Garbage Collection 탭에서 확인할 수 있습니다.
월드의 ForceGarbageCollection(true) 함수를 실행해 GC 시스템에게 바로 자원 회수를 명령할 수 있습니다. 이는 GC 시스템에 의해 많은 수의 언리얼 오브젝트를 한번에 회수할 때 일시적인 렉이 생기는 딸꾹질(Hiccup) 현상을 미연에 방지해 줄 수 있는 효과가 있습니다. 반대로 언리얼 오브젝트의 삭제를 명령했음에도 불구하고 계속 언리얼 오브젝트를 메모리에 유지시키고 싶을 떄에는 주석처리한 AddToRoot() 함수를 사용하면 자원회수를 원천적으로 봉쇄하는 것도 가능합니다.
언리얼 오브젝트 메모리 관리는 공유 포인터와 동일한 방식으로 동작하기 때문에, 위에서 언급한 공유 포인터의 순환 참조의 문제에서 자유롭지 않습니다. 그래서 언리얼 C++은 언리얼 오브젝트를 약하게 참조하는 TWeakObjectPtr이라는 별도의 라이브러리를 제공하고 있습니다. 특정 언리얼 오브젝트를 참조할 때 반드시 소유권이 필요하지 않는 경우에는 약 참조를 걸어주는 것을 추천합니다.
예를 들어 UI의 리스트박스에서 언리얼 오브젝트의 목록을 보여주고 싶을 때에는 TWeakObjectPtr을 사용해 언리얼 오브젝트를 약참조(Weak Referencing)하는 것이 바람직합니다. 일반 참조를 걸게되면 UI가 띄워져 있는 동안에는 UI에서 보여지는 모든 언리얼 오브젝트의 레퍼런스 카운팅이 올라가게되므로 언리얼 오브젝트를 삭제해도 GC시스템에서 회수가 일어나지 않습니다. 이는 많은 분들이 간과하는 사항입니다. 이번 강좌를 통해 약참조의 용도를 잘 기억해두시면 언젠가는 도움되지 않을까 생각합니다.