클라가 느리면 서버도 느려진다
보스전처럼 받을 데이터가 몰리는 순간 클라이언트가 이를 제때 처리하지 못하면, 밀린 데이터가 TCP 흐름 제어를 타고 서버의 송신 버퍼와 앱 송신 큐까지 쌓이는 과정을 보여주는 영상이다. 느린 클라이언트 몇 명은 티가 나지 않지만, 보스전처럼 한꺼번에 몰리면 서버 메모리와 처리량에 영향을 주고, 구조에 따라서는 한 명이 존 전체를 멈추게 할 수도 있다. 대응은 양쪽 모두에서 한다. 클라이언트는 소켓을 빨리 비우고, 서버는 클라이언트당 보내는 양을 묶어 송신 큐 상한에 닿지 않게 한다.
01보스전에 생기는 일
받아야 할 양은 늘고, 클라가 처리하는 속도는 떨어진다 · ▶ 이 장면 보기
그래프는 클라이언트 한 명 기준의 초당 패킷 수다. 메인 스레드가 한 프레임에 처리할 수 있는 패킷을 200개로 잡았고, 화면에 예시라고 표시되어 있다.
보스가 등장하면 받아야 할 패킷이 초당 1,500개에서 8,000개로 늘어난다. 같은 시점에 화면에 그릴 것이 많아져 FPS가 60에서 15로 떨어진다. 처리할 수 있는 패킷은 FPS × 200이므로 초당 12,000개에서 3,000개로 줄어든다.
받을 양이 가장 많은 순간에 처리 속도는 가장 느려진다. 두 선 사이의 빨간 영역이 처리하지 못하고 밀린 패킷이고, 오른쪽 위 숫자로 계속 늘어난다. 이 차이는 어딘가에 쌓이며, 클라이언트 안에서 시작해 서버까지 올라올 수 있다.
02클라 안의 수신 큐
수신 스레드를 따로 둔 구조라면: 수신 스레드가 받고, 메인 스레드가 꺼낸다 · ▶ 이 장면 보기
클라이언트 내부 구조다. 수신 스레드를 따로 둔 구조라면, 커널 수신 버퍼에 들어온 데이터를 수신 스레드가 바로 꺼내 앱 메모리의 수신 큐에 넣는다. 메인 스레드는 프레임마다 이 큐에서 정해진 한도, 여기서는 200개만큼 꺼내 게임 로직과 렌더에 반영한다.
평소에는 초당 1,500개가 들어오고 FPS 60에서 초당 12,000개를 꺼낼 수 있으니 큐가 바로바로 비워진다. 보스전에는 초당 8,000개가 들어오는데 FPS 15에서 초당 3,000개밖에 꺼내지 못해, 꺼내는 속도보다 쌓이는 속도가 빠르다.
한 프레임에 큐를 다 비우면 그 프레임이 길어지므로 처리량에 한도를 두고, 그만큼 큐에 남는다. 이 단계까지는 클라이언트 안의 문제다. 오래된 이동을 늦게 반영해 렉과 러버밴딩이 생기고 클라이언트 메모리가 늘어난다. 수신 스레드가 커널 버퍼를 계속 비우고 있으므로 아직 서버에는 영향이 없다. 메인 스레드가 프레임마다 소켓을 직접 읽는 구조라면, FPS가 떨어지는 것만으로 커널 수신 버퍼가 차서 바로 서버까지 압력이 간다.
03소켓이 안 비워지는 순간
수신 스레드가 멈추거나, 회선이 느리거나 · ▶ 이 장면 보기
수신 스레드가 멈추면 상황이 달라진다. 커널 수신 버퍼를 비우는 쪽이 없어지니 소켓이 비워지지 않는다. 영상은 수신 스레드가 멈추는 경우 네 가지와 느린 회선을 든다.
- 수신 큐가 상한에 닿음: 큐가 가득 차면 수신 스레드가 자리가 날 때까지 기다린다.
- GC 정지: C#, 유니티 클라이언트에서는 GC가 관리 스레드를 잠깐 멈춘다.
- 락 경합: 메인 스레드가 큐 락을 오래 잡고 있으면 수신 스레드가 막힌다.
- 앱 백그라운드: 모바일 OS가 앱을 멈추면 수신 스레드도 멈춘다.
- 느린 회선: 클라이언트 구현과 무관하다. 클라이언트 앱은 도착한 데이터를 바로 읽으므로 Zero Window 없이 서버 커널 송신 버퍼가 바로 찬다. 클라이언트 최적화로는 고칠 수 없다.
수신 스레드가 멈추면 이때부터 클라이언트 커널 수신 버퍼가 차기 시작하고, 압력이 서버 방향으로 향한다.
04압력이 서버로 거꾸로 온다
수신 버퍼 → Zero Window → 송신 버퍼 → 앱 송신 큐 · ▶ 이 장면 보기
네 개의 탱크가 서버에서 클라이언트 방향으로 놓여 있다. 왼쪽부터 서버 앱 송신 큐(세션별, 메모리), 서버 커널 송신 버퍼(SO_SNDBUF), 클라이언트 커널 수신 버퍼(SO_RCVBUF), 클라이언트 수신 큐다. 클라이언트 수신 큐는 이미 가득 차서 수신 스레드가 멈춘 상태다. 서버에서는 보스전 브로드캐스트가 계속 만들어진다.
- ① 클라이언트 커널 수신 버퍼가 찬다.
- ② 클라이언트가 ACK에 수신 윈도우 0을 실어 보낸다. 더 받을 수 없다는 뜻이고, 이를 Zero Window라고 한다.
- ③ 서버 커널은 전송을 멈추고, 서버 커널 송신 버퍼가 찬다.
- ④ 논블로킹 소켓에서
send()가EAGAIN을 돌려준다. - ⑤ 보내지 못한 데이터가 서버 앱 송신 큐에 계속 쌓인다.
TCP는 받는 쪽이 못 받으면 보내는 쪽을 멈추게 되어 있다. 그래서 클라이언트가 뽑아내지 못한 양이 결국 서버 앱 메모리에 남는다.
05상한 전에도 서버는 느려진다
메모리 · 버퍼 · 재시도 — 몇 명이면 작고, 몰리면 커진다 · ▶ 이 장면 보기
송신 큐가 상한에 닿기 전에도 서버는 비용을 치른다. 네 장의 카드가 이를 보여준다.
- ① 세션 송신 큐 메모리: 느린 세션 수 × 쌓인 양만큼 메모리를 쓰고, 할당, 복사, GC 부담이 늘어난다. 카드의 큐 메모리 표시는 2.0GB에서 6.0GB까지 오른다.
- ② 공유 브로드캐스트 버퍼: 같은 패킷 버퍼를 여러 세션이 참조해서 보낸다. 다른 세션이 모두 보내도 가장 느린 세션이 보낼 때까지 버퍼가 해제되지 않는다. 카드에서 참조 수가
ref 1로 남는 장면이다. - ③ 쓰기 재시도: 윈도우가 조금씩 열리는 세션마다 작은 부분 전송과
EAGAIN이 반복된다. 윈도우가 0으로 멈춘 소켓은 쓰기 가능 알림 전까지 비용이 거의 없다. 이런 세션이 많아지면 IO 스레드 CPU 막대가 오른다. 이 막대는 예시로 표시되어 있다. - ④ 기다리는 구조라면 1명으로도: 로직 스레드가
send()나 락에서 기다리는 구조면, 느린 클라이언트 1명 때문에 그 존의 틱 전체가 멈춘다. Zone A 타임라인에서 긴 빨간 구간이 그 대기다.
①~③은 느린 클라이언트가 몇 명일 때는 티가 나지 않는다. 보스전처럼 느린 클라이언트가 몰리고 트래픽도 최고일 때 겹쳐서 커진다.
06송신 큐 상한은 안전장치
닿으면 끊긴다 — 닿지 않게 운영한다 · ▶ 이 장면 보기
서버는 보통 세션 송신 큐에 상한을 두고, 닿으면 그 연결을 끊는다. 왼쪽은 상한에 닿는 경우다. 보스 패턴 동안 큐가 쌓여 상한에 닿고, 접속이 끊기고, 재접속한 뒤 다음 보스전에서 같은 일이 반복된다. 그 유저는 보스전마다 계속 튕긴다.
오른쪽은 상한에 닿기 전에 보내는 양을 묶은 경우다. 이동은 최신값으로 덮어쓰고, 먼 대상은 갱신 빈도를 낮추고, 시야 인원에 상한을 둔다. 큐가 상한 아래에서 유지된다.
장비 임계치나 인원 제한은 서버 전체 기준이라서 한 세션의 큐를 막지 못한다. 한 세션의 큐 크기는 그 클라이언트가 받아가는 속도에 달려 있다. 송신 큐 상한은 넉넉하게 두는 마지막 안전장치이고, 실제 운영에서는 클라이언트당 보내는 양을 줄여 상한에 닿지 않게 한다.
07해결 방법
클라는 빨리 뽑아내고, 서버는 클라당 보내는 양을 묶는다 · ▶ 이 장면 보기
클라이언트 쪽은 소켓을 빨리 비우는 데 집중한다.
- 수신 전용 스레드: 렌더와 무관하게 소켓을 계속 비운다.
- 프레임당 처리 예산과 최신값만: 밀린 이동은 버리고 마지막 상태만 반영한다.
- 수신 경로 할당 줄이기: 버퍼 풀과 재사용으로 GC 정지를 줄인다.
- 보스전 렌더 부하 낮추기: 이펙트와 LOD를 조절해 메인 스레드 여유를 확보한다.
- 큐 락은 짧게: 꺼낼 때 큐를 통째로 바꿔치기(swap)한다.
서버 쪽은 클라이언트당 보내는 양을 묶는다.
- 논블로킹 send와 I/O 분리: 로직 스레드는
send()를 기다리지 않는다. - 클라이언트당 송신량 묶기: 시야 인원 상한을 두고 먼 대상은 갱신 빈도를 낮춘다.
- 송신 큐의 이동은 덮어쓰기: 같은 대상의 이전 이동 패킷은 새 것으로 교체한다.
- 대기 요청과 공유 버퍼에도 상한: IOCP 대기 바이트와 참조 버퍼가 묶여 있지 않게 한다.
- 큐 상한은 마지막 안전장치: 닿으면 끊기므로 위 단계로 닿지 않게 막는다.
느린 회선은 클라이언트가 고칠 수 없고, 클라이언트 처리 속도는 서버가 고칠 수 없다. 그래서 양쪽 모두 손봐야 한다.
08인프라에서 확인하는 법
Send-Q · Zero Window · 세션 큐 · 끊김 수 · 클라 지표 · ▶ 이 장면 보기
왼쪽 터미널은 확인에 쓰는 명령과 지표다.
ss -tn state established '( sport = :7777 )'로 게임 포트의 연결별Send-Q를 본다. 예시 출력에서 세 번째 연결의Send-Q가2621440으로, 2.5MB가 아직 전달되지 않은 느린 클라이언트다.- Wireshark에서
tcp.analysis.zero_window필터로 클라이언트가 Zero Window를 보낸 패킷을 찾는다. - 앱 지표로
session_send_queue_bytes의 p99와 max,session_kicked_slow_total을 본다.
오른쪽은 판단 기준이다.
- 소수 세션만 Send-Q가 크고 틱과 메모리는 정상이면, 그 클라이언트의 회선이나 기기 문제다.
- 보스전에 Send-Q 큰 세션이 몰리고 메모리와 틱 p99도 오르면, 느린 클라이언트 몫을 서버가 떠안고 있는 중이다.
session_kicked_slow_total이 보스전마다 증가하면 상한에 닿고 있다는 뜻이다. 클라이언트당 송신량과 클라이언트 처리를 점검한다.- 클라이언트 지표인 수신 큐 길이와 프레임 시간이 보스전에 같이 튀면 클라이언트 처리가 병목이다.
영상은 Send-Q를 커널 송신 버퍼에 남은 바이트(아직 못 보냈거나 ACK를 기다리는 것)로 설명하고, 느린 클라이언트를 찾는 가장 직접적인 신호로 꼽는다.
이 영상에 나온 용어는 용어집에 정리했다.