언리얼의 Non-UObject 스마트 포인터

김혁·2025년 8월 26일

챌린지

목록 보기
8/14

TSharedPtr

개념

  • C++ std::shared_ptr와 유사
  • 참조 카운팅 기반으로 관리
  • 객체를 여러 곳에서 공유할 때 사용

공유 소유권

// 철검 객체 생성. 현재 사용자: 1명
TSharedPtr<InventoryItem> player1Sword = MakeShared<InventoryItem>("철검");

// 같은 검을 참조. 현재 사용자: 2명
TSharedPtr<InventoryItem> player2Sword = player1Sword;

// 첫 번째 플레이어가 참조 해제. 현재 사용자: 1명
player1Sword = nullptr;

// 두 번째 플레이어도 참조 해제. 현재 사용자: 0명
player2Sword = nullptr;
// -> 검 객체가 자동으로 메모리에서 삭제

참조 카운팅 시스템의 내부 구조

template<typename T>
class TSharedPtr
{
private:
	T* ObjectPtr;							// 실제 객체
    FReferenceController* RefController;	// 참조 카운트와 제어 정보
};
  • FReferenceController의 내부 값들
    • SharedRefCount : 강한 참조(TSharedPtr)가 몇 개인지
    • WeakRefCount : 약한 참조(TWeakPtr)가 몇 개인지


MakeShared vs MakeSharable

  • MakeShared
    • 언리얼에서 권장되는 방식
    • 객체와 참조 카운트 블록을 한 번에 할당 -> 성능 좋음
    • 항상 새로운 객체 생성을 할 때 사용
TSharedPtr<MyClass> Ptr = MakeShared<MyClass>("Hello");
  • MakeSharable
    • 외부 라이브러리에서 new로 받은 포인터를 TSharedPtr로 관리하고 싶을 때 사용
    • 단점 : MakeShared처럼 메모리 풀 최적화가 적용되지 않음 -> MakeShared 권장
MyClass* RawPtr = new MyClass();
TSharedPtr<MyClass> Ptr = MakeShareable(RawPtr);

TSharedPtr vs TSharedRef

  • TSharedPtr
    • 둘 다 참조 카운팅 기반 스마트 포인터이고, 기본 동작은 거의 동일
    • nullptr 허용됨 -> 기본 초기값은 nullptr
    • 사용 안정성 : 널 체크 필요
    • 선택적으로 존재할 수 있는 객체에서 사용
TSharedPtr<Quest> maybeQuest = nullptr;
if (maybeQuest.IsValid())
{
	maybeQuest->StartQuest();
}
  • TSharedRef
    • nullptr 불가능 -> 항상 유효한 객체를 가리켜야 함
    • 사용 안정성 : 항상 유효 -> 널 체크 필요 없음
    • 반드시 존재해야 하는 핵심 객체, API 반환값에서 사용
TSharedRef<Quest> definiteQuest = MakeShared<Quest>("드래곤 토벌");
definiteQuest->StartQuest();

TWeakPtr

개념

  • TSharedPtr에서 파생
  • 실제 객체 소유권을 가지지 않고, 약한 참조만 유지 -> 참조 카운트를 증가시키지 않음
  • TSharedPtr이 사라지면 TWeakPtr은 자동으로 무효화됨

순환 참조 문제

  • 기존 C++에서 std::weak_ptr처럼 TSharedPtr에서 발생할 수 있는 순환 참조 문제를 해결하기 위해 등장
  • 서로 TSharedPtr을 통해 강한 참조를 하는 경우, 참조 해제를 하더라도 참조 카운팅이 사라지지 않고, 메모리 해제가 되지 않는 문제가 발생
class Parent
{
public:
	TSharedPtr<Child> MyChild;  // 부모 -> 자식 (강한 참조)
}

class Child
{
public:
	TSharedPtr<Parent> MyParent;  // 자식 -> 부모 (강한 참조)
}

void CreateReference()
{
	TSharedPtr<Parent> parent = MakeShared<Parent>();
	TSharedPtr<Child> child = MakeShared<Child>();
	
	parent->MyChild = child;
	child->MyParent = parent;
}
  • 한 쪽을 TWeakPtr로 변경을 해서, 포인터 삭제를 방해하지 않고 순환 참조 문제를 해결할 수 있다.
class Parent
{
public:
	TSharedPtr<Child> MyChild;  // 부모 -> 자식 (강한 참조)
}

class Child
{
public:
	TWeakPtr<Parent> MyParent;  // 자식 -> 부모 (약한 참조로 변경)
}

void CreateReference()
{
	TSharedPtr<Parent> parent = MakeShared<Parent>();
	TSharedPtr<Child> child = MakeShared<Child>();
	
	parent->MyChild = child;
	child->MyParent = parent;
}

Pin()

  • TWeakPtr 자체는 직접 객체를 사용할 수 없음
  • Pin()을 호출해서 임시로 TSharedPtr을 얻어 객체에 안전하게 접근
  • 동작 방식
    • 원본 객체가 살아있다면 -> 유효한 TSharedPtr을 반환
    • 원본이 파괴되었다면 -> nullptr을 담은 TSharedPtr 반환
TSharedPtr<T> TWeakPtr<T>::Pin() const
{
    if (IsValid())	// 살아있다면 유효한 TSharedPtr 생성
    {
        return TSharedPtr<T>(ObjectPtr, RefController);
    }
    return TSharedPtr<T>(); // 이미 삭제된 경우 → 빈 SharedPtr 반환
}	
  • 사용 예시
