← Swift & Foundation 로드맵 31 / 33

31 · 숫자와 부동소수점

Swift가 정수 오버플로에서 조용히 넘어가지 않고 SIGTRAP으로 죽이는 이유부터, 0.1의 실제 저장값 0.10000000000000000555, NaN을 담은 배열의 sorted()가 정렬을 아예 포기하고 Set이 방금 넣은 NaN을 못 찾는 실측, 2^53+1이 Double을 거치면 조용히 1 줄어드는 문제, 그리고 SIMD까지 직접 실행해 확인한다.
진행률
0 / 0 완료

숫자는 프로그래밍에서 가장 먼저 배우고 가장 늦게까지 틀리는 주제다. IntDouble은 너무 익숙해서 "그냥 숫자"로 취급되지만, 둘 다 유한한 비트에 무한한 수를 억지로 밀어 넣은 근사 장치다. 이 챕터는 그 근사가 어디서 새는지를 실측으로 짚는다.

29장과 30장이 메모리 안전을 다뤘다면 이 챕터는 수치 안전을 다룬다. 그런데 두 이야기는 같은 구조를 갖고 있다 — 29장에서 위험도를 종료 코드로 줄 세웠던 것처럼, 여기서도 시끄럽게 죽는 실패와 조용히 틀린 값을 남기는 실패가 갈린다. Swift는 정수 쪽에서는 시끄럽게 죽는 쪽을 택했고(Q1), 부동소수점 쪽에서는 그럴 수 없었다(Q3·Q4). 그 비대칭이 이 챕터의 뼈대다.

📝 노트

본문의 모든 수치·종료 코드·출력은 Swift 6.2.1 (swiftlang-6.2.1.4.8) / macOS 26.6.1 / arm64에서 직접 컴파일·실행한 실측값이다. 돈 계산에 Decimal을 써야 하는 이유는 Part B 23장 Q5에서 이미 다뤘으므로, 여기서는 그 아래층인 부동소수점 자체의 동작에 집중한다.

Q1. 정수 오버플로는 왜 기본이 트랩인가? &+ · addingReportingOverflow · clamping은 각각 언제 쓰는가?

🔑 30초 답변

C 계열 언어는 부호 있는 정수 오버플로를 정의되지 않은 동작으로 두거나 조용히 감싸(wrap) 넘긴다. Swift는 기본 연산자(+, -, *)가 오버플로에서 즉시 트랩하도록 정했다 — 실측으로 Int.max + 1은 종료 코드 133(SIGTRAP)으로 프로세스를 끝낸다. 잘못된 값으로 계산을 계속하는 것보다 멈추는 게 안전하다는 판단이다. 감싸는 동작이 의도인 경우(해시 계산, 체크섬, 링 버퍼 인덱스)를 위해 &+·&-·&*가 따로 있고, 오버플로 여부를 값으로 받아 분기하려면 addingReportingOverflow, 경계로 고정하려면 init(clamping:)을 쓴다.

원리

Swift의 설계 결정은 "정수 연산은 수학적으로 올바른 결과를 내거나, 그럴 수 없으면 실행을 멈춘다"는 것이다. 중간이 없다. 이는 Part A 03장 Q5에서 본 precondition·fatalError의 철학과 동일하다 — 복구할 수 없는 프로그래머 오류는 계속 진행하지 않는다.

왜 이 선택이 중요한가. 오버플로로 음수가 되거나 감싸진 값이 배열 인덱스나 버퍼 크기 계산에 쓰이면, 수치 오류가 그대로 메모리 접근 오류로 번진다 — 29장에서 본 "조용한 실패"가 메모리 영역까지 이어지는 경로다. Swift는 그 연결을 끊기 위해 수치 오류를 메모리 오류로 자라기 전에 잘라낸다. (정수 오버플로가 실제 보안 취약점 분류에서 얼마나 큰 비중인지는 이 강의가 확인하지 않았으므로 수치로 주장하지 않는다.)

내부 동작

+는 하드웨어 덧셈 뒤에 오버플로 플래그 검사와 조건부 트랩을 붙인 형태로 컴파일된다. 이 검사가 붙는 연산과 붙지 않는 연산을 실측으로 가르면 이렇다.

다섯 가지 연산의 종료 코드 — 실측
Int.max + 1        → exit 133      # SIGTRAP — 오버플로
Int(1e30)          → exit 133      # SIGTRAP — 표현 범위 초과
Int.min / -1       → exit 133      # SIGTRAP — 결과가 Int.max+1
10 / 0             → exit 133      # SIGTRAP — 0으로 나누기
Int.max &+ 1       → exit 0        # 정상 — 감싸서 Int.min

네 번째와 다섯 번째가 대비의 핵심이다. 앞의 넷은 전부 같은 신호로 죽는다 — 종류가 달라도 "더 진행할 수 없다"는 판단은 같다. 반면 &+는 검사를 아예 생성하지 않으므로 종료 코드가 0이다.

Int.min / -1이 트랩하는 이유를 짚어둘 만하다. 수학적 답은 +9223372036854775808인데 이는 Int.max보다 1 크다. 2의 보수 표현에서 음수 범위가 양수보다 하나 넓기 때문에 생기는 유일한 나눗셈 오버플로다.

감싸기와 보고의 실제 값 — 실측
print("Int.max         =", Int.max)
print("Int.max &+ 1    =", Int.max &+ 1)
let (v, o) = Int.max.addingReportingOverflow(1)
print("addingReportingOverflow(1) = (\(v), overflow: \(o))")
실행 결과
Int.max         = 9223372036854775807
Int.max &+ 1    = -9223372036854775808
addingReportingOverflow(1) = (-9223372036854775808, overflow: true)

addingReportingOverflow가 돌려주는 값이 &+의 결과와 정확히 같다는 점에 주의한다. 차이는 값이 아니라 overflow: true라는 정보가 함께 온다는 것이다. 그래서 이 API는 "감싸도 되는지"를 호출자가 판단할 수 있게 해준다.

실험 · 도구

네 가지 도구의 역할을 한 표로 정리하면 선택이 기계적으로 된다.

도구오버플로 시쓸 자리
+ - *트랩 (exit 133)기본값 — 오버플로가 버그인 모든 계산
&+ &- &*감싼다 (모듈로 2ⁿ)감싸기가 의도일 때 — 해시, 체크섬, 링 인덱스
addingReportingOverflow감싼 값 + true외부 입력 검증 — 분기해서 처리
init(clamping:)경계값으로 고정UI 값·좌표처럼 "범위를 넘으면 끝값"이 맞을 때
init(exactly:)nil변환 실패를 옵셔널로 다루고 싶을 때 (Q5)
clamping과 truncatingIfNeeded의 실제 값 — 실측
print("Int8(exactly: 200) =", Int8(exactly: 200) as Any)
print("Int8(truncatingIfNeeded: 200) =", Int8(truncatingIfNeeded: 200))
print("UInt8(clamping: 300) =", UInt8(clamping: 300),
      "| UInt8(clamping: -5) =", UInt8(clamping: -5))
실행 결과
Int8(exactly: 200) = nil
Int8(truncatingIfNeeded: 200) = -56
UInt8(clamping: 300) = 255 | UInt8(clamping: -5) = 0

200-56이 되는 것이 truncatingIfNeeded의 정체다. 하위 8비트만 남기니 11001000이 되고, Int8에서 이 비트 패턴은 -56이다. 이 결과를 원한 게 아니라면 이 API를 쓰면 안 된다 — 이름에 "필요하면 잘라낸다"가 들어 있는 이유다.

프로젝트 적용

① 가장 흔한 실전 사고는 외부에서 온 숫자로 산술을 하는 것이다. 네트워크 응답의 개수 필드, 파일 헤더의 길이, 사용자 입력. 여기서 +를 그냥 쓰면 악의적이거나 손상된 입력이 크래시를 만든다 — 29장 Q4에서 capacity를 상한으로 잘라야 했던 것과 같은 종류의 문제다.

외부 입력 산술 — 보고형 API로 분기한다
func totalBytes(count: Int, itemSize: Int) -> Int? {
    // ❌ count * itemSize  — 손상된 입력이면 트랩
    let (product, overflow) = count.multipliedReportingOverflow(by: itemSize)
    guard !overflow, product >= 0 else { return nil }   // 거부한다
    return product
}

// 서버가 count: Int.max 를 보내도 크래시가 아니라 nil 이 된다
print(totalBytes(count: Int.max, itemSize: 4) as Any)   // nil

② &+를 쓰는 것이 맞는 자리도 분명히 있다. Part A 10장에서 본 해시 결합이 대표적이다 — 해시는 비트를 섞는 것이 목적이므로 감싸기가 정상 동작이다. 다만 그럴 때는 주석으로 의도를 남기는 것이 리뷰에 도움이 된다.

