12 분 소요

Non-UObject 스마트 포인터와 소유권에 대하여 알아보자

김하연 튜터님의 Notion 자료를 바탕으로 강의를 들으며
수정 및 재작성한 블로깅용 글

작은 데이터 하나 쓰려하는데 UObject를 쓰는것은 생각보다 무거움
그런데 Unreal GC에 포함되지 않기에
우리가 잘 관리해주어야 함

  • 프로그래머의 실수를 예방하려면?
    -> 스마트 포인터!

1. 기존 메모리 관리의 문제점을 복습해보자 💰

Raw Pointer의 위험성

// 메모리 누수 예시
InventoryItem* sword = new InventoryItem("철검");
// delete를 잊으면 메모리 누수 발생!

// 이중 삭제
delete sword;
delete sword; // 💥 크래시!

// 삭제된 메모리 접근
delete sword;
cout << sword->getName(); // 💥 크래시!
  • Heap 메모리 어딘가에 만들어주기
  • 프로그래머의 실수 가능성
    • 메모리 누수하면 몇시간 뒤에 터질수도 있음
    • 2중 삭제
    • 댕글리 포인터 문제
  • 안전한 타이밍을 고려하지 못한 메모리 실수는
    생각보다 많이 일어남

스마트 포인터?

// 스마트 포인터 사용
TSharedPtr<InventoryItem> sword = MakeShared<InventoryItem>("철검");

// 사용
cout << sword->getName(); // 안전하게 접근 가능

// sword 변수가 스코프에서 사라지면
// 메모리는 자동으로 해제
  • 메모리 자동으로 돌려주기!
  • 프로그래머의 실수를 예방 -> 인적 효율 상승

2. TSharedPtr - 공유해서 쓰는 스마트 포인터 🧸

2-1. 공유 소유권 이해하기

// 철검을 만들었음. 현재 사용자: 1명
TSharedPtr<InventoryItem> sword1 = MakeShared<InventoryItem>("철검");

// 두 번째 플레이어도 같은 검을 참조. 현재 사용자: 2명
TSharedPtr<InventoryItem> sword2 = sword1;

// 첫 번째 플레이어가 검을 내려놓음. 현재 사용자: 1명
sword1 = nullptr;

// 두 번째 플레이어도 검을 내려놓음. 현재 사용자: 0명
sword2 = nullptr;

// 이 때! 철검 객체가 자동으로 메모리에서 삭제됩니다.
  • 참조 카운팅 방식을 통해
    메모리 해제 타이밍을 잡음
  • 0이 된 순간, 메모리를 해제

간단한 사용법

// 1. 생성 - MakeShared 사용
TSharedPtr<InventoryItem> sword = MakeShared<InventoryItem>("철검");

// 2. 복사 - 같은 객체를 공유
TSharedPtr<InventoryItem> anotherSword = sword;

// 3. 사용 - 화살표 연산자로 접근
sword->Use();
FString name = sword->GetName();

// 4. null 체크 - IsValid() 사용
if (sword.IsValid())
{
    sword->Attack();
}

// 5. 해제 - nullptr 대입하면 참조 카운트 감소
sword = nullptr;
anotherSword = nullptr; // 이 순간 객체 자동 삭제
  • Heap 생성 이후, 참조 카운트 1
    (강한 참조)

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

template<typename T>
class TSharedPtr 
{
private:
    T* ObjectPtr;                     // 실제 객체
    FReferenceController* RefController; // 참조 카운트와 제어 정보
};
  • FReferenceController 안에 들어 있는 값
    • SharedRefCount : 강한 참조 (TSharedPtr)가 몇 개인지
    • WeakRefCount : 약한 참조 (TWeakPtr)가 몇 개인지
  • 일반 포인터보다 비용이 조금 더 나가긴 하지만
    그렇게까지 신경쓸 정도는 아닌 편
// 객체 생성
TSharedPtr<InventoryItem> sword1 = MakeShared<InventoryItem>("철검");
TSharedPtr<InventoryItem> sword2 = sword1;
TSharedPtr<InventoryItem> sword3 = sword1;

sword1 = nullptr;  // SharedRefCount = 2
sword2 = nullptr;  // SharedRefCount = 1
sword3 = nullptr;  // SharedRefCount = 0 → 객체 삭제!

