---
title: "0.1 은 2진수로 끝나지 않는다"
description: "0.1 + 0.2 가 0.3 이 아닌 이유. 실수는 값을 그대로 담지 않고 부호·지수·가수 세 조각으로 나눠 담습니다."
author: "유니얼"
pubDate: 2026-09-08
category: "programming/csharp/basics"
tags: ["C#", "기초"]
canonical: https://blog.unialgames.com/posts/csharp-floating-point/
---
앞 글까지가 **정수** 이야기였습니다. `3`, `-42` 처럼 소수점이 없는 수요.
정수는 담을 수 있는 값 안에서는 **정확했습니다.** `int` 에 5를 넣으면 정확히 5고,
문제는 범위를 벗어날 때만 생겼습니다.

이번에는 **실수**입니다. `0.5`, `3.14`, `-0.001` 처럼 **소수점이 붙는 수**입니다.
거리 · 속도 · 확률처럼 딱 떨어지지 않는 값에 쓰고, C# 에서는 `float` 과 `double` 로 담습니다.
앞에서 본 대로 `float` 은 4바이트, `double` 은 8바이트입니다.

그때 표에서 실수만 "범위" 가 아니라 **"믿을 수 있는 자릿수"** 라고 적어 뒀습니다.
그 이유가 이 글의 내용입니다.

실수는 정수와 다릅니다.

```csharp
Console.WriteLine(0.1 + 0.2);          // 0.30000000000000004
Console.WriteLine(0.1 + 0.2 == 0.3);   // False
```

버그가 아닙니다. C# 만 그런 것도 아닙니다.
파이썬도 자바스크립트도 자바도 똑같이 나옵니다.

> **0.1 은 처음부터 정확하게 담기지 않았습니다.**

## 실수는 값을 그대로 담지 않는다

정수는 비트에 값을 그대로 적었습니다. 실수는 그럴 수가 없습니다.
소수점이 어디에 오는지가 값마다 다르기 때문입니다.

그래서 **과학적 표기법**을 씁니다. 1억 2천만을 `1.2 × 10⁸` 으로 적는 그 방식입니다.
2진수 버전으로 바꾸면 이렇게 됩니다.

```text
0.15625  =  1.01 × 2⁻³
             │     │
             가수   지수
```

`float` 은 32비트를 이렇게 나눠 씁니다.

<figure class="diagram">
<svg viewBox="0 0 680 132" role="img" aria-label="float 32비트가 부호 1비트, 지수 8비트, 가수 23비트로 나뉜 그림">
<text x="39" y="30" text-anchor="middle" fill="var(--warn)" font-size="12">부호</text>
<text x="127" y="30" text-anchor="middle" fill="var(--accent2)" font-size="12">지수</text>
<text x="427" y="30" text-anchor="middle" fill="var(--accent)" font-size="12">가수</text>
<rect x="30" y="40" width="19" height="44" rx="4" fill="none" stroke="var(--warn)"></rect>
<rect x="49" y="40" width="155" height="44" fill="none" stroke="var(--accent2)"></rect>
<rect x="204" y="40" width="446" height="44" rx="4" fill="none" stroke="var(--accent)"></rect>
<text x="39" y="68" text-anchor="middle" fill="var(--warn)" font-size="13" class="bit">1</text>
<text x="127" y="68" text-anchor="middle" fill="var(--accent2)" font-size="13" class="bit">8</text>
<text x="427" y="68" text-anchor="middle" fill="var(--accent)" font-size="13" class="bit">23</text>
<text x="39" y="106" text-anchor="middle" fill="var(--text-muted)" font-size="11">양수 · 음수</text>
<text x="127" y="106" text-anchor="middle" fill="var(--text-muted)" font-size="11">소수점 위치</text>
<text x="427" y="106" text-anchor="middle" fill="var(--text-muted)" font-size="11">유효한 숫자들</text>
</svg>
<figcaption>지수가 소수점을 옮기고, 가수가 실제 숫자를 담습니다. 그래서 "부동(浮動) 소수점" 입니다.</figcaption>
</figure>

`double` 은 64비트에 같은 구조입니다. 지수 11비트, 가수 52비트로 넓어질 뿐입니다.

여기서 문제가 나옵니다. **가수는 23칸뿐입니다.**
담을 숫자가 그보다 길면 나머지는 버려집니다.

## 0.1 은 2진수로 끝나지 않는다

10진수에서 `1/3` 을 소수로 적어보면 이렇습니다.

```text
1/3 = 0.3333333333...
```

끝나지 않습니다. 어디선가 끊어 적을 수밖에 없고, 끊은 만큼이 오차가 됩니다.
**2진수에서 `0.1` 에 똑같은 일이 일어납니다.**

값을 바꿔가며 눌러보세요. **어떤 수는 2진수로 딱 떨어지고 어떤 수는 안 떨어집니다.**

<x-float>
<script type="application/json">
{
  "label": "적은 값과 실제로 저장되는 값",
  "values": ["0.5", "0.25", "0.75", "0.1", "0.2", "0.3"],
  "default": "0.1",
  "note": "0.5 · 0.25 · 0.75 는 2진수로 끝나서 정확히 담깁니다. 분모가 2의 거듭제곱인 수만 그렇습니다."
}
</script>
</x-float>

`0.5` 는 2진수로 `0.1` 입니다. 한 자리에서 끝나니 정확합니다.
`0.25` 는 `0.01`, `0.75` 는 `0.11` 입니다. 전부 끝납니다.

`0.1` 은 이렇습니다.

