---
title: "음수는 메모리에 어떻게 들어가 있나"
description: "음수라고 특별한 표시가 붙지 않습니다. 같은 자리에 비트만 다르게 채워질 뿐입니다. 부호 비트와 2의 보수를 그림으로 봅니다."
author: "유니얼"
pubDate: 2026-09-08
updatedDate: 2026-10-26
category: "programming/csharp/basics"
tags: ["C#", "기초"]
canonical: https://blog.unialgames.com/posts/csharp-negative-numbers/
---
메모리에는 0과 1만 들어갑니다. 그 한 자리가 비트이고, 비트 8개(1바이트)로 만들 수 있는 조합은 2<sup>8</sup> = 256가지라서
`byte` 는 0~255 를 담습니다. 그런데 `int` 는 -21억부터 시작합니다. 마이너스 기호를 적을 칸은 따로 없습니다.
**음수는 어디에 들어가 있는 걸까요.**

이걸 알아 두면 `int.MaxValue` 에 1을 더했더니 갑자기 -21억이 되는 버그나, `uint` 에서 빼기 한 번에 42억이 나오는 버그를 바로 설명할 수 있습니다.

결론부터 쓰면 이렇습니다.

> **음수라고 다른 곳에 저장되지 않습니다.
> 같은 자리에 비트만 다르게 채워질 뿐이고, 맨 앞 비트 하나가 부호를 맡습니다.**

## 맨 앞 1비트를 부호에 쓴다

`byte` 는 8비트를 **전부 값에** 씁니다. 그래서 0~255 이고 음수가 없습니다.

`sbyte` · `short` · `int` · `long` 은 다릅니다. 비트 하나를 부호에 떼어 줍니다.

| `int` 의 32비트 | 쓰는 곳 |
| --- | --- |
| 맨 앞 1비트 | 부호. `0`이면 양수, `1`이면 음수 |
| 나머지 31비트 | 값 |

그래서 `int` 가 담는 값은 한쪽으로 42억 가지가 아니라 2<sup>31</sup> = **21억 가지씩 두 쪽**입니다.

아래에서 **"읽는 방식"** 을 바꿔 보세요. **비트는 하나도 안 바뀌는데 값만 바뀝니다.**
그리고 부호 있게 읽는 순간, 맨 앞 칸의 자릿값이 `128` 에서 `부호` 로 바뀝니다.

<x-bits>
<script type="application/json">
{
  "label": "같은 비트, 읽는 방식만 바꾸기",
  "bits": 8,
  "value": 251,
  "signed": true,
  "showChar": false,
  "note": "부호 없이 읽으면 0~255, 부호 있게 읽으면 -128~127 입니다. 가짓수는 256으로 똑같고, 그 256개를 어디에 배치하느냐만 다릅니다."
}
</script>
</x-bits>

`11111011` 은 부호 없이 읽으면 **251**, 부호 있게 읽으면 **-5** 입니다.
어느 쪽이 맞느냐는 질문은 성립하지 않습니다. **타입이 정합니다.**

## 0이 양수 쪽에 들어간다

여기서 한 가지가 어긋납니다. 0은 부호가 없는데, 어딘가에는 넣어야 합니다.
**0은 양수 쪽에 들어갑니다.**

| | 범위 | 개수 |
| --- | --- | --- |
| 양수 | `0` ~ `2,147,483,647` | 0이 한 자리를 먹어서 하나 모자람 |
| 음수 | `-1` ~ `-2,147,483,648` | 0을 안 쓰니 그대로 21억 |

**음수가 하나 더 많습니다.**

```csharp
Console.WriteLine(int.MaxValue);   //  2147483647
Console.WriteLine(int.MinValue);   // -2147483648  ← 절댓값이 1 더 크다
```

`int.MinValue` 의 절댓값이 `int.MaxValue` 보다 1 큰 이유가 이겁니다.
헷갈리면 위의 8비트 그림에서 확인하면 됩니다. 부호 있게 읽으면 **-128 ~ 127** 입니다.

## -5 는 메모리에 어떻게 놓이나

말로만 하면 안 남으니 직접 넣어 봅니다.

<x-mem>
<script type="application/json">
{
  "label": "음수를 넣으면",
  "name": "hp",
  "value": -5,
  "types": [["int", 4], ["long", 8]],
  "default": 4,
  "baseAddr": 8192,
  "note": "음수라고 다른 곳에 저장되는 게 아닙니다. 양수와 똑같은 4바이트 자리이고, 채워지는 비트만 다릅니다. 작은 자리가 낮은 주소에 먼저 놓여서 FB 가 맨 앞에 보입니다."
}
</script>
</x-mem>

`FB FF FF FF` 입니다. **"음수 표시" 같은 건 없습니다.**
값의 비트를 보면 맨 앞이 `1` 이고, 그게 신호의 전부입니다.

## 음수는 뒤집고 1을 더해 만든다

`-5` 를 만드는 방법은 이렇습니다. 8비트로 보면 세 단계입니다.

```text
1단계   00000101    5
2단계   11111010    모든 비트를 뒤집는다
3단계   11111011    1을 더한다  →  -5
```

