← 문제로

19. 스레드 안전성의 수준 분류와 합성(composition)의 함정

난이도 중
내 답안
모범답안

문제 19 해설 — 스레드 안전성의 수준 분류와 합성의 함정

난이도: 중상

1. 스레드 안전성 수준 분류

"스레드 안전"은 이분법이 아니라 호출자가 무엇을 책임지는가의 스펙트럼이다.

(1) 불변(immutable)

생성 후 상태가 절대 변하지 않는다. 데이터 레이스의 정의 자체가 "동시 접근 중 최소 하나가 쓰기"이므로 쓰기가 없으면 레이스도 없다. 동기화가 전혀 필요 없고 자유롭게 공유·복제할 수 있다.

  • 호출자 책임: 없음. 단, "안전하게 발행(safe publication)"은 필요하다 — 다른 스레드가 그 참조를 보려면 readonly 필드 초기화, volatile/Interlocked 쓰기, 락 등 happens-before 를 만드는 경로로 전달돼야 한다.
  • 예: System.String, ImmutableArray<T>/ImmutableDictionary<K,V>, C++ 의 const 객체.
  • 게임서버: 몬스터 스탯 테이블, 스킬 데이터, 맵 지오메트리 같은 정적 게임 데이터는 전부 불변으로 만들어 락 없이 모든 워커가 읽게 한다. 가장 싸고 확실한 동시성 전략.

(2) 스레드 안전(thread-safe / unconditionally thread-safe)

가변이지만 모든 공개 연산 하나하나가 내부적으로 동기화되어, 외부 동기화 없이 임의 스레드에서 호출해도 개별 연산은 정확하다.

  • 호출자 책임: 개별 연산은 없음. 그러나 여러 연산의 조합은 여전히 본인 책임(2번 항목).
  • 예: ConcurrentDictionary<K,V>, ConcurrentQueue<T>, Interlocked 로 감싼 카운터, BlockingCollection<T>.

(3) 조건부 스레드 안전(conditionally thread-safe)

개별 연산은 안전하지만, 일부 연산 묶음은 호출자가 외부 락으로 보호해야 정확하다. "어떤 묶음이 위험한지"를 문서가 알려줘야 한다.

  • 호출자 책임: 문서가 지정한 복합 연산 구간에 클라이언트 측 락.
  • 예: ConcurrentDictionary 의 열거 + 수정 조합, Hashtable.Synchronized(...) 래퍼(개별 연산만 안전), Java 의 Vector/ConcurrentHashMap 이 대표적으로 이 범주로 분류된다.

(4) 스레드 호환(thread-compatible / not thread-safe but safe when externally synchronized)

동기화가 전혀 없다. 하지만 호출자가 외부에서 락으로 감싸면 정상 동작한다. 라이브러리의 절대다수가 여기 속한다.

  • 호출자 책임: 모든 접근에 대한 외부 동기화. 단, "동시 읽기만 하면 안전"인 경우가 많다(List<T> 도 쓰기가 없으면 동시 읽기는 안전).
  • 예: List<T>, Dictionary<K,V>, StringBuilder, C++ std::vector(동일 객체에 대한 동시 쓰기 금지, 동시 읽기 허용).
  • 게임서버: 세션별 인벤토리 리스트, 존별 엔티티 딕셔너리 등 대부분의 자료구조. "이 데이터는 존 스레드만 만진다"는 소유권 규약으로 락 없이 쓰는 것이 정석.

(5) 스레드 적대(thread-hostile)

외부에서 락을 걸어도 안전하게 만들 수 없다. 전역/정적 상태나 프로세스 전역 자원을 몰래 건드리기 때문이다.

  • 호출자 책임: 사실상 사용 금지, 또는 단일 스레드로 격리.
  • 예: 프로세스 전역 로케일/환경 변수를 바꾸는 함수(setlocale, putenv), 정적 버퍼를 돌려주는 C 런타임 함수(strtok, localtime, gmtime), 전역 errno 를 스레드 로컬 없이 쓰는 구현.
  • 게임서버: 전역 상태를 바꾸는 서드파티 네이티브 SDK(그래픽/물리/암호 라이브러리 초기화 함수 등)가 종종 여기 해당한다. 전용 스레드 하나에 가두고 큐로 요청을 넘기는 것이 유일한 해법.

실무 팁: 이 다섯 분류는 문서에 쓰기 위한 어휘다. 자신이 만든 클래스가 어디에 속하는지 말할 수 없다면 그 클래스는 아직 설계가 끝나지 않은 것이다.

2. 합성의 함정 — 원자적 연산의 합은 원자적이지 않다

(가) ContainsKey → TryAdd

if (!concurrentDict.ContainsKey(id))
    concurrentDict.TryAdd(id, CreateSession(id));

