← iOS 개발자 CS 로드맵 30 / 35

30 · 수치 정확도

IEEE 754가 0.1을 표현하지 못하는 이유, 통화 계산에 Double을 쓰면 터지는 지점과 Decimal의 진짜 함정, 반올림 규칙 여섯 가지, 합산 순서가 결과를 바꾸는 누적 오차, 수치 결과를 테스트하는 방법.
진행률
0 / 0 완료

29장에서 DateDouble이라 기준점에서 멀수록 눈금이 성겨진다는 것을 봤다. 이 챕터는 그 Double 자체를 파고든다.

부동소수점 버그의 성질은 이 티어의 다른 주제들과 같다 — 크래시가 나지 않는다. 계산은 정상적으로 끝나고 값도 그럴듯하다. 다만 1원이 안 맞고, 합계가 항목 합과 다르고, 같은 데이터를 다른 순서로 더하면 다른 답이 나온다.

P1 19장에서 정수 오버플로가 Swift 런타임 트랩으로 잡힌다는 것을 봤다. 부동소수점은 정반대다 — 트랩이 없다. 오버플로는 infinity가 되고, 정의되지 않은 연산은 nan이 되어 조용히 전파된다.

Q1. IEEE 754는 왜 0.1 + 0.2 ≠ 0.3인가?

🔑 30초 답변

0.1을 이진 분수로 정확히 적을 수 없기 때문이다. IEEE 754 배정밀도는 값을 부호 × 가수 × 2^지수로 저장하는데, 0.1은 2의 거듭제곱 합으로 유한하게 표현되지 않는 무한 순환 이진소수다. 10진수에서 1/3을 유한 자릿수로 못 적는 것과 같은 이유다. 그래서 저장되는 실제 값은 0.1000000000000000055511151231257827…이고, 두 근삿값을 더하면 0.30000000000000004가 나온다. 연산이 틀린 게 아니라 입력이 이미 근삿값이다.

CS 원리

부동소수점은 고정된 비트 수로 넓은 범위의 실수를 근사하려는 설계다. 배정밀도(Double)는 64비트를 이렇게 나눈다.

핵심은 가수가 이진수라는 것이다. 표현 가능한 값은 2의 거듭제곱의 유한 합뿐이다. 0.5(2⁻¹), 0.25(2⁻²), 0.75(2⁻¹+2⁻²)는 정확하지만, 0.1은 그런 합으로 만들어지지 않는다.

이는 진법의 문제다. 10진법에서 유한 소수인 분수는 분모가 2와 5로만 이루어진 것뿐이고, 2진법에서는 분모가 2의 거듭제곱인 것뿐이다. 0.1 = 1/10 = 1/(2×5)인데 분모에 5가 있어 2진법에서 무한 순환한다.

여기서 두 번째 성질이 나온다 — 정밀도는 값의 크기에 상대적이다. 가수 비트 수가 고정이므로, 값이 커지면 표현 가능한 값 사이의 간격(ULP)도 커진다. 그래서 2⁵³ + 1Double로 표현되지 않고 2⁵³과 같은 값이 된다.

0.1 은 2의 거듭제곱 합으로 유한하게 적히지 않는다 10진법 0.1 — 한 자리로 끝난다 분모 10 = 2×5, 10진법의 밑과 맞는다 2진법 0.0001100110011… 무한 순환 분모에 5가 있어 2진법으로 안 떨어진다 그래서 저장되는 값은 가장 가까운 표현 가능한 수 우리가 쓴 것: 0.1 실제 저장: 0.1000000000000000055511… 우리가 쓴 것: 0.2 실제 저장: 0.2000000000000000111022… 합 = 0.30000000000000004 연산이 틀린 게 아니라 입력이 이미 근삿값이다 10진법으로 1/3 을 유한하게 못 적는 것과 같은 종류의 한계다
0.1의 실제 저장값은 0.1000000000000000055511151231257827021181583404541015625다.

iOS에서는

Swift에서 이를 직접 확인할 수 있다. Doubledescription원래 값으로 되돌아가는 가장 짧은 표기를 보여주므로, 실제 저장값을 보려면 다른 수단이 필요하다.

0.1 + 0.2                    // 0.30000000000000004
0.1 + 0.2 == 0.3             // false

// 실제 저장값을 보려면 자릿수를 늘려 출력한다
String(format: "%.55f", 0.1)
//   0.1000000000000000055511151231257827021181583404541015625

// 비트 구성 확인
let bits = 0.1.bitPattern
String(bits, radix: 2)       // 부호 1 + 지수 11 + 가수 52 비트
0.1.exponent                 // -4
0.1.significandBitPattern    // 가수의 실제 비트

정확히 표현되는 값도 있다. 2의 거듭제곱과 그 합은 오차가 없다.

0.5 + 0.25 == 0.75           // true   — 둘 다 2의 거듭제곱
1.0 + 2.0 == 3.0             // true   — 작은 정수는 정확하다
0.1 + 0.1 == 0.2             // true   — 같은 오차가 상쇄되는 우연

// 정수도 한계가 있다 — 2^53 을 넘으면 인접한 정수가 표현되지 않는다
Double(9_007_199_254_740_992)      // 2^53
Double(9_007_199_254_740_993)      // 2^53 + 1 인데 결과는 2^53 과 같다
Double(2 << 52) == Double((2 << 52) + 1)   // true

이 마지막이 실무에서 ID를 Double로 다룰 때 사고를 낸다. 64비트 정수 ID를 JSON에서 Double로 파싱하면 큰 값에서 조용히 뭉개진다(31장에서 다시 다룬다).

// ❌ JSON 숫자를 Double 로 받으면 큰 ID 가 손상된다
struct Bad: Codable { let id: Double }

// ✅ 정수는 정수로, 아주 크면 문자열로 받는다
struct Good: Codable { let id: Int64 }
struct Safer: Codable { let id: String }

실험 · 도구

표현 한계를 직접 재현해 보자.

import Foundation

// 어떤 10진 소수가 정확히 표현되는지 확인한다
for v in [0.5, 0.25, 0.75, 0.1, 0.2, 0.3, 0.125, 1.0 / 3.0] {
    let exact = Decimal(v)                      // Double 의 실제 값을 10진으로 펼친다
    let isExact = "\(v)" == "\(exact)"
    print(String(format: "%-8@ %@ %@", "\(v)" as NSString,
                 isExact ? "정확" : "근사", "\(exact)" as NSString))
}
// 0.5      정확
// 0.1      근사  0.1000000000000000055511151231257827
// 0.3      근사  0.299999999999999988897769753748434595

ULP가 값의 크기에 따라 어떻게 변하는지도 볼 수 있다.

for v in [1.0, 1_000.0, 1e9, 787_234_200.0, 1e15, 1e16] {
    print(String(format: "%16.1f  ulp = %.3e", v, v.ulp))
}
//              1.0  ulp = 2.220e-16
//           1000.0  ulp = 1.137e-13
//     1000000000.0  ulp = 1.192e-07     ← 29장 Date 의 정밀도가 이 값이다
//   1000000000000000.0  ulp = 1.250e-01  ← 1보다 커졌다
//  10000000000000000.0  ulp = 2.000e+00  ← 정수도 건너뛴다

커맨드라인에서 빠르게 확인할 수도 있다.

# 특정 값의 실제 저장값을 10진으로 펼쳐 본다
python3 -c "from decimal import Decimal; print(Decimal(0.1))"
#   0.1000000000000000055511151231257827021181583404541015625

# 비트 패턴 확인
python3 -c "
import struct
for v in [0.1, 0.5, 0.3]:
    b = format(struct.unpack('<Q', struct.pack('<d', v))[0], '064b')
    print(f'{v}: 부호 {b[0]} 지수 {b[1:12]} 가수 {b[12:]}')
"

프로젝트 적용

  1. 부동소수점을 ==로 비교하지 않는다. 허용 오차를 정해 비교하거나(Q5), 애초에 정확한 타입을 쓴다(Q2).
  2. 돈·수량처럼 정확해야 하는 값에 Double을 쓰지 않는다. Q2에서 다룬다.
  3. 큰 정수 ID를 Double로 통과시키지 않는다. 2⁵³을 넘으면 조용히 뭉개진다. JSON 파싱 경로를 특히 확인한다.
  4. "소수점 두 자리니까 안전하다"는 가정을 버린다. 0.01도 이진으로 정확하지 않다.
  5. 표시와 계산을 분리한다. 화면에 0.30000000000000004가 보이는 것은 서식 문제이지만, 그 값으로 판정하는 것은 정확도 문제다.
⚠️ 흔한 오해

"부동소수점 연산에 버그가 있다"는 틀렸다 — 연산은 규격대로 정확하고, 입력이 이미 근삿값이다. "소수점 아래 자릿수가 적으면 안전하다"도 틀렸다 — 0.1도 0.01도 이진으로 정확하지 않다. "Float 대신 Double을 쓰면 해결된다"도 틀렸다 — 정밀도가 늘 뿐 같은 종류의 문제가 남는다. "정수는 Double에 안전하게 담긴다"도 2⁵³까지만 맞다. "0.1 + 0.2 != 0.3이니 모든 부동소수점 비교가 실패한다"도 과장이다 — 0.5 + 0.25 == 0.75이다.

🧒 쉽게 이해하기

