EveryDay.DevUp

boxing과 unboxing — 값 타입이 힙에 올라가는 순간 본문

C# 심화

boxing과 unboxing — 값 타입이 힙에 올라가는 순간

EveryDay.DevUp 2026. 3. 30. 20:21

boxing과 unboxing — 값 타입이 힙에 올라가는 순간

눈에 보이지 않는 한 줄이 매 프레임 힙을 오염시킨다. boxing이 뭔지 모르면, Unity Profiler에서 GC Alloc이 왜 찍히는지도 모른다.


문제 제기 — 한 줄이 만드는 프레임 드랍

Unity에서 Update 루프 안에 이런 코드가 있다고 하자.

C#
void Update()
{
    int score = GetCurrentScore();
    Debug.Log(string.Format("Score: {0}", score));
}

겉보기엔 문제가 없다. 하지만 Unity Profiler의 GC Alloc 열을 켜면, 이 한 줄이 매 프레임마다 힙 메모리를 할당하고 있다는 사실이 드러난다. 60fps 게임이라면 1초에 60번, 1분이면 3,600번 힙에 쓰레기가 쌓인다.

원인은 string.Format의 시그니처가 string.Format(string, object)이기 때문이다. int는 값 타입인데, object는 참조 타입이다. 이 간극을 메우기 위해 CLR(Common Language Runtime, C# 코드를 실행하는 런타임 엔진)이 자동으로 수행하는 변환이 바로 boxing이다.

Boxing은 눈에 보이지 않는다. 컴파일 에러도 경고도 없다. 하지만 Unity의 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)는 이 작은 객체들을 수거하기 위해 전체 게임을 멈춘다. 이것이 GC 스파이크 — 갑작스러운 프레임 드랍의 원인이다.

