36. 송신 백프레셔 부재: 무제한 송신 큐와 블로킹 send (C#)
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 송신 백프레셔 부재: 무제한 송신 큐와 블로킹 send (C#)
난이도: 중상
요약
송신 경로에 백프레셔(backpressure)가 없다. (1) _sendQueue가 상한 없이 무한히 자라 느린/악의적 클라이언트 하나가 서버 메모리를 고갈시키고(느린 소비자 문제), (2) Send가 블로킹 소켓 호출이라 브로드캐스트를 도는 게임 로직 스레드가 한 세션의 소켓 버퍼가 가득 차면 그 자리에서 멈춰 존 전체 브로드캐스트가 정지한다. 한 클라이언트가 전체 서버를 인질로 잡는다.
문제점
1) [무제한 큐] 느린 소비자 → 메모리 고갈 (증상: OOM)
- 증상: (A)에서
Enqueue는 상한 검사 없이 무조건 추가한다. 클라이언트가 TCP 윈도우를 열지 않으면(수신 안 함)Send가 진행되지 못하고 큐에 프레임이 계속 쌓인다. 악의적 클라이언트는 일부러 recv 를 멈춰 이 큐를 무한히 키워 서버 힙을 소진시킨다(느린 소비자/slow-drain DoS). - 재현조건: 송신 속도 > 클라이언트 소비 속도가 지속.
- 근본원인: 큐 크기/바이트 상한과 초과 시 정책(드롭·연결종료)이 없다.
2) [블로킹 I/O가 로직 스레드를 점유] 브로드캐스트 정지 (증상: 전체 지연/멈춤)
- 증상: (B)의
_socket.Send(...)는 블로킹 소켓에서 커널 송신 버퍼가 가득 차면 반환하지 않고 대기한다.Enqueue→Flush가 게임 로직 스레드에서 동기 실행되므로(그리고 (C) 브로드캐스트 루프가 그 스레드에서 세션을 순차 순회하므로), 뒤처진 세션 하나에서Send가 블로킹되면 그 뒤 모든 세션이 업데이트를 못 받고 틱 루프가 밀린다. - 근본원인: I/O를 로직 스레드에서 동기·블로킹으로 수행.
3) [short write 미처리] (부수) Send 반환값 무시
- 증상: (B)는
sent를 받지만 쓰지 않는다.Send는 요청보다 적게 보낼 수 있는데(부분 전송), 성공을 가정하고 곧바로Dequeue하면 프레임 뒷부분이 유실돼 스트림 프레이밍이 깨진다. (블로킹 소켓에서도 시그널/타임아웃으로 발생 가능.)
수정안
비동기 논블로킹 송신 + 세션별 상한 큐 + 초과 시 격리 정책.
public sealed class Session
{
private readonly Socket _socket;
private readonly Queue<byte[]> _sendQueue = new();
private long _queuedBytes;
private bool _sending;
private const long MaxQueuedBytes = 256 * 1024; // 세션별 상한
// 로직 스레드: 절대 블로킹하지 않는다. 큐만 채우고 즉시 반환.
public bool Enqueue(byte[] frame)
{
lock (_sendQueue)
{
if (_queuedBytes + frame.Length > MaxQueuedBytes)
return false; // 백프레셔: 호출자에게 실패 통지 → 드롭/킥
_sendQueue.Enqueue(frame);
_queuedBytes += frame.Length;
if (_sending) return true; // 이미 비동기 송신 진행 중
_sending = true;
}
BeginSend(); // 논블로킹 비동기 시작
return true;
}
private void BeginSend()
{
byte[] frame;
lock (_sendQueue) { frame = _sendQueue.Peek(); }
_socket.BeginSend(frame, 0, frame.Length, SocketFlags.None, OnSent, frame);
}
private void OnSent(IAsyncResult ar)
{
int sent = _socket.EndSend(ar); // 부분 전송 처리
var frame = (byte[])ar.AsyncState!;
lock (_sendQueue)
{
if (sent < frame.Length) { // short write: 남은 부분 재큐
_sendQueue.Dequeue();
var rest = frame[sent..];
// 남은 조각을 앞에 다시 처리(간략화: 큐 앞 삽입 구조 사용)
_queuedBytes -= sent;
_sendQueue.Enqueue(rest); // 실제로는 우선 처리 큐 필요
} else {
_sendQueue.Dequeue();
_queuedBytes -= frame.Length;
}
if (_sendQueue.Count == 0) { _sending = false; return; }
}
BeginSend(); // 다음 프레임 이어서 비동기
}
}
- 로직 스레드는
Enqueue가 즉시 반환하므로 절대 I/O로 막히지 않는다. 실제 전송은 완료 콜백 체인(또는SocketAsyncEventArgs)으로. - 큐가 상한을 넘으면
Enqueue가false를 반환 → 호출자는 그 세션에 대해 비필수 프레임 드롭(예: 위치 스냅샷) 또는 연결 종료로 격리. 존 전체는 계속 진행.
더 나은 설계 (트레이드오프)
- 프레임 우선순위/합치기(coalescing): 뒤처진 세션에는 최신 스냅샷만 남기고 중간 프레임을 버리면(state-based) 큐 폭증을 근본 차단. 트레이드오프: 순서/증분 프로토콜엔 부적합.
- SocketAsyncEventArgs 풀 + 제로카피: 고동접에서
BeginSend의 할당을 줄이고 GC 압박 완화. - 관측·격리: 세션별 큐 깊이/지연을 메트릭으로 노출하고, 임계 초과 세션을 자동 스로틀/킥. 트레이드오프: 복잡도.
면접 포인트
- 블로킹 소켓
Send가 커널 버퍼 만차 시 블로킹된다는 점과, 그게 로직 스레드를 점유하면 왜 전체가 멈추는지. - 느린 소비자(slow consumer) 문제와 백프레셔 전략(상한·드롭·킥·coalescing)을 설명할 수 있는가.
Send/recv의 부분 전송(short write) 처리 필요성.
해설 · C++
해설 — 송신 백프레셔 부재와 송신 버퍼 수명 (C++)
난이도: 상
요약
송신 경로에 백프레셔가 없고, 더해 송신 버퍼의 수명이 큐에 저장된 포인터보다 짧다. (1) queue_가 상한 없이 무한 증가해 느린 클라이언트가 서버 메모리를 고갈, (2) 블로킹 send가 로직 스레드를 점유해 존 전체 브로드캐스트 정지, (3) (C)에서 만든 Frame이 buf(호출자 소유, 곧 소멸/재사용)를 가리키기만 하는데 (A)에서 그 포인터를 큐에 저장 → 비동기/지연 전송 시점에 원본이 사라지면 use-after-free, (4) send 반환값 무시로 부분 전송 유실.
문제점
1) [무제한 큐] 느린 소비자 → 메모리 고갈 (증상: OOM/DoS)
- 증상: (A)의
push_back에 상한 검사가 없다. 클라이언트가 recv 를 멈추면send가 진전되지 못하고 큐가 무한히 자란다. 악의적 클라이언트가 이를 노려 힙을 소진. - 근본원인: 큐 바이트/개수 상한과 초과 정책 부재.
2) [블로킹 send가 로직 스레드 점유] 브로드캐스트 정지 (증상: 틱 밀림)
- 증상: (B)의
send는 커널 송신 버퍼 만차 시 블로킹된다.Enqueue→Flush가 로직 스레드에서 동기 실행되므로 뒤처진 세션 하나에서 막히면 이후 모든 세션 전송이 멈춘다.
3) [댕글링 포인터] Frame 이 원본 버퍼를 소유하지 않음 (증상: UAF)
- 증상: (C)의
buf는Broadcast호출자의 지역/임시std::vector<char>다.Frame{buf.data(), size}는 그 내부 포인터만 담는다. (A)에서 큐에 저장된 뒤 실제send가 지연되면(비동기화하거나, 다른 세션 처리 중),buf가 스코프를 벗어나 소멸/재사용되어f.data는 해제된 메모리를 가리킨다 → UAF, 손상된 프레임 전송. (현재는 동기 send 라 우연히 살아있지만, 백프레셔를 위해 비동기화하는 순간 즉시 터진다.) - 근본원인: 송신 데이터의 소유권이 큐로 이전되지 않았다(뷰만 저장).
4) [short write 미처리] send 반환값 무시
send는 요청보다 적게 보낼 수 있고EAGAIN(논블로킹)/EINTR도 반환한다. (B)는 반환값을 무시하고 곧장pop_front→ 프레임 뒷부분 유실, 프레이밍 붕괴.
수정안
소유권 있는 버퍼(shared_ptr) + 상한 큐 + 논블로킹 비동기 송신 + 부분 전송 처리.
#include <memory>
using Buf = std::shared_ptr<std::vector<char>>; // 소유권을 큐가 공유
class Session {
int fd_;
std::mutex m_;
std::deque<std::pair<Buf,size_t>> queue_; // {버퍼, 이미 보낸 바이트}
size_t queuedBytes_ = 0;
static constexpr size_t kMax = 256*1024;
public:
explicit Session(int fd): fd_(fd) {}
// 로직 스레드: 절대 블로킹하지 않는다. 상한 초과면 거부.
bool Enqueue(const Buf& b) {
std::lock_guard<std::mutex> lk(m_);
if (queuedBytes_ + b->size() > kMax) return false; // 백프레셔 → 드롭/킥
queue_.emplace_back(b, 0);
queuedBytes_ += b->size();
// 실제 전송은 이벤트 루프(epoll EPOLLOUT)에서 OnWritable() 이 수행
return true;
}
// 소켓이 쓰기 가능할 때 이벤트 루프가 호출(논블로킹)
void OnWritable() {
std::lock_guard<std::mutex> lk(m_);
while (!queue_.empty()) {
auto& [buf, off] = queue_.front();
int n = ::send(fd_, buf->data()+off, buf->size()-off, 0);
if (n <= 0) break; // EAGAIN 등: 다음 기회에
off += n; queuedBytes_ -= n;
if (off == buf->size()) queue_.pop_front(); // 완전 전송 시에만 제거
else break; // 부분 전송: 나머지는 다음에
}
}
};
void Broadcast(std::vector<Session*>& sessions, Buf frame) { // shared 소유 전달
for (Session* s : sessions) s->Enqueue(frame);
}
- 스니펫 검증:
g++ -std=c++17 -fsyntax-only통과(::send자리표시자 선언 포함 시). - 핵심: 송신 데이터는
shared_ptr로 큐가 소유해 수명 문제를 없애고, 전송은EPOLLOUT기반 논블로킹으로, 부분 전송은 오프셋으로 관리.
더 나은 설계 (트레이드오프)
- 프레임 coalescing: 뒤처진 세션엔 최신 스냅샷만 유지. 큐 폭증 근본 차단(state-based 프로토콜에 한함).
- writev/scatter-gather + 버퍼 풀: 여러 프레임을 한 번의 시스템콜로, 할당은 풀로. 고동접 성능.
- 세션 격리 정책: 큐 깊이 임계 초과 시 자동 킥/스로틀 + 메트릭. 트레이드오프: 정책 튜닝 복잡도.
면접 포인트
- "뷰(포인터)만 저장 vs 소유(shared_ptr) 이전"의 수명 차이 — 동기일 땐 우연히 살지만 비동기화하면 즉시 UAF.
- 블로킹 send 의 커널 버퍼 만차 블로킹, 논블로킹+EPOLLOUT 이벤트 기반 송신 모델.
- 백프레셔 전략과 short write(부분 전송/EAGAIN) 처리.