---
title: "박싱은 값을 힙으로 보내는 일이다"
description: "object 자리에 int 를 넣으면 값이 상자에 담겨 힙으로 갑니다. 그 상자값이 얼마인지 봅니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/advanced"
tags: ["C#", "최적화", "메모리"]
canonical: https://blog.unialgames.com/posts/csharp-boxing/
---
제네릭 편에서 이 질문을 던지고 답을 미뤄 뒀습니다.

```csharp
object 담기(object 값) { ... }
```

아무거나 담고 싶으면 `object` 로 받으면 되지 않나. 그때는 "꺼낼 때 캐스팅을 해야 하고 타입이 틀리면 터진다" 까지만 말했습니다.
그것 말고 하나가 더 있습니다.

> **값 타입을 `object` 에 넣는 순간 그 값은 힙으로 갑니다. 이걸 박싱이라고 합니다.**

## 왜 그래야 하나

`object` 는 참조 타입입니다. 참조 타입 변수 안에 들어 있는 것은 값이 아니라 **주소**입니다.

그런데 `int n = 10` 의 `10` 은 스택의 `n` 자리에 그대로 들어 있습니다. 줄 주소가 없습니다.
스택 자리의 주소를 줄 수도 없습니다. 그 자리는 메서드가 끝나면 사라지는데 `object` 는 더 오래 살 수 있으니까요.

그래서 방법이 하나뿐입니다. 힙에 상자를 하나 만들고, 값을 거기 복사해 넣고, 그 주소를 줍니다.

<x-heap>
<script type="application/json">
{
  "label": "int 를 object 에 넣으면",
  "steps": [
    {
      "code": "int n = 10;",
      "why": "값 타입입니다. 스택 자리 안에 10 이 그대로 들어 있습니다.",
      "stack": [{ "name": "n", "value": "10", "hot": true }],
      "heap": []
    },
    {
      "code": "object o = n;",
      "why": "힙에 상자를 만들고 10 을 복사해 넣습니다. o 에는 그 상자의 주소가 들어갑니다. 이게 박싱입니다.",
      "stack": [{ "name": "n", "value": "10" }, { "name": "o", "ref": 0, "hot": true }],
      "heap": [{ "id": 0, "title": "(박싱된 int)", "fields": ["10"], "hot": true }]
    },
    {
      "code": "n = 20;",
      "why": "n 을 바꿔도 상자는 그대로입니다. 넣을 때 복사했으니 이제 남남입니다.",
      "stack": [{ "name": "n", "value": "20", "hot": true }, { "name": "o", "ref": 0 }],
      "heap": [{ "id": 0, "title": "(박싱된 int)", "fields": ["10"] }]
    },
    {
      "code": "int m = (int)o;",
      "why": "언박싱. 상자를 열어 값을 꺼내 m 자리에 복사합니다. 또 복사입니다.",
      "stack": [{ "name": "n", "value": "20" }, { "name": "o", "ref": 0 }, { "name": "m", "value": "10", "hot": true }],
      "heap": [{ "id": 0, "title": "(박싱된 int)", "fields": ["10"] }]
    }
  ],
  "note": "값 하나를 옮기는 데 힙 할당 한 번과 복사 두 번이 들었습니다."
}
</script>
</x-heap>

`n = 20` 을 했는데 `(int)o` 가 여전히 `10` 인 것에 주목할 만합니다.
박싱은 **값을 복사해 넣는 것**이지 원래 변수를 가리키는 것이 아닙니다.

## 꺼낼 때는 타입이 정확히 맞아야 한다

언박싱은 캐스팅보다 까다롭습니다.

```csharp
object o = 10;          // int 를 박싱

int a = (int)o;         // 됩니다
long b = (long)o;       // 터집니다. InvalidCastException
```

`int` 에서 `long` 은 평소에 그냥 되는 변환입니다. 그런데 언박싱에서는 안 됩니다.
상자에 "이건 int 다" 라고 적혀 있고, 언박싱은 그 표를 그대로 확인하기 때문입니다.

한 단계씩 나눠야 합니다.

```csharp
long b = (int)o;        // 먼저 int 로 꺼내고, 그다음 long 으로
```

## 어디서 몰래 일어나는가

박싱이라고 써 붙인 자리는 없습니다. 대입 하나로 조용히 일어납니다.

**목록에 넣을 때.** 옛날 `ArrayList` 는 안이 `object[]` 입니다.

```csharp
ArrayList list = new ArrayList();
for (int i = 0; i < 10000; i++)
{
    list.Add(i);        // 만 번 박싱
}
```

`List<int>` 는 안이 `int[]` 라서 한 번도 박싱하지 않습니다. 제네릭이 있는 이유가 이것이었습니다.
제네릭 편에서 "우리가 쓰던 것들이 전부 이것이었다" 고 한 자리의 나머지 절반입니다.

**인터페이스로 받을 때.**

```csharp
struct 점 : IComparable { ... }

점 p = new 점();
IComparable c = p;      // 박싱
```

인터페이스는 참조 타입입니다. struct 를 인터페이스 자리에 넣으면 박싱됩니다.

**문자열에 섞을 때.**

```csharp
Console.WriteLine("점수: " + score);
```

`+` 로 잇는 쪽은 요즘 컴파일러가 `score.ToString()` 으로 바꿔 줘서 박싱이 안 일어납니다.
하지만 `string.Format` 이나 옛날 방식의 가변 인자는 `object[]` 를 만들기 때문에 박싱이 남아 있습니다.

## 왜 신경 쓰나

한 번은 아무것도 아닙니다. 힙에 아주 작은 상자 하나가 생길 뿐입니다.

문제는 **횟수**입니다. 게임 루프처럼 초에 예순 번 도는 자리에서 프레임마다 수백 번 박싱하면
힙에 쓰레기가 계속 쌓입니다. 쌓이면 GC 가 돌고, GC 가 도는 동안 프레임이 한 번 튑니다.

값 자체가 무거워서가 아니라 **할당이 쌓여서** 문제가 됩니다.
이건 오브젝트 풀이 푸는 문제와 정확히 같은 문제입니다.

## 안 하는 법

대부분 제네릭으로 해결됩니다.

```csharp
ArrayList list = new ArrayList();   // 박싱
List<int> list = new List<int>();   // 안 함

Hashtable h = new Hashtable();               // 박싱
Dictionary<string, int> d = new ...();       // 안 함
```

제네릭은 `T` 자리에 `int` 가 들어오면 `int` 를 그대로 담는 코드를 따로 만들어 줍니다.
그래서 `object` 를 거칠 일이 없습니다.

정렬이나 비교에서는 `IComparable` 대신 `IComparable<T>` 를 씁니다.
제네릭 쪽은 인터페이스지만 struct 가 박싱 없이 구현할 수 있게 처리됩니다.

## 정리

- 값 타입을 `object` 나 인터페이스 자리에 넣으면 힙에 상자가 생깁니다. 이게 박싱입니다
- 넣을 때 복사, 꺼낼 때 복사입니다. 원래 변수와는 남남이 됩니다
- 언박싱은 상자에 적힌 타입과 **정확히** 같아야 합니다. `int` 를 `(long)` 으로 못 꺼냅니다
- 한 번은 싸고 만 번은 비쌉니다. 문제는 값이 아니라 할당 횟수입니다
- `List<int>`, `Dictionary<K,V>` 처럼 제네릭을 쓰면 안 일어납니다

박싱은 한 번은 싸고 만 번은 비쌌습니다. 값이 무거워서가 아니라 횟수 때문이었습니다.