← 목록으로
IEEE 754 · 부동소수점 · 게임

컴퓨터와 실수

Real Number or Mistake?

컴퓨터에게 실수(實數)는 늘 실수(失手)가 됩니다. 실수를 정확히 저장하지 못하고, 그저 가장 가까운 값으로 반올림해 둘 뿐이죠. 이 단순한 사실이 한쪽에서는 전설적인 최적화 해킹(퀘이크)을, 다른 한쪽에서는 황당한 버그 (마인크래프트)를 낳았습니다. 같은 IEEE 754가 만든 두 얼굴을 따라가 봅시다.

0전설의 코드

이야기는 1999년 퀘이크 III 아레나의 소스 코드에서 시작합니다. 역제곱근 \(1/\sqrt{x}\) 을 구하는 Q_rsqrt 함수인데, 주석에는 대놓고 "what the f***" 이라고 적혀 있죠.

float Q_rsqrt( float number )
{
    long i;
    float x2, y;
    const float threehalfs = 1.5F;

    x2 = number * 0.5F;
    y  = number;
    i  = * ( long * ) &y;                       // evil floating point bit level hacking
    i  = 0x5f3759df - ( i >> 1 );               // what the f***?
    y  = * ( float * ) &i;
    y  = y * ( threehalfs - ( x2 * y * y ) );   // 1차 뉴턴 반복
//  y  = y * ( threehalfs - ( x2 * y * y ) );   // 2차 반복 (생략 가능)

    return y;
}

정수와 실수를 막 섞고, 0x5f3759df 라는 정체불명의 상수에서 비트를 절반으로 줄인 값을 뺍니다. 그런데도 거의 정확한 \(1/\sqrt{x}\) 가 나오죠. 이 마법을 이해하려면, 먼저 컴퓨터가 실수를 어떻게 저장하는지부터 알아야 합니다.

왜 역제곱근이 중요할까?
3D 그래픽은 벡터를 정규화(길이를 1로)할 때마다 \(\vec v \cdot \dfrac{1}{\sqrt{x^2+y^2+z^2}}\) 를 계산합니다. 조명·반사·충돌 판정 등 초당 수백만 번 일어나는 연산이라, 느린 나눗셈·제곱근을 피하는 것이 곧 프레임 속도였습니다.

1부동소수점(IEEE 754)

32비트 float(단정밀도)는 과학적 표기법 \(\;\pm 1.\!\text{(가수)} \times 2^{\text{(지수)}}\;\) 를 흉내 내어 비트를 셋으로 나눕니다.

0
부호 (1비트)
0
1
1
1
1
1
1
1
지수 E (8비트)
0
0
0
0
0
0
가수 M (23비트)

부호를 무시하면(양수만 다룹니다), 실제 값은 다음과 같습니다. \(E\)는 저장된 지수 필드, \(M\)은 가수 비트를 정수로 읽은 값, 지수에는 바이어스 127이 더해져 있습니다.

$$ v \;=\; \Bigl(1 + \frac{M}{2^{23}}\Bigr)\, 2^{\,E-127} $$

핵심은 지수가 곧 \(\log_2\)의 정수 부분이라는 점입니다. 자연스럽게 로그가 등장하죠. 양변에 \(\log_2\)를 취해 둡시다. 뒤에서 다시 씁니다.

$$ \log_2 v \;=\; \log_2\!\Bigl(1 + \frac{M}{2^{23}}\Bigr) + (E - 127) $$

비트 들여다보기 — 당신의 숫자는 정확히 저장될까?

숫자를 넣으면 32비트 float이 실제로 저장하는 비트와, 그 비트가 나타내는 진짜 값을 보여줍니다.


20.1 + 0.2 는 0.3이 아니다

컴퓨터에게 \(0.1 + 0.2\) 를 시켜 보면 충격적인 답이 돌아옵니다.

>>> 0.1 + 0.2
0.30000000000000004

버그가 아닙니다. 원인은 2진법이에요. 10진법에서 \(\tfrac13 = 0.3333\ldots\) 이 끝나지 않듯, 2진법에서는 \(0.1\) 이 무한히 반복되는 소수가 됩니다.

$$ 0.1_{(10)} \;=\; 0.0001100110011001100\ldots_{(2)} $$

