← 문제로

5. C# 송신 큐 백프레셔 + 끊김/재접속 세션 복구 (복합)

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — C# 송신 큐 백프레셔 + 끊김/재접속 세션 복구 (복합)

난이도: 최상

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

세 가지 어려운 주제(백프레셔 / 송신 직렬화 race / 재접속 정합성)가 한 코드에서 얽혀 있다. 핵심 결함:

  1. 무제한 송신 큐 — 느린 클라 하나가 OOM을 유발(백프레셔 부재).
  2. 끊김 중에도 큐를 계속 채우고 보관 — 좀비 메모리 + 만료 정리 없음(누수).
  3. Resume의 소켓 교체와 송신 루프가 race — 옛 소켓으로 보내거나, 송신 루프가 둘로 갈라지거나, _sending 플래그가 어긋나 멈춤(use-after / 패킷 유실·중복·정지).
  4. 재접속 시 "보내다 만" 경계 모호SendAsync 도중 끊기면 부분 전송된 패킷의 재전송으로 클라가 깨진 프레임/중복을 받음(정합성).

문제점

(A) 무제한 송신 큐 → OOM (분류: 성능·메모리)

  • 증상: _sendQueue.Enqueue에 상한이 없다. 느린(또는 멈춘) 클라의 큐로 브로드 캐스트가 계속 쌓여 한 세션이 수백 MB~GB를 점유. 슬로우 컨슈머가 서버 전체를 끌어내림.
  • 재현조건: 모바일/해외 클라 + 고빈도 브로드캐스트. 또는 클라가 ACK 없이 멈춤.
  • 근본원인: 흐름 제어(backpressure) 정책 부재. 생산 속도 > 소비 속도일 때 완충할 한계와 초과 시 정책(드롭/병합/연결 종료)이 없음.

(B) _sending 플래그 기반 루프 기동의 race (분류: 동시성/정확성)

  • 증상: EnqueueResume 둘 다 if (!_sending) { _sending=true; SendLoop }.
    • Resume_sendQueue 락을 잡지 않고 _sending을 읽는다 → Enqueue 중인 스레드와 race. 동시에 통과하면 송신 루프가 2개 떠서 같은 소켓에 동시 WriteAsync(직렬화 깨짐, InvalidOperationException/데이터 인터리빙).
    • 반대로 둘 다 "이미 sending"으로 보고 아무도 새 루프를 안 띄우면 큐가 멈춤.
  • 근본원인: _sending 상태 전이가 단일 락으로 보호되지 않고 여러 진입점에 분산.

(C)+(E) Resume 소켓 교체 vs in-flight Send race (분류: 동시성/수명관리)

  • 증상: SendLoopAsyncawait _socket.SendAsync(...)에 들어가 있는 동안 Resume_socket = newSocket으로 바꾼다. await가 깨어나면:
    • 옛 소켓으로의 전송 완료/예외가 새 컨텍스트와 섞임.
    • _socket 필드를 락 없이 교체해 가시성/원자성 보장 없음(참조 찢김은 없지만 어느 소켓을 보는지 비결정적).
    • 옛 소켓이 Dispose됐다면 ObjectDisposedException.
  • 근본원인: "송신 중 소켓 교체"라는 위험한 연산을 정지(quiesce) 없이 수행.

(D)+(F) 끊김 중 큐 보관 + ResumeWindow 미정리 → 누수 (분류: 수명관리/메모리)

  • 증상: OnDisconnect는 큐를 그대로 둔다. 그런데 끊긴 세션에도 (브로드캐스트가 세션을 못 골라내면) 계속 Enqueue될 수 있고, 큐는 끝없이 자란다. SessionRegistryResumeWindow 만료 정리 코드가 아예 없어 재접속 안 한 세션이 토큰 맵에 영원히 남는다(좀비 + 누수). 게다가 Dictionary가 락 없이 다중 스레드 접근(자료구조 race).
  • 근본원인: 끊김 상태에서의 큐 정책 부재 + 보관 객체의 만료/회수(GC reaper) 부재.

(C) 재접속 시 부분 전송 패킷의 경계 모호 → 정합성 (분류: 정확성)

  • 증상: SendAsync 도중 TCP가 끊기면 그 패킷이 0%/50%/100% 어디까지 갔는지 서버는 모른다. 재접속 후 같은 패킷을 처음부터 다시 보내면 클라는 앞쪽 절반 + 전체를 받아 프레임이 깨지거나 중복 적용된다. "어디까지 클라가 처리했나"를 추적하는 시퀀스/ACK가 없다.
  • 근본원인: 신뢰적 재개를 위한 시퀀스 번호 + ACK 기반 재전송 경계가 없음.

수정안

핵심: 바운디드 채널(백프레셔) + 단일 라이터 + 정지 후 소켓 교체 + 시퀀스/ACK + 만료 reaper

using System.Threading.Channels;
using System.Collections.Concurrent;