2-3. 실제 게임에서 어떻게 쓰일까?

class GameManager 
{
private:
    TArray<TSharedPtr<Quest>> ActiveQuests;

public:
    void StartQuest(FString QuestName)
    {
        // 하나의 퀘스트 객체만 생성
        TSharedPtr<Quest> NewQuest = MakeShared<Quest>(QuestName);

        // 게임 매니저가 관리
        ActiveQuests.Add(NewQuest);

        // 여러 시스템이 동일한 객체를 공유
        UIManager->ShowQuest(NewQuest);
        AudioManager->PlayQuestSound(NewQuest);
        SaveManager->RegisterQuest(NewQuest);
    }
};
  • SharedPtr을 제작하고
    여러 Manager 등에게 공유

  • 만든 시점 이후, 참조 카운트가 하나 사라져도(범위 벗어남)
    GameManager가 들고 있기에 다른 Manager 들이 사용 가능

2-4. MakeShared vs. MakeShareable

Unreal에서의 사용 방식 2개

MakeShared - 권장하는 방법

TSharedPtr<Quest> quest = MakeShared<Quest>("드래곤 토벌");
  • 참조 카운트 블록이 하나로 묶여있음
    (같이 묶여 있음)

MakeShareable - 예전 방법

Quest* rawQuest = new Quest("드래곤 토벌");
TSharedPtr<Quest> quest = MakeShareable(rawQuest);
  • 참조 카운트 블록을 따로 생성
    (잠재적인 파편화 유발 가능성이 존재)
  • 이 방식이 남아 있는 경우?
    • Raw Pointer를 Shared 로 감싸고 싶음
      (ex : 외부 라이브러리)
    • 그런 경우를 위해 남겨둔 레거시 방식
// 커스텀 삭제 함수 예시
void CustomDeleter(MyObject* Obj)
{
    // 특별한 정리 로직
    Obj->Cleanup();
    delete Obj;
}

TSharedPtr<MyObject> Ptr(new MyObject(), [](MyObject* Obj){ CustomDeleter(Obj); });
  • 불가치하게 먼저 Pointer를 사용하는 경우 등에 대비하여 MakeShareable
  • 그 외에는 MakeShared 사용 권장

2-5. TSharedRef vs TSharedPtr

// TSharedPtr - 비어있을 수도 있다
TSharedPtr<Quest> maybeQuest = nullptr;
if (maybeQuest.IsValid()) // null 체크 필요
{
    maybeQuest->StartQuest();
}

// TSharedRef - 절대 비어있을 수 없다
TSharedRef<Quest> definiteQuest = MakeShared<Quest>("드래곤 토벌");
definiteQuest->StartQuest(); // 바로 사용 가능, null 체크 불필요
  • Ptr : Null 가능
    • IsValid 체크 필요
  • Ref : Null 불가, 객체 있어야 함
    • 뭔가 들어있는게 보장됨
    • 생성시, 반드시 뭔가 넣어야 하며, 빈 깡통으로 쓸 수 없음
// TSharedRef → TSharedPtr: 항상 가능 (암시적 변환)
TSharedRef<Quest> QuestRef = MakeShared<Quest>("드래곤 토벌");
TSharedPtr<Quest> QuestPtr = QuestRef; // ✅ 자동 변환

// TSharedPtr → TSharedRef: 조건부로 가능 (명시적 변환 필요)
TSharedPtr<Quest> MaybeQuest = MakeShared<Quest>("보물 찾기");
if (MaybeQuest.IsValid())
{
    TSharedRef<Quest> QuestRef = MaybeQuest.ToSharedRef(); // ✅ 유효할 때만
}

// 주의: null인 TSharedPtr을 TSharedRef로 변환하면 크래시!
TSharedPtr<Quest> NullQuest = nullptr;
// TSharedRef<Quest> BadRef = NullQuest.ToSharedRef(); // ❌ 런타임 assert!
  • 서로 변환 가능?
    • Ref -> Ptr : 항상 가능
    • Ptr -> Ref : 명시적 변환해야 함
      (이 때 ptr이 null이라면 크래시)
  • Null일 수 없는 경우, Ref 를 사용하면 불필요한 체크를 피할 수 있어 편리