ContainsKey 도 원자적이고 TryAdd 도 원자적이다. 그러나 둘 사이에 틈이 있다.

시각 스레드 A 스레드 B
t1 ContainsKey(42) → false
t2 ContainsKey(42) → false
t3 CreateSession(42) 실행 (소켓 할당, DB 조회)
t4 CreateSession(42) 실행 (두 번째 세션 객체 생성)
t5 TryAdd → true
t6 TryAdd → false(이미 있음)

문제는 두 가지다.

  1. CreateSession 이 두 번 실행된다. 부수효과가 있으면(소켓 오픈, DB row insert, 재화 지급) 그 자체가 버그다.
  2. B 가 만든 세션은 딕셔너리에 못 들어가고 버려지지만 해제되지 않는다 — 소켓/네이티브 자원 누수. TryAdd 의 반환값을 무시하면 B 는 자기 세션이 등록됐다고 착각하고 계속 쓴다 → 같은 플레이어에 대해 서로 다른 두 세션 객체가 살아 움직인다(유령 세션).

GetOrAdd(id, CreateSession) 로 바꿔도 CreateSession 이 두 번 불릴 수 있다는 점은 그대로다. ConcurrentDictionary.GetOrAdd 는 "반환값은 하나로 통일되지만 팩토리는 락 밖에서 호출되어 중복 실행될 수 있다"고 문서화돼 있다. 부수효과 있는 팩토리를 넘기면 안 된다.

(나) Contains → Add

if (!list.Contains(x)) list.Add(x);

List<T> 는 아예 thread-compatible 이라 더 나쁘다. 두 스레드가 동시에 Add 하면 _size++ 와 _items[_size] 대입이 겹쳐 원소 유실, 배열 인덱스 예외, 심지어 내부 배열 손상이 난다. 설령 Add 를 원자적으로 만들어도 Contains → Add 사이의 틈 때문에 중복 원소가 들어간다. 게임서버에서는 "파티 멤버 중복 추가", "버프 중복 적용" 같은 형태로 나타난다.

안전하게 만드는 방법과 트레이드오프

방법 내용 장점 단점
원자적 복합 연산 API TryAdd 반환값만 사용, GetOrAdd, AddOrUpdate 락 없음, 가장 빠름 표현 가능한 조합이 제한됨. 팩토리 중복 호출 주의
CAS 루프 TryUpdate(k, newV, oldV) 를 실패 시 재시도 락프리, 경합 적을 때 최고 재시도 로직 복잡, 경합 심하면 라이브락성 낭비
외부 락으로 묶기 lock (_gate) { if (!d.ContainsKey) d.Add(...); } 단순·명확, 임의 조합 가능 동시성 컬렉션의 이점이 사라짐(그럴 거면 Dictionary + 락이 더 빠름)
지연 초기화 래핑 값 타입을 Lazy<T>(mode: ExecutionAndPublication) 로 저장 팩토리 정확히 한 번 실행 보장 Lazy 객체 할당 비용, 팩토리 안에서 락 잡으면 데드락 위험
단일 소유 스레드로 직렬화 해당 컬렉션은 특정 액터/존 스레드만 접근, 나머지는 메시지 전달 동기화 자체가 사라짐, 추론 쉬움 지연 증가, 메시지 오버헤드, 설계 변경 필요

게임서버 세션 맵의 실전 정답은 보통 GetOrAdd + Lazy 이거나, 접속 처리를 한 스레드(액셉터/액터)로 직렬화하는 것이다.

3. 클라이언트 측 락과 락 캡슐화 위반

동작하는 경우

클라이언트 측 락은 "컬렉션이 자기 내부 동기화에 쓰는 락 객체와, 내가 밖에서 잡는 락 객체가 같다"는 전제에서만 성립한다.

lock (list.SyncRoot) {            // 옛 컬렉션이 SyncRoot 를 공개했던 이유
    if (!list.Contains(x)) list.Add(x);
}

이 전제가 성립하면 내부 연산과 외부 복합 연산이 같은 상호배제 영역에 들어가 정확해진다.

조용히 실패하는 경우

  1. 컬렉션이 다른 락 객체를 쓴다. ConcurrentDictionary 는 내부적으로 락 배열(스트라이프 락) 을 쓰고 자기 자신을 락으로 쓰지 않는다. lock (concurrentDict) 는 다른 클라이언트 코드에 대해서만 상호배제를 줄 뿐, 내부 연산을 하는 다른 스레드는 전혀 막지 못한다. 컴파일도 되고 예외도 없이 틀린 코드가 된다.
  2. 락프리 구현: 내부가 CAS 기반이면 잡을 락 자체가 없다.
  3. 래핑/위임(delegation): SynchronizedList 같은 래퍼는 래퍼 자신을 락하는데, 호출자가 래핑된 내부 리스트를 락하면 서로 다른 객체를 잡는다. 반대도 마찬가지.
  4. 구현 변경에 취약: 라이브러리가 내부 동기화 전략을 바꾸면 (공개 API 는 그대로인데) 클라이언트 락이 조용히 무력화된다. 캡슐화를 뚫는 결합이라 버전 업그레이드가 곧 레이스 도입이다.

