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

난이도 중 해설 보기 →

결함을 모두 찾고 원인·수정안·더 나은 설계를 제시하라. 마커 (A)(B) 는 주목 위치 힌트다.

결함 코드 · C#
// [시나리오]
// 게임 내 재화(골드)와 경험치를 다루는 영속화 계층. 상점 구매 시 할인율/세율이 곱해지고,
// 파티 분배 시 인원수로 나뉜다. 잔액은 DB 의 `balance REAL` 컬럼에 저장한다.
//
//   CREATE TABLE wallet (
//       account_id BIGINT PRIMARY KEY,
//       balance    REAL NOT NULL,      -- 골드 잔액
//       updated_at TIMESTAMP NOT NULL
//   );
//
// 운영 중 보고된 현상:
//   - 정확히 잔액만큼의 아이템을 살 때 "골드 부족"으로 실패하는 경우가 있다.
//   - 파티 분배를 반복하면 서버 전체 골드 총량이 조금씩 늘거나 줄어든다.
//   - 아주 큰 잔액을 가진 유저는 소액(1골드) 획득이 반영되지 않는다.
//   - 정산 배치가 계산한 총합과 개별 잔액 합계가 일치하지 않는다.
//
// [요구사항]
// - 재화는 손실·증식 없이 총량이 보존되어야 한다.
// - "잔액 >= 가격" 판정은 정확해야 한다.
// - 감사(audit)를 위해 계산 결과가 재현 가능해야 한다.
//
// [과제]
// (A), (B) 지점을 중심으로 위 현상이 발생하는 조건을 찾고, 재현 시나리오와 수정안을
// 제시하시오.

using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Threading.Tasks;

namespace GameServer.Persistence
{
    public sealed class WalletService
    {
        private readonly DbConnection _db;
        public WalletService(DbConnection db) { _db = db; }

        public async Task<double> GetBalanceAsync(long accountId)
        {
            using var cmd = _db.CreateCommand();
            cmd.CommandText = "SELECT balance FROM wallet WHERE account_id = @id";
            return Convert.ToDouble(await cmd.ExecuteScalarAsync());
        }

        // 상점 구매: 정가에 할인율과 세율을 적용한 뒤 차감한다.
        public async Task<bool> PurchaseAsync(long accountId, double listPrice,
                                              double discountRate, double taxRate)
        {
            double balance = await GetBalanceAsync(accountId);
            double price = listPrice * (1.0 - discountRate) * (1.0 + taxRate);

            // (A)
            if (balance < price)
                return false;

            double newBalance = balance - price;
            await UpdateBalanceAsync(accountId, newBalance);
            await WriteLedgerAsync(accountId, -price, "purchase");
            return true;
        }

        // 파티 전리품 분배: 총액을 인원수로 나눠 각자에게 지급한다.
        public async Task DistributeLootAsync(IReadOnlyList<long> members, double totalGold)
        {
            // (B)
            double share = totalGold / members.Count;
            foreach (var id in members)
            {
                double balance = await GetBalanceAsync(id);
                await UpdateBalanceAsync(id, balance + share);
                await WriteLedgerAsync(id, share, "loot");
            }
        }

        private async Task UpdateBalanceAsync(long accountId, double newBalance)
        {
            using var cmd = _db.CreateCommand();
            cmd.CommandText = "UPDATE wallet SET balance = @b, updated_at = NOW() WHERE account_id = @id";
            await cmd.ExecuteNonQueryAsync();
        }

        private Task WriteLedgerAsync(long accountId, double delta, string reason)
            => Task.CompletedTask;
    }
}
내 리뷰 · C#
내 답안 · 자동 저장

작성 후 위 해설 보기에서 모범 해설과 대조하세요.