← SEARCHABLE ARCHIVE
Computer Science · 2026-08-08

IEEE 754 부동소수점은 어떻게 실수를 저장할까?

binary32와 binary64, 반올림, NaN, 누적 오차까지 이해하기

INTERACTIVE READING MODE →

IEEE 754 부동소수점은 어떻게 실수를 저장할까?

본 블로그의 글은 강의를 목적으로 작성되었습니다. CPU, 컴파일러 옵션, 프로그래밍 언어에 따라 세부 동작은 달라질 수 있으니 실제 환경도 함께 확인해주세요.

프로그래밍을 처음 배울 때 0.1 + 0.2가 정확히 0.3이 되지 않는다는 이야기를 듣곤 한다. 처음에는 컴퓨터가 계산을 틀린 것처럼 보인다. 하지만 문제는 계산 능력이 아니라 표현 방법에 있다. 컴퓨터는 무한히 많은 실수를 유한한 비트 안에 넣어야 하고, 그 과정에서 대부분의 값을 가장 가까운 수로 근사한다.

IEEE 754는 이런 부동소수점 표현과 연산을 여러 컴퓨터에서 일관되게 다루기 위한 표준이다. 단순히 float의 비트 배치만 정하는 규칙이 아니다. 기본 연산의 반올림, 무한대와 NaN, 언더플로, 예외 상태까지 함께 다룬다.

이 글에서는 가장 널리 쓰이는 binary32와 binary64를 직접 분해하고, 0.1이 왜 정확히 저장되지 않는지 살펴본다. 마지막에는 비교, 누적, 돈 계산처럼 실무에서 자주 틀리는 부분을 C++ 코드로 정리한다.

고정소수점과 부동소수점의 차이

소수점을 표현하는 가장 단순한 방법은 소수점 위치를 미리 고정하는 것이다. 예를 들어 정수 12345를 항상 소수 둘째 자리까지 있는 값으로 해석하면 실제 값은 123.45가 된다. 이를 고정소수점이라고 한다.

고정소수점은 일정한 간격을 유지하므로 돈처럼 자릿수가 정해진 값을 다루기 편하다. 반면 아주 작은 값과 아주 큰 값을 한 형식에 함께 담기는 어렵다.

부동소수점은 과학적 표기법과 비슷하다.

6.25 = 1.5625 × 2²
0.15625 = 1.25 × 2⁻³

유효숫자와 지수를 따로 저장하므로 소수점의 위치가 움직인다. 같은 비트 수로 훨씬 넓은 범위를 다룰 수 있지만, 모든 구간에서 값 사이의 간격이 같지는 않다.

IEEE 754가 정하는 범위

현재 활성 표준인 IEEE 754-2019는 이진 및 십진 부동소수점 형식, 연산, 형식 변환, 반올림, 예외 조건과 기본 처리 방식을 규정한다. 구현은 하드웨어만으로 만들 수도 있고 소프트웨어를 섞을 수도 있다.

프로그래머가 가장 자주 만나는 이진 형식은 다음 둘이다.

이름흔한 언어 타입전체 비트부호지수저장된 fraction실제 정밀도
binary32보통 float32182324비트
binary64보통 double641115253비트

여기서 “보통”이라고 쓴 이유가 중요하다. C와 C++의 타입 이름 자체가 모든 플랫폼에서 IEEE 형식을 강제하는 것은 아니다. C++에서는 std::numeric_limits<T>::is_iec559 같은 속성으로 구현 특성을 확인할 수 있다.

binary32의 32비트 나누기

binary32는 다음 세 필드를 이어 붙인다.

부호 1비트 | 지수 8비트 | fraction 23비트

정규화된 유한수의 값은 다음 식으로 계산한다.

(-1)^s × (1.fraction)₂ × 2^(E - 127)

s는 부호 비트이고 E는 저장된 지수다. 지수에는 127이라는 bias를 더해서 음수 지수도 부호 없는 비트 패턴으로 저장한다. fraction 앞의 1은 정규화된 값이라면 항상 존재하므로 실제 비트에 기록하지 않는다. 이를 숨은 비트 또는 implicit leading bit라고 부른다.

fraction은 23비트만 저장하지만 숨은 비트까지 포함하면 유효숫자 정밀도는 24비트가 된다.

