EveryDay.DevUp

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

C# 심화

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

EveryDay.DevUp 2026. 3. 17. 20:25

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

코드에 new가 없다고 해서 힙 할당이 없는 건 아니다.


boxing과 unboxing — 왜 존재하는가

C# 타입 시스템이 만든 구조적 긴장

C#은 성능과 통일성이라는 두 목표를 동시에 추구한다. 성능을 위해 int, float, struct 같은 값 타입(Value Type)은 스택(Stack, 메서드가 호출될 때 생성되는 임시 메모리 공간)에 직접 저장한다. 힙(Heap, CLR이 관리하는 장기 메모리 공간)을 거치지 않으므로 할당과 해제가 빠르다.

한편 통일성을 위해 C#은 모든 타입이 object를 상속하는 단일 계층 구조를 갖는다. intfloat도 결국 System.Object의 자손이다. 문제는 object 변수가 힙 주소를 가리키는 참조 타입인데, 스택에 있는 값 타입을 object로 다루려면 힙에 옮겨야 한다는 것이다.

이 필요에서 boxing이 탄생했다. 그리고 그 역방향이 unboxing이다.


boxing — 스택의 값이 힙으로 올라가는 순간

boxing은 값 타입을 object 또는 인터페이스 타입으로 변환할 때 발생하는 과정이다. CLR(Common Language Runtime, .NET 실행 환경)은 힙에 새 객체를 할당하고 스택의 값을 복사한다.

C#
int i = 42;
object boxed = i; // boxing: 힙에 Int32 객체 생성, 42 복사
IL
.method public hidebysig static void BasicBoxing () cil managed
{
    .maxstack 8

    IL_0000: ldc.i4.s 42       // 정수 42를 계산 스택에 로드
    IL_0002: box [System.Runtime]System.Int32
    // ★ CLR이 힙에 Int32 크기의 객체를 할당
    //   스택의 42를 힙으로 복사
    //   생성된 힙 객체의 주소(참조)를 스택에 푸시
    IL_0007: pop                // (결과를 사용하지 않아 버림)
    IL_0008: ret
} // end of method BasicBoxing

boxing된 객체는 단순한 값의 복사가 아니다. 힙에 생성된 객체는 실제 값 외에 두 가지 추가 정보를 항상 포함한다:

  • 타입 객체 포인터(Type Object Pointer): 이 객체가 어떤 타입인지 가리키는 참조 (8byte, 64bit)
  • 동기화 블록 인덱스(Sync Block Index): lock 등 스레드 동기화를 위한 인덱스 (8byte, 64bit)

4바이트 int 하나를 boxing하면 힙에서 최소 20바이트 이상을 차지한다. 값 자체보다 오버헤드가 더 크다.


unboxing — 힙에서 값을 꺼내는 순간

unboxing은 boxing된 객체에서 원래 값 타입을 꺼내는 과정이다. 반드시 명시적 캐스팅이 필요하며, 타입이 맞지 않으면 런타임에 InvalidCastException이 발생한다.

C#
object boxed = 42;     // boxing: int → object
int unboxed = (int)boxed; // unboxing: object → int (정상)
// double wrong = (double)boxed; // InvalidCastException! int를 double로 꺼낼 수 없음
IL
.method public hidebysig static void BasicBoxingUnboxing () cil managed
{
    .maxstack 8

    IL_0000: ldc.i4.s 42
    IL_0002: box [System.Runtime]System.Int32      // ★ boxing: 힙에 Int32 객체 생성
    IL_0007: unbox.any [System.Runtime]System.Int32
    // ★ unboxing:
    //   1. 힙 객체의 타입이 Int32인지 검사
    //   2. 타입 일치 → 값을 스택으로 복사
    //   3. 타입 불일치 → InvalidCastException 발생
    IL_000c: pop                // 결과 버림 (Release 최적화)
    IL_000d: ret
} // end of method BasicBoxingUnboxing

unbox.any는 타입 검사와 값 복사를 한 번에 수행한다. 내부적으로는 unbox(힙 객체 내부의 주소를 가져옴)와 ldobj(해당 주소의 값을 스택으로 복사)의 조합이다.


성능 비용 — 이 두 과정이 비싼 이유

boxing 하나가 발생할 때 실제로 일어나는 일을 분해하면 이렇다:

  1. 힙 메모리 할당: CLR이 관리 힙에서 공간을 탐색하고 확보한다. 스택 할당(esp 레지스터 하나 조작)과 달리 메모리 관리자가 개입한다.
  2. 데이터 복사: 스택의 값을 힙으로 복사한다. unboxing 시에는 힙 → 스택으로 다시 복사한다. 데이터는 항상 두 번 이동한다.
  3. GC 대상 등록: 힙에 생성된 boxed 객체는 GC(Garbage Collector, 더 이상 참조되지 않는 힙 객체를 자동으로 회수하는 런타임 구성요소)가 추적해야 한다. 아무리 짧게 사용해도 GC 사이클이 돌기 전까지 메모리를 점유한다.

한두 번의 boxing은 문제가 없다. 문제는 루프와 핫패스다.

C#
// 1만 번 boxing 발생 — 힙 객체 10000개 생성
ArrayList scores = new ArrayList();
for (int i = 0; i < 10000; i++)
{
    scores.Add(i); // 매 반복 boxing → 단명 객체가 Gen 0(0세대, GC가 가장 먼저 수집하는 단기 객체 영역)를 채움
}
// for 루프 종료 후 10000개의 boxing 객체가 GC 대상으로 남음

이 10000개의 객체가 Gen 0를 가득 채우면 GC 사이클이 강제로 실행된다. 게임에서 GC 사이클은 곧 프레임 정지다.


boxing이 숨어있는 곳 — 생각보다 많다

boxing은 명시적인 object boxed = value 형태에서만 발생하지 않는다. 컴파일러가 경고 없이 암묵적으로 처리하는 경우가 더 많다. 어디서 일어나는지 알아야 막을 수 있다.


1. object 타입에 할당

가장 기본적인 형태다. 값 타입을 object 변수에 대입하면 즉시 boxing이 발생한다.

