← 문제로

37. 인접 테이블의 락 없는 순회와 손상된 열거자 (읽기는 안전하다는 착각)

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

해설 — 인접 테이블의 락 없는 순회와 손상된 열거자 (읽기는 안전하다는 착각)

난이도: 중

요약

Enter/Leave(A)는 _neighbors(Dictionary)를 락으로 보호하지만, BroadcastSnapshot(B)은 "값을 바꾸지 않으니 안전하다"는 가정으로 락 없이 foreach로 순회한다. Dictionary<K,V>의 열거는 내부 버킷 배열을 직접 훑는 구현이라, 열거 도중 다른 스레드가 삽입(특히 내부 재해시가 트리거되는 삽입)하면 순회 중인 배열의 모양이 통째로 바뀐다. 최선의 경우 InvalidOperationException: Collection was modified로 죽고, 최악의 경우 예외 없이 항목을 건너뛰거나 중복 방문한다.

문제점

1. (동기화 없는 공유 컬렉션 순회) 락 없는 foreach — (B)

  • 증상: 브로드캐스트 도중 간헐적으로 InvalidOperationException: Collection was modified; enumeration operation may not execute가 발생하며 해당 틱의 브로드캐스트가 통째로 실패한다. 예외가 상위에서 잡히지 않으면 서버 스레드가 죽는다.
  • 재현조건: 동접이 많아 Enter/Leave 빈도가 높고, 마침 BroadcastSnapshot이 순회 중인 순간에 다른 스레드가 삽입/제거를 실행. 특히 Dictionary가 용량을 넘겨 **내부 배열을 재할당(rehash급 재구성)**하는 삽입일 때 확률이 크게 뛴다 — 그래서 "동접이 늘면서" 재현 빈도가 늘어난 것처럼 보인다.
  • 근본원인: Dictionary<K,V>는 스레드 안전을 보장하지 않으며, 열거자는 내부적으로 버전 카운터(_version)를 캡처해 MoveNext마다 비교한다. 다른 스레드의 구조적 변경(삽입/삭제)이 버전을 바꾸면 열거자가 이를 감지해 예외를 던진다 — "읽기 전용 순회니 락이 필요 없다"는 가정이 틀렸다: 순회는 읽기 연산이지만, 컬렉션이 변경되지 않는다는 보장이 있을 때만 락이 필요 없다. 버전 검사가 항상 예외를 던져준다면 그나마 다행이고, 타이밍에 따라 검사를 통과한 채 내부적으로 이미 이동된 버킷을 잘못 읽어(같은 항목 중복 방문/일부 항목 누락) 조용히 잘못된 스냅샷을 보낼 수도 있다 — 예외 없이도 데이터가 틀릴 수 있다는 뜻이라 더 위험하다.

수정안

읽기(순회)도 쓰기와 같은 락으로 보호한다. 락 보유 시간을 최소화하려면 락 안에서는 스냅샷 배열만 뜨고, 실제 전송(네트워크 I/O)은 락 밖에서 수행한다.

public void BroadcastSnapshot()
{
    PlayerHandle[] snapshot;
    lock (_lock)
    {
        snapshot = new PlayerHandle[_neighbors.Count];
        _neighbors.Values.CopyTo(snapshot, 0);   // 락 보유 중에만 복사
    }

    foreach (var p in snapshot)                  // 전송은 락 밖에서
    {
        SendSnapshotPacket(p);
    }
}
  • _neighbors.Values.CopyTo(...).ToArray()는 락 안에서 실행해야 의미가 있다 — 락 밖에서 ToArray()를 호출하면 그 자체가 다시 보호되지 않은 순회다.
  • 락 보유 시간이 걱정되면 ConcurrentDictionary<long, PlayerHandle>로 바꿔 Enter/Leave/순회를 모두 락 없이 처리하는 방법도 있다. 단, ConcurrentDictionary의 열거는 특정 시점의 원자적 스냅샷을 보장하지 않는다(순회 중 일어난 변경이 일부 반영될 수 있음)는 점은 감안해야 한다 — 브로드캐스트처럼 "약간의 stale은 허용, 예외/충돌만 없으면 된다"는 요구에는 적합하다.

더 나은 설계

  • 인접 테이블처럼 "쓰기는 빈번, 읽기(순회)는 주기적"인 구조는 ReaderWriterLockSlim으로 세분화하면 동시 읽기끼리는 블로킹 없이 처리 가능하다. 단, 읽기 락 보유 중 절대 컬렉션을 변경하지 않도록(승급 금지) 주의.
  • 브로드캐스트 주기가 짧고 인접 수가 많다면, 매 틱 스냅샷 배열을 새로 할당하는 대신 이중 버퍼(더블 버퍼링)로 GC 압박을 줄이는 것도 고려할 수 있다.
  • "락 없이 읽기만 하면 안전하다"는 직관은 불변(immutable) 데이터 구조나 원자적 교체(예: Interlocked.Exchange로 스냅샷 참조 자체를 통째로 교체)에만 성립한다는 점을 팀 규칙으로 명문화해두면 재발을 막을 수 있다.

면접 포인트

  1. "읽기 전용 순회니까 락이 필요 없다"는 가정은 컬렉션이 그동안 변경되지 않는다는 보장이 있을 때만 성립한다 — Dictionary의 열거자는 구조적 변경(삽입/삭제)에 취약하며 버전 검사로 예외를 던지거나(운 좋으면), 조용히 잘못된 데이터를 넘길 수 있다(운 나쁘면).
  2. 예외 발생 여부가 재현 빈도에 좌우된다는 사실 자체가 데이터 레이스의 특징이다 — "가끔 터진다"는 보고는 락 누락을 의심할 신호다.
  3. 대안(락 전체 보호 vs ConcurrentDictionary vs ReaderWriterLockSlim)마다 트레이드오프가 다르다: 정확한 시점 스냅샷이 필요한지, 락 경합 비용을 얼마나 감당할 수 있는지로 선택 기준이 갈린다.