감싸기가 의도인 자리 — 의도를 명시한다
// 링 버퍼 인덱스 — 감싸는 것이 정상 동작이다
struct RingIndex {
    private var raw: UInt32 = 0
    let capacity: UInt32
    // private 저장 프로퍼티가 있으면 멤버와이즈 init 이 비공개가 되므로 직접 선언한다
    init(capacity: UInt32) { self.capacity = capacity }
    mutating func advance() {
        raw = raw &+ 1                 // 의도적 wrap — 2^32 주기
    }
    var slot: Int { Int(raw % capacity) }
}

③ 릴리스 빌드에서도 검사는 살아 있다. 29장 Q2에서 배타적 접근 검사가 -O에서도 켜져 있음을 확인했는데, 산술 오버플로 검사도 같다 — 위 실측이 전부 -O 빌드다. 검사를 끄는 -Ounchecked가 존재하지만, Part A 03장 Q5에서 다뤘듯 안전 검사를 통째로 제거하므로 일반 앱에서 쓸 선택지가 아니다.

⚠️ 흔한 오해

"Swift는 오버플로를 자동으로 감싸준다"는 C 습관에서 온 오해다 — 정반대로 기본은 트랩이고, 감싸려면 & 계열을 명시해야 한다. 또 "&++보다 빠르다"는 대개 무의미한 차이다. 오버플로 검사는 CPU 플래그 확인 + 조건 분기 한 번이고, 분기 예측이 거의 항상 맞아 실측 가능한 차이가 나오는 경우는 드물다 — 정확성을 성능과 바꾸지 말 것. 마지막으로 "addingReportingOverflow는 오버플로 시 값을 안 준다"도 틀렸다. 감싼 값을 함께 주며, 그 값은 &+의 결과와 동일하다.

🧒 쉽게 이해하기

자동차 주행거리계를 생각하자. 999999km에서 1km를 더 달리면 계기판이 000000으로 돌아간다 — 이것이 "감싸기"(&+)다. C 언어는 이렇게 조용히 0으로 돌아가는 계기판이다. Swift는 999999에서 더 달리려 하면 시동을 꺼버리는 차다. 황당해 보이지만 이유가 있다 — 계기판이 0으로 돌아간 걸 모른 채 "이 차는 새 차니까 정비 안 해도 되네"라고 판단하면 훨씬 큰 사고가 나기 때문이다. 그래서 정말 계기판을 돌리고 싶을 때는 "나 지금 일부러 돌립니다"라는 버튼(&+)을 따로 눌러야 한다.

꼬리 질문

왜 네 가지 다른 오류가 모두 exit 133으로 똑같이 죽는가?
전부 컴파일러가 인라인으로 삽입한 검사가 실패한 경우이기 때문이다. 오버플로든 0으로 나누기든 표현 범위 초과든, 컴파일러는 조건을 확인해 실패 시 트랩 명령을 실행하는 같은 형태의 코드를 만든다. 트랩 명령은 SIGTRAP(128+5=133)을 발생시킨다. 29장 Q2 꼬리 질문에서 배타적 접근 위반이 134(SIGABRT)였던 것과 대비되는데, 그쪽은 런타임 라이브러리 함수가 진단 메시지를 먼저 출력한 뒤 중단하기 때문이다. 실무적 함의는 이렇다 — 크래시 로그에 133이 보이고 진단 문구가 없으면 산술·인덱스·강제 언래핑 같은 인라인 검사 실패를 먼저 의심하면 된다.
쉽게 말하면 여러 가지 이유로 차가 멈출 수 있지만, "즉시 정지 버튼"으로 멈춘 경우는 계기판에 다 똑같은 표시가 뜨는 것과 같다. 왜 눌렀는지는 다른 단서로 찾아야 한다.
Int.min / -1만 트랩하고 다른 나눗셈은 안 하는 이유는?
2의 보수 표현이 비대칭이기 때문이다. 64비트 Int의 범위는 -9223372036854775808 ~ 9223372036854775807로, 음수 쪽이 하나 더 넓다. 그래서 Int.min의 절댓값은 Int.max보다 1 크고, Int.min / -1 = -Int.min은 표현할 수 없다. 이것이 정수 나눗셈에서 결과가 범위를 벗어나는 유일한 경우다(0으로 나누기는 오버플로가 아니라 정의 불가라 별개다). 같은 이유로 -Int.min이나 Int.min.magnitudeInt로 받으려는 코드도 위험하다 — magnitudeUInt를 반환하는 것이 그 배려다.
쉽게 말하면 왼쪽으로는 100걸음, 오른쪽으로는 99걸음까지만 갈 수 있는 길에서, 왼쪽 끝(100)에 서서 "반대 방향 같은 거리"로 가려 하면 갈 수 없는 것과 같다.
clampingtruncatingIfNeeded 중 어느 것을 기본으로 삼아야 하는가?
둘 다 기본이 되어선 안 된다. 기본은 init(exactly:)실패를 옵셔널로 받아 명시적으로 처리하는 것이고, 그것이 번거로운 자리에서만 나머지 둘을 쓴다. clamping은 "범위를 넘으면 끝값이 의미상 맞는" 도메인 — 화면 좌표, 볼륨, 진행률 퍼센트 — 에서 안전하다. truncatingIfNeeded비트 패턴을 그대로 재해석하겠다는 선언이므로, 바이너리 포맷 파싱이나 프로토콜 구현처럼 그 비트 해석이 정확히 요구되는 자리에만 쓴다. 실측에서 200-56이 된 것처럼 값의 의미가 완전히 달라지므로, 일반 산술에서 쓰면 조용히 틀린 값이 흘러간다 — 29장의 언어로 말하면 "exit 0으로 통과하는" 종류의 실패다.
쉽게 말하면 큰 옷을 작은 상자에 넣을 때, clamping은 "안 들어가면 최대 크기로 접어 넣기"고 truncatingIfNeeded는 "삐져나온 부분을 가위로 잘라내기"다. 후자는 옷이 망가지므로 정말 그걸 원할 때만 써야 한다.

Q2. BinaryInteger · FixedWidthInteger 계층은 무엇을 보장하며, 나눗셈·나머지의 부호는 어떻게 정해지는가?

🔑 30초 답변

Swift 정수 프로토콜은 보장의 계단이다. BinaryInteger는 "2진 정수로서의 산술·비교·비트 연산"을, 그 하위인 FixedWidthInteger는 거기에 bitWidth·max·min·오버플로 보고 API를 더한다. 임의 정밀도 정수도 담을 수 있게 열어두려면 BinaryInteger를, Int.max 같은 경계가 필요하면 FixedWidthInteger를 요구한다. 나눗셈은 0 방향 절단이라 실측으로 -7 / 2 == -3이고, 나머지는 피제수(왼쪽)의 부호를 따라 -7 % 2 == -1, 7 % -2 == 1이다 — 수학의 modulo와 다르므로 음수에서 인덱스를 계산할 때 자주 사고가 난다.

원리

계층이 필요한 이유는 제네릭 코드가 요구할 최소 보장을 고를 수 있게 하는 것이다. Part A 07·08장에서 본 프로토콜 설계 원칙 그대로다 — 필요 이상을 요구하면 쓸 수 있는 타입이 줄어든다.

계층 확인 — 실측
print("Int is BinaryInteger    :", Int.self is any BinaryInteger.Type)
print("Int is FixedWidthInteger:", Int.self is any FixedWidthInteger.Type)
print("Int is SignedInteger    :", Int.self is any SignedInteger.Type)
print("UInt is SignedInteger   :", UInt.self is any SignedInteger.Type)
print("Double is BinaryFloatingPoint:", Double.self is any BinaryFloatingPoint.Type)
실행 결과
Int is BinaryInteger    : true
Int is FixedWidthInteger: true
Int is SignedInteger    : true
UInt is SignedInteger   : false
Double is BinaryFloatingPoint: true

내부 동작

Numeric BinaryInteger 산술 · 비교 · 비트 연산 FloatingPoint NaN · infinity · ulp FixedWidthInteger bitWidth · max · min · 오버플로 보고 BinaryFloatingPoint significandBitCount · bitPattern SignedInteger Int · Int8… UnsignedInteger UInt · UInt8… Double · Float · Float16
아래로 갈수록 보장이 늘어난다. 제네릭 제약은 필요한 만큼만 아래로 내려가 고른다.

나눗셈 의미론은 별도의 실측이 필요하다. Swift는 0 방향 절단(truncation toward zero)을 택했고, 나머지는 그 정의에서 유도된다 — a == (a / b) * b + (a % b)가 항상 성립해야 하므로 나머지의 부호가 피제수를 따른다.

나눗셈·나머지의 부호 — 실측
print("-7 / 2 = \(-7 / 2)  -7 % 2 = \(-7 % 2)  7 / -2 = \(7 / -2)  7 % -2 = \(7 % -2)")
print("(-7).quotientAndRemainder(dividingBy: 2) =", (-7).quotientAndRemainder(dividingBy: 2))
실행 결과
-7 / 2 = -3  -7 % 2 = -1  7 / -2 = -3  7 % -2 = 1
(-7).quotientAndRemainder(dividingBy: 2) = (quotient: -3, remainder: -1)

