← 문제로

14. 길드/전체 채팅 브로드캐스트 중 수신자 목록 변경

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

해설 — 길드/전체 채팅 브로드캐스트 중 수신자 목록 변경

난이도: 하

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

요약

핵심 결함은 공유 가변 컬렉션(List<Session>)을 락 없이 순회하면서 송신한다는 것이다. 브로드캐스트가 도는 동안 다른 스레드가 MembersAdd/Remove 하면 foreachInvalidOperationException: Collection was modified 로 터지고, 그 길드 채팅 전체가 중단된다. 또한 s.Connected 를 확인한 직후(check)와 s.Send(act) 사이에 세션이 끊겨 SocketDispose 되면 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 를 분리해 한 수신자가 전체를 막지 않게 하는 설계.
  • 예상 질문:
    1. "왜 foreach 가 터지나? Remove 만 막으면 되나?" → List 의 enumerator 는 버전 토큰으로 구조 변경을 감지해 던진다. Add/Remove 둘 다 문제. 근본 해법은 스냅샷 또는 동시성 컬렉션.
    2. "스냅샷을 떴는데 그 사이 끊긴 세션이 들어있으면?" → 송신을 큐로 위임하고 큐가 끊김 플래그를 원자적으로 확인하면 소켓 직접 접근(Dispose 경합)을 피한다.
    3. "락을 잡고 그 안에서 다 보내면 안 되나?" → 송신이 블로킹되면 그 길드의 가입/탈퇴까지 전부 멈춘다. 락은 스냅샷만, I/O 는 락 밖.

변별 메모: 같은 session_network 의 problem12(귓속말: 대상이 막 로그아웃/채널이동)는 단일 대상 1:1 의 "보내려는 찰나 상대가 사라짐" 이고, 본 문제는 1:N 브로드캐스트의 "순회 중 목록·세션 상태 동시 변경 + 팬아웃/I/O 분리" 가 초점이다. problem5(백프레셔)는 한 세션의 송신 큐 폭증·재접속 복구가 축이고, 본 문제는 그 송신 큐를 왜/어떻게 브로드캐스트가 활용해야 하는지(팬아웃 설계)에 무게가 있다.