6.25를 직접 인코딩해 보기

십진수 6.25를 이진수로 쓰면 110.01₂다. 정규화하면 다음과 같다.

110.01₂ = 1.1001₂ × 2²

따라서 세 필드는 이렇게 결정된다.

  • 양수이므로 부호 비트는 0이다.
  • 실제 지수 2에 bias 127을 더한 129는 10000001₂다.
  • 맨 앞의 1을 제외한 fraction은 1001000...0이다.

전체 비트를 적으면 다음과 같다.

0 10000001 10010000000000000000000

16진수로는 0x40C80000이다.

C++20의 std::bit_cast를 사용하면 값의 객체 표현을 안전하게 정수로 복사해 확인할 수 있다.

#include <bit>
#include <cstdint>
#include <iomanip>
#include <iostream>

int main()
{
    float value = 6.25f;
    std::uint32_t bits = std::bit_cast<std::uint32_t>(value);

    std::cout << "0x"
              << std::hex << std::uppercase
              << std::setw(8) << std::setfill('0')
              << bits << '\n';
}

일반적인 IEEE binary32 환경에서는 0x40C80000이 출력된다.

지수 필드의 양 끝은 특별하다

지수 비트가 모두 0이거나 모두 1이면 평범한 정규화 수로 해석하지 않는다.

지수fraction의미
모두 0모두 0부호 있는 0
모두 00이 아님subnormal 수
1부터 254어떤 값정규화된 유한수
모두 1모두 0양의 무한대 또는 음의 무한대
모두 10이 아님NaN

이 예약 규칙 때문에 binary32의 정규화된 실제 지수 범위는 -126부터 127까지다.

0과 음의 0

fraction과 지수가 모두 0일 때 부호 비트는 그대로 남는다. 그래서 IEEE 754에는 +0과 -0이 있다. 두 값은 일반 비교에서 같지만 어떤 연산에서는 방향 정보가 드러난다.

#include <cmath>
#include <iostream>

int main()
{
    double positiveZero = 0.0;
    double negativeZero = -0.0;

    std::cout << std::boolalpha;
    std::cout << (positiveZero == negativeZero) << '\n';
    std::cout << std::signbit(negativeZero) << '\n';
    std::cout << 1.0 / positiveZero << '\n';
    std::cout << 1.0 / negativeZero << '\n';
}

IEEE 연산을 따르는 일반적인 환경에서는 비교 결과가 참이고, 나눗셈 결과는 각각 양의 무한대와 음의 무한대가 된다.

subnormal 수가 필요한 이유

정규화된 수는 유효숫자가 항상 1.fraction 형태다. 하지만 0에 가까워지면 사용할 수 있는 가장 작은 정규화 수와 0 사이에 큰 빈틈이 생긴다.

지수 필드가 모두 0이고 fraction이 0이 아닐 때는 숨은 1을 사용하지 않는다.

(-1)^s × (0.fraction)₂ × 2^-126

이 값들을 subnormal 또는 비정규 수라고 한다. 정밀도를 조금씩 포기하면서 0까지 부드럽게 가까워지게 해준다. 이를 gradual underflow라고 한다.

binary32에서 대표적인 경계는 다음과 같다.

  • 가장 작은 양의 정규화 수는 대략 1.17549435 × 10^-38이다.
  • 가장 작은 양의 subnormal 수는 대략 1.40129846 × 10^-45다.

일부 고성능 환경은 subnormal을 0으로 처리하는 모드를 사용할 수 있다. 성능과 수치적 의미가 달라질 수 있으므로 SIMD나 GPU 코드를 다룰 때는 실행 환경을 확인해야 한다.

무한대는 아주 큰 유한수가 아니다

지수가 모두 1이고 fraction이 0이면 무한대다. 부호 비트에 따라 양의 무한대와 음의 무한대로 나뉜다.

유한 값의 연산 결과가 형식이 표현할 수 있는 범위를 넘으면 반올림 모드에 따라 무한대 또는 가장 큰 유한 값이 될 수 있다. 0이 아닌 유한 값을 0으로 나눈 경우에도 일반적인 기본 처리에서는 부호에 맞는 무한대가 나온다.

#include <cmath>
#include <iostream>
#include <limits>

