본문으로 건너뛰기
AZ Tools

프로그램을 거치면 숫자가 바뀌는 이유

숫자는 시스템 사이를 옮기기에 가장 안전해 보이지만, 실제로는 가장 자주 미묘하게 틀린 채 도착하는 값이다. 이유는 흔히 쓰이는 숫자 타입이 배정밀도 실수, 즉 53비트 정밀도의 2진 분수이기 때문이다. 그런데 사람들이 신경 쓰는 값은 10진 소수와 긴 식별자이고, 그 형식은 둘 다 정확히 담지 못한다. 게다가 화면 출력이 오차를 반올림해 지워버리기 때문에 실패는 조용히 일어난다.

10진 소수는 2진법에 존재하지 않는다

3분의 1은 10진법으로 정확히 적을 수 없다. 0.3333이라고 쓰다가 어딘가에서 멈출 뿐이다. 2진법에서는 10분의 1에 같은 일이 일어난다. 0.1에 가장 가까운 저장값은 0.1000000000000000055511151231257827021181583404541015625이고 0.2도 마찬가지로 조금 어긋나 있어서, 둘을 더하면 0.30000000000000004로 출력되는 값에 도달한다.

이건 특정 언어의 버그가 아니다. 같은 64비트 형식을 쓰는 모든 환경이 같은 답을 낸다. 그 답이 형식의 성질이기 때문이다. 2진법에서 정확한 값은 정수와 분모가 2의 거듭제곱인 분수뿐이라, 2분의 1과 4분의 1은 완벽하게 동작하고 10분의 1은 영원히 그렇지 못하다.

9천조를 넘으면 정수가 더 이상 연속하지 않는다

배정밀도 실수는 2의 53승, 즉 9007199254740992까지 정수를 정확히 저장한다. 그보다 하나 큰 수는 아예 표현되지 않는다. 9007199254740993을 넣으면 9007199254740992가 조용히 돌아온다. 이 지점을 넘어서면 표현 가능한 정수의 간격이 2가 되고, 그다음엔 4가 되는 식이다.

그 경계는 흔한 데이터 타입 한가운데에 놓여 있다. 64비트 식별자 — 데이터베이스 키, 대형 플랫폼의 메시지·게시물 ID, 스노플레이크 방식의 ID — 는 일상적으로 그 값을 넘기므로, 배정밀도 실수를 거친 ID는 이웃한 숫자가 되어 나온다. 여전히 ID처럼 생겼지만 아무것도 가리키지 않는 값이다.

보통은 JSON에서 일어난다

JSON 자체는 숫자의 크기를 제한하지 않는다. 형식은 텍스트이고 자릿수는 전부 거기 적혀 있다. 자릿수를 잃게 만드는 쪽은 파서다. 대부분의 파서가 읽는 즉시 모든 숫자를 배정밀도 실수로 바꾸기 때문이다. 그래서 손상은 받는 쪽에서 일어나고, 보낸 쪽 로그는 멀쩡해 보인다.

현실적인 규칙은 큰 식별자를 문자열로 실어 보내는 것이다. ID는 수량이 아니다. 두 개를 더할 일이 없으므로 따옴표를 씌워도 잃는 게 없고, 임의의 파서를 왕복해도 살아남는 방법은 그것뿐이다. 페이로드를 바꿀 수 없다면, 최소한 받은 ID를 파싱된 숫자가 아니라 원문 텍스트와 비교하라.

돈이 물리는 또 하나의 자리

가격은 10진 소수이므로 위의 모든 문제를 그대로 물려받는다. 10분의 1은 정확하지 않고, 10분의 1의 세 배는 10분의 3이 아니며, 긴 합계는 1원의 몇 분의 몇씩 밀리다가 결국 눈에 보이는 차이로 반올림된다. 돈은 가장 작은 단위의 정수로 두거나, 그 용도로 만들어진 10진 타입에 담아라.

반올림은 별개의 결정인데, 사람들은 그것이 만국 공통이라고 여긴다. 실제로는 아니다. 0.5를 올리는 방식과 0.5를 가까운 짝수로 보내는 방식 둘 다 회계에서 오랜 역사를 갖고 있고, 언어와 스프레드시트마다 기본값이 다르다. 모든 입력이 일치하는 두 시스템이 합계에서 어긋나는 이유가 이것이다.

화면 출력이 손상을 가린다

배정밀도 실수를 출력할 때는 보통 같은 값으로 되읽히는 가장 짧은 표기를 보여준다. 그래서 조금 틀린 숫자도 대개는 기대했던 깔끔한 값으로 찍힌다. 두 숫자가 똑같이 표시되면서도 같은지 비교하면 실패할 수 있고, 이 부류의 버그가 초자연적으로 느껴지는 상황이 정확히 이것이다.

그러니 같은 층위끼리 비교하라. 실제 저장값을 보려면 유효숫자 17자리로 출력하거나 10진수로 변환하고, 계산된 두 값을 비교할 때는 정확히 같은지를 묻는 대신 값의 크기에 맞춘 허용 오차를 두어라.

충분한 자릿수를 적지 않으면 왕복에서 잃는다

배정밀도 실수를 텍스트로 바꿨다가 되돌리는 일이 정확하려면 유효숫자 17자리가 필요하다. 32비트 실수라면 9자리다. 읽기 좋으라고 소수점 여섯 자리로 형식을 맞추는 것 — 로그 한 줄, CSV 내보내기, 설정 파일 — 은 값의 일부를 버린 것이고, 그것을 다시 읽으면 시작할 때와 다른 숫자가 된다.

정밀도를 일부러 낮출 때도 마찬가지다. 32비트 실수는 유효숫자 일곱 자리쯤을 담으므로, 배정밀도 값을 거기에 넣었다 빼면 0.1은 0.100000001490116119384765625가 된다. 게임 속 좌표라면 괜찮고, 앞으로 백만 번 더할 측정값이라면 괜찮지 않다.

숫자가 이상할 때 확인할 것

놀랄 때까지 기다리지 말고 경계를 일부러 시험하라. 0.1 더하기 0.2를 그 경로로 통과시켜 여분의 자릿수가 나타나는지 보고, 9007199254740993을 보내 그대로 돌아오는지 보고, 유효숫자 17자리가 넘는 값을 보내 무엇이 살아남는지 보라.

어긋난 것을 발견하면 어느 단계가 정보를 잃었는지부터 가려라. 출력인지, 파서인지, 실수 변환인지, 연산 자체인지. 각각 해법이 다르다. 자릿수를 늘리거나, ID에 따옴표를 씌우거나, 더 넓은 타입을 쓰거나, 정수로 옮기는 것이다. 엉뚱한 해법을 적용하면 문제는 그대로 남은 채 더 보기 어려워지기만 한다.

관련 도구