EP.05 — 다목적 참조 카운팅: shared_ptr 하나면 되는 걸 왜 7개로 나누는가
이 글은 『서버 개발 수기』 시리즈의 다섯 번째 글이다.
지난 회에서는 16슬롯 버퍼 풀을 다뤘다.
오늘은 버퍼를 빌린 다음 이야기다. 돌려주는 것.
※ 이 시리즈에서 다루는 서버 코드는 Project-AO(Ancient Origin)라는 가명으로 부른다.
책을 돌려주지 않는 사람
지난 회에서 버퍼 풀을 도서관에 비유했다. 빌리고 반납하는 시스템.
근데 빌렸으면 돌려줘야 한다. 안 돌려주면? 반환되지 않은 메모리가 쌓인다. 도서관에 책이 점점 줄어들고, 결국 빌려줄 책이 없어진다. 이걸 메모리 누수(Memory Leak)라고 한다.
게임서버는 24시간 돌아간다. 메모리 누수가 생기면 며칠에 걸쳐 천천히 죽는다. 메모리를 조금씩 먹다가 결국 터진다.
그런데 문제가 하나 더 있다. 버퍼만이 아니라 유저 객체도 관리해야 한다. 그리고 유저 객체는 버퍼랑 다르게, 여러 곳에서 동시에 쳐다보고 있다.
한 권의 책을 여러 명이 보고 있다
유저 객체가 하나 있다. CPlayer* player.
이 객체를 여러 곳에서 동시에 쳐다보고 있다.
스레드 1: player의 패킷 처리 중
스레드 3: player한테 타이머 이벤트 발생
스레드 5: 다른 유저가 player한테 거래 요청
이 순간 player가 접속을 끊었다. 객체를 삭제해야 할까?
스레드 1이 삭제했는데 스레드 3이 아직 쓰고 있으면? 이미 없어진 메모리를 읽으려고 하니까 크래시. 서버가 터진다.
먹고 있는데 누가 내 접시를 치워버린 꼴.
참조 카운팅이라는 것
"지금 이 객체를 쓰고 있는 놈이 몇 명인지" 세는 거다.
player->AddRef(); // "나 쓸 거야" → 카운트 +1
// ... 작업 ...
player->Release(); // "나 다 썼어" → 카운트 -1
카운트가 0이 되면 아무도 안 쓰는 거니까 그때 삭제한다.
카운트 3: 스레드 1, 3, 5가 사용 중
스레드 5 Release → 카운트 2
스레드 1 Release → 카운트 1
스레드 3 Release → 카운트 0 → 삭제!
도서관 대출 카드 같은 거다. 빌린 사람이 0명이 되면 책을 다시 선반에 꽂는다.
C++의 shared_ptr이 정확히 이거다. 그런데 2006년에는 shared_ptr이 없었다. C++11에서 들어온 게 2011년이니까. 직접 만들 수밖에 없었다.
근데 그냥 만든 게 아니었다.
코드를 열었다
XIOObject.h를 열었다. AddRef가 7개다.
class XIOObject {
void AddRef(); // 일반 참조
void AddRefTimer(); // 타이머용
void AddRefTemp(); // 임시 참조
void AddRefVar(); // 변수 참조
void AddRefVarEx(); // 확장 변수 참조
void AddRefSelf(); // 자기 참조
void AddRefIO(); // I/O 작업용
};
처음에는 왜 이렇게 많은지 이해가 안 됐다. 참조 카운팅이면 숫자 하나 올리고 내리면 되는 거 아니야?
Claude한테 던졌다. "이거 왜 7개야?"
누가 안 돌려줬는지 알아야 한다
답을 듣고 소름이 돋았다.
shared_ptr의 문제가 뭐냐면, 카운트가 숫자 하나다.
shared_ptr 카운트: 5
카운트가 5인데 누수가 생겼다. 카운트가 영원히 0이 안 된다. 서버가 메모리를 계속 먹는다. 근데 누가 Release를 안 한 건지 모른다. 그냥 숫자 5만 보일 뿐.
Project-AO는 이랬다.
일반 참조 : 2
타이머 참조 : 1
I/O 참조 : 1
임시 참조 : 1
──────────
합계: 5
카운트가 안 줄어든다면? "타이머 참조가 1 남아있네 → 타이머 쪽 코드에 버그가 있구나." 바로 범인을 찾을 수 있다.
도서관으로 비유하면 이랬다.
shared_ptr 방식:
대출 기록: 5명이 빌려감
책이 안 돌아온다. 5명 중 누가 안 돌려준 건지 모르겠다.
Project-AO 방식:
대출 기록:
- 학생 대출: 2건
- 교수 대출: 1건
- 열람실 대출: 1건
- 행사용: 1건
책이 안 돌아온다. "열람실 대출이 1건 남아있네 → 열람실 가서 확인하자." 끝.
새벽 3시의 차이
이게 왜 중요한지는 새벽에 서버가 터져봐야 안다.
메모리 누수가 생겼다. 서버가 며칠에 걸쳐 천천히 죽고 있다.
shared_ptr이면:
"어딘가에서 Release를 안 했다"
→ 코드 10만 줄을 뒤져야 한다
→ 재현도 안 된다 (며칠에 한 번 발생)
→ 새벽 3시에 시작해서 아침이 돼도 못 찾는다
Project-AO면:
"AddRefIO 카운트가 안 줄어든다"
→ I/O 관련 코드만 보면 된다
→ 범위가 확 좁아든다
→ 새벽 3시에 시작해서 4시에 집에 간다
수만 명이 접속하는 라이브 서버에서, 이 차이는 개발자의 수면 시간을 결정한다. 농담이 아니다.
SI에서는 왜 필요 없었나
내가 SI에서 델파이로 개발할 때는 이런 고민이 없었다. DB에 직접 붙여서 화면 만들면 끝이니까. 객체를 여러 스레드가 동시에 쳐다보는 상황 자체가 없었다. 웹을 할 때도 마찬가지였다. 요청이 들어오면 처리하고 응답하면 끝. 객체가 요청 하나 안에서 태어나고 죽으니까, 누가 안 놓았는지 고민할 일이 없었다.
근데 게임서버는 다르다. 유저가 접속해 있는 동안 객체가 계속 살아있다. 타이머가 참조하고, I/O가 참조하고, 다른 유저의 거래 요청이 참조하고. 이 얽힌 참조들 사이에서 하나라도 놓치면 누수가 생긴다.
그리고 누수는 며칠 후에 서버를 죽인다.
지금이라면 어떻게 할까
shared_ptr이 있으니까 참조 카운팅 자체는 직접 만들 필요가 없다. AddressSanitizer나 Valgrind 같은 도구로 누수를 감지할 수도 있다.
근데 여기에 함정이 있다. shared_ptr는 "누가 참조하는지"를 알려주지 않는다. 카운트만 있을 뿐. Sanitizer는 코드에 메모리 감시 장치를 끼워넣어주는 도구인데, 켜면 서버가 몇 배 느려진다. 프로덕션 서버에서는 켜놓을 수 없다.
근데 프로덕션 서버에서 Sanitizer를 켜놓을 수는 없다. 그때 "누가 안 놓았는지"를 코드 자체가 알려주는 이 설계를 보면, 도구가 해결 못 하는 영역이 아직 있다는 걸 느끼게 된다.
20년 전 코드에서 배우는 게 바로 이런 거다.
EP.03, EP.04와 연결된다
여기서 지난 두 회가 다시 보인다.
스핀락은 락을 빠르게 잡는 방법이었다. 버퍼 풀은 락 경합을 줄이는 방법이었다. 참조 카운팅은 빌린 버퍼를 안전하게 돌려받는 방법이다.
빌리고(EP.04) → 쓰고 → 돌려주고(EP.05). 스핀락(EP.03)이 이 전체 과정을 빠르게 해주고.
따로 노는 기법이 아니라 하나의 링크다. 이게 보일수록 이 코드가 자꾸 다르게 보인다.
https://npackgames.tistory.com/156
EP.03 — 3단계 적응형 스핀락: 왜 spin하다 yield하다 sleep하는가
이 글은 『서버 개발 수기』 시리즈의 세 번째 글이다.지난 회에서는 서버의 3계층 구조를 살펴봤다.오늘부터 Layer 2 안으로 들어간다. 첫 번째는 락.※ 이 시리즈에서 다루는 서버 코드는 Project-A
npackgames.tistory.com
https://npackgames.tistory.com/158
EP.04 — 16슬롯 버퍼 풀: 도서관을 16개로 늘린 이유
이 글은 『서버 개발 수기』 시리즈의 네 번째 글이다. 지난 회에서는 3단계 스핀락을 다뤘다. 오늘은 그 스핀락이 지키는 버퍼 풀 이야기다. ※ 이 시리즈에서 다루는 서버 코드는 Project-AO(Ancient
npackgames.tistory.com
다음 회에서는
빌리고 돌려주는 건 해결했다. 그러면 데이터를 보내는 건?
유저 하나가 스킬을 쓰면, 주변 유저 10명에게 이펙트 패킷을 보내야 한다. send()를 10번 부를까? 아니면 모아서 한 번에?
그게 Write 버퍼 병합이다.
→ EP.06: Write 버퍼 병합 — send()를 덜 부를수록 빠른 이유