끝이 없는 큐는 메모리가 대신 끝을 낸다
앞에서 소켓 앞에 큐를 하나 뒀습니다. 넣는 쪽은 기다리지 않고 돌아가고, 꺼내는 쪽이 하나씩 보냅니다.
그런데 넣는 속도가 꺼내는 속도보다 빠르면 어떻게 될까요.
지하철에서 인터넷이 끊긴 플레이어를 생각해 봅니다. 틱은 계속 돌면서 초당 열 개씩 넣는데 그쪽은 못 받아 갑니다.
큐에 상한이 없으면 남은 메모리가 상한이 됩니다. 그리고 그건 우리가 고른 값이 아닙니다.
무한 큐는 공격 통로다
var queue = Channel.CreateUnbounded<byte[]>();
편해 보입니다. 넣기가 절대 실패하지 않습니다.
그런데 이 코드에는 “여기까지” 가 없습니다. 느린 클라이언트 하나가 붙으면 그 세션의 큐가 계속 자랍니다. 백 명이 그러면 백 개의 큐가 같이 자랍니다.
그러다 서버가 죽습니다. 그리고 죽는 시점을 우리가 정하지 않았습니다.
이건 성능 문제가 아니라 공격 통로입니다. 데이터를 받기만 하고 안 읽는 클라이언트를 몇 개 붙이면 그것만으로 서버를 내릴 수 있습니다. 트래픽을 많이 보낼 필요도 없습니다.
그래서 원칙이 하나 나옵니다.
밖에서 들어오는 것과 이어진 큐에는 반드시 상한이 있어야 합니다.
가득 찼을 때 셋 중 하나를 고른다
상한을 정하면 그다음 질문이 따라옵니다. 가득 차면 어떻게 할 것인가.
| 방법 | 무엇을 잃나 | 언제 |
|---|---|---|
| 기다린다 | 생산자가 멈춘다 | 생산자가 멈춰도 되는 경우 |
| 버린다 | 그 데이터를 잃는다 | 최신 값만 의미 있는 경우 |
| 끊는다 | 그 연결을 잃는다 | 밀린 상대가 사실상 죽은 경우 |
Channel 은 이걸 FullMode 로 고르게 해 줍니다.
BoundedChannelFullMode.Wait // 자리 날 때까지 기다림
BoundedChannelFullMode.DropOldest // 제일 오래된 것을 버림
BoundedChannelFullMode.DropNewest // 방금 것을 버림
BoundedChannelFullMode.DropWrite // 넣으려던 것을 버림
기다리는 것이 기본값처럼 보이지만 제일 위험합니다. 생산자가 서기 때문입니다. 그 생산자가 여럿을 대신 일하는 스레드라면 그쪽이 통째로 멈춥니다.
버리는 것은 게임에서 자주 맞습니다. 몬스터 위치는 최신 하나만 있으면 되니까요.
100ms 전 위치를 굳이 보낼 이유가 없습니다. DropOldest 가 그 자리입니다.
끊는 것은 좀 세 보이지만, 밀릴 대로 밀린 연결은 이미 못 쓰는 연결입니다. 붙잡고 있어 봐야 메모리만 먹습니다.
상한은 시간으로 정한다
숫자를 어떻게 고를까요. 1024 는 큰 값인가요?
그 자체로는 알 수 없습니다. 의미가 생기려면 얼마나 버티는가로 바꿔 봐야 합니다.
용량을 그대로 두고 초당 개수를 10 에서 60 으로 올려 보면 버티는 시간이 6분의 1로 줄어듭니다. 같은 숫자가 틱 주기에 따라 전혀 다른 뜻이 됩니다. 그래서 개수만 보고는 고를 수 없습니다.
실제로 이렇게 정했다
제 서버는 세션당 1024 로 잡았습니다. 이유를 주석에 적어 뒀습니다.
/// 세션당 송신 큐 용량(프레임 수). 넘치면 그 세션을 끊는다 — 방 틱이 10Hz 라 1024 는
/// 약 100초치다. 여기까지 밀린 클라는 사실상 죽은 연결이고, 무한 큐로 두면
/// 느린 클라 하나가 서버 메모리를 계속 먹는다(DoS 벡터).
public const int SendQueueCapacity = 1024;
10Hz 니까 1024 개는 약 100초입니다. 100초 동안 한 프레임도 못 받아 간 연결은 죽은 것으로 봅니다. 숫자를 시간으로 환산해서 정한 것입니다.
가득 차면 끊습니다.
if (!_sendQueue.Writer.TryWrite(frame))
{
_logger.LogWarning(
"Send queue full ({Capacity}) for session {SessionId} — disconnecting (client cannot keep up)",
SendQueueCapacity, SessionId);
Disconnect();
}
FullMode 를 Wait 로 두고 안 기다리는 이유
여기가 처음 보면 이상한 부분입니다. 옵션은 Wait 인데 실제로는 안 기다립니다.
FullMode = BoundedChannelFullMode.Wait,
/// FullMode.Wait 를 쓰지만 Wait 하지 않는다 — TryWrite 로만 넣어 가득 차면 즉시 false 를
/// 받는다(대기 모드는 TryWrite 가 실패를 알려주는 유일한 모드다).
/// 생산자가 틱 스레드라 절대 블록시키면 안 된다.
Drop* 모드를 고르면 TryWrite 가 항상 true 를 돌려줍니다. 넣는 대신 뭔가를 버리고 성공했다고 하니까요.
그러면 큐가 가득 찼다는 사실을 넣는 쪽이 알 수가 없습니다.
“가득 찼다” 를 알아야 끊을 수 있습니다. 그걸 알려 주는 모드가 Wait 뿐입니다.
그래서 모드는 Wait 로 두고, 기다리는 WriteAsync 대신 안 기다리는 TryWrite 만 씁니다.
옵션 이름이 “대기” 인데 대기를 안 하니 헷갈립니다. 그래서 주석이 필요했습니다. API 이름과 실제 의도가 어긋나는 자리에는 이유를 적어 두는 편이 낫습니다.
배압이라는 말
넣는 쪽이 꺼내는 쪽 사정을 알게 되는 것을 배압이라고 부릅니다.
무한 큐에는 배압이 없습니다. 넣는 쪽은 아무 문제가 없다고 믿고 계속 넣습니다. 문제는 나중에 전혀 상관없어 보이는 곳에서 터집니다. 메모리 부족으로요. 그때 원인을 찾으려면 한참 걸립니다.
유계 큐는 문제를 그 자리에서 드러냅니다. “이 세션이 못 따라온다” 는 사실이 넣는 순간 나옵니다. 로그도 그 자리에 남습니다.
빠르게 실패하는 것이 조용히 자라는 것보다 낫습니다.
정리
- 큐에 상한이 없으면 남은 메모리가 상한이 됩니다. 우리가 고른 값이 아닙니다
- 밖에서 들어오는 것과 이어진 큐는 반드시 유계로 둡니다. 무한 큐는 공격 통로입니다
- 가득 차면 기다리거나, 버리거나, 끊습니다. 기다리는 게 제일 위험합니다
- 용량은 개수가 아니라 시간으로 환산해서 정합니다. 같은 1024 도 틱 주기에 따라 뜻이 다릅니다
Drop*모드는TryWrite가 항상 성공해서 포화를 알 수 없습니다. 끊으려면Wait모드여야 합니다- 유계 큐는 문제를 그 자리에서 드러냅니다. 조용히 자라다 엉뚱한 곳에서 터지는 것보다 낫습니다