반응형

[PART2.변수와 기본 데이터 타입(5/13)] 실수형 — float · double · decimal

왜 실수형이 세 개나 있는가 / IEEE 754 부동소수점 오차의 정체 / 돈 계산에 decimal을 써야 하는 이유 / Unity가 float를 고집하는 구조적 근거


1. 문제 제기 — "0.1 + 0.2 == 0.3 이 왜 false 입니까"

Unity 프로젝트에서 인앱 결제 금액을 합산하거나, 캐릭터 위치를 10cm 단위로 움직일 때 이런 경험이 있으실 겁니다.

C#
float sum = 0.1f + 0.2f;
Debug.Log(sum == 0.3f);  // False

누구도 가르쳐주지 않았지만, 프로그래밍을 조금만 해보면 반드시 한 번은 만나게 되는 함정입니다. 값은 분명 0.3 근처인데 == 로 비교하면 계속 false. 이건 C#의 버그가 아니라 IEEE 754 부동소수점 이라는 국제 표준의 근본 동작입니다.

그리고 이 문제를 방치하면 실무에서 심각한 결과가 나옵니다.

  • 인앱 결제: 0.99달러짜리 아이템을 100번 샀는데 총액이 98.99999999달러로 찍히면, 회계 감사에서 문제가 됩니다.
  • 격투 게임 리플레이: 플레이어 A의 CPU와 플레이어 B의 CPU가 float 연산 결과의 마지막 비트가 미세하게 다르면, 같은 입력에 다른 결과가 나와 동기화가 깨집니다.
  • 오픈월드 좌표: 원점에서 10km 떨어지면 float 정밀도가 무너져 캐릭터가 부들부들 떠는 "jitter" 현상이 생깁니다.

C#은 이 문제에 세 가지 다른 무기를 제공합니다: float, double, decimal. 각각은 정확성 · 성능 · 메모리 의 삼각 트레이드오프에서 서로 다른 지점을 지키고 있습니다. 이 글에서는 세 타입이 "왜 따로 있는지", 내부적으로 "어떻게 다른지" (IL 까지 들여다봅니다), 그리고 Unity에서 "무엇을 언제 쓰는지" 까지 한 번에 정리합니다.

IEEE 754 — Institute of Electrical and Electronics Engineers 의 754번 표준. 1985년에 제정된 이진 부동소수점 표현 규격으로, 오늘날 거의 모든 CPU·GPU의 실수 연산이 이 방식을 따릅니다. floatdouble 은 이 표준의 단정밀도·배정밀도 타입입니다.

2. 개념 정의 — 저울 세 개로 무게를 다는 법

비유: 주방 저울 · 체중계 · 금 저울

세 실수 타입을 이렇게 떠올리시면 이해가 쉽습니다.

  • float주방 저울입니다. 빠르고 가볍지만, 눈금이 1g 단위입니다. 재료 100g ± 1g 오차는 요리에 문제없죠. Unity 좌표계 같은 "대충 7자리면 충분한 계산" 에 쓰입니다.
  • double체중계입니다. 0.1kg 단위까지 보여줍니다. 눈금이 훨씬 많아서 정밀하지만, 주방 저울보다 크고 무겁습니다. 과학 계산이나 일반 수학 연산의 기본값입니다.
  • decimal금 저울입니다. 0.001g 단위까지 보이는 대신, 저울 자체가 비싸고 느립니다. 금 1g 차이가 수십만 원이 되는 상황, 즉 돈 계산이 딱 그 용도입니다.

구조와 크기 비교

세 타입의 메모리 구조를 눈으로 보면 차이가 명확해집니다.

float (System.Single) — 32bit / 4byte — IEEE 754

요점만 기억하시면 됩니다.

  • float / double = 이진수 과학적 표기법. (부호) × (가수) × 2^(지수) — 그래서 10진수 0.1 을 정확히 못 담습니다.
  • decimal = 10진수 과학적 표기법. (부호) × (유효숫자) × 10^(-스케일)0.11 × 10^(-1)정확히 담습니다.

