부동소수점과 SQLite

부동소수점과 SQLite

SQLite 가 숫자를 어떻게 저장하는지, 부동소수점 값이 왜 근사치인지, 그리고 부동소수점을 다루기 위한 확장 기능(ieee754, decimal)을 소개하는 문서예요.

출처: Floating Point Numbers

본문

1. SQLite 가 숫자를 저장하는 방식

SQLite 는 정수 값을 64비트 2의 보수(twos-complement) 형식¹으로 저장해요. 이는 -9223372036854775808 부터 +9223372036854775807 까지(양끝 포함)의 저장 범위를 제공해요. 이 범위 안의 정수는 정확해요.

소위 "REAL" 즉 부동소수점 값은 IEEE 754 Binary-64 형식¹으로 저장돼요. 이는 약 1.7976931348623157e+308 에서 4.9406564584124654e-324 사이의 양수 값 범위를 제공하고, 음수에도 같은 범위가 있어요. binary-64 는 또한 0.0(과 -0.0), 양의 무한대와 음의 무한대, 그리고 "NaN" 또는 "Not-a-Number"가 될 수도 있어요. 부동소수점 값은 근사치예요.

이전 문장을 주의 깊게 봐주세요: 부동소수점 값은 근사치예요.

정확한 답이 필요하다면 SQLite 든 다른 어떤 제품이든 binary-64 부동소수점 값을 사용하면 안 돼요. 이것은 SQLite 의 제한이 아니에요. 부동소수점 숫자의 설계에 내재된 수학적 제한이에요.


¹ 예외: R-Tree 확장은 정보를 32비트 부동소수점 또는 정수 값으로 저장해요.

1.1. 부동소수점 정확도

SQLite 는 부동소수점 값의 유효 숫자를 가능한 한 많이 보존해요. 하지만 IEEE 754 double 은 약 15.95 개의 유효 숫자만 담아요. 게다가 SQLite 는 부동소수점 값에 대한 계산의 정확도가 이 정도까지라도 보장하지 않아요. 그런 보장은 불가능하기 때문이에요. 부동소수점 값에 수학 연산을 수행하면 오차가 생겨요. 예를 들어, 크기가 비슷한 두 부동소수점 숫자를 빼려고 할 때 어떤 일이 일어나는지 생각해볼게요.

1152693165.1106291898
-1152693165.1106280772
0.0000011126

위에 표시된 결과(0.0000011126)가 올바른 답이에요. 하지만 이 계산을 binary-64 부동소수점으로 하면 답은 0.00000095367431640625, 즉 약 14% 의 오차가 돼요. 프로그램에서 비슷한 계산을 많이 수행하면 오차가 누적되어 최종 결과가 완전히 무의미해질 수 있어요.

이 오차는 각 숫자의 약 15.95 개의 유효 숫자만 IEEE 754 binary-64 형식으로 정확히 저장되고, 빼는 두 숫자의 첫 번째 차이가 16번째 자리에서 발생하기 때문에 생겨요.

1.2. 부동소수점 숫자

binary-64 부동소수점 형식은 숫자당 64비트를 사용해요. 따라서 가능한 부동소수점 값이 1.845e+19 개 있어요. 한편 1.7977e+308 에서 4.9407e-324 범위에는 실수가 무한히 많아요. 따라서 binary-64 는 그 범위 안의 모든 가능한 실수를 절대 표현할 수 없다는 결론이 나와요. 근사가 필요해요.

IEEE 754 부동소수점 값은 정수에 2 의 거듭제곱을 곱한 것이에요: M × 2^E

여기서 M 값은 "가수(mantissa)", E 는 "지수(exponent)"예요. M 과 E 는 둘 다 정수예요.

Binary-64 에서 M 은 53비트 정수이고 E 는 11비트 정수로, -1074 에서 +972 사이(양끝 포함)의 값 범위를 나타내도록 오프셋돼 있어요.

