[PART2.변수와 기본 데이터 타입(5/13)] 실수형 — float · double · decimal
왜 실수형이 세 개나 있는가 / IEEE 754 부동소수점 오차의 정체 / 돈 계산에 decimal을 써야 하는 이유 / Unity가 float를 고집하는 구조적 근거
목차
1. 문제 제기 — "0.1 + 0.2 == 0.3 이 왜 false 입니까"
Unity 프로젝트에서 인앱 결제 금액을 합산하거나, 캐릭터 위치를 10cm 단위로 움직일 때 이런 경험이 있으실 겁니다.
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의 실수 연산이 이 방식을 따릅니다.float와double은 이 표준의 단정밀도·배정밀도 타입입니다.
2. 개념 정의 — 저울 세 개로 무게를 다는 법
비유: 주방 저울 · 체중계 · 금 저울
세 실수 타입을 이렇게 떠올리시면 이해가 쉽습니다.
float는 주방 저울입니다. 빠르고 가볍지만, 눈금이 1g 단위입니다. 재료 100g ± 1g 오차는 요리에 문제없죠. Unity 좌표계 같은 "대충 7자리면 충분한 계산" 에 쓰입니다.double은 체중계입니다. 0.1kg 단위까지 보여줍니다. 눈금이 훨씬 많아서 정밀하지만, 주방 저울보다 크고 무겁습니다. 과학 계산이나 일반 수학 연산의 기본값입니다.decimal은 금 저울입니다. 0.001g 단위까지 보이는 대신, 저울 자체가 비싸고 느립니다. 금 1g 차이가 수십만 원이 되는 상황, 즉 돈 계산이 딱 그 용도입니다.
구조와 크기 비교
세 타입의 메모리 구조를 눈으로 보면 차이가 명확해집니다.

요점만 기억하시면 됩니다.
- float / double = 이진수 과학적 표기법.
(부호) × (가수) × 2^(지수)— 그래서 10진수0.1을 정확히 못 담습니다. - decimal = 10진수 과학적 표기법.
(부호) × (유효숫자) × 10^(-스케일)—0.1을1 × 10^(-1)로 정확히 담습니다.
접미사 규칙 — 리터럴의 타입을 결정합니다
C# 컴파일러는 소수점이 있는 리터럴을 기본적으로 double 로 해석합니다. 그래서 다음 코드는 컴파일되지 않습니다.
float a = 3.14; // ❌ 컴파일 에러: double → float 암시 변환 불가
decimal c = 3.14; // ❌ 컴파일 에러: double → decimal 변환 불가
접미사로 타입을 명시해야 합니다.
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에서 어떻게 다른가
세 리터럴을 넣고 덧셈 한 번씩만 하는 최소 코드로 비교해 보겠습니다.
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 입니다.
.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이 증명하는 세 가지 사실
float=ldc.r4/double=ldc.r8— CLR에는 두 타입 전용 로드 명령어가 있습니다. 상수 하나를 적재하는 데 IL 한 줄이면 됩니다.decimal은 전용 명령어가 없습니다. 매 리터럴마다 5개의int32(lo/mid/hi/sign/scale) 를 스택에 쌓고System.Decimal::.ctor를 호출해 구조체를 초기화합니다. IL 6~7줄이 드는 셈입니다.- 연산자도 차이 납니다.
float/double의+는 IL 명령어add한 줄.decimal의+는 정적 메서드op_Addition의call한 줄입니다. CPU의 FPU(부동소수점 유닛)가 직접 처리하는 것과, CLR이 메서드를 호출해 소프트웨어적으로 처리하는 것의 차이입니다.
이 구조적 차이 때문에 decimal 은 double 보다 약 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비트만 기억하고 나머지는 버립니다. double 은 52비트를 기억합니다. 둘 다 "저장된 값은 진짜 0.1 이 아니라 살짝 큰 근사치" 라는 운명을 피할 수 없습니다.

