1. 이론
1.1. 스마트 포인터란 무엇인가?
스마트 포인터는 C++에서 동적으로 할당된 메모리(힙 메모리)의 생명주기를 자동화하여 관리해주는 클래스 템플릿이다. 기존의 C-style 포인터(Raw Pointer)는 new
로 메모리를 할당한 후, 프로그래머가 직접 delete
를 호출하여 해제해야 했다. 이 과정에서 delete
를 잊어버리면 메모리 누수(Memory Leak)가 발생하고, 이미 해제된 메모리에 접근하면 댕글링 포인터(Dangling Pointer) 문제가, 같은 메모리를 두 번 해제하면 이중 해제(Double Free) 오류가 발생하는 등 메모리 관리의 어려움이 컸다.
스마트 포인터는 RAII(Resource Acquisition Is Initialization, 자원 획득은 초기화다) 패턴을 기반으로 문제를 해결한다. RAII는 객체가 생성될 때(초기화) 자원을 획득하고, 객체가 소멸될 때(스코프를 벗어날 때) 자원을 자동으로 해제하는 C++의 핵심 디자인 패턴이다.
스마트 포인터 객체는 스택에 생성되며, 내부적으로 힙에 할당된 메모리 주소를 가리킨다. 스마트 포인터 객체가 스코프를 벗어나 소멸될 때, 소멸자에서 자동으로 delete
를 호출하여 힙 메모리를 해제해준다.
1.2. C++ 표준 스마트 포인터의 종류
C++11 표준부터 도입된 주요 스마트 포인터는 std::unique_ptr
, std::shared_ptr
, std::weak_ptr
세 가지다.
A. std::unique_ptr
(유일한 소유권)
- 개념:
unique_ptr
는 특정 자원에 대한 유일한(exclusive) 소유권을 가진다. 즉, 하나의 자원은 단 하나의unique_ptr
만이 가리킬 수 있다.
복사 생성자와 복사 대입 연산자가delete
로 선언되어 있어 복사가 불가능하며, 소유권을 이전하고 싶을 때는std::move
를 사용한 이동(move) 시맨틱을 사용해야 한다. - 동작 원리:
unique_ptr
는 내부에 관리하는 객체를 가리키는 단일 포인터만을 멤버로 가진다. 따라서 포인터 변수 하나와 크기가 같아(zero-cost abstraction) 성능 저하가 거의 없다.
스코프를 벗어나면 소멸자가 호출되고, 내부 포인터가nullptr
이 아니면delete
를 호출하여 메모리를 해제한다. - 커스텀 삭제자(Custom Deleter):
unique_ptr
는 두 번째 템플릿 인자로 삭제자(Deleter)를 지정할 수 있다. 이는malloc
으로 할당한 메모리를free
로 해제하거나, 파일 핸들을fclose
로 닫는 등delete
가 아닌 다른 방식으로 자원을 해제해야 할 때 유용하다. 삭제자의 타입이unique_ptr
의 타입의 일부가 되므로, 다른 삭제자를 사용하는unique_ptr
끼리는 서로 다른 타입으로 취급된다.struct FileCloser { void operator()(FILE* fp) const { if (fp) fclose(fp); } }; std::unique_ptr<FILE, FileCloser> file_ptr(fopen("data.txt", "r"));
- 배열 관리:
std::unique_ptr<T[]>
형태를 지원하여 동적으로 할당된 배열을 안전하게 관리할 수 있다. 이 경우 소멸자는delete[]
를 호출한다.
B. std::shared_ptr
(공유된 소유권)
- 개념:
shared_ptr
는 하나의 자원을 여러 포인터가 공유(shared)하여 소유할 수 있게 한다. 자원을 참조하는shared_ptr
가 몇 개인지 세는 참조 카운트(Reference Count)를 통해 메모리를 관리한다. 새로운shared_ptr
가 자원을 가리키거나 복사될 때 참조 카운트가 1 증가하고,shared_ptr
가 소멸되거나 다른 자원을 가리키게 될 때 참조 카운트가 1 감소한다. 참조 카운트가 0이 되면 자원은 안전하게 해제된다. - 동작 원리 (계층 구조):
- 사용자 영역:
std::shared_ptr<T> ptr = std::make_shared<T>(...)
코드를 작성. - C++ 표준 라이브러리:
std::make_shared
는 힙에 제어 블록(Control Block)과 관리 대상 객체(Managed Object)를 한 번의 할당으로 연속된 메모리 공간에 생성한다. (성능 이점)shared_ptr
객체 자체는 스택에 생성되며, 내부적으로 두 개의 포인터를 가진다.- 하나는 관리 대상 객체를 가리키는 포인터.
- 다른 하나는 제어 블록을 가리키는 포인터.
- 제어 블록은 다음 정보를 포함한다:
- 참조 카운트 (Strong Reference Count):
shared_ptr
의 개수. - 약한 참조 카운트 (Weak Reference Count):
weak_ptr
의 개수. - 관리 대상 객체를 해제하기 위한 삭제자(Deleter).
- (필요시) 커스텀 할당자(Allocator).
- 참조 카운트 (Strong Reference Count):
- 메모리 관리: 참조 카운트는 원자적(atomic)으로 증감된다. 이는 멀티스레드 환경에서도
shared_ptr
자체를 여러 스레드에서 복사하고 소멸시키는 것이 안전함을 보장한다. (단, 관리 대상 객체에 대한 접근은 별도의 동기화가 필요.) - 운영체제: C++ 런타임의 메모리 할당 요청(
new
)은 최종적으로 운영체제의 시스템 콜(e.g.,VirtualAlloc
,mmap
)을 통해 커널로부터 물리 메모리를 할당받아 사용자 공간에 매핑된다.
- 사용자 영역:
std::make_shared
vsshared_ptr<T>(new T)
:- 성능:
std::make_shared
는 제어 블록과 객체를 한 번의 힙 할당으로 생성하지만,shared_ptr<T>(new T)
는 객체 할당(new T
)과 제어 블록 할당(내부적으로) 두 번의 힙 할당이 필요하다. 따라서make_shared
가 더 효율적이다. - 예외 안전성:
make_shared
가 더 안전하다. 예를 들어foo(std::shared_ptr<T>(new T()), std::shared_ptr<U>(new U()))
와 같은 코드에서, 컴파일러가new T()
,new U()
,shared_ptr
생성을 어떤 순서로 실행할지 모르기 때문에new T()
호출 후new U()
에서 예외가 발생하면 첫 번째로 할당된 T 객체에 대한 메모리 누수가 발생할 수 있다.make_shared
는 이런 문제를 원천적으로 방지한다.
- 성능:
C. std::weak_ptr
(비소유 참조)
- 개념:
weak_ptr
는shared_ptr
가 관리하는 객체에 대한 비소유(non-owning) 참조다.weak_ptr
는 객체를 관찰만 할 뿐, 참조 카운트를 증가시키지 않으므로 객체의 생명주기에 영향을 주지 않는다. - 주요 용도: 순환 참조(Circular Reference) 해결:
- 두 객체가 서로를
shared_ptr
로 가리키는 경우, 서로가 서로의 참조 카운트를 1씩 들고 있어 참조 카운트가 절대 0이 되지 않는 문제가 발생한다. 이는 두 객체가 모두 메모리에서 해제되지 않는 메모리 누수로 이어진다. - 이때 한쪽(또는 양쪽)의 참조를
weak_ptr
로 바꾸면, 소유 관계의 고리가 끊어져 순환 참조가 해결된다.
- 두 객체가 서로를
- 사용법:
weak_ptr
는 직접적으로 객체에 접근할 수 없다. 객체에 접근하려면lock()
멤버 함수를 호출하여 유효한shared_ptr
를 얻어야 한다. 만약 원본 객체가 이미 소멸되었다면lock()
은 비어있는shared_ptr
를 반환한다.expired()
함수로 객체의 유효성을 미리 확인할 수도 있다.
1.3. 스마트 포인터 비교
특징 | Raw Pointer (T* ) |
std::unique_ptr<T> |
std::shared_ptr<T> |
std::weak_ptr<T> |
---|---|---|---|---|
소유권 | 불분명 (프로그래머 책임) | 유일한 소유권 (Exclusive) | 공유된 소유권 (Shared) | 소유권 없음 (Non-owning) |
생명주기 관리 | 수동 (new /delete ) |
자동 (RAII) | 자동 (참조 카운트) | 관찰만 함, 생명주기에 영향 없음 |
복사/이동 | 자유롭게 복사 가능 (위험) | 복사 불가, 이동만 가능 (std::move ) |
복사/이동 모두 가능 (복사 시 참조 카운트 증가) | 자유롭게 복사 가능 |
오버헤드 | 없음 | 거의 없음 (Raw Pointer와 동일 크기) | 큼 (제어 블록 포인터 추가, 원자적 연산 비용) | shared_ptr 와 유사한 크기 |
주요 사용처 | 레거시 코드, 소유권 없는 단순 참조(권장 안함) | 객체의 유일한 소유자. Pimpl Idiom. 기본 선택지. | 소유권 공유가 명확히 필요한 경우. (e.g., 그래프 노드) | shared_ptr 의 순환 참조 방지. 캐싱. |
위험성 | 메모리 누수, 댕글링 포인터, 이중 해제 | 안전. 단, get() 으로 얻은 포인터 관리 부주의 시 위험 |
순환 참조로 인한 메모리 누수 위험 | 접근 전 lock() 으로 유효성 검사 필수 (댕글링 포인터 방지) |
2. 실습 코드 (게임 시나리오)
다음은 게임에서 플레이어(Player
)가 고유한 무기(Weapon
)를 소유하고, 여러 플레이어가 하나의 길드(Guild
)에 가입하는 상황을 스마트 포인터로 구현한 예제다.
#include <iostream>
#include <string>
#include <vector>
#include <memory>
// 무기 클래스
class Weapon {
public:
Weapon(const std::string& name) : name_(name) {
std::cout << name_ << " 무기 생성!" << std::endl;
}
~Weapon() {
std::cout << name_ << " 무기 파괴!" << std::endl;
}
void attack() const {
std::cout << name_ << "(으)로 공격!" << std::endl;
}
private:
std::string name_;
};
class Player; // 전방 선언
// 길드 클래스
class Guild {
public:
Guild(const std::string& name) : name_(name) {
std::cout << "길드 " << name_ << " 창설!" << std::endl;
}
~Guild() {
std::cout << "길드 " << name_ << " 해체!" << std::endl;
}
void addMember(std::weak_ptr<Player> player);
void printMembers() const;
private:
std::string name_;
std::vector<std::weak_ptr<Player>> members_;
};
// 플레이어 클래스
class Player : public std::enable_shared_from_this<Player> {
public:
Player(const std::string& name) : name_(name) {
std::cout << "플레이어 " << name_ << " 생성!" << std::endl;
}
~Player() {
std::cout << "플레이어 " << name_ << " 소멸!" << std::endl;
}
void equipWeapon(std::unique_ptr<Weapon> weapon) {
// Q1: 플레이어는 무기를 유일하게 소유합니다.
// 여기서 std::unique_ptr를 사용하는 이유는 무엇일까요?
// 만약 std::shared_ptr<Weapon>을 사용했다면 어떤 장단점이 있을까요?
weapon_ = std::move(weapon);
}
void attack() const {
if (weapon_) {
weapon_->attack();
} else {
std::cout << "맨손 공격!" << std::endl;
}
}
void joinGuild(std::shared_ptr<Guild> guild) {
guild_ = guild;
// Q2: 플레이어가 길드에 가입할 때, 길드는 플레이어를 weak_ptr로 참조합니다.
// 왜 shared_ptr가 아닌 weak_ptr를 사용해야 할까요?
// 만약 길드가 shared_ptr로 멤버를 관리한다면 어떤 문제가 발생할 수 있나요?
guild->addMember(shared_from_this());
std::cout << name_ << "님이 " << guild.get() << " 길드에 가입했습니다." << std::endl;
}
const std::string& getName() const { return name_; }
private:
std::string name_;
std::unique_ptr<Weapon> weapon_;
std::shared_ptr<Guild> guild_; // 플레이어는 길드를 공유 소유
};
void Guild::addMember(std::weak_ptr<Player> player) {
members_.push_back(player);
}
void Guild::printMembers() const {
std::cout << "--- " << name_ << " 길드원 목록 ---" << std::endl;
for (const auto& weak_member : members_) {
if (auto member = weak_member.lock()) { // weak_ptr로 shared_ptr를 얻어옴
std::cout << " - " << member->getName() << std::endl;
} else {
std::cout << " - (탈퇴한 멤버)" << std::endl;
}
}
std::cout << "------------------------" << std::endl;
}
int main() {
// 길드 생성
auto guild = std::make_shared<Guild>("용사단");
{
// 플레이어 1 생성 및 무기 장착
// Q3: std::make_shared를 사용하여 플레이어를 생성했습니다.
// Player(new Player(...)) 대신 make_shared를 사용하는 것의 이점은 무엇인가요?
auto player1 = std::make_shared<Player>("검사");
auto sword = std::make_unique<Weapon>("엑스칼리버");
player1->equipWeapon(std::move(sword));
// 플레이어 2 생성
auto player2 = std::make_shared<Player>("마법사");
// 길드 가입
player1->joinGuild(guild);
player2->joinGuild(guild);
guild->printMembers();
player1->attack();
player2->attack();
std::cout << "--- '검사' 플레이어가 게임을 종료합니다. ---" << std::endl;
} // player1, player2의 shared_ptr 스코프 종료. player1, player2 소멸
// '검사' 플레이어가 사라진 후 길드원 목록 확인
// Q4: '검사' 플레이어 객체는 위 블록이 끝나면서 소멸되었습니다.
// 아래 printMembers()를 호출했을 때, 길드는 어떻게 '검사'가 탈퇴했음을 알 수 있을까요?
guild->printMembers();
return 0;
} // main 함수 종료. guild의 마지막 shared_ptr 소멸. guild와 weapon 소멸
3. 요약
C++에서 new
로 만든 객체는 다 쓰고 나서 delete
로 꼭 지워줘야 하는데 이걸 깜빡하면 메모리가 계속 쌓이는 '메모리 누수'가 생겨서 프로그램이 느려지거나 멈출 수 있습니다. 스마트 포인터는 이 delete
를 자동으로 해주는 도구입니다. RAII라는 원칙을 이용해서, 자기가 사라질 때(스코프를 벗어날 때) 자기가 가리키던 메모리도 알아서 착실하게 청소해줍니다.
종류는 크게 세 가지가 있습니다.
첫째, unique_ptr
는 '이건 내 거야!' 하고 혼자만 독점적으로 소유하는 포인터입니다. 그래서 복사는 안 되고, 소유권을 넘기고 싶으면 std::move
로 이사 보내듯이 통째로 옮겨야 합니다. 가볍고 빨라서 일단 스마트 포인터를 쓴다 하면 이걸 가장 먼저 고려하는 게 좋습니다.
둘째, shared_ptr
는 이름처럼 여러 포인터가 하나의 객체를 '공동 소유'할 때 씁니다. '참조 카운트'라는 걸 둬서, 자기를 가리키는 포인터가 몇 개인지 세고 있다가, 그 숫자가 0이 되면 '아, 이제 아무도 날 안 쓰는구나' 하고 메모리를 해제합니다. 여러 곳에서 같은 객체를 참조해야 할 때 유용하지만, unique_ptr
보다는 조금 무겁습니다.
셋째, weak_ptr
는 shared_ptr
의 단짝 친구인데, 소유권은 없는 '관찰자' 역할만 합니다. shared_ptr
끼리 서로를 가리키면 '너 때문에 나 못 죽어', '아니, 너 때문에 나도 못 죽어' 하면서 영원히 메모리에 남아버리는 '순환 참조' 문제가 생길 수 있기 때문입니다. 이때 weak_ptr
를 쓰면 이 고리를 끊어서 메모리 누수를 막을 수 있습니다. 객체에 접근하려면 lock()
을 통해 임시로 shared_ptr
를 얻어서 안전하게 사용해야 합니다.
C++에서 동적 메모리를 다룰 땐 가급적 스마트 포인터를 사용해서 메모리 누수나 다른 골치 아픈 문제들을 예방하는 게 현대적인 C++ 프로그래밍의 핵심이라고 할 수 있습니다.
'C++' 카테고리의 다른 글
std::vector (0) | 2025.09.12 |
---|---|
std::map (1) | 2025.09.11 |
C++의 4대 캐스팅 (0) | 2025.09.09 |
RAII (Resource Acquisition Is Initialization) (0) | 2025.09.08 |
RTTI (Run-Time Type Information) (1) | 2025.09.05 |