C#
int level = 10;
object obj = level; // boxing
IL
IL_0000: ldc.i4.s 10
IL_0002: box [System.Runtime]System.Int32  // ★ boxing 발생
IL_0007: pop

2. 인터페이스 타입으로 캐스팅

struct가 인터페이스를 구현하더라도, 인터페이스 타입 변수에 담는 순간 boxing이 발생한다. 더 나쁜 것은 인터페이스 메서드 호출 시 인수도 별도로 boxing될 수 있다는 점이다.

C#
int score = 100;
IComparable comparable = score;          // boxing #1 — int → IComparable 참조
int result = comparable.CompareTo(50);   // boxing #2 — 인수 50도 object로 boxing
IL
.method public hidebysig static void InterfaceBoxing () cil managed
{
    .maxstack 8

    IL_0000: ldc.i4.s 100
    IL_0002: box [System.Runtime]System.Int32     // ★ boxing #1: score를 IComparable 객체로 포장
    IL_0007: ldc.i4.s 50
    IL_0009: box [System.Runtime]System.Int32     // ★ boxing #2: CompareTo(object) 인수로 50도 boxing
    IL_000e: callvirt instance int32 [System.Runtime]System.IComparable::CompareTo(object)
    IL_0013: pop
    IL_0014: ret
} // end of method InterfaceBoxing

comparable.CompareTo(50) 단 한 줄에 boxing이 두 번 발생한다. IComparable<int>를 사용하면 두 번 모두 제거할 수 있다.


3. 비제네릭 컬렉션

ArrayList, Hashtable, Stack, Queue 등 .NET 1.x 시절 컬렉션은 내부적으로 object[]를 사용한다. 값 타입을 넣을 때마다 boxing, 꺼낼 때마다 unboxing이 발생한다.

C#
// 나쁜 예 — Add마다 boxing, 인덱서마다 unboxing
ArrayList list = new ArrayList();
list.Add(1);   // boxing
list.Add(2);   // boxing
int val = (int)list[0]; // unboxing

// 좋은 예 — boxing/unboxing 없음
List<int> generic = new List<int>();
generic.Add(1);   // no boxing
generic.Add(2);   // no boxing
int val2 = generic[0]; // no unboxing
IL
// ── 비제네릭 ArrayList ──────────────────────────────────────────
.method public hidebysig static void NonGenericCollection () cil managed
{
    .maxstack 8

    IL_0000: newobj instance void [System.Runtime]System.Collections.ArrayList::.ctor()
    IL_0005: dup
    IL_0006: ldc.i4.1
    IL_0007: box [System.Runtime]System.Int32     // ★ boxing: 1 → object
    IL_000c: callvirt instance int32 [System.Runtime]System.Collections.ArrayList::Add(object)
    IL_0011: pop
    IL_0012: dup
    IL_0013: ldc.i4.2
    IL_0014: box [System.Runtime]System.Int32     // ★ boxing: 2 → object
    IL_0019: callvirt instance int32 [System.Runtime]System.Collections.ArrayList::Add(object)
    IL_001e: pop
    IL_001f: ldc.i4.0
    IL_0020: callvirt instance object [System.Runtime]System.Collections.ArrayList::get_Item(int32)
    IL_0025: unbox.any [System.Runtime]System.Int32  // ★ unboxing: object → int
    IL_002a: pop
    IL_002b: ret
} // end of method NonGenericCollection

// ── 제네릭 List<int> ────────────────────────────────────────────
.method public hidebysig static void GenericCollection () cil managed
{
    .maxstack 8

    IL_0000: newobj instance void class [System.Collections]System.Collections.Generic.List`1<int32>::.ctor()
    IL_0005: dup
    IL_0006: ldc.i4.1
    IL_0007: callvirt instance void class [System.Collections]System.Collections.Generic.List`1<int32>::Add(!0)
    // ↑ Add(!0): !0은 타입 파라미터 0번 = int32. box 명령어 없음
    IL_000c: dup
    IL_000d: ldc.i4.2
    IL_000e: callvirt instance void class [System.Collections]System.Collections.Generic.List`1<int32>::Add(!0)
    IL_0013: ldc.i4.0
    IL_0014: callvirt instance !0 class [System.Collections]System.Collections.Generic.List`1<int32>::get_Item(int32)
    // ↑ !0 반환: int32 그대로 반환, unbox.any 없음
    IL_0019: pop
    IL_001a: ret
} // end of method GenericCollection

두 IL의 차이는 명확하다. ArrayListboxAdd(object)unbox.any 삼단 구조인데, List<int>Add(!0) 하나로 끝난다. !0int32로 특수화되어 있으므로 boxing이 발생할 여지가 없다.


4. params object[] — Debug.Log의 함정

Unity 개발자가 가장 자주 쓰는 Debug.Log(object)는 인수를 object로 받는다. 값 타입을 넘기면 즉시 boxing이 발생한다. string.Formatparams object[]를 받는 모든 메서드가 동일하다.

C#
int damage = 50;
float ratio = 0.75f;

// Debug.Log(object) → boxing 1회
Debug.Log(damage);

// params object[] → 배열 생성 + boxing 2회
Debug.Log($"damage={damage}, ratio={ratio}"); // C# 9 이하에서는 boxing 가능
LogDebug("damage={0}, ratio={1}", damage, ratio); // boxing 확실
IL
.method public hidebysig static void ParamsBoxing () cil managed
{
    .maxstack 5
    .locals init ([0] int32, [1] float32)

    IL_0000: ldc.i4.s 50
    IL_0002: stloc.0               // damage = 50
    IL_0003: ldc.r4 0.75
    IL_0008: stloc.1               // ratio = 0.75f
    IL_0009: ldstr "damage={0}, ratio={1}"
    IL_000e: ldc.i4.2
    IL_000f: newarr [System.Runtime]System.Object  // ★ object[2] 배열을 힙에 생성
    IL_0014: dup
    IL_0015: ldc.i4.0
    IL_0016: ldloc.0
    IL_0017: box [System.Runtime]System.Int32      // ★ boxing: damage(int) → object
    IL_001c: stelem.ref                            //   배열[0]에 저장
    IL_001d: dup
    IL_001e: ldc.i4.1
    IL_001f: ldloc.1
    IL_0020: box [System.Runtime]System.Single     // ★ boxing: ratio(float) → object
    IL_0025: stelem.ref                            //   배열[1]에 저장
    IL_0026: call void NewBoxingCases::LogDebug(string, object[])
    IL_002b: ret
} // end of method ParamsBoxing

