23. 재화를 부동소수점으로 다룰 때의 총량 붕괴와 판정 오류 (C#)
난이도 중내 리뷰 · 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.000000000000010.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으로 계산하면 이진 표현 오차 자체가 없다. 트레이드오프 — 오버플로 주의 필요. - 정산 대사 배치: 원장 합계와 잔액 스냅샷을 주기적으로 대조해 불일치를 조기 감지.
면접 포인트
0.1 + 0.2 != 0.3이 왜 생기는가. IEEE 754 이진 표현에서 십진 소수가 무한소수가 되는 이유와,decimal(십진 부동소수점)이 이를 해결하는 원리 및 그 한계(1/3 은 여전히 부정확).- 화폐를 정수 최소단위로 다루는 관행(센트·사토시·최소 골드 단위).
long의 범위와 게임 경제 규모를 비교해 오버플로 위험을 어떻게 판단하는가. - 나눗셈 분배에서 총량 보존. "몫 + 나머지 명시 배분"이 왜 필수이며, 반올림 정책 (banker's rounding vs away-from-zero)이 대량 집계에서 만드는 편향의 차이.
SELECT후 앱에서 계산해UPDATE하는 대신UPDATE ... WHERE balance >= @price를 쓰면 갱신 유실과 조건 판정이 동시에 해결되는 이유.
해설 · C++
해설 — 재화를 부동소수점으로 다룰 때의 총량 붕괴와 판정 오류 (C++)
난이도: 중상
요약
재화를 double 로 계산하고 REAL 컬럼에 저장한다. 이진 부동소수점은 십진 소수를 정확히
표현하지 못하고, 연산마다 반올림 오차가 쌓이며, 값이 커지면 최소 증분(ULP)이 1을 넘어선다.
C++ 에서는 여기에 decimal 같은 표준 십진 타입이 없다는 점, x87/FMA·최적화 옵션에 따라
결과가 달라진다는 점, double → 정수 변환 UB 가 더해져 위험이 커진다.
문제점
1) 정확히 잔액만큼의 구매가 실패 — (A) [표현 오차]
- 증상: 잔액 100.0, 정가 100.0, 할인 10%, 세율 10% 구매가 "골드 부족"으로 실패.
- 재현 조건:
double price = 100.0 * (1.0 - 0.1) * (1.0 + 0.1); std::printf("%.17g\n", price); // 99.0000000000000140.1은 이진수로 무한소수라 정확히 저장되지 않고, 곱셈 두 번의 반올림이 누적된다. - 근본 원인: 화폐 계산에 이진 부동소수점 사용. 곱셈은 결합법칙이 성립하지 않아 계산 순서만 바꿔도 값이 달라지고, 감사 재현성이 사라진다.
2) 컴파일러·플랫폼에 따라 결과가 달라짐 — (A) [결정론]
- 증상: 같은 코드가 서버 A(x86-64, FMA 사용)와 서버 B(다른 최적화 옵션)에서 다른 잔액을
만든다.
-ffast-math가 켜져 있으면 컴파일러가(1.0-0.1)*(1.0+0.1)을 재결합해도 되므로 값이 또 달라진다. - 근본 원인: 중간 계산이 더 넓은 정밀도로 수행되거나(x87 80비트, FMA 융합) 재결합될 수 있다. 게임 서버가 여러 노드에서 같은 정산을 수행하는 구조라면 노드 간 잔액 불일치로 이어진다.
3) 분배에서 총량 비보존 — (B) [분배/반올림]
- 증상: 100 골드를 6명에게 나누면 각
16.666666666666668, 합계100.00000000000001(총량 증가). 12명이면 각8.333333333333334, 합계99.999999999999986(총량 감소). 반복될수록 전체 통화량이 양방향으로 표류한다. - 주의: 3명(
33.333333333333336)은 오차가 상쇄되어 합계가 정확히100.0이 된다. 나누어떨어지지 않아도 우연히 맞는 경우가 있다는 점이 오히려 위험하다. - 근본 원인: 나눗셈 결과를 그대로 지급하고 나머지 처리 정책이 없다.
4) 큰 잔액에서 소액 가산 소실 — [정밀도 흡수]
- 증상: 잔액
1e16이상에서balance + 1.0 == balance. 길드 자금고·경매장 에스크로처럼 값이 큰 계정에서 소액 입금이 사라진다. - 근본 원인:
double은 53비트 가수. 2^53(약 9.0e15) 을 넘으면 인접한 정수를 모두 표현할 수 없다.std::int64_t는 9.2e18 까지 모든 정수를 정확히 표현한다.
5) 컬럼 타입 REAL — 스키마 [영속화 정밀도]
- PostgreSQL 기준
REAL은 4바이트 단정밀도로 유효 자릿수 약 7자리(SQLite 의REAL은 8바이트로 DB 마다 다르다).double계산 결과를 저장할 때 또 반올림되어 애플리케이션 값과 DB 값이 어긋난다. 정산 배치의SUM(balance)과 개별 잔액 합계 불일치의 직접 원인.
6) 읽기-수정-쓰기 갱신과 트랜잭션 부재 — (A)+(B) [갱신 유실/원자성]
getBalance→ 앱 계산 → 절댓값UPDATE는 동시 실행 시 갱신 유실이 난다. 잔액 갱신과 원장 기록이 서로 다른 문장이고 트랜잭션이 없어 부분 반영이 남는다.distributeLoot는 루프 중간 실패 시 일부만 지급된다.
7) members.size() 가 0 일 때 — (B) [경계]
totalGold / 0.0은 정수 나눗셈과 달리 UB 가 아니라inf(또는NaN)를 만든다. 그 값이 조용히 DB 로 흘러가 잔액이inf가 되고, 이후 모든 비교가 이상해진다. 정수로 바꾸면 0 나눗셈이 UB 이므로 사전 검사가 필수가 된다.
수정안
스키마
ALTER TABLE wallet ALTER COLUMN balance TYPE BIGINT; -- 최소 단위 정수
-- 또는 NUMERIC(20, 2)
애플리케이션: 최소 단위 std::int64_t
using Gold = std::int64_t; // 최소 단위(1 골드) 정수. 필요하면 전용 값 타입으로 감싼다.
// 비율은 부동소수가 아니라 정수 유리수(분자/분모)로 표현한다.
struct Rate { std::int64_t num; std::int64_t den; }; // 10% -> {1, 10}
// 반올림 정책을 명시한 정수 곱셈 (내림)
Gold applyRate(Gold v, Rate r) {
// 오버플로 방지를 위해 __int128 (또는 checked 곱셈) 사용
return static_cast<Gold>(static_cast<__int128>(v) * r.num / r.den);
}
bool WalletService::purchase(std::int64_t accountId, Gold listPrice,
Rate discount, Rate tax) {
Gold discounted = listPrice - applyRate(listPrice, discount);
Gold price = discounted + applyRate(discounted, tax);
// (6) 판정과 차감을 한 문장으로 — 원자 조건부 UPDATE
Transaction tx = db_.begin();
int affected = tx.execute(
"UPDATE wallet SET balance = balance - ?, updated_at = NOW() "
"WHERE account_id = ? AND balance >= ?", price, accountId, price);
if (affected == 0) { tx.rollback(); return false; } // 잔액 부족
writeLedger(tx, accountId, -price, "purchase");
tx.commit();
return true;
}
분배: 몫 + 나머지 명시 배분
void WalletService::distributeLoot(const std::vector<std::int64_t>& members, Gold totalGold) {
if (members.empty()) return; // (7) 0 나눗셈 사전 차단
const auto n = static_cast<Gold>(members.size());
const Gold share = totalGold / n;
const Gold remainder = totalGold % n; // 합계 = totalGold 보장
Transaction tx = db_.begin();
for (std::size_t i = 0; i < members.size(); ++i) {
const Gold amount = share + (static_cast<Gold>(i) < remainder ? 1 : 0);
tx.execute("UPDATE wallet SET balance = balance + ?, updated_at = NOW() "
"WHERE account_id = ?", amount, members[i]);
writeLedger(tx, members[i], amount, "loot");
}
tx.commit();
}
totalGold 가 음수일 수 있다면 % 의 부호 규칙(C++11 이후 몫은 0 방향 절단)까지 고려해
나머지 배분 방향을 명시해야 한다.
더 나은 설계
- 화폐 전용 값 타입:
class Gold { explicit Gold(std::int64_t units); }로 감싸고double생성자를= delete로 막는다. 부동소수점이 재화 경로에 들어올 수 없게 타입이 강제한다. 트레이드오프 — 초기 리팩터링 비용. - 곱셈 오버플로 방어:
__int128또는__builtin_mul_overflow로 검사. 정수로 바꾸면 정밀도 문제는 사라지지만 오버플로가 새 위험이 되므로 반드시 함께 다룬다. - 원장 우선(ledger-first) 설계: 잔액을 원장 합계로 정의하고 잔액 컬럼은 스냅샷으로. 이중부기가 총량 보존을 구조적으로 보장한다. 트레이드오프 — 읽기 비용, 스냅샷 관리.
- 정산 대사 배치:
SUM(ledger.delta)와wallet.balance를 주기 대조해 불일치 조기 감지.
면접 포인트
- IEEE 754 이진 부동소수점에서 십진 소수가 정확히 표현되지 않는 이유, 2^53 경계의 의미,
-ffast-math·FMA·x87 확장 정밀도가 결정론을 깨뜨리는 방식. - C++ 에는
decimal표준 타입이 없다. 대안(정수 최소단위, 고정소수점, 외부 십진 라이브러리)의 트레이드오프를 설명할 수 있는가. - 화폐 나눗셈에서 총량 보존.
share = total / n; remainder = total % n패턴과 나머지 배분 정책을 왜 도메인 규칙으로 명시해야 하는가. - 정수로 전환하면 정밀도 문제는 사라지지만 오버플로와 0 나눗셈이 새로 생긴다. 어떤 방어가 필요한가.