본문 바로가기
Game DevTip/C++

13. C++ 스마트 포인터에 대해서

by LIKE IT.라이킷 2025. 7. 19.

 

C++ 스마트 포인터란? 

메모리 관리는 C++ 프로그램에서 가장 중요하면서도 까다로운 부분 중 하나이다. 

전통적으로 C++에서는 new와 delete를 사용하여 수동으로 메모리를 할당 하고 해제해야 하지만, 

때문에 이 과정에서 실수 하나로 메모리 누수나 댕글링 포인터 같은 문제가 발생하게 된다. 

 

스마트 포인터는 C++11에서 이러한 문제를 해결하기 위해서 도입된 세가지 주요 스마트 포인터 기능이다. 

스마트 포인터의 종류는 아래 3가지가 있다. 

 

1. unique_ptr

2. shared_ptr

3. weak_ptr

 

해당 스마트 포인터들의 개념, 사용법, 그리고 실전 예제를 통해

메모리 관리를 안정적으로 하는 법을 알아보자. 

 

1. unique_ptr: 단일 소유권 포인터

std::unique_ptr는 가장 간단하면서도 자주 사용되는 스마트 포인터입니다. 이름에서 알 수 있듯이, 하나의 객체에 대한 소유권을 단 하나의 포인터만 가질 수 있습니다.

1.1 unique_ptr의 핵심 특징

특징설명

단일 소유권  unique_ptr만 특정 객체를 소유 가능
자동 메모리 해제 unique_ptr가 소멸될 때 자동으로 delete 호출
복사 불가 unique_ptr는 복사 생성/대입이 불가능
이동 지원 std::move로 소유권 이전 가능
make_unique 사용 권장 예외 안전하고 간결함

1.2 unique_ptr 기본 사용법

#include <iostream>
#include <memory>
using namespace std;

class Player {
private:
    int id;
    int* pHaveWeaponID;
public:
    Player() : id(10){
        pHaveWeaponID = new int[10];
        cout << "플레이어 무기할당" << endl;
    }
    ~Player() {
        cout << "MyClass 소멸\\n";
        delete[] pHaveWeaponID;
        pHaveWeaponID = nullptr;
    }
    void printHaveWeapon() { cout << "플레이어 무기 장착 ID : " << id << endl; }
};

int main() {
    // 생성 및 초기화
    unique_ptr<Player> ptr = make_unique<Player>();
    ptr->printHaveWeapon(); // 메서드 호출

    // 소유권 이전
    unique_ptr<Player> ptr2 = move(ptr);

    // ptr은 이제 nullptr
    if (!ptr) {
        cout << "ptr은 더 이상 객체를 소유하지 않음\\n";
    }

    // ptr2는 이제 객체를 소유
    ptr2->printHaveWeapon();

    // 함수 종료 시 ptr2가 소멸되면서 Player 객체도 자동으로 소멸됨
}

 

해당 에제에서 주목할 점은 다음과 같다. 

 

1. make_unique<Player>() 를 사용해서 Player 객체를 생성한다. 

2. move(ptr) 를 통해서 ptr에서 ptr2로 소유권 이전

3. 소유권 이전 후 ptr은 더이상 객체를 가리키지 않는다. 

4. Main함수가 종료될 대 ptr2가 자동으로 소멸되면서 Player 객체도 자동으로 소멸된다.(소멸자 호출)

 

1.3 make_unique vs new

unique_ptr를 초기화 할떄는 직접 new를 사용하는 것 보다 make_unique를 사용하는 걸 권장. 

이유는 예외 발생시에도 안전하게 작동하기 떄문이다.

// 권장: 예외 안전
auto ptr = std::make_unique<MyClass>();

// 비권장: 예외 발생 시 누수 가능성
std::unique_ptr<MyClass> ptr(new MyClass());

 

make_unique는 C++14에서 추가 되었으며, 메모리 할당과 객체 생성을 한단계로 

