← 전체 글로

AI 사용기

AI 활용기: Swift Int(Double) 크래시 'Double value cannot be converted to Int' 원인과 막는 법

Swift 6.4에서 Int(Double)에 NaN, 무한대, 2^63을 넣어 보고 어느 값에서 프로세스가 죽는지 확인했다. 그럴듯한 방어 코드가 새는 경계값과 죽지 않는 변환 함수를 설명한다.

이 글의 목적

Swift에서 Double을 Int로 바꾸다 앱이 죽는 개발자가 원인 값과 안전한 변환 방법을 찾도록 돕는다.

핵심 내용

Int(Double)은 NaN, 무한대, 범위 밖 값에서 오류가 아니라 프로세스 종료로 끝난다. 상한 검사는 2^63 이상을 걸러야 하고, 정수가 아니거나 범위 밖인 값을 거부해도 되면 Int(exactly:), 소수점은 버리고 끝값으로 잘려도 되면 범위를 직접 자르는 함수를 쓴다.

읽고 나서

코드에서 Int(…)에 Double을 직접 넣는 곳을 찾아 사용자 입력이나 계산 결과가 닿는지 확인하고, 그 변환을 범위 검사를 거치는 함수 하나로 모은다.

목차

먼저 답하면

Int(someDouble)은 someDouble이 NaN, 무한대, 또는 Int가 담을 수 없는 크기이면 에러를 던지지 않고 프로세스를 종료시킨다. 이 시험에서 종료 코드는 133이었다. 막으려면 변환 전에 범위를 검사해야 하는데, 상한은 <= Double(Int.max)가 아니라 2^63 이상(>= 9223372036854775808.0) 을 걸러야 한다. Double(Int.max)가 반올림되어 정확히 2^63이 되기 때문이다. 정수가 아니거나 범위 밖인 값을 거부(nil)해도 되는 곳에는 Int(exactly:)를, 어떤 값이든 일단 Int가 필요하고 끝값으로 잘려도 되는 곳에는 범위를 직접 자르는 함수를 쓴다.

증상: 입력 하나에 앱이 그냥 사라진다

디버그 빌드에서는 이런 한 줄을 남기고 끝난다. 실제 출력 앞에는 표준 라이브러리 인터페이스 파일명과 줄 번호가 붙지만 적지 않았다.

Fatal error: Double value cannot be converted to Int because it is either infinite or NaN
Fatal error: Double value cannot be converted to Int because the result would be greater than Int.max
Fatal error: Double value cannot be converted to Int because the result would be less than Int.min

최적화(-O) 빌드에서는 이 시험에서 메시지가 아예 출력되지 않았고 종료 코드만 133이었다. 크래시 로그가 없으면 변환 한 줄이 원인이라는 것을 눈치채기 어렵다.

환경

macOS 26.6.2, Apple Swift 6.4, arm64에서 2026년 10월 6일 실행했다. 디버그(swiftc)와 최적화(swiftc -O) 빌드를 모두 시험했다. Linux, 32비트 환경, -Ounchecked, Float·Float16 변환은 시험하지 않았다. 언어 모드 옵션 없이 swiftc 기본 설정으로 컴파일했고, 전역 변수를 쓰는 아래 코드가 Swift 6 언어 모드 프로젝트에서도 컴파일되는지는 확인하지 않았다.

재현: 실행 시점에 값을 넣는다

상수를 Int(...)에 직접 넣으면 컴파일러가 미리 막기도 한다. 이 시험(디버그 빌드)에서 print(Int(1e19))는 컴파일 오류 invalid conversion: '1e19' overflows 'Int'였지만, let x = 1e19 뒤에 Int(x)를 부르면 컴파일은 통과하고 실행 중에 죽었다. 사용자 입력이나 계산 결과는 컴파일러가 볼 수 없으므로 아래처럼 인자로 받는다.

import Foundation
let x = Double(CommandLine.arguments[1])!   // 실행 시점에 들어온 값
print(Int(x))

swiftc conv.swift -o conv로 컴파일하고 값을 바꿔 가며 실행했다. 디버그 빌드 결과이며, 오류 줄은 길어서 ...로 줄여 적었다.

2.9                       -> 2
-2.9                      -> -2
nan                       -> Fatal error: ... either infinite or NaN        (exit 133)
inf                       -> Fatal error: ... either infinite or NaN        (exit 133)
1e19                      -> Fatal error: ... greater than Int.max          (exit 133)
-1e19                     -> Fatal error: ... less than Int.min             (exit 133)
9.2e18                    -> 9200000000000000000
9223372036854775808       -> Fatal error: ... greater than Int.max          (exit 133)
9223372036854774784       -> 9223372036854774784
-9223372036854775808      -> -9223372036854775808