```text
0.1 (10진수)  =  0.00011001100110011001100110011...  (2진수)
```

`0011` 이 끝없이 반복됩니다. 10진수의 `1/3` 과 같은 상황입니다.
그런데 가수는 23칸(또는 52칸)뿐이니 **거기서 잘립니다.**

그래서 `double` 에 `0.1` 을 넣으면 실제로 들어가는 값은 이것입니다.

```text
0.1000000000000000055511151231257827021182
```

`0.1` 이 아닙니다. **`0.1` 에 가장 가까운, 2진수로 표현 가능한 값**입니다.

## 그래서 이런 일이 생긴다

조금씩 어긋난 값끼리 계산하면 어긋남이 드러납니다.

```csharp
Console.WriteLine(0.1 + 0.2);   // 0.30000000000000004
```

`0.1` 도 조금 크고 `0.2` 도 조금 큽니다. 둘을 더하니 `0.3` 보다 조금 커졌습니다.
그런데 `0.3` 자체도 정확하지 않아서(`0.29999999999999998...`) 두 값이 다릅니다.

더할수록 벌어집니다.

```csharp
double sum = 0;
for (int i = 0; i < 10; i++) sum += 0.1;

Console.WriteLine(sum);          // 0.9999999999999999
Console.WriteLine(sum == 1.0);   // False
```

**0.1을 열 번 더해도 1이 되지 않습니다.**

## `==` 로 비교하지 않는다

그래서 실수는 같은지를 `==` 로 묻지 않습니다.
**차이가 충분히 작은지**를 묻습니다.

<x-compare>
<script type="application/json">
{
  "label": "실수를 비교하는 법",
  "tabs": [
    { "label": "이렇게 하면", "code": "if (sum == 1.0)\n{\n    // 여기 안 들어옵니다\n}", "stats": [["결과", "False"], ["이유", "마지막 자리가 다름"]], "tone": "bad" },
    { "label": "이렇게", "code": "const double EPS = 1e-9;\n\nif (Math.Abs(sum - 1.0) < EPS)\n{\n    // 들어옵니다\n}", "stats": [["결과", "True"], ["기준", "허용할 오차를 직접 정함"]], "tone": "good" }
  ]
}
</script>
</x-compare>

`EPS`(엡실론)는 **"이 정도 차이는 같은 것으로 치겠다"** 는 기준입니다.
값의 크기에 따라 적절한 값이 달라지므로 상황에 맞게 정합니다.
게임에서 위치를 비교한다면 `0.0001` 정도면 충분한 경우가 많습니다.

> `float.Epsilon` 은 이 용도가 아닙니다. 그건 0에 가장 가까운 값이라
> 비교 기준으로 쓰기에는 너무 작습니다. 기준은 직접 정하세요.

## float 과 double 중 무엇을 쓰나

| | 크기 | 믿을 수 있는 자릿수 |
| --- | --- | --- |
| `float` | 4바이트 | 약 7자리 |
| `double` | 8바이트 | 약 15~16자리 |

**C# 에서 소수점 리터럴의 기본은 `double` 입니다.** `float` 에 넣으려면
`5.75f` 처럼 `f` 를 붙여야 하는 이유가 이것입니다.

그런데 유니티는 좌표도 시간도 대부분 `float` 입니다. 메모리와 GPU 때문입니다.
그래서 이런 성질을 알아두면 좋습니다.

**`float` 의 정밀도는 값이 커질수록 나빠집니다.** 가수가 23칸으로 고정인데
지수가 커지면 한 칸이 나타내는 간격이 그만큼 벌어지기 때문입니다.

| 좌표 | 표현할 수 있는 가장 가까운 두 위치의 간격 |
| --- | --- |
| 1 | 0.00000012 |
| 1,000 | 0.00006 |
| 10,000 | 0.00098 |
| 100,000 | 0.0078 |
| 1,000,000 | 0.0625 |

원점에서 100만 단위까지 나가면 **6cm 단위로만 위치를 표현할 수 있습니다.**
넓은 맵에서 멀리 나갔을 때 물체가 미세하게 떠는 현상이 이것입니다.
해결책은 정밀도를 올리는 게 아니라 **원점을 옮기는 것**입니다.

## 돈은 실수로 계산하지 않는다

금액은 `float` 이나 `double` 로 다루면 안 됩니다.
`0.1` 원이 정확하지 않은 통화 계산은 언젠가 1원이 어긋납니다.

C# 에는 이를 위한 타입이 따로 있습니다.

```csharp
decimal price = 0.1m;
Console.WriteLine(price + 0.2m == 0.3m);   // True
```

`decimal` 은 16바이트를 쓰고 **2진수가 아니라 10진수 기반**입니다.
그래서 `0.1` 이 정확합니다. 대신 느리고 범위가 좁습니다.
**돈과 회계에만 쓰고, 좌표나 물리에는 쓰지 않습니다.**

## 정리

- 실수는 값을 그대로 담지 않는다. 부호 · 지수 · 가수 세 조각으로 나눠 담는다.
- 가수 칸이 정해져 있어서, 2진수로 끝나지 않는 수는 거기서 잘린다.
- `0.1` 은 2진수로 끝나지 않는다. 10진수의 `1/3` 과 같은 상황이다.
- 분모가 2의 거듭제곱인 수(`0.5` · `0.25` · `0.75`)만 정확하다.
- 실수는 `==` 대신 **차이가 충분히 작은지**로 비교한다.
- `float` 은 값이 커질수록 정밀도가 나빠진다. 멀리 나가면 원점을 옮긴다.
- 돈은 `decimal`.