큰 struct를 그냥 넘기면 안 되는 이유 — in·ref·readonly struct
1. 문제 제기
앞 글에서 "좌표는 struct가 맞다"까지 왔다. 그럼 이제 struct를 어떻게 넘길 것인가가 남는다.
성능 글을 읽고 이렇게 고쳐본 적이 있을 것이다.
// 64바이트 struct를 값으로 넘기면 복사가 생긴다던데?
static double Process(Matrix4x4 m) => m.Determinant();
// 그럼 in을 붙이면 되겠네
static double Process(in Matrix4x4 m) => m.Determinant();
깔끔해 보인다. 그런데 이 수정으로 복사가 하나도 줄지 않을 수 있다. 심지어 8바이트짜리 작은 struct에 같은 짓을 하면 원래 없던 복사가 새로 생긴다.
struct Small { public int X, Y; }
static int A(Small s) => s.Sum(); // IL상 복사 명령 없음
static int B(in Small s) => s.Sum(); // ldobj 1회 ← in을 붙였는데 늘었다
왜 이런 일이 벌어지나? 그리고 in을 제대로 쓰려면 무엇이 더 필요한가? 이번에도 답은 IL에 있다.
2. 개념 정의
비유 (Analogy)
두꺼운 계약서 한 부를 동료에게 검토받는다고 하자. 방법이 세 가지다.
- 값 전달 = 전체를 복사해서 준다. 동료가 뭘 하든 내 원본은 안전하다. 대신 100장짜리면 100장을 복사해야 한다.
ref전달 = 원본이 있는 서랍 열쇠를 준다. 복사가 없다. 대신 동료가 원본에 낙서할 수도 있다.in전달 = "보기만 하세요"라는 조건으로 열쇠를 준다. 복사도 없고 원본도 안전해 보인다.
문제는 마지막이다. 열쇠를 받은 동료가 **"펼쳐서 계산해 봐야 하는데, 이 과정에서 내가 뭔가 적게 될지 나도 모르겠다"**고 하면? 조심성 있는 관리자(컴파일러)는 그냥 복사본을 떠서 건넨다. "보기만 하세요"라고 말한 의미가 사라진다.
이 사태를 막는 유일한 방법은 계약서 자체에 "이 문서는 필기 불가"라는 도장이 찍혀 있는 것이다. 그게 readonly struct다.
다이어그램 (Diagram)
코드 (Example)
// 64바이트 — Matrix4x4와 같은 크기
struct Big { public double A, B, C, D, E, F, G, H; public double Sum() => A + B + C + D; }
readonly struct BigRO { public readonly double A, B, C, D, E, F, G, H; public double Sum() => A + B + C + D; }
static double ByValue(Big b) => b.Sum(); // 호출 측에서 64B 복사
static double ByRef(ref Big b) => b.Sum(); // 복사 없음
static double ByIn(in Big b) => b.Sum(); // ← 복사 없을 것 같지만 있다
static double ByIn(in BigRO b) => b.Sum(); // 복사 없음
정의 (Plain → Technical)
쉬운 말로: in은 "주소를 넘기되 못 고치게 하겠다"는 뜻이지, "복사가 없다"는 보장이 아니다.
기술적으로:
ref— 인자로 **managed pointer(byref)**를 넘긴다. 호출된 쪽이 원본을 읽고 쓸 수 있다.in— 같은 byref를 넘기되 읽기 전용으로 제한한다. IL 시그니처는ref와 동일한T&이고, 구분은 특성으로 붙는다(§3-1 실측).readonly struct— 모든 필드가 readonly임을 타입에 새긴다. 컴파일러에게 "이 타입은 스스로 상태를 못 바꾼다"는 보장을 주고, 그 대가로 최적화 자유를 얻는다.- 방어적 복사(defensive copy) —
in으로 받은 값의 메서드/프로퍼티를 호출할 때, 그 호출이 원본을 바꿀지 알 수 없어 컴파일러가 삽입하는 임시 복사.
in의 도입 의도 자체는 문서에 명확히 적혀 있다. C# 12 in a Nutshell:
"This modifier is most useful when passing a large value type to the method because it allows the compiler to avoid the overhead of copying the argument prior to passing it in while still protecting the original value from modification."
핵심은 "large value type"과 "prior to passing it in"이다. 큰 값에 쓰라는 것이고, 없애주는 건 넘기기 전의 복사다. 넘긴 다음의 복사는 별개 문제다.
3. 내부 동작
측정 환경: .NET SDK 10.0.203 / net10.0 / Release / ilspycmd 10.1.1
3-1. 값 / ref / in — 복사는 어디서 일어나나
앞 글에서 "in을 붙여도 readonly struct가 아니면 복사가 남는다"는 결론까지는 봤다. 여기서는 그 복사가 호출 측과 수신 측 중 어디서 일어나는지를 갈라 본다. 같은 64바이트 Big에 같은 Sum() 호출이다.
// ByValue(Big b)
IL_0000: ldarga.s b // 값 파라미터의 "주소"를 뜬다
IL_0002: call instance float64 Big::Sum()
// ByRef(ref Big b)
IL_0000: ldarg.0 // 이미 주소다
IL_0001: call instance float64 Big::Sum()
// ByIn(in Big b)
IL_0000: ldarg.0
IL_0001: ldobj Big // ★ 방어적 복사 — 64바이트 통째로
IL_0006: stloc.0
IL_0007: ldloca.s 0
IL_0009: call instance float64 Big::Sum()
// ByIn(in BigRO b)
IL_0000: ldarg.0
IL_0001: call instance float64 BigRO::Sum() // ★ 복사 없음
여기서 함정에 빠지기 쉽다. 본문만 보면 ByValue가 ByIn보다 명령이 적다. 값 전달의 복사는 본문이 아니라 호출 측에서 일어나기 때문이다.
// 호출 측 (Main)
IL_0046: ldloc.0
IL_0047: call float64 Cases::ByValue(valuetype Big) // 64B 값을 push
IL_0051: ldloca.s 0
IL_0053: call float64 Cases::ByRef(valuetype Big&) // 주소만 push
IL_005d: ldloca.s 0
IL_005f: call float64 Cases::ByIn(valuetype Big&) // 주소만 push
양쪽을 합쳐야 전체가 보인다.
| 호출 측 | 수신 측 | 총 복사 | |
|---|---|---|---|
ByValue(Big) |
64B push | — | 1회 |
ByRef(ref Big) |
주소 | — | 0회 |
ByIn(in Big) |
주소 | ldobj 64B |
1회 |
ByIn(in BigRO) |
주소 | — | 0회 |
in만 붙이면 복사가 이사만 갈 뿐 총량은 그대로다. 오히려 간접 참조가 하나 늘었다. readonly struct가 함께 있어야 비로소 0이 된다.
in과 ref의 IL 시그니처가 둘 다 valuetype Big&로 같다는 것도 확인된다. 둘을 가르는 건 전달 방식이 아니라 메타데이터다.
.method public hidebysig static
float64 ByIn (
[in] valuetype Big& b // ← ref와 같은 Big&
) cil managed
{
.param [1]
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 ) // ← 이것이 in의 정체
in은 별도 전달 방식이 아니라 **읽기 전용 표시가 붙은 ref**다.
3-2. 방어적 복사가 생기는 정확한 조건
같은 in Big인데 본문에서 무엇을 하느냐로 갈린다. 다섯 가지를 나란히 컴파일했다.
| 본문 | IL | 복사 |
|---|---|---|
b.A + b.B (필드 직접) |
ldarg.0 → ldfld |
없음 |
b.Total (비readonly 프로퍼티) |
ldobj Big |
있음 |
b.Sum() (비readonly 메서드) |
ldobj Big |
있음 |
b.Sum() — readonly 멤버 |
ldarg.0 → call |
없음 |
b.BumpAndSum() (비readonly 멤버) |
ldobj BigMember |
있음 |
// 필드 직접 접근 — 복사 없음
IL_0000: ldarg.0
IL_0001: ldfld float64 Big::A
IL_0006: ldarg.0
IL_0007: ldfld float64 Big::B
IL_000d: ret
// C# 8 readonly 멤버 — 타입 전체가 readonly가 아닌데도 복사 없음
IL_0000: ldarg.0
IL_0001: call instance float64 BigMember::Sum()
IL_0006: ret
네 번째 행이 실무에서 가장 쓸모 있다. 타입 전체를 readonly struct로 바꾸지 못해도, 문제되는 메서드에만 readonly를 붙이면 복사가 사라진다.
public struct Bounds
{
public double A, B, C, D;
public readonly double Sum() => A + B + C + D; // C# 8 — this를 안 바꾼다고 선언
}
기존 코드베이스에 점진적으로 적용할 수 있는 현실적인 탈출구다. 덤으로 컴파일러가 this 변경을 막아주니 안전성도 함께 얻는다.
여기에 더해 주의할 조건이 하나 더 있다 — ToString()처럼 object에서 온 메서드도 override가 readonly가 아니면 복사를 만든다.
3-3. 작은 struct에 in을 붙이면 손해다
서론의 그 사례를 IL로 확인한다. Small은 int×2 = 8바이트다.
// SmallByValue(Small s)
IL_0000: ldarga.s s
IL_0002: call instance int32 Small::Sum()
// SmallByIn(in Small s)
IL_0000: ldarg.0
IL_0001: ldobj Small // ★ 8바이트를 굳이 복사
IL_0006: stloc.0
IL_0007: ldloca.s 0
IL_0009: call instance int32 Small::Sum()
// SmallROByIn(in SmallRO s)
IL_0000: ldarg.0
IL_0001: call instance int32 SmallRO::Sum() // 복사 없음
레지스터로 그냥 넘어갔을 8바이트를 주소로 바꿔 넘기고, 수신 측에서 다시 복사했다. in은 공짜 최적화가 아니다.
3-4. rvalue를 in에 넘기면 임시 변수가 생긴다
// ByIn(new Big { A = 1 })
IL_0000: ldloca.s 0
IL_0002: initobj Big // ★ 컴파일러가 만든 임시 지역변수
...
IL_0018: ldloca.s 0
IL_001a: call float64 Cases::ByIn(valuetype Big&)
in은 주소를 넘겨야 하는데 리터럴·계산 결과·메서드 반환값은 안정적인 저장 위치가 없다. 그래서 컴파일러가 임시 local을 만든다.
Process(in BuildState()); // 임시 생성 — 이득 없음
var state = GetCachedState();
Process(in state); // 이미 있는 lvalue를 빌려옴 — 이게 in의 자리
3-5. ref도 대입하면 복사한다
// CopyByRef(ref Big dst, ref Big src) : dst = src
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: ldobj Big // src에서 64B 읽기
IL_0007: stobj Big // dst에 64B 쓰기
ref가 없애는 건 "인자 전달 시 복사"지 "값 복사 그 자체"가 아니다.
3-6. ref return / ref local — 사본 함정의 정면 해법
// GetRef(int i) : ref _arr[i]
IL_0006: ldelema Big // 배열 요소의 주소를 반환
IL_000b: ret // 반환 타입: valuetype Big&
// ref Big slot = ref GetRef(0); slot.A = 42;
IL_0001: call valuetype Big& Cases::GetRef(int32)
IL_0006: ldc.r8 42
IL_000f: stfld float64 Big::A // ★ 배열 요소를 in-place 수정
앞 글에서 본 "컬렉션 안의 struct를 고쳐도 사본만 바뀐다"는 함정의 정면 해법이다.
3-7. 배열 순회 — ldelem vs ldelema
// Big b = arr[i];
IL_0010: ldelem Big // ★ 반복마다 64바이트 복사
IL_0015: stloc.2
// ref Big b = ref arr[i];
IL_0010: ldelema Big // ★ 주소만
IL_0015: stloc.2
원소 하나가 64바이트인 배열을 100만 번 순회하면 값 방식은 64MB의 추가 memcpy를 하는 셈이다. 다만 이건 IL 기준의 산술이고, JIT이 이 복사를 제거할 수 있는 경우도 있다(§5의 인라이닝 항목).
3-8. Span<T>의 인덱서는 byref를 돌려준다
// Span<int> buf = stackalloc int[8];
IL_0006: newobj instance void valuetype [System.Runtime]System.Span`1<int32>::.ctor(void*, int32)
IL_0013: call instance !0& valuetype [System.Runtime]System.Span`1<int32>::get_Item(int32)
// 대조군: List<Big>
IL_0002: callvirt instance !0 List`1<Big>::get_Item(int32) // !0 = 값 반환
IL_0007: stloc.0
Span<T>의 newobj는 값 타입 생성자 호출이지 힙 할당이 아니다(void*를 받는다 — stackalloc 포인터). 그리고 인덱서 반환 타입이 !0&, 즉 byref다. List<T>의 !0(값)과 나란히 놓으면 차이가 명확하다 — Span에는 사본 함정이 구조적으로 없다.
IL 명령 요약
| IL | 의미 | 비용 |
|---|---|---|
ldarga |
값 파라미터의 주소 | — (복사는 호출 측에서 끝남) |
ldobj T |
byref에서 값 전체 읽기 | 크기만큼 복사 |
stobj T |
byref에 값 전체 쓰기 | 크기만큼 복사 |
initobj T |
값 0초기화 | 힙 할당 아님 |
ldelem T |
배열 요소를 값으로 | 크기만큼 복사 |
ldelema T |
배열 요소의 주소 | 없음 |
!0& 반환 |
byref 반환 | 없음 |
cpobj는 이번 실측에서 나오지 않았다. Roslyn이 ldobj+stobj 조합을 쓰기 때문이며 의미는 같다.
4. 실전 적용
4-1. ref의 정체 — managed pointer
ref가 넘기는 것은 일반 포인터가 아니다. Pro .NET Memory Management는 이렇게 구분한다.
object reference는 항상 객체의 MethodTable 필드를 가리킨다(객체의 시작점). CLR에는 또 다른 포인터 타입 — managed pointer가 있다.
| object reference | managed pointer (byref) | |
|---|---|---|
| 가리키는 곳 | 객체 시작 | 객체 중간(필드), 배열 요소, 스택 위치 |
| 별칭 | — | interior pointer |
| GC | 객체 단위 추적 | interior pointer를 역추적해 소속 객체를 찾고, compaction 시 주소를 새 위치로 갱신 |
GC가 힙을 압축해 객체를 옮겨도 byref가 깨지지 않는 이유가 여기 있다. 런타임이 관리하는 포인터이기 때문이다.
여기서 제약도 나온다. byref는 힙에 저장할 수 없다 — 클래스 필드가 될 수 없고 배열 요소가 될 수 없다. 스택 주소를 가리킬 수 있는 값이 힙에 살아남으면 dangling pointer가 되기 때문이다.
4-2. ref struct — 그 제약을 타입 수준으로
ref struct는 위 제약을 타입 전체로 확장한 것이다. C# 12 in a Nutshell:
"Ref structs were introduced mainly for the benefit of the
Span<T>andReadOnlySpan<T>structs. BecauseSpan<T>… can exist only on the stack, it's possible for them to safely wrap stack-allocated memory."
"Ref structs cannot partake in any C# feature that directly or indirectly introduces the possibility of existing on the heap. This includes … lambda expressions, iterators, and asynchronous functions … Also, ref structs cannot appear inside non-ref structs, and they cannot implement interfaces (because this could result in boxing)."
제약 목록이 답답해 보이지만, 전부 "힙에 갈 가능성"을 막는 같은 규칙에서 나온다.
4-3. 값 전달의 실제 경계 — ABI
몇 바이트부터 비싸지나? ABI마다 다르다.
| struct 크기 | Windows x64 | SysV AMD64 (Linux/macOS) |
|---|---|---|
| 1·2·4·8B | 레지스터 또는 스택 슬롯 | 레지스터 |
| 9~16B | 호출자가 임시 메모리를 만들고 포인터 전달 | 최대 2개 레지스터 |
| 16B 초과 | 포인터 전달 | 메모리 전달(숨은 포인터) |
여기서 흔한 오해를 하나 짚어야 한다. "16바이트 struct는 레지스터 2개로 전달된다"는 설명은 SysV 쪽 이야기다. Windows x64에서는 8바이트를 넘는 값 타입 인자부터 간접 전달을 경계하는 편이 안전하다.
SysV에서도 "16B 이하 = 무조건 레지스터"는 과장이다. SysV는 필드 타입에 따라 INTEGER/SSE/MEMORY로 분류하므로, float×4짜리 16B struct는 SIMD 레지스터 경로를 탈 수 있다.
그리고 반환값에도 대칭적인 비용이 있다. 큰 struct를 반환하면 호출자가 버퍼를 잡고 그 주소를 숨은 인자로 넘기는 방식(hidden return buffer, sret)이 쓰인다.
단, CoreCLR의 managed-to-managed 호출은 JIT 내부 규칙을 따르므로 네이티브 ABI와 다를 수 있고, 인라이닝되면 이 경계 자체가 사라진다. 아래 §5의 측정 주의사항을 함께 보라.
4-4. Unity
Vector3에in을 붙이지 마라. 12바이트로 작고,UnityEngine.Vector3는readonly struct가 아니다 — §3-2와 §3-3의 함정을 동시에 밟는다Matrix4x4(64B)는 크기상in/ref가 의미 있지만 역시readonly struct가 아니다. 필드 직접 접근(§3-2 첫 행)이나ref를 쓴다- 내가 만드는 값 타입은 처음부터
readonly struct로. 나중에 붙이는 것보다 훨씬 싸다
4-5. 게임서버
// wire format — 불변 값. readonly struct
public readonly struct PacketHeader
{
public readonly int Length;
public readonly ushort OpCode;
public PacketHeader(int length, ushort opCode) { Length = length; OpCode = opCode; }
}
// runtime state — 가변·공유. class
public sealed class PlayerSession { public long LastSeq; /* ... */ }
- 대용량 배열 순회는
reflocal (§3-7의ldelema) - 패킷 파싱은
ReadOnlySpan<byte>우선. 슬라이싱이 무할당인 이유는Span이 (포인터, 길이) 짝을 든ref struct뷰라서 잘라도 새 버퍼가 안 생기기 때문이다. 여기에 §3-8의 byref 인덱서가 더해져 요소 접근에도 사본이 없다 CollectionsMarshal.AsSpan,GetValueRefOrNullRef로 컬렉션 in-place 갱신
5. 함정과 주의사항
| 함정 | 근거 | 대응 |
|---|---|---|
in만 붙이면 빨라진다 |
§3-1 ldobj |
readonly struct 또는 readonly 멤버와 짝으로 |
작은 struct에 in |
§3-3, 8B에서 복사 발생 | 작은 값은 그냥 값 전달. 경계는 플랫폼마다 다르다(§4-3) — Windows x64는 8B, SysV는 16B가 대략의 분기점 |
Process(in Build()) |
§3-4 임시 변수 | 이미 있는 lvalue에만 in |
ref니까 복사 없다 |
§3-5 ldobj+stobj |
대입 자체가 복사 |
| 프로퍼티 하나가 복사를 부른다 | §3-2 | getter에 readonly |
ToString() 같은 override |
override가 비readonly면 복사 | public override readonly string ToString() |
측정할 때 반드시 지킬 것
in/ref의 효과는 측정 조건에 극도로 민감하다.
- Debug 빌드를 믿지 말 것 — 최적화가 꺼져 있어 결과가 무의미하다
- Tiered Compilation — Tier 0에서는
readonly struct이득이 안 나온다. Tier 1 승격 후를 봐야 한다 - 인라이닝이 결과를 뒤집는다 — 인라이닝되면 호출 경계가 사라져 전달 방식 차이 자체가 무의미해진다
- 결과값이 제거되지 않게 — dead code elimination 방지
- Unity는 따로 측정 — IL2CPP는 C++ 컴파일러 최적화에 의존하므로 CoreCLR 결과가 그대로 이전되지 않는다
보편적인 임계값은 없다. 알려진 경향은 이렇다.
in이 이득: 큰 struct(수십~수백 바이트) +readonly struct+ 호출 경계를 자주 넘음 + 인라이닝 안 됨in이 손해: 작은 struct + 비readonly라 방어적 복사 + 인라이닝되면 원래 복사도 없었음 + rvalue 전달
6. 버전별 변화
| 기능 | C# | 효과 |
|---|---|---|
ref return / ref local |
7.0 | 배열·컬렉션 요소 in-place 갱신 (§3-6) |
readonly struct |
7.2 | 모든 필드 readonly 강제 → 방어적 복사 제거 |
in 매개변수 |
7.2 | 읽기 전용 byref 전달 |
ref readonly |
7.2 | 읽기 전용 참조 반환 |
ref struct |
7.2 | 스택 전용 타입 (Span<T>의 기반) |
| readonly 인스턴스 멤버 | 8.0 | 멤버 단위 부분 적용 — 기존 코드의 현실적 탈출구 |
record struct |
10 | 값 기반 equality 상용구 제거 |
ref field / scoped |
11 | ref struct 안에 byref 필드, 수명 규칙 명시 |
C# 7.2가 readonly struct·in·ref struct를 함께 낸 것은 우연이 아니다. 셋은 하나의 설계다 — 값 타입을 복사 없이 안전하게 다루기 위한 세트다. in만 떼어 쓰면 반쪽만 가져오는 셈이다.
7. 정리
in은 넘기기 전의 복사를 없앤다. 넘긴 다음 메서드나 프로퍼티를 호출하면 컴파일러가 **방어적 복사(ldobj)**를 되살린다. 복사가 이사만 갈 뿐 총량은 그대로다.- 그 복사를 실제로 없애는 건
readonly struct, 또는 C# 8의 **멤버 단위readonly**다. 후자는 타입 전체를 못 바꾸는 기존 코드에 쓸 수 있는 탈출구다. in은 공짜가 아니다. 8바이트 struct에 붙였더니 원래 없던 복사가 IL에 생겼다. 작은 값은 그냥 값으로 넘겨라.ref는 managed pointer(byref)를 넘긴다. GC가 compaction 때 주소를 갱신해주는 관리 포인터이며, 그래서 힙에 저장할 수 없다.ref struct는 이 제약을 타입 수준으로 확장한 것이고Span<T>가 그 위에 선다.- 값 전달의 실제 경계는 ABI가 정한다. Windows x64와 SysV가 다르고, 인라이닝되면 경계 자체가 사라진다. 그래서 크기 숫자 하나로 판단하지 말고 반드시 Release + Tier 1에서 측정하라.
8. Unity 실전 가이드펼치기
여기부터(§8·§9)는 사람이 읽는 해설이 아니라 AI 에이전트가 코드를 작성할 때 참조하는 실전 규칙이다. 앞의 7섹션과 문체·목적이 다르다.
1-2가 "값 타입으로 만들 것인가"였다면, 이 절은 이미 struct인 것을 전달·순회·수정하는 방법의 규칙이다.
근거는 IL 실측(ldobj / stobj / ldelem vs ldelema / [in] T& + IsReadOnlyAttribute / !0& 반환)이다.
0. 이 주제에서 반드시 기억할 4가지
in은 복사를 없애는 게 아니라 옮긴다. 값 전달의 복사는 호출 측에서,in의 방어적 복사는 수신 측(ldobj)에서 난다.readonly struct가 함께 있어야 총 복사가 0이 된다. — 그리고 Unity 내장 수학 타입은 하나도readonly struct가 아니다.- 작은 struct에
in을 붙이면 없던 복사가 생긴다. 8바이트 struct 실측에서in이ldobj를 만들었다.Vector2(8B)·Vector3(12B)·Quaternion(16B)에in을 붙이는 것은 순손해다. - 방어적 복사는 "무엇을 호출하느냐"로 갈린다. 필드 직접 접근·
readonly멤버 → 복사 없음. 비readonly 메서드/프로퍼티/ToString()override →ldobj.m.inverse,v.normalized,q.eulerAngles가 전부 여기 해당. - IL2CPP는 이 복사를 지워주지 않는다. IL의
ldobj는 생성된 C++에서 구조체 대입으로 남고, 제거 여부는 clang/MSVC 손에 달렸다. → IL 단계에서 없애야 한다(=readonly struct/readonly멤버 /ref/ 필드 직접 접근).
1. Unity 타입별 전달 방식 결정표
| 타입 | 크기 | readonly struct? |
권장 전달 | 금지 |
|---|---|---|---|---|
Vector2 |
8B | ❌ | 값 | in (없던 ldobj 생성) |
Vector3 / Vector3Int |
12B | ❌ | 값 | in |
Vector4 / Quaternion / Color |
16B | ❌ | 값 | in |
Bounds / Ray |
24B | ❌ | 값(핫 루프면 ref + 필드 접근) |
in + 메서드 호출 |
RaycastHit |
40B대 | ❌ | out 반환 / 배열 재사용 |
값으로 여러 단계 릴레이 |
Matrix4x4 |
64B | ❌ | ref 또는 필드(m00…) 직접 접근 |
in + .inverse/.determinant |
Unity.Mathematics.float3 |
12B | ❌ | 값 (Burst가 SIMD 레지스터로 처리) | in/ref (Burst 벡터화 방해) |
Unity.Mathematics.float4x4 |
64B | ❌ | Burst 내부는 값, 매니지드 경계는 ref |
— |
RenderParams 등 Unity API가 in을 요구하는 타입 |
큼 | ❌ | 필드로 캐싱한 lvalue를 넘김 | 호출부에서 인라인 생성(임시 지역변수) |
| 내가 만드는 값 타입 | — | ✅ 처음부터 readonly struct | 큰 건 in, 작은 건 값 |
나중에 붙이기 |
판단 규칙 한 줄:
in은 "16바이트 초과" AND "readonly struct" 둘 다 참일 때만 쓴다. Unity 내장 타입은 두 번째 조건이 항상 거짓이다.
2. DON'T / DO
2-1. Unity 수학 타입에 in을 붙이기
// ❌ DON'T — 세 줄 모두 in이 손해다
static float Dist(in Vector3 a, in Vector3 b) // 12B: 값이 더 싸다. 게다가
=> (a - b).magnitude; // magnitude는 프로퍼티 → 각 인자 ldobj
static float Det(in Matrix4x4 m) => m.determinant; // 64B ldobj 1회 (프로퍼티)
static Vector3 Fwd(in Quaternion q) => q * Vector3.forward; // 16B에 in — 의미 없음
// ✅ DO (a) — 16B 이하는 그냥 값. 그리고 프로퍼티 대신 필드로 접근한다.
static float DistSqr(Vector3 a, Vector3 b)
{
float dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z; // ldfld만, 복사 없음
return dx * dx + dy * dy + dz * dz;
}
// ✅ DO (b) — Matrix4x4(64B)는 ref. in이 아니다.
static void TransformPoints(ref Matrix4x4 m, Vector3[] src, Vector3[] dst)
{
for (int i = 0; i < src.Length; i++)
{
Vector3 p = src[i];
dst[i] = new Vector3( // 필드 직접 접근 → ldobj 없음
m.m00 * p.x + m.m01 * p.y + m.m02 * p.z + m.m03,
m.m10 * p.x + m.m11 * p.y + m.m12 * p.z + m.m13,
m.m20 * p.x + m.m21 * p.y + m.m22 * p.z + m.m23);
}
}
ref로 받으면 호출자가 값을 바꿀 수 있다는 게 걸릴 수 있다. 하지만 in으로 바꾸는 순간 ldobj가 돌아온다.
Unity 타입에 대해서는 "불변성은 코드 규약으로, 성능은 ref로" 가 현실적인 타협이다.
2-2. 내가 만든 struct를 readonly 없이 만들고 in으로 덮기
// ❌ DON'T — in을 붙였지만 호출마다 방어적 복사
public struct AgentSnapshot // 4×12 + 8 = 56B
{
public Vector3 Pos, Vel, Target;
public float Health, Stamina;
public float SpeedSqr() => Vel.sqrMagnitude; // 비readonly 메서드
public bool IsAlive => Health > 0f; // 비readonly 프로퍼티
}
static bool Chase(in AgentSnapshot a) => a.IsAlive && a.SpeedSqr() < 1f; // ldobj ×2 (56B ×2)
// ✅ DO (a) — 처음부터 readonly struct. in이 실제로 0복사가 된다.
public readonly struct AgentSnapshot
{
public readonly Vector3 Pos, Vel, Target;
public readonly float Health, Stamina;
public AgentSnapshot(Vector3 pos, Vector3 vel, Vector3 target, float hp, float sp)
{ Pos = pos; Vel = vel; Target = target; Health = hp; Stamina = sp; }
public float SpeedSqr() => Vel.sqrMagnitude;
public bool IsAlive => Health > 0f;
}
static bool Chase(in AgentSnapshot a) => a.IsAlive && a.SpeedSqr() < 1f; // 복사 0
// ✅ DO (b) — 가변이어야만 하는 타입(인스펙터 편집·Job 쓰기 대상)은
// C# 8 "readonly 멤버"로 부분 적용한다. Unity 2020.2+ 필요.
[System.Serializable]
public struct SpawnArea
{
public Vector3 Center; // 인스펙터에서 편집해야 하므로 가변 유지
public Vector3 Extents;
public readonly bool Contains(Vector3 p) // ← this를 안 바꾼다고 선언 → ldobj 제거
=> Mathf.Abs(p.x - Center.x) <= Extents.x
&& Mathf.Abs(p.y - Center.y) <= Extents.y
&& Mathf.Abs(p.z - Center.z) <= Extents.z;
public readonly Vector3 Min => Center - Extents; // 프로퍼티에도 readonly
public override readonly string ToString() => $"{Center}±{Extents}"; // override도 잊지 말 것
}
규칙: in으로 받을 타입의 모든 읽기 전용 멤버에는 readonly를 붙인다. ToString()/Equals()/GetHashCode() override 포함.
하나라도 빠지면 그 호출 지점에서만 조용히 전체 복사가 부활한다.
2-3. in 인자에 rvalue를 넘기기
in은 주소가 필요한데 리터럴·계산 결과·프로퍼티 반환값은 저장 위치가 없다 → 컴파일러가 임시 지역변수를 만든다(= 복사).
Unity의 프로퍼티는 전부 rvalue다 (transform.position, renderer.bounds, Matrix4x4.TRS(...)).
// ❌ DON'T — in인데 호출할 때마다 임시 지역변수 생성. 이득 0.
Render(in Matrix4x4.TRS(pos, rot, scale));
Cull(in _camera.worldToCameraMatrix); // 프로퍼티 반환값 = rvalue
Graphics.RenderMesh(new RenderParams(_mat) { layer = 0 }, _mesh, 0, m); // 매 호출 새 struct
// ✅ DO — lvalue를 필드/로컬로 잡아두고 그것을 넘긴다
Matrix4x4 view = _camera.worldToCameraMatrix; // 네이티브 get 1회 + lvalue 확보
Cull(ref view);
RenderParams _rp; // 필드로 캐싱 (Unity API가 in을 요구하는 케이스)
void Awake() { _rp = new RenderParams(_mat) { layer = 0, receiveShadows = false }; }
void Update() => Graphics.RenderMesh(in _rp, _mesh, 0, _matrix);
2-4. 큰 struct 배열을 값으로 순회하기 (ldelem)
IL 근거: Big b = arr[i] → ldelem(요소 크기만큼 복사) / ref Big b = ref arr[i] → ldelema(주소만).
// ❌ DON'T — 반복마다 64B 복사, 그리고 수정은 사본에만 적용된다
Matrix4x4[] _matrices = new Matrix4x4[1023];
foreach (var m in _matrices) { /* 64B × 1023 = 65KB memcpy / 프레임 */ }
for (int i = 0; i < _matrices.Length; i++)
{
Matrix4x4 m = _matrices[i]; // ldelem 64B
m.m03 += offset; // 사본만 바뀜
_matrices[i] = m; // 다시 64B 쓰기 → 왕복 128B
}
// ✅ DO (a) — ref local. 배열 요소를 in-place로 고친다 (C# 7.0, Unity 2018.3+)
for (int i = 0; i < _matrices.Length; i++)
{
ref Matrix4x4 m = ref _matrices[i]; // ldelema — 복사 0
m.m03 += offset; // 배열 원본을 직접 수정
}
// ✅ DO (b) — foreach도 ref로 (Span/배열)
Span<Matrix4x4> span = _matrices;
foreach (ref Matrix4x4 m in span) m.m13 += offset;
// ✅ DO (c) — 읽기 전용 순회는 ref readonly
foreach (ref readonly Matrix4x4 m in (ReadOnlySpan<Matrix4x4>)_matrices) Submit(in m);
규칙: 요소가 24B를 넘는 struct 배열은 foreach (var x in ...) 금지. ref local 또는 Span<T>.
2-5. NativeArray<T> 요소를 인덱서로 고치기
Unity 고유 함정: NativeArray<T>의 인덱서는 값을 반환한다(get_Item이 T, byref 아님).
Span<T>의 !0& 반환과 다르다. 그래서 요소가 큰 struct면 읽기 1회 + 쓰기 1회로 매번 두 번 복사된다.
그리고 arr[i].Pos.x = 1f; 는 아예 컴파일되지 않는다(CS1612 — 인덱서는 프로퍼티).
// ❌ DON'T
NativeArray<AgentState> agents; // AgentState가 48B라 치면
for (int i = 0; i < agents.Length; i++)
{
// agents[i].Pos += v; // CS1612
var a = agents[i]; // 48B 복사
a.Pos += v * dt;
agents[i] = a; // 48B 복사 — 왕복 96B × N
}
// ✅ DO (a) — Collections 패키지의 ElementAt은 ref를 반환한다.
// 단 이 확장의 제공 여부는 com.unity.collections 버전에 따라 다르다.
// 프로젝트에서 먼저 확인하고, 없으면 아래 (b)/(c)를 쓴다.
for (int i = 0; i < agents.Length; i++)
{
ref AgentState a = ref agents.ElementAt(i); // byref — 복사 0
a.Pos += v * dt;
}
// ✅ DO (b) — AsSpan()으로 받아 ref 인덱싱 (Unity 2022.2+)
Span<AgentState> s = agents.AsSpan();
for (int i = 0; i < s.Length; i++) s[i].Pos += v * dt; // Span 인덱서는 !0& 반환 → 합법
// ✅ DO (c) — 위 API를 못 쓰는 버전이면 unsafe 포인터
unsafe {
var p = (AgentState*)agents.GetUnsafePtr();
for (int i = 0; i < agents.Length; i++) p[i].Pos += v * dt;
}
Job 안에서도 동일하다.
IJobParallelFor.Execute(int i)에서array[i]읽기-수정-쓰기는 요소 크기의 2배 트래픽이다. 다만[NativeDisableParallelForRestriction]없이ElementAt/AsSpan으로 쓰기를 하려면 안전 시스템 규칙(자기 인덱스만 쓰기)을 지켜야 한다.
2-6. ref니까 복사가 없다고 믿기
IL 근거: dst = src (둘 다 ref) → ldobj + stobj. ref가 없애는 건 인자 전달 복사지 값 대입 복사가 아니다.
// ❌ DON'T — ref를 썼지만 대입이 64B 복사 그 자체다
static void Blend(ref Matrix4x4 dst, ref Matrix4x4 a, ref Matrix4x4 b, float t)
{
dst = t < 0.5f ? a : b; // ldobj + stobj = 64B memcpy
}
// ✅ DO — 필요한 필드만 쓰거나, 아예 인덱스/핸들을 바꾼다
static void Blend(ref Matrix4x4 dst, ref Matrix4x4 a, ref Matrix4x4 b, float t)
{
ref Matrix4x4 src = ref (t < 0.5f ? ref a : ref b); // ref 삼항 (C# 7.2) — 복사 없이 별칭만 선택
dst.m03 = src.m03; dst.m13 = src.m13; dst.m23 = src.m23;
}
2-7. Unity 타입에 readonly를 못 붙이는 문제 → 확장 메서드로 우회
UnityEngine.Matrix4x4에는 readonly 멤버를 추가할 수 없다. 대신 this ref / this in 확장 메서드로
"필드 직접 접근" 경로를 만들어 두면, 호출부에서 방어적 복사 없이 쓸 수 있다.
public static class MatrixExt
{
// ref 확장 — 수신자가 lvalue일 때만 호출 가능. 복사 0.
public static Vector3 GetPosition(this ref Matrix4x4 m) => new Vector3(m.m03, m.m13, m.m23);
// in 확장 — rvalue도 받지만 임시 지역변수가 생긴다. 필드만 읽으면 그 이상 복사는 없다.
public static float TraceOf(this in Matrix4x4 m) => m.m00 + m.m11 + m.m22 + m.m33;
}
// 사용
Matrix4x4[] mats = ...;
Vector3 p = mats[3].GetPosition(); // ldelema → ref 확장. 64B 복사 없음
// transform.localToWorldMatrix.GetPosition(); // 컴파일 에러(rvalue) — 의도적으로 실수를 막아준다
Matrix4x4 l2w = transform.localToWorldMatrix; // 한 번 lvalue로 받고
Vector3 q = l2w.GetPosition();
Unity 2021.2+ 에는
Matrix4x4.GetPosition()이 내장되어 있다. 위 패턴은 그 외 타입/커스텀 연산에 적용한다.
2-8. ref struct(Span)를 Unity의 비동기·이터레이터 문맥에 넣기
Unity 고유 함정: **코루틴은 이터레이터(IEnumerator)**이고, 이터레이터는 상태를 힙 클래스로 승격시킨다.
ref struct는 힙에 존재할 가능성이 있는 모든 문법에 들어갈 수 없다 → 코루틴·async·람다·UnityEvent 콜백에서 Span<T> 사용 불가.
// ❌ DON'T — 전부 컴파일 에러
IEnumerator Stream(byte[] data)
{
Span<byte> s = data; // CS4013: 이터레이터 안에서 ref struct 불가
yield return null;
}
async Task Parse(byte[] d) { ReadOnlySpan<byte> s = d; await Task.Yield(); } // 불가
class Foo : MonoBehaviour { Span<byte> _buf; } // 필드 불가
Action a = () => { Span<int> s = stackalloc int[4]; }; // 캡처 시 불가
// ✅ DO — Span은 동기 메서드 내부로 가둔다. 경계에서는 배열/NativeArray로 넘긴다.
IEnumerator Stream(byte[] data)
{
int n = ParseHeader(data); // Span 사용은 이 안에서 끝난다
yield return null;
}
static int ParseHeader(byte[] data)
{
ReadOnlySpan<byte> s = data; // 동기 메서드 — 합법
return System.BitConverter.ToInt32(s.Slice(0, 4));
}
// ✅ DO — 코루틴/필드에 들고 있어야 하면 NativeArray(구조체지만 ref struct가 아님)
NativeArray<byte> _persistent; // 필드 가능, 코루틴 통과 가능. 단 Dispose 책임은 내 몫
2-9. stackalloc을 무제한으로 쓰기
stackalloc + Span<T>은 프레임당 임시 배열 할당을 GC 0으로 바꾸는 최고의 수단이지만, 모바일 스택은 작다
(iOS 메인 스레드 1MB, 보조 스레드는 훨씬 작고, Job 워커 스레드도 제한적). 넘치면 예외가 아니라 스택 오버플로 크래시다.
// ❌ DON'T — 길이가 런타임 입력에 따라 결정됨. 언젠가 반드시 터진다.
Span<Vector3> tmp = stackalloc Vector3[hitCount]; // hitCount가 10,000이면 120KB
// ✅ DO — 상한을 고정하고 초과분은 풀링된 배열로 폴백
const int StackLimit = 128; // Vector3 128개 = 1.5KB
void Process(int hitCount)
{
Span<Vector3> stack = stackalloc Vector3[StackLimit];
Vector3[] rented = null;
Span<Vector3> tmp;
if (hitCount <= StackLimit) tmp = stack.Slice(0, hitCount);
else { rented = System.Buffers.ArrayPool<Vector3>.Shared.Rent(hitCount);
tmp = rented.AsSpan(0, hitCount); }
try { /* tmp 사용 — 어느 쪽이든 GC 할당 0 또는 풀 재사용 */ }
finally { if (rented != null) System.Buffers.ArrayPool<Vector3>.Shared.Return(rented); }
}
가이드: 프레임 핫 패스의 stackalloc은 1~4KB 이내 고정 크기로. 코루틴/Job 안에서는 특히 보수적으로.
3. 모바일 성능 함정
3-1. GC가 아니라 메모리 대역폭이 주범이다
이 주제의 복사는 힙 할당이 아니다(방어적 복사는 스택 임시). 그래서 Profiler의 GC Alloc이 0인데도 프레임이 느린 전형적 케이스다.
모바일 LPDDR은 데스크톱 대비 대역폭이 좁아 memcpy 비용이 그대로 프레임 타임에 나타난다.
Matrix4x4[1023] 인스턴싱 배치 × 10배치 × 60fps
값 순회(ldelem) : 64B × 1023 × 10 × 60 ≈ 39 MB/s
읽기-수정-쓰기(사본 왕복) : 그 2배 ≈ 79 MB/s
ref local(ldelema) : 0 B/s
in을 붙였는데 readonly가 아니라 ldobj가 남아 있으면, 값 전달과 트래픽이 같으면서 간접 참조만 하나 더 늘어난 상태다.
측정 지표: GC Alloc이 아니라 Profiler의 해당 메서드 self time / Frame Debugger 배치 준비 시간을 본다.
3-2. 배터리 — 매 프레임 반복되는 방어적 복사
in Matrix4x4 + .inverse 호출이 엔티티 1,000개 루프에 있으면 프레임당 64KB의 불필요한 복사가 생기고,
CPU가 그만큼 더 높은 클럭에 머문다. 발열 → 서멀 스로틀링 → 프레임 드랍의 순서로 나타난다.
.inverse 같은 프로퍼티는 매 접근마다 재계산되기도 하므로, 복사와 계산을 함께 캐싱한다.
// ❌ DON'T
void LateUpdate() {
for (int i = 0; i < _n; i++)
_out[i] = _world.inverse.MultiplyPoint3x4(_pts[i]); // 프로퍼티 재계산 + 복사 × N
}
// ✅ DO
void LateUpdate() {
Matrix4x4 inv = _world.inverse; // 1회 계산, lvalue 확보
for (int i = 0; i < _n; i++)
_out[i] = inv.MultiplyPoint3x4(_pts[i]);
}
3-3. 드로우콜 — 인스턴싱 버퍼는 ref로 채운다
Graphics.DrawMeshInstanced / RenderMeshInstanced는 Matrix4x4[](배치당 최대 1023)를 그대로 GPU 버퍼로 넘긴다.
버퍼를 채우는 루프가 값 복사면 드로우콜을 줄여 얻은 이득을 CPU에서 되돌려준다.
readonly Matrix4x4[] _batch = new Matrix4x4[1023];
// ✅ DO — 요소 주소에 직접 기록. 임시 Matrix4x4 로컬을 만들어 대입(stobj)하지 않는다.
void FillBatch()
{
for (int i = 0; i < _count; i++)
{
ref Matrix4x4 m = ref _batch[i];
m = Matrix4x4.TRS(_pos[i], _rot[i], _scale[i]); // 단 1회 stobj (불가피)
}
Graphics.DrawMeshInstanced(_mesh, 0, _mat, _batch, _count);
}
MaterialPropertyBlock.SetMatrixArray / SetVectorArray도 배열을 요구한다 — List<T>로 들고 있다가 ToArray()하면
프레임마다 64KB 힙 할당이 된다. 배열을 재사용하고 ref로 갱신하는 것이 유일한 무할당 경로다.
3-4. Burst/Job — in·ref의 의미가 달라진다
- Job struct 자체는
Schedule()시 값으로 복사된다. Job에 큰 struct 필드를 여러 개 두면 스케줄마다 그만큼 복사된다. → 데이터는NativeArray핸들(포인터+길이, 작음)로 들고, 값 본체는 Job 밖에 둔다. [ReadOnly]속성은in/readonly struct와 무관하다. 그것은 Job 안전 시스템의 의존성 스케줄링 힌트일 뿐, 방어적 복사를 없애주지 않는다. 두 개념을 섞지 말 것.- Burst 컴파일된 함수에서
float3/float4는 SIMD 레지스터에 올라간다. 여기에in/ref를 붙이면 메모리 경유를 강제해 벡터화를 방해할 수 있다 → Burst 코드의 작은 수학 타입은 값 전달이 정답. - 큰 struct(
float4x4이상)를 Burst 함수 경계로 자주 넘긴다면ref, 또는 애초에NativeArray인덱스로 넘긴다.
4. 플랫폼별 차이 — IL2CPP vs Mono
| 항목 | Mono (에디터 JIT) | IL2CPP (디바이스 AOT) | 코드 작성 시 조치 |
|---|---|---|---|
ldobj 방어적 복사 |
JIT가 일부 제거 가능 | C++ 구조체 대입으로 그대로 방출 — 제거는 clang/MSVC 최적화 운 | IL 단계에서 없앤다: readonly struct / readonly 멤버 / 필드 접근 / ref |
| 인라이닝 | Unity Mono는 구형 JIT — 인라인 공격성이 낮음 | C++ 컴파일러가 함수 단위 인라인, LTO 여부에 좌우 | 전달 방식 차이가 인라인으로 사라질 거라 가정하지 않는다 |
| 값 파라미터 전달 | JIT 내부 규칙(ABI 근사) | 생성 C++의 by-value 파라미터 → 호출마다 구조체 복사 | 64B 이상은 ref로 명시 |
ref/in 시그니처 |
T& |
C++ T* (또는 T&) |
동일하게 동작. in이 ref보다 빠를 이유는 없다 |
Span<T>/stackalloc |
지원 | 지원 (alloca 계열로 방출) |
스택 크기 제한이 플랫폼마다 다름 — §2-9 |
ref struct |
지원 | 지원 | 코루틴/async/필드 제약은 컴파일 타임이라 양쪽 동일 |
ref 반환 API의 AOT |
문제 없음 | 제네릭 ref 반환이 리플렉션으로만 인스턴스화되면 실패 가능 |
정적 호출 지점을 최소 1곳 두거나 [Preserve] |
P/Invoke의 ref/in |
마샬링 | 동일 | 네이티브 플러그인 인자는 blittable struct + ref가 가장 싸다 |
실무 규칙
- 에디터(Mono)에서 잰
invs 값 결과를 디바이스(IL2CPP)로 이식하지 않는다. 두 런타임의 복사 제거 능력이 다르다. - IL2CPP 빌드는
Il2CppSetOption/최적화 레벨(Master vs Release)에 따라 결과가 또 갈린다. 벤치마크는 최종 빌드 구성으로. - 확실한 것은 하나뿐이다: IL에
ldobj가 없으면 어떤 런타임에서도 그 복사는 없다. →readonly struct를 붙이는 것이 유일하게 플랫폼 독립적인 해법이다.
Unity C# 버전 가용성 (이 주제 기능들)
| 기능 | 필요 C# | Unity 최소 버전 | 비고 |
|---|---|---|---|
ref return / ref local |
7.0 | 2018.3+ | 배열 in-place 수정의 기본기 |
readonly struct, in, ref struct, ref readonly, ref 삼항 |
7.2/7.3 | 2018.3+ | 세트로 도입된 기능 |
stackalloc → Span<T> 대입 |
7.2 | 2018.3+ (System.Memory 접근 가능한 API 레벨) |
API Compatibility Level 확인 |
readonly 인스턴스 멤버 |
8.0 | 2020.2+ | 가변 struct의 현실적 탈출구 |
System.Span<T> / MemoryMarshal 정식 |
— | 2021.2+ (.NET Standard 2.1) | 그 이전은 프로파일 확인 |
record struct |
10 | ❌ 미지원 | Unity 6 기준 언어 버전은 C# 9. 상위 버전을 쓴다면 해당 릴리스의 C# language support 문서로 재확인 |
ref 필드 / scoped |
11 | ❌ 미지원 | 직접 만든 ref struct에 ref 필드를 둘 수 없다 → Span<T>나 unsafe 포인터로 대체 |
Unity 2020.2 미만 프로젝트에서는
readonly멤버를 쓸 수 없다. 이 경우 가변 struct에는in을 쓰지 말고 값 전달 + 필드 직접 접근, 또는ref로 간다. (in을 쓰면 무조건ldobj가 남는다.)
5. 코드 작성 전 체크리스트
in / ref를 쓰려고 할 때:
- [ ] 이 타입이 16바이트를 초과하는가? (아니면
in금지 — 없던 복사가 생긴다) - [ ] 이 타입이
readonly struct인가? 아니라면 호출할 모든 멤버에readonly가 붙어 있는가? → 둘 다 아니면in이 아니라ref또는 값 전달. - [ ]
UnityEngine.*/Unity.Mathematics.*타입인가? →readonly struct가 아니다.in쓰지 않는다. - [ ] 넘기는 인자가 lvalue인가? (
transform.position,.inverse,Matrix4x4.TRS(...)는 rvalue → 임시 생성) - [ ] 메서드 본문에서 프로퍼티/메서드 대신 필드로 접근할 수 있는가? (
m.m03vsm.GetColumn(3)) - [ ] Burst/Job 코드인가? → 작은 수학 타입은 값.
in으로 SIMD를 막고 있지 않은가?
새 값 타입을 선언할 때:
- [ ]
readonly struct로 시작했는가? (인스펙터 편집·Job 쓰기 대상이라 불가능한 경우에만 가변) - [ ] 가변으로 남겼다면 읽기 전용 멤버 전부에
readonly를 붙였는가?ToString()/Equals()/GetHashCode()override 포함 (Unity 2020.2+) - [ ] 프로퍼티 getter에도
readonly를 붙였는가? - [ ]
ref struct로 만들었다면 — 코루틴·async·람다·MonoBehaviour 필드에 들어갈 일이 정말 없는가?
기존 코드를 고칠 때 (즉시 반려 시그널):
in Vector3 / in Vector2 / in Quaternion / in Color // 작고 비readonly — 이중 손해
in Matrix4x4 m => m.inverse // 64B ldobj
in float3 / in float4 (Burst 내부) // SIMD 방해
foreach (var m in matrices) // 요소>24B면 ldelem 복사
var a = nativeArray[i]; a.X = 1; nativeArray[i] = a; // 왕복 복사 → ElementAt/AsSpan
Process(in SomeProperty) // rvalue → 임시 지역변수
public struct Big { public double Sum() => ... } // readonly 없는 멤버 (in 대상이면)
Span<byte> in IEnumerator / async / lambda // ref struct 제약 위반
stackalloc T[runtimeLength] // 상한 없는 스택 할당
리팩터 우선순위(효과 큰 순):
- 큰 struct 배열 순회를
reflocal로 (ldelem→ldelema) — 프레임당 수십 KB memcpy 제거 NativeArray읽기-수정-쓰기를ElementAt/AsSpan으로 — 트래픽 절반- 내 struct에
readonly멤버 부착 —in경로의ldobj제거 - Unity 타입의
in을 제거하거나ref로 교체 — 없던 복사 제거 - rvalue → lvalue 캐싱 — 임시 생성 + 프로퍼티 재계산 동시 제거
6. 근거 요약 (IL 실측 → Unity 해석)
| IL | 언제 나오나 | Unity에서의 의미 |
|---|---|---|
ldobj T |
in 파라미터에 비readonly 멤버 호출 |
in Matrix4x4 + .inverse = 호출마다 64B 복사. IL2CPP가 안 지워준다 |
ldobj 없음 (ldarg.0→call) |
readonly struct 또는 readonly 멤버 |
내가 만든 struct에서만 달성 가능. Unity 내장 타입은 불가 |
ldarga.s |
값 파라미터의 멤버 호출 | 값 전달은 본문에 복사 명령이 없다 — 복사는 호출 측에 있다 |
[in] T& + IsReadOnlyAttribute |
in 파라미터 |
in은 별도 전달이 아니라 읽기 전용 표시가 붙은 ref |
initobj + 임시 local |
rvalue를 in에 전달 |
Process(in transform.position) 류가 여기 해당 |
ldelem T |
arr[i]를 값으로 읽기 |
Matrix4x4[] 값 순회 = 요소당 64B |
ldelema T |
ref var x = ref arr[i] |
인스턴싱 버퍼 in-place 갱신의 근거 |
ldobj + stobj |
ref 변수끼리 대입 |
ref여도 대입 자체는 복사 |
!0& 반환 |
Span<T> 인덱서 |
사본 함정 없음. 반면 NativeArray<T> 인덱서는 값 반환 — 반드시 구분 |
9. 게임서버 실전 가이드펼치기
이 절은 AI 에이전트가 게임서버 C# 코드를 작성할 때 참조하는 규범이다.
1-2가 "값을 struct로 만들 것인가"를 다뤘다면, 이 문서는 "struct로 만들었다면 어떻게 넘길 것인가" 만 다룬다. 할당(GC)이 아니라 복사(memcpy)와 참조 수명(byref lifetime) 이 주제다.
서버에서 이 주제가 왜 따로 필요한가:
| 클라이언트 | 서버 | |
|---|---|---|
| 복사 비용이 드러나는 곳 | 프레임당 1회 로직 | 패킷당 × RPS × 세션 수로 곱해진다 |
| 지표 | 프레임 시간 | throughput 상한 + P99 tail |
| 참조 수명 위험 | 단일 스레드라 거의 없음 | byref/Span을 워커 스레드·await 경계로 넘기려는 압력이 상시 존재 |
못 박고 시작할 사실 하나 (result.md §3-1 IL 실측):
값 전달의 복사는 [호출 측]에서 일어난다.
in 의 방어적 복사는 [수신 측]에서 일어난다.
→ in 만 붙이면 복사는 이사만 갈 뿐 총량은 그대로다. 오히려 간접 참조가 하나 늘어난다.
복사가 0이 되는 건 in + readonly(타입 또는 멤버) 가 짝을 이룰 때뿐이다.
1. DON'T — in/ref가 서버에서 만드는 구체적 사고
DON'T 1. in을 "무조건 빠른 키워드"로 취급해 시그니처에 도배한다
// ❌ 나쁜 코드 — in을 붙였는데 복사는 그대로 남고 간접 참조만 늘었다
public struct PacketHeader // readonly 가 아니다
{
public int Length;
public ushort OpCode;
public uint Sequence;
public long TimestampTicks;
public long SessionSalt; // 총 32B
public bool IsValid() => Length > 0 && Length <= 8192; // 비readonly 메서드
public ushort Op => OpCode; // 비readonly 프로퍼티
}
public void Dispatch(in PacketHeader h)
{
if (!h.IsValid()) return; // ★ ldobj PacketHeader — 32B 방어적 복사
_table[h.Op].Invoke(); // ★ ldobj 또 1회 — 프로퍼티도 마찬가지
}
IL 근거: result.md §3-1 / §3-2.
in 파라미터에서 비readonly 메서드·프로퍼티·ToString()을 호출하면 매번 ldobj 가 박힌다.
값 전달이었으면 복사 1회로 끝났을 것이 여기서는 호출 지점 수만큼 복사된다.
패킷 1건 처리에 헤더 멤버를 5번 만지면 32B × 5 = 160B의 스택 memcpy가 요청마다 추가된다.
DON'T 2. 작은 ID/좌표 struct에 in을 붙인다
// ❌ 나쁜 코드 — 8B struct. in 때문에 원래 없던 복사가 생긴다
public struct EntityId { public int Realm; public int Index; }
public PlayerSession? Find(in EntityId id) // ★ 8B를 주소로 넘기고 수신 측에서 다시 복사
=> _sessions.TryGetValue(id, out var s) ? s : null;
IL 근거: result.md §3-3. 8바이트 Small에 in을 붙였더니 값 전달에는 없던 ldobj가 생겼다.
레지스터 하나로 끝났을 값을 주소로 바꿔 넘기고 수신 측에서 되읽는다.
서버에서 이게 특히 나쁜 이유: EntityId 같은 타입은 가장 자주 호출되는 시그니처(조회·라우팅·로깅)에 들어간다.
손해가 코드베이스 전체로 곱해진다.
DON'T 3. rvalue를 in으로 넘겨 임시 지역변수를 양산한다
// ❌ 나쁜 코드 — 넘길 대상이 lvalue가 아니다
Send(in BuildHeader(opCode, seq)); // 컴파일러가 임시 local 생성 + initobj
Broadcast(in _snapshotFactory.Create(tick)); // 마찬가지
IL 근거: result.md §3-4. in은 안정적인 주소가 필요한데 메서드 반환값에는 저장 위치가 없다.
컴파일러가 임시 local을 만들어 채운 뒤 그 주소를 넘긴다 — in으로 얻으려던 이득이 정확히 0이고 코드만 복잡해진다.
DON'T 4. ref로 받아놓고 대입해서 "복사 없음"이라 믿는다
// ❌ 나쁜 코드 — ref인데 128B memcpy가 두 번(읽기+쓰기) 일어난다
public struct WorldSnapshot { /* ≈128B */ }
void SwapBuffers(ref WorldSnapshot front, ref WorldSnapshot back)
{
WorldSnapshot tmp = front; // ldobj 128B
front = back; // ldobj + stobj 128B
back = tmp; // stobj 128B
}
IL 근거: result.md §3-5. ref가 없애는 것은 인자 전달 시의 복사지 값 복사 그 자체가 아니다.
틱마다 도는 스냅샷 더블버퍼링에서 이 패턴은 그대로 대역폭이 된다. 버퍼는 참조 타입 배열을 스왑하거나 인덱스만 토글하라.
DON'T 5. 대용량 엔티티 배열을 foreach (var e in ...) 로 틱 순회한다
// ❌ 나쁜 코드 — 반복마다 요소 전체를 복사하고, 수정은 사본에만 반영된다
struct EntityState { public Vector3 Pos, Vel; public long Hp; /* ≈64B */ }
EntityState[] _entities = new EntityState[50_000];
void Tick(float dt)
{
foreach (var e in _entities) // ★ ldelem EntityState — 64B × 50,000
{
e.Pos += e.Vel * dt; // ★ 사본만 바뀐다. 월드가 멈춘 것처럼 보인다
}
}
IL 근거: result.md §3-7. ldelem T는 요소를 값으로 읽는다.
50,000 엔티티 × 64B = 틱당 3.2MB memcpy, 20Hz면 64MB/s가 순수 낭비다.
그리고 더 나쁜 것은 버그가 조용하다는 점이다 — 예외도 경고도 없이 이동이 반영되지 않는다.
DON'T 6. ref struct(Span/커서)를 await 경계 너머로 끌고 가려 한다
서버 코드는 거의 전부 async다. 이 제약이 실제로 발목을 잡는 지점은 여기다.
// ❌ 컴파일 에러 — ref struct는 async 상태 머신 필드가 될 수 없다
public async Task HandleAsync(Socket sock)
{
Span<byte> buf = stackalloc byte[512];
int n = await sock.ReceiveAsync(buf); // CS4007 / CS8175
Parse(buf.Slice(0, n));
}
// ❌ 더 나쁜 "해결" — 에러가 사라졌다고 문제가 사라진 게 아니다
public async Task HandleAsync2(Socket sock)
{
byte[] buf = new byte[512]; // ★ 요청마다 힙 할당으로 도망침
int n = await sock.ReceiveAsync(buf);
Parse(buf.AsSpan(0, n));
}
// ❌ 이터레이터에서도 같은 벽
public IEnumerable<PacketView> Split(ReadOnlySpan<byte> stream) // CS1073 — 불가
{
yield return ...;
}
// ❌ 람다/로컬함수 캡처도 불가
void Schedule(ReadOnlySpan<byte> body)
{
_queue.Enqueue(() => Process(body)); // CS8175 — Span은 힙에 못 간다
}
근거: result.md §4-1 / §4-2. byref는 스택 위치를 가리킬 수 있는 managed pointer여서 힙에 저장할 수 없고,
ref struct는 그 제약을 타입 수준으로 확장한 것이다. async 상태 머신·이터레이터·클로저는 전부 힙 객체다.
서버에서의 진짜 피해는 컴파일 에러가 아니라 그 다음 판단이다.
개발자가 에러를 피하려고 new byte[]로 되돌아가면, 무할당 파싱을 하려던 목적 자체가 사라진다.
DON'T 7. byref/Span을 워커 스레드·큐·필드로 넘긴다
// ❌ 전부 불가능하거나(컴파일 에러) 위험하다
class BadWorker
{
private Span<byte> _pending; // CS8345 — ref struct 필드 불가
private ref EntityState _target; // ref field는 ref struct 안에서만
void Enqueue(ref EntityState e)
{
Task.Run(() => Apply(ref e)); // 캡처 불가
_channel.Writer.TryWrite(e); // ★ 컴파일은 되지만 여기서 값 복사
}
}
ref/Span은 동기 호출 스택 안에서만 유효한 빌림이다.
스레드를 넘기려면 소유권이 있는 것 — byte[](풀에서 대여), Memory<byte>, ArraySegment<byte>, 또는 struct 값 자체 — 를 넘겨야 한다.
"byref를 다른 스레드로 넘기고 싶다"는 욕구가 생기면 설계가 틀린 것이지 언어가 부족한 게 아니다.
DON'T 8. 로그 hot path에서 in 파라미터의 ToString()을 부른다
// ❌ 나쁜 코드 — override가 readonly가 아니면 복사가 생긴다 (그리고 문자열도 할당한다)
public struct MoveCommand
{
public EntityId Id; public Vector3 To; public long Tick; // ≈32B
public override string ToString() => $"{Id}->{To}@{Tick}"; // 비readonly override
}
void Handle(in MoveCommand cmd)
{
_log.Trace(cmd.ToString()); // ★ ldobj 32B 복사 + 문자열 할당 (레벨 꺼져 있어도!)
}
근거: result.md §3-2 마지막 문단. object에서 온 override도 readonly가 아니면 ldobj를 만든다.
복사 + 박싱 회피 실패 + 할당이 한 줄에 겹친다.
DON'T 9. MemoryMarshal.AsRef<T> / Read<T> 로 wire 버퍼를 그냥 struct로 캐스팅한다
in/ref를 배우면 바로 다음에 손대게 되는 코드인데, 여기가 가장 위험하다.
// ❌ 나쁜 코드 — 정렬·엔디언·레이아웃 세 가지를 동시에 가정한다
public struct WireHeader { public int Length; public ushort OpCode; public long Ticks; }
void Parse(ReadOnlySpan<byte> buf)
{
ref readonly var h = ref MemoryMarshal.AsRef<WireHeader>(buf); // ★
// 1) buf가 4/8B 정렬이라는 보장 없음 (ArrayPool 대여 버퍼의 offset 슬라이스)
// 2) 리틀엔디언 호스트 가정 — 네트워크 바이트 오더와 불일치
// 3) [StructLayout] 없으면 필드 순서/패딩을 런타임이 재배치할 수 있음
// → 같은 서버 바이너리라도 x64/arm64에서 sizeof가 다를 수 있다
}
AsRef<T>는 길이 검사만 한다. 정렬을 맞춰주지 않고, 엔디언을 바꿔주지 않는다.
arm64 서버(Graviton 등)로 옮기는 순간 조용한 데이터 깨짐 또는 성능 저하가 난다.
2. DO — 서버에서 맞는 전달 패턴
DO 1. wire format은 readonly struct + 명시 레이아웃, 가변 런타임 상태는 class
경계를 이렇게 그으면 in/ref 판단이 자동으로 따라온다.
// ✅ 불변 wire 값 → readonly struct. in 을 붙여도 방어적 복사가 없다
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public readonly struct PacketHeader
{
public readonly int Length;
public readonly ushort OpCode;
public readonly uint Sequence;
public readonly long TimestampTicks;
public PacketHeader(int length, ushort opCode, uint seq, long ticks)
=> (Length, OpCode, Sequence, TimestampTicks) = (length, opCode, seq, ticks);
public bool IsValid() => Length > 0 && Length <= 8192; // readonly struct → call 직행
}
// ✅ 가변·공유·수명 긴 것 → class. 애초에 in/ref 논의 대상이 아니다
public sealed class PlayerSession
{
public EntityId Id { get; }
private long _lastSeq;
public long NextSeq() => Interlocked.Increment(ref _lastSeq);
}
// 32B + readonly struct → in이 정당한 자리
public void Dispatch(in PacketHeader h, PlayerSession session)
{
if (!h.IsValid()) return; // ldobj 없음 (result.md §3-1 네 번째 행)
_handlers[h.OpCode](session, in h);
}
규칙: in을 쓰기 전에 그 타입이 readonly struct인지 먼저 확인한다. 아니면 in을 붙이지 않는다.
DO 2. 레거시 struct는 멤버 단위 readonly(C# 8) 로 국소 탈출
타입 전체를 readonly로 못 바꾸는 상황(가변 필드가 실제로 필요하거나, 외부 타입이거나)이 서버 코드에는 흔하다.
// ✅ 필드는 가변으로 두고, in 경로에서 호출되는 멤버에만 readonly
public struct MovementState
{
public Vector3 Position; // 틱마다 갱신되어야 함 — 가변 유지
public Vector3 Velocity;
public long LastTick;
public readonly bool IsMoving() => Velocity.LengthSquared() > 1e-6f; // 복사 없음
public readonly float Speed => Velocity.Length(); // 복사 없음
public override readonly string ToString() => $"{Position}"; // 복사 없음
public void Advance(float dt) { Position += Velocity * dt; } // 가변 — in 금지
}
근거: result.md §3-2 네 번째 행. 타입 전체가 readonly가 아닌데도 ldarg.0 → call로 직행한다.
기존 코드베이스에 점진 적용하는 현실적 경로다.
우선순위: in 파라미터 경로에서 호출되는 멤버부터 readonly를 붙인다.
<EnforceCodeStyleInBuild> + IDE0251(멤버를 readonly로) 분석기를 켜두면 자동으로 잡힌다.
DO 3. 패킷 파싱은 ReadOnlySpan<byte> + BinaryPrimitives로 엔디언을 명시
AsRef<T> 대신 이것이 기본값이어야 한다.
// ✅ 무할당 + 정렬 무관 + 엔디언 명시
public static bool TryReadHeader(ReadOnlySpan<byte> buf, out PacketHeader header, out int consumed)
{
header = default; consumed = 0;
if (buf.Length < 18) return false;
int len = BinaryPrimitives.ReadInt32BigEndian(buf); // 정렬 요구 없음
ushort op = BinaryPrimitives.ReadUInt16BigEndian(buf[4..]); // 슬라이스는 무할당
uint seq = BinaryPrimitives.ReadUInt32BigEndian(buf[6..]);
long tick = BinaryPrimitives.ReadInt64BigEndian(buf[10..]);
if (len <= 0 || len > 8192) return false;
header = new PacketHeader(len, op, seq, tick);
consumed = 18;
return true;
}
포인트:
Span슬라이싱이 무할당인 이유는Span이 (포인터, 길이) 짝을 든 뷰라서다 (result.md §4-2).- 인덱서 반환 타입이
!0&(byref)라 요소 접근에도 사본이 없다 (§3-8).List<T>의!0(값)과 대조된다. BinaryPrimitives는 JIT가movbe/bswap한 명령으로 접는다. 안전성 대비 비용이 사실상 없다.
MemoryMarshal.Read<T>를 굳이 써야 한다면 조건을 전부 만족시켜라.
// ✅ 조건부로만 허용: 자체 정의 타입 + Pack=1 + 호스트 엔디언과 wire 엔디언 일치 확인
if (!BitConverter.IsLittleEndian) throw new PlatformNotSupportedException();
var h = MemoryMarshal.Read<PacketHeader>(buf); // 값 복사(정렬 안전). AsRef보다 이쪽이 기본
Read<T>는 값을 복사해서 돌려주므로 정렬 문제가 없다.
AsRef<T>는 byref를 돌려주므로 빠르지만 정렬 보장이 필요하다 — 대용량 배열 in-place 갱신에만 쓴다.
DO 4. 틱 순회는 ref local + CollectionsMarshal.AsSpan
// ✅ ldelema — 주소만. 복사 0, 수정이 원본에 반영됨
void Tick(float dt)
{
Span<EntityState> span = _entities; // 배열은 암시적 변환
for (int i = 0; i < span.Length; i++)
{
ref EntityState e = ref span[i]; // Span 인덱서는 byref 반환 (§3-8)
e.Pos += e.Vel * dt; // 원본이 바뀐다
}
}
// ✅ List<T>도 동일하게 (.NET 5+)
void Tick(List<EntityState> list, float dt)
{
foreach (ref EntityState e in CollectionsMarshal.AsSpan(list))
{
e.Pos += e.Vel * dt;
}
}
foreach (ref T e in span)이 가능하다는 점이 핵심이다. 인덱스 루프보다 경계 검사 제거에도 유리하다.
for (int i...; i < span.Length; i++) 형태를 유지하면 JIT가 bounds check를 제거한다 —
_entities.Length나 별도 변수를 조건에 쓰면 제거가 깨진다.
DO 5. 세션/상태 딕셔너리는 GetValueRefOrNullRef로 in-place 갱신
패킷마다 도는 가장 뜨거운 경로다.
// ❌ 조회(복사) → 수정 → 재삽입(복사) — 해시 계산 2회 + 값 복사 2회
if (_states.TryGetValue(id, out var st)) { st.Hp -= dmg; _states[id] = st; }
// ✅ 해시 1회, 복사 0회 (.NET 6+)
ref CombatState slot = ref CollectionsMarshal.GetValueRefOrNullRef(_states, id);
if (!Unsafe.IsNullRef(ref slot))
{
slot.Hp -= dmg; // 딕셔너리 내부 엔트리를 직접 수정
slot.LastHitTick = tick;
}
주의(서버 고유): 반환된 ref는 딕셔너리를 수정하는 순간 무효화된다.
Add/Remove/리사이즈가 일어나면 내부 버킷 배열이 교체되어 dangling ref가 된다.
// ❌ 절대 금지 — slot을 잡은 채로 컬렉션을 변경
ref var slot = ref CollectionsMarshal.GetValueRefOrNullRef(_states, id);
_states.Remove(otherId); // ★ 내부 배열이 바뀔 수 있다
slot.Hp -= dmg; // ★ 엉뚱한 메모리를 쓴다 (GC는 막아주지 않는다)
그리고 이 API는 동기화를 제공하지 않는다. ConcurrentDictionary에는 없고, Dictionary에 쓸 때는
해당 딕셔너리가 단일 스레드(또는 파티션 소유 스레드) 에서만 접근된다는 전제가 있어야 한다.
게임서버에서는 "룸/샤드마다 전용 스레드"라는 설계와 짝을 이룰 때만 안전하다.
DO 6. await 경계와 Span을 구조적으로 분리한다
DON'T 6의 정답. Span 제약을 우회하지 말고 레이어를 나눈다.
// ✅ I/O는 Memory<byte>(힙 저장 가능), 파싱은 동기 메서드에서 Span으로
public async Task PumpAsync(Socket sock, CancellationToken ct)
{
byte[] rented = ArrayPool<byte>.Shared.Rent(8192);
try
{
int filled = 0;
while (!ct.IsCancellationRequested)
{
// Memory<byte>는 ref struct가 아니다 → await 경계를 넘을 수 있다
int n = await sock.ReceiveAsync(rented.AsMemory(filled), SocketFlags.None, ct);
if (n == 0) break;
filled += n;
// 파싱 전체는 동기 구간. 여기서만 Span으로 내려간다
int consumed = ParseAll(rented.AsSpan(0, filled));
if (consumed > 0 && consumed < filled)
Buffer.BlockCopy(rented, consumed, rented, 0, filled - consumed);
filled -= consumed;
}
}
finally { ArrayPool<byte>.Shared.Return(rented); }
}
// ✅ 동기 메서드 — ref struct 자유롭게 사용 가능
private int ParseAll(ReadOnlySpan<byte> buf)
{
int consumed = 0;
while (TryReadHeader(buf[consumed..], out var h, out int hdrLen))
{
if (buf.Length - consumed < h.Length) break; // 부분 수신
// 핸들러가 await가 필요하면 여기서 값을 '소유 가능한 형태'로 복사해 넘긴다
Dispatch(in h, buf.Slice(consumed + hdrLen, h.Length - hdrLen));
consumed += h.Length;
}
return consumed;
}
규칙 3줄
Memory<T>/ArraySegment<T>/byte[]= await·스레드·필드를 넘을 수 있다.Span<T>/ref= 동기 호출 스택 안에서만.- 경계에서 넘겨야 하면 소유권 있는 표현으로 전환한다. 그 전환이 곧 복사 지점이다 — 의식적으로 두어라.
핸들러가 비동기여야 한다면 Span 조각을 그대로 넘기지 말고 풀 버퍼 소유권을 이관한다.
// ✅ await가 필요한 핸들러에는 소유권을 넘긴다
ValueTask HandleAsync(in PacketHeader h, ReadOnlySpan<byte> body)
{
// Span은 여기서 끝. 비동기로 가야 하면 풀 버퍼로 복사(=명시적 소유권 이전)
byte[] owned = ArrayPool<byte>.Shared.Rent(body.Length);
body.CopyTo(owned);
return ProcessAsync(h.OpCode, new ArraySegment<byte>(owned, 0, body.Length)); // 반납은 수신 측 책임
}
DO 7. 큰 정적 테이블은 ref readonly 반환으로 조회
밸런스 테이블·스폰 테이블처럼 크고 불변이며 조회가 잦은 데이터에 정확히 맞는다.
// ✅ ldelema로 요소 주소만 반환 — 조회할 때마다 구조체 전체를 복사하지 않는다
public sealed class SkillTable
{
private readonly SkillDef[] _defs; // SkillDef ≈ 96B
public ref readonly SkillDef Get(int id) => ref _defs[id]; // (§3-6)
}
// 호출부
ref readonly SkillDef def = ref _table.Get(skillId); // 복사 0
float dmg = def.BaseDamage * ratio;
// ❌ 이렇게 받으면 96B 복사 — ref 를 빼먹으면 조용히 원래대로 돌아간다
SkillDef copied = _table.Get(skillId);
ref readonly는 원본 배열을 외부에서 못 고치게 하면서 복사도 없앤다.
C# 11부터는 호출 측에서 ref readonly 수신을 강제하도록 [UnscopedRef]/ref readonly 파라미터 규칙이 정리됐다.
DO 8. ArrayPool / ArraySegment의 복사 지점을 명시적으로 안다
byte[] rented = ArrayPool<byte>.Shared.Rent(1024); // 요청보다 큰 배열이 올 수 있다
Span<byte> view = rented.AsSpan(0, len); // 복사 0 — 뷰
var seg = new ArraySegment<byte>(rented, 0, len); // 복사 0 — 값 3개(배열참조/offset/count)
byte[] copy = view.ToArray(); // ★ 복사 + 힙 할당. 이름에 드러나 있다
seg.ToArray(); // ★ 동일
// ArraySegment<T>의 인덱서는 값 반환이다 → struct 요소면 사본
var st = segOfStates[i]; // 복사
st.Hp -= 1; // 반영 안 됨
// ✅ Span으로 내려서 byref 인덱서를 쓴다
ref var s = ref segOfStates.AsSpan()[i];
s.Hp -= 1; // 반영됨
Rent는 요청 크기 이상을 돌려주므로 rented.Length가 아니라 항상 실제 길이로 슬라이스해야 한다.
Return(rented, clearArray: true)는 배열 전체를 0으로 채운다 — 세션 토큰 같은 민감 데이터가 있을 때만 켜라(그 자체가 memset 비용이다).
DO 9. 값 크기별 전달 규칙 (기본값)
| 크기 | 권장 | 근거 |
|---|---|---|
≤ 8B (EntityId, Tick) |
값 전달. in 금지 |
§3-3, Windows x64 레지스터 경계 |
| 9~16B | 값 전달 기본. 리눅스 전용 배포면 값 유지 | §4-3, SysV는 16B까지 레지스터 |
17~64B (PacketHeader, MovementState) |
in + readonly struct 필수 |
§3-1 |
> 64B (WorldSnapshot, SkillDef) |
in/ref/ref readonly 반환. 애초에 값으로 반환하지 마라 |
§4-3 hidden return buffer |
| 가변 + 배열/컬렉션 안 | ref local |
§3-6, §3-7 |
단, 숫자는 판단의 출발점이지 결론이 아니다. 인라이닝되면 호출 경계 자체가 사라져 전달 방식 차이가 무의미해진다(§5).
3. 서버 성능 함정
3-1. in 남발은 allocation rate에 안 잡힌다 — 그래서 더 늦게 발견된다
방어적 복사는 스택 임시다. GC 지표(alloc-rate, gen-0-gc-count)에 한 줄도 안 나온다.
100,000 req/s × 요청당 in 헤더 멤버 접근 5회 × 32B = 16 MB/s 의 스택 memcpy
→ alloc-rate: 변화 없음
→ time-in-gc: 변화 없음
→ CPU 사용률만 슬쩍 오르고, throughput 상한이 내려간다
진단 방법이 1-2와 다르다. dotnet-counters로는 안 보인다.
dotnet-trace+PerfView/speedscope로 CPU 샘플링 →memcpy/CORINFO_HELP_MEMCPY, 호출 대상 메서드의 프롤로그 비중을 본다.- 의심 메서드는
DOTNET_JitDisasm=MethodName으로 디스어셈블을 직접 확인한다(.NET 7+ Release에서 사용 가능). - 마지막 검증은 IL:
ilspycmd로ldobj가 남아 있는지 본다. IL에ldobj가 있으면 의도가 실패한 것이다.
3-2. byref는 GC의 interior pointer — 공짜 참조가 아니다
ref가 넘기는 것은 raw 포인터가 아니라 managed pointer다(result.md §4-1).
객체 중간·배열 요소·스택 위치를 가리킬 수 있고, GC가 compaction 시 주소를 갱신해준다.
서버 관점의 함의:
- byref가 살아 있는 동안 GC는 그것을 스캔하고 소속 객체를 역추적해야 한다. 개수가 많으면 마킹 비용이 는다.
- byref는 힙에 저장할 수 없다 — class 필드 불가, 배열 요소 불가, 상태 머신 필드 불가. DON'T 6/7의 근본 이유다.
reflocal을 잡은 채 긴 구간을 도는 코드는 그동안 대상 객체를 붙잡는 효과가 있다. 풀 버퍼에 대한ref를 오래 들고 있으면 그 버퍼가 승격될 수 있다.
3-3. 멀티스레드에서 byref가 위험한 이유는 GC가 아니라 원자성이다
// ❌ 8B 초과 struct는 tearing 가능 — 다른 스레드가 반쯤 갱신된 값을 읽는다
ref EntityState e = ref span[i];
e.Pos = newPos; // Vector3 12B — 여러 store로 쪼개진다
// 다른 스레드에서
EntityState snapshot = span[i]; // ★ 찢어진 상태를 읽을 수 있다
CLR이 원자성을 보장하는 것은 네이티브 워드 크기 이하의 필드뿐이다.
struct 전체 대입은 원자적이지 않고, Interlocked는 struct에 걸 수 없다.
// ✅ 배열 요소의 스칼라 필드에는 ref로 Interlocked 가능
Interlocked.Increment(ref _states[i].Sequence); // Sequence가 int/long일 때
// ✅ 그 외에는 소유권 모델로 — 엔티티를 파티션에 나누고, 파티션은 한 스레드만 만진다
규칙: ref로 얻은 슬롯을 다른 스레드가 동시에 만질 수 있다면 그 설계는 ref를 쓸 자격이 없다.
ref는 "이 구간 동안 나만 이 슬롯을 만진다"는 전제 위에서만 성립한다.
3-4. 큰 struct 반환은 전달만큼 비싸다 (hidden return buffer)
in은 신경 쓰면서 반환은 놓치는 경우가 많다.
// ❌ 128B struct를 값으로 반환 — 호출자가 버퍼를 잡고 그 주소를 숨은 인자로 넘긴다(sret)
public WorldSnapshot BuildSnapshot(int tick) { ... }
foreach (var s in _sessions) Send(s, BuildSnapshot(tick)); // 세션 수만큼 재구축 + 복사
// ✅ 한 번 만들고 ref readonly로 빌려준다
private WorldSnapshot _current;
public ref readonly WorldSnapshot Current => ref _current;
foreach (var s in _sessions) Send(s, in _world.Current); // 복사 0
근거: result.md §4-3. 반환에도 전달과 대칭적인 비용이 있다.
3-5. in이 인터페이스 구현·델리게이트와 만나면 이득이 사라진다
public interface IHandler { void Handle(in PacketHeader h); }
// ❌ 인터페이스로 호출하면 devirtualize 실패 → 인라인 실패 → 호출 경계가 남는다
IHandler h = _table[op];
h.Handle(in header); // callvirt
// ✅ 제네릭 제약으로 JIT 특수화를 유도 (값 타입 제네릭은 코드가 공유되지 않는다)
static void Invoke<THandler>(THandler handler, in PacketHeader header)
where THandler : struct, IHandler
=> handler.Handle(in header); // constrained. → 인라인 가능
델리게이트(Action<PacketHeader> 등) 테이블도 같다. in 파라미터를 델리게이트로 표현하려면
delegate void Handler(in PacketHeader h); 같은 커스텀 델리게이트가 필요하고(Action<>은 in 수식자를 표현 못 한다),
그마저도 호출은 간접이라 인라인되지 않는다.
서버 현실 타협: 디스패치 테이블 자체는 간접 호출을 피하기 어렵다.
그 대신 테이블 진입 이후 내부 경로에서 in/readonly struct가 값을 발휘하도록 설계하라.
3-6. ArrayPool 대여 버퍼는 정렬을 보장하지 않는다
byte[] rented = ArrayPool<byte>.Shared.Rent(4096);
// 배열 시작은 정렬되지만, 스트림 파싱 중 offset 슬라이스는 임의 정렬이 된다
ReadOnlySpan<byte> mid = rented.AsSpan(7, 64);
ref readonly var h = ref MemoryMarshal.AsRef<PacketHeader>(mid); // ★ 미정렬 byref
x64는 미정렬 접근을 허용하지만(캐시 라인을 걸치면 느려짐), arm64에서는 일부 명령이 폴트하거나 크게 느려진다.
클라우드 arm 인스턴스로 옮길 계획이 조금이라도 있으면 AsRef<T>를 스트림 파싱에 쓰지 마라 — BinaryPrimitives가 정답이다.
3-7. stackalloc을 루프 안에 쓰면 스택 오버플로
Span을 쓰기 시작하면 반드시 밟는 함정이고, 서버에서는 스레드 풀 스레드가 죽고 프로세스가 즉사한다(catch 불가).
// ❌ 스택은 반복마다 회수되지 않는다
for (int i = 0; i < n; i++)
{
Span<byte> tmp = stackalloc byte[256]; // ★ 누적. n이 크면 SO
}
// ✅ 루프 밖에서 한 번, 또는 크기 기준으로 풀 전환
Span<byte> tmp = stackalloc byte[256];
for (int i = 0; i < n; i++) { tmp.Clear(); /* ... */ }
// ✅ 크기가 가변이면 임계값 분기 (서버 기본 스택은 1MB, 절대 다 쓰지 마라)
byte[]? pooled = null;
Span<byte> buf = size <= 512
? stackalloc byte[512]
: (pooled = ArrayPool<byte>.Shared.Rent(size));
try { /* ... */ }
finally { if (pooled is not null) ArrayPool<byte>.Shared.Return(pooled); }
4. 런타임별 차이
4-1. ABI 차이 — 로컬 Windows 벤치와 리눅스 프로덕션 결과가 다르다
이 주제에서 가장 서버스러운 함정이다. 개발은 Windows, 배포는 리눅스 컨테이너인 팀이 많다.
| struct 크기 | Windows x64 | SysV AMD64 (Linux/macOS) |
|---|---|---|
| ≤ 8B | 레지스터 | 레지스터 |
| 9~16B | 호출자가 임시 메모리 생성 + 포인터 전달 | 최대 2개 레지스터 (필드 분류에 따름) |
| > 16B | 포인터 전달 | 메모리 전달(숨은 포인터) |
근거: result.md §4-3. "16B 이하는 레지스터"는 SysV 이야기다. Windows x64에서는 8B 초과부터 간접 전달을 경계해야 한다.
실무 지침:
- 12~16B struct(
Vector3,PacketId등)의in여부는 배포 플랫폼에서 측정한다. Windows 결과를 리눅스에 이식하지 마라. - arm64(SysV 계열이지만 AAPCS64)는 또 다르다 — 16B 이하 HFA/HVA는 레지스터, 초과는 메모리.
- CoreCLR의 managed-to-managed 호출은 JIT 내부 규칙을 따르므로 네이티브 ABI와도 완전히 같지 않다. 표는 방향성일 뿐이다.
4-2. Tier 0 vs Tier 1 — 서버 기동 구간의 왜곡
| Tier 0 | Tier 1 | |
|---|---|---|
| 인라이닝 | 최소 | 적극 |
| struct 레지스터 승격 | 거의 없음 | 적용 |
readonly struct의 이득 |
거의 안 나옴 | 나옴 |
in 남발의 손해 |
그대로 다 나옴 | 인라인되면 사라질 수 있음 |
즉 in 잘못 쓴 코드의 피해는 기동 직후(Tier 0) 구간에 집중된다.
서버 롤링 배포 직후 첫 수천~수만 요청의 P99가 나쁜 데에는 이 성분이 섞여 있다.
<PropertyGroup>
<TieredPGO>true</TieredPGO>
<PublishReadyToRun>true</PublishReadyToRun> <!-- 기동 구간 Tier 0 회피 -->
</PropertyGroup>
측정 규칙(result.md §5):
- Debug 빌드 결과는 무의미하다. 최적화가 꺼져 있다.
- Tier 1 승격 후를 봐라. BenchmarkDotNet의 warm-up이 이걸 처리한다.
- 인라이닝되면 결과가 뒤집힌다. 호출 경계가 사라지면 전달 방식 차이도 사라진다.
[MethodImpl(MethodImplOptions.NoInlining)]으로 강제 격리한 벤치는 프로덕션 동작과 다르다는 점을 명심하라. - 결과값이 DCE로 제거되지 않게 소비하라.
4-3. Workstation GC vs Server GC — 이 주제에서의 실제 접점
방어적 복사는 스택이라 GC와 직접 관계가 없다. 접점은 두 군데다.
| 항목 | Workstation GC | Server GC |
|---|---|---|
| interior pointer 스캔 | 단일 힙 | 힙마다 병렬 마킹 — byref가 많아도 분산됨 |
| 풀 버퍼 승격 | Gen 0 budget 작음 → 풀 버퍼가 빨리 Gen 2로 | budget 큼 → 오래 Gen 0에 머무름 |
ref 오래 들기 |
승격 유발 가능성 높음 | 상대적으로 완화 |
ArrayPool 대여 버퍼는 결국 오래 사는 객체가 되어 Gen 2에 정착한다. 이건 정상이고 의도된 동작이다.
문제는 풀에 반납하지 않아 매번 새로 대여할 때다 — Return을 finally에 두지 않은 코드는 사실상 new byte[]와 같다.
4-4. .NET / C# 버전별로 이 주제에 실제로 달라지는 것
| 버전 | 변화 | 서버에서의 의미 |
|---|---|---|
| C# 7.2 / .NET Core 2.1 | readonly struct, in, ref struct, Span<T> |
세 개는 한 세트다. in만 떼어 쓰면 반쪽 |
| C# 8 / .NET Core 3.0 | 멤버 단위 readonly |
레거시 struct 점진 적용의 유일한 현실적 경로 |
| .NET 5 | CollectionsMarshal.AsSpan |
List<struct> 틱 순회 in-place |
| .NET 6 | CollectionsMarshal.GetValueRefOrNullRef |
세션 상태 딕셔너리 재삽입 제거 |
| C# 11 / .NET 7 | ref field, scoped |
커스텀 ref struct 커서/리더를 안전하게 작성 가능 |
| C# 12 / .NET 8 | ref readonly 파라미터, 인라인 배열([InlineArray]) |
고정 크기 패킷 버퍼를 struct 안에 둘 수 있다 |
| C# 13 / .NET 9 | allows ref struct 제네릭 anti-constraint |
Span을 제네릭 파서·IUtf8SpanParsable 등에 넘길 수 있게 됨 — DON'T 6의 벽이 일부 낮아짐 |
| .NET 8~9 | JIT physical promotion 개선 | 큰 struct 인자 복사 일부를 JIT가 자동 소거. 그래도 IL의 ldobj가 사라지진 않는다 |
| Native AOT (.NET 7+) | Tier 0 없음, 전 코드 사전 최적화 | readonly struct 이득이 기동 시점부터 나온다 |
allows ref struct(.NET 9)는 이런 코드를 가능하게 한다.
// C# 13+ — 제네릭 파라미터에 Span 계열을 허용
static bool TryParseBody<TReader>(scoped ref TReader reader, out MoveCommand cmd)
where TReader : allows ref struct, IPacketReader
{ ... }
여전히 async/이터레이터/힙 저장은 불가다. 완화된 것은 제네릭 인스턴스화 한정이다.
4-5. Unity 클라이언트와 타입을 공유할 때
UnityEngine.Vector3는readonly struct가 아니고 12B다.in을 붙이면 §3-2와 §3-3 함정을 동시에 밟는다. 서버·클라 공유 패킷 타입에는UnityEngine.Vector3대신 자체readonly struct Vec3를 정의하라.- IL2CPP는 C++ 컴파일러 최적화에 의존하므로 CoreCLR IL 관찰 결과가 그대로 이전되지 않는다. 공유 타입의 성능은 양쪽에서 따로 측정한다.
- Unity 쪽 C# 버전이 낮으면
ref field·allows ref struct·[InlineArray]를 공유 어셈블리에 쓸 수 없다.
5. 코드 작성 전 체크리스트
in을 타이핑하기 직전:
- [ ] 이 타입이
readonly struct인가? 아니면 호출할 멤버 전부에readonly가 붙어 있는가? → 아니면in을 지워라 - [ ] 크기가 16B를 넘는가? → 8~16B는 값 전달이 보통 더 빠르다(Windows x64는 8B가 분기점)
- [ ] 넘기는 것이 lvalue인가?
Foo(in Build())는 임시만 만든다 - [ ] 본문에서 프로퍼티·
ToString()·비readonly 메서드를 부르는가? → 각각이ldobj1회다 - [ ] 이 메서드가 인라인될 만큼 작은가? → 그렇다면
in의 이득도 손해도 대부분 사라진다
ref를 쓸 때:
- [ ] 대입(
a = b)을 하는가? →ref여도ldobj+stobj복사다 - [ ] 이
ref가 유효한 동안 대상 컬렉션을 수정할 가능성이 있는가? →GetValueRefOrNullRef무효화 - [ ] 이 슬롯을 다른 스레드가 동시에 만지는가? →
ref를 쓸 자격이 없는 설계다 - [ ]
Interlocked가 필요한 필드인가? → 배열 요소의 스칼라 필드에만ref로 걸 수 있다
패킷 타입을 정의할 때:
- [ ] wire format인가? →
readonly struct+[StructLayout(Sequential, Pack=1)] - [ ] 가변 런타임 상태인가? →
sealed class.in/ref논의 대상이 아니다 - [ ]
ToString()을 override했는가? →public override readonly string ToString() - [ ] 파싱에
MemoryMarshal.AsRef<T>를 쓰려 하는가? → 정렬·엔디언·arm64를 검토했나. 기본값은BinaryPrimitives
Span/ref struct 코드를 쓸 때:
- [ ] 이 메서드가
async인가? →Span은 못 들어간다.Memory<T>로 받고 동기 메서드에서 Span으로 내려라 - [ ] 이터레이터(
yield)인가? →ref struct파라미터 불가. 콜백 또는 인덱스 반환으로 바꿔라 - [ ] 람다·로컬함수에 캡처하는가? → 불가. static 람다 + 인자 전달로 전환
- [ ]
stackalloc이 루프 안에 있는가? → 밖으로 빼거나 임계값 이상은ArrayPool - [ ]
Rent한 배열을finally에서Return하는가? → 안 하면new byte[]와 동일하다 - [ ]
rented.Length가 아니라 실제 길이로 슬라이스하는가?
틱/순회 루프를 쓸 때:
- [ ]
foreach (var e in arr)로 struct를 수정하려 하는가? →foreach (ref var e in span) - [ ] 요소가 32B를 넘는가? →
ldelem은 그만큼의 memcpy다.reflocal 필수 - [ ] 루프 조건이
span.Length인가? → 다른 변수를 쓰면 bounds check 제거가 깨진다
측정·배포:
- [ ] Release + Tier 1(또는 R2R/AOT)에서 측정했는가?
- [ ] 배포 플랫폼(Linux x64 / arm64)에서 측정했는가? Windows 결과는 ABI가 다르다
- [ ]
ilspycmd로ldobj가 남아 있는지 확인했는가? — 의도 검증의 최종 수단 - [ ]
dotnet-counters로는 이 문제가 안 보인다는 것을 알고 있는가? → CPU 샘플링 프로파일이 필요하다
6. 한 줄 요약
in은 넘기기 전의 복사만 없앤다. 넘긴 다음의 복사(ldobj)를 없애는 건readonly다. 둘은 항상 짝이다.in은 공짜가 아니다. 8B struct에 붙이면 없던 복사가 생긴다.EntityId에in을 붙이지 마라.ref는 인자 전달의 복사를 없앨 뿐, 대입은 여전히ldobj+stobj다.- 서버의 진짜 이득 구간은 두 곳이다 — 큰 wire/테이블 struct의
in/ref readonly전달, 그리고 대용량 배열·딕셔너리의reflocal in-place 갱신. Span/ref는 동기 호출 스택의 빌림이다.await·스레드·필드·yield를 넘어야 하는 순간Memory<T>/풀 버퍼로 소유권을 바꿔라. 그 전환점이 유일하게 허용된 복사 지점이다.- 이 주제의 비용은 GC 지표에 안 잡힌다.
alloc-rate가 아니라 CPU 프로파일과 IL을 봐야 한다.
리서치 소스
| 소스 | 기여 |
|---|---|
| NotebookLM (CLR via C#, C# 12 in a Nutshell, Pro .NET Memory Management) | managed pointer vs object reference 원문 정의, in의 도입 의도, readonly struct의 보장, ref struct 제약 목록 원문 |
GPT (gpt-5.6-terra) |
Windows x64 vs SysV ABI 차이와 흔한 오해 교정, hidden return buffer, 방어적 복사 조건 보강, 벤치마크 주의점 |
Claude + ilspycmd 실측 |
IL 9종 검증. 특히 C# 8 readonly 멤버가 방어적 복사를 없앤다는 증거와 8바이트 struct에 in이 복사를 만든다는 반례 |