← 문제로

1. 골드 이체 서비스

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

해설 — 골드 이체 서비스

난이도: 중

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

요약

플레이어 간 골드 이체. 보내는 쪽 차감(C)과 받는 쪽 가산(D)을 별개의 락 구간으로 쪼개고, 멱등성 체크(B)/기록(E)도 락 밖에서 처리한다. 그 결과 총합 비보존(골드 증발/복사), 멱등성 무력화로 인한 중복 이체, 공유 자료구조 동시성 버그가 동시에 존재한다. 핵심 통찰은 "이체는 차감+가산+멱등성기록이 하나의 직렬화된 트랜잭션이어야 한다" 는 것이다.

난이도가 '중'인 이유: 락 자체는 걸려 있어 "락만 추가하면 끝" 으로 보이기 쉽지만, 진짜 어려움은 (1) 임계 구역의 경계를 어디로 잡아야 원자성이 성립하는지, (2) 두 락을 동시에 잡을 때 생기는 락 순서 데드락을 피하는 것, (3) 멱등성이 왜 "직렬화" 로만 보장되는지를 설명하는 데 있다. 단순 "락 빠짐" 문제가 아니다.


문제점

(C)+(D) 차감과 가산이 한 트랜잭션이 아님 — 원자성 위반 (정확성/동시성)

  • 증상: 차감은 됐는데 가산 직전 예외가 나거나 서버가 죽으면 골드가 증발한다. 중간 상태(차감만 반영된 상태)가 다른 스레드/감사에 관측된다.
  • 재현 조건: from.Gold -= amount 직후 lock (to.Lock) 진입 전에 _players[toId] 접근이 던지는 경우(받는 사람 탈퇴/잘못된 ID로 KeyNotFoundException은 (D) 이전 _players[toId] 에서 이미 나지만, 일반화하면 두 락 사이의 어떤 예외/크래시든) 차감만 반영되고 가산은 누락 → 총합 감소. 두 lock 구간이 분리돼 있어 그 사이가 관측 가능한 중간 상태다.
  • 근본 원인: 이체는 "차감+가산"이 하나의 원자 단위여야 하는데 두 개의 독립 락 구간으로 쪼개졌다. 실패 시 롤백 경로도 없다.

(B)+(E) 멱등성 체크/기록이 락 밖 — 중복 이체 (정확성/동시성/보안)

  • 증상: 같은 requestId 두 요청이 동시에 들어오면 골드가 두 번 이동한다. 어뷰저가 의도적으로 같은 요청을 동시에 쏘면 재화 복사로 이어진다.
  • 재현 조건: 동일 requestId 두 요청이 거의 동시에 도착. 둘 다 (B)에서 "아직 없음" 을 통과(이 시점엔 누구도 (E)에서 Add 하지 않았다) → 둘 다 이체 수행 → 둘 다 (E)에서 Add. 체크와 기록 사이가 비어 있어 두 스레드가 같은 틈으로 빠져나간다. 게다가 HashSet 은 스레드 안전하지 않아 동시 Add/Contains 가 내부 구조를 손상 시켜 무한 루프/오동작까지 유발할 수 있다.
  • 근본 원인 (★멱등성이 왜 직렬화로만 성립하는가): 멱등성은 "이 requestId 가 이미 처리됐는가" 라는 질문에 대한 답이 처리 도중에 바뀌면 안 된다. 즉 Contains(check) → 이체(process) → Add(record) 세 단계가 불가분(원자적) 이어야 한다. 어떤 락도 이 셋을 함께 감싸지 않으면, 두 스레드가 동시에 Contains==false 를 관측하는 TOCTOU 창이 항상 존재한다. 이 창을 없애는 유일한 방법은 "같은 requestId 에 대한 체크-처리-기록 전체를 한 번에 한 스레드만 실행하도록 직렬화" 하는 것이다.
    • 수정안에서 멱등성 검사/기록을 이체와 같은 임계 구역 안에 넣으면, 락을 먼저 잡은 스레드가 처리+기록을 끝낸 뒤에야 두 번째 스레드가 임계 구역에 들어온다. 그때는 이미 _processed 에 requestId 가 있으므로 두 번째는 Contains==true 로 조기 반환한다. "먼저 들어온 자가 기록까지 마친다" 는 직렬 순서가 보장되기 때문에 중복이 원천적으로 불가능해진다. 락이 보장하는 것은 상호 배제이고, 그 상호 배제가 곧 "체크-처리-기록의 불가분 실행 = 직렬화" 다.