(참고: IEEE 754 의 일반적인 설명은 더 복잡하고, IEEE 754 의 세부 사항, 장점, 한계를 진정으로 이해하려면 추가된 복잡성을 이해하는 것이 중요해요. 하지만 여기 보여드린 정수 설명은 정확히 맞지는 않지만 이해하기 더 쉬우며 이 문서의 목적에 충분해요.)

1.2.1. 표현할 수 없는 숫자

16 자리 미만의 유효 숫자를 가진 모든 십진수가 binary-64 숫자로 정확히 표현될 수 있는 것은 아니에요. 사실 소수점 오른쪽에 자릿수가 있는 대부분의 십진수는 정확한 binary-64 등가물이 없어요. 예를 들어, 달러와 센트로 상품 가격을 담기 위한 데이터베이스 컬럼이 있다면, 정확히 표현할 수 있는 센트 값은 0.00, 0.25, 0.50, 0.75 뿐이에요. 소수점 오른쪽의 다른 숫자는 모두 근사치를 만들어내요. "price" 값으로 47.49 를 제공하면, 그 숫자는 binary-64 로 다음과 같이 표현돼요:

6683623321994527 × 2^-47

이는 다음과 같이 계산돼요:

47.49000000000000198951966012828052043914794921875

그 숫자는 47.49 에 아주 가깝지만 정확하지는 않아요. 조금 너무 커요. M 을 1 줄여서 6683623321994526 으로 만들면 다음으로 더 작은 가능한 binary-64 값이 나와요:

47.4899999999999948840923025272786617279052734375

이 두 번째 숫자는 너무 작아요. 첫 번째 숫자가 원하는 값 47.49 에 더 가깝기 때문에 그것이 사용돼요. 하지만 정확하지는 않아요. 대부분의 십진수 값이 IEEE 754 에서 이렇게 동작해요. 위에서 말한 핵심 요점을 기억하세요: 부동소수점 값은 근사치예요.

부동소수점 값에 대해 다른 것을 기억하지 못한다면, 이 핵심 아이디어 하나만은 잊지 말아주세요.

1.2.2. 충분히 가깝나요?

IEEE 754 Binary-64 가 제공하는 정밀도는 대부분의 계산에 충분해요. 예를 들어 "47.49" 가 가격을 나타내고 인플레이션이 연 2% 로 돌고 있다면, 가격은 초당 약 0.0000000301 달러씩 올라가요. 기록된 47.49 값의 오차는 약 66 나노초 분량의 인플레이션을 나타내요. 따라서 47.49 가격을 입력할 때 정확하다면, 인플레이션의 영향으로 실제 값이 실제 저장된 값(47.4900000000000019895196601282805204391479492187)과 정확히 같아지는 데 1초의 천만분의 1보다 짧은 시간이 걸려요. 확실히 그 수준의 정밀도는 대부분의 목적에 충분하지 않나요?

1.3. 표시를 위한 부동소수점 숫자 반올림

binary-64 숫자를 표시 목적으로 텍스트로 변환할 때(예를 들어 sqlite3_column_text()sqlite3_value_text() 를 호출하거나 SQL 에서 CAST 연산자를 사용할 때), 값은 보통 합리적인 유효 숫자 개수로 반올림돼요. 6683623321994527×pow(2,-47) 의 49 자리 유효 숫자를 모두 보고 싶어하는 사람은 거의 없어요. 첫 네 자리, 이 경우 "47.49" 가 보통 더 나은 표시 옵션이에요.

반올림에는 성능 이유도 있어요. 6683623321994527×pow(2,-47) 은 "47.49" 로 비교적 빠르게 변환될 수 있지만, 49 자리 전체 확장을 생성하려면 훨씬 더 많은 계산이 필요해요.