대안 비교

설계 장점 단점
락 객체를 공개(SyncRoot) 호출자가 임의 복합 연산 가능 캡슐화 파괴, 외부가 락을 오래 잡아 내부 성능/데드락을 통제 불가. .NET 이 SyncRoot 를 폐기 방향으로 간 이유
복합 연산을 클래스 안으로(AddIfAbsent, TryTransferItem) 캡슐화 유지, 락 범위를 구현자가 통제, 최적화 자유 API 표면이 커짐. 필요한 조합을 미리 다 예측해야 함
콜백 주입(WithLock(Action<T>), Update(Func<T,T>)) 임의 연산을 안전 구간에서 실행 콜백 안에서 무슨 짓을 할지 모른다 — 다른 락 획득 시 데드락, 긴 작업 시 전체 정지(재진입/이벤트 발행 금지 규약 필요)
불변 + 원자적 교체(Interlocked.CompareExchange(ref _snapshot, newMap, old)) 읽기 완전 무잠금, 조합도 CAS 로 원자화 쓰기마다 복사 비용. 읽기 압도적 우위일 때만
소유권 기반 직렬화(액터) 락 자체가 사라짐, 조합 문제 소멸 지연, 메시지 설계 비용

권장 순서: 복합 연산을 클래스 안으로 넣는다 → 안 되면 콜백 주입 → 락 객체 공개는 최후의 수단.

4. 뷰·반복자 반환이 캡슐화를 깨는 문제

스레드 안전한 클래스가 내부 컬렉션의 살아있는 참조를 반환하면, 그 순간부터 호출자는 클래스의 동기화 밖에서 내부 상태를 만진다. 클래스는 더 이상 자기 불변식을 지킬 수 없다.

public IReadOnlyList<Entity> Entities => _entities;   // 위험: 살아있는 뷰

IReadOnlyList 는 호출자가 못 바꾼다는 뜻일 뿐, 다른 스레드가 바꾸는 동안 안전하게 읽을 수 있다는 뜻이 아니다. 열거 중 내부에서 Add 가 일어나면 InvalidOperationException 이거나, 더 나쁘게 찢어진 상태를 본다.

.NET 동시성 컬렉션의 일관성 수준 구분

API 일관성 의미
ConcurrentDictionary 의 foreach(GetEnumerator) 약한 일관성 / 스냅샷 아님 예외는 안 나지만, 열거 도중의 추가·삭제가 보일 수도 안 보일 수도 있다. 어떤 시점에도 실제로 존재한 적 없는 조합을 볼 수 있다
Keys / Values 프로퍼티 모든 락을 잡고 만드는 스냅샷 복사본 그 시점의 일관된 목록. 대신 전체 락 획득 + 배열 할당이라 비싸고, 자주 부르면 쓰기 스레드를 다 막는다
ToArray() 마찬가지로 일관된 스냅샷 비용 동일. 원자적 스냅샷이 필요하면 이걸 써야 한다
Count 모든 락을 잡음 겉보기와 달리 O(1) 아니고 싸지 않다. 핫패스에서 if (dict.Count > 0) 는 안티패턴
ConcurrentQueue.GetEnumerator 스냅샷 열거 시작 시점의 스냅샷을 본다

즉 같은 클래스 안에서도 API 마다 일관성 수준이 다르다. "ConcurrentDictionary 는 스레드 안전"이라는 한 문장으로는 아무것도 결정할 수 없다.

안전한 대안과 비용

  • 스냅샷 반환(ToArray): 가장 이해하기 쉽다. 비용은 O(n) 복사 + 할당(GC 압력). 존에 엔티티 5천 개인데 매 틱 스냅샷을 뜨면 그것만으로 예산을 다 쓴다. → 틱당 한 번만 뜨고 재사용, 또는 버퍼 재사용(ArrayPool).
  • 불변 컬렉션 + 원자적 교체: 읽기는 완전 무잠금이고 스냅샷이 공짜(참조 하나 읽기). 쓰기가 O(log n) 구조 공유 복사. 읽기:쓰기 = 1000:1 인 데이터(길드 목록, 설정, 채널 목록)에 최적.
  • 콜백 주입(ForEachUnderLock(Action<Entity>)): 복사 없음. 대신 콜백 안에서 오래 걸리는 일이나 재진입을 하면 전체가 멈춘다. 규약을 문서화하고 코드 리뷰로 강제해야 한다.
  • 더블 버퍼링: 읽기 전용 버퍼와 쓰기 버퍼를 두고 틱 경계에서 교체. 게임 틱 루프와 궁합이 가장 좋다 — 틱 중간에는 아무도 안 바뀌므로 락도 스냅샷도 필요 없다.