(G) TotalGold 가 락 없이 순회 (동시성)

  • 증상: 총합 감사(audit) 값이 틀린다. Dictionary 순회 중 다른 스레드가 _players 를 수정하면 InvalidOperationException(컬렉션 변경됨).
  • 근본 원인: 공유 컬렉션 비보호 순회 + long 필드를 락 없이 읽음(64비트 정렬 읽기는 보통 찢기지 않으나 메모리 가시성/이체 도중 스냅샷 정합성은 별개 문제).

(A)+(F) Gold 가 public 가변 필드 (유지보수/보안)

  • 어디서든 player.Gold = ... 로 직접 수정 가능 → 락/멱등성/감사 경로를 우회하는 코드가 언제든 생긴다. 캡슐화 부재가 위 모든 보장을 무력화한다.

락 순서: 잠재적 데드락 (동시성)

  • 현재는 lock 구간이 겹치지 않아 데드락은 없다. 그러나 원자성을 위해 두 락을 동시에 잡도록 고치는 순간, Transfer(A→B)Transfer(B→A) 가 동시에 일어나면 락 순서 역전 데드락이 생긴다. 수정안에서 반드시 전역 락 순서로 회피해야 한다.

수정안

핵심: ① 두 플레이어 락을 항상 같은 순서(Id 오름차순) 로 잡아 차감+가산을 하나의 원자 트랜잭션으로 만들고, ② 멱등성 체크/기록을 같은 임계 구역 안에서 처리해 체크-처리-기록을 직렬화한다.

public class GoldService
{
    private readonly Dictionary<long, Player> _players;
    private readonly object _ledgerLock = new object();   // _processed 보호 전용
    private readonly HashSet<Guid> _processed = new HashSet<Guid>();

    public GoldService(Dictionary<long, Player> players) => _players = players;

    public bool Transfer(Guid requestId, long fromId, long toId, long amount)
    {
        if (amount <= 0 || fromId == toId) return false;

        if (!_players.TryGetValue(fromId, out var from)) return false;
        if (!_players.TryGetValue(toId,   out var to))   return false;

        // 데드락 회피: 항상 Id 오름차순으로 락 획득(전역 락 순서 규칙)
        Player first  = fromId < toId ? from : to;
        Player second = fromId < toId ? to   : from;

        lock (first.Lock)
        lock (second.Lock)
        {
            // 멱등성: 체크-처리-기록을 같은 임계 구역에서 → 직렬화로 중복 차단.
            // 두 플레이어 락을 잡은 상태이므로, 같은 (from,to) 쌍에 대한 동시 요청은
            // 여기서 한 번에 하나만 통과한다. 먼저 들어온 스레드가 _processed 에
            // 기록까지 마치고 락을 놓아야 다음 스레드가 들어오고, 그땐 이미 처리됨.
            lock (_ledgerLock)
            {
                if (_processed.Contains(requestId)) return true;   // 이미 처리 → 멱등
            }

            if (from.Gold < amount) return false;

            // 차감+가산이 같은 임계 구역 → 중간 상태 관측 불가, 총합 보존
            from.Gold -= amount;
            to.Gold   += amount;

            lock (_ledgerLock) { _processed.Add(requestId); }
        }
        return true;
    }