처리하여 예외 발생시의 메모리 누수를 방지한다. 

 

1.4 배열에 대한 unique_ptr

unique_ptr은 배열도 관리가 가능. 

auto arr = std::make_unique<int[]>(5);
arr[0] = 10;
// arr이 소멸될 때 delete[] 호출됨

 

1.5 unique_ptr::get() - 원시 포인터 접근

경우에 따라 원시 포인터(raw pointer)가 필요할 때 get() 메서드를 사용하여 접근이 가능. 

*주의사항
- get()으로 얻은 원시 포인터를 delete하면 안된다는 것. 메모리관리는 여전히 unique_ptr이 자동 관리 담당.

#include <iostream>
#include <memory>
using namespace std;

int main()
{
    unique_ptr<int> myPtr = make_unique<int>(123);
    
    int* raw = myPtr.get();
    cout << *raw << endl; // 출력 123
    
    //여전하게 unique_ptr인 myPtr이 해당 메모리를 관리중.
    *raw = 456;
    cout << *myPtr << std::endl; //출력 456
}

 

 

1.6 unique_ptr 역참조 방법들

unique_ptr로 관리되는 객체에 접근하는 다양한 방법들

class Player {
public:
    void printId() {
        cout << "Player ID" << endl;
    }

    void printIdReference(Player* p) {
        cout << "Reference to Player: " << p << endl;
    }
};

int main() {
    unique_ptr<Player> RedPlayer(new Player());

    // 원시 포인터 얻기 (가장 일반적인 방법)
    Player* pPlayer = RedPlayer.get();

    // 다른 접근 방법들
    Player& pPlayer1 = *RedPlayer;         // 참조로 접근
    Player* pPlayer2 = &(*RedPlayer);      // 역참조 후 주소 얻기

    RedPlayer->printId();                  // 멤버 함수 직접 호출
    RedPlayer->printIdReference(pPlayer);  // 원시 포인터 전달

    return 0;
}

 

 


2. shared_ptr: 공유 소유권 포인터

shread_ptr은 여러 포인터가 하나의 객체를 공유할 수 있는 스마트 포인터. 

참조 카운팅(reference counting) 매커니즘을 사용해서 객체의 수명을 관리한다. 다만 순환참조로 인해서 메모리 누수가 될 가능성이 있는데 이것은 아래에서 보도록 하겠다.

 

2.1 shared_ptr의 핵심 특징

공유 가능 여러 shared_ptr가 같은 객체를 가리킬 수 있음
자동 메모리 관리 참조 카운트가 0이 되면 자동으로 삭제
use_count() 몇 개의 shared_ptr가 해당 객체를 공유하는지 확인
make_shared<T>() 안전하고 효율적인 생성 방법

 

2.2 shared_ptr의 기본 사용법

#include <iostream>
#include <memory>
using namespace std;

class Player {
public:
    void printId() {
        cout << "Player ID: " << this << endl;
    }
};

int main() {
    // 객체 생성
    shared_ptr<Player> player1 = make_shared<Player>();

    // 공유
    shared_ptr<Player> player2 = player1;

    // 사용
    player1->printId();
    player2->printId();

    // 참조 카운트 확인 (2가 출력됨)
    cout << "Reference count: " << player1.use_count() << endl;

    return 0;
    // 함수 종료 시 player1과 player2가 소멸되며 참조 카운트가 0이 되어
    // Player 객체도 자동으로 소멸됨
}

 

이 예제에서 눈여겨 볼 사항들이 있다면, 

 

1. make_shared<Player>()으로 Player 객체 생성

2. player2가 player1과 같은 객체를 공유하면서 참조 카운드가 2가 됨. 

3. 두 포인터 모두 같은 객체를 가리키므로 같은 주소를 출력함. 

4. use_count()로 현재 참조 카운트를 확인할 수 있다. 

5. main 함수 종료 시 두 포인터가 자동 소멸 되면서 참조 카운트가 0이 되고, Player 객체도 소멸.

 

