← 문제로

8. 길드 자금고 동시 출금

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 길드 자금고 동시 출금

난이도: 상

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

출금에서 잔액 확인(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" 를 엮으면 시니어 수준.
  • 예상 질문:
    1. "차감만 락으로 감쌌는데 왜 잔액이 음수가 되나?" → 잔액 검증이 락 밖이라 두 출금이 같은 잔액을 보고 둘 다 통과(TOCTOU). 검증을 같은 임계 구역에 넣어야 한다.
    2. "한 임원이 동시에 두 번 출금해 한도를 뚫는 이유는?" → 누적 read-modify-write 가 비원자(lost update). 같은 락/DB UNIQUE(officer,day)로.
    3. "같은 길드원이 두 서버에 붙어 동시에 출금하면?" → 인메모리 락 무력. DB 행 잠금/조건부 UPDATE로 직렬화. 잔액은 이벤트 합으로 도출.