3. TWeakPtr - 순환 참조 문제를 해결하는 열쇠 🔐

3-1. 순환 참조 문제가 뭔지 복습

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

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

void CreateCircularReference()
{
    TSharedPtr<Parent> parent = MakeShared<Parent>();
    TSharedPtr<Child> child = MakeShared<Child>();

    parent->MyChild = child;   // 자식 참조 카운트 +1
    child->MyParent = parent;  // 부모 참조 카운트 +1
}
  • Shared_Ptr 끼리 서로를 가리키면
    양 측의 강한 참조가 0이 안되기에
    메모리에 무한히 남아버림

3-2. TWeakPtr이 어떻게 이 문제를 해결할까?

class Parent
{
public:
    TSharedPtr<Child> MyChild; // 강한 참조
};

class Child
{
public:
    TWeakPtr<Parent> MyParent; // 약한 참조로 변경!
};
void FixedCircularReference()
{
    TSharedPtr<Parent> parent = MakeShared<Parent>();
    TSharedPtr<Child> child = MakeShared<Child>();

    parent->MyChild = child;   // 강한 참조 → 자식 보호
    child->MyParent = parent;  // 약한 참조 → 부모 삭제 방해하지 않음
}
  • 연관성이 비교적 적은 편 or 개념 상 하위 객체에
    WeakPtr을 사용하여 순환 참조 해결

3-3. Pin() 메서드 사용

if (TSharedPtr<Parent> p = MyParent.Pin())
{
    // Pin 성공: 부모 객체가 아직 살아있음
    // p가 있는 동안은 RefCount가 올라가므로 안전하게 접근 가능
    p->DoSomething();
}
else
{
     // Pin 실패: 부모 객체가 이미 삭제됨
    UE_LOG(LogTemp, Warning, TEXT("부모는 이미 삭제됨!"));
}
  • SharedPtr을 통해 가져오기에 RefCount 추가하여
    당장은 사라지지 않아 안전하게 사용 가능

Pin() 메서드 (단순화된 내부 구현)

// TWeakPtr::Pin() 내부 동작 (단순화)
TSharedPtr<T> TWeakPtr<T>::Pin() const
{
    if (IsValid()) // 원본 객체가 아직 살아있다면
    {
        // RefController를 통해 새로운 SharedPtr 생성
        return TSharedPtr<T>(ObjectPtr, RefController);
    }
    return TSharedPtr<T>(); // 이미 삭제된 경우 → 빈 SharedPtr 반환
}

안전한 사용 패턴

class Child
{
private:
    TWeakPtr<Parent> MyParent; // 부모에 대한 약한 참조
    
public:
    void TalkToParent()
    {
        // 1. Pin()으로 약한 참조 → 강한 참조 승격
        TSharedPtr<Parent> ParentPtr = MyParent.Pin();

        // 2. 유효성 검사
        if (ParentPtr.IsValid()) 
        {
            // 3. 안전한 접근 (Pin으로 보호된 범위 내)
            ParentPtr->ListenToChild();
            ParentPtr->RespondToChild("잘했구나!");

            // 복잡한 작업도 문제 없음 (스코프 끝날 때까지 삭제 안 됨)
            for (int i = 0; i < 100; ++i)
            {
                ParentPtr->ProcessChildRequest(i);
            }
        }
        else
        {
            // 부모 객체가 이미 삭제됨
            UE_LOG(LogTemp, Warning, TEXT("부모가 이미 삭제되어 통신 불가"));
            HandleOrphanState(); // 고아 상태 처리
        }

        // 4. ParentPtr이 스코프를 벗어나면 자동으로 RefCount 감소
    }
    
    // ❌ 잘못된 예시 (하지 말 것)
    void DangerousDirectAccess()
    {
        // Pin 없이 바로 접근 → 컴파일 에러
        // MyParent->ListenToChild();

        // 매번 Pin() 호출하는 것도 비효율적
        if (MyParent.Pin())
        {
            MyParent.Pin()->ListenToChild(); // Pin()을 두 번 호출 → 불필요한 RefCount 증감
        }
    }
};
  • Pin 성능?
    Shared_ptr을 직접 복사하는 것과 비슷하거나 약간 적을 수 있음
    (성능 상의 이슈를 걱정할 필요는 현재로선 딱히?)

