5. C# 송신 큐 백프레셔 + 끊김/재접속 세션 복구 (복합)
난이도 최상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — C# 송신 큐 백프레셔 + 끊김/재접속 세션 복구 (복합)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
세 가지 어려운 주제(백프레셔 / 송신 직렬화 race / 재접속 정합성)가 한 코드에서 얽혀 있다. 핵심 결함:
- 무제한 송신 큐 — 느린 클라 하나가 OOM을 유발(백프레셔 부재).
- 끊김 중에도 큐를 계속 채우고 보관 — 좀비 메모리 + 만료 정리 없음(누수).
Resume의 소켓 교체와 송신 루프가 race — 옛 소켓으로 보내거나, 송신 루프가 둘로 갈라지거나,_sending플래그가 어긋나 멈춤(use-after / 패킷 유실·중복·정지).- 재접속 시 "보내다 만" 경계 모호 —
SendAsync도중 끊기면 부분 전송된 패킷의 재전송으로 클라가 깨진 프레임/중복을 받음(정합성).
문제점
(A) 무제한 송신 큐 → OOM (분류: 성능·메모리)
- 증상:
_sendQueue.Enqueue에 상한이 없다. 느린(또는 멈춘) 클라의 큐로 브로드 캐스트가 계속 쌓여 한 세션이 수백 MB~GB를 점유. 슬로우 컨슈머가 서버 전체를 끌어내림. - 재현조건: 모바일/해외 클라 + 고빈도 브로드캐스트. 또는 클라가 ACK 없이 멈춤.
- 근본원인: 흐름 제어(backpressure) 정책 부재. 생산 속도 > 소비 속도일 때 완충할 한계와 초과 시 정책(드롭/병합/연결 종료)이 없음.
(B) _sending 플래그 기반 루프 기동의 race (분류: 동시성/정확성)
- 증상:
Enqueue와Resume둘 다if (!_sending) { _sending=true; SendLoop }.Resume는_sendQueue락을 잡지 않고_sending을 읽는다 → Enqueue 중인 스레드와 race. 동시에 통과하면 송신 루프가 2개 떠서 같은 소켓에 동시 WriteAsync(직렬화 깨짐,InvalidOperationException/데이터 인터리빙).- 반대로 둘 다 "이미 sending"으로 보고 아무도 새 루프를 안 띄우면 큐가 멈춤.
- 근본원인:
_sending상태 전이가 단일 락으로 보호되지 않고 여러 진입점에 분산.
(C)+(E) Resume 소켓 교체 vs in-flight Send race (분류: 동시성/수명관리)
- 증상:
SendLoopAsync가await _socket.SendAsync(...)에 들어가 있는 동안Resume가_socket = newSocket으로 바꾼다. await가 깨어나면:- 옛 소켓으로의 전송 완료/예외가 새 컨텍스트와 섞임.
_socket필드를 락 없이 교체해 가시성/원자성 보장 없음(참조 찢김은 없지만 어느 소켓을 보는지 비결정적).- 옛 소켓이 Dispose됐다면
ObjectDisposedException.
- 근본원인: "송신 중 소켓 교체"라는 위험한 연산을 정지(quiesce) 없이 수행.
(D)+(F) 끊김 중 큐 보관 + ResumeWindow 미정리 → 누수 (분류: 수명관리/메모리)
- 증상:
OnDisconnect는 큐를 그대로 둔다. 그런데 끊긴 세션에도 (브로드캐스트가 세션을 못 골라내면) 계속 Enqueue될 수 있고, 큐는 끝없이 자란다.SessionRegistry는 ResumeWindow 만료 정리 코드가 아예 없어 재접속 안 한 세션이 토큰 맵에 영원히 남는다(좀비 + 누수). 게다가Dictionary가 락 없이 다중 스레드 접근(자료구조 race). - 근본원인: 끊김 상태에서의 큐 정책 부재 + 보관 객체의 만료/회수(GC reaper) 부재.
(C) 재접속 시 부분 전송 패킷의 경계 모호 → 정합성 (분류: 정확성)
- 증상:
SendAsync도중 TCP가 끊기면 그 패킷이 0%/50%/100% 어디까지 갔는지 서버는 모른다. 재접속 후 같은 패킷을 처음부터 다시 보내면 클라는 앞쪽 절반 + 전체를 받아 프레임이 깨지거나 중복 적용된다. "어디까지 클라가 처리했나"를 추적하는 시퀀스/ACK가 없다. - 근본원인: 신뢰적 재개를 위한 시퀀스 번호 + ACK 기반 재전송 경계가 없음.
수정안
핵심: 바운디드 채널(백프레셔) + 단일 라이터 + 정지 후 소켓 교체 + 시퀀스/ACK + 만료 reaper
using System.Threading.Channels;
using System.Collections.Concurrent;
public sealed class GameSession
{
public string ResumeToken;
private Socket _socket;
// 바운디드 채널: 상한 도달 시 정책 적용(여기선 가장 오래된 것 드롭).
private readonly Channel<Outgoing> _send =
Channel.CreateBounded<Outgoing>(new BoundedChannelOptions(capacity: 1024)
{
FullMode = BoundedChannelFullMode.DropOldest, // 또는 Wait/거부 후 끊기
SingleReader = true, // 송신 루프 단 하나
SingleWriter = false
});
private long _seq = 0; // 송신 시퀀스(서버→클라)
private long _ackedSeq = 0; // 클라가 ACK한 마지막 시퀀스
private readonly object _socketLock = new();
private volatile bool _connected = true;
public DateTime DisconnectedAt { get; private set; }
private readonly struct Outgoing { public long Seq; public byte[] Data;
public Outgoing(long s, byte[] d){ Seq=s; Data=d; } }
public GameSession(Socket s)
{
_socket = s;
_ = RunSendLoopAsync(); // 세션 생성 시 단 한 번만 기동
}
// 게임 로직 스레드들에서 호출. 백프레셔 정책에 따라 거부/드롭 가능.
public bool Enqueue(byte[] payload)
{
long seq = Interlocked.Increment(ref _seq);
var item = new Outgoing(seq, Frame(seq, payload));
// TryWrite: 큐가 차고 DropOldest면 오래된 걸 밀어내고 성공.
// Wait 모드를 쓸 거면 WriteAsync + 생산자 백프레셔로.
return _send.Writer.TryWrite(item);
}
// 송신 루프는 "정확히 하나"만 존재(채널 SingleReader). 절대 둘로 갈라지지 않음.
private async Task RunSendLoopAsync()
{
await foreach (var item in _send.Reader.ReadAllAsync())
{
// 끊긴 동안엔 전송하지 않고 대기(재접속 시 _connected=true 되면 진행).
while (!_connected)
await Task.Delay(50);
Socket sock;
lock (_socketLock) { sock = _socket; }
try
{
await SendFullAsync(sock, item.Data);
// 전송 "완료"한 것만 acked 후보. 클라 ACK로 _ackedSeq 갱신은 수신부에서.
}
catch (Exception) // 소켓 에러 → 끊김 처리에 위임, 이 패킷은 재개 시 재전송 대상
{
MarkDisconnected();
// item 은 미확인 상태로 남는다(아래 재전송 버퍼 참고).
}
}
}
// 부분 전송 방지: 전부 보낼 때까지 루프(프레이밍 + 길이 보장).
private static async Task SendFullAsync(Socket s, byte[] data)
{
int sent = 0;
while (sent < data.Length)
sent += await s.SendAsync(data.AsMemory(sent), SocketFlags.None);
}
// seq + length-prefix 프레이밍(클라가 경계를 알 수 있게)
private static byte[] Frame(long seq, byte[] payload) { /* [len][seq][payload] */ return payload; }
public void MarkDisconnected()
{
_connected = false;
DisconnectedAt = DateTime.UtcNow;
// 소켓만 닫고, 큐/세션 상태는 ResumeWindow 동안 보존.
}
// 재접속: 송신을 잠깐 멈추고(정지) 소켓을 원자적으로 교체한 뒤 재개.
public void Resume(Socket newSocket, long clientAckedSeq)
{
lock (_socketLock)
{
try { _socket?.Dispose(); } catch { }
_socket = newSocket;
}
// 클라가 마지막으로 받은 시퀀스 이후만 다시 보내야 함(재전송 버퍼에서).
Volatile.Write(ref _ackedSeq, clientAckedSeq);
// 단일 송신 루프는 계속 살아있다 → _connected만 켜면 같은 루프가 재개.
_connected = true;
// (재전송 버퍼 구현은 "더 나은 설계" 참고)
}
}
public sealed class SessionRegistry
{
private readonly ConcurrentDictionary<string, GameSession> _byToken = new();
private readonly TimeSpan _resumeWindow = TimeSpan.FromSeconds(30);
private readonly Timer _reaper;
public SessionRegistry()
=> _reaper = new Timer(_ => Reap(), null, 5000, 5000);
public GameSession FindForResume(string token)
=> _byToken.TryGetValue(token, out var s) ? s : null;
public void Register(GameSession s) => _byToken[s.ResumeToken] = s;
private void Reap() // ResumeWindow 만료 세션 정리 → 누수/좀비 차단
{
var now = DateTime.UtcNow;
foreach (var kv in _byToken)
{
var s = kv.Value;
if (s.IsDisconnectedLongerThan(_resumeWindow, now))
if (_byToken.TryRemove(kv.Key, out var dead))
dead.Dispose();
}
}
}
(위 GameSession에 IsDisconnectedLongerThan, Dispose는 자명하게 추가.)
핵심 정리
Channel.CreateBounded+SingleReader=true: (1) 상한으로 OOM 차단, (2) FullMode로 백프레셔 정책 명시(DropOldest/DropWrite/Wait), (3) 송신 루프가 구조적으로 단 하나 → (B)의 "루프 2개/멈춤" race 원천 차단.- 소켓 교체를
_socketLock으로 원자화 + 송신 루프가 매번 락으로 현재 소켓을 읽음 → (E) 옛 소켓 사용/ObjectDisposed 완화. (더 견고하게는 "정지 후 교체": 재개 신호 전까지 루프가_connected==false에서 대기.) - **
SendFullAsync**로 부분 전송을 방지하고, seq/ACK + 재전송 버퍼로 재접속 시 "클라가 받은 다음 것부터" 정확히 재전송 → (C) 정합성. ConcurrentDictionary+ Reaper로 (D)(F) 누수/좀비/자료구조 race 해결.
더 나은 설계
1) 신뢰적 재개 프로토콜: 시퀀스 + ACK + 재전송 버퍼 (트레이드오프 핵심)
- 서버는 보낸 패킷을
seq와 함께 **재전송 버퍼(ring buffer)**에 보관. 클라는 주기적으로 "내가 받은 마지막 seq"를 ACK. ACK된 것은 버퍼에서 제거(메모리 회수). 재접속 시 클라가lastAckedSeq를 보내면 그 이후만 재전송 → 유실·중복·역전 방지. - 트레이드오프: 재전송 버퍼도 무한정 보관하면 안 됨 → "ResumeWindow 또는 버퍼 상한" 중 먼저 도달하면 세션 포기(full resync로 강등). 즉 seamless resume은 best-effort, 실패 시 전체 상태 재동기화로 폴백하는 2단계 정책이 현업 표준.
2) 백프레셔 정책을 패킷 종류별로 분리
- 패킷마다 손실 허용도가 다르다: 위치 스냅샷(최신만 의미 있음)은 DropOldest/병합 (coalesce), 채팅/거래 결과(반드시 도착)는 신뢰 큐 + 흐름 제어(Wait) 또는 도달 보장 채널. 한 큐에 섞지 말고 우선순위/신뢰도별 다중 채널로.
- 트레이드오프: 큐 분리는 복잡도·메모리↑, 그러나 "느린 클라가 중요한 패킷을 드롭당하는" 사고를 막는다. 큐 한계 초과가 지속되면 그 세션은 끊는 게 정답일 때도 많음.
3) 세션 상태머신으로 전 구간 모델링
Active → Disconnected(resumable) → Resuming → Active/→ Expired(reap). 각 전이는 CAS로 단일화. 송신 루프는 상태를 보고 동작(Active만 전송, Disconnected는 버퍼링 한도 내 보관, Expired는 폐기). 재접속은Disconnected→Resuming을 한 스레드만 성공하게 해 동시 재접속 두 개가 같은 세션을 두고 싸우는 race도 막는다.
4) per-session 작업 직렬화(액터)
- 한 세션의 Enqueue/Send/Disconnect/Resume를 단일 메일박스로 직렬화하면 위 락들이 사라지고 순서가 결정적이 된다. 트레이드오프: 세션 수만큼의 경량 액터 스케줄링 비용.
면접 포인트
- "슬로우 컨슈머로 인한 송신 큐 폭증을 어떻게 막나? 드롭 vs 블록 vs 끊기?" → 바운디드 큐가 전제. 패킷 신뢰도별 정책: 갱신성 데이터는 최신만 남기고 드롭/병합, 필수 데이터는 흐름 제어(생산자 블록) 또는 한도 초과 시 세션 종료. "모두 보존"은 불가능하니 도메인별 손실 정책을 정의하는 게 핵심.
- "끊김 후 재접속에서 패킷 유실/중복을 없애려면 무엇이 필요한가?" → 송신 시퀀스 번호 + 클라 ACK + 재전송 버퍼. 재접속 시 lastAckedSeq 이후만 재전송. 부분 전송은 length-prefix 프레이밍 + SendFull로 경계 보장. 버퍼/윈도우 초과 시 full resync 폴백.
- "소켓 교체(재접속)와 진행 중인 비동기 Send의 race를 어떻게 다루나?" → 송신 루프를 단일화(SingleReader)하고, 재접속은 루프를 정지(quiesce)시킨 뒤 소켓을 락으로 원자 교체하고 재개 신호. 옛 소켓 Dispose 타이밍과 in-flight Send를 분리해 ObjectDisposed/데이터 인터리빙을 방지. 상태머신 CAS로 동시 재접속도 단일화.
해설 · C++
해설 — C++ 송신 큐 백프레셔 + 끊김/재접속 세션 복구 (복합)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
세 가지 어려운 주제(백프레셔 / 송신 직렬화 race / 재접속 정합성)가 한 코드에서 얽혀 있다. 핵심 결함:
- 무제한 송신 큐 — 느린 클라 하나가 OOM을 유발(백프레셔 부재).
- 끊김 중에도 큐를 계속 채우고 보관 + 만료 정리 없음 — 좀비 메모리 + 누수
(
SessionRegistry에 락도 없어 data race). Resume의 소켓 교체와 송신 스레드가 race — 옛 소켓(이미 close/delete된 fd)으로 보내거나,sending_을 락 없이 검사해 송신 스레드가 둘로 갈라지거나 멈춤 (use-after-free / 패킷 유실·중복·정지).detach()된 스레드 + raw 포인터 수명 — 세션이 정리돼도 detach된 스레드가this/socket_를 계속 만져 UAF.- 재접속 시 "보내다 만" 경계 모호 + 부분 전송 미처리 —
send도중 끊기면 부분 전송된 패킷의 재전송으로 클라가 깨진 프레임/중복을 받음(정합성).
문제점
(A) 무제한 송신 큐 → OOM (분류: 성능·메모리)
- 증상:
sendQueue_.push에 상한이 없다. 느린(또는 멈춘) 클라의 큐로 브로드캐스트가 계속 쌓여 한 세션이 수백 MB~GB를 점유. 슬로우 컨슈머가 서버 전체를 끌어내림. - 재현조건: 모바일/해외 클라 + 고빈도 브로드캐스트. 또는 클라가 ACK 없이 멈춤.
- 근본원인: 흐름 제어(backpressure) 정책 부재. 생산 속도 > 소비 속도일 때 완충할 한계와 초과 시 정책(드롭/병합/연결 종료)이 없음.
(B) sending_ 플래그 + detach 스레드 기동의 race (분류: 동시성/정확성)
- 증상:
Enqueue는 락 안에서sending_을 검사하지만,Resume는 락을 잡지 않고sending_을 읽는다 → Enqueue 중인 스레드와 data race. 동시에 통과하면 송신 스레드가 2개 떠서 같은 소켓에 동시send(직렬화 깨짐, 데이터 인터리빙). 반대로 둘 다 "이미 sending"으로 보고 아무도 새 스레드를 안 띄우면 큐가 멈춤. 매 활성화마다std::thread(...).detach()로 새 스레드를 생성하는 것도 비용/관리 측면에서 나쁘다(스레드 폭증 가능). - 근본원인:
sending_상태 전이가 단일 락으로 보호되지 않고 여러 진입점에 분산.
(C)+(E) Resume 소켓 교체 vs in-flight send race + dangling fd (분류: 동시성/수명관리)
- 증상:
SendLoop가socket_->Send(...)에 들어가 블록된 동안Resume가socket_ = newSocket으로 락 없이 바꾼다.- 옛
Socket*이 이미delete/close됐다면 use-after-free / 잘못된 fd로 send. socket_포인터를 락 없이 교체해 가시성/원자성 보장 없음(어느 소켓을 보는지 비결정).
- 옛
- 근본원인: "송신 중 소켓 교체"라는 위험한 연산을 정지(quiesce)·락 없이 수행. 옛 소켓 수명도 raw 포인터라 누가 언제 해제하는지 불명확.
(B)/수명 detach 스레드가 죽은 this를 만짐 (분류: 수명관리, 치명)
- 증상:
std::thread(...).detach()한 송신 스레드는 세션 객체보다 오래 살 수 있다. 세션이 ResumeWindow 만료로delete되면, 아직 도는 송신 스레드가this->queueMutex_,socket_를 만져 UAF. join도 안 하므로 종료 순서를 보장할 수 없다. - 근본원인: 스레드 수명과 객체 수명의 결합이 없음. detach는 정리 책임을 포기한 것.
(D)+(F) 끊김 중 큐 보관 + ResumeWindow 미정리 + 무락 맵 → 누수/race (분류: 수명관리/메모리/동시성)
- 증상:
OnDisconnect는 큐를 그대로 둔다. 끊긴 세션에도 (브로드캐스트가 못 골라내면) 계속 Enqueue될 수 있어 큐가 끝없이 자란다.SessionRegistry는 ResumeWindow 만료 정리 코드가 아예 없어 재접속 안 한 세션이 토큰 맵에 영원히 남는다(좀비 + 누수). 게다가unordered_map이 락 없이 다중 스레드 접근 → data race. - 근본원인: 끊김 상태에서의 큐 정책 부재 + 보관 객체의 만료/회수(reaper) 부재 + 공유 맵 무동기화.
(C) 재접속 시 부분 전송 패킷의 경계 모호 → 정합성 (분류: 정확성)
- 증상:
send가 요청 길이보다 적게 보내는 부분 전송(short write) 을 처리하지 않는다. TCP가 도중에 끊기면 그 패킷이 0%/50%/100% 어디까지 갔는지 모른다. 재접속 후 같은 패킷을 처음부터 다시 보내면 클라는 앞쪽 절반 + 전체를 받아 프레임이 깨지거나 중복 적용된다. "어디까지 클라가 처리했나"를 추적하는 시퀀스/ACK가 없다. - 근본원인: 신뢰적 재개를 위한 시퀀스 번호 + ACK 기반 재전송 경계 + 부분 전송 루프가 없음.
수정안
핵심: 바운디드 큐(백프레셔) + 단일 영속 송신 스레드 + 정지 후 소켓 교체 + 시퀀스/ACK + 만료 reaper + shared_ptr 수명
#include <queue>
#include <vector>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <atomic>
#include <memory>
#include <unordered_map>
#include <string>
#include <chrono>
struct Socket { int fd; int Send(const char* p, int n); void Close(); };
struct Outgoing { uint64_t seq; std::vector<char> data; };
class GameSession {
public:
std::string resumeToken;
private:
std::shared_ptr<Socket> socket_;
std::mutex mtx_;
std::condition_variable cv_;
std::queue<Outgoing> sendQueue_; // 상한 적용
static constexpr size_t kMaxQueue = 1024; // 백프레셔 한계
std::atomic<uint64_t> seq_{0};
std::atomic<bool> connected_{true};
std::atomic<bool> stop_{false};
std::thread sendThread_; // 단 하나, 세션 수명 동안 유지
std::chrono::steady_clock::time_point disconnectedAt_;
public:
explicit GameSession(std::shared_ptr<Socket> s) : socket_(std::move(s)) {
sendThread_ = std::thread([this] { SendLoop(); }); // 단 한 번 기동
}
~GameSession() {
stop_.store(true);
cv_.notify_all();
if (sendThread_.joinable()) sendThread_.join(); // join 으로 수명 보장
}
// 백프레셔: 큐가 차면 가장 오래된 것 드롭(또는 거부/연결 종료 정책).
bool Enqueue(std::vector<char> payload) {
uint64_t s = seq_.fetch_add(1) + 1;
std::lock_guard<std::mutex> lk(mtx_);
if (sendQueue_.size() >= kMaxQueue) sendQueue_.pop(); // DropOldest
sendQueue_.push(Outgoing{ s, std::move(payload) });
cv_.notify_one();
return true;
}
// 송신 루프는 "정확히 하나"만 존재. cv 로 대기 → 절대 둘로 갈라지지 않음.
void SendLoop() {
while (!stop_.load()) {
Outgoing item;
std::shared_ptr<Socket> sock;
{
std::unique_lock<std::mutex> lk(mtx_);
cv_.wait(lk, [this] {
return stop_.load() || (connected_.load() && !sendQueue_.empty());
});
if (stop_.load()) return;
item = std::move(sendQueue_.front());
sendQueue_.pop();
sock = socket_; // 락 안에서 현재 소켓의 shared_ptr 복사(수명 고정)
}
// 락 밖에서, 부분 전송 방지 루프로 전부 보낸다.
if (!SendFull(sock.get(), item.data)) {
MarkDisconnected(); // 실패 → 끊김. item 은 재전송 버퍼에 남김(설계 참고)
}
}
}
static bool SendFull(Socket* s, const std::vector<char>& d) {
int sent = 0;
while (sent < (int)d.size()) {
int n = s->Send(d.data() + sent, (int)d.size() - sent);
if (n <= 0) return false;
sent += n;
}
return true;
}
void MarkDisconnected() {
connected_.store(false);
std::lock_guard<std::mutex> lk(mtx_);
disconnectedAt_ = std::chrono::steady_clock::now();
}
// 재접속: 소켓을 락으로 원자 교체한 뒤 재개. 송신 스레드는 계속 살아있다.
void Resume(std::shared_ptr<Socket> newSocket, uint64_t /*clientAckedSeq*/) {
{
std::lock_guard<std::mutex> lk(mtx_);
socket_ = std::move(newSocket); // 원자 교체(옛 소켓은 shared_ptr 해제 시점에 정리)
}
connected_.store(true);
cv_.notify_one(); // 같은 루프 재개
}
};
class SessionRegistry {
std::unordered_map<std::string, std::shared_ptr<GameSession>> byToken_;
std::mutex mtx_;
std::chrono::seconds resumeWindow_{30};
public:
std::shared_ptr<GameSession> FindForResume(const std::string& token) {
std::lock_guard<std::mutex> lk(mtx_);
auto it = byToken_.find(token);
return it == byToken_.end() ? nullptr : it->second;
}
void Register(const std::shared_ptr<GameSession>& s) {
std::lock_guard<std::mutex> lk(mtx_);
byToken_[s->resumeToken] = s;
}
void Reap() { // 타이머 스레드가 주기 호출 → 만료 세션 정리(누수/좀비 차단)
std::lock_guard<std::mutex> lk(mtx_);
// s->IsDisconnectedLongerThan(resumeWindow_) 인 항목 erase → shared_ptr 해제 → 소멸
}
};
핵심 정리
- 바운디드 큐 + 단일 영속 송신 스레드(cv 기반): (1) 상한으로 OOM 차단, (2) DropOldest
등 백프레셔 정책 명시, (3) 송신 루프가 구조적으로 단 하나 → (B)의 "스레드 2개/멈춤"
race 원천 차단.
detach대신join으로 수명 보장. - 소켓을
shared_ptr로 + 락 안에서 복사: in-flight send가 자기 사본을 들고 있어 Resume이 교체해도 옛 소켓이 살아있다 → (E) UAF/dangling fd 차단. - **
SendFull**로 부분 전송을 방지하고, seq/ACK + 재전송 버퍼로 재접속 시 "클라가 받은 다음 것부터" 정확히 재전송 → (C) 정합성. unordered_map+ mutex + Reaper로 (D)(F) 누수/좀비/data race 해결.- 세션을
shared_ptr<GameSession>으로 관리해 detach 스레드의 죽은this접근(UAF) 제거.
더 나은 설계
1) 신뢰적 재개 프로토콜: 시퀀스 + ACK + 재전송 버퍼 (트레이드오프 핵심)
- 서버는 보낸 패킷을
seq와 함께 **재전송 버퍼(ring buffer)**에 보관. 클라는 주기적으로 "내가 받은 마지막 seq"를 ACK. ACK된 것은 버퍼에서 제거(메모리 회수). 재접속 시 클라가lastAckedSeq를 보내면 그 이후만 재전송 → 유실·중복·역전 방지. - 트레이드오프: 재전송 버퍼도 무한정 보관 금지 → "ResumeWindow 또는 버퍼 상한" 중 먼저 도달하면 세션 포기(full resync로 강등). seamless resume은 best-effort, 실패 시 전체 상태 재동기화로 폴백하는 2단계 정책이 현업 표준.
2) 백프레셔 정책을 패킷 종류별로 분리
- 위치 스냅샷(최신만 의미)은 DropOldest/병합(coalesce), 채팅/거래 결과(반드시 도착)는 신뢰 큐 + 흐름 제어로. 한 큐에 섞지 말고 우선순위/신뢰도별 다중 채널로.
- 트레이드오프: 큐 분리는 복잡도·메모리↑, 그러나 "느린 클라가 중요한 패킷을 드롭당하는" 사고를 막는다. 한계 초과가 지속되면 그 세션은 끊는 게 정답일 때도 많음.
3) 세션 상태머신으로 전 구간 모델링
Active → Disconnected(resumable) → Resuming → Active/→ Expired(reap). 각 전이는compare_exchange로 단일화. 재접속은Disconnected→Resuming을 한 스레드만 성공하게 해 동시 재접속 race도 막는다.
4) per-session 작업 직렬화(액터/스트랜드)
- 한 세션의 Enqueue/Send/Disconnect/Resume를 단일 잡 큐로 직렬화하면 위 락들이 줄고 순서가 결정적. 트레이드오프: 세션 수만큼의 경량 액터 스케줄링 비용.
면접 포인트
- "슬로우 컨슈머로 인한 송신 큐 폭증을 어떻게 막나? 드롭 vs 블록 vs 끊기?" → 바운디드 큐가 전제. 패킷 신뢰도별 정책: 갱신성 데이터는 드롭/병합, 필수 데이터는 흐름 제어 또는 한도 초과 시 세션 종료. "모두 보존"은 불가능하니 도메인별 손실 정책을 정의하는 게 핵심.
- "송신 스레드를
bool플래그로 띄우고detach하면 어떤 문제가 있나?" → 락 없는 플래그 검사로 스레드가 둘로 갈라지거나 멈춤. detach는 객체보다 스레드가 오래 살아 UAF. 단일 영속 스레드 + condition_variable +join으로 수명을 묶는다. - "소켓 교체(재접속)와 진행 중인 send의 race를 어떻게 다루나?"
→ 송신 루프를 단일화하고, 재접속은 소켓을 락으로 원자 교체. 소켓을
shared_ptr로 잡아 in-flight send가 옛 소켓 수명을 보장하게 한다. 시퀀스/ACK + SendFull로 부분 전송·재전송 정합성을 보장하고, 윈도우 초과 시 full resync 폴백.