-7 / 2-4가 아니라 -3이다. 수학의 floor 나눗셈이라면 -4가 되지만, 0 방향 절단은 -3.5를 0쪽으로 당겨 -3을 만든다. 그리고 -7 % 2 == -1이므로 %의 결과가 음수가 될 수 있다 — 이것이 다음 절의 사고 원인이다.

실험 · 도구

%가 음수를 낼 수 있다는 사실은 배열 인덱스를 감쌀 때 바로 크래시로 이어진다.

음수 인덱스 사고 — 가장 흔한 실전 버그
let items = ["a", "b", "c"]

func at(_ i: Int) -> String {
    items[i % items.count]        // ❌ i 가 음수면 인덱스가 음수
}
// at(-1) → -1 % 3 == -1 → items[-1] → 트랩 (exit 133)

func atSafe(_ i: Int) -> String {
    let n = items.count
    let m = ((i % n) + n) % n     // ✅ 항상 0..<n
    return items[m]
}
print(atSafe(-1), atSafe(-4), atSafe(4))   // c c b
실행 결과
c c b

((i % n) + n) % n 관용구를 기억해두면 좋다. %가 음수를 낼 수 있는 언어에서 "수학적 modulo"를 얻는 표준 패턴이다.

프로젝트 적용

① 제네릭 제약은 필요한 보장만 요구한다. 아래 두 함수의 차이가 그 원칙을 보여준다.

필요한 만큼만 요구한다
// 산술만 필요하다 — BinaryInteger 로 넓게 받는다
func sumAll<T: BinaryInteger>(_ xs: [T]) -> T {
    xs.reduce(0, +)
}

// 경계값이 필요하다 — FixedWidthInteger 를 요구해야 한다
func saturatingSum<T: FixedWidthInteger>(_ xs: [T]) -> T {
    var acc: T = 0
    for x in xs {
        let (v, o) = acc.addingReportingOverflow(x)
        acc = o ? (x > 0 ? T.max : T.min) : v      // max/min 은 여기서만 쓸 수 있다
    }
    return acc
}
print(saturatingSum([Int.max, 1, 1]))   // 9223372036854775807 — 포화
실행 결과
9223372036854775807

② 서로 다른 정수 타입 사이의 산술은 Swift가 암묵 변환하지 않는다. C에서 오는 사람이 가장 자주 부딪히는 벽이며, 이는 Q5의 변환 규칙과 직결된다. 명시적으로 변환하되 어떤 변환인지 고른다.

타입이 다르면 명시적으로 변환한다 — 어떤 변환인지가 중요하다
let count: Int32 = 1000
let size: Int = 8

// ❌ count * size — 컴파일 에러: 타입 불일치
// ✅ 어떤 변환인지 결정한다
let total1 = Int(count) * size                        // 넓히기 — 항상 안전
guard let narrowed = Int32(exactly: size) else { ... } // 좁히기 — 실패 가능

③ Int의 폭은 플랫폼 의존이다 — 실측으로 Int.bitWidth == 64이지만 이는 arm64 기준이고, 바이너리 포맷·네트워크 프로토콜에서는 Int를 쓰지 않는다. Int32/UInt64처럼 폭이 이름에 박힌 타입을 쓰고, Part B 18장의 Data 직렬화와 함께 엔디언까지 명시하는 것이 규율이다.

⚠️ 흔한 오해

"%는 나머지(modulo)니까 항상 0 이상"이라는 것이 이 절의 핵심 오해다 — 실측대로 -7 % 2 == -1이다. Swift 문서도 이 연산을 modulo가 아니라 remainder로 부른다. 또 "Int는 64비트다"도 플랫폼 가정이다. 마지막으로 "BinaryInteger로 받으면 항상 더 유연하다"도 단순화다 — max/min/bitWidth가 필요한 알고리즘을 BinaryInteger로 작성하려 하면 그 API가 없어 컴파일되지 않는다. 유연성과 필요한 보장은 트레이드오프다.

🧒 쉽게 이해하기

프로토콜 계층은 운전면허 등급과 같다. 아래로 갈수록 할 수 있는 일이 늘어난다 — "바퀴 달린 걸 몬다"(Numeric) → "자동차를 몬다"(BinaryInteger) → "크기와 한계를 아는 특정 차종을 몬다"(FixedWidthInteger). 함수를 만들 때는 정말 필요한 등급만 요구해야 한다. "자동차면 된다"는 일에 "대형 특수 면허"를 요구하면 올 수 있는 사람이 줄어든다. 나눗셈 이야기는 따로다 — -7 / 2-3인 건 "0쪽으로 당겨서 자른다"는 규칙이고, 그 때문에 나머지가 음수가 될 수 있다. 비유가 깨지는 곳: 사람은 "나머지는 당연히 0 이상"이라고 생각하지만, 컴퓨터는 "몫 × 나누는 수 + 나머지 = 원래 수"라는 식이 맞기만 하면 되고, 그 식을 지키려면 나머지가 음수여야 하는 경우가 생긴다.

꼬리 질문

Swift는 왜 floor 나눗셈이 아니라 0 방향 절단을 택했는가?
하드웨어와 C 계열 언어의 관례를 따른 것으로 보인다(Swift 측의 공식 설명을 확인하지는 못했으므로 이 인과는 필자의 정리다 — 동작 자체는 위 실측이다). 현대 CPU의 정수 나눗셈 명령이 0 방향 절단을 수행하므로, 그 의미론을 채택하면 나눗셈이 명령 하나로 컴파일된다. floor 나눗셈을 기본으로 하려면 부호를 검사해 보정하는 코드를 매번 붙여야 하고, 이는 가장 흔한 연산에 비용을 얹는 셈이다. 또 C·C++·Java·Rust 등이 모두 같은 규칙이므로, 다른 규칙을 택하면 언어 간 포팅에서 조용한 버그가 생긴다. 대가는 이 절에서 본 "나머지가 음수일 수 있다"이며, 그래서 표준 라이브러리가 quotientAndRemainder(dividingBy:)로 두 값을 함께 얻는 API와 isMultiple(of:) 같은 의도 명확한 API를 함께 제공한다.
쉽게 말하면 계산기 부품이 원래 "0쪽으로 자르는" 방식으로 만들어져 있어서, 그걸 그냥 쓰면 빠르고 다른 방식으로 바꾸려면 매번 손을 더 봐야 하는 것이다. 그래서 빠른 쪽을 기본으로 뒀다.
isMultiple(of:)% == 0보다 나은 이유가 있는가?
있다. 첫째, 의도가 드러난다 — "나머지가 0인지"를 계산하는 게 아니라 "배수인지"를 묻는 것이므로 읽는 사람이 부호 문제를 걱정하지 않아도 된다. 둘째, Int.min 경계에서 안전하다. Int.min % -1 같은 표현은 Q1에서 본 나눗셈 오버플로 영역에 닿을 수 있는데, isMultiple(of:)은 그런 경계를 내부적으로 처리한다. 셋째, 0으로 나누기를 트랩하지 않는다 — x.isMultiple(of: 0)x == 0일 때 true를 반환하지만 x % 0은 트랩한다. 짝수 판정처럼 흔한 자리에서 x.isMultiple(of: 2)를 쓰는 습관이 실전에서 값을 만든다.
쉽게 말하면 "3으로 나눈 나머지가 0인가?"라고 돌려 묻는 대신 "3의 배수인가?"라고 직접 묻는 것이다. 같은 답이 나오지만 질문이 명확해서 실수가 줄어든다.
제네릭 함수에서 BinaryInteger를 요구했는데 T.max가 필요해졌다. 어떻게 하는가?
제약을 FixedWidthInteger좁히는 것이 정답이다. max/min/bitWidth는 "폭이 고정된 정수"에만 존재하는 개념이므로, 임의 정밀도 정수까지 허용하는 BinaryInteger에는 원리적으로 있을 수 없다. 제약을 좁히면 쓸 수 있는 타입이 줄어들지만 — 실제로는 표준 라이브러리의 모든 정수 타입(Int·Int8~Int64·UInt 계열)이 FixedWidthInteger를 만족하므로 실무 손실은 거의 없다. 손실이 생기는 것은 외부 BigInt 라이브러리를 함께 받으려는 경우이고, 그때는 함수를 둘로 나누거나 max가 필요 없는 알고리즘으로 다시 쓰는 것이 맞다. 이는 Part A 08장에서 본 "제약을 어디까지 열어둘지"의 실제 사례다.
쉽게 말하면 "가장 큰 값이 얼마냐"는 질문은 한계가 정해진 그릇에만 할 수 있다. 무한히 커질 수 있는 그릇에는 그 질문 자체가 성립하지 않으니, 그 질문이 필요하면 "한계 있는 그릇만 받겠다"고 조건을 좁혀야 한다.

Q3. 0.1 + 0.2 != 0.3인 이유는? 부동소수점은 어떻게 비교해야 하는가?

🔑 30초 답변

