개요
이론 정리 - < 데이터 레이스, 뮤텍스, 아토믹 >에서 분량 때문에 잘라둔 절반이다. 앞 편이 여럿이 같은 값을 건드릴 때 무슨 일이 나는지였다면, 이번 편은 그 값이 언제까지 살아 있는지다.
앞 편에서 lock_guard를 쓰면서 이렇게 적고 넘어갔다.
중간에 return을 하든 예외가 터지든 똑같이 풀린다. 잠금의 수명을 lock 객체의 수명에 묶어둔 것이다 ( 이 방식이 다음 편에서 다룰 RAII 이다 )
그 미뤄둔 이름부터 시작한다. 쓰고는 있었는데 이름을 몰랐던 것이라, 이번 편에서 제일 먼저 나올 자격이 있다.
이번 복습도 세 칸으로 갈렸다. 동적 메모리를 해제 안 하면 샌다는 건 그대로 나왔다. const T&가 복사를 줄이면서 수정을 막는다도 그대로였다. 그런데 포인터 두 개가 같은 객체를 가리키는 세 줄짜리 코드에서 두 번째 줄을 잘못 읽고 있었다. 안다고 생각한 것과 정확히 읽는 것은 다르다..
이 포인터가 가리키는 것을 치울 책임은 누구에게 있는가? 이번 편은 처음부터 끝까지 이 질문 하나다.
( 코드는 C++14 이상을 기준으로 개념만 보이게 줄였다. make_unique에는 C++14 이상이 필요하다 )
1. RAII — 자원의 수명을 객체의 수명에 묶는다
짝을 사람이 맞추면 반드시 놓친다
자원을 쓰는 코드는 늘 짝으로 생긴다. 열었으면 닫고, 잠갔으면 풀고, 잡았으면 놓는다. 문제는 그 사이에 무슨 일이 생길지 모른다는 것이다.
void spend(int cost)
{
goldMutex.lock();
if (gold < cost) return; // 여기서 나가면 영영 안 풀린다
gold -= cost;
goldMutex.unlock();
}
return 하나 늘렸을 뿐인데 잠금이 남는다. 예외가 터져도 똑같다. 사람이 모든 출구를 다 기억해서 짝을 맞추는 건 언젠가 반드시 실패한다.
RAII(Resource Acquisition Is Initialization) 는 이 짝맞추기를 사람 손에서 뺏어온다. 자원을 객체가 소유하게 만들고, 그 객체가 사라질 때 소멸자가 자원을 놓게 한다. 자동 저장 기간을 가진 RAII 객체는 스코프 종료와 예외에 따른 스택 되감기에서 소멸자가 호출된다. 강제 프로세스 종료처럼 정상적인 정리 흐름을 거치지 않는 경우까지 보장하는 것은 아니다.
void spend(int cost)
{
lock_guard<mutex> lock(goldMutex);
if (gold < cost) return; // lock 이 사라지며 알아서 풀린다
gold -= cost;
}
적용 대상은 Mutex만이 아니다.
| 자원 | 짝 | RAII로 감싸면 |
|---|---|---|
| Mutex | lock / unlock |
lock_guard, unique_lock
|
| 동적 메모리 | new / delete |
unique_ptr, shared_ptr
|
| 파일 | open / close | fstream |
| 소켓 | 연결 / 종료 | 직접 감싼 래퍼 |
유니티에서 걸렸던 것과 정확히 반대편
유니티 학습 프로젝트 - < 발동 지점이 빠진 스킬 >에서 이벤트 구독을 어디에 걸지 고민한 대목이 있다.
Awake에서 걸고OnDestroy에서 푸는 게 자연스러워 보였는데, 풀링을 생각하면 그러면 안 된다. 오브젝트 풀은 좀비를 죽을 때마다 지우는 대신 껐다 켜면서 재사용하는 방식이다. 객체가 파괴되지 않으니OnDestroy가 영영 안 돈다.
그때 답으로 고른 게 OnEnable / OnDisable 짝이었다. 획득과 해제를 같은 수명에 묶는다는 발상 자체가 RAII와 같다. 다르게 고른 건 어떤 수명에 묶느냐뿐이다.
여기서 중요한 것은 관리 객체의 수명을 실제 자원 사용 기간과 맞추는 것이다. RAII 객체가 반드시 소멸해야만 자원을 놓을 수 있는 것은 아니다. unique_ptr::reset()이나 unique_lock::unlock()처럼 일찍 반납할 수도 있고, 풀에서 빌린 기간만 감싸는 별도 객체를 둘 수도 있다. Unity의 OnEnable/OnDisable 짝은 이 수명 설계에 대한 비유이며 C++ 소멸자와 같은 언어적 보장은 아니다.
더 쉬운 비유로
직접 unlock = 방을 나갈 때마다 불을 끄기로 마음먹는 것
RAII = 사람이 나가면 저절로 꺼지는 센서등을 다는 것
마음먹기는 급할 때 잊는다. 뒷문으로 나가면 아예 스위치를 지나치지도 않는다. 센서등은 어느 문으로 나가든 똑같이 꺼진다.
RAII는 정리를 잘 하게 만드는 규칙이 아니라, 정리를 잊을 수 없게 만드는 구조다.
2. Dangling Pointer — nullptr 검사로는 못 막는다
포인터를 복사하면 무엇이 복사되나
개요에서 말한 그 세 줄이다.
Player* p = new Player();
Player* q = p;
delete p;
두 번째 줄을 “q가 p의 주소를 가리킨다” 로 읽었다. 주소의 주소, 그러니까 이중 포인터에 가깝게 본 것이다.
아니다. 복사되는 건 p가 들고 있던 값, 즉 Player 객체의 주소다. 그래서 p와 q는 서로를 모른 채 같은 객체 하나를 가리키게 된다.
세 번째 줄에서 객체가 사라진다. 그런데 사라지는 건 객체지 주소값이 아니다. p도 q도 여전히 예전 주소를 들고 있다. 이 상태의 포인터를 Dangling Pointer라고 부른다.
그래서 null 검사가 안전을 보장하지 못한다
delete p; 다음에 p = nullptr; 을 붙이는 습관이 있다. 나쁘지 않은데, 이게 지켜주는 건 p 하나뿐이다.
delete p;
p = nullptr;
if (q != nullptr) // 여전히 참이다
{
q->attack(); // 이미 없는 객체를 부른다
}
q는 자기가 가리키던 객체가 죽은 걸 알 방법이 없다. q != nullptr 은 “이 포인터에 값이 들어 있다”는 뜻이지 “그 값이 유효하다”는 뜻이 아니다.
앞 편의 TOCTOU와 같은 모양이다. 확인한 것(값이 있다)과 실제로 필요한 것(대상이 살아 있다)이 다르다.
포인터를 복사하는 건 쉽고, 그 복사본이 죽었다는 걸 알리는 건 어렵다. 이 비대칭이 소유권을 타입으로 말해야 하는 이유다.
3. raw pointer 앞에서 던지는 여섯 개의 질문
raw pointer가 무조건 나쁜 건 아니다. 소유하지 않고 잠깐 보기만 하는 용도로는 여전히 제일 가볍다. 위험한 건 포인터 자체가 아니라 그 포인터에 대해 답할 수 없는 상태다.
그래서 코드에서 raw pointer를 만나면 여섯 개를 물어보기로 했다.
| # | 질문 | 답이 없으면 |
|---|---|---|
| 1 | 무엇을 가리키는가 | 이름부터 다시 짓는다 |
| 2 | 실제 주인은 누구인가 | 주인을 정하고 그쪽을 스마트 포인터로 |
| 3 | 이 포인터가 수명을 책임지는가 | 책임진다면 raw 로 두면 안 된다 |
| 4 | nullptr 이 될 수 있는가 | 될 수 있으면 쓰는 쪽마다 검사 |
| 5 | 대상이 먼저 죽을 수 있는가 | shared_ptr 소유 객체라면 weak_ptr, 그 외에는 수명 계약이나 핸들 검증 |
| 6 | 여러 Thread 가 동시에 만지는가 | 앞 편으로 돌아간다 |
3번이 갈림길이다. 책임지지 않는다면 raw pointer로 둬도 된다. 책임진다면 그 순간부터 이건 소유권 문제이고, 다음 섹션으로 넘어가야 한다.
4. 스마트 포인터 — 소유권을 타입으로 말하기
셋을 가르는 기준은 성능이 아니다
세 개를 외울 때 “가벼운 것 / 무거운 것” 으로 나눠 놨었는데, 기준이 틀렸다. 진짜 기준은 치울 책임이 어디에 있느냐다.
| 주인 | 복사 | 치우는 시점 | 쓸 곳 | |
|---|---|---|---|---|
unique_ptr |
하나 | 안 됨 (move 만) | 주인이 사라질 때 | 기본값. 대부분 여기 |
shared_ptr |
여럿 | 됨 | 세는 값이 0이 될 때 | 진짜로 공동 소유일 때만 |
weak_ptr |
없음 | 됨 | 아무것도 안 치움 | 보기만 할 때 |
shared_ptr은 세는 값과 관리 블록을 따로 들고 다니는 비용이 있다. 그래서 “잘 모르겠으면 shared_ptr” 이 아니라 “기본은 unique_ptr, 공동 소유가 증명되면 shared_ptr” 이 맞다.
move 이후 원본은 죽지 않는다
여기가 흐릿했다. unique_ptr을 넘기면 원본 변수까지 없어지는 걸로 알고 있었다.
unique_ptr<Player> a = make_unique<Player>();
unique_ptr<Player> b = move(a);
a라는 변수는 멀쩡히 살아 있다. 사라진 건 a가 들고 있던 소유권이고, a는 비어 있는 상태가 된다. 그래서 a를 검사하거나 새 객체를 넣는 것은 가능하지만, 비어 있는 a를 역참조하면 잘못된 접근이다. 이 빈 상태 보장은 여기의 unique_ptr 이동에 관한 것이며, 모든 타입이 move 후 비어 있는 것은 아니다.
move는 변수를 없애는 게 아니라 내용물을 옮기는 것이다. 상자는 남고 안이 빈다.
순환 참조 — 유니티에서 콜백으로 끊었던 그것
shared_ptr 둘이 서로를 소유하면 세는 값이 영원히 0이 안 된다. 아무도 안 쓰는데 안 지워진다.
유니티 4편에서 좀비가 죽을 때 누가 Despawn을 부를지 고르면서 정확히 이 모양을 만났었다. 좀비가 스포너를 참조하게 하면 순환이 생기고, 스포너 없이는 죽지 못하는 좀비가 된다. 그때 고른 답이 Action<GameObject> 콜백 주입이었다.
C++에서 shared_ptr의 순환 소유를 끊을 때는 한쪽을 weak_ptr 관찰자로 낮출 수 있다. 다만 앞의 Unity 사례는 타입 간 결합을 줄이는 문제이고, shared_ptr 사례는 참조 횟수 때문에 수거가 안 되는 문제다. C#의 추적형 GC는 도달할 수 없는 순환 참조를 수거할 수 있다. 콜백도 대상 객체를 붙잡을 수 있으므로, 콜백 주입 자체가 수명 문제를 해결한다는 뜻은 아니다.
| 유니티 4편 | C++ | |
|---|---|---|
| 문제 | 좀비 ↔ 스포너 서로 참조 |
shared_ptr 끼리 서로 소유 |
| 증상 | 스포너 없인 좀비가 못 죽음 | 세는 값이 0이 안 되어 안 지워짐 |
| 해법 | 콜백을 주입해 타입을 모르게 | 한쪽을 weak_ptr 로 낮춤 |
| 비교할 점 | 타입 의존 관계를 줄임. 콜백의 참조 수명은 별도 관리 | 강한 소유 관계를 끊음 |
weak_ptr로 사용할 때
expired()만 확인하고 나중에 raw pointer로 접근하면 검사 사이에 객체가 사라질 수 있다. lock()으로 shared_ptr을 얻고, 사용하는 동안 그 소유권을 유지한다.
if (auto owner = observer.lock()) {
owner->attack();
}
이 코드는 객체의 수명을 확보한다. 객체 내부 데이터를 여러 Thread가 변경할 때 필요한 동기화까지 제공하지는 않는다.
5. Handle + Generation — 주소 대신 번호표를 넘긴다
번호만으로는 부족하다
객체를 많이 다루면 포인터 대신 배열의 몇 번째인지를 넘기고 싶어진다. 가볍고, 배열이 옮겨져도 안 깨진다.
그런데 자리를 재사용하는 순간 문제가 생긴다. 7번 자리의 좀비가 죽고 그 자리에 새 좀비가 들어오면, 예전에 7번을 받아둔 쪽은 새 좀비를 예전 좀비로 착각한다. 번호가 맞으니 검사도 통과한다.
Generation은 자리마다 붙은 세대 번호다. 자리를 비울 때마다 하나씩 올린다. 번호표에 세대까지 적어두면, 예전 표는 세대가 안 맞아 스스로 무효가 된다.
// <cstddef>, <cstdint> 필요. slots는 occupied와 generation을 가진 슬롯 배열이다.
struct Handle
{
std::size_t index;
std::uint64_t generation;
};
bool IsAlive(const Handle& h)
{
if (h.index >= slots.size()) return false;
const auto& slot = slots[h.index];
return slot.occupied && slot.generation == h.generation;
}
빈 슬롯은 occupied=false로 표시하고, 슬롯을 해제할 때 generation을 증가시키는 전제다. 세대 번호가 한 바퀴 돌아 옛 핸들과 같아질 수 있으므로 폭과 재사용 정책도 정해야 한다. 위 함수는 단일 Thread 또는 동일 잠금 안에서만 사용한다. 검사 직후 해제될 수 있는 환경에서는 검사와 실제 사용을 함께 보호해야 한다.
Dangling Pointer와 비교하면 차이가 뚜렷하다. 주소는 죽었는지 물어볼 수가 없지만, 번호표는 물어볼 수 있다.
이게 그 “죽은 채로 부활한 좀비”다
유니티 4편에 남겨둔 부채 중 하나가 이거였다.
2.
Health에 부활·리셋 통로가 없다._alreadyDead를 되돌릴 방법이 없어서 풀링을 붙이면 죽은 채로 부활한 좀비가 나온다.
그때는 상태 초기화를 안 해서 생기는 문제로만 봤다. 지금 보면 같은 자리를 다시 쓰는 데서 오는 문제라는 점이 Generation과 겹친다. 오브젝트 풀은 슬롯 재사용 그 자체이니, 풀링을 붙이는 순간 초기화와 식별 두 가지를 같이 챙겨야 한다.
( 아직 풀링을 안 붙였으니 실제로 겪은 건 아니다. 붙일 때 확인할 목록에 하나 더 늘었다 )
6. 지우는 시점을 미룬다 — Deferred Destruction
돌면서 지우면 발밑이 무너진다
목록을 순회하는 도중에 그 목록에서 원소를 빼면, 순회하던 자리가 어긋난다. 하나를 건너뛰거나, 이미 지운 것을 또 만진다.
그래서 나온 방식이 지금 지우지 않고 지워달라고 적어두는 것이다. 순회가 다 끝난 안전한 지점에서 적힌 것들을 한꺼번에 치운다. 이 적어두는 곳을 보통 Command Buffer라 부르고, 치우는 시점을 동기화 지점이라 부른다.
멀티스레드가 끼면 이유가 하나 더 생긴다. 순회 도중에 구조가 바뀌면 다른 Thread가 보던 것까지 같이 무너진다. 앞 편의 Critical Section 얘기와 같은 문제인데, 여기서는 삭제 시점을 모으되, 그 시점 전에 모든 읽기 작업이 끝났다는 동기화가 필요하다. Command Buffer에 넣기만 한다고 다른 Thread의 접근이 자동으로 안전해지지는 않는다.
유니티의 Destroy도 같은 방식이다
유니티에서 Destroy(gameObject)를 부르면 그 줄에서 바로 사라지지 않는다. 시간 인수를 생략한 Destroy의 실제 파괴는 현재 Update 루프 뒤, 렌더링 전에 이뤄진다. 안전한 시점에 정리한다는 점에서 지연 삭제와 연결해 이해할 수 있지만, 엔진 내부 구현 이유까지 이 비유만으로 단정할 수는 없다.
미루면 새로 생기는 일
공짜는 아니다. 지우기로 한 것과 아직 안 지운 것 사이에 틈이 생긴다. 그 사이에 다른 시스템이 그 객체를 보면 “죽었는데 아직 있는” 상태를 만난다.
| 방식 | 방법 | 비용 |
|---|---|---|
| 표시만 해두고 넘기기 | 시스템마다 죽었는지 검사 | 검사가 곳곳에 흩어지고, 한 곳만 빼먹어도 샌다 |
| 산 것과 죽은 것을 나누기 | 목록을 둘로 관리 | 검사가 사라지는 대신 옮기는 시점이 까다로워진다 |
정답이 있는 게 아니라 어느 비용을 감당할지 고르는 문제다. 여기까지 오니 앞 편 3번에서 Lock을 굵게 잡을지 잘게 잡을지 고민하던 것과 모양이 똑같아 보인다.
수명 관리는 언제 지우느냐를 정하는 일이고, 어떤 답을 골라도 그에 딸린 청구서가 있다.
정리
앞 편과 같은 방식으로 나눠서 적어둔다.
기억이 확실했던 것
- 동적 메모리를 해제하지 않으면 샌다. 이건 흐릿한 데가 없었다
const T&는 복사 비용을 줄이면서 수정을 막는다- RAII 라는 이름은 몰랐지만
lock_guard가 알아서 푸는 건 쓰고 있었다
알고는 있었는데 바로 안 나오던 것
-
shared_ptr은 기본값이 아니다. 기본은unique_ptr, 공동 소유가 증명되면 그때 바꾼다 - raw pointer 가 악은 아니다. 소유하지 않는 접근에는 여전히 쓴다. 답할 수 없는 상태가 문제다
반대로 / 흐릿하게 잡고 있던 것
-
Player* q = p;는 주소의 주소가 아니다. 주소값이 복사되어 둘이 같은 객체를 가리킨다 -
p = nullptr은 q 를 지켜주지 않는다. null 검사는 값의 유무지 대상의 유효성이 아니다 - unique_ptr을 move해도 원본 변수는 살아 있고 비어 있다. 다른 타입의 이동 후 상태는 해당 타입의 계약을 확인한다
이번에 새로 얹은 것
- RAII 는 자원의 수명을 객체의 수명에 묶는 것. 출구가 몇 개든 소멸자가 정리한다
- 자원을 쓰는 기간과 관리 객체의 수명을 맞춘다. 풀링에는 대여 기간별 관리나 명시적 반납을 설계한다
- raw pointer 앞에서 던질 질문 여섯 개. 3번(수명을 책임지는가)이 갈림길이다
-
weak_ptr은 소유하지 않고 관찰한다. 순환 참조를 끊는 데 쓴다 - Handle + Generation — 자리 번호에 세대를 붙이면 옛 번호표가 스스로 무효가 된다
-
Deferred Destruction — 순회 중에 지우지 않고 안전한 지점까지 미룬다. 유니티
Destroy가 그 방식이다
참고 자료
- cppreference - std::unique_ptr
- cppreference - std::shared_ptr
- cppreference - std::weak_ptr
- 이론 정리 - < 데이터 레이스, 뮤텍스, 아토믹 > — 이 글의 앞 절반
- 유니티 학습 프로젝트 - < 발동 지점이 빠진 스킬 > — 순환 참조와 풀링 이야기
보완에 참고한 문서
한줄 평
- 새롭게 배우는 지식들을 게임에서 어떻게 이용되는지와 연관하여 학습하니 꽤나 이해하기 편했다.