int main()
{
    double inf = std::numeric_limits<double>::infinity();
    std::cout << std::isinf(inf) << '\n';
    std::cout << (inf > 1.0e300) << '\n';
}

NaN은 비교 규칙부터 다르다

지수가 모두 1이고 fraction이 0이 아니면 NaN, 즉 Not a Number다. 0.0 / 0.0, 음수의 실수 제곱근처럼 실수 범위에서 정의할 수 없는 연산이 NaN을 만들 수 있다.

NaN은 일반 숫자처럼 정렬할 수 없다. 자기 자신과의 비교조차 거짓이다.

#include <cmath>
#include <iostream>
#include <limits>

int main()
{
    double nan = std::numeric_limits<double>::quiet_NaN();

    std::cout << std::boolalpha;
    std::cout << (nan == nan) << '\n';
    std::cout << (nan < 0.0) << '\n';
    std::cout << std::isnan(nan) << '\n';
}

NaN을 찾으려고 value == NaN처럼 비교하면 안 된다. C와 C++에서는 isnan 또는 std::isnan을 사용한다.

표준은 quiet NaN과 signaling NaN을 구분하지만, 언어와 하드웨어가 signaling NaN을 노출하고 처리하는 방식은 같지 않다. payload도 직렬화 규약으로 사용하기 전에 대상 환경을 확인해야 한다.

0.1은 왜 정확히 저장되지 않을까?

십진수의 0.1은 다음 분수다.

1 / 10 = 1 / (2 × 5)

유한한 이진 소수로 끝나려면 분모를 약분했을 때 소인수가 2만 남아야 한다. 분모에 5가 있으므로 0.1의 이진 표현은 반복된다.

0.1₁₀ = 0.000110011001100110011...₂

binary64도 유효숫자 53비트까지만 보관할 수 있으므로 반복되는 뒤쪽 비트를 반올림한다. 저장되는 값은 정확한 0.1보다 아주 조금 크다.

#include <iomanip>
#include <iostream>

int main()
{
    double value = 0.1;
    std::cout << std::setprecision(20) << value << '\n';
}

보통 다음과 비슷한 값이 보인다.

0.10000000000000000555

0.1 + 0.2 문제는 오류가 갑자기 생긴 것이 아니다. 두 입력을 저장할 때 이미 근사가 일어났고, 덧셈 결과를 다시 표현 가능한 값으로 반올림한다.

값 사이의 간격은 일정하지 않다

부동소수점 수는 실수 축에 균일하게 놓여 있지 않다. 같은 지수 구간에서는 간격이 일정하지만 지수가 커지면 간격도 커진다.

binary32는 24비트 정밀도를 가지므로 2의 24제곱인 16,777,216까지의 정수는 정확히 표현할 수 있다. 그보다 큰 구간에서는 이웃한 표현 가능 값의 차이가 2 이상이 되기 시작한다.

#include <iostream>

int main()
{
    float a = 16'777'216.0f;
    float b = a + 1.0f;

    std::cout << std::boolalpha << (a == b) << '\n';
}

일반적인 binary32에서는 참이 출력된다. a 근처에는 a + 1을 따로 나타낼 비트 패턴이 없기 때문이다.

epsilon 하나로 모든 비교를 해결할 수 없다

std::numeric_limits<double>::epsilon()은 1과 그다음 표현 가능 값의 차이를 나타낸다. 모든 크기의 값에 그대로 사용할 만능 허용 오차는 아니다.

bool almostEqual(double a, double b)
{
    return std::abs(a - b) <= std::numeric_limits<double>::epsilon();
}

이 코드는 값이 아주 크거나 0에 매우 가까울 때 의도와 다르게 동작할 수 있다. 보통은 문제 영역에 맞는 절대 오차와 상대 오차를 함께 둔다.

#include <algorithm>
#include <cmath>

bool almostEqual(double a, double b,
                 double absoluteTolerance = 1e-12,
                 double relativeTolerance = 1e-9)
{
    double difference = std::abs(a - b);
    if (difference <= absoluteTolerance)
    {
        return true;
    }

    double scale = std::max(std::abs(a), std::abs(b));
    return difference <= scale * relativeTolerance;
}

허용 오차의 값은 데이터의 단위와 필요한 정확도에 맞춰 정해야 한다. 좌표, 시간, 물리 시뮬레이션, 금융 계산에 같은 값을 무심코 복사하면 안 된다.