Double은 2진 분수만 정확히 표현할 수 있다. 0.1은 2진법으로 무한소수라 가장 가까운 2진 값으로 반올림되어 저장되고, 실측으로 그 값은 0.10000000000000000555다. 그러니 0.1 + 0.20.30000000000000004가 되고 == 0.3false다. 이는 Swift의 버그가 아니라 IEEE 754를 따르는 모든 언어의 동일한 동작이다. 비교는 == 대신 허용 오차로 해야 하는데, 고정된 상수(0.0001)보다 값의 크기에 비례하는 ulp 기반 비교가 정확하다. 돈이라면 애초에 Decimal을 쓴다(23장 Q5).

원리

Double은 64비트를 부호 1 + 지수 11 + 유효숫자 52비트로 나눠 쓴다. 값은 유효숫자 × 2지수 형태이므로, 표현 가능한 것은 m / 2k 꼴의 분수뿐이다. 0.5(1/2), 0.25(1/4), 0.75(3/4)는 정확하다. 그런데 0.1 = 1/10은 분모에 5가 있어 2의 거듭제곱으로 나눌 수 없다 — 10진법에서 1/30.333…로 끝나지 않는 것과 정확히 같은 상황이다.

유효숫자 비트 수 — 실측
print("Double.significandBitCount =", Double.significandBitCount)
print("Float.significandBitCount  =", Float.significandBitCount)
실행 결과
Double.significandBitCount = 52
Float.significandBitCount  = 23

내부 동작

저장된 실제 값을 20자리까지 찍어보면 근사가 눈에 보인다.

0.1의 실제 저장값 — 실측
import Foundation
print("0.1 + 0.2       =", 0.1 + 0.2)
print("0.1+0.2 == 0.3  :", (0.1 + 0.2) == 0.3)
print("0.1 실제값(20자리) =", String(format: "%.20f", 0.1))
var acc = 0.0
for _ in 0..<10 { acc += 0.1 }
print("0.1 x10 누적 =", acc, "| == 1.0 :", acc == 1.0)
print("Double.ulpOfOne =", Double.ulpOfOne, "| (0.1).nextUp =", (0.1).nextUp)
실행 결과
0.1 + 0.2       = 0.30000000000000004
0.1+0.2 == 0.3  : false
0.1 실제값(20자리) = 0.10000000000000000555
0.1 x10 누적     = 0.9999999999999999 | == 1.0 : false
Double.ulpOfOne = 2.220446049250313e-16 | (0.1).nextUp = 0.10000000000000002

세 가지가 드러난다. ① 0.1은 실제로 0.1보다 약간 크게 저장된다(…00555). ② 그 오차가 누적되어 0.1을 열 번 더하면 1.0이 아니라 0.9999999999999999가 된다 — 오차가 상쇄되지 않고 쌓인다. ③ ulpOfOne2.22e-16인데, 이것이 "1.0 근처에서 표현 가능한 두 값 사이의 최소 간격"이다. (0.1).nextUp0.10000000000000002인 것도 같은 이야기다 — 0.1과 그다음 표현 가능한 값 사이에 아무것도 없다.

표현 가능 한 값들 0.1 (수학) 가장 가까운 격자로 반올림 0.10000000000000000555 0.3 (수학) 0.1 + 0.2 는 "반올림된 0.1" + "반올림된 0.2" 를 더한 뒤 다시 반올림한 값이다 그 결과가 "반올림된 0.3"과 같은 격자점일 이유가 없다 → 0.30000000000000004
표현 가능한 값은 수직선 위에 이산적으로 놓인다. 십진 소수는 대개 격자점에 정확히 얹히지 않는다.

실험 · 도구

비교 방법을 세 가지로 나눠 실측 비교하면 무엇을 써야 하는지 분명해진다.

세 가지 비교 방법
let a = 0.1 + 0.2
let b = 0.3

// ① == — 실패한다
print("① ==            :", a == b)

// ② 고정 허용 오차 — 값의 크기에 따라 부적절해질 수 있다
print("② abs 차 < 1e-9 :", abs(a - b) < 1e-9)

// ③ ulp 기반 — 값의 크기에 비례한 오차를 쓴다
func isClose(_ x: Double, _ y: Double, ulps: Int = 4) -> Bool {
    if x == y { return true }
    let scale = max(abs(x), abs(y))
    return abs(x - y) <= Double(ulps) * scale.ulp
}
print("③ isClose       :", isClose(a, b))
실행 결과
① ==            : false
② abs 차 < 1e-9 : true
③ isClose       : true

②와 ③이 둘 다 true인데 왜 ③이 나은가. 값의 크기가 달라지면 ②가 무너진다. 1e9 근처에서는 두 인접한 Double 사이의 간격이 이미 1e-7보다 크므로, "차이가 1e-9 미만"은 사실상 ==와 같아진다. 반대로 1e-15 근처에서는 완전히 다른 값도 1e-9 안에 들어와 모두 같다고 판정된다.

고정 오차가 무너지는 지점 — 실측
let big1 = 1e9, big2 = 1e9 + 1e-7
print("1e9 근처 — 두 값이 다른가:", big1 != big2)
print("  고정 오차 판정(1e-9):", abs(big1 - big2) < 1e-9)
print("  ulp 판정            :", isClose(big1, big2))
print("  (1e9).ulp =", (1e9).ulp)

let tiny1 = 1e-15, tiny2 = 5e-16
print("1e-15 근처 — 두 값은 두 배 차이:", tiny1, tiny2)
print("  고정 오차 판정(1e-9):", abs(tiny1 - tiny2) < 1e-9, "← 같다고 판정한다")
print("  ulp 판정            :", isClose(tiny1, tiny2))
실행 결과
1e9 근처 — 두 값이 다른가: true
  고정 오차 판정(1e-9): false
  ulp 판정            : true
  (1e9).ulp = 1.1920928955078125e-07
1e-15 근처 — 두 값은 두 배 차이: 1e-15 5e-16
  고정 오차 판정(1e-9): true ← 같다고 판정한다
  ulp 판정            : false

두 경우에서 고정 오차와 ulp 판정이 정반대 답을 낸다. 1e9 근처에서는 ulp 간격 자체가 1.19e-7이라 두 값이 "인접한 이웃"이므로 같다고 봐야 하고, 1e-15 근처에서는 두 배 차이가 나는 값을 같다고 하면 안 된다. ulp는 그 판단을 값의 크기에 맞춰 자동으로 조정해준다.

프로젝트 적용

① 애초에 부동소수점을 쓰지 않는 것이 첫 번째 선택지다. 도메인별로 이렇게 갈린다.

다루는 것쓸 타입
돈 · 회계Decimal 또는 정수 최소단위(원·센트)10진 정확성이 요구사항이다 (23장 Q5)
개수 · 인덱스 · IDInt 계열애초에 정수 도메인
좌표 · 물리 · 그래픽Double / Float근사가 도메인상 허용된다
비율 · 통계Double + ulp 비교근사 허용 + 비교 규율 필요

② Double을 써야 하면 비교 헬퍼를 프로젝트에 하나 두고 ==를 금지하는 것이 실전에서 잘 통한다. 특히 테스트 코드에서 중요하다.

프로젝트 공용 근사 비교 — 확장으로 둔다
extension Double {
    /// 값의 크기에 비례한 허용 오차로 비교한다.
    func isClose(to other: Double, ulps: Int = 4) -> Bool {
        if self == other { return true }           // ±0.0, 동일 값 처리
        if isNaN || other.isNaN { return false }   // NaN 은 무엇과도 같지 않다 (Q4)
        let scale = Swift.max(abs(self), abs(other))
        guard scale.isFinite else { return false }
        return abs(self - other) <= Double(ulps) * scale.ulp
    }
}
print((0.1 + 0.2).isClose(to: 0.3))   // true

③ 누적 오차는 알고리즘으로 줄인다. 실측에서 0.1을 열 번 더해 0.9999999999999999가 됐는데, 같은 계산을 "곱셈 한 번"으로 바꾸면 오차가 누적되지 않는다. 반복 덧셈 대신 인덱스 × 간격으로 계산하는 것이 규율이다.

반복 덧셈을 곱셈으로 바꾼다 — 실측 대비
// ❌ 오차가 10번 누적된다
var acc = 0.0
for _ in 0..<10 { acc += 0.1 }
print("누적 덧셈:", acc, acc == 1.0)

// ✅ 오차가 한 번만 생긴다
let byMultiply = 0.1 * 10
print("곱셈    :", byMultiply, byMultiply == 1.0)

// ✅ 눈금 생성도 인덱스 기반으로
for i in 0...10 {
    let t = Double(i) / 10.0    // 매번 독립 계산 — 누적 없음
    _ = t
}
실행 결과
누적 덧셈: 0.9999999999999999 false
곱셈    : 1.0 true
⚠️ 흔한 오해

