Unity가 좌표를 class가 아니라 struct로 만든 진짜 이유
1. 문제 제기
Unity에서 Vector3의 정의로 점프해 보면, 신입이 흔히 예상하는 class가 아니다.
public struct Vector3 { public float x, y, z; ... } // class 아님!
public struct Quaternion { ... }
public struct Color { ... }
좌표·회전·색깔 — 게임에서 가장 많이 쓰는 타입이 전부 struct다. 반면 우리가 만드는 Player, Enemy, GameManager는 전부 class다. 왜 Unity는 좌표만 굳이 struct로 만들었을까?
취향 문제가 아니다. 이걸 모르면 아래 세 가지에서 반드시 발목이 잡힌다.
// (1) 왜 이게 컴파일 에러지?
transform.position.x = 5f; // ❌ CS1612
// (2) 왜 수천 마리가 이걸 돌리는데 GC 스파이크가 안 뜨지?
Vector3 dir = (target.position - transform.position).normalized;
// (3) in을 붙였는데 왜 여전히 복사가 일어나지?
float Dist(in Vector3 p) => p.magnitude;
세 미스터리의 답은 전부 같은 곳에 있다. 대입 한 줄이 힙을 건드리느냐다. IL까지 내려가면 한 번에 풀린다.
2. 개념 정의
비유 (Analogy)
struct(값 타입)와 class(참조 타입)의 차이는 연락처를 주고받는 방식이다.
- struct = 명함을 복사해서 건네기. 상대는 내 정보가 통째로 찍힌 독립된 사본을 갖는다. 내 명함에 낙서해도 상대 것은 그대로다. 대신 정보가 많으면 매번 통째로 복사하는 비용이 든다.
- class = 사물함 번호만 알려주기. "3번 사물함에 있어"라고 번호만 준다. 나와 상대가 같은 사물함을 연다. 내가 안을 바꾸면 상대가 열어도 바뀌어 있다. 번호(주소)만 오가니 가볍지만, 사물함 자체는 어딘가 따로 만들어져 있어야 하고 관리인(GC)이 그 사물함을 계속 장부에 올려두고 추적해야 한다.
좌표 (1, 2, 3)은 명함에 가깝다. 어디서 만들었든 값이 같으면 같은 좌표고, 매 프레임 수없이 만들어 잠깐 쓰고 버린다. 이런 데이터마다 사물함을 하나씩 새로 파고 장부에 올리는 건 낭비다.
다이어그램 (Diagram)
코드 (Example)
같은 모양의 대입 한 줄인데 결과가 정반대다.
struct PointS { public float X; }
class PointC { public float X; }
PointS s1 = new PointS { X = 7 };
PointS s2 = s1; s1.X = 9; // s2.X == 7 (복사 — 독립)
PointC c1 = new PointC { X = 7 };
PointC c2 = c1; c1.X = 9; // c2.X == 9 (공유 — 같은 객체)
정의 (Plain → Technical)
쉬운 말로: struct 변수는 값을 직접 들고 있고, class 변수는 값이 있는 주소를 들고 있다.
기술적으로:
- 값 타입(struct) — 변수가 인스턴스의 필드를 직접 담는다. 대입은 필드 단위 복사(field-by-field copy).
System.ValueType을 상속하며 상속 계층을 만들 수 없고 null이 될 수 없다. - 참조 타입(class) — 변수가 managed heap상의 객체 주소를 담는다. 대입은 주소 복사. 따라서 두 변수가 하나의 객체를 공유할 수 있다.
여기서 흔한 오해 하나를 먼저 버려야 한다.
"struct는 스택, class는 힙"은 부정확하다.
정확히는 변수가 선언된 문맥이 결정한다.
| struct가 어디에 있나 | 실제 저장 위치 |
|---|---|
| 지역 변수 / 매개변수 | 스택 또는 CPU 레지스터 |
| class의 필드 | 그 객체 안에 인라인 → 힙 |
| 배열 요소 | 배열 버퍼 안에 인라인 → 힙 |
| static 필드 | 힙 |
| 박싱된 상태 | 별도 힙 객체 |
즉 Vector3[]도 배열 자체는 힙에 있다. 핵심은 "스택이냐"가 아니라 각 좌표가 독립된 힙 객체가 되느냐다.
3. 내부 동작
여기서부터는 추측이 아니라 실제로 컴파일해서 확인한 IL이다.
- 측정 환경: .NET SDK 10.0.203 /
net10.0/ Release 빌드 /ilspycmd10.1.1 - 대상:
PointS(struct, float×3),PointC(class, float×3),PointR(readonly struct, float×3)
3-1. 배열 — stelem vs stelem.ref
가장 결정적인 차이다. 같은 모양의 루프를 struct와 class로 각각 돌렸다.
var arr = new PointS[n];
for (int i = 0; i < n; i++) arr[i] = new PointS(i, i, i);
// StructArray
IL_0001: newarr PointS
...
IL_0013: newobj instance void PointS::.ctor(float32, float32, float32)
IL_0018: stelem PointS // ★ 값 12바이트를 배열 슬롯에 직접 씀
// ClassArray
IL_0001: newarr PointC
...
IL_0013: newobj instance void PointC::.ctor(float32, float32, float32) // ★ 반복마다 힙 할당
IL_0018: stelem.ref // 주소 8바이트만 씀
stelem PointS는 값을 배열 안에 그대로 쓰고, stelem.ref는 참조를 쓴다. 같은 newobj라도 struct 쪽은 값 생성일 뿐이고, class 쪽은 n번의 힙 할당이다.
이 차이가 만드는 숫자는 이렇다. 좌표 1,000개 기준, 64비트:
Vector3[] (struct) |
class였다면 | |
|---|---|---|
| 데이터 | 12,000B (연속 1블록) | 참조 8,000B + 객체 32,000B |
| 총합 | 약 12KB | 약 40KB |
| GC가 추적할 객체 수 | 1개 | 1,001개 |
마지막 행이 진짜 비용이다. CLR via C#에 따르면 CLR GC는 reference tracking GC로, 수집 때마다 루트에서 시작해 참조 그래프를 재귀적으로 따라가며 마킹한다. class 배열이면 GC가 한 번 훑을 때 1,001개를 개별 방문해야 한다. Unity의 non-generational GC에서는 수집이 일어날 때마다 이 비용을 전부 치른다. CoreCLR처럼 세대별 GC라면 Gen 0 수집에서는 전부 방문하지 않을 수 있지만, 객체 수가 늘어난다는 사실 자체는 달라지지 않는다.
3-2. 힙 객체 오버헤드의 정확한 크기
CLR via C# 4판: 힙 객체는 데이터 외에 type object pointer와 sync block index 두 개를 항상 얹는다.
| 환경 | type object ptr | sync block index | 합계 |
|---|---|---|---|
| 32비트 | 4B | 4B | 8B |
| 64비트 | 8B | 8B | 16B |
64비트에서 필드가 하나도 없는 빈 클래스 인스턴스조차 정렬 포함 최소 24바이트다. 데이터 12B짜리 좌표를 class로 만들면:
Vector3 struct : [x 4][y 4][z 4] = 12B
Vector3 class : [sync block 8][type obj ptr 8][x 4][y 4][z 4][pad 4] ≈ 32B
데이터보다 관리 비용이 더 크다. 좌표 10만 개면 헤더만으로 1.6MB가 날아간다.
3-3. 메서드 호출 — ldloca+call vs callvirt
// CallStruct
IL_0016: ldloca.s 0 // 지역 변수의 주소를 스택에
IL_0018: call instance float32 PointS::Length() // 비가상 호출, null 체크 없음
// CallClass
IL_000f: newobj instance void PointC::.ctor(...) // 힙 할당
IL_0014: callvirt instance float32 PointC::Length() // null 체크 동반
struct는 this를 주소로 넘겨 복사 없이 호출한다. class 쪽 callvirt는 Length()가 virtual이 아닌데도 나오는데, C# 컴파일러가 null 체크를 얻으려고 관례적으로 쓰기 때문이다. 즉 callvirt가 곧 가상 디스패치를 뜻하지는 않는다.
3-4. 박싱 — box
PointS p = new PointS(1, 2, 3);
object o = p;
IL_000f: newobj instance void PointS::.ctor(...)
IL_0014: box PointS // ★ 힙 할당 + 12바이트 복사
box 한 줄이 하는 일은 세 단계다(CLR via C#): ① managed heap에서 값 크기 + 오버헤드 16B만큼 할당 → ② 필드를 복사 → ③ 주소 반환. 이 순간 struct의 장점이 통째로 사라진다.
인터페이스에 담아도 똑같다. 인터페이스는 참조 타입이기 때문이다.
// IShiftable i = s; i.Shift();
IL_0002: initobj Shiftable
IL_0009: box Shiftable // ★ 힙에 사본 생성
IL_000e: callvirt instance void IShiftable::Shift() // 사본의 값이 증가, 원본 s는 그대로
박싱 + 가변 struct가 겹치면 값이 안 바뀌는 버그까지 딸려온다.
3-5. 제네릭 제약이 박싱을 없애는 증거 — constrained.
같은 인터페이스 메서드 호출인데, 제네릭 제약을 쓰면 box가 사라진다.
static void GenericNoBox<T>(T t) where T : IShiftable => t.Shift();
IL_0000: ldarga.s t
IL_0002: constrained. !!T // ★ box 없음
IL_0008: callvirt instance void IShiftable::Shift()
constrained. 접두어는 T가 값 타입이면 주소로 직접 호출하고, 참조 타입이면 평소대로 처리한다. 3-4와 나란히 놓고 보면 제약 한 줄이 힙 할당을 없앤다는 게 IL로 드러난다.
3-6. in을 붙여도 복사된다 — ldobj
가장 반직관적인 결과다. 똑같이 in을 붙였는데 IL이 다르다.
static float InMutableStruct(in PointS p) => p.Length(); // PointS: 그냥 struct
static float InReadonlyStruct(in PointR p) => p.Length(); // PointR: readonly struct
// InMutableStruct
IL_0000: ldarg.0
IL_0001: ldobj PointS // ★ 방어적 복사 — 값 전체를 읽어옴
IL_0006: stloc.0
IL_0007: ldloca.s 0
IL_0009: call instance float32 PointS::Length()
// InReadonlyStruct
IL_0000: ldarg.0
IL_0001: call instance float32 PointR::Length() // ★ 복사 없이 바로 호출
이유는 이렇다. in은 "읽기 전용 참조"를 뜻하지만, Length()가 this를 바꾸지 않는다는 보장이 타입에 없다. 그래서 컴파일러는 호출자의 원본을 지키려고 사본을 만들어 그 위에서 호출한다. readonly struct면 그 보장이 타입에 새겨져 있으니 복사가 사라진다.
in만 붙이면 복사가 사라진다는 말은 틀렸다.readonly struct(또는 C# 8의readonly멤버)가 함께 있어야 성립한다.
3-7. 컬렉션 안의 struct는 사본이다
var list = new List<PointS> { new PointS(1, 2, 3) };
var item = list[0];
item.X = 99;
return list[0].X; // → 1
IL_0021: callvirt instance !0 List`1<PointS>::get_Item(int32)
IL_0026: stloc.0 // ★ 반환값(사본)을 지역 변수로
IL_0027: ldloca.s 0
IL_002e: stfld float32 PointS::X // 사본의 X만 바뀜
IL_0034: callvirt ... get_Item(int32)
IL_0039: ldfld float32 PointS::X // → 여전히 1
get_Item이 값을 반환하니 stloc된 사본을 고친 것이다. 이게 transform.position.x = 5f가 막히는 것과 같은 뿌리다. 다만 그쪽은 사본조차 지역 변수가 아닌 임시값이라 컴파일러가 아예 CS1612로 거부한다. 조용히 틀리는 것보다 나은 설계다.
3-8. 큰 struct는 그대로 복사된다
// PassBig(BigStruct) — BigStruct = float×16 = 64바이트
IL_0093: initobj BigStruct
IL_0099: ldloc.3 // ★ 64바이트 값을 그대로 push
IL_009a: call float32 Cases::PassBig(valuetype BigStruct)
Matrix4x4와 정확히 같은 크기다. struct가 공짜가 아니라는 증거이자, in/ref가 필요해지는 지점이다(Part 1-3).
IL 요약
| IL 명령 | 나온 곳 | 의미 | 비용 |
|---|---|---|---|
newobj (class) |
ClassArray 루프 | 힙 할당 | GC Alloc × n |
stelem.ref |
ClassArray | 참조 저장 | 역참조 유발 |
stelem PointS |
StructArray | 값 인라인 저장 | 없음 |
box |
BoxIt / 인터페이스 대입 | 값 → 힙 | GC Alloc + 복사 |
constrained. |
제네릭 제약 호출 | 제약 디스패치 | 박싱 회피 |
ldobj |
in + 비readonly struct |
방어적 복사 | 값 전체 복사 |
callvirt |
CallClass | null 체크/가상 호출 | 간접 분기 |
ldloca + call |
CallStruct | in-place 호출 | 복사 회피 |
4. 실전 적용
4-1. Unity — 프레임 예산이라는 제약
60fps의 한 프레임 예산은 16.67ms, 120fps면 8.33ms다. 이 안에 로직·물리·애니메이션·렌더 준비·UI·네트워크가 전부 들어가야 한다. 평소 14ms인 프레임에 GC가 4ms 끼어들면 18ms → 약 55fps. 평균 FPS 그래프에서는 잘 안 보이지만 플레이어는 끊김으로 체감한다.
Unity의 전통적인 GC는 Boehm 계열로 non-generational, non-compacting이다. CoreCLR처럼 "방금 만든 단명 객체를 Gen 0에서 싸게 회수"하는 경로가 없다. 짧게 쓰고 버리는 임시 객체가 특히 불리한 구조다.
만약 Vector3가 class여서 프레임당 임시 좌표가 5,000개 생긴다면:
5,000 × 32B = 160,000B ≈ 160KB / frame
160KB × 60fps ≈ 9.6MB / sec
10,000개면 약 19.2MB/sec다. 여기에 LINQ 이터레이터·클로저·문자열 포맷·코루틴 상태 객체는 포함도 안 된 숫자다.
Unity 2019.1에 도입된 Incremental GC는 긴 pause를 여러 프레임으로 쪼개준다. 하지만 이건 폭발을 분할하는 도구지 할당을 공짜로 만드는 도구가 아니다. 완화되는 것과 안 되는 것을 구분해야 한다.
| Incremental GC가 완화하는 것 | 완화하지 못하는 것 |
|---|---|
| 단발성 긴 프레임 드롭 | 할당량 자체 |
| 한 프레임에 몰리는 mark 작업 | 큰 live object graph |
| 체감되는 GC 스파이크 | non-compacting에서 오는 조각화 |
주의: "몇 KB 할당하면 몇 ms 멈춘다" 같은 절대 수치는 제시할 수 없다. 플랫폼, Unity 버전, Mono/IL2CPP, 살아있는 객체 수, 힙 크기에 따라 크게 달라진다. 확실한 건 방향이다.
4-2. 캐시 지역성 — DOTS로 이어지는 길
캐시 라인은 흔히 64바이트다. Vector3가 12B이므로 한 라인에 약 5개가 들어간다. 순차 순회하면 prefetcher가 잘 동작하고 SIMD·Burst 최적화 여지가 생긴다.
class 배열은 배열 슬롯 → 객체 포인터 → 객체 데이터의 pointer chasing이 된다. 참조 배열 자체는 연속이지만 대상 객체는 흩어져 있고, Unity GC가 non-compacting이라 시간이 지날수록 더 흩어진다.
| 접근 위치 | 대략적 지연 |
|---|---|
| L1 hit | 수 사이클 |
| L2 hit | 10여 사이클 |
| L3 hit | 수십 사이클 |
| DRAM | 100~300+ 사이클 |
float 3개 더하는 연산보다 메모리를 기다리는 시간이 훨씬 커질 수 있다는 뜻이다. 이 논리를 끝까지 밀어붙인 결과가 DOTS/ECS(archetype chunk에 컴포넌트를 연속 배치), Burst, NativeArray<T>다. Vector3를 struct로 만든 판단과 ECS는 같은 문제에 대한 같은 답이다.
4-3. 게임서버 — 같은 원칙, 다른 지표
서버는 보통 Unity가 아니라 CoreCLR에서 돈다. 세대별 GC가 있어 단명 객체 회수가 Unity보다 유리하다. 하지만 면죄부는 아니다. 서버의 지표는 프레임 타임이 아니라 처리량과 P99/P99.9 latency다.
초당 100,000 요청 × 요청당 1KB 낭비 = 약 100MB/sec allocation rate
Gen 0가 있어도 이 정도의 할당률 자체가 성능 문제가 된다.
// struct가 맞는 것 — 작고, 값이고, 정체성이 없다
public readonly struct EntityId : IEquatable<EntityId>
{
public readonly long Value;
public EntityId(long value) => Value = value;
public bool Equals(EntityId other) => Value == other.Value;
public override int GetHashCode() => Value.GetHashCode();
}
// class가 맞는 것 — 크고, 공유되고, 복사되면 사고다
public sealed class PlayerSession
{
public EntityId PlayerId;
public SocketConnection Connection;
public ConcurrentDictionary<int, Item> Inventory;
public DateTime LastHeartbeatUtc;
}
서버 고유의 주의점: 직렬화 경로에서 object나 비제네릭 컬렉션에 담으면 3-4의 box가 요청마다 터진다. Span<byte> + 제네릭 제약으로 무할당 경로를 만드는 게 정석이다(PART 14).
5. 함정과 주의사항
| 함정 | IL 근거 | 대응 |
|---|---|---|
transform.position.x = 5f → CS1612 |
getter가 값 사본 반환 | 지역 변수로 받아 수정한 뒤 다시 대입 |
list[0].X = 99가 반영 안 됨 |
§3-7 stloc |
var t = list[0]; t.X = 99; list[0] = t; |
in인데 복사 발생 |
§3-6 ldobj |
readonly struct / C# 8 readonly 멤버 |
| 인터페이스 대입 시 박싱 | §3-4 box |
where T : struct, IEquatable<T> (§3-5) |
ValueType.Equals 기본 구현이 느림 |
필드별 비교 + 리플렉션 경로 가능성, 박싱 | IEquatable<T> 직접 구현 또는 record struct |
| 큰 struct 값 전달 | §3-8, 64B 복사 | in + readonly struct (작은 struct엔 역효과) |
올바른 좌표 수정
// ❌ 컴파일 에러 (CS1612)
transform.position.x = 5f;
// ✅ 값을 꺼내 고치고 되돌려 넣는다
Vector3 pos = transform.position;
pos.x = 5f;
transform.position = pos;
struct를 딕셔너리 키로 쓸 때
Equals/GetHashCode를 직접 구현하지 않으면 System.ValueType의 기본 구현으로 떨어진다. 최신 런타임이 단순한 값 타입에 빠른 경로를 제공하는 경우도 있지만, 일반적으로 필드별 비교·리플렉션 경로·박싱 가능성이 따라온다. Dictionary/HashSet의 키로 쓸 struct라면 IEquatable<T>를 반드시 구현한다.
크기 기준을 규칙으로 오해하지 말 것
CLR via C#은 struct 권장 조건으로 약 16바이트 이하, 또는 16B를 넘더라도 매개변수·반환값으로 오가지 않는 경우를 든다. 다만 절대 규칙은 아니다. Unity Matrix4x4는 64B struct인데, 배열 연속 배치와 네이티브 교환의 이득이 복사 비용을 넘어서기 때문이다. 판단 기준은 크기 하나가 아니라 값 의미론 + 크기 + 전달 빈도의 조합이다.
6. 버전별 변화
| 기능 | C# | 효과 | Unity에서 |
|---|---|---|---|
ref return / ref local |
7.0 | 복사 없이 내부 값 참조 | 2018.3(C# 7.3) 계열부터 |
readonly struct / in / ref readonly |
7.2 | 불변 강제 + 방어적 복사 제거 | 2018.3 계열부터 |
readonly 인스턴스 멤버 |
8.0 | 메서드 단위로 방어적 복사 완화 | 2020.2 계열부터 |
record struct, 매개변수 없는 struct 생성자 |
10 | equality 상용구 제거 | 공식 지원 범위 확인 필요 |
ref field / ref struct 개선 |
11 | 고성능 저수준 코드 | IL2CPP·Burst 호환 확인 필요 |
readonly struct가 §3-6의 방어적 복사를 없애는 핵심이다. 전체를 readonly로 만들기 어렵다면 C# 8의 멤버 단위 readonly로도 완화된다.
public struct Bounds
{
public Vector3 Center;
public readonly bool Contains(Vector3 p) { ... } // this를 안 바꾼다고 선언
}
주의할 점은 Unity의 C# 언어 버전이 최신 .NET SDK보다 늦다는 것이다. Unity 6에서도 최신 문법이 전부 지원되리라 가정하면 안 되고, 사용 전에 해당 LTS의 C# language support 문서를 확인해야 한다.
7. 정리
- Unity가
Vector3를 struct로 만든 이유는 "스택에 올리려고"가 아니다. 좌표는 정체성 없는 값이고, class로 만들면 좌표 하나당 64비트에서 16B 헤더가 붙어 12B 데이터가 약 32B가 되며, GC가 추적할 객체 수가 개수만큼 늘어난다. - IL이 그대로 보여준다. struct 배열은
stelem으로 값을 인라인 저장하지만, class 배열은 요소마다newobj힙 할당 +stelem.ref다. 순회 시엔 pointer chasing과 캐시 미스가 따라온다. - struct도 공짜가 아니다.
box(인터페이스·object 대입),ldobj(방어적 복사), 64B 값 복사가 IL에 그대로 찍힌다. 박싱은where T : struct, IEquatable<T>의constrained.로 피한다. in만으로는 복사가 사라지지 않는다.readonly struct나 C# 8readonly멤버가 함께 있어야ldobj가 사라진다.- 판단 기준은 하나다 — 값 동등성이 자연스러운가, 참조 동일성이 필요한가. 좌표·ID·패킷 헤더는 전자, 세션·엔티티·매니저는 후자다. 클라이언트는 프레임 타임 안정성을, 서버는 allocation rate와 P99를 본다.
8. Unity 실전 가이드펼치기
여기부터(§8·§9)는 사람이 읽는 해설이 아니라 AI 에이전트가 코드를 작성할 때 참조하는 실전 규칙이다. 앞의 7섹션과 문체·목적이 다르다.
이 절은 AI 에이전트가 Unity에서 좌표/회전/행렬 같은 값 타입을 다루는 코드를 쓸 때 참조하는 규칙이다.
근거는 IL 실측(stelem vs stelem.ref, box, constrained., ldobj)이다.
0. 이 주제에서 반드시 기억할 3가지
UnityEngine.Vector3/Quaternion/Matrix4x4는readonly struct가 아니다. 필드가 public 가변이다. →in Vector3를 붙여도 인스턴스 멤버(.magnitude,.normalized)를 호출하는 순간ldobj방어적 복사가 남는다.transform.position은 필드가 아니라 네이티브를 호출해 값을 복사해 돌려주는 프로퍼티다. →transform.position.x = 5f가 CS1612인 이유이고, 한 프레임에 여러 번 읽으면 안 되는 이유다.- struct를
object/ 인터페이스 / 비제네릭 API에 넣는 순간box— struct의 장점이 통째로 사라진다. Vector3 박싱 1회 = 12B 데이터 + 16B 헤더 ≈ 32B 힙 할당.
1. DON'T / DO
1-1. transform.position 을 필드처럼 다루기
position getter는 값 사본을 반환한다. 사본에 쓰는 것은 무의미하므로 컴파일러가 CS1612로 막는다.
더 큰 문제는 +=가 get 1회 + set 1회이고, set마다 Transform dirty 전파(자식 순회)가 일어난다는 점이다.
// ❌ DON'T
void Update()
{
// transform.position.x = 5f; // CS1612 — 임시 사본에 대입
transform.position += velocity * Time.deltaTime; // get+set, dirty 전파
if (transform.position.y < 0f) // 또 네이티브 get
transform.position = new Vector3(transform.position.x, 0f, transform.position.z); // 또 get×2 + set
}
// ✅ DO — 한 번 읽고, 로컬 struct에서 전부 수정하고, 한 번 쓴다
void Update()
{
Vector3 pos = transform.position; // 네이티브 get 1회
pos += velocity * Time.deltaTime; // 전부 스택/레지스터 상의 값 연산 (GC 0)
if (pos.y < 0f) pos.y = 0f; // 로컬 변수이므로 필드 대입이 합법
transform.position = pos; // 네이티브 set 1회
}
규칙: 한 프레임에 같은 Transform 프로퍼티를 2번 이상 읽거나 쓰면 리팩터 대상이다.
회전도 동일 — transform.rotation을 여러 번 읽지 말고 Quaternion q = transform.rotation;으로 받는다.
1-2. in Vector3 로 복사를 없앴다고 착각하기
IL 근거(§3-6): in + 비 readonly struct → ldobj(값 전체 복사) 후 호출.
Unity의 Vector3/Quaternion/Matrix4x4는 전부 비 readonly struct이므로 in은 복사를 없애주지 않는다.
// ❌ DON'T — in을 붙였지만 호출마다 ldobj 방어적 복사
static float Dist(in Vector3 a, in Vector3 b) => (a - b).magnitude; // a,b 각각 복사됨
// ✅ DO (a) — Vector3(12B)처럼 작은 타입은 그냥 값으로 받는다. in이 오히려 손해다.
static float DistSqr(Vector3 a, Vector3 b)
{
float dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z; // 필드 직접 접근: 복사/프로퍼티 호출 없음
return dx * dx + dy * dy + dz * dz; // sqrt 회피까지 겸함
}
// ✅ DO (b) — Matrix4x4(64B)처럼 큰 타입은 in이 아니라 ref, 또는 필드/인덱서만 읽는다.
static void MulRow(ref Matrix4x4 m, Vector3 v, out Vector3 r)
{
r.x = m.m00 * v.x + m.m01 * v.y + m.m02 * v.z; // 필드 접근은 방어적 복사를 유발하지 않는다
r.y = m.m10 * v.x + m.m11 * v.y + m.m12 * v.z;
r.z = m.m20 * v.x + m.m21 * v.y + m.m22 * v.z;
}
// ✅ DO (c) — 내가 만드는 값 타입은 처음부터 readonly struct로. 그래야 in이 실제로 동작한다.
public readonly struct GridCoord : IEquatable<GridCoord>
{
public readonly int X, Y;
public GridCoord(int x, int y) { X = x; Y = y; }
public bool Equals(GridCoord o) => X == o.X && Y == o.Y;
public override bool Equals(object o) => o is GridCoord g && Equals(g);
public override int GetHashCode() => (X * 397) ^ Y;
}
핵심: in은 readonly struct와 세트다. Unity 내장 수학 타입에는 세트가 성립하지 않으니 in을 남발하지 않는다.
전체를 readonly로 못 만들면 C# 8 readonly 멤버로 부분 완화한다.
public struct Bounds2D
{
public Vector2 Center, Extents; // 가변 필드 유지
public readonly bool Contains(Vector2 p) => // this를 안 바꾼다고 선언 → 호출 시 복사 제거
Mathf.Abs(p.x - Center.x) <= Extents.x &&
Mathf.Abs(p.y - Center.y) <= Extents.y;
}
1-3. 좌표 컨테이너를 class로 만들기
IL 근거(§3-1): class 배열은 요소마다 newobj(힙 할당) + stelem.ref. struct 배열은 stelem으로 값 인라인.
// ❌ DON'T — 웨이포인트/경로/파티클 좌표를 class로
public class Waypoint { public float x, y, z; }
Waypoint[] path = new Waypoint[1000];
for (int i = 0; i < 1000; i++) path[i] = new Waypoint(); // 힙 객체 1000개, GC 추적 대상 1001개
// ✅ DO — 좌표는 값. 배열에 인라인된다.
Vector3[] path = new Vector3[1000]; // 힙 객체 1개(배열), 12KB 연속 블록
for (int i = 0; i < 1000; i++) path[i] = default;
1,000개 기준: struct ≈ 12KB / GC 추적 객체 1개, class ≈ 40KB / GC 추적 객체 1,001개. Unity GC는 non-generational이라 수집 때마다 이 1,001개를 전부 마킹한다.
1-4. 컬렉션 안의 struct를 직접 수정하려 하기
IL 근거(§3-7): List<T>.get_Item은 값을 반환한다 → stloc된 사본을 고치는 꼴.
// ❌ DON'T — 조용히 아무 일도 일어나지 않는다
List<Vector3> pts = ...;
pts[0] = pts[0]; // ok
// pts[0].x = 5f; // CS1612 (List 인덱서는 프로퍼티)
Vector3 p = pts[0]; p.x = 5f; // 사본만 바뀜, pts[0]은 그대로
// ✅ DO (a) — 읽고, 고치고, 되돌려 넣는다
Vector3 p = pts[0];
p.x = 5f;
pts[0] = p;
// ✅ DO (b) — 배열은 요소가 변수다. 직접 수정 가능하고 복사도 없다.
Vector3[] arr = ...;
arr[0].x = 5f; // 합법. 배열 요소는 인라인 저장이므로 사본이 아니다
// ✅ DO (c) — 대량 수정 루프는 List 대신 배열/NativeArray, 또는 ref 접근
Span<Vector3> span = arr; // 또는 CollectionsMarshal.AsSpan(pts) (지원 버전 확인)
for (int i = 0; i < span.Length; i++)
span[i].y = 0f; // ref 반환 인덱서 → 사본 없음
규칙: 좌표 배열을 매 프레임 in-place 갱신해야 하면 List<Vector3>가 아니라 Vector3[] / NativeArray<Vector3>를 쓴다.
1-5. struct를 object/인터페이스에 담기 (box)
IL 근거(§3-4): box = 힙 할당(값 크기 + 16B 헤더) + 필드 복사. Unity API에는 박싱 함정이 흩어져 있다.
// ❌ DON'T — 전부 프레임마다 box
void Update()
{
SendMessage("OnMove", transform.position); // object 파라미터 → box
animator.SetTrigger(GetHashOrString()); // (문자열 관련은 별개)
Debug.LogFormat("pos {0}", transform.position); // params object[] → 배열 1 + box 1
var tbl = new Dictionary<string, object>();
tbl["pos"] = transform.position; // box
IComparable c = transform.position.x; // float도 box
}
// ❌ DON'T — struct가 인터페이스를 구현해도 컬렉션에 담으면 요소마다 box
interface IShape { float Area(); }
struct Circle : IShape { public float R; public float Area() => Mathf.PI * R * R; }
List<IShape> shapes = new();
shapes.Add(new Circle { R = 1 }); // box 1회 + 이후 mutation은 사본에 적용됨
// ✅ DO — 제네릭 제약으로 constrained. 디스패치를 유도해 박싱 제거 (IL 근거 §3-5)
static float TotalArea<T>(T[] shapes) where T : struct, IShape
{
float sum = 0f;
for (int i = 0; i < shapes.Length; i++) sum += shapes[i].Area(); // box 없음, 인라인 가능
return sum;
}
// ✅ DO — 로그/문자열은 값 타입 자체의 ToString을 쓰고, 애초에 릴리스에서 제거
if (Debug.isDebugBuild) Debug.Log("pos " + transform.position.ToString());
// ✅ DO — 메시지 전달은 object 파라미터 API 대신 타입 있는 이벤트/인터페이스 참조
public event Action<Vector3> OnMoved; // 델리게이트 시그니처가 Vector3 → box 없음
OnMoved?.Invoke(pos);
1-6. 커스텀 struct를 Dictionary 키로 쓰면서 IEquatable을 빼먹기
IEquatable<T> 미구현 시 System.ValueType.Equals(object)로 떨어진다 → 비교마다 박싱 + 필드별/리플렉션 경로.
타일맵/헥스 그리드 조회처럼 프레임당 수백~수천 번 도는 경로에서 치명적이다.
// ❌ DON'T — 조회 1회마다 box 2회 + 느린 비교
public struct HexCoord { public int Q, R; }
Dictionary<HexCoord, Tile> map = new();
// ✅ DO — IEquatable<T> + GetHashCode + (선택) EqualityComparer 명시
public readonly struct HexCoord : IEquatable<HexCoord>
{
public readonly int Q, R;
public HexCoord(int q, int r) { Q = q; R = r; }
public bool Equals(HexCoord o) => Q == o.Q && R == o.R; // 박싱 없음
public override bool Equals(object o) => o is HexCoord h && Equals(h);
public override int GetHashCode() => unchecked(Q * 486187739 + R);
}
1-7. Vector3 를 키/동등성 비교에 쓰기 (==와 Equals가 다르다)
Unity 고유 함정: Vector3.operator==는 근사 비교(차이의 sqrMagnitude < 1e-10)지만,
Dictionary/HashSet/List.Contains가 쓰는 Equals는 필드 정확 비교다. 둘의 결과가 갈린다.
// ❌ DON'T — float 좌표를 그대로 키로
var set = new HashSet<Vector3>();
set.Add(a);
bool hit = set.Contains(b); // a == b 가 true여도 여기서는 false일 수 있다
// ✅ DO — 격자 좌표(정수)로 양자화해서 키로 쓴다
static Vector3Int Quantize(Vector3 v, float cell) =>
new Vector3Int(Mathf.FloorToInt(v.x / cell),
Mathf.FloorToInt(v.y / cell),
Mathf.FloorToInt(v.z / cell));
var cells = new Dictionary<Vector3Int, List<Entity>>(); // Vector3Int는 IEquatable<Vector3Int> 구현체
1-8. Vector3? (Nullable) 를 핫 패스 센티널로 쓰기
struct는 null이 될 수 없으니 개발자들이 Vector3?를 남발한다. Nullable은 **struct 래퍼(값 + bool, 정렬 포함 16B)**이고,
object로 올리면 내부 값이 박싱되며, Job/Burst에서 쓰기 부적합하다.
// ❌ DON'T
Vector3? target;
if (target.HasValue) Move(target.Value); // 복사 2회 + 크기 증가, 컬렉션에 담으면 더 나쁨
// ✅ DO — 성공 여부를 분리한 Try 패턴
bool TryGetTarget(out Vector3 target) { target = default; ... return found; }
if (TryGetTarget(out var t)) Move(t);
1-9. Unity 직렬화에서 class를 "공유 참조"로 기대하기
Unity 직렬화기는 UnityEngine.Object가 아닌 일반 class 참조를 인라인 사본으로 직렬화한다(순환 참조 불가, 공유 소실).
struct는 애초에 값이므로 이 함정에 걸리지 않는다 — 즉 값 데이터는 struct가 정답이고, 공유가 필요하면 참조를 명시해야 한다.
// ❌ DON'T — 두 필드가 같은 인스턴스를 가리키길 기대해도, 역직렬화 후 서로 다른 사본이 된다
[Serializable] public class SpawnConfig { public Vector3 offset; }
[SerializeField] SpawnConfig a, b; // a == b 로 넣어도 로드 후 분리됨
// ✅ DO (a) — 값 데이터는 struct로. 사본이 되는 게 의미상 옳다.
[Serializable] public struct SpawnConfig { public Vector3 offset; public float delay; }
// ✅ DO (b) — 진짜 공유가 필요하면 ScriptableObject(UnityEngine.Object 참조) 또는 [SerializeReference]
[SerializeField] SpawnConfigAsset shared; // ScriptableObject → 참조가 보존됨
2. 모바일 성능 함정
2-1. GC 압박 — 박싱 좌표는 프레임당 KB 단위로 샌다
Vector3 박싱 1회 ≈ 32B(12B 데이터 + 16B 헤더 + 정렬).
적 500마리 × Update에서 박싱 2회 × 60fps = 60,000 box/sec ≈ 1.9 MB/sec
Unity의 기본 GC는 Boehm 계열 — non-generational, non-compacting이다. CoreCLR의 "Gen 0에서 단명 객체를 싸게 회수"하는 경로가 없어, 짧게 쓰고 버리는 박싱 좌표가 특히 불리하다. Incremental GC는 pause를 분할할 뿐 할당량을 줄이지 않는다. 프레임 예산 16.67ms(60fps) / 8.33ms(120fps)에서 분할된 mark 작업도 매 프레임 CPU를 갉아먹으며, 저가형 안드로이드에서는 이것만으로 스루풋이 흔들린다.
측정 기준: Profiler의 GC Alloc 열이 좌표 계산 구간에서 0B여야 정상이다. 0B가 아니면 박싱/클로저/LINQ를 의심한다.
// ❌ DON'T — Vector3[] 위의 LINQ: 이터레이터 class 할당 + 델리게이트/클로저 할당 (매 프레임)
var near = points.Where(p => (p - origin).sqrMagnitude < r2).ToArray();
// ✅ DO — 값 배열 순회는 for 루프. 할당 0.
int n = 0;
for (int i = 0; i < points.Length; i++)
if ((points[i] - origin).sqrMagnitude < r2) buffer[n++] = points[i];
2-2. foreach 열거자 박싱
List<T>.Enumerator는 struct다. 하지만 대상을 IEnumerable<T> / IList<T>로 타이핑하면
GetEnumerator()가 인터페이스를 반환 → 열거자가 박싱되어 프레임마다 힙 할당된다.
// ❌ DON'T
IEnumerable<Vector3> pts = _list;
foreach (var p in pts) { ... } // 열거자 box (프레임당 40B 내외)
// ✅ DO — 구체 타입으로 잡거나 배열/Span 사용
List<Vector3> pts = _list;
foreach (var p in pts) { ... } // struct 열거자, 할당 0
for (int i = 0; i < arr.Length; i++) { } // 더 확실
2-3. 배터리 — 프로퍼티 왕복과 캐시 미스
transform.positionget/set은 managed→native 전이를 동반한다. 값 타입이라 반환은 싸지만 호출 자체가 싸지 않다. 캐릭터 1,000개 × 프레임당 5회 접근 = 30만 회/초의 전이 → CPU 클럭 상승 → 발열/배터리. → 읽기 1회, 쓰기 1회로 접는다(§1-1).- 캐시 라인 64B에 Vector3(12B)가 약 5개 들어간다.
Vector3[]순차 순회는 prefetcher가 잘 먹지만, 좌표를 class로 만들면배열 슬롯 → 포인터 → 힙 객체pointer chasing이 되고 DRAM 왕복(100~300+ 사이클)이 발생한다. Unity GC가 non-compacting이라 시간이 지날수록 더 흩어진다. 모바일은 메모리 대역폭이 좁아 이 차이가 데스크톱보다 크게 나온다.
2-4. 드로우콜 — 인스턴싱은 struct 연속 배열을 전제로 한다
Graphics.DrawMeshInstanced는 Matrix4x4[](배치당 최대 1023)를 받고, GPU 버퍼로 그대로 blit된다.
좌표/변환을 class로 들고 있으면 매 프레임 struct 배열로 변환하는 할당이 생겨 인스턴싱 이득을 까먹는다.
// ✅ DO — 행렬 버퍼를 struct 배열로 미리 잡고 in-place 갱신 (프레임당 할당 0)
readonly Matrix4x4[] _matrices = new Matrix4x4[1023];
void Update()
{
for (int i = 0; i < _count; i++)
_matrices[i] = Matrix4x4.TRS(_pos[i], _rot[i], _scale[i]); // 배열 요소에 값 인라인 기록
Graphics.DrawMeshInstanced(_mesh, 0, _mat, _matrices, _count);
}
MaterialPropertyBlock.SetVectorArray도 Vector4[]를 요구한다 — 값 타입 배열이 아니면 경로 자체가 없다.
2-5. Jobs/Burst로 넘기려면 unmanaged struct여야 한다
NativeArray<T>의 T는 **관리 참조를 하나도 포함하지 않는 struct(unmanaged)**여야 한다.
좌표 데이터에 class를 한 개라도 섞으면 Job/Burst 경로가 통째로 막힌다.
// ❌ DON'T — class 필드 하나 때문에 NativeArray<T> 불가
public struct AgentData { public Vector3 Pos; public Transform Owner; } // Transform = class
// ✅ DO — 인덱스로 참조를 끊는다
public struct AgentData { public Vector3 Pos; public float3 Vel; public int OwnerIndex; }
NativeArray<AgentData> agents = new(count, Allocator.Persistent);
Burst 대상 코드에서는 UnityEngine.Vector3보다 Unity.Mathematics.float3를 쓴다(SIMD 벡터화 친화).
Vector3를 struct로 만든 판단과 ECS/Burst는 같은 문제에 대한 같은 답이며, class 좌표는 이 사다리를 처음부터 오를 수 없다.
3. 플랫폼별 차이 — IL2CPP vs Mono
| 항목 | Mono (JIT) | IL2CPP (AOT) | 코드 작성 시 조치 |
|---|---|---|---|
| 값 타입 제네릭 | 인스턴스화별 코드 생성 | 인스턴스화별 C++ 생성 — 코드/바이너리 크기 증가 | 좌표 struct의 제네릭 인스턴스화 종류를 무한정 늘리지 않는다 |
| 참조 타입 제네릭 | 하나의 코드 공유 | 공유(Il2CppObject) |
class 제네릭은 크기 부담이 적다 |
리플렉션으로만 생기는 Dictionary<MyStruct, X> 등 |
JIT가 런타임 생성 | AOT라 생성 불가 → ExecutionEngineException | 코드 어딘가에서 해당 조합을 정적으로 1회 인스턴스화하거나 link.xml/[Preserve] |
ldobj 방어적 복사 |
JIT가 일부 제거 | 생성된 C++에서 구조체 복사로 남고 C++ 컴파일러 최적화에 의존 | readonly struct / readonly 멤버로 IL 단계에서 없애는 게 유일하게 확실 |
| 큰 struct(Matrix4x4 64B) 값 전달 | JIT 인라인/enregister에 기대 | C++ by-value 전달 — 인라인 여부에 따라 memcpy | ref 전달 또는 필드 직접 접근 |
| P/Invoke·네이티브 플러그인 | 비블리터블이면 마샬링 | 동일 | 좌표 struct는 blittable(float/int만) 유지, bool/char/class 필드 금지 |
| 박싱 비용 | 힙 할당 | 힙 할당(동일 GC) | 양쪽 모두 box는 그대로 비용 — 제네릭 제약으로 회피 |
| GC | Boehm(기본) / Incremental 옵션 | 동일 | 플랫폼이 아니라 할당 자체를 없애는 게 답 |
실무 규칙:
- 에디터(Mono)에서 GC Alloc 0이었다고 디바이스(IL2CPP)에서도 0이라 가정하지 않는다. 반드시 디바이스 프로파일링.
- IL2CPP 빌드에서만 터지는
ExecutionEngineException은 대부분 값 타입 제네릭 조합 부재다. 아래처럼 앵커를 둔다.
#if ENABLE_IL2CPP
[UnityEngine.Scripting.Preserve]
static class AotAnchors
{
static void Force()
{
_ = new System.Collections.Generic.Dictionary<HexCoord, int>();
_ = new System.Collections.Generic.List<GridCoord>();
_ = System.Collections.Generic.EqualityComparer<HexCoord>.Default;
}
}
#endif
4. 코드 작성 전 체크리스트
새로 타입을 만들 때:
- [ ] 이 데이터에 정체성이 있는가? (같은 값이면 같은 것인가) → 값이면 struct, 정체성이 필요하면 class. 좌표·ID·타일 인덱스·패킷 헤더 = struct / 세션·엔티티·매니저·풀 = class.
- [ ] struct라면 16B 이하인가? 넘는다면 매개변수·반환값으로 자주 오가지 않는가? (Matrix4x4는 64B지만 배열 연속 배치·네이티브 전달 이득이 커서 예외)
- [ ]
readonly struct로 선언했는가? 못 한다면 최소한 읽기 메서드에readonly멤버를 붙였는가? - [ ]
IEquatable<T>+GetHashCode를 구현했는가? (Dictionary/HashSet 키,Contains사용 시 필수) - [ ] 필드가 전부 blittable인가? (Jobs/Burst/네이티브 플러그인 대상이면 필수)
- [ ] class 필드를 섞어서
NativeArray<T>경로를 막고 있지 않은가?
기존 코드를 수정할 때:
- [ ]
transform.position/.rotation/.localScale을 한 메서드 안에서 2번 이상 읽거나 쓰지 않는가? - [ ]
in Vector3/in Quaternion/in Matrix4x4를 쓰고 있는가? → Unity 타입은 readonly struct가 아니므로 제거하거나ref로 바꾼다. - [ ] struct가
object/ 인터페이스 /params object[]/Dictionary<_, object>/SendMessage로 흘러가지 않는가? (box) - [ ] struct가 인터페이스를 구현한다면
List<IFoo>가 아니라where T : struct, IFoo제네릭으로 받고 있는가? (constrained.) - [ ]
List<Vector3>요소를 수정하는 코드가 사본만 고치고 있지 않은가? (읽고-고치고-되돌려넣기, 또는 배열/Span) - [ ]
IEnumerable<T>로 타이핑된 컬렉션을 매 프레임 foreach 하고 있지 않은가? (열거자 박싱) - [ ]
Vector3?를 핫 패스에서 쓰고 있지 않은가? →TryGet(out Vector3)패턴. - [ ] float 좌표를 Dictionary/HashSet 키로 쓰고 있지 않은가? (
==는 근사,Equals는 정확 — 불일치) → 정수 양자화. - [ ] 매 프레임 좌표 배열을 새로
new하고 있지 않은가? → 버퍼 재사용 + in-place 갱신.
리뷰 시 즉시 반려할 시그널:
transform.position.x = ... // CS1612 — 값 사본 대입
in Vector3 / in Quaternion // Unity 타입은 readonly struct가 아님 → 방어적 복사
public class Waypoint { float x,y,z; } // 좌표를 class로
List<IShape> where IShape impl by struct // 요소마다 box
Dictionary<MyStruct, T> without IEquatable // 조회마다 box + 느린 비교
HashSet<Vector3> / Dictionary<Vector3, T> // float 정확 비교 함정
5. 근거 요약 (IL 실측)
| IL 명령 | 언제 나오나 | Unity에서의 의미 |
|---|---|---|
stelem |
struct 배열 대입 | Vector3[] — 값 인라인, GC 추적 객체 1개 |
newobj + stelem.ref |
class 배열 대입 | 좌표 class화 시 요소마다 힙 할당, 추적 객체 n+1개 |
box |
object/인터페이스 대입 | SendMessage, params object[], List<IShape> — 프레임당 KB 누수 |
constrained. |
where T : struct, IFoo |
박싱 제거 + 인라인 가능 — 인터페이스 쓰려면 이 형태로 |
ldobj |
in + 비 readonly struct |
Unity Vector3/Quaternion/Matrix4x4가 여기 해당 — in이 무력화 |
ldloca + call |
struct 인스턴스 호출 | 주소 전달, 복사·null 체크 없음 |
callvirt |
class 인스턴스 호출 | null 체크 동반(가상 아님에도) |
9. 게임서버 실전 가이드펼치기
이 절은 AI 에이전트가 게임서버 C# 코드를 작성할 때 참조하는 규범이다. Unity 클라이언트 관점(프레임 예산 16.67ms)은 여기서 다루지 않는다. 서버의 지표는 allocation rate(MB/s) 와 P99 / P99.9 latency 다.
판단이 갈리는 지점 하나만 먼저 못 박는다.
| Unity (Boehm, non-generational, non-compacting) | 서버 (CoreCLR, 세대별 + compacting) | |
|---|---|---|
| 단명 임시 객체 | 수집 때마다 전액 비용 | Gen 0에서 대부분 싸게 회수 |
| 그래서 struct를 쓰는 이유 | GC pause 자체를 없애려고 | Gen 0 승격/할당률을 억제하고 P99 꼬리를 자르려고 |
| 진짜 위험 | 프레임 스파이크 | Gen 2 / LOH 승격, Server GC의 모든 코어 stop-the-world |
핵심: CoreCLR의 Gen 0이 있으니 "박싱 좀 해도 된다"는 결론은 틀렸다. Gen 0 수집은 싸지만 공짜가 아니고, Server GC에서는 전 코어가 동시에 멈춘다. 초당 10만 요청 경로에서 요청당 20바이트 박싱은 곧 2MB/s × 상시 이며, 그 비용은 평균 latency가 아니라 P99.9에만 나타난다.
1. DON'T — 서버에서 struct/class 선택이 만드는 구체적 사고
DON'T 1. 패킷 필드를 object / 비제네릭 컬렉션에 담는다 (box 폭발)
요청당 수십 번의 힙 할당이 조용히 생기고, 부하가 올라가면 P99만 튄다.
// ❌ 나쁜 코드 — 직렬화 경로의 박싱 폭발
public sealed class BadPacketWriter
{
private readonly List<object> _fields = new(); // object = 무조건 box
public void Write(int v) => _fields.Add(v); // box int
public void Write(long v) => _fields.Add(v); // box long
public void Write(float v) => _fields.Add(v); // box float
public byte[] Flush()
{
var ms = new MemoryStream();
foreach (object f in _fields) // 역참조 + 언박싱
ms.Write(BitConverter.GetBytes((int)f)); // GetBytes도 byte[] 할당
return ms.ToArray(); // 또 할당
}
}
IL 근거: object o = p; → box PointS (result.md §3-4).
box는 ①힙 할당(값 크기 + 64비트 헤더 16B) ②필드 복사 ③주소 반환의 3단계다.
int 하나 박싱 = 24바이트 힙 객체. 필드 20개짜리 패킷이면 요청당 480B,
100k RPS면 48MB/s가 순수 낭비로 Gen 0에 쌓인다.
DON'T 2. EntityId 같은 ID struct를 IEquatable<T> 없이 Dictionary 키로 쓴다
서버는 프레임마다가 아니라 패킷마다 세션 조회를 한다. 가장 뜨거운 경로다.
// ❌ 나쁜 코드 — ValueType.Equals 기본 구현으로 떨어진다
public struct EntityId
{
public long Value;
public int Realm;
}
// 조회 한 번마다 ValueType.Equals(object) 경로 → 인자 박싱 + 필드별 비교
private readonly Dictionary<EntityId, PlayerSession> _sessions = new();
IEquatable<T>가 없으면 EqualityComparer<T>.Default가
ObjectEqualityComparer<T>로 떨어지고, Equals(object) 호출마다 비교 대상이 박싱된다.
필드가 참조 타입을 포함하거나 패딩이 있으면 리플렉션 기반 느린 경로까지 탄다.
DON'T 3. PlayerSession을 struct로 만든다 (복사되면 사고)
// ❌ 나쁜 코드 — 정체성이 있는 것을 값 타입으로
public struct PlayerSession
{
public EntityId PlayerId;
public Socket Connection;
public int SequenceNumber;
public DateTime LastHeartbeatUtc;
}
// 호출부
void OnPacket(PlayerSession session) // ★ 통째로 복사됨
{
session.SequenceNumber++; // 사본만 증가 → 원본은 그대로
session.LastHeartbeatUtc = DateTime.UtcNow; // 하트비트 갱신이 유실
}
증상이 컴파일 에러가 아니라 무작위 연결 끊김으로 나타난다.
result.md §3-7의 list[0].X = 99가 반영 안 되는 것과 정확히 같은 뿌리인데,
서버에서는 그게 타임아웃 버그가 된다.
DON'T 4. in만 붙이고 readonly struct를 빼먹는다
// ❌ 나쁜 코드 — in을 붙였는데 방어적 복사가 남는다
public struct PacketHeader // readonly 아님
{
public ushort OpCode;
public ushort Length;
public uint Sequence;
public long Timestamp; // 16B
public bool IsValid() => Length > 0 && Length <= 8192;
}
static void Dispatch(in PacketHeader h) // in을 붙였지만…
{
if (h.IsValid()) { } // ★ ldobj — 16B 방어적 복사
}
IL 근거: result.md §3-6. in + 비-readonly struct는 ldobj로 값 전체를 복사한 뒤
사본 위에서 호출한다. 서버 hot path에서 in을 남발하면
"복사를 줄이려고 넣은 키워드가 복사를 그대로 남기는" 상태가 된다.
DON'T 5. struct를 async 상태 머신 / 클로저에 캡처한다
이건 서버에서 특히 치명적이다. 클라이언트와 달리 서버 코드는 대부분 async다.
// ❌ 나쁜 코드 — struct의 이점이 상태 머신 힙 승격으로 전부 사라진다
public async Task HandleAsync(PacketHeader header, EntityId id)
{
await _db.SaveAsync(id); // ★ await 경계를 넘음
Log(header.OpCode); // header/id가 상태 머신 필드로 이동
}
await가 하나라도 있고 그 뒤에서 지역 struct를 쓰면,
컴파일러가 상태 머신을 만들고 AsyncTaskMethodBuilder가 그 상태 머신을 힙에 박싱한다
(IAsyncStateMachine은 인터페이스 → §3-4의 box와 같은 메커니즘).
struct 필드는 이제 힙 객체 안에 인라인되어 Gen 0 객체의 크기를 키운다.
클로저도 같다.
// ❌ EntityId가 값 타입인데 클로저에 캡처되어 힙 DisplayClass에 실림
_timer.Schedule(() => Kick(id)); // id가 힙으로 승격
DON'T 6. 큰 struct를 값으로 던지며 "struct니까 빠르다"고 믿는다
// ❌ 나쁜 코드 — 128B struct를 값 전달
public struct WorldSnapshot // Vector3 × 8 + 기타 ≈ 128B
{
public Vector3 P0, P1, P2, P3, P4, P5, P6, P7;
public long Tick;
}
void Broadcast(WorldSnapshot snap) // 128B 복사
{
foreach (var s in _sessions) Send(s, snap); // 세션 수만큼 또 128B 복사
}
IL 근거: §3-8. 64B BigStruct도 ldloc 한 방에 그대로 push된다.
1,000 세션 브로드캐스트면 128KB의 순수 memcpy가 틱마다 발생한다.
힙 할당은 0이지만 CPU와 메모리 대역폭을 태운다 — 서버에서는 이게 throughput 상한이 된다.
DON'T 7. struct 배열을 크게 잡고 LOH를 모른 척한다
struct 배열이 GC 추적 객체 수를 1개로 줄여준다는 건 맞다(§3-1). 하지만 서버에서는 그 1개가 85,000바이트를 넘으면 LOH로 간다.
// ❌ 나쁜 코드 — 틱마다 LOH 할당
void Tick()
{
var positions = new Vector3[10_000]; // 12B × 10,000 = 120,000B > 85,000B
// ... // ★ LOH 직행 = 사실상 Gen 2
}
LOH는 기본적으로 압축되지 않고 Gen 2 수집 때만 회수된다. 틱마다 이걸 하면 Gen 2 GC가 주기적으로 돌면서 P99.9에 수십~수백 ms 스파이크를 찍는다.
2. DO — 서버에서 맞는 패턴
DO 1. 직렬화는 Span<byte> + 제네릭 제약으로 무할당 경로를 만든다
// ✅ 좋은 코드 — box 0, 배열 할당 0
public ref struct PacketWriter
{
private readonly Span<byte> _buffer;
private int _offset;
public PacketWriter(Span<byte> buffer)
{
_buffer = buffer;
_offset = 0;
}
public int Written => _offset;
// where T : unmanaged → constrained. 디스패치, 박싱 없음
public void Write<T>(T value) where T : unmanaged
{
var dest = _buffer.Slice(_offset);
MemoryMarshal.Write(dest, in value);
_offset += Unsafe.SizeOf<T>();
}
}
// 호출부 — 버퍼는 풀에서
public void SendMove(PlayerSession session, in Vector3 pos)
{
byte[] rented = ArrayPool<byte>.Shared.Rent(64);
try
{
var w = new PacketWriter(rented);
w.Write<ushort>(OpCode.Move);
w.Write(session.PlayerId.Value);
w.Write(pos); // struct 그대로, box 없음
session.Send(rented.AsSpan(0, w.Written));
}
finally { ArrayPool<byte>.Shared.Return(rented); }
}
포인트 세 가지.
ref struct라서 힙에 절대 올라갈 수 없다(필드 캡처·박싱·async 캡처를 컴파일러가 금지).where T : unmanaged제약이 §3-5의constrained.를 유도해 박싱을 제거한다.ArrayPool로 §DON'T 7의 LOH 회피까지 함께 처리한다.
DO 2. ID 타입은 readonly struct + IEquatable<T> + 연산자
// ✅ 좋은 코드 — Dictionary 키로 안전한 ID
public readonly struct EntityId : IEquatable<EntityId>
{
public readonly long Value;
public EntityId(long value) => Value = value;
public bool Equals(EntityId other) => Value == other.Value;
// object 오버로드도 반드시 — 실수로 박싱 경로를 타도 정확성은 지킨다
public override bool Equals(object? obj) => obj is EntityId o && Equals(o);
public override int GetHashCode() => Value.GetHashCode();
public static bool operator ==(EntityId a, EntityId b) => a.Equals(b);
public static bool operator !=(EntityId a, EntityId b) => !a.Equals(b);
public override string ToString() => $"E:{Value}";
}
// EqualityComparer<EntityId>.Default가 GenericEqualityComparer로 잡히고,
// JIT가 Equals(EntityId)를 인라인한다 → 조회 경로 할당 0
private readonly ConcurrentDictionary<EntityId, PlayerSession> _sessions = new();
C# 10 이상이면 public readonly record struct EntityId(long Value); 로도 되지만,
record struct의 자동 ToString()은 내부에서 StringBuilder를 할당한다.
로그 hot path에서 ToString()을 호출한다면 직접 구현하거나 오버라이드하라.
DO 3. 정체성이 있는 것은 sealed class, 값인 것만 struct
// ✅ 공유되고, 복사되면 사고나고, 수명이 긴 것 → class
public sealed class PlayerSession
{
public EntityId PlayerId { get; } // 값 → struct 필드로 인라인
public SocketConnection Connection { get; }
public ConcurrentDictionary<int, Item> Inventory { get; } = new();
private long _lastHeartbeatTicks; // Interlocked 대상이므로 필드
private int _sequence;
public PlayerSession(EntityId id, SocketConnection conn)
{
PlayerId = id;
Connection = conn;
}
public int NextSequence() => Interlocked.Increment(ref _sequence);
public void Touch() =>
Interlocked.Exchange(ref _lastHeartbeatTicks, DateTime.UtcNow.Ticks);
}
sealed를 붙이는 이유는 서버 고유다. §3-3에서 봤듯 class 메서드는 callvirt로 나가는데,
sealed면 JIT가 devirtualize + 인라인할 여지가 생긴다.
패킷당 수십 번 호출되는 세션 메서드에서는 실질적 차이가 난다.
판단 규칙 (서버판)
| 질문 | Yes → struct | Yes → class |
|---|---|---|
| 값이 같으면 같은 것인가? | ○ | |
| 여러 스레드가 같은 인스턴스를 봐야 하는가? | ○ | |
Interlocked / lock 대상 필드가 있는가? |
○ | |
| 수명이 요청 하나보다 긴가? | ○ | |
| 16B 이하이고 불변인가? | ○ | |
IDisposable인가 (소켓·스트림 보유)? |
○ |
DO 4. in은 반드시 readonly struct와 짝으로
// ✅ 좋은 코드 — ldobj 없음, 복사 없음
public readonly struct PacketHeader
{
public readonly ushort OpCode;
public readonly ushort Length;
public readonly uint Sequence;
public readonly long Timestamp;
public PacketHeader(ushort opCode, ushort length, uint seq, long ts)
=> (OpCode, Length, Sequence, Timestamp) = (opCode, length, seq, ts);
public bool IsValid() => Length > 0 && Length <= 8192;
}
static void Dispatch(in PacketHeader h)
{
if (h.IsValid()) { } // call 직행 — 방어적 복사 없음 (§3-6)
}
전체를 readonly로 못 만들 때는 멤버 단위 readonly(C# 8) 로 국소 완화한다.
public struct MovementState
{
public Vector3 Position; // 가변 유지 필요
public Vector3 Velocity;
public readonly bool IsMoving() => Velocity != Vector3.Zero; // 이 호출만 복사 제거
}
크기 기준: 16B 이하면 in 없이 값 전달이 보통 더 빠르다(레지스터 전달 가능).
in은 24B 이상 + readonly struct일 때 이득이 난다.
DO 5. hot path에서는 인터페이스 대신 제네릭 제약
// ❌ 인터페이스 파라미터 — 값 타입 넘기면 호출마다 box
void Handle(IPacketBody body) => body.Apply();
// ✅ 제네릭 제약 — constrained. 로 박싱 제거 + JIT 특수화 + 인라인
void Handle<T>(in T body) where T : struct, IPacketBody => body.Apply();
IL 근거: §3-5. constrained. !!T 접두어가 값 타입이면 주소로 직접 호출한다.
게다가 CoreCLR은 값 타입 제네릭마다 별도 코드를 JIT하므로
인터페이스 디스패치 자체가 사라지고 인라인까지 걸린다.
(참조 타입 제네릭은 코드를 공유하므로 이 이득이 없다 — 그래서 where T : struct가 중요하다.)
DO 6. async 경계를 넘는 struct는 캡처를 최소화한다
// ✅ 좋은 코드 — 상태 머신에 실리는 값을 최소 필드로 줄인다
public async Task HandleAsync(PacketHeader header, EntityId id)
{
// await 이전에 필요한 것만 추출 → header(16B) 전체가 상태 머신에 안 실림
ushort opCode = header.OpCode;
await _db.SaveAsync(id).ConfigureAwait(false);
Log(opCode); // 2B만 상태 머신 필드
}
// ✅ 동기 완료가 흔한 경로는 ValueTask로 상태 머신 할당 자체를 회피
public ValueTask<PlayerSession?> GetSessionAsync(EntityId id)
{
if (_sessions.TryGetValue(id, out var s))
return new ValueTask<PlayerSession?>(s); // 할당 0
return new ValueTask<PlayerSession?>(LoadSlowAsync(id));
}
규칙: await 뒤에서 살아남아야 하는 값만 상태 머신에 남긴다. 큰 struct는 await 이전에 필요한 스칼라로 분해하라.
DO 7. 큰 struct 컬렉션은 ref 접근으로 복사를 없앤다
// ❌ foreach는 요소를 복사한다
foreach (var e in _entities) { /* e는 사본 — 수정해도 반영 안 됨 */ }
// ✅ Span + ref 로 in-place 접근
Span<EntityState> span = CollectionsMarshal.AsSpan(_entities); // List<T> → Span
for (int i = 0; i < span.Length; i++)
{
ref EntityState e = ref span[i]; // 복사 없음
e.Position += e.Velocity * dt; // 원본이 바뀐다
}
// ✅ Dictionary 값 struct 갱신도 ref로
ref var slot = ref CollectionsMarshal.GetValueRefOrNullRef(_states, id);
if (!Unsafe.IsNullRef(ref slot))
slot.Hp -= damage; // 재삽입 불필요
§3-7의 "컬렉션 안의 struct는 사본"이라는 함정을 근본적으로 없애는 방법이다.
CollectionsMarshal은 .NET 5+ (GetValueRefOrNullRef는 .NET 6+).
3. 서버 성능 함정
3-1. allocation rate → P99가 아니라 P99.9에 나타난다
100,000 req/s × 요청당 480B 박싱 = 48 MB/s
Gen 0 budget이 예컨대 수 MB 수준이면 → 초당 수십 회 Gen 0 GC
Gen 0 pause 자체는 짧지만(수백 µs), Server GC는 그때 모든 워커 스레드를 멈춘다
→ 평균 latency 변화 거의 없음 / P99.9만 수 ms 튐
진단 지표: dotnet-counters의
alloc-rate, gen-0-gc-count, gen-2-gc-count, time-in-gc, loh-size.
평균 응답 시간만 보면 이 문제는 영원히 안 보인다.
3-2. 승격(promotion)이 진짜 적
Gen 0 객체가 짧게 죽으면 사실상 공짜다. 문제는 살아남는 것이다.
// ❌ 박싱된 값이 장수 컬렉션에 들어가면 Gen 2까지 승격
private readonly List<object> _recentEvents = new(); // 5분 보관
_recentEvents.Add(entityId); // box → Gen 0 생존 → Gen 1 → Gen 2
// ✅ struct를 struct 그대로 담는다 — 배열 버퍼 1개만 추적 대상
private readonly List<EntityEvent> _recentEvents = new(); // EntityEvent는 struct
§3-1의 "GC가 추적할 객체 수" 논리가 서버에서는 Gen 2 힙 크기로 직결된다. class 이벤트 100만 개 = 추적 객체 100만 개 = Gen 2 마킹 시간 폭증. struct 리스트면 추적 객체는 배열 버퍼 1개다.
3-3. false sharing — struct 배열의 서버 고유 함정
값 인라인 배치는 캐시 지역성을 주지만, 멀티스레드에서는 역효과가 난다.
// ❌ 워커별 카운터가 한 캐시 라인(64B)에 몰려 있다
private struct Counter { public long Value; } // 8B
private Counter[] _perWorker = new Counter[8]; // 64B — 전부 한 라인!
// 워커 0이 쓰면 워커 1~7의 캐시 라인이 무효화된다
// ✅ 캐시 라인 단위로 패딩
[StructLayout(LayoutKind.Explicit, Size = 64)]
private struct PaddedCounter
{
[FieldOffset(0)] public long Value;
}
private PaddedCounter[] _perWorker = new PaddedCounter[8]; // 512B, 라인당 1개
Unity 단일 스레드 로직에서는 나오지 않는, 서버 전용 함정이다.
3-4. lock 대상으로 struct를 쓰면 매번 다른 객체를 잠근다
// ❌ 컴파일은 되지만(박싱) 매 호출 다른 힙 객체를 잠금 → 상호배제 실패
EntityId id = ...;
lock ((object)id) { } // box가 매번 새 객체
lock은 sync block index(참조 타입 헤더)를 쓴다. struct는 그게 없어
박싱된 임시 객체를 잠그게 되고 동기화가 아무 효과를 못 낸다.
값 단위 잠금이 필요하면 _locks[id.GetHashCode() & mask] 같은 락 스트라이핑을 써라.
3-5. 인터페이스로 받은 struct 열거자는 박싱된다
// ❌ IEnumerable<T>로 받으면 List<T>.Enumerator(struct)가 박싱된다
void Process(IEnumerable<EntityId> ids) { foreach (var id in ids) { } }
// ✅ 구체 타입으로 받으면 struct enumerator가 그대로 쓰인다 (할당 0)
void Process(List<EntityId> ids) { foreach (var id in ids) { } }
// ✅ 또는 제네릭 제약으로
void Process<TEnum>(TEnum ids) where TEnum : struct, IEnumerator<EntityId> { }
브로드캐스트 루프처럼 틱마다 도는 곳에서 시그니처 하나가 할당 유무를 가른다.
3-6. Interlocked / volatile은 struct 필드에 못 건다
// ❌ struct 안의 필드를 원자적으로 갱신하려는 시도
public struct SessionState { public int Sequence; }
// 사본을 증가시키면 의미가 없다
// ✅ 참조 타입 안의 필드로 두거나, 배열 요소의 ref로 접근
Interlocked.Increment(ref _states[i].Sequence); // 배열 요소는 ref 가능
struct가 값으로 복사되는 순간 원자성 보장은 사라진다. 동시성 상태를 struct에 넣지 마라 — 이것이 서버에서 struct/class를 가르는 가장 강한 신호다.
4. 런타임별 차이
4-1. Workstation GC vs Server GC
| 항목 | Workstation GC | Server GC |
|---|---|---|
| 힙 개수 | 1 | 논리 코어당 1 |
| GC 스레드 | 1 (concurrent 백그라운드 별도) | 코어당 전용 스레드 |
| Gen 0 budget | 작음 | 크다 (수십 MB 급) |
| 개별 pause | 짧음 | 길지만 빈도 낮음 |
| 박싱 남용 시 증상 | GC 빈발 | 한 번에 전 코어 stop-the-world → P99.9 스파이크 |
Server GC 기본값은 ASP.NET Core 호스팅 시 켜져 있고, 콘솔 앱은 꺼져 있다. 게임서버를 콘솔 호스트로 짤 때 명시적으로 켜야 한다.
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
<TieredPGO>true</TieredPGO>
</PropertyGroup>
주의: Server GC의 큰 Gen 0 budget 때문에 박싱을 해도 GC 카운트가 안 올라간다.
그래서 부하 테스트에서 놓치고, 프로덕션 피크에서 터진다.
gen-0-gc-count가 아니라 alloc-rate를 봐라.
컨테이너 환경에서는 CPU limit이 Server GC 힙 개수를 결정한다.
<ServerGarbageCollection>true</> + 코어 1개짜리 컨테이너는 최악의 조합이 될 수 있으니
DOTNET_GCHeapCount 로 명시 제어를 검토하라.
4-2. .NET 버전별로 이 주제에 실제로 달라지는 것
| 버전 | struct/class 판단에 영향을 주는 변화 |
|---|---|
| .NET Core 3.0 | Span<T> 기반 API 전면화, MemoryMarshal 성숙 → 무할당 직렬화 실용화 |
| .NET 5 | CollectionsMarshal.AsSpan — List<struct> in-place 갱신 가능 |
| .NET 6 | CollectionsMarshal.GetValueRefOrNullRef — Dictionary struct 값 재삽입 제거 |
| .NET 7 | 제네릭 math(INumber<T>) — static abstract로 struct 인터페이스 무박싱 확장 |
| .NET 8 | DATAS(Dynamic Adaptation To Application Size) 도입, .NET 9부터 Server GC 기본 ON |
| .NET 8 | Native AOT 성숙 — struct 제네릭 특수화가 시작 시간/메모리에 직접 반영 |
| .NET 9 | struct 인자에 대한 JIT 물리적 승격(physical promotion) 개선 — 큰 struct 복사 일부 소거 |
DATAS 주의: .NET 9부터 Server GC에서 DATAS가 기본 활성화되어 힙 개수와 Gen 0 budget을 부하에 따라 동적으로 줄인다. 게임서버처럼 일정한 고할당률 + 낮은 tail latency가 목표인 워크로드에서는 DATAS가 힙을 줄여 GC 빈도를 올릴 수 있다. 벤치마크 후 필요하면 끈다.
<GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>
4-3. Tiered JIT와 struct
Tier 0(초기 JIT)에서는 struct 인라인·레지스터 승격이 대부분 안 걸린다.
readonly struct + 제네릭 제약의 이득은 Tier 1로 승격된 뒤에야 나온다.
따라서:
- 마이크로벤치마크는 반드시 warm-up 이후를 측정한다(BenchmarkDotNet 기본 동작).
- 서버 기동 직후 첫 수천 요청의 latency가 나쁜 것은 정상이며, 필요하면
TieredPGO+ 워밍업 트래픽을 넣는다.
4-4. Unity 클라이언트와 코드를 공유할 때
같은 readonly struct를 클라이언트/서버가 공유하는 경우:
- C# 언어 버전은 Unity 쪽이 낮은 쪽에 맞춰야 한다(
record struct,ref field등은 확인 필요). - IL2CPP는 제네릭 값 타입마다 코드를 생성하므로, 제네릭 제약 남용이 빌드 크기를 키운다.
- 서버는 Gen 0가 받아주지만 클라이언트 Boehm GC는 안 받아준다 — 공유 타입은 더 보수적으로(struct + 불변) 설계하라.
5. 코드 작성 전 체크리스트
새 타입을 만들 때:
- [ ] 값 동등성이 자연스러운가? → struct / 아니면
sealed class - [ ]
Interlocked·lock·IDisposable·소켓·수명 > 1요청 중 하나라도 해당? → 무조건 class - [ ] struct라면
readonly struct인가? (아니면in이ldobj를 남긴다) - [ ] 크기가 16B 이하인가? 초과라면
in전달을 쓰는가? - [ ] Dictionary/HashSet 키로 쓸 가능성이 있는가? →
IEquatable<T>+GetHashCode()+==구현 - [ ]
ToString()이 로그 hot path에서 불리는가? → 직접 구현 (record struct 자동 구현은 할당함)
패킷/직렬화 코드를 쓸 때:
- [ ]
object/ArrayList/IPacketBody같은 인터페이스 파라미터가 있는가? →where T : unmanaged또는where T : struct, I...로 교체 - [ ] 버퍼를
new byte[]로 만드는가? →ArrayPool<byte>.Shared+Span<byte> - [ ] 버퍼 크기가 85,000B를 넘을 수 있는가? → LOH. 청크 분할 또는 풀링
- [ ] Writer/Reader를
ref struct로 만들어 힙 승격을 컴파일러가 막게 했는가? - [ ]
string.Format/ 보간 문자열이 hot path에 있는가? → 로그 레벨 가드 또는[InterpolatedStringHandler]
async 코드를 쓸 때:
- [ ]
await뒤에서 살아남는 struct가 큰가? → 필요한 스칼라만 남기고 분해 - [ ] 동기 완료가 흔한 경로인가? →
ValueTask반환 - [ ] struct를 람다에 캡처하는가? → 힙 DisplayClass 승격. static 람다 + 상태 인자로 전환
- [ ]
ConfigureAwait(false)를 라이브러리 코드에 붙였는가?
컬렉션을 다룰 때:
- [ ]
List<struct>를foreach로 수정하려 하는가? →CollectionsMarshal.AsSpan+ref - [ ]
Dictionary<K, struct>값을 갱신하는가? →GetValueRefOrNullRef - [ ]
IEnumerable<T>로 받아서 hot loop를 도는가? → 구체 타입으로 받아 struct enumerator 유지 - [ ] 워커별 카운터 배열인가? → 캐시 라인 패딩(false sharing)
배포 설정:
- [ ]
ServerGarbageCollection명시했는가? (콘솔 호스트는 기본 off) - [ ] .NET 9+ 인데 DATAS가 tail latency를 해치지 않는지 측정했는가?
- [ ] 컨테이너 CPU limit과 GC 힙 개수가 맞는가?
- [ ]
dotnet-counters로alloc-rate/time-in-gc/loh-size를 모니터에 올렸는가? - [ ] 부하 테스트에서 평균이 아니라 P99 / P99.9를 보고 있는가?
6. 한 줄 요약
- struct = 값이고, 작고, 불변이고, 요청 수명 안에서만 사는 것 (
EntityId,PacketHeader,Vector3) - class = 정체성이 있고, 공유되고, 동기화·리소스를 들고 있는 것 (
PlayerSession,Room,World) - 서버에서 struct를 쓰는 이유는 GC pause 회피가 아니라 allocation rate 억제 + Gen 2 추적 객체 수 억제 → P99.9 안정화다.
- struct의 이점을 실제로 받으려면
readonly struct+IEquatable<T>+ 제네릭 제약(constrained.) 세 가지가 함께 있어야 한다. - 하나만 빠져도 IL에
box또는ldobj가 남고, 그 비용은 평균 지표에 절대 안 보인다.
리서치 소스
| 소스 | 기여 |
|---|---|
| NotebookLM (CLR via C#, C# in Depth, C# 12 in a Nutshell, Pro .NET Memory Management) | 오버헤드 정확한 바이트 수, GC marking 메커니즘, 박싱 3단계, 16바이트 설계 지침 |
GPT (gpt-5.6-terra) |
Unity 맥락 — Boehm GC 특성, Incremental GC의 한계, 프레임 예산 계산, DOTS/Burst 연결, C#↔Unity 버전 대응 |
Claude + ilspycmd 실측 |
IL 8종 검증(stelem/stelem.ref, box, constrained., ldobj 등) 및 3소스 교차 검증 |