---
title: "정수는 직선이 아니라 원이다"
description: "int.MaxValue 에 1을 더하면 int.MinValue 가 됩니다. 예외도 경고도 없이 값만 바뀝니다. 왜 하필 최솟값인지 그림으로 봅니다."
author: "유니얼"
pubDate: 2026-09-08
category: "programming/csharp/basics"
tags: ["C#", "기초"]
canonical: https://blog.unialgames.com/posts/csharp-integer-is-a-circle/
---
앞의 두 글에서 이런 걸 봤습니다. 타입의 크기가 담을 수 있는 범위를 정하고,
부호 있는 타입은 맨 앞 비트를 부호에 씁니다.

그러면 이 코드는 무엇을 찍을까요.

```csharp
int max = int.MaxValue;
Console.WriteLine(max + 1);
```

0이 아닙니다. 오류도 아닙니다.

```
-2147483648
```

**21억이 갑자기 -21억이 됩니다.** 예외도 경고도 나지 않고, 로그도 남지 않습니다.
왜 하필 최솟값인지 보려면 머릿속의 그림 하나를 바꿔야 합니다.

## 자릿수가 정해지면 값은 돌 수밖에 없다

정수를 배울 때 대부분 **수직선**을 떠올립니다. 왼쪽 끝에 최솟값, 오른쪽 끝에 최댓값.
그 그림이면 최댓값에서 1을 더할 때 오른쪽 벽에 부딪혀 멈추거나 넘칠 것 같습니다.

실제로는 그렇지 않습니다. **정수는 원입니다.**

<x-ring>
<script type="application/json">
{
  "label": "정수는 직선이 아니라 원이다",
  "bits": 8,
  "note": "타입을 바꾸면 이음매에 적힌 두 숫자가 바뀝니다. 원의 모양은 그대로입니다. 32비트도 원리가 같고 눈금만 촘촘해질 뿐입니다."
}
</script>
</x-ring>

위가 0이고 시계 방향으로 값이 커집니다. 그래서 오른쪽 반이 양수, 왼쪽 반이 음수입니다.
**맨 아래 붉은 선에서 최댓값과 최솟값이 맞닿아 있습니다.**

점이 거기를 지나는 순간을 보세요. 값이 뛰는 게 아니라 **원이 원래 그렇게 생겼습니다.**
칸이 8개뿐인데 계속 세면 처음 자리로 돌아오는 것 말고 갈 데가 없습니다.

시계와 같습니다. 12시에서 한 시간을 더 가면 13시가 아니라 1시입니다.
시계가 고장 난 게 아니라 자리가 12개뿐이라서 그렇습니다.

## 8비트로 보면 한눈에 보인다

원이 왜 그렇게 되는지는 비트를 직접 세어보면 바로 나옵니다.
**"부호 있음"** 으로 두고 **"자동으로 세기"** 를 눌러 보세요.

<x-bits>
<script type="application/json">
{
  "label": "127 다음에 무엇이 오나",
  "bits": 8,
  "value": 124,
  "signed": true,
  "showChar": false,
  "note": "부호 있게 읽는 중이라면 뒤집히는 지점은 0이 아니라 127 다음입니다. 그 순간 한 번 번쩍입니다."
}
</script>
</x-bits>

`126`, `127` 까지는 순조롭습니다. 그다음에 `128` 이 아니라 **`-128`** 이 나옵니다.

비트를 보면 이유가 그대로 보입니다.

```text
  0111 1111    127  =  sbyte.MaxValue
+ 0000 0001    1
= 1000 0000    -128 =  sbyte.MinValue
```

값 비트가 **전부 1인 상태에서 1을 더하면 자리올림이 부호 비트까지 올라갑니다.**
맨 앞이 1이 되는 순간 그 비트 묶음은 음수로 읽힙니다.

> **오버플로우는 값이 넘치는 게 아니라 자리올림이 부호 비트를 침범하는 일입니다.**

