8. 길드 자금고 동시 출금
난이도 상해설 — 길드 자금고 동시 출금
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
출금에서 잔액 확인(E)·한도 확인(F)이 락 밖에 있고, 차감(G)만 락 안, 누적/로그
갱신(H)은 다시 락 밖이다. 즉 "검증 → 차감 → 한도·로그 기록" 이 하나의 임계 구역으로
묶이지 않아 (1) 잔액을 초과한 over-withdraw, (2) 일일 한도 lost update 로 우회,
(3) 차감과 로그의 불일치(감사 붕괴), (4) 공유 Dictionary/List 동시 수정 손상이
모두 발생한다. 락 객체(bank.Lock)가 있는데도 보호 범위가 잘못 그어진 전형적 사례다.
문제점
(E)→(G) 잔액 검증과 차감 분리 — over-withdraw (정확성/동시성/보안) ★간판
- 증상: 금고 잔액보다 더 많이 출금돼 잔액이 음수가 된다(자금 복사).
- 재현 조건: 잔액 1000, 두 임원이 동시에 800씩 출금. 둘 다 (E)에서
Balance(1000) >= 800을 통과 → 각자 (G)lock안에서Balance -= 800→ 잔액 -600. 검증 시점의 잔액이 차감 시점엔 이미 남이 가져갔다(TOCTOU).lock이 차감 한 줄만 감싸고 검증을 포함하지 않아, 락은 "동시에 두 차감이 겹쳐 깨지는 것" 만 막을 뿐 순차적 over-withdraw 를 못 막는다. - 근본 원인: "잔액이 충분하면 그만큼 뺀다" 는 조건부 차감이 검증+차감 원자성을 요구하는데, 검증이 임계 구역 밖에 있다.
(F)→(H) 일일 한도 lost update — 한도 우회 (정확성/동시성/보안)
- 증상: 일일 한도가 1000인데 한 임원이 하루에 1800을 빼간다.
- 재현 조건: 한 임원이 두 클라이언트로 동시에 900씩 출금. 둘 다 (F)에서
usedToday=0(아직 아무도 (H)에 기록 안 함)을 읽어0+900 <= 1000통과 → 둘 다 (H)에서WithdrawnToday[id] = 0+900 = 900. 두 출금이 모두 반영(1800)됐지만 누적은 900으로만 기록 → 한도가 영구히 어긋나고 추가 출금까지 허용된다(고전적 lost update). - 근본 원인: 누적값 read(F)-modify-write(H)가 비원자적이고, 검증과 갱신 사이에 동시
요청이 끼어든다. 게다가
Dictionary자체가 스레드 안전하지 않다.
(G) vs (H) 차감과 로그가 다른 구간 — 감사 불일치 (정확성)
- 증상:
Balance는 줄었는데 (H) 직전 예외/크래시로 로그가 누락되거나, 반대로 로그만 남고 차감과 어긋난다. 요구사항 "로그 합계 = 잔액 변화" 가 깨진다. - 재현 조건: (G) 성공 후 (H)에서
Logs.Add/Dictionary접근이 동시 수정과 충돌해 예외 → 차감만 반영. 감사 로그로 실제 출금을 재구성할 수 없게 된다. - 근본 원인: 잔액 변경과 그 변경의 회계 기록이 원자적으로 함께 일어나지 않는다.
(B)+(C) 공유 컬렉션 비보호 — 자료구조 손상 (동시성)
WithdrawnToday(Dictionary),Logs(List)를 락 밖에서 동시 읽기/쓰기 → rehash/resize 중 내부 손상,InvalidOperationException, 또는 항목 유실.
(E) 잔액 읽기도 락 밖 (동시성)
bank.Balance를 락 없이 읽어 stale 값/가시성 문제. (D) Deposit 은 락 안에서 쓰는데 (E) Withdraw 는 락 밖에서 읽어 보호 비대칭.
권한/일자 경계 (정확성/보안) — 부차적
- officer 권한 검증이 코드에 없다(요구사항엔 "권한 있는 임원"). 또
WithdrawnToday가 "오늘" 의 경계(자정 리셋)를 어떻게 관리하는지 없음 — 날짜 키가 없어 한도가 영원히 누적되거나 리셋 시점에 경합.
수정안
핵심: 검증(잔액+한도) → 차감 → 누적/로그 갱신 전체를 bank.Lock 한 임계 구역으로
묶는다. 모든 실패 검증을 상태 변경 전에 끝내 롤백을 없앤다.
public bool Withdraw(long guildId, long officerId, long amount)
{
if (amount <= 0) return false;
if (!_banks.TryGetValue(guildId, out var bank)) return false;
// (권한 검증: officer 가 이 길드의 출금 권한이 있는지 — 서버 권위로 확인)
if (!HasWithdrawPermission(guildId, officerId)) return false;
lock (bank.Lock) // 검증-차감-기록을 하나의 임계 구역으로 → 직렬화
{
// 일자 경계: 날짜가 바뀌었으면 누적 리셋(또는 (date,officer) 키 사용)
RollDailyIfNeeded(bank);
if (bank.Balance < amount) return false; // over-withdraw 차단
long usedToday = bank.WithdrawnToday.TryGetValue(officerId, out var u) ? u : 0;
if (usedToday + amount > bank.DailyLimit) return false; // 한도 우회 차단
// 모두 통과 → 커밋(같은 임계 구역이라 부분 반영/lost update 없음)
bank.Balance -= amount;
bank.WithdrawnToday[officerId] = usedToday + amount;
bank.Logs.Add(new WithdrawLog {
OfficerId = officerId, Amount = amount, At = DateTime.UtcNow
});
return true;
}
}
public bool Deposit(long guildId, long playerId, long amount)
{
if (amount <= 0) return false;
if (!_banks.TryGetValue(guildId, out var bank)) return false;
lock (bank.Lock) { bank.Balance += amount; }
return true;
}
단일
bank.Lock만 사용하므로 락 순서 데드락이 없다(중첩 락 없음). Deposit 도 같은 락을 써서 잔액 읽기/쓰기의 가시성 비대칭을 없앤다.
더 나은 설계
1) 영속 트랜잭션 + 조건부 UPDATE (멀티 인스턴스 대비)
인메모리 락은 길드원이 다른 게임 서버에 붙어 동시에 출금하면 무력하다. DB로:
BEGIN;
-- 잔액과 한도를 한 번에 검사+차감(조건 불충족 시 affected=0 → 롤백)
UPDATE guild_bank SET balance = balance - ?
WHERE guild_id = ? AND balance >= ?;
UPDATE officer_daily SET used = used + ?
WHERE guild_id = ? AND officer_id = ? AND day = ? AND used + ? <= ?;
INSERT INTO withdraw_log(guild_id, officer_id, amount, at) VALUES(?,?,?,now());
COMMIT; -- 두 UPDATE 중 하나라도 affected=0 이면 전체 롤백
officer_daily에(guild_id, officer_id, day)UNIQUE → 일자 경계와 lost update 를 DB 직렬화로 해결. 트레이드오프: DB 왕복. 자금 정합성·감사가 절대 우선이라 정당.
2) 감사 로그를 단일 진실원(이벤트 소싱)으로
Balance를 출금/입금 이벤트의 합으로 도출(append-only ledger)하면 "로그=잔액" 이 정의상 보장된다. 잔액은 캐시일 뿐. 분쟁/롤백 시 재계산 가능.
3) 한도/권한을 데이터·정책으로 외부화
- 일일 한도, 등급별 권한을 코드 상수가 아니라 설정/DB로 두고 모든 출금을 권한 검증과 함께 기록. 길드 자금 횡령은 운영 클레임 1순위라 감사 추적이 중요.
면접 포인트
- 면접관이 듣고 싶은 핵심: 락이 있어도 "보호 범위" 가 검증을 포함하지 않으면 over-withdraw 와 lost update 를 못 막는다는 통찰. "검증-차감-기록을 한 트랜잭션으로 직렬화" + "멀티 인스턴스면 DB 조건부 UPDATE/UNIQUE" 를 엮으면 시니어 수준.
- 예상 질문:
- "차감만 락으로 감쌌는데 왜 잔액이 음수가 되나?" → 잔액 검증이 락 밖이라 두 출금이 같은 잔액을 보고 둘 다 통과(TOCTOU). 검증을 같은 임계 구역에 넣어야 한다.
- "한 임원이 동시에 두 번 출금해 한도를 뚫는 이유는?" → 누적 read-modify-write 가 비원자(lost update). 같은 락/DB UNIQUE(officer,day)로.
- "같은 길드원이 두 서버에 붙어 동시에 출금하면?" → 인메모리 락 무력. DB 행 잠금/조건부 UPDATE로 직렬화. 잔액은 이벤트 합으로 도출.
해설 — 길드 자금고 동시 출금
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
출금에서 잔액 확인(E)·한도 확인(F)이 락 밖에 있고, 차감(G)만 락 안, 누적/로그
갱신(H)은 다시 락 밖이다. 즉 "검증 → 차감 → 한도·로그 기록" 이 하나의 임계 구역으로
묶이지 않아 (1) 잔액을 초과한 over-withdraw, (2) 일일 한도 lost update 로 우회,
(3) 차감과 로그의 불일치(감사 붕괴), (4) 공유 unordered_map/vector 동시 변경
UB가 모두 발생한다. bank->lock 이 있는데도 보호 범위가 잘못 그어진 전형적 사례다.
문제점
(E)→(G) 잔액 검증과 차감 분리 — over-withdraw (정확성/동시성/보안) ★간판
- 증상: 금고 잔액보다 더 많이 출금돼 잔액이 음수가 된다(자금 복사).
- 재현 조건: 잔액 1000, 두 임원이 동시에 800씩 출금. 둘 다 (E)에서
balance(1000) >= 800을 통과 → 각자 (G)lock안에서balance -= 800→ 잔액 -600. 검증 시점의 잔액이 차감 시점엔 이미 남이 가져갔다(TOCTOU).lock이 차감 한 줄만 감싸고 검증을 포함하지 않아, 락은 "동시에 두 차감이 겹쳐 깨지는 것" 만 막을 뿐 순차적 over-withdraw 를 못 막는다. - 근본 원인: "잔액이 충분하면 그만큼 뺀다" 는 조건부 차감이 검증+차감 원자성을 요구하는데, 검증이 임계 구역 밖에 있다.
(F)→(H) 일일 한도 lost update + map 동시 변경 — 한도 우회/UB (정확성/동시성/보안)
- 증상: 일일 한도가 1000인데 한 임원이 하루에 1800을 빼간다. 최악엔
unordered_map동시 변경으로 rehash 중 손상 → 크래시. - 재현 조건: 한 임원이 두 클라이언트로 동시에 900씩 출금. 둘 다 (F)에서
usedToday=0(아직 아무도 (H)에 기록 안 함)을 읽어0+900 <= 1000통과 → 둘 다 (H)에서withdrawnToday[id] = 0+900 = 900. 두 출금이 모두 반영(1800)됐지만 누적은 900으로만 기록 → 한도가 영구히 어긋난다(고전적 lost update). 또한 (H) 의withdrawnToday[...]쓰기와 다른 스레드의 (F)count()/operator[]가 락 밖에서 겹치면 컨테이너 손상 = UB. - 근본 원인: 누적값 read(F)-modify-write(H)가 비원자적이고, 검증과 갱신 사이에 동시
요청이 끼어든다. 게다가
unordered_map은 같은 객체 동시 쓰기를 보장하지 않는다.
(G) vs (H) 차감과 로그가 다른 구간 — 감사 불일치 (정확성)
- 증상:
balance는 줄었는데 (H) 직전 예외/크래시로 로그가 누락되거나, 반대로 로그만 남고 차감과 어긋난다. 요구사항 "로그 합계 = 잔액 변화" 가 깨진다. - 재현 조건: (G) 성공 후 (H)에서
logs.push_back의 재할당/unordered_map접근이 동시 수정과 충돌해 손상/예외 → 차감만 반영. 감사 로그로 실제 출금을 재구성 불가. - 근본 원인: 잔액 변경과 그 변경의 회계 기록이 원자적으로 함께 일어나지 않는다.
(B)+(C) 공유 컨테이너 비보호 — 자료구조 손상 (동시성)
withdrawnToday(unordered_map),logs(vector)를 락 밖에서 동시 읽기/쓰기 → rehash/재할당 중 내부 손상, 반복자 무효화, 항목 유실, UB.
(E) 잔액 읽기도 락 밖 (동시성)
bank->balance를 락 없이 읽어 데이터 레이스/가시성 문제. (D) Deposit 은 락 안에서 쓰는데 (E) Withdraw 는 락 밖에서 읽어 보호 비대칭.
권한/일자 경계 (정확성/보안) — 부차적
- officer 권한 검증이 코드에 없다(요구사항엔 "권한 있는 임원"). 또
withdrawnToday가 "오늘" 의 경계(자정 리셋)를 어떻게 관리하는지 없음 — 날짜 키가 없어 한도가 영원히 누적되거나 리셋 시점에 경합.
수정안
핵심: 검증(잔액+한도) → 차감 → 누적/로그 갱신 전체를 bank->lock 한 임계 구역으로
묶는다. 모든 실패 검증을 상태 변경 전에 끝내 롤백을 없앤다.
bool Withdraw(int64_t guildId, int64_t officerId, int64_t amount) {
if (amount <= 0) return false;
auto it = banks_.find(guildId);
if (it == banks_.end()) return false;
GuildBank* bank = it->second;
// (권한 검증: officer 가 이 길드의 출금 권한이 있는지 — 서버 권위로 확인)
if (!HasWithdrawPermission(guildId, officerId)) return false;
std::lock_guard<std::mutex> lk(bank->lock); // 검증-차감-기록을 하나의 임계 구역으로
// 일자 경계: 날짜가 바뀌었으면 누적 리셋(또는 (date,officer) 키 사용)
RollDailyIfNeeded(bank);
if (bank->balance < amount) return false; // over-withdraw 차단
int64_t usedToday = bank->withdrawnToday.count(officerId)
? bank->withdrawnToday[officerId] : 0;
if (usedToday + amount > bank->dailyLimit) return false; // 한도 우회 차단
// 모두 통과 → 커밋(같은 임계 구역이라 부분 반영/lost update 없음)
bank->balance -= amount;
bank->withdrawnToday[officerId] = usedToday + amount;
int64_t now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
bank->logs.push_back(WithdrawLog{officerId, amount, now});
return true;
}
bool Deposit(int64_t guildId, int64_t playerId, int64_t amount) {
if (amount <= 0) return false;
auto it = banks_.find(guildId);
if (it == banks_.end()) return false;
std::lock_guard<std::mutex> lk(it->second->lock);
it->second->balance += amount;
return true;
}
단일
bank->lock만 사용하므로 락 순서 데드락이 없다(중첩 락 없음). Deposit 도 같은 락을 써서 잔액 읽기/쓰기의 가시성 비대칭과 컨테이너 동시 변경을 모두 없앤다.
더 나은 설계
1) 영속 트랜잭션 + 조건부 UPDATE (멀티 인스턴스 대비)
인메모리 락은 길드원이 다른 게임 서버에 붙어 동시에 출금하면 무력하다. DB로:
BEGIN;
-- 잔액과 한도를 한 번에 검사+차감(조건 불충족 시 affected=0 → 롤백)
UPDATE guild_bank SET balance = balance - ?
WHERE guild_id = ? AND balance >= ?;
UPDATE officer_daily SET used = used + ?
WHERE guild_id = ? AND officer_id = ? AND day = ? AND used + ? <= ?;
INSERT INTO withdraw_log(guild_id, officer_id, amount, at) VALUES(?,?,?,now());
COMMIT; -- 두 UPDATE 중 하나라도 affected=0 이면 전체 롤백
officer_daily에(guild_id, officer_id, day)UNIQUE → 일자 경계와 lost update 를 DB 직렬화로 해결. 트레이드오프: DB 왕복. 자금 정합성·감사가 절대 우선이라 정당.
2) 감사 로그를 단일 진실원(이벤트 소싱)으로
balance를 출금/입금 이벤트의 합으로 도출(append-only ledger)하면 "로그=잔액" 이 정의상 보장된다. 잔액은 캐시일 뿐. 분쟁/롤백 시 재계산 가능.
3) 한도/권한을 데이터·정책으로 외부화
- 일일 한도, 등급별 권한을 코드 상수가 아니라 설정/DB로 두고 모든 출금을 권한 검증과 함께 기록. 길드 자금 횡령은 운영 클레임 1순위라 감사 추적이 중요.
면접 포인트
- 면접관이 듣고 싶은 핵심: 락이 있어도 "보호 범위" 가 검증을 포함하지 않으면 over-withdraw 와 lost update 를 못 막는다는 통찰. "검증-차감-기록을 한 트랜잭션으로 직렬화" + "멀티 인스턴스면 DB 조건부 UPDATE/UNIQUE" 를 엮으면 시니어 수준.
- 예상 질문:
- "차감만 락으로 감쌌는데 왜 잔액이 음수가 되나?" → 잔액 검증이 락 밖이라 두 출금이 같은 잔액을 보고 둘 다 통과(TOCTOU). 검증을 같은 임계 구역에 넣어야 한다.
- "한 임원이 동시에 두 번 출금해 한도를 뚫는 이유는?
unordered_map도 왜 문제?" → 누적 read-modify-write 가 비원자(lost update). 게다가 동시 map 변경은 UB. 같은 락 안에서 처리하거나 DB UNIQUE(officer,day)로. - "같은 길드원이 두 서버에 붙어 동시에 출금하면?" → 인메모리 락 무력. DB 행 잠금/조건부 UPDATE로 직렬화. 잔액은 이벤트 합으로 도출.