유한한 비트(23칸)에 무한 소수를 담을 수 없으니, 컴퓨터는 가장 가까운 값으로 반올림합니다. 그래서 저장된 \(0.1\)은 사실 정확히 \(0.1\)이 아닙니다. 위 "비트 들여다보기"에 0.1 을 넣어 보면 저장된 실제 값이 \(0.1000000014\ldots\) 라는 걸 볼 수 있죠. 이렇게 살짝 어긋난 \(0.1\)과 \(0.2\)를 더하면, 그 오차가 합쳐져 \(0.3\)을 살짝 빗나갑니다.

그럼 0.1 + 0.1 은?
재밌게도 0.1 + 0.1 === 0.2입니다. \(0.1\)의 반올림 오차가 두 배 되면서 \(0.2\)의 반올림 오차와 우연히 맞아떨어지거든요. 부동소수점의 함정은 이렇게 예측하기 어렵게 여기저기 숨어 있습니다. 그래서 돈 계산에는 절대 부동소수점을 쓰면 안 됩니다.

큰 수 + 작은 수 = 큰 수 그대로 (흡수, absorption)

또 하나의 함정. 아주 큰 수에 아주 작은 수를 더하면, 작은 수가 흔적도 없이 사라집니다. 가수는 23(혹은 double은 52)비트뿐이라, 두 수의 크기 차이가 그 정밀도를 넘어서면 작은 쪽은 반올림 과정에서 통째로 묻혀 버리기 때문이죠. 직접 확인해 보세요.

흡수 실험실

\(2^{53}\)(약 9천조)을 넘어서는 순간, 정수조차 1씩 셀 수 없게 됩니다. 9007199254740992 + 1 === 9007199254740992 이 참이 되는 거죠. 이 "수가 커질수록 간격이 벌어진다"는 성질이, 곧 뒤에서 만날 마인크래프트 버그의 씨앗입니다.


30.1초가 부른 비극 — 패트리어트 미사일

"0.1이 정확하지 않다"는 건 학교 예제 속 사소한 결함처럼 보입니다. 하지만 1991년, 바로 이 오차가 28명의 목숨을 앗아갔습니다.

걸프전이 한창이던 1991년 2월 25일, 사우디아라비아 다란(Dhahran)의 미군 기지. 패트리어트 방공 시스템이 날아오는 이라크군 스커드 미사일을 요격해야 했습니다. 그런데 패트리어트는 스커드를 탐지하고도 요격하지 않았고, 미사일은 미군 막사에 명중해 28명이 숨지고 약 100명이 다쳤습니다.

범인은 0.1

1970년대에 설계된 이 시스템은 24비트 산술을 썼습니다. 내부 시계는 시간을 0.1초 단위의 정수로 셌고, 날아오는 표적의 위치를 예측하려면 그 횟수에 \(0.1\)을 곱해 초로 바꿔야 했죠. 그런데 2장에서 봤듯, \(0.1\)은 2진법에서 무한 반복소수라 24비트로 자르면 약 \(9.5\times10^{-8}\)초만큼 어긋납니다.

한 번의 오차는 1억분의 1초 수준이라 무시할 만합니다. 문제는 누적이었습니다. 배터리를 끄지 않고 100시간(\(=3{,}600{,}000\) 틱) 연속 가동하자, 그 미세한 오차가 차곡차곡 쌓여 계산된 시간이 실제보다 약 0.34초나 어긋났습니다.

$$ 9.5\times10^{-8}\ \text{초} \;\times\; 3{,}600{,}000 \;\approx\; 0.34\ \text{초} $$

스커드 미사일은 마하 5(초속 약 1,676 m)로 날아옵니다. 0.34초면 약 570 m를 이동하죠. 패트리어트는 레이더가 표적을 다시 확인할 "추적 창(range gate)"을 예측 위치에 띄우는데, 시간이 0.34초 어긋나니 창이 엉뚱한 하늘을 바라봤습니다. 결국 레이더에 잡힌 스커드를 "실제 표적이 아니다"라고 판단해 무시해 버린 겁니다.

하루 늦게 도착한 패치
더 안타까운 건, 시간 처리를 개선한 수정 소프트웨어가 이미 만들어져 있었다는 점입니다. 그 패치는 공격 바로 다음 날 다란에 도착했습니다. 오차가 누적되지 않도록 시스템을 주기적으로 재부팅하라는 권고도 있었지만, "얼마나 자주"가 명확하지 않았죠. 부동소수점의 작은 반올림 하나가, 어떻게 현실의 비극으로 번지는지 보여주는 가장 유명한 사례입니다.