접미사 규칙 — 리터럴의 타입을 결정합니다

C# 컴파일러는 소수점이 있는 리터럴을 기본적으로 double 로 해석합니다. 그래서 다음 코드는 컴파일되지 않습니다.

C#
float a = 3.14;       // ❌ 컴파일 에러: double → float 암시 변환 불가
decimal c = 3.14;     // ❌ 컴파일 에러: double → decimal 변환 불가

접미사로 타입을 명시해야 합니다.

C#
float   a = 3.14f;   // f 또는 F
double  b = 3.14;    // 생략 가능 (기본값), 또는 3.14d / 3.14D
decimal c = 3.14m;   // m 또는 M  ("money" 의 m)

m 이 "money" 를 뜻한다는 점이 decimal 의 용도를 압축적으로 보여줍니다.

기본 C# 코드와 IL — 세 타입이 IL에서 어떻게 다른가

세 리터럴을 넣고 덧셈 한 번씩만 하는 최소 코드로 비교해 보겠습니다.

C#
public class Program
{
    public static void Main()
    {
        float a = 3.14f;
        double b = 3.14;
        decimal c = 3.14m;

        float sumF = a + 1.0f;
        double sumD = b + 1.0;
        decimal sumM = c + 1.0m;
    }
}

이 코드를 Debug 빌드한 뒤 ilspycmd 로 디컴파일한 실제 IL 입니다.

IL
.method public hidebysig static void Main () cil managed
{
    .locals init (
        [0] float32,                                      // float a
        [1] float64,                                      // double b
        [2] valuetype [System.Runtime]System.Decimal,     // decimal c (값 타입)
        [3] float32, [4] float64,
        [5] valuetype [System.Runtime]System.Decimal
    )

    // --- float a = 3.14f; ---
    IL_0001: ldc.r4 3.14                                  // 32bit 부동소수점 상수 적재
    IL_0006: stloc.0

    // --- double b = 3.14; ---
    IL_0007: ldc.r8 3.14                                  // 64bit 부동소수점 상수 적재
    IL_0010: stloc.1

    // --- decimal c = 3.14m; ---  ★ 전용 로드 명령어가 없습니다
    IL_0011: ldloca.s 2                                   // c의 주소를 스택에
    IL_0013: ldc.i4 314                                   // lo  = 314
    IL_0018: ldc.i4.0                                     // mid = 0
    IL_0019: ldc.i4.0                                     // hi  = 0
    IL_001a: ldc.i4.0                                     // sign= 0 (양수)
    IL_001b: ldc.i4.2                                     // scale= 2  → 314 × 10^(-2) = 3.14
    IL_001c: call instance void [System.Runtime]System.Decimal::.ctor(int32, int32, int32, bool, uint8)

    // --- float sumF = a + 1.0f; ---
    IL_0021: ldloc.0
    IL_0022: ldc.r4 1
    IL_0027: add                                          // CPU 네이티브 add (FPU)
    IL_0028: stloc.3

    // --- double sumD = b + 1.0; ---
    IL_0029: ldloc.1
    IL_002a: ldc.r8 1
    IL_0033: add                                          // CPU 네이티브 add (FPU)
    IL_0034: stloc.s 4

    // --- decimal sumM = c + 1.0m; ---  ★ 메서드 호출입니다
    IL_0036: ldloc.2
    IL_0037: ldc.i4.s 10                                  // lo=10, scale=1 → 10 × 10^(-1) = 1.0
    IL_0039: ldc.i4.0
    IL_003a: ldc.i4.0
    IL_003b: ldc.i4.0
    IL_003c: ldc.i4.1
    IL_003d: newobj instance void [System.Runtime]System.Decimal::.ctor(int32, int32, int32, bool, uint8)
    IL_0042: call valuetype [System.Runtime]System.Decimal [System.Runtime]System.Decimal::op_Addition(
                 valuetype [System.Runtime]System.Decimal, valuetype [System.Runtime]System.Decimal)
    IL_0047: stloc.s 5
    IL_0049: ret
}