1을 3으로 나눠 보자. 0.333333… 이 끝없이 이어진다. 종이에 정확히 적을 수가 없다. 그래서 어딘가에서 끊고 "대충 이 정도"라고 적는다.

컴퓨터도 똑같은 일을 겪는데, 다른 숫자에서 겪는다. 컴퓨터는 2로 나눈 조각들만 정확히 다룰 수 있다 — 0.5, 0.25, 0.125 같은 것들이다.

그런데 0.1은 그런 조각들을 아무리 더해도 딱 맞게 안 된다. 0.0625 + 0.03125 + … 이렇게 계속 더해가도 끝없이 조금씩 모자라거나 넘친다. 우리가 1/3을 못 적는 것과 똑같은 상황이다.

그래서 컴퓨터는 0.1에 가장 가까운 값을 대신 저장한다. 아주 가깝지만 정확히 0.1은 아니다.

0.1과 0.2를 더하면? 대충 맞는 값 두 개를 더하니 대충 맞는 값이 나온다. 그게 0.30000000000000004다.

그러니까 컴퓨터가 계산을 틀린 게 아니다. 애초에 정확한 0.1을 받은 적이 없다.

꼬리 질문

화면에 0.30000000000000004가 보이는 것은 서식으로 고칠 수 있는가?

보이는 것은 고칠 수 있지만 값은 그대로다. 이 구분이 중요하다.

let v = 0.1 + 0.2

// 표시 — 반올림해서 보여준다
v.formatted(.number.precision(.fractionLength(2)))   // "0.3"
String(format: "%.2f", v)                            // "0.30"

// 값 — 여전히 0.30000000000000004 다
v == 0.3                                             // false
if v > 0.3 { … }                                     // true 로 들어간다

그래서 어디서 문제가 생겼는지를 먼저 판정해야 한다.

  • 표시만 이상하다 — 서식으로 해결된다. 값으로는 아무 판정도 하지 않는 경우.
  • 합계가 안 맞는다 — 값의 문제다. 반올림해서 보여주면 화면상 항목 합과 총합이 달라 보이는 더 나쁜 상황이 된다.
  • 비교가 틀린다 — 값의 문제다. 허용 오차 비교나 정확한 타입이 필요하다.

서식으로 덮는 것이 위험한 이유는 문제를 숨기기 때문이다. 표시상으로는 맞아 보이는데 내부 값이 계속 어긋나 있으면, 나중에 그 값으로 비교·정렬·집계할 때 원인을 찾기 어려워진다.

쉽게 말하면 저울이 100.0001g을 가리키는데 소수점을 지우고 100g으로 보여주는 것과 같다. 화면은 깔끔해지지만 저울 안의 숫자는 그대로다. 그 숫자로 "100g과 같은가?"를 물으면 여전히 아니라고 답한다.
Float와 Double은 언제 골라야 하는가?

기본은 Double이고, Float는 이유가 있을 때만 쓴다.

Float (32비트)Double (64비트)
가수23비트52비트
10진 유효자리약 7자리약 15~17자리
정수 정확 한계2²⁴ (약 1,677만)2⁵³ (약 900조)

Float를 고르는 정당한 이유는 셋이다.

  • GPU·그래픽스 — Metal 셰이더와 정점 데이터는 Float가 기본이다. 변환 비용을 피한다.
  • 대량 데이터의 메모리 — 수백만 개 배열이면 절반 크기가 의미 있다.
  • 외부 형식이 정한 경우 — 오디오 샘플, 센서 데이터 등.

주의할 점은 Float정수 한계가 생각보다 낮다는 것이다. 2²⁴는 약 1,677만이라, 천만 단위 금액이나 큰 카운터를 담으면 이미 정밀도가 부족하다.

Float(16_777_216) == Float(16_777_217)   // true  ← 2^24 를 넘으면 인접 정수가 뭉개진다
Double(16_777_217)                        // 정확하다

Swift의 CGFloat는 64비트 플랫폼에서 Double과 같은 크기이며, Swift 5.5부터 Double암묵적으로 변환되어 대부분의 코드에서 구분할 필요가 줄었다.

쉽게 말하면 자를 두 종류 준다. 하나는 눈금이 성긴 짧은 자, 하나는 눈금이 촘촘한 긴 자. 촘촘한 쪽이 기본이고, 자리를 아껴야 하거나 그래픽 카드가 성긴 자만 읽을 때만 짧은 걸 쓴다.
NaN과 infinity는 어떻게 다루어야 하는가?

정수 오버플로와 정반대로 조용히 전파된다는 점이 핵심이다. P1 19장에서 본 정수 오버플로는 런타임 트랩으로 즉시 죽지만, 부동소수점은 특수 값을 만들어 계속 흘러간다.

let x = 1.0 / 0.0            // inf   — 크래시하지 않는다
let y = 0.0 / 0.0            // nan
let z = y + 100              // nan   — 한 번 오염되면 계속 번진다

// NaN 은 자기 자신과도 같지 않다 — IEEE 754 규정이다
Double.nan == Double.nan     // false
Double.nan != Double.nan     // true

// 그래서 == 로는 검출할 수 없다
if z.isNaN { … }             // ✅
if z == .nan { … }           // ❌ 절대 참이 되지 않는다

NaN이 정렬을 깨뜨리는 것도 중요하다. NaN이 섞인 배열을 sorted()로 정렬하면 비교자가 엄격 약순서를 위반해 결과가 예측 불가능해진다(28장 Q2 꼬리질문과 같은 구조).

// 경계에서 검증해 오염을 차단한다
extension Double {
    var finiteOrNil: Double? { isFinite ? self : nil }
}

guard let ratio = (a / b).finiteOrNil else {
    logger.error("비정상 값: a=\(a) b=\(b)")
    return .zero
}

실무 원칙은 "들어오는 곳에서 막는다"이다. 나눗셈·로그·제곱근처럼 특수 값을 만들 수 있는 연산 직후에 검증하고, NaN을 저장하거나 전송하지 않는다 — JSON에는 NaN 표현이 없어 직렬화에서 실패하거나 null로 뭉개진다(31장).

쉽게 말하면 계산 중에 "말이 안 되는 값"이 나오면, 컴퓨터는 멈추지 않고 "몰라"라는 표시를 만들어 계속 간다. 문제는 이 "몰라"가 어디에 더하든 결과가 또 "몰라"가 된다는 점이다. 그래서 한참 뒤에야 화면에 이상한 게 뜨고, 어디서 시작됐는지 찾기가 어렵다. 그러니 생길 만한 자리에서 바로 확인해야 한다.

Q2. 통화 계산에 Double을 쓰면 언제 터지는가?

🔑 30초 답변

합계·비교·반올림이 겹치는 지점에서 1원이 어긋난다. 해법은 10진 기반 타입이다 — Swift의 Decimal은 부호·유효숫자·10의 거듭제곱 지수로 10진 소수를 최대 38자리까지 정확히 표현하므로 0.1을 열 번 더해도 정확히 1이다. 그런데 진짜 함정은 Decimal을 쓰는 방식에 있다. Decimal(0.1)처럼 Double을 거쳐 만들면Double의 오차가 그대로 옮겨붙는다. Decimal(0.1 + 0.2)0.3000000000000000512가 되어 Decimal(string: "0.3")과 다르다.

CS 원리

돈 계산이 부동소수점과 맞지 않는 이유는 요구되는 불변식이 다르기 때문이다.

과학·그래픽 계산금융 계산
중요한 것상대 오차가 작을 것절대적으로 정확할 것
1원 차이무시할 만함회계가 안 맞는다
합의 성질순서가 조금 달라도 됨항목 합 = 총합이 반드시
적합한 표현2진 부동소수점10진 고정소수점 · 정수

선택지는 둘이다.

여기서 중요한 원리 하나 — 정확한 타입을 쓰더라도 경계에서 오염될 수 있다. Decimal은 정확하지만, 이미 오차가 낀 Double을 감싸면 그 오차를 정확하게 보존할 뿐이다. 그래서 값이 Double을 한 번도 거치지 않게 경로 전체를 지켜야 한다.

Decimal 은 정확하다 — 다만 무엇을 받았느냐에 달렸다 ✅ 안전한 경로 "0.1" 문자열 Decimal(string:) 정확히 0.1 ❌ 오염된 경로 0.1 + 0.2 (Double) 이미 0.30000000000000004 Decimal(0.3000000000000000512) Decimal 이 오차를 정확하게 보존했을 뿐이다 널리 퍼진 단순화도 실측과 다르다 "Decimal(0.1) 은 항상 다르다" 틀렸다 — 이 툴체인에서는 같다 진짜 함정은 Decimal(0.1 + 0.2) 연산을 거친 Double 을 감쌀 때다 규칙 — 값이 Double 을 한 번도 거치지 않게 한다 디코딩부터 표시까지 경로 전체를 지켜야 한다
정확한 타입을 쓰는 것만으로는 부족하다. 경로 전체가 정확해야 한다.

iOS에서는

Decimal의 성질과 함정을 같이 봐야 한다.

import Foundation

// 정확하다 — 0.1 을 열 번 더해도 정확히 1
var sum = Decimal(0)
for _ in 0..<10 { sum += Decimal(string: "0.1")! }
sum == Decimal(1)                    // true

// Double 이었다면
var d = 0.0
for _ in 0..<10 { d += 0.1 }
d == 1.0                             // false — 0.9999999999999999

