C#boxingunboxingGCGCAllocconstrainedIEquatablegenericILCLR성능

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나 인터페이스가 그런 창구다. 현금을 그냥 낼 수 없으니, 상자를 하나 사서(힙 할당) 그 안에 현금을 넣고(값 복사), 상자에 "안에 든 건 현금입니다"라는 송장(타입 정보)을 붙여야 접수가 된다.

문제는 이 상자다.

box는 이 "상자에 담기"다. 알맹이(값)를 바꾸는 게 아니라, 상자를 새로 만드는 일이다. 그래서 할당이다.

다이어그램 (Diagram)

object o = 42; 한 줄이 힙에 만드는 것 int 42는 4바이트 값 하나 · boxed 객체는 헤더·타입 정보·정렬까지 붙은 별도 GC 객체 값 그대로 (스택/레지스터/인라인) int n = 42; 42 4 bytes object o = n; box System.Int32 GC Heap — 새 객체 (x64에서 대략 24바이트) Object header / sync block (8B) Method Table ptr → System.Int32 (8B) payload: 42 (4B, 값 복사본) alignment padding (4B) o ──▶ 읽는 법 • box는 "타입 태그만 바꾸기"가 아니다 — 새 객체 할당 + 타입 정보 기록 + 값 복사 3가지를 한다 • 값 4B를 담으려고 객체 관리 비용까지 붙어, 실제 데이터보다 관리 비용이 더 크다 • 이 객체는 대개 Gen0에 쌓였다 금방 죽는다 → 매 프레임 반복되면 GC 압력이 된다
object o = 42 한 줄이 힙에 만드는 것 — boxed 객체의 레이아웃

코드 (Example)

int n = 42;
object o = n;      // ← box. 여기서 힙에 객체 하나가 생긴다
int back = (int)o; // ← unbox. 상자에서 값을 꺼낸다

정의 (Plain → Technical)

쉬운 말로: boxing은 값 타입을 담을 새 힙 객체를 만들고, 그 안에 값을 복사해 넣는 일이다. object나 인터페이스처럼 "객체 참조"를 요구하는 자리에 값 타입을 놓으려 할 때 CLR이 자동으로 한다.

기술적으로, object o = n; 한 줄에서 CLR은 네 가지를 한다.

  1. GC 힙에 System.Int32용 객체를 할당한다.
  2. 그 객체에 System.Int32의 Method Table 포인터(타입 정보)를 기록한다.
  3. n의 값 42를 그 객체의 payload 영역에 복사한다.
  4. 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이 결정적으로 다르다.

같은 값을 다루는데 — box가 나오는 길과 안 나오는 길 IL 실측 (.NET 10 · Release) · 왼쪽은 box 발생, 오른쪽은 같은 목적을 box 없이 box 발생 → GC Alloc box 없음 object o = n; box System.Int32 void M<T>(T v) { ... } // M(n) T가 int로 특수화 — box 없음 ArrayList.Add(n) box System.Int32 (요소마다) List<int>.Add(n) callvirt Add(!0) — int[]에 직접 저장 string.Format("{0}", hp) box Int32 (+ object[] 할당) $"HP={hp}" (.NET 6+) AppendFormatted<int> — 제네릭, box 없음 IShape s = circle; box Circle → callvirt M<T>(T x) where T : IShape constrained. T → callvirt — box 없음 bare.ToString() // override 없음 constrained.가 암묵적 box point.ToString() // override 있음 constrained.가 직접 호출 — box 없음 한 줄 규칙 • 값을 object · 비제네릭 인터페이스 · 비제네릭 컬렉션 경계로 넘기면 box가 나온다 • 제네릭 · where 제약 · 값 타입 자기 메서드로 값을 값인 채 유지하면 box가 사라진다
같은 값을 다루는데 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.의 규칙 때문이다.

즉 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

4-4. 게임서버

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. 정리

  1. boxing은 타입 변환이 아니라 객체 생성이다. object o = 42;의 box 한 명령이 힙 할당 + 타입 기록 + 값 복사를 한다. new를 안 썼어도 newobj와 같은 비용의 GC Alloc이다.
  2. box는 값이 object·비제네릭 인터페이스·비제네릭 컬렉션 경계를 넘을 때 나온다. ArrayList.Add(int), string.Format("{0}", int), IShape s = circle;가 전형이다.
  3. IL에 box가 안 보여도 boxing이 있을 수 있다. override 없는 struct의 ToString()/Equals()는 constrained.가 런타임에 암묵적으로 box한다. struct에 이들을 override하면 사라진다.
  4. 해결책은 제네릭이다(C# 2.0). 값을 값으로 유지하고, List<T>·IEquatable<T>·EqualityComparer<T>.Default·보간 문자열로 세 경계를 넘지 않게 설계하면 boxing 기반 GC Alloc의 대부분이 사라진다.
  5. 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, 커스텀 object API) → box. 제네릭/보간으로 교체.
  • [ ] ArrayList/Hashtable/Queue(비제네릭) 를 쓰는가? → List<T>/Dictionary<K,V>/Queue<T>로 교체.
  • [ ] struct를 인터페이스 타입 컬렉션(List<ISomething>)에 넣는가? → 구체 struct 리스트 또는 tag+데이터 struct.
  • [ ] 이 struct가 Dictionary/HashSet 키이거나 자주 비교되는가? → IEquatable<T> + Equals(object)/GetHashCode override 필수.
  • [ ] 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 에이전트가 게임서버 코드를 쓸 때 참조하는 규칙이다. 사람용 해설이 아니다. 핵심 전제: box IL 한 명령은 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로 받아도 메서드 내부에서 다시 object API에 넘기면 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/GetHashCode override가 있는가? 없으면 constrained. 암묵적 box(§3-3) — box grep으로 안 잡힘.
  • [ ] 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 부재 → 수동 회피 필요.