← 문제로

35. 이벤트 버스 디스패치 중 구독 해제 경합

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — 이벤트 버스 디스패치 중 구독 해제 경합

난이도: 최상

요약

EventBus.Publish_listeners[eventName] 이 가리키는 List<IEventListener>foreach 로 순회하며 각 리스너를 호출한다(순회 자체는 A 지점에서 구독이 추가된 바로 그 리스트를 대상으로 한다). 그런데 리스너 콜백 안에서 스스로(또는 다른 리스너를) 구독 해제하거나, 다른 스레드가 동시에 Unsubscribe(B)를 호출하면 그 List<T>.Remove 가 순회 중인 것과 동일한 리스트 인스턴스를 변경한다. List<T> 의 열거자는 버전 카운터를 검사하므로 순회 도중 컬렉션이 변경되면 InvalidOperationException("Collection was modified") 이 발생해 Publish 전체가 중단되고, 그 시점 이후의 리스너들은 통지를 받지 못한다. 스레드 동기화도 전혀 없어 Subscribe/Unsubscribe/Publish 가 여러 스레드에서 동시에 호출되면 List<T> 내부 상태 자체가 손상될 수도 있다.

문제점

  • 분류: 반복자 무효화(iterator invalidation) / 공유 컬렉션에 대한 동기화 부재
  • 증상: (1) 리스너 콜백 안에서 자기 자신을 구독 해제하는 일반적인 "일회성 리스너" 패턴을 쓰면 Publish 도중 InvalidOperationException 이 던져져, 그 이벤트를 기다리던 나머지 리스너들이 통지를 받지 못한 채 예외가 호출자까지 전파된다. (2) 다른 스레드가 Publish 진행 중에 Unsubscribe 를 호출하면 같은 예외가 타이밍에 따라 나타나거나, 운이 나쁘면 List<T> 내부 배열 상태가 꼬여 잘못된 리스너가 호출되거나 건너뛰어질 수 있다.
  • 재현 조건: (1) 단일 스레드 재현 — 리스너 A 의 OnEvent 안에서 bus.Unsubscribe(eventName, A) 를 호출하도록 등록한 뒤 Publish 호출. (2) 멀티스레드 재현 — 스레드 T1 이 Publish 를 실행 중인 동안 스레드 T2 가 같은 eventName 에 대해 Unsubscribe 를 호출.
  • 근본 원인:
    • (A) Subscribe 가 리스트에 리스너를 추가할 때, 이후 Publish 가 그 리스트를 직접 참조해 순회한다는 사실 자체가 문제의 전제를 만든다 — 순회 대상과 변경 대상이 같은 가변 컬렉션 인스턴스다.
    • (B) UnsubscribeList<T>.Remove 로 그 컬렉션을 즉시, 그리고 어떤 동기화도 없이 변형한다. PublishforeachUnsubscribeRemove 사이에 스레드 간 순서를 보장하는 락이 전혀 없어, 두 연산이 임의의 순서로 겹칠 수 있다.

수정안

스냅샷(순회용 복사본)을 만들어 순회 중에는 원본 리스트가 변경되어도 영향받지 않게 하고, Subscribe/Unsubscribe/Publish 전체를 락으로 보호해 데이터 레이스를 없앤다.

using System;
using System.Collections.Generic;
using System.Linq;

public interface IEventListener
{
    void OnEvent(string eventName, object payload);
}

public sealed class EventBus
{
    private readonly object _gate = new();
    private readonly Dictionary<string, List<IEventListener>> _listeners = new();

    public void Subscribe(string eventName, IEventListener listener)
    {
        lock (_gate)
        {
            if (!_listeners.TryGetValue(eventName, out var list))
            {
                list = new List<IEventListener>();
                _listeners[eventName] = list;
            }
            list.Add(listener);
        }
    }

    public void Unsubscribe(string eventName, IEventListener listener)
    {
        lock (_gate)
        {
            if (_listeners.TryGetValue(eventName, out var list))
            {
                list.Remove(listener);
            }
        }
    }