이걸 **2의 보수(two's complement)** 라고 합니다.
위 `<x-bits>` 에서 `11111011` 을 만들어 두고 부호 있게 읽으면 정말 -5 가 나옵니다.

왜 뒤집고 1을 더하는지는 숫자로 보면 보입니다. 8비트를 전부 1로 채우면 255 입니다.
5를 뒤집는 것은 255 에서 5를 빼는 것과 같아서 250 이 되고, 1을 더하면 **256 - 5 = 251** 입니다.
처음에 부호 없이 읽었을 때 251 이던 그 비트가 바로 -5 였던 이유가 이것입니다. 8비트에서 -5 는 "256 에서 5 모자란 수" 로 적힙니다.

## 왜 하필 이렇게 만드나

"맨 앞 비트만 1로 바꾸면 되지 않나?" 가 자연스러운 질문입니다.
그렇게 안 하는 이유는 **덧셈 회로 하나로 뺄셈까지 하려고**입니다.

2의 보수로 만들어 두면 **뺄셈이 그냥 덧셈이 됩니다.** `a - b` 는 `a + (-b)` 로 계산하면 되기 때문입니다. `5 + (-5)` 를 8비트로 직접 더해봅니다.

```text
  00000101   (5)
+ 11111011   (-5)
= 100000000
```

9번째 비트는 8비트 밖으로 밀려나 **버려지고**, 남는 건 `00000000` = 0 입니다.
덧셈 회로 하나로 뺄셈까지 처리됩니다.

시계를 3시간 뒤로 돌리는 것과 앞으로 9시간 돌리는 게 같은 것과 같은 원리입니다.
자릿수가 정해져 있으면 뒤로 가는 것과 앞으로 도는 것이 같아집니다.

> 맨 앞 비트만 뒤집는 방식(부호-크기 표현)을 쓰면 `+0` 과 `-0` 이 따로 생기고,
> 덧셈 회로가 부호를 따로 봐야 합니다. 2의 보수는 그 둘을 다 해결합니다.

## int.MaxValue 에 1을 더하면 -21억이 된다

같은 원리로 넘침(오버플로)도 설명됩니다. 8비트로 보면 부호 있는 최댓값 127 은 `01111111` 입니다. 여기에 1을 더합니다.

```text
  01111111   (127)
+ 00000001   (1)
= 10000000   (-128)
```

자리올림이 맨 앞 칸, 곧 **부호 비트까지 올라갑니다.** 맨 앞이 1이 됐으니 음수로 읽히고, 값은 가장 작은 -128 입니다.
`int` 도 똑같아서 `int.MaxValue` 에 1을 더하면 `int.MinValue` 가 됩니다. **예외도 경고도 나지 않습니다.**

```csharp
int hp = int.MaxValue;
hp = hp + 1;
Console.WriteLine(hp);   // -2147483648
```

점수나 돈처럼 커질 수 있는 값은 `long` 을 쓰거나, `checked` 로 감싸서 넘치는 순간 예외(프로그램이 그 줄에서 멈추고 오류를 알리는 것)가 나게 합니다.

## u 가 붙은 타입은 부호 비트를 안 쓴다

`byte` · `ushort` · `uint` · `ulong` 은 부호 비트가 없습니다. 전부를 값에 씁니다.

| 타입 | 비트 | 범위 |
| --- | --- | --- |
| `sbyte` | 8 | -128 ~ 127 |
| `byte` | 8 | 0 ~ 255 |
| `short` | 16 | -32,768 ~ 32,767 |
| `ushort` | 16 | 0 ~ 65,535 |
| `int` | 32 | 약 -21억 ~ 21억 |
| `uint` | 32 | 0 ~ 약 42억 |

**가짓수는 같습니다.** 8비트면 어느 쪽이든 256가지고, 그 256개를 0부터 세느냐
가운데를 기준으로 나누느냐만 다릅니다.

기본은 `int` 입니다. 음수가 안 나온다고 `uint` 를 쓰기 시작하면
빼기 한 번에 값이 42억으로 튀는 자리가 생깁니다. `uint` 0 에서 1을 빼면 비트가 전부 1이 되고,
부호 없이 읽으니 가장 큰 값인 4,294,967,295 가 됩니다. 정말 필요할 때만 씁니다.

## 정리

- 음수도 그냥 비트다. 다른 곳에 저장되지 않는다.
- 부호 있는 타입은 맨 앞 1비트를 부호에 쓴다. `0`이면 양수, `1`이면 음수.
- 같은 비트를 부호 없이 읽으면 251, 부호 있게 읽으면 -5. 무엇으로 읽을지는 타입이 정한다.
- 0이 양수 쪽에 들어가므로 음수가 하나 더 많다.
- 음수는 뒤집고 1을 더해 만든다(2의 보수). 그래야 뺄셈이 덧셈이 된다.
- 최댓값에 1을 더하면 자리올림이 부호 비트까지 올라가 가장 작은 음수가 된다. 예외도 경고도 없다.
- `u` 가 붙은 타입은 부호 비트를 안 쓴다. 가짓수는 같고 배치만 다르다.