← 문제로

19. 친구 추가/차단이 양쪽에서 동시에 일어나는 상황 (소셜 상태 정합)

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

해설 — 친구 추가/차단이 양쪽에서 동시에 일어나는 상황 (소셜 상태 정합)

난이도: 하

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

모든 메서드가 공유 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 로 통과한 직후, T2 Block(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 등)도 같은 락(또는 스냅샷)으로 보호해야 일관 읽기가 된다.

더 나은 설계 (+트레이드오프)

  1. 쌍 단위 락(lock striping): 전역 락은 소셜 트래픽이 많으면 병목. (min(a,b), max(a,b)) 순서로 두 플레이어 락을 항상 같은 순서로 잡으면 데드락 없이 동시성↑. 트레이드오프: 구현 복잡, 락 순서 규칙 엄수 필요.
  2. 불변 검증을 한 곳에: "친구이면 서로 비차단" 을 add/block 경로 모두에서 강제하는 단일 진입점(트랜잭션). 분산/다중 서버라면 DB 유니크/제약 + 트랜잭션으로 보장.
  3. 양방향 일관성은 단일 진실원: 친구 관계를 정렬된 쌍 1 row 로 저장하면 "반쪽" 자체가 불가능. 조회는 인덱스로. 트레이드오프: 조회 쿼리 형태 변경.
  4. 요청 멱등성: 중복 친구추가/차단 요청은 한 번만 반영(연타/재시도 방어).

면접 포인트 (예상 질문)

  1. "A 가 B 친구추가" 와 "B 가 A 차단" 이 동시에 올 때 왜 모순이 남는가? 인터리빙으로 설명하라.
  2. 전역 락 대신 두 플레이어 락을 잡을 때 데드락을 어떻게 피하는가? (락 순서 고정)
  3. FriendsOf 가 "읽기처럼 보이지만 실제로는 쓰기" 인 이유와, 그게 동시성에서 왜 위험한가?