← 문제로

20. 송신 측 부분 전송(short write)과 동시 전송으로 인한 프레임 경계 붕괴 (C#)

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

해설 — 송신 측 부분 전송(short write)과 동시 전송으로 인한 프레임 경계 붕괴 (C#)

난이도: 중상

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

요약

SendMessage 가 길이 프리픽스 프레임을 부분 전송도, 동시 전송도, 엔디안도 고려하지 않는다. (A) Socket.Send반환값(보낸 바이트 수)을 무시한다. 송신 버퍼가 차면 요청보다 적게 보낼 수 있는데, 나머지를 마저 보내지 않으면 프레임 일부만 나가 수신 측이 길이만큼 못 읽거나 다음 메시지를 옛 프레임 잔여로 오해한다. (B) 여러 스레드가 같은 소켓에 동시에 SendMessage 를 호출하는데 직렬화가 없어 두 메시지의 바이트가 와이어에서 교차(interleave) 한다. (C) 헤더와 페이로드를 별도 Send 로 보내, 단일 생산자라도 그 사이에 다른 전송이 끼면 프레임이 찢어진다. (D) BitConverter.GetBytes머신 엔디안이라, 합의 포맷(또는 상대 바이트오더)과 다르면 길이를 오독한다. 결과는 모두 프레임 경계 디싱크 → 영구 오파싱이다. 정답 한 줄: 프레임(헤더+페이로드) 을 하나의 버퍼로 만들어 연결별 락/송신 큐로 직렬화하고, "다 보낼 때까지" 루프(부분 전송 처리)로 내보내며, 길이 프리픽스는 고정 엔디안(BinaryPrimitives)으로 직렬화하라.

변별:

  • protocol_version/problem1(길이 프리픽스 수신/부분 수신): 본 문제는 송신 측 프레이밍 무결성(short write·interleave·엔디안)이다.
  • session_network/problem5(백프레셔 송신 큐 폭증 + 재접속 복구): 그쪽은 큐 크기/복구 정책. 본 문제는 큐 유무를 떠나 한 프레임을 원자적·완전하게 내보내는 정확성이 초점.

문제점

(A) Send 반환값 무시 — 부분 전송 미처리 (정합/프로토콜) ★간판

  • 분류 태그: ignored return value / short write.
  • 증상: Socket.Send(...) 는 보낸 바이트 수를 반환하며 요청보다 적을 수 있다. 헤더 4B 중 일부, 페이로드 일부만 나가고 나머지를 안 보내면 그 연결의 모든 후속 프레임이 밀려 오파싱.
  • 재현조건: 송신량 > 커널 송신 버퍼 여유(대용량/브로드캐스트/느린 피어), 논블로킹 소켓.
  • 근본 원인: "보낸 바이트만큼 오프셋 전진하며 전부 보낼 때까지 반복" 하는 루프 부재. SocketError.WouldBlock(논블로킹 시 큐잉) 처리도 없음.

(B) 동시 전송 직렬화 부재 — 바이트 교차 (동시성·프로토콜) ★간판

  • 분류 태그: data race / missing serialization.
  • 증상: 두 스레드가 같은 소켓에 동시에 SendMessage. 두 메시지의 헤더/페이로드가 와이어에서 섞여(A_hdr, B_hdr, B_pay, A_pay) 수신 측이 프레임을 복원 못 한다.
  • 근본 원인: 한 연결의 출력 스트림은 직렬 자원인데 상호배제/단일 라이터가 없다. (NetworkStream/Socket 의 동시 쓰기는 안전 보장이 없음.)

(C) 헤더·페이로드 분리 전송 — 프레임 찢김 (프로토콜·동시성)

  • 분류 태그: non-atomic framing.
  • 증상: Send(hdr)Send(payload) 사이에 (B)의 다른 전송이 끼면 분리된다. (A)와 결합 시 악화.
  • 근본 원인: 한 프레임을 두 번에 나눠 쓰며 그 구간이 보호되지 않음.

(D) 길이 프리픽스 엔디안 미합의 — 길이 오독 (프로토콜/호환)

  • 분류 태그: endianness / wire-format assumption.
  • 증상: BitConverter.GetBytesBitConverter.IsLittleEndian 에 따라 바이트오더가 갈린다. 합의 포맷이 big-endian 이거나 상대가 다른 바이트오더면 길이를 뒤집어 읽어 거대/엉뚱한 프레임.
  • 근본 원인: 와이어 바이트오더를 고정/변환하지 않음.

수정안

핵심: 프레임을 하나의 버퍼로, 연결별 락(또는 송신 큐) 으로 직렬화, "다 보낼 때까지" 루프, 고정 엔디안.

using System;
using System.Buffers.Binary;
using System.Net.Sockets;

public class MessageSender
{
    private readonly Socket _sock;
    private readonly object _sendGate = new();   // 연결별 송신 직렬화

    public MessageSender(Socket sock) { _sock = sock; }

    public void SendMessage(ReadOnlySpan<byte> payload)
    {
        // 헤더+페이로드를 하나의 프레임 버퍼로 (한 번에)
        var frame = new byte[4 + payload.Length];
        BinaryPrimitives.WriteUInt32BigEndian(frame.AsSpan(0, 4), (uint)payload.Length); // (D) 고정 BE
        payload.CopyTo(frame.AsSpan(4));                                                  // (C) 한 버퍼

        lock (_sendGate)                         // (B) 연결별 직렬화
        {
            SendAll(frame);                      // (A) 전부 보낼 때까지 루프
        }
    }

    private void SendAll(byte[] buf)
    {
        int sent = 0;
        while (sent < buf.Length)
        {
            int n = _sock.Send(buf, sent, buf.Length - sent, SocketFlags.None, out var err);
            if (n > 0) { sent += n; continue; }
            if (err == SocketError.WouldBlock || err == SocketError.NoBufferSpaceAvailable)
            {
                // 논블로킹: 쓰기 가능해질 때까지 대기하거나, 남은 부분을 큐에 보관 후 재드레인
                if (!_sock.Poll(-1, SelectMode.SelectWrite)) throw new SocketException();
                continue;
            }
            if (err == SocketError.Interrupted) continue;
            throw new SocketException((int)err);  // 연결 오류 → 상위에서 종료 처리
        }
    }
}

포인트

  • 한 버퍼 한 프레임 + 연결별 락(또는 송신 큐+단일 라이터) → interleave·찢김 제거.
  • Send 반환값으로 오프셋 전진 + 전부 보낼 때까지 루프, WouldBlockPoll/큐잉 → short write·백프레셔에도 프레임 완전 전송.
  • BinaryPrimitives.WriteUInt32BigEndian 으로 고정 엔디안(수신도 동일 규약 디코드).
  • 고처리량이면 동기 lock+blocking 대신 연결별 비동기 송신 큐(Channel<T> + 단일 소비자 + SendAsync)로 직렬화하는 편이 낫다(아래 설계 1).

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

  1. 연결별 송신 큐 + 단일 소비자(async): 생산자는 프레임을 Channel 에 enqueue, 한 소비자가 SendAsync 로 순서대로 비운다. 락 경합·블로킹 제거, interleave 원천 차단. 트레이드오프: 큐/상태 관리.
  2. 송신 큐 상한 + 백프레셔 정책: 한계 초과 시 느린 피어 종료/드롭/우선순위(session5 연계). 트레이드오프: 지연 vs 메모리.
  3. 코덱 일원화: 길이/엔디안/최대 크기/버전을 한 직렬화 계층에 모아 송수신 대칭(수신 측 길이 검증·상한과 일치). 트레이드오프: 추상화 비용.
  4. 버퍼 풀링: 프레임 버퍼를 ArrayPool<byte> 로 재사용해 GC 압력 완화. 트레이드오프: 수명/반납 관리.

면접 포인트 (예상 질문)

  1. Socket.Send 가 요청보다 적게 보낼 수 있는 이유와, 반환값을 무시하면 수신 측 프레이밍이 어떻게 영구히 어긋나는가? WouldBlock 은 어떻게 처리하나?
  2. 한 연결에 여러 스레드가 동시에 쓰면 왜 바이트가 섞이는가? 헤더·페이로드를 따로 보내는 것이 왜 위험한가? 한 프레임을 어떻게 원자적으로 내보내는가?
  3. BitConverter 의 엔디안 의존성이 왜 문제인가? 이 문제(송신)와 problem1(수신)·session5 (백프레셔)는 무엇이 다른가?