26. 만료(기간제) 아이템 자동 삭제와 사용이 동시에 일어나는 상황
난이도 중해설 — 만료(기간제) 아이템 자동 삭제와 사용이 동시에 일어나는 상황
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
UseItem 의 "만료 검사 → 효과 적용 → 차감" 과 SweepExpired 의 삭제가 공유 인벤토리에
락 없이 동시 실행된다. (A) 사용 핸들러는 만료를 검사한 뒤 효과/차감을 별도로 하므로,
더블클릭 등으로 두 UseItem 이 동시에 들어오면 둘 다 만료 검사를 통과 → 효과 2회 적용 →
Count 가 음수가 되거나 Remove 가 두 번 일어난다(TOCTOU). 또한 만료 검사 직후 스윕이
같은 아이템을 삭제하면 "효과는 적용됐는데 아이템은 사라진" 모순, 혹은 사용이 막 효과를
적용한 만료 아이템을 스윕이 또 처리한다. (B) SweepExpired 는 foreach 로 bag 를 순회하며
bag.Remove 를 호출해 컬렉션 변경 예외(InvalidOperationException)를 내고, 사용 경로와
경합한다. (C) Dictionary 는 스레드세이프하지 않아 동시 지급/조회/삭제가 자료구조를 손상시킨다.
정답 한 줄: (플레이어 단위) 락으로 "만료 재검사 → 차감(예약) → 효과 적용" 을 한 임계구역으로
원자화하고, 스윕은 만료 키를 모은 뒤 같은 락 아래에서 삭제하라(순회 중 변경 금지).
변별: problem9(강화 성공/실패)·problem21(가방 가득 동시 획득)과 달리 본 문제의 핵심은 "수명(만료)에 의한 비동기 자동 삭제" 와 "사용" 이 같은 아이템에서 경합할 때의 TOCTOU· use-after-delete·순회 중 삭제이다. 만료 판정의 권위 시계가 서버라는 점도 포인트.
문제점
(A) 사용 경로 TOCTOU — 만료 후 사용 / 효과·차감 이중 적용 (동시성·정합) ★간판
- 분류 태그: check-then-act / TOCTOU.
- 증상:
- 더블클릭 동시 사용:
Count=1인 부스터에 두 요청이 동시 진입. 둘 다ExpireAtMs > now통과 → 둘 다_applyEffect호출(효과 2회) →Count가 1→0→-1,Remove가 두 번(이미 없는 키 제거는 무시되지만 효과·차감 정합은 이미 깨짐). - 사용 vs 스윕:
UseItem이 만료 검사를 통과한 직후SweepExpired가 같은 아이템을Remove. 이후UseItem이_applyEffect를 적용하면 이미 만료·삭제된 아이템에 효과가 들어가고,item.Count--는 맵에서 떨어져 나온 객체를 만져 무의미해진다.
- 더블클릭 동시 사용:
- 재현조건: 같은 (플레이어,uid)에 사용 요청이 동시에 오거나, 사용과 스윕이 만료 경계에서 겹침.
- 근본 원인: 만료 검사·효과·차감이 하나의 원자 단위가 아님. 효과를 차감(예약)보다 먼저 하는 순서도 잘못(효과만 나가고 차감 실패/롤백 불가).
(B) 순회 중 삭제 + 사용과의 경합 (동시성/런타임) ★간판
- 분류 태그: collection-modified-during-enumeration / data race.
- 증상:
foreach (var kv in bag) { ... bag.Remove(kv.Key); }는 열거 중 컬렉션을 변경해InvalidOperationException을 던진다. 동시에UseItem/AddItem이 같은bag를 만지면 열거자/버킷이 손상되거나 무한 루프가 난다. - 근본 원인: 삭제 대상을 모은 뒤 삭제하지 않고 순회 도중 제거. 사용·스윕·지급이 같은 컬렉션을 동기화 없이 공유.
(C) 비스레드세이프 Dictionary — 자료구조 손상 (동시성)
- 분류 태그: data race.
- 증상:
_inv/bag에 대한 동시TryGetValue/인덱싱/Remove가 내부 버킷을 손상시켜InvalidOperationException(컬렉션 변경) 또는 무한 루프/유실을 낸다. - 근본 원인: 공유 컬렉션에 락/동시성 컬렉션 미사용.
수정안
핵심: 플레이어 단위 락으로 사용·스윕·지급을 직렬화하고, 사용은 "락 안에서 만료 재검사 → 차감(예약) → (가능하면 락 밖) 효과 적용", 스윕은 "만료 키 수집 → 삭제(순회 중 변경 금지)".
public class TimedItemService
{
private readonly Dictionary<long, Dictionary<long, ItemInstance>> _inv = new();
private readonly Dictionary<long, object> _locks = new();
private readonly object _locksGate = new();
private readonly Func<long> _nowMs;
private readonly Action<long, int> _applyEffect;
public TimedItemService(Func<long> nowMs, Action<long, int> applyEffect)
{ _nowMs = nowMs; _applyEffect = applyEffect; }
private object LockOf(long playerId)
{
lock (_locksGate)
{
if (!_locks.TryGetValue(playerId, out var l)) { l = new object(); _locks[playerId] = l; }
return l;
}
}
public bool UseItem(long playerId, long uid)
{
int itemId;
lock (LockOf(playerId))
{
if (!_inv.TryGetValue(playerId, out var bag)) return false;
if (!bag.TryGetValue(uid, out var item)) return false;
// 락 안에서 만료 재검사 → 차감(예약)을 먼저, 효과 적용 가능 여부 확정
if (item.ExpireAtMs != 0 && item.ExpireAtMs <= _nowMs()) return false;
if (item.Count <= 0) return false;
item.Count--; // 예약(차감)을 먼저
if (item.Count == 0) bag.Remove(uid);
itemId = item.ItemId;
}
// 부작용(효과)은 차감이 확정된 뒤 정확히 한 번. 무거우면 락 밖에서.
_applyEffect(playerId, itemId);
return true;
}
public void SweepExpired(long playerId)
{
lock (LockOf(playerId))
{
if (!_inv.TryGetValue(playerId, out var bag)) return;
long now = _nowMs();
// 삭제 대상을 먼저 수집(순회 중 변경 금지)
List<long>? expired = null;
foreach (var kv in bag)
if (kv.Value.ExpireAtMs != 0 && kv.Value.ExpireAtMs <= now)
(expired ??= new()).Add(kv.Key);
if (expired != null)
foreach (var k in expired) bag.Remove(k);
}
}
public void AddItem(long playerId, ItemInstance item)
{
lock (LockOf(playerId))
{
if (!_inv.TryGetValue(playerId, out var bag))
{ bag = new Dictionary<long, ItemInstance>(); _inv[playerId] = bag; }
bag[item.Uid] = item;
}
}
}
포인트
- 만료 재검사·차감이 같은 락 → 더블클릭 동시 사용이 하나만 성공,
Count음수/효과 2회 없음. - 차감을 효과보다 먼저: 효과가 무겁거나 실패할 수 있어도 "예약된 1회" 만 소비. 효과를 꼭 락 안에서 해야 한다면 그렇게 하되, 외부 호출(재진입/데드락)을 주의.
- 사용과 스윕이 같은 락 → "만료 검사 통과한 아이템을 스윕이 가로채 삭제" 불가. 만료 경계는 사용 시점의 락 안 재검사로 일관.
- 스윕은 수집 후 삭제 → 순회 중 변경 예외 제거.
- (대안)
ConcurrentDictionary로 컬렉션 자체는 안전하게 하되, "검사+차감" 복합 연산은 여전히 엔트리 단위 락/CAS 가 필요(컬렉션 동시성 ≠ 연산 원자성).
더 나은 설계 (+트레이드오프)
- 지연 만료(lazy expiry) 일원화: 자동 스윕을 없애거나 청소 전용으로만 두고, 권위 판정은 항상 사용 시점에 한다(만료면 거절+그 자리에서 정리). 백그라운드 스윕은 메모리 회수/표시용 best-effort. 트레이드오프: 만료 아이템이 가방에 잠시 남아 보일 수 있음(UI는 회색 처리).
- 단일 라이터(액터/파티션 큐): 플레이어별 인벤토리 변경을 한 스레드가 순차 처리하면 락 없이 원자성. 사용·스윕·지급을 같은 큐로. 트레이드오프: 파티셔닝/지연.
- 상태 전이로 use-after-delete 제거: 아이템에
Consumed/Expired상태를 두고 단조 전이만 허용(한 번Consumed면 스윕이 효과를 안 건드림). 트레이드오프: 상태/툼스톤 관리. - 서버 권위 시계 + 효과 멱등: 만료는 서버 시계로만 판정(클라 시각 불신), 효과 지급은 (플레이어,uid,사용시각) 키로 멱등화해 어떤 경합에서도 1회만(problem14/17 멱등성 연계).
면접 포인트 (예상 질문)
- 효과를 차감보다 먼저 적용했을 때와 차감을 먼저 했을 때, 동시 사용/실패 시 결과가 어떻게 달라지는가? 왜 "예약 후 효과" 가 안전한가?
foreach도중Remove가 왜 위험한가? 삭제 대상을 모아서 지우는 것 말고ConcurrentDictionary만 쓰면 이 문제가 다 해결되는가?(아니라면 왜)- 자동 스윕을 두면서도 "만료 후 사용"·"효과만 적용되고 아이템은 사라짐" 모순을 없애려면 판정 권위를 어디에 두어야 하는가(lazy expiry)?
해설 — 만료(기간제) 아이템 자동 삭제와 사용이 동시에 일어나는 상황 (C++)
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
useItem 의 "만료 검사 → 효과 → 차감" 과 sweepExpired 의 erase 가 공유 맵에 동기화 없이
동시 실행된다. (A) 사용 핸들러가 만료 검사 후 효과·차감을 따로 하므로, 더블클릭으로 두
useItem 이 동시 진입하면 둘 다 만료 통과 → 효과 2회 → count 음수가 되고, 만료 경계에서
사용과 스윕이 겹치면 "효과는 적용됐는데 노드는 사라진" 모순이 난다. 게다가 C++ 특유로
ItemInstance& item 참조를 erase 후에도 들고 있어 댕글링(use-after-free)이 발생한다.
(B) sweepExpired 는 for (auto& kv : bag) 순회 중 bag.erase 를 호출해 반복자 무효화/UB
(다음 ++it 가 무효 노드를 가리킴)에 빠지고, 사용 경로와 경합한다. (C) unordered_map 동시
삽입은 rehash 로 노드/참조를 무효화한다. 정답 한 줄: 플레이어 단위 mutex 로 사용·스윕·지급을
직렬화하고, 사용은 "락 안 만료 재검사 → 차감(예약) → 효과", erase 는 it = erase(it) 패턴으로
순회하며, 무효화될 수 있는 참조를 임계구역 밖으로 들고 나가지 마라.
변별: problem9·problem21 과 달리, 본 문제의 핵심은 수명(만료) 기반 비동기 자동 삭제와 사용의 경합 = TOCTOU + 반복자/참조 무효화(C++) + use-after-erase 이다.
문제점
(A) 사용 경로 TOCTOU + 댕글링 참조 — 만료 후 사용/효과 2회/UAF (동시성·메모리) ★간판
- 분류 태그: check-then-act / use-after-free.
- 증상:
- 더블클릭 동시 사용:
count=1에 두 요청이 만료 검사를 통과 →applyEffect2회,count1→0→-1. ItemInstance& item = iit->second참조를 들고 있는데, 같은 스레드의bag.erase(iit)이후(또는 동시sweepExpired의 erase 후) 그 참조를 다시 쓰면 해제된 노드 접근(UAF).- 사용이 만료 검사를 통과한 직후 스윕이 그 노드를 erase 하면, 사용은 이미 만료/삭제된
아이템에 효과를 넣고 떨어진 메모리에
count--.
- 더블클릭 동시 사용:
- 재현조건: 같은 (player,uid)에 동시 사용, 또는 사용과 스윕이 만료 경계에서 겹침.
- 근본 원인: 검사·효과·차감이 원자 단위가 아니고, 무효화 가능한 참조의 수명을 안전구간 밖으로 끌고 나감. 효과를 차감보다 먼저 한 순서도 오류.
(B) 순회 중 erase — 반복자 무효화/UB (런타임) ★간판
- 분류 태그: iterator invalidation.
- 증상:
for (auto& kv : bag) bag.erase(kv.first);는unordered_map에서 지운 노드의 반복자를 무효화하고, range-for 의 내부++it가 무효 반복자를 증가시켜 UB(크래시/무한 순회). 동시useItem/addItem의 rehash 와 겹치면 더 악화. - 근본 원인: 순회 중 컨테이너 구조 변경. 표준 패턴(
it = erase(it)) 미사용.
(C) unordered_map 동시 접근/rehash — 노드·참조 무효화 (동시성/메모리)
- 분류 태그: data race / pointer invalidation.
- 증상:
addItem의inv_[playerId][uid] = item삽입이 rehash 를 유발하면 다른 스레드가 들고 있던 참조/반복자가 무효화된다. 동시 read/write 자체가 데이터 레이스(UB). - 근본 원인: 공유 컨테이너에 동기화 없음. (참고:
unordered_map은 rehash 시 반복자는 무효화하지만 노드의 포인터/참조(값 주소) 는 보존된다 — 그러나erase는 그 노드 자체를 파괴하므로 (A)의 참조 무효화는 rehash 와 무관하게 발생한다.)
수정안
핵심: 플레이어 단위 mutex, 락 안에서 만료 재검사 → 차감(예약) → 효과, erase 는 안전 패턴.
#include <mutex>
#include <memory>
#include <vector>
class TimedItemService {
public:
TimedItemService(std::function<int64_t()> nowMs, std::function<void(int64_t,int)> applyEffect)
: nowMs_(std::move(nowMs)), applyEffect_(std::move(applyEffect)) {}
bool useItem(int64_t playerId, int64_t uid) {
int itemId = 0;
{
std::lock_guard<std::mutex> g(lockOf(playerId));
auto pit = inv_.find(playerId);
if (pit == inv_.end()) return false;
auto& bag = pit->second;
auto iit = bag.find(uid);
if (iit == bag.end()) return false;
ItemInstance& item = iit->second;
if (item.expireAtMs != 0 && item.expireAtMs <= nowMs_()) return false;
if (item.count <= 0) return false;
item.count--; // 예약(차감)을 먼저
itemId = item.itemId; // 값만 복사 — 참조를 밖으로 안 들고 나감
if (item.count == 0) bag.erase(iit); // 더 이상 item 참조 사용 안 함
}
applyEffect_(playerId, itemId); // 부작용은 차감 확정 뒤 1회 (락 밖)
return true;
}
void sweepExpired(int64_t playerId) {
std::lock_guard<std::mutex> g(lockOf(playerId));
auto pit = inv_.find(playerId);
if (pit == inv_.end()) return;
auto& bag = pit->second;
int64_t now = nowMs_();
for (auto it = bag.begin(); it != bag.end(); ) {
if (it->second.expireAtMs != 0 && it->second.expireAtMs <= now)
it = bag.erase(it); // erase 반환값으로 안전하게 전진
else
++it;
}
}
void addItem(int64_t playerId, const ItemInstance& item) {
std::lock_guard<std::mutex> g(lockOf(playerId));
inv_[playerId][item.uid] = item;
}
private:
std::mutex& lockOf(int64_t playerId) {
std::lock_guard<std::mutex> g(locksGate_);
auto it = locks_.find(playerId);
if (it == locks_.end())
it = locks_.emplace(playerId, std::make_unique<std::mutex>()).first;
return *it->second;
}
std::unordered_map<int64_t, std::unordered_map<int64_t, ItemInstance>> inv_;
std::unordered_map<int64_t, std::unique_ptr<std::mutex>> locks_; // mutex 는 이동/복사 불가
std::mutex locksGate_;
std::function<int64_t()> nowMs_;
std::function<void(int64_t,int)> applyEffect_;
};
포인트
- 만료 재검사·차감이 같은 락 → 더블클릭 1회만 성공,
count음수/효과 2회 없음. - 참조를 임계구역 밖으로 들고 나가지 않음(필요한 값은 복사) → UAF 제거.
it = bag.erase(it)표준 패턴으로 순회 중 삭제 UB 제거.mutex는 복사/이동 불가 → 락 맵은unique_ptr<std::mutex>로 보관(emplace).
더 나은 설계 (+트레이드오프)
- 지연 만료(lazy expiry): 권위 판정은 항상 사용 시점, 스윕은 메모리 회수용 best-effort. 트레이드오프: 만료 아이템이 잠시 남아 보임(UI 비활성화 처리).
- 단일 라이터(액터/파티션 큐): 플레이어별 인벤토리 변경을 한 스레드가 순차 처리 → 락 없이 원자성. 트레이드오프: 파티셔닝/지연.
- 수명 안전 핸들(shared_ptr/세대 인덱스): 아이템을
shared_ptr로 들거나 (uid,generation) 핸들로 접근해 erase 후 stale 핸들을 거부 → use-after-erase 원천 차단. 트레이드오프: 간접/메모리. - 상태 전이 + 효과 멱등:
Consumed/Expired단조 전이 + (player,uid,사용시각) 멱등 키로 어떤 경합에서도 효과 1회(problem14/17 연계).
면접 포인트 (예상 질문)
ItemInstance&를 erase 이후에도 쓰면 왜 UAF 인가?unordered_map의 rehash 가 참조와 반복자에 각각 어떤 영향을 주는지 설명하라.for (auto& kv : bag) bag.erase(kv.first)가 왜 UB 이고,it = erase(it)가 왜 안전한가?- 효과를 차감보다 먼저 적용하면 동시 사용/실패 시 무엇이 깨지는가? "예약 후 효과" 의 이점은?