// ⚠️ 널리 퍼진 단순화는 정확하지 않다
Decimal(0.1) == Decimal(string: "0.1")!      // 이 툴체인에서는 true
//   리터럴 0.1 의 Double 표현이 "가장 짧게 반올림한 표현"과 우연히 일치한다

// ✅ 진짜 함정은 이쪽이다
Decimal(0.1 + 0.2) == Decimal(string: "0.3")!   // false
Decimal(0.1 + 0.2)                              // 0.3000000000000000512
//   이미 Double 로 연산을 거친 값을 감싸면 오차가 그대로 옮겨붙는다
//   NSDecimalNumber(value:) 도 동일하게 오염된다

그래서 디코딩부터 Decimal이어야 한다. Double 프로퍼티를 한 번이라도 거치면 그 순간 반올림 손실이 확정된다.

// ❌ Double 로 받았다가 나중에 변환 — 이미 늦었다
struct BadItem: Codable { let price: Double }

// ✅ 처음부터 Decimal 로 디코딩한다
struct Item: Codable {
    let price: Decimal          // JSONDecoder 가 직접 Decimal 로 파싱한다
    let quantity: Int
    var total: Decimal { price * Decimal(quantity) }
}

// 표시는 통화 서식으로 (28장 Q6)
item.total.formatted(.currency(code: "KRW"))

정수 최소 단위 방식도 유효하다. 원 단위 앱이라면 오히려 이쪽이 단순하다.

// 원 단위 정수 — 나눗셈이 없으면 가장 안전하고 빠르다
struct Money: Codable, Hashable {
    let won: Int64
    static func + (l: Self, r: Self) -> Self { Money(won: l.won + r.won) }
    var text: String { won.formatted(.currency(code: "KRW")) }
}
//   할인율·이자처럼 나눗셈이 필요해지면 반올림 규칙을 정해야 한다 (Q3)

실험 · 도구

오염 경로를 직접 재현해 보면 규칙이 분명해진다.

import Foundation

func show(_ label: String, _ d: Decimal) {
    print(String(format: "%-34@ %@", label as NSString, "\(d)" as NSString))
}

show("Decimal(string: \"0.3\")", Decimal(string: "0.3")!)      // 0.3
show("Decimal(0.3)",             Decimal(0.3))                 // 0.3
show("Decimal(0.1 + 0.2)",       Decimal(0.1 + 0.2))           // 0.3000000000000000512
show("Decimal(0.1) + Decimal(0.2)", Decimal(0.1) + Decimal(0.2))  // 0.3

// 합계 비교 — 항목 합과 총합이 맞는지 본다
let prices = ["19900", "29900", "9900", "0.1", "0.2"]
let dec = prices.compactMap { Decimal(string: $0) }.reduce(0, +)
let dbl = prices.compactMap { Double($0) }.reduce(0, +)
print("Decimal 합: \(dec)")     // 59700.3
print("Double  합: \(dbl)")     // 59700.30000000001

기존 코드에서 위험한 경로를 훑는다.

# 금액 성격 필드가 Double 인 곳
grep -rnE "(price|amount|total|balance|fee|tax|discount).*: *(Double|Float)" \
     --include=*.swift .

# Double 을 Decimal 로 감싸는 곳 — 이미 오염됐을 수 있다
grep -rn "Decimal(" --include=*.swift . | grep -v "Decimal(string:" | grep -v "Decimal(sign:"

# JSON 디코딩에서 금액이 Double 로 들어오는지 확인
grep -rn -B3 -A3 "price\|amount" --include=*.swift . | grep -i "double"

프로젝트 적용

  1. 금액 타입을 하나 정해 전 계층에서 쓴다. Decimal이든 정수 Money든, 섞지 않는 것이 핵심이다.
  2. 디코딩 단계부터 정확한 타입으로 받는다. Double을 한 번 거치면 그 시점에 손실이 확정된다.
  3. Decimal(리터럴) 대신 Decimal(string:)을 쓰는 습관을 들인다. 지금 툴체인에서 우연히 맞더라도, 의도가 드러나는 쪽이 안전하다.
  4. 금액에 Double이 등장하면 린트로 막는다. 새로 들어오는 것을 차단하는 것이 이미 있는 것을 찾는 것보다 싸다.
  5. 서버와 금액 표현을 합의한다. JSON 숫자는 파서에 따라 Double로 해석될 수 있어, 문자열로 주고받는 방식이 안전한 경우가 많다(31장).
⚠️ 흔한 오해

"Decimal(0.1)은 항상 Decimal(string: "0.1")과 다르다"는 흔한 단순화이지만 정확하지 않다 — 이 툴체인에서는 같다. 리터럴 0.1Double 표현이 가장 짧은 표기와 일치하기 때문이다. 진짜 함정은 이미 연산을 거친 Double을 감싸는 것이다. "Decimal을 쓰면 무조건 안전하다"도 틀렸다 — 입력 경로가 오염되면 소용없다. "금액은 소수점 두 자리니까 Double로 충분하다"도 틀렸다 — 0.01도 이진으로 정확하지 않다. "원 단위라 소수가 없으니 Double이어도 된다"는 2⁵³까지만 맞고, 나눗셈이 들어오는 순간 깨진다.

🧒 쉽게 이해하기

돈 계산에는 "대충 맞음"이 없다. 1원이 안 맞으면 장부가 안 맞는 것이다.

그런데 컴퓨터의 기본 소수 계산은 대충 맞는 방식이다(Q1). 그래서 돈에는 다른 계산기를 써야 한다. 10진법으로 정확히 세는 계산기가 있는데, 그게 Decimal이다.

여기서 사람들이 자주 놓치는 게 있다. 정확한 계산기를 샀는데도 틀리는 경우가 있다.

왜냐하면 계산기에 넣기 전에 이미 틀린 숫자를 들고 왔기 때문이다. 대충 계산기로 0.1 더하기 0.2를 해서 0.30000000000000004를 얻은 다음, 그걸 정확한 계산기에 넣으면? 정확한 계산기는 그 틀린 값을 아주 정확하게 보관해 준다.

그러니까 규칙은 하나다 — 돈은 처음부터 끝까지 정확한 계산기로만 다녀야 한다. 중간에 한 번이라도 대충 계산기를 거치면, 그 순간 이미 늦었다.

꼬리 질문

할인율·부가세처럼 나눗셈이 들어가면 어떻게 처리하는가?

반올림 지점과 규칙을 명시적으로 정해야 한다. 나눗셈은 대개 딱 떨어지지 않으므로 어딘가에서 반드시 버리거나 올려야 하고, 그 지점이 회계 결과를 바꾼다.

정해야 할 것이 셋이다.

  • 어느 단계에서 반올림하는가 — 항목별로 할지, 합계에서 한 번 할지. 결과가 다르다.
  • 어느 방향으로 — 버림·올림·반올림(Q3).
  • 누가 손해를 보는가 — 사업 규칙이다. 세금은 보통 법으로 정해져 있다.
// 항목별 반올림 vs 합계 반올림 — 결과가 다르다
let items = [Decimal(string: "333")!, Decimal(string: "333")!, Decimal(string: "333")!]
let rate  = Decimal(string: "0.1")!

// (A) 항목별로 반올림한 뒤 합산
let a = items.map { round2($0 * rate) }.reduce(0, +)     // 33.3 → 33 씩 → 99

// (B) 합산한 뒤 반올림
let b = round2(items.reduce(0, +) * rate)                // 999 × 0.1 = 99.9 → 100

func round2(_ d: Decimal) -> Decimal {
    var input = d, result = Decimal()
    NSDecimalRound(&result, &input, 0, .plain)
    return result
}

어느 쪽도 틀리지 않지만 1원 차이가 난다. 그래서 회계·세무 요구를 먼저 확인하고 코드에 주석으로 근거를 남긴다.

그리고 서버와 같은 규칙을 써야 한다. 클라이언트가 표시한 금액과 서버가 청구한 금액이 1원 다르면 사용자 신뢰가 무너진다 — 가능하면 최종 금액은 서버가 계산하고 클라이언트는 표시만 한다(P0 16장 Q4의 "믿을 값은 서버"와 같은 원칙).

쉽게 말하면 3300원짜리 세 개에 10% 할인을 한다고 하자. 하나씩 계산해서 반올림한 뒤 더할지, 다 더한 뒤 한 번에 계산할지에 따라 총액이 1원 달라진다. 둘 다 말이 되니까 어느 쪽으로 할지 미리 정하고, 가게와 손님이 같은 방식을 써야 다툼이 없다.
Decimal은 얼마나 느린가? 성능이 문제되는 경우가 있는가?

Double보다 느리지만 대부분의 앱에서는 문제되지 않는다. Decimal은 하드웨어 명령 하나로 끝나는 Double과 달리 소프트웨어로 10진 연산을 수행하므로 수 배~수십 배 차이가 난다.

문제가 되는 조건은 대량 반복이다.

  • 문제 안 됨 — 화면에 보이는 항목 수십~수백 개의 금액 계산. 사용자는 차이를 느끼지 못한다.
  • 측정 필요 — 수만 건 거래 내역의 집계, 실시간 차트 갱신.
  • 대안 검토 — 정수 최소 단위로 바꾸면 Double급 속도에 정확성까지 얻는다. 나눗셈만 직접 관리하면 된다.
