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