---
title: "값을 옮기면 무슨 일이 생기나"
description: "넓은 칸으로 갈 때는 채우고, 좁은 칸으로 갈 때는 잘라냅니다. (int)는 반올림이 아니라 버림이고, 10 / 3 은 나눗셈 시점에 이미 3입니다."
author: "유니얼"
pubDate: 2026-09-08
updatedDate: 2026-10-26
category: "programming/csharp/basics"
tags: ["C#", "기초"]
canonical: https://blog.unialgames.com/posts/csharp-type-conversion/
---
`byte` 변수에 300을 넣었더니 44가 나오고, `double` 에 `10 / 3` 을 담았는데 3이 나옵니다.
둘 다 에러도 경고도 없이 일어나서, 모르면 버그를 한참 찾게 됩니다.

변수는 타입이 정한 크기의 메모리 칸입니다(`byte` 는 1바이트, `int` 는 4바이트).
타입이 다른 변수로 값을 옮기면 **칸의 크기가 바뀌고**, 이것을 형 변환이라고 부릅니다.
`int` 로 센 점수를 `float` 를 받는 유니티 API 에 넘기거나, 실수로 계산한 값을 정수 칸에 담을 때마다 일어납니다.

방향은 둘뿐입니다.

- **넓은 칸으로** 갈 때는 남는 자리를 채웁니다. 잃는 게 없습니다
- **좁은 칸으로** 갈 때는 안 들어가는 만큼 잘라냅니다. 잃는 게 있습니다

C# 이 둘을 다르게 다루는 이유가 여기 있습니다. 앞은 알아서 해주고,
뒤는 **직접 쓰라고 요구합니다.**

## 넓은 칸으로 갈 때는 채운다

`byte` 에 든 5를 `int` 에 넣어봅니다. 타입을 바꿔가며 보세요.

<x-mem>
<script type="application/json">
{
  "label": "같은 5, 칸만 넓어질 때",
  "name": "hp",
  "value": 5,
  "types": [["byte", 1], ["short", 2], ["int", 4]],
  "default": 1,
  "baseAddr": 4096,
  "note": "칸이 넓어져도 값을 이루는 비트는 그대로입니다. 늘어난 앞쪽이 0으로 채워질 뿐입니다."
}
</script>
</x-mem>

`05` 하나였던 것이 `05 00 00 00` 이 됩니다. **값은 그대로고 자리만 늘었습니다.**
메모리에는 작은 자리부터 낮은 주소에 놓여서 `05` 가 먼저 보일 뿐, 수로 읽으면 `00 00 00 05` 입니다. 앞자리가 0으로 채워진 것입니다.
잃는 게 없으니 C# 이 알아서 해줍니다.

```csharp
byte small = 5;
int big = small;      // 그냥 됩니다
double d = big;       // 이것도 됩니다
```

이걸 **암시적 변환**이라고 합니다. 규칙은 하나입니다.

> **받는 타입이 보내는 타입의 모든 값을 표현할 수 있을 때만 자동입니다.**

`byte`(0~255)의 어떤 값도 `int`(±21억)에 들어갑니다. 그래서 자동입니다.
반대는 아닙니다. `int` 의 21억은 `byte` 에 안 들어갑니다.

### 자동인데 정확하지는 않은 경우

여기에 함정이 하나 있습니다. `int` 를 `float` 에 넣는 것은 **자동입니다.**

```csharp
int n = 16_777_217;      // 2의 24제곱 + 1
float f = n;             // 경고 없이 됩니다
Console.WriteLine(f);    // 16777216
```

`float` 의 범위는 `int` 보다 훨씬 넓습니다. 그래서 규칙상 자동이 됩니다.
그런데 `float` 는 32비트를 부호, 소수점 위치, 숫자의 자릿수로 나눠 쓰고, **자릿수는 2진수 24자리만큼만** 담을 수 있습니다.
10진수 세 자리로 999까지 세듯이, 2진수 24자리로 정확히 셀 수 있는 정수는 2<sup>24</sup> = 16,777,216 까지입니다. 그다음 16,777,217 은 담을 자리가 없어서
가장 가까운 값인 16,777,216 으로 저장됩니다. 범위는 넉넉해도 **자릿수가 모자라서** 1이 사라졌습니다.

> 암시적 변환이 보장하는 것은 **범위**지 **정밀도**가 아닙니다.
> 큰 정수를 실수에 넣을 때는 `double` 을 쓰세요.

## 좁은 칸으로 갈 때는 잘라낸다

이번엔 300을 넣어봅니다. `byte` 를 눌러보세요.