이제 실제로 이 오차를 IL 수준에서 확인해 보겠습니다.
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);
}
}
.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이 주는 힌트
- 상수 폴딩 (
constant folding) 이 보입니다. C# 컴파일러는0.1f + 0.2f를 컴파일 타임에 계산해ldc.r4 0.3으로 치환합니다. 그런데도 런타임의0.3f리터럴과 비트가 다릅니다 — 왜냐하면0.1f + 0.2f의 반올림 오차가 누적된 결과와,0.3이라는 리터럴을 float으로 변환한 결과의 마지막 비트가 다르기 때문입니다. ceq— 부동소수점 비교는 "비트 패턴이 완전히 동일한가" 를 봅니다. 한 비트라도 다르면false.- 해결책 IL —
Math.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 + 4를7로 바꾸는 식입니다. 런타임 비용을 0으로 만드는 대신, 컴파일 타임 반올림이 들어갈 수 있습니다.
4. 실전 적용 — 돈 계산은 decimal, 게임 좌표는 float
Before/After: 인앱 결제 금액 합산
Unity 상점 시스템에서 구매 내역을 누적할 때 이런 코드를 보신 적 있을 겁니다.
// ❌ 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 이 나오지 않습니다. 마지막 비트 오차가 매 덧셈마다 누적되기 때문입니다. 합산 횟수가 늘어날수록 오차가 커집니다.
// ✅ 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을 비교해 보면 성능 차이의 근거도 보입니다.
// 비교용 최소 코드
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;
// ▼ 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이 말해주는 성능 차이
double루프 본체: 덧셈 1회당 IL 3줄 (ldloc → ldc.r8 → add). CPU에서 실제add는 1~4클럭이면 끝납니다.decimal루프 본체: 덧셈 1회당 IL 8줄 +newobj+call op_Addition. 매번0.1m이라는 새 Decimal 구조체를 만들고,op_Addition정적 메서드를 호출합니다.- 즉,
decimal연산은 CPU FPU가 아니라 소프트웨어로 구현된 10진 연산 알고리즘을 거칩니다. 그래서 10~20배 느린 것입니다.
판단 기준이 명확해집니다: Unity에서도 "돈·재화" 관련 누적 합산은 결제 액수가 클수록 decimal 이 안전하지만, 게임 내 소수점 없는 재화(골드 개수 등)라면 int 나 long 이 더 낫습니다. double 은 이 둘 사이에 끼어 어정쩡한 역할입니다.
Unity 핫패스 — 왜 Vector3 가 float 기반인가
Unity의 Transform.position, Rigidbody.velocity, Mathf.Sin 은 모두 float 를 씁니다. double 버전은 아예 제공되지 않습니다. 여기에는 세 가지 구조적 이유가 있습니다.

