42. 소비 아이템 공유 쿨다운 그룹의 동시 사용 우회 (C#)
난이도 중해설 — 소비 아이템 공유 쿨다운 그룹의 동시 사용 우회 (C#)
난이도: 중
요약
쿨다운 검사(A)와 재고 차감·쿨다운 갱신(B)이 분리되어 있고, 그 사이에 await ApplyEffectAsync
라는 비동기 경계가 존재한다. 같은 플레이어의 사용 요청이 동시에 들어오면 두 요청 모두 (A)를
통과한 뒤 (B)에 도달해, 쿨다운을 우회하고 재고를 이중 차감하거나 음수로 만든다.
문제점
- [동시성/검사-처리 비원자] 쿨다운 그룹 우회. (A)의
last검사와 (B)의GroupLastUse[group]=now갱신 사이가 원자적이지 않다. 재현: 두 개의 서로 다른 HP 포션(같은 그룹)을 거의 동시에 사용. 요청1이 (A)를 통과하고await에서 양보 → 요청2도 아직 갱신 전이라 (A) 통과 → 둘 다 효과 적용. 10초 쿨다운이 실질적으로 무력화되어 순간 회복량이 2배가 된다(밸런스 붕괴). - [동시성/lost update] 재고 이중 차감·음수. (B)의
cnt - 1은 (A) 시점에 읽은cnt에 기반한 read-modify-write다. 개수가 1인 아이템을 동시에 두 번 사용하면 둘 다cnt=1을 읽어 각각0을 써서 실제로는 2개를 소비하고 재고는 1만 줄거나(유실), 경로에 따라 음수가 된다. - [동시성/자료구조] Dictionary 동시 접근.
Dictionary<,>는 스레드 안전하지 않다. 동시 쓰기 중 리사이즈가 겹치면 내부 버킷이 손상되어 무한 루프·예외·데이터 손상까지 발생할 수 있다.
근본 원인
플레이어 상태에 대한 "검사 → 부수효과(await) → 커밋"이 하나의 임계 구역으로 직렬화되지 않았다.
특히 await 이후에 상태를 커밋하기 때문에, await 경계에서 다른 요청이 낡은 스냅샷으로 진입한다.
수정안
플레이어 단위로 요청을 직렬화한다. 가장 견고한 방법은 플레이어별 액터/단일 실행 큐로 같은 플레이어의 요청을 순차 처리하는 것이다. 최소 수정으로는 플레이어별 비동기 락을 쓰되, 쿨다운·재고 갱신을 효과 적용과 함께 하나의 임계 구역 의미로 커밋한다.
private sealed class Player
{
public readonly SemaphoreSlim Gate = new(1, 1); // 플레이어별 직렬화
public Dictionary<int, int> ItemCount = new();
public Dictionary<int, DateTime> GroupLastUse = new();
}
public async Task<bool> UseItemAsync(long playerId, int itemId)
{
Player p = _players[playerId];
int group = _itemGroup[itemId];
double cd = _groupCooldown[group];
await p.Gate.WaitAsync();
try
{
// 검사(A)를 임계 구역 안에서 수행하고, 통과하면 즉시 쿨다운을 "예약"한다.
DateTime now = DateTime.UtcNow;
if (p.GroupLastUse.TryGetValue(group, out DateTime last)
&& (now - last).TotalSeconds < cd)
return false;
if (!p.ItemCount.TryGetValue(itemId, out int cnt) || cnt <= 0)
return false;
// 부수효과 전에 상태를 선점(예약)해 다른 요청이 낡은 스냅샷으로 통과하지 못하게 한다.
p.ItemCount[itemId] = cnt - 1;
p.GroupLastUse[group] = now;
try { await ApplyEffectAsync(playerId, itemId); }
catch { /* 롤백: 예약 취소 */ p.ItemCount[itemId] = cnt; p.GroupLastUse.Remove(group); throw; }
return true;
}
finally { p.Gate.Release(); }
}
핵심은 "검사 후 즉시 예약(선점) → 부수효과 → 실패 시 롤백" 순서로 바꿔, await 경계에서
다른 요청이 통과할 여지를 없애는 것이다. SemaphoreSlim은 await 를 가로질러 안전하게 잡을 수 있다.
더 나은 설계
- 플레이어별 단일 스레드/액터 모델: 한 플레이어의 모든 상태 변경을 하나의 큐에서 순차 실행하면 락 자체가 필요 없어지고, 쿨다운·재고·버프 적용을 자연스럽게 원자적으로 다룰 수 있다. 트레이드오프는 플레이어별 큐 관리 비용과, 한 요청이 오래 걸리면 뒤 요청이 지연된다는 점이다.
- 쿨다운을 시각이 아니라 "다음 사용 가능 시각"으로 저장하고 비교를 단조 시계(Stopwatch/틱)로 하면
시스템 시간 되감김에 영향받지 않는다.
DateTime.UtcNow는 NTP 보정으로 역행할 수 있다. - 재고는 DB 영속 계층에서도 원자
UPDATE ... SET cnt=cnt-1 WHERE cnt>0로 이중 방어한다.
면접 포인트
- "
await앞에서 검사하고 뒤에서 커밋"이 왜 위험한가 — await 경계는 양보 지점이며, 그 사이 상태가 바뀔 수 있다. TOCTOU(검사-사용 시점 차이)의 비동기 버전이다. - 낙관적 선점(먼저 차감하고 실패 시 롤백) vs 비관적 락(끝까지 잡기)의 트레이드오프.
- 쿨다운 판정에 벽시계(UtcNow)를 쓸 때의 시계 역행/서버 간 편차 문제.
해설 — 소비 아이템 공유 쿨다운 그룹의 동시 사용 우회 (C++)
난이도: 중
요약
쿨다운 검사(A)와 재고 차감·쿨다운 갱신(B)이 임계 구역으로 묶이지 않았고, 그 사이 ApplyEffect가
끼어 있다. 여러 워커가 같은 플레이어를 동시에 처리하면 두 요청 모두 (A)를 통과해 쿨다운을 우회하고
재고를 이중 차감한다. 게다가 std::unordered_map을 동기화 없이 동시 읽기/쓰기 하므로 자료구조 자체가
깨진다.
문제점
- [동시성/검사-처리 비원자] 쿨다운 우회. (A)~(B)가 원자적이지 않아 두 요청이 같은
last를 보고 모두 통과한다. 공유 그룹 쿨다운이 무력화되어 순간 회복/버프가 중첩된다. - [동시성/lost update] 재고 이중 차감.
remaining은 (A) 시점의 스냅샷이다. 개수 1을 동시에 두 번 사용하면 둘 다remaining=1로 읽어 각각0을 써, 2개를 소비했지만 재고는 1만 줄거나 음수가 된다. - [동시성/자료구조 손상] 무보호 map 동시 접근.
players_,p.itemCount,p.groupLastUse에 대한 동시 삽입/수정은 데이터 레이스이자 미정의 동작이다.players_[playerId]는 새 플레이어면 rehash를 유발할 수 있고, 동시 rehash는 반복자·노드 포인터를 무효화해 크래시로 이어진다. (A)에서 얻은it/ci반복자도 다른 스레드의 삽입으로 무효화될 수 있다.
근본 원인
플레이어 상태(쿨다운·재고)에 대한 "검사 → 부수효과 → 커밋"이 하나의 락으로 보호되지 않았다. C#판과 달리 C++에서는 동시 접근이 곧바로 컨테이너 무효화/UAF 급 손상으로 번진다.
수정안
플레이어별 뮤텍스로 검사·차감·쿨다운 갱신을 하나의 임계 구역으로 묶는다. 무거운 부수효과를 락 밖에서 하고 싶다면, 먼저 차감(예약)하고 락을 놓은 뒤 효과를 적용하되 실패 시 롤백한다.
#include <mutex>
struct Player {
std::mutex m;
std::unordered_map<int, int> itemCount;
std::unordered_map<int, Clock::time_point> groupLastUse;
};
// players_ 자체의 조회/삽입도 보호해야 한다.
Player& GetPlayer(std::int64_t id) {
std::lock_guard<std::mutex> g(playersMx_);
return players_[id]; // 참조는 노드 기반이라 rehash에도 안정(unordered_map)
}
bool UseItem(std::int64_t playerId, int itemId) {
int group = itemGroup_.at(itemId);
double cd = groupCooldown_.at(group);
Player& p = GetPlayer(playerId);
int reservedFrom;
{
std::lock_guard<std::mutex> g(p.m);
auto now = Clock::now();
auto it = p.groupLastUse.find(group);
if (it != p.groupLastUse.end() &&
std::chrono::duration<double>(now - it->second).count() < cd)
return false;
auto ci = p.itemCount.find(itemId);
if (ci == p.itemCount.end() || ci->second <= 0)
return false;
reservedFrom = ci->second;
ci->second = reservedFrom - 1; // 선점(예약)
p.groupLastUse[group] = now; // 쿨다운도 즉시 예약
}
try {
ApplyEffect(playerId, itemId); // 무거운 작업은 락 밖에서
} catch (...) {
std::lock_guard<std::mutex> g(p.m); // 롤백
p.itemCount[itemId] = reservedFrom;
p.groupLastUse.erase(group);
throw;
}
return true;
}
주의:
std::unordered_map은 rehash 시 참조/포인터는 유지되지만 반복자는 무효화된다. 따라서 락 밖으로Player&를 들고 나가는 것은 안전하지만, 반복자를 들고 나가면 안 된다.players_에 새 플레이어를 삽입하는 동안 다른 스레드가 같은 map을 읽는 것도 반드시playersMx_로 막아야 한다.
더 나은 설계
- 플레이어별 단일 처리 스레드(액터): 한 플레이어의 모든 요청을 하나의 큐에서 순차 실행하면 락이 사라지고 원자성이 자연히 보장된다. 트레이드오프는 큐 관리·지연.
- 쿨다운 판정을
steady_clock(단조)로 한 것은 좋다 — 시스템 시간 역행에 영향받지 않는다. 벽시계를 쓰면 NTP 보정 시 판정이 붕괴한다. - 재고 차감은 서버 메모리와 DB 양쪽에서 방어(
WHERE cnt>0).
면접 포인트
unordered_map의 무효화 규칙: rehash 시 반복자는 무효화, 참조·포인터는 유효. 이 차이가 락 설계에 영향을 준다.- "검사-후-예약, 실패 시 롤백"(낙관적) vs "끝까지 락 보유"(비관적)의 지연/처리량 트레이드오프.
- 왜 C++판이 C#판보다 위험한가 — GC가 없어 자료구조 손상이 곧 UAF/크래시로 이어진다.