소수점은 0 쪽으로 버려졌고(2.9는 2, -2.9는 -2), 2^63인 9223372036854775808에서 처음으로 죽었다. 그보다 한 칸 작은 9223372036854774784는 변환되었다. 하한은 -2^63이 변환되었고, 그보다 작은 -9223372036854777856은 less than Int.min 메시지로 죽었다(표에는 적지 않았다).

원인

Swift 표준 라이브러리 소스(IntegerTypes.swift.gyb)에서 Int(_ source: Double)는 값이 유한한지, 하한보다 큰지, 상한보다 작은지를 _precondition으로 검사하고 위 세 메시지를 낸다. 같은 파일의 문서 주석은 범위를 벗어나면 “a runtime error may occur”라고 쓴다. 즉 던질 수 있는 오류(throws)나 nil이 아니라 실행 중단이며, do/catch나 옵셔널 처리로는 막을 수 없다. 소스는 2026년 10월 6일에 확인한 main 브랜치이고, 시험한 Swift 6.4의 소스와 같은지는 대조하지 않았다. 한편 Int(exactly:)는 같은 파일에 실패 가능한 초기화(init?)로 있고, 시험에서 NaN, 2.5, 1e19, 2^63에 nil을, 3.0에 Optional(3)을 돌려주었다.

그럴듯한 방어 코드가 새는 곳

아래 함수는 그럴듯해 보인다. 유한한 값이고 Int.max 이하이면 변환한다.

import Foundation
setvbuf(stdout, nil, _IONBF, 0)   // 파이프로 받아도 트랩 직전 출력이 남게 한다

func naiveInt(_ x: Double) -> Int? {
    guard x.isFinite, abs(x) <= Double(Int.max) else { return nil }
    return Int(x)
}

print(naiveInt(2.9) as Any, naiveInt(.nan) as Any, naiveInt(1e19) as Any, naiveInt(-1e19) as Any)
let edge = Double(CommandLine.arguments.count) * 9223372036854775808.0 / 2   // 인자를 하나 주면 2^63
print("edge =", edge)
print(naiveInt(edge) as Any)

인자 하나를 주고(./naive x) 실행한 최적화 빌드의 출력이다.

Optional(2) nil nil nil
edge = 9.223372036854776e+18
(여기서 프로세스 종료, exit 133)

앞의 네 값은 기대대로 통과했는데 2^63에서 죽었다. Double(Int.max)는 Int.max(2^63-1)를 정확히 담지 못해 2^63으로 반올림된다(시험에서 Double(Int.max) == pow(2.0, 63)이 참이었다. 이 비교와 Int(exactly:), Int.max * 2 시험 코드는 이 글에 싣지 않았다). 그래서 x <= Double(Int.max)는 2^63에서 참이 되고 그대로 Int(x)로 들어간다. 이 시험에서 x < Double(Int.max)는 2^63에서 거짓이었으므로 <로 쓰면 이 경계는 막힌다. 반대로 하한은 -2^63이 정확히 표현되고 변환도 되므로 >=로 검사하면 된다. 표에 적은 몇 개의 값(2.9, NaN, ±1e19)만 확인하는 시험은 이 함수를 통과시킨다.

해결: 죽지 않는 변환 함수 하나로 모은다

정수가 아니거나 범위 밖이면 거부해도 되는 입력에는 Int(exactly:)를 쓰고, 어떤 값이든 일단 정수로 바꿔야 하는 곳(소수점을 버려도 되는 표시용 값, 개수 계산 등)에는 범위를 직접 자르는 함수를 쓴다. 아래는 NaN이면 nil(NaN은 자를 끝값이 없다), 범위 밖이면 끝값으로 자르는 예다. 위 시험의 경계값을 반영해 상한은 2^63 이상, 하한은 -2^63 미만으로 검사한다.

import Foundation

func clampedInt(_ x: Double) -> Int? {
    if x.isNaN { return nil }
    if x >= 9223372036854775808.0 { return Int.max }    // 2^63 이상
    if x < -9223372036854775808.0 { return Int.min }    // -2^63 미만
    return Int(x)                                       // 여기서는 트랩하지 않는다
}