    public void Publish(string eventName, object payload)
    {
        IEventListener[] snapshot;
        lock (_gate)
        {
            if (!_listeners.TryGetValue(eventName, out var list) || list.Count == 0)
                return;

            // 락 안에서 스냅샷(배열 복사본)을 뜬다 — 이후 콜백 호출은 락 밖에서
            // 이 복사본만을 대상으로 하므로, 콜백 도중 원본 리스트가 바뀌어도
            // 이번 Publish 의 순회 자체는 영향받지 않는다.
            snapshot = list.ToArray();
        }

        // 이 시점 이후 원본 리스트가 다른 스레드에 의해 변경되어도(Unsubscribe),
        // 우리는 이미 뜬 스냅샷만 순회하므로 컬렉션 변경 예외가 나지 않는다.
        // "그 시점에 구독 중이던 리스너 전원에게 정확히 한 번씩 통지" 요구사항도
        // 스냅샷이 그 시점의 구독 상태를 그대로 반영하므로 만족한다.
        foreach (var listener in snapshot)
        {
            listener.OnEvent(eventName, payload);
        }
    }
}

더 나은 설계

  • 락 + 스냅샷(채택안): 구현이 단순하고, Publish 도중 구독 해제가 일어나도 예외 없이 안전하게 끝난다. 다만 리스너가 아주 많고 Publish 빈도가 매우 높다면 매번 배열을 복사하는 비용(GC 압박)이 누적될 수 있다.
  • Copy-on-Write 리스트: Subscribe/Unsubscribe 가 항상 새 배열을 만들어 원자적으로 교체(Interlocked.Exchange 또는 volatile 참조 교체)하고, Publish 는 그 시점의 참조 하나만 읽어 락 없이 순회한다. 구독/해제가 드물고 발행이 매우 빈번한 이벤트 버스(전형적인 read-heavy 워크로드)에 적합하며 Publish 경로에 락이 전혀 없어 성능이 가장 좋다. 다만 구독/해제가 잦으면 배열 재할당 비용이 오히려 커진다.
  • 지연 제거(lazy removal) + null 마킹: Unsubscribe 는 즉시 제거하지 않고 "무효화됨" 표시만 하고, Publish 순회 중 무효 리스너는 건너뛰며, 별도 GC 스윕이 주기적으로 실제 제거를 수행. 구현이 복잡해지는 대신 Publish 경합을 더 줄일 수 있다.
  • 트레이드오프: 이벤트 버스처럼 발행이 압도적으로 잦고 구독 변경이 드문 구조라면 Copy-on-Write 가 장기적으로 가장 유리하다. 반대로 구독/해제가 빈번한 동적인 시스템(예: 일회성 리스너를 매 이벤트마다 새로 등록/해제)이라면 락+스냅샷이 더 예측 가능한 비용을 가진다.

면접 포인트

  • "순회 중인 컬렉션을 다른 경로(다른 스레드, 또는 콜백 재진입)가 변경할 수 있는가?"는 이벤트 시스템, 옵저버 패턴을 설계할 때 항상 물어야 하는 질문이다 — 콜백이 임의의 코드를 실행할 수 있는 한, 그 콜백이 우리가 순회 중인 자료구조를 건드리지 않는다는 보장은 없다.
  • 스냅샷(방어적 복사)과 락의 역할을 구분해서 설명할 수 있어야 한다 — 락은 "동시 접근으로부터 자료구조 자체를 보호"하고, 스냅샷은 "긴 순회/콜백 구간 동안 락을 계속 쥐고 있지 않기 위해" 쓰인다. 락을 순회 전체 동안 쥐고 있으면 리스너 콜백 안에서 다시 Subscribe/Unsubscribe 를 호출할 때 재진입 데드락 위험도 생긴다는 점을 지적할 수 있으면 좋다.
  • Copy-on-Write 와 락+스냅샷의 트레이드오프(쓰기 비용 vs 읽기 비용, GC 압박)를 이벤트 버스의 실제 발행/구독 빈도 패턴에 따라 논할 수 있어야 한다.