"Swift의 Double이 부정확한 것"이라는 오해가 가장 흔하다 — 이는 IEEE 754를 따르는 모든 언어에서 동일하며, Python·JavaScript·Java·C에서도 0.1 + 0.2는 같은 값이다. 또 "Float 대신 Double을 쓰면 해결된다"도 틀렸다 — 정밀도가 52비트로 늘어날 뿐 0.1을 정확히 표현할 수 없다는 사실은 그대로다. 마지막으로 "반올림해서 비교하면 된다"(round(a*100)/100)도 부분적 해결이다. 반올림 경계에 걸린 값에서는 여전히 어긋나고, 무엇보다 돈 계산에서는 반올림 시점과 방식이 도메인 규칙이라 임의로 정하면 안 된다.

🧒 쉽게 이해하기

10진법으로 1/3을 적어보자. 0.333333…인데 종이가 유한하니 어딘가에서 끊어야 한다. 0.3333을 세 번 더하면 0.9999가 되고 1이 아니다. 컴퓨터는 2진법을 쓰는데, 2진법에서는 1/10(=0.1)이 바로 그 "끝나지 않는 소수"다. 그래서 0.1을 적을 때 이미 아주 조금 틀린 값을 적어두고 시작하며, 그걸 열 번 더하면 1에 살짝 못 미친다. 비유가 깨지는 곳: 사람은 1/3이 끝나지 않는다는 걸 알고 조심하지만, 컴퓨터의 0.1은 화면에 깔끔하게 0.1이라고 보여줘서 틀린 줄 모르게 만든다 — 그게 진짜 함정이다.

꼬리 질문

0.50.25는 정확히 표현되는가?
정확하다. 0.5 = 1/2, 0.25 = 1/4, 0.75 = 3/4처럼 분모가 2의 거듭제곱인 분수는 2진 유효숫자로 유한하게 적을 수 있다. 그래서 0.5 + 0.25 == 0.75true이고, 0.5를 열 번 더하면 정확히 5.0이 된다. 이 사실이 실무에서 쓸모가 있는 자리가 있다 — 예컨대 화면 배율이 0.5 단위이거나 오디오 게인이 2의 거듭제곱 단위라면 Double 산술이 정확하다. 반대로 0.1·0.2·0.3처럼 분모에 5가 들어가는 십진 소수가 문제를 만든다. 그래서 "부동소수점은 항상 부정확하다"가 아니라 "2진 분수가 아닌 값이 부정확하다"가 정확한 표현이다.
쉽게 말하면 반으로 자르고 또 반으로 자르는 방식(2진법)으로는 절반·사분의 일은 딱 맞게 만들 수 있지만, 십분의 일은 아무리 잘라도 딱 맞지 않는다.
ulp는 정확히 무엇이고 ulpOfOne과 어떻게 다른가?
x.ulp는 "x 주변에서 표현 가능한 인접한 두 값 사이의 간격"이다(unit in the last place). 부동소수점은 값이 커질수록 간격이 넓어지므로 ulpx에 따라 달라진다 — 실측에서 (1e9).ulp1.19e-07이었다. 반면 Double.ulpOfOne1.0에서의 ulp라는 특정 상수이고, 실측값 2.220446049250313e-16이다(전통적으로 "머신 엡실론"이라 부르는 값이다). 비교 함수에서 scale.ulp를 쓰는 이유가 여기 있다 — 비교하려는 값의 크기에서의 간격을 기준으로 삼아야 판정이 크기와 무관하게 일관되기 때문이다. ulpOfOne을 고정 상수로 쓰면 1.0 근처에서만 맞는 판정이 된다.
쉽게 말하면 자의 눈금 간격이 재는 길이에 따라 달라지는 자를 쓰고 있는 셈이다. x.ulp는 "지금 이 길이에서의 눈금 간격"이고, ulpOfOne은 "길이 1에서의 눈금 간격"이다.
테스트에서 부동소수점을 비교할 때 어떻게 하는 것이 맞는가?
테스트 프레임워크가 제공하는 허용 오차 파라미터를 쓰는 것이 첫 번째다 — 정확도 인자를 받는 비교 단정문이 이런 목적으로 존재한다. 그런데 더 중요한 것은 허용 오차 값을 어떻게 정하는가이며, 여기서 두 가지 실수가 흔하다. 첫째, 오차를 테스트가 통과할 때까지 키우는 것 — 이러면 실제 회귀를 못 잡는 테스트가 된다. 둘째, 모든 테스트에 같은 상수를 쓰는 것 — 위 실측대로 값의 크기에 따라 적절한 오차가 다르다. 권장 순서는 이렇다. ① 가능하면 정확한 값이 나오는 입력으로 테스트를 설계한다(2진 분수 사용). ② 그럴 수 없으면 도메인이 요구하는 정밀도를 근거로 오차를 정한다("소수 둘째 자리까지 맞으면 된다"). ③ 순수 수치 알고리즘이라면 ulp 단위로 표현한다("4 ulp 이내").
쉽게 말하면 "얼마나 비슷하면 같다고 볼까"를 테스트가 통과하는 선이 아니라 실제로 얼마나 정확해야 하는지를 근거로 정해야 한다.

Q4. NaN-0.0은 비교·정렬·해싱에서 무엇을 깨뜨리는가?

🔑 30초 답변

이 챕터에서 가장 파괴적인 실측이다. NaN은 자기 자신과도 같지 않으므로(nan == nanfalse) 비교 기반 알고리즘의 전제를 깬다. 실측 결과 — [3.0, nan, 1.0, 2.0].sorted()[3.0, nan, 1.0, 2.0], 즉 전혀 정렬되지 않은 배열을 돌려주고, Setnan을 두 번 넣으면 원소가 2개가 되며 set.contains(nan)false다 — 방금 넣은 것을 찾을 수 없다. 이는 Part A 11장 Q5의 "strict weak ordering을 어기면 무슨 일이 일어나는가"의 실제 사례다. 반면 -0.00.0==이고 해싱도 일관되지만, 1/(-0.0)-inf라 나눗셈에서 갈린다.

원리

IEEE 754는 NaN(Not a Number)을 "정의되지 않은 연산의 결과"로 규정하고, 어떤 값과의 비교도 false가 되도록 정했다(!=true). 이유는 NaN이 "값을 모른다"는 표시이므로 "모르는 값이 3보다 큰가?"에 truefalse도 정직하지 않기 때문이다. IEEE는 false를 택했다.

문제는 정렬·탐색·자료구조가 전부 비교가 일관된다는 전제 위에 서 있다는 점이다. 구체적으로 <strict weak ordering을 만족해야 한다 — 그중 하나가 "a < a는 항상 거짓"이고 또 하나가 "ab가 비교 불가면 동등하다고 취급해도 일관되어야 한다"다. NaN은 이 전제를 깬다.

내부 동작

NaN 비교 — 실측 (컴파일러도 경고를 준다)
let nan = Double.nan
print("nan == nan :", nan == nan)     // warning: '.nan' == '.nan' is always false
print("nan != nan :", nan != nan)
print("nan <  1.0 :", nan < 1.0, "| nan > 1.0 :", nan > 1.0)
print("nan.isNaN  :", nan.isNaN)
실행 결과
nan == nan : false
nan != nan : true
nan <  1.0 : false | nan > 1.0 : false
nan.isNaN  : true

nan < 1.0nan > 1.0둘 다 false인 것이 핵심이다. 일반적인 값이라면 둘 중 하나는 참이어야 한다. 정렬 알고리즘은 이 성질에 의존해 "작지 않으면 크거나 같다"고 추론하는데, NaN에서는 그 추론이 무너진다.

NaN과 sorted() — 가장 충격적인 실측
let arr: [Double] = [3.0, nan, 1.0, 2.0]
print("sorted() :", arr.sorted())

let arr2: [Double] = [nan, 3.0, 1.0, 2.0]
print("nan 앞에 :", arr2.sorted())

print("max()    :", arr.max() ?? -1, "| min() :", arr.min() ?? -1)
실행 결과 — 정렬이 되지 않는다
sorted() : [3.0, nan, 1.0, 2.0]
nan 앞에 : [nan, 1.0, 2.0, 3.0]
max()    : 3.0 | min() : 1.0

첫 줄이 입력과 완전히 동일하다. 정렬 함수를 호출했는데 아무것도 정렬되지 않았고, 에러도 경고도 없다. 두 번째 줄은 같은 원소 집합인데 nan의 위치만 바꿨더니 나머지가 정렬됐다. 즉 결과가 입력 순서에 따라 달라지며 둘 다 신뢰할 수 없다.

NaN과 해싱 — 넣은 것을 찾을 수 없다
var set = Set<Double>()
set.insert(nan)
set.insert(nan)
print("Set 에 nan 두 번 넣은 크기 :", set.count)
print("set.contains(nan) :", set.contains(nan))
실행 결과
Set 에 nan 두 번 넣은 크기 : 2
set.contains(nan) : false

Set같은 해시값 + ==로 중복을 판단한다(Part A 10장). NaN은 해시가 같아도 ==false이므로 매번 새 원소로 취급되고, 조회할 때도 ==가 실패해 못 찾는다. Dictionary의 키로 쓰면 넣을 수는 있지만 절대 꺼낼 수 없는 항목이 쌓인다.