이 IL이 증명하는 세 가지 사실

  1. float = ldc.r4 / double = ldc.r8 — CLR에는 두 타입 전용 로드 명령어가 있습니다. 상수 하나를 적재하는 데 IL 한 줄이면 됩니다.
  2. decimal 은 전용 명령어가 없습니다. 매 리터럴마다 5개의 int32(lo/mid/hi/sign/scale) 를 스택에 쌓고 System.Decimal::.ctor 를 호출해 구조체를 초기화합니다. IL 6~7줄이 드는 셈입니다.
  3. 연산자도 차이 납니다. float / double+ 는 IL 명령어 add 한 줄. decimal+ 는 정적 메서드 op_Additioncall 한 줄입니다. CPU의 FPU(부동소수점 유닛)가 직접 처리하는 것과, CLR이 메서드를 호출해 소프트웨어적으로 처리하는 것의 차이입니다.

이 구조적 차이 때문에 decimaldouble 보다 약 10~20배 느립니다. 대신 10진 오차가 없습니다.

FPU (Floating-Point Unit) — CPU 내부에 있는 부동소수점 연산 전용 회로. float / double 의 덧셈·곱셈을 하드웨어 명령어 한 번으로 처리하며, 이 때문에 이진 부동소수점은 근본적으로 빠릅니다.

3. 내부 동작 — IEEE 754가 0.1을 표현할 수 없는 이유

0.1 + 0.2 != 0.3 을 이해하려면 IEEE 754가 숫자를 어떻게 저장하는지를 봐야 합니다.

2진수로 0.1 표현하기

10진수 0.1 을 2진수로 바꾸면 이렇게 됩니다.

0.1 (10진) = 0.00011001100110011001100110011... (2진, 무한 반복)

10진에서 1/3 = 0.33333... 이 무한 반복이듯, 2진에서는 0.1 이 무한 반복합니다. IEEE 754 float 는 이 무한 반복 중 앞쪽 23비트만 기억하고 나머지는 버립니다. double52비트를 기억합니다. 둘 다 "저장된 값은 진짜 0.1 이 아니라 살짝 큰 근사치" 라는 운명을 피할 수 없습니다.

10진 0.1을 2진 float(32bit)에 저장할 때

이제 실제로 이 오차를 IL 수준에서 확인해 보겠습니다.

C#
using System;

public class Program
{
    public static void Main()
    {
        // Before: float == 직접 비교 (오차 때문에 실패 가능)
        float a = 0.1f + 0.2f;
        bool equalsDirect = (a == 0.3f);

        // After: epsilon 허용 비교
        bool equalsEpsilon = Math.Abs(a - 0.3f) < 1e-6f;

        Console.WriteLine(equalsDirect);
        Console.WriteLine(equalsEpsilon);
    }
}
IL
.method public hidebysig static void Main () cil managed
{
    .locals init ([0] float32, [1] bool, [2] bool)

    // ★ 컴파일러 상수 폴딩: 0.1f + 0.2f 를 미리 계산해서 0.3f로 직접 로드
    IL_0001: ldc.r4 0.3                      // a = 0.3 (정확히는 0.30000001192092896...)
    IL_0006: stloc.0

    // --- bool equalsDirect = (a == 0.3f); ---
    IL_0007: ldloc.0
    IL_0008: ldc.r4 0.3
    IL_000d: ceq                             // 두 float32 비트 패턴 비교
    IL_000f: stloc.1

    // --- bool equalsEpsilon = Math.Abs(a - 0.3f) < 1e-6f; ---
    IL_0010: ldloc.0
    IL_0011: ldc.r4 0.3
    IL_0016: sub                             // a - 0.3f
    IL_0017: call float32 [System.Runtime]System.Math::Abs(float32)
    IL_001c: ldc.r4 1E-06                    // epsilon
    IL_0021: clt                             // Abs(차) < epsilon ?
    IL_0023: stloc.2
    ...
}

IL이 주는 힌트

  1. 상수 폴딩 (constant folding) 이 보입니다. C# 컴파일러는 0.1f + 0.2f 를 컴파일 타임에 계산해 ldc.r4 0.3 으로 치환합니다. 그런데도 런타임의 0.3f 리터럴과 비트가 다릅니다 — 왜냐하면 0.1f + 0.2f 의 반올림 오차가 누적된 결과와, 0.3 이라는 리터럴을 float으로 변환한 결과의 마지막 비트가 다르기 때문입니다.
  2. ceq — 부동소수점 비교는 "비트 패턴이 완전히 동일한가" 를 봅니다. 한 비트라도 다르면 false.
  3. 해결책 ILMath.Abs(a - 0.3f) 로 차이를 구하고, clt (compare less than) 로 epsilon(1e-6f) 보다 작은지 확인합니다.

결론: float / double 은 절대 == 로 비교하지 마십시오. Unity라면 Mathf.Approximately(a, b) 를, 순수 C#이라면 Math.Abs(a - b) < epsilon 을 쓰십시오.

상수 폴딩 (constant folding) — 컴파일러가 컴파일 타임에 계산 가능한 표현식을 미리 계산해 결과값으로 치환하는 최적화. 3 + 47 로 바꾸는 식입니다. 런타임 비용을 0으로 만드는 대신, 컴파일 타임 반올림이 들어갈 수 있습니다.

4. 실전 적용 — 돈 계산은 decimal, 게임 좌표는 float

Before/After: 인앱 결제 금액 합산

Unity 상점 시스템에서 구매 내역을 누적할 때 이런 코드를 보신 적 있을 겁니다.

C#
// ❌ Before: double 로 금액 합산 — 오차 누적
public class ShopServiceBad
{
    private double _total;
    public void AddPurchase(double price) => _total += price;
    public double Total => _total;
}

// 호출
var shop = new ShopServiceBad();
for (int i = 0; i < 100; i++) shop.AddPurchase(0.1);   // 0.1 달러짜리 아이템 100개
Debug.Log(shop.Total);  // 9.99999999999998  ← 10달러가 안 나옵니다!

실제로 double 을 100번 더하면 10.0 이 나오지 않습니다. 마지막 비트 오차가 매 덧셈마다 누적되기 때문입니다. 합산 횟수가 늘어날수록 오차가 커집니다.

C#
// ✅ After: decimal 로 금액 합산 — 오차 없음
public class ShopService
{
    private decimal _total;
    public void AddPurchase(decimal price) => _total += price;
    public decimal Total => _total;
}

var shop = new ShopService();
for (int i = 0; i < 100; i++) shop.AddPurchase(0.1m);
Debug.Log(shop.Total);  // 10.0  ← 정확합니다

두 패턴의 IL을 비교해 보면 성능 차이의 근거도 보입니다.

C#
// 비교용 최소 코드
double priceD = 0.0;
for (int i = 0; i < 100; i++) priceD += 0.1;

decimal priceM = 0m;
for (int i = 0; i < 100; i++) priceM += 0.1m;
IL
// ▼ double 누적 루프
IL_0001: ldc.r8 0.0                   // priceD = 0.0
IL_000a: stloc.0
...
// loop start
    IL_000f: ldloc.0
    IL_0010: ldc.r8 0.1               // 0.1 상수 적재
    IL_0019: add                      // CPU 네이티브 덧셈 ← 1사이클짜리
    IL_001a: stloc.0
...

// ▼ decimal 누적 루프
IL_0028: ldloca.s 1
IL_002a: initobj [System.Runtime]System.Decimal    // priceM = 0m (구조체 0 초기화)
...
// loop start
    IL_0035: ldloc.1
    IL_0036: ldc.i4.1                                   // lo=1
    IL_0037: ldc.i4.0
    IL_0038: ldc.i4.0
    IL_0039: ldc.i4.0
    IL_003a: ldc.i4.1                                   // scale=1 → 0.1m
    IL_003b: newobj instance void [System.Runtime]System.Decimal::.ctor(int32, int32, int32, bool, uint8)
    IL_0040: call valuetype [System.Runtime]System.Decimal [System.Runtime]System.Decimal::op_Addition(
                 valuetype [System.Runtime]System.Decimal, valuetype [System.Runtime]System.Decimal)
    IL_0045: stloc.1
...

IL이 말해주는 성능 차이

  1. double 루프 본체: 덧셈 1회당 IL 3줄 (ldloc → ldc.r8 → add). CPU에서 실제 add 는 1~4클럭이면 끝납니다.
  2. decimal 루프 본체: 덧셈 1회당 IL 8줄 + newobj + call op_Addition. 매번 0.1m 이라는 새 Decimal 구조체를 만들고, op_Addition 정적 메서드를 호출합니다.
  3. 즉, decimal 연산은 CPU FPU가 아니라 소프트웨어로 구현된 10진 연산 알고리즘을 거칩니다. 그래서 10~20배 느린 것입니다.

판단 기준이 명확해집니다: Unity에서도 "돈·재화" 관련 누적 합산은 결제 액수가 클수록 decimal 이 안전하지만, 게임 내 소수점 없는 재화(골드 개수 등)라면 intlong 이 더 낫습니다. double 은 이 둘 사이에 끼어 어정쩡한 역할입니다.

Unity 핫패스 — 왜 Vector3float 기반인가

Unity의 Transform.position, Rigidbody.velocity, Mathf.Sin 은 모두 float 를 씁니다. double 버전은 아예 제공되지 않습니다. 여기에는 세 가지 구조적 이유가 있습니다.

SIMD 128bit 레지스터 한 번에 담기는 실수 개수
  1. SIMD 친화성 — Burst 컴파일러는 CPU의 128비트 SIMD 레지스터에 float4(float 4개)를 한 번에 넣어 4개 연산을 1사이클에 처리합니다. double 이면 2개밖에 못 넣으니 처리량이 절반으로 떨어집니다. Vector3 · Quaternion · Matrix4x4 가 모두 float 기반인 이유가 여기 있습니다.
  2. GPU 친화성 — 그래픽스 하드웨어는 전통적으로 단정밀도(float) 에 극단적으로 최적화돼 있습니다. 셰이더 언어(HLSL/GLSL)의 기본 실수 타입도 float 입니다. CPU ↔ GPU 간 버퍼 전송도 float 배열이 자연스럽습니다.
  3. 메모리 대역폭 — 60fps 게임에서 초당 60프레임 × 수천 개 오브젝트 × 각 오브젝트 Transform 3개 float(위치) = 초당 수만 번의 실수 읽기·쓰기입니다. double 로 바꾸면 메모리 사용량·캐시 미스·GC 스캔 시간이 전부 두 배가 됩니다.
SIMD (Single Instruction Multiple Data) — CPU가 하나의 명령어로 여러 데이터를 동시에 처리하는 기능. 128비트 레지스터에 32비트 float 4개를 묶어 한 번에 곱셈하는 식입니다. Unity의 Burst 컴파일러가 DOTS 성능 향상에 적극 활용합니다.

형변환 규칙 — 언제 implicit, 언제 explicit인가

C#
public class Program
{
    public static void Main()
    {
        float f = 1.5f;

        // 암시적 변환: float → double (손실 없음)
        double d = f;

        // 명시적 변환: double → float (정밀도 손실 가능)
        double big = 3.1415926535d;
        float small = (float)big;

        // 명시적 변환: double → decimal
        decimal m = (decimal)big;

        // 명시적 변환: decimal → double
        double back = (double)m;
    }
}
IL
.locals init (
    [0] float32,
    [1] float64,
    [2] float64,
    [3] float32,
    [4] valuetype [System.Runtime]System.Decimal,
    [5] float64
)

// --- double d = f; ---
IL_0007: ldloc.0
IL_0008: conv.r8                       // ★ float32 → float64, IL 한 줄, 손실 없음
IL_0009: stloc.1

// --- float small = (float)big; ---
IL_0014: ldloc.2
IL_0015: conv.r4                       // ★ float64 → float32, 반올림 발생
IL_0016: stloc.3

// --- decimal m = (decimal)big; ---
IL_0017: ldloc.2
IL_0018: call valuetype Decimal [System.Runtime]System.Decimal::op_Explicit(float64)
                                       // ★ 메서드 호출로 변환 (2진→10진)
IL_001d: stloc.s 4

// --- double back = (double)m; ---
IL_001f: ldloc.s 4
IL_0021: call float64 [System.Runtime]System.Decimal::op_Explicit(valuetype Decimal)
IL_0026: conv.r8                       // 10진→2진
IL_0027: stloc.s 5

IL 핵심

  1. float → doubleconv.r8 한 줄로 끝납니다. 비트를 확장할 뿐 정보 손실이 없어 C#이 implicit 으로 허용합니다.
  2. double → floatconv.r4 — 가수 비트가 잘려나가므로 반드시 (float) 명시 캐스팅 필요합니다.
  3. float·doubledecimalop_Explicit 메서드 호출로 변환됩니다. 내부 표현이 근본적으로 달라서 (이진 vs 10진) C#은 항상 명시 캐스팅을 요구합니다. 이 호출 자체가 비싸다는 점을 기억하십시오 — 핫패스에서 decimaldouble 을 왔다갔다 하면 안 됩니다.

5. 함정과 주의사항

함정 1: Update() 루프에서 매 프레임 decimal 연산

C#
// ❌ 잘못된 패턴
void Update()
{
    decimal damage = (decimal)baseDamage * (decimal)multiplier;
    ApplyDamage((float)damage);
}

게임 로직에서 decimal 을 쓰면 매 프레임 newobj + op_* 호출이 반복됩니다. float 대비 10~20배 느리고, Decimal 구조체는 16바이트라 스택 복사 비용도 두 배입니다. 게임 연산에는 의미 없는 오버헤드입니다.

C#
// ✅ 올바른 패턴 — 게임 로직은 float
void Update()
{
    float damage = baseDamage * multiplier;
    ApplyDamage(damage);
}

판단 기준: "이 연산의 오차가 플레이어에게 1원의 손해라도 일으키는가?" 아니라면 float 입니다.

함정 2: 좌표가 커지면 float 정밀도가 무너집니다

C#
// ❌ 잘못된 패턴 — 오픈월드에서 원점에서 10km 떨어진 지점
transform.position = new Vector3(10000.0f, 0, 0);
transform.position += new Vector3(0.01f, 0, 0);   // 1cm 이동?
// 실제로는 이동하지 않을 수 있습니다

float 는 값이 커지면 표현 가능한 간격이 벌어집니다. 10000 근처에서 인접한 두 float 사이의 간격은 약 0.00097. 0.01은 표현 가능하지만 오차가 누적되면 미세한 jitter 가 생깁니다. 원점에서 100km 정도 떨어지면 1cm 단위 이동이 아예 반영되지 않는 수준이 됩니다.

C#
// ✅ 올바른 패턴 — 월드 오리진 시프트 (World Origin Shift)
void LateUpdate()
{
    if (player.position.magnitude > 1000f)
    {
        Vector3 offset = player.position;
        foreach (var root in sceneRoots) root.position -= offset;
        player.position = Vector3.zero;
    }
}

오픈월드·우주 게임에서는 플레이어를 항상 원점 근처에 유지하고 세계 전체를 반대로 움직이는 기법을 씁니다. 그래야 float 7자리 정밀도를 최대로 활용합니다.

함정 3: Mathf.Approximately 는 대소 비교가 아닙니다

C#
// ❌ 잘못된 기대
if (Mathf.Approximately(speed, 0f)) {/* 멈춰 있다 */}
if (speed < 0.0001f)                 {/* 거의 멈췄다 — 이것도 동의어가 아닙니다 */}

Mathf.Approximately(a, b) 는 내부적으로 Mathf.Abs(a - b) < Mathf.Max(1e-06f * Mathf.Max(Mathf.Abs(a), Mathf.Abs(b)), Mathf.Epsilon * 8) 을 검사합니다. 값이 커질수록 허용 오차도 커지는 상대 오차 비교입니다. 0에 가까운 값끼리는 잘 동작하지만, 값 자체가 매우 작아지면 Mathf.Epsilon * 8 에 달라붙습니다.

