boxing이 GC Alloc을 만드는 진짜 이유 — IL box를 직접 본다
1. 문제 제기
Unity Profiler를 열고 Update를 봤더니 GC Alloc이 매 프레임 잡힌다. new를 쓴 데는 없다. 코드를 훑어봐도 객체를 만드는 줄이 안 보인다. 그런데 할당이 있다.
범인은 대개 이런 평범한 줄들이다.
void Update()
{
Debug.Log(_frame); // int
_list.Add(_state); // ArrayList에 enum
_label.text = string.Format("HP {0}", _hp); // int
}
int, enum 어디에도 new가 없다. 그런데 이 세 줄은 각각 힙에 객체를 만든다. boxing 때문이다.
boxing은 "값 타입을 object로 바꾸는 것"이라고 흔히 배운다. 그런데 그 "바꾼다"가 정확히 무슨 일인지 — 왜 그게 할당인지 — 는 잘 안 짚고 넘어간다. 이 글은 그 한 줄을 IL로 열어본다. box라는 명령이 실제로 무엇을 하는지, 왜 new와 다를 게 없는지, 그리고 어떤 코드는 box가 나오고 어떤 코드는 안 나오는지를 눈으로 확인한다.
2. 개념 정의
비유 (Analogy)
값 타입은 손에 든 현금이다. 5만 원짜리 지폐 한 장. 건네주면 그걸로 끝이다. 별도의 포장도, 꼬리표도 없다.
그런데 어떤 창구는 "물건은 반드시 택배 상자에 담아 접수하세요"라고 요구한다. object나 인터페이스가 그런 창구다. 현금을 그냥 낼 수 없으니, 상자를 하나 사서(힙 할당) 그 안에 현금을 넣고(값 복사), 상자에 "안에 든 건 현금입니다"라는 송장(타입 정보)을 붙여야 접수가 된다.
문제는 이 상자다.
- 상자는 매번 새로 산다(GC Alloc). 현금 5만 원을 담으려고 상자·완충재·송장까지 딸려온다 — 알맹이보다 포장이 더 크다.
- 접수하고 나면 상자는 금방 버려진다(Gen0 단명 객체). 한 번 쓰고 버리는 상자가 매 프레임 수천 개 쌓이면, 그걸 치우는 사람(GC)이 주기적으로 와서 라인을 멈춘다.
box는 이 "상자에 담기"다. 알맹이(값)를 바꾸는 게 아니라, 상자를 새로 만드는 일이다. 그래서 할당이다.
다이어그램 (Diagram)
코드 (Example)
int n = 42;
object o = n; // ← box. 여기서 힙에 객체 하나가 생긴다
int back = (int)o; // ← unbox. 상자에서 값을 꺼낸다
정의 (Plain → Technical)
쉬운 말로: boxing은 값 타입을 담을 새 힙 객체를 만들고, 그 안에 값을 복사해 넣는 일이다. object나 인터페이스처럼 "객체 참조"를 요구하는 자리에 값 타입을 놓으려 할 때 CLR이 자동으로 한다.
기술적으로, object o = n; 한 줄에서 CLR은 네 가지를 한다.
- GC 힙에
System.Int32용 객체를 할당한다. - 그 객체에
System.Int32의 Method Table 포인터(타입 정보)를 기록한다. n의 값42를 그 객체의 payload 영역에 복사한다.o에 그 객체의 참조를 저장한다.
즉 o는 42를 직접 든 게 아니라, 42의 복사본을 담은 별도 객체를 가리킨다. 원본 n과도 독립적이다 — n을 나중에 바꿔도 o 안의 값은 그대로다. 값을 복사해 새 객체를 만든 것이지 참조를 공유한 게 아니기 때문이다.
한 가지 흔한 오해를 짚어둔다. "값 타입은 스택에 있고 boxing하면 힙으로 옮긴다"는 설명은 직관에는 좋지만 정확하지 않다. 값 타입은 스택뿐 아니라 클래스 필드 안, 배열 요소 안 등 여러 곳에 인라인으로 존재한다. boxing의 본질은 "스택→힙 이동"이 아니라 **"현재 위치와 무관하게, 그 값을 담은 새 객체를 힙에 새로 만드는 것"**이다. 이미 힙에 있는 player.Score(클래스 필드)를 object에 담아도 boxing이다.
3. 내부 동작
측정 환경: .NET SDK 10.0.203 / net10.0 / Release / ilspycmd 10.1.1
3-1. box는 newobj와 다를 게 없다
가장 단순한 케이스부터. object o = 42;를 컴파일하면:
// object BoxToObject()
IL_0000: ldc.i4.s 42
IL_0002: box [System.Runtime]System.Int32 // ★ 여기서 힙 할당
IL_0007: ret
box System.Int32 한 명령이 위 §2의 4단계(할당·타입 기록·복사·참조)를 전부 수행한다. C# 소스에는 new가 없지만 IL 수준에서는 newobj로 객체를 만드는 것과 비용이 같은 할당이다.
반대 방향, (int)o는:
// int UnboxFromObject(object o)
IL_0000: ldarg.0
IL_0001: unbox.any [System.Runtime]System.Int32 // 상자에서 값을 꺼내 복사
IL_0006: ret
unbox.any는 boxed 객체의 payload를 읽어 값으로 꺼낸다. 이건 새 할당이 아니라 복사다. 즉 비용이 다르다 — box는 할당, unbox는 복사.
엄밀히는 JIT가 관찰 가능한 동작을 바꾸지 않는 선에서 특정
box를 특수화·제거할 여지가 있다. 하지만 개발자 입장에서 안전한 기준은 하나다: IL에box가 보이면 일단 GC Alloc 후보로 취급한다.
3-2. box가 나오는 길, 안 나오는 길 — 나란히 본다
같은 목적의 코드를 두 방식으로 짜서 IL을 비교했다. box가 나오는 쪽과 안 나오는 쪽이 명확히 갈린다.
① object / 인터페이스 경계 → box
// IShape s = circle; s.Area();
IL_0019: box Circle // ★ 인터페이스로 올리며 box
IL_001e: callvirt instance float64 IShape::Area()
② 제네릭 + where 제약 → box 없음
// double AreaConstrained<T>(T shape) where T : IShape → shape.Area();
IL_0000: ldarga.s shape
IL_0002: constrained. !!T // ★ box 대신 constrained.
IL_0008: callvirt instance float64 IShape::Area()
둘 다 인터페이스 메서드 Area()를 호출한다. 그런데 ①은 값을 인터페이스 타입 변수에 담기 위해 box를 먼저 한다. ②는 T가 Circle로 특수화되므로 box 없이 값의 주소를 그대로 넘겨 호출한다. 이 constrained. 접두사가 제네릭이 boxing을 없애는 핵심 장치다(§6에서 자세히).
③ 컬렉션 — ArrayList vs List<int>
// ArrayList.Add(1); Add(2);
IL_0007: box [System.Runtime]System.Int32 // ★ 요소마다 box
IL_000c: callvirt instance int32 ArrayList::Add(object)
IL_0014: box [System.Runtime]System.Int32 // ★ 또 box
IL_0019: callvirt instance int32 ArrayList::Add(object)
// List<int>.Add(1); Add(2);
IL_0006: ldc.i4.1
IL_0007: callvirt instance void List`1<int32>::Add(!0) // box 없음 — int[]에 직접
IL_000d: ldc.i4.2
IL_000e: callvirt instance void List`1<int32>::Add(!0)
ArrayList는 요소 타입이 object라 넣을 때마다 box한다. List<int>는 내부가 int[]라서 값을 그대로 저장한다 — box가 아예 없다. 요소 100개면 box 100번의 차이다.
④ 문자열 — string.Format vs 보간
// string.Format("HP={0} MP={1}", hp, mp);
IL_0006: box [System.Runtime]System.Int32 // ★ hp box
IL_000c: box [System.Runtime]System.Single // ★ mp box
IL_0011: call string String::Format(string, object, object)
// $"HP={hp} MP={mp}";
IL_0017: call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0) // 제네릭, box 없음
IL_002c: call instance void DefaultInterpolatedStringHandler::AppendFormatted<float32>(!!0)
string.Format은 인자를 object로 받으므로 값 타입마다 box한다. 위 IL은 인자가 2개라 Format(string, object, object) 오버로드로 가서 배열이 없다 — 인자 1~3개는 개별 object 파라미터라 배열을 안 만든다. 인자 4개 이상이면 배열 경로를 타는데, 여기서도 .NET 9/C# 13부터는 params ReadOnlySpan<object> 오버로드 + 스택 InlineArray를 써서 힙 object[]까지는 만들지 않는다(구형 런타임은 object[] 힙 배열을 만든다). 어느 쪽이든 값 인자의 box는 남는다. 반면 C# 10/.NET 6부터 보간 문자열 $"..."은 DefaultInterpolatedStringHandler로 컴파일되고, 값은 제네릭 AppendFormatted<T>로 넘어가 box되지 않는다. 같은 결과 문자열인데 IL이 결정적으로 다르다.
3-3. IL에 box가 안 보여도 boxing이 일어나는 경우
여기가 이 주제에서 가장 반직관적인 지점이다. struct의 ToString()을 호출하는 두 케이스를 보자. 하나는 struct가 ToString을 override했고(Point), 하나는 안 했다(Bare).
// Point (ToString override 있음)
IL_001c: constrained. Point
IL_0022: callvirt instance string [System.Runtime]System.Object::ToString()
// Bare (ToString override 없음)
IL_001c: constrained. Bare
IL_0022: callvirt instance string [System.Runtime]System.Object::ToString()
두 IL이 사실상 똑같다. 둘 다 constrained. + callvirt이고, 어디에도 box 명령이 없다. 그런데 실제 동작은 다르다. ECMA-335가 정한 constrained.의 규칙 때문이다.
- 대상 struct가 그 가상 메서드를 직접 override했으면 → 값의 주소를 그대로 넘겨 struct 구현을 호출한다. box 없음. (
Point) - struct가 override하지 않아 base(
Object/ValueType) 구현을 써야 하면 → CLR이 호출 직전에 암묵적으로 box한다. (Bare)
즉 Bare.ToString()은 IL에 box가 안 적혀 있는데도 런타임에 boxing이 발생한다. box를 텍스트로 grep하는 것만으로는 이 할당을 못 잡는다는 뜻이다. 해법은 간단하다 — struct에 ToString/Equals/GetHashCode를 override하면 이 숨은 box가 사라진다.
3-4. object.Equals(struct, struct) — box 두 번
struct 비교에서 흔히 나오는 함정이다.
// bool EqualsBoxing(Point a, Point b) → a.Equals((object)b)
IL_0000: ldarga.s a // 수신자 a의 주소
IL_0002: ldarg.1 // 인자 b
IL_0003: box Point // ★ (object)b — 인자 b를 box
IL_0008: constrained. Point
IL_000e: callvirt instance bool [System.Runtime]System.Object::Equals(object)
(object)b가 명시적 box를 만든다. 게다가 Point가 Equals를 override하지 않았다면 §3-3에 따라 수신자 a도 암묵적으로 box된다. 한 번의 비교에 box가 둘. 게다가 ValueType.Equals의 기본 구현은 리플렉션으로 필드를 훑어 느리기까지 하다.
IL 명령 요약
| IL | 의미 | 비용 |
|---|---|---|
box T |
값 → 힙 객체 생성 | GC Alloc (newobj와 동급) |
unbox.any T |
boxed에서 값 꺼내 복사 | 복사(할당 아님) |
unbox T |
boxed payload의 주소 | 복사 아님(주소만) |
constrained. T + callvirt |
값 타입 가상 호출 | override 있으면 box 없음 / 없으면 암묵적 box |
newobj |
참조 타입 할당 | GC Alloc |
4. 실전 적용
boxing을 없애는 원리는 하나로 요약된다. 값을 값인 채로 유지하고, object·비제네릭 인터페이스·비제네릭 컬렉션이라는 세 경계를 넘지 않게 설계한다.
4-1. 제네릭으로 경계를 없앤다
// ❌ object 경계 — 호출마다 box
void Add(object value) { ... }
Add(10);
// ✅ 제네릭 — 값을 값으로 유지할 기회
void Add<T>(T value) { ... }
Add(10); // T = int, box 없음
단, 제네릭이 "자동으로 항상 무할당"은 아니다. 메서드 내부에서 다시 value를 object API에 넘기면 거기서 box가 살아난다. 제네릭은 box를 없애주는 게 아니라, 없앨 수 있는 자리를 만들어줄 뿐이다.
4-2. 비교되는 struct는 IEquatable<T>를 구현한다
Dictionary·HashSet 키나 자주 비교하는 struct라면 반드시:
public readonly struct EntityId : IEquatable<EntityId>
{
public readonly int Value;
public EntityId(int value) => Value = value;
public bool Equals(EntityId other) => Value == other.Value; // box 없는 경로
public override bool Equals(object obj) => obj is EntityId o && Equals(o);
public override int GetHashCode() => Value;
}
이렇게 하면 Dictionary<EntityId, T>나 EqualityComparer<EntityId>.Default가 Equals(EntityId)를 직접 써서 box 없이 비교한다. IEquatable<T>가 없으면 ValueType.Equals(object) 경로로 빠져 비교마다 box + 느린 리플렉션이 된다.
4-3. Unity
- 핫패스(
Update/FixedUpdate/충돌/AI tick)에서Debug.Log(값타입),string.Format,ArrayList를 경계한다. 모두 §3에서 본 box 경로다. - 문자열은
string.Format대신 보간$"..."또는StringBuilder(PART 6에서 상세). List<int>·Dictionary<int,T>같은 제네릭 컬렉션만 쓴다.ArrayList/Hashtable금지.- IL2CPP도 boxing을 없애주지 않는다. IL의 box 의미를 그대로 C++로 옮긴다 — 디바이스 빌드라고 공짜가 되지 않는다.
4-4. 게임서버
- 틱 루프·패킷 직렬화·세션 처리·전투 계산에서 box를 구조적으로 제거한다. 초당 수만~수십만 번 도는 경로라 작은 할당도 누적된다.
- 구조화 로깅 주의.
logger.LogDebug("Tick={Tick}", tick)은object/object[]경로를 타면 인자를 box한다. 로그 레벨이 꺼져 있어도 인자 평가와 배열 생성이 먼저 일어날 수 있다.LoggerMessage/source-generated logger로 이 비용을 없앤다. - Server GC라고 boxing이 공짜가 되지 않는다. 할당률이 오르면 Gen0 GC 빈도가 오르고 p99/p999 tail latency가 흔들린다.
5. 함정과 주의사항
| 함정 | 근거 | 대응 |
|---|---|---|
int/enum을 Debug.Log·object API에 전달 |
§3-1 box |
보간 문자열, 제네릭 오버로드 |
ArrayList/Hashtable 사용 |
§3-2 요소마다 box |
List<T>/Dictionary<K,V> |
string.Format에 값 타입 인자 |
§3-2 box ×인자수 (배열은 인자 4+·구형 런타임만) |
보간 $"..." (.NET 6+) |
struct에 ToString/Equals 미구현 |
§3-3 constrained. 암묵적 box |
override 추가 |
object.Equals(a, b)로 struct 비교 |
§3-4 box ×2 + 리플렉션 |
a.Equals(b) + IEquatable<T> |
struct를 인터페이스 컬렉션에 저장 (List<ICommand>) |
§3-2 ① 저장 시 box |
List<MoveCommand> 또는 tag+데이터 struct |
Dictionary<int, T>는 키를 box하지 않는다 — 하지만
Dictionary<int, string> a; // int 키 직접 저장 — box 없음
Dictionary<IComparable, string> b; // int 넣으면 box (키 타입이 인터페이스)
Dictionary<object, string> c; // int 넣으면 box
제네릭 인자를 구체 값 타입으로 두면 box가 없다. 키 타입을 object나 인터페이스로 열어두는 순간 삽입마다 box가 생긴다.
Nullable<T>의 특수 boxing
int? a = null;
object oa = a; // → null (Nullable 객체가 아니라 진짜 null 참조)
int? b = 10;
object ob = b; // → boxed int (Nullable<int>가 아니라 int!)
Console.WriteLine(ob.GetType()); // System.Int32
Nullable<T>는 box될 때 CLR이 특수 처리한다. HasValue가 false면 결과가 null, true면 내부 int가 box된다(Nullable<int>가 box되는 게 아니다). IL로는 평범한 box System.Nullable`1<int32>로 보이지만 런타임 동작이 다르다. 이 특수 규칙은 다음 편(1-6)에서 깊게 다룬다.
6. 버전별 변화
| 기능 | C#/.NET | boxing에 준 영향 |
|---|---|---|
제네릭 + List<T>/Dictionary<K,V> |
C# 2.0 | 컬렉션 요소 boxing 제거의 결정타 |
constrained. prefix |
C# 2.0 (IL) | 값 타입 가상 호출에서 box 회피 |
EqualityComparer<T>.Default |
.NET 2.0 | IEquatable<T> 활용해 box 없는 비교 |
in/readonly struct |
C# 7.2 | 복사 감소용 — boxing과는 별개 (혼동 주의) |
| 보간 문자열 handler | C# 10 / .NET 6 | $"..."가 box 없이 포맷 |
Span<T>/ref struct |
C# 7.2+ | 타입 차원에서 boxing 자체를 금지 |
두 가지를 분명히 구분해야 한다.
제네릭(C# 2.0)이 진짜 해결책이다. 그 전까지 ArrayList는 값 타입을 넣을 때마다 box하고 꺼낼 때마다 unbox했다. 제네릭은 T를 값 타입으로 특수화해 이 왕복을 없앴다. constrained. IL 접두사가 그 밑에서 값 타입 가상 호출을 box 없이 처리한다.
in/readonly struct는 boxing 기능이 아니다. 이건 큰 struct의 복사를 줄이는 도구다(1-3 참조). boxing 제거와 자주 혼동되는데, 해결하는 문제가 다르다. 반면 ref struct(Span<T>)는 boxing 자체가 불가능하다 — object o = span;은 컴파일 오류다. 스택 메모리를 참조하는 타입이 힙으로 새면 수명 규칙이 깨지므로 언어가 원천 차단한다(1-8에서 상세).
7. 정리
- boxing은 타입 변환이 아니라 객체 생성이다.
object o = 42;의box한 명령이 힙 할당 + 타입 기록 + 값 복사를 한다.new를 안 썼어도newobj와 같은 비용의 GC Alloc이다. - box는 값이
object·비제네릭 인터페이스·비제네릭 컬렉션 경계를 넘을 때 나온다.ArrayList.Add(int),string.Format("{0}", int),IShape s = circle;가 전형이다. - IL에
box가 안 보여도 boxing이 있을 수 있다. override 없는 struct의ToString()/Equals()는constrained.가 런타임에 암묵적으로 box한다. struct에 이들을 override하면 사라진다. - 해결책은 제네릭이다(C# 2.0). 값을 값으로 유지하고,
List<T>·IEquatable<T>·EqualityComparer<T>.Default·보간 문자열로 세 경계를 넘지 않게 설계하면 boxing 기반 GC Alloc의 대부분이 사라진다. in/readonly struct는 복사 감소이지 boxing 제거가 아니다. 둘을 구분하라. boxing 자체를 타입 차원에서 막는 건ref struct(Span<T>)다.
8. Unity 실전 가이드펼치기
여기부터(§8·§9)는 사람이 읽는 해설이 아니라 AI 에이전트가 코드를 작성할 때 참조하는 실전 규칙이다. 앞의 7섹션과 문체·목적이 다르다.
AI 에이전트용 코딩 규칙. 이 절의 대상은 boxing으로 생기는 GC Alloc 하나다. struct 복사(
in/readonly struct, 1-3 주제)나 일반 Unity 최적화는 여기서 다루지 않는다. 판정 기준: 값 타입이object·비제네릭 인터페이스·비제네릭 컬렉션 경계를 넘으면 box → GC Alloc.
0. 핵심 규칙 (한 줄 요약)
Update/FixedUpdate/충돌/AI tick 등 핫패스에서 값 타입이object로 승격되면 매 프레임 힙 할당이다.box는newobj와 동급 할당이다 (result.md §3-1). C# 소스에new가 없어도 할당은 있다.- IL2CPP는 boxing을 없애지 않는다. IL의
box의미를 그대로 C++로 번역할 뿐 — 디바이스 빌드도 힙 할당이 그대로 난다.
1. DON'T — boxing을 유발하는 Unity 핫패스 패턴
1-1. 값 타입을 Debug.Log에 직접 넘기기
// ❌ BAD — int/float/enum이 object 파라미터로 승격되며 매 프레임 box
void Update()
{
Debug.Log(_frame); // int → object. box System.Int32
Debug.Log(_hp); // float → object. box System.Single
Debug.Log(_state); // enum → object. box (enum도 값 타입)
}
Debug.Log(object message) 시그니처라 값 타입은 무조건 box된다. 릴리스 빌드에서 로그가 화면에 안 보여도 인자 box와 ToString() 호출은 먼저 일어난다.
1-2. string.Format / 문자열 연결에 값 타입 인자
// ❌ BAD — 인자마다 box (result.md §3-2 ④). 2개 인자라 object[] 배열은 없지만 box는 그대로
_label.text = string.Format("HP {0} / MP {1}", _hp, _mp); // box ×2
// ❌ BAD — object.ToString() 경로로 box되는 연결
_label.text = "HP " + _hp; // _hp(int) box
1-3. 비제네릭 컬렉션 (ArrayList, Hashtable)
// ❌ BAD — 요소 타입이 object라 Add마다 box, 꺼낼 때 unbox (result.md §3-2 ③)
ArrayList _states = new ArrayList();
void OnHit(int dmg) => _states.Add(dmg); // box System.Int32 매 호출
Hashtable _table = new Hashtable();
void Set(int id) => _table[id] = true; // key int box + value bool box
1-4. struct를 인터페이스 컬렉션에 저장 (List<ICommand>)
// ❌ BAD — struct를 인터페이스 슬롯에 담는 순간 box (result.md §3-2 ①)
public interface ICommand { void Execute(); }
public struct MoveCommand : ICommand { public void Execute() { } }
List<ICommand> _queue = new();
void Enqueue(MoveCommand cmd) => _queue.Add(cmd); // box Circle 식으로 struct box
struct를 인터페이스로 올리면 힙에 boxed 사본이 생기고, 리스트는 그 참조를 담는다. 명령 큐가 프레임마다 채워지면 명령 개수만큼 할당이다.
1-5. struct에 Equals/GetHashCode/ToString override 미구현
// ❌ BAD — override가 없으면 IL에 box가 안 보여도 런타임에 암묵적 box (result.md §3-3)
public struct Tile { public int X, Y; }
var seen = new HashSet<Tile>(); // 비교/해시마다 ValueType.Equals(object) → box + 리플렉션
seen.Add(new Tile { X = 1, Y = 2 });
constrained. + callvirt은 struct가 해당 가상 메서드를 override하지 않으면 CLR이 호출 직전 암묵적으로 box한다. box를 grep해도 안 잡히는 숨은 할당이다.
1-6. object.Equals(a, b)로 struct 비교
// ❌ BAD — box 두 번 + 느린 리플렉션 비교 (result.md §3-4)
bool Same(Tile a, Tile b) => a.Equals((object)b); // (object)b box + 수신자 a 암묵적 box
1-7. Dictionary/HashSet 키를 인터페이스/object로 열어두기
// ❌ BAD — 키 타입이 인터페이스라 int/enum 넣을 때마다 box (result.md §5)
Dictionary<IComparable, GameObject> a; // int 키 삽입마다 box
Dictionary<object, GameObject> b; // 마찬가지
1-8. enum을 object API / enum→int 캐스팅 우회로
// ❌ BAD — enum은 값 타입. object 경계를 넘으면 box
void SendEvent(object payload) { }
SendEvent(EnemyState.Idle); // enum box
// ❌ BAD — SendMessage/이벤트 인자에 값 타입 넣기 (object 파라미터)
gameObject.SendMessage("OnDamage", 10); // int → object box
2. DO — box 없는 대응 패턴
2-1. 로그/문자열은 보간 문자열 또는 조건부 로깅
// ✅ GOOD — 보간 문자열은 DefaultInterpolatedStringHandler + 제네릭 AppendFormatted<T>, box 없음 (result.md §3-2 ④)
_label.text = $"HP {_hp} / MP {_mp}"; // AppendFormatted<int>, box 아님
// ✅ GOOD — 핫패스 로그는 컴파일에서 제거 (인자 평가 자체가 안 일어남)
[System.Diagnostics.Conditional("UNITY_EDITOR")]
void HotLog(int frame) => Debug.Log($"frame {frame}");
주의: 보간이라도 결과 문자열은 매번 새
string할당이다. 매 프레임 UI 텍스트는 값이 바뀔 때만 갱신한다. box는 없앴지만 string 할당은 별개.
2-2. 제네릭 컬렉션만 사용
// ✅ GOOD — 내부가 int[]/제네릭 배열이라 값 그대로 저장, box 없음 (result.md §3-2 ③)
List<int> _states = new();
Dictionary<int, bool> _table = new();
_states.Add(dmg); // box 없음
_table[id] = true; // box 없음
2-3. 명령 큐는 인터페이스 대신 구체 struct 리스트 또는 tag+데이터
// ✅ GOOD — 구체 struct 리스트: 값 그대로 저장, box 없음
List<MoveCommand> _moves = new();
_moves.Add(cmd);
// ✅ GOOD — 다형이 필요하면 tag(enum) + 데이터 struct 하나로 통합
public struct Command
{
public CommandKind Kind; // enum tag
public Vector3 Target;
public int Amount;
}
List<Command> _queue = new(); // box 없이 다형 명령 처리
2-4. 비교/키로 쓰는 struct는 IEquatable<T> + override 구현
// ✅ GOOD — IEquatable<T>로 box 없는 비교 경로 확보 (result.md §4-2)
public readonly struct Tile : IEquatable<Tile>
{
public readonly int X, Y;
public Tile(int x, int y) { X = x; Y = y; }
public bool Equals(Tile other) => X == other.X && Y == other.Y; // box 없는 경로
public override bool Equals(object obj) => obj is Tile t && Equals(t);
public override int GetHashCode() => (X * 397) ^ Y;
public override string ToString() => $"({X},{Y})"; // constrained. 암묵적 box 제거
}
// Dictionary<Tile,T> / HashSet<Tile> 이 EqualityComparer<Tile>.Default → Equals(Tile) 직접 사용
Unity struct(Vector3, Vector2Int, Color 등)는 대부분 IEquatable<T>를 이미 구현하므로 그대로 키로 써도 안전하다. 직접 만든 struct 키에만 위 구현이 필수다.
2-5. 다형 API는 인터페이스 파라미터 대신 제네릭 제약
// ✅ GOOD — 제네릭 + where로 constrained. 호출, box 없음 (result.md §3-2 ②)
void Execute<T>(T cmd) where T : ICommand => cmd.Execute(); // T=MoveCommand로 특수화, box 없음
단, 메서드 내부에서 cmd를 다시 object/인터페이스 변수에 담으면 box가 부활한다. 제네릭은 "없앨 자리"를 줄 뿐 자동 무할당이 아니다 (result.md §4-1).
3. 모바일 성능 함정
- Gen0 GC 압박 = 프레임 스파이크. boxed 객체는 전형적인 단명(Gen0) 객체다. 매 프레임 수백~수천 개 box가 쌓이면 GC가 주기적으로 라인을 멈춘다. Unity의 Incremental GC를 써도 할당률 자체가 높으면 스파이크와 배터리 소모가 함께 오른다.
- Profiler 판독 규칙:
Update에서 "GC Alloc" 컬럼이 0이 아닌데 소스에new가 없으면 1순위 용의자는 boxing이다.Debug.Log(값타입),string.Format,ArrayList,List<인터페이스>, override 없는 struct 키를 먼저 의심한다. box가 안 보이는 할당까지 본다. IL/디컴파일에서box를 grep해도 override 없는 struct의constrained. callvirt(§1-5)는 안 잡힌다. Deep Profile에서ValueType.Equals/ValueType.GetHashCode/RuntimeType프레임이 보이면 struct에 override가 없다는 신호다.- 드로우콜과 직접 연관은 없지만, GC 스톨이 프레임을 밀면 배치 처리와 무관하게 체감 프레임이 떨어진다. boxing 제거는 CPU-메인스레드 stall 완화용이다.
- enum 애니메이션/상태머신 주의. 상태를 enum으로 두고
Dictionary<object,...>나ArrayList로 전이 테이블을 만들면 tick마다 box. 키는Dictionary<StateEnum, T>로 구체화한다 (enum은IEquatable불필요, 제네릭 구체화만으로 box 없음).
4. 플랫폼별 차이 (IL2CPP vs Mono)
- boxing은 두 백엔드 모두에서 발생한다. IL2CPP는 IL의
box의미론을 그대로 C++로 번역한다 — 힙 할당이 사라지지 않는다 (result.md §4-3). "디바이스 빌드니까 괜찮겠지"는 틀렸다. - 제네릭 코드 공유(full generic sharing)의 함정: IL2CPP는 참조 타입 제네릭 인스턴스를 코드 공유하지만,
List<int>같은 값 타입 제네릭은 타입별로 특수화한다. 즉List<int>는 IL2CPP에서도 box 없이 int를 직접 저장한다 — 제네릭 컬렉션 이점은 IL2CPP에서도 유효하다. - Mono(에디터)에서 안 보이던 할당이 디바이스에서 터지지 않는다 — boxing 관점에서는 두 백엔드의 할당 지점이 동일하다. 따라서 에디터 Profiler에서 잡은 boxing GC Alloc은 디바이스에서도 그대로 있다고 간주하고 미리 제거한다.
- AOT 제약과 무관: boxing 제거 기법(제네릭,
IEquatable<T>)은 리플렉션·동적 코드가 아니라 IL2CPP AOT에서 안전하다.EqualityComparer<T>.Default도 AOT에서 정상 작동한다.
5. 코드 작성 전 체크리스트
값 타입(struct/enum/int/float/bool/Vector 등)을 다루는 코드를 쓸 때 확인한다.
- [ ] 이 값이
object파라미터로 넘어가는가? (Debug.Log,SendMessage,string.Format, 커스텀objectAPI) → box. 제네릭/보간으로 교체. - [ ]
ArrayList/Hashtable/Queue(비제네릭) 를 쓰는가? →List<T>/Dictionary<K,V>/Queue<T>로 교체. - [ ] struct를 인터페이스 타입 컬렉션(
List<ISomething>)에 넣는가? → 구체 struct 리스트 또는 tag+데이터 struct. - [ ] 이 struct가 Dictionary/HashSet 키이거나 자주 비교되는가? →
IEquatable<T>+Equals(object)/GetHashCodeoverride 필수. - [ ] struct의
ToString/Equals/GetHashCode를 override했는가? → 안 했으면constrained.암묵적 box (§1-5). - [ ] Dictionary/HashSet의 키 타입 인자가
object나 인터페이스로 열려 있는가? → 구체 값 타입으로 닫는다. - [ ] enum을
object/비제네릭 컬렉션 경계로 넘기는가? → 제네릭으로 유지. - [ ] 핫패스(
Update/FixedUpdate/충돌/tick) 여부 확인 — 핫패스면 위 항목을 특히 엄격히 적용. - [ ] 보간 문자열로 box는 없앴지만 결과 string 할당이 남는가? → 값 변경 시에만 갱신하거나
StringBuilder/캐싱.
6. 빠른 판정표
| 코드 패턴 | box? | 근거 | 교체 |
|---|---|---|---|
Debug.Log(intVal) |
O | §3-1 box |
$"{intVal}" + Conditional |
string.Format("{0}", v) |
O (인자마다 box; 배열은 인자 4+·구형만) | §3-2 ④ | 보간 $"..." |
arrayList.Add(int) |
O (매 요소) | §3-2 ③ | List<int> |
List<ICommand>.Add(structCmd) |
O | §3-2 ① | List<Cmd> / tag struct |
override 없는 struct의 HashSet/Dictionary 키 |
O (암묵적) | §3-3 | IEquatable<T>+override |
a.Equals((object)b) (struct) |
O ×2 | §3-4 | a.Equals(b)+IEquatable |
Dictionary<object/IFace, T>에 값 키 |
O | §5 | 구체 값 타입 키 |
List<int> / Dictionary<int,T> |
X | §3-2 ③ | (그대로 OK) |
Execute<T>(T) where T:IFace |
X | §3-2 ② | (그대로 OK) |
$"HP {hp}" |
X (box만; string 할당 별도) | §3-2 ④ | (그대로 OK) |
9. 게임서버 실전 가이드펼치기
AI 에이전트가 게임서버 코드를 쓸 때 참조하는 규칙이다. 사람용 해설이 아니다. 핵심 전제:
boxIL 한 명령은newobj와 동급의 힙 할당이다(result.md §3-1). C# 소스에new가 없어도 boxing이면 Gen0에 단명 객체가 쌓인다. 서버는 이 경로를 초당 수만~수십만 번 밟으므로, 프레임당 한 번은 무시해도 되는 클라이언트와 달리 **누적 할당률(Gen0 alloc rate)**이 처리량과 p99/p999를 흔든다.
핫패스 정의(이 절 전체에서): 틱 루프 · 패킷 직렬화/역직렬화 · 세션 수신 처리 · 전투/스탯 계산 · 매칭·브로드캐스트 팬아웃. 이 안에서는 box가 IL에 뜨거나 뜰 수 있는 코드를 전부 제거 대상으로 본다.
1. DON'T — 서버에서 boxing을 만드는 구체적 실수
DON'T ①: 패킷 필드를 object/object[] 경로로 직렬화
가장 흔한 서버 boxing 발생원. 직렬화기가 object 또는 params object[]로 값을 받으면 필드 하나마다 box된다(result.md §3-2 ④). 게다가 아래처럼 API가 params object[]를 직접 선언한 경우 호출마다 object[] 힙 배열까지 별도로 할당된다(사용자 시그니처가 배열이라 string.Format의 span 최적화 대상이 아니다).
// ❌ 비제네릭 write — 필드마다 box + object[] 할당
public void WritePacket(BinaryWriter w, params object[] fields)
{
foreach (var f in fields) w.Write(f.ToString()); // 각 int/enum이 이미 box된 상태
}
// 호출: 초당 수만 패킷
WritePacket(w, playerId, hp, mp, stateEnum); // box ×4 + object[4] 1개
// ❌ ArrayList/Hashtable 기반 직렬화 버퍼 — 요소마다 box (§3-2 ③)
var payload = new ArrayList();
payload.Add(playerId); // box
payload.Add(hp); // box
payload.Add(stateEnum); // box
DON'T ②: 로그 레벨이 꺼져 있어도 인자를 box하는 구조화 로깅
ILogger.LogDebug("Tick={Tick} Entity={Id}", tick, id)는 값 인자를 object/object[] 경로로 넘긴다. 로그 레벨이 Debug 미만이라 실제로 안 찍혀도 인자 box와 object[] 배열 생성은 호출 시점에 먼저 일어난다(result.md §4-4). 틱마다 도는 로그면 이게 순수 낭비다.
// ❌ 틱 루프 안. Debug 꺼져 있어도 tick·id가 box되고 object[] 2개짜리 배열이 매 틱 생성됨
_logger.LogDebug("Tick={Tick} Entity={Id}", currentTick, entity.Id);
DON'T ③: Dictionary/ConcurrentDictionary 키 struct에 IEquatable<T> 미구현
키가 struct인데 IEquatable<T>가 없으면 조회·삽입마다 ValueType.Equals(object) 경로로 빠져 비교마다 box(수신자+인자 최대 2회, §3-4) + 리플렉션 필드 순회가 발생한다. 세션 맵·엔티티 맵은 매 틱 수천 번 조회되므로 치명적이다.
// ❌ IEquatable<T> 없음 → GetHashCode/Equals 기본 구현 = box + reflection
public struct SessionKey { public int WorldId; public long ConnId; }
var sessions = new ConcurrentDictionary<SessionKey, Session>();
sessions.TryGetValue(key, out var s); // 조회마다 box + 리플렉션 비교
DON'T ④: 핫패스에서 struct를 비제네릭 인터페이스 자리에 올림
List<ICommand>, Queue<IPacket>처럼 struct를 인터페이스 컬렉션에 담으면 저장 순간 box(§3-2 ①, box + callvirt). 명령 큐·이벤트 큐가 전형적 함정이다.
// ❌ 명령을 struct로 만들어도 인터페이스 큐에 넣는 순간 box
public struct MoveCommand : ICommand { public int Dx, Dy; }
Queue<ICommand> _queue = new();
_queue.Enqueue(new MoveCommand { Dx = 1, Dy = 0 }); // box
DON'T ⑤: override 없는 struct의 ToString()/Equals() 호출 (숨은 box)
IL에 box가 안 보여도 boxing이 일어나는 경우(§3-3). struct가 ToString/Equals/GetHashCode를 override하지 않으면 constrained. + callvirt이 런타임에 암묵적으로 box한다. box를 grep해도 안 잡힌다.
// ❌ ToString override 없는 struct. 로그·직렬화에서 .ToString() 호출마다 런타임 box
public struct Vec2 { public float X, Y; }
_logger.LogTrace(pos.ToString()); // constrained. → base 구현 → 암묵적 box
DON'T ⑥: string.Format으로 패킷/로그 문자열 조립
string.Format("HP={0} MP={1}", hp, mp)는 인자를 object로 받아 값 타입마다 box한다(§3-2 ④). 인자 2개는 개별 object 오버로드라 배열은 없지만 box는 그대로다(인자 4+·구형 런타임이면 배열까지).
// ❌ hp, mp가 box (2개 인자라 object[] 배열은 없음)
string msg = string.Format("HP={0} MP={1}", hp, mp);
2. DO — 올바른 서버 패턴
DO ①: 직렬화는 제네릭 + 구체 타입 write로 값을 값인 채 유지
// ✅ 제네릭 오버로드/구체 write — box 없음. writer가 int/long/enum을 직접 처리
public void WriteInt(Span<byte> dst, int v) => BinaryPrimitives.WriteInt32LittleEndian(dst, v);
public void WriteEnum<TEnum>(Span<byte> dst, TEnum v) where TEnum : struct, Enum
=> BinaryPrimitives.WriteInt32LittleEndian(dst, Unsafe.As<TEnum, int>(ref v)); // box 없음
// 컬렉션은 제네릭만: List<int>는 내부가 int[]라 요소 box가 아예 없다 (§3-2 ③)
var ids = new List<int>();
ids.Add(playerId); // box 없음
params object[]직렬화 API를 보면 호출부가 아니라 API 시그니처를 제네릭/구체 타입으로 바꾼다.T로 받아도 메서드 내부에서 다시objectAPI에 넘기면 box가 살아난다(§4-1) — 경로 끝까지 값으로 유지되는지 확인한다.
DO ②: 구조화 로깅은 source-generated LoggerMessage로
LoggerMessage.Define 또는 [LoggerMessage] source generator를 쓰면 델리게이트가 제네릭으로 강타입 인자를 받아 box하지 않고, 레벨이 꺼져 있으면 인자 평가 자체를 건너뛴다.
// ✅ source-generated logger. 강타입 인자 → box 없음, 레벨 off면 즉시 return
public static partial class Log
{
[LoggerMessage(Level = LogLevel.Debug, Message = "Tick={Tick} Entity={Id}")]
public static partial void Tick(ILogger logger, long tick, int id);
}
// 호출: Debug 꺼져 있으면 tick/id 접근도 안 함, box도 object[]도 없음
Log.Tick(_logger, currentTick, entity.Id);
핫패스에서 source-gen을 못 쓸 상황이면 최소한 레벨 가드를 먼저 건다.
// ✅ 차선책 — 가드로 인자 평가·box 자체를 막는다
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Tick={Tick} Entity={Id}", currentTick, entity.Id);
DO ③: 딕셔너리 키 struct는 IEquatable<T> + GetHashCode 구현
// ✅ box 없는 비교 경로. ConcurrentDictionary가 Equals(SessionKey) 직접 호출
public readonly struct SessionKey : IEquatable<SessionKey>
{
public readonly int WorldId;
public readonly long ConnId;
public SessionKey(int worldId, long connId) { WorldId = worldId; ConnId = connId; }
public bool Equals(SessionKey o) => WorldId == o.WorldId && ConnId == o.ConnId; // box 없음
public override bool Equals(object obj) => obj is SessionKey o && Equals(o);
public override int GetHashCode() => HashCode.Combine(WorldId, ConnId);
}
EqualityComparer<SessionKey>.Default가 IEquatable<T>를 감지해 box 없는 비교를 쓴다(§4-2). enum 키도 마찬가지 — enum을 Dictionary 키로 쓸 때 값 타입 그대로 두면 box가 없다.
DO ④: 명령/이벤트 큐는 구체 struct 또는 tag+데이터 struct
// ✅ 구체 타입 큐 — box 없음
Queue<MoveCommand> _moves = new();
_moves.Enqueue(new MoveCommand { Dx = 1, Dy = 0 });
// ✅ 이종 명령이면 tagged struct 하나로 (인터페이스 대신 discriminant)
public struct Command
{
public CommandType Type; // enum tag
public int A, B; // 페이로드
}
Queue<Command> _cmds = new(); // 값 그대로 저장, box 없음
DO ⑤: 자주 문자열화/비교되는 struct는 ToString/Equals/GetHashCode override
override가 있으면 constrained.이 struct 구현을 직접 호출해 숨은 box가 사라진다(§3-3).
// ✅ override로 암묵적 box 제거
public readonly struct Vec2
{
public readonly float X, Y;
public Vec2(float x, float y) { X = x; Y = y; }
public override string ToString() => $"({X},{Y})"; // 보간 → 내부도 box 없음
}
DO ⑥: 문자열은 보간 $"..." 또는 StringBuilder/Utf8
// ✅ .NET 6+ 보간 handler — AppendFormatted<T>로 넘어가 box 없음 (§3-2 ④)
string msg = $"HP={hp} MP={mp}";
3. 서버 성능 함정 (GC/할당 · 직렬화 · 로깅)
3-1. box는 전부 Gen0 단명 객체 → alloc rate가 진짜 지표
boxed 객체는 만들어지고 즉시 버려진다. 서버에서 문제는 개별 할당 크기가 아니라 초당 할당 횟수다.
- 패킷당 필드 4개를 box하고 초당 3만 패킷이면 초당 12만 box 객체. +
object[]까지면 15만+. - Gen0가 이 속도로 차면 GC가 자주 트리거되고, GC 사이의 처리량은 높아 보여도 GC 순간마다 p99/p999 tail latency가 튄다.
- 프로파일링 지표:
dotnet-counters의gen-0-gc-count,alloc-rate(Allocation Rate). box를 제거하면 이 둘이 눈에 띄게 내려간다.box를 개별로 세지 말고 alloc rate로 검증한다.
3-2. 직렬화 경로는 필드 수 × 패킷 수로 곱해진다
클라이언트의 프레임당 boxing은 60fps라 초당 수십~수백 번이지만, 서버 직렬화는 연결 수 × 패킷 레이트 × 필드 수로 곱해진다. object[] 파라미터 하나가 만드는 배열 할당은 필드 box와 별개로 추가된다(§3-2 ④). 직렬화 계층은 boxing 제로를 목표로 제네릭/구체 타입으로 설계한다.
3-3. 로깅은 "안 찍혀도 비용" 함정
LogDebug(...)의 인자 boxing과 object[] 생성은 레벨 필터보다 먼저 일어난다. 프로덕션에서 Debug를 꺼도 핫패스 로그가 그대로 있으면 alloc이 남는다. 규칙:
- 핫패스의 모든 파라미터 로그는 source-generated
[LoggerMessage]또는IsEnabled가드 필수. - 인자가 값 타입(int/long/enum/struct)이면 특히 주의 — 전부 box 후보다.
3-4. 동기화 자료구조에서의 boxing 증폭
ConcurrentDictionary 키 struct에 IEquatable<T>가 없으면, box + 리플렉션 비교가 락/CAS 경합 구간 안에서 반복된다. 비교가 느려질수록 경합 창이 길어져 boxing 비용이 락 경합으로 증폭된다. 공유 맵의 키 struct는 예외 없이 IEquatable<T> + GetHashCode 구현을 강제한다.
4. 런타임별 차이 (.NET 버전 · Workstation vs Server GC)
4-1. Server GC라도 boxing은 공짜가 아니다
- Server GC(
<ServerGarbageCollection>true)는 코어마다 힙·GC 스레드를 둬 처리량을 높이지만, 할당 자체를 없애주지 않는다. alloc rate가 높으면 Gen0 수집 빈도가 오르고, 수집 때 짧게라도 스레드가 멈춰 p99/p999가 흔들린다. - Workstation GC(기본, 단일 스레드 GC)에서는 boxing 폭증이 더 직접적으로 처리량을 깎는다. 게임서버는 보통 Server GC +
<ConcurrentGarbageCollection>을 쓰지만, 어느 모드든 "box를 안 만드는 것"이 GC 모드 튜닝보다 우선이다. - 결론: GC 모드로 boxing 부하를 상쇄하려 하지 말고 할당 경로 자체를 없앤다.
4-2. .NET 버전별 boxing 회피 수단
| 수단 | 도입 | 서버에서 의미 |
|---|---|---|
제네릭 List<T>/Dictionary<K,V> + constrained. |
C# 2.0 | 컬렉션·가상 호출 boxing 제거의 기본. ArrayList/Hashtable은 레거시 코드에서만 발견 대상 |
EqualityComparer<T>.Default |
.NET 2.0 | 키 struct의 IEquatable<T>를 활용해 box 없는 비교 |
[LoggerMessage] source generator |
.NET 6 / C# 9 partial | 구조화 로깅 boxing 제거 — 이전엔 LoggerMessage.Define 수동 |
| 보간 문자열 handler | .NET 6 / C# 10 | $"..."가 DefaultInterpolatedStringHandler로 box 없이 포맷 (§3-2 ④) |
Span<T>/ref struct |
.NET Core 2.1+ / C# 7.2 | 타입 차원에서 boxing 금지 — object o = span은 컴파일 오류. 무할당 직렬화 버퍼의 기반 |
- .NET 6 미만 런타임을 타깃하면 보간 handler와 source-gen logger가 없어 boxing 회피가 수동이 된다(수동
StringBuilder,LoggerMessage.Define). 서버 타깃 프레임워크를 먼저 확인한다. in/readonly struct는 복사 감소용이지 boxing 제거가 아니다(result.md §6). 혼동 금지 — 큰 struct를in으로 넘겨도 그걸object에 담으면 box된다.- IL2CPP(클라)든 CoreCLR(서버)든 boxing 의미는 동일하게 유지된다 — 런타임이 box를 최적화로 없애줄 것이라 가정하지 않는다(§3-1: "IL에
box가 보이면 GC Alloc 후보로 취급").
5. 코드 작성 전 체크리스트
핫패스(틱/직렬화/세션/전투/브로드캐스트)에 코드를 쓰기 전에 확인한다.
- [ ] 값 타입(int/long/float/enum/struct)이
object· 비제네릭 인터페이스 · 비제네릭 컬렉션 이 세 경계를 넘는가? 넘으면 box다. - [ ] 직렬화 API 시그니처가
object/params object[]인가? → 제네릭T또는 구체 타입 write로 교체.object[]배열 할당도 함께 제거. - [ ]
ArrayList/Hashtable이 있는가? →List<T>/Dictionary<K,V>. - [ ] 핫패스 로그가
LogDebug/LogTrace(..., 값타입인자)인가? →[LoggerMessage]source-gen 또는IsEnabled가드. "레벨 꺼도 인자 box는 발생"을 기억. - [ ]
Dictionary/ConcurrentDictionary/HashSet키가 struct인가? →IEquatable<T>+GetHashCode구현 확인. 없으면 비교마다 box×2 + 리플렉션. - [ ] struct를
List<인터페이스>/Queue<인터페이스>에 담는가? → 구체 타입 컬렉션 또는 tagged struct. - [ ] 자주 문자열화/비교되는 struct에
ToString/Equals/GetHashCodeoverride가 있는가? 없으면constrained.암묵적 box(§3-3) —boxgrep으로 안 잡힘. - [ ]
string.Format을 보간$"..."로 바꿨는가? (.NET 6+) - [ ] 검증:
box를 세지 말고dotnet-counters의 alloc-rate / gen-0-gc-count로 전후 비교. Server GC라도 alloc rate가 내려가야 p99/p999가 안정된다. - [ ] 타깃 프레임워크가 .NET 6+인가? 아니면 보간 handler·source-gen logger 부재 → 수동 회피 필요.