개요

이론 정리 - < 멀티 스레드 심화 >에서 분량 때문에 잘라둔 절반이다. 앞 편이 여럿이 같은 값을 건드릴 때 무슨 일이 나는지였다면, 이번 편은 그 값이 언제까지 살아 있는지다.

앞 편에서 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는 서로를 모른 채 같은 객체 하나를 가리키게 된다.

Dangling Pointer

세 번째 줄에서 객체가 사라진다. 그런데 사라지는 건 객체지 주소값이 아니다. 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번을 받아둔 쪽은 새 좀비를 예전 좀비로 착각한다. 번호가 맞으니 검사도 통과한다.

Handle과 Generation

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 가 그 방식이다

참고 자료

한줄 평

  • 새롭게 배우는 지식들을 게임에서 어떻게 이용되는지와 연관하여 학습하니 꽤나 이해하기 편했다.