C#
// ✅ 올바른 패턴 — 용도에 맞는 epsilon 사용
const float VELOCITY_EPSILON = 0.01f;  // "1cm/s 미만이면 정지한 것으로 본다"
if (Mathf.Abs(speed) < VELOCITY_EPSILON) {/* 멈췄다 */}

판단 기준: "이 문제에서 '같다' 로 인정할 오차는 얼마인가?" 를 상수로 정의하십시오. Mathf.Approximately 는 범용 유틸이지 모든 비교의 정답이 아닙니다.

함정 4: 결정론적 시뮬레이션에 float 를 쓰는 실수

C#
// ❌ 잘못된 패턴 — RTS / 격투 게임 P2P 동기화
void Simulate(PlayerInput input)
{
    playerPosition += input.direction * speed * deltaTime;  // float
    // 플레이어 A와 B의 CPU가 같은 입력으로 다른 결과를 낼 수 있습니다
}

IEEE 754 표준은 "연산 결과"를 보장하지만, 컴파일러 최적화·CPU 아키텍처·스레드 스케줄링에 따라 마지막 비트가 달라질 수 있습니다. Intel x86 과 ARM에서 Mathf.Sin(1.0f) 이 비트 수준으로 다를 수 있다는 뜻입니다. P2P 네트워크 게임에서 이건 치명적입니다 — 두 클라이언트의 시뮬레이션이 서서히 벌어집니다.

C#
// ✅ 올바른 패턴 — 고정소수점 (fixed-point) 라이브러리 사용
struct Fix64  // long 기반, 16.48 고정소수점 같은 구현
{
    private long _rawValue;
    public static Fix64 operator+(Fix64 a, Fix64 b) => new Fix64 { _rawValue = a._rawValue + b._rawValue };
    // ...
}

void Simulate(PlayerInput input)
{
    playerPosition += input.direction * speed * deltaTime;  // Fix64
    // 모든 CPU에서 비트 수준으로 동일한 결과가 보장됩니다
}

결정론적 게임은 보통 long 기반 고정소수점(Fix64 등)을 직접 구현하거나 서드파티를 씁니다. decimal 은 느리고 크기가 커서 이 용도에도 부적합합니다.


6. C# 버전별 변화

실수 타입 자체의 정의는 C# 1.0 부터 현재까지 동일합니다 — 32/64/128비트, 접미사, 변환 규칙 모두 그대로입니다. 대신 리터럴 문법과 보조 기능이 버전을 거쳐 개선됐습니다.

C# 7.0 — 숫자 리터럴 구분자

긴 숫자의 가독성을 높이는 _ 구분자가 도입됐습니다.

C#
// ❌ Before (C# 6 이하)
decimal salary = 1234567.89m;   // 자릿수 세기 어렵습니다

// ✅ After (C# 7.0+)
decimal salary = 1_234_567.89m; // 눈에 들어옵니다
double lightSpeed = 299_792_458;

컴파일된 IL은 동일합니다 — _ 는 컴파일러가 무시하는 시각 장식일 뿐입니다.

IL
// decimal salary = 1_234_567.89m;  와  decimal salary = 1234567.89m;  는
IL_0001: ldc.i4 ...        // 동일한 IL로 컴파일됩니다
IL_0006: ldc.i4 ...
...
IL_0017: newobj instance void System.Decimal::.ctor(int32,int32,int32,bool,uint8)

C# 7.0 — out var / 튜플과의 조합

실수 자체 변화는 아니지만, 파싱 관련 API 사용성이 개선됐습니다.

C#
// ❌ Before
double d;
if (double.TryParse(input, out d)) { /* 사용 */ }

// ✅ After (C# 7.0+)
if (double.TryParse(input, out double d)) { /* 사용 */ }
if (double.TryParse(input, out var d))    { /* 사용 */ }

.NET 5 / C# 9 — System.Half (16비트 부동소수점)

