본문 바로가기

실수 자료형을 사용할 때 발생할 수 있는 문제

@iamrain2025. 11. 19. 12:47

1. 이론

1.1. 실수 자료형과 표현 방식의 한계

컴퓨터는 모든 데이터를 0과 1의 조합인 이진수로 표현합니다. 정수는 이진수로 명확하게 변환할 수 있지만, 우리가 현실에서 사용하는 많은 소수는 이진수로 정확하게 표현할 수 없습니다. 예를 들어, 십진수 0.1은 이진수로 변환하면 0.0001100110011...처럼 무한히 반복되는 소수가 됩니다.

컴퓨터의 메모리는 유한하기 때문에 이 무한 소수를 그대로 저장할 수 없어 특정 지점에서 잘라내고 근사치를 저장합니다. 이 과정에서 표현 오차(Representation Error)가 발생하며, 이는 실수 연산 시 발생하는 대부분의 문제의 근본적인 원인이 됩니다.

1.2. 내부 구조: IEEE 754 표준

현대 대부분의 시스템은 실수를 표현하기 위해 IEEE 754 표준을 따릅니다. 이 표준은 실수를 부호(Sign), 지수(Exponent), 가수(Mantissa 또는 Significand) 세 부분으로 나누어 저장합니다.

  • float (단정밀도, 32비트)
    • 부호: 1비트
    • 지수: 8비트
    • 가수: 23비트
  • double (배정밀도, 64비트)
    • 부호: 1비트
    • 지수: 11비트
    • 가수: 52비트

동작 방식:

  1. 정규화(Normalization): 실수를 1.xxxx... * 2^n 형태로 변환합니다. 가수의 첫 번째 비트는 항상 1이므로 (이를 'hidden bit'라 함), 실제로는 저장하지 않아 가수에 1비트를 추가로 사용하는 효과를 얻습니다.
  2. 부호 비트: 양수면 0, 음수면 1을 저장합니다.
  3. 지수부: n 값에 특정 값(bias)을 더해 저장합니다. float의 bias는 127, double은 1023입니다. 이는 지수가 음수일 경우를 처리하기 위함입니다.
  4. 가수부: 정규화된 형태에서 소수점 이하 부분인 xxxx...를 저장합니다.

이 구조 때문에 실수는 정수처럼 연속적이지 않고, 0에 가까울수록 정밀하고 0에서 멀어질수록 듬성듬성 분포하게 됩니다.

1.3. 주요 문제점

1.3.1. 비교 연산(==)의 위험성

표현 오차 때문에 두 실수 값이 논리적으로는 같아야 하지만, 실제 메모리 표현은 미세하게 다를 수 있습니다.

float a = 0.1f;
float b = 0.2f;
float c = 0.3f;

if (a + b == c) {
    // 이 코드는 실행되지 않을 가능성이 매우 높다.
}

a + b의 결과는 0.3의 근사치일 뿐, c에 저장된 0.3의 근사치와 정확히 일치하지 않을 수 있습니다.

해결책: 엡실론(Epsilon)이라는 매우 작은 값을 사용한 범위 비교로 대체해야 합니다.
if (abs(a + b - c) < FLT_EPSILON)

1.3.2. 정밀도 손실 (Loss of Precision)

Absorption

매우 큰 수와 매우 작은 수를 더할 때 작은 수가 무시되는 현상입니다.

float big_num = 100000000.0f;
float small_num = 0.1f;
float result = big_num + small_num; // result는 여전히 100000000.0f일 수 있다.

small_numbig_num의 정밀도로 표현될 수 있는 범위를 벗어났기 때문에 연산 과정에서 버려집니다. 게임에서 플레이어의 위치를 누적 이동량으로 계속 더해나갈 때, 월드 원점에서 멀어질수록 작은 이동 벡터가 무시되어 움직임이 떨리거나 벽을 통과하는 등의 문제를 일으킬 수 있습니다.

Catastrophic Cancellation

비슷한 크기의 두 수를 뺄 때, 유효 숫자가 대폭 줄어들어 오차가 커지는 현상입니다.

예를 들어, 0.1234570.123456을 뺀다고 가정해봅시다. 두 수 모두 유효숫자가 6개입니다. 결과는 0.000001로, 유효숫자가 1개로 줄어듭니다. 이 과정에서 초기 값들이 가지고 있던 작은 오차들이 결과값에 큰 영향을 미치게 됩니다.

1.3.3. 연산 순서에 따른 결과 변화

실수 연산은 결합 법칙((a + b) + c == a + (b + c))이 성립하지 않습니다.

float a = 100000000.0f;
float b = 0.1f;
float c = 0.1f;

float r1 = (a + b) + c; // a+b에서 b가 흡수, 결과는 a+c와 비슷
float r2 = a + (b + c); // b+c가 먼저 계산, a와 더할 때 흡수될 수 있음
// r1과 r2는 다른 값을 가질 수 있다.

이는 컴파일러 최적화나 병렬 처리 시 연산 순서가 바뀌면 예기치 않은 다른 결과를 낳을 수 있음을 의미합니다.

1.4. 계층별 동작 구조

  1. 사용자 영역 (User Code): 개발자가 C++ 코드로 float a = 0.1f;와 같이 실수를 사용합니다.
  2. 컴파일러 계층:
    • 컴파일러는 0.1f라는 리터럴을 IEEE 754 표준에 맞는 32비트 이진수 근사치로 변환합니다.
    • a + b와 같은 연산은 CPU의 부동소수점 연산 장치(FPU)를 사용하는 어셈블리 코드(예: ADDSS on x86)로 번역됩니다.
    • 최적화 단계에서 컴파일러는 연산 순서를 변경할 수 있으며, 이는 결과에 영향을 줄 수 있습니다.
  3. 운영체제(OS) 계층:
    • OS는 프로세스의 FPU 상태(레지스터 등)를 컨텍스트 스위칭 시 저장하고 복원하여 여러 프로세스가 FPU 자원을 공유할 수 있도록 합니다.
  4. 하드웨어 계층 (CPU/FPU):
    • FPU는 컴파일된 명령어를 직접 실행합니다.
    • FPU 내부 레지스터는 floatdouble보다 더 높은 정밀도(예: 80비트)를 가질 수 있습니다.
    • 연산은 이 높은 정밀도로 수행된 후, 결과가 다시 floatdouble 크기로 변환(라운딩)되어 메모리에 저장됩니다. 이 라운딩 과정에서도 오차가 발생할 수 있습니다.

1.5. 해결 방안 및 대안

  • float vs double: doublefloat보다 훨씬 높은 정밀도를 제공하므로 오차 누적이 훨씬 적습니다. 메모리나 성능이 극도로 중요한 상황(예: 대규모 버텍스 데이터 GPU 전송)이 아니라면, 중요한 계산(서버에서의 물리 연산 등)에는 double 사용을 고려해야 합니다.
  • 고정 소수점 (Fixed-Point Arithmetic): 정수를 사용하여 소수를 표현하는 방식입니다. 예를 들어, 정수 1000을 실제 값 1.0으로 간주하는 식입니다.
    • 장점: 연산이 빠르고 결정적이며, 표현 오차가 없습니다.
    • 단점: 표현할 수 있는 수의 범위가 제한적이고, 오버플로우/언더플로우를 직접 관리해야 합니다.
    • 사용처: 돈 계산, 또는 값의 범위가 예측 가능한 게임의 특정 수치(예: 일부 능력치)에 적합합니다.
  • 안정적인 알고리즘 사용: Kahan의 합산 알고리즘처럼, 정밀도 손실을 최소화하도록 설계된 수치적으로 안정적인 알고리즘을 사용합니다.

2. 요약

 컴퓨터에서 실수는 IEEE 754 표준에 따라 부호, 지수, 가수로 구성된 이진수 근사치로 저장됩니다. 이 과정에서 십진수 소수를 이진수로 완벽히 옮기지 못해 '표현 오차'가 발생합니다.

 이 오차 때문에 발생하는 주요 문제는 세 가지입니다.

첫째, 0.1 + 0.20.3과 정확히 같지 않을 수 있어 == 비교가 위험합니다. 따라서 항상 아주 작은 값(엡실론)보다 작은지 비교해야 합니다.