반올림은 어느 시점에 해야 할까요? 17 자리 이상의 유효 숫자로 반올림하면, 그 텍스트를 다시 binary-64 로 변환했을 때 시작했던 값과 정확히 같은 값이 나와요. 15 자리 이하로 반올림하면 종종 이 정확한 왕복(round-trip) 결과가 나오지만 항상 그렇지는 않아요. 한편 15 자리로 반올림하면 사람 독자에게 더 보기 좋은 결과가 나오는 경향이 있는 반면, 17 자리로 반올림하면 중간에 '0' 이나 '9' 가 많은 긴 숫자가 나올 수 있어요.

SQLite 3.52.0 (2026-03-06) 이전에는 6683623321994527×pow(2,-47) 을 17 자리로 반올림하려고 하면, SQLite 가 처음 18 자리("47.4900000000000019")를 보고 마지막 자리가 5 이상이라 "47.490000000000002" 로 올렸어요. 그 이유로 이전 SQLite 버전에서는 반올림이 15 자리에서 이루어졌어요. 16번째 자리를 보고, 0 이므로 그냥 잘라낸 다음, 뒤쪽의 0 을 제거하면 "47.49" 가 남는데, 이것이 대부분의 사람이 원하는 답이에요. 그리고 텍스트 "47.49" 를 binary-64 로 다시 변환하면 6683623321994527×pow(2,-47) 이 나오므로, binary-64→text→binary-64 를 왕복해도 값이 변하지 않아요. 모든 것이 괜찮아요.

하지만 이는 모든 경우에 작동하지 않아요. 예를 들어 1.23 과 2.34 의 곱을 고려해볼게요. 숫자 1.23 은:

2769713770832855 × 2^-51 = 1.229999999999999982236431605997495353221893310546875

그리고 2.34 는:

5269211564023480 × 2^-51 = 2.339999999999999857891452847979962825775146484375

이 두 값을 정확히 곱하면:

2.878199999999999783639736961049495926597557229698714817531408904915934954260592348873615264892578125

이는 binary-64 로 표현할 수 없어요. CPU 의 부동소수점 하드웨어가 이를 다음 값으로 잘라내요:

6481130223748880 × 2^-51 = 2.87819999999999964757080306299030780792236328125

그 마지막 값을 15 자리 유효 숫자로 반올림하면 2.8782 가 나오는데, 이것이 대부분의 사람이 기대하는 답이에요. 17 자리로 반올림하면 2.8781999999999997 이 나와요. 하지만 binary-64 에서 2.8782 와 2.8781999999999997 은 같은 숫자가 아니에요.

  • 2.8782 → 6481130223748881 × 2^-51
  • 2.8781999999999997 → 6481130223748880 × 2^-51

가수가 1 만큼 달라요. 그래서 텍스트 "2.8782" 를 binary-64 로 다시 변환해도 원래의 1.23×2.34 곱으로 돌아가지 않아요. 아주 가깝지만 정확하지는 않아요. 반면 텍스트 "2.8781999999999997" 를 binary-64 로 다시 변환하면 원래 값으로 돌아가요. 그래서 그런 의미에서 2.8781999999999997 이 더 정확한 답이에요.

1.3.1. 표시할 부동소수점 자릿수 선택

그래서 트레이드오프가 있어요. (A) 15 자리로 반올림해서 사람이 기대하는 것에 더 가까운 답을 제공할까요, 아니면 (B) 17 자리로 반올림해서 소수점 뒤에 '0' 이나 '9' 가 많은 답을 제공하되 원래 binary-64 로 왕복(round-trip)할 수 있는 답을 제공할까요?

SQLite 3.51.2 이하에서는 해결책 (A)가 유일한 선택이었어요. SQLite 3.52.0 (2026-03-06) 부터 해결책 (B)가 가능해졌고 기본값이 되었지만, 애플리케이션은 해결책 (A)로 돌아가도록 선택할 수 있어요. C 코드에서는 다음과 같은 코드를 실행해 옵션 (A)를 선택할 수 있어요:

sqlite3_db_config(db, SQLITE_DBCONFIG_FP_DIGITS, 15, 0);