5. 문서화 의무와 게임서버 적용

반드시 문서화할 것

  1. 어느 수준인가 — 위 5분류 중 하나를 명시. "thread-safe" 한 단어로 끝내지 말 것.
  2. 어떤 연산이 원자적인가, 그리고 어떤 조합이 원자적이지 않은가(check-then-act 예시 포함).
  3. 호출자가 외부 동기화를 해야 하는 구간과, 그때 무엇을 락해야 하는가(락 객체를 공개하는지 여부).
  4. 열거/뷰/Count 의 일관성 수준 — 스냅샷인지 약한 일관성인지.
  5. 콜백·이벤트가 어떤 락을 잡은 채로 호출되는지 — 데드락과 재진입의 원천. "이벤트 핸들러 안에서 이 객체의 다른 메서드를 부르지 말 것" 같은 규약.
  6. 불변식과 발행 규약 — 생성자에서 this 를 유출하지 않는지, 안전하게 발행되는지.
  7. 성능 특성 — Count, Keys 처럼 겉보기보다 비싼 연산.

게임서버에서 나타나는 실제 버그

  • 중복 세션 생성: 위 (가) 그대로. 재접속·리커넥트 폭주 시 같은 계정에 세션 두 개가 살아 한쪽 소켓으로만 응답이 가고 다른 쪽은 타임아웃된다. 재현이 어렵고 로그상 "유령 접속"으로만 보인다.
  • 잘못된 인벤토리 수량: var cur = inv[itemId]; inv[itemId] = cur + n; — 각 인덱서 접근은 원자적이지만 read-modify-write 가 원자적이 아니다. 두 스레드가 동시에 지급하면 갱신 유실(lost update)로 아이템이 사라지거나, 반대로 소모 처리가 유실되어 아이템 복사(dupe) 익스플로잇이 된다. 재화는 유저가 적극적으로 레이스를 유발한다는 점이 특히 위험하다. → AddOrUpdate 의 원자적 갱신, Interlocked, 또는 재화 변경은 전용 원장(ledger) 서비스에서 단일 직렬화.
  • 존 이동 중 유령 엔티티: oldZone.Remove(e); newZone.Add(e); 두 연산 사이에 다른 스레드가 순회하면 엔티티가 어느 존에도 없거나(패킷 유실, 피격 무효), 순서를 바꾸면 양쪽에 다 존재(중복 스폰, 이중 데미지)한다. 각 컬렉션이 아무리 concurrent 여도 "이동"이라는 하나의 논리적 트랜잭션을 표현하지 못한다. → 이동을 단일 존 매니저의 원자적 연산으로 만들거나, 틱 경계에서만 처리하는 이동 큐로 미룬다.
  • 약한 일관성 열거로 인한 이중 처리: 매칭 큐를 foreach 로 돌며 매칭하는데 그 사이 추가된 항목이 보였다 안 보였다 하면서 같은 플레이어가 두 방에 배정된다.

근본적으로 더 나은 설계 방향

  1. 공유를 줄여라 — 최고의 동시성 대책은 동기화가 아니라 공유하지 않는 것이다. 존/방/세션 단위로 상태를 분할하고 각 단위는 한 스레드가 소유한다(액터, SingleThreadedSynchronizationContext, per-zone job queue).
  2. 불변 데이터를 기본값으로 — 정적 게임 데이터, 설정, 스냅샷은 불변. 변경은 새 객체 + 원자적 교체.
  3. 트랜잭션 경계를 타입으로 표현 — "존 이동", "재화 이체" 같은 논리적 원자 단위를 하나의 메서드로 만들고, 그 안에서만 락을 잡는다. 호출자가 조각을 조립하게 두지 않는다.
  4. 틱 경계 활용 — 실시간 게임서버의 강점. 틱 중에는 읽기만, 변경은 큐에 모아 틱 경계에서 일괄 적용하면 동시성 문제의 상당 부분이 설계적으로 소멸한다.
  5. 동시성 컬렉션은 "마지막 수단"으로 — 정말로 여러 스레드가 같은 자료구조를 두드려야 할 때만 쓰고, 쓰더라도 복합 연산은 반드시 원자적 API 로 표현한다.

한 줄 요약: 동시성 컬렉션은 "각 연산의 안전"을 팔 뿐 "내 로직의 안전"을 팔지 않는다. 안전의 단위는 연산이 아니라 불변식(invariant) 이고, 불변식을 지키는 범위를 정하는 것은 언제나 설계자의 몫이다.