← 문제로

28. 공유 설정의 제자리 수정과 안전 공개(찢긴 스냅샷) (C#)

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

해설 — 공유 설정의 제자리 수정과 안전 공개(찢긴 스냅샷) (C#)

난이도: 최상

요약

C# 에서 참조 대입은 원자적이라 참조 자체는 찢기지 않는다. 그래서 "Config 객체를 통째로 바꿔치기"하면 안전하다. 그러나 이 코드는 그러지 않고 Reload공개된 Config 객체의 필드를 제자리(in place)에서 수정한다(B). 워커가 잡은 c(=_current 와 같은 객체)는 진짜 스냅샷이 아니라 살아 움직이는 객체라, DropTable.CountDropTable[...] 사이에 DropTable 이 교체되어 찢긴 스냅샷(옛 Version + 새 DropTable, 또는 인덱스 범위 초과 예외)이 난다. 더해 _current 가 volatile 이 아니라 안전 공개(safe publication) 보장도 없다.

문제점

(1) [찢긴 읽기] 제자리 수정으로 스냅샷이 깨짐 — (A)(B)

  • 증상: RollDrop 에서 n = c.DropTable.Count(옛 리스트, 예: 3) 직후 Reloadc.DropTable = 새 리스트(크기 1) 로 바꾸면, c.DropTable[seed % 3] 이 새 리스트를 인덱싱해 IndexOutOfRangeException 또는 엉뚱한 값. Version 은 옛 값, DropTable 은 새 값인 불일치 스냅샷도 발생.
  • 재현: 워커들이 RollDrop 도는 중 갱신 스레드가 Reload.
  • 근본원인: "스냅샷"이 불변이 아니다. 공개된 객체의 필드를 나중에 바꾸면, 그 객체를 읽는 모든 워커가 영향을 받는다. List<T> 자체도 동시 읽기/쓰기가 스레드 안전하지 않다(열거 중 변경 예외·내부 배열 손상).

(2) [안전 공개] 가시성·재정렬 — _current 비휘발성

  • 증상: 새 객체를 만들어 대입하더라도, _current 가 일반 필드면 다른 코어가 참조는 보이는데 그 객체의 필드 초기화는 아직 안 보이는 순서로 관측할 여지가 있다(약한 메모리 모델/ARM). 즉 부분 초기화 객체 노출.
  • 근본원인: 공개 시점에 메모리 배리어(Volatile.Write/Interlocked)가 없음.

C++ 트윈과의 언어 차이

  • C++ 에서는 같은 shared_ptr 객체에 동시 read/write 자체가 데이터 레이스(UB) 라, 불변 스냅샷을 "통째로 바꿔치기"해도 std::atomic_load/store 가 필요했다.
  • C# 에서는 참조 대입이 원자적이라 "통째로 바꿔치기"는 안전하다(참조가 찢기지 않음). 그래서 C# 버전의 버그는 재대입이 아니라 제자리 수정 + 안전 공개 누락이다. 즉 같은 시나리오라도 올바른 패턴이 언어마다 다르다.

수정안

불변 스냅샷을 새로 만들어 한 번에 공개하고, 워커는 참조를 한 번만 읽어 그 스냅샷만 쓴다(읽기 락-프리). 공개는 Volatile.Write/Interlocked.Exchange 로.

public sealed class Config            // 공개 후 불변로 취급
{
    public int Version { get; init; }
    public IReadOnlyList<int> DropTable { get; init; } = System.Array.Empty<int>();
    public string MapName { get; init; } = "";
}

public sealed class World
{
    private Config _current = new Config();

    public int RollDrop(int seed)
    {
        Config c = Volatile.Read(ref _current);   // 참조를 한 번만 읽어 스냅샷 확정
        int n = c.DropTable.Count;
        return n == 0 ? 0 : c.DropTable[seed % n]; // 같은 c 만 사용 → 일관
    }

    public void Reload(int version, IReadOnlyList<int> drops, string map)
    {
        var next = new Config { Version = version, DropTable = drops, MapName = map };
        Volatile.Write(ref _current, next);        // 완전히 만든 뒤 한 번에 공개
        // 또는 Interlocked.Exchange(ref _current, next);
    }
}
  • 새 객체는 공개 전에 완전 초기화되고, Volatile.Write그 초기화가 참조 공개보다 먼저 보이도록 보장(안전 공개).
  • 워커는 Volatile.Read 로 잡은 c 만 쓰므로, 갱신이 와도 자기 스냅샷은 불변.

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

  • 불변 스냅샷 + 원자 참조 교체(RCU 패턴): 읽기 압도적 우세(설정/라우팅/월드 스냅샷)에 최적. 읽기 락-프리, 갱신만 새 객체.
  • 진짜로 부분 갱신이 잦고 큰 객체면 ImmutableInterlocked/ReaderWriterLockSlim 검토. 그래도 "공개된 객체는 변경 금지" 원칙은 유지.
  • record(값 의미론·불변)로 Config 를 정의하면 의도가 더 분명.

흔한 오해·함정

  • "참조 대입이 원자적이니 동시성 안전" — 참조 교체는 안전하지만 공개된 객체의 필드 수정은 안전하지 않다. 불변으로 공개해야 한다.
  • volatile 없이 새 객체 공개 → 약한 메모리 모델에서 부분 초기화 노출 위험.
  • List<T> 를 여러 스레드가 동시에 읽고 쓰기 — 스레드 안전 아님.

면접 포인트

  • C# 참조 대입의 원자성과 그 한계(객체 내부 가변성·가시성)를 정확히 구분.
  • 안전 공개: 완전히 만든 뒤 Volatile.Write/Interlocked 로 공개.
  • 같은 핫스왑 문제라도 C++(atomic shared_ptr) vs C#(불변+원자 참조 교체) 로 올바른 패턴이 갈린다는 점을 설명할 수 있는지.