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