반올림 모드

정확한 결과가 두 표현 가능 값 사이에 있을 때 어느 쪽을 선택할지 정해야 한다. IEEE 754-2019는 여러 반올림 방향을 정의하며, 흔한 기본값은 가장 가까운 값으로 반올림하되 정확히 중간이면 마지막 비트가 짝수가 되는 쪽을 고르는 방식이다. 이를 ties to even이라고 한다.

C와 C++의 <fenv.h> 또는 <cfenv>는 구현이 지원하는 반올림 방향과 상태 플래그를 다루는 인터페이스를 제공한다.

#include <cfenv>
#include <iostream>

#pragma STDC FENV_ACCESS ON

int main()
{
    std::fesetround(FE_DOWNWARD);
    volatile double a = 1.0;
    volatile double b = 10.0;
    volatile double result = a / b;
    std::cout << result << '\n';

    std::fesetround(FE_TONEAREST);
}

컴파일러 최적화가 부동소수점 환경의 변경을 무시할 수 있으므로 관련 언어 규칙과 컴파일 옵션을 함께 확인해야 한다.

다섯 가지 예외 상태

IEEE 754에서 예외는 반드시 C++의 throw처럼 실행 흐름을 중단한다는 뜻이 아니다. 기본 처리로 결과를 만들고 상태 플래그를 기록하는 경우가 많다.

  • invalid operation은 정의할 수 없는 연산에서 발생한다.
  • division by zero는 유한한 0이 아닌 값을 0으로 나눌 때 발생한다.
  • overflow는 반올림된 결과의 크기가 대상 형식 범위를 넘을 때 발생한다.
  • underflow는 아주 작은 결과에서 정확성을 잃는 상황과 관계된다.
  • inexact는 정확한 결과를 대상 형식에 그대로 나타낼 수 없을 때 발생한다.
#include <cfenv>
#include <iostream>

#pragma STDC FENV_ACCESS ON

int main()
{
    std::feclearexcept(FE_ALL_EXCEPT);

    volatile double one = 1.0;
    volatile double zero = 0.0;
    volatile double result = one / zero;
    (void)result;

    if (std::fetestexcept(FE_DIVBYZERO))
    {
        std::cout << "division by zero flag\n";
    }
}

계산 순서를 바꾸면 결과도 바뀔 수 있다

실수의 덧셈은 수학적으로 결합 법칙을 만족하지만 유한 정밀도 부동소수점 연산은 중간 반올림 때문에 그렇지 않을 수 있다.

#include <iostream>

int main()
{
    double large = 1e16;
    double small = 1.0;

    std::cout << ((large + small) - large) << '\n';
    std::cout << (small + (large - large)) << '\n';
}

첫 계산에서 large + small은 small을 잃을 수 있다. 컴파일러의 빠른 수학 옵션이 식을 재배치해도 된다고 가정하면 결과와 예외 의미가 더 달라질 수 있다.

많은 값을 더할 때 생기는 오차

작은 값을 순서대로 더하면 각 단계의 반올림 오차가 누적된다. 단순 합산보다 보정 합산이 필요한 경우가 있다. 대표적인 방법이 Kahan summation이다.

#include <vector>

double kahanSum(const std::vector<double>& values)
{
    double sum = 0.0;
    double correction = 0.0;

    for (double value : values)
    {
        double adjusted = value - correction;
        double next = sum + adjusted;
        correction = (next - sum) - adjusted;
        sum = next;
    }

    return sum;
}

항상 Kahan 합산이 필요한 것은 아니다. 데이터 범위, 오차 허용치, 성능 요구를 먼저 측정해야 한다. 크기가 비슷한 값끼리 더하거나 더 높은 정밀도의 누산기를 쓰는 방법도 있다.

돈을 float로 저장하면 곤란한 이유

십진 소수 둘째 자리까지 정확해야 하는 돈을 이진 부동소수점으로 저장하면 0.01 같은 값부터 근사된다. 세금과 반올림 규칙이 얽히면 작은 차이가 실제 오류가 된다.

보통 다음 중 하나를 선택한다.

  • 원, 센트처럼 최소 단위를 정수로 저장한다.
  • 언어 또는 라이브러리가 제공하는 십진 고정소수점, decimal 타입을 사용한다.
  • 반올림 시점과 방향을 도메인 규칙으로 명확히 정한다.