`int` 도 똑같습니다. 32비트일 뿐 원리가 같습니다. 위 그림에서 `int (32비트)` 를 눌러도
원의 모양은 그대로고 이음매에 적힌 숫자만 커집니다.

## 반대로도 돕니다

최솟값에서 1을 빼면 최댓값이 됩니다. 원이니까 당연합니다.

```csharp
int min = int.MinValue;
Console.WriteLine(min - 1);   // 2147483647
```

## 실무에서 만나는 모습

여기까지는 퀴즈처럼 보입니다. 문제는 **아무 신호도 없다**는 점입니다.
예외도, 경고도, 로그도 없습니다. 값만 조용히 바뀝니다.

그래서 이런 모습으로 나타납니다.

- **누적 점수나 골드가 어느 날 갑자기 음수**가 됩니다. 그날 뭘 고쳤는지 아무리 뒤져도 안 나옵니다
- **파일 크기 합계가 2GB 근처에서 깨집니다.** `int` 로 바이트를 더하면 21억이 한계입니다
- **경과 시간을 밀리초로 재다가** 25일쯤에서 음수가 됩니다

공통점은 **한참 잘 돌다가 어느 순간 깨진다**는 것입니다. 테스트에서 안 잡히는 이유도
이것입니다. 값이 그만큼 커지려면 시간이 걸립니다.

## 어떻게 막나

세 가지입니다. 상황에 맞는 걸 고르면 됩니다.

<x-compare>
<script type="application/json">
{
  "label": "고르는 세 가지",
  "tabs": [
    { "label": "그냥 두기", "code": "int gold = player.Gold;\ngold += reward;   // 21억을 넘으면 음수", "stats": [["넘칠 때", "조용히 음수"], ["비용", "없음"]], "tone": "bad" },
    { "label": "넓히기", "code": "long gold = player.Gold;\ngold += reward;   // 922경까지", "stats": [["넘칠 때", "사실상 안 넘침"], ["비용", "값 하나당 4바이트 더"]], "tone": "good" },
    { "label": "검사하기", "code": "if (gold > int.MaxValue - reward)\n    gold = int.MaxValue;   // 상한에서 멈춘다\nelse\n    gold += reward;", "stats": [["넘칠 때", "상한에 붙음"], ["비용", "더할 때마다 비교 한 번"]], "tone": "good" },
    { "label": "예외 던지기", "code": "checked\n{\n    gold += reward;   // OverflowException\n}", "stats": [["넘칠 때", "예외"], ["비용", "약간 느려짐"]], "tone": "good" }
  ]
}
</script>
</x-compare>

**넓히기**가 가장 단순합니다. 누적값처럼 계속 커지는 것은 처음부터 `long` 으로 잡는 게
낫습니다. 앞 글에서 봤듯이 8바이트로 담을 수 있는 건 922경이라 현실에서 안 넘칩니다.

**검사하기**는 게임에서 자주 씁니다. 골드가 상한에서 멈추는 편이
음수가 되는 것보다 훨씬 낫습니다.

**`checked`** 는 넘치면 `OverflowException` 을 던집니다.
"여기서 넘치면 그건 버그다" 라고 말하고 싶을 때 씁니다.

> C# 에서 정수 연산은 기본이 `unchecked` 입니다. 조용히 도는 게 기본 동작이고,
> `checked` 는 그걸 켜는 스위치입니다. 프로젝트 전체에 켜려면
> `.csproj` 에 `<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>` 를 넣습니다.
> 상수식은 예외로, 컴파일러가 컴파일할 때 잡아줍니다.

## 정리

- 정수는 직선이 아니라 원이다. 자릿수가 정해진 이상 값은 돌 수밖에 없다.
- 최댓값 다음은 최솟값, 최솟값 이전은 최댓값이다.
- 오버플로우는 값이 넘치는 게 아니라 자리올림이 부호 비트를 침범하는 일이다.
- **예외도 경고도 없다.** 한참 잘 돌다가 어느 날 값만 바뀐다.
- 막는 방법은 넓히기 · 검사하기 · `checked` 셋이다.