2.3 주의할 점: 순환 참조 문제

shared_ptr를 사용할 때 가장 주의해야 할 문제는 순환 참조(circular reference)이다.

struct A;
struct B;

struct A {
    shared_ptr<B> b_ptr;
};

struct B {
    shared_ptr<A> a_ptr;
};

int main() {
    auto a = make_shared<A>();
    auto b = make_shared<B>();

    a->b_ptr = b; // A가 B를 참조
    b->a_ptr = a; // B가 A를 참조

    // 여기서 a와 b의 참조 카운트는 각각 2
    // main 함수가 종료되어도 참조 카운트는 1로 유지되어
    // A와 B 객체는 소멸되지 않음 -> 메모리 누수 발생!
}

 

2.4 주의할 점: 순환 참조로 인한 오류 예시 코드

#include <iostream>
#include <memory>
using namespace std;

class FPSPlayer {
private:
    int mLv;
    string mName;
    shared_ptr<FPSPlayer> mTeamPlayer;
public:
    FPSPlayer(const string& name) : mLv(1), mName(name)
    {
        cout << "FPSPlayer 플레이어 : " << mName << " 생성자 호출" << endl;
    }
    ~FPSPlayer()
    {
        cout << "FPS 플레이어 : " << mName << " 소멸자 호출" << endl;
    }
    void setTeamPlayer(shared_ptr<FPSPlayer>& player)
    {
        if (player == nullptr)
            return;
        mTeamPlayer = player;
        cout << "TeamPlayer Name : " << mTeamPlayer->mName << endl;
    }
};

int main() {
    auto pRedPlayer = make_shared<FPSPlayer>("RedPlayer");
    auto pOrangePlayer = make_shared<FPSPlayer>("OrangePlayer");

    pRedPlayer->setTeamPlayer(pOrangePlayer);
    pOrangePlayer->setTeamPlayer(pRedPlayer);
}

실행 결과:

FPSPlayer 플레이어 : RedPlayer 생성자 호출
FPSPlayer 플레이어 : OrangePlayer 생성자 호출
TeamPlayer Name : OrangePlayer
TeamPlayer Name : RedPlayer

 

위 코드는 소멸자가 호출되지 않는다. 

두개의 FPSPlayer 객체가 서로를 shared_ptr로 참조하고 있기 때문이다. 

 

(문제점)

  • pRedPlayer →(shared_ptr)→ pOrangePlayer
  • pOrangePlayer →(shared_ptr)→ pRedPlayer

해당 순환 참조로 인해서 참조 카운트가 0이 되지 않아 소멸자가 호출되지 않고

메모리 누수가 발생한다. 

 


3. weak_ptr: 약한 참조 포인터

weak_ptr은 shared_ptr과 함께 사용되는 스마트 포인터이다.

shared_ptr의 참조 카운트를 증가시키지 않고 객체를 약하게 참조한다. 

주로 순환참조 문제를 해결하기 위해서 사용. 

항목 shared_ptr weak_ptr
소유권 가짐 없음
참조 카운트 증가시킴 (use_count++) 증가시키지 않음 (use_count 유지)
메모리 해제 마지막 shared_ptr 소멸 시 영향 없음
객체 접근 방법 -> 바로 접근 가능 .lock() 후 접근 가능

 

#include <iostream>
#include <memory>
using namespace std;

class Player {
public:
    void sayHello() {
        cout << "Hello from Player!" << endl;
    }
};

int main() {
    shared_ptr<Player> sp = make_shared<Player>();
    weak_ptr<Player> wp = sp;  // 소유하지 않음, 참조만 함

    cout << "use_count: " << sp.use_count() << endl; // 1 출력

    if (auto locked = wp.lock()) {
        // weak_ptr을 shared_ptr로 잠금 (락)
        locked->sayHello();
        cout << "Locked use_count: " << locked.use_count() << endl; // 2 출력
    } else {
        cout << "Player is already destroyed." << endl;
    }

    sp.reset();  // 소유자가 소멸되면...

    if (auto locked = wp.lock()) {
        locked->sayHello();
    } else {
        cout << "Player is gone." << endl; // 이 메시지 출력됨(무조건)
    }

    return 0;
}

 

