← 문제로

39. 수신 버퍼 컴팩션 누락: 부분 프레임 유실로 인한 프레임 desync (C#)

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

해설 — 수신 버퍼 컴팩션 누락: 부분 프레임 유실로 인한 프레임 desync (C#)

난이도: 중상

요약

OnReceive 는 완결 프레임을 처리한 뒤 _count = 0 으로 버퍼를 통째로 비운다. 하지만 마지막에 남은 "다음 프레임의 앞부분(부분 프레임)"은 아직 처리되지 않았는데도 버려진다. 그 결과 다음 수신분이 잘린 프레임의 중간부터 이어지지 않고 처음처럼 파싱되어, 길이 필드를 엉뚱한 바이트에서 읽는 프레임 경계 붕괴(desync) 가 발생한다. 또한 누적 버퍼에 이어붙일 때 용량 검사가 없어 오버런 위험이 있다.

문제점

(A) 처리 후 남은 바이트를 버림 — 버퍼 컴팩션 누락 → 프레임 desync

  • 증상: 특정 조각 경계(프레임이 헤더/페이로드 중간에서 쪼개져 도착)에서 이후 모든 프레임이 깨진다. 랜덤한 길이·가비지 페이로드가 디스패치되거나 파싱이 영구적으로 어긋난다.
  • 재현 조건: 완결 프레임 몇 개 + 다음 프레임의 앞 3바이트가 한 번에 도착. while 루프는 완결분만 소비하고 break, offset 은 그 3바이트 앞을 가리킨다. 그런데 _count = 0 이 그 3바이트를 버린다. 다음 OnReceive 는 페이로드의 나머지를 프레임 헤더로 오해 → desync.
  • 근본 원인: "소비한 바이트만 제거하고 미소비(부분) 바이트는 앞으로 당겨 보존"하는 컴팩션을 하지 않고, 버퍼 전체를 리셋했다. offset(소비량)과 _count(전체량)의 차이가 곧 보존해야 할 잔여인데 그것을 버렸다.

(B) 누적 append 시 용량 검사 부재 — 버퍼 오버런

  • 증상: data.CopyTo(_buffer.AsSpan(_count)) 가 남은 공간보다 큰 데이터를 복사하려 하면 ArgumentException(대상 span 초과)으로 연결이 죽는다. 컴팩션을 고쳐 잔여가 쌓이기 시작하면 64KB 고정 버퍼가 더 쉽게 참.
  • 재현 조건: 잔여 + 신규 수신 합계가 64KB 를 초과. 특히 (A)를 고친 뒤 큰 프레임이 여러 조각으로 나뉘어 올 때.
  • 근본 원인: 고정 크기 버퍼에 최대 프레임 크기 정책과 남은 공간 검사가 없다.

수정안

소비한 만큼만 앞으로 당겨(컴팩션) 잔여를 보존하고, append 전에 용량·최대 프레임 크기를 검사한다.

public void OnReceive(ReadOnlySpan<byte> data)
{
    const int MaxFrame = 16 * 1024;

    // (B) 용량 검사: 남은 공간이 부족하면 방어(끊기/에러)
    if (data.Length > _buffer.Length - _count)
        throw new InvalidOperationException("recv buffer overflow"); // 실제로는 연결 종료 처리

    data.CopyTo(_buffer.AsSpan(_count));
    _count += data.Length;

    int offset = 0;
    while (_count - offset >= 2)
    {
        int len = BitConverter.ToUInt16(_buffer.AsSpan(offset, 2));
        if (len > MaxFrame) throw new InvalidOperationException("frame too large");
        if (_count - offset - 2 < len) break;   // 부분 프레임 → 대기

        OnFrame(_buffer.AsSpan(offset + 2, len).ToArray());
        offset += 2 + len;
    }

    // (A) 소비한 offset만 제거하고 잔여를 앞으로 당김(컴팩션)
    int remaining = _count - offset;
    if (remaining > 0 && offset > 0)
        _buffer.AsSpan(offset, remaining).CopyTo(_buffer.AsSpan(0));
    _count = remaining;
}
  • 핵심은 _count = remaining(= _count - offset) 로 미소비 바이트를 살리는 것. offset > 0 일 때만 CopyTo 로 앞당겨 겹침 없이 이동한다.
  • MaxFrame 상한으로 악의적 길이(거대 페이로드 대기 → 메모리·HoL)를 차단한다.

더 나은 설계

  • 링 버퍼 / Pipe(System.IO.Pipelines): PipeReaderSequencePosition 으로 소비/검토(consumed/examined)를 분리해 부분 프레임 보존을 프레임워크가 처리한다. 수작업 컴팩션 실수를 없앤다.
  • 읽기 오프셋 유지(head/tail): 매번 앞으로 당기는 대신 readPos/writePos 를 두고 여유가 없을 때만 컴팩션 → 복사 비용 절감.
  • 트레이드오프: 매 수신 컴팩션은 단순하지만 O(잔여) 복사. 링 버퍼/Pipe 는 복사를 줄이지만 구현·경계 관리가 복잡. 게임 서버의 프레임 크기·빈도에 맞춰 선택.

면접 포인트

  • TCP 는 스트림이라 "메시지 경계"는 애플리케이션이 만든다 — 부분 프레임 보존이 프레이밍의 핵심이며, 여기서 실수하면 조각 경계에 의존하는 간헐적·재현 어려운 desync 가 난다.
  • consumed vs examined(어디까지 소비했나 vs 어디까지 봤나)의 구분과 컴팩션 위치.
  • 최대 프레임 크기 상한이 없으면 부분 프레임 보존이 곧 무제한 버퍼링(메모리·HoL)로 악용될 수 있다.