EP.06 — Write 버퍼 병합: send()를 덜 부를수록 빠른 이유
이 글은 『서버 개발 수기』 시리즈의 여섯 번째 글이다.
지난 회에서는 7종 참조 카운팅을 다뤘다.
오늘은 데이터를 보내는 이야기다.
※ 이 시리즈에서 다루는 서버 코드는 Project-AO(Ancient Origin)라는 가명으로 부른다.
택배 이야기
MMORPG에서 유저가 필드에 서 있다. 서버가 이 유저한테 보내야 할 정보가 한꺼번에 쏟아져 나온다.
1. 옆에 있는 유저가 움직였다 (12바이트)
2. 몬스터가 스킬 썼다 (20바이트)
3. 파티원 HP가 변했다 (8바이트)
4. 채팅 메시지 왔다 (50바이트)
5. 버프 시간이 줄었다 (6바이트)
이걸 어떻게 보낼까?
내가 생각한 방법은 간단했다. 생길 때마다 바로 보내면 되지 않나? send() 5번.
Claude한테 물어봤더니 "그게 왜 느린지 아세요?" 하고 되물어 왔다.
send()는 생각보다 비싸다
send()를 부를 때마다 이런 일이 일어난단다.
내 코드가 돌고 있는 세계(유저 모드)에서 OS의 세계(커널 모드)로 건너간다. OS가 데이터를 커널 버퍼에 복사하고, TCP 헤더를 붙이고, 네트워크 카드에 전달하고, 다시 내 코드로 복귀한다.
이 "경계선을 넘는 것 자체"가 비싸단다.
CPU가 모드를 전환하고, 레지스터 저장하고, 커널 코드로 점프하고. 한 번에 수 마이크로초씩 걸린다.
그리고 데이터가 12바이트든 1000바이트든 이 비용은 거의 똑같다.
택배로 치면, 물건 크기에 상관없이 택배비가 5000원인 거다. 편지 한 장 보내도 5000원, 박스 보내도 5000원.
편지 5통을 따로 보내면 25,000원. 한 박스에 넣어서 보내면 5,000원.
아.
규모를 키워보면
유저 1명한테 초당 패킷 50개를 보낸다고 하면.
동접 1만 명 × 초당 50개 = 초당 50만 개 패킷
개별 전송: send() 50만 회/초
병합 전송: send() 5만 회/초 (10개씩 묶었을 때)
시스템 콜 횟수가 10분의 1로 줄어든다. 그만큼 CPU가 게임 로직에 쓸 시간이 생기는 거다.
50만 회라는 숫자를 보는 순간, 내가 웹에서 하던 세계와는 차원이 다르다는 걸 다시 느꼈다.
코드를 열었다
XIOSocket.cpp를 열었다.
void XIOSocket::WriteWithLock(XIOBuffer* pBuffer) {
// ...
} else if (m_pFirstBuf != m_pLastBuf &&
m_pLastBuf->m_dwSize + pBuffer->m_dwSize <= BUFFER_SIZE) {
// 마지막 버퍼에 여유 공간이 있으면 병합
memcpy(m_pLastBuf->m_buffer + m_pLastBuf->m_dwSize,
pBuffer->m_buffer, pBuffer->m_dwSize);
m_pLastBuf->m_dwSize += pBuffer->m_dwSize;
pBuffer->Free();
} else {
// 여유 공간 없으면 새 버퍼를 큐에 추가
m_pLastBuf->m_pNext = pBuffer;
m_pLastBuf = pBuffer;
}
}
보내려는 패킷이 생기면 바로 send()를 부르는 게 아니었다. 큐에 담아두는데, 마지막 버퍼에 여유 공간이 있으면 거기에 덮붙여버린다. 여유가 없으면 그때야 새 버퍼를 추가한다.
택배 박스에 들어가면 계속 넣고, 꽉 차면 그때 새 박스를 여는 거다.
근데 모으면 느리지 않아?
나도 처음에 그 생각을 했다. "모아서 보내면 그만큼 늦게 보내는 거 아니야?"
알고 보니 그게 아니었다. 일부러 기다리는 게 아니라, 어차피 쌓이는 걸 모아서 보내는 거다.
패킷1 생김 → 큐에 담음
패킷2 생김 → 큐에 담음
패킷3 생김 → 큐에 담음
← 이 시점에 send 담당이 큐를 확인
큐에 3개 쌓여있네? → 합쳐서 한 번에 send()
스레드가 게임 로직을 처리하는 동안 자연스럽게 쌓이는 걸 묶는 거다. "100ms 기다려야지" 같은 인위적 딜레이가 아니라, 어차피 생기는 틈을 활용하는 거다. 그래서 체감 지연이 거의 없단다.
똑똑하다고 생각했다.
TCP에도 비슷한 게 있지 않아?
나도 그 생각이 들었다. TCP에 Nagle 알고리즘이라는 게 있다. OS가 자동으로 작은 패킷을 모아서 보내주는 기능이다.
"그러면 OS한테 맡기면 되는 거 아니야?"
문제는 Nagle이 얼마나 모을지, 언제 보낼지를 내가 못 정한단다. OS 맘대로다. 게임에서 캐릭터가 움직이는데 OS가 "좀 더 모아볼게~" 하면서 40ms 기다리면? 캐릭터가 뚝뚝 끊겨 보인다.
그래서 게임서버는 보통 이렇게 한단다.
TCP_NODELAY = ON ← OS야, 모으지 마. 내가 보내면 바로 보내.
+ 애플리케이션 병합 ← 대신 내가 직접 모아서 보낼게.
모으는 타이밍을 내가 컨트롤하는 거다. OS한테 맡기면 예측이 안 되니까.
이것도 "몰라도 되게" 만들어둔 거였다.
Layer 3에서 나는 그냥 Write() 함수를 불렀을 뿐인데, Layer 2에서 이런 병합이 일어나고 있었다.
SI에서는 왜 필요 없었나
내가 웹을 할 때는 이런 고민이 없었다. HTTP 요청이 들어오면 응답 하나 보내면 끝이니까. 병합할 게 없다. 요청 하나에 응답 하나.
근데 게임서버는 다르다. 유저 하나가 스킬을 쓰면 주변 10명에게 이펙트 패킷, 대상에게 HP 변경 패킷, 시전자에게 쿨타임 패킷. 한 번의 액션에서 패킷이 여러 개 나온다. 이게 동접 1만 명이 동시에 하고 있다.
send() 한 번의 비용이 작아 보여도, 50만 번 모이면 엄청난 거였다.
지금이라면 어떻게 할까
writev()라는 게 있단다. 여러 버퍼를 복사 없이 한 번의 시스템 콜로 전송할 수 있다. Project-AO는 memcpy로 데이터를 복사해서 병합했는데, writev()는 복사 자체가 필요 없다.
io_uring에서는 여러 send 요청을 링크해서 한 번에 서브밋할 수도 있단다. 커널 수준에서 배치 처리해주는 거다.
도구는 좋아졌지만, 원리는 똑같다. "날려보내는 횟수를 줄여라." 택배비는 건당이니까.
여섯 개 기법이 연결된다
여기서 지난 세 회가 다시 보인다.
패킷이 들어온다. 버퍼 풀에서 버퍼를 빌린다(EP.04). 스핀락이 그 과정을 빠르게 해준다(EP.03). 참조 카운팅이 버퍼를 안전하게 관리한다(EP.05). 게임 로직이 처리된다. 응답 패킷들이 생긴다. 그걸 병합해서 보낸다(EP.06).
패킷 하나가 들어와서 나가는 길 위에 여섯 개 기법이 전부 얹혀있다. EP.02에서 그 그림을 처음 봤을 때는 그냥 그림이었는데, 이제 각 기법을 하나씩 들여다보고 나니까 그 그림이 다르게 보인다.
따로 노는 기술이 아니라, 하나의 흐름이다.
다음 회에서는
여기까지 스핀락, 버퍼 풀, 참조 카운팅, Write 병합을 다뤘다. 전부 락과 메모리와 시스템 콜 이야기였다.
다음은 조금 다른 세계다. 락 없이 포인터를 바꾸는 방법. 락이 필요할 것 같은데 없이 해결한다.
그게 락-프리 스마트 포인터다.
→ EP.07: 락-프리 스마트 포인터 — mutex 없이 포인터를 바꿀 수 있다고?