16. 스마트 포인터, 이동 의미론, 그리고 더 안전한 코드
16. 스마트 포인터, 이동 의미론, 그리고 더 안전한 코드
지난 장에서 우리는 new와 delete를 직접 다루며, C++의 힘과 위험이 같은 문에서 들어온다는 사실을 보았습니다. 이제는 한 단계 더 나아갈 차례입니다. 현대 C++이 정말로 묻는 질문은 "언제 delete하지?"가 아닙니다. "이 자원을 누가 소유하느냐?" 입니다.
이 관점이 중요합니다. 원시 포인터(raw pointer)는 주소는 들고 있지만, 그 주소의 수명과 책임까지 설명해 주지는 못합니다. 반면 현대 C++은 객체의 수명에 자원 정리를 묶는 RAII 방식으로, 메모리와 자원을 더 안전하게 다루도록 유도합니다.12

raw new/delete 중심 사고에서 왜 벗어나야 할까
초보자 시절에는 new로 만들고 delete로 지우면 된다고 생각하기 쉽습니다. 하지만 실제 코드는 그렇게 반듯하게 흘러가지 않습니다. 함수가 중간에 return할 수도 있고, 예외가 생길 수도 있고, 코드가 길어지면서 "누가 치워야 하지?"가 흐려지기 쉽습니다. 그러면 메모리 누수, 이중 해제, 댕글링 포인터가 뒤늦게 튀어나옵니다.
현대 C++은 이런 위험을 줄이기 위해, 자원을 객체가 소유하게 하고 객체가 사라질 때 자동으로 정리되게 하자는 쪽을 선택했습니다.12 다시 말해, 손으로 반납 시점을 외우는 대신, 타입 자체에 책임을 넣는 것입니다.
그래서 오늘의 핵심 문장은 이것입니다.
원시 포인터는 "주소"를 표현하는 데는 좋지만, "소유권"을 표현하는 데는 부족하다.
std::unique_ptr: 한 자원에는 한 주인
스마트 포인터의 출발점은 std::unique_ptr입니다. 이름 그대로 오직 한 객체만 자원을 소유합니다.3 누가 봐도 책임자가 분명하다는 뜻입니다. 그래서 현대 C++에서는 공유가 꼭 필요하다는 확신이 없으면, 우선 unique_ptr부터 떠올리는 습관이 좋습니다.3
가장 편한 생성 방법은 std::make_unique입니다.
#include <iostream>
#include <memory>
#include <string>
using namespace std;
class Item {
private:
string name;
public:
Item(string name) : name(std::move(name)) {}
void print() const {
cout << name << 'n';
}
};
void useItem(unique_ptr<Item> item) {
item->print();
}
int main() {
auto item = make_unique<Item>("나무 검");
useItem(std::move(item));
if (!item) {
cout << "소유권이 다른 곳으로 이동했습니다.n";
}
}여기서 중요한 장면은 std::move(item)입니다. unique_ptr는 복사할 수 없습니다. 둘이 동시에 같은 대상을 소유하면, 나중에 둘 다 지우려 들 수 있기 때문입니다. 그래서 unique_ptr는 복사 금지, 이동 가능이라는 성격을 가집니다.3
이 점이 오히려 장점입니다. 코드가 우리에게 묻습니다. "정말 넘겨줄 거야?" 그리고 우리가 std::move로 명시했을 때만 소유권이 이동합니다. 실수로 슬쩍 복사하는 일이 막히는 셈입니다.
또한 unique_ptr는 매우 가볍게 설계되어 있어, 기본적인 경우에는 원시 포인터에 가까운 비용으로 사용할 수 있습니다.3 그래서 "안전한데 너무 무거운 것 아닌가요?"라는 걱정은 많은 경우 과한 걱정입니다.
std::shared_ptr: 여러 명이 함께 책임질 때
가끔은 자원 하나를 여러 객체가 함께 써야 할 때가 있습니다. 예를 들어 같은 문서를 화면에도 보여 주고, 자동 저장 기능도 붙잡고 있고, 미리 보기 창도 참조할 수 있습니다. 이럴 때는 std::shared_ptr를 씁니다. shared_ptr는 참조 수(reference count) 를 세다가, 마지막 소유자가 사라졌을 때 자원을 해제합니다.3
#include <iostream>
#include <memory>
using namespace std;
int main() {
auto data = make_shared<int>(42);
auto a = data;
auto b = data;
cout << *data << 'n';
cout << data.use_count() << 'n';
}위 코드에서 data, a, b는 같은 정수 하나를 함께 소유합니다. 그래서 셋 중 하나가 먼저 사라져도 당장 메모리가 해제되지 않습니다. 마지막 소유자까지 사라져야 비로소 정리됩니다.3
다만 shared_ptr는 편리한 만큼 비용도 있습니다. 참조 수를 관리해야 하고, 소유 관계가 복잡해질수록 코드 읽기도 어려워집니다. 그래서 실무 감각으로 말하면 이렇습니다.
shared_ptr는 "편해서 기본값"이 아니라, 정말 공유 소유가 필요할 때만 쓰는 특별한 선택지입니다.
std::weak_ptr: 붙잡지 않고 바라보기
shared_ptr를 배우면 거의 바로 따라오는 문제가 하나 있습니다. 바로 순환 참조입니다.3
예를 들어 A가 B를 shared_ptr로 가지고 있고, B도 A를 shared_ptr로 가지고 있으면 서로가 서로를 붙잡습니다. 둘 다 참조 수가 0이 되지 않으니 메모리가 해제되지 않습니다. 마치 "네가 먼저 손 놔"만 반복하는 상황입니다.
이럴 때 std::weak_ptr를 씁니다. weak_ptr는 대상을 관찰만 하고 소유하지는 않습니다. 즉, 참조 수를 늘리지 않습니다.3
#include <memory>
using namespace std;
struct B;
struct A {
shared_ptr<B> b;
};
struct B {
weak_ptr<A> a;
};weak_ptr는 필요할 때 lock()으로 잠시 shared_ptr를 얻어 사용합니다. 대상이 이미 사라졌다면 빈 포인터를 받습니다. 그래서 weak_ptr는 "같이 책임지는 사람"이 아니라 "상황을 지켜보는 사람"에 가깝습니다.
초보자 단계에서는 이렇게 기억하면 충분합니다. shared_ptr를 쓰는 순간, 언젠가 weak_ptr도 함께 배워야 합니다.
이동 의미론: 복사 대신 넘겨주기
이제 스마트 포인터와 찰떡궁합인 개념, 이동 의미론(move semantics) 으로 가 봅시다.
예전 방식의 복사는 원본의 내용을 통째로 새 메모리에 다시 만드는 일입니다. 작은 정수 하나쯤이야 괜찮지만, 큰 문자열, 거대한 vector, 파일 핸들처럼 복사가 비싸거나 아예 불가능한 자원은 이야기가 달라집니다. 이때 C++11은 "복사하지 말고, 필요하면 자원을 넘겨주자"는 길을 열어 주었습니다.4
간단한 느낌은 이렇습니다.
#include <iostream>
#include <string>
#include <utility>
using namespace std;
int main() {
string a = "Hello";
string b = a; // 복사
string c = std::move(a); // 이동
cout << "b: " << b << 'n';
cout << "c: " << c << 'n';
}복사는 "원본도 남기고 사본도 만든다"에 가깝고, 이동은 "원본이 들고 있던 자원을 새 객체가 가져간다"에 가깝습니다.4 그래서 이동은 대개 더 빠르고, 큰 객체일수록 이점이 커집니다.
여기서 꼭 조심할 점이 있습니다. std::move는 이름만 보면 뭔가를 번쩍 들어 옮길 것 같지만, 그 자체가 실제 이동을 수행하는 것은 아닙니다. std::move는 "이 객체를 이동 가능한 대상으로 취급해도 좋다"고 표시해 주는 도구에 가깝습니다. 실제 자원 이전은 이동 생성자나 이동 대입 연산자가 맡습니다.5
즉, std::move는 이삿짐센터가 아니라, 이삿짐센터를 부를 수 있게 문을 열어 주는 버튼에 더 가깝습니다.
이동된 객체는 어떻게 생각해야 할까
이동이 끝난 뒤의 원본 객체는 흔히 moved-from 상태가 됩니다. 이 상태의 객체는 파괴해도 안전하고, 새 값을 다시 대입해도 괜찮습니다. 하지만 예전 값을 그대로 기대하면 안 됩니다.45
예를 들어 std::string은 이동 후 비어 보일 때가 많지만, 그것을 규칙처럼 믿고 코드를 짜는 것은 좋지 않습니다. 반면 std::unique_ptr는 이동 후 보통 빈 포인터가 되므로 if (!ptr)처럼 상태를 확인하기 쉽습니다.3
안전한 초보자 규칙은 간단합니다.
std::move를 쓴 뒤 원본은 "비워졌을 수 있다"고 생각하고, 다시 값을 넣기 전까지는 중요한 용도로 쓰지 않는다.
왜 unique_ptr와 이동 의미론이 잘 맞을까
unique_ptr는 복사할 수 없고 이동만 됩니다. 이 말은 곧 소유권 이전이 문법으로 드러난다는 뜻입니다. 그래서 코드의 의도가 또렷해집니다.
#include <iostream>
#include <memory>
#include <vector>
using namespace std;
class Item {
private:
string name;
public:
Item(string name) : name(std::move(name)) {}
void print() const { cout << name << 'n'; }
};
int main() {
vector<unique_ptr<Item>> inventory;
auto sword = make_unique<Item>("나무 검");
inventory.push_back(std::move(sword));
inventory.push_back(make_unique<Item>("빵"));
if (!sword) {
cout << "검의 소유권은 inventory로 이동했습니다.n";
}
for (const auto& item : inventory) {
item->print();
}
}이 예제는 아주 현대적인 C++ 냄새가 납니다. new도 없고, delete도 없습니다. 그런데 동적 메모리는 분명히 쓰고 있습니다. 소유권은 unique_ptr가 맡고, 이동은 std::move가 드러내며, 정리는 스코프가 끝날 때 자동으로 일어납니다.13
게다가 표준 컨테이너도 가능하면 이동을 활용해 성능을 끌어올립니다. 예를 들어 push_back(std::move(x))는 기존 객체를 복사하지 않고 옮길 기회를 줍니다.4
이제 원시 포인터는 어떻게 봐야 할까
그렇다고 원시 포인터가 완전히 악당은 아닙니다. 다만 역할이 달라집니다.
현대 C++에서 원시 포인터는 주로 소유자(owner) 라기보다 잠깐 가리키는 관찰자에 가깝습니다.3 예를 들어 다음 같은 상황입니다.
어떤 객체를 잠시 참조만 할 때
소유권이 없는 함수 매개변수를 받을 때
오래된 라이브러리 API와 연결할 때
이럴 때는 스마트 포인터의 get()으로 원시 포인터를 꺼내 넘길 수도 있습니다.3 하지만 그 순간에도 "소유권은 여전히 스마트 포인터 쪽에 있다"는 사실을 잊지 않는 것이 중요합니다.
즉, 오늘 이후의 감각은 이렇게 바뀌어야 합니다.
원시 포인터를 보면 "주소"를 떠올리고, unique_ptr나 shared_ptr를 보면 "소유권"을 떠올린다.
초보자를 위한 선택 기준
처음에는 종류가 많아 보여도, 실제 선택 기준은 의외로 단순합니다.
힙 할당이 꼭 필요 없다면, 먼저 지역 객체와
std::vector,std::string같은 값 타입을 생각합니다.1소유자가 한 명이면
std::unique_ptr를 먼저 고려합니다.3여러 곳이 정말 함께 책임져야 할 때만
std::shared_ptr를 씁니다.3소유하지 않고 바라보기만 할 때는
std::weak_ptr, 참조자, 원시 포인터 같은 비소유 수단을 씁니다.3객체 생성은 가능하면
new보다make_unique,make_shared를 우선합니다.3std::move를 썼다면, 원본은 다시 채우기 전까지 조심해서 다룹니다.45
이 여섯 줄만 몸에 익어도, C++ 코드의 안정성과 읽기 쉬움이 눈에 띄게 달라집니다.
마무리
이번 장의 진짜 주인공은 문법이 아니라 관점의 전환입니다.
예전의 사고방식은 이렇습니다. "메모리를 만들었으니 나중에 내가 잘 지워야지." 현대 C++의 사고방식은 다릅니다. "누가 소유하는지를 타입으로 표현하고, 수명이 끝나면 자동으로 정리되게 하자."
이 차이는 생각보다 큽니다. 버그가 줄어들고, 코드의 책임이 또렷해지고, 큰 객체를 다룰 때 성능까지 좋아집니다. unique_ptr는 소유권을 분명하게 만들고, shared_ptr는 정말 필요한 공유를 표현하며, 이동 의미론은 복사 대신 효율적인 자원 이전을 가능하게 합니다.
그리고 바로 여기서, 현대 C++의 멋이 드러납니다.
더 안전한 코드는 대개 더 명확한 코드이고, C++에서는 그 명확함이 곧 성능으로도 이어진다.
참고
1Object lifetime and resource management in Modern C++ | Microsoft Learn
3Smart pointers in Modern C++ | Microsoft Learn
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.