해당 예제를 보면 주목해야할 부분은 다음과 같다. 

 

1. weak_ptr은 shared_ptr를 참조하지만 참조 카운트를 증가시키지 않는다. 

2. .lock()을 호출하면 참조하는 객체가 아직 살아있는지 확인하고, 살아있다면 그 객체를 가리키는 shared_ptr을 반환. 

3. sp.reset()으로 원본 shared_ptr이 해제되면 wp.lock()은 빈 shared_ptr을 반환. 

 

3.3 순환 참조 문제 해결하기

앞서 보았던 순환 참조 문제를 weak_ptr로 해결해 보자.

#include <iostream>
#include <memory>
using namespace std;

class FPSPlayer {
private:
    int mLv;
    string mName;
    weak_ptr<FPSPlayer> mTeamPlayer; // 🔁 weak_ptr 사용
public:
    FPSPlayer(const string& name) : mLv(1), mName(name)
    {
        cout << "FPSPlayer 플레이어 : " << mName << " 생성자 호출" << endl;
    }
    ~FPSPlayer()
    {
        cout << "FPS 플레이어 : " << mName << " 소멸자 호출" << endl;
    }
    void setTeamPlayer(shared_ptr<FPSPlayer>& player)
    {
        if (player == nullptr)
            return;
        mTeamPlayer = player;
        if (auto team = mTeamPlayer.lock()) { // 🔓 shared_ptr로 잠금
            cout << "TeamPlayer Name : " << team->mName << endl;
        }
    }
};

int main() {
    auto pRedPlayer = make_shared<FPSPlayer>("RedPlayer");
    auto pOrangePlayer = make_shared<FPSPlayer>("OrangePlayer");

    pRedPlayer->setTeamPlayer(pOrangePlayer);
    pOrangePlayer->setTeamPlayer(pRedPlayer);

    cout << "main 종료 직전" << endl;
}

실행 결과:

FPSPlayer 플레이어 : RedPlayer 생성자 호출
FPSPlayer 플레이어 : OrangePlayer 생성자 호출
TeamPlayer Name : OrangePlayer
TeamPlayer Name : RedPlayer
main 종료 직전
FPS 플레이어 : OrangePlayer 소멸자 호출
FPS 플레이어 : RedPlayer 소멸자 호출

 

 

이제 소멸자가 제대로 호출 된다.

즉, 이 경우에는 class 내부에서 shared_ptr이 아닌 weak_ptr을 써야 순환참조를 막을 수 있다.

 

3.4 weak_ptr::lock()의 필요성과 작동 방식

weak_ptr은 직접 객체에 접근할 수 없고 .lock()을 통해

일시적인 shared_ptr을 얻어야 접근할 수 있다.

 

이유는 lock()의 동작 방식때문이다. 

1. weak_ptr이 참조하는 객체가 아직 살아있는지 검사. 

2. 살아있다면 해당 객체를 가리키는 새로운 shared_ptr을 반환(참조 카운트 증가)

3. 죽어있다면 빈(nullptr) shared_ptr을 반환. 

 

weak_ptr<Player> wp = sp; // sp는 shared_ptr

if (auto locked = wp.lock()) {
    // 객체가 살아있음: locked는 유효한 shared_ptr
    locked->doSomething(); // 안전하게 객체 사용 가능
} else {
    // 객체가 소멸됨: locked는 nullptr
    cout << "객체가 더 이상 존재하지 않습니다." << endl;
}

 

해당 방식의 경우 객체가 소멸 되었는지 안전하게 확인이 가능하고, 

일시적으로만 참조 카운트를 증가시켜서 객체에 접근하기 때문에

