32. 길드 해산과 자금고/창고 접근의 수명·자금 경합
난이도 중해설 — 길드 해산과 자금고/창고 접근의 수명·자금 경합
난이도: 중
요약
Disband(해산)과 Withdraw(출금)이 같은 길드 상태를 락 없이 만진다.
출금의 "잔액 확인→차감→지급"과 해산의 "환급→0→제거"가 서로 끼어들어 같은
자금이 두 번 빠져나가고(환급 + 출금), 해산으로 길드가 레지스트리에서
사라진 뒤에도 이미 길드 참조를 얻은 출금이 진행돼 좀비 길드에 출금(무에서
자금 생성)이 일어난다. Dictionary 동시 접근 자체도 비스레드세이프다.
문제점
(1) [원자성] 자금 이중 유출 — (B)(C)
- 증상: 해산이
Bank.Give(leader, g.Funds)로 전액 환급하기 직전, 출금이g.Funds >= amount를 통과해 차감·지급한다. 결과적으로 같은 잔액이 환급과 출금으로 두 번 나간다(자금 복제). - 재현: 길드장이 해산을 누르는 순간 임원이 출금. 두 워커가 동시에
g.Funds를 읽고 각자 지급. - 근본원인: 확인-차감-지급(또는 환급-0)이 한 임계구역으로 묶이지 않은 read-modify-write 경합.
(2) [수명/TOCTOU] 제거된 길드에 대한 출금 — (A)(C)
- 증상: 출금이
TryGetValue로g참조를 얻은 직후 해산이Remove한다. 출금은 이미 손에 든g로 계속 진행 → 레지스트리에는 없는 "유령 길드"의 자금을 깎아 지급(무에서 자금 생성). 반대로 해산 후 다시 등록된 동명 길드와 섞일 수도. - 근본원인: 존재 확인(A)과 실제 사용 사이에 제거(C)가 끼어드는 구간이 보호 되지 않음. 상태 enum(해산됨 여부) 없이 "맵에 있나"로만 판단.
(3) [데이터 레이스] Dictionary 동시 접근
_guilds의 동시TryGetValue/Remove는 비스레드세이프(손상·예외 가능).
수정안
길드 단위 락으로 확인·차감·지급(또는 환급·제거)을 한 임계구역에 묶고, "해산됨" 상태 플래그로 좀비 접근을 차단한다.
public sealed class Guild
{
public long Id; public long LeaderId; public long Funds;
public List<int> Storage = new();
public bool Disbanded; // 수명 상태
public readonly object Gate = new();
}
public sealed class GuildService
{
private readonly ConcurrentDictionary<long, Guild> _guilds = new();
public bool Withdraw(long guildId, long memberId, long amount)
{
if (!_guilds.TryGetValue(guildId, out var g)) return false;
lock (g.Gate)
{
if (g.Disbanded) return false; // 해산된 길드 거부(좀비 차단)
if (g.Funds < amount) return false; // 확인·차감을 같은 락 안에서
g.Funds -= amount;
Bank.Give(memberId, amount);
return true;
}
}
public void Disband(long guildId, long leaderId)
{
if (!_guilds.TryGetValue(guildId, out var g)) return;
lock (g.Gate)
{
if (g.Disbanded || g.LeaderId != leaderId) return;
Bank.Give(g.LeaderId, g.Funds); // 환급도 같은 락 안에서
g.Funds = 0;
g.Storage.Clear();
g.Disbanded = true; // 먼저 무효화 → 이후 출금 거부됨
}
_guilds.TryRemove(guildId, out _); // 표시 후 제거
}
}
핵심: 자금 변경과 수명 전이를 같은 락에 두고, 제거 전에 Disbanded 로
먼저 무효화해 "맵에서 빠지는 순간에 든 옛 참조"의 출금을 거부한다.
더 나은 설계 (트레이드오프)
- 영속 트랜잭션: 자금 이동을 메모리가 아니라 DB 단일 트랜잭션(또는 멱등 키) 으로 커밋하면 서버 재기동에도 이중 유출을 막는다. 대신 지연·복잡도 ↑.
- 상태 머신: 길드 수명을
Active→Disbanding→Disbanded로 명시하고, 해산 진행 중에는 신규 출금을 막되 진행 중 작업은 정리. - 락 입도: 길드 단위 락으로 전역 경합을 피하고 핫 길드만 직렬화.
면접 포인트
- "객체가 컬렉션에 있나"로 수명을 판단하면 안 된다 — 참조를 든 뒤 제거되면 TOCTOU. 명시적 수명 상태 + 같은 락으로 좀비 접근을 막는다.
- 환급/출금처럼 같은 자원을 줄이는 경로가 둘이면, 확인·차감을 반드시 같은 임계구역에 둬 총합을 보존.
- 제거(Remove)와 무효화(플래그) 순서: 무효화 먼저, 제거 나중.
해설 — 길드 해산과 자금고/창고 접근의 수명·자금 경합 (C++)
난이도: 중
요약
disband(해산)과 withdraw(출금)이 같은 길드 상태를 동기화 없이 만진다.
출금의 "잔액 확인→차감→지급"과 해산의 "환급→0→erase→delete"가 끼어들어 같은
자금이 두 번 유출되고, 결정적으로 해산이 delete g 한 뒤 출금이 그 포인터를
역참조하면 use-after-free(UB) 가 난다. unordered_map 동시 접근 자체도
데이터 레이스다.
문제점
(1) [원자성] 자금 이중 유출 — (B)(C)
- 증상: 해산이
Bank::give(leader, g->funds)로 전액 환급하기 직전, 출금이g->funds >= amount를 통과해 차감·지급 → 같은 잔액이 두 번 나감(자금 복제). - 근본원인: 확인-차감-지급(또는 환급-0)이 한 임계구역으로 묶이지 않은 경합.
(2) [use-after-free] 해제된 길드 접근 — (A)(C)
- 증상: 출금이
find로g포인터를 얻은 직후 해산이erase+delete g를 한다. 출금은 이미 손에 든g로g->funds를 읽고 써 해제된 메모리를 역참조(UB) — 크래시 또는 조용한 메모리 손상. - 근본원인: 잠금 없는 환경에서 "다른 스레드가 보고 있을 수 있는 객체"를
즉시
delete. 수명 관리(소유권/참조 카운트)와 동기화가 없다.
(3) [데이터 레이스] unordered_map 동시 접근
find/erase동시 호출은 그 자체로 UB(리해시 중 노드 이동).
수정안
맵 자체를 뮤텍스로 보호하고, 길드를 shared_ptr 로 소유해 참조가 살아 있는
동안 해제되지 않게 한다. 길드 단위 락으로 자금 변경과 수명 전이를 묶고,
disbanded 플래그로 좀비 접근을 막는다.
struct Guild {
int64_t id, leaderId, funds;
std::vector<int> storage;
bool disbanded = false; // 수명 상태
std::mutex gate; // 길드 단위 락(복사 불가 → shared_ptr 로 보관)
};
class GuildService {
public:
void registerGuild(std::shared_ptr<Guild> g) {
std::lock_guard<std::mutex> lk(mapMtx_);
guilds_[g->id] = std::move(g);
}
bool withdraw(int64_t guildId, int64_t memberId, int64_t amount) {
auto g = lookup(guildId); // shared_ptr 사본 → 수명 보장
if (!g) return false;
std::lock_guard<std::mutex> lk(g->gate);
if (g->disbanded) return false; // 해산된 길드 거부
if (g->funds < amount) return false; // 확인·차감을 같은 락 안에서
g->funds -= amount;
Bank::give(memberId, amount);
return true;
}
void disband(int64_t guildId, int64_t leaderId) {
auto g = lookup(guildId);
if (!g) return;
{
std::lock_guard<std::mutex> lk(g->gate);
if (g->disbanded || g->leaderId != leaderId) return;
Bank::give(g->leaderId, g->funds);
g->funds = 0;
g->storage.clear();
g->disbanded = true; // 먼저 무효화
}
std::lock_guard<std::mutex> lk(mapMtx_);
guilds_.erase(guildId); // 표시 후 맵에서만 제거
} // 객체는 마지막 shared_ptr 가 사라질 때 해제
private:
std::shared_ptr<Guild> lookup(int64_t id) {
std::lock_guard<std::mutex> lk(mapMtx_);
auto it = guilds_.find(id);
return it == guilds_.end() ? nullptr : it->second;
}
std::mutex mapMtx_;
std::unordered_map<int64_t, std::shared_ptr<Guild>> guilds_;
};
핵심: shared_ptr 로 "보고 있는 동안 해제 안 됨"을 보장(UAF 차단) + 자금
변경과 수명 전이를 같은 락에 두고 무효화 먼저, 제거 나중.
더 나은 설계 (트레이드오프)
- 영속 트랜잭션/멱등 키: 자금 이동을 DB 트랜잭션으로 커밋해 재기동에도 이중 유출 방지.
- 상태 머신:
Active→Disbanding→Disbanded로 신규 출금 차단·진행 작업 정리. delete대신 소유권 기반 수명 관리(shared_ptr/풀)로 잠금 없는 경로의 UAF 원천 차단.
면접 포인트
- C++ 에서 "다른 스레드가 참조 가능한 객체"를 즉시
delete하면 UAF. 수명은shared_ptr/참조 카운트로, 동기화는 별도로 — 둘은 다른 문제다. - 존재 확인(맵에 있나)과 사용 사이의 TOCTOU + 같은 자원 감소 경로의 원자성.
- 무효화(플래그)와 제거(erase)·해제(delete)의 순서 설계.