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++
// [시나리오]
// 게임 내 재화(골드)를 다루는 C++ 영속화 계층. 상점 구매 시 할인율/세율이 곱해지고,
// 파티 분배 시 인원수로 나뉜다. 잔액은 DB 의 `balance REAL` 컬럼에 저장한다.
//
// CREATE TABLE wallet (
// account_id BIGINT PRIMARY KEY,
// balance REAL NOT NULL,
// updated_at TIMESTAMP NOT NULL
// );
//
// 운영 중 보고된 현상:
// - 정확히 잔액만큼의 아이템을 살 때 "골드 부족"으로 실패하는 경우가 있다.
// - 파티 분배를 반복하면 서버 전체 골드 총량이 조금씩 늘거나 줄어든다.
// - 아주 큰 잔액을 가진 유저는 소액(1골드) 획득이 반영되지 않는다.
// - 정산 배치의 총합과 개별 잔액 합계가 일치하지 않는다.
//
// [요구사항]
// - 재화는 손실·증식 없이 총량이 보존된다.
// - "잔액 >= 가격" 판정은 정확해야 한다.
// - 계산 결과가 재현 가능해야 한다(감사 대응).
//
// [과제]
// (A), (B) 지점을 중심으로 위 현상이 발생하는 조건을 찾고, 재현 시나리오와 수정안을
// 제시하시오.
#include <cstdint>
#include <string>
#include <vector>
namespace gs {
class DbConnection {
public:
double queryDouble(const std::string& sql, std::int64_t param);
void execute(const std::string& sql, double value, std::int64_t id);
};
class WalletService {
public:
explicit WalletService(DbConnection& db) : db_(db) {}
double getBalance(std::int64_t accountId) {
return db_.queryDouble("SELECT balance FROM wallet WHERE account_id = ?", accountId);
}
// 상점 구매
bool purchase(std::int64_t accountId, double listPrice,
double discountRate, double taxRate) {
double balance = getBalance(accountId);
double price = listPrice * (1.0 - discountRate) * (1.0 + taxRate);
// (A)
if (balance < price) return false;
double newBalance = balance - price;
db_.execute("UPDATE wallet SET balance = ?, updated_at = NOW() WHERE account_id = ?",
newBalance, accountId);
writeLedger(accountId, -price, "purchase");
return true;
}
// 파티 전리품 분배
void distributeLoot(const std::vector<std::int64_t>& members, double totalGold) {
// (B)
double share = totalGold / static_cast<double>(members.size());
for (std::int64_t id : members) {
double balance = getBalance(id);
db_.execute("UPDATE wallet SET balance = ?, updated_at = NOW() WHERE account_id = ?",
balance + share, id);
writeLedger(id, share, "loot");
}
}
private:
void writeLedger(std::int64_t /*accountId*/, double /*delta*/, const char* /*reason*/) {}
DbConnection& db_;
};
} // namespace gs
int main() { return 0; } 내 리뷰 · C#
내 답안 · 자동 저장
작성 후 위 해설 보기에서 모범 해설과 대조하세요.
내 리뷰 · C++
내 답안 · 자동 저장
작성 후 위 해설 보기에서 모범 해설과 대조하세요.