newarrobject[] 배열이 힙에 생성되고, 두 값이 각각 boxing되어 배열에 저장된다. 단 한 번의 로그 호출에 힙 할당이 3번(배열 1 + boxing 2) 발생한다. Unity Update()에서 매 프레임 이런 호출이 있다면 초당 180번의 힙 할당이 쌓인다.

$"damage={damage.ToString()}, ratio={ratio.ToString()}" 형태로 바꾸면 boxing과 배열 생성은 제거되지만, ToString()이 반환하는 string 객체와 최종 보간 string은 여전히 힙에 할당된다. boxing 회피의 의미는 힙 할당 제거가 아니라 불필요한 추가 할당 감소다 — boxing 객체는 string을 만들고 나면 즉시 버려지는 순수 낭비이기 때문이다. 로그 출력 자체가 string을 요구하는 이상 string 힙 할당은 피할 수 없다. Update()에서 GC 할당을 완전히 없애려면 Debug.Log 호출 자체를 #if UNITY_EDITOR로 에디터 전용으로 제한하거나 제거해야 한다.


5. string.Format / 문자열 보간

string.Format(string, object, object) 오버로드는 인수를 object로 받는다. 문자열 보간($"")도 C# 9 이하에서는 같은 오버로드로 컴파일되어 boxing이 발생한다.

C#
int hp = 100, maxHp = 200;

// 나쁜 예: boxing 2회
string s1 = string.Format("HP: {0}/{1}", hp, maxHp);

// 좋은 예: boxing 없음 — ToString()은 string(참조 타입)을 반환
string s2 = $"HP: {hp.ToString()}/{maxHp.ToString()}";
IL
// ── string.Format — boxing 2회 ──────────────────────────────────
.method public hidebysig static void WithBoxing () cil managed
{
    .locals init ([0] int32, [1] int32)

    IL_0009: ldstr "HP: {0}/{1}"
    IL_000e: ldloc.0
    IL_000f: box [System.Runtime]System.Int32     // ★ boxing: hp → object
    IL_0014: ldloc.1
    IL_0015: box [System.Runtime]System.Int32     // ★ boxing: maxHp → object
    IL_001a: call string [System.Runtime]System.String::Format(string, object, object)
    IL_001f: pop
    IL_0020: ret
}

// ── ToString() 명시 호출 — boxing 없음 ──────────────────────────
.method public hidebysig static void WithoutBoxing () cil managed
{
    .locals init ([0] int32, [1] int32)

    IL_0009: ldstr "HP: "
    IL_000e: ldloca.s 0
    IL_0010: call instance string [System.Runtime]System.Int32::ToString()  // ldloca.s: 주소만 전달, 복사 없음
    IL_0015: ldstr "/"
    IL_001a: ldloca.s 1
    IL_001c: call instance string [System.Runtime]System.Int32::ToString()  // boxing 없음
    IL_0021: call string [System.Runtime]System.String::Concat(string, string, string, string)
    IL_0026: pop
    IL_0027: ret
}

ToString() 명시 호출 경우 ldloca.s(스택 변수의 주소 로드)로 값을 복사하지 않고 주소만 전달한다. Int32.ToString()string(참조 타입)을 반환하므로 boxing이 필요 없다. 컴파일러는 이 패턴을 String.Concat으로 최적화하여 String.Format보다 가볍게 처리한다.


6. ToString() / Equals() / GetHashCode()를 오버라이드하지 않은 struct

struct에서 System.Object의 가상 메서드를 오버라이드하지 않으면, 호출 시 boxing이 발생한다. IL에서는 constrained. 접두어로 처리되지만, 오버라이드가 없으면 JIT(Just-In-Time, IL을 런타임에 네이티브 코드로 변환하는 컴파일러)가 boxing을 수행한다.

C#
// 나쁜 예: boxing 발생
public struct EnemyPositionBad
{
    public float X, Y;
    // ToString() 미오버라이드
}

// 좋은 예: boxing 없음
public struct EnemyPositionGood
{
    public float X, Y;
    public override string ToString() => $"({X:F1}, {Y:F1})"; // 직접 호출
}

var bad  = new EnemyPositionBad  { X = 1f, Y = 2f };
var good = new EnemyPositionGood { X = 1f, Y = 2f };
Debug.Log(bad.ToString());  // boxing
Debug.Log(good.ToString()); // no boxing
IL
// ── ToString() 미오버라이드 — 런타임에 boxing ──────────────────
.method public hidebysig static string WithoutOverride () cil managed
{
    .locals init ([0] valuetype PointNoOverride)

    IL_0000: ldloca.s 0          // 스택에 있는 p의 주소 로드
    IL_0004: call instance void PointNoOverride::.ctor(int32, int32)
    IL_0009: ldloca.s 0
    IL_000b: constrained. PointNoOverride   // ★ 전처리: 오버라이드 여부 확인
    IL_0011: callvirt instance string [System.Runtime]System.Object::ToString()
    // ↑ PointNoOverride가 ToString()을 오버라이드하지 않음
    //   → JIT가 런타임에 boxing 수행 후 Object.ToString() 호출
    IL_0016: ret
}

// ── ToString() 오버라이드 — boxing 없음 ─────────────────────────
.method public hidebysig static string WithOverride () cil managed
{
    .locals init ([0] valuetype PointWithOverride)

    IL_0000: ldloca.s 0
    IL_0004: call instance void PointWithOverride::.ctor(int32, int32)
    IL_0009: ldloca.s 0
    IL_000b: constrained. PointWithOverride  // ★ 전처리: 오버라이드 확인
    IL_0011: callvirt instance string [System.Runtime]System.Object::ToString()
    // ↑ PointWithOverride가 ToString()을 오버라이드했음
    //   → JIT가 PointWithOverride::ToString()을 주소 기반으로 직접 호출
    //   boxing 없음
    IL_0016: ret
}