실전 예제: 이벤트 리스너

class EventManager
{
private:
    TArray<TWeakPtr<IEventListener>> Listeners; // 약한 참조로 리스너 관리
    
public:
    void RegisterListener(TSharedPtr<IEventListener> Listener)
    {
        // TSharedPtr를 TWeakPtr로 저장 (자동 변환)
        Listeners.Add(Listener);
    }
    
    void BroadcastEvent(const FGameEvent& Event)
    {
        // 역순 순회로 안전하게 삭제 처리
        for (int32 i = Listeners.Num() - 1; i >= 0; --i)
        {
            if (TSharedPtr<IEventListener> Listener = Listeners[i].Pin())
            {
                // ✅ 살아있는 리스너에게 이벤트 전달
                Listener->OnEvent(Event);
            }
            else
            {
                // ❌ 이미 삭제된 리스너 → 목록에서 제거
                Listeners.RemoveAt(i);
                UE_LOG(LogTemp, Log, TEXT("삭제된 리스너 제거됨"));
            }
        }
    }
};
  • 이전에 보았던 EnemyManager 처럼
    하나의 적이 피격받았을때, 다른 Enemy들도 반응하고 싶을 때 사용가능한 패턴

3-4. 실제 게임에서 TWeakPtr 활용하기 - UI 시스템

문제 상황: 모두 강한 참조일 때

class UIWidget
{
private:
    TSharedPtr<UIWidget> ParentWidget;     // 부모 (강한 참조)
    TArray<TSharedPtr<UIWidget>> Children; // 자식들 (강한 참조)

public:
    void AddChild(TSharedPtr<UIWidget> Child)
    {
        Children.Add(Child);
        Child->ParentWidget = AsShared(); // 자식이 부모를 강하게 참조
        // 🚨 순환 참조 발생! 부모와 자식이 서로를 놓지 않음 → 메모리 누수
    }
};
  • AsShared()?
    자기 자신을 가리키는 Shared_ptr를 만들어 반환
    (TSharedFromThis 를 상속받아야 가능함)
  • 서로 Shared Ptr로 묶는 것은 주의할 것

해결책: 자식 → 부모를 TWeakPtr로 변경

// AsShared()를 사용하려면 TSharedFromThis를 상속받아야 함
class UIWidget : public TSharedFromThis<UIWidget>
{
private:
    TWeakPtr<UIWidget> ParentWidget;       // 자식 → 부모 : 약한 참조 ✅
    TArray<TSharedPtr<UIWidget>> Children; // 부모 → 자식 : 강한 참조 유지

public:
    void NotifyParent(FString Message)
    {
        // 1. 부모가 아직 살아있는지 확인
        TSharedPtr<UIWidget> Parent = ParentWidget.Pin();

        if (Parent.IsValid())
        {
            // 2. Pin 성공 → 부모 안전하게 접근
            Parent->ReceiveMessage(Message);
            UE_LOG(LogTemp, Log, TEXT("부모에게 메시지 전달: %s"), *Message);
        }
        else
        {
            // 3. 부모가 이미 삭제됨
            UE_LOG(LogTemp, Warning, TEXT("부모가 없어 메시지 전달 실패"));
        }
    }

    void AddChild(TSharedPtr<UIWidget> Child)
    {
        Children.Add(Child);                  // 부모 → 자식 : 강한 참조
        Child->ParentWidget = AsShared();     // 자식 → 부모 : 약한 참조 (TWeakPtr에 대입하면 자동 변환)
        // ✅ 순환 참조 없음! 부모 삭제 시 자식도 자연스럽게 정리
    }
};
  • AsShared 가 TWeakPtr에 대입하면 자동 변환되기에 안전

4. TUniquePtr - 독점 소유권의 힘 🏛️

4-1. 독점 소유권이 뭔지 이해해보자

// 생성
TUniquePtr<Weapon> myWeapon = MakeUnique<Weapon>("레이저 소드");

// 복사 불가능
// TUniquePtr<Weapon> anotherWeapon = myWeapon; // ❌ 컴파일 에러!

// 이동은 가능
TUniquePtr<Weapon> newOwner = MoveTemp(myWeapon); // ✅ 소유권 이전
// 이제 myWeapon은 nullptr

