← 문제로

36. 송신 백프레셔 부재: 무제한 송신 큐와 블로킹 send (C#)

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 송신 백프레셔 부재: 무제한 송신 큐와 블로킹 send (C#)

난이도: 중상

요약

송신 경로에 백프레셔(backpressure)가 없다. (1) _sendQueue가 상한 없이 무한히 자라 느린/악의적 클라이언트 하나가 서버 메모리를 고갈시키고(느린 소비자 문제), (2) Send가 블로킹 소켓 호출이라 브로드캐스트를 도는 게임 로직 스레드가 한 세션의 소켓 버퍼가 가득 차면 그 자리에서 멈춰 존 전체 브로드캐스트가 정지한다. 한 클라이언트가 전체 서버를 인질로 잡는다.

문제점

1) [무제한 큐] 느린 소비자 → 메모리 고갈 (증상: OOM)

  • 증상: (A)에서 Enqueue는 상한 검사 없이 무조건 추가한다. 클라이언트가 TCP 윈도우를 열지 않으면(수신 안 함) Send가 진행되지 못하고 큐에 프레임이 계속 쌓인다. 악의적 클라이언트는 일부러 recv 를 멈춰 이 큐를 무한히 키워 서버 힙을 소진시킨다(느린 소비자/slow-drain DoS).
  • 재현조건: 송신 속도 > 클라이언트 소비 속도가 지속.
  • 근본원인: 큐 크기/바이트 상한과 초과 시 정책(드롭·연결종료)이 없다.

2) [블로킹 I/O가 로직 스레드를 점유] 브로드캐스트 정지 (증상: 전체 지연/멈춤)

  • 증상: (B)의 _socket.Send(...)는 블로킹 소켓에서 커널 송신 버퍼가 가득 차면 반환하지 않고 대기한다. EnqueueFlush가 게임 로직 스레드에서 동기 실행되므로(그리고 (C) 브로드캐스트 루프가 그 스레드에서 세션을 순차 순회하므로), 뒤처진 세션 하나에서 Send가 블로킹되면 그 뒤 모든 세션이 업데이트를 못 받고 틱 루프가 밀린다.
  • 근본원인: I/O를 로직 스레드에서 동기·블로킹으로 수행.

3) [short write 미처리] (부수) Send 반환값 무시

  • 증상: (B)는 sent를 받지만 쓰지 않는다. Send는 요청보다 적게 보낼 수 있는데(부분 전송), 성공을 가정하고 곧바로 Dequeue하면 프레임 뒷부분이 유실돼 스트림 프레이밍이 깨진다. (블로킹 소켓에서도 시그널/타임아웃으로 발생 가능.)

수정안

비동기 논블로킹 송신 + 세션별 상한 큐 + 초과 시 격리 정책.

public sealed class Session
{
    private readonly Socket _socket;
    private readonly Queue<byte[]> _sendQueue = new();
    private long _queuedBytes;
    private bool _sending;
    private const long MaxQueuedBytes = 256 * 1024; // 세션별 상한

    // 로직 스레드: 절대 블로킹하지 않는다. 큐만 채우고 즉시 반환.
    public bool Enqueue(byte[] frame)
    {
        lock (_sendQueue)
        {
            if (_queuedBytes + frame.Length > MaxQueuedBytes)
                return false;                    // 백프레셔: 호출자에게 실패 통지 → 드롭/킥
            _sendQueue.Enqueue(frame);
            _queuedBytes += frame.Length;
            if (_sending) return true;           // 이미 비동기 송신 진행 중
            _sending = true;
        }
        BeginSend();                             // 논블로킹 비동기 시작
        return true;
    }

    private void BeginSend()
    {
        byte[] frame;
        lock (_sendQueue) { frame = _sendQueue.Peek(); }
        _socket.BeginSend(frame, 0, frame.Length, SocketFlags.None, OnSent, frame);
    }

    private void OnSent(IAsyncResult ar)
    {
        int sent = _socket.EndSend(ar);          // 부분 전송 처리
        var frame = (byte[])ar.AsyncState!;
        lock (_sendQueue)
        {
            if (sent < frame.Length) {           // short write: 남은 부분 재큐
                _sendQueue.Dequeue();
                var rest = frame[sent..];
                // 남은 조각을 앞에 다시 처리(간략화: 큐 앞 삽입 구조 사용)
                _queuedBytes -= sent;
                _sendQueue.Enqueue(rest);        // 실제로는 우선 처리 큐 필요
            } else {
                _sendQueue.Dequeue();
                _queuedBytes -= frame.Length;
            }
            if (_sendQueue.Count == 0) { _sending = false; return; }
        }
        BeginSend();                             // 다음 프레임 이어서 비동기
    }
}
  • 로직 스레드는 Enqueue가 즉시 반환하므로 절대 I/O로 막히지 않는다. 실제 전송은 완료 콜백 체인(또는 SocketAsyncEventArgs)으로.
  • 큐가 상한을 넘으면 Enqueuefalse를 반환 → 호출자는 그 세션에 대해 비필수 프레임 드롭(예: 위치 스냅샷) 또는 연결 종료로 격리. 존 전체는 계속 진행.

더 나은 설계 (트레이드오프)

  • 프레임 우선순위/합치기(coalescing): 뒤처진 세션에는 최신 스냅샷만 남기고 중간 프레임을 버리면(state-based) 큐 폭증을 근본 차단. 트레이드오프: 순서/증분 프로토콜엔 부적합.
  • SocketAsyncEventArgs 풀 + 제로카피: 고동접에서 BeginSend의 할당을 줄이고 GC 압박 완화.
  • 관측·격리: 세션별 큐 깊이/지연을 메트릭으로 노출하고, 임계 초과 세션을 자동 스로틀/킥. 트레이드오프: 복잡도.

면접 포인트

  • 블로킹 소켓 Send가 커널 버퍼 만차 시 블로킹된다는 점과, 그게 로직 스레드를 점유하면 왜 전체가 멈추는지.
  • 느린 소비자(slow consumer) 문제와 백프레셔 전략(상한·드롭·킥·coalescing)을 설명할 수 있는가.
  • Send/recv의 부분 전송(short write) 처리 필요성.