두 IL은 구조가 동일하다. constrained. 접두어를 보고 JIT가 런타임에 판단한다 — 오버라이드가 있으면 직접 호출, 없으면 boxing 후 기반 클래스 메서드 호출. IL만 보면 boxing이 보이지 않지만, 오버라이드 없는 쪽은 실행 시 boxing이 발생한다.


7. Enum.HasFlag()

Enum.HasFlag(Enum flag)는 매개변수를 Enum(참조 타입) 기반으로 받는다. Mono 환경에서는 flagsflag 인수 모두 boxing된다.

C#
[Flags]
enum AbilityFlags { None = 0, Jump = 1, Attack = 2, Dash = 4 }

AbilityFlags flags = AbilityFlags.Jump | AbilityFlags.Attack;

bool a = flags.HasFlag(AbilityFlags.Jump);  // boxing 2회 (Mono)
bool b = (flags & AbilityFlags.Jump) != 0;  // boxing 없음
IL
// ── HasFlag — boxing 2회 ──────────────────────────────────────
.method public hidebysig static bool HasFlagBoxing (...) cil managed
{
    IL_0000: ldarg.0
    IL_0001: box NewBoxingCases/AbilityFlags      // ★ boxing: flags → Enum 참조
    IL_0006: ldc.i4.1
    IL_0007: box NewBoxingCases/AbilityFlags      // ★ boxing: AbilityFlags.Jump → Enum 참조
    IL_000c: call instance bool [System.Runtime]System.Enum::HasFlag(class [System.Runtime]System.Enum)
    IL_0011: ret
}

// ── 비트 연산 — boxing 없음 ──────────────────────────────────────
.method public hidebysig static bool HasFlagNoBoxing (...) cil managed
{
    IL_0000: ldarg.0
    IL_0001: ldc.i4.1
    IL_0002: and          // ★ 비트 AND 연산 — 힙 할당 없음
    IL_0003: ldc.i4.0
    IL_0004: cgt.un       // 0보다 큰지 비교 (unsigned)
    IL_0006: ret
}

HasFlagBoxing은 4줄의 IL에서 boxing이 2회 발생하고 가상 메서드 호출까지 한다. HasFlagNoBoxingand + cgt.un 두 개의 정수 연산으로 끝난다. Unity의 Mono 환경에서 매 프레임 상태 플래그를 검사한다면 비트 연산이 필수다.


8. GetType() 호출

GetType()System.Object에 정의된 비가상 메서드다. 값 타입에서 호출하면 boxing 없이 타입 정보를 가져올 방법이 없다.

C#
int n = 5;
Type t = n.GetType(); // boxing — 값 타입을 힙에 올려야 타입 정보를 읽을 수 있다

// 대안: typeof()는 boxing 없음 (컴파일 타임 해결)
Type t2 = typeof(int); // no boxing
IL
.method public hidebysig static void GetTypeBoxing () cil managed
{
    .locals init ([0] int32)

    IL_0000: ldc.i4.5
    IL_0001: stloc.0
    IL_0002: ldloc.0
    IL_0003: box [System.Runtime]System.Int32   // ★ boxing: n → object
    IL_0008: call instance class [System.Runtime]System.Type [System.Runtime]System.Object::GetType()
    IL_000d: pop
    IL_000e: ret
}

typeof(int)는 컴파일 타임에 Type 객체를 직접 로드하는 ldtoken으로 처리되어 boxing이 없다. GetType()이 필요한 상황에서 타입이 미리 알려져 있다면 typeof()로 대체할 수 있다.


제네릭으로 boxing 제거하기

boxing이 발생하는 대부분의 상황에는 제네릭 대안이 있다. 패턴별로 대응 방법을 정리한다.

컬렉션은 항상 제네릭 버전으로

C#
// boxing 발생
ArrayList   → List<T>
Hashtable   → Dictionary<TKey, TValue>
Stack       → Stack<T>
Queue       → Queue<T>

// 예시
List<int> scores = new List<int>();
scores.Add(100); // no boxing

인터페이스는 제네릭 버전 구현

C#
// 나쁜 예: CompareTo(object) → 호출 시 boxing 2회
public struct ItemLevel : IComparable
{
    public int Value;
    public int CompareTo(object? obj) => Value.CompareTo(((ItemLevel)obj!).Value);
}

// 좋은 예: CompareTo(ItemLevel) → boxing 없음
public struct ItemLevel : IComparable<ItemLevel>
{
    public int Value;
    public int CompareTo(ItemLevel other) => Value.CompareTo(other.Value);
}

// 호출 지점
ItemLevel a = new ItemLevel { Value = 5 };
ItemLevel b = new ItemLevel { Value = 3 };

IComparable comp = a;       // boxing
comp.CompareTo(b);          // b도 object로 boxing

a.CompareTo(b);             // no boxing
IL
// ── 비제네릭 IComparable — boxing 2회 ────────────────────────────
.method public hidebysig static int32 CompareBoxing () cil managed
{
    .locals init (
        [0] valuetype ItemLevelBad,
        [1] valuetype ItemLevelBad
    )

    // a 초기화 — slot 1을 임시 공간으로 사용
    IL_0000: ldloca.s 1
    IL_0002: initobj ItemLevelBad        // slot 1을 0으로 초기화 (힙 할당 없음)
    IL_0008: ldloca.s 1
    IL_000a: ldc.i4.5
    IL_000b: stfld int32 ItemLevelBad::Value
    IL_0010: ldloc.1          // ★ a = {Value=5}를 스택에 올려 보관 (로컬에 저장하지 않음)

    // b 초기화 — slot 1 재활용
    IL_0011: ldloca.s 1
    IL_0013: initobj ItemLevelBad        // slot 1을 다시 0으로 초기화
    IL_0019: ldloca.s 1
    IL_001b: ldc.i4.3
    IL_001c: stfld int32 ItemLevelBad::Value
    IL_0021: ldloc.1          // b = {Value=3}
    IL_0022: stloc.0          // b를 slot 0에 저장

    IL_0023: box ItemLevelBad   // ★ boxing #1: 스택에 유지된 a → IComparable 참조
    IL_0028: ldloc.0            // b 로드 (slot 0)
    IL_0029: box ItemLevelBad   // ★ boxing #2: b → object 참조
    IL_002e: callvirt instance int32 [System.Runtime]System.IComparable::CompareTo(object)
    IL_0033: ret
} // end of method CompareBoxing

