C++ 스마트 포인터와 이동 의미론, 실측으로 이해하기
C++ 강좌 4. unique_ptr·shared_ptr·weak_ptr, 소멸자 출력으로 보는 순환 누수, std::move 의 정체, noexcept 유무에 따른 vector 복사 횟수와 시간 실측.
시리즈: C++ 강좌 4/6 (중급). 클래스, 생성자, RAII 까지는 아는데(3강) 코드 리뷰 때마다 std::unique_ptr, std::move, && 를 지적받는 분을 위한 글입니다. 다 읽으면 상황에 맞는 스마트 포인터를 고르고, shared_ptr 순환을 소멸자 출력과 macOS leaks 로 찾고, 이동 후 객체에 무엇이 남는지 설명하고, noexcept 하나 빠뜨려서 std::vector 가 3 배 느려진 이유를 말할 수 있습니다.
게임 회사나 증권사 C++ 면접에서 단골로 나오는 질문이 "이동 생성자에 noexcept 를 왜 붙이나요?", "shared_ptr 로 누수가 날 수 있나요?" 입니다. 외워서 답하기는 쉽지만, 직접 숫자로 본 적이 있는 사람은 많지 않습니다. 이 글은 그 답을 카운터와 시간으로 보여 줍니다. 아래는 모두 Apple Silicon Mac(M1 Pro), macOS 26, Apple clang 21.0.0 + libc++ 에서 clang++ -std=c++23 -Wall -Wextra -pedantic, 시간 측정은 -O2 로 실행했습니다. GCC, MSVC, Linux, Valgrind 는 여기서 돌리지 않았습니다.
스마트 포인터 세 종류를 프로그램 하나로
스마트 포인터는 힙 객체를 소유하고 자기 소멸자에서 지워 주는 작은 객체입니다(3강의 RAII 그대로입니다). 표준 라이브러리에는 세 가지가 있습니다.
std::unique_ptr<T>: 소유자는 정확히 하나. 복사는 안 되고 이동만 된다.std::shared_ptr<T>: 참조 카운트로 공유. 마지막 소유자가 지운다.std::weak_ptr<T>:shared_ptr객체를 소유하지 않고 지켜본다.lock()하면 아직 살아 있을 때만shared_ptr를 준다.
#include <cstdio>
#include <memory>
#include <string>
struct Res {
std::string name;
explicit Res(std::string n) : name(std::move(n)) { std::printf(" acquire %s\n", name.c_str()); }
~Res() { std::printf(" release %s\n", name.c_str()); }
};
std::unique_ptr<Res> make(const char *n) { return std::make_unique<Res>(n); }
int main() {
std::printf("sizeof raw=%zu unique_ptr=%zu shared_ptr=%zu weak_ptr=%zu\n",
sizeof(Res *), sizeof(std::unique_ptr<Res>),
sizeof(std::shared_ptr<Res>), sizeof(std::weak_ptr<Res>));
{
std::printf("block 1\n");
auto a = make("A");
auto b = std::move(a); // ownership moves; a is now null
std::printf(" a=%s b=%s\n", a ? "set" : "null", b ? b->name.c_str() : "null");
}
std::printf("block 2\n");
std::shared_ptr<Res> s1 = std::make_shared<Res>("S");
{
auto s2 = s1;
std::printf(" use_count=%ld\n", s1.use_count());
std::weak_ptr<Res> w = s1;
std::printf(" weak use_count=%ld expired=%d\n", w.use_count(), w.expired());
}
std::printf(" use_count=%ld\n", s1.use_count());
std::weak_ptr<Res> w = s1;
s1.reset();
std::printf(" after reset: expired=%d lock()=%s\n", w.expired(), w.lock() ? "obj" : "nullptr");
std::printf("end of main\n");
}
sizeof raw=8 unique_ptr=8 shared_ptr=16 weak_ptr=16
block 1
acquire A
a=null b=A
release A
block 2
acquire S
use_count=2
weak use_count=2 expired=0
use_count=1
release S
after reset: expired=1 lock()=nullptr
end of main
이 출력에서 읽을 것:
unique_ptr는 raw 포인터와 똑같이 8 바이트입니다. 기본 deleter 라면 크기 비용이 0 입니다.shared_ptr와weak_ptr는 16 바이트로, 객체를 가리키는 포인터와 카운트를 담은 제어 블록을 가리키는 포인터 두 개입니다.release A는main끝이 아니라 block 1 의 닫는 중괄호에서 찍혔습니다.delete는 한 번도 쓰지 않았습니다.weak_ptr는use_count를 올리지 않습니다(s1,s2,w가 있어도 2). 마지막 소유자가reset한 뒤lock()은 매달린 포인터가 아니라nullptr를 돌려줍니다.
unique_ptr 를 복사하려 하면 컴파일 오류가 나는데, 이건 고마운 오류입니다.
copyerr.cpp:4:10: error: call to implicitly-deleted copy constructor of 'unique_ptr<int>'
4 | auto b = a;
| ^ ~
.../c++/v1/__memory/unique_ptr.h:209:55: note: copy constructor is implicitly deleted because 'unique_ptr<int>' has a user-declared move constructor
의도를 분명히 쓰면 됩니다. 소유권을 넘기려면 auto b = std::move(a);, 빌리기만 하려면 auto& b = a; 입니다.
make_shared 는 할당을 두 번이 아니라 한 번 합니다. 전역 operator new 를 횟수를 세는 버전으로 바꿔서 확인했습니다.
make_unique: 1 allocation(s)
shared_ptr<T>(new T): 2 allocation(s)
make_shared: 1 allocation(s)
shared_ptr<T>(new T) 는 객체와 제어 블록을 따로 할당하고, make_shared 는 한 블록에 합칩니다. make_unique / make_shared 를 쓰면 코드에 맨 new 도 남지 않습니다. (make_unique 는 C++14 부터입니다. -std=c++11 에서는 no member named 'make_unique' in namespace 'std' 가 났습니다.)
shared_ptr 순환: 세어 보면 보이는 누수
참조 카운트에는 사각지대가 하나 있습니다. A 가 B 를, B 가 A 를 소유하면 어느 쪽 카운트도 0 이 되지 않습니다.
#include <cstdio>
#include <memory>
#include <string>
static int alive = 0;
struct Person {
std::string name;
std::shared_ptr<Person> partner; // strong link: the bug
// std::weak_ptr<Person> partner; // weak link: the fix
explicit Person(std::string n) : name(std::move(n)) { ++alive; std::printf("ctor %s\n", name.c_str()); }
~Person() { --alive; std::printf("dtor %s\n", name.c_str()); }
};
int main() {
{
auto a = std::make_shared<Person>("alice");
auto b = std::make_shared<Person>("bob");
a->partner = b;
b->partner = a;
std::printf("use_count a=%ld b=%ld\n", a.use_count(), b.use_count());
}
std::printf("after scope: alive=%d\n", alive);
}
ctor alice
ctor bob
use_count a=2 b=2
after scope: alive=2
생성자 두 번, 소멸자 0 번. 스코프가 끝나면 a 와 b 가 사라지면서 카운트가 2 에서 1 로 내려갈 뿐이고, 두 객체는 서로를 영원히 살려 둡니다. 죽지도 않고 ASan 도 조용하니 리뷰를 통과합니다. 위처럼 소멸자 출력을 세는 것이 가장 싸게 쓸 수 있는 테스트입니다.
macOS 에 들어 있는 leaks 는 이 패턴을 이름으로 짚어 줍니다(일부 생략).
$ leaks --atExit -- ./cycle
Process 42649: 2 leaks for 160 total leaked bytes.
2 (160 bytes) ROOT CYCLE: <std::__shared_ptr_emplace<Person> 0x726c24050> [80]
1 (80 bytes) ROOT CYCLE: <std::__shared_ptr_emplace<Person> 0x726c240a0> [80]
CYCLE BACK TO <std::__shared_ptr_emplace<Person> 0x726c24050> [80]
하나에 80 바이트인 것은 제어 블록과 Person 이 할당 한 번에 들어 있기 때문입니다(앞의 make_shared 구조). 주석 두 줄을 맞바꿔 partner 를 weak_ptr 로 하면 이렇게 됩니다.
ctor alice
ctor bob
use_count a=1 b=1
dtor bob
dtor alice
after scope: alive=0
Process 42675: 0 leaks for 0 total leaked bytes.
원칙은 소유 관계는 트리로입니다. 부모→자식은 shared_ptr(또는 unique_ptr), 자식→부모, 관찰자→대상, 캐시→항목은 weak_ptr 나 일반 참조로 둡니다. iOS 앱을 만들어 봤다면 Swift 의 순환 참조와 weak var 와 같은 이야기입니다. Swift ARC 글에서 다른 언어로 같은 누수를 볼 수 있습니다.
std::move 는 아무것도 옮기지 않는다
std::move(x) 는 캐스트일 뿐입니다. x 를 rvalue(T&&)로 바꿔서 오버로드 해석이 이동 생성자나 이동 대입을 고르게 할 뿐입니다. 실제로 "옮기는" 일은 그 생성자가 하고, 대개 안쪽 포인터를 가져가고 원본을 비웁니다.
std::string s = "a string long enough to live on the heap";
std::string t = std::move(s);
std::printf("s=\"%s\" size=%zu | t size=%zu\n", s.c_str(), s.size(), t.size());
std::vector<int> v(5, 1);
std::vector<int> w = std::move(v);
v.push_back(3); // valid: reuse after move is allowed
const std::string c = "const string that cannot be moved from at all";
std::string d = std::move(c); // silently copies
s="" size=0 | t size=40
small="" size=0
v.size=0 w.size=5
after push_back v.size=1
c still="const..." size=45
- 이동된 객체는 유효하지만 명시되지 않은 상태입니다. libc++ 에서는 긴 문자열, 5 글자짜리 짧은 문자열, vector 모두 비어 있었지만 표준이 약속하는 건 "유효함"뿐입니다. 대입하거나 소멸시키기만 하고 내용을 읽지 마세요.
const객체에std::move를 쓰면-Wall -Wextra -pedantic에서도 경고 없이 컴파일되고 복사됩니다.const std::string&&는 이동 생성자에 묶일 수 없어서 복사 생성자가 선택되기 때문입니다.- 이동 후 사용은
clang++ --analyze가 잡았습니다:Method called on moved-from object 's' of type 'std::basic_string' [cplusplus.Move]. 객체 세 개에 보고 세 건입니다.
반대로, 컴파일러가 더 잘하는 곳에 std::move 를 덧붙이는 실수도 있습니다.
std::vector<int> build() {
std::vector<int> v(1000, 7);
return std::move(v); // looks faster, is not
}
movedfrom.cpp:8:12: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
movedfrom.cpp:8:12: note: remove std::move call here
return v; 면 호출한 쪽 변수에 결과를 바로 만들 수 있습니다(복사 생략). return v; 라고 쓰세요.
복사와 이동, 실측
차이는 얼마나 될까요. 무작위 영문자 32 글자(std::mt19937, 시드 42) 문자열 100 만 개짜리 std::vector<std::string> 을 만들어 복사와 이동을 했습니다. -O2, 7 회 중앙값입니다.
auto t0 = clk::now();
std::vector<std::string> copy = src; // copy: 1,000,001 heap allocations
auto t1 = clk::now();
std::vector<std::string> moved = std::move(copy); // move: 3 pointers change hands
auto t2 = clk::now();
copy: median 16.97 ms
move: median 0 ns
"0 ns" 는 진짜 값이 아닙니다. 이 Mac 의 steady_clock 은 약 42 ns 단위로 움직여서(now() 를 연달아 두 번 부르면 42 와 0 이 나왔습니다) 이동이 시계 해상도보다 짧았을 뿐입니다. 그래서 같은 vector 를 컴파일러 배리어를 끼워 1,000 만 번 왕복시키고 나눴습니다.
1.56 ns per move assignment, a.size=1000000
복사 약 17 ms, 이동 약 1.6 ns. 대략 1,000 만 배 차이이고, 이동 시간은 원소 수와 상관없습니다. 복사할 때 문자열마다 힙 할당이 일어나는 이유는 32 글자가 문자열 내부의 작은 버퍼에 들어가지 않기 때문입니다(그 경계는 6강에서 잽니다).
noexcept 와 vector 확장: 부탁하지 않은 복사
std::vector 는 용량이 모자라면 더 큰 블록을 할당하고 기존 원소를 옮겨 담습니다. 이때 이동을 쓰는 것은 이동 생성자가 noexcept 일 때뿐이고, 아니면 복사합니다. 이유는 강한 예외 보장입니다. 이동이 중간에 예외를 던지면 원래 원소는 이미 속이 비어 되돌릴 수 없지만, 복사는 실패해도 원래 블록이 멀쩡합니다.
noexcept 만 다른 타입으로 세어 봤습니다.
static long copies = 0, moves = 0;
template <bool NoExcept>
struct Item {
std::string s;
explicit Item(int i) : s(40, char('a' + i % 26)) {}
Item(const Item &o) : s(o.s) { ++copies; }
Item(Item &&o) noexcept(NoExcept) : s(std::move(o.s)) { ++moves; }
Item &operator=(const Item &) = default;
Item &operator=(Item &&) = default;
};
// for (int i = 0; i < n; ++i) v.push_back(Item<NoExcept>(i));
move without noexcept n=1000 copies=1023 moves=1000 (nothrow_move=0)
move with noexcept n=1000 copies=0 moves=2023 (nothrow_move=1)
move without noexcept n=1000000 copies=1048575 moves=1000000 (nothrow_move=0)
move with noexcept n=1000000 copies=0 moves=2048575 (nothrow_move=1)
median of 7: without noexcept 43.32 ms, with noexcept 14.57 ms
(앞 네 줄은 시간 열을 뺐습니다.) 첫 줄의 이동 1,000 번은 push_back(Item(i)) 가 임시 객체를 옮긴 것이라 어느 쪽에서나 생깁니다. 복사 1,023 번이 재배치입니다. libc++ 는 용량을 1, 2, 4, …, 1024 로 두 배씩 늘리므로 1 + 2 + … + 512 = 1,023 개를 옮겼습니다. noexcept 가 없으면 그 하나하나가 할당을 동반한 문자열 복사가 됩니다. 원소 100 만 개면 복사 1,048,575 번, 시간은 약 3 배입니다.
noexcept 를 신경 쓰지 않고도 맞게 만드는 방법은 3강의 rule of zero 입니다. 특수 멤버 함수를 하나도 쓰지 않은 struct Plain { std::string s; }; 는 컴파일러가 이동 생성자를 만들어 주고, std::is_nothrow_move_constructible_v<Plain> 은 1 이었습니다. 문제는 특수 멤버 하나만 손으로 썼을 때 생깁니다. 연습 문제 2 가 가장 알아채기 어려운 예입니다.
흔한 실수 → 실제로 일어난 일 → 해결
소유권 버그 세 개를 한 파일에 넣고, 그냥 빌드와 -fsanitize=address,undefined -g 빌드로 실행했습니다.
struct Widget : std::enable_shared_from_this<Widget> {
int id = 7;
std::shared_ptr<Widget> self_wrong() { return std::shared_ptr<Widget>(this); }
std::shared_ptr<Widget> self_right() { return shared_from_this(); }
};
// in main(), selected by argv[1]:
if (mode == 1) { // two owners of one raw pointer
int *raw = new int(42);
std::unique_ptr<int> a(raw);
std::unique_ptr<int> b(raw);
} else if (mode == 2) { // raw pointer outlives its owner
auto up = std::make_unique<Widget>();
Widget *view = up.get();
up.reset();
std::printf("id=%d\n", view->id);
} else if (mode == 3) { // shared_ptr(this)
auto sp = std::make_shared<Widget>();
auto again = sp->self_wrong();
}
| 실수 | 그냥 빌드 | ASan 빌드 | 해결 |
|---|---|---|---|
new 하나로 unique_ptr 두 개 |
Trace/BPT trap: 5, 종료 코드 133 |
attempting double-free, 19 행 |
make_unique 로 만들고 raw 포인터를 두 번 감싸지 않기 |
reset() 뒤에 get() 값 사용 |
id=0 출력, 종료 코드 0 |
heap-use-after-free, 23 행 |
소유자를 살려 두거나 shared_ptr + weak_ptr |
메서드 안에서 shared_ptr<T>(this) |
Trace/BPT trap: 5, 종료 코드 133 |
bad-free (attempting free on address which was not malloc()-ed) |
enable_shared_from_this 상속 후 shared_from_this() |
shared_ptr 양방향 연결 |
오류 없음, 소멸자 안 불림 | 조용함 | 역방향은 weak_ptr, leaks --atExit 로 확인 |
return std::move(local); |
동작하지만 복사 생략을 막음 | — | return local; (-Wpessimizing-move) |
| 소멸자만 선언, 이동 없음 | 확장할 때마다 복사 | — | rule of zero, 또는 다섯 개 모두 = default |
가장 위험한 건 mode 2 입니다. 그냥 빌드는 그럴듯한 id=0 을 찍고 정상 종료했습니다. mode 3 은 "콜백에 자기 자신을 등록하는" 실무에서 흔한 모양인데, 같은 객체를 제어 블록 두 개가 소유하게 됩니다. shared_from_this() 로 바꾸면 둘 다 use_count=2, 즉 제어 블록이 하나였습니다.
어떤 소유 방식을 고를까
| 상황 | 쓸 것 | 이유 |
|---|---|---|
| 소유자가 분명히 하나(멤버, 팩토리 반환값) | std::unique_ptr<T> |
8 바이트, 카운트 없음, 이동이 싸다 |
| 소유자가 여럿이고 수명 끝을 알 수 없음(공유 캐시, 비동기 콜백) | std::shared_ptr<T> |
제어 블록의 카운트. 16 바이트, 카운트 갱신은 원자적 |
| 역방향 링크, 관찰자, 살려 두면 안 되는 캐시 | std::weak_ptr<T> |
소유하지 않음. 쓸 때 lock() |
| 함수가 객체를 쓰기만 함 | T& 또는 T* 매개변수 |
시그니처에 소유권을 끌어들이지 않음 |
| 객체 배열 | std::vector<T> |
이미 원소를 소유함 |
unique_ptr 로 시작하세요. 나중에 shared_ptr 로 바꿀 수 있지만(std::shared_ptr<T> s = std::move(u);) 반대는 안 됩니다. 그리고 읽기만 하는 함수에 스마트 포인터를 값으로 넘기지 마세요. void draw(const Shape&) 가 의도도 분명하고, 호출할 때마다 원자적 카운터를 건드리지도 않습니다.
솔직한 한계와 버전 메모
- 실행 환경은 Apple clang 21.0.0(libc++,
_LIBCPP_VERSION220106), arm64 macOS 26 뿐입니다. GCC/libstdc++, MSVC, Linux, Valgrind 는 돌리지 않았습니다. libstdc++ 에서는 이동된 문자열이나 확장 비율이 다를 수 있습니다. - 버전:
unique_ptr,shared_ptr,weak_ptr, 이동 의미론,noexcept는 C++11,make_unique는 C++14 입니다(-std=c++11에서 거부되는 것 확인). 이 글의 코드는-std=c++17,-std=c++20으로도 컴파일됐습니다. - 확장 비율 2 배, 할당 크기(
make_shared<Person>하나에 80 바이트)는 libc++ 구현 사항이지 표준이 아닙니다. - 시간은 한 대에서 잰 중앙값입니다. 복사는 실행마다 16.5~17.0 ms, noexcept 비교는 별도 두 번 실행에서 43.3 ms 대 14.6 ms 였습니다.
- Apple Silicon macOS 에서는 AddressSanitizer 의 누수 검출이 동작하지 않습니다(C 강좌 3강 참고). 그래서
leaks를 썼습니다.
연습 문제
1. 각 Node 가 children 과 부모로 가는 parent 링크를 갖는 트리를 만드세요. 루트를 놓으면 전부 해제되도록 포인터 타입을 고르고, 소멸자 출력과 leaks --atExit 로 증명하세요.
2. 아래 타입은 std::vector 를 느리게 만듭니다. 이동에 대해서는 아무것도 쓰지 않았는데 왜일까요?
struct Logged {
std::string text;
~Logged() {} // added "just to set a breakpoint"
};
정답
1. 부모→자식은 소유, 자식→부모는 지켜보기만 합니다.
struct Node {
std::string name;
std::weak_ptr<Node> parent; // child -> parent: weak
std::vector<std::shared_ptr<Node>> children; // parent -> child: owning
explicit Node(std::string n) : name(std::move(n)) {}
~Node() { std::printf("dtor %s\n", name.c_str()); }
};
루트, 자식 a, 손자 a1 을 만들고 손자만 바깥의 leaf 로 들고 있으면, a1's parent is a 다음 스코프가 끝나는 순간 dtor root, dtor a, 이어서 tree gone? parent expired=1, 마지막에 dtor a1 이 나왔습니다. leaks 는 0 leaks for 0 total leaked bytes. 였습니다. lock() 은 임시 shared_ptr 를 돌려주므로 if (auto p = ...) 안에서 쓰세요.
2. 소멸자를 직접 선언하면 암시적 이동 생성자가 만들어지지 않습니다. 복사는 되기 때문에 std::is_nothrow_move_constructible_v<Logged> 는 0 이었습니다(예외를 던질 수 있는 string 복사로 대신하므로). 용량이 1,024 에서 2,048 로 늘어나는 vector 에 뒤쪽 1,000 개를 넣는 동안 할당 횟수를 세면 Logged=3025, 소멸자를 지운 Clean=1001 입니다. 차이 2,024 번은 재배치 복사 1,024 번과, 넘긴 임시 객체 복사 1,000 번입니다. 빈 소멸자를 지우거나(rule of zero), Logged(Logged&&) noexcept = default; 등 다섯 개를 갖춰 쓰세요.
다음 강의
5강은 템플릿입니다. 114 줄짜리 템플릿 오류 읽는 법, C++20 콘셉트로 그것이 22 줄이 되는 모습, 람다와 STL 알고리즘, 지연 평가되는 ranges, 그리고 std::sort, std::stable_sort, qsort 실측입니다.
- 이전: C++ 강좌 3강: 클래스, RAII, rule of five
- 다음: C++ 강좌 5강: 템플릿, STL 알고리즘, ranges
- 시리즈 처음: 1강, 2강. 마지막: 6강 성능과 동시성
- 관련: C 포인터와 malloc(ASan), 프로그래밍 학습 로드맵
자주 묻는 질문
vector 가 커질 때 원소를 이동하지 않고 복사하는 이유는?
이동 생성자가 noexcept 가 아니기 때문입니다. vector 는 강한 예외 보장을 지키려고, 재배치 중 이동이 예외를 던질 수 있으면 복사를 택합니다. 실측에서 100 만 개 push_back 시 noexcept 가 없으면 복사 1,048,575 번·43.3 ms, 있으면 복사 0 번·14.6 ms 였습니다. 소멸자를 직접 선언하기만 해도 암시적 이동 생성자가 사라져 같은 일이 생깁니다.
shared_ptr 를 써도 메모리 누수가 생기나요?
생깁니다. 두 객체가 서로를 shared_ptr 로 들고 있으면 참조 카운트가 0 이 되지 않습니다. 예제에서는 소멸자가 한 번도 불리지 않았고, 스코프를 벗어나도 alive=2 그대로였으며, macOS 의 leaks 는 "2 leaks for 160 total leaked bytes" 와 ROOT CYCLE 을 보고했습니다. 한쪽 방향을 weak_ptr 로 바꾸자 두 소멸자가 모두 실행되고 leaks 도 0 이 됐습니다.
std::move 한 뒤 객체에는 무엇이 남나요?
'유효하지만 명시되지 않은 상태'입니다. libc++ 에서는 긴 문자열, 짧은 문자열, vector 모두 비어 있었고, 그 뒤 vector 에 push_back 해도 문제없었습니다. 그래도 내용에 기대지 말고 새 값을 대입하거나 소멸시키기만 하세요. const 객체에 std::move 를 쓰면 실제로는 복사되며 경고도 나오지 않습니다.