14. 던전 인스턴스 정원 초과 (동시 입장 경합) — C#
난이도 중해설 — 던전 인스턴스 정원 초과 (동시 입장 경합) — C#
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
Interlocked/Volatile 를 썼다고 스레드 세이프한 게 아니다. 핵심 결함은 "정원 검사 → 중복
검사 → 입장 반영"이 하나의 원자 단위가 아니라 분리된 연산들의 나열이라는 점이다. 정원이
5인데 여러 스레드가 동시에 (A) _count >= 5 를 모두 거짓으로 보면 정원을 초과해 입장한다
(check-then-act 레이스). 또한 List<long> 은 스레드 세이프하지 않아 동시 Add/Contains
가 내부 배열을 손상시키거나 InvalidOperationException/요소 유실을 유발한다. 정답 한 줄:
검사-반영 전체를 하나의 락으로 직렬화하고, 카운트는 컬렉션 크기로 일원화한다.
문제점
(A)+(C) 검사-반영 비원자 — 정원 초과 레이스 (동시성) ★간판
- 증상: 정원 5짜리 인스턴스에 6명 이상 입장.
- 재현 조건:
_count == 4일 때 두 스레드가 동시에 (A)4 >= 5거짓 → 둘 다 통과 → 둘 다 (C)Interlocked.Increment→_count == 6. - 근본 원인:
Volatile.Read검사와Interlocked.Increment증가가 분리된 두 연산.Interlocked는 단일 연산만 원자적이지 "검사 후 조건부 증가"를 묶지 못한다. 필요한 것은 CAS(Interlocked.CompareExchange) 또는 락.
(B)+(C) List<long> 동시 접근 — 자료구조 손상 (동시성/메모리) ★언어 변별
- 증상: 드물게 크래시/
IndexOutOfRange/InvalidOperationException/요소 유실·중복. - 재현 조건: 한 스레드가 (B)
Contains로 순회하는 동안 다른 스레드가 (C)Add→ 내부 배열 재할당/버전 변경과 겹침. 두 스레드 동시Add도 내부 인덱스 경합. - 근본 원인:
List<T>는 다중 쓰기에 안전하지 않다._count만 atomic 으로 보호해도 컨테이너 본체는 무방비.
(A)+(B) 중복 입장 레이스 (동시성)
- 증상: 같은 플레이어가 두 번 멤버가 됨.
- 재현 조건: 더블클릭으로 같은
playerId두 요청이 동시에 (B)Contains==false통과 → 둘 다Add. - 근본 원인: 중복 검사와 삽입이 같은 임계 구역에 없음.
동기화 도메인 분리 — 개념 오류
_count(Interlocked) 와_members(비보호 List) 가 서로 다른 동기화 도메인이라 둘이 절대 일관될 수 없다. 한 자원의 여러 필드는 같은 락으로 묶어야 한다.
수정안
핵심: ① 단일 lock 으로 검사~중복검사~반영을 하나의 임계 구역으로, ② 카운트는
_members.Count 로 일원화(별도 필드 제거 → 불일치 차단), ③ 멤버십 조회가 잦으면
HashSet<long> 사용.
public class DungeonInstance
{
private readonly int _capacity;
private readonly object _gate = new object();
private readonly HashSet<long> _members = new HashSet<long>();
public DungeonInstance(int capacity) { _capacity = capacity; }
public bool TryEnter(long playerId)
{
lock (_gate)
{
if (_members.Count >= _capacity) return false; // 정원
return _members.Add(playerId); // 중복(원자): 신규면 true
}
}
public int Count { get { lock (_gate) return _members.Count; } }
}
HashSet.Add가 (중복 검사 + 삽입)을 한 번에 처리하고 신규 여부를 반환해 중복 입장 레이스까지 닫는다. 카운트를Count로 일원화해 두 도메인 불일치를 제거한 것이 포인트.
동시성 컬렉션을 쓴다면 ConcurrentDictionary<long, byte> + 원자 카운터로도 가능하지만,
정원이라는 상한 제약은 결국 임계 구역이 가장 단순·정확하다.
더 나은 설계
1) 입장은 "자리 예약" 모델
- 매칭 단계에서 서버가 권위적으로 좌석을 사전 배정하고, 접속은 예약 토큰만 검증. 입장 폭주 자체가 사라진다. 트레이드오프: 예약 만료/노쇼 처리 필요.
2) 단일 소유 스레드(액터)
- 인스턴스의 모든 입·퇴장을 소유 스레드가 직렬 처리 → 락 제거. 던전 서버 정석 패턴.
3) 분산 정원
- 인스턴스를 여러 노드가 참조하면 정원은 분산 카운터/락 또는 단일 소유 노드 라우팅으로 보호. 단순 Interlocked 로는 불가.
4) 거부 사유 / 관측성
- 정원 초과·중복 입장 거부를
S_EnterRejected(reason)+ 메트릭으로 노출.
면접 포인트
- 핵심: Interlocked ≠ 임계 구역. check-then-act 는 CAS 또는 락. 한 자원의 여러 필드는 같은 동기화 도메인.
- 예상 질문:
- "Interlocked.Increment 를 썼는데 왜 정원 초과?" → 검사와 증가가 분리된 두 연산. 조건부 증가가 아님. CAS/락 필요.
- "List 를 동시에 Add 하면?" → 내부 배열 손상/예외/유실.
lock또는 동시성 컬렉션. - "count 와 members 를 따로 두면?" → 영구 불일치. Count 로 일원화.
해설 — 던전 인스턴스 정원 초과 (동시 입장 경합) — C++
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
std::atomic 을 썼다고 해서 스레드 세이프한 것이 아니다. 이 코드의 핵심 결함은 "정원 검사 →
중복 검사 → 입장 반영"이 하나의 원자 연산이 아니라 여러 독립 연산의 나열이라는 점이다.
정원이 5인데 6개 스레드가 동시에 (A)를 통과해 모두 count_ < 5 를 보면 정원을 초과해
입장한다(check-then-act 레이스). 또한 members_(std::vector)는 atomic 이 아니라서 동시
push_back 은 재할당 중 데이터 레이스 UB이고, std::find 순회 중 다른 스레드가
push_back 하면 이터레이터 무효화 UB 다. 정답 한 줄: 검사-반영 전체를 하나의 락(또는
CAS 루프)으로 직렬화하고, 멤버십은 동시성 안전한 집합으로 관리한다.
문제점
(A)+(C) 검사-반영 비원자 — 정원 초과 레이스 (동시성) ★간판
- 증상: 정원 5짜리 인스턴스에 6명 이상이 입장한다. 보스 방 인원/보상 분배/물리 시뮬레이션 가정이 깨진다.
- 재현 조건:
count_ == 4일 때 두 스레드가 동시에 (A)4 >= 5거짓 → 둘 다 통과 → 둘 다 (C)fetch_add→count_ == 6. atomic 은 "읽고-검사하고-증가"를 묶어주지 않는다. - 근본 원인:
load()검사와fetch_add()증가가 분리된 별개 원자 연산이다. 필요한 것은 "현재값이 capacity 미만일 때만 증가"라는 조건부 원자 갱신(CAS) 또는 락.
(B)+(C) 멤버 목록 동시 접근 — 데이터 레이스 / 이터레이터 무효화 UB (메모리) ★C++ 변별
- 증상: 드물고 비결정적인 크래시·중복 멤버·메모리 손상.
- 재현 조건: 한 스레드가 (B)
std::find로members_를 순회하는 동안 다른 스레드가 (C)push_back→ 재할당 시 순회 중 포인터가 무효화(UB). 두 스레드 동시push_back도 재할당 경합으로 UB. - 근본 원인:
std::vector는 동시 변경에 안전하지 않다.atomic<int>로 카운트만 보호해봤자 컨테이너 본체는 무방비.
(A)+(B) 중복 입장 레이스 (동시성)
- 증상: 같은 플레이어가 두 번 멤버가 된다.
- 재현 조건: 더블클릭으로 같은
playerId두 요청이 동시에 (B)를 통과(아직 누구도 push 안 함) → 둘 다 push. - 근본 원인: 중복 검사와 삽입이 같은 임계 구역에 있지 않다.
atomic 의 오용 — 개념 오류
- 카운트와 멤버 목록이 서로 다른 동기화 도메인에 있어, 둘이 절대 일관될 수 없다 (count 는 atomic, vector 는 비보호). 한 자원의 여러 필드는 같은 락으로 묶어야 한다.
수정안
핵심: ① 단일 std::mutex 로 검사~중복검사~반영을 하나의 임계 구역으로, ② 카운트는
members_.size() 로 일원화(별도 atomic 제거 → 불일치 원천 차단), ③ 멤버십 조회가 잦으면
unordered_set 사용.
#include <mutex>
#include <unordered_set>
class DungeonInstance {
public:
explicit DungeonInstance(int capacity) : capacity_(capacity) {}
bool TryEnter(int64_t playerId) {
std::lock_guard<std::mutex> lk(mtx_);
if (static_cast<int>(members_.size()) >= capacity_) return false; // 정원
if (!members_.insert(playerId).second) return false; // 중복(원자)
return true;
}
int Count() { std::lock_guard<std::mutex> lk(mtx_); return (int)members_.size(); }
private:
const int capacity_;
std::mutex mtx_;
std::unordered_set<int64_t> members_;
};
unordered_set::insert는 (중복 검사 + 삽입)을 원자적으로 수행하고.second로 신규 여부를 알려줘 중복 입장 레이스도 한 번에 닫힌다. 카운트를 size 로 일원화해 "두 도메인 불일치"를 제거한 것이 포인트.
락프리를 고집한다면 카운트만은 CAS 루프로:
int cur = count_.load(std::memory_order_relaxed);
do { if (cur >= capacity_) return false; }
while (!count_.compare_exchange_weak(cur, cur + 1)); // 자리 선점 성공
// 자리를 잡은 뒤 멤버 목록은 여전히 락으로 보호해야 함(또는 lock-free set)
다만 멤버 목록까지 락프리로 만드는 복잡도 대비 이득이 적어, 인스턴스 입장 경로는 락이 정석.
더 나은 설계
1) 입장은 "자리 예약" 모델
- 매칭 단계에서 인스턴스에 좌석을 사전 예약(서버가 권위적으로 5자리 배정)하고, 실제 접속은 예약 토큰을 검증만 한다. 입장 폭주 자체가 사라진다. 트레이드오프: 예약 만료/ 노쇼 처리 로직 필요.
2) 단일 소유 스레드(액터)
- 한 인스턴스의 모든 입·퇴장을 그 인스턴스를 소유한 단일 스레드가 직렬 처리하면 락이 사라짐. MMO 던전 서버에서 흔한 구조.
3) 분산(여러 노드가 같은 인스턴스 참조)
- 인스턴스가 한 노드에 고정되지 않으면, 정원은 분산 카운터/락으로 보호해야 한다(원자
INCR+ 한도, 또는 단일 소유 노드로 라우팅). 단순 atomic 으로는 불가.
4) 관측성
- 정원 초과/중복 입장 거부를 메트릭으로 노출해 매칭-입장 경로의 레이스/노쇼를 모니터링.
면접 포인트
- 핵심: atomic ≠ 임계 구역. "조건부 갱신(check-then-act)"은 CAS 또는 락이 필요하다. 그리고 한 자원의 여러 필드(카운트+목록)는 같은 동기화 도메인에 둬야 한다.
- 예상 질문:
- "atomic 카운터를 썼는데 왜 정원을 넘나?" → load 검사와 fetch_add 가 분리된 두 연산. 조건부 증가가 아니다. CAS 또는 락 필요.
- "vector 를 동시에 push_back 하면?" → 재할당 중 데이터 레이스/이터레이터 무효화 UB.
- "카운트와 목록을 따로 보호하면 무슨 문제?" → 둘이 절대 일관되지 않음. size 로 일원화.