// 대량 집계는 정수로
let totalWon = transactions.reduce(Int64(0)) { $0 + $1.amountWon }
//   Int64 덧셈이라 빠르고 정확하다. 오버플로만 주의한다.

// 오버플로 방어 — P1 19장에서 본 트랩이 여기서 도움이 된다
let safe = transactions.reduce(Int64(0)) { acc, t in
    let (v, overflow) = acc.addingReportingOverflow(t.amountWon)
    precondition(!overflow, "금액 합계 오버플로")
    return v
}

측정 없이 Decimal을 피하는 것은 잘못된 최적화다. 1원이 틀리는 비용이 몇 밀리초보다 훨씬 크다.

쉽게 말하면 정확한 계산기는 손으로 자릿수를 맞춰가며 계산하니 조금 느리다. 그런데 영수증 몇 장 계산하는 정도로는 전혀 느껴지지 않는다. 수만 장을 한꺼번에 합칠 때만 고민하면 되고, 그때는 원 단위 정수로 바꾸는 방법이 있다.
여러 통화를 다룰 때 금액 타입을 어떻게 설계해야 하는가?

금액과 통화를 한 타입으로 묶고, 다른 통화끼리 연산이 불가능하게 만드는 것이 핵심이다. 타입 시스템으로 실수를 막는 전형적 사례다(32장).

struct Money: Hashable, Codable {
    let amount: Decimal
    let currency: String        // ISO 4217 — "KRW", "USD"

    static func + (l: Money, r: Money) throws -> Money {
        guard l.currency == r.currency else { throw MoneyError.currencyMismatch(l.currency, r.currency) }
        return Money(amount: l.amount + r.amount, currency: l.currency)
    }
}
//   서로 다른 통화를 더하려 하면 컴파일이 아니라 런타임에 막힌다.
//   제네릭 파라미터로 통화를 표현하면 컴파일 타임에 막을 수도 있다.

추가로 신경 써야 할 것이 소수 자릿수가 통화마다 다르다는 점이다.

  • 0자리 — 원(KRW), 엔(JPY). 소수점이 없다.
  • 2자리 — 달러, 유로.
  • 3자리 — 디나르(KWD, BHD).

그래서 "센트 단위 정수"라는 가정이 모든 통화에 통하지 않는다. 통화별 최소 단위 지수를 데이터로 갖고 있어야 하고, 표시는 로케일 서식에 맡긴다(28장 Q6).

환율 변환은 언제 어느 환율로 변환했는지를 기록해야 한다 — 나중에 재계산하면 값이 달라지므로, 변환 결과와 함께 환율과 시각을 저장한다(29장 Q6의 "파생값이 아니라 입력을 저장한다"와 같은 원칙).

쉽게 말하면 1000원과 1000달러를 그냥 더하면 안 된다. 그래서 숫자에 어느 나라 돈인지 꼬리표를 붙여 두고, 꼬리표가 다르면 더하기 자체를 막아 버린다. 그리고 나라마다 동전 단위가 달라서, 한국은 1원이 최소인데 미국은 1센트가 최소다. 이것도 나라별로 적어 둬야 한다.

Q3. 반올림 규칙은 몇 가지이며 무엇을 골라야 하는가?

🔑 30초 답변

"정확히 절반"을 어디로 보낼지가 규칙을 가른다. Swift의 FloatingPointRoundingRule은 여섯 가지다 — .toNearestOrAwayFromZero(우리가 학교에서 배운 반올림, rounded()의 기본값), .toNearestOrEven(은행가 반올림, IEEE 754의 기본), .up·.down(수직선 기준 올림·내림), .towardZero·.awayFromZero(0 기준). 금융에서 .toNearestOrEven을 쓰는 이유는 절반을 항상 위로 보내면 합계가 계통적으로 커지기 때문이다. 그리고 음수에서 .down.towardZero가 다르다는 점이 흔한 버그의 원인이다.

CS 원리

반올림은 표현 가능한 값 집합으로의 사상이다. 대부분의 값은 가장 가까운 값이 하나뿐이라 논란이 없지만, 정확히 중간인 값에서 규칙이 갈린다.

여기서 편향(bias)이라는 개념이 나온다. 학교에서 배운 "5는 올림"은 중간값을 항상 위로 보내므로, 값이 많아지면 합계가 계통적으로 커진다. 개별 오차는 작아도 같은 방향으로 쌓이면 무시할 수 없다.

은행가 반올림(.toNearestOrEven)은 중간값을 짝수 쪽으로 보낸다. 0.5→0, 1.5→2, 2.5→2, 3.5→4. 위아래로 번갈아 가므로 편향이 상쇄된다. IEEE 754가 이것을 기본 반올림 모드로 정한 이유다.

두 번째로 음수에서의 방향이 헷갈린다. 두 가지 축이 있다.

양수에서는 .down.towardZero같지만 음수에서는 다르다. 환불·차감처럼 음수가 등장하는 계산에서 이 차이가 드러난다.

갈리는 지점은 둘 — "정확히 절반"과 "음수" 입력 0.5 1.5 2.5 −2.5 −2.4 .toNearestOr AwayFromZero 1 2 3 −3 −2 학교에서 배운 방식 · 기본값 .toNearestOrEven 0 2 2 −2 −2 짝수 쪽 — 편향이 상쇄된다 .down −3 .towardZero −2 양수에서는 같고 음수에서 갈린다 "5는 올림"을 반복하면 합계가 계통적으로 커진다 0.5·1.5·2.5·3.5 → 1+2+3+4 = 10 짝수 반올림 → 0+2+2+4 = 8
원래 합은 8이다. 항상 위로 보내는 방식은 2만큼 부풀린다.

iOS에서는

부동소수점은 rounded(_:), DecimalNSDecimalRound를 쓴다. 규칙 이름이 다르므로 대응을 알아둬야 한다.

// Double — FloatingPointRoundingRule
(2.5).rounded()                              // 3   (.toNearestOrAwayFromZero 기본)
(2.5).rounded(.toNearestOrEven)              // 2   은행가 반올림
(2.5).rounded(.up)                           // 3
(-2.5).rounded(.down)                        // -3  수직선 기준
(-2.5).rounded(.towardZero)                  // -2  0 기준 — .down 과 다르다

// 자릿수 반올림은 직접 스케일링해야 한다 — 여기서 오차가 낀다
func round2(_ v: Double) -> Double { (v * 100).rounded() / 100 }
round2(1.005)                                // 1.0  ← 1.005 가 이미 근삿값이라 기대와 다르다

자릿수 반올림에서 Double이 특히 위험하다. 1.005는 이진으로 1.00499999…이므로 100을 곱해도 100.49999…가 되어 내림된다. 이런 경우가 Decimal을 써야 하는 이유다.

import Foundation

func rounded(_ d: Decimal, scale: Int, mode: NSDecimalNumber.RoundingMode) -> Decimal {
    var input = d, result = Decimal()
    NSDecimalRound(&result, &input, scale, mode)
    return result
}

let v = Decimal(string: "1.005")!
rounded(v, scale: 2, mode: .plain)      // 1.01  ← 기대대로
rounded(v, scale: 2, mode: .bankers)    // 1.00  ← 짝수 쪽
rounded(v, scale: 2, mode: .down)       // 1.00
rounded(v, scale: 2, mode: .up)         // 1.01

규칙 대응은 이렇다.

의도DoubleDecimal
학교식 반올림.toNearestOrAwayFromZero.plain
은행가 반올림.toNearestOrEven.bankers
버림(0 방향).towardZero.down
올림(0 반대).awayFromZero.up

Decimal.down·.up0 기준이라 Double.down(수직선 기준)과 이름이 같지만 음수에서 동작이 다르다. 이 대응표를 팀 문서에 박아 두는 편이 안전하다.

실험 · 도구

여섯 규칙의 결과를 나란히 찍어 보면 차이가 분명해진다.

import Foundation

let rules: [(String, FloatingPointRoundingRule)] = [
    ("toNearestOrAwayFromZero", .toNearestOrAwayFromZero),
    ("toNearestOrEven",         .toNearestOrEven),
    ("up",                      .up),
    ("down",                    .down),
    ("towardZero",              .towardZero),
    ("awayFromZero",            .awayFromZero),
]
let inputs = [0.5, 1.5, 2.5, -0.5, -2.5, -2.4]

print("규칙".padding(toLength: 24, withPad: " ", startingAt: 0)
      + inputs.map { String(format: "%6.1f", $0) }.joined())
for (name, rule) in rules {
    let row = inputs.map { String(format: "%6.0f", $0.rounded(rule)) }.joined()
    print(name.padding(toLength: 24, withPad: " ", startingAt: 0) + row)
}

편향이 실제로 쌓이는지도 측정할 수 있다.

// 0.5 단위 값 1000개를 두 규칙으로 반올림해 합계를 비교한다
let values = (0..<1000).map { Double($0) + 0.5 }
let exact  = values.reduce(0, +)
let away   = values.map { $0.rounded(.toNearestOrAwayFromZero) }.reduce(0, +)
let even   = values.map { $0.rounded(.toNearestOrEven) }.reduce(0, +)

print("정확한 합:      \(exact)")
print("항상 올림 합:   \(away)   차이 \(away - exact)")   // 계통적으로 크다
print("짝수 반올림 합: \(even)   차이 \(even - exact)")   // 거의 0 에 수렴

