Floating-Point의 끝판왕: Russ Cox가 제안한 새로운 변환 알고리즘
개발자 생활을 오래 하다 보면, 절대 건드리고 싶지 않은 ‘심연’ 같은 코드들이 있습니다. 그중 하나가 바로 Floating-Point(부동소수점)를 문자열로 변환하는 알고리즘 입니다.
0.1 + 0.2 != 0.3이라는 짤방은 웃고 넘기지만, 실제로 IEEE 754 배정밀도(double) 숫자를 “가장 짧으면서도, 다시 파싱했을 때 정확히 원래 숫자가 되는” 10진수 문자열로 바꾸는 건 수학적 난제에 가깝습니다. 그동안 우리는 Dragon4, Grisu3, 그리고 최근의 Ryū 알고리즘까지 거치며 “이제 더 이상 최적화할 게 없겠지”라고 생각했습니다.
그런데, Go 언어의 Tech Lead인 Russ Cox가 2026년 1월, 이 판을 뒤집는 글을 올렸습니다. 결론부터 말하자면, 기존의 가장 빠른 알고리즘들(Ryū, Schubfach 등)보다 더 빠르고, 훨씬 더 단순합니다.
오늘은 이 알고리즘이 도대체 무슨 마법을 부렸길래 ‘Simple and Fast’라는 타이틀을 달 수 있었는지 기술적으로 파고들어 보겠습니다.
기존의 고통: 정확도 vs 속도
부동소수점 변환의 핵심은 $f = m \times 2^e$ 형태의 이진 실수를 $d \times 10^p$ 형태의 십진수로 바꾸는 것입니다. 여기서 문제는 정확한 라운딩(Rounding) 입니다.
과거의 알고리즘들은 크게 두 가지 접근법을 취했습니다:
- Bignum 방식 (Dragon4): 임의 정밀도 정수 연산을 사용합니다. 정확하지만 끔찍하게 느립니다.
- 고정 정밀도 최적화 (Grisu, Ryū): 64비트나 128비트 정수 연산만으로 근사치를 계산합니다. 빠르지만 구현이 복잡하고 엣지 케이스 처리가 까다롭습니다.
특히 Ryū 알고리즘이 나왔을 때만 해도 “이게 끝판왕이다” 싶었습니다. 하지만 코드를 보신 분은 아시겠지만, 테이블 룩업과 시프트 연산이 얽혀 있어 이해하기가 쉽지 않죠.
핵심 아이디어: Unrounded Scaling
Russ Cox의 접근법은 “Unrounded Scaling” 이라는 개념에서 출발합니다. 실수 연산을 하되, 마지막에 정수로 딱 떨어지게 만드는 게 아니라, “어떤 정보가 소실되었는지(Sticky bit)” 만 남겨두고 연산을 수행하는 것입니다.
핵심 함수인 uscale은 2의 거듭제곱과 10의 거듭제곱을 곱해서 ‘Unrounded’ 타입으로 반환합니다. 여기서 ‘Unrounded’는 정수부와 함께 하위 비트들이 0인지, 0.5인지, 아니면 그 외의 값인지(sticky) 를 2비트의 추가 정보로 들고 다닙니다.
Go 구현의 미학
이 알고리즘의 uscale 구현은 놀라울 정도로 짧습니다. 복잡한 반복문 없이 64비트 곱셈(bits.Mul64) 몇 번으로 끝납니다.
// uscale returns unround(x * 2**e * 10**p).
func uscale(x uint64, c scaler) unrounded {
hi, mid := bits.Mul64(x, c.pm.hi)
sticky := uint64(1)
// Fast path: 상위 비트 계산만으로 충분한 경우 하위 연산 생략
if hi&(1<<(c.s&63)-1) == 0 {
mid2, _ := bits.Mul64(x, c.pm.lo)
sticky = bool2[uint64](mid-mid2 > 1)
hi -= bool2[uint64](mid < mid2)
}
return unrounded(hi>>c.s | sticky)
}
이 코드가 Ryū보다 빠른 이유는 “Omit Needless Multiplications” 섹션에서 설명됩니다. 대부분의 경우 상위 64비트 곱셈만으로도 라운딩 결정을 내리기에 충분한 정보가 모입니다. 즉, 하위 비트 연산을 아예 건너뛰는(Fast Path) 비율이 매우 높다는 것이죠. 이 분기 예측이 성공하면서 Throughput이 비약적으로 상승합니다.
성능: Ryū를 이기다
Russ Cox가 공개한 벤치마크(Apple M4, AMD Ryzen 9) 결과는 충격적입니다.
- Ryū: 기존 최강자.
- uscale (Go & C port): Ryū보다 미세하게, 하지만 확실히 빠릅니다.
특히 1e100 같은 큰 지수 범위에서도 Bignum을 쓰지 않기 때문에 성능 저하가 없습니다. 그래프상에서 날개(wing) 모양으로 느려지는 구간 없이 일정한 Latency를 보여줍니다. 이는 서버 사이드에서 JSON 직렬화를 많이 하는 Go 언어의 특성상 엄청난 이득이 될 것입니다.
개인적인 생각: 단순함이 주는 신뢰
솔직히 고백하자면, 처음 제목을 봤을 때는 “또 새로운 근사 알고리즘이 나왔나 보네” 하고 넘기려 했습니다. 하지만 논문(에 가까운 포스트)을 읽으면서 무릎을 쳤습니다.
- 가독성: Ryū나 Grisu의 C++ 구현체를 들여다보다가 포기한 적이 있다면, 이번 알고리즘은 “이해할 수 있는” 수준입니다. 유지보수 관점에서 이건 엄청난 장점입니다.
- 검증(Verification): Russ Cox는 단순히 코드를 짠 게 아니라, Ivy 라는 도구를 사용해 수학적 증명을 곁들였습니다. 엔지니어링에서 ‘작동한다’와 ‘증명했다’의 차이는 하늘과 땅 차이입니다.
- Go 1.27 탑재 예정: 이건 실험실의 장난감이 아닙니다. 2026년 8월 예정인 Go 1.27에 실제로 들어간다고 합니다. 수십억 개의 요청을 처리하는 전 세계의 Go 서버들이 이 혜택을 보게 될 겁니다.
Hacker News의 반응과 Context
Hacker News 스레드에서도 흥미로운 토론이 오갔습니다.
한 유저는 “Schubfach 알고리즘이나 Teju Jaguá가 코드 사이즈는 더 작지 않나?” 라는 지적을 했습니다. 임베디드 환경에서는 코드 크기(Binary Size)가 깡패니까요. 실제로 uscale 방식은 룩업 테이블이 좀 필요하기 때문에, 극도로 메모리가 제한된 MCU 환경에서는 Schubfach가 여전히 매력적일 수 있습니다.
하지만 일반적인 서버/데스크톱 환경에서는 Throughput 이 왕입니다. Russ Cox의 방식은 분기 예측을 영리하게 활용하여 ‘평균적인 경우’의 연산 비용을 극도로 낮췄습니다.
Verdict: Production Ready?
강력 추천합니다. 만약 여러분이 언어 런타임을 개발하거나, 고성능 JSON 라이브러리를 직접 짜야 하는 상황이라면 이 알고리즘을 포팅하는 것을 진지하게 고려해야 합니다.
복잡도로 악명 높은 Floating-Point 변환 분야에서, 성능을 높이면서 코드 라인 수를 줄이는 건 ‘혁신’ 이라고 불러도 부족하지 않습니다. Go 팀이 또 한 번 해냈네요.
References: