float와 double은 제한된 비트 안에서 실수를 근사하여 저장한다.
따라서 일반적인 정수 연산에서는 보기 어려운 표현 오차, 누적 오차, 정밀도 손실 등의 문제가 발생할 수 있다.
1. 표현 오차
0.1처럼 이진수로 유한하게 표현할 수 없는 값은 가장 가까운 부동소수점 값으로 반올림되어 저장된다.
#include <iomanip>
#include <iostream>
int main()
{
double value = 0.1 + 0.2;
std::cout << std::setprecision(17);
std::cout << value << '\n';
}
출력 결과는 일반적으로 다음과 비슷하다.
0.30000000000000004
수학적으로는 0.3이지만, 내부에는 정확한 0.3과 조금 다른 값이 저장된다.
2. 실수 직접 비교 문제
표현 오차 때문에 계산을 거친 실수는 ==로 직접 비교할 때 예상과 다른 결과가 나올 수 있다.
double a = 0.1 + 0.2;
double b = 0.3;
if (a == b)
{
// 실행되지 않을 수 있다.
}
일반적으로 두 값의 차이가 허용 오차 이하인지 검사한다.
#include <cmath>
bool NearlyEqual(
double a,
double b,
double tolerance = 1e-9) // 1 × 10⁻⁹
{
return std::abs(a - b) <= tolerance;
}
하지만 고정된 절대 오차만 사용하면 값의 크기가 매우 클 때 적절하지 않을 수 있다.
절대 오차와 상대 오차를 함께 사용할 수 있다.
#include <algorithm>
#include <cmath>
bool NearlyEqual(
double a,
double b,
double absoluteTolerance = 1e-9,
double relativeTolerance = 1e-9)
{
const double difference = std::abs(a - b);
if (difference <= absoluteTolerance)
{
return true;
}
const double largest =
std::max(std::abs(a), std::abs(b));
return difference <= relativeTolerance * largest;
}
허용 오차는 좌표, 회전, 시간, 물리 등 시스템의 목적에 맞게 정해야 한다.
3. 누적 오차
작은 표현 오차도 반복 연산을 수행하면 누적될 수 있다.
float elapsedTime = 0.0f;
for (int i = 0; i < 1000000; ++i)
{
elapsedTime += 0.001f;
}
수학적으로는 1000.0f가 되어야 하지만 실제 결과는 정확하게 일치하지 않을 수 있다.
게임에서는 다음 상황에서 누적 오차가 발생할 수 있다.
- 매 프레임 DeltaTime 누적
- 위치와 회전 반복 누적
- 장시간 물리 시뮬레이션
- 애니메이션 재생 시간 계산
- 서버와 클라이언트의 시간 동기화
가능하면 작은 값을 반복해서 더하기보다 기준값으로부터 현재 값을 계산하는 방식이 안정적이다.
// 누적 방식
ElapsedTime += DeltaTime;
// 기준 시각 기반 방식
ElapsedTime = CurrentTime - StartTime;
멀티플레이 타이머에서는 서버의 종료 시각을 저장하고 현재 서버 시간과의 차이로 남은 시간을 계산하는 방식이 유리하다.
4. 큰 값에서의 정밀도 손실
부동소수점은 값이 커질수록 인접한 표현 가능 값 사이의 간격도 커진다. (십진 유효 자릿수 때문!)
float value = 100000000.0f;
value += 1.0f;
이 경우 1.0f를 더해도 값이 변하지 않을 수 있다.
100000000 부근에서 float가 표현할 수 있는 값 사이의 간격이 이미 1보다 크기 때문이다.
게임 월드에서 원점으로부터 멀어지면 다음 문제가 발생할 수 있다.
- 캐릭터와 카메라 떨림
- 메시 버텍스 흔들림
- 작은 이동량 무시
- 충돌 판정 불안정
- 물리 계산 오차
대규모 월드에서는 지역 좌표계, 월드 원점 재배치, 큰 월드 좌표 시스템 등을 고려할 수 있다.
5. 연산 순서에 따른 결과 차이
부동소수점에서는 수학적인 결합법칙이 항상 성립하지 않는다.
float a = 100000000.0f;
float b = -100000000.0f;
float c = 1.0f;
float result1 = (a + b) + c;
float result2 = a + (b + c);
result1은 1이 될 수 있지만 result2는 0이 될 수 있다.
b + c를 먼저 계산하면 큰 값에 비해 1.0f가 너무 작아 정밀도 손실로 사라질 수 있기 때문이다.
다음 조건에 따라서도 결과가 조금씩 달라질 수 있다.
- 연산 순서
- CPU 아키텍처
- 컴파일러 최적화
- SIMD 명령
- FMA 사용 여부
- 멀티스레드 실행 순서
네트워크 록스텝이나 결정론적 리플레이처럼 동일한 입력에서 완전히 같은 결과가 필요한 시스템에서는 연산 환경을 통제하거나 고정소수점 사용을 검토해야 한다.
*네트워크 록스텝(Network Lockstep)은 각 클라이언트가 게임의 전체 상태를 계속 전송하는 대신, 플레이어의 입력만 공유하고 모든 컴퓨터가 같은 시뮬레이션을 직접 실행하는 동기화 방식이다. 따라서 모든 환경에서 동일한 결과가 나오는 결정론이 필요하며, 연산 결과가 달라지면 디싱크가 발생할 수 있다.
*결정론적 리플레이는 전체 게임 상태나 영상을 저장하는 대신 초기 상태, 난수 시드, 입력 기록을 저장하고 시뮬레이션을 다시 실행하여 플레이를 재현하는 방식이다.
6. 오버플로와 언더플로
계산 결과가 표현 가능한 최대 유한값보다 커지는 현상을 오버플로라고 한다.
#include <limits>
float value = std::numeric_limits<float>::max();
value *= 2.0f;
IEEE 754 환경에서는 결과가 Infinity가 될 수 있다.
반대로 값이 0에 지나치게 가까워 정상적인 정밀도로 표현하기 어려워지는 현상을 언더플로라고 한다.
언더플로가 발생하면 다음 상태가 될 수 있다.
- 정밀도가 낮은 서브노멀 값
- 부호가 있는 0
오버플로는 범위를 벗어나는 현상이고, Infinity는 그 결과를 나타낼 수 있는 특수 값이다.
두 개념은 동일하지 않다.
7. Infinity
Infinity는 양의 무한대 또는 음의 무한대를 나타내는 부동소수점 특수 값이다.
double positiveInfinity = 1.0 / 0.0;
double negativeInfinity = -1.0 / 0.0;
다음 상황에서 발생할 수 있다.
- 부동소수점 오버플로
- 유한한 값을 0.0으로 나눔
- 계산 결과가 표현 범위를 초과함
정수의 0 나눗셈은 부동소수점 연산과 다르며 정의되지 않은 동작이다.
int value = 1 / 0; // 사용하면 안 된다.
8. NaN
NaN(난)은 Not a Number의 약자로 숫자로 정의할 수 없는 계산 결과를 나타낸다.
#include <cmath>
double value = std::sqrt(-1.0);
다음 연산에서도 NaN이 발생할 수 있다.
0.0 / 0.0
Infinity - Infinity
Infinity / Infinity
NaN은 자신을 포함한 어떤 값과도 같지 않다.
double value = std::sqrt(-1.0);
bool result = (value == value); // false
if (value < minValue || value > maxValue)
{
// NaN이면 두 조건이 모두 false가 될 수 있다.
}
NaN은 이후 연산으로 계속 전파된다. (위치, 회전, 속도, 행렬 등에 들어가면 액터가 사라지거나 물리와 렌더링 계산이 오염될 수 있다.)
따라서 표준 함수로 유효성을 검사하는 것이 안전하다.
#include <cmath>
if (!std::isfinite(value))
{
// NaN 또는 Infinity 처리
}
9. FLT_EPSILON의 의미
FLT_EPSILON 또는 std::numeric_limits<float>::epsilon()은 일반적으로 1.0f와 그다음 표현 가능한 float 값 사이의 차이를 의미한다.
#include <limits>
float epsilon =
std::numeric_limits<float>::epsilon();
모든 크기의 값에 사용할 수 있는 범용 허용 오차는 아니다.
값이 커질수록 인접한 부동소수점 값 사이의 간격도 커지기 때문이다.
실수 비교에는 시스템의 단위와 값의 크기에 맞는 절대 오차와 상대 오차를 사용해야 한다.
+ 정확한 값이 필요한 경우
화폐, 아이템 수량, 점수처럼 정확한 값이 필요한 데이터에는 부동소수점이 적합하지 않을 수 있다.
정수 또는 고정소수점을 사용할 수 있다.
#include <cstdint>
int64_t priceInCents = 1299; // 12.99
고정소수점은 일정한 배율을 적용한 정수로 실수를 표현하는 방식이다.
장점은 다음과 같다.
- 동일한 정수 연산에서 결과를 예측하기 쉽다.
- 결정론을 확보하기 쉽다.
- 지정한 단위까지 정확하게 표현할 수 있다.
단점은 다음과 같다.
- 표현 범위와 정밀도를 미리 설계해야 한다.
- 곱셈과 나눗셈에서 스케일을 관리해야 한다.
- 중간 계산의 오버플로에 주의해야 한다.
실무 대응 방법
부동소수점 문제를 완전히 없앨 수는 없다. 시스템 목적에 맞게 오차를 관리해야 한다.
- 계산된 실수는 목적에 맞는 허용 오차로 비교한다.
- 작은 값을 장시간 반복 누적하지 않는다.
- 장시간 시간 계산이나 높은 정밀도가 필요한 값에는 double을 고려한다.
- 중요한 계산 결과에는 std::isfinite 검사를 적용한다.
- 대규모 월드에서는 지역 좌표계나 원점 재배치를 사용한다.
- 정확한 값이 필요한 데이터에는 정수나 고정소수점을 사용한다.
- 결정론이 필요한 시스템에서는 연산 순서와 실행 환경을 통제한다.
핵심 정리
- 부동소수점은 대부분의 실수를 근삿값으로 저장한다.
- 계산된 실수의 직접 비교는 주의해야 한다.
- 허용 오차는 시스템의 단위와 목적에 맞게 정해야 한다.
- 반복 연산은 누적 오차를 만들 수 있다.
- 큰 값에서는 작은 변화량이 사라질 수 있다.
- 부동소수점 연산은 연산 순서에 따라 결과가 달라질 수 있다.
- Infinity와 NaN은 연산 전체로 전파될 수 있다.
- 정확성이 필요한 값은 정수나 고정소수점 사용을 고려해야 한다.
'C++ > C++ 문법과 핵심 개념' 카테고리의 다른 글
| C++ 동적 메모리 할당은 왜 필요할까? - Stack과 Heap (0) | 2026.08.27 |
|---|---|
| C++ 멤버 접근 제어 방법 - Getter/Setter, friend, using (1) | 2026.08.26 |
| C++ Struct와 Class의 차이, 서로 상속할 수 있을까? (0) | 2026.08.26 |
| [C++ 실수 자료형 2] float와 double의 내부 구조 (0) | 2026.08.06 |
| [C++ 실수 자료형 1] 이진수로 소수를 표현하는 방법 (0) | 2026.08.06 |