프로젝트 적용

  1. 반올림 규칙을 코드가 아니라 명세로 정한다. 회계·세무 요구가 규칙을 정하고, 코드는 그것을 반영할 뿐이다. 주석에 근거를 남긴다.
  2. 기본값에 기대지 않는다. rounded()는 학교식이고 IEEE 754 기본은 은행가식이다 — 둘이 다르다는 사실을 알고 명시적으로 고른다.
  3. 음수가 등장하는 계산에서 .down.towardZero를 구분한다. 환불·차감·조정 로직이 해당한다.
  4. 자릿수 반올림은 Decimal로 한다. Double에 100을 곱해 반올림하는 방식은 1.005에서 이미 어긋난다.
  5. 반올림 지점을 한 곳에 모은다. 계산 중간마다 반올림하면 오차가 누적된다. 마지막에 한 번만 하는 것이 원칙이다.
⚠️ 흔한 오해

"반올림은 하나뿐"이라는 것이 가장 흔한 오해다 — 여섯 가지이고 결과가 다르다. "rounded()가 IEEE 754 표준 방식"도 틀렸다 — 표준 기본은 은행가 반올림이고 Swift의 기본은 학교식이다. ".down은 버림"도 음수에서 틀리다 — 버림은 .towardZero다. "(v * 100).rounded() / 100이면 소수 둘째 자리 반올림"도 틀렸다1.005가 반례다. "은행가 반올림은 특이해서 안 쓴다"도 오해다 — IEEE 754와 여러 회계 기준의 기본이다.

🧒 쉽게 이해하기

2.5를 반올림하면 3이라고 배웠다. 그런데 이게 유일한 방법이 아니다.

왜 다른 방법이 필요할까? 2.5를 3으로 올리는 건 항상 위로 보내는 것이다. 0.5도 1로, 1.5도 2로, 2.5도 3으로. 딱 절반인 값을 매번 위로 보낸다.

한두 번은 상관없다. 그런데 천 번 만 번 하면? 조금씩 위로 보낸 게 쌓여서 합계가 실제보다 커진다. 은행이라면 이게 돈이다.

그래서 은행에서 쓰는 방법이 있다 — 짝수 쪽으로 보내기다. 0.5는 0으로(0이 짝수), 1.5는 2로(2가 짝수), 2.5도 2로, 3.5는 4로. 이러면 위로 갔다 아래로 갔다 번갈아 가서 쏠림이 사라진다.

또 하나 헷갈리는 게 있다. 음수다. −2.5를 "내림"하면 뭘까? −3일 수도 있고 −2일 수도 있다. 수직선에서 왼쪽으로 가면 −3이고, 0에 가까워지는 쪽으로 가면 −2다. 둘 다 "내림"이라고 부를 수 있어서, 어느 쪽을 말하는지 정확히 골라야 한다.

꼬리 질문

계산 중간마다 반올림하면 왜 안 되는가?

오차가 누적되고, 결합법칙이 더 심하게 깨지기 때문이다.

반올림은 정보를 버리는 연산이다. 한 번 버린 정보는 되찾을 수 없으므로, 중간에 여러 번 버리면 최종 결과가 원래 값에서 멀어진다.

let items = [Decimal(string: "0.335")!, Decimal(string: "0.335")!, Decimal(string: "0.335")!]

// (A) 매 단계 반올림 — 정보를 세 번 버린다
let a = items.map { rounded($0, scale: 2, mode: .plain) }.reduce(0, +)   // 0.34 × 3 = 1.02

// (B) 마지막에 한 번만 — 정보를 한 번만 버린다
let b = rounded(items.reduce(0, +), scale: 2, mode: .plain)              // 1.005 → 1.01

// 실제 합은 1.005 다. (B) 가 더 가깝다.

다만 중간 반올림이 요구되는 경우도 있다. 항목별 세금처럼 각 항목의 금액이 청구서에 표시되고 그 합이 총액이어야 하면, 항목마다 반올림한 값을 써야 화면상 합계가 맞는다.

수학적 정확성표시 일관성이 충돌한다. 이때는 표시 일관성이 이긴다 — 사용자가 항목을 더해봤을 때 총액과 다르면 신뢰를 잃기 때문이다. 대신 그 규칙을 서버와 동일하게 맞춰야 한다.

쉽게 말하면 물건 무게를 잴 때마다 소수점을 지우고 적으면 조금씩 틀린 값이 쌓인다. 다 재고 나서 마지막에 한 번만 정리하는 게 정확하다. 그런데 영수증에는 물건마다 가격이 적혀 있어야 하고 그걸 더하면 총액이 나와야 하니까, 그때는 어쩔 수 없이 물건마다 정리해야 한다.
백분율 합계가 100%가 안 되는 문제는 어떻게 해결하는가?

가장 큰 나머지 방식(largest remainder method)이 표준 해법이다. 각 항목을 반올림한 뒤 남은 차이를 버려진 소수가 큰 순서로 분배한다.

/// 비율을 정수 백분율로 배분하되 합이 정확히 total 이 되게 한다
func allocate(_ values: [Decimal], total: Int) -> [Int] {
    let sum = values.reduce(0, +)
    guard sum > 0 else { return Array(repeating: 0, count: values.count) }

    // 1) 내림으로 기본 배분
    let exact = values.map { $0 / sum * Decimal(total) }
    var base  = exact.map { (v: Decimal) -> Int in
        var input = v, out = Decimal()
        NSDecimalRound(&out, &input, 0, .down)
        return (out as NSDecimalNumber).intValue
    }

    // 2) 남은 몫을 나머지가 큰 순서로 하나씩 준다
    var remaining = total - base.reduce(0, +)
    let order = exact.indices.sorted {
        (exact[$0] - Decimal(base[$0])) > (exact[$1] - Decimal(base[$1]))
    }
    for i in order where remaining > 0 { base[i] += 1; remaining -= 1 }
    return base
}

allocate([Decimal(1), Decimal(1), Decimal(1)], total: 100)   // [34, 33, 33] — 합 100

이 방식은 합이 정확히 맞는다는 것을 보장하고, 어느 항목이 이득을 볼지도 결정적이다(같은 입력이면 항상 같은 결과).

주의할 점은 동률 처리다. 나머지가 같은 항목이 여럿이면 순서가 불안정해질 수 있으므로, 28장 Q2 꼬리질문에서 본 것처럼 안정적인 2차 기준(ID 등)을 붙여야 한다.

쉽게 말하면 사탕 100개를 세 명에게 똑같이 나누면 33개씩 주고 1개가 남는다. 이 1개를 그냥 버리면 합이 99가 되어 이상하다. 그래서 가장 아깝게 못 받은 사람에게 준다. 누가 가장 아까운지는 계산으로 정해지니까 매번 같은 답이 나온다.
서버와 클라이언트의 반올림 결과가 다르면 어떻게 맞추는가?

계산 주체를 하나로 정하는 것이 가장 확실하다. 29장 Q4 꼬리질문과 같은 결론이다.

순서대로 확인한다.

(1) 언어별 기본 규칙이 다르다. Java의 BigDecimal은 반올림 모드를 명시하지 않으면 예외를 던지고, Python의 round()은행가 반올림이 기본이며, JavaScript의 Math.round()−0.5를 −0이 아니라 0으로 보내는 등 미묘하게 다르다. "당연히 같겠지"가 성립하지 않는다.

(2) 반올림 지점이 다르다. 서버는 항목별로, 클라이언트는 합계에서 반올림하면 결과가 갈린다(앞 꼬리질문).

(3) 중간 타입이 다르다. 서버가 BigDecimal로 계산하고 클라이언트가 Double로 하면 애초에 입력이 다르다.

실무 해법은 이렇다.

  • 최종 금액은 서버가 계산해 내려준다. 클라이언트는 표시만 한다.
  • 클라이언트 계산은 미리보기로만 쓰고, "예상 금액"임을 UI에 밝힌다.
  • 공유 테스트 벡터를 둔다. 경계값 목록을 정해 양쪽이 같은 결과를 내는지 CI에서 확인한다(28장 Q3 꼬리질문과 같은 방식).
쉽게 말하면 가게와 손님이 각자 계산기로 두드려서 다른 답이 나오면 다툼이 생긴다. 그래서 계산은 가게가 하고 손님 화면에는 "예상 금액"이라고 적어 두는 게 안전하다. 그리고 같은 예제 문제 몇 개를 정해서 양쪽이 같은 답을 내는지 미리 맞춰 본다.

Q4. 누적 오차는 어디서 커지는가?

🔑 30초 답변

크기가 크게 다른 값을 더할 때 작은 값이 통째로 삼켜진다. Double은 유효 자릿수가 고정이라, 큰 값에 아주 작은 값을 더하면 작은 값이 표현 범위 밖으로 밀려나 아무 일도 일어나지 않는다. 실측으로 (1e16 + 1) - 1e160이고 (1e16 - 1e16) + 11이다 — 결합법칙이 깨진다. 그래서 합산 순서가 결과를 바꾼다. 대응은 셋이다 — 작은 값부터 더하기, 보정 합산(Kahan) 쓰기, 아니면 애초에 정확한 타입으로 가기.

CS 원리

덧셈에서 오차가 생기는 메커니즘은 지수 맞추기다. 두 부동소수점을 더하려면 지수를 같게 만든 뒤 가수를 더해야 하는데, 지수 차이가 크면 작은 값의 가수가 오른쪽으로 밀려나 잘린다.