    public long TotalGold()
    {
        // 감사 정합성: 모든 플레이어 락을 일관된 순서로 잡거나(아래 더 나은 설계),
        // 최소한 각 플레이어 락 안에서 읽는다.
        long sum = 0;
        foreach (var p in _players.Values) { lock (p.Lock) sum += p.Gold; }
        return sum;
    }
}

주의: 두 _ledgerLock 구간(체크/기록)은 모두 두 플레이어 락 안쪽에 있으므로, 같은 requestId 의 동시 요청은 같은 (from,to) 락을 두고 직렬화된다. 만약 서로 다른 (from,to) 쌍이 우연히 같은 requestId 를 보낸다면(정상 도메인에선 없음), _ledgerLock 자체가 _processed 접근을 직렬화하므로 여전히 한 번만 처리된다. 락 획득 순서는 항상 first.Lock → second.Lock → _ledgerLock 로 고정되어 데드락이 없다.

Gold 는 private 으로 바꾸고 Deposit/Withdraw 메서드로만 접근하게 캡슐화한다.


더 나은 설계

1) 진짜 영속성이 필요하면 DB 트랜잭션이 정답

인메모리 락은 프로세스 크래시에 무력하다. 차감 후 크래시 = 골드 증발. 실서비스 재화는 DB 트랜잭션 + 멱등성 키로 처리한다.

BEGIN;
-- UNIQUE(request_id): 중복이면 INSERT 가 실패 → "이미 처리"로 멱등 응답
INSERT INTO transfers(request_id, from_id, to_id, amount) VALUES(?,?,?,?);
UPDATE players SET gold = gold - ? WHERE id = ? AND gold >= ?;  -- affected=0 → 잔액부족 → ROLLBACK
UPDATE players SET gold = gold + ? WHERE id = ?;
COMMIT;
  • 트레이드오프: DB 왕복 지연. 대신 원자성/내구성/멱등성이 한 번에 보장된다. 여기서 UNIQUE(request_id) 가 인메모리의 "같은 임계 구역" 역할을 DB 차원의 직렬화로 대체한다 — 멱등성을 락이 아니라 제약(constraint) 으로 강제.

2) 락-프리/싱글스레드 액터 모델

재화 계정을 샤딩해 각 샤드를 단일 스레드(액터)가 소유하면 락 자체가 사라진다.

  • 같은 샤드 내 이체는 락 없이 안전(직렬 처리). 샤드 간 이체는 2-페이즈/사가 패턴.
  • 트레이드오프: 크로스 샤드 트랜잭션 복잡도 증가.

3) 멱등성 키 만료

_processed 가 무한히 커진다. requestId 에 TTL(예: 24h)을 두거나 DB UNIQUE + 주기적 파티션 정리로 메모리 누수를 막는다.


면접 포인트

  • 면접관이 듣고 싶은 핵심: "이체는 단일 트랜잭션이고, 멱등성은 체크-처리-기록의 직렬화로만 성립한다" 는 통찰. 원자성·총합 보존·멱등성·데드락 회피(락 순서)를 하나로 엮어 설명하고, 특히 "왜 멱등성 체크가 락 안에 있어야 하는가(TOCTOU 창 제거)" 를 메커니즘으로 짚으면 강하다.
  • 예상 질문:
    1. "멱등성 체크를 락 밖에서 하면 정확히 무슨 일이 생기나?" → 두 동시 요청이 모두 Contains==false 를 관측하는 TOCTOU 창이 열려 둘 다 이체. 체크-처리-기록을 한 임계 구역에 넣어 직렬화해야 그 창이 닫힌다.
    2. "두 플레이어 락을 동시에 잡으면 데드락은?" → 전역 락 순서(Id 오름차순) 고정. A→B 와 B→A 가 같은 순서로 잡혀 역전이 없다.
    3. "차감 후 서버가 죽으면 인메모리로 막을 수 있나?" → 못 막는다. 영속 트랜잭션/원장 로그가 본질적 해법. DB UNIQUE(request_id)가 멱등성까지 함께 보장한다.