-0.0은 정반대로 일관되다. 다만 값이 갈리는 자리가 따로 있다.

-0.0 — 실측
print("-0.0 == 0.0      :", -0.0 == 0.0)
print("(-0.0).sign      :", (-0.0 as Double).sign, "| (0.0).sign :", (0.0 as Double).sign)
print("1/(-0.0)         :", 1 / (-0.0 as Double), "| 1/(0.0) :", 1 / (0.0 as Double))
print("-0.0 bitPattern  :", (-0.0 as Double).bitPattern, "| 0.0 :", (0.0 as Double).bitPattern)
var s2 = Set<Double>(); s2.insert(-0.0); s2.insert(0.0)
print("Set{-0.0, 0.0}.count :", s2.count)
실행 결과
-0.0 == 0.0      : true
(-0.0).sign      : minus | (0.0).sign : plus
1/(-0.0)         : -inf | 1/(0.0) : inf
-0.0 bitPattern  : 9223372036854775808 | 0.0 : 0
Set{-0.0, 0.0}.count : 1

bitPattern이 완전히 다른데(0 vs 부호비트만 켜진 값) ==true이고 Set도 하나로 합친다 — ==와 해싱이 일관하므로 자료구조가 깨지지 않는다. 그런데 1/(-0.0)-inf, 1/(0.0)inf다. "같은" 두 값이 나눗셈에서 부호가 반대인 무한대를 낸다.

실험 · 도구

두 특이값이 깨뜨리는 것을 표로 정리하면 방어 지점이 보인다.

성질NaN-0.0
x == xfalse — 자기와도 다르다true
sorted()정렬 실패 (조용히)정상
Set/Dictionary중복 누적 · 조회 실패정상 (0.0과 합쳐짐)
min()/max()위치에 따라 결과 달라짐정상
나눗셈모두 NaN-inf — 부호가 갈린다
비트 표현여러 패턴 존재0.0과 다르다

방어는 경계에서 걸러내는 것이다. NaN은 대개 외부 입력 파싱, 0/0, 음수의 sqrt에서 들어온다.

경계에서 NaN을 거른다 — 자료구조에 들어가기 전에
func sanitize(_ xs: [Double]) -> [Double]? {
    guard xs.allSatisfy({ $0.isFinite }) else { return nil }   // NaN·inf 모두 거른다
    return xs
}

// 파싱 경계
func parseScore(_ s: String) -> Double? {
    guard let d = Double(s), d.isFinite else { return nil }
    return d
}
print(parseScore("1.5") as Any, parseScore("nan") as Any, parseScore("inf") as Any)
실행 결과
Optional(1.5) nil nil

Double("nan")성공적으로 NaN을 만든다는 점에 주의한다 — 문자열 파싱이 nil을 주지 않는다. 그래서 isFinite 검사를 따로 붙여야 한다.

프로젝트 적용

① 정렬해야 하는 Double 컬렉션은 정렬 전에 NaN을 처리한다. 세 가지 방식이 있고, 도메인에 따라 고른다.

NaN을 다루는 세 방식
let xs: [Double] = [3.0, .nan, 1.0, 2.0]

// ① 제거 — 대개 이것이 맞다
let cleaned = xs.filter { !$0.isNaN }.sorted()
print("① 제거:", cleaned)

// ② 끝으로 몰기 — 순위 표에서 "측정 실패"를 맨 뒤로
let pushed = xs.sorted { a, b in
    if a.isNaN { return false }     // NaN 은 항상 뒤
    if b.isNaN { return true }
    return a < b
}
print("② 뒤로:", pushed)

// ③ 거부 — 애초에 있으면 안 되는 데이터라면
guard xs.allSatisfy({ !$0.isNaN }) else { print("③ 거부: 잘못된 입력"); exit(0) }
실행 결과
① 제거: [1.0, 2.0, 3.0]
② 뒤로: [1.0, 2.0, 3.0, nan]
③ 거부: 잘못된 입력

②의 비교 클로저가 strict weak ordering을 실제로 만족한다는 점이 중요하다 — NaN을 "모든 값보다 큰 것"으로 일관되게 정의했으므로 정렬이 정상 동작한다. Part A 11장 Q5에서 다룬 규칙을 지킨 형태다.

② DoubleDictionary 키나 Set 원소로 쓰는 설계 자체를 피한다. NaN 문제 외에도 Q3의 표현 오차 때문에 "계산으로 만든 키를 나중에 다시 계산해 조회"하는 패턴이 조용히 실패한다. 정수나 문자열로 정규화한 키를 쓰는 편이 안전하다.

부동소수점을 키로 쓰지 않는다
// ❌ 계산으로 만든 Double 키 — 다시 계산하면 못 찾을 수 있다
var cache1: [Double: String] = [:]
cache1[0.1 + 0.2] = "값"
print(cache1[0.3] as Any)          // nil — 0.30000000000000004 != 0.3

// ✅ 정수 눈금으로 정규화한 키
var cache2: [Int: String] = [:]
func key(_ d: Double) -> Int { Int((d * 1000).rounded()) }
cache2[key(0.1 + 0.2)] = "값"
print(cache2[key(0.3)] as Any)     // Optional("값")
실행 결과
nil
Optional("값")

③ -0.0은 나눗셈·부호 판정에서만 조심하면 된다. x == 0으로 0 검사를 하면 -0.0도 걸리므로 대부분 안전하다. 그러나 나누기 전에 0을 걸러내는 코드에서 부호까지 필요하면 sign이나 x.isZero + sign을 함께 본다.

🟣 한 걸음 더 — 29·31장의 실패 등급을 한 표로 실측 종합

29장에서 메모리 위반을 종료 코드로 줄 세웠던 표를 수치 쪽으로 확장하면, 이 강의가 반복해서 말하는 원칙이 한눈에 보인다.

실패신호발견 가능성
Int.max + 1 · 10 / 0 · Int(1e30)exit 133 (SIGTRAP)높음 — 즉시 죽는다
배타적 접근 위반 (29장)exit 134 + 진단 메시지높음 — 원인까지 알려준다
Int8(truncatingIfNeeded: 200)-56없음낮음 — 값만 틀린다
0.1 * 10 != 1.0 누적 오차없음낮음 — 근사라 눈에 안 띈다
NaN이 섞인 sorted()없음가장 낮음 — 정렬된 척한다
포인터 클로저 밖 탈출 (29장)없음 · exit 0가장 낮음 — 정답처럼 보인다

아래 네 줄에 공통점이 있다 — 프로그램이 계속 실행되고, 틀린 값이 다음 로직으로 흘러간다. 그래서 이 강의가 29장에서는 Span을, 여기서는 경계에서의 검증(isFinite·exactly:)을 강조한다. 둘 다 목적이 같다 — 조용한 실패를 시끄러운 실패나 컴파일 에러로 바꾸는 것.

⚠️ 흔한 오해

"sorted()NaN이 있으면 크래시하거나 예외를 던진다"는 틀렸다 — 실측대로 조용히 정렬되지 않은 배열을 돌려준다. "NaN이 맨 뒤로 밀린다"도 근거 없는 기대이며, 실측에서 nan이 두 번째 자리에 그대로 남았다. 또 "SetNaN을 넣으면 무시된다"도 아니다 — 넣을 때마다 새 원소가 추가되므로 메모리가 계속 늘어날 수 있다. 마지막으로 "-0.00.0과 완전히 같다"도 부정확하다. ==와 해싱은 같지만 비트 패턴과 나눗셈 결과가 다르다.

🧒 쉽게 이해하기

NaN"몰라요"라고 적힌 카드다. 키 순서로 줄을 세우는데 한 명이 "제 키는 몰라요" 카드를 들고 있으면, "이 사람이 저 사람보다 큰가?"라는 질문에 답할 수가 없다. 그래서 줄 세우는 사람이 혼란에 빠져 아무도 제대로 세우지 못하고 끝난다 — 그런데 "다 세웠습니다"라고 말한다. 이게 실측에서 본 sorted()다. 더 이상한 건 명단 관리다. "몰라요" 카드는 자기 카드와도 같다고 인정되지 않아서, 같은 사람을 두 번 등록하면 두 명으로 기록되고, 나중에 그 사람을 찾으면 없다고 나온다. 비유가 깨지는 곳: 사람이라면 "몰라요 카드는 일단 맨 뒤에 세우자"고 알아서 정리하겠지만, 컴퓨터는 그렇게 하라고 내가 명시적으로 시켜야 한다(위 ② 방식).

꼬리 질문

