54. 재적용 버프의 조기 만료 (stale 일회성 타이머 + 무조건 제거)
난이도 중해설 — 재적용 버프의 조기 만료 (stale 일회성 타이머 + 무조건 제거)
난이도: 중
요약
ApplyOrRefresh가 재적용될 때마다 만료 시각 (A)만 갱신하고, 매번 새 일회성 제거 콜백을 예약한다. 그러나 먼저 예약된 오래된 콜백은 취소되지 않는다. 그 오래된 콜백이 원래 예정 시각(최초 부여 +10초)에 실행되면 RemoveExpired가 (B)에서 만료 시각을 확인하지 않고 무조건 버프를 제거한다. 결과적으로 재적용해 놓은 버프가 갱신된 만료 시각보다 훨씬 일찍 사라진다. 게다가 _buffs 딕셔너리를 전투 스레드와 타이머 스레드가 락 없이 공유해 자료구조 자체가 레이스에 노출된다.
문제점
- 분류태그: stale timer / check 없는 무조건 삭제 + 공유 자료구조 데이터 레이스
- 증상: 버프를 계속 재적용(도트 갱신)했는데도 10초마다 한 번씩 버프가 툭 끊긴다. 특히 재적용 간격이 지속시간보다 짧을 때 "왜 갱신했는데 사라지지?" 하는 버그로 보고된다.
- 재현조건: t=0 부여(만료 10s, 콜백#1 예약 t=10) → t=6 재적용(만료 16s, 콜백#2 예약 t=16). t=10에 콜백#1이 실행되어 버프를 제거 → 실제 만료(16s) 전에 사라짐.
- 근본원인: (1) 만료 판단의 단일 진실 원천이
ExpireAtMs인데, 제거 콜백이 이 값을 보지 않고 예약 시점만 믿는다. (2) 재적용이 이전 예약을 취소/재조정하지 않는다. (3) 공유 딕셔너리 접근이 비동기·비원자다.
수정안
제거 콜백을 "그 시점에 실제로 만료됐는지" 재확인하도록 만들고(멱등·자기검증), 공유 상태를 락으로 보호한다.
private readonly object _gate = new object();
public void ApplyOrRefresh(int playerId, long nowMs)
{
lock (_gate)
{
if (!_buffs.TryGetValue(playerId, out var buff))
{
buff = new Buff();
_buffs[playerId] = buff;
}
buff.ExpireAtMs = nowMs + DurationMs;
}
// 예약은 계속 여러 개 걸려도 무해하도록, 콜백이 스스로 만료를 재검증한다.
_scheduler.ScheduleAfter(DurationMs, () => RemoveExpired(playerId));
}
public void RemoveExpired(int playerId)
{
lock (_gate)
{
if (_buffs.TryGetValue(playerId, out var b) && NowMs() >= b.ExpireAtMs)
_buffs.Remove(playerId); // 실제로 만료됐을 때만 제거
}
}
핵심: 여러 개의 stale 콜백이 떠 있어도, 각 콜백이 ExpireAtMs와 현재 시각을 비교하므로 아직 유효한 버프는 절대 지우지 않는다(제거 연산의 멱등·조건화). NowMs()는 콜백 실행 시점의 시계.
더 나은 설계
- 재조정형 타이머: 재적용 시 이전 예약을 취소(또는 토큰/세대 번호로 무효화)하고 하나만 유지. 트레이드오프: 취소 가능한 스케줄러 API와 토큰 관리 필요. stale 콜백이 애초에 실행되지 않아 스캔 비용도 준다.
- 만료를 이벤트가 아니라 폴링으로: 타임휠/우선순위 큐에
(ExpireAtMs, playerId)를 넣고, 만료 스캔이 항상 최신ExpireAtMs만 보게 한다. 재적용은 키의 우선순위만 갱신. 대규모 버프에 유리. - 세대(generation) 토큰: 콜백에 부여 당시 세대 번호를 캡처시키고, 제거 시 현재 세대와 다르면 무시(ABA 회피). 취소 API가 없을 때 유용.
면접 포인트
- "삭제/만료 콜백은 예약 시점이 아니라 실행 시점의 상태로 판단해야 한다"는 원칙(자기검증·멱등).
- 재적용(refresh)에서 이전 타이머를 취소하지 않으면 왜 조기 만료가 생기는가, 세대 토큰/타임휠이 이를 어떻게 없애는가.
- 공유 자료구조(딕셔너리)에 서로 다른 스레드가 접근할 때의 최소 보호 범위.
해설 — 재적용 버프 조기 만료 + 소유권 결함 (C++)
난이도: 중
요약
C# 트윈과 같은 논리 결함(재적용 시 이전 일회성 제거 콜백을 취소하지 않고, 제거 콜백이 만료 시각을 재확인하지 않는 (B))에 더해, C++에서는 소유권/수명 결함이 겹친다. buffs_는 Buff* 원시 포인터를 소유하고 RemoveExpired가 delete한다 (B). stale 콜백이 아직 유효한(재적용된) 버프를 조기에 delete하면, 전투 스레드가 보관하던 포인터나 GetPower 경로가 이미 해제된 객체를 참조할 수 있다. 또한 unordered_map을 전투/타이머 스레드가 락 없이 공유해 자료구조 자체가 레이스·재해시 무효화에 노출된다.
문제점
- 분류태그: stale timer + 무조건 제거, 원시 포인터 delete 후 접근, 공유 컨테이너 데이터 레이스
- 증상: (1) 재적용했는데 10초마다 버프가 끊김(조기 만료). (2) 조기
delete후 전투 계산이 해제된Buff를 읽으면 쓰레기 값·크래시(ASan: heap-use-after-free). - 재현조건: t=0 부여(콜백#1 예약 t=10) → t=6 재적용(만료 16s). t=10 콜백#1이
delete→ 이후 t<16 구간의 전투 계산이 dangling 포인터 접근. - 근본원인: 제거가 만료 시각을 보지 않고 무조건 수행되며, 제거가 곧
delete(수명 종료)라 논리 버그가 메모리 안전성 문제로 승격된다. 공유 컨테이너 미보호.
수정안
(1) 제거를 조건화(실제 만료 시만), (2) 소유권을 shared_ptr로 바꿔 참조 중 삭제를 안전하게, (3) 컨테이너를 락으로 보호.
#include <memory>
#include <mutex>
std::mutex mtx_;
std::unordered_map<int, std::shared_ptr<Buff>> buffs_;
void ApplyOrRefresh(int player_id, int64_t now_ms) {
{
std::lock_guard<std::mutex> lk(mtx_);
auto& b = buffs_[player_id];
if (!b) b = std::make_shared<Buff>();
b->expire_at_ms = now_ms + kDurationMs;
}
sched_.ScheduleAfter(kDurationMs, [this, player_id] { RemoveExpired(player_id); });
}
void RemoveExpired(int player_id) {
std::lock_guard<std::mutex> lk(mtx_);
auto it = buffs_.find(player_id);
if (it != buffs_.end() && NowMs() >= it->second->expire_at_ms)
buffs_.erase(it); // shared_ptr: 아직 참조 중이면 실제 해제는 마지막 참조 소멸 시
}
// 전투 계산은 shared_ptr 사본을 잡고 사용하면 참조 동안 해제되지 않음
std::shared_ptr<Buff> Get(int player_id) {
std::lock_guard<std::mutex> lk(mtx_);
auto it = buffs_.find(player_id);
return it == buffs_.end() ? nullptr : it->second;
}
shared_ptr로 바꾸면 맵에서 erase해도 사용 중인 참조가 남아있는 한 객체가 살아있어 UAF가 사라진다. 조건부 제거로 조기 만료도 방지.
더 나은 설계
- 취소 가능한 타이머(토큰/세대) 또는 타임휠로 stale 콜백 자체를 없애면
delete시점이 명확해진다. - 세션당 단일 스레드 실행(액터)로 컨테이너 락을 제거. 트레이드오프: 크로스 스레드 조회 시 메시지 전달 필요.
- 버프를 포인터가 아니라 값/인덱스(슬롯 + 세대)로 관리하면 수명 문제를 구조적으로 회피.
면접 포인트
- 논리 버그(조기 만료)가 C++에서 왜 메모리 안전성 문제(UAF)로 승격되는가 — 제거=
delete. shared_ptr가 "사용 중 삭제"를 어떻게 안전하게 만드는가, 그래도 남는 정확성 문제(조건부 제거)는 별개라는 점.unordered_map동시 접근 시 재해시로 인한 반복자/포인터 무효화.