← 문제로

23. 재화를 부동소수점으로 다룰 때의 총량 붕괴와 판정 오류 (C#)

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

해설 — 재화를 부동소수점으로 다룰 때의 총량 붕괴와 판정 오류 (C#)

난이도: 중

요약

게임 재화를 double 과 REAL 컬럼으로 다룬다. 부동소수점은 십진 소수를 정확히 표현하지 못하고, 연산마다 반올림 오차가 누적되며, 값이 커질수록 표현 가능한 최소 증분이 커진다. 그 결과 "잔액 == 가격"인 구매가 실패하고, 나눗셈 분배에서 총량이 보존되지 않으며, 큰 잔액에 소액 가산이 삼켜진다. 여기에 읽기-수정-쓰기 갱신과 원장 기록의 비원자성까지 겹친다.

문제점

1) 정확히 잔액만큼의 구매가 실패 — (A) [표현 오차]

  • 증상: 잔액 100.0, 정가 100.0, 할인 10%, 세율 10% 인 구매가 "골드 부족"으로 실패한다.
  • 재현 조건:
    double price = 100.0 * (1.0 - 0.1) * (1.0 + 0.1);
    Console.WriteLine(price == 99.0);        // False
    Console.WriteLine(price.ToString("R"));  // 99.00000000000001
    
    0.1 은 이진 부동소수점으로 정확히 표현되지 않는다. 곱셈 두 번의 반올림이 누적되어 값이 이론값보다 커진다. 잔액이 99.0 이면 balance < price 가 참이 되어 실패한다.
  • 근본 원인: 화폐 계산에 이진 부동소수점을 썼다. 게다가 곱셈 순서가 결과를 바꾸므로 (부동소수점 곱셈은 결합법칙이 성립하지 않는다) 같은 거래를 다른 코드 경로로 계산하면 다른 값이 나와 감사 재현성도 깨진다.

2) 분배에서 총량 비보존 — (B) [분배/반올림]

  • 증상: 총 100 골드를 6명에게 분배하면 각자 16.666666666666668 을 받고 합은 100.00000000000001 이 된다(총량 증가). 12명이면 각자 8.333333333333334, 합은 99.999999999999986 으로 총량이 줄어든다. 반복되면 서버 전체 골드가 양쪽으로 표류한다.
  • 주의: 나누어떨어지지 않는다고 항상 어긋나지는 않는다. 3명(33.333333333333336)은 세 배의 오차가 100 근방 ULP 의 정확히 절반이 되어 ties-to-even 으로 100.0 에 떨어진다. "어떤 경우에는 우연히 맞는다"는 사실이 이 결함을 더 위험하게 만든다 — 테스트에서 통과하고 운영에서 터진다.
  • 재현 조건: members.Count 가 총액을 나누어떨어지지 않게 하는 경우 중, 반올림 오차가 상쇄되지 않는 조합(6, 7, 12 등).
  • 근본 원인: 나눗셈 결과를 그대로 각자에게 지급하고, 나머지를 어떻게 처리할지에 대한 정책이 없다. 화폐 분배는 "몫 + 나머지 배분"이라는 명시적 규칙이 필요하다.

3) 큰 잔액에서 소액 가산 소실 — [정밀도 흡수]

  • 증상: 잔액이 1e16 을 넘는 유저(누적 랭커, 길드 자금고, 경매장 에스크로)는 1골드를 획득해도 잔액이 그대로다.
  • 재현 조건:
    double big = 1e16;
    Console.WriteLine(big + 1.0 == big);   // True
    
  • 근본 원인: double 의 유효 자릿수는 약 15~17자리. 값이 커지면 ULP(최소 표현 간격)가 1보다 커져 작은 가산이 반올림으로 사라진다. 정수형이라면 long 으로 9.2e18 까지 모든 정수를 정확히 표현한다.

4) 컬럼 타입이 REAL — 스키마 [영속화 정밀도]

  • 증상: PostgreSQL 기준 REAL 은 4바이트 단정밀도로 유효 자릿수가 약 7자리다(DB 마다 다르다 — SQLite 의 REAL 은 8바이트다). double 로 계산한 값을 저장하는 순간 추가 반올림이 일어나, 애플리케이션 값과 DB 값이 다르다. 정산 배치의 SUM(balance) 과 개별 잔액 합계가 일치하지 않는 직접 원인이다.
  • 근본 원인: 화폐 컬럼에 근사 수치 타입을 썼다. DB 마다 REAL 정밀도가 달라 이식성도 없다.

5) 읽기-수정-쓰기 갱신 — (A)+(B) [갱신 유실]

  • GetBalanceAsync → 앱에서 계산 → 절댓값 UPDATE. 동시 트랜잭션이 같은 초기 잔액을 읽으면 한쪽 갱신이 사라진다(lost update). READ COMMITTED 로는 막히지 않는다.

6) 잔액 갱신과 원장 기록의 비원자성 — (A)+(B) [원자성]

  • UpdateBalanceAsync 와 WriteLedgerAsync 가 별도 문장이고 트랜잭션이 없다. 사이에서 실패하면 잔액은 변했는데 원장이 없거나 그 반대가 된다. DistributeLootAsync 는 루프 중간 실패 시 일부만 지급된 채로 남는다.