public sealed class GameSession
{
    public string ResumeToken;
    private Socket _socket;

    // 바운디드 채널: 상한 도달 시 정책 적용(여기선 가장 오래된 것 드롭).
    private readonly Channel<Outgoing> _send =
        Channel.CreateBounded<Outgoing>(new BoundedChannelOptions(capacity: 1024)
        {
            FullMode = BoundedChannelFullMode.DropOldest, // 또는 Wait/거부 후 끊기
            SingleReader = true,                          // 송신 루프 단 하나
            SingleWriter = false
        });

    private long _seq = 0;            // 송신 시퀀스(서버→클라)
    private long _ackedSeq = 0;       // 클라가 ACK한 마지막 시퀀스
    private readonly object _socketLock = new();
    private volatile bool _connected = true;
    public DateTime DisconnectedAt { get; private set; }

    private readonly struct Outgoing { public long Seq; public byte[] Data;
        public Outgoing(long s, byte[] d){ Seq=s; Data=d; } }

    public GameSession(Socket s)
    {
        _socket = s;
        _ = RunSendLoopAsync();       // 세션 생성 시 단 한 번만 기동
    }

    // 게임 로직 스레드들에서 호출. 백프레셔 정책에 따라 거부/드롭 가능.
    public bool Enqueue(byte[] payload)
    {
        long seq = Interlocked.Increment(ref _seq);
        var item = new Outgoing(seq, Frame(seq, payload));
        // TryWrite: 큐가 차고 DropOldest면 오래된 걸 밀어내고 성공.
        // Wait 모드를 쓸 거면 WriteAsync + 생산자 백프레셔로.
        return _send.Writer.TryWrite(item);
    }

    // 송신 루프는 "정확히 하나"만 존재(채널 SingleReader). 절대 둘로 갈라지지 않음.
    private async Task RunSendLoopAsync()
    {
        await foreach (var item in _send.Reader.ReadAllAsync())
        {
            // 끊긴 동안엔 전송하지 않고 대기(재접속 시 _connected=true 되면 진행).
            while (!_connected)
                await Task.Delay(50);

            Socket sock;
            lock (_socketLock) { sock = _socket; }

            try
            {
                await SendFullAsync(sock, item.Data);
                // 전송 "완료"한 것만 acked 후보. 클라 ACK로 _ackedSeq 갱신은 수신부에서.
            }
            catch (Exception) // 소켓 에러 → 끊김 처리에 위임, 이 패킷은 재개 시 재전송 대상
            {
                MarkDisconnected();
                // item 은 미확인 상태로 남는다(아래 재전송 버퍼 참고).
            }
        }
    }

    // 부분 전송 방지: 전부 보낼 때까지 루프(프레이밍 + 길이 보장).
    private static async Task SendFullAsync(Socket s, byte[] data)
    {
        int sent = 0;
        while (sent < data.Length)
            sent += await s.SendAsync(data.AsMemory(sent), SocketFlags.None);
    }

    // seq + length-prefix 프레이밍(클라가 경계를 알 수 있게)
    private static byte[] Frame(long seq, byte[] payload) { /* [len][seq][payload] */ return payload; }

    public void MarkDisconnected()
    {
        _connected = false;
        DisconnectedAt = DateTime.UtcNow;
        // 소켓만 닫고, 큐/세션 상태는 ResumeWindow 동안 보존.
    }

    // 재접속: 송신을 잠깐 멈추고(정지) 소켓을 원자적으로 교체한 뒤 재개.
    public void Resume(Socket newSocket, long clientAckedSeq)
    {
        lock (_socketLock)
        {
            try { _socket?.Dispose(); } catch { }
            _socket = newSocket;
        }
        // 클라가 마지막으로 받은 시퀀스 이후만 다시 보내야 함(재전송 버퍼에서).
        Volatile.Write(ref _ackedSeq, clientAckedSeq);
        // 단일 송신 루프는 계속 살아있다 → _connected만 켜면 같은 루프가 재개.
        _connected = true;
        // (재전송 버퍼 구현은 "더 나은 설계" 참고)
    }
}

public sealed class SessionRegistry
{
    private readonly ConcurrentDictionary<string, GameSession> _byToken = new();
    private readonly TimeSpan _resumeWindow = TimeSpan.FromSeconds(30);
    private readonly Timer _reaper;

    public SessionRegistry()
        => _reaper = new Timer(_ => Reap(), null, 5000, 5000);

    public GameSession FindForResume(string token)
        => _byToken.TryGetValue(token, out var s) ? s : null;

    public void Register(GameSession s) => _byToken[s.ResumeToken] = s;

    private void Reap()  // ResumeWindow 만료 세션 정리 → 누수/좀비 차단
    {
        var now = DateTime.UtcNow;
        foreach (var kv in _byToken)
        {
            var s = kv.Value;
            if (s.IsDisconnectedLongerThan(_resumeWindow, now))
                if (_byToken.TryRemove(kv.Key, out var dead))
                    dead.Dispose();
        }
    }
}