// ── 제네릭 IComparable<T> — boxing 없음 ───────────────────────────
.method public hidebysig static int32 CompareNoBoxing () cil managed
{
    .locals init (
        [0] valuetype ItemLevelGood,
        [1] valuetype ItemLevelGood,
        [2] valuetype ItemLevelGood   // 초기화 임시 슬롯
    )

    // a 초기화 — slot 2를 임시 공간으로 사용
    IL_0000: ldloca.s 2
    IL_0002: initobj ItemLevelGood
    IL_0008: ldloca.s 2
    IL_000a: ldc.i4.5
    IL_000b: stfld int32 ItemLevelGood::Value
    IL_0010: ldloc.2
    IL_0011: stloc.0            // a = { Value = 5 }
    IL_0012: ldloca.s 2
    IL_0014: initobj ItemLevelGood
    IL_001a: ldloca.s 2
    IL_001c: ldc.i4.3
    IL_001d: stfld int32 ItemLevelGood::Value
    IL_0022: ldloc.2
    IL_0023: stloc.1            // b = { Value = 3 }

    IL_0024: ldloca.s 0         // a의 스택 주소 로드 (복사 없음)
    IL_0026: ldloc.1            // b의 값을 스택에 로드 (값 타입 직접 전달)
    IL_0027: call instance int32 ItemLevelGood::CompareTo(valuetype ItemLevelGood)
    // ↑ box 명령어 없음 — ItemLevelGood이 그대로 전달됨
    IL_002c: ret
} // end of method CompareNoBoxing

CompareBoxing에서는 box ItemLevelBad가 두 번 실행된다. CompareNoBoxing에서는 call instance int32 ItemLevelGood::CompareTo(valuetype ItemLevelGood) — 매개변수 타입이 valuetype ItemLevelGood이므로 boxing이 발생할 여지가 없다.

struct에는 반드시 ToString() / Equals() / GetHashCode() 오버라이드

C#
// 나쁜 예: 세 메서드 모두 미오버라이드 → 호출 시 boxing 발생
public struct EnemyStateBad
{
    public int Hp;
}

// 좋은 예: IEquatable<T> 구현 + 세 메서드 오버라이드 → boxing 없음
public struct EnemyStateGood : IEquatable<EnemyStateGood>
{
    public int Hp;
    public override string ToString() => $"Hp:{Hp}";
    public bool Equals(EnemyStateGood other) => Hp == other.Hp;         // no boxing
    public override bool Equals(object? obj) => obj is EnemyStateGood o && Equals(o);
    public override int GetHashCode() => Hp.GetHashCode();
}

EnemyStateBad bad1 = new EnemyStateBad { Hp = 100 };
EnemyStateBad bad2 = new EnemyStateBad { Hp = 100 };
bad1.Equals(bad2);   // bad2가 object?로 boxing

EnemyStateGood good1 = new EnemyStateGood { Hp = 100 };
EnemyStateGood good2 = new EnemyStateGood { Hp = 100 };
good1.Equals(good2); // Equals(EnemyStateGood) 직접 호출 — no boxing
IL
// ── IEquatable<T> 미구현 — Equals 호출 시 boxing ─────────────
.method public hidebysig static bool EqualsBoxing () cil managed
{
    .locals init ([0] valuetype EnemyStateBad, [1] valuetype EnemyStateBad)

    // bad1, bad2 초기화 생략 ...
    IL_0026: ldloca.s 0
    IL_0028: ldloc.1
    IL_0029: box EnemyStateBad          // ★ boxing: bad2 → object?
    IL_002e: constrained. EnemyStateBad // 전처리: EnemyStateBad가 Equals를 오버라이드했는가?
    IL_0034: callvirt instance bool [System.Runtime]System.Object::Equals(object)
    // ↑ 오버라이드 없음 → Object.Equals(object) 호출, boxing 완료
    IL_0039: ret
}

// ── IEquatable<T> 구현 — boxing 없음 ──────────────────────────
.method public hidebysig static bool EqualsNoBoxing () cil managed
{
    .locals init ([0] valuetype EnemyStateGood, [1] valuetype EnemyStateGood)

    // good1, good2 초기화 생략 ...
    IL_0026: ldloca.s 0
    IL_0028: ldloc.1
    IL_0029: call instance bool EnemyStateGood::Equals(valuetype EnemyStateGood)
    // ↑ box 없음 — IEquatable<EnemyStateGood>.Equals(EnemyStateGood) 직접 호출
    IL_002e: ret
}

EqualsBoxing에서는 box EnemyStateBad가 실행되어 bad2가 힙으로 올라간다. EqualsNoBoxing에서는 call instance bool EnemyStateGood::Equals(valuetype EnemyStateGood) — 매개변수가 valuetype이므로 boxing이 없다.

GetHashCode()도 동일한 원리다. override GetHashCode()가 없으면 constrained. 전처리 후 boxing이 발생한다. Dictionary나 HashSet에 struct를 키로 쓸 때 매번 boxing이 일어나는 이유가 여기 있다.

string 보간에서 ToString() 명시 호출

C#
int damage = 150;

// C# 9 이하에서 boxing 발생
Debug.Log($"데미지: {damage}");

