Oracle 쿼리를 다른 DB에 그대로 옮기면 틀리는 이유
1. 도입
기업 환경에서 라이선스 비용이나 클라우드 도입을 위해 불가피하게 오라클 데이터베이스에서 다른 데이터베이스로 전환해야 하는 일을 겪게 됩니다. 오라클은 좋은 데이터베이스이며 오랫동안 잘 사용해왔다면 오라클과 다른 데이터베이스의 차이점과 특징을 간과하여 슬픈 현실들을 마주하게 됩니다.
여기 수년간 잘 돌아가던 대출 이자 산출 쿼리가 있습니다. 문법만 바꿔서 여러 데이터베이스에서 돌려봤습니다. 그런데 오라클과 1원의 차이가 발생합니다. 같은 쿼리인데 말이죠. 특히나 금융 환경 같은 경우는 1원의 오차를 그냥 넘어갈 수 없습니다. 허용 오차는 0입니다.
이 포스트에서 그 1원의 오차가 왜 생기는지 정리해보았습니다. 원인은 생각보다 깊은 곳에 있었고, 해결책은 두 가지였습니다. 후순위 나눗셈과 명시적 형변환이었습니다. BigQuery, Spanner, PostgreSQL, MySQL, Presto, Trino 6종의 데이터베이스를 테스트해보았습니다.
2. 테스트 환경
- 원천 환경: Oracle Database 21c (대출 이자 산출 쿼리, 100,000건 데이터)
- 테스트 대상: Google BigQuery, Google Spanner, PostgreSQL, MySQL, Presto, Trino 총 6종
- 테스트 목표: Oracle 결과와 동일하게 1원도 오차가 발생하지 않도록
종합 테스트 결과 요약
같은 쿼리를 각 플랫폼에서 그대로 실행했을 때와, 후순위 나눗셈 적용 후 결과를 나란히 정리했습니다.
| 데이터베이스 | 수치 타입 | 중간 연산 정밀도 처리 | 번역 쿼리 일치율 (10만 건) | 후순위 나눗셈 적용 후 |
|---|---|---|---|---|
| Oracle | NUMBER |
가변 길이 Base-100 인코딩, 단계별 고정 스케일 정규화 없음 | 100% (0건 차이) | 100% |
| BigQuery | BIGNUMERIC |
scale=38 고정 소수점, 연산 단계별 정규화 | 99.9990% (1건 차이) | 100% |
| Spanner | NUMERIC |
scale=9 고정 소수점, ROUND_HALF_UP | 99.9840% (16건 차이) | 100% |
| PostgreSQL | NUMERIC |
임의 정밀도(Arbitrary Precision) | 100% (0건 차이) | 100% |
| MySQL | DECIMAL |
div_precision_increment 한계 |
99.9980% (2건 차이) | 100% |
| Trino | DECIMAL |
38자리 고정, 초과 시 스케일 축소 | 81.7680% (18,232건 차이) | 100% |
| Presto | DECIMAL |
max(s1,s2) 엄격 적용, 조기 스케일 축소 |
4.5840% (95,416건 차이) | 100% |
테스트 절차 요약
graph TD
A[Oracle 데이터 추출] --> B[대상 DB 스키마 정의 및 데이터 로드]
B --> C[1차 번역 쿼리 실행]
C --> D[정합성 테스트 및 오차 발생]
D --> E[DB별 소수점 연산 방식 분석]
E --> F[대수적 통분 및 후순위 나눗셈 수식 설계]
F --> G[최적화 쿼리 실행]
G --> H[6종 DB 전수 테스트 및 결과 기록]
3. 대출 이자 수식 및 스키마 이행
3.1 스키마 매핑 분석
소수점 정밀도 손실을 막기 위해 Oracle NUMBER 타입에 BigQuery BIGNUMERIC 타입을 매핑했습니다.
| 컬럼명 | Oracle 타입 | BigQuery 타입 | 설명 |
|---|---|---|---|
기준년월 |
VARCHAR2(6) |
STRING |
정산 기준년월 |
고객관리번호 |
NUMBER(15) |
BIGNUMERIC(15) |
고객 식별 번호 |
대출관리번호 |
VARCHAR2(20) |
STRING |
대출 건 식별 번호 |
대출잔액 |
NUMBER(15) |
BIGNUMERIC(15) |
이자 계산 기준 잔액 |
약정금리 |
NUMBER(5,2) |
BIGNUMERIC(5,2) |
연 이자율 (%) |
대출실행일자 |
DATE |
DATE |
이자 기산 시작일 |
이자계산기준일 |
DATE |
DATE |
이자 정산 기준일 |
BIGNUMERIC(precision, scale): 괄호 안 첫 번째 숫자는 전체 유효 자릿수, 두 번째는 소수점 이하 자릿수입니다.BIGNUMERIC(5,2)는 최대999.99까지 표현할 수 있으며, 이자율8.50,14.60같은 값을 저장합니다.
BigQuery 스키마 정의 (schema.json):
[
{"name": "기준년월", "type": "STRING", "mode": "REQUIRED"},
{"name": "고객관리번호", "type": "BIGNUMERIC", "mode": "REQUIRED"},
{"name": "대출관리번호", "type": "STRING", "mode": "REQUIRED"},
{"name": "대출잔액", "type": "BIGNUMERIC", "mode": "REQUIRED"},
{"name": "약정금리", "type": "BIGNUMERIC", "mode": "REQUIRED"},
{"name": "대출실행일자", "type": "DATE", "mode": "REQUIRED"},
{"name": "이자계산기준일", "type": "DATE", "mode": "REQUIRED"}
]
데이터 로드 스크립트 (prepare_and_load.py):
import csv
import json
import subprocess
def prepare_csv():
with open("oracle_data.csv", "r", encoding="utf-8") as fin:
reader = csv.reader(fin)
rows = list(reader)
bq_rows = [rows[0][:7]]
for r in rows[1:]:
if len(r) >= 7:
bq_rows.append(r[:7])
with open("bq_load_data.csv", "w", newline="", encoding="utf-8") as fout:
writer = csv.writer(fout)
writer.writerows(bq_rows)
print(f"Prepared bq_load_data.csv with {len(bq_rows) - 1} rows.")
def create_and_load_bq():
schema = [
{"name": "기준년월", "type": "STRING", "mode": "REQUIRED"},
{"name": "고객관리번호", "type": "BIGNUMERIC", "mode": "REQUIRED"},
{"name": "대출관리번호", "type": "STRING", "mode": "REQUIRED"},
{"name": "대출잔액", "type": "BIGNUMERIC", "mode": "REQUIRED"},
{"name": "약정금리", "type": "BIGNUMERIC", "mode": "REQUIRED"},
{"name": "대출실행일자", "type": "DATE", "mode": "REQUIRED"},
{"name": "이자계산기준일", "type": "DATE", "mode": "REQUIRED"},
]
with open("schema.json", "w", encoding="utf-8") as sf:
json.dump(schema, sf, indent=2, ensure_ascii=False)
subprocess.run(["bq", "rm", "-f", "-t", "DM.월별대출이자산출"], check=False)
cmd = [
"bq", "load", "--source_format=CSV", "--skip_leading_rows=1",
"DM.월별대출이자산출", "bq_load_data.csv", "schema.json"
]
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode == 0:
print("Successfully loaded data into BigQuery table DM.월별대출이자산출!")
else:
print("Failed:", res.stderr)
if __name__ == "__main__":
prepare_csv()
create_and_load_bq()
3.2 대출 이자 수식 정의
보통 이자는 대출 실행일 다음 날부터 계산합니다.
정산 주기 내 최소 이자를 보장하는 기산가산일수(30일) 조건이 수식에 붙어 있습니다.
TRUNC(값, 소수점_자릿수): 두 번째 인자가 소수점 이하 몇 자리까지 남길지를 결정합니다.TRUNC(76551.789, 0)은 소수점 아래를 모두 버려76551이 됩니다. 반올림이 아니라 버림이라,76551.999도76551입니다.
Oracle 원천 SQL:
SELECT N10.약정금리
, (N10.약정금리 / 100) AS 연산1
, N10.대출잔액
, (N10.대출잔액 * (N10.약정금리 / 100)) AS 연산2
, (N10.이자계산기준일 - N10.대출실행일자) AS 연산3
, ((N10.이자계산기준일 - N10.대출실행일자) / 365) AS 연산4
, (((N10.이자계산기준일 - N10.대출실행일자) / 365) + 30 / 365) AS 연산5
, (N10.대출잔액 * (N10.약정금리 / 100))
* (((N10.이자계산기준일 - N10.대출실행일자) / 365) + 30 / 365) AS 연산6
, TRUNC(
(N10.대출잔액 * (N10.약정금리 / 100))
* (((N10.이자계산기준일 - N10.대출실행일자) / 365) + 30 / 365)
, 0) AS 이자금액
FROM 월별대출이자산출 N10
4. 문제 발견: 그대로 번역한 쿼리에서 1원 오차 발생
Oracle 원천 SQL을 각 데이터베이스 문법에 맞게 번역하여 실행했습니다. 여기서는 BigQuery를 예시로 설명합니다.
번역 쿼리:
-- naive: 나눗셈을 수식 중간에 배치 (오차 발생 구간)
SELECT 대출관리번호
, TRUNC(
(CAST(대출잔액 AS BIGNUMERIC) * (CAST(약정금리 AS BIGNUMERIC) / CAST(100 AS BIGNUMERIC)))
* ((CAST(DATE_DIFF(이자계산기준일, 대출실행일자, DAY) AS BIGNUMERIC) / CAST(365 AS BIGNUMERIC))
+ CAST(30 AS BIGNUMERIC) / CAST(365 AS BIGNUMERIC))
, 0) AS 이자금액
FROM `DM.월별대출이자산출`
ORDER BY 대출관리번호
4.1 DB별 오차 발생 사례
10만 건 테스트에서 각 데이터베이스별로 확인된 번역 쿼리 오차입니다. PostgreSQL을 제외한 모든 데이터베이스에서 오차가 발생했습니다. 해결 방법과 최적화 방안은 6장과 7장에 정리하였습니다.
| 데이터베이스 | 조건 | Oracle 기준값 | 번역 쿼리 결과 | 오차 |
|---|---|---|---|---|
| BigQuery | 잔액 2,184,500원 / 금리 8.5% / 52일 | 76,551원 | 76,550원 | -1원 |
| Spanner | 잔액 2,193,667원 / 금리 17.1% / 67일 | 73,217원 | 73,218원 | +1원 |
| PostgreSQL | 10만 건 전건 | — | 오차 없음 | 0원 |
| MySQL | 잔액 3,550,720원 / 금리 7.5% / 55일 | 139,992원 | 139,991원 | -1원 |
| Trino | 10만 건 중 18,232건 오차 (81.77%) | — | 대규모 오차 | — |
| Presto | 10만 건 중 95,416건 오차 (95.42%) | — | 대규모 오차 | — |
Spanner는 ROUND_HALF_UP 정책 특성상 +1원 상향 오차도 발생합니다. Presto와 Trino는 나눗셈 스케일 규칙이 엄격해 거의 대부분의 케이스에서 오차가 발생합니다.
4.2 오차가 집중되는 케이스만 골라 테스트
BigQuery BIGNUMERIC의 scale=38 반올림을 Python으로 에뮬레이션하여, 참값이 정수 경계에 정확히 걸리는 케이스만 추출해 5,003건을 테스트한 결과:
- 번역 쿼리 오차율: 47.43% (5,003건 중 2,373건에서 1원 하향 오차)
- 최적화 쿼리 오차율: 0.00% (0건 오차, 전건 정합)
중간 나눗셈의 반올림 오차가 누적되어 최종값이 정수 경계 아래로 내려갈 때 TRUNC가 1원 하향 오차를 만들어냅니다.
5. 원인: 데이터베이스별 수치 연산 아키텍처 차이
5.1 플랫폼별 정밀도 처리 방식 비교
대출 이자 수식의 핵심은 30 / 365 나눗셈입니다. 이 값은 무한 순환소수입니다:
각 데이터베이스는 이 무한소수를 처리하는 방식이 다릅니다.
Oracle NUMBER: 가변 길이 십진 연산
Oracle NUMBER 타입은 1바이트 지수부와 최대 20바이트 가수부로 구성된 가변 길이 Base-100 인코딩을 사용하며, 최대 38자리 십진 정밀도를 지원합니다. OCI 헤더에 정의된 OCINumber 구조체는 22바이트입니다.
/* Oracle OCI Number Type (oci.h) */
struct OCINumber {
ub1 OCINumberData[22];
};
Oracle은 연산 단계마다 결과를 고정 스케일로 강제 정규화하지 않습니다. 중간 나눗셈에서 소수 자릿수가 조기에 잘리지 않기 때문에, 번역 쿼리로도 오차가 발생하지 않습니다. 10만 건 테스트에서 단 1건의 오차도 없었습니다.
BigQuery BIGNUMERIC: scale=38 단계별 정규화
BIGNUMERIC은 소수점 이하 38자리 고정 소수점입니다. 연산 단계마다 결과를 38자리로 강제 정규화합니다:
참값이 정수 경계에 걸릴 때, 중간 단계의 반올림 오차가 누적되어 최종값이 진짜 정수보다 미세하게 낮아지면 TRUNC에서 1원 하향 오차가 됩니다.
Spanner NUMERIC: scale=9, ROUND_HALF_UP
NUMERIC은 소수점 이하 9자리로 고정되며 반올림(ROUND_HALF_UP)을 적용합니다. 절사 방향이 일정하지 않아 오차가 +1원 또는 -1원 양방향으로 발생합니다.
PostgreSQL NUMERIC: 임의 정밀도 (Oracle과 동일)
연산 중 자릿수가 동적으로 확장되는 임의 정밀도(Arbitrary Precision)를 지원합니다. Oracle과 동일하게 별도 보정 없이도 오차가 발생하지 않습니다.
MySQL DECIMAL: div_precision_increment 한계
나눗셈 정밀도가 시스템 변수 div_precision_increment(기본값: 4자리 추가)에 의해 제한됩니다. 이 값은 MySQL 설정에 따라 달라질 수 있으며, 이 테스트는 기본값(4) 환경에서 수행했습니다.
Presto / Trino DECIMAL: max(s1, s2) 엄격 적용
ANSI SQL 나눗셈 스케일 규칙(max(s1, s2))을 엄격하게 적용하여 중간 나눗셈 결과의 소수 자릿수가 조기에 심각하게 축소됩니다. Presto는 번역 쿼리 기준 4.58% 일치율에 그칩니다.
플랫폼별 수치 처리 아키텍처를 정리하면 다음과 같습니다.
graph TD
Input["수치 리터럴 입력 (예: 30/365)"] --> Oracle["Oracle NUMBER"]
Input --> Binary["FLOAT / double (IEEE 754)"]
Input --> Decimal["DECIMAL / NUMERIC"]
Oracle --> OraclePhys["가변 길이 100진법 인코딩 (최대 22바이트)"]
Binary --> BinaryPhys["고정 길이 2진법 인코딩 (8바이트)"]
Decimal --> DecimalPhys["정수/소수부 분리 고정 인코딩 (선언 자릿수)"]
OraclePhys --> Software["연산 중 고정 스케일 강제 정규화 없음"]
BinaryPhys --> FPU["CPU FPU 하드웨어 병렬 연산"]
DecimalPhys --> Premature["사칙연산 단계별 강제 스케일 정제"]
Software --> OracleResult["중간 자릿수 유실 없는 정합성 보존"]
FPU --> BinaryResult["2진 무한소수화 — 가수부 누적 오차"]
Premature --> DecimalResult["나눗셈 중간 연산 시 소수 하위 잘림"]
5.2 Goldberg 이론이 설명하는 것, 설명 못하는 것
지금까지 각 데이터베이스가 소수를 서로 다른 방식으로 처리한다는 것을 봤습니다. 이 오차를 짚어볼 때 컴퓨터 과학에서 가장 많이 인용되는 논문 하나를 빼놓을 수 없습니다.
Goldberg, D. (1991). What Every Computer Scientist Should Know About Floating-Point Arithmetic. ACM Computing Surveys, 23(1), 5–48.
DOI: 10.1145/103162.103163
전문 보기
Goldberg의 1991년 논문은 IEEE 754 이진 부동소수점 연산의 수학적 토대를 제시합니다. 반올림 오차(Rounding Error), 가드 디지트(Guard Digits), 유효자리 소실(Catastrophic Cancellation)을 단일 코어 직렬 FPU 환경에서 정밀하게 증명했습니다.
FLOAT64 타입으로 연산할 때 발생하는 오차는 이 이론으로 직접 설명됩니다. 0.5나 14.6 같은 십진 소수는 이진수로 정확하게 표현할 수 없어 미세한 근사 오차가 생기고, 연산이 누적될수록 커집니다. 5.3절에서 다루는 UNION ALL 정밀도 손실가 대표적입니다 — 고정소수점 컬럼이 FLOAT64로 강제 변환되는 순간, 바로 이 이진 근사 오차가 개입합니다.
그런데 우리가 마주친 주요 오차는 FLOAT64가 아니라 BIGNUMERIC이나 NUMERIC 같은 고정소수점 타입에서 발생했습니다. 이 타입들은 이진 부동소수점을 쓰지 않으므로 Goldberg 이론의 적용 범위 밖입니다. 우리가 테스트한 환경은 Goldberg가 상정한 환경과 구조적으로 다릅니다:
- 고정소수점 라이브러리: BigQuery
BIGNUMERIC, SpannerNUMERIC등은 이진 부동소수점 대신 소프트웨어 에뮬레이션 십진 고정소수점 라이브러리를 사용합니다. - 조기 정규화(Premature Normalization): 분산 환경의 데이터 일관성을 위해 이항 연산 단계마다 결과를 고정 스케일(BigQuery scale=38, Spanner scale=9)로 강제 조정합니다.
- MPP 비결정론적 셔플: 부동소수점 덧셈은 교환법칙이 성립하나 $(A + B) + C \neq A + (B + C)$ 결합법칙이 성립하지 않습니다. 분산 집계 순서가 매 실행마다 달라지면 결과도 달라집니다.
- CBO 대수적 변환: 비용 기반 옵티마이저가 $A \times B + A \times C$를 $A \times (B + C)$로 내부 치환할 때, 유한 정밀도 환경에서는 수치 경로가 달라집니다.
Goldberg 이론은 FLOAT64 오차를 설명하는 데 유효합니다. 다만 고정소수점 타입의 정규화 오차는 현대 분산 데이터베이스 환경에 맞는 별도의 관점이 필요합니다.
5.3 UNION ALL에서 정밀도가 희생되는 이유
고정소수점과 부동소수점을 UNION ALL로 합치면 부동소수점이 공통 상위 타입이 됩니다. BigQuery를 예시로 들면, BIGNUMERIC과 FLOAT64를 병합할 때 표현 범위가 넓은 FLOAT64를 슈퍼타입으로 결정합니다.
SQL 표준은 UNION ALL 결과 타입을 “공통 상위 타입(Supertype)”으로 결정하도록 규정합니다. 대부분의 데이터베이스는 표현 정밀도보다 표현 범위를 우선하기 때문에, 고정소수점과 부동소수점이 섞이면 부동소수점이 슈퍼타입이 됩니다. Oracle의 FLOAT 타입이 유일한 예외입니다 — FLOAT은 NUMBER의 서브타입이라 정밀도 손실이 발생하지 않습니다. 단, Oracle에도 진짜 IEEE 754 타입인 BINARY_DOUBLE이 있으며, 이 경우 다른 DB와 동일하게 정밀도가 손실됩니다.
Oracle이 이런 동작을 보이는 근본 이유 중 하나는 숫자 리터럴의 기본 타입입니다. Oracle은 정수든 소수든 모든 숫자 리터럴을 NUMBER로 처리합니다. 반면 다른 DB들은 소수 리터럴을 이진 부동소수점으로 파싱합니다.
| DB | 정수 리터럴 1 |
소수 리터럴 1.0 |
|---|---|---|
| Oracle | NUMBER |
NUMBER |
| PostgreSQL | INTEGER |
NUMERIC |
| BigQuery | INT64 |
FLOAT64 |
| MySQL | BIGINT |
DOUBLE |
| Presto / Trino | INTEGER |
DOUBLE |
Oracle에서는 어떤 숫자 리터럴을 써도 NUMBER 계열이라 UNION ALL에서 정밀도가 유지됩니다. BigQuery에서 1.0을 명시적 캐스팅 없이 쓰면 FLOAT64가 개입해 정밀도가 손실되는 것과 대조됩니다.
정밀도(Precision)와 범위(Range): 두 개념은 다릅니다.
- 정밀도: 소수점 몇 자리까지 정확하게 표현하는가. 고정소수점(
BIGNUMERIC,DECIMAL,NUMERIC)이 훨씬 높습니다.- 범위: 얼마나 큰 숫자까지 표현할 수 있는가. 부동소수점(
FLOAT64,DOUBLE,DOUBLE PRECISION)이 훨씬 넓습니다.
데이터베이스 고정소수점 타입 / 최대 범위 비교 타입 (부동소수점) 비교 타입 최대 범위 Oracle NUMBER / ≈ 10¹²⁶ FLOAT (NUMBER 서브타입, 십진수 저장) ≈ 10¹²⁶ → 정밀도 유지 Oracle NUMBER / ≈ 10¹²⁶ BINARY_DOUBLE (IEEE 754, 이진수 저장) ≈ 10³⁰⁸ → 정밀도 손실 BigQuery BIGNUMERIC / ≈ 10³⁸ FLOAT64 (IEEE 754, 이진수 저장) ≈ 10³⁰⁸ → 정밀도 손실 PostgreSQL NUMERIC(38,22) / ≈ 10¹⁶ DOUBLE PRECISION (IEEE 754, 이진수 저장) ≈ 10³⁰⁸ → 정밀도 손실 MySQL DECIMAL(38,22) / ≈ 10¹⁶ DOUBLE (IEEE 754, 이진수 저장) ≈ 10³⁰⁸ → 정밀도 손실 Presto / Trino DECIMAL(38,22) / ≈ 10¹⁶ DOUBLE (IEEE 754, 이진수 저장) ≈ 10³⁰⁸ → 정밀도 손실 Oracle의
FLOAT는 다른 DB의DOUBLE/FLOAT64와 다릅니다. Oracle 공식 문서는 이를 명확히 정의합니다:“The
FLOATdata type is a subtype of theNUMBERdata type.”
“AFLOATvalue is represented internally asNUMBER.”
— Oracle Database SQL Language Reference즉, Oracle
FLOAT는 IEEE 754 이진 부동소수점이 아니라 NUMBER의 서브타입으로, 내부적으로 NUMBER와 동일한 십진수 방식으로 저장됩니다. 범위도 NUMBER와 같아 사실상 같은 계열이기 때문에 UNION ALL 시 NUMBER로 유지됩니다.Oracle에서 진짜 IEEE 754 이진 부동소수점이 필요하다면
BINARY_FLOAT(32비트)나BINARY_DOUBLE(64비트)을 사용합니다. 이 타입들은 다른 DB의 DOUBLE과 동일한 이진 저장 방식입니다.
| 데이터베이스 | 고정소수점 타입 | 부동소수점 타입 | UNION ALL 결과 | 결정 기준 |
|---|---|---|---|---|
| Oracle | NUMBER |
FLOAT (NUMBER 서브타입) |
NUMBER |
FLOAT가 NUMBER와 같은 계열 → 정밀도 유지 |
| Oracle | NUMBER |
BINARY_DOUBLE (IEEE 754) |
BINARY_DOUBLE |
표현 범위 우선 → 정밀도 손실 |
| BigQuery | BIGNUMERIC |
FLOAT64 |
FLOAT64 |
표현 범위 우선 → 정밀도 손실 |
| PostgreSQL | NUMERIC |
DOUBLE PRECISION |
DOUBLE PRECISION |
표현 범위 우선 → 정밀도 손실 |
| MySQL | DECIMAL |
DOUBLE |
DOUBLE |
표현 범위 우선 → 정밀도 손실 |
| Presto | DECIMAL |
DOUBLE |
DOUBLE |
표현 범위 우선 → 정밀도 손실 |
| Trino | DECIMAL |
DOUBLE |
DOUBLE |
표현 범위 우선 → 정밀도 손실 |
이 슈퍼타입 결정 규칙은 시스템 설정으로 변경할 수 없습니다. SQL 컴파일러에 하드코딩된 동작이기 때문에, 유일한 방어 수단은 명시적 캐스팅뿐입니다.
테스트 데이터: 123456789012345.6789012345678901234567 (소수부 22자리)와 1.0을 UNION ALL로 병합.
| 데이터베이스 | 판별 함수 | 최종 타입 | 출력값 | 정합성 |
|---|---|---|---|---|
| Oracle 21c | 내장 타입 검사 | NUMBER(38,22) |
123456789012345.6789012345678901234567 |
100% 보존 |
| BigQuery | bq show 스키마 |
FLOAT64 |
1.2345678901234567E14 |
20자리 유실 |
| PostgreSQL | pg_typeof() |
double precision |
123456789012345.67 |
20자리 유실 |
| MySQL 8.0 | DESCRIBE 임시 테이블 |
double |
123456789012345.67 |
20자리 유실 |
| Presto | typeof() |
double |
1.2345678901234567E14 |
20자리 유실 |
| Trino | typeof() |
double |
1.2345678901234567E14 |
20자리 유실 |
이 현상이 특히 위험한 이유는 SQL 컴파일 타임에 조용히 발생한다는 점입니다. 런타임 오류가 아니기 때문에 결과값이 그냥 틀린 값으로 반환됩니다.
실무에서 이 문제가 자주 발생하는 세 가지 상황:
- UNION ALL로 브랜치 병합: 한 브랜치에 소수 리터럴(
1.0,0.5등)이 포함되면 그 브랜치 전체가FLOAT64로 추론됩니다.UNION ALL로 합쳐지는 순간 나머지 브랜치의BIGNUMERIC컬럼도FLOAT64로 강제 정밀도가 손실됩니다. - CTE 타입 추론:
WITH temp AS (SELECT 대출잔액 / 365.0 + 0.5 AS ratio ...)소수 리터럴이 섞인 CTE는 결과 타입이FLOAT64로 추론되어, 이후 이 CTE를 참조하는 모든 연산이 부동소수점 영역에서 진행됩니다. - 혼합 타입 산술:
BIGNUMERIC컬럼에 명시적CAST없이 소수 리터럴을 더하거나 곱하면 BigQuery는 컴파일 오류(No matching signature for operator)를 내거나, 조용히FLOAT64로 변환합니다.
모든 경우의 해결책은 같습니다. 수식에 등장하는 모든 수치 요소를 동일한 고정소수점 타입으로 명시적으로 캐스팅합니다.
-- BigQuery: 모든 브랜치를 BIGNUMERIC으로 통일
SELECT CAST('123456789012345.6789012345678901234567' AS BIGNUMERIC) AS val
UNION ALL
SELECT CAST(1.0 AS BIGNUMERIC) AS val;
-- 결과: 123456789012345.6789012345678901234567 (100% 보존)
DB별 실증 쿼리
각 데이터베이스에서 정밀도 손실을 직접 확인하고 해결하는 방법입니다.
BigQuery : 쿼리 실행 결과로 정밀도 손실 확인
-- 정밀도 손실: BIGNUMERIC + FLOAT64 → FLOAT64
SELECT CAST('123456789012345.6789012345678901234567' AS BIGNUMERIC) AS val
UNION ALL
SELECT CAST(1.0 AS FLOAT64) AS val;
+------------------------+
| val |
+------------------------+
| 1.2345678901234567e+14 | ← 과학적 표기법, 20자리 유실
| 1.0 |
+------------------------+
-- 해결: 모든 브랜치를 BIGNUMERIC으로 통일
SELECT CAST('123456789012345.6789012345678901234567' AS BIGNUMERIC) AS val
UNION ALL
SELECT CAST(1.0 AS BIGNUMERIC) AS val;
+----------------------------------------+
| val |
+----------------------------------------+
| 123456789012345.6789012345678901234567 | ← 100% 보존
| 1.0 |
+----------------------------------------+
PostgreSQL : pg_typeof()로 타입 확인
-- 정밀도 손실: NUMERIC + DOUBLE PRECISION → DOUBLE PRECISION
SELECT pg_typeof(val), val
FROM (
SELECT CAST('123456789012345.6789012345678901234567' AS NUMERIC(38,22)) AS val
UNION ALL
SELECT CAST(1.0 AS DOUBLE PRECISION) AS val
) q;
pg_typeof | val
------------------+--------------------
double precision | 123456789012345.67 ← 20자리 유실
double precision | 1
-- 해결: 모든 브랜치를 NUMERIC으로 통일
SELECT pg_typeof(val), val
FROM (
SELECT CAST('123456789012345.6789012345678901234567' AS NUMERIC(38,22)) AS val
UNION ALL
SELECT CAST(1.0 AS NUMERIC(38,22)) AS val
) q;
pg_typeof | val
-----------+----------------------------------------
numeric | 123456789012345.6789012345678901234567 ← 100% 보존
numeric | 1.00000000000000000000
MySQL : DESCRIBE로 임시 테이블 스키마 확인
-- 정밀도 손실: DECIMAL + DOUBLE → DOUBLE
CREATE TEMPORARY TABLE temp_union AS
SELECT CAST('123456789012345.6789012345678901234567' AS DECIMAL(38,22)) AS val
UNION ALL
SELECT CAST(1.0 AS DOUBLE) AS val;
DESCRIBE temp_union;
+-------+--------+------+
| Field | Type | Null |
+-------+--------+------+
| val | double | NO | ← DOUBLE로 변환 (정밀도 손실)
+-------+--------+------+
-- 해결: 모든 브랜치를 DECIMAL(38,22)로 통일
CREATE TEMPORARY TABLE temp_union_safe AS
SELECT CAST('123456789012345.6789012345678901234567' AS DECIMAL(38,22)) AS val
UNION ALL
SELECT CAST(1.0 AS DECIMAL(38,22)) AS val;
DESCRIBE temp_union_safe;
+-------+----------------+------+
| Field | Type | Null |
+-------+----------------+------+
| val | decimal(38,22) | NO | ← 고정소수점 유지
+-------+----------------+------+
Presto / Trino : typeof()로 결과 타입 확인
-- 정밀도 손실: DECIMAL + DOUBLE → DOUBLE
SELECT typeof(val), val
FROM (
SELECT CAST('123456789012345.6789012345678901234567' AS DECIMAL(38,22)) AS val
UNION ALL
SELECT CAST(1.0 AS DOUBLE) AS val
);
_col0 | _col1
---------+------------------------
double | 1.2345678901234567E14 ← 과학적 표기법, 20자리 유실
double | 1.0
-- 해결: 모든 브랜치를 DECIMAL(38,22)로 통일
SELECT typeof(val), val
FROM (
SELECT CAST('123456789012345.6789012345678901234567' AS DECIMAL(38,22)) AS val
UNION ALL
SELECT CAST(1.0 AS DECIMAL(38,22)) AS val
);
_col0 | _col1
----------------+----------------------------------------
decimal(38,22) | 123456789012345.6789012345678901234567 ← 100% 보존
decimal(38,22) | 1.0000000000000000000000
Oracle : BINARY_DOUBLE(IEEE 754)과 병합 시 정밀도 손실
Oracle FLOAT은 NUMBER 서브타입이라 정밀도 손실이 없지만, 진짜 IEEE 754 타입인 BINARY_DOUBLE과 병합하면 동일하게 정밀도가 손실됩니다. 아래 두 쿼리를 비교하면 차이가 명확합니다.
아래 쿼리는
TO_CHAR(val)로 감쌌습니다. sqlplus 클라이언트가 BINARY_DOUBLE 값을 바로 출력하지 못하는 경우가 있어 문자열로 변환한 것이며, 실제 업무 쿼리에는 필요 없습니다.
-- NUMBER + FLOAT(NUMBER 서브타입) → NUMBER 유지
SELECT TO_CHAR(val) FROM (
SELECT CAST('123456789012345.6789012345678901234567' AS NUMBER(38,22)) AS val FROM dual
UNION ALL
SELECT CAST(1.0 AS FLOAT) FROM dual
);
123456789012345.6789012345678901234567 ← 소수 22자리 완전 보존
1
-- NUMBER + BINARY_DOUBLE(IEEE 754) → BINARY_DOUBLE로 변환 (정밀도 손실)
-- (BINARY_DOUBLE 표시 제약으로 짧은 값 사용)
SELECT TO_CHAR(val) FROM (
SELECT CAST(12345.6789 AS NUMBER(10,4)) AS val FROM dual
UNION ALL
SELECT CAST(1.0 AS BINARY_DOUBLE) FROM dual
);
1.2345678900000001E+004 ← 과학적 표기법, 이진 근사 오차 발생
1.0E+000
1.2345678900000001E+004는 실제 값 12345.6789가 IEEE 754 이진수로 저장되면서 12345.6789000000001...로 변환된 결과입니다. 12345.6789는 이진수로 정확하게 표현할 수 없어 끝 자리에 미세한 오차가 붙습니다.
-- 해결: BINARY_DOUBLE 대신 NUMBER 계열로 통일
SELECT TO_CHAR(val) FROM (
SELECT CAST(12345.6789 AS NUMBER(10,4)) AS val FROM dual
UNION ALL
SELECT CAST(1.0 AS NUMBER(10,4)) FROM dual
);
12345.6789 ← 정확히 보존
1
Oracle이 슈퍼타입 결정에서 예외로 불리는 이유는 FLOAT이 NUMBER와 같은 계열이기 때문이며, BINARY_DOUBLE(IEEE 754)을 쓰면 Oracle도 동일하게 정밀도가 손실됩니다.
6. 해결: 두 가지 핵심 기법
문제는 두 가지입니다. 해결책도 두 가지입니다.
| 문제 | 원인 | 해결 기법 |
|---|---|---|
| 연산 중 소수점 유실 | 나눗셈 중간 단계에서 스케일 정규화 | 후순위 나눗셈 |
| 묵시적 정밀도 손실 | 혼합 타입 연산 시 고정소수점이 부동소수점으로 수렴 | 명시적 형변환(CAST) |
두 문제는 독립적으로 발생합니다. 후순위 나눗셈만 적용해도 UNION ALL에서 타입이 정밀도가 손실되면 다시 오차가 생깁니다. 두 기법을 함께 써야 완전히 방어됩니다.
6.1 후순위 나눗셈: 연산 중 소수점 유실 방지
아이디어는 단순합니다. 수식 중간의 나눗셈을 모두 없애고, 딱 한 번만 TRUNC 직전에 나누는 겁니다. 수학적으로는 기존 수식과 같지만, 중간에 소수점이 잘릴 일이 없습니다.
수학적 동치 증명
번역 쿼리 수식:
\[\text{이자}_{\text{번역}} = \text{TRUNC}\!\left(\text{잔액} \times \frac{\text{금리}}{100} \times \left(\frac{D}{365} + \frac{30}{365}\right),\ 0\right)\]대수적 통분 : 나눗셈을 마지막으로 후순위 배치:
\[= \text{TRUNC}\!\left(\text{잔액} \times \frac{\text{금리}}{100} \times \frac{D + 30}{365},\ 0\right)\] \[= \text{TRUNC}\!\left(\frac{\text{잔액} \times \text{금리} \times (D + 30)}{36{,}500},\ 0\right)\] \[\therefore\quad \text{이자}_{\text{opt}} = \text{TRUNC}\!\left(\frac{\text{잔액} \times \text{금리} \times (D + 30)}{36{,}500},\ 0\right) \quad \blacksquare\]BigQuery 적용 SQL
-- optimized: 후순위 나눗셈 — 나눗셈 1회, TRUNC 직전 최종 단계에서만
SELECT 대출관리번호
, TRUNC(
(CAST(대출잔액 AS BIGNUMERIC)
* CAST(약정금리 AS BIGNUMERIC)
* (CAST(DATE_DIFF(이자계산기준일, 대출실행일자, DAY) AS BIGNUMERIC)
+ BIGNUMERIC '30'))
/ BIGNUMERIC '36500'
, 0) AS 이자금액
FROM `DM.월별대출이자산출`
ORDER BY 대출관리번호
6.2 명시적 형변환: 타입 안전성 보장
올바른 수식 설계만으로는 충분하지 않습니다. SQL에서 리터럴 상수, 함수 반환값, UNION ALL 브랜치의 타입이 하나라도 FLOAT64나 DOUBLE이면 전체 연산 결과가 부동소수점으로 수렴합니다. 수식이 아무리 정교해도 타입이 정밀도가 손실되는 순간 오차가 다시 생깁니다.
리터럴 상수 처리
BigQuery SQL에서 30이나 36500 같은 정수 리터럴은 INT64로, 소수 리터럴 0.5는 FLOAT64로 자동 추론됩니다. BIGNUMERIC 컬럼과 다른 타입 리터럴을 섞어서 산술 연산하면 컴파일 타임에 이런 오류가 납니다:
No matching signature for operator * for argument types: BIGNUMERIC, FLOAT64
오류를 마주한 개발자가 빠지기 쉬운 함정이 있습니다. 오류 메시지를 없애려고 컬럼을 반대 방향으로 낮추는 것입니다.
-- 잘못된 해결: BIGNUMERIC을 FLOAT64로 낮춰서 오류를 없앰
CAST(대출잔액 AS FLOAT64) * 약정금리 * (경과일수 + 30.0) / 36500.0
오류는 사라지지만 결과가 더 나빠집니다. FLOAT64는 IEEE 754 이진 부동소수점이라 십진 소수를 정확하게 표현하지 못합니다. 30.0이나 36500.0처럼 단순해 보이는 값도 내부적으로 근사치로 저장됩니다. 이 상태에서 계산을 이어가면 오차가 연산마다 누적되어 최종 결과에서 원치 않는 차이가 발생합니다.
올바른 방향은 반대입니다. 리터럴을 BIGNUMERIC으로 올려서 수식 전체를 고정소수점 영역에서 유지합니다.
-- 올바른 해결: 리터럴을 BIGNUMERIC으로 올림
CAST(대출잔액 AS BIGNUMERIC) * CAST(약정금리 AS BIGNUMERIC)
* (CAST(경과일수 AS BIGNUMERIC) + BIGNUMERIC '30')
/ BIGNUMERIC '36500'
BIGNUMERIC '30' 처럼 프리픽스 리터럴 표기를 쓰면 CAST(30 AS BIGNUMERIC)과 동일하게 동작하면서 더 간결합니다.
UNION ALL 4단계 방어
Oracle에서 이식한 쿼리에 UNION ALL이 포함된 경우, 브랜치 하나의 타입 실수가 전체 결과셋을 망칩니다. 아래 4단계를 적용합니다.
Tier 1 : 모든 브랜치 수치 컬럼 명시적 형변환:
SELECT CAST(대출잔액 AS BIGNUMERIC) AS 총액 FROM 대출_브랜치A
UNION ALL
SELECT CAST(대출잔액 AS BIGNUMERIC) AS 총액 FROM 대출_브랜치B
Tier 2 : 리터럴 상수 BIGNUMERIC 기입:
SELECT BIGNUMERIC '30' AS 기산가산일수
Tier 3 : 수식 피연산자 사전 업캐스팅:
SELECT CAST(DATE_DIFF(이자계산기준일, 대출실행일자, DAY) AS BIGNUMERIC)
/ CAST(365 AS BIGNUMERIC) AS 일수비율
Tier 4 : 마이그레이션 린터: UNION ALL 구조가 있는 쿼리는 빌드 파이프라인에서 수치 컬럼 타입을 전수 테스트합니다. 배포 전에 타입 불일치를 잡아내는 게 운영 중에 오차를 발견하는 것보다 훨씬 낫습니다.
7. 6종 데이터베이스 교차 테스트 결과
7.1 테스트 자동화 스크립트
Oracle vs. BigQuery 대조 (compare_results.py):
import csv, subprocess, json, os
def run_bq_query(query):
temp_file = "temp_query.sql"
with open(temp_file, "w", encoding="utf-8") as f:
f.write(query)
cmd = f"bq query --use_legacy_sql=false --max_rows=150000 --format=json < {temp_file}"
res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
os.remove(temp_file)
if res.returncode != 0:
print("Query failed:", res.stderr)
return []
return json.loads(res.stdout)
def main():
oracle_rows = {}
with open("oracle_data.csv", "r", encoding="utf-8") as fin:
reader = csv.DictReader(fin)
for row in reader:
oracle_rows[row['대출관리번호']] = {
'oracle_fee': int(row['oracle_이자금액'])
}
query_naive = """
SELECT 대출관리번호,
TRUNC(
(CAST(대출잔액 AS BIGNUMERIC) * (CAST(약정금리 AS BIGNUMERIC) / CAST(100 AS BIGNUMERIC)))
* ((CAST(DATE_DIFF(이자계산기준일, 대출실행일자, DAY) AS BIGNUMERIC) / CAST(365 AS BIGNUMERIC))
+ CAST(30 AS BIGNUMERIC) / CAST(365 AS BIGNUMERIC))
, 0) AS 이자금액_naive
FROM `DM.월별대출이자산출`
ORDER BY 대출관리번호
"""
query_optimized = """
SELECT 대출관리번호,
TRUNC(
(CAST(대출잔액 AS BIGNUMERIC)
* CAST(약정금리 AS BIGNUMERIC)
* (CAST(DATE_DIFF(이자계산기준일, 대출실행일자, DAY) AS BIGNUMERIC) + BIGNUMERIC '30'))
/ BIGNUMERIC '36500'
, 0) AS 이자금액_optimized
FROM `DM.월별대출이자산출`
ORDER BY 대출관리번호
"""
print("Running naive query...")
res_naive = {r['대출관리번호']: int(float(r['이자금액_naive'])) for r in run_bq_query(query_naive)}
print("Running optimized query...")
res_opt = {r['대출관리번호']: int(float(r['이자금액_optimized'])) for r in run_bq_query(query_optimized)}
diff_naive = diff_opt = 0
for lid, data in oracle_rows.items():
if data['oracle_fee'] != res_naive.get(lid, -1): diff_naive += 1
if data['oracle_fee'] != res_opt.get(lid, -1): diff_opt += 1
n = len(oracle_rows)
print(f"\nTotal: {n:,}")
print(f"naive 불일치: {diff_naive:,} / {n:,} ({diff_naive/n*100:.4f}%)")
print(f"optimized 불일치: {diff_opt:,} / {n:,} ({diff_opt/n*100:.4f}%)")
if __name__ == "__main__":
main()
주의:
bq CLI기본 결과 출력 한계가 100건이므로 반드시--max_rows=150000을 명시합니다.
정수 경계 시뮬레이션 (verify.py):
import decimal, math
from decimal import Decimal
decimal.getcontext().prec = 100
def bq_div(a, b):
return (a / b).quantize(Decimal('1e-38'), rounding=decimal.ROUND_HALF_UP)
def run_naive(amount, rate, days):
s1 = bq_div(rate, Decimal('100'))
s2 = bq_div(days, Decimal('365'))
s3 = s2 + bq_div(Decimal('30'), Decimal('365'))
return int((amount * s1 * s3).quantize(Decimal('1'), rounding=decimal.ROUND_DOWN))
def run_optimized(amount, rate, days):
return int(bq_div(amount * rate * (days + Decimal('30')), Decimal('36500'))
.quantize(Decimal('1'), rounding=decimal.ROUND_DOWN))
def run_true(amount, rate, days):
return amount * (rate / Decimal('100')) * ((days + Decimal('30')) / Decimal('365'))
def main():
tested = naive_err = opt_err = 0
for d in range(1, 366):
factor = d + 30
for r_int in range(50, 240, 5):
rate_dec = Decimal(r_int) / Decimal('10')
g = math.gcd(r_int * factor, 3650000)
base_A = 3650000 // g
for k in range(1, 5):
amt = Decimal(base_A * k * 1000)
if amt > 100_000_000: continue
true_val = run_true(amt, rate_dec, Decimal(d))
if true_val % 1 != 0: continue
tested += 1
if run_naive(amt, rate_dec, Decimal(d)) != int(true_val): naive_err += 1
if run_optimized(amt, rate_dec, Decimal(d)) != int(true_val): opt_err += 1
if tested >= 5000: break
print(f"정수 경계 케이스: {tested}건")
print(f"naive 오차율: {naive_err}/{tested} ({naive_err/tested*100:.2f}%)")
print(f"optimized 오차율: {opt_err}/{tested} ({opt_err/tested*100:.2f}%)")
if __name__ == "__main__":
main()
7.2 BigQuery 10만 건 테스트 결과
- 번역 쿼리
BIGNUMERIC불일치: 1건 (일치율 99.9990%) - 최적화 쿼리 불일치: 0건 (일치율 100%)
번역 쿼리 불일치 사례:
| 대출관리번호 | 조회 정보 | Oracle 기준값 | 번역 쿼리 결과 | 최적화 쿼리 결과 |
|---|---|---|---|---|
LOAN043217 |
잔액 2,184,500원 / 금리 8.5% / 52일 | 76,551원 | 76,550원 (-1원) |
76,551원 ✅ |
7.3 Spanner 10만 건 테스트 결과
- 번역 쿼리
NUMERIC불일치: 16건 (일치율 99.9840%) - 최적화 쿼리 불일치: 0건 (일치율 100%)
Spanner는 ROUND_HALF_UP 정책으로 오차가 +1원 또는 -1원 양방향으로 발생합니다.
상향 오차 사례 (+1원):
-- Spanner naive SQL
SELECT TRUNC(
(CAST(2193667 AS NUMERIC) * (CAST(17.1 AS NUMERIC) / CAST(100 AS NUMERIC)))
* ((CAST(67 AS NUMERIC) / CAST(365 AS NUMERIC)) + CAST(30 AS NUMERIC) / CAST(365 AS NUMERIC))
)
-- 결과: 73,218원 (Oracle: 73,217원, +1원 격차)
-- Spanner optimized SQL (후순위 나눗셈)
SELECT TRUNC(
CAST(2193667 AS NUMERIC) * CAST(17.1 AS NUMERIC) * (CAST(67 AS NUMERIC) + CAST(30 AS NUMERIC))
/ CAST(36500 AS NUMERIC)
)
-- 결과: 73,217원 ✅ (정합)
7.4 MySQL / Presto / Trino 결과 요약
| DB | 번역 쿼리 불일치 | 최적화 쿼리 불일치 |
|---|---|---|
| MySQL | 2건 (99.9980%) | 0건 ✅ |
| Trino | 18,232건 (81.7680%) | 0건 ✅ |
| Presto | 95,416건 (4.5840%) | 0건 ✅ |
Presto와 Trino는 번역 쿼리에서 max(s1,s2) 나눗셈 스케일 규칙으로 인해 사실상 대부분의 케이스에서 오차가 발생합니다. 후순위 나눗셈 적용 후 전건 정합을 달성했습니다.
7.5 6종 데이터베이스 종합 결과
| 데이터베이스 | 수치 타입 | 번역 쿼리 불일치 | 최적화 쿼리 불일치 |
|---|---|---|---|
| Oracle | NUMBER |
0건 (100%) | 0건 ✅ |
| BigQuery | BIGNUMERIC |
1건 (99.9990%) | 0건 ✅ |
| Spanner | NUMERIC |
16건 (99.9840%) | 0건 ✅ |
| PostgreSQL | NUMERIC |
0건 (100%) | 0건 ✅ |
| MySQL | DECIMAL |
2건 (99.9980%) | 0건 ✅ |
| Trino | DECIMAL |
18,232건 (81.7680%) | 0건 ✅ |
| Presto | DECIMAL |
95,416건 (4.5840%) | 0건 ✅ |
후순위 나눗셈과 명시적 형변환을 적용한 최적화 쿼리는 Oracle을 포함한 7종 데이터베이스 전체에서 단 1건의 오차도 없이 100% 정합성을 달성했습니다.
8. 프로덕션 방어 수칙
Oracle에서 이식한 정산 쿼리를 분산 데이터베이스 환경에서 안전하게 운영하기 위한 필수 체크리스트입니다.
후순위 나눗셈 : 연산 중 소수점 유실 방지
- 나눗셈은 항상 마지막 단계로 배치 :
TRUNC직전 단 1회만 수행 - 중간 나눗셈이 불가피한 경우: 분자·분모를 먼저 곱셈으로 통합 후 단일 나눗셈으로 대체
명시적 형변환 : 정밀도 손실 방지
- 모든 수치 컬럼에 명시적
CAST(col AS BIGNUMERIC)선언 - 소수 리터럴은
BIGNUMERIC '30'프리픽스 또는CAST(30 AS BIGNUMERIC)형태로 기입 FLOAT64/DOUBLE타입과의 혼합 연산 금지- CTE 내 중간 결과 컬럼에도 타입 명시 (
FLOAT64로 추론되지 않도록)
UNION ALL : 슈퍼정밀도 손실 방지
- 모든 브랜치의 수치 컬럼 타입을 동일한 고정소수점 타입으로 명시적 통일 (Tier 1)
- 상수 리터럴 브랜치도 예외 없이 타입 프리픽스 적용 (Tier 2)
- 수식 피연산자 사전 업캐스팅 (Tier 3)
- 마이그레이션 린터로 빌드 타임 사전 테스트 (Tier 4)
테스트 파이프라인
bq CLI대량 쿼리 시--max_rows=150000명시 (기본값 100건 제한 우회)- 정수 경계 케이스 전용 시뮬레이션(
verify.py) 마이그레이션 전 실행 - Oracle 원천 결과와 1:1 전수 대조 (
compare_results.py) 실행
9. 결론
후순위 나눗셈과 명시적 형변환, 두 기법을 함께 적용해 6종 데이터베이스 전체에서 100% 정합성을 달성했습니다.
정리하면 이렇습니다:
수학적으로 동치인 수식이라도, 연산 순서와 타입 관리가 정밀도를 결정한다.
Oracle에서 오차가 발생하지 않았던 이유는 운이 아닙니다. Oracle NUMBER 타입이 연산 단계마다 소수 자릿수를 고정 스케일로 강제 정규화하지 않기 때문입니다.
반면 BigQuery, Spanner, MySQL, Presto, Trino는 연산 단계마다 고정 스케일 정규화를 강제 적용하므로, 중간 나눗셈이 있는 수식은 정수 경계 케이스에서 오차를 유발합니다. 여기에 UNION ALL이나 소수 리터럴이 섞이면 타입이 조용히 정밀도가 손실되어 오차가 재발합니다.
플랫폼을 바꿀 때는 수식과 타입을 함께 검토해야 합니다. / 365, / 12, / 100처럼 무한 순환소수를 만드는 나눗셈이 수식 중간에 있다면 후순위 나눗셈을 적용하고, 모든 수치 컬럼과 리터럴에 명시적 CAST를 선언해야 합니다. 어느 하나만으로는 절반만 방어한 겁니다.