이게 왜 좋을까?

  • 메모리 효율성이 뛰어남
  • 소유권이 명확
  • 자동 정리가 확실

4-2. RAII 패턴이란?

void SaveGameFunction()
{
    // 파일 자동 관리
    TUniquePtr<FileHandle> saveFile = MakeUnique<FileHandle>("save.dat");

    saveFile->WriteData("플레이어 레벨: 50");
    saveFile->WriteData("골드: 10000");
    saveFile->WriteData("현재 위치: 던전 입구");

    // 함수 종료 시 파일 자동으로 닫힘
}
  • Resource Acquisition Is Initialization
    메모리/리소스의 생명주기를 ‘객체’의 생명주기에 묶어 자동으로 관리
  • 객체 생성 시, 자원 획득
    파괴 시, 자원 해제
    이 개념에 가장 적합한 스마트 포인터임
    • 스마트 포인터는 생성자 및 소멸자를 자동으로 호출하기에

4-3. Move 의미론을 이해해보자

// 무기를 만들어요
TUniquePtr<Weapon> sword = MakeUnique<Weapon>("엑스칼리버");

// 복사 시도 - 불가능
// TUniquePtr<Weapon> copiedSword = sword; // ❌ 컴파일 에러

// 이동 - 가능
TUniquePtr<Weapon> movedSword = MoveTemp(sword); // ✅ ㅇㅋ

if (sword.IsValid())
{
    // 이 블록은 실행되지 않음
    UE_LOG(LogTemp, Log, TEXT("sword는 여전히 유효"));
}
else
{
    // 이 블록이 실행됨
    UE_LOG(LogTemp, Log, TEXT("sword는 이제 nullptr"));
}

// movedSword가 엑스칼리버를 소유
movedSword->Attack(); // ✅ 정상 작동
  • Move : RValue와 연관된 개념이며
    데이터의 소유권을 넘긴다는 느낌
    (넘긴 순간부터, 넘긴 포인터는 이제 사용하면 안됨)
    • 복사는 성능적으로 무거운 편
    • 이동은 포인터하나 바꾸면 됨!
  • 소유권을 넘길때, 기존의 객체가 ‘더 이상 필요 없어야 함’
    (이러한 값 형태를 표현하기에 RValue를 사용)
    • 그렇기에 C++에서 RValue 캐스팅을 해야 하는 이유

실제 활용 패턴

// 팩토리 패턴에서의 활용
TUniquePtr<Enemy> CreateEnemy(EnemyType Type)
{
    switch(Type)
    {
        case EnemyType::Orc:
            return MakeUnique<OrcEnemy>(); // 자동으로 Move 발생
        case EnemyType::Dragon:
            return MakeUnique<DragonEnemy>(); // 자동으로 Move 발생
        default:
            return nullptr;
    }
}

// 사용하는 쪽
void SpawnEnemies()
{
    TUniquePtr<Enemy> Orc = CreateEnemy(EnemyType::Orc);
    
    // 컨테이너에 저장할 때도 Move 사용
    TArray<TUniquePtr<Enemy>> Enemies;
    Enemies.Add(MoveTemp(Orc)); // Move로 효율적으로 저장
    
    // 이제 Orc는 nullptr
    // Enemies 배열이 오크의 유일한 소유자
}
  • 팩토리는 Unique_Ptr로만 생성하여 넘겨줌
  • 사용하는 쪽에서 반환받을때 Unique_Ptr을 TArray로 관리
    (Move를 통해 사용)

4-4. 실제 게임에서 TUniquePtr 활용하기

class TextureManager
{
private:
    // 각 텍스처는 매니저가 독점적으로 소유
    TMap<FString, TUniquePtr<Texture>> LoadedTextures;
    
public:
    void LoadTexture(FString TextureName)
    {
        // 새 텍스처를 로드
        TUniquePtr<Texture> NewTexture = MakeUnique<Texture>(TextureName);
        
        UE_LOG(LogTemp, Log, TEXT("텍스처 로딩 시작: %s"), *TextureName);
        
        // 맵에 이동 (소유권 이전)
        LoadedTextures.Add(TextureName, MoveTemp(NewTexture));
        
        UE_LOG(LogTemp, Log, TEXT("텍스처 로드 완료: %s"), *TextureName);
        // 이 시점에서 NewTexture는 nullptr
    }
    
