20. 송신 측 부분 전송(short write)과 동시 전송으로 인한 프레임 경계 붕괴 (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.GetBytes는BitConverter.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반환값으로 오프셋 전진 + 전부 보낼 때까지 루프,WouldBlock시Poll/큐잉 → short write·백프레셔에도 프레임 완전 전송.BinaryPrimitives.WriteUInt32BigEndian으로 고정 엔디안(수신도 동일 규약 디코드).- 고처리량이면 동기
lock+blocking 대신 연결별 비동기 송신 큐(Channel<T>+ 단일 소비자 +SendAsync)로 직렬화하는 편이 낫다(아래 설계 1).
더 나은 설계 (+트레이드오프)
- 연결별 송신 큐 + 단일 소비자(async): 생산자는 프레임을
Channel에 enqueue, 한 소비자가SendAsync로 순서대로 비운다. 락 경합·블로킹 제거, interleave 원천 차단. 트레이드오프: 큐/상태 관리. - 송신 큐 상한 + 백프레셔 정책: 한계 초과 시 느린 피어 종료/드롭/우선순위(session5 연계). 트레이드오프: 지연 vs 메모리.
- 코덱 일원화: 길이/엔디안/최대 크기/버전을 한 직렬화 계층에 모아 송수신 대칭(수신 측 길이 검증·상한과 일치). 트레이드오프: 추상화 비용.
- 버퍼 풀링: 프레임 버퍼를
ArrayPool<byte>로 재사용해 GC 압력 완화. 트레이드오프: 수명/반납 관리.
면접 포인트 (예상 질문)
Socket.Send가 요청보다 적게 보낼 수 있는 이유와, 반환값을 무시하면 수신 측 프레이밍이 어떻게 영구히 어긋나는가?WouldBlock은 어떻게 처리하나?- 한 연결에 여러 스레드가 동시에 쓰면 왜 바이트가 섞이는가? 헤더·페이로드를 따로 보내는 것이 왜 위험한가? 한 프레임을 어떻게 원자적으로 내보내는가?
BitConverter의 엔디안 의존성이 왜 문제인가? 이 문제(송신)와 problem1(수신)·session5 (백프레셔)는 무엇이 다른가?
해설 — 송신 측 부분 전송(short write)과 동시 전송으로 인한 프레임 경계 붕괴 (C++)
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
sendMessage 가 길이 프리픽스 프레임을 부분 전송도, 동시 전송도, 엔디안도 고려하지 않는다.
(A) rawSend 의 반환값을 무시한다. send() 는 송신 버퍼가 차면 요청보다 적게 보내는데,
나머지를 마저 보내지 않으면 프레임의 일부만 와이어에 나가고 그 뒤 바이트들이 잘려, 수신 측은
길이만큼 못 읽거나 다음 메시지를 옛 프레임의 잔여로 오해한다. (B) 여러 스레드가 같은 소켓에
동시에 sendMessage 를 호출하는데 직렬화가 없어, 두 메시지의 바이트가 와이어에서 교차
(interleave) 한다. (C) 헤더(4B)와 페이로드를 별도 호출로 보내므로, 단일 생산자라도 그 사이에
다른 전송이 끼면 "msg1 헤더 → msg2 헤더+페이로드 → msg1 페이로드" 처럼 프레임이 찢어진다.
(D) 길이를 호스트 엔디안 그대로 직렬화해, 다른 바이트오더의 수신자가 길이를 거대/엉뚱하게
읽어 프레임이 어긋난다. 결과는 모두 프레임 경계 디싱크 → 영구 오파싱이다. 정답 한 줄:
프레임(헤더+페이로드)을 하나의 버퍼로 만들어 연결별 송신 큐에 넣고, 단일 라이터가 "다 보낼
때까지" 루프(부분 전송/EINTR/EAGAIN 처리)로 내보내며, 길이 프리픽스는 고정 엔디안으로 직렬화하라.
변별:
- protocol_version/problem1(길이 프리픽스 수신/부분 수신): 본 문제는 송신 측 프레이밍 무결성(short write·interleave·엔디안)이다.
- session_network/problem5(백프레셔 송신 큐 폭증 + 재접속 복구): 그쪽은 큐 크기/복구 정책. 본 문제는 큐 유무를 떠나 한 프레임을 원자적·완전하게 내보내는 정확성이 초점.
문제점
(A) rawSend 반환값 무시 — 부분 전송 미처리 (정합/프로토콜) ★간판
- 분류 태그: ignored return value / short write.
- 증상:
send()/rawSend는 송신 버퍼 여유만큼만 보내고 보낸 바이트 수를 반환한다. 4바이트 헤더 중 3바이트만, 또는 페이로드 1KB 중 200B 만 나갈 수 있다. 나머지를 안 보내면 수신 측 길이 해석이 어긋나 그 연결의 모든 후속 프레임이 밀려 오파싱된다. - 재현조건: 송신량 > 커널 송신 버퍼 여유(대용량/브로드캐스트/느린 피어).
- 근본 원인: "보낸 바이트 수만큼 전진하며 전부 보낼 때까지 반복" 하는 루프가 없다.
EINTR(재시도)·EAGAIN/EWOULDBLOCK(논블로킹 시 큐잉) 처리도 없다.
(B) 동시 전송 직렬화 부재 — 바이트 교차 (동시성·프로토콜) ★간판
- 분류 태그: data race / missing serialization.
- 증상: 두 스레드가 같은
sock_에 동시에sendMessage. 두 메시지의 헤더/페이로드 바이트가 와이어에서 섞여(A_hdr, B_hdr, B_pay, A_pay등) 수신 측이 프레임을 복원할 수 없다. - 근본 원인: 한 연결의 출력 스트림은 직렬 자원인데 상호배제/단일 라이터가 없다.
(C) 헤더·페이로드 분리 전송 — 프레임 찢김 (프로토콜·동시성)
- 분류 태그: non-atomic framing.
- 증상:
rawSend(hdr)후rawSend(payload)사이에 (B)의 다른 전송이 끼면 헤더와 페이로드가 분리된다. (A)와 결합하면 한쪽만 부분 전송돼 더 악화. - 근본 원인: 한 프레임을 두 번에 나눠 쓰며 그 구간이 보호되지 않음.
(D) 길이 프리픽스 엔디안 미합의 — 길이 오독 (프로토콜/호환)
- 분류 태그: endianness / wire-format assumption.
- 증상:
memcpy(hdr, &len, 4)는 호스트 바이트오더(보통 little-endian)를 그대로 쓴다. big-endian 수신자(또는 합의 포맷이 BE)면 길이를 뒤집어 읽어(예: 0x01000000) 거대 프레임을 기대하거나 잘못 분할한다. - 근본 원인: 와이어 포맷의 바이트오더를 고정/변환하지 않음.
수정안
핵심: 프레임을 하나의 버퍼로 만들고, 연결별 송신 큐 + 단일 라이터로 "다 보낼 때까지" 루프 전송, 길이는 고정 엔디안.
#include <cstdint>
#include <cstring>
#include <vector>
#include <deque>
#include <mutex>
#include <cerrno>
static void putU32BE(uint8_t* p, uint32_t v) { // 고정 big-endian
p[0] = uint8_t(v >> 24); p[1] = uint8_t(v >> 16);
p[2] = uint8_t(v >> 8); p[3] = uint8_t(v);
}
class MessageSender {
public:
explicit MessageSender(Socket& sock) : sock_(sock) {}
// 프레임을 통째로 만들어 큐에 넣고 드레인 (여러 스레드 호출 안전)
void sendMessage(const std::vector<uint8_t>& payload) {
std::vector<uint8_t> frame(4 + payload.size());
putU32BE(frame.data(), static_cast<uint32_t>(payload.size())); // (D) 고정 엔디안
std::memcpy(frame.data() + 4, payload.data(), payload.size()); // (C) 헤더+페이로드 한 버퍼
std::lock_guard<std::mutex> g(mu_); // (B) 연결별 직렬화
outbox_.push_back(std::move(frame));
drainLocked(); // 단일 라이터(여기선 락 보유 스레드)가 순서대로 전송
}
private:
void drainLocked() {
while (!outbox_.empty()) {
auto& buf = outbox_.front();
// (A) 보낸 바이트만큼 전진하며 "전부 보낼 때까지" 루프
while (sent_ < buf.size()) {
long n = sock_.rawSend(reinterpret_cast<const char*>(buf.data()) + sent_,
buf.size() - sent_);
if (n > 0) { sent_ += static_cast<size_t>(n); continue; }
if (n < 0 && errno == EINTR) continue; // 시그널 → 재시도
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) // 송신 버퍼 가득
return; // 큐에 남겨두고, 소켓 쓰기 가능(EPOLLOUT) 시 다시 drain
// 그 외 n<0: 연결 오류 → 상위에서 연결 종료 처리
return;
}
sent_ = 0;
outbox_.pop_front(); // 한 프레임을 끝까지 보낸 뒤에야 다음으로
}
}
Socket& sock_;
std::mutex mu_;
std::deque<std::vector<uint8_t>> outbox_; // 연결별 송신 큐(프레임 단위)
size_t sent_ = 0; // 현재 프레임에서 보낸 바이트
};
포인트
- 한 버퍼 한 프레임 + 연결별 큐/단일 라이터 → interleave·찢김 제거(프레임 경계 보존).
- 부분 전송 루프(
sent_전진) +EINTR재시도 +EAGAIN시 큐 보존(논블로킹/EPOLLOUT재드레인) → short write 에도 프레임 완전 전송, 백프레셔에도 유실/붕괴 없음. - 고정 big-endian 길이 → 송수신 바이트오더 합의(수신도 동일 규약으로 디코드).
- (대안) writev/sendmsg 로 헤더+페이로드를 한 시스템콜에 모아 보내되, 그 호출도 부분 전송이 가능하므로 동일하게 "남은 부분 루프" 가 필요.
더 나은 설계 (+트레이드오프)
- 연결별 송신 액터 + EPOLLOUT 기반 드레인: 애플리케이션은 프레임을 enqueue 만 하고, IO 스레드가 쓰기 가능 이벤트에 맞춰 큐를 비운다. 락 경합·블로킹 최소화. 트레이드오프: 이벤트 루프/상태머신 복잡도.
- 송신 큐 상한 + 백프레셔 정책: 큐가 한계를 넘으면 느린 피어를 끊거나(slow consumer), 우선순위/드롭 정책 적용(session_network/problem5 연계). 트레이드오프: 지연 vs 메모리.
- 메시지 코덱 일원화: 길이/엔디안/최대 크기/버전을 한 직렬화 계층에 모아 송수신이 같은 규약을 공유(수신 측 길이 검증·상한과 대칭). 트레이드오프: 추상화 비용.
- scatter-gather(writev): 헤더와 페이로드 복사 없이 한 번에. 트레이드오프: 부분 전송 시 iovec 오프셋 관리가 복잡.
면접 포인트 (예상 질문)
send()가 요청보다 적게 보낼 수 있는 이유와, 반환값을 무시하면 수신 측 프레이밍이 어떻게 영구히 어긋나는지 설명하라.EAGAIN/EINTR은 각각 어떻게 처리하나?- 한 연결에 여러 스레드가 동시에 쓰면 왜 바이트가 섞이는가? 헤더·페이로드를 따로 보내는 것이 왜 위험한가? 어떻게 한 프레임을 원자적으로 내보내는가?
- 길이 프리픽스의 엔디안을 고정하지 않으면 무슨 일이 생기는가? 이 문제(송신)와 problem1 (수신)·session5(백프레셔)는 무엇이 다른가?