가수가 52비트이므로, 두 값의 크기 차이가 2⁵³배를 넘으면 작은 값은 완전히 사라진다. 이것이 1e16 + 1 == 1e16인 이유다.

여기서 대수적 성질이 깨진다. 29장 Q2에서 달력 산술의 결합법칙이 깨지는 것을 봤는데, 부동소수점도 같은 문제를 갖는다.

마지막이 실무에서 곤란하다. 여러 스레드로 나눠 합산하면 결합 순서가 비결정적이라 실행마다 미세하게 다른 결과가 나온다. 이는 버그가 아니라 부동소수점의 성질이지만, "같은 입력이면 같은 출력"을 기대하는 테스트를 깨뜨린다.

대응 원리는 큰 값과 작은 값이 만나는 횟수를 줄이는 것이다. 작은 값부터 더하면 비슷한 크기끼리 합쳐지므로 손실이 줄고, 보정 합산은 잘려나간 부분을 따로 기억했다가 다음 덧셈에 되돌린다.

덧셈은 지수를 맞춘 뒤 가수를 더한다 — 차이가 크면 잘린다 1e16 가수 52비트가 여기를 채운다 1 1 ← 표현 범위 밖으로 밀려나 잘린다 합 = 1e16 — 아무 일도 일어나지 않았다 그래서 순서가 결과를 바꾼다 (1e16 + 1) − 1e16 = 0 (1e16 − 1e16) + 1 = 1 결합법칙이 성립하지 않으므로 병렬 합산은 실행마다 답이 달라질 수 있다
29장 Q2의 달력 산술과 같은 구조다 — 대수적 성질을 가정하면 안 되는 연산이다.

iOS에서는

순서에 따른 차이를 직접 확인할 수 있다.

// 결합법칙이 깨진다
(1e16 + 1) - 1e16        // 0.0
(1e16 - 1e16) + 1        // 1.0

// 큰 값 하나에 작은 값을 많이 더하면 전부 삼켜진다
var s = 1e16
for _ in 0..<1000 { s += 1 }
s == 1e16                // true — 1000 번 더했는데 그대로다

// 작은 값부터 더하면 살아남는다
var t = 0.0
for _ in 0..<1000 { t += 1 }
t += 1e16
t - 1e16                 // 1000.0

보정 합산(Kahan summation)은 잘려나간 부분을 별도 변수에 기억해 다음 덧셈에 되돌린다.

/// 잘려나간 하위 비트를 보정하며 합산한다
func kahanSum(_ values: [Double]) -> Double {
    var sum = 0.0
    var c = 0.0                      // 아직 반영되지 못한 보정분
    for v in values {
        let y = v - c                // 이전에 잘린 만큼을 먼저 되돌린다
        let t = sum + y
        c = (t - sum) - y            // 이번에 잘린 양을 다시 기억한다
        sum = t
    }
    return sum
}

let vals = Array(repeating: 0.1, count: 10)
vals.reduce(0, +)        // 0.9999999999999999
kahanSum(vals)           // 1.0

다만 돈 계산에는 이것도 답이 아니다. 보정 합산은 오차를 줄일 뿐 정확하게 만들지는 않는다. 정확성이 요구되면 Decimal이나 정수로 가야 한다(Q2).

// 통계·그래픽처럼 근사가 허용되는 곳 → Kahan 또는 정렬 후 합산
// 금액·수량처럼 정확해야 하는 곳    → Decimal / 정수

// 정렬 후 합산 — 작은 값부터 더해 손실을 줄인다
let safer = values.sorted { abs($0) < abs($1) }.reduce(0, +)

실험 · 도구

순서가 결과를 바꾸는 것을 측정해 보자.

import Foundation

// 큰 값 하나 + 작은 값 다수
var values = Array(repeating: 1.0, count: 100_000)
values.append(1e16)

let forward  = values.reduce(0, +)                              // 큰 값이 앞쪽에 오는 경우
let sorted   = values.sorted { abs($0) < abs($1) }.reduce(0, +) // 작은 값부터
let kahan    = kahanSum(values)

print("정확한 합:   \(1e16 + 100_000)")
print("순서대로:    \(forward)")
print("정렬 후:     \(sorted)")
print("Kahan:      \(kahan)")
//   순서대로 더하면 작은 값들이 삼켜져 1e16 에 가깝게 나온다

병렬 합산의 비결정성도 확인할 수 있다.

// 같은 배열을 여러 번 병렬 합산하면 결과가 미세하게 흔들릴 수 있다
func parallelSum(_ v: [Double]) async -> Double {
    await withTaskGroup(of: Double.self) { group in
        let chunk = v.count / 4
        for i in 0..<4 {
            let slice = Array(v[(i * chunk)..<min((i + 1) * chunk, v.count)])
            group.addTask { slice.reduce(0, +) }
        }
        var total = 0.0
        for await partial in group { total += partial }   // 도착 순서가 비결정적이다
        return total
    }
}
//   부분합의 결합 순서가 매번 다를 수 있어 하위 비트가 흔들린다.
//   결정성이 필요하면 부분합을 인덱스 순으로 모아 더한다.

기존 코드에서 위험한 합산을 찾는다.

# 부동소수점 누적 합산 — 값의 크기 분포를 확인할 후보
grep -rn "reduce(0" --include=*.swift . | grep -iE "double|float|sum|total"

# 병렬 합산 — 결정성이 필요한 곳인지 검토
grep -rn -A5 "withTaskGroup" --include=*.swift . | grep -iE "\+=|reduce"

프로젝트 적용

  1. 합산 결과를 ==로 비교하지 않는다. 순서가 조금만 달라도 하위 비트가 다르다(Q5).
  2. 값의 크기 분포를 본다. 크기가 고르면 순서가 큰 문제가 안 되지만, 큰 값 하나에 작은 값 다수가 전형적인 위험 패턴이다.
  3. 정확성이 필요하면 보정 합산이 아니라 정확한 타입으로 간다. Kahan은 줄일 뿐 없애지 못한다.
  4. 병렬 합산에 결정성이 필요하면 결합 순서를 고정한다. 부분합을 인덱스 순으로 모은다.
  5. 스냅샷 테스트에 부동소수점 합계를 그대로 넣지 않는다. 플랫폼·최적화 수준에 따라 하위 비트가 달라질 수 있다.
⚠️ 흔한 오해

"덧셈은 순서와 무관하다"가 부동소수점에서는 틀리다 — 결합법칙이 깨진다. "값이 작으면 오차도 작다"도 틀렸다 — 큰 값과 만나면 통째로 삼켜진다. "Kahan 합산을 쓰면 정확해진다"도 틀렸다 — 오차를 줄일 뿐이다. "병렬로 더해도 같은 답이 나온다"도 틀렸다 — 결합 순서가 비결정적이면 결과가 흔들린다. "reduce(0, +)는 안전한 관용구"도 맥락에 달렸다 — 값의 크기 분포가 넓으면 위험하다.

🧒 쉽게 이해하기

저울에 유효 숫자가 정해져 있다고 생각해 보자. 예를 들어 앞에서부터 16자리까지만 적을 수 있다.

지금 저울에 1경(1 뒤에 0이 16개)이 올라가 있다. 여기에 1을 더 올리면?

1은 17번째 자리에 해당한다. 그런데 우리는 16자리까지만 적을 수 있으니, 적을 자리가 없다. 그래서 저울 숫자는 그대로다. 1을 천 번 올려도 그대로다.

그런데 순서를 바꾸면 다르다. 1을 천 번 먼저 더해서 1000을 만든 다음, 그걸 1경에 더하면? 1000은 자리가 있어서 제대로 더해진다.

그러니까 같은 숫자들을 더하는데 순서에 따라 답이 달라진다. 학교에서 배운 "더하기는 순서를 바꿔도 된다"가 여기서는 안 통한다.

이게 특히 곤란한 경우가 있다. 여러 사람이 나눠서 동시에 더하면, 누가 먼저 끝나느냐에 따라 합치는 순서가 달라진다. 그래서 같은 데이터인데 실행할 때마다 답이 조금씩 다르게 나올 수 있다.

꼬리 질문

평균·분산 계산에서 특히 조심할 점은?

분산 계산의 순진한 공식이 심각한 오차를 낸다. 교과서 공식 E[X²] − (E[X])²큰 값에서 거의 같은 두 수를 빼므로 유효 자릿수가 대량으로 손실된다(catastrophic cancellation).

// ❌ 순진한 분산 — 값이 크면 음수 분산이 나오기도 한다
func naiveVariance(_ v: [Double]) -> Double {
    let n = Double(v.count)
    let sumSq = v.reduce(0) { $0 + $1 * $1 }
    let mean = v.reduce(0, +) / n
    return sumSq / n - mean * mean          // 큰 값끼리 빼기 — 위험
}

// ✅ Welford 온라인 알고리즘 — 수치적으로 안정적이다
func welfordVariance(_ v: [Double]) -> Double {
    var n = 0.0, mean = 0.0, m2 = 0.0
    for x in v {
        n += 1
        let delta = x - mean
        mean += delta / n
        m2 += delta * (x - mean)
    }
    return n > 1 ? m2 / n : 0
}

let big = (0..<1000).map { 1e9 + Double($0) }
naiveVariance(big)      // 음수나 0 이 나올 수 있다
welfordVariance(big)    // 안정적인 값