// boxing 없음, string 힙 할당은 발생 — .NET 버전에 관계없이 안전
Debug.Log($"데미지: {damage.ToString()}");
IL
// ── string.Format 방식 — boxing ───────────────────────────────
IL_0003: ldstr "HP: {0}"
IL_0008: ldloc.0
IL_0009: box [System.Runtime]System.Int32    // ★ boxing: damage → object
IL_000e: call string [System.Runtime]System.String::Format(string, object)
IL_0013: ret

// ── ToString() 명시 — boxing 없음 ─────────────────────────────
IL_0003: ldstr "HP: "
IL_0008: ldloca.s 0                          // damage의 스택 주소만 전달
IL_000a: call instance string [System.Runtime]System.Int32::ToString()  // box 없음
IL_000f: call string [System.Runtime]System.String::Concat(string, string)
IL_0014: ret

Enum 플래그 검사는 비트 연산으로

C#
// Mono에서 boxing 2회
if (flags.HasFlag(AbilityFlags.Jump)) { }

// boxing 없음
if ((flags & AbilityFlags.Jump) != 0) { }
IL
// ── HasFlag — boxing 2회 ───────────────────────────────────────
IL_0001: box AbilityFlags   // ★ boxing: flags → Enum
IL_0007: box AbilityFlags   // ★ boxing: AbilityFlags.Jump → Enum
IL_000c: call instance bool [System.Runtime]System.Enum::HasFlag(class [System.Runtime]System.Enum)

// ── 비트 연산 — boxing 없음 ────────────────────────────────────
IL_0000: ldarg.0
IL_0001: ldc.i4.1
IL_0002: and     // 정수 AND 연산 — 힙 접근 없음
IL_0003: ldc.i4.0
IL_0004: cgt.un  // 0보다 큰지 비교
IL_0006: ret

C#이 boxing에 맞서온 역사

boxing은 C# 초창기부터 알려진 문제였다. 언어 설계팀은 버전마다 boxing이 불필요하게 발생하는 상황을 하나씩 공략해왔다. 이 흐름을 이해하면 버전별 코드 패턴이 왜 다른지 이해된다.

C# 1.0 — boxing이 피할 수 없었던 시절

제네릭이 없었다. 모든 범용 자료구조는 object[] 기반이었다. 타입 안전성도 없고 boxing도 피할 수 없었다.

C#
// C# 1.0 — 유일한 선택
ArrayList list = new ArrayList();
list.Add(42);           // boxing — 대안이 없다
int val = (int)list[0]; // unboxing — 명시적 캐스팅 필수
IL
// C# 1.0 유일한 패턴 — Add마다 box, 읽을 때마다 unbox.any
IL_0006: ldc.i4.s 42
IL_0008: box [System.Runtime]System.Int32        // ★ boxing 불가피
IL_000d: callvirt instance int32 System.Collections.ArrayList::Add(object)
IL_0013: ldc.i4.0
IL_0014: callvirt instance object System.Collections.ArrayList::get_Item(int32)
IL_0019: unbox.any [System.Runtime]System.Int32  // ★ unboxing 불가피

이 시절 코드에는 ArrayListHashtable이 가득했다. 모든 범용 라이브러리 API가 object를 인수로 받았고, 개발자들은 boxing 비용을 성능 측정을 통해 경험적으로 파악해야 했다.

C# 2.0 — 제네릭 도입, 컬렉션 boxing 해결

C# 2.0(.NET 2.0, 2005)에서 제네릭이 도입됐다. boxing 문제가 제네릭 설계의 핵심 동기 중 하나였다. List<T>, Dictionary<K,V> 등 타입 전용 컬렉션이 생기면서 컬렉션 boxing이 대부분 사라졌다.