게임·머신러닝 수요에 맞춰 반정밀도(Half, 16비트) 타입이 표준 라이브러리에 추가됐습니다. C# 언어 자체 리터럴 접미사는 없어 (Half)3.14f 식 캐스팅으로만 만듭니다.

C#
// Half — 16bit / 3~4자리 정밀도 / ML·GPU 버퍼·대량 데이터 전송에 유용
using System;

Half h = (Half)3.14f;         // 리터럴 접미사 없음 — 캐스팅 필수
Half sum = (Half)(h + (Half)1.0f);

Unity 6 기준 Half 를 엔진 API에서 바로 쓰긴 아직 제한적이지만, ComputeBufferNativeArray<Half> 같은 저수준 버퍼에서 메모리 절약 목적으로 활용됩니다.

.NET 7 / C# 11 — 제네릭 수학 인터페이스 (INumber<T>)

C#에 "숫자를 다루는 제네릭 추상화" 가 추가됐습니다. 이제 float, double, decimal같은 제네릭 메서드 하나로 다룰 수 있습니다.

C#
// ❌ Before (C# 10 이하) — 타입마다 오버로드를 다 만들어야 했습니다
public static float  Sum(float[]   values)  { /* ... */ }
public static double Sum(double[]  values)  { /* ... */ }
public static decimal Sum(decimal[] values) { /* ... */ }

// ✅ After (C# 11+) — 하나의 제네릭으로 해결
public static T Sum<T>(T[] values) where T : INumber<T>
{
    T total = T.Zero;
    foreach (var v in values) total += v;
    return total;
}

// 호출
float  sf = Sum(new float[]  { 1.0f, 2.0f });
double sd = Sum(new double[] { 1.0,  2.0  });
decimal sm = Sum(new decimal[] { 1.0m, 2.0m });

주의: INumber<T> 기반 코드는 제네릭 특성상 constrained. / callvirt 로 인해 Unity IL2CPP 환경에서 AOT 컴파일 오버헤드가 있을 수 있습니다. 핫패스에서는 여전히 타입별 오버로드가 안전합니다.


7. 정리 — 기억해야 할 것들

  • 타입 선택은 "정확성 vs 성능" 트레이드오프. Unity 게임 좌표·물리 = float, 과학 계산 = double, 돈 = decimal.
  • float/double== 로 비교하지 마십시오. Math.Abs(a - b) < epsilon 또는 Mathf.Approximately 를 쓰십시오.
  • decimal 은 CPU 네이티브 타입이 아닙니다. 전용 IL 명령어 없이 newobj + op_Addition 메서드 호출로 구현되어 10~20배 느립니다. 핫패스·게임 로직에 쓰지 마십시오.
  • 접미사 규칙: 3.14f (float) / 3.14 또는 3.14d (double) / 3.14m (decimal). 소수 리터럴의 기본 타입은 double 입니다.
  • 형변환: float → double 만 implicit. 나머지는 모두 explicit. float/double ↔ decimalop_Explicit 메서드 호출이라 비용이 있습니다.
  • Unity가 float 기반인 이유: 32비트 × 4개 = SIMD 128비트 한 번에 처리, GPU 친화성, 메모리 대역폭. double 을 고집할 구조적 이유가 없습니다.
  • 오픈월드에서 좌표가 커지면 float 정밀도가 무너집니다. World Origin Shift 기법으로 원점 근처를 유지하십시오.
  • 결정론적 멀티플레이 (격투·RTS) 는 float 대신 long 기반 고정소수점을 쓰십시오. decimal 도 부적합합니다.
  • IL 레벨에서: ldc.r4 (float) / ldc.r8 (double) / newobj System.Decimal::.ctor + call op_Addition (decimal). 이 차이가 성능 차이의 직접 원인입니다.
  • C# 11+INumber<T> 로 세 타입을 하나의 제네릭으로 묶을 수 있지만, Unity IL2CPP 핫패스에서는 여전히 타입별 오버로드가 안전합니다.
반응형

+ Recent posts