평균도 합을 먼저 구해 나누는 방식이 큰 배열에서 위험하다. Welford처럼 온라인으로 갱신하면 중간 합이 커지지 않아 안정적이다.

일반 원리는 "거의 같은 두 수를 빼지 않는다"이다. 뺄셈으로 상위 자릿수가 상쇄되면 남는 것은 하위 비트의 오차뿐이 된다.

쉽게 말하면 아주 큰 숫자 둘을 빼서 작은 차이를 구하려 하면, 큰 숫자들이 이미 대충 적힌 값이라 차이가 거의 오차만 남는다. 10억 하고 조금과 10억 하고 조금을 빼면, "조금"들이 정확하지 않았으니 결과도 못 믿는다. 그래서 큰 값을 만들지 않고 조금씩 갱신하는 방법을 쓴다.
-ffast-math 같은 최적화가 결과를 바꿀 수 있는가?

바꿀 수 있다. 공격적인 부동소수점 최적화는 결합법칙이 성립한다고 가정하고 연산 순서를 재배치하는데, 앞서 봤듯 그 가정이 틀리다.

Swift는 기본적으로 IEEE 754 의미론을 보존하므로 이런 재배치를 하지 않는다. 그래서 Swift 코드만 쓰면 대체로 안전하다. 위험은 C/C++ 의존성에서 온다.

  • SPM·CocoaPods로 들어온 C 라이브러리가 -ffast-math로 빌드됐을 수 있다.
  • Accelerate·SIMD 연산은 벡터 폭 단위로 부분합을 만들므로 스칼라 합산과 결과가 다를 수 있다. 이건 최적화 플래그와 무관하게 알고리즘 차이다.
import Accelerate

let v = Array(repeating: 0.1, count: 1000)
let scalar = v.reduce(0, +)
var vector = 0.0
vDSP_sveD(v, 1, &vector, vDSP_Length(v.count))
scalar == vector            // 반드시 같지는 않다 — 부분합 결합 순서가 다르다

실무 대응은 동등성 대신 허용 오차로 검증하는 것이다(Q5). 그리고 정확성이 요구되는 계산에는 SIMD·근사 최적화를 쓰지 않는다.

쉽게 말하면 컴퓨터가 "더하기는 순서 바꿔도 되지"라고 생각하고 계산 순서를 마음대로 바꿔 속도를 높이는 경우가 있다. 그런데 앞에서 봤듯 순서를 바꾸면 답이 달라진다. Swift는 이런 재배치를 안 하지만, 다른 언어로 만든 부품은 할 수 있다.
돈이 아닌데도 정확한 합산이 필요한 경우가 있는가?

있다. "정확해야 하는가"는 도메인이 아니라 그 값으로 무엇을 판정하는가가 정한다.

대표적인 경우들이다.

  • 진행률·백분율 — 합이 100%가 되어야 하는 경우(Q3 꼬리질문).
  • 재고·수량 — 개수는 정수이며 1개 차이가 의미를 갖는다.
  • 포인트·크레딧 — 사실상 돈이다.
  • 바이트 수·오프셋 — 1바이트가 어긋나면 파일이 깨진다. 정수로 다뤄야 한다.
  • 체크섬·해시 입력 — 값이 조금만 달라도 완전히 다른 결과가 된다.

반대로 근사가 허용되는 경우도 분명하다 — 화면 좌표, 애니메이션 보간, 통계 지표, 물리 시뮬레이션. 이런 곳에서 Decimal을 쓰는 것은 불필요한 비용이다.

판단 기준을 한 문장으로 하면 이렇다 — "이 값이 다른 값과 정확히 같은지를 묻는가?" 묻는다면 정확한 타입이 필요하고, 묻지 않는다면 Double로 충분하다.

쉽게 말하면 "정확해야 하나?"는 돈이냐 아니냐가 아니라 1이 틀리면 문제가 되냐로 정한다. 화면에서 점 위치가 0.0001 어긋나는 건 아무도 모르지만, 재고가 1개 어긋나면 물건이 없는데 팔린다.

Q5. 수치 결과를 어떻게 테스트하는가?

🔑 30초 답변

동등성 대신 허용 오차로 비교한다. Swift Testing과 XCTest 모두 부동소수점 근사 비교를 기본 제공하지 않으므로(XCTest의 XCTAssertEqual(_:_:accuracy:)는 있지만 Swift Testing에는 등가물이 없다) 직접 정하거나 swift-numericsisApproximatelyEqual()을 쓴다. 중요한 것은 절대 오차와 상대 오차 중 무엇을 쓸지다 — 값이 0 근처면 절대 오차, 크면 상대 오차가 맞다. 그리고 정확해야 하는 값은 애초에 근사 비교를 쓰면 안 된다. Decimal·정수라면 ==엄격하게 비교해야 회귀가 잡힌다.

CS 원리

부동소수점 비교의 어려움은 "같다"의 정의가 맥락에 달렸다는 데 있다.

방식판정적합한 곳
절대 오차|a − b| < ε값의 크기가 0 근처로 알려진 경우
상대 오차|a − b| / max(|a|,|b|) < ε값이 크거나 범위가 넓은 경우
ULP 거리표현 가능한 값 몇 칸 차이인가수치 라이브러리 검증

절대 오차만 쓰면 큰 값에서 지나치게 엄격해진다 — 1e9 근처는 ULP가 이미 1.19e-7이라(Q1) ε = 1e-9로는 절대 통과하지 못한다. 반대로 상대 오차만 쓰면 0 근처에서 지나치게 관대해진다 — 0에 가까운 두 값은 상대 오차가 쉽게 커진다.

그래서 실무에서는 둘을 결합한다. 작은 값에는 절대 오차, 큰 값에는 상대 오차를 적용하는 형태다.

더 중요한 원칙이 하나 있다 — 근사 비교는 근사가 허용되는 값에만 쓴다. 금액을 Decimal로 정확히 계산해 놓고 테스트에서 accuracy: 0.01로 비교하면, 1원 오차를 통과시키는 테스트가 된다. 정확한 타입에는 엄격한 ==가 맞다.

"같다"의 기준이 값의 크기에 따라 달라야 한다 0 1e6 1e15 값의 크기 → 절대 오차가 잘 맞는 구간 상대 오차가 잘 맞는 구간 절대 오차만 쓰면 1e9 근처는 ULP 가 이미 1.19e-7 ε=1e-9 로는 절대 통과 못 한다 상대 오차만 쓰면 0 근처에서 분모가 작아진다 사소한 차이도 크게 판정된다 실무 — 둘을 결합해 판정한다 단, 정확해야 하는 값에 근사 비교를 쓰면 회귀를 놓친다
근사 비교는 근사가 허용되는 값에만 쓴다. Decimal·정수는 ==로 엄격하게 본다.

iOS에서는

Swift Testing에는 정확도 비교 등가물이 없으므로 직접 정하거나 라이브러리를 쓴다.

import Testing
import Numerics        // swift-numerics

@Test func 평균이_기대값에_가깝다() {
    let result = average(of: samples)

    // ✅ swift-numerics — 절대·상대 오차를 함께 다룬다
    #expect(result.isApproximatelyEqual(to: 42.0, relativeTolerance: 1e-9))

    // ✅ 직접 정할 수도 있다
    #expect(abs(result - 42.0) <= 1e-9 * max(abs(result), 42.0))
}

// XCTest 에는 정확도 인자가 있다
XCTAssertEqual(result, 42.0, accuracy: 1e-9)

정확한 타입은 엄격하게 비교한다.

@Test func 금액_합계가_정확하다() {
    let total = cart.items.reduce(Decimal(0)) { $0 + $1.price }

    // ✅ Decimal 은 정확하므로 == 로 본다
    #expect(total == Decimal(string: "59700.30")!)

    // ❌ 근사 비교를 쓰면 1원 오차를 통과시킨다
    // #expect(abs(total - expected) < 0.01)
}

속성 기반 검사가 수치 코드에 특히 잘 맞는다. 개별 값이 아니라 불변식을 검사한다.

@Test(arguments: [
    [0.1, 0.2, 0.3], [1e16, 1.0, 1.0], [-5.0, 5.0], Array(repeating: 0.1, count: 100)
])
func 합산은_순서와_무관해야_한다_근사적으로(_ values: [Double]) {
    let a = values.reduce(0, +)
    let b = values.reversed().reduce(0, +)
    // 정확히 같지는 않지만 상대 오차 범위 안이어야 한다
    #expect(a.isApproximatelyEqual(to: b, relativeTolerance: 1e-12)
            || (a.isFinite && b.isFinite))
}

@Test func 반올림은_원본과_반올림단위_이내여야_한다() {
    for v in stride(from: -100.0, through: 100.0, by: 0.37) {
        #expect(abs(v.rounded() - v) <= 0.5)
    }
}

실험 · 도구

허용 오차를 잘못 잡으면 어떻게 되는지 확인해 보자.

import Foundation

func compare(_ a: Double, _ b: Double, absEps: Double, relEps: Double) -> String {
    let absOK = abs(a - b) <= absEps
    let relOK = abs(a - b) <= relEps * Swift.max(abs(a), abs(b))
    return "절대 \(absOK ? "통과" : "실패")  상대 \(relOK ? "통과" : "실패")"
}