<x-mem>
<script type="application/json">
{
  "label": "300 을 좁은 칸에 넣으면",
  "name": "score",
  "value": 300,
  "types": [["byte", 1], ["short", 2], ["int", 4]],
  "default": 1,
  "baseAddr": 8192,
  "note": "300 은 9비트가 필요한데 byte 는 8칸뿐입니다. 넘치는 앞쪽 비트가 그대로 버려집니다."
}
</script>
</x-mem>

`300` 을 넣었는데 읽으면 `44` 가 나옵니다. 비트를 보면 이유가 보입니다.
컴퓨터는 수를 0과 1만으로 적습니다(2진수). 그 한 자리가 비트이고, 1바이트는 비트 8개입니다.
300 = 256 + 32 + 8 + 4 라서 2진수로 `1 0010 1100`, 아홉 자리가 됩니다.

```text
300  =  1 0010 1100    (9비트가 필요합니다)
byte =    0010 1100    (8칸에 안 들어가는 앞자리가 버려집니다)
      =   44
```

맨 앞의 1(256 자리)이 잘려 나가서 300 - 256 = 44 가 남았습니다.

이건 **저절로 되지 않습니다.** 직접 써야 합니다. 값 앞에 괄호로 타입을 적는 `(byte)n` 같은 것을
**명시적 변환**, 흔히 **캐스트**라고 부릅니다.

```csharp
int n = 300;
byte b = n;          // 컴파일 에러
byte b = (byte)n;    // 44
```

`(byte)` 는 부탁이 아닙니다.

> **`(byte)` 는 "잘려도 좋으니 넣어라" 는 지시입니다.**
> 컴파일러는 그 말을 듣고 검사를 그만둡니다. 범위를 보장하는 건 그때부터 개발자 몫입니다.

값이 칸을 넘쳐 잘려도(오버플로) **아무 신호도 없습니다.** 에러도 경고도 나지 않습니다.
넘칠 것 같으면 `checked` 로 감싸거나 넣기 전에 범위를 검사하세요. `checked` 안에서는 넘치는 순간 예외가 나서 바로 알 수 있습니다.
예외는 프로그램이 그 줄에서 멈추고 무엇이 잘못됐는지 알리는 오류입니다.

```csharp
int n = 300;
byte b = checked((byte)n);   // OverflowException: 잘리기 전에 멈춥니다
```

## `(int)` 는 반올림이 아니라 버림이다

실수를 정수로 옮길 때가 가장 많이 틀리는 자리입니다.

```csharp
Console.WriteLine((int)9.78);    //  9
Console.WriteLine((int)9.99);    //  9
Console.WriteLine((int)-9.78);   // -9
```

`9.99` 도 `9` 입니다. **반올림이 아니라 소수점 아래를 그냥 버립니다.**
음수도 `-10` 이 아니라 `-9` 입니다. 0 쪽으로 자릅니다.

반올림이 필요하면 `Math.Round` 를 씁니다. 그런데 여기에도 함정이 있습니다.

```csharp
Console.WriteLine(Math.Round(0.5));   // 0  ← 1이 아닙니다
Console.WriteLine(Math.Round(1.5));   // 2
Console.WriteLine(Math.Round(2.5));   // 2  ← 3이 아닙니다
```

`Math.Round` 의 기본은 **정확히 0.5일 때 짝수 쪽으로** 보냅니다.
통계에서 반올림이 한쪽으로만 쏠리는 걸 막는 방식이고, 흔히 은행가 반올림이라고 부릅니다.

학교에서 배운 반올림을 원하면 방식을 지정하세요.

<x-compare>
<script type="application/json">
{
  "label": "2.5 를 반올림하면",
  "tabs": [
    { "label": "(int) 로", "code": "(int)2.5", "stats": [["결과", "2"], ["하는 일", "소수점 아래 버림"]], "tone": "bad" },
    { "label": "Math.Round 기본", "code": "Math.Round(2.5)", "stats": [["결과", "2"], ["하는 일", "0.5는 짝수 쪽으로"]] },
    { "label": "학교에서 배운 반올림", "code": "Math.Round(2.5, MidpointRounding.AwayFromZero)", "stats": [["결과", "3"], ["하는 일", "0.5는 0에서 먼 쪽으로"]], "tone": "good" }
  ]
}
</script>
</x-compare>

`(int)Math.Round(x)` 처럼 반올림한 다음에 캐스팅하는 게 보통의 답입니다.

> 실수를 정수로 옮길 때 값이 `int` 범위 밖이면 결과가 **정해져 있지 않습니다.**
> 0이 나올 수도, 엉뚱한 값이 나올 수도 있습니다. `checked` 를 씌우면 예외가 납니다.

## `10 / 3` 은 나눗셈 시점에 이미 3이다

가장 자주 나오는 질문입니다.