sorted()가 실패했는데 왜 에러를 내지 않는가?
정렬 함수는 비교 결과를 신뢰하도록 만들어져 있고, 비교가 일관된지 검증하지 않는다. 검증하려면 모든 원소 쌍을 확인해야 하므로 O(n²)이 되어 정렬보다 비싸진다 — 정렬의 존재 이유를 없애는 비용이다. 그래서 Swift는 "<가 strict weak ordering을 만족한다"를 호출자의 책임으로 두는 계약을 택했다(Part A 11장 Q5). NaN은 그 계약을 어긴 입력이고, 계약 위반의 결과는 "정렬되지 않은 배열"이다. 참고로 이는 메모리 안전성은 유지된다는 점에서 C++의 같은 상황과 다르다 — C++에서는 비교자가 계약을 어기면 컨테이너 범위를 벗어나 정의되지 않은 동작이 될 수 있지만, Swift의 sorted()는 잘못된 순서를 낼 뿐이다. 일부러 최악의 비교자를 넣어 실측했다.
망가진 비교자로 정렬 — 원소가 보존되는가
let out  = Array(0..<200).sorted { _, _ in true }        // 항상 true
let out2 = Array(0..<500).sorted { _, _ in rnd() }       // 무작위

항상 true  → 원소 수: 200  합 보존: true
무작위     → 원소 수: 500  합 보존: true
NaN 정렬   → 개수: 4       NaN 개수 보존: 1
exit = 0
원소가 사라지지도 중복되지도 않고(합이 보존된다) 크래시도 없다. 순서만 무의미해질 뿐이라는 것이 이 계약 위반의 정확한 결과다.
쉽게 말하면 줄 세우는 사람은 "누가 더 큰지" 판정을 믿고 일할 뿐, 그 판정이 앞뒤가 맞는지 검사하지 않는다. 검사하려면 모든 사람을 서로 다 비교해야 해서 줄 세우는 것보다 오래 걸린다.
NaN이 여러 비트 패턴을 가질 수 있다는 게 무슨 뜻인가?
IEEE 754에서 NaN은 "지수 비트가 모두 1이고 유효숫자가 0이 아닌" 모든 값이다. 유효숫자에 들어갈 수 있는 값이 많으므로 NaN을 나타내는 비트 패턴이 2⁵² 가지 가까이 존재한다. 이 여분 비트는 원래 "어떤 연산에서 NaN이 생겼는지"를 담는 페이로드로 쓰라는 의도였다. 실무적 함의는 둘이다. 첫째, bitPattern으로 NaN을 비교하면 안 된다 — 같은 NaN이라도 패턴이 다를 수 있다. 판정은 항상 isNaN으로 한다. 둘째, 부동소수점 값을 바이트로 직렬화할 때(Part B 18·19장) NaN이 섞여 있으면 왕복 후 비트가 보존되더라도 == 비교로는 검증할 수 없다. 그래서 직렬화 테스트에서는 isNaN을 따로 확인해야 한다.
쉽게 말하면 "몰라요" 카드에도 여러 종류가 있어서 뒷면에 이유가 적혀 있는 것과 같다. 앞면은 다 "몰라요"지만 뒷면이 달라서, 카드를 통째로 대조하면 "다른 카드"로 보인다.
Comparable을 채택한 Double이 이런 성질을 갖는 것은 프로토콜 위반이 아닌가?
엄밀히 말하면 DoubleComparable의 요구사항을 모든 값에 대해 만족하지는 않는다 — 이는 IEEE 754 준수와 Comparable 계약이 근본적으로 충돌하는 지점이고, Swift는 실용성을 위해 IEEE 준수를 택했다. 그래서 표준 라이브러리는 보완 API를 제공한다. 대표적으로 isTotallyOrdered(belowOrEqualTo:)NaN-0.0까지 포함해 모든 값에 완전한 순서를 정의한다(IEEE 754의 totalOrder 술어). 정렬이 반드시 결정적이어야 하는 자리에서는 이것을 비교자로 쓸 수 있다. 다만 그 순서에서 NaN의 위치는 도메인 의미와 무관하게 정해지므로, 실무에서는 Q4의 ①·② 방식(제거 또는 명시적으로 뒤로 몰기)이 의도를 더 잘 드러낸다.
쉽게 말하면 "키 순서로 세운다"는 규칙과 "키를 모르는 사람도 있다"는 현실이 충돌하는 것이다. 그래서 "모르는 사람은 이 자리"라고 따로 정해둔 규칙(totalOrder)이 별도로 있고, 필요하면 그걸 쓴다.

Q5. 타입 변환은 어디서 트랩하고 어디서 조용히 값을 잃는가? SIMD는 언제 쓰는가?

🔑 30초 답변

변환 API는 실패 방식으로 이름이 갈린다. Int(3.7)처럼 기본 이니셜라이저는 0 방향 절단 후 범위를 넘으면 트랩(Int(1e30) → exit 133)하고, init(exactly:)nil, init(clamping:)경계값, init(truncatingIfNeeded:)비트를 잘라 전혀 다른 값을 만든다. 가장 위험한 것은 이름에 아무 경고가 없는 Double(Int)다 — 실측으로 9007199254740993(2⁵³+1)을 Double로 바꿔 되돌리면 9007199254740992가 되어 1이 조용히 사라진다. SIMD는 별개 주제로, 같은 연산을 여러 값에 한 번에 적용하는 벡터 타입이다 — SIMD4<Float>는 실측 16바이트에 정렬도 16이다.

원리

Swift는 정수·부동소수점 사이에 암묵 변환이 없다. 모든 변환은 이니셜라이저 호출이고, 그 이름이 실패 시 무엇을 할지를 말해준다. 이는 Part A 03장의 옵셔널 설계와 같은 철학이다 — 실패 가능성을 타입과 이름에 드러낸다.

내부 동작

Double → Int — 절단과 트랩
print("Int(3.7)   =", Int(3.7), "| Int(-3.7) =", Int(-3.7))
print("Int(exactly: 3.7) =", Int(exactly: 3.7) as Any)
print("Int(exactly: 3.0) =", Int(exactly: 3.0) as Any)
// Int(1e30) → exit 133 (트랩)
실행 결과
Int(3.7)   = 3 | Int(-3.7) = -3
Int(exactly: 3.7) = nil
Int(exactly: 3.0) = Optional(3)

Int(-3.7)-4가 아니라 -3인 것이 Q2의 "0 방향 절단"과 같은 규칙이다. 반올림이 필요하면 (-3.7).rounded()를 먼저 호출해야 한다.

이제 반대 방향이다. IntDouble은 이니셜라이저 이름에 아무 경고가 없어서 안전해 보이지만, Double의 유효숫자가 52비트뿐이므로 2⁵³을 넘는 정수는 정확히 담을 수 없다.

Int → Double 정밀도 손실 — 조용한 실패
let big = 9_007_199_254_740_993          // 2^53 + 1
print("원본        :", big)
print("Double 경유 :", Int(Double(big)))
print("같은가      :", Int(Double(big)) == big)
print("Double.significandBitCount =", Double.significandBitCount)
실행 결과 — 1이 사라진다
원본        : 9007199254740993
Double 경유 : 9007199254740992
같은가      : false
Double.significandBitCount = 52

같은 일이 Float에서는 훨씬 이른 지점(2²⁴)부터 일어난다.

Float은 1677만에서 이미 손실 — 실측
print("Float(16777217) =", Float(16_777_217), "| Int =", Int(Float(16_777_217)))
print("Float.significandBitCount =", Float.significandBitCount)
실행 결과
Float(16777217) = 16777216.0 | Int = 16777216
Float.significandBitCount = 23

16_777_217(2²⁴+1)이 16777216이 된다. 천만 단위 정수도 Float에는 안전하지 않다는 뜻이다 — 사용자 ID, 타임스탬프(밀리초), 금액을 Float에 담으면 조용히 어긋난다.

실험 · 도구

변환 API를 실패 방식으로 정리하면 선택이 기계적이다.

API실패 시발견 가능성쓸 자리
Int(_:) · Double(_:)트랩 또는 조용한 손실경우에 따라 없음손실이 불가능하다고 확신할 때만
init(exactly:)nil높음 — 옵셔널로 강제기본 선택 — 외부 입력·경계
init(clamping:)경계값중간 — 의도적이면 OK좌표·볼륨처럼 끝값이 맞는 도메인
init(truncatingIfNeeded:)비트 절단낮음 — 전혀 다른 값바이너리 포맷·비트 조작
rounded() 후 변환높음 — 의도가 명확반올림이 필요할 때

SIMD는 다른 이야기다. 같은 연산을 여러 값에 한 번에 적용하는 고정 크기 벡터이며, 30장의 InlineArray처럼 원소를 인라인으로 담는다.

SIMD — 실측
let a = SIMD4<Float>(1, 2, 3, 4), b = SIMD4<Float>(10, 20, 30, 40)
print("SIMD4<Float> size =", MemoryLayout<SIMD4<Float>>.size,
      "align =", MemoryLayout<SIMD4<Float>>.alignment)
print("a+b =", a + b)
print("a*b =", a * b, "| (a*b).sum() =", (a * b).sum())
print("a .< b =", a .< b, "| a.max() =", a.max())
print("SIMD8<Int16> size =", MemoryLayout<SIMD8<Int16>>.size)
실행 결과
SIMD4<Float> size = 16 align = 16
a+b = SIMD4<Float>(11.0, 22.0, 33.0, 44.0)
a*b = SIMD4<Float>(10.0, 40.0, 90.0, 160.0) | (a*b).sum() = 300.0
a .< b = SIMDMask<SIMD4<Int32>>(true, true, true, true) | a.max() = 4.0
SIMD8<Int16> size = 16

