| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- base64
- sha
- RSA
- adfit
- 샘플
- 최적화
- TextMeshPro
- 암호화
- 패스트캠퍼스
- Dots
- Unity Editor
- 가이드
- AES
- Job 시스템
- 환급챌린지
- ui
- 직장인자기계발
- Framework
- Custom Package
- C#
- Tween
- 패스트캠퍼스후기
- job
- unity
- 오공완
- 2D Camera
- 프레임워크
- 직장인공부
- 게임개발
- DotsTween
- Today
- Total
EveryDay.DevUp
boxing과 unboxing — 값 타입이 힙에 올라가는 순간 본문
boxing과 unboxing — 값 타입이 힙에 올라가는 순간
눈에 보이지 않는 한 줄이 매 프레임 힙을 오염시킨다. boxing이 뭔지 모르면, Unity Profiler에서 GC Alloc이 왜 찍히는지도 모른다.
목차
문제 제기 — 한 줄이 만드는 프레임 드랍
Unity에서 Update 루프 안에 이런 코드가 있다고 하자.
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)
기본 코드로 확인
int i = 42;
object boxed = i; // boxing
int unboxed = (int)boxed; // unboxing
Console.WriteLine(unboxed);
.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에서 box와 unbox.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 내부에서 정확히 세 가지 일이 벌어진다:
- 힙 할당 — 관리 힙(Managed Heap)에
Object Header(8바이트) + Method Table Pointer(8바이트) + 실제 값 + 패딩크기의 메모리를 할당한다 - 값 복사 — 스택에 있던 값을 힙의 박스 안으로 복사한다 (memcpy, 메모리 블록 복사)
- 참조 반환 — 힙 객체에 대한 참조를 스택에 올린다
unboxing의 2단계
unboxing은 boxing보다 단순하지만, 타입이 정확히 일치해야 한다:
- 타입 검증 — 박스의 Method Table을 확인하여 요청한 타입과 일치하는지 검사
- 포인터 반환 + 값 복사 — 박스 내부의 값 위치를 가리키는 포인터를 돌려주고, 이어서 값을 스택으로 복사
핵심은 int를 boxing한 것을 long으로 unboxing할 수 없다는 것이다. 반드시 원래 타입으로만 꺼낼 수 있다.
IL에서 constrained 접두사
제네릭 코드에서 boxing을 피하는 핵심 메커니즘이 constrained. 접두사다. 이것은 다음 섹션에서 자세히 다루겠지만, 원리를 먼저 이해해두자.
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로 캐스팅
가장 단순하고 명시적인 형태다.
int value = 10;
object boxed = value; // boxing
대응: object 변수에 값 타입을 담아야 하는 상황 자체를 피한다. 제네릭을 사용하면 object를 거치지 않아도 된다.
상황 2: 인터페이스로 캐스팅
struct가 인터페이스를 구현하더라도, 인터페이스 타입 변수에 대입하면 boxing이 발생한다. 인터페이스는 참조 타입이고, 인터페이스 디스패치는 vtable(가상 메서드 테이블)을 통해 이루어지기 때문이다.
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_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 발생
static void BoxingFormat()
{
int score = 100;
string s = string.Format("Score: {0}", score);
Console.WriteLine(s);
}
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 회피
static void NoBoxingToString()
{
int score = 100;
string s = "Score: " + score.ToString();
Console.WriteLine(s);
}
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, Hashtable 등 System.Collections 네임스페이스의 구형 컬렉션은 내부적으로 object[]를 사용한다.
❌ Before — ArrayList (boxing/unboxing 발생)
static void WithArrayList()
{
ArrayList list = new ArrayList();
list.Add(42); // boxing
int val = (int)list[0]; // unboxing
}
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
Add에 box, get_Item 후에 unbox.any — 넣을 때와 꺼낼 때 모두 비용이 발생한다.
✅ After — List<T> (boxing/unboxing 없음)
static void WithGenericList()
{
List<int> list = new List<int>();
list.Add(42); // boxing 없음
int val = list[0]; // unboxing 없음
}
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 반환
box와 unbox.any가 완전히 사라졌다. List<int>는 내부적으로 int[] 배열을 사용하기 때문에 값 타입을 그대로 저장한다. JIT가 T를 int로 특수화(specialization)한 네이티브 코드를 생성하므로 boxing이 근본적으로 불필요하다.
상황 5: Equals/GetHashCode/ToString 미재정의
값 타입이 이 메서드들을 재정의하지 않으면, System.ValueType의 기본 구현이 호출된다. ValueType.Equals(object other)의 매개변수가 object이므로 비교 대상이 boxing된다.
❌ Before — IEquatable<T> 미구현
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_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> 구현
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_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 — 제약 없음
static string FormatNoConstraint<T>(T value)
{
return string.Format("Value: {0}", value);
}
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 — 인터페이스 제약
static string FormatWithConstraint<T>(T value) where T : IFormattable
{
return value.ToString(null, null);
}
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이 발생한다.
❌ 문제 코드
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_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 Logger 후 newobj Action<string> — delegate 생성 시 boxing이 발생한다. 이 delegate를 매 프레임 새로 만들면 매번 boxing + delegate 객체 할당이 일어난다.
✅ 대응: delegate에 바인딩할 메서드는 class에 두거나, static 메서드를 사용한다.
함정 2: Enum을 Dictionary 키로 사용
Unity 프로젝트에서 상태 머신 등에 enum을 Dictionary 키로 자주 사용한다. .NET Framework와 Mono 환경에서 기본 EqualityComparer<TEnum>이 GetHashCode() 호출 시 boxing을 유발했다.
❌ 문제 코드
enum State { Idle, Running, Jumping }
// Mono/구형 .NET — 키 비교마다 boxing 발생 가능
var stateMap = new Dictionary<State, string>();
stateMap[State.Idle] = "대기";
✅ 대응: 커스텀 IEqualityComparer<T>를 제공한다.
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
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
// ❌ 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
ArrayList list = new ArrayList();
list.Add(42); // box int → object
✅ After (C# 2.0): List<T> — 타입별 특수화
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로 변환하려 하면 컴파일 에러가 난다.
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 없음
// 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이 없다.
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의 _target이 object — 값 타입은 boxing됨 |
| 9 | Unity Profiler의 GC Alloc 열로 boxing 지점 탐지 | Deep Profile 모드에서 모든 할당 추적 가능 |
| 10 | Debug.Log를 릴리스 빌드에서 제거 |
LogFormat의 params object[]가 boxing 유발 |
'C# 심화' 카테고리의 다른 글
| var — 타입 추론의 원리와 한계 (0) | 2026.03.30 |
|---|---|
| null 처리 연산자 — ??, ?., ??= (0) | 2026.03.30 |
| null이란 무엇인가 — null의 두 얼굴 (0) | 2026.03.30 |
| 값 타입과 참조 타입 — C#은 왜 둘로 나눴는가 (0) | 2026.03.29 |
| Claude Code로 C# 블로그와 유튜브 채널을 자동으로 운영하는 방법 (0) | 2026.03.29 |