이 글에서는 boxing이 정확히 무엇이고, 언제 일어나며, 어떻게 피하는지를 IL(Intermediate Language, C# 코드가 컴파일되는 중간 언어) 수준에서 증명한다.


개념 정의 — boxing과 unboxing이란

택배 상자에 넣고 꺼내기

비유로 시작하자. 값 타입은 책상 위에 놓인 메모지다. 가볍고, 바로 읽을 수 있다. 그런데 누군가 "택배로 보내야 하니까 상자에 넣어"라고 하면? 메모지(값)를 상자(힙 객체)에 넣고, 상자에 운송장(object header)을 붙이고, 상자를 창고(힙)에 보관한다. 이게 boxing이다.

나중에 상자를 받아서 "이 안에 메모지가 맞지?" 확인한 뒤 꺼내는 것이 unboxing이다.

문제는 명확하다:

  • 상자를 만드는 데 비용이 든다 (힙 할당)
  • 창고에 상자가 쌓이면 정리 인력(GC)이 필요하다
  • 정리하는 동안 모든 작업이 멈춘다 (Stop-the-world)
스택 (Stack)

기본 코드로 확인

C#
int i = 42;
object boxed = i;          // boxing
int unboxed = (int)boxed;  // unboxing
Console.WriteLine(unboxed);
IL
.locals init (
    [0] int32,       // i
    [1] object,      // boxed
    [2] int32        // unboxed
)

IL_0000: ldc.i4.s 42           // 스택에 42 로드
IL_0002: stloc.0               // i에 저장
IL_0003: ldloc.0               // i 로드
IL_0004: box [System.Runtime]System.Int32  // 💥 boxing — 힙에 24바이트 할당
IL_0009: stloc.1               // boxed에 참조 저장
IL_000a: ldloc.1               // boxed 참조 로드
IL_000b: unbox.any [System.Runtime]System.Int32  // unboxing — 타입 확인 후 값 복사
IL_0010: stloc.2               // unboxed에 저장

IL에서 boxunbox.any가 boxing/unboxing의 정체다.

  • box: 스택 위의 값 타입을 힙에 할당된 참조 타입 객체로 변환한다. 타입 토큰(System.Int32)을 받아 어떤 타입의 박스를 만들지 결정한다.
  • unbox.any: 박스된 객체에서 값을 꺼내 스택에 복사한다. 타입이 일치하지 않으면 InvalidCastException이 발생한다. int를 boxing한 뒤 long으로 unboxing하면 예외가 터진다.

쉽게 말하면, boxing은 값을 상자에 넣어 힙에 보관하는 것이고, unboxing은 상자에서 꺼내는 것이다.

정확히 표현하면, boxing은 값 타입의 인스턴스를 관리 힙에 할당하고 object header와 method table pointer를 추가하여 참조 타입 객체로 변환하는 암시적 변환이다. unboxing은 그 역변환으로, 타입 검증 후 박스 내부의 값에 대한 포인터를 반환하는 명시적 변환이다.


내부 동작 — 메모리에서 일어나는 일

boxing의 3단계

boxing이 실행되면 CLR 내부에서 정확히 세 가지 일이 벌어진다:

  1. 힙 할당 — 관리 힙(Managed Heap)에 Object Header(8바이트) + Method Table Pointer(8바이트) + 실제 값 + 패딩 크기의 메모리를 할당한다
  2. 값 복사 — 스택에 있던 값을 힙의 박스 안으로 복사한다 (memcpy, 메모리 블록 복사)
  3. 참조 반환 — 힙 객체에 대한 참조를 스택에 올린다
boxing 3단계: int (4 bytes) → boxed object (24 bytes)

unboxing의 2단계

unboxing은 boxing보다 단순하지만, 타입이 정확히 일치해야 한다:

  1. 타입 검증 — 박스의 Method Table을 확인하여 요청한 타입과 일치하는지 검사
  2. 포인터 반환 + 값 복사 — 박스 내부의 값 위치를 가리키는 포인터를 돌려주고, 이어서 값을 스택으로 복사

핵심은 int를 boxing한 것을 long으로 unboxing할 수 없다는 것이다. 반드시 원래 타입으로만 꺼낼 수 있다.

IL에서 constrained 접두사

제네릭 코드에서 boxing을 피하는 핵심 메커니즘이 constrained. 접두사다. 이것은 다음 섹션에서 자세히 다루겠지만, 원리를 먼저 이해해두자.

IL
constrained. !!T
callvirt instance string [System.Runtime]System.Object::ToString()

constrained. 접두사가 붙으면 JIT(Just-In-Time, 실행 시점에 IL을 네이티브 코드로 변환하는 컴파일러)가 T의 실제 타입을 확인한다:

  • T가 값 타입이고 해당 메서드를 재정의했다면 → boxing 없이 직접 호출
  • T가 값 타입이고 재정의하지 않았다면 → boxing 후 호출
  • T가 참조 타입이면 → 일반적인 callvirt 수행

이것이 제네릭 제약 조건(where T : IComparable<T>)이 boxing을 방지하는 내부 원리다.


실전 적용 — boxing이 일어나는 6가지 상황과 대응법

상황 1: object로 캐스팅

가장 단순하고 명시적인 형태다.

C#
int value = 10;
object boxed = value; // boxing

대응: object 변수에 값 타입을 담아야 하는 상황 자체를 피한다. 제네릭을 사용하면 object를 거치지 않아도 된다.

상황 2: 인터페이스로 캐스팅

struct가 인터페이스를 구현하더라도, 인터페이스 타입 변수에 대입하면 boxing이 발생한다. 인터페이스는 참조 타입이고, 인터페이스 디스패치는 vtable(가상 메서드 테이블)을 통해 이루어지기 때문이다.

C#
struct MyNumber : IComparable<int>
{
    public int Value;
    public int CompareTo(int other) => Value - other;
}

// boxing 발생
MyNumber num = new MyNumber { Value = 10 };
IComparable<int> comp = num; // 💥 box MyNumber
int result = comp.CompareTo(5);
IL
IL_0014: ldloc.0
IL_0015: box MyNumber                      // 💥 인터페이스 변수에 담기 위해 boxing
IL_001a: stloc.1
IL_001b: ldloc.1
IL_001c: ldc.i4.5
IL_001d: callvirt instance int32 class [System.Runtime]System.IComparable`1<int32>::CompareTo(!0)  // 가상 디스패치

box MyNumber이 보인다. struct가 인터페이스를 구현해도, 인터페이스 변수에 넣는 순간 힙에 박스가 만들어진다.

대응: 인터페이스 타입 변수에 직접 담지 말고, 제네릭 제약 조건을 사용한다 (상황 6에서 상세 설명).

상황 3: string.Format과 문자열 보간

string.Format(string, object)의 두 번째 매개변수가 object이므로, 값 타입을 넘기면 boxing된다.

Before — boxing 발생

C#
static void BoxingFormat()
{
    int score = 100;
    string s = string.Format("Score: {0}", score);
    Console.WriteLine(s);
}
IL
IL_0001: ldc.i4.s 100
IL_0003: stloc.0
IL_0004: ldstr "Score: {0}"
IL_0009: ldloc.0
IL_000a: box [System.Runtime]System.Int32   // 💥 boxing — score가 object로 변환
IL_000f: call string [System.Runtime]System.String::Format(string, object)

After — ToString 명시 호출로 boxing 회피

C#
static void NoBoxingToString()
{
    int score = 100;
    string s = "Score: " + score.ToString();
    Console.WriteLine(s);
}
IL
IL_0004: ldstr "Score: "
IL_0009: ldloca.s 0                         // 값의 주소를 로드 (boxing 아님)
IL_000b: call instance string [System.Runtime]System.Int32::ToString()  // 직접 호출
IL_0010: call string [System.Runtime]System.String::Concat(string, string)

box 명령어가 완전히 사라졌다. ldloca.s는 값 타입의 주소를 로드하는 명령어로, boxing과는 완전히 다르다. 값을 힙으로 복사하지 않고 스택에 있는 그 자리에서 ToString()을 직접 호출한다.

💡 C# 10 이상 팁: $"Score: {score}" 형태의 문자열 보간은 .NET 6+에서 DefaultInterpolatedStringHandler로 처리되어 boxing이 발생하지 않는다. 하지만 Unity의 .NET 버전에 따라 다를 수 있으므로, Unity에서는 ToString() 명시 호출이 가장 안전하다.

상황 4: 비제네릭 컬렉션

ArrayList, HashtableSystem.Collections 네임스페이스의 구형 컬렉션은 내부적으로 object[]를 사용한다.

Before — ArrayList (boxing/unboxing 발생)

C#
static void WithArrayList()
{
    ArrayList list = new ArrayList();
    list.Add(42);            // boxing
    int val = (int)list[0];  // unboxing
}
IL
IL_0008: ldc.i4.s 42
IL_000a: box [System.Runtime]System.Int32        // 💥 Add(object) — boxing
IL_000f: callvirt instance int32 [System.Runtime]System.Collections.ArrayList::Add(object)
IL_0014: pop
IL_0015: ldloc.0
IL_0016: ldc.i4.0
IL_0017: callvirt instance object [System.Runtime]System.Collections.ArrayList::get_Item(int32)
IL_001c: unbox.any [System.Runtime]System.Int32  // 💥 object → int — unboxing

Addbox, get_Item 후에 unbox.any — 넣을 때와 꺼낼 때 모두 비용이 발생한다.

After — List<T> (boxing/unboxing 없음)

C#
static void WithGenericList()
{
    List<int> list = new List<int>();
    list.Add(42);            // boxing 없음
    int val = list[0];       // unboxing 없음
}
IL
IL_0008: ldc.i4.s 42
IL_000a: callvirt instance void class [System.Collections]System.Collections.Generic.List`1<int32>::Add(!0)  // int 그대로
IL_0010: ldloc.0
IL_0011: ldc.i4.0
IL_0012: callvirt instance !0 class [System.Collections]System.Collections.Generic.List`1<int32>::get_Item(int32)  // int 반환

boxunbox.any가 완전히 사라졌다. List<int>는 내부적으로 int[] 배열을 사용하기 때문에 값 타입을 그대로 저장한다. JIT가 T를 int로 특수화(specialization)한 네이티브 코드를 생성하므로 boxing이 근본적으로 불필요하다.

상황 5: Equals/GetHashCode/ToString 미재정의

값 타입이 이 메서드들을 재정의하지 않으면, System.ValueType의 기본 구현이 호출된다. ValueType.Equals(object other)의 매개변수가 object이므로 비교 대상이 boxing된다.

Before — IEquatable<T> 미구현

C#
struct PointBad
{
    public int X;
    public int Y;
}

PointBad a = new PointBad { X = 1, Y = 2 };
PointBad b = new PointBad { X = 1, Y = 2 };
bool same = a.Equals(b); // boxing 발생
IL
IL_0035: ldloca.s 0                          // a의 주소
IL_0037: ldloc.1                             // b의 값
IL_0038: box PointBad                        // 💥 인자 b가 boxing됨
IL_003d: constrained. PointBad
IL_0043: callvirt instance bool [System.Runtime]System.Object::Equals(object)

box PointBad가 보인다. Equals(object)의 매개변수가 object이므로 비교 대상 b가 boxing된다. constrained. 접두사가 있어서 a 자체는 boxing되지 않지만, 인자는 여전히 boxing이다.

After — IEquatable<T> 구현

C#
struct PointGood : IEquatable<PointGood>
{
    public int X;
    public int Y;

    public bool Equals(PointGood other)
    {
        return X == other.X && Y == other.Y;
    }

    public override bool Equals(object? obj)
    {
        return obj is PointGood other && Equals(other);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(X, Y);
    }
}

PointGood a = new PointGood { X = 1, Y = 2 };
PointGood b = new PointGood { X = 1, Y = 2 };
bool same = a.Equals(b); // boxing 없음
IL
IL_0035: ldloca.s 0
IL_0037: ldloc.1
IL_0038: call instance bool PointGood::Equals(valuetype PointGood)  // ✅ 직접 호출, boxing 없음

box가 완전히 사라졌다. 컴파일러가 IEquatable<PointGood>.Equals(PointGood)를 우선 선택하기 때문에, 매개변수도 PointGood 그대로 전달된다. callvirt가 아닌 call — 가상 디스패치도 없다.

📌 struct를 만들면 항상 세 가지를 재정의하라: Equals(PointGood), Equals(object), GetHashCode(). IEquatable<T>를 구현하면 Dictionary<TKey, TValue> 등의 컬렉션에서도 boxing 없이 비교된다.

상황 6: 제네릭 제약 조건 — boxing을 구조적으로 제거하는 방법

제네릭에 제약 조건이 없으면 string.Format(string, object)를 거치면서 boxing이 발생한다. 인터페이스 제약을 걸면 constrained. 접두사로 boxing 없이 직접 호출한다.

Before — 제약 없음

C#
static string FormatNoConstraint<T>(T value)
{
    return string.Format("Value: {0}", value);
}
IL
IL_0001: ldstr "Value: {0}"
IL_0006: ldarg.0
IL_0007: box !!T                            // 💥 T가 값 타입이면 boxing
IL_000c: call string [System.Runtime]System.String::Format(string, object)

box !!T — T가 값 타입이면 무조건 boxing된다.

After — 인터페이스 제약

C#
static string FormatWithConstraint<T>(T value) where T : IFormattable
{
    return value.ToString(null, null);
}
IL
IL_0001: ldarga.s 'value'                   // 값의 주소 로드
IL_0003: ldnull
IL_0004: ldnull
IL_0005: constrained. !!T                   // ✅ constrained — boxing 회피
IL_000b: callvirt instance string [System.Runtime]System.IFormattable::ToString(string, class [System.Runtime]System.IFormatProvider)

box가 없다. constrained. !!T 접두사가 JIT에게 "T가 값 타입이면 boxing 없이 직접 호출하라"고 지시한다. ldarga.s는 값의 주소를 로드하므로 힙 할당이 전혀 없다.


함정과 주의사항

함정 1: delegate에 값 타입의 메서드 연결

delegate는 내부적으로 _target 필드에 대상 객체의 참조를 보관한다. 이 필드의 타입이 object이므로, 값 타입의 인스턴스 메서드를 delegate에 바인딩하면 boxing이 발생한다.

문제 코드

C#
struct Logger
{
    public int Id;
    public void Log(string msg)
    {
        Console.WriteLine($"[{Id}] {msg}");
    }
}

Logger logger = new Logger { Id = 1 };
Action<string> action = logger.Log; // 💥 boxing
action("Hello");
IL
IL_0013: ldloc.0
IL_0014: box Logger                        // 💥 delegate의 _target에 저장하기 위해 boxing
IL_0019: ldftn instance void Logger::Log(string)
IL_001f: newobj instance void class [System.Runtime]System.Action`1<string>::.ctor(object, native int)

box Loggernewobj Action<string> — delegate 생성 시 boxing이 발생한다. 이 delegate를 매 프레임 새로 만들면 매번 boxing + delegate 객체 할당이 일어난다.

대응: delegate에 바인딩할 메서드는 class에 두거나, static 메서드를 사용한다.

함정 2: Enum을 Dictionary 키로 사용

Unity 프로젝트에서 상태 머신 등에 enum을 Dictionary 키로 자주 사용한다. .NET Framework와 Mono 환경에서 기본 EqualityComparer<TEnum>GetHashCode() 호출 시 boxing을 유발했다.

문제 코드

C#
enum State { Idle, Running, Jumping }

// Mono/구형 .NET — 키 비교마다 boxing 발생 가능
var stateMap = new Dictionary<State, string>();
stateMap[State.Idle] = "대기";

대응: 커스텀 IEqualityComparer<T>를 제공한다.

C#
struct StateComparer : IEqualityComparer<State>
{
    public bool Equals(State x, State y) => x == y;
    public int GetHashCode(State obj) => (int)obj;
}

var stateMap = new Dictionary<State, string>(new StateComparer());
💡 .NET Core/.NET 5+ 에서는 enum의 EqualityComparer<T>.Default가 boxing 없이 동작하도록 개선되었다. 하지만 Unity의 Mono 런타임을 사용하는 프로젝트에서는 여전히 커스텀 comparer가 필요할 수 있다.

함정 3: Nullable<T>의 특수한 boxing

C#
int? nullable = 42;
object o = nullable; // boxing — 하지만 Nullable<int>가 아닌 int가 boxing됨

Nullable<T>의 boxing은 CLR이 특별히 처리한다:

  • 값이 있으면 → T만 boxing된다 (Nullable<T> 자체가 boxing되는 게 아니다)
  • 값이 null이면 → null 참조가 된다

이 때문에 o.GetType()을 호출하면 System.Int32가 반환된다. Nullable<Int32>가 아니다. 이 동작은 많은 개발자를 혼란스럽게 한다.

함정 4: Unity의 Debug.Log

C#
// ❌ boxing 발생 — LogFormat 내부에서 object[] 사용
Debug.LogFormat("HP: {0}", currentHP);

// ✅ boxing 회피 — ToString 명시 호출
Debug.Log("HP: " + currentHP.ToString());

// ✅ 가장 좋은 방법 — 릴리스 빌드에서 로그 제거
#if UNITY_EDITOR
Debug.Log("HP: " + currentHP.ToString());
#endif

Unity의 Debug.LogFormat은 내부적으로 string.Format(string, params object[])을 사용하므로 값 타입 인자마다 boxing이 발생한다. 프로덕션 빌드에서는 조건부 컴파일로 로그를 완전히 제거하는 것이 최선이다.


C# 버전별 변화

C# 2.0 — 제네릭 도입 (2005)

가장 혁명적인 변화다. 제네릭 이전에는 모든 범용 컨테이너가 object 기반이었으므로 값 타입을 다루려면 boxing이 불가피했다.

Before (C# 1.0): ArrayList — 모든 요소가 object

C#
ArrayList list = new ArrayList();
list.Add(42); // box int → object

After (C# 2.0): List<T> — 타입별 특수화

C#
List<int> list = new List<int>();
list.Add(42); // boxing 없음

.NET의 제네릭은 Java와 달리 reification(런타임 구체화)을 지원한다. JIT가 값 타입 T에 대해 전용 네이티브 코드를 생성하므로 boxing이 근본적으로 불필요하다.

C# 7.2 — ref struct (2017)

ref struct는 스택에만 존재할 수 있는 타입이다. boxing이 원천적으로 불가능하다. 힙에 올릴 수 없으므로 object로 변환하려 하면 컴파일 에러가 난다.

C#
Span<int> span = stackalloc int[10];
// object o = span; // ❌ 컴파일 에러 — ref struct는 boxing 불가

Span<T>이 대표적인 ref struct다. 힙 할당 없이 배열이나 메모리의 일부를 안전하게 참조할 수 있다.

C# 10.0 — DefaultInterpolatedStringHandler (2021)

문자열 보간의 패러다임이 바뀌었다. DefaultInterpolatedStringHandler는 값 타입의 ISpanFormattable을 직접 호출하여 boxing 없이 문자열을 조립한다.

Before (C# 9 이하): $"Score: {score}"string.Format → boxing

After (C# 10+): $"Score: {score}"DefaultInterpolatedStringHandler → boxing 없음

IL
// C# 10+에서의 문자열 보간 (boxing 없음)
call instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)

AppendFormatted<int32> — 제네릭 메서드로 int를 직접 받으므로 boxing이 없다. int, double 등 기본 타입은 모두 ISpanFormattable을 구현하므로 제로 박싱(zero-boxing)으로 문자열이 만들어진다.

C# 11.0 — Generic Math / static abstract interface members (2022)

INumber<T> 등 제네릭 수학 인터페이스가 도입되었다. static abstract 멤버를 통해 값 타입의 연산을 인터페이스로 추상화하면서도 boxing이 없다.

C#
static T Add<T>(T a, T b) where T : INumber<T>
{
    return a + b; // boxing 없이 연산자 호출
}

제약 기반 디스패치이므로 JIT가 직접 호출 코드를 생성한다.


정리 — boxing 방지 체크리스트

# 규칙 이유
1 ArrayList, Hashtable 대신 List<T>, Dictionary<TKey, TValue> 사용 비제네릭 컬렉션은 object 기반 — 넣고 꺼낼 때마다 boxing/unboxing
2 struct를 만들면 Equals, GetHashCode, ToString을 항상 재정의 미재정의 시 ValueType 기본 구현이 리플렉션 + boxing 사용
3 struct에 IEquatable<T> 구현 Dictionary 키 비교, List.Contains 등에서 boxing 제거
4 string.Format 대신 ToString() 명시 호출 또는 C# 10+ 보간 사용 string.Format(string, object)object 매개변수가 boxing 유발
5 제네릭 메서드에 인터페이스 제약 조건 추가 constrained. 접두사로 boxing 없이 인터페이스 메서드 직접 호출
6 Unity Update 루프 안에서 boxing 코드 금지 매 프레임 힙 할당 → GC 스파이크 → 프레임 드랍
7 Enum Dictionary 키에 커스텀 IEqualityComparer<T> 제공 (Unity Mono) Mono의 기본 comparer가 enum boxing 유발
8 delegate에 값 타입 메서드 바인딩 회피 delegate의 _targetobject — 값 타입은 boxing됨
9 Unity Profiler의 GC Alloc 열로 boxing 지점 탐지 Deep Profile 모드에서 모든 할당 추적 가능
10 Debug.Log를 릴리스 빌드에서 제거 LogFormatparams object[]가 boxing 유발