세 가지가 눈에 띈다. ① size == 16이고 alignment도 16이다 — CPU의 벡터 레지스터에 바로 올리기 위한 정렬 요건이다. ② 비교 연산자가 .<처럼 점이 붙은 형태이고 결과가 Bool이 아니라 SIMDMask다 — 원소별 비교 결과를 모두 담기 때문이다. ③ SIMD8<Int16>도 16바이트다(8 × 2) — 원소 폭이 좁으면 더 많이 담긴다.

프로젝트 적용

① 변환 규율 세 줄. 이것만 지켜도 이 챕터의 조용한 실패 대부분이 사라진다.

🔑 핵심

외부에서 온 값의 변환은 init(exactly:)로 시작하고, nil정상 경로로 처리한다. IntDouble 왕복이 필요한 설계를 의심한다 — 2⁵³을 넘는 ID·타임스탬프는 정수로만 다룬다. Float는 그래픽·오디오처럼 정밀도가 충분한 도메인에만 쓰고, 식별자·금액·시각에는 쓰지 않는다.

JSON에서 온 ID — 흔한 실전 사고와 해법
// ❌ JSON 숫자를 Double 로 받으면 큰 ID가 어긋난다
struct BadUser: Decodable { let id: Double }        // 2^53 넘으면 손실

// ✅ 정수로 받는다. 더 안전하게는 문자열로 받는다
struct GoodUser: Decodable { let id: Int }
struct SafestUser: Decodable { let id: String }     // 서버가 문자열로 준다면 최선

Part B 19·20장에서 다룬 Codable과 직결되는 문제다. JSON 명세에 정수/실수 구분이 없으므로 디코딩 타입을 무엇으로 선언하는지가 정밀도를 결정한다.

② SIMD를 쓸 자리는 좁고 분명하다. 같은 연산을 많은 값에 반복하고, 프로파일링이 그 루프를 지목했을 때다. 대표적으로 색상 변환, 좌표 변환, 오디오 믹싱이다.

SIMD가 잘 맞는 자리 — 원소별 동일 연산
// RGBA 픽셀에 감마 보정 — 4채널을 한 번에
func applyGain(_ px: SIMD4<Float>, gain: Float) -> SIMD4<Float> {
    px * gain                       // 4개 곱셈이 한 연산으로
}

// 두 점 사이 거리 — 내적을 sum() 으로
func distance(_ p: SIMD3<Float>, _ q: SIMD3<Float>) -> Float {
    let d = p - q
    return (d * d).sum().squareRoot()
}
print(distance(SIMD3(0, 0, 0), SIMD3(3, 4, 0)))   // 5.0
실행 결과
5.0

③ SIMD를 성능 만능 도구로 쓰지 않는다. 원소 수가 적거나 분기가 많은 로직에서는 오히려 느려질 수 있고, 무엇보다 컴파일러가 이미 자동 벡터화하는 경우가 많다. 30장의 InlineArray와 마찬가지로 측정이 먼저다.

⚠️ 흔한 오해

"IntDouble 변환은 값이 커지는 방향이니 안전하다"는 이 절의 핵심 오해다 — Double표현 범위는 훨씬 넓지만 정밀도는 52비트뿐이라, 큰 정수에서 하위 비트가 사라진다. 또 "Float는 소수점 7자리까지 정확하니 천만 단위 정수는 괜찮다"도 틀렸다 — 실측대로 16777217에서 이미 어긋난다. SIMD 쪽에서는 "SIMD4<Float>Float 4개를 담으니 [Float]보다 항상 빠르다"가 과장이다. 벡터 레지스터에 올려 이득을 보려면 정렬·연속성·분기 없음이 함께 갖춰져야 한다.

🧒 쉽게 이해하기

변환은 물건을 다른 그릇에 옮기는 일이다. 그릇이 작으면 세 가지 중 하나가 일어난다 — 못 담겠다고 포기하거나(exactly: → nil), 넘치는 걸 버리거나(truncatingIfNeeded:), 아예 사고를 내 멈춘다(기본 이니셜라이저 → 트랩). 그런데 가장 무서운 네 번째가 있다. IntDouble에 담는 것은 "훨씬 큰 그릇"으로 옮기는 것처럼 보이는데, 그 큰 그릇은 눈금이 굵어서 잔돈을 표시할 수 없다. 그래서 …993을 담았다가 꺼내면 …992가 된다 — 넘치지도 않았고 사고도 안 났는데 1이 없어졌다. 비유가 깨지는 곳: 실제 그릇이라면 물건이 없어진 걸 눈으로 보지만, 여기서는 아무 표시도 없이 숫자만 조용히 달라진다.

꼬리 질문

2⁵³이 정확히 경계인 이유는?
Double의 유효숫자가 52비트 저장 + 암묵적 선두 1비트 = 53비트의 정밀도를 갖기 때문이다. 53비트로는 0부터 2⁵³까지의 모든 정수를 하나씩 정확히 표현할 수 있다. 2⁵³을 넘으면 표현 가능한 정수 사이의 간격이 2가 되고(그다음 구간에서는 4, 8…), 따라서 홀수를 표현할 수 없다. 실측에서 2⁵³ + 1 = 90071992547409939007199254740992로 내려앉은 것이 정확히 이 현상이다 — 그 구간에서 표현 가능한 값이 짝수뿐이라 가장 가까운 짝수로 반올림된 것이다. Float는 유효숫자가 23비트 저장 + 1 = 24비트이므로 같은 경계가 2²⁴ = 16777216에 온다. 실무 규칙으로는 JavaScript의 Number.MAX_SAFE_INTEGER가 바로 이 경계라는 점을 기억해두면 좋다 — 정확히는 2⁵³ − 1 = 9007199254740991로, "모든 정수를 하나씩 정확히 표현할 수 있는 마지막 값"이다(2⁵³ 자체는 표현되지만 그 위로는 홀수가 빠지기 시작한다). JavaScript의 숫자가 Double이므로 한계가 같을 수밖에 없고, 그래서 웹 API와 주고받는 ID에서 자주 문제가 된다.
쉽게 말하면 눈금이 1칸씩 있는 자로는 모든 정수를 짚을 수 있는데, 어느 지점을 넘으면 눈금이 2칸씩으로 벌어져서 홀수를 짚을 수가 없어진다. 그 경계가 2⁵³이다.
SIMDalignment가 16인 것이 실무에서 문제가 되는가?
될 수 있다. 정렬 16은 "이 값의 주소가 16의 배수여야 한다"는 요건이고, 이는 두 곳에서 영향을 준다. 첫째, struct에 넣으면 그 struct의 정렬도 16으로 올라가 크기가 예상보다 커질 수 있다 — Part A 01장 Q4에서 본 "필드 순서가 크기를 바꾼다"가 여기서 크게 작용한다. 둘째, 29·30장에서 다룬 포인터/RawSpan으로 바이트를 재해석할 때 정렬을 지키지 않으면 정의되지 않은 동작이다 — 임의 오프셋에서 SIMD4<Float>로 로드하면 안 된다. 그래서 바이너리 포맷 파싱에서는 정렬이 보장되지 않는 오프셋에서 원소를 하나씩 읽어 SIMD구성하는 편이 안전하다.
쉽게 말하면 큰 가구는 "정해진 칸에만 놓을 수 있다"는 규칙이 있어서, 그 가구가 들어간 방은 배치가 통째로 달라지고 크기도 커진다.
JSON에서 큰 ID를 안전하게 받는 최선의 방법은 무엇인가?
순서대로 ① 서버가 문자열로 주도록 합의하는 것이 최선이다. JSON 명세에는 숫자의 정밀도 보장이 없고, 중간 경로(프록시, 로그 파이프라인, JavaScript 클라이언트)에서 Double로 파싱될 위험이 항상 남기 때문이다. 실제로 큰 규모 API들이 ID를 문자열로 내보내는 이유가 이것이다. 그럴 수 없으면 Swift 쪽에서 Int(또는 Int64)로 디코딩한다 — JSONDecoder는 대상 타입이 정수면 정수로 파싱하므로 Double을 거치지 않는다. 어느 쪽이든 왕복 테스트를 둔다 — 경계값(2⁵³, 2⁵³+1, Int64.max)을 넣어 인코딩·디코딩 후 값이 보존되는지 확인하는 테스트가 이 종류의 회귀를 잡는 유일한 자동 장치다. Part B 20장의 진단 기법(codingPath)과 함께 쓰면 문제 필드를 빠르게 찾을 수 있다.
쉽게 말하면 아주 긴 번호는 숫자로 취급하면 중간에 반올림될 위험이 있으니, 처음부터 "글자"로 다루는 게 안전하다. 전화번호를 계산에 쓰지 않고 문자열로 두는 것과 같은 이유다.

출처 · 참고자료