```csharp
double result = 10 / 3;
Console.WriteLine(result);   // 3
```

`double` 에 담았는데 왜 `3.333` 이 아닐까요.
**변환이 나눗셈보다 늦게 일어나기 때문**입니다.

<x-steps>
<script type="application/json">
{
  "label": "이 한 줄이 실행되는 순서",
  "steps": [
    { "tag": "STEP 1", "title": "타입을 정한다", "text": "10 도 int, 3 도 int 입니다. 그래서 이 나눗셈은 정수 나눗셈입니다." },
    { "tag": "STEP 2", "title": "나눈다", "text": "정수 나눗셈은 몫만 냅니다. 나머지 1은 그 자리에서 버려지고 3이 남습니다." },
    { "tag": "STEP 3", "title": "그다음에 옮긴다", "text": "이미 3이 된 값을 double 로 넓힙니다. 3.0 입니다. 없어진 소수는 돌아오지 않습니다." }
  ]
}
</script>
</x-steps>

사탕 10개를 3명에게 나누면 3개씩입니다. 남은 1개는 쪼개지 않습니다.
**정수 나눗셈은 처음부터 그런 계산입니다.**

그래서 소수가 필요하면 **나누기 전에** 한쪽을 실수로 만들어야 합니다.

<x-compare>
<script type="application/json">
{
  "label": "고치는 법",
  "tabs": [
    { "label": "안 되는 것", "code": "double r = 10 / 3;\n// 3", "stats": [["나눗셈", "정수끼리"], ["변환 시점", "나눈 뒤"]], "tone": "bad" },
    { "label": "되는 것", "code": "double r = 10 / 3.0;\n// 3.3333333333333335", "stats": [["나눗셈", "실수가 섞임"], ["변환 시점", "나누기 전"]], "tone": "good" },
    { "label": "변수일 때", "code": "int a = 10, b = 3;\ndouble r = (double)a / b;\n// 3.3333333333333335", "stats": [["나눗셈", "실수가 섞임"], ["주의", "괄호 위치"]], "tone": "good" }
  ]
}
</script>
</x-compare>

마지막 것에서 괄호 위치를 조심하세요.

```csharp
double r1 = (double)a / b;     // 3.3333...  (a 를 먼저 실수로)
double r2 = (double)(a / b);   // 3          (정수로 나눈 뒤에 변환)
```

`r2` 는 처음 코드와 똑같습니다. **캐스팅이 나눗셈보다 늦으면 아무 의미가 없습니다.**

## 문자열과 숫자 사이는 캐스트로 못 옮긴다

입력창에서 받은 `"123"` 을 숫자로 쓰고 싶을 때 `(int)"123"` 은 컴파일 에러입니다.
캐스트는 **숫자 칸 사이에서 크기를 바꾸는 일**인데, 문자열은 글자들의 묶음이라 칸의 모양부터 다릅니다.
글자 `'1'` `'2'` `'3'` 을 읽어 123 이라는 수를 **새로 계산해야** 합니다. 그 일을 하는 메서드가 따로 있습니다.

```csharp
int n = int.Parse("123");             // 123
string s = n.ToString();              // "123"

int.Parse("abc");                     // FormatException: 숫자가 아닙니다
bool ok = int.TryParse("abc", out int m);   // ok = false, m = 0. 예외 없이 실패를 알려 줍니다
```

`out int m` 은 "변환에 성공하면 결과를 `m` 에 넣어 달라" 는 뜻이고, 돌려받는 `ok` 가 성공했는지를 알려 줍니다.
사용자가 입력한 값처럼 숫자가 아닐 수 있는 것은 `TryParse` 로 받습니다.

## 정리

- 넓은 칸으로 갈 때는 앞을 채운다. 잃는 게 없어서 자동이다.
- 좁은 칸으로 갈 때는 잘라낸다. 잃는 게 있어서 직접 써야 한다.
- 암시적 변환이 보장하는 것은 **범위**지 정밀도가 아니다. `int` 를 `float` 에 넣으면 자릿수가 깎일 수 있다.
- `(byte)` 는 부탁이 아니라 "잘려도 좋다" 는 지시다. 범위 보장은 그때부터 개발자 몫이다.
- `(int)` 는 반올림이 아니라 버림이다. 음수도 0 쪽으로 자른다.
- `Math.Round` 의 기본은 0.5를 짝수 쪽으로 보낸다.
- `10 / 3` 은 나눗셈 시점에 이미 3이다. 소수가 필요하면 나누기 전에 한쪽을 실수로 만든다.
- 문자열과 숫자 사이는 캐스트가 아니라 `int.Parse` · `TryParse` · `ToString` 으로 옮긴다.