var state: UInt64 = 20261006
func next() -> UInt64 {                       // SplitMix64: 64비트 전체가 고르게 섞인다
    state = state &+ 0x9E3779B97F4A7C15
    var z = state
    z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9
    z = (z ^ (z >> 27)) &* 0x94D049BB133111EB
    return z ^ (z >> 31)
}

let edges: [Double] = [.nan, .infinity, -.infinity, 9223372036854775808.0, -9223372036854775808.0, -9223372036854777856.0, 1e19, -1e19]
var nilCount = 0
for _ in 0..<1_000_000 {
    let x: Double
    switch next() % 3 {
    case 0:  x = edges[Int(next() % UInt64(edges.count))]               // 경계값을 일부러 자주 뽑는다
    case 1:  x = Double(bitPattern: next())                             // 임의 비트 패턴
    default: x = Double(Int64(bitPattern: next())).nextUp               // Int 범위 전체에서 뽑은 값(경계 근처는 거의 안 뽑힌다)
    }
    if clampedInt(x) == nil { nilCount += 1 }
}
print("1,000,000 cases survived, nil:", nilCount)
for x in [2.9, -2.9, .nan, .infinity, 9223372036854775808.0, 9223372036854774784.0, -9223372036854775808.0] {
    print(x, "->", clampedInt(x) as Any)
}

디버그와 최적화 빌드 모두에서 같은 결과가 나왔다.

1,000,000 cases survived, nil: 42078
2.9 -> Optional(2)
-2.9 -> Optional(-2)
nan -> nil
inf -> Optional(9223372036854775807)
9.223372036854776e+18 -> Optional(9223372036854775807)
9.223372036854775e+18 -> Optional(9223372036854774784)
-9.223372036854776e+18 -> Optional(-9223372036854775808)

100만 번의 변환이 끝까지 돌았다. 같은 반복문에서 clampedInt 자리에 앞의 naiveInt를 넣어 -O로 돌리면 프로세스가 종료 코드 133으로 죽었다. 이 반복이 2^63을 만난 것은 임의 난수 덕이 아니라 edges 배열에 경계값을 직접 넣어 두었기 때문이다. 같은 난수 생성기로 따로 센 값에서 임의 비트 패턴 분기는 약 33만 건 중 NaN이 191건, 무한대가 0건이었으므로 NaN·무한대·경계는 목록이 책임지고 난수는 그 사이의 값을 채운다. 확인 방법의 핵심은 경계값을 손으로 목록에 넣고 변환을 반복해 보는 것이다. 값 몇 개를 적은 표(앞의 naiveInt 시험)는 2^63을 빠뜨리면 통과한다.

같은 계열: 정수 오버플로

시험에서 Int.max * 2를 실행 시점 값으로 계산하면 디버그와 최적화 빌드 모두 종료 코드 133으로 죽었다. 반면 Int.max.multipliedReportingOverflow(by: 2)는 죽지 않고 (-2, true)를 돌려주었다. 크기를 알 수 없는 정수 곱셈도 오버플로 여부를 알려 주는 쪽을 쓰는 편이 안전해 보인다. 덧셈과 뺄셈은 시험하지 않았다. 이 글은 이 한 경우만 확인했다.

확인 방법과 안 되는 경우

크래시가 Int(Double)인지 보려면 같은 입력을 디버그 빌드로 재현해 위 세 메시지 중 하나가 나오는지 확인한다. 최적화 빌드는 메시지가 없고 종료 코드만 남았으며, 정수 오버플로도 같은 종료 코드 133이라 종료 코드만으로는 둘을 구분할 수 없다. 크래시 리포트나 호출 스택은 시험하지 않았다. 해결 뒤에는 NaN, ±무한대, ±2^63과 그 바로 바깥 값, ±1e19를 직접 넣는 시험을 하나 추가한다.

이 글은 Int(64비트)와 Double만 시험했다. Int32, UInt, Float, Decimal 변환과 -Ounchecked 빌드, Linux는 시험하지 않았다. 값을 끝값으로 자르는 clampedInt는 입력이 범위를 크게 벗어났을 때 조용히 틀린 숫자를 만들 수 있으므로, 금액이나 수량처럼 틀리면 안 되는 값은 자르지 말고 입력을 거부하는 쪽이 맞다.

AI가 자료 조사, 초안 작성, 시험 프로그램 작성과 실행을 수행했습니다. 자동 품질 검사와 배포 검증 후 공개했으며 사람 검토는 공개 후 피드백으로 받습니다. 사람이 사전 승인했다고 기록하지 않습니다.