객체 접근전에 항상 유효성 검사가 강제로 진행되므로 안전하게 사용가능. 

 

3.5 실전 예제: FPS 게임의 타겟팅 시스템

실전에서 weak_ptr이 어떻게 활용될 수 있는지 게임 타겟팅 시스템 예제를 통해 살펴보자.

#include <iostream>
#include <memory>
using namespace std;

class FPSPlayer {
private:
    string mName;
    weak_ptr<FPSPlayer> mTarget; // 🔁 소유하지 않음

public:
    FPSPlayer(const string& name) : mName(name) {
        cout << mName << " 생성됨" << endl;
    }

    ~FPSPlayer() {
        cout << mName << " 사망 (소멸자 호출)" << endl;
    }

    void setTarget(shared_ptr<FPSPlayer>& target) {
        mTarget = target;
        cout << mName << "이(가) " << target->mName << "을(를) 타겟으로 설정함" << endl;
    }

    void fireAtTarget() {
        if (auto locked = mTarget.lock()) {
            cout << mName << "이(가) " << locked->mName << "을(를) 공격함!" << endl;
        } else {
            cout << mName << "의 타겟은 이미 죽었습니다!" << endl;
        }
    }
};

int main() {
    auto red = make_shared<FPSPlayer>("RedPlayer");
    auto blue = make_shared<FPSPlayer>("BluePlayer");

    red->setTarget(blue); // Red가 Blue를 타겟으로 지정

    red->fireAtTarget(); // 아직 살아있음 → 공격 가능

    cout << "\\n--- BluePlayer 사망 처리 ---\\n";
    blue.reset(); // BluePlayer 사망 (메모리 해제)

    red->fireAtTarget(); // 이제 공격 불가능, lock() 실패

    return 0;
}

실행 결과:

RedPlayer 생성됨
BluePlayer 생성됨
RedPlayer이(가) BluePlayer을(를) 타겟으로 설정함
RedPlayer이(가) BluePlayer을(를) 공격함!

--- BluePlayer 사망 처리 ---
BluePlayer 사망 (소멸자 호출)
RedPlayer의 타겟은 이미 죽었습니다!

 

 

해당 예제에서는 weak_ptr이 잘 사용된다. 

1. RedPlayer가 BluePlayer를 타겟으로 설정(weak_ptr로 참조)

2. 타겟에 공격 시도 전 항상 lock()으로 유효성 검사.

3. BluePlayer가 사망(reset)하면 자동으로 타겟 참조가무효화 됨. 

4. 이후 공격 시도는 안전하게 실패 처리 가능.

 

해당 패턴의 경우 유닛간 상호참조(팀원, 적등등)과, Ai의 타겟 추적, 미사일이나 총알 타겟 추적등등에도 유용하며

더 나아가 UI요소가 게임 객체를 참조하거나 네트워크 세션이나 리소스를 참조 할떄도 유용하게 사용이 가능하다. 

 


 

4. 스마트 포인터 선택 가이드

상황권장 스마트 포인터

단일 객체에 대한 단독 소유권 unique_ptr
여러 객체가 리소스를 공유해야 함 shared_ptr
순환 참조 가능성이 있음 shared_ptr + weak_ptr
캐시, 옵저버 패턴 등 weak_ptr

 

일반적인 권장 사항

1. 기본적으로 unique_ptr을 사용. : 가장 가벼우며 의도를 명확하게 전달한다. 

2. 진짜 공유가 필요할 때만 shared_ptr을 사용 : 참조 카운팅에 약간의 오버헤드가 있음. 

3. 순환 참조 가능성이 존재할 시 weak_ptr을 사용 : 부모 - 자식, 상호 참조 관계 등. 

4. 항상 make_unique, make_shared 사용 : 예외 안전성과 성능을 위해서 이다. 

5. 원시 포인터로 변환은 자제 할 것 : 꼭 필요한 경우에만 .get() 사용. 

 

 

 

반응형

댓글