15. 차단 목록 갱신과 메시지 전달의 경합 (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 동시 순회/변경 (동시성) ★간판
- 분류: 컬렉션 동시 수정.
- 증상:
DeliverWhisper가to.Blocked를foreach로 순회하는 동안Block/Unblock이Add/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).
- 예상 질문:
- "왜 foreach 중에 죽나?" → 순회 중 구조 변경 시 enumerator 가 예외.
Contains+ 락(또는 스냅샷)으로 해결. - "차단 검사 방향이 왜 중요한가?" → 발신자 기준으로 검사하면 의미가 뒤집힌다. 수신자의 목록에서 발신자를 찾아야 한다(이 코드는 방향은 맞음).
- "TOCTOU 를 완전히 없애야 하나?" → 메시지 전달은 그 순간의 스냅샷이면 충분. 과한 원자화보다 일관된 단일 읽기로.
- "왜 foreach 중에 죽나?" → 순회 중 구조 변경 시 enumerator 가 예외.
변별 메모: session14(브로드캐스트 중 수신자 목록 변경)는 다수 수신자 순회 + 세션 생명주기(소켓 Dispose) 가 축이고, 본 문제는 개인 차단 집합의 동시 변경 + 검사 방향/ O(1) 조회 가 축이다. 난이도 하: 단일 집합·단발 전달로 범위가 좁다.
해설 — 차단 목록 갱신과 메시지 전달의 경합 (C++)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
std::unordered_set<int64_t> blocked 가 스레드세이프가 아닌데 소셜 API 스레드
(insert/erase)와 메시지 워커 스레드(range-for 순회)가 동기화 없이 동시에 접근한다.
C++ 표준상 동일 컨테이너에 대한 동시 비-const 접근은 데이터 레이스(UB) — 순회 중
insert 로 리해시가 돌면 반복자가 무효화되어 크래시/무한루프. (B) 선형 순회는 집합인데
count/find O(1) 를 안 써 비효율. (C) 검사-전달 TOCTOU 로 막 차단한 메시지가 한 건
샐 수 있다. 차단 방향은 올바르다(to.blocked 에서 fromId 검사). players_[id] 는
operator[] 라 없는 키를 조용히 새로 만들어 유령 플레이어를 삽입하는 버그가 있다.
정답 한 줄: 차단 집합을 뮤텍스(또는 copy-on-write 스냅샷)로 보호하고, find O(1) 조회,
players_ 는 find 로 존재 확인.
문제점
(A)+(B) unordered_set 동시 접근 — 데이터 레이스/UB (동시성) ★간판
- 증상:
DeliverWhisper의 range-for 가to.blocked를 순회하는 동안Block/Unblock이insert/erase→ 표준상 UB.insert가 리해시를 유발하면 진행 중 반복자/노드 포인터 무효화로 세그폴트 또는 무한 루프. - 재현조건: 귓속말 수신 순간 그 수신자가 차단/해제. 채팅 트래픽이 많아 상시.
- 근본 원인: 공유 컨테이너에 동기화 부재.
(B) 선형 순회 — O(n) (성능)
- 증상:
for (id : to.blocked) if (id==fromId)는 O(n).to.blocked.count(fromId)/findO(1) 를 써야 한다. 차단 목록이 길면 핫패스 낭비.
(C) 검사-전달 TOCTOU (동시성·정확성)
- 증상:
blocked계산 후Deliver사이에 차단이 추가되면 한 건 샌다. 단발성이라 치명도는 낮으나 "차단했는데 왔다" 민원 유발. 전달은 본질상 스냅샷이라 일관된 한 번의 읽기면 충분.
(보너스) operator[] 의 유령 삽입 (견고성) ★C++ 특유
- 증상:
players_[toId]는 키가 없으면 기본 생성된 Player 를 삽입한다. 막 로그아웃한 대상에게 귓속말이 오면 빈 플레이어가 맵에 슬그머니 생겨 메모리 누수/오동작.find로 존재를 확인해야 한다. (C# 판은 같은 자리에서 예외가 나는 점과 대비.)
(참고) 방향은 올바름
- 수신자
to.blocked에서fromId를 찾는 것은 정확. 트랩 아님.
수정안
핵심: ① 뮤텍스로 집합 보호, ② find/count O(1), ③ players_.find 로 존재 확인.
#include <mutex>
struct Player {
int64_t id = 0;
void Deliver(const std::string& msg) { /* 전송 */ }
bool IsBlocked(int64_t who) const {
std::lock_guard<std::mutex> lk(mtx_);
return blocked_.count(who) != 0; // O(1)
}
void Block(int64_t t) { std::lock_guard<std::mutex> lk(mtx_); blocked_.insert(t); }
void Unblock(int64_t t) { std::lock_guard<std::mutex> lk(mtx_); blocked_.erase(t); }
private:
mutable std::mutex mtx_;
std::unordered_set<int64_t> blocked_;
};
void SocialService::DeliverWhisper(int64_t fromId, int64_t toId, const std::string& msg) {
auto it = players_.find(toId); // 유령 삽입 방지
if (it == players_.end()) return;
Player& to = it->second;
if (to.IsBlocked(fromId)) return;
to.Deliver(msg);
}
players_자체도 다른 스레드가 로그인/로그아웃으로 변경한다면 그 맵에도shared_mutex가 필요(여기선 차단 집합에 집중). 읽기 잠금(shared_lock) + 쓰기 잠금으로 분리 가능.
대안: copy-on-write 스냅샷
읽기 多·쓰기 少면 std::shared_ptr<const std::unordered_set<int64_t>> 를 atomic 교체.
읽기는 auto s = std::atomic_load(&blocked_); s->count(who); 로 무락.
더 나은 설계
1) 읽기 우선 자료구조
- copy-on-write +
shared_ptr또는std::shared_mutex(읽기 공유/쓰기 배타). 전달이 압도적 다수인 워크로드에 적합. 트레이드오프: 쓰기 비용 증가(드묾).
2) 차단 검사 공통화
- 귓속말·초대·거래·따라가기 등이 같은 차단 정책을 참조하도록 공통 서비스로 추출.
3) 대규모 차단은 외부 저장 + 캐시
- 수천 건이면 Redis/DB + 핫 캐시, 무효화 이벤트로 일관성.
면접 포인트
- 핵심: unordered_set 동시 접근 UB 와
operator[]의 유령 삽입, 읽기 多 워크로드의 동기화 선택. - 예상 질문:
- "range-for 중 insert 가 왜 위험한가?" → 리해시 시 반복자 무효화(UB). 뮤텍스/스냅샷 필요.
- "operator[] 대신 find 를 쓰는 이유?" →
[]는 없는 키를 삽입. 조회엔find. - "shared_mutex 가 유리한 경우?" → 읽기(전달) 빈도 ≫ 쓰기(차단) 일 때.
빌드/검증
g++ -std=c++17 -fsyntax-only problem.cpp
변별 메모: session14 는 다수 세션 순회 + 소켓 생명주기, 본 문제는 개인 차단 집합 동시 변경 + 방향/조회. C++ 판은
operator[]유령 삽입이 추가 함정.