4비트를 정수로 읽으면 = 로그다

이제 퀘이크로 돌아갑니다. 똑같은 32비트를 그냥 정수 \(I\)로 읽으면 어떤 값일까요? 지수 필드는 가수보다 \(2^{23}\)배 위쪽 자리에 있으므로:

$$ I \;=\; E\cdot 2^{23} + M \qquad\Longrightarrow\qquad \frac{I}{2^{23}} = E + \frac{M}{2^{23}} $$

한편 \(0\le t<1\) 범위에서 \(\log_2(1+t)\approx t + \sigma\) 로 근사할 수 있습니다 (\(\sigma\)는 직선이 곡선에 가장 잘 맞도록 잡는 작은 보정항). 이걸 1장의 식에 넣으면:

$$ \log_2 v \;\approx\; \frac{M}{2^{23}} + \sigma + E - 127 \;=\; \underbrace{\Bigl(E + \frac{M}{2^{23}}\Bigr)}_{=\,I/2^{23}} + \sigma - 127 $$
결론 한 줄
$$ \log_2 v \;\approx\; \frac{I}{2^{23}} + \sigma - 127 $$
부동소수점의 비트 패턴을 정수로 읽은 값 \(I\)는 그 수의 \(\log_2\)에 거의 비례합니다. 비트가 곧 로그표인 셈이죠.

5마법의 상수 0x5f3759df

우리가 원하는 답은 \(y = 1/\sqrt v = v^{-1/2}\). 로그 세계에서 이건 그냥 \(-\tfrac12\)을 곱하는 일입니다.

$$ \log_2 y \;=\; -\tfrac{1}{2}\log_2 v $$

\(y\)의 비트를 정수로 읽은 값을 \(I_y\), \(v\)의 것을 \(I_v\)라 하고, 4장의 "결론 한 줄"을 양쪽에 대입해 \(I_y\)에 대해 정리하면:

$$ I_y \;=\; \underbrace{\tfrac{3}{2}\,2^{23}\,(127 - \sigma)}_{\text{마법의 상수}} \;-\; \frac{1}{2}\,I_v $$

오른쪽 끝 항 \(\tfrac12 I_v\)는 코드의 i >> 1(오른쪽 1비트 시프트 = 정수 나누기 2) 바로 그것이고, 앞의 상수가 우리가 찾던 마법의 수입니다. 보정항 \(\sigma\)를 오차가 가장 작아지도록 약 \(0.0450466\)로 잡으면:

$$ \tfrac{3}{2}\cdot 2^{23}\cdot(127 - 0.0450466) \;\approx\; 1597463007 \;=\; \texttt{0x5f3759df} $$
그래서 그 수상한 한 줄은…
i = 0x5f3759df - ( i >> 1 ); 는 사실 로그 영역에서 \(-\tfrac12\)을 곱해 \(1/\sqrt v\)의 비트 패턴을 곧장 추정하는 식입니다. 나눗셈도 제곱근도 없이, 정수 뺄셈과 시프트 한 번으로 끝나죠.

6마무리 손질: 뉴턴 메소드

비트 해킹으로 얻은 값은 "꽤 좋은 첫 추정"일 뿐입니다. 마지막 줄 y = y * ( threehalfs - ( x2 * y * y ) ); 가 정밀도를 끌어올리는데, 이건 뉴턴-랩슨 반복입니다.

입력을 \(x\)라 할 때 우리는 \(y = 1/\sqrt x\), 즉 \(\dfrac{1}{y^2} = x\) 를 풀고 싶습니다. 그래서 다음 함수의 근을 찾습니다.

$$ f(y) = \frac{1}{y^2} - x, \qquad f'(y) = -\frac{2}{y^3} $$

뉴턴 반복의 핵심 아이디어는 "곡선을 그 자리의 접선으로 대신한다"입니다. 현재 추정값 \(y_n\)에서 곡선에 접선을 긋고, 그 접선이 0과 만나는 곳을 다음 추정값 \(y_{n+1}\)로 삼죠. 아래 그래프에서 직접 한 스텝씩 밟아 보세요.

뉴턴-랩슨, 접선으로 근에 다가가기

