20. 캐릭터 삭제/복구와 진행 중 비동기 저장의 경합
난이도 최상해설 — 캐릭터 삭제/복구와 진행 중 비동기 저장의 경합
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
삭제/복구/퍼지와 느린 비동기 저장(UPSERT) 이 동기화·순서·버전 보장 없이 경합한다.
(A) SaveAsync 는 메모리 스냅샷을 떠 느린 Upsert(없으면 insert) 를 하는데, 그 사이
Delete+MarkDeleted 가 끝나면 늦게 도착한 UPSERT 가 삭제된 행을 다시 insert → 좀비
부활(삭제 유실). (B) MarkDeleted 와 in-flight Upsert 사이에 순서/펜싱이 없어 최종 DB
상태가 레이스로 결정되고, _live 는 락 없는 Dictionary 라 손상 위험. (C) Restore 가
캐릭터를 되살려도 Delete 가 건 퍼지 타이머가 유예 만료 후 그대로 HardDelete 를
실행해 복구된 캐릭터를 영구 삭제한다(취소/세대 검사 없음). 정답 한 줄: 캐릭터 단위로
모든 영속 연산을 직렬화(락/액터)하고, 모든 쓰기를 단조 버전으로 펜싱하며, 삭제는 행 제거가
아닌 상태(tombstone)로 두고, 퍼지는 (id, 삭제 세대)로 멱등·취소 가능하게 한다.
변별: concurrency11/19 는 "저장 순서·coalesce(늦은 저장이 최신을 덮음)" 가 핵심이고, 본 문제는 "삭제/복구/퍼지 생명주기(tombstone·grace·복구 취소)" 가 핵심이다. "늦은 저장이 덮어쓴다" 테마는 공유하나 여기서는 그 덮어쓰기가 삭제를 되살리는 방향이다.
문제점
(A) in-flight 저장이 삭제를 되살림 — 좀비 부활 / 삭제 유실 (동시성/버그) ★간판
- 증상: 인터리빙:
- T1
SaveAsync(7)가_live[7]스냅샷을 뜸 → 느린Upsert진입(아직 미완). - T2
Delete(7):_live.Remove(7)+_db.MarkDeleted(7)완료. - T1 의
Upsert(snapshot)가 그제서야 DB 도달 → 행이 "삭제됨" 이었는데 UPSERT 가 다시 살아있는 행으로 덮어쓰거나 insert → 삭제한 캐릭터가 부활.
- T1
- 재현조건: 저장 주기와 삭제가 겹치고 DB 쓰기가 느릴 때(부하 시 상시). 결제/탈퇴 직후 자동 저장과 겹치면 실제 운영 사고.
- 근본 원인: 저장 스냅샷이 "삭제 이전" 상태이고, 쓰기에 삭제 상태 확인/버전 펜싱이 없다. UPSERT 의 insert 의미가 tombstone 을 무시.
(C) 퍼지가 복구를 무시하고 영구 삭제 — 복구 캐릭터 소멸 (동시성/버그)
- 증상:
Delete(7)가SchedulePurge(7, 600). 유예 내Restore(7)로_live[7]복구 +ClearDeleted. 그러나 600초 후 예약된Purge(7)가 무조건HardDelete(7)→ 방금 복구한 캐릭터가 영구 삭제. 타이머는 복구를 모른다(취소·세대 검사 없음). - 근본 원인: 퍼지가 "삭제 시점의 결정" 을 무조건 집행. 복구가 그 결정을 무효화하는 메커니즘(세대/취소 토큰/상태 재확인)이 없다.
(B) Restore ↔ Delete/Save 순서 경합 + 스냅샷 신선도 (동시성/정확성)
- 증상:
Restore가_db.Load(id)로 읽는데, in-flightUpsert(A) 가 아직 안 끝났으면 옛 상태를 로드하거나,Restore직후 늦은Upsert가 더 옛 스냅샷으로 덮어써 복구 상태가 퇴행.Delete와Restore가 거의 동시면 "메모리엔 복구, DB 엔 삭제표시" 같은 메모리-DB 불일치도 가능. - 근본 원인: 연산 간 순서·버전이 없어 "가장 최근 결정" 을 식별할 수 없다.
(공통) 락 없는 Dictionary 동시 접근 — 손상/예외
_live에SaveAsync/Delete/Restore가 동시TryGetValue/Remove/인덱서 → 비스레드세이프Dictionary손상,InvalidOperationException, 무한루프 가능.
수정안
핵심: ① 캐릭터 단위 락(또는 액터)로 직렬화, ② 단조 버전 펜싱 으로 모든 쓰기 조건화, ③ 삭제는 행 제거가 아니라 상태(deleted)+버전(tombstone), ④ 퍼지는 삭제 세대로 멱등·취소.
// DB 계약(개념): 모든 쓰기는 버전/상태 조건부 (CAS)
// UpsertIfNewer(ch, expectVersion): row.version < ch.version 일 때만 적용,
// 단 row.deleted=true 면 절대 insert/되살리기 금지(tombstone 우선).
// HardDeleteIfGen(id, deleteGen): 현재 삭제 세대가 일치할 때만 영구 삭제.
public sealed class CharacterEntry
{
public readonly object Gate = new();
public Character Mem; // 메모리 상태(없으면 null)
public long Version; // 단조 증가
public bool Deleted;
public long DeleteGen; // 삭제할 때마다 증가(퍼지 매칭/복구 취소용)
}
public class CharacterStore
{
private readonly Dictionary<long, CharacterEntry> _entries = new();
private readonly object _mapGate = new();
private readonly IDatabase _db;
public CharacterStore(IDatabase db) { _db = db; }
private CharacterEntry GetEntry(long id)
{
lock (_mapGate)
{
if (!_entries.TryGetValue(id, out var e))
_entries[id] = e = new CharacterEntry();
return e;
}
}
public void SaveAsync(long id)
{
var e = GetEntry(id);
Character snap; long ver;
lock (e.Gate)
{
if (e.Mem == null || e.Deleted) return; // 삭제됐으면 저장 안 함
ver = ++e.Version; // 이 저장의 버전 확정
snap = Clone(e.Mem); snap.Version = ver;
}
// 락 밖에서 느린 I/O. DB 가 버전·tombstone 으로 펜싱:
_db.UpsertIfNewer(snap, ver); // row.deleted=true 면 무시(되살리지 않음)
}
public void Delete(long id)
{
var e = GetEntry(id);
long gen;
lock (e.Gate)
{
e.Deleted = true;
e.Mem = null; // 메모리에서 내림
gen = ++e.DeleteGen; // 이번 삭제 세대
e.Version++; // 삭제도 버전 전진(늦은 저장 펜싱)
}
_db.MarkDeleted(id); // 행은 남기고 deleted=true (tombstone)
SchedulePurge(id, gen, graceSec: 600);
}
public bool Restore(long id)
{
var e = GetEntry(id);
lock (e.Gate)
{
if (!e.Deleted) return false;
var ch = _db.Load(id); // tombstone 행에서 최신 상태
ch.Deleted = false;
ch.Version = ++e.Version; // 복구도 버전 전진
e.Deleted = false;
e.Mem = ch;
e.DeleteGen++; // 세대 변경 → 예약된 퍼지를 무효화
_db.ClearDeleted(id);
return true;
}
}
private void Purge(long id, long deleteGen)
{
var e = GetEntry(id);
lock (e.Gate)
{
if (e.Deleted == false) return; // 복구됨 → 퍼지 취소
if (e.DeleteGen != deleteGen) return; // 세대 불일치(재삭제/복구) → 취소
}
_db.HardDeleteIfGen(id, deleteGen); // DB 도 세대 조건부 영구 삭제
}
private void SchedulePurge(long id, long deleteGen, int graceSec) { /* 타이머 등록 */ }
private static Character Clone(Character c) =>
new Character { Id = c.Id, Gold = c.Gold, Level = c.Level };
}
포인트
- 버전 펜싱:
UpsertIfNewer는 더 오래된 스냅샷·tombstone 을 무시 → 늦은 저장이 삭제를 못 되살린다(좀비 부활 차단). - 삭제 세대(DeleteGen): 복구는 세대를 바꿔 예약된 퍼지를 자연 무효화, 퍼지는
HardDeleteIfGen으로 DB 에서도 세대 일치 시에만 영구 삭제 → 복구 캐릭터 소멸 방지. - 모든 상태 전이를 엔트리 단위 락으로 직렬화 → 메모리-DB 일관성 + Dictionary 손상 차단.
- 느린
Upsert는 락 밖, 단 버전을 락 안에서 확정해 "어느 저장이 최신인지" 가 단조 결정.
더 나은 설계 (+트레이드오프)
- 캐릭터 단위 액터 + 단일 라이터: 한 캐릭터의 save/delete/restore/purge 를 한 큐로 직렬 처리. 락이 사라지고 순서가 자명. 트레이드오프: 캐릭터별 처리량 상한·핫 엔트리 지연.
- 상태 컬럼 + 단조 version 으로 CAS 영속화: 모든 쓰기
WHERE id=? AND version<?, 삭제는state='deleted'(행 유지). 다중 서버/리플레이에도 동일 불변식. 트레이드오프: 스키마/쿼리 복잡, 읽기 시 상태 필터 필요. - 퍼지를 "삭제 세대 키" 의 멱등 잡으로: 잡 큐에 (id, deleteGen) 으로 등록, 실행 시 세대 재확인. 타이머 취소에 의존하지 않아 재기동에도 안전. 트레이드오프: 잡 인프라 필요.
- 삭제/복구를 이벤트 소싱(DeleteRequested/Restored/Purged): 감사·복구·정합성 추적이 쉬워짐. 트레이드오프: 저장량·복잡도 증가.
면접 포인트 (예상 질문)
- 느린 비동기 저장이 어떻게 "삭제한 캐릭터를 되살리는지" 인터리빙으로 설명하라. 버전 펜싱(UpsertIfNewer)이 왜 이를 막는가?
- 삭제 후 600초 뒤 퍼지 타이머가, 그 사이 복구된 캐릭터를 영구 삭제하는 문제를 "삭제 세대" 로 어떻게 해결하는가? 서버 재기동까지 고려하면 타이머 취소만으로 충분한가?
- 소프트 삭제를 "행 제거" 가 아니라 "tombstone 상태" 로 두어야 하는 이유는? UPSERT 의 insert 의미가 왜 위험한가?
해설 — 캐릭터 삭제/복구와 진행 중 비동기 저장의 경합 (C++)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
C# 판의 좀비 부활·복구 소멸·순서 경합에 더해, C++ 에서는 수명(메모리) 버그가 즉시
치명적이다. (A) saveAsync 가 Character* 를 들고 락 밖에서 느린 upsert 를 하는 동안
(B) remove 가 같은 객체를 delete 하면, 저장 스레드의 ch->gold 등은 해제 후
접근(UAF) 이 된다. 동시에 늦은 upsert 가 삭제된 행을 다시 insert → 좀비 부활(삭제
유실). (B) markDeleted 와 in-flight upsert 간 펜싱이 없고, (C) restore 가 되살려도
예약된 purge 가 무조건 hardDelete → 복구 캐릭터 영구 소멸. unordered_map 자체도
락 없이 동시 find/erase/insert 되어 UB. 정답 한 줄: 캐릭터 단위 락(또는 액터)으로
직렬화 + shared_ptr 수명 + 단조 버전 펜싱 + tombstone + 삭제 세대 기반 멱등 퍼지.
변별: concurrency11/19 는 "저장 순서·coalesce" 가 핵심이고, 본 문제는 "삭제/복구/퍼지 생명주기 + raw 포인터 수명(UAF)" 이 핵심이다. 늦은 저장이 삭제를 되살리는 방향.
문제점
(A)+(B) in-flight 저장 중 delete — UAF + 좀비 부활 (수명/동시성/UB) ★간판
- 증상:
- 수명: T1
saveAsync(7)가Character* ch를 얻은 뒤 스냅샷 필드를 읽는 동안 ({ch->id, ch->gold, ch->level, ...}) T2remove(7)가delete it->second→ T1 의ch->gold/ch->level읽기가 해제 후 접근(UAF) → UB(크래시/오염). UAF 창은upsertI/O 중이 아니라 "스냅샷 필드 read 와delete의 동시성" 구간이다(읽고 나서 값으로 복사된snapshot자체는 안전하지만, 그 read 시점이 보호되지 않는 게 문제). - 부활: 늦은
upsert(snapshot)가markDeleted이후 DB 도달 → 삭제 행을 다시 insert → 좀비 부활(삭제 유실).
- 수명: T1
- 재현조건: 저장 주기와 삭제가 겹치고 DB I/O 가 느릴 때. 부하 시 상시.
- 근본 원인: raw 포인터 공유 + 저장 스냅샷이 삭제 이전 + 쓰기 버전/tombstone 펜싱 부재.
(C) 퍼지가 복구를 무시 — 복구 캐릭터 소멸 (동시성/버그)
- 증상:
remove의schedulePurge(7,600)가 600초 후 무조건hardDelete(7). 유예 내restore(7)로 살아난 캐릭터를 영구 삭제. 타이머는 복구를 모른다(세대/취소 없음). - 근본 원인: 퍼지가 삭제 시점 결정을 무조건 집행, 복구가 이를 무효화할 메커니즘 부재.
(B') restore 신선도/순서 + (공통) map 동시 접근 — UB
- 증상:
restore의db_->load가 in-flight upsert 와 엇갈려 옛 상태 로드, 또는 복구 후 늦은 upsert 가 옛 스냅샷으로 퇴행.live_(unordered_map)에 락 없이 동시find/erase/insert→ 데이터 레이스 UB(리해시 중 erase 면 즉시 크래시). - 근본 원인: 연산 간 순서·버전 부재 + 컨테이너 동기화 부재.
수정안
핵심: ① 엔트리 단위 std::mutex, ② shared_ptr<Character> 로 저장 중 수명 보장,
③ 단조 버전 펜싱(UpsertIfNewer), ④ tombstone + 삭제 세대 멱등 퍼지.
#include <mutex>
#include <memory>
#include <unordered_map>
// DB 계약(개념): upsertIfNewer(ch, ver) → row.version < ver 일 때만,
// 단 row.deleted=true 면 되살리지 않음. hardDeleteIfGen(id, gen) → 세대 일치 시만.
struct Entry {
std::mutex m;
std::shared_ptr<Character> mem; // 없으면 null
int64_t version = 0;
bool deleted = false;
int64_t deleteGen = 0;
};
class CharacterStore {
public:
explicit CharacterStore(IDatabase* db) : db_(db) {}
std::shared_ptr<Entry> getEntry(int64_t id) {
std::lock_guard<std::mutex> lk(mapM_);
auto& e = entries_[id];
if (!e) e = std::make_shared<Entry>();
return e;
}
void saveAsync(int64_t id) {
auto e = getEntry(id);
Character snap; int64_t ver;
{
std::lock_guard<std::mutex> lk(e->m);
if (!e->mem || e->deleted) return; // 삭제됐으면 저장 안 함
ver = ++e->version;
snap = *e->mem; snap.version = ver; // 값 복사 스냅샷(공유 객체 수명 무관)
}
db_->upsertIfNewer(snap, ver); // tombstone/옛버전이면 무시
}
void remove(int64_t id) {
auto e = getEntry(id);
int64_t gen;
{
std::lock_guard<std::mutex> lk(e->m);
e->deleted = true;
e->mem.reset(); // 마지막 참조 소멸 시에만 해제(UAF 없음)
gen = ++e->deleteGen;
++e->version;
}
db_->markDeleted(id); // 행 유지(tombstone)
schedulePurge(id, gen, 600);
}
bool restore(int64_t id) {
auto e = getEntry(id);
std::lock_guard<std::mutex> lk(e->m);
if (!e->deleted) return false;
auto ch = std::make_shared<Character>(db_->load(id));
ch->deleted = false;
ch->version = ++e->version;
e->deleted = false;
e->mem = ch;
++e->deleteGen; // 예약된 퍼지 무효화
db_->clearDeleted(id);
return true;
}
void purge(int64_t id, int64_t deleteGen) {
auto e = getEntry(id);
{
std::lock_guard<std::mutex> lk(e->m);
if (!e->deleted) return; // 복구됨 → 취소
if (e->deleteGen != deleteGen) return;
}
db_->hardDeleteIfGen(id, deleteGen);
}
private:
void schedulePurge(int64_t id, int64_t gen, int graceSec) { /* 타이머 */ }
std::mutex mapM_;
std::unordered_map<int64_t, std::shared_ptr<Entry>> entries_;
IDatabase* db_;
};
포인트
shared_ptr<Character>+ 값 복사 스냅샷 → 저장 중 객체가 해제돼도 UAF 없음.- 버전 펜싱(
upsertIfNewer)으로 늦은 저장이 tombstone 을 못 되살림(좀비 부활 차단). - 삭제 세대로 복구가 예약 퍼지를 무효화 + DB
hardDeleteIfGen으로 재기동에도 안전. - 엔트리 락으로 map/상태 직렬화 →
unordered_map동시 접근 UB 제거.
더 나은 설계 (+트레이드오프)
- 캐릭터 단위 액터(단일 라이터): save/remove/restore/purge 를 한 큐로 직렬 처리 → 락·UB 소멸, 순서 자명. 트레이드오프: 핫 엔트리 처리량 상한.
- 상태 컬럼 + 단조 version CAS 영속화(
WHERE id=? AND version<?, 삭제는 state): 다중 서버/리플레이에도 동일 불변식. 트레이드오프: 스키마/쿼리 복잡. - 퍼지를 (id, deleteGen) 멱등 잡으로: 타이머 취소 의존 대신 실행 시 세대 재확인 → 서버 재기동 안전. 트레이드오프: 잡 인프라.
- RAII/
weak_ptr로 콜백 수명 관리 + ASAN/TSAN 상시화로 UAF·레이스 회귀 차단.
면접 포인트 (예상 질문)
saveAsync가 rawCharacter*를 들고 느린 I/O 를 하는 동안remove가delete하면 왜 UAF 인가?shared_ptr와 값 스냅샷이 각각 어떻게 막는가?- 늦게 끝난
upsert가 삭제된 캐릭터를 되살리는 인터리빙과, 버전 펜싱이 막는 원리는? - 복구된 캐릭터가 예약 퍼지에 삭제되는 문제를 "삭제 세대" 로 푸는 방법과, 서버 재기동 시 타이머 취소만으로 부족한 이유는?