if (TSharedPtr<MyClass> Pinned = WeakPtr.Pin())
{
    // 객체가 아직 살아있음
    Pinned->DoSomething();
}
else
{
    // 객체가 이미 파괴됨
    UE_LOG(LogTemp, Warning, TEXT("Object expired!"));
}

TUniquePtr

개념

  • 객체의 유일한 소유권을 가지는 스마트 포인터
  • 하나의 TUniquePtr만 특정 객체를 가질 수 있고, 복사는 불가능하지만 이동(move)은 가능
  • 따라서 소유권 이전을 확실하게 표현할 때 사용

특징

  1. 단일 소유권 보장 -> 메모리 효율성이 뛰어남 (참조 카운팅 없음)
    • 같은 객체를 여러 개의 TUniquePtr이 가질 수 없음
    • 복사 생성자와 복사 대입 연산자가 delete되어 있음
  2. 이동 가능 -> 소유권이 명확
    • C++ 표준의 std::move와 비슷하게 MoveTemp()를 통해 소유권 이동 가능
  3. 자동 해제 -> 자동 정리가 확실함
    • TUniquePtr이 스코프를 벗어나면 delete가 호출됨
    • 리소스 관리에 안전 -> 메모리 누수 방지

사용 예시

TUniquePtr<MyClass> Ptr = MakeUnique<MyClass>(10);
Ptr->DoSomething();

// TUniquePtr<MyClass> Copy = Ptr; -> 컴파일 에러 발생

TUniquePtr<MyClass> Moved = MoveTemp(Ptr);
if (!Ptr)
{
	UE_LOG(LogTemp, Log, TEXT("Ptr is null!"));
}

RAII (Resource Acquisition Is Initialization)

  • RAII의 핵심 개념
    • C++의 핵심 철학 중 하나
      • "자원의 획득은 곧 초기화이며, 해제는 스코프 종료와 함께 일어난다"
    • 리소스(메모리, 파일 핸들 등)는 객체의 생성자에서 획득 (acquire)
    • 소멸자에서 자동으로 해제 (release)
    • 즉, 스코프 기반의 자동 관리가 이루어짐
  • TUniquePtr에서 RAII
    • TUniquePtr은 생성될 때 new로 생성된 객체를 가짐
    • TUniquePtr이 스코프를 벗어나면 자동으로 delete 호출 -> 자동으로 자원 해제

-> 예외가 발생하거나 return으로 빠져도, 자동으로 자원이 해제되어 메모리 누수 방지가 보장됨

Move 의미론

  • 일반 복사와의 차이
    • 복사 : 객체의 데이터를 복제 -> 두 객체가 같은 데이터를 가짐
    • 이동 : 복제하지 않고 소유권을 넘김 -> 원래 객체는 비워짐(null)
  • TUniquePtr에서 Move 의미론
    • TUniquePtr은 복사가 금지되어 있음 (복사 생성자/대입 연산자 delete)
    • Move 생성자 / Move 대입 연산자만 지원
    • 즉, MoveTemp()를 써서 소유권을 한 번만 옮길 수 있음

-> 불필요한 복사를 줄이고 성능 최적화 + 소유권 관리가 명확해짐

스마트 포인터 선택하기

스마트 포인터 선택하는 방법

  1. UObject인가?
    • UObject라면 -> UPROPERTY() 키워드를 통해 엔진이 관리
    • 아니라면 -> 스마트 포인터 필요
  2. 혼자서만 사용하나? (소유권 패턴 파악)
    • 독점 소유가 명확한 경우 -> TUniquePtr 적합
    • 공유가 필요한 경우 -> TSharedPtr 적합
  3. 여러 곳에서 공유해야 하나? (접근 패턴 분석)
    • 진짜 공유가 필요한 경우 - 여러 시스템이 생명주기를 함께 책임 -> TSharedPtr 적합
    • 실제로는 단순 접근만 필요한 경우 -> TUniquePtr에서 접근만 하기
  4. null이 될 수 있나? (생명주기 안정성)
    • null 가능 -> TSharedPtr 적합
    • null 불가능, 항상 유효 -> TSharedRef 적합
  5. 순환 참조 위험이 있나? (관계 구조 분석)
    - 순환 참조 위험 있음 -> TWeakPtr 적합
    - 순환 참조 위험 없음, 단방향 관계 -> TSharedPtr 적합

선택하기 어려운 상황들

  1. 임시로 쓰다가 나중에 바뀔 수 있는 경우
    -> 일단 더 안전한 선택부터 (TUniquePtr보다는 TSharedPtr)
  2. 성능이 중요한지 안전성이 중요한지 애매한 경우
    -> 매 프레임 실행되는 코드 : 성능 우선 -> TUniquePtr
    -> 가끔 실행되는 코드, 크리티컬한 코드 : 안전성 우선 -> TSharedPtr
    -> 프로토타입 단계 : 개발 속도 우선 -> TSharedPtr
  3. 팀원들의 숙련도가 다른 경우
    -> 숙련도가 높은 팀 -> 최적화된 설계 (TUniquePtr)
    -> 숙련도가 다양한 팀 -> 안전한 설계 (TSharedPtr)


출처 : 팀스파르타 내일배움캠프
profile
게임 개발자를 향해..

0개의 댓글