35. 이벤트 버스 디스패치 중 구독 해제 경합
난이도 최상해설 — 이벤트 버스 디스패치 중 구독 해제 경합
난이도: 최상
요약
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)
Unsubscribe가List<T>.Remove로 그 컬렉션을 즉시, 그리고 어떤 동기화도 없이 변형한다.Publish의foreach와Unsubscribe의Remove사이에 스레드 간 순서를 보장하는 락이 전혀 없어, 두 연산이 임의의 순서로 겹칠 수 있다.
- (A)
수정안
스냅샷(순회용 복사본)을 만들어 순회 중에는 원본 리스트가 변경되어도 영향받지 않게 하고, 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 압박)를 이벤트 버스의 실제 발행/구독 빈도 패턴에 따라 논할 수 있어야 한다.
해설 — 이벤트 버스 디스패치 중 구독 해제 경합
난이도: 최상
요약
EventBus::Publish 는 listeners_[eventName] 이 가리키는 std::vector<IEventListener*> 를 범위 기반 for 로 순회하며 각 리스너의 OnEvent 를 호출한다. 이 리스너들은 raw 포인터로만 등록되어 있어(A) EventBus 는 리스너의 생존 여부를 전혀 알지 못한다. 리스너 콜백 안에서 자기 자신을 구독 해제하거나, 다른 스레드가 Unsubscribe(B)로 벡터를 변형하면 순회 중인 반복자가 무효화되어 정의되지 않은 동작(잘못된 메모리 접근, 다음 리스너를 건너뜀 또는 중복 호출)이 발생한다. 더 심각한 것은, 어떤 모듈이 소멸자에서 자신을 Unsubscribe 하도록 되어 있다는 전제에서, 그 Unsubscribe 호출 자체가 아직 일어나지 않은 상태로 객체가 먼저 파괴되면 listeners_ 에는 이미 죽은 객체를 가리키는 댕글링 포인터가 남고, 다음 Publish 가 그 포인터로 가상 함수(OnEvent)를 호출해 use-after-free 로 이어진다.
문제점
- 분류: 반복자 무효화(iterator invalidation) + 메모리 안전성(use-after-free / dangling pointer)
- 증상:
- 리스너 콜백 안에서
bus->Unsubscribe(eventName, this)를 호출하는 일회성 리스너 패턴을 쓰면,Publish의for (IEventListener* listener : it->second)순회 도중 벡터의 원소가 제거되어 범위 기반 for 의 내부 반복자가 무효화된다 — 이후 반복에서 정의되지 않은 동작이 발생한다(스킵, 재방문, 크래시 등 컴파일러/구현에 따라 다양하게 나타난다). - 어떤 모듈 객체가 소멸될 때
Unsubscribe를 호출하는 것을 잊었거나, 소멸과Unsubscribe사이에 다른 스레드가 끼어들어Publish가 먼저 그 포인터로 접근하면, 이미 해제된 메모리에 대해 가상 함수를 호출하는 use-after-free 가 발생한다 — 크래시로 바로 드러날 수도 있고, 운이 나쁘면 해제된 메모리가 재사용되어 엉뚱한 객체의 메서드가 호출되는 조용한 오동작으로 나타날 수도 있다.
- 리스너 콜백 안에서
- 재현 조건: (1) 단일 스레드 — 리스너 A 의
OnEvent안에서bus.Unsubscribe(eventName, this)호출 후Publish실행. (2) 멀티스레드/수명 — 스레드 T1 이Publish를 순회하는 동안 스레드 T2 가 리스너 객체를delete하거나, 리스너가 소멸되면서Unsubscribe를 호출하려는 순간과 겹침. - 근본 원인:
- (A)
Subscribe가 리스너의 수명을 전혀 추적하지 않는 raw 포인터를 그대로 저장한다.EventBus입장에서 그 포인터가 여전히 유효한 객체를 가리키는지 알 방법이 없다. - (B)
Unsubscribe가 동기화 없이 벡터를 즉시 변형하며, 이 변형과Publish의 순회 사이에 어떤 순서 보장도 없다. 벡터 순회 중 벡터 자체가 변경되는 것과, 순회 중인 포인터가 가리키는 객체가 파괴되는 것, 두 가지 서로 다른 위험이 겹쳐 있다.
- (A)
수정안
(1) 순회는 스냅샷(복사본)으로 하고 락으로 벡터 변경과의 경합을 없애 반복자 무효화를 방지한다. (2) 리스너 수명 문제는 raw 포인터 대신 std::weak_ptr 로 추적해, 이미 파괴된 리스너는 lock() 실패로 자연스럽게 걸러지도록 해 use-after-free 를 원천 차단한다.
#include <algorithm>
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
class IEventListener
{
public:
virtual void OnEvent(const std::string& eventName, void* payload) = 0;
virtual ~IEventListener() = default;
};
class EventBus
{
public:
// 리스너는 shared_ptr 로 소유권이 관리되어야 하고, EventBus 는 그 수명에
// 관여하지 않는 weak_ptr 만 보관한다 — 리스너가 파괴되면 weak_ptr 은
// 자동으로 무효화되어 댕글링 포인터로 남지 않는다.
void Subscribe(const std::string& eventName, const std::shared_ptr<IEventListener>& listener)
{
std::lock_guard<std::mutex> lock(mutex_);
listeners_[eventName].push_back(listener);
}
void Unsubscribe(const std::string& eventName, const std::shared_ptr<IEventListener>& listener)
{
std::lock_guard<std::mutex> lock(mutex_);
auto it = listeners_.find(eventName);
if (it == listeners_.end())
return;
auto& list = it->second;
list.erase(
std::remove_if(list.begin(), list.end(),
[&listener](const std::weak_ptr<IEventListener>& w) {
return w.lock() == listener;
}),
list.end());
}
void Publish(const std::string& eventName, void* payload)
{
std::vector<std::weak_ptr<IEventListener>> snapshot;
{
std::lock_guard<std::mutex> lock(mutex_);
auto it = listeners_.find(eventName);
if (it == listeners_.end() || it->second.empty())
return;
// 락 안에서 스냅샷을 뜬다 — 이후 콜백은 락 밖에서 이 복사본만
// 순회하므로, 콜백 도중 원본 벡터가 바뀌어도 반복자 무효화가
// 일어나지 않는다.
snapshot = it->second;
}
for (const auto& weakListener : snapshot)
{
// 이미 파괴된 리스너는 lock() 이 nullptr 을 반환해 자연스럽게
// 건너뛴다 — use-after-free 가 원천적으로 발생하지 않는다.
if (auto listener = weakListener.lock())
{
listener->OnEvent(eventName, payload);
}
}
}
private:
std::mutex mutex_;
std::unordered_map<std::string, std::vector<std::weak_ptr<IEventListener>>> listeners_;
};
더 나은 설계
weak_ptr+ 락 + 스냅샷(채택안): 리스너 수명 문제(use-after-free)와 순회 중 변경 문제(반복자 무효화)를 동시에 해결한다. 리스너를shared_ptr로 관리해야 하므로 리스너 소유권 모델을EventBus도입 시점에 함께 정리해야 하는 비용이 있다.- RAII 구독 핸들(scoped subscription):
Subscribe가 "구독 취소 토큰" 객체(소멸자에서 자동으로Unsubscribe를 호출)를 반환하도록 하면, 리스너가 명시적으로Unsubscribe를 호출하는 것을 잊는 실수 자체를 방지할 수 있다.weak_ptr방식과 함께 쓰면 이중 방어가 된다. - Copy-on-Write 벡터:
Subscribe/Unsubscribe가 항상 새 벡터를 만들어std::atomic<std::shared_ptr<const std::vector<...>>>로 원자적으로 교체하고,Publish는 락 없이 그 시점의 스냅샷 포인터만 로드해 순회한다. 발행이 압도적으로 잦은 이벤트 버스에서 락 경합 자체를 없앨 수 있지만,shared_ptr원자 연산의 플랫폼별 성능 특성을 이해해야 하고 구현 난이도가 가장 높다. - 트레이드오프:
weak_ptr방식은 리스너를 반드시shared_ptr로 관리해야 한다는 제약이 생기지만(기존에 raw 포인터/스택 객체로 리스너를 만들던 코드는 리팩터링이 필요), 수명 안전성을 컴파일러가 아니라 런타임에서라도 명확하게 보장받을 수 있어 이벤트 버스처럼 콜백 타이밍이 불확실한 구조에는 가장 안전한 선택이다.
면접 포인트
- C++ 에서 콜백/리스너 패턴을 raw 포인터로 구현하면, "그 포인터가 가리키는 객체가 아직 살아있는가"를 호출하는 쪽(
EventBus)이 알 방법이 없다는 근본적 한계가 있다 —weak_ptr은 "약한 참조"를 통해 이 질문에 안전하게 답할 수 있게 해주는 표준 도구임을 설명할 수 있어야 한다. - 반복자 무효화(컨테이너 구조 변경)와 use-after-free(가리키는 객체 자체의 파괴)는 서로 다른 문제이지만 이벤트 버스처럼 "콜백이 임의의 코드를 실행할 수 있는" 구조에서는 흔히 함께 나타난다 — 두 문제를 구분해서 각각의 해법(스냅샷 vs weak_ptr)을 짝지어 설명할 수 있어야 한다.
- 스냅샷을 뜨는 비용(벡터 복사,
shared_ptr참조 카운트 증가)과 락 경합 감소 사이의 트레이드오프, 그리고 이를 Copy-on-Write 로 더 밀어붙일 때의 복잡도 상승을 실제 발행 빈도에 맞춰 논할 수 있어야 한다.