    void UnloadTexture(FString TextureName)
    {
        // 맵에서 제거하면 TUniquePtr이 자동으로 메모리를 해제
        if (LoadedTextures.Remove(TextureName) > 0)
        {
            UE_LOG(LogTemp, Log, TEXT("텍스처 언로드 완료: %s"), *TextureName);
        }
        else
        {
            UE_LOG(LogTemp, Warning, TEXT("언로드할 텍스처가 없어요: %s"), *TextureName);
        }
    }
    
    Texture* GetTexture(FString TextureName)
    {
        // 텍스처를 찾아서 원시 포인터로 반환
        // (소유권은 넘기지 않고, 접근만 허용)
        if (TUniquePtr<Texture>* Found = LoadedTextures.Find(TextureName))
        {
            return Found->Get(); // 원시 포인터 반환
        }
        return nullptr; // 못 찾았음
    }
    
    // 매니저가 소멸될 때 모든 텍스처가 자동으로 정리
    ~TextureManager()
    {
        UE_LOG(LogTemp, Log, TEXT("텍스처 매니저 종료 - 모든 텍스처 정리"));
        // LoadedTextures 맵이 정리되면서 모든 TUniquePtr도 자동으로 정리
    }
};
  • Map에서 제거하자마자 메모리 해제

  • 소멸자 시점에서 Unique_ptr이 정리되기에
    메모리 누수에 안전

5. 언제 뭘 써야 할까? - 선택 가이드 🙄

  • 가이드이며 정답은 아님
    (케바케)

5-1. 스마트 포인터 선택하는 방법

1단계: 이게 UObject인가?

// UObject 계열 - 엔진이 관리
UPROPERTY()
class AMyActor* GameActor;

// 일반 C++ 클래스 - 스마트 포인터 필요
class GameLogic {};  // UObject 상속 안 함
TSharedPtr<GameLogic> Logic = MakeShared<GameLogic>();
  • Unreal GC에게 맡기자!
  • 정말 작은 경우에만 스마트 포인터 고민

2단계: 혼자서만 사용하나? (소유권 패턴 파악)

// 독점 소유가 명확한 경우 - TUniquePtr 적합
class AudioManager 
{
private:
    TUniquePtr<SoundEngine> Engine; // 오디오 매니저만 엔진을 소유
};

// 여러 곳에서 접근 - 다음 단계 판단 필요
class GameData 
{
    // UI, 게임로직, 세이브시스템 등 여러 곳에서 필요
};
  • 혼자서 사용한다면 Unique_Ptr이 제일 효율적

3단계: 여러 곳에서 공유해야 하나? (접근 패턴 분석)

// ❌ 비권장 - 오버헤드 발생
void ProcessQuest(TSharedPtr<Quest> InQuest)
{
    InQuest->Update();
}

// ✅ 권장 - 더 효율적
void ProcessQuest(const Quest& InQuest)
{
    InQuest.Update();
}

// 또는 포인터로
void ProcessQuest(Quest* InQuest)
{
    if (InQuest)
    {
        InQuest->Update();
    }
}
  • Shared_ptr을 매개변수로 주었을때도, RefCount가 늘어나는 점은 유의할 것
    (참조 or 포인터 권장)

4단계: null이 될 수 있나? (생명주기 안정성)

// null 가능 - TSharedPtr
TSharedPtr<NetworkConnection> Connection; 

// 항상 유효 - TSharedRef
TSharedRef<InputManager> Input = MakeShared<InputManager>(); 
  • Reference 는 ‘Null’ 대입이 안된다…

5단계: 순환 참조 위험이 있나? (관계 구조 분석)

// 순환 참조 위험 - TWeakPtr 필요
class UIPanel 
{
    TArray<TSharedPtr<UIWidget>> Children; // 강한 참조
};

class UIWidget 
{
    TWeakPtr<UIPanel> Parent; // 약한 참조로 순환 방지
};
  • Weak 고려!

5-2. 실제 선택하기 어려운 상황들과 해결법

상황 1: “지금은 임시인데, 나중에 요구가 바뀔 수 있는” 경우