(위 GameSessionIsDisconnectedLongerThan, Dispose는 자명하게 추가.)

핵심 정리

  • Channel.CreateBounded + SingleReader=true: (1) 상한으로 OOM 차단, (2) FullMode로 백프레셔 정책 명시(DropOldest/DropWrite/Wait), (3) 송신 루프가 구조적으로 단 하나 → (B)의 "루프 2개/멈춤" race 원천 차단.
  • 소켓 교체를 _socketLock으로 원자화 + 송신 루프가 매번 락으로 현재 소켓을 읽음 → (E) 옛 소켓 사용/ObjectDisposed 완화. (더 견고하게는 "정지 후 교체": 재개 신호 전까지 루프가 _connected==false에서 대기.)
  • **SendFullAsync**로 부분 전송을 방지하고, seq/ACK + 재전송 버퍼로 재접속 시 "클라가 받은 다음 것부터" 정확히 재전송 → (C) 정합성.
  • ConcurrentDictionary + Reaper로 (D)(F) 누수/좀비/자료구조 race 해결.

더 나은 설계

1) 신뢰적 재개 프로토콜: 시퀀스 + ACK + 재전송 버퍼 (트레이드오프 핵심)

  • 서버는 보낸 패킷을 seq와 함께 **재전송 버퍼(ring buffer)**에 보관. 클라는 주기적으로 "내가 받은 마지막 seq"를 ACK. ACK된 것은 버퍼에서 제거(메모리 회수). 재접속 시 클라가 lastAckedSeq를 보내면 그 이후만 재전송 → 유실·중복·역전 방지.
  • 트레이드오프: 재전송 버퍼도 무한정 보관하면 안 됨 → "ResumeWindow 또는 버퍼 상한" 중 먼저 도달하면 세션 포기(full resync로 강등). 즉 seamless resume은 best-effort, 실패 시 전체 상태 재동기화로 폴백하는 2단계 정책이 현업 표준.

2) 백프레셔 정책을 패킷 종류별로 분리

  • 패킷마다 손실 허용도가 다르다: 위치 스냅샷(최신만 의미 있음)은 DropOldest/병합 (coalesce), 채팅/거래 결과(반드시 도착)는 신뢰 큐 + 흐름 제어(Wait) 또는 도달 보장 채널. 한 큐에 섞지 말고 우선순위/신뢰도별 다중 채널로.
  • 트레이드오프: 큐 분리는 복잡도·메모리↑, 그러나 "느린 클라가 중요한 패킷을 드롭당하는" 사고를 막는다. 큐 한계 초과가 지속되면 그 세션은 끊는 게 정답일 때도 많음.

3) 세션 상태머신으로 전 구간 모델링

  • Active → Disconnected(resumable) → Resuming → Active / → Expired(reap). 각 전이는 CAS로 단일화. 송신 루프는 상태를 보고 동작(Active만 전송, Disconnected는 버퍼링 한도 내 보관, Expired는 폐기). 재접속은 Disconnected→Resuming을 한 스레드만 성공하게 해 동시 재접속 두 개가 같은 세션을 두고 싸우는 race도 막는다.

4) per-session 작업 직렬화(액터)

  • 한 세션의 Enqueue/Send/Disconnect/Resume를 단일 메일박스로 직렬화하면 위 락들이 사라지고 순서가 결정적이 된다. 트레이드오프: 세션 수만큼의 경량 액터 스케줄링 비용.

면접 포인트

  1. "슬로우 컨슈머로 인한 송신 큐 폭증을 어떻게 막나? 드롭 vs 블록 vs 끊기?" → 바운디드 큐가 전제. 패킷 신뢰도별 정책: 갱신성 데이터는 최신만 남기고 드롭/병합, 필수 데이터는 흐름 제어(생산자 블록) 또는 한도 초과 시 세션 종료. "모두 보존"은 불가능하니 도메인별 손실 정책을 정의하는 게 핵심.
  2. "끊김 후 재접속에서 패킷 유실/중복을 없애려면 무엇이 필요한가?" → 송신 시퀀스 번호 + 클라 ACK + 재전송 버퍼. 재접속 시 lastAckedSeq 이후만 재전송. 부분 전송은 length-prefix 프레이밍 + SendFull로 경계 보장. 버퍼/윈도우 초과 시 full resync 폴백.
  3. "소켓 교체(재접속)와 진행 중인 비동기 Send의 race를 어떻게 다루나?" → 송신 루프를 단일화(SingleReader)하고, 재접속은 루프를 정지(quiesce)시킨 뒤 소켓을 락으로 원자 교체하고 재개 신호. 옛 소켓 Dispose 타이밍과 in-flight Send를 분리해 ObjectDisposed/데이터 인터리빙을 방지. 상태머신 CAS로 동시 재접속도 단일화.