← 문제로

15. 차단 목록 갱신과 메시지 전달의 경합 (C#)

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

해설 — 차단 목록 갱신과 메시지 전달의 경합 (C#)

난이도: 하

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

요약

HashSet<long> Blocked스레드세이프가 아닌데 소셜 API 스레드(Add/Remove)와 메시지 워커 스레드(foreach 순회)가 락 없이 동시에 건드린다. (A)+(B) 순회 중 다른 스레드가 Add/Remove 하면 InvalidOperationException: Collection was modified 또는 내부 버킷 손상으로 전달 스레드가 죽는다. 차단 검사와 전달이 분리돼 있어 검사 통과 직후 차단이 추가돼도 메시지가 새는 TOCTOU 도 있다. 핵심 로직은 맞게 짠 편이다(수신자 to.Blocked 에서 발신자 fromId 검사 = 방향은 정확). 다만 _players[id] 가 없는 키면 KeyNotFoundException (막 로그아웃/삭제된 대상). 정답 한 줄: 차단 집합을 스레드세이프하게(락 또는 불변 스냅샷/ConcurrentDictionary-기반) 관리하고, 검사는 Contains O(1) 로 하며, 없는 플레이어를 안전하게 처리한다.


문제점

(A)+(B) 비스레드세이프 HashSet 동시 순회/변경 (동시성) ★간판

  • 분류: 컬렉션 동시 수정.
  • 증상: DeliverWhisperto.Blockedforeach 로 순회하는 동안 Block/UnblockAdd/Remove 하면 enumerator 의 다음 MoveNext() 에서 InvalidOperationException. 운이 나쁘면 리사이즈와 겹쳐 내부 상태 손상. 채팅은 트래픽이 많아(초당 다수) 사실상 상시.
  • 재현조건: 한 수신자에게 귓속말이 들어오는 순간 그 수신자가 누군가를 차단/해제.
  • 근본 원인: 공유 가변 집합에 동기화가 없다.

(B) 선형 순회로 차단 검사 (성능)

  • 증상: foreach ... if (id==fromId) 는 O(n). HashSet 인데 Contains(fromId) O(1) 를 안 쓴다. 차단 목록이 길면(수백 명) 핫패스에서 낭비.
  • 근본 원인: 집합의 해시 조회를 활용하지 않음.

(C) 검사-전달 TOCTOU (동시성·정확성)

  • 증상: blocked 를 계산한 뒤 Deliver 사이에 차단이 추가되면, 방금 차단한 상대의 메시지가 한 건 새어 들어간다(또는 그 반대). 단발성이라 치명도는 낮지만 "차단했는데 왔다"는 민원의 원인.
  • 근본 원인: 검사와 행동이 원자적이지 않다. 다만 메시지 전달은 본질적으로 "그 순간의 스냅샷"이라, 완벽한 원자성보다 일관된 한 번의 읽기로 충분하다.

(보너스) 존재하지 않는 플레이어 — KeyNotFoundException (견고성)

  • _players[toId]/_players[me] 가 막 로그아웃·삭제된 대상이면 예외. TryGetValue 로 안전 처리해야 한다.

(참고) 방향은 올바름

  • "수신자가 발신자를 차단했는가" = to.Blocked.Contains(fromId) 로 방향은 정확하다. (흔한 실수인 발신자 기준 검사가 아님 — 이 부분은 트랩이 아니라 정상.)

수정안

핵심: ① 집합 접근을 락으로 보호(또는 불변 스냅샷 교체), ② Contains O(1), ③ TryGetValue.

public class Player
{
    public long Id;
    private readonly HashSet<long> _blocked = new HashSet<long>();
    private readonly object _lock = new object();

    public void Block(long target)   { lock (_lock) _blocked.Add(target); }
    public void Unblock(long target) { lock (_lock) _blocked.Remove(target); }
    public bool IsBlocked(long who)  { lock (_lock) return _blocked.Contains(who); }

    public void Deliver(string msg) { /* 세션으로 전송 */ }
}

public void DeliverWhisper(long fromId, long toId, string msg)
{
    if (!_players.TryGetValue(toId, out var to)) return;   // 막 로그아웃/삭제
    if (to.IsBlocked(fromId)) return;                       // O(1), 락 보호
    to.Deliver(msg);
}

대안: 불변 스냅샷 교체(copy-on-write)

읽기(전달)가 압도적으로 많고 변경(차단)이 드물면, 변경 시 새 HashSet 을 만들어 통째로 교체하고 읽기는 락 없이 현재 참조를 읽는다. 읽기 경로에 락이 사라져 처리량이 좋다.

private volatile HashSet<long> _blocked = new HashSet<long>();
public void Block(long t)  { lock(_w){ var s=new HashSet<long>(_blocked){t}; _blocked=s; } }
public bool IsBlocked(long w) => _blocked.Contains(w);   // 락 없이 스냅샷 읽기

더 나은 설계

1) 읽기 최적화 자료구조

  • 차단은 "읽기 多·쓰기 少". copy-on-write 스냅샷 또는 ImmutableHashSet 으로 읽기 무락. 트레이드오프: 차단이 잦은 경우(드묾) 변경 비용 증가.

2) 차단 정합성 범위 명시

  • 메시지 전달의 차단 검사는 "전달 시점 스냅샷" 으로 충분하다는 합의(완벽한 TOCTOU 제거는 불필요). 대신 차단 즉시 진행 중인 대화창 갱신은 별도 이벤트로 클라에 통지.

3) 양방향/광역 차단

  • 귓속말뿐 아니라 파티 초대·거래·따라가기 등도 같은 차단을 참조하도록 차단 검사를 공통 서비스로 추출. 일관된 정책·캐시.

4) 대규모 차단 목록은 외부 저장 + 캐시

  • 차단이 수천 건이면 인메모리 대신 Redis/DB + 핫 캐시. 일관성은 캐시 무효화 이벤트로.

면접 포인트

  • 핵심: 비스레드세이프 컬렉션의 동시 순회/변경 이 어떻게 예외/손상으로 이어지는지와, 읽기 多 워크로드에 맞는 동기화 선택(락 vs copy-on-write).
  • 예상 질문:
    1. "왜 foreach 중에 죽나?" → 순회 중 구조 변경 시 enumerator 가 예외. Contains + 락(또는 스냅샷)으로 해결.
    2. "차단 검사 방향이 왜 중요한가?" → 발신자 기준으로 검사하면 의미가 뒤집힌다. 수신자의 목록에서 발신자를 찾아야 한다(이 코드는 방향은 맞음).
    3. "TOCTOU 를 완전히 없애야 하나?" → 메시지 전달은 그 순간의 스냅샷이면 충분. 과한 원자화보다 일관된 단일 읽기로.

변별 메모: session14(브로드캐스트 중 수신자 목록 변경)는 다수 수신자 순회 + 세션 생명주기(소켓 Dispose) 가 축이고, 본 문제는 개인 차단 집합의 동시 변경 + 검사 방향/ O(1) 조회 가 축이다. 난이도 하: 단일 집합·단발 전달로 범위가 좁다.