PHP와 Lua에서 특정 밑에 대한 로그 구현이 경계에서 불연속성을 만들어, 기대되는 단조성이 드물게 깨지는 이유를 살펴본다.
July 22, 2026
만약 이고 이라면, 임을 증명할 수 있다. (다시 말해, 는 를 만족하는 값 를 뜻한다.) 직관적으로도 아주 자연스럽다. “보통의” 수에 대해서는, 같은 를 만들기 위해 가 클수록 필요한 는 더 작아지기 때문이다.
그런데 PHP에게 어떻게 생각하는지 물어보면, 드물지만 몇몇 경우에는 당신이 틀렸다고 말한다:
<?php
$x = 2.93;
$a = 10 + 2 ** -49;
$b = 10;
assert($a > $b);
var_dump(log($x, $a) < log($x, $b));
var_dump(log($x, $a) == log($x, $b));
분명히 해두자면, 이것은 흔히 말하는 부동소수점 부정확성의 사례가 아니다. 부동소수점 연산이 부정확하다는 사실은 모두가 이미 알고 있고, 그것만으로는 블로그 글감이 되기 어렵다. 이 예시는 의도적으로 전혀 다른 현상을 유발하도록 만들어졌다.
만약 결과가 대신 를 가리켰다면, 그것은 아주 자연스러웠을 것이다. 모든 실수를 float로 정확히 표현할 수 있는 것은 아니므로, 반올림 때문에 가까운 결과가 같아 보일 수 있기 때문이다. 실제로 같은 수에 대해 Python의 math.log 는 “같음” 결과를 낸다. 하지만 PHP에서는 결과가 어쩐지 뒤집힌다. 그리고 Lua도 그렇다! 그런데 Rust나 C#에서는 아니다. 모두 같은 머신과 같은 OS에서 말이다! 어째서일까?
또 한 가지 주의할 점은, 여기서는 밑만 바꾸고 있다는 것이다. 만약 진수까지 함께 바꾼다면 어떤 언어에서든 동작하는 반례를 많이 찾을 수 있다. 예를 들면 다음과 같다:
log(243 ** 3, 3 ** 3) != log(243, 3)
…이는 log 에 사용되는 공식이 완전하지 않기 때문이다. 우리가 여기서 이야기하는 것은 그것이 아니다.
왜 이런 일이 일어나는지 알아보려면, 언어들이 보통 math.log 를 어떻게 구현하는지 이야기해 볼 필요가 있다.
초월함수를 제공하는 라이브러리 libm 은 로그 계산을 위한 여러 함수를 노출한다. log, log10, log2 등이 그것이다. 각 함수는 하나의 밑만 처리한다. log 는 밑 를, log10 은 밑 을 사용하는 식이다. 정밀도에 대한 보장은 없지만, 대체로 꽤 정확하고, 적어도 단조적이다(브루트포스).
하지만 임의의 밑에 대한 함수는 없으므로, 두 인자를 받는 log 를 제공하는 언어들은 편법을 써야 한다. 수학적으로 이므로, 임의의 로그는 밑 로그 두 개로 계산할 수 있다.
이 방식은 약간 부정확한데, 그래서 math.log(243^3, 3^3) == math.log(243, 3) 가 실패한다. ln 에서의 반올림이 두 번 들어가고 나눗셈까지 거치면서 계산된 값이 조금 어긋나기 때문이다.
하지만 이것만으로는 글 첫머리의 사례를 설명할 수 없다. 그 예시에서는 가 바뀌지 않으므로 분자는 그대로이고(), 분모는 줄어든다(적어도 기호적으로는). 그런데 결과가 어째서 줄어드는 것일까? float라 해도 이상한 일이다!
더 이상한 점은, PHP나 Lua에서 실제로 와 를 계산해 보면 둘 다 같은 값으로 반올림된다는 사실이다! 그러니까 분자도 안 바뀌고 분모도 안 바뀌었는데 결과만 바뀐 셈이다??
아마 이제 이유가 보일 것이다. 은 꽤 특수한 반례이고, 이중 log 정밀도 오차에는 log10 모양의 수상한 빈틈이 있다.
PHP와 Lua는 항상 공식을 쓰는 것이 아니다. libm 이 직접 구현하는 밑, 즉 밑 과 밑 에 대해서는 자연로그를 거치지 않고 해당 함수(log10 과 log2)를 직접 호출한다. 따라서 문제의 코드는 두 개의 계산을 비교하는 것이 아니라, log(x) / log(10 + eps) 와 log10(x) 를 비교하는 셈이다. 이 둘은 완전히 다른 방법이므로, 오차가 서로 다른 방향으로 나타날 수 있다는 사실은 놀랄 일이 아니다.
의도 자체는 좋다. 가능할 때 log10 은 더 정확하고 더 빠른 결과를 제공한다. 하지만 두 가지 평가 방법을 섞어 쓰면 경계에서 불연속성이 생기고, 각 방법이 따로따로는 만족하는 그럴듯한 가정들이 깨져 버린다!
나는 이것을 꼭 버그라고 부르지는 않겠지만, 분명 간과된 문제이긴 하다. 안타깝게도 이런 일은 부동소수점 세계에서 꽤 흔하다. IEEE-754 자체는 믿기 어려울 정도로 견고하지만, 여기저기서 생각 없이 내려진 구현 결정들이 그것을 오염시켜서, 사람들은 거의 모든 FP 버그를 내재적 부정확성 탓으로 돌리게 된다.
Lua는 math.log 만 제공하고 math.log10 은 제공하지 않으므로, 올바른 선택은 math.log10 을 추가하고 math.log 에서 특수 처리를 제거하는 것일 것이다. 이렇게 하면 밑 을 고정해서 쓰는 사람은 더 빠르고 단순한 math.log10 방식을 사용할 수 있고, 정밀도 경계도 더 나아질 수 있다. 반면 밑을 변수로 쓰는 사람은 신경 써야 할 예외 경계가 사라지는 이점을 얻는다. 그런데 잠깐, Lua 5.2에서는 거의 정확히 그 반대를 했다! 사람들이 정확성을 중시하는 모습은 참 보기 좋다.
반면 PHP는 이미 log10 이 있는데도 밑 과 를 특수 처리한다. 아마 매뉴얼을 읽지 않는 사람들을 위한 것이겠지만, 그게 그들의 주 사용자층이라고 해도 이상하진 않다. 다만 누가 감히 PHP 함수 이름을 추측하려 들 만큼 제정신이 아닐 수 있을지는 모르겠다. 이쯤 되니 보내는 신호가 너무 혼란스러워서, 두통이 오기 전에 이 글을 끝내는 편이 좋겠다.
흥미롭게도 PHP와 C#은 log(x, 1) 도 특수 처리해서, 와 상관없이 NaN 을 반환한다. 반면 Lua와 Python은 보통 를 반환한다. 참으로 다양한 생태계다.
아, 그리고 한 가지 더. log 에 대한 PHP의 시그니처를 보면 기본 base 가 M_E(즉 의 근사값)라고 되어 있는데, 문서에서는 밑을 주지 않은 log 가 자연로그를 반환한다고 말한다. 그렇다면 log(x) 는 를 계산하는 걸까, 아니면 를 계산하는 걸까? (이 질문은 보기보다 중요하다. 예를 들어 sin(M_PI) 가 0이 아닌 값을 정상적으로 반환하는 이유는 M_PI 가 정확히 가 아니기 때문이다.) 적어도 Lua는 이 점을 슬쩍 얼버무리는 정도의 품위는 있다. 스포일러를 하자면 정답은 후자다. 하지만 사실 log(x, M_E) 도 같은 결과를 낸다. 에서의 반올림 오차가 워낙 커서 둘이 일치해 버리기 때문이다.