// 작은 값 — 절대 오차가 적합
print(compare(0.1 + 0.2, 0.3, absEps: 1e-9, relEps: 1e-9))     // 둘 다 통과

// 큰 값 — 절대 오차는 실패한다
let big = 1e9
print(compare(big + 0.1, big, absEps: 1e-9, relEps: 1e-9))     // 절대 실패, 상대 통과

// 0 근처 — 상대 오차가 지나치게 엄격해진다
print(compare(1e-20, 2e-20, absEps: 1e-9, relEps: 1e-9))       // 절대 통과, 상대 실패

테스트에서 위험한 패턴을 찾는다.

# 부동소수점을 == 로 단언하는 테스트 — 플랫폼에 따라 깨진다
grep -rnE "#expect\(.*(Double|Float).*==" --include=*Tests*.swift .

# Decimal 을 근사 비교하는 테스트 — 정확성 회귀를 놓친다
grep -rn -B2 "accuracy:" --include=*Tests*.swift . | grep -i decimal

# 스냅샷에 부동소수점 값이 그대로 박혀 있는지
grep -rnE "[0-9]+\.[0-9]{10,}" --include=*.json __Snapshots__/ 2>/dev/null | head

프로젝트 적용

  1. 타입에 따라 비교 방식을 정한다. Double·Float는 근사, Decimal·정수는 엄격. 이 규칙을 팀 컨벤션으로 박아 둔다.
  2. 허용 오차를 상수로 정의하고 근거를 남긴다. 1e-9가 어디서 나왔는지 아무도 모르는 상황을 피한다.
  3. 절대·상대를 결합한 헬퍼를 하나 만들어 쓴다. 매 테스트에서 다시 고민하지 않게 한다.
  4. 불변식을 검사한다. "합이 100이다", "순서를 바꿔도 근사적으로 같다", "반올림 오차가 반올림 단위 이내다" 같은 성질은 개별 값보다 강한 검증이다.
  5. 경계값을 테스트에 고정한다. 0, 음수, 2⁵³, 1e16, nan, infinity, 아주 작은 값. 이 목록이 이 챕터의 요약이기도 하다.
⚠️ 흔한 오해

"부동소수점은 accuracy만 주면 안전하게 비교된다"는 불충분하다 — 값의 크기에 따라 절대·상대를 골라야 한다. "Swift Testing에도 accuracy가 있다"는 틀렸다 — XCTest에는 있지만 Swift Testing에는 등가물이 없어 직접 정하거나 swift-numerics를 쓴다. "Decimal도 근사 비교하는 게 안전하다"는 정반대다 — 정확한 타입에 근사 비교를 쓰면 회귀를 놓친다. "테스트가 통과하니 수치가 정확하다"도 위험하다 — 허용 오차가 너무 넓으면 아무것도 검증하지 못한다.

🧒 쉽게 이해하기

두 값이 "같은지" 확인하는 방법이 하나가 아니다.

키를 잰다고 해 보자. 170.0cm와 170.0001cm는 같다고 봐도 된다. 0.0001cm 차이는 자로 잴 수도 없다.

그런데 무엇을 재느냐에 따라 기준이 달라져야 한다.

머리카락 굵기를 잰다면 0.0001cm가 엄청난 차이다. 반대로 서울에서 부산까지 거리를 잰다면 1미터 차이도 아무것도 아니다.

그래서 "얼마나 가까우면 같다고 볼까"를 재는 대상의 크기에 맞춰 정해야 한다. 작은 것에는 절대적인 기준("0.001 이내면 같다"), 큰 것에는 비율 기준("0.0001% 이내면 같다")을 쓴다.

마지막으로 중요한 게 하나 있다. 돈처럼 정확해야 하는 것에는 이런 봐주기를 쓰면 안 된다. "1원쯤 차이는 같은 걸로 치자"고 테스트를 만들면, 1원이 틀리는 버그를 영영 못 잡는다.

꼬리 질문

허용 오차 값은 어떻게 정하는가?

근거를 셋 중 하나에서 가져온다. 감으로 정한 1e-9가 가장 나쁘다.

(1) 연산 횟수에서 역산한다. 부동소수점 연산 하나의 상대 오차는 최대 ULP의 절반이다(Double이면 약 1.1e-16). n번 연산하면 오차가 대략 n배까지 쌓일 수 있으므로, 연산 횟수 × 머신 엡실론이 하한이 된다.

// 1000 개를 합산한다면
let epsilon = Double.ulpOfOne          // 2.22e-16
let tolerance = Double(values.count) * epsilon    // 약 2.2e-13
#expect(result.isApproximatelyEqual(to: expected, relativeTolerance: tolerance))

(2) 도메인 요구에서 가져온다. "화면 좌표는 0.5픽셀 이내", "센서 정밀도가 0.01도" 같은 물리적 근거가 있으면 그것을 쓴다. 이 경우 오차는 부동소수점이 아니라 도메인이 정한다.

(3) 측정해서 정한다. 실제 데이터로 여러 번 돌려 오차 분포를 보고, 관측된 최대치에 여유를 둔다. 다만 이 방법은 회귀를 놓칠 위험이 있으므로 상한을 함께 정한다.

어느 쪽이든 상수에 이름과 주석을 붙인다.

/// 1000회 누적 합산의 이론적 오차 상한 (n × ulpOfOne) 에 10배 여유
private let sumTolerance = 2.2e-12
쉽게 말하면 "얼마나 틀려도 봐줄까"를 아무렇게나 정하면 안 된다. 계산을 몇 번 했는지에서 계산으로 뽑거나, "자로 재도 이 정도는 못 재니까"처럼 현실적인 근거에서 가져와야 한다. 그리고 왜 그 숫자인지 적어 둬야 나중에 사람이 마음대로 늘리지 않는다.
회귀 테스트에 계산 결과를 그대로 박아 두면 안 되는가?

부동소수점은 위험하고, 정확한 타입은 괜찮다.

Double 결과를 문자열로 스냅샷에 박아 두면 다음에 깨질 수 있다.

  • 플랫폼 차이 — SIMD 폭이나 라이브러리 구현이 다르면 하위 비트가 달라진다.
  • 컴파일러 버전 — 최적화가 바뀌면 연산 순서가 달라질 수 있다.
  • 병렬 처리 — 결합 순서가 비결정적이면 실행마다 다르다(Q4).

대신 불변식과 근사 비교로 검증한다.

// ❌ 하위 비트까지 고정 — 환경이 바뀌면 깨진다
#expect("\(result)" == "0.30000000000000004")

// ✅ 성질을 검사한다
#expect(result.isApproximatelyEqual(to: 0.3, relativeTolerance: 1e-12))
#expect(result > 0.29 && result < 0.31)

반대로 Decimal·정수 결과는 스냅샷에 박아도 안전하다 — 결정적이기 때문이다. 오히려 박아 두는 것이 회귀 검출에 유리하다. 금액 계산의 스냅샷 테스트는 권장할 만하다.

28장 Q2 꼬리질문에서 정렬 결과를 스냅샷으로 고정하지 말라고 한 것과 같은 원리다 — 결정적이지 않은 것을 고정하면 유지되지 않는 테스트가 된다.

쉽게 말하면 답을 소수점 끝자리까지 그대로 적어 두고 비교하면, 계산기를 바꾸거나 계산 순서가 조금만 달라져도 틀렸다고 나온다. 그래서 대충 계산하는 값은 "이 근처면 맞다"로 확인하고, 정확한 계산기로 낸 값만 끝자리까지 적어 둔다.
수치 버그를 프로덕션에서 어떻게 발견하는가?

불변식을 런타임에 검사하고 위반을 관측 지표로 올린다. P1 18장에서 만든 파이프라인이 여기서 쓰인다.

검사할 만한 불변식들이다.

  • 합계 일치 — 항목 합과 총액이 같은가. 다르면 이벤트를 남긴다.
  • 범위 — 백분율이 0~100인가, 금액이 음수가 아닌가.
  • 유한성nan·infinity가 흘러다니지 않는가(Q1 꼬리질문).
  • 부호 — 환불액이 원 결제액을 넘지 않는가.
func validate(_ invoice: Invoice) {
    let itemSum = invoice.items.reduce(Decimal(0)) { $0 + $1.total }
    if itemSum != invoice.total {
        logger.error("합계 불일치: 항목합 \(itemSum) vs 총액 \(invoice.total)")
        metrics.increment("invoice.sum_mismatch")     // 지표로 추세를 본다
    }
}

크래시시키지 않는 것이 중요하다. precondition으로 죽이면 사용자가 결제를 못 하게 되고, 그건 버그보다 나쁘다. 기록하고 계속 진행하되 지표로 빈도를 추적한다.

그리고 어느 입력에서 생겼는지를 함께 남긴다 — 23장 Q6에서 본 대로 값 자체가 아니라 특성(자릿수, 부호, 범위)을 남기면 프라이버시를 지키면서 진단할 수 있다.

쉽게 말하면 계산이 틀렸는지 스스로 확인하는 검사를 코드에 넣어 둔다. "물건값을 다 더한 게 총액과 같은가?" 같은 것이다. 다르면 앱을 죽이지는 말고 조용히 기록해 둔다. 그러면 나중에 "이런 일이 하루에 몇 번 일어나는지" 알 수 있고, 늘어나면 바로 알아챈다.

출처 · 참고자료