CLI 에서는 다음과 같이 할 수 있어요:

.dbconfig fp_digits 15

이 변경은 3.52.0 에서 반올림 알고리즘이 다음과 같이 동작하도록 개선되었기 때문에 발생했어요:

  • 15 자리로 반올림해요.
  • 텍스트 결과를 binary-64 로 다시 변환해요.
  • 이전 단계의 binary-64 값이 다르면:
    • 17 자리 반올림으로 (a) 단계를 다시 해요.

15 자리로 반올림하는 것이 보통 충분해요. (c) 단계는 거의 발생하지 않아요. '0' 과 '9' 가 많은 텍스트 렌더링은 그 차이가 중요해지는 드문 경우에만 나타나요. 3.52.0 에서 이 개선이 이루어진 이유는 binary-64→text→binary-64 변환 루틴이 부동소수점 계산 대신 64비트 정수 연산을 사용하도록 다시 작성되어, 위 알고리즘의 (b) 단계를 수행하는 것이 계산상 합리적이게 되었기 때문이에요. 3.52.0 의 binary-64→text 변환은 (b)와 (c) 단계가 추가되었음에도 여전히 이전보다 빠르요. (무시할 만한) (b), (c) 단계의 오버헤드를 원하지 않는다면 SQLITE_DBCONFIG_FP_DIGITS 를 15 로 설정하면 돼요.

2. 부동소수점 숫자를 다루기 위한 확장

2.1. ieee754.c 확장

ieee754 확장은 부동소수점 숫자를 binary-64 표현과 M×2^E 형식 사이에서 변환해요. 즉 표현식에서:

F = M × 2^E

ieee754 확장은 F 와 (M,E) 사이를, 그리고 다시 그 반대도 변환해요.

ieee754 확장은 amalgamation 의 일부가 아니지만, CLI 에는 기본 포함돼요. 애플리케이션에 ieee754 확장을 포함하려면 별도로 컴파일하고 로드해야 해요.

2.1.1. ieee754() 함수

ieee754(F) SQL 함수는 단일 부동소수점 인자를 입력으로 받아 다음과 같은 문자열을 반환해요:

'ieee754(M,E)'

단, M 과 E 는 그 부동소수점 숫자의 가수와 지수로 대체돼요. 예를 들어:

sqlite> .mode box
sqlite> SELECT ieee754(47.49) AS x;
┌───────────────────────────────┐
│               x               │
├───────────────────────────────┤
│ ieee754(6683623321994527,-47) │
└───────────────────────────────┘

반대 방향으로, ieee754() 의 2-인자 버전은 M 과 E 값을 받아 해당하는 F 값으로 변환해요:

sqlite> select ieee754(6683623321994527,-47) as x;
┌───────┐
│   x   │
├───────┤
│ 47.49 │
└───────┘
2.1.2. ieee754_mantissa() 와 ieee754_exponent() 함수

ieee754() 의 1-인자 형태의 텍스트 출력은 사람이 읽기에 좋지만, 더 큰 표현식의 일부로 사용하기는 어색해요. 따라서 단일 인자 F 값에 해당하는 M 과 E 값을 반환하는 ieee754_mantissa()ieee754_exponent() 루틴이 추가됐어요. 예를 들어:

sqlite> .mode box
sqlite> SELECT ieee754_mantissa(47.49) AS M, ieee754_exponent(47.49) AS E;
┌──────────────────┬─────┐
│        M         │  E  │
├──────────────────┼─────┤
│ 6683623321994527 │ -47 │
└──────────────────┴─────┘
2.1.3. ieee754_from_blob() 와 ieee754_to_blob() 함수

ieee754_to_blob(F) SQL 함수는 부동소수점 숫자 F 를 그 숫자의 빅엔디안 binary-64 인코딩인 8바이트 BLOB 으로 변환해요. ieee754_from_blob(B) 함수는 반대 방향으로, 8바이트 blob 을 binary-64 인코딩이 나타내는 부동소수점 값으로 변환해요.