- SIMD 친화성 — Burst 컴파일러는 CPU의 128비트 SIMD 레지스터에
float4(float 4개)를 한 번에 넣어 4개 연산을 1사이클에 처리합니다.double이면 2개밖에 못 넣으니 처리량이 절반으로 떨어집니다.Vector3·Quaternion·Matrix4x4가 모두float기반인 이유가 여기 있습니다. - GPU 친화성 — 그래픽스 하드웨어는 전통적으로 단정밀도(
float) 에 극단적으로 최적화돼 있습니다. 셰이더 언어(HLSL/GLSL)의 기본 실수 타입도float입니다. CPU ↔ GPU 간 버퍼 전송도float배열이 자연스럽습니다. - 메모리 대역폭 — 60fps 게임에서 초당 60프레임 × 수천 개 오브젝트 × 각 오브젝트 Transform 3개
float(위치) = 초당 수만 번의 실수 읽기·쓰기입니다.double로 바꾸면 메모리 사용량·캐시 미스·GC 스캔 시간이 전부 두 배가 됩니다.
SIMD (Single Instruction Multiple Data) — CPU가 하나의 명령어로 여러 데이터를 동시에 처리하는 기능. 128비트 레지스터에 32비트 float 4개를 묶어 한 번에 곱셈하는 식입니다. Unity의 Burst 컴파일러가 DOTS 성능 향상에 적극 활용합니다.
형변환 규칙 — 언제 implicit, 언제 explicit인가
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;
}
}
.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 핵심
float → double은conv.r8한 줄로 끝납니다. 비트를 확장할 뿐 정보 손실이 없어 C#이implicit으로 허용합니다.double → float은conv.r4— 가수 비트가 잘려나가므로 반드시(float)명시 캐스팅 필요합니다.float·double↔decimal은op_Explicit메서드 호출로 변환됩니다. 내부 표현이 근본적으로 달라서 (이진 vs 10진) C#은 항상 명시 캐스팅을 요구합니다. 이 호출 자체가 비싸다는 점을 기억하십시오 — 핫패스에서decimal과double을 왔다갔다 하면 안 됩니다.
5. 함정과 주의사항
함정 1: Update() 루프에서 매 프레임 decimal 연산
// ❌ 잘못된 패턴
void Update()
{
decimal damage = (decimal)baseDamage * (decimal)multiplier;
ApplyDamage((float)damage);
}
게임 로직에서 decimal 을 쓰면 매 프레임 newobj + op_* 호출이 반복됩니다. float 대비 10~20배 느리고, Decimal 구조체는 16바이트라 스택 복사 비용도 두 배입니다. 게임 연산에는 의미 없는 오버헤드입니다.
// ✅ 올바른 패턴 — 게임 로직은 float
void Update()
{
float damage = baseDamage * multiplier;
ApplyDamage(damage);
}
판단 기준: "이 연산의 오차가 플레이어에게 1원의 손해라도 일으키는가?" 아니라면 float 입니다.
함정 2: 좌표가 커지면 float 정밀도가 무너집니다
// ❌ 잘못된 패턴 — 오픈월드에서 원점에서 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 단위 이동이 아예 반영되지 않는 수준이 됩니다.
// ✅ 올바른 패턴 — 월드 오리진 시프트 (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 는 대소 비교가 아닙니다
// ❌ 잘못된 기대
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 에 달라붙습니다.
// ✅ 올바른 패턴 — 용도에 맞는 epsilon 사용
const float VELOCITY_EPSILON = 0.01f; // "1cm/s 미만이면 정지한 것으로 본다"
if (Mathf.Abs(speed) < VELOCITY_EPSILON) {/* 멈췄다 */}
판단 기준: "이 문제에서 '같다' 로 인정할 오차는 얼마인가?" 를 상수로 정의하십시오. Mathf.Approximately 는 범용 유틸이지 모든 비교의 정답이 아닙니다.
함정 4: 결정론적 시뮬레이션에 float 를 쓰는 실수
// ❌ 잘못된 패턴 — 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 네트워크 게임에서 이건 치명적입니다 — 두 클라이언트의 시뮬레이션이 서서히 벌어집니다.
// ✅ 올바른 패턴 — 고정소수점 (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 — 숫자 리터럴 구분자
긴 숫자의 가독성을 높이는 _ 구분자가 도입됐습니다.
// ❌ Before (C# 6 이하)
decimal salary = 1234567.89m; // 자릿수 세기 어렵습니다
// ✅ After (C# 7.0+)
decimal salary = 1_234_567.89m; // 눈에 들어옵니다
double lightSpeed = 299_792_458;
컴파일된 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 사용성이 개선됐습니다.
// ❌ 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 식 캐스팅으로만 만듭니다.
// Half — 16bit / 3~4자리 정밀도 / ML·GPU 버퍼·대량 데이터 전송에 유용
using System;
Half h = (Half)3.14f; // 리터럴 접미사 없음 — 캐스팅 필수
Half sum = (Half)(h + (Half)1.0f);
Unity 6 기준 Half 를 엔진 API에서 바로 쓰긴 아직 제한적이지만, ComputeBuffer 나 NativeArray<Half> 같은 저수준 버퍼에서 메모리 절약 목적으로 활용됩니다.
.NET 7 / C# 11 — 제네릭 수학 인터페이스 (INumber<T>)
C#에 "숫자를 다루는 제네릭 추상화" 가 추가됐습니다. 이제 float, double, decimal 을 같은 제네릭 메서드 하나로 다룰 수 있습니다.
// ❌ 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 ↔ decimal은op_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 핫패스에서는 여전히 타입별 오버로드가 안전합니다.
'C# 기초' 카테고리의 다른 글
| [PART2.변수와 기본 데이터 타입(7/13)] string — 문자의 묶음 (0) | 2026.04.24 |
|---|---|
| [PART2.변수와 기본 데이터 타입(6/13)] bool과 char — 참·거짓 한 비트와 유니코드 한 조각 (0) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(4/13)] 네이티브 정수 — nint · nuint (C# 9 / 11에서 키워드 승격) (0) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(3/13)] 숫자 리터럴 표기법 — 2진수·16진수·구분자 (C# 7) (1) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(2/13)] 정수형 — `int` · `long` · `short` · `byte` (0) | 2026.04.24 |