19. 친구 추가/차단이 양쪽에서 동시에 일어나는 상황 (소셜 상태 정합)
난이도 하해설 — 친구 추가/차단이 양쪽에서 동시에 일어나는 상황 (소셜 상태 정합)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
모든 메서드가 공유 Dictionary/HashSet 을 락 없이 읽고 쓴다. (A) AddFriend 는
"차단 검사 → 양쪽 추가" 가 분리된 check-then-act 라, B 가 동시에 A 를 Block 하면
"A 의 친구 목록엔 B 가 있는데 B 는 A 를 차단" 한 모순 상태가 남는다(차단된 상대와 친구).
또 양쪽 추가가 비원자라 한쪽만 친구가 되는 반쪽 관계도 가능하다. 게다가 HashSet/
Dictionary 는 스레드세이프하지 않아 동시 Add/Remove 가 컬렉션을 손상시키거나 예외를
던진다. 정답 한 줄: 두 플레이어 쌍에 대한 친구/차단 변경을 하나의 임계구역으로 직렬화하고,
"차단 검사와 친구 추가" 를 원자적으로 수행한다(전역 락 또는 안정된 순서의 쌍 단위 락).
문제점
(A) AddFriend 의 차단 검사–추가 TOCTOU — 차단된 상대와 친구 (동시성/정합) ★간판
- 증상: T1
AddFriend(A,B)가BlocksOf(B).Contains(A)를 false 로 통과한 직후, T2Block(B,A)가BlocksOf(B).Add(A)+ 친구 해제를 실행. 그 뒤 T1 이FriendsOf(A).Add(B); FriendsOf(B).Add(A);를 마치면 → B 는 A 를 차단했는데 양쪽 친구 목록엔 서로가 남는다. 요구사항 "차단 중인데 친구 목록에 있음 금지" 위반. - 재현조건: A 의 친구 추가와 B 의 차단이 거의 동시(서로 다른 스레드).
- 근본 원인: 검사와 변경이 같은 임계구역에 없다. 차단 상태가 그 사이 바뀔 수 있다.
(공통) 비스레드세이프 컬렉션 동시 변경 — 손상/예외 (동시성) ★간판
- 증상:
_friends/_blocks(Dictionary)와 그 안의HashSet에 여러 스레드가 동시Add/Remove.FriendsOf가 없는 키를 만들며_friends[id]=s로 쓰기까지 한다. 동시 구조변경 시InvalidOperationException, 무한 루프, 또는 조용한 데이터 오염. - 근본 원인: 공유 가변 컬렉션에 동기화가 전혀 없다. 읽기처럼 보이는
FriendsOf도 실제로는 쓰기(지연 생성)다.
(A) 양방향 추가 비원자 — 반쪽 친구 관계 (정합)
- 증상:
FriendsOf(a).Add(b)와FriendsOf(b).Add(a)사이에 다른 변경/예외가 끼면 한쪽만 친구가 된다. 차단 해제/재추가가 겹치면 비대칭이 영구화될 수 있다. - 근본 원인: 두 컬렉션 변경이 하나의 트랜잭션이 아니다.
(C) Block 의 차단 추가–친구 해제 비원자 (정합)
- 증상:
BlocksOf(a).Add(b)후 친구 해제 사이에AddFriend(a,b)가 끼면 차단했는데 친구가 다시 생긴다. (A) 와 대칭인 경합.
수정안
핵심: 친구/차단 변경 전체를 단일 락으로 직렬화하고, "차단 검사 + 친구 추가" 를 한 번에.
public class SocialManager
{
private readonly object _gate = new();
private readonly Dictionary<long, HashSet<long>> _friends = new();
private readonly Dictionary<long, HashSet<long>> _blocks = new();
public bool AddFriend(long a, long b)
{
if (a == b) return false;
lock (_gate)
{
if (BlocksOf(a).Contains(b) || BlocksOf(b).Contains(a)) return false;
FriendsOf(a).Add(b);
FriendsOf(b).Add(a);
return true;
}
}
public bool Block(long a, long b)
{
if (a == b) return false;
lock (_gate)
{
BlocksOf(a).Add(b);
FriendsOf(a).Remove(b);
FriendsOf(b).Remove(a);
return true;
}
}
private HashSet<long> FriendsOf(long id) { /* 락 안에서만 호출 */
if (!_friends.TryGetValue(id, out var s)) { s = new(); _friends[id] = s; }
return s;
}
private HashSet<long> BlocksOf(long id) {
if (!_blocks.TryGetValue(id, out var s)) { s = new(); _blocks[id] = s; }
return s;
}
}
포인트
- 차단 검사와 친구 추가가 같은 락 안 → TOCTOU 제거.
- 양방향 추가/해제가 원자적 → 반쪽 관계 제거.
- 읽기(
IsFriend등)도 같은 락(또는 스냅샷)으로 보호해야 일관 읽기가 된다.
더 나은 설계 (+트레이드오프)
- 쌍 단위 락(lock striping): 전역 락은 소셜 트래픽이 많으면 병목.
(min(a,b), max(a,b))순서로 두 플레이어 락을 항상 같은 순서로 잡으면 데드락 없이 동시성↑. 트레이드오프: 구현 복잡, 락 순서 규칙 엄수 필요. - 불변 검증을 한 곳에: "친구이면 서로 비차단" 을 add/block 경로 모두에서 강제하는 단일 진입점(트랜잭션). 분산/다중 서버라면 DB 유니크/제약 + 트랜잭션으로 보장.
- 양방향 일관성은 단일 진실원: 친구 관계를 정렬된 쌍 1 row 로 저장하면 "반쪽" 자체가 불가능. 조회는 인덱스로. 트레이드오프: 조회 쿼리 형태 변경.
- 요청 멱등성: 중복 친구추가/차단 요청은 한 번만 반영(연타/재시도 방어).
면접 포인트 (예상 질문)
- "A 가 B 친구추가" 와 "B 가 A 차단" 이 동시에 올 때 왜 모순이 남는가? 인터리빙으로 설명하라.
- 전역 락 대신 두 플레이어 락을 잡을 때 데드락을 어떻게 피하는가? (락 순서 고정)
FriendsOf가 "읽기처럼 보이지만 실제로는 쓰기" 인 이유와, 그게 동시성에서 왜 위험한가?
해설 (C++) — 친구 추가/차단이 양쪽에서 동시에 일어나는 상황 (소셜 상태 정합)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
C# 판과 동일한 TOCTOU(차단 검사–친구 추가 분리) 와 양방향 비원자 문제에 더해, C++
판은 동기화가 없어 unordered_map/unordered_set 의 동시 변경이 UB 다. 특히 FriendsOf/
BlocksOf 가 map[id] 로 없는 키를 삽입하며 리해시를 유발할 수 있어, 다른 스레드가 같은
맵을 순회/접근 중이면 노드·버킷 포인터가 무효화돼 조용한 메모리 오염이 난다. 정답 한 줄:
쌍 단위(또는 전역) 뮤텍스로 친구/차단 변경을 직렬화하고 "차단 검사 + 친구 추가" 를 한
임계구역에서 수행한다.
문제점
(A) AddFriend 의 차단 검사–추가 TOCTOU — 차단된 상대와 친구 (동시성/정합) ★간판
- 증상: T1
AddFriend(A,B)가BlocksOf(B).count(A)==0통과 직후 T2Block(B,A)가 차단+친구 해제 → T1 이 양쪽insert완료 → B 는 A 를 차단했는데 친구 목록엔 서로 존재. - 근본 원인: 검사와 변경이 같은 임계구역에 없다.
(공통) unordered_map/set 무동기 동시 변경 — UB ★간판
- 증상: 락 없이 동시
insert/erase.friends_[id]/blocks_[id]의 묵시적 삽입이 리해시를 일으키면 참조/이터레이터 무효화. C# 의 예외와 달리 C++ 는 정의되지 않은 동작 (크래시 또는 조용한 손상). - 근본 원인: 공유 가변 컨테이너에 동기화 없음. 읽기처럼 보이는 헬퍼가 실제로는 쓰기.
(A) 양방향 insert 비원자 — 반쪽 친구 관계 (정합)
FriendsOf(a).insert(b)와FriendsOf(b).insert(a)사이 끼어듦 → 한쪽만 친구.
(C) Block 의 차단 추가–친구 해제 비원자 (정합)
- 차단 직후 친구 해제 사이에
AddFriend가 끼면 차단했는데 친구가 다시 생긴다.
수정안
#include <mutex>
class SocialManager {
public:
bool AddFriend(std::int64_t a, std::int64_t b) {
if (a == b) return false;
std::lock_guard<std::mutex> lk(m_);
if (BlocksOf(a).count(b) || BlocksOf(b).count(a)) return false;
FriendsOf(a).insert(b);
FriendsOf(b).insert(a);
return true;
}
bool Block(std::int64_t a, std::int64_t b) {
if (a == b) return false;
std::lock_guard<std::mutex> lk(m_);
BlocksOf(a).insert(b);
FriendsOf(a).erase(b);
FriendsOf(b).erase(a);
return true;
}
bool IsFriend(std::int64_t a, std::int64_t b) {
std::lock_guard<std::mutex> lk(m_);
auto it = friends_.find(a);
return it != friends_.end() && it->second.count(b) != 0;
}
private:
std::unordered_set<std::int64_t>& FriendsOf(std::int64_t id) { return friends_[id]; }
std::unordered_set<std::int64_t>& BlocksOf(std::int64_t id) { return blocks_[id]; }
std::mutex m_;
std::unordered_map<std::int64_t, std::unordered_set<std::int64_t>> friends_;
std::unordered_map<std::int64_t, std::unordered_set<std::int64_t>> blocks_;
};
포인트
- 검사+추가를 같은 락 안에서 → TOCTOU 제거.
- 읽기 경로(
IsFriend)는find로 묵시 삽입을 피하고 같은 락으로 보호.
더 나은 설계 (+트레이드오프)
- 쌍 단위 락(
std::scoped_lock으로 두 뮤텍스를 데드락 없이): 또는(min,max)순서로 두 플레이어 락을 고정 순서 획득. 전역 락보다 동시성↑, 구현 복잡. - 친구 관계를 정렬된 쌍 1개로 저장: "반쪽 친구" 가 구조적으로 불가능.
- shared_mutex 읽기/쓰기 분리: 조회가 많으면
std::shared_mutex로 읽기 동시성↑. 트레이드오프: 쓰기 기아 주의. - 요청 멱등성: 중복 추가/차단 한 번만 반영.
면접 포인트 (예상 질문)
map[id]의 묵시적 삽입이 왜 동시성에서 위험한가? (리해시·무효화, "읽기" 가 실제로 쓰기)- 두 플레이어 뮤텍스를 동시에 잡을 때 데드락을 어떻게 막는가? (
std::scoped_lock또는 고정 순서) - 차단된 상대와 친구가 되는 모순을 막는 트랜잭션 경계는 어디여야 하는가?
구문 검증:
g++ -std=c++17 -fsyntax-only problem.cpp통과.