예를 들어, Wikipedia 에서 최소 양수 binary-64 값의 인코딩이 0x0000000000000001 이라고 읽었다면, 다음과 같이 해당 부동소수점 값을 찾을 수 있어요:

sqlite> .mode box
sqlite> SELECT ieee754_from_blob(x'0000000000000001') AS F;
┌───────────────────────┐
│           F           │
├───────────────────────┤
│ 4.94065645841247e-324 │
└───────────────────────┘

또는 반대 방향으로:

sqlite> .mode box
sqlite> SELECT quote(ieee754_to_blob(4.94065645841247e-324)) AS binary-64;
┌─────────────────────┐
│      binary-64      │
├─────────────────────┤
│ X'0000000000000001' │
└─────────────────────┘

2.2. decimal.c 확장

decimal 확장은 텍스트 문자열로 저장된 숫자에 임의 정밀도 십진 산술을 제공해요. 숫자가 임의 정밀도로 텍스트로 저장되기 때문에 근사가 필요 없어요. 계산을 정확히 할 수 있어요.

decimal 확장은 (현재) SQLite amalgamation 의 일부가 아니에요. 하지만 CLI 에는 포함돼 있어요. 이 확장의 소스 코드는 ext/misc/decimal.c 에서 찾을 수 있어요.

decimal 확장은 다음 SQL 함수와 정렬 순서(collating sequence)를 지원해요:

2.2.1. decimal_add(A,B), decimal_sub(A,B), decimal_mul(A,B) 함수

이 함수들은 각각 두 입력 A 와 B 의 합, 차, 곱을 계산하고 결과를 텍스트로 반환해요. 입력은 십진 텍스트 또는 숫자 값이 될 수 있어요. 숫자 입력은 계산을 수행하기 전에 십진 텍스트로 변환돼요.

나눗셈이 항상 유한 길이의 십진 결과를 가지는 것은 아니므로 "decimal_div(A,B)" 함수는 없어요.

2.2.2. decimal_pow2(N) 함수

decimal_pow2(N) 함수는 2 의 N 제곱의 정확한 십진 표현을 계산해요. N 은 -20000 과 +20000 사이의 정수여야 해요.

이 함수는 N 값이 크면 느릴 수 있고 많은 메모리를 사용할 수 있어요.

2.2.3. decimal(X) 와 decimal_exp(X) 함수

decimal(X)decimal_exp(X) 는 입력 X 에 대한 십진 표현을 생성해요. 입력 X 는 정수, 부동소수점 숫자, 또는 텍스트 십진일 수 있어요. decimal_exp(X) 함수는 지수 표기법(끝에 "e+NN" 포함)으로 결과를 반환하고, decimal(X) 는 순수 십진("e+NN" 없이)을 반환해요.

입력 X 가 부동소수점 값이면 정확한 십진 등가물로 확장돼요. 예를 들어:

sqlite> .mode qbox
sqlite> select decimal(47.49);
┌──────────────────────────────────────────────────────┐
│                    decimal(47.49)                    │
├──────────────────────────────────────────────────────┤
│ '47.49000000000000198951966012828052043914794921875' │
└──────────────────────────────────────────────────────┘
2.2.4. decimal_cmp(X) 함수

decimal_cmp(A,B) 함수는 두 십진 값 A 와 B 를 비교해요. A 가 B 보다 작으면 결과는 음수, 같으면 0, 크면 양수예요.

2.2.5. decimal_sum(X) 집계 함수

decimal_sum(X) 함수는 내장된 total() 집계 함수와 같은 집계 함수예요. 단, decimal_sum() 은 결과를 임의 정밀도로 계산하므로 정확해요.

2.2.6. decimal 정렬 순서

decimal 확장은 십진 텍스트 문자열을 숫자 순서로 비교하는 "decimal" 정렬 순서를 제공해요.

더 알아보기 (Learn more)