← 문제로

22. 세션별 수신 처리의 직렬화 부재 (동시/재진입 수신 콜백)

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

해설 — 세션별 수신 처리의 직렬화 부재 (동시/재진입 수신 콜백)

난이도: 중상

요약

한 세션의 누적 수신 버퍼(RecvBuf)와 파싱 상태(RecvLen)는 "한 번에 한 곳"에서만 만져야 하는 상태인데, 코드가 다음 수신을 먼저 걸고(D) 처리는 스레드풀에 던진다(C). 결과적으로 같은 세션의 두 수신 완료가 동시에 OnData를 실행할 수 있어 프레임 경계가 깨지고, 누적 시 용량 검사도 없으며(A), 길이 프리픽스를 신뢰해 버퍼 밖을 읽거나 예외로 세션이 죽는다.

문제점

1. (동시성/재진입) 세션 수신 경로가 직렬화되지 않음 — (C)(D)

  • 증상: 한 세션에서 프레임이 꼬인다. 같은 바이트가 두 번 처리되거나, 일부가 누락되고, RecvLen이 음수/과대값이 되어 파싱이 폭주한다.
  • 재현조건: 데이터가 빠르게 여러 조각으로 도착해 첫 OnData가 끝나기 전에 두 번째 수신 완료가 들어오는 경우. BeginReceive(D)를 처리 전에 즉시 호출하므로 두 번째 콜백이 곧바로 또 QueueUserWorkItem을 던진다.
  • 근본원인: RecvBuf/RecvLen은 세션당 공유 가변 상태인데 접근이 직렬화되지 않는다. 스레드풀 작업 두 개가 같은 세션 상태를 동시에 읽고 쓴다(데이터 경합). (A)의 Array.Copy와 (B)의 잔여 압축이 서로의 오프셋을 덮어쓴다.

2. (메모리/경계) 누적 시 버퍼 용량 미검사 — (A)

  • 증상: RecvLen + n > 8192이면 Array.Copy가 대상 범위를 넘어 IndexOutOfRangeException(또는 인접 영역 오염).
  • 재현조건: 소비가 느리거나 8KB에 근접하는 프레임이 누적될 때.
  • 근본원인: 고정 8192 버퍼인데 누적 전에 남은 공간을 확인하지 않는다. 8KB보다 큰 단일 프레임은 영원히 완성되지 못한다.

3. (프로토콜/입력검증) 길이 프리픽스 미검증 — flen

  • 증상: 음수 flen이면 RecvLen - off - 4 < flen이 거짓이 되어 ProcessFrame(..., off+4, 음수)로 진입 → 버퍼 밖 접근/예외. 과대 flen은 버퍼를 점유한 채 진행을 막는다(최대 프레임 상한 부재).
  • 근본원인: 신뢰할 수 없는 입력에서 읽은 flen[0, MaxFrame] 범위로 검증하지 않는다.

4. (안정성) 예외 비격리 — 세션 사망

  • 위 결함들이 던지는 예외가 잡히지 않아 잘못된 패킷 하나가 세션(또는 워커)을 죽인다.

수정안

핵심은 세션당 수신 처리를 직렬화하고, 누적 전 용량/길이 검증, 현재 청크를 다 처리한 뒤 다음 수신을 거는 것이다.

public class Session
{
    public Socket Sock;
    public byte[] RecvBuf = new byte[64 * 1024];
    public int    RecvLen;
    public readonly object RecvGate = new();   // 세션당 수신 직렬화
    public const int MaxFrame = 32 * 1024;
}

public void OnReceiveCompleted(Session s, byte[] buf, int n)
{
    if (n <= 0) { Close(s); return; }
    try
    {
        // 처리를 끝낸 뒤에 다음 수신을 건다(재진입 차단). 세션당 직렬화.
        lock (s.RecvGate)
        {
            OnData(s, buf, n);
        }
    }
    catch (ProtocolException) { Close(s); return; }   // 프로토콜 오류 → 세션만 종료
    BeginReceive(s);                                   // 처리 완료 후 재무장
}

public void OnData(Session s, byte[] data, int n)
{
    if (s.RecvLen + n > s.RecvBuf.Length)
        throw new ProtocolException("recv overflow");  // 또는 버퍼 확장/백프레셔

    Array.Copy(data, 0, s.RecvBuf, s.RecvLen, n);
    s.RecvLen += n;

    int off = 0;
    while (s.RecvLen - off >= 4)
    {
        int flen = BitConverter.ToInt32(s.RecvBuf, off);
        if (flen < 0 || flen > Session.MaxFrame)
            throw new ProtocolException("bad frame length");
        if (s.RecvLen - off - 4 < flen) break;         // 아직 미완성
        ProcessFrame(s, s.RecvBuf, off + 4, flen);
        off += 4 + flen;
    }
    Buffer.BlockCopy(s.RecvBuf, off, s.RecvBuf, 0, s.RecvLen - off);
    s.RecvLen -= off;
}

lock은 같은 세션의 콜백이 겹쳐도 한 번에 하나만 OnData를 실행하게 한다. 단, BeginReceiveOnData (처리 후)에서 부르는 것이 핵심이다 — 그래야 직전 청크 처리 전에 새 콜백이 쌓이지 않는다.

더 나은 설계

  • 세션 = 액터/스트랜드(single-threaded executor): 한 세션의 모든 I/O 완료를 같은 직렬 큐로 보내 락 없이 직렬화한다. 락 경합이 사라지고 추론이 쉬워진다.
  • SocketAsyncEventArgs 한 개를 세션당 재사용해 "동시에 단 하나의 outstanding recv"를 보장하면 애초에 콜백이 겹치지 않는다(가장 간단한 직렬화).
  • 링 버퍼 + 길이 상한 + 백프레셔: 매 콜백 Array.Copy 압축(O(n)) 대신 링 버퍼로 잔여를 관리하고, 과대 프레임/느린 소비는 상한·흐름제어로 막는다.
  • 트레이드오프: 액터/스트랜드는 세션당 처리량 상한이 단일 코어에 묶일 수 있다(보통 게임 세션엔 충분). 전역 락 하나로 묶으면 절대 안 된다 — 세션 간 병렬성을 죽인다.

면접 포인트

  1. "비동기 수신에서 다음 BeginReceive언제 거는가"가 정합성을 좌우한다 — 처리 전에 재무장하면 재진입/경합이 열린다.
  2. 세션 상태 보호는 전역 락이 아니라 세션 단위 직렬화(스트랜드/액터/세션당 락)로 — 병렬성과 정합성을 동시에.
  3. 길이 프리픽스 프로토콜의 3대 검증: flen >= 0, flen <= MaxFrame, 누적 버퍼 용량. 하나라도 빠지면 OOB/DoS.