둘째, 큰 수와 작은 수를 더하면 작은 수가 무시되는 '정밀도 손실'이 발생할 수 있습니다.

셋째, 비슷한 두 수를 빼면 유효 숫자가 크게 줄어드는 '재앙적 소거' 문제가 있습니다.

 이러한 문제에 대응하기 위해, 정밀도가 더 높은 double을 사용하거나, 정수를 활용하는 '고정 소수점' 방식을 도입하거나, 오차를 보정하는 안정적인 알고리즘을 적용하는 것을 고려해야 합니다.

3. 실습 코드

#include <iostream>
#include <vector>
#include <cmath>    // std::abs
#include <cfloat>   // FLT_EPSILON
#include <iomanip>  // std::fixed, std::setprecision

bool isApproximatelyEqual(float a, float b) {
    return std::abs(a - b) <= FLT_EPSILON * std::max(1.0f, std::max(std::abs(a), std::abs(b)));
}

// 플레이어의 상태를 나타내는 구조체
struct PlayerState {
    float maxHealth = 100.0f;
    float currentHealth = 100.0f;
    int defense = 10;
};

// 플레이어에게 데미지를 적용하는 함수
void applyDamage(PlayerState& player, float rawDamage) {
    float finalDamage = std::max(0.0f, rawDamage - player.defense);
    player.currentHealth -= finalDamage;
    std::cout << "데미지 " << finalDamage << " 적용. 현재 체력: " << player.currentHealth << std::endl;
}

int main() {
    // 부동소수점 출력을 고정 소수점 형식으로 설정하여 관찰 용이하게 함
    std::cout << std::fixed << std::setprecision(10);

    PlayerState player;

    // --- 문제 상황 1: 누적 오차로 인한 비교 실패 ---
    std::cout << "--- 문제 1: 누적 오차 테스트 ---" << std::endl;
    // 0.1f 데미지를 10번 연속으로 가함
    for (int i = 0; i < 10; ++i) {
        player.currentHealth -= 0.1f;
    }

    // 총 1.0f의 체력이 감소했으므로, 99.0f가 되어야 할 것으로 기대됨
    std::cout << "10번의 0.1f 데미지 후, 예상 체력: 99.0, 실제 체력: " << player.currentHealth << std::endl;

    if (player.currentHealth == 99.0f) {
        std::cout << "결과: 체력이 정확히 99.0 입니다." << std::endl;
    } else {
        std::cout << "결과: 체력이 99.0이 아닙니다!" << std::endl;
    }

    // 올바른 비교 방법
    if (isApproximatelyEqual(player.currentHealth, 99.0f)) {
        std::cout << "엡실론 비교 결과: 체력이 거의 99.0 입니다." << std::endl;
    }
    std::cout << std::endl;


    // --- 문제 상황 2: 정밀도 손실 (흡수) ---
    std::cout << "--- 문제 2: 정밀도 손실 테스트 ---" << std::endl;
    player.currentHealth = 100000000.0f; // 체력이 매우 큰 값이라고 가정 (예: 월드 보스)
    float originalHealth = player.currentHealth;
    float smallHeal = 0.00001f;

    player.currentHealth += smallHeal; // 아주 작은 양의 치유

    if (player.currentHealth == originalHealth) {
        std::cout << "결과: 작은 양의 치유가 적용되지 않았습니다!" << std::endl;
        std::cout << "치유 시도 후 체력: " << player.currentHealth << std::endl;
    } else {
        std::cout << "결과: 치유가 적용되었습니다." << std::endl;
        std::cout << "치유 시도 후 체력: " << player.currentHealth << std::endl;
    }

    return 0;
}

'C++' 카테고리의 다른 글

RTTI와 RAII  (0) 2025.11.20
인라인 함수 Inline Function  (0) 2025.11.19
class와 struct의 기능 호환성  (1) 2025.11.13
class와 struct의 차이  (0) 2025.11.13
STL 컨테이너의 주요 분류와 내부 구조  (0) 2025.09.29
iamrain
@iamrain :: Annals of Unreal

iamrain 님의 블로그 입니다.

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차