49. 장비 내구도 소모·파괴 처리의 원자성 (C#)
난이도 중해설 — 장비 내구도 소모·파괴 처리의 원자성 (C#)
난이도: 중상
요약
GetEquipped 는 락으로 보호되지만, 정작 보호가 필요한 읽기-수정-쓰기(내구도 차감) 와
검사-후-행동(파괴 1회 보장) 은 락 밖에서 수행된다. 게다가 파괴 처리 중간에 await 가
있어 임계구역이 되어야 할 구간이 중단점으로 쪼개진다. 결과적으로 내구도가 덜 깎이거나,
파편 보상이 중복 지급된다.
문제점
1) 내구도 차감의 갱신 유실 — (A) [동시성/lost update]
- 증상: 두 워커가 각각 10씩 소모시켰는데 내구도가 10만 줄어든다. 장비가 "닳지 않는" 버그.
- 재현 조건: 워커 T1, T2 가 같은
item에 대해 거의 동시에 진입. 둘 다durability = 100을 읽고, 각자90을 계산해 쓴다. 마지막 쓰기가 앞선 쓰기를 덮는다. - 근본 원인:
_lock이 딕셔너리 조회만 보호하고, 반환된EquipmentItem의 필드 접근은 전혀 보호하지 않는다. 락의 범위가 보호 대상(가변 상태)이 아니라 컨테이너에만 걸려 있다.
2) 파괴 처리의 중복 실행 — (A)+(B) [동시성/check-then-act, 멱등성]
- 증상: 파편 보상이 2회 이상 지급되고, 파괴 알림이 중복 전송된다. 재화/아이템 증식.
- 재현 조건: 내구도 5인 장비에 T1, T2 가 각각 5를 소모. 둘 다
Destroyed == false를 통과 하고 둘 다durability <= 0판정에 도달 →AddShards가 두 번 호출된다. - 근본 원인:
Destroyed검사와 설정 사이가 원자적이지 않다(check-then-act). 파괴 플래그가 경쟁의 승자를 결정하는 유일한 관문인데, 그 관문 자체가 경쟁에 노출돼 있다.
3) await 로 인한 임계구역 분할 — (B) [비동기/원자성 경계]
- 증상:
PersistAsync대기 중 다른 요청이 같은 캐릭터의 장비 상태를 관측/변경해, DB 에는 파괴가 기록됐는데 인벤토리 제거가 아직 안 된 중간 상태가 노출된다. 그 사이 장비 해제/교체가 들어오면 이미 파괴된 아이템을 다시 다루게 된다. - 재현 조건:
PersistAsync지연(수 ms) 동안 같은 캐릭터의 장비 해제 요청 처리. - 근본 원인: 상태 전이와 부수효과(제거·지급·알림)가 하나의 원자 단위로 묶이지 않았고,
I/O 가 그 사이에 놓였다. 또한
lock은await를 가로질러 유지할 수 없다는 제약을 고려한 설계가 없다.
4) 착용 슬롯에서 제거되지 않음 — (B) [상태 정합성]
- 증상:
_inventory.Remove로 인벤토리에서만 지우고_equipped딕셔너리는 그대로 둔다. 이후GetEquipped는 파괴된 아이템을 계속 반환한다.Destroyed검사 덕분에 재파괴는 막히지만, 스탯 계산·툴팁·저장 로직은 존재하지 않는 장비를 착용 중인 것으로 본다. - 재현 조건: 파괴 직후 캐릭터 스탯을 다시 계산하는 모든 경로.
- 근본 원인: "파괴"라는 하나의 논리적 전이가 여러 자료구조에 흩어져 있는데, 그중 하나를 빠뜨렸다. 전이를 한 함수에 모으지 않으면 이런 누락은 반복된다.
수정안
핵심은 (1) 상태 전이를 락 안에서 원자적으로 끝내고, (2) "내가 파괴시킨 승자인가"를 락 안에서 결정한 뒤, (3) I/O·알림 같은 부수효과는 락 밖·전이 확정 후에 승자만 수행하는 것이다.
public sealed class DurabilityService
{
private readonly Dictionary<long, EquipmentItem> _equipped = new();
private readonly InventoryService _inventory = new();
private readonly object _lock = new object();
public async Task ConsumeDurabilityAsync(long ownerId, int slot, int amount)
{
EquipmentItem destroyedItem = null;
// 1) 상태 전이는 락 안에서 원자적으로. await 를 절대 포함하지 않는다.
lock (_lock)
{
if (!_equipped.TryGetValue(Key(ownerId, slot), out var item)) return;
if (item.Destroyed) return;
item.Durability = Math.Max(0, item.Durability - amount);
if (item.Durability > 0) return;
item.Destroyed = true; // 이 스레드가 유일한 '파괴 승자'
_equipped.Remove(Key(ownerId, slot));
destroyedItem = item;
}
// 2) 부수효과는 승자만, 락 밖에서.
await PersistDestroyedAsync(ownerId, destroyedItem); // 멱등 키 포함
_inventory.Remove(ownerId, destroyedItem.ItemId);
_inventory.AddShards(ownerId, destroyedItem.ShardReward);
Notify(ownerId, destroyedItem.ItemId);
}
private static long Key(long ownerId, int slot) => ownerId * 100 + slot;
}
영속화·지급은 서버 재기동/재시도에도 1회여야 하므로 멱등 키를 함께 넘긴다.
INSERT INTO item_destroy_log (item_id, owner_id, shards)
VALUES (@itemId, @ownerId, @shards)
ON CONFLICT (item_id) DO NOTHING; -- 재시도 시 중복 지급 방지
락 보유 시간이 문제라면 아이템 단위 상태 전이만 Interlocked 로 처리할 수도 있다.
// durability 를 int 필드로 두고 CAS 루프
int prev, next;
do
{
prev = Volatile.Read(ref item._durability);
if (prev == 0) return; // 이미 파괴됨
next = Math.Max(0, prev - amount);
} while (Interlocked.CompareExchange(ref item._durability, next, prev) != prev);
if (next == 0) { /* prev > 0 && next == 0 인 스레드만 승자 */ }
더 나은 설계
- 캐릭터 단위 액터/직렬 큐: 한 캐릭터의 모든 상태 변경을 단일 논리 스레드에서 순차 처리하면 이런 경쟁 자체가 사라진다. 트레이드오프 — 캐릭터 간 병렬성은 유지되지만, 큐 지연이 생기고 교차 캐릭터 연산(거래 등)은 별도 조정이 필요하다.
- 상태 전이를 명시적 상태 머신으로:
Intact → Destroying → Destroyed로 두고 전이 함수만이 상태를 바꾸게 하면 "승자 판정"이 코드 구조로 강제된다. 트레이드오프 — 코드량 증가. - 부수효과 아웃박스: 지급·알림을 아웃박스 테이블에 기록하고 릴레이가 at-least-once 로 발송, 소비자는 멱등 처리. 트레이드오프 — 지연 증가, 인프라 복잡도 상승.
면접 포인트
- 락이 "컨테이너"가 아니라 "가변 상태"를 보호해야 하는 이유. 조회만 락으로 감싸고 반환된 객체를 락 밖에서 수정하는 패턴이 왜 흔한 실수인가.
lock은await를 가로질러 유지할 수 없다. 비동기 코드에서 임계구역을 설계하는 방법 (동기 전이 + 비동기 부수효과 분리, 또는SemaphoreSlim.WaitAsync).- "정확히 1회" 부수효과를 인메모리 플래그만으로 보장할 수 없는 이유 — 프로세스 재기동/재시도를 고려하면 저장소 레벨 멱등 키가 최종 방어선이다.
해설 — 장비 내구도 소모·파괴 처리의 원자성 (C++)
난이도: 상
요약
getEquipped 만 뮤텍스로 보호하고, 반환된 원시 포인터를 락 밖에서 읽고 쓴다. C# 버전과 달리
C++ 에서는 갱신 유실·중복 지급에 더해 비원자 접근에 의한 데이터 레이스(UB) 와, 파괴 경로에서
맵의 unique_ptr 을 지운 뒤 그 포인터를 계속 쓰는 use-after-free 까지 겹친다.
문제점
1) 반환된 원시 포인터의 무보호 접근 — (A) [데이터 레이스/UB]
- 증상: 디버그 빌드에서는 재현이 잘 안 되다가 최적화 빌드에서 내구도가 튀거나 파괴가 누락된다.
ThreadSanitizer 를 켜면
durability,destroyed에 대한 레이스가 즉시 잡힌다. - 재현 조건: 두 워커가 같은
EquipmentItem*에 동시에 진입. - 근본 원인:
mutex_는equipped_조회만 보호한다.durability는 비원자int,destroyed는 비원자bool이며 동시 읽기/쓰기가 일어난다. C++ 메모리 모델에서 이는 정의되지 않은 동작이라 "값이 하나 틀리는" 수준이 아니라 컴파일러가 조건 검사를 재배치·제거할 수도 있다.
2) 갱신 유실과 파괴 중복 지급 — (A)+(B) [check-then-act]
- 증상: 파편 보상 중복 지급(아이템 증식), 내구도 소모량 누락.
- 재현 조건: 내구도 5에 두 워커가 각각 5를 소모 → 둘 다
destroyed == false통과. - 근본 원인: 검사와 갱신이 하나의 원자 단위가 아니다. 승자 판정 수단이 없다.
3) 컨테이너에서 지운 뒤의 포인터 사용 — (B) [수명/use-after-free]
- 증상: 간헐적 크래시, 혹은 해제된 메모리에서 읽은 쓰레기
itemId로 알림 전송. - 재현 조건:
eraseEquipped가unique_ptr을 파괴한 직후notify(ownerId, item->itemId)가 같은item을 역참조한다. 단일 스레드에서도 100% 재현되는 결함이다. - 근본 원인: 소유권이
unordered_map의unique_ptr에 있는데 호출 측은 소유권 없는 원시 포인터를 들고 있다. 소유자가 사라지는 시점과 사용 시점의 관계가 코드로 표현되어 있지 않다. 동시 시나리오에서는 다른 스레드가 먼저erase하면 (A) 지점의 접근도 UAF 가 된다.
4) erase 와 부수효과의 순서 — (B) [정합성]
- 인벤토리 제거·파편 지급이 먼저 일어나고
equipped_제거가 나중이라, 그 사이 다른 요청이 이미 파괴된 장비를 여전히 착용 중으로 관측한다.
수정안
소유권을 shared_ptr 로 바꿔 사용 중 수명을 보장하고, 상태 전이는 아이템 자체의 뮤텍스로
원자화하며, 파괴 승자만 부수효과를 수행한다.
struct EquipmentItem {
std::mutex m; // 이 아이템의 가변 상태를 보호
std::int64_t itemId = 0;
int durability = 0;
bool destroyed = false;
int shardReward = 3;
};
class DurabilityService {
public:
void consumeDurability(std::int64_t ownerId, int slot, int amount) {
std::shared_ptr<EquipmentItem> item = getEquipped(ownerId, slot); // 수명 연장
if (!item) return;
bool iDestroyedIt = false;
std::int64_t itemId = 0;
int shards = 0;
{
std::lock_guard<std::mutex> lk(item->m);
if (item->destroyed) return;
item->durability = std::max(0, item->durability - amount);
if (item->durability > 0) return;
item->destroyed = true; // 승자는 단 하나
iDestroyedIt = true;
itemId = item->itemId; // 락 안에서 필요한 값 복사
shards = item->shardReward;
}
if (!iDestroyedIt) return;
eraseEquipped(ownerId, slot); // 맵에서 제거 (shared_ptr 이므로 item 은 아직 유효)
inventory_.remove(ownerId, itemId);
inventory_.addShards(ownerId, shards);
notify(ownerId, itemId); // 해제된 메모리 역참조 없음
}
private:
std::shared_ptr<EquipmentItem> getEquipped(std::int64_t ownerId, int slot) {
std::lock_guard<std::mutex> lk(mutex_);
auto it = equipped_.find(ownerId * 100 + slot);
return it == equipped_.end() ? nullptr : it->second; // 복사 → refcount 증가
}
std::mutex mutex_;
std::unordered_map<std::int64_t, std::shared_ptr<EquipmentItem>> equipped_;
InventoryService inventory_;
};
핵심 포인트 세 가지: (i) 부수효과에 쓸 값(itemId, shards)을 락 안에서 지역 변수로 복사해
락 밖 역참조를 없앤다. (ii) shared_ptr 복사본이 살아 있으므로 다른 스레드가 맵에서 지워도
UAF 가 나지 않는다. (iii) 부수효과는 iDestroyedIt 인 스레드만 수행한다.
락 없이 가고 싶다면 내구도를 std::atomic<int> 로 두고 CAS 루프로 승자를 판정한다.
int prev = dur.load(std::memory_order_relaxed), next;
do {
if (prev == 0) return; // 이미 파괴
next = std::max(0, prev - amount);
} while (!dur.compare_exchange_weak(prev, next,
std::memory_order_acq_rel, std::memory_order_relaxed));
if (next == 0) { /* prev>0 && next==0 인 스레드가 유일한 승자 */ }
더 나은 설계
- 소유권을 타입으로 표현: 조회 API 가 원시 포인터 대신
shared_ptr이나 (index, generation) 핸들을 반환하면 UAF 가 구조적으로 차단된다. 트레이드오프 — refcount 원자 연산 비용, 핸들 방식은 resolve 단계 추가. - 엔티티 단위 커맨드 큐(액터): 캐릭터별 단일 스레드 처리로 락 자체를 제거. 트레이드오프 — 교차 엔티티 연산에 별도 조정 필요, 큐 지연.
- 파괴를 지연 회수(deferred destroy) 로: 틱 경계에서만 실제 파괴/제거를 수행하면 사용 중 파괴가 원천 봉쇄된다. 트레이드오프 — 한 틱 동안 좀비 상태가 관측될 수 있어 조회 측 필터 필요.
면접 포인트
- "컨테이너를 락으로 보호했다"와 "컨테이너가 담은 객체를 락으로 보호했다"의 차이. 원시 포인터를 반환하는 순간 락의 보호 범위가 끝난다는 점.
- 락 안에서 필요한 값을 복사해 나오는 패턴(copy-out)이 락 보유 시간 단축과 UAF 방지를 동시에 달성하는 이유.
- C# 이었다면 GC 덕분에 UAF 는 없고 논리적 중복 지급만 발생한다. 같은 결함이 언어에 따라 "데이터 손상"과 "메모리 안전성 붕괴"로 갈리는 이유를 설명할 수 있는가.