해결 접근법: 처음에는 안전하게, 소유권이 명확해지면 최적화

  • 스마트 포인터에서 가장 중요한 것은
    소유권 설계‘임!

  • 처음부터 설계하면 구조가 너무 딱딱해질 수 있음

// 초기: 요구가 불명확 → 안전한 공유 모델
class InventoryManager
{
private:
    TSharedPtr<PlayerInventory> Inventory;
public:
    TSharedPtr<PlayerInventory> GetInventory() { return Inventory; }
};

// 최적화: 독점 소유가 명확해졌을 때만 전환
class InventoryManager
{
private:
    TUniquePtr<PlayerInventory> Inventory;  // 독점 소유
public:
    PlayerInventory* GetInventory() { return Inventory.Get(); } // 접근만 허용
};

상황 2: “성능이 중요한지, 안전성이 중요한지” 애매한 경우

  • 성능 기준만으로 스마트 포인터를 선택하는 것은 잘못된 접근
    • Shared_Ptr이 무겁다고 Unique_ptr로 바꾸는 것은 잘못된 선택일 수 있다!
// 독점 소유 → TUniquePtr
class Renderer
{
private:
    TUniquePtr<RenderQueue> Queue;  
public:
    void Render() { Queue->Process(); }
};

// 여러 시스템에서 참조 → TSharedPtr 또는 TWeakPtr
class EventSystem
{
private:
    TArray<TWeakPtr<EventListener>> Listeners; 
};

상황 3: “팀원들의 숙련도가 제각각인” 경우

팀에 C++ 초보자가 있다면, 기술적으로 최적이 아니더라도 이해하기 쉬운 선택이 더 나을 수 있음.

  • 혼자 작업하는게 아님
  • 최적화는 일단 ‘안정성’이 보장되어야 하는 것임을 잊지 말자
// 고급 팀(숙련자 위주): 효율적 구조 선택 가능
class ResourceManager
{
private:
    TMap<FString, TUniquePtr<Resource>> Resources;
public:
    Resource* Get(const FString& Name); // 소유권 전달 없음
};

// 다양한 숙련도 팀: 안전한 공유 모델이 유지보수에 유리
class ResourceManager
{
private:
    TMap<FString, TSharedPtr<Resource>> Resources;
public:
    TSharedPtr<Resource> Get(const FString& Name); 
};

상황 4: “레거시 코드와 섞어야 하는” 경우

기존 프로젝트에 스마트 포인터를 도입할 때는 점진적으로 적용

  • 예전 코드를 뜯어고치는 것은 다시한번 생각해보기
    • 간단한 부분부터 천천히 고쳐보기
// 1단계: 새 코드부터 스마트 포인터 적용
class NewFeatureManager
{
private:
    TUniquePtr<NewFeature> Feature;
public:
    NewFeature* GetFeature() { return Feature.Get(); }
};

// 2단계: 레거시 코드에는 raw 포인터로 전달
class LegacySystem
{
public:
    void UseFeature(NewFeature* F) { /* 기존 방식 유지 */ }
};

// 3단계: 레거시 영역을 점진적으로 스마트 포인터화

정리

  • 기본적으로 UObject라면 GC 관리에
  • UObject가 아니라면 Smart 포인터를 통해
    메모리 누수 상황을 방지하기

  • Smart 포인터의 설정은 ‘소유권’에 있다는 점을 인식
    • 혼자만 사용하는게 ‘정말 명확’하다면 Unique_Ptr
    • 기본적으로 Shared_Ptr이 안전
      • 다만 상호 Shared_ptr 연결이라면 한쪽을 Weak로
    • Weak_ptr은 ‘확인’만 하기 위함
      (명확한 소유 주체가 따로 있는 경우, Weak_ptr로 확인)
      (Shared_Ptr만 체크할 수 있음!)

TMI - TObjectPtr

UObject을 추적하는 포인터

  • 스마트 포인터는 아님
  • GC 최적화용으로 감싸는 레핑용 포인터
  • 기본 포인터보단 사용이 권장되긴 함(Unreal 엔진)
    • UObject를 멤버 변수로 ‘보관’하는 경우
    • 매개변수,지역변수 등에선 RawPointer가 더 가벼우며, 불필요한 최적화 대상을 늘리지 않음

댓글남기기