39. 수신 버퍼 컴팩션 누락: 부분 프레임 유실로 인한 프레임 desync (C#)
난이도 중내 리뷰 · 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):PipeReader는SequencePosition으로 소비/검토(consumed/examined)를 분리해 부분 프레임 보존을 프레임워크가 처리한다. 수작업 컴팩션 실수를 없앤다. - 읽기 오프셋 유지(head/tail): 매번 앞으로 당기는 대신
readPos/writePos를 두고 여유가 없을 때만 컴팩션 → 복사 비용 절감. - 트레이드오프: 매 수신 컴팩션은 단순하지만 O(잔여) 복사. 링 버퍼/Pipe 는 복사를 줄이지만 구현·경계 관리가 복잡. 게임 서버의 프레임 크기·빈도에 맞춰 선택.
면접 포인트
- TCP 는 스트림이라 "메시지 경계"는 애플리케이션이 만든다 — 부분 프레임 보존이 프레이밍의 핵심이며, 여기서 실수하면 조각 경계에 의존하는 간헐적·재현 어려운 desync 가 난다.
consumedvsexamined(어디까지 소비했나 vs 어디까지 봤나)의 구분과 컴팩션 위치.- 최대 프레임 크기 상한이 없으면 부분 프레임 보존이 곧 무제한 버퍼링(메모리·HoL)로 악용될 수 있다.
해설 · C++
해설 — 수신 버퍼 컴팩션 누락: 부분 프레임 유실로 인한 프레임 desync (C++)
난이도: 중상
요약
onReceive 는 완결 프레임을 처리한 뒤 buffer_.clear() 로 누적 버퍼를 통째로 비운다. 하지만 마지막에 남은 "다음 프레임의 앞부분(부분 프레임)"까지 함께 버려져, 다음 recv 분이 잘린 프레임과 이어지지 않고 처음처럼 파싱된다 → 프레임 경계 붕괴(desync). 게다가 컴팩션을 고쳐 잔여가 쌓이기 시작하면 buffer_ 에 최대 크기 상한이 없어 무제한 증가(DoS) 위험이 드러난다.
문제점
(A) 처리 후 남은 바이트를 버림 — 버퍼 컴팩션 누락 → 프레임 desync
- 증상: 프레임이 헤더/페이로드 중간에서 쪼개져 도착하는 특정 조각 경계에서 이후 모든 프레임이 깨진다.
len을 엉뚱한 바이트에서 읽어 가비지 프레임을 디스패치하거나 파싱이 영구히 어긋난다. - 재현 조건: 완결 프레임 몇 개 + 다음 프레임의 앞 3바이트가 한 recv 로 도착.
while은 완결분만 소비하고break,offset은 그 3바이트 앞을 가리킨다. 그런데clear()가 그 3바이트를 지운다. 다음 recv 는 나머지 페이로드를 헤더로 오해. - 근본 원인: 소비한
[0, offset)만 제거하고 미소비[offset, size)는 앞으로 당겨 보존해야 하는데, 버퍼 전체를 비웠다.
(B) 누적 버퍼 최대 크기 상한 부재 — 무제한 증가(DoS)
- 증상: (A)를 고쳐 잔여를 보존하기 시작하면, 페이로드가 계속 덜 오는(혹은 악의적으로 헤더에 큰
len을 넣고 조금씩만 보내는) 연결이buffer_를 무한히 키운다. - 재현 조건:
len= 65535 를 선언하고 1바이트씩 천천히 전송하는 slow-loris 류. - 근본 원인: 프레임 최대 크기·누적 버퍼 상한 정책이 없다.
수정안
소비량만큼 erase(또는 앞당김)로 잔여를 보존하고, 최대 프레임 크기로 방어한다.
void onReceive(const uint8_t* data, size_t n) {
constexpr size_t kMaxFrame = 16 * 1024;
constexpr size_t kMaxBuffer = 64 * 1024;
// (B) 누적 상한 검사
if (buffer_.size() + n > kMaxBuffer) { /* 연결 종료 처리 */ return; }
size_t oldSize = buffer_.size();
buffer_.resize(oldSize + n);
std::memcpy(buffer_.data() + oldSize, data, n);
size_t offset = 0;
while (buffer_.size() - offset >= 2) {
uint16_t len = 0;
std::memcpy(&len, buffer_.data() + offset, 2);
if (len > kMaxFrame) { /* 프로토콜 위반 → 종료 */ return; }
if (buffer_.size() - offset - 2 < len) break; // 부분 프레임 → 대기
std::vector<uint8_t> frame(buffer_.begin() + offset + 2,
buffer_.begin() + offset + 2 + len);
if (onFrame) onFrame(frame);
offset += 2 + len;
}
// (A) 소비한 offset만 제거, 잔여를 앞으로 보존
if (offset > 0)
buffer_.erase(buffer_.begin(), buffer_.begin() + offset);
}
erase(begin, begin+offset)는 소비분만 제거하고 나머지를 앞으로 당겨 잔여를 보존한다. 부분 프레임이 다음 recv 와 자연스럽게 이어진다.len을 읽는 순간kMaxFrame으로 검증해 거대 프레임 대기·slow-loris 를 차단한다.
더 나은 설계
- head 오프셋 + 지연 컴팩션: 매 recv 마다
erase(O(잔여) 이동) 대신readPos_를 전진시키고, 앞 공간이 커지거나 뒤 공간이 부족할 때만 한 번에memmove로 컴팩션 → 복사 최소화. asio::streambuf/링 버퍼: 검증된 스트림 버퍼는 consume/commit 경계를 관리해 수작업 오프셋 실수를 없앤다.- iterator/포인터 무효화 주의: 파싱 도중
buffer_.resize/erase가 일어나면 잡고 있던 반복자·포인터가 무효화된다. 본 수정은 파싱을 끝낸 뒤 한 번만erase하므로 안전하지만, 콜백 안에서 같은 버퍼를 다시 만지는 재진입은 금지해야 한다. - 트레이드오프: 매번
erase는 단순하지만 O(잔여) 복사, head 오프셋 방식은 빠르지만 관리 복잡.
면접 포인트
- TCP 스트림에서 프레이밍의 본질은 "부분 메시지 보존". 소비량(
offset)과 전체량(size)을 분리하고, 소비분만 제거하는 컴팩션을 정확히 구현해야 한다. std::vector::erase/resize의 반복자 무효화 규칙과, 파싱 루프가 버퍼를 재할당하는 순간의 위험.- 부분 프레임 보존은 반드시 최대 프레임/버퍼 상한과 함께여야 한다 — 아니면 보존이 곧 무제한 버퍼링(DoS)이 된다.