15. 던전 클리어 보상 지급 중 인스턴스 만료/정리 경합 (C#)
난이도 상해설 — 던전 클리어 보상 지급 중 인스턴스 만료/정리 경합 (C#)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
보상 지급(OnBossKilled)과 정리 타이머(SweepIdle)가 같은 인스턴스와 _instances 맵을
락 없이 동시에 다룬다. C++ 처럼 메모리 해제(UAF)는 아니지만(GC 가 객체 자체는 살림),
정리 타이머가 Dispose() 로 DB 핸들 등 자원을 먼저 해제하면 진행 중이던 GrantTo 가
ObjectDisposedException 으로 터져 일부 멤버만 보상받고 나머지는 유실된다. (B)의
foreach (inst.Members) 도중 다른 스레드가 멤버를 변경하면 InvalidOperationException.
(C)의 SweepIdle 은 순회 중 _instances.Remove 라 같은 예외. Dictionary 자체의
동시 읽기/쓰기로 내부 손상, RewardGranted 비원자라 재시도 시 중복 지급. 정답 한 줄:
인스턴스 상태 머신(Active/Clearing/Done)으로 정리 자격을 판정하고, 지급중이면 Dispose
금지, 맵/멤버 접근을 락으로 보호하며, 지급을 멱등 게이트로 한 번만 수행한다.
문제점
(B)+(C) 지급 중 Dispose / 순회 중 Remove — 자원 사용·컬렉션 예외 (동시성/수명) ★간판
- 증상: T1 이 멤버를 순회하며 느린
GrantTo를 도는 동안 T2(SweepIdle)가 같은 인스턴스를Dispose()→ 다음GrantTo에서ObjectDisposedException. 그 결과 뒤쪽 멤버는 보상을 못 받고,RewardGranted=true에 도달 못 해 유실. 또SweepIdle의foreach (_instances) { ... _instances.Remove(...); }는 순회 중 수정이라InvalidOperationException. - 재현 조건: 보스 처치 직후 마지막 멤버 퇴장/유휴 타임아웃으로 지급과 정리가 겹침. DB I/O 가 길수록 창이 커진다.
- 근본 원인: 인스턴스 수명/정리 자격을 판정하는 상태 머신이 없고, 공유 맵·멤버에 임계 구역이 없다. "지급중"을 모르는 정리가 자원을 먼저 회수한다.
_instances / Members Dictionary·List 동시 접근 — 자료구조 손상 (동시성)
- 증상: T1
_instances[instanceId]와 T2Remove/순회가 락 없이 겹치면 내부 상태 손상·InvalidOperationException. 존재하지 않는 id 면_instances[id]가KeyNotFoundException으로 처리 스레드가 죽는다. - 근본 원인: 비동시성 컬렉션 + 임계 구역 부재.
TryGetValue+ 락 필요.
RewardGranted 멱등성 없음 — 중복/유실 지급 (정확성)
- 증상:
Cleared/RewardGranted비원자 플래그라 보스 처치 패킷 중복/재시도 시 두 번 지급. 지급 도중 예외로 중단되면 부분 지급 후 유실. - 근본 원인: "정확히 한 번" 의 단일 진실 소스(상태 전이 + 영속 멱등키)가 없다.
정리 자격이 'Members.Count==0' 뿐 — 진행 중 무시 (설계)
- 증상: 보상 지급 진행 중이어도 멤버가 비면 회수 대상.
- 근본 원인: 생명주기 상태 머신 부재.
수정안
핵심: ① 인스턴스 상태 머신 + 멱등 게이트(Active→Clearing→Done), ② 정리는 Clearing
이 아닐 때만, ③ 맵/멤버 접근 락 보호 + 스냅샷 순회.
public enum InstState { Active, Clearing, Done, Recyclable }
public class DungeonInstance : IDisposable
{
public long Id;
public int State = (int)InstState.Active; // Interlocked 용 int 백킹
public bool RewardGranted;
public readonly object Sync = new object();
public List<long> Members = new();
public Reward Reward;
public long LastActiveTick;
private bool _disposed;
public void GrantTo(long pid) { if (_disposed) throw new ObjectDisposedException("inst"); /* DB */ }
public void Dispose() { _disposed = true; }
}
public void OnBossKilled(long instanceId)
{
DungeonInstance inst;
lock (_mapSync)
{
if (!_instances.TryGetValue(instanceId, out inst)) return;
}
// 멱등 게이트: Active→Clearing 한 번만
if (Interlocked.CompareExchange(ref inst.State,
(int)InstState.Clearing, (int)InstState.Active) != (int)InstState.Active)
return; // 이미 처리 중/완료
long[] snapshot;
lock (inst.Sync) { snapshot = inst.Members.ToArray(); } // 스냅샷 순회
foreach (long pid in snapshot)
inst.GrantTo(pid); // Clearing 이라 Sweep 이 Dispose 안 함
inst.RewardGranted = true;
Interlocked.Exchange(ref inst.State, (int)InstState.Recyclable);
}
public void SweepIdle(long now)
{
lock (_mapSync)
{
var dead = new List<long>();
foreach (var kv in _instances)
{
var inst = kv.Value;
bool empty; lock (inst.Sync) empty = inst.Members.Count == 0;
bool busy = Volatile.Read(ref inst.State) == (int)InstState.Clearing;
if (empty && !busy && IdleExpired(inst, now)) dead.Add(kv.Key);
}
foreach (var id in dead) // 순회 끝난 뒤 일괄 제거
{
_instances[id].Dispose();
_instances.Remove(id);
}
}
}
핵심: 정리는
Clearing상태를 절대 건드리지 않는다. 지급이 끝나Recyclable이 된 뒤에만 회수하므로ObjectDisposedException·유실이 사라진다. 순회/제거 분리로 컬렉션 예외도 제거.
더 나은 설계
1) 생명주기 상태 머신 명문화
Active→Clearing→Done→Recyclable. 회수는Recyclable에서만. 모든 정리 판정을 이 상태로 일원화.
2) 보상 멱등성은 영속 계층까지
- 메모리 플래그론 서버 재기동/분산에서 중복 위험.
(instanceId)지급 로그 유니크 제약 /멱등키로 DB exactly-once. 트레이드오프: DB 왕복 vs 중복 차단.
3) 보상 지급을 인스턴스 수명과 분리
- 클리어 시 "보상 청구권"을 영속화하면 인스턴스가 사라져도 우편 등으로 수령 가능. I/O 지연이 인스턴스 수명을 붙잡지 않는다.
4) 단일 액터 / ConcurrentDictionary
- 매니저를 단일 스레드 액터로 두거나
_instances를ConcurrentDictionary로. 단, "지급중 회수 금지"는 여전히 상태 머신으로 보장해야 한다(컬렉션 동시성만으론 부족).
면접 포인트
- 면접관이 듣고 싶은 핵심: 느린 비동기 작업과 정리(Dispose)의 수명 경합을 상태 머신으로 어떻게 푸나 + 멱등 지급 + 순회/제거 분리.
- 예상 질문:
- "C# 은 GC 라 UAF 없는데 뭐가 문제?" → 객체는 살아도
Dispose로 자원이 먼저 풀려ObjectDisposedException·부분 지급. 수명≠자원수명. - "락으로 인스턴스 통째 잡고 지급하면?" → DB I/O 동안 그 인스턴스 전체가 멈춘다. 락은 스냅샷/플래그만, 진행 보호는 상태 머신.
- "재기동 중복 지급은?" → 영속 멱등키/유니크 제약.
- "C# 은 GC 라 UAF 없는데 뭐가 문제?" → 객체는 살아도
변별 메모: concurrency14(정원 초과 동시 입장)는 입장 시점 상한 check-then-act, 본 문제는 인스턴스 수명 종료(정리)와 진행 중 지급의 경합·멱등성이 축. C++ 트윈은
delete로 인한 UAF, C# 은 GC 로 객체는 살되 Dispose 된 자원 사용/유실 이라는 언어차가 학습 포인트.
해설 — 던전 클리어 보상 지급 중 인스턴스 만료/정리 경합 (C++)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
보상 지급(OnBossKilled)과 정리 타이머(SweepIdle)가 같은 인스턴스를 원시 포인터로
공유하면서 락 없이 동시에 접근한다. 보상 지급은 DB I/O 로 느린데, 그 사이 마지막 멤버가
나가 members.empty() 가 되면 SweepIdle 이 delete inst 로 객체를 파괴한다. 그러면
진행 중이던 for (... members) inst->GrantTo(...) 가 해제된 메모리를 순회·역참조하는
Use-After-Free 가 된다(크래시/메모리 손상/보상 일부만 지급). 더해 instances_ 맵 자체를
두 스레드가 동시에 읽고/erase 해 컨테이너가 손상되고, rewardGranted 플래그는 멱등성
보호가 없어 재시도 시 중복 지급이 가능하다. 정답 한 줄: 인스턴스 수명을 shared_ptr 로
공유해 진행 중이면 회수되지 않게 하고, 상태 머신(클리어/지급중/회수가능)으로 정리 자격을
판정하며, 맵 접근을 락으로 보호한다.
문제점
(B)+(C) 지급 중 인스턴스 delete — Use-After-Free (메모리/동시성) ★간판
- 증상: T1 이
inst->members를 순회하며 느린GrantTo를 도는 동안, T2(SweepIdle) 가 같은inst를members.empty()로 보고delete inst. 이후 T1 의 다음 반복에서 해제된inst/members를 접근 → UAF. C++ 은 검출하지 않아 조용히 손상되거나 크래시. - 재현 조건: 보스 처치 직후 마지막 멤버가 즉시 퇴장(또는 유휴 타임아웃)해서 보상 지급과 정리가 겹침. DB I/O 가 길수록 창이 커진다.
- 근본 원인: 원시 포인터로 수명을 공유. 진행 중인 작업이 객체 생존을 보장하지 못한다.
소유권을
shared_ptr로 공유해 "참조가 살아있는 한 회수 금지"여야 한다.
instances_ 맵 동시 접근 — 컨테이너 손상 (동시성)
- 증상: T1
instances_[instanceId]조회와 T2 의erase/순회가 락 없이 겹치면unordered_map내부가 손상(UB). 또 존재하지 않는 id 면operator[]가 nullptr 를 묵시 삽입해 다음inst->cleared가 널 역참조. - 근본 원인: 공유 맵에 임계 구역 부재 +
operator[]의 삽입 부작용.find+ 락 사용.
rewardGranted 멱등성 없음 — 중복/유실 지급 (정확성)
- 증상:
cleared/rewardGranted가 비원자 플래그라, 보스 처치 패킷이 두 번 오거나 재시도되면 두 번 지급. 지급 도중 회수되면rewardGranted=true에 도달 못 해 멤버 일부만 받고 유실. - 근본 원인: "정확히 한 번" 보장의 단일 진실 소스(상태 전이)가 임계 구역·영속 계층에 없다.
정리 자격 판정이 'members.empty()' 뿐 — 진행 중 무시 (설계)
- 증상: 보상 지급이 진행 중이어도 멤버가 비면 회수 대상이 된다. "지급중" 상태를 고려하지 않는다.
- 근본 원인: 인스턴스 생명주기 상태 머신 부재. 회수는 "지급 완료 + 참조 0" 일 때만.
수정안
핵심: ① shared_ptr<DungeonInstance> 로 수명 공유(진행 중이면 회수 안 됨), ② 인스턴스
상태 머신으로 회수 자격 판정, ③ 맵은 락으로 보호하고 find 사용, ④ 지급 멱등 게이트.
#include <memory>
#include <mutex>
#include <atomic>
class DungeonInstance {
public:
enum class State { Active, Clearing, Done };
int64_t id;
std::atomic<State> state{State::Active};
std::atomic<bool> rewardGranted{false};
std::mutex mtx;
std::vector<int64_t> members;
Reward reward;
Clock_t lastActiveTick; // 유휴 판정용 단조 시계
};
class DungeonManager {
std::mutex mapMtx_;
std::unordered_map<int64_t, std::shared_ptr<DungeonInstance>> instances_;
public:
void OnBossKilled(int64_t instanceId) {
std::shared_ptr<DungeonInstance> inst;
{
std::lock_guard<std::mutex> lk(mapMtx_);
auto it = instances_.find(instanceId);
if (it == instances_.end()) return;
inst = it->second; // refcount++ → 회수돼도 객체 생존
}
// 멱등 게이트: Active→Clearing 으로 한 번만 진입
auto expected = DungeonInstance::State::Active;
if (!inst->state.compare_exchange_strong(expected, DungeonInstance::State::Clearing))
return; // 이미 처리 중/완료
std::vector<int64_t> snapshot;
{
std::lock_guard<std::mutex> lk(inst->mtx);
snapshot = inst->members; // 지급 대상 스냅샷
}
for (int64_t pid : snapshot)
inst->GrantTo(pid); // shared_ptr 가 살아있어 UAF 없음
inst->rewardGranted.store(true);
inst->state.store(DungeonInstance::State::Done);
}
void SweepIdle(Clock_t now) {
std::lock_guard<std::mutex> lk(mapMtx_);
for (auto it = instances_.begin(); it != instances_.end(); ) {
auto& inst = it->second;
bool empty;
{ std::lock_guard<std::mutex> l2(inst->mtx); empty = inst->members.empty(); }
// 회수 자격: 지급이 진행 중이 아니고(Clearing 아님), 유휴 경과
bool busy = inst->state.load() == DungeonInstance::State::Clearing;
if (empty && !busy && IdleExpired(inst, now))
it = instances_.erase(it); // shared_ptr 소멸 → 마지막 참조일 때만 실제 해제
else
++it;
}
}
};
두 안전장치가 함께 작동한다. ①
shared_ptr로 진행 중 작업이 참조를 쥐고 있으면erase해도 객체는 살아있다(refcount). ② 상태 머신이Clearing인 인스턴스는 애초에 회수 대상에서 제외해 "맵에서 빠졌지만 지급은 계속" 도 안전.
더 나은 설계
1) 수명: shared_ptr 소유 + weak_ptr 관찰
- 맵은
shared_ptr로 소유, 외부 핸들은weak_ptr. 작업 시작 시lock()으로 승격해 생존을 보장. 회수는 자연스럽게 "마지막 참조 소멸"에 위임.
2) 보상 멱등성은 영속 계층까지
- 메모리 플래그만으로는 서버 재기동/분산에서 중복 위험.
(instanceId)보상 지급 로그에 유니크 제약 또는 멱등키로 DB 수준 exactly-once. 트레이드오프: DB 왕복 vs 중복 차단.
3) 인스턴스 생명주기 상태 머신 명문화
Active→Clearing→Done→Recyclable. 회수는Recyclable+ refcount 0 에서만. 보상 지급 완료 이벤트가Recyclable로 전이시킨다.
4) 보상 지급을 인스턴스 수명과 분리
- 클리어 시점에 "보상 청구권(claim)"을 영속화하면, 인스턴스가 사라져도 플레이어가 나중에 수령 가능(우편 폴백). I/O 지연이 인스턴스 수명을 붙잡지 않는다.
면접 포인트
- 면접관이 듣고 싶은 핵심: 느린 작업과 GC/정리의 수명 경합을 어떻게 푸나 —
shared_ptr/weak_ptr소유권 + 상태 머신으로 회수 자격 판정 + 멱등 지급. - 예상 질문:
- "락으로 인스턴스를 통째로 잡고 지급하면?" → DB I/O 동안 락을 쥐면 그 인스턴스의
모든 처리가 멈춘다. 수명은
shared_ptr, 락은 짧게(스냅샷·플래그). - "맵에서 erase 했는데 작업이 살아있어도 되나?" →
shared_ptrrefcount 덕에 객체는 마지막 참조까지 생존. erase 는 맵 엔트리만 제거. - "재기동 시 중복 지급은?" → 메모리 플래그론 부족. 영속 멱등키/유니크 제약.
- "락으로 인스턴스를 통째로 잡고 지급하면?" → DB I/O 동안 락을 쥐면 그 인스턴스의
모든 처리가 멈춘다. 수명은
변별 메모: concurrency14(인스턴스 정원 초과 동시 입장)는 생성·입장 시점의 상한 check-then-act 가 축이고, 본 문제는 인스턴스 수명 종료(정리)와 진행 중 작업의 UAF· 멱등 지급 이 축이다. session4(비동기 소켓 종료 UAF)와 UAF 본질은 닮았으나, 본 문제는 소켓이 아니라 게임 오브젝트(인스턴스) 수명 + 보상 정확성 이라는 도메인 정합성이 핵심.