수정안

스키마: 정수 최소단위 또는 DECIMAL

-- 방법 1(권장): 최소 단위 정수. 골드의 최소 단위가 1이면 그대로, 소수점 2자리면 100배 스케일.
ALTER TABLE wallet ALTER COLUMN balance TYPE BIGINT;

-- 방법 2: 정확한 십진 타입
ALTER TABLE wallet ALTER COLUMN balance TYPE NUMERIC(20, 2);

애플리케이션: long(최소단위) 또는 decimal

// 가격 계산: decimal 로 하고, 화폐 단위로 반올림 정책을 명시한다.
public async Task<bool> PurchaseAsync(long accountId, decimal listPrice,
                                      decimal discountRate, decimal taxRate)
{
    // 반올림 시점을 명시 — 정책이 코드에 드러나야 감사 재현이 가능하다.
    decimal discounted = decimal.Round(listPrice * (1m - discountRate), 0, MidpointRounding.ToEven);
    decimal price      = decimal.Round(discounted * (1m + taxRate),     0, MidpointRounding.ToEven);
    long priceUnits    = (long)price;

    // (5)(6) 원자 조건부 UPDATE + 같은 트랜잭션에서 원장 기록
    using var tx = await _db.BeginTransactionAsync();
    using var cmd = _db.CreateCommand();
    cmd.Transaction = tx;
    cmd.CommandText = @"
        UPDATE wallet SET balance = balance - @price, updated_at = NOW()
        WHERE account_id = @id AND balance >= @price";     // 판정과 차감이 한 문장
    int affected = await cmd.ExecuteNonQueryAsync();
    if (affected == 0) { await tx.RollbackAsync(); return false; }   // 잔액 부족

    await WriteLedgerAsync(tx, accountId, -priceUnits, "purchase");
    await tx.CommitAsync();
    return true;
}

balance >= @price 를 SQL 조건에 넣으면 판정과 차감이 원자적으로 처리되어 (5)가 사라진다.

분배: 몫 + 나머지 배분 정책

public async Task DistributeLootAsync(IReadOnlyList<long> members, long totalGold)
{
    int n = members.Count;
    long share = totalGold / n;          // 정수 나눗셈
    long remainder = totalGold % n;      // 남은 골드

    using var tx = await _db.BeginTransactionAsync();
    for (int i = 0; i < n; i++)
    {
        // 나머지를 앞에서부터 1씩 배분 → 합계는 정확히 totalGold
        long amount = share + (i < remainder ? 1 : 0);
        await AddBalanceAsync(tx, members[i], amount);   // UPDATE ... SET balance = balance + @a
        await WriteLedgerAsync(tx, members[i], amount, "loot");
    }
    await tx.CommitAsync();
}

나머지 배분 규칙(선착순/파티장 우선/랜덤)은 도메인 정책이므로 명시적으로 결정하고 문서화한다. 합계 검증 단언 Σamount == totalGold 를 테스트에 넣으면 회귀를 막을 수 있다.

더 나은 설계

  • 원장(ledger) 우선 설계: 잔액을 상태로 두지 않고 원장 항목의 합으로 정의하고, 잔액 컬럼은 스냅샷·캐시로만 둔다. 이중부기로 총량 보존을 구조적으로 강제할 수 있다. 트레이드오프 — 읽기 비용 증가, 스냅샷 관리 필요.
  • 화폐를 전용 값 타입으로(readonly record struct Gold(long Units)): double 이 재화 API 에 들어올 수 없게 타입으로 막는다. 곱셈·나눗셈은 반올림 정책 인자를 필수로 받게 한다. 트레이드오프 — 초기 도입 비용.
  • 비율 연산을 정수 유리수로: 할인 10% 를 0.1 이 아니라 (9, 10) 처럼 분자/분모로 두고 price * 9 / 10 으로 계산하면 이진 표현 오차 자체가 없다. 트레이드오프 — 오버플로 주의 필요.
  • 정산 대사 배치: 원장 합계와 잔액 스냅샷을 주기적으로 대조해 불일치를 조기 감지.

면접 포인트

  1. 0.1 + 0.2 != 0.3 이 왜 생기는가. IEEE 754 이진 표현에서 십진 소수가 무한소수가 되는 이유와, decimal(십진 부동소수점)이 이를 해결하는 원리 및 그 한계(1/3 은 여전히 부정확).
  2. 화폐를 정수 최소단위로 다루는 관행(센트·사토시·최소 골드 단위). long 의 범위와 게임 경제 규모를 비교해 오버플로 위험을 어떻게 판단하는가.
  3. 나눗셈 분배에서 총량 보존. "몫 + 나머지 명시 배분"이 왜 필수이며, 반올림 정책 (banker's rounding vs away-from-zero)이 대량 집계에서 만드는 편향의 차이.
  4. SELECT 후 앱에서 계산해 UPDATE 하는 대신 UPDATE ... WHERE balance >= @price 를 쓰면 갱신 유실과 조건 판정이 동시에 해결되는 이유.