C#
// C# 2.0 이후 — boxing 없음
List<int> list = new List<int>();
list.Add(42);    // no boxing
int val = list[0]; // no unboxing
IL
// C# 2.0 제네릭 — box 명령어가 사라졌다
IL_0000: newobj instance void class System.Collections.Generic.List`1<int32>::.ctor()
IL_0005: dup
IL_0006: ldc.i4.s 42
IL_0008: callvirt instance void class System.Collections.Generic.List`1<int32>::Add(!0)
// ↑ Add(!0): !0 = int32. box 명령어 없음
IL_000d: ldc.i4.0
IL_000e: callvirt instance !0 class System.Collections.Generic.List`1<int32>::get_Item(int32)
// ↑ !0 반환 = int32 직접 반환. unbox.any 없음

또한 IComparable<T>, IEquatable<T>, IEnumerable<T> 같은 제네릭 인터페이스가 도입되어 비교와 열거 연산의 boxing도 제거할 수 있게 됐다.

C#
// C# 1.0: IComparable.CompareTo(object) → boxing
// C# 2.0: IComparable<Score>.CompareTo(Score) → no boxing
public struct Score : IComparable<Score>
{
    public int Value;
    public int CompareTo(Score other) => Value.CompareTo(other.Value); // no boxing
}
IL
// C# 2.0 IComparable<Score> — CompareTo 구현부
.method public final hidebysig newslot virtual
    instance int32 CompareTo (valuetype Score other) cil managed
{
    IL_0000: ldarg.0
    IL_0001: ldflda int32 Score::Value
    IL_0006: ldarg.1
    IL_0007: ldfld int32 Score::Value   // other.Value를 스택에 로드 — box 없음
    IL_000c: call instance int32 [System.Runtime]System.Int32::CompareTo(int32)
    IL_0011: ret
}
// 매개변수가 'valuetype Score' — object가 아니므로 boxing 발생 지점 자체가 없다

이 시점에서 컬렉션 boxing은 해결됐지만, 인터페이스 변수 할당, 가상 메서드 호출 등 나머지 상황은 여전히 남아있었다.

.NET Core 2.1 — Enum.HasFlag() boxing 제거

기존 Enum.HasFlag(Enum flag)는 인수를 Enum 타입으로 받아 boxing을 피할 수 없었다. .NET Core 2.1에서 JIT 인트린식(Intrinsic, JIT가 특정 메서드 호출을 인식하여 최적화된 네이티브 코드로 직접 대체하는 기법)으로 최적화되어, JIT 컴파일 시 HasFlag 호출이 비트 연산 코드로 치환된다.

C#
// .NET Core 2.1 이전: boxing 2회
// .NET Core 2.1 이후: JIT가 (flags & flag) != 0 으로 치환 → boxing 없음
if (flags.HasFlag(AbilityFlags.Jump)) { }
IL
// .NET Core 2.1 이전 / Unity Mono — HasFlag IL (boxing 2회)
IL_0001: box AbilityFlags   // ★ boxing: flags → Enum
IL_0007: box AbilityFlags   // ★ boxing: AbilityFlags.Jump → Enum
IL_000c: call instance bool [System.Runtime]System.Enum::HasFlag(class [System.Runtime]System.Enum)

// .NET Core 2.1 이후 — JIT 인트린식으로 치환되는 네이티브 코드 (IL이 아닌 JIT 결과)
// mov eax, [flags]
// and eax, 1          ; (flags & AbilityFlags.Jump)
// test eax, eax       ; != 0
// setne al
// IL 레벨에서는 동일하지만 JIT가 box 명령어를 제거하고 AND 연산으로 대체한다
Unity 주의: Unity의 Mono 백엔드는 이 최적화가 적용되지 않는다. IL2CPP 빌드에서는 적용될 수 있으나, 에디터(Mono)에서는 boxing이 발생한다. 플랫폼에 관계없이 비트 연산을 사용하는 것이 안전하다.

C# 10 / .NET 6 — 문자열 보간의 boxing 제거

C# 10에서 문자열 보간의 컴파일 방식이 바뀌었다. C# 9까지 $"{value}" 형태는 string.Format(string, object)으로 컴파일되어 boxing이 발생했다. C# 10부터는 DefaultInterpolatedStringHandler를 사용하는 코드로 컴파일된다. 이 핸들러는 AppendFormatted<T>(T value) 제네릭 메서드를 통해 boxing 없이 처리한다.

C#
int hp = 100;

// C# 9 이하: String.Format(string, object) → boxing
// C# 10 이상: DefaultInterpolatedStringHandler.AppendFormatted<int> → no boxing
string s = $"HP: {hp}";
IL
// ── C# 9 이하: string.Format — boxing ───────────────────────────
IL_0003: ldstr "HP: {0}"
IL_0008: ldloc.0
IL_0009: box [System.Runtime]System.Int32        // ★ boxing: hp → object
IL_000e: call string [System.Runtime]System.String::Format(string, object)

// ── C# 10 이상: DefaultInterpolatedStringHandler — boxing 없음 ──
IL_0004: call instance void DefaultInterpolatedStringHandler::.ctor(int32, int32)
IL_000b: ldstr "HP: "
IL_0010: call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0017: ldfld int32 ..::Hp
IL_001d: call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
// ↑ AppendFormatted<int32>: 제네릭 메서드 — box 없음
IL_0024: call instance string DefaultInterpolatedStringHandler::ToStringAndClear()

AppendFormatted<T>(T value)의 타입 파라미터가 int32로 특수화되므로 boxing이 발생하지 않는다. 이것이 C# 10 문자열 보간 최적화의 핵심이다.

Unity 2022.2부터 C# 10 기능이 일부 지원되기 시작했지만, Mono 백엔드에서 이 최적화가 완전히 적용되는지는 Unity Profiler로 직접 확인해야 한다. 불확실하다면 ToString() 명시 호출이 더 안전하다.

C# 11 — 제네릭 수학으로 numeric boxing 패턴 해결

C# 11에서 정적 추상 인터페이스 멤버(static abstract interface members)가 도입됐다. 이를 기반으로 System.Numerics.INumber<T> 같은 제네릭 수학 인터페이스가 추가됐다. 이전에는 "임의의 숫자 타입"을 다루려면 object나 비제네릭 인터페이스를 써야 했다.

C#
// C# 10 이전: 숫자 타입을 범용으로 다루려면 boxing 불가피
static void PrintValue(IComparable value) => Console.WriteLine(value.ToString()); // boxing

// C# 11 이후: 제네릭 제약으로 boxing 없음
static void PrintValue<T>(T value) where T : INumber<T> => Console.WriteLine(value.ToString()); // no boxing

// 호출 지점
PrintValue(42);          // boxing: int → IComparable
PrintValue<int>(42);     // no boxing: T = int로 특수화
IL
// ── 호출 지점 비교 — CallSites() ────────────────────────────────
.method public hidebysig static void CallSites () cil managed
{
    // PrintWithBoxing(42): IComparable 인수 → boxing
    IL_0000: ldc.i4.s 42
    IL_0002: box [System.Runtime]System.Int32     // ★ boxing: 42 → IComparable
    IL_0007: call void GenericMathDemo::PrintWithBoxing(class [System.Runtime]System.IComparable)

    // PrintNoBoxing<int>(42): T = int32로 특수화 → no boxing
    IL_000c: ldc.i4.s 42
    IL_000e: call void GenericMathDemo::PrintNoBoxing<int32>(!!0)
    // ↑ !!0 = T = int32. box 없음 — int32가 그대로 전달됨
    IL_0013: ret
}

// ── PrintNoBoxing<T> 내부 — constrained. T 처리 ─────────────────
.method public hidebysig static void PrintNoBoxing<(INumber`1<!!T>) T> (!!0 'value') cil managed
{
    IL_0000: ldarga.s 'value'           // T 값의 주소 로드
    IL_0002: constrained. !!T           // T가 오버라이드했는지 JIT가 판단
    IL_0008: callvirt instance string [System.Runtime]System.Object::ToString()
    // T = int32이고 Int32가 ToString()을 오버라이드함 → boxing 없이 직접 호출
    IL_000d: call void [System.Console]System.Console::WriteLine(string)
    IL_0012: ret
}

CallSites에서 PrintWithBoxing(42) 호출은 box System.Int32가 먼저 실행된다. PrintNoBoxing<int32>(42) 호출은 box 없이 !!0(= int32)으로 직접 전달된다. C# 11 제네릭 수학 인터페이스가 boxing을 제거하는 방식이 IL 레벨에서 명확히 보인다.


