그냥 글을 써 봅니다/서버 개발 수기

EP.04 — 16슬롯 버퍼 풀: 도서관을 16개로 늘린 이유

Tiboong 2026. 3. 10. 23:54
728x90

이 글은 『서버 개발 수기』 시리즈의 네 번째 글이다.

지난 회에서는 3단계 스핀락을 다뤘다.

오늘은 그 스핀락이 지키는 버퍼 풀 이야기다.

※ 이 시리즈에서 다루는 서버 코드는 Project-AO(Ancient Origin)라는 가명으로 부른다.


도서관 이야기

지난 회에서 화장실 비유를 했으니까, 오늘은 도서관으로 바꾸어본다.
패킷이 들어올 때마다 데이터를 담을 버퍼가 필요하다. 그런데 패킷이 올 때마다 메모리를 새로 할당하면 느리다. 그래서 버퍼를 미리 수천 개 만들어놓고, 빌려주고 반납받는 시스템을 만든다. 이게 버퍼 풀이다.
도서관이라고 생각하면 된다. 책이 필요할 때마다 인쇄하는 게 아니라, 도서관에 꽂아두고 빌려가고 반납하는 거다.
간단해 보이는데, 문제가 하나 있다.


도서관이 1개면

스레드 8개가 돌고 있다. 패킷이 들어올 때마다 버퍼를 빌린다. 초당 수만 번.
버퍼 풀에는 "빌려줄 수 있는 버퍼 목록"이 있다. 스레드 1이 버퍼 하나 가져가려는데, 동시에 스레드 2도 가져가려고 한다. 락 없이 둘 다 만들면? 같은 버퍼를 중복으로 가져간다. 난리가 난다.
그래서 락이 필요하다. EP.03에서 다뤘던 그 스핀락.
근데 락이 1개다.

스레드 1: 🔒 버퍼 가져가는 중...
스레드 2: 대기 ⏳
스레드 3: 대기 ⏳
스레드 5: 대기 ⏳

 
한 놈이 빌리는 동안 나머지가 전부 멈춘다. 버퍼 빌리고 반납하는 건 패킷 하나마다 일어난다. 초당 수만 번. 그때마다 줄 서서 기다리면 서버가 느려진다.
도서관 하나에 창구가 하나. 수만 명이 책 빌리러 줄 서는 꼴.


코드를 열었다

XIOBuffer.cpp를 열었다.

#define BUFFER_POOL_SIZE 16
static XIOBuffer::CSlot g_slotBuffer[BUFFER_POOL_SIZE];

XIOBuffer* XIOBuffer::Alloc() {
    long nBufferIndex = g_nBufferIndex.fetch_add(1, std::memory_order_relaxed);
    CSlot* pSlot = &g_slotBuffer[nBufferIndex & (BUFFER_POOL_SIZE - 1)];
    pSlot->m_lock.Lock();
    // ...
}

 
16이라는 숫자가 보인다. 그리고 & (BUFFER_POOL_SIZE - 1). 이게 뭔데?
Claude한테 던졌다.


도서관을 16개로 늘렸다

알고 보니 도서관을 분관 16개로 나눈 거였다.

슬롯 0:  [버퍼들...] + 🔒 락0
슬롯 1:  [버퍼들...] + 🔒 락1
슬롯 2:  [버퍼들...] + 🔒 락2
...
슬롯 15: [버퍼들...] + 🔒 락15

 
각 분관마다 자기 버퍼와 자기 락이 따로 있다. 버퍼가 필요하면 아무 분관이나 가는 게 아니라, 순번대로 돌아가면서 분산시킨다.

첫 번째 요청 → 슬롯 0
두 번째 요청 → 슬롯 1
세 번째 요청 → 슬롯 2
...
열일곱 번째 → 슬롯 1 (다시 처음부터)

 
이러면 스레드들이 각각 다른 락을 잡으니까 서로 안 기다린다.

스레드 1: 🔒 락3 잠금 → 버퍼 가져감 (슬롯 3에서)
스레드 2: 🔒 락7 잠금 → 버퍼 가져감 (슬롯 7에서)  ← 동시에 가능!
스레드 5: 🔒 락12 잠금 → 버퍼 가져감 (슬롯 12에서)  ← 동시에 가능!

 
락이 1개일 때는 한 번에 1스레드만 버퍼를 가져갈 수 있었는데, 16개로 나누니까 동시에 16스레드까지 가능해졌다.
락을 없애는 게 아니라, 충돌할 확률을 1/16로 줄인 거다.


