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(이미 있음) |
문제는 두 가지다.
CreateSession이 두 번 실행된다. 부수효과가 있으면(소켓 오픈, DB row insert, 재화 지급) 그 자체가 버그다.- 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);
}
이 전제가 성립하면 내부 연산과 외부 복합 연산이 같은 상호배제 영역에 들어가 정확해진다.
조용히 실패하는 경우
- 컬렉션이 다른 락 객체를 쓴다.
ConcurrentDictionary는 내부적으로 락 배열(스트라이프 락) 을 쓰고 자기 자신을 락으로 쓰지 않는다.lock (concurrentDict)는 다른 클라이언트 코드에 대해서만 상호배제를 줄 뿐, 내부 연산을 하는 다른 스레드는 전혀 막지 못한다. 컴파일도 되고 예외도 없이 틀린 코드가 된다. - 락프리 구현: 내부가 CAS 기반이면 잡을 락 자체가 없다.
- 래핑/위임(delegation):
SynchronizedList같은 래퍼는 래퍼 자신을 락하는데, 호출자가 래핑된 내부 리스트를 락하면 서로 다른 객체를 잡는다. 반대도 마찬가지. - 구현 변경에 취약: 라이브러리가 내부 동기화 전략을 바꾸면 (공개 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. 문서화 의무와 게임서버 적용
반드시 문서화할 것
- 어느 수준인가 — 위 5분류 중 하나를 명시. "thread-safe" 한 단어로 끝내지 말 것.
- 어떤 연산이 원자적인가, 그리고 어떤 조합이 원자적이지 않은가(check-then-act 예시 포함).
- 호출자가 외부 동기화를 해야 하는 구간과, 그때 무엇을 락해야 하는가(락 객체를 공개하는지 여부).
- 열거/뷰/
Count의 일관성 수준 — 스냅샷인지 약한 일관성인지. - 콜백·이벤트가 어떤 락을 잡은 채로 호출되는지 — 데드락과 재진입의 원천. "이벤트 핸들러 안에서 이 객체의 다른 메서드를 부르지 말 것" 같은 규약.
- 불변식과 발행 규약 — 생성자에서
this를 유출하지 않는지, 안전하게 발행되는지. - 성능 특성 —
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로 돌며 매칭하는데 그 사이 추가된 항목이 보였다 안 보였다 하면서 같은 플레이어가 두 방에 배정된다.
근본적으로 더 나은 설계 방향
- 공유를 줄여라 — 최고의 동시성 대책은 동기화가 아니라 공유하지 않는 것이다. 존/방/세션 단위로 상태를 분할하고 각 단위는 한 스레드가 소유한다(액터,
SingleThreadedSynchronizationContext, per-zone job queue). - 불변 데이터를 기본값으로 — 정적 게임 데이터, 설정, 스냅샷은 불변. 변경은 새 객체 + 원자적 교체.
- 트랜잭션 경계를 타입으로 표현 — "존 이동", "재화 이체" 같은 논리적 원자 단위를 하나의 메서드로 만들고, 그 안에서만 락을 잡는다. 호출자가 조각을 조립하게 두지 않는다.
- 틱 경계 활용 — 실시간 게임서버의 강점. 틱 중에는 읽기만, 변경은 큐에 모아 틱 경계에서 일괄 적용하면 동시성 문제의 상당 부분이 설계적으로 소멸한다.
- 동시성 컬렉션은 "마지막 수단"으로 — 정말로 여러 스레드가 같은 자료구조를 두드려야 할 때만 쓰고, 쓰더라도 복합 연산은 반드시 원자적 API 로 표현한다.
한 줄 요약: 동시성 컬렉션은 "각 연산의 안전"을 팔 뿐 "내 로직의 안전"을 팔지 않는다. 안전의 단위는 연산이 아니라 불변식(invariant) 이고, 불변식을 지키는 범위를 정하는 것은 언제나 설계자의 몫이다.