14. 길드/전체 채팅 브로드캐스트 중 수신자 목록 변경
난이도 하해설 — 길드/전체 채팅 브로드캐스트 중 수신자 목록 변경
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
핵심 결함은 공유 가변 컬렉션(List<Session>)을 락 없이 순회하면서 송신한다는 것이다.
브로드캐스트가 도는 동안 다른 스레드가 Members 를 Add/Remove 하면 foreach 가
InvalidOperationException: Collection was modified 로 터지고, 그 길드 채팅 전체가 중단된다.
또한 s.Connected 를 확인한 직후(check)와 s.Send(act) 사이에 세션이 끊겨 Socket 이
Dispose 되면 ObjectDisposedException 으로 막 끊긴 한 명 때문에 나머지 전원에게 전파가
멈춘다(TOCTOU). 동기 Socket.Send 를 순회 안에서 호출하므로 한 수신자가 느리면(송신 버퍼
가득) 브로드캐스트 전체가 블로킹되어 채팅 처리량이 무너진다. 정답 한 줄: 순회용 스냅샷을
짧은 락 안에서 떠서 락 밖에서 송신하고, 송신은 세션별 비동기 큐로 위임하며, 개별 실패를
격리한다.
문제점
(A) 락 없는 컬렉션 동시 순회/수정 — 예외로 브로드캐스트 중단 (동시성) ★간판
- 증상:
foreach (Session s in g.Members)도중 다른 스레드가Members.Add/Remove를 호출하면 enumerator 가 무효화되어InvalidOperationException이 던져진다. 채팅이 잦으므로(초당 수백 회) 충돌 확률이 높고, 예외가 잡히지 않으면 처리 스레드/길드 채팅이 죽는다. - 재현 조건: T1 이
BroadcastGuildChat순회 중, T2 가 같은 길드에 가입/탈퇴 또는 접속 종료 정리로Members를 변경. - 근본 원인:
List<T>는 스레드 세이프하지 않고, 순회 중 구조 변경을 허용하지 않는다. 공유 목록을 임계 구역/스냅샷 없이 직접 순회했다.
(B) Connected 검사 후 Send 사이 TOCTOU — 끊긴 세션 송신 (동시성/생명주기)
- 증상:
if (s.Connected)통과 직후 T2 가 해당 세션을 끊고Socket.Dispose()하면,s.Send에서ObjectDisposedException/SocketException. 순회 안에서 예외가 나면 그 뒤 수신자들은 메시지를 못 받는다. - 근본 원인: 끊김 상태 검사와 실제 송신이 원자적이지 않다. 그리고 송신 자체를 try/catch 로 격리하지 않아 한 명의 실패가 전체로 번진다.
동기 Send 를 순회 내 호출 — 느린 수신자가 전체 블로킹 (성능/백프레셔)
- 증상:
Socket.Send는 송신 버퍼가 차면 블로킹된다(블로킹 소켓) 또는 부분 송신만 한다(논블로킹인데 반환값 무시 시 데이터 잘림). 한 명의 느린/혼잡 수신자가 길드 전체 브로드캐스트를 지연시켜 채팅 지연·처리량 붕괴. - 근본 원인: 브로드캐스트(팬아웃)와 실제 I/O 를 분리하지 않았다. 송신은 세션별 비동기 송신 큐로 위임해야 한다.
입력/존재 검증 부재 — 견고성
- 증상:
_guilds[guildId]가 없으면KeyNotFoundException. 변조된/이미 해산된 guildId 패킷에 서버가 예외. 발신자가 실제 그 길드 소속인지(권한) 검증도 없어 스푸핑 가능. - 근본 원인:
TryGetValue+ 발신자 멤버십/뮤트(채금) 검증 누락.
수정안
핵심: ① 짧은 락 안에서 수신자 스냅샷을 떠서 순회 중 수정 문제를 제거, ② 송신을 락 밖에서, ③ 세션별 비동기 송신 큐로 위임, ④ 개별 송신 실패를 try/catch 로 격리.
public void BroadcastGuildChat(int guildId, long fromPlayerId, string text)
{
if (!_guilds.TryGetValue(guildId, out var g)) return;
byte[] packet = BuildChatPacket(fromPlayerId, text);
// 1) 짧은 락으로 스냅샷만 확보 (송신은 락 밖에서)
Session[] targets;
lock (g.SyncRoot)
{
// 발신자 멤버십/채금 검증은 호출 전에 끝났다고 가정
targets = g.Members.ToArray();
}
// 2) 락 밖에서 송신, 개별 실패 격리
foreach (var s in targets)
{
try
{
// 3) 동기 Send 대신 세션별 비동기 송신 큐에 enqueue
// (Connected 최종 판정과 폐기 안전성은 Enqueue 내부에서 처리)
s.EnqueueSend(packet);
}
catch (Exception ex)
{
// 끊긴 세션 등 — 로그만, 다음 수신자로 계속
// (필요 시 끊김 처리 트리거)
}
}
}
Session.EnqueueSend 의 안전성(끊김 후 송신 무시):
public bool EnqueueSend(byte[] packet)
{
// 끊김 플래그를 원자적으로 확인하고 큐에 적재.
// 이미 닫혔으면 false 반환(소켓 직접 접근 안 함 → ObjectDisposed 회피).
if (Volatile.Read(ref _closed)) return false;
_sendQueue.Enqueue(packet); // 락프리/락 보호 큐
TryKickSendLoop(); // 송신 워커가 비동기로 flush
return true;
}
핵심은 순회 대상은 불변 스냅샷(
ToArray)이고, 실제 소켓 I/O 는 송신 루프가 단독으로 소유한다는 것. 그래야 순회-수정 충돌도, Dispose 경합도, 느린 수신자 블로킹도 사라진다.
대안: Members 자체를 동시성 컬렉션으로. 송신 대상이 자주 바뀌면 copy-on-write
(ImmutableList) 또는 ConcurrentDictionary<long, Session> 의 Values 스냅샷을
순회한다. List + lock 보다 읽기 경합이 적다.
더 나은 설계
1) 팬아웃과 I/O 분리 (송신 파이프라인)
- 브로드캐스트는 "패킷 1개를 N 큐에 넣기"까지만. 실제 송신은 세션별 단일 송신 루프가 담당해 순서 보장 + 백프레셔(큐 상한 초과 시 드롭/끊기)를 일관 처리.
- 트레이드오프: 큐/워커 비용 vs 브로드캐스트 지연·블로킹 제거. 채팅처럼 팬아웃이 큰 경로에선 거의 필수.
2) 직렬화 1회 + 버퍼 공유
- 같은 패킷을 N 명에게 보내므로
BuildChatPacket은 한 번만. 직렬화된 바이트(불변)를 공유 참조로 큐에 넣어 복사/연산을 줄인다(풀링된 read-only 버퍼).
3) 채금/스팸/권한은 브로드캐스트 이전 단계에서
- 발신자가 실제 그 길드 소속인지, 채팅 금지/도배 레이트리밋에 걸리는지는 팬아웃 전에 컷. N 명에게 보낸 뒤 되돌릴 수 없다.
4) 큰 길드/전체 채팅의 백프레셔
- 수만 명 채널이면 동기 팬아웃조차 부담. 샤딩된 브로드캐스트(채널→구독 그룹) 또는 pub/sub 로 분산. 개별 세션 큐가 임계치를 넘으면 그 세션만 드롭/끊고 전체는 계속.
면접 포인트
- 면접관이 듣고 싶은 핵심: 공유 목록을 순회하며 동시 수정이 왜 위험한지(enumerator 무효화) + 팬아웃과 I/O 를 분리해 한 수신자가 전체를 막지 않게 하는 설계.
- 예상 질문:
- "왜
foreach가 터지나?Remove만 막으면 되나?" →List의 enumerator 는 버전 토큰으로 구조 변경을 감지해 던진다. Add/Remove 둘 다 문제. 근본 해법은 스냅샷 또는 동시성 컬렉션. - "스냅샷을 떴는데 그 사이 끊긴 세션이 들어있으면?" → 송신을 큐로 위임하고 큐가 끊김 플래그를 원자적으로 확인하면 소켓 직접 접근(Dispose 경합)을 피한다.
- "락을 잡고 그 안에서 다 보내면 안 되나?" → 송신이 블로킹되면 그 길드의 가입/탈퇴까지 전부 멈춘다. 락은 스냅샷만, I/O 는 락 밖.
- "왜
변별 메모: 같은 session_network 의 problem12(귓속말: 대상이 막 로그아웃/채널이동)는 단일 대상 1:1 의 "보내려는 찰나 상대가 사라짐" 이고, 본 문제는 1:N 브로드캐스트의 "순회 중 목록·세션 상태 동시 변경 + 팬아웃/I/O 분리" 가 초점이다. problem5(백프레셔)는 한 세션의 송신 큐 폭증·재접속 복구가 축이고, 본 문제는 그 송신 큐를 왜/어떻게 브로드캐스트가 활용해야 하는지(팬아웃 설계)에 무게가 있다.
해설 — 길드/전체 채팅 브로드캐스트 중 수신자 목록 변경 (C++)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
핵심 결함은 공유 std::vector<Session*> 를 락 없이 순회하면서, 다른 스레드가 동시에
erase(반복자/포인터 무효화)하고 Session 을 delete(use-after-free) 한다는 것이다.
C#과 달리 C++ 의 범위 기반 for 는 예외를 던지지 않고 조용히 UB 가 된다 — 재할당된
버퍼를 가리키는 무효 반복자 순회, 또는 이미 해제된 Session* 역참조(s->connected,
s->Send)로 크래시/메모리 손상/정보 유출이 발생한다. 동기 블로킹 Send 를 순회 안에서
호출해 느린 수신자가 전체를 막는 것도 동일하다. 정답 한 줄: 수명을 shared_ptr 로
관리하고, 짧은 락 안에서 shared_ptr 스냅샷을 떠서 락 밖에서 비동기 송신 큐로 위임한다.
문제점
(A) 락 없는 vector 동시 순회/수정 — 반복자·포인터 무효화 UB (동시성/메모리) ★간판
- 증상: 순회 중 다른 스레드가
members.push_back(재할당 시 기존 포인터 전부 무효) 또는erase(이후 요소 이동) 하면, 범위 for 가 사용하는 begin/end 반복자가 무효가 된다. C++ 은 검사하지 않으므로 예외 없이 잘못된 메모리를 읽어 크래시/쓰레기 송신. - 재현 조건: T1 브로드캐스트 순회 중 T2 가 같은 길드
members변경. 특히 capacity 초과push_back의 재할당이 치명적. - 근본 원인:
std::vector는 스레드 세이프하지 않고 동시 수정 중 순회는 UB. 공유 컨테이너를 스냅샷/락 없이 순회했다.
(B) 해제된 Session 역참조 — Use-After-Free (메모리) ★간판
- 증상:
if (s->connected.load())또는s->Send시점에 다른 스레드가 그Session을 이미delete했다면 UAF. 운 좋으면 크래시, 나쁘면 조용히 손상된 메모리로 송신. - 근본 원인: 원시 포인터(
Session*)로 수명을 공유했다. 순회 측이 객체 생존을 보장하지 못한다.shared_ptr로 소유권을 공유해 순회 동안 살아있게 해야 한다.
동기 블로킹 Send 를 순회 내 호출 — 느린 수신자가 전체 블로킹 (성능/백프레셔)
- 증상:
::send가 송신 버퍼 가득으로 블로킹되면 길드 전체 브로드캐스트가 멈춘다. 부분 송신 반환값을 무시하면 데이터 잘림. - 근본 원인: 팬아웃과 실제 I/O 미분리. 세션별 비동기 송신 큐로 위임해야 한다.
입력/권한 검증 부재 — 견고성
- 증상: 발신자가 실제 그 길드 소속인지, 채금/도배 제한 검증이 없어 스푸핑/스팸 가능.
- 근본 원인: 팬아웃 전 멤버십/레이트리밋 컷 누락.
수정안
핵심: ① shared_ptr<Session> 로 수명 공유, ② 짧은 락으로 스냅샷 확보, ③ 락 밖에서
세션별 비동기 송신 큐에 enqueue.
class Guild {
public:
std::mutex mtx;
std::vector<std::shared_ptr<Session>> members;
};
void ChatService::BroadcastGuildChat(Guild& g, int64_t from, const std::string& text) {
auto pkt = BuildSharedChatPacket(from, text); // 불변 공유 버퍼 1회 직렬화
// 1) 짧은 락으로 shared_ptr 스냅샷 — 순회 동안 객체 생존 보장
std::vector<std::shared_ptr<Session>> targets;
{
std::lock_guard<std::mutex> lk(g.mtx);
targets = g.members; // shared_ptr 복사 → refcount++
}
// 2) 락 밖에서 송신, 개별 실패 격리
for (auto& s : targets) {
if (!s->connected.load(std::memory_order_acquire)) continue;
s->EnqueueSend(pkt); // 비동기 송신 큐로 위임 (직접 ::send 안 함)
}
}
Session::EnqueueSend 의 안전성:
bool Session::EnqueueSend(std::shared_ptr<const Buffer> pkt) {
if (closed_.load(std::memory_order_acquire)) return false;
{
std::lock_guard<std::mutex> lk(sendMtx_);
sendQueue_.push_back(std::move(pkt));
}
KickSendLoop(); // 송신 워커가 비동기 flush, 부분 송신 누적 처리
return true;
}
shared_ptr스냅샷이 핵심: 순회하는 동안 대상Session들은 refcount 로 살아있고, vector 재할당/erase 는 원본g.members에서만 일어나 스냅샷에 영향 없다.
대안(읽기 많은 경우): copy-on-write 로 members 를 shared_ptr<const vector<...>> 로
두고, 변경 시 새 벡터로 교체(atomic_store). 브로드캐스트는 현재 스냅샷을 원자적으로
load 해 락조차 없이 순회.
더 나은 설계
1) 수명 정책을 코드 전반에서 일관되게
- 세션을 원시 포인터로 들고 다니면 UAF 가 잠재. 소유는
shared_ptr, 비소유 관찰은weak_ptr로. 브로드캐스트는weak_ptr목록에서lock()해 살아있는 것만 송신해도 됨.
2) 팬아웃과 I/O 분리 + 직렬화 1회
- 패킷은 한 번 직렬화해 불변 공유 버퍼로 N 큐에 push. 송신은 세션별 단일 워커가 순서· 백프레셔를 책임진다. 트레이드오프: 큐/refcount 비용 vs 블로킹·UAF 제거.
3) 백프레셔/대형 채널
- 세션 송신 큐가 상한 초과면 그 세션만 드롭/끊고 전체는 계속. 수만 명 채널은 샤딩/ pub-sub 로 분산.
면접 포인트
- 면접관이 듣고 싶은 핵심: C++ 에선 동시 수정 순회와 UAF 가 예외 없이 UB 라는 점,
그리고
shared_ptr스냅샷으로 수명·순회를 동시에 푸는 패턴. - 예상 질문:
- "C# 은 예외라도 나는데 C++ 은?" → vector 동시 수정 순회·해제 객체 역참조는 UB. 검출이 안 되니 더 위험. 스냅샷 + shared_ptr 가 정석.
- "
members를 mutex 로 감싸 통째로 락 잡고 보내면?" → 송신 블로킹이 가입/탈퇴까지 멈춤. 락은 스냅샷 복사까지만. - "shared_ptr 복사 비용이 부담되면?" → COW +
atomic_load스냅샷 또는 RCU 류로 읽기 경합 제거.
변별 메모: session12(귓속말)는 단일 대상 1:1 의 "보내려는 찰나 상대 소멸", 본 문제는 1:N 브로드캐스트의 "순회 중 컨테이너·객체 수명 동시 변경". C++ 트윈은 C# 의
InvalidOperationException(검출됨)과 달리 조용한 UB/UAF 라는 언어차가 학습 포인트다.