& 15의 비밀

여기서 하나 걸렸다. 슬롯을 고르는 방법이 nBufferIndex & (BUFFER_POOL_SIZE - 1)이다. 즉 & 15.
보통 나누기를 하면 % 16을 쓸 텐데. 왜 &인가?
알고 보니 비트 연산이 나눗셈보다 빠르단다. % 16은 CPU가 나눗셈을 해야 하는데, & 15는 비트 마스크 한 번이면 끝난다. 단, 16이 2의 거듭제곱이니까 가능한 트릭이다.
초당 수만 번 호출되는 코드에서 나눗셈 한 번을 아끼는 거다. 이런 디테일에서 성능이 갈린다는 걸 새삼 느꼈다.


그런데 16배 빨라지는 거야?

아니다.
여기서 처음에 헷갈렸다. 빨라지는 건 "버퍼를 빌리는 순간"만이다. 패킷 처리 전체가 빨라지는 게 아니다.
패킷 하나가 처리되는 과정을 보면:

1. 패킷 도착
2. 버퍼 빌림          ← 여기서 락
3. 데이터 복사
4. 게임 로직 처리     ← 데미지 계산, 이동 처리 등
5. 응답 패킷 생성
6. 버퍼 반납          ← 여기서도 락
7. 전송

 
16슬롯으로 빨라지는 건 2번과 6번만이다. 4번 게임 로직이 빨라지는 게 아니다.
마트 계산대를 1개에서 16개로 늘린다고, 물건 고르는 시간이 줄어드는 게 아닌 것처럼.
그런데.
동접이 적을 때는 애초에 계산대 앞에 줄이 없다. 차이가 없다. 동접 1만 명이 되면 초당 수만 번 버퍼를 빌리고 반납하니까, 락 1개짜리는 심각한 병목이 된다. 그때 16슬롯이 버텨주는 거다.
동접이 적을 때는 의미 없고, 수만 명일 때 서버가 안 죽게 해주는 기법. SI에서 필요 없었던 이유가 여기 있었다.


EP.03과 연결된다

여기서 지난 회의 스핀락이 다시 보인다.
스핀락이 빠르려면 락을 잡는 시간이 짧아야 한다고 했다. 그리고 같은 락에 몰리지 않아야 한다고.
버퍼 풀이 그 두 가지를 해결하는 거였다.

  • 락 안에서 하는 일이 "버퍼 하나 가져오기"라서 극도로 짧다 → 스핀락 Phase 1에서 끝난다
  • 16슬롯으로 분산해서 같은 락에 몰리지 않는다 → Phase 2, 3까지 갈 일이 없다

따로 노는 기법이 아니었다. 스핀락과 버퍼 풀은 한 쌍이었다. 하나가 다른 하나의 전제를 지켜주는 구조.
이게 보이니까 이 서버 설계가 또 다르게 보인다.


지금이라면 어떻게 할까

jemalloc이나 tcmalloc을 링크하면 된단다. 스레드별로 독립된 메모리 풀을 자동으로 관리해주니까, 16슬롯보다 더 효율적이다. C++11의 thread_local로 스레드마다 독립 풀을 만들 수도 있다.
근데 2006년에는 jemalloc도 초기 단계였고(2005년 FreeBSD에서 등장), thread_local은 아예 없었다. 애플리케이션 레벨에서 직접 슬롯 분산을 구현한 게 당시 최선이었다.
Java의 ConcurrentHashMap이 똑같은 아이디어(Segment 기법)를 Java 5(2004년)에서 도입했는데, C++ 게임서버에서 비슷한 시기에 독립적으로 구현된 셈이다.
원리는 똑같다. "하나로 몰아놓지 말고, 나눠서 충돌을 줄여라." 도구가 바뀐 것일 뿐.


다음 회에서는

스핀락과 버퍼 풀로 "빌리고 반납하기"는 해결했다. 그런데 버퍼를 빌렸으면 언젠가 돌려줘야 한다. 안 돌려주면? 메모리 누수다.
근데 게임서버에서는 하나의 버퍼를 여러 곳에서 동시에 참조하고 있다. 누가 마지막으로 놓는지를 알아야 돌려줄 수 있다.
그게 7종 참조 카운팅이다.
 
→ EP.05: 7종 참조 카운팅 — 누가 이 버퍼를 놓지 않았나

728x90