C# 버전별 boxing 해결 흐름을 정리하면 이렇다:

버전 해결한 boxing 상황
C# 2.0 컬렉션, 제네릭 인터페이스
.NET Core 2.1 Enum.HasFlag() (JIT 인트린식)
C# 10 / .NET 6 문자열 보간 (DefaultInterpolatedStringHandler)
C# 11 제네릭 수학 인터페이스 (INumber<T>)

언어는 발전했지만 아직도 해결되지 않은 상황이 남아있다 — 인터페이스 변수 할당, 미오버라이드 가상 메서드, GetType(). 그리고 Unity 환경에서는 최신 최적화가 적용되지 않는 경우가 많다.


Unity에서의 boxing — GC 스파이크의 주범

Unity의 GC가 boxing에 특히 민감한 이유

Unity는 표준 .NET의 세대별 GC 대신 Boehm GC를 사용한다. Boehm GC의 핵심 특성은 보수적 수집(Conservative Collection)이다. 표준 .NET GC는 힙을 직접 관리하여 어떤 메모리가 포인터인지 정확히 안다. 반면 Boehm GC는 스택과 레지스터를 스캔하면서 포인터처럼 보이는 모든 값을 잠재적 포인터로 간주한다.

이 때문에 스택의 int 값이 우연히 힙 객체 주소와 동일한 비트 패턴을 가지면, GC가 해당 객체를 "참조 중"이라 오판하고 수거하지 못한다. boxing으로 생성된 단명 객체들이 GC에 의해 제때 수거되지 못하면 힙이 점점 불어난다.

Update()처럼 매 프레임 호출되는 메서드에서 boxing이 발생하면:

  1. 매 프레임 힙에 단명 객체가 누적된다
  2. Gen 0가 빠르게 소진된다
  3. GC 사이클이 빈번하게 촉발된다
  4. GC 실행 중 게임이 순간적으로 멈춘다 — 이것이 GC 스파이크
  5. GC 스파이크는 프레임 드랍으로 사용자에게 직접 느껴진다

🎮 핫패스 — 하지 말아야 할 것과 해야 할 것

C#
// 나쁜 예 — 매 프레임 boxing 발생
void Update()
{
    // 1. Debug.Log(object) → boxing 1회
    Debug.Log(currentHp);

    // 2. string.Format → boxing 2회
    Debug.Log(string.Format("HP: {0}/{1}", currentHp, maxHp));

    // 3. 인터페이스 변수에 struct 할당 → boxing
    IComparable comp = currentHp;

    // 4. HasFlag → boxing 2회 (Mono)
    if (playerState.HasFlag(StateFlags.Grounded)) { }

    // 5. 비제네릭 컬렉션 → 매 추가마다 boxing
    ArrayList tempList = new ArrayList();
    tempList.Add(transform.position.x);
}
C#
// 좋은 예 — boxing 제거 (string 힙 할당은 여전히 발생)
void Update()
{
    // 1. boxing 제거 — string 할당 1회 (boxing 있을 때는 boxing 객체 + string으로 2회)
    Debug.Log(currentHp.ToString());

    // 2. boxing 제거 — string 할당은 발생 (Debug.Log 자체가 string을 요구)
    Debug.Log($"HP: {currentHp.ToString()}/{maxHp.ToString()}");

    // 3. 직접 타입 메서드 사용
    int result = currentHp.CompareTo(maxHp);

    // 4. 비트 연산으로 대체
    if ((playerState & StateFlags.Grounded) != 0) { }

    // 5. 제네릭 컬렉션 + 가능하면 미리 할당
    _tempPositions.Clear(); // List<float> 필드로 재사용
    _tempPositions.Add(transform.position.x);
}

🎮 IL2CPP에서의 차이

IL2CPP(iOS, Android AOT 빌드)는 제네릭 코드를 타입별 완전한 C++ 코드로 미리 생성한다. List<int>List<float>는 완전히 별개의 C++ 클래스가 된다. 런타임에 object로 변환할 필요 자체가 사라지므로 제네릭을 사용한 경우 boxing 위험이 더욱 줄어든다.

반면 Mono 환경(에디터 플레이 모드)에서는 JIT가 실행되며, .NET Core 2.1의 HasFlag 최적화나 C# 10의 보간 최적화가 적용되지 않을 수 있다. 성능 측정은 반드시 타겟 플랫폼(IL2CPP 빌드)에서 Unity Profiler로 확인해야 한다.

🎮 Unity Profiler로 boxing 감지

Unity Profiler Memory 탭 → GC Alloc 컬럼을 확인한다. Update()에서 매 프레임 0 bytes가 아닌 할당이 보이면 boxing을 의심한다. CPU 탭에서 해당 메서드를 클릭하면 어떤 호출 경로에서 할당이 발생하는지 추적할 수 있다.


정리

boxing은 C# 타입 시스템의 구조적 필요에서 탄생했다. 문제는 이것이 가장 흔한 코드 패턴들 속에 조용히 숨어있다는 것이다.

상황 boxing? 대안
object obj = intValue O 꼭 필요한 경우에만
인터페이스 변수에 struct 할당 O 제네릭 인터페이스
ArrayList.Add(int) O List<int>
Debug.Log(intValue) O Debug.Log(intValue.ToString())
string.Format("{0}", int) O .ToString() 명시
struct에서 ToString() 미오버라이드 O (런타임) override ToString() 추가
flags.HasFlag(...) (Mono) O (flags & f) != 0
n.GetType() O typeof(T) (가능한 경우)
List<int>.Add(int) X
IComparable<T>.CompareTo(T) X
override ToString() 있는 struct X
$"{n.ToString()}" X
📌 네 가지 습관만으로 대부분의 의도치 않은 boxing을 제거할 수 있다:

1. 컬렉션은 항상 제네릭
2. struct에는 ToString() / Equals() / GetHashCode() 오버라이드
3. 문자열 보간 시 .ToString() 명시 호출
4. Enum 플래그 검사는 비트 연산

boxing이 나쁜 것이 아니다. boxing이 반복되는 핫패스 안에 숨어있는 것이 나쁘다.