게임 좌표나 그래픽 계산에는 이진 부동소수점이 매우 알맞다. 도구가 나쁜 것이 아니라 문제에 맞는 표현을 골라야 한다.

직렬화할 때 생각할 점

부동소수점 값을 파일이나 네트워크에 저장할 때는 타입 이름만 적지 말고 형식과 바이트 순서를 정해야 한다.

  • binary32인지 binary64인지 기록한다.
  • 정수 비트 패턴으로 옮긴 뒤 프로토콜의 엔디안을 따른다.
  • NaN payload와 음의 0을 보존해야 하는지 정한다.
  • 텍스트로 저장한다면 왕복에 필요한 자릿수를 사용한다.

C++의 max_digits10은 유한한 값을 십진 문자열로 출력한 뒤 같은 타입으로 읽었을 때 원래 값을 구분해 복원하는 데 필요한 자릿수와 관련된다.

#include <iomanip>
#include <iostream>
#include <limits>

int main()
{
    double value = 0.1;
    std::cout << std::setprecision(
        std::numeric_limits<double>::max_digits10)
              << value << '\n';
}

구현 특성을 코드에서 확인하기

추측하는 대신 std::numeric_limits로 현재 타입의 속성을 출력할 수 있다.

#include <iostream>
#include <limits>

template <typename T>
void printLimits(const char* name)
{
    using Limits = std::numeric_limits<T>;

    std::cout << name << '\n';
    std::cout << "  IEC 559: " << Limits::is_iec559 << '\n';
    std::cout << "  radix: " << Limits::radix << '\n';
    std::cout << "  significand digits: " << Limits::digits << '\n';
    std::cout << "  decimal digits: " << Limits::digits10 << '\n';
    std::cout << "  max_digits10: " << Limits::max_digits10 << '\n';
    std::cout << "  min normal: " << Limits::min() << '\n';
    std::cout << "  min subnormal: " << Limits::denorm_min() << '\n';
    std::cout << "  max: " << Limits::max() << '\n';
}

int main()
{
    std::cout << std::boolalpha;
    printLimits<float>("float");
    printLimits<double>("double");
}

min()은 가장 작은 양수가 아니라 가장 작은 양의 정규화 값이다. 가장 낮은 음수 값은 lowest(), 가장 작은 양의 subnormal은 denorm_min()으로 확인한다.

실무에서 기억할 규칙

첫째, float와 double은 실수 전체를 저장하지 않는다. 표현 가능한 유한한 값 중 하나를 저장한다.

둘째, 정확한 동등 비교가 언제나 잘못된 것은 아니다. 동일한 비트 패턴을 기대하는 상태 값이나 정수 범위의 값이라면 쓸 수 있다. 계산으로 얻은 연속량을 비교할 때는 문제에 맞는 오차 기준이 필요하다.

셋째, NaN은 일반 비교로 찾지 않는다. 전용 판별 함수를 사용한다.

넷째, 값의 크기가 커질수록 인접한 표현 가능 값의 간격도 커진다. 큰 좌표에 작은 이동량을 계속 더할 때 특히 눈에 띈다.

다섯째, 돈과 식별자처럼 정확성이 필수인 값은 정수 또는 적절한 십진 타입을 검토한다.

마지막으로, 재현성이 중요하면 컴파일러의 부동소수점 최적화 옵션, FMA 사용, 중간 정밀도, 반올림 모드와 하드웨어 차이를 빌드 규칙에 포함한다.

참고 자료

정리

IEEE 754 부동소수점은 부호, 유효숫자, 지수를 이용해 한정된 비트로 매우 넓은 범위의 값을 표현한다. 그 대가로 많은 십진 소수가 근삿값이 되고, 값의 크기에 따라 간격이 달라지며, 연산 순서에 따라 결과가 조금씩 달라질 수 있다.

이 특징을 버그처럼 피하려고 하기보다 표현 범위와 반올림 규칙을 이해하는 편이 낫다. 그러면 0.1 + 0.2, NaN 비교, 큰 좌표의 떨림, 누적 오차 같은 현상이 모두 같은 원리에서 나온다는 것을 볼 수 있다.