문제
우리 회사는 PostgreSQL에 실수 값이 `real(float4)` 타입으로 저장되어 있으며, 이 데이터를 JavaScript에서 `Math.round()`를 사용하여 반올림하여 통계를 제공하고 있었다.
새로운 통계 방식을 위해 Superset을 도입했고, 이 과정에서 SQL의 `ROUND()` 함수를 사용하여 반올림했다.
하지만 통계 수치나 비교 결과가 불일치하는 문제가 발생했다.
원인
부동소수점의 한계
앞서 얘기했듯, PostgreSQL에는 실수가 `real` 타입으로 저장되고 있었다.
`real`과 `double precision` 같은 부동소수점 타입은 이진수로 표현할 수 있는 숫자만 정확히 저장할 수 있어서, 대부분의 10진 실수는 근사값으로 저장된다.
SELECT 0.1::real; -- 출력: 0.1 (처럼 보임)
SELECT 0.1::real::numeric(20,18);
-- numeric을 사용하여 정확한 수치 출력: 0.100000001490116119
SELECT 0.1::real = 0.1::numeric; -- false
이러한 차이로 인해 `real` 타입으로 저장된 실수에 `ROUND` 함수를 적용하면 오차가 발생할 수 있다.
서로 다른 반올림 방식
JavaScript에서 사용하는 `Math.round()` 함수는 사사오입 방식을 채택하고 있고, PostgreSQL은 Banker’s rounding 방식을 채택하고 있다.
- 사사오입 방식 - 0.5 이상이면 무조건 올림
- Banker’s rounding 방식 - .5일 때는 가장 가까운 짝수로 반올림
예)
| 값 | Math.round() | ROUND() |
| 1.5 | 2 | 2 |
| 2.5 | 3 | 2 |
| 3.5 | 4 | 4 |
| 4.5 | 5 | 4 |
PostgreSQL이 Banker’s rounding 방식을 채택한 이유는 한쪽으로 쏠리는 누적 오차를 줄이기 위함이라고 한다.
해결
먼저 부동소수점의 한계를 해결하기 위해 데이터를 `text`로 변환한 후에 `numeric`으로 변환했다.
그 후에 SQL의 `ROUND` 함수를 사용하지 않고, JavaScript에서 사용하는 사사오입 방식을 그대로 구현했다.
FLOOR(실수::text::numeric * 1000 + 0.5) / 1000