공식으로 쓰면, \(y_{n+1} = y_n - \dfrac{f(y_n)}{f'(y_n)}\) 를 정리해:

$$ y_{n+1} \;=\; y_n - \frac{\tfrac{1}{y_n^2}-x}{-\,2/y_n^3} \;=\; y_n\Bigl(\tfrac{3}{2} - \tfrac{x}{2}\,y_n^2\Bigr) $$

여기서 \(\tfrac{3}{2}\)가 코드의 threehalfs, \(\tfrac{x}{2}\)가 x2. 즉 마지막 줄은 이 공식을 글자 그대로 옮긴 것입니다.

왜 딱 한 번만 반복할까?
뉴턴 메소드는 오차의 자릿수를 매번 두 배로 줄입니다(2차 수렴). 비트 해킹의 첫 추정이 이미 상대오차 약 3.4% 이내라, 한 번 반복하면 약 0.17% 이내로 떨어집니다. 게임 그래픽에는 차고 넘치는 정밀도라 원본은 2차 반복을 주석 처리해 버렸습니다.

7직접 해보기

숫자를 넣으면 각 단계가 실제로 어떻게 진행되는지 보여줍니다. 비트 추정 한 번, 뉴턴 한 번, 그리고 진짜 답과 비교해 보세요.

역제곱근 단계별 계산기


8누가 만들었나

이 코드는 흔히 퀘이크의 존 카맥의 것으로 알려졌지만, 추적해 보면 그가 처음 쓴 것은 아닙니다. SGI/3dfx의 Gary Tarolli, Greg Walsh 등을 거쳐 거슬러 올라가며, 그 뿌리에는 비트로 로그를 근사하는 오래된 아이디어와 William Kahan(IEEE 754의 아버지) 등 수치해석 전통이 닿아 있습니다. 누가 \(\sigma\)를 골라 \(\texttt{0x5f3759df}\) 를 확정했는지는 지금도 미스터리로 남아 있죠. (Chris Lomont, Jan Kadlec 등은 오차를 더 줄인 변형 \(\texttt{0x5f375a86}\) 도 찾아냈습니다.)

오늘날에는?
현대 CPU/GPU에는 역제곱근을 한 명령으로 처리하는 하드웨어(rsqrtss 같은 SIMD 명령)가 있어, 실무에서 이 트릭을 직접 쓸 일은 거의 없습니다. 그래도 "비트가 곧 로그"라는 통찰은 여전히 아름답습니다.

9부동소수점의 다른 얼굴: 마인크래프트 보트 버그

퀘이크가 부동소수점의 구조를 영리하게 이용한 이야기라면, 마인크래프트는 그 한계가 황당한 버그로 터져 나온 이야기입니다. 2024년 수학 유튜버 Matt Parker(Stand-up Maths)가 소개해 유명해진 "보트 떨어뜨리기 미스터리"가 대표적이죠.

현상은 이렇습니다. 보트를 떨어뜨리면 부서지는데, "높을수록 더 잘 부서진다"가 아닙니다. 어떤 좁은 높이 구간에서는 부서지지만, 그보다 더 높은 곳에서는 멀쩡히 살아남는, 줄무늬(band) 같은 이상한 패턴이 나옵니다.

보트가 부서지는 높이 — 줄무늬 패턴

부서짐 약 12–13, 49–51, 111–114, 198–202, 310–315 블록 · 멀쩡 나머지 구간
※ Java 에디션 기준. 각 띠 위 숫자는 착지 속도(블록/틱)입니다.

줄무늬의 정체: 속도가 딱 정수가 되는 순간

마인크래프트 물리는 1초를 20등분한 틱(tick) 단위로 계산됩니다. 떨어지는 보트는 매 틱 중력만큼 빨라지는데, 그 값이 틱²당 약 0.04블록입니다. 즉 \(n\)틱째의 낙하 속도는 \(0.04\,n\) 블록/틱이죠.

여기서 결정적인 사실. 부서지는 높이 구간들을 시뮬레이션해 보면, 우연이 아니라 보트의 낙하 속도가 정확히 1, 2, 3, 4, 5 블록/틱 같은 정수에 도달하는 순간과 딱 들어맞습니다. 중력이 \(0.04\)이므로 속도가 정수가 되는 건 25틱마다 (\(0.04 \times 25 = 1\), \(\times 50 = 2\), …)이고, 그때까지 떨어진 거리가 바로 그 높이들입니다.

속도 (블록/틱)걸린 시간 (틱)낙하 높이 (블록)
125≈ 13
250≈ 51
375≈ 114
4100≈ 202
5125≈ 315

높이가 속도의 제곱에 비례(낙하 거리 \(\propto v^2 \propto n^2\))하기 때문에, 부서지는 높이는 약 \(12.5\,m^2\)(\(m=1,2,3,\dots\))로 띄엄띄엄, 점점 멀어지며 나타납니다. 구간 사이 간격이 37, 62, 87, 112…로 25씩 늘어나는 것도 이 2차식의 흔적이죠.

그럼 왜 하필 "정수 속도"에서 부서질까

보트는 매 틱 사이에는 존재하지 않고 틱 경계에서만 위치가 갱신됩니다(스냅샷처럼). 착지하는 틱에 누적된 낙하량으로 피해가 계산되는데, 그 피해가 보트를 부술 만한지는 아주 좁은 문턱(threshold) 비교 하나로 갈립니다. 그리고 보트의 속도는 \(0.04\)를 매 틱 더해 쌓아 올린 값인데, \(0.04\)는 2진법에서 정확히 표현되지 않습니다(2장의 \(0.1\)과 똑같죠). 그래서 25·50·75…틱째에 만들어진 "1.0, 2.0, 3.0…"은 사실 진짜 정수가 아니라 아주 살짝 어긋난 값이고, 그 미세한 오차가 문턱을 아슬아슬하게 넘느냐 못 넘느냐를 가릅니다.

결과적으로 "높을수록 매끄럽게 더 부서짐"이 아니라, 특정 속도에 닿는 순간에만 켜졌다 꺼지는 줄무늬가 생깁니다. \(0.1 + 0.2\)를 \(0.3\)에서 빗나가게 한 그 IEEE 754가, 게임 속 보트도 똑같이 골탕 먹이고 있는 셈입니다.

정직하게 덧붙이면
위 높이·틱·속도 수치는 직접 낙하 시뮬레이션으로 재현해 확인한 값입니다. 다만 "피해가 보트를 부수는 정확한 조건(코드의 어느 한 줄)"까지는 Matt Parker의 영상에 더 자세히 나옵니다. 버전에 따라 숫자가 조금 달라질 수 있다는 점도 염두에 두세요.

10부동소수점의 기묘한 규칙들

부동소수점에는 처음 보면 버그 같지만 사실 규칙대로인 기묘한 동작이 많습니다. 아래는 당신의 브라우저에서 실제로 지금 실행한 결과예요.

부동소수점 콘솔 (실제 실행 결과)

NaN 은 자기 자신과도 다르다

NaN === NaN거짓입니다. NaN(Not a Number)은 \(\sqrt{-1}\) 이나 \(0/0\) 처럼 "정의되지 않은 결과"를 뜻하는 특별한 값인데, 표준은 "어떤 NaN도 무엇과도 같지 않다"고 못박았습니다. 그래서 x !== x 가 참이면 x가 NaN인지 알 수 있죠. 이 규칙 때문에 NaN이 섞인 배열을 정렬하면 순서가 엉키는 등 실전 버그가 자주 생깁니다.

0은 두 개다 (+0 과 −0)

부동소수점에는 양의 0과 음의 0이 따로 있습니다. 0 === -0 은 참이지만, \(1/0 = +\infty\) 인 반면 \(1/(-0) = -\infty\) 라서 둘은 분명히 다르게 행동합니다. 무한대(\(\infty\))도 정식 값이라, 오버플로가 나면 프로그램이 죽는 대신 \(\infty\)가 됩니다.

사촌격 사고 — 아리안 5 로켓 (1996)
유럽 아리안 5 로켓은 발사 40초 만에 폭발했습니다. 원인은 64비트 실수로 된 수평 속도를 16비트 정수로 변환하다 값이 32,767을 넘겨 오버플로가 난 것. (반올림이 아니라 형 변환에서 넘친 사고라, 부동소수점의 사촌격 이야기입니다.) 게다가 그 코드는 구형 아리안 4에서 가져온, 발사 후엔 필요도 없던 코드였죠. 날아간 비용은 약 3.7억 달러. 숫자의 표현 범위를 얕보면 로켓도 잃습니다.

퀘이크의 천재적 해킹부터 마인크래프트의 줄무늬 버그, 패트리어트와 아리안의 비극까지 — 전부 "컴퓨터는 실수를 근사할 뿐"이라는 한 문장에서 흘러나온 이야기였습니다. 부동소수점은 놀랍도록 유용하지만, 그 가장자리에는 늘 이런 함정이 숨어 있습니다.


참고 자료