| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
- 최적화
- Unity Editor
- sha
- adfit
- 게임개발
- Custom Package
- AES
- TextMeshPro
- 패스트캠퍼스
- job
- 환급챌린지
- 암호화
- 오공완
- Job 시스템
- base64
- 샘플
- 프레임워크
- 2D Camera
- C#
- ui
- 직장인공부
- 가이드
- Framework
- RSA
- 패스트캠퍼스후기
- unity
- DotsTween
- 직장인자기계발
- Tween
- Dots
- Today
- Total
EveryDay.DevUp
struct vs class — 값 타입과 참조 타입, 무엇이 다른가 본문
struct vs class — 값 타입과 참조 타입, 무엇이 다른가
Unity로 게임을 만들다 보면 자연스럽게 이런 질문을 하게 됩니다.
"Vector3는 왜 struct고, MonoBehaviour는 왜 class인가?"
단순히 "struct는 스택, class는 힙"이라고 외우는 수준으로는 Unity에서 발생하는 GC(Garbage Collector, 사용되지 않는 메모리를 자동으로 회수하는 런타임 구성요소) 스파이크나 예상치 못한 버그를 잡을 수 없습니다. 이 글에서는 메모리 동작 원리부터 IL(Intermediate Language, C# 코드가 컴파일되면 생성되는 중간 언어) 수준의 차이, 실전 함정까지 깊이 파고듭니다.
스택과 힙 — 메모리 구조 이해
프로세스와 스레드
프로그램이 실행되면 운영체제는 프로세스(Process, 실행 중인 프로그램의 독립된 인스턴스)를 생성합니다. 각 프로세스는 자신만의 가상 메모리 공간을 가지며, 이 공간은 크게 네 영역으로 나뉩니다.
| 영역 | 역할 |
|---|---|
| 코드(Code) | 컴파일된 명령어 (읽기 전용) |
| 데이터(Data) | 전역·정적 변수 |
| 힙(Heap) | 동적으로 할당되는 객체 |
| 스택(Stack) | 메서드 호출과 지역 변수 |
그 안에서 실제로 코드를 실행하는 흐름이 스레드(Thread, 프로세스 내에서 독립적으로 실행되는 실행 단위)입니다. 하나의 프로세스에는 여러 스레드가 존재할 수 있으며, 스레드들은 힙을 공유하지만 스택은 스레드마다 독립적으로 갖습니다.
C# 애플리케이션(Unity 포함)은 시작 시 메인 스레드 하나가 생성되고, 이 스레드의 스택 위에서 코드가 실행됩니다.
스택 (Stack)
메서드 호출과 지역 변수를 관리하는 영역입니다.
- LIFO(Last In, First Out, 마지막에 들어온 것이 먼저 나가는) 구조
- 스택 포인터 하나만 이동하면 할당/해제가 완료됨 → 극도로 빠름
- 메서드가 리턴하면 해당 스택 프레임 전체가 자동으로 정리 → GC 없음
- 크기 제한이 있음 (기본 1~4MB) → 너무 큰 데이터는
StackOverflowException
힙 (Heap)
동적으로 생성되는 객체가 살아 있는 영역입니다.
new키워드로class인스턴스를 만들면 여기에 생성됨- 더 이상 참조되지 않는 객체는 GC가 수거함
- Unity는 표준 .NET의 세대별 GC가 아닌 Boehm GC(세대 구분 없이 전체 힙을 스캔하는 방식의 GC)를 사용 → GC 실행 시 Stop-the-World(GC가 실행되는 동안 모든 스레드를 일시 정지시키는 현상) 발생 → 프레임 드랍의 주요 원인
값 타입과 참조 타입
C#의 모든 타입은 두 가지로 나뉩니다.
| 값 타입 (Value Type) | 참조 타입 (Reference Type) | |
|---|---|---|
| 대표 키워드 | struct, enum, int, float 등 |
class, interface, delegate, string |
| 변수에 저장되는 것 | 데이터 자체 | 힙 객체의 주소(참조) |
| 할당 시 동작 | 데이터 전체 복사 | 주소만 복사 |
| 기본 할당 위치 | 스택 (조건부 힙) | 힙 |
| GC 대상 | 아님 (스택의 경우) | 해당 |
| 상속 | 불가 | 가능 |
| null 가능 | 기본 불가 (Nullable<T> 사용 시 가능) |
가능 |
struct는 System.ValueType을 상속하고, class는 System.Object를 직접 상속합니다.
다음 두 타입을 예시로 두고 차이를 살펴봅니다.
public class PlayerData // 참조 타입 — 힙에 생성
{
public int Hp;
public float Speed;
}
public struct PlayerDataStruct // 값 타입 — 스택에 생성
{
public int Hp;
public float Speed;
}
IL을 보면 선언 방식의 차이가 곧바로 드러납니다. class는 newobj 명령으로 힙에 객체를 만들고, struct는 initobj 명령으로 스택 공간을 초기화합니다.
// class 인스턴스 생성
IL_0001: newobj instance void PlayerData::.ctor() // 힙에 객체 할당
IL_0006: stloc.0 // a에 힙 주소 저장
// struct 인스턴스 초기화
IL_0001: ldloca.s 2
IL_0003: initobj PlayerDataStruct // 스택에 struct 초기화 (힙 할당 없음)
IL_001e: stloc.0 // a에 저장
newobj와 initobj의 차이가 곧 GC 부담의 차이입니다. 힙 할당이 없으면 GC가 추적할 객체도 없습니다.
struct는 항상 스택인가?
그렇지 않습니다. struct가 힙에 올라가는 경우가 있습니다.
// 1. 로컬 변수 — 스택에 할당
PlayerDataStruct local = new PlayerDataStruct { Hp = 100 };
// 2. class의 멤버 필드로 포함될 때 — class가 힙에 있으므로 struct도 힙에 존재
public class Enemy
{
public Vector3 position; // Vector3는 struct지만, Enemy 메모리 블록 안에 인라인으로 위치
}
// 3. boxing 발생 시 — 힙에 래퍼 객체 생성
object boxed = new PlayerDataStruct { Hp = 100 };
IL을 보면 세 가지 경우의 차이가 명확합니다.
// 1. 로컬 struct — 스택 프레임에 공간 확보, 힙 할당 없음
.locals init ([0] valuetype PlayerDataStruct)
IL_0000: ldloca.s 0
IL_0001: initobj PlayerDataStruct // 스택 초기화, newobj 없음
// 2. class 인스턴스 생성 — struct 필드는 class 메모리 블록에 인라인으로 포함됨
IL_0000: newobj instance void Enemy::.ctor() // Enemy를 힙에 할당
IL_0005: stloc.0
// position(Vector3)에 대한 별도 newobj 없음
// struct 필드는 class 객체 메모리 블록 안에 직접 배치됨
// 3. boxing — 스택의 값을 힙 래퍼 객체로 승격
IL_0000: ldloca.s 0
IL_0001: initobj PlayerDataStruct // 스택에 값 생성
IL_0007: ldloc.0
IL_0008: box PlayerDataStruct // ← 힙에 래퍼 객체 생성 + 값 복사, GC 대상이 됨
IL_000d: stloc.1
로컬 struct는 initobj로 스택에만 올라가고, class 안에서는 class 메모리 블록에 인라인으로 포함되며, boxing 시에만 box 명령으로 힙에 별도 래퍼가 생성됩니다.
struct 사용 시 주의할 함정
struct의 값 복사 특성에서 비롯되는 흔한 함정들이 있습니다.
함정 1 — 복사 의미론과 복사 비용
class — 참조 복사
var a = new PlayerData { Hp = 100, Speed = 5.0f };
var b = a; // a가 가리키는 힙 객체의 주소만 복사
b.Hp = 999;
Console.WriteLine(a.Hp); // 999 — a와 b는 같은 객체를 가리키고 있음
.locals init (
[0] class PlayerData, // a: 힙 주소를 담는 변수
[1] class PlayerData // b: 힙 주소를 담는 변수
)
IL_0019: stloc.0 // a에 힙 주소 저장
IL_001a: ldloc.0 // a의 주소를 로드
IL_001b: stloc.1 // b에 그 주소를 복사 ← 주소 4/8바이트만 복사
ldloc.0 → stloc.1 딱 두 명령. 힙 객체 자체는 건드리지 않고 주소값만 복사합니다.
struct — 값 복사
var a = new PlayerDataStruct { Hp = 100, Speed = 5.0f };
var b = a; // Hp, Speed 모든 필드가 통째로 복사됨
b.Hp = 999;
Console.WriteLine(a.Hp); // 100 — a와 b는 완전히 독립된 데이터
.locals init (
[0] valuetype PlayerDataStruct, // a
[1] valuetype PlayerDataStruct, // b
[2] valuetype PlayerDataStruct // 초기화 임시 변수
)
IL_001f: stloc.0 // a에 저장
IL_0020: ldloc.0 // a 전체 로드
IL_0021: stloc.1 // b에 복사 ← Hp + Speed 모든 필드가 스택에서 복사됨
핵심 차이: class는 항상 주소 4/8바이트만 복사하지만, struct는 필드 수에 비례해 복사 비용이 커집니다.
// 큰 struct는 값 전달 시마다 비용이 커진다
public struct EnemyState
{
public Vector3 position; // 12 bytes
public Vector3 velocity; // 12 bytes
public float health; // 4 bytes
public float armor; // 4 bytes
public int level; // 4 bytes
// 합계: 36 bytes — 메서드 전달 시마다 36바이트 복사
}
void ProcessEnemy(EnemyState state) { ... } // 호출마다 36바이트 복사
in 키워드 — 복사 없이 읽기 전용 전달
in 키워드는 값 타입을 읽기 전용 참조로 전달합니다. 복사 없이 원본에 직접 접근하면서, 호출된 메서드가 원본을 변경하지 못하도록 컴파일러가 강제합니다.
struct에서 in
// 36바이트 struct를 참조 하나(8바이트)로 전달
void ProcessEnemy(in EnemyState state)
{
float x = state.position.x; // ✅ 읽기 가능
// state.health = 100f; // ❌ CS8332: in 매개변수 수정 불가
}
// in EnemyState — managed reference(주소)로 전달, 36바이트 복사 없음
IL_0000: ldarg.1 // EnemyState의 주소 로드 (참조)
IL_0001: ldflda valuetype Vector3 EnemyState::position
IL_0006: ldfld float32 Vector3::x // 참조를 통해 직접 필드 읽기
// ldobj 없음 — 36바이트 복사 발생하지 않음
class에서 in
class에서 in을 사용하면 변수 재할당은 막히지만, 참조를 통한 객체 내부 수정은 막히지 않습니다.
void ProcessClass(in PlayerData data)
{
// data = new PlayerData(); // ❌ CS8331: in 참조 변수에 재할당 불가
data.Hp = 10; // ✅ 객체 내부 수정은 가능 — in은 참조(변수) 자체만 보호
}
// in PlayerData — PlayerData 참조 변수의 주소(byref)가 전달됨
IL_0000: ldarg.0 // &data (PlayerData 참조 변수의 주소, static 메서드의 첫 번째 매개변수)
IL_0001: ldind.ref // *(&data) → 힙 객체의 실제 주소 역참조
IL_0002: ldc.i4.s 10
IL_0004: stfld int32 PlayerData::Hp // 힙 객체 직접 수정 ← in이라도 원본 수정 가능
struct에서 in은 값 자체를 보호하지만, class에서 in은 "이 변수를 다른 객체로 교체 불가"만 보장합니다. class의 내부 상태는 여전히 변경 가능하므로 완전한 불변성이 필요하다면 readonly 접근 제어나 record class를 활용해야 합니다.
방어적 복사 (Defensive Copy)
in으로 struct를 전달해도 함정이 있습니다. readonly가 아닌 struct의 non-readonly 메서드를 호출할 때, 컴파일러는 원본 보호를 위해 암묵적으로 복사본을 만들어 복사본에서 메서드를 실행합니다.
public struct Counter
{
private int _count;
public void Increment() { _count++; } // readonly가 아닌 메서드
public int Value => _count;
}
void Process(in Counter counter)
{
counter.Increment(); // ❌ 컴파일러가 counter의 복사본을 만들어 Increment 호출
// counter.Value는 변하지 않음 — 원본이 수정되지 않았으므로
}
// void Process(in Counter counter) { counter.Increment(); }
.locals init ([0] valuetype Counter) // 방어적 복사본을 위한 로컬 변수
IL_0000: ldarg.0 // in Counter (주소) 로드, static 메서드의 첫 번째 매개변수
IL_0001: ldobj Counter // ← 방어적 복사: struct 값 전체를 임시 변수에 복사
IL_0006: stloc.0 // 복사본을 로컬에 저장
IL_0007: ldloca.s 0 // 복사본의 주소 로드
IL_0009: call instance void Counter::Increment() // 복사본에서 호출 (원본 보호)
복사 비용을 줄이려고 in을 썼는데 ldobj로 오히려 복사가 발생하는 역설입니다. C# 7.2의 readonly struct가 이 문제를 해결합니다 — 버전별 특징 참조.
함정 2 — struct 배열에서의 의도치 않은 복사
// ❌ 나쁜 예: 로컬 변수로 꺼내면 복사본이 생김
Vector3[] positions = GetPositions();
for (int i = 0; i < positions.Length; i++)
{
Vector3 pos = positions[i]; // positions[i]의 복사본
pos.y += 1.0f; // 복사본만 수정됨, 원본 배열 변경 없음
}
// ✅ 좋은 예: 인덱스로 직접 접근
for (int i = 0; i < positions.Length; i++)
{
positions[i].y += 1.0f; // 배열 원소를 직접 수정
}
ref 키워드 — 배열 원소 직접 참조
ref 로컬 변수를 사용하면 배열 원소의 주소를 얻어 복사 없이 접근하고 수정할 수 있습니다. 특히 메서드에 넘기면서도 원본을 유지하고 싶을 때 유용합니다.
struct 배열에서 ref
// ✅ ref 로컬: 배열 원소 주소를 직접 참조
for (int i = 0; i < positions.Length; i++)
{
ref Vector3 pos = ref positions[i]; // positions[i]의 주소, 복사 없음
pos.y += 1.0f; // 원본 배열 원소 직접 수정
}
// 메서드에 ref로 전달하면 배열 원소를 직접 수정할 수 있음
void Elevate(ref Vector3 v, float dy) { v.y += dy; }
for (int i = 0; i < positions.Length; i++)
Elevate(ref positions[i], 1.0f); // 배열 원소 주소 직접 전달
// ❌ Vector3 pos = positions[i] — 복사 발생
IL_0001: ldloc.0 // positions 배열
IL_0002: ldloc.1 // 인덱스 i
IL_0003: ldelem [UnityEngine]UnityEngine.Vector3 // ← 복사: 24바이트 스택으로 꺼냄
// ✅ ref Vector3 pos = ref positions[i] — 복사 없음
IL_0001: ldloc.0 // positions 배열
IL_0002: ldloc.1 // 인덱스 i
IL_0003: ldelema [UnityEngine]UnityEngine.Vector3 // ← 원소 주소 로드, 복사 없음
IL_0008: stloc.2 // ref 변수에 주소 저장
// pos.y += 1.0f — 원소 직접 수정
IL_0009: ldloc.2
IL_000a: ldloc.2
IL_000b: ldfld float32 [UnityEngine]UnityEngine.Vector3::y
IL_0010: ldc.r4 1.0
IL_0012: add
IL_0013: stfld float32 [UnityEngine]UnityEngine.Vector3::y // 배열 원소 직접 수정
ldelem(값 복사)이 아닌 ldelema(주소)를 사용하므로 24바이트 복사가 발생하지 않습니다.
class 배열에서 ref
class 변수는 이미 힙 주소(참조)를 담고 있습니다. 여기에 ref를 추가하면 "참조에 대한 참조"가 됩니다. struct에서 ref가 복사 방지 목적이라면, class에서 ref는 참조 변수 자체를 읽고 쓰는 것, 즉 호출 측 변수가 가리키는 객체를 다른 객체로 교체하는 데 쓰입니다.
메서드 매개변수에서 ref class
ref 없이 class를 전달하면 힙 주소 값이 복사됩니다. 필드 수정은 원본에 반영되지만, 로컬에서 변수를 재할당해도 호출 측에는 영향이 없습니다.
// ref 없이 — 필드 수정은 반영되지만, 변수 재할당은 호출 측에 영향 없음
public static void NoRef(PlayerData data)
{
data.Hp = 0; // ✅ 힙 객체 필드 수정 — 호출 측에 반영됨
data = null; // ❌ 로컬 파라미터 변수만 null — 호출 측 변수는 그대로
}
// ref 있을 때 — 변수 재할당도 호출 측에 반영됨
public static void WithRef(ref PlayerData data)
{
data.Hp = 0; // ✅ 힙 객체 필드 수정 — 호출 측에 반영됨
data = null; // ✅ 호출 측 변수도 null이 됨
}
// NoRef(PlayerData data) — 힙 주소 값이 복사되어 전달
IL_0000: ldarg.0 // data (힙 주소 직접)
IL_0001: ldc.i4.0
IL_0002: stfld int32 PlayerData::Hp // 힙 객체 필드 수정 — 원본에 반영
IL_0007: ldnull
IL_0008: starg.s data // 로컬 파라미터 복사본에만 null 저장 — 호출 측 무관
// WithRef(ref PlayerData data) — data 변수의 주소(PlayerData&)가 전달
IL_0000: ldarg.0 // &data (data 변수의 주소)
IL_0001: ldind.ref // *(&data) → 힙 주소 역참조
IL_0002: ldc.i4.0
IL_0003: stfld int32 PlayerData::Hp // 힙 객체 필드 수정 — 원본에 반영
IL_0008: ldarg.0 // &data
IL_0009: ldnull
IL_000a: stind.ref // *(&data) = null → 호출 측 변수도 null
// 호출 측 C_Caller() — 두 메서드 호출 비교
// NoRef(p) 호출 — 힙 주소 값을 그대로 전달
IL_000d: call void RefClassDemo::NoRef(class PlayerData)
// WithRef(ref q) 호출 — q 변수의 주소를 전달
IL_001f: stloc.0 // q를 로컬에 저장
IL_0020: ldloca.s 0 // q의 주소 로드 (PlayerData&) ← ldloca로 주소 전달
IL_0022: call void RefClassDemo::WithRef(class PlayerData&)
NoRef는 힙 주소(4/8바이트)를 값으로 전달(call PlayerData)하므로 starg.s로 로컬 복사본만 변경됩니다. WithRef는 변수의 주소(ldloca.s)를 전달(call PlayerData&)하므로 stind.ref가 호출 측 변수를 직접 덮어씁니다.
struct에서 ref는 메모리 복사를 막는 것이고, class에서 ref는 간접 수준을 한 단계 더 추가해 변수 자체를 교체 가능하게 만드는 것입니다.
배열 원소 접근 시에도 같은 원리가 적용됩니다. 배열 원소에서 참조를 꺼내는 방식(ldelem.ref)과 원소 자체의 주소를 얻는 방식(ldelema)은 IL 수준에서 엄연히 다릅니다.
ref 없이 — ldelem.ref: 참조를 꺼내 객체 내부 수정
// ref 없이 class 배열 원소 내부 수정
PlayerData[] players = { new PlayerData { Hp = 100 }, new PlayerData { Hp = 200 } };
players[0].Hp = 999; // 원본 힙 객체 Hp 수정
// players[0].Hp = 999
IL_0029: ldc.i4.0 // 인덱스 0
IL_002a: ldelem.ref // 배열[0]의 참조(힙 주소)를 스택에 로드 — 복사가 아닌 주소
IL_002b: ldc.i4 999
IL_0030: stfld int32 PlayerData::Hp // 로드한 주소의 힙 객체 필드 직접 수정
ldelem.ref는 배열 원소에 저장된 힙 주소를 꺼냅니다. 그 주소를 통해 stfld로 힙 객체를 직접 수정합니다. struct의 ldelem(값 전체 복사)과 달리, class는 주소(4/8바이트)만 꺼냅니다.
ref 사용 — ldelema + stind.ref: 배열 원소(참조 변수) 자체를 교체
// ref로 배열 원소(참조 변수) 자체를 다른 객체로 교체
ref PlayerData first = ref players[0];
first = new PlayerData { Hp = 500 }; // players[0]이 새 객체를 가리키게 됨
Console.WriteLine(players[0].Hp); // 500 — 배열 원소 자체가 교체됨
// ref PlayerData first = ref players[0]
IL_0029: ldc.i4.0
IL_002a: ldelema PlayerData // 배열 원소의 주소(managed reference) 로드 — 원소 자체의 위치
// first = new PlayerData { Hp = 500 }
IL_002f: newobj instance void PlayerData::.ctor() // 새 객체 힙 할당
IL_0034: dup
IL_0035: ldc.i4 500
IL_003a: stfld int32 PlayerData::Hp
IL_003f: stind.ref // *(배열 원소 주소) = 새 힙 주소 → 배열 원소 자체 교체
ldelema는 배열 원소가 저장된 메모리 위치(managed reference)를 반환합니다. stind.ref로 그 위치에 새 힙 주소를 쓰면 players[0]이 가리키는 객체 자체가 바뀝니다.
| IL 명령 | 결과 | |
|---|---|---|
| ref 없이 내부 수정 | ldelem.ref → stfld |
기존 힙 객체의 필드 수정 |
| ref로 원소 교체 | ldelema → stind.ref |
배열 원소가 새 힙 객체를 가리킴 |
ref 매개변수 — 메서드 간 참조 교체 (Swap)
ref 매개변수로 class를 받으면 힙 주소가 담긴 변수 자체의 주소가 전달됩니다(class PlayerData&). 두 변수가 가리키는 객체를 맞바꾸는 Swap도 가능합니다.
public static void Swap(ref PlayerData a, ref PlayerData b)
{
PlayerData temp = a;
a = b;
b = temp;
}
// 호출 측
Swap(ref players[0], ref players[1]); // 두 원소가 가리키는 객체 교체
// Swap(ref PlayerData a, ref PlayerData b)
// 매개변수 타입: class PlayerData& — byref to class reference (힙 주소를 담은 변수의 주소)
.locals init ([0] class PlayerData) // temp
IL_0000: ldarg.0 // &a (a 변수의 주소)
IL_0001: ldind.ref // *(&a) → a가 가리키는 힙 주소 로드
IL_0002: stloc.0 // temp = a
IL_0003: ldarg.0 // &a
IL_0004: ldarg.1 // &b
IL_0005: ldind.ref // *(&b) → b가 가리키는 힙 주소 로드
IL_0006: stind.ref // *(&a) = b의 힙 주소 → a 변수가 b 객체를 가리킴
IL_0007: ldarg.1 // &b
IL_0008: ldloc.0 // temp (원래 a의 힙 주소)
IL_0009: stind.ref // *(&b) = temp → b 변수가 원래 a 객체를 가리킴
ldind.ref로 byref를 역참조해 힙 주소를 읽고, stind.ref로 byref가 가리키는 위치에 새 힙 주소를 씁니다. 힙 객체는 전혀 복사되지 않으며, 힙 주소(4/8바이트)만 교환됩니다.
ref return — 배열 원소 주소를 반환
메서드가 ref를 반환하면 배열 원소의 managed reference를 호출 측에 직접 전달할 수 있습니다.
public static ref PlayerData GetRef(PlayerData[] arr, int i)
{
return ref arr[i]; // 배열 원소의 주소를 반환
}
// 호출 측: 반환된 주소를 통해 배열 원소 직접 교체
ref PlayerData slot = ref GetRef(players, 0);
slot = new PlayerData { Hp = 999 }; // players[0] 교체
// ref return — 반환 타입: class PlayerData&
IL_0000: ldarg.0 // arr 배열
IL_0001: ldarg.1 // 인덱스 i
IL_0002: ldelema PlayerData // 원소 주소 로드
IL_0007: ret // managed reference 그대로 반환 (복사 없음)
struct에서 ref는 복사 방지가 주목적이지만, class에서 ref는 참조 변수 자체를 읽고 쓰는 간접 수준을 한 단계 더 추가하는 것입니다. ldind.ref/stind.ref가 그 간접 수준을 IL로 표현합니다.
함정 3 — boxing: 값 타입이 힙에 올라가는 순간
boxing(값 타입을 object 타입 변수에 넣을 때 힙에 래퍼 객체를 생성하는 현상)은 예상치 못한 곳에서 발생합니다.
// interface 타입 변수에 struct를 담으면 boxing 발생
public interface IDamageable { void TakeDamage(int amount); }
public struct Enemy : IDamageable
{
public int Hp;
public void TakeDamage(int amount) { Hp -= amount; }
}
IDamageable target = new Enemy { Hp = 100 }; // boxing — 힙에 래퍼 객체 생성
target.TakeDamage(10); // boxing된 복사본에 적용 → 원본 struct에 반영 안 됨
boxing이 일어나는 순간을 IL로 보면 명확합니다.
int hp = 100;
object boxed = hp; // boxing
int unboxed = (int)boxed; // unboxing
IL_0001: ldc.i4.s 100
IL_0003: stloc.0 // int hp = 100 (스택)
IL_0004: ldloc.0
IL_0005: box [System.Runtime]System.Int32 // ← 힙에 Int32 래퍼 객체 생성 + 값 복사
IL_000a: stloc.1 // object boxed에 힙 주소 저장
IL_000b: ldloc.1
IL_000c: unbox.any [System.Runtime]System.Int32 // ← 힙 객체에서 int 값 추출
IL_0011: stloc.2
box 명령 하나가 힙 할당 + 데이터 복사를 동시에 수행합니다. 이것이 GC 압박의 원인이 됩니다.
함정 4 — == 연산자: struct, class, record의 비교 방식
타입 종류에 따라 == 연산자의 동작이 완전히 다릅니다.
struct — == 미정의, Equals()는 내부적으로 boxing
일반 struct는 ==가 자동으로 정의되지 않아 컴파일 오류가 발생합니다. Equals()는 값 비교를 하지만, System.ValueType의 기본 구현이 내부적으로 인수를 boxing한 뒤 비교합니다.
var a = new PlayerDataStruct { Hp = 100, Speed = 5.0f };
var b = new PlayerDataStruct { Hp = 100, Speed = 5.0f };
// Console.WriteLine(a == b); // ❌ CS0019: struct에 == 연산자 미정의
Console.WriteLine(a.Equals(b)); // ✅ true — 값 비교 (내부적으로 boxing 발생)
// a.Equals(b) — 비교 대상 b가 boxing됨
IL_0001: ldloca.s 0 // a의 주소 (constrained 호출용 receiver)
IL_0003: ldloc.1 // b 로드
IL_0004: box PlayerDataStruct // ← b를 boxing! 힙 할당 → GC 대상
IL_0009: constrained. PlayerDataStruct
IL_000f: callvirt instance bool [System.Runtime]System.Object::Equals(object)
// 비교할 때마다 힙 할당 발생
class — ==는 참조 비교, Equals()도 기본은 참조 비교
var c = new PlayerData { Hp = 100 };
var d = new PlayerData { Hp = 100 };
Console.WriteLine(c == d); // false — 다른 힙 객체 (주소가 다름)
Console.WriteLine(c.Equals(d)); // false — Object.Equals 기본 구현도 참조 비교
// c == d — 힙 주소 비교 (boxing 없음)
// ※ Release 빌드에서는 미사용 비교가 최적화로 제거되므로 개념적 표현
IL_0001: ldloc.0 // c (힙 주소)
IL_0002: ldloc.1 // d (힙 주소)
IL_0003: ceq // 주소가 같은지 비교 (참조 비교, boxing 없음)
record struct — 컴파일러가 필드별 직접 비교 생성
record struct는 컴파일러가 모든 필드를 직접 비교하는 ==를 자동 생성합니다. boxing이 없어 GC 부담도 없습니다.
public record struct DamageInfo(int Amount, DamageType Type);
var e = new DamageInfo(100, DamageType.Fire);
var f = new DamageInfo(100, DamageType.Fire);
Console.WriteLine(e == f); // true — 컴파일러 생성 ==: 모든 필드 값 비교
// e == f — 컴파일러 생성 op_Equality 호출 (boxing 없음)
IL_0021: ldloc.s 4 // e 로드
IL_0023: ldloc.s 5 // f 로드
IL_0025: call bool DamageInfo::op_Equality(valuetype DamageInfo, valuetype DamageInfo)
// op_Equality 내부: DamageInfo::Equals(DamageInfo)에 위임
// → EqualityComparer<int32>.Default.Equals(Amount, other.Amount)
// → EqualityComparer<DamageType>.Default.Equals(Type, other.Type)
// EqualityComparer<T>는 원시 타입에 대해 boxing 없이 직접 비교 수행
record class — 값 비교이지만 힙 객체끼리 비교
record class도 컴파일러가 모든 필드를 비교하는 ==를 자동 생성합니다. 서로 다른 힙 객체라도 내용이 같으면 true를 반환합니다.
public record class HitInfo(Vector3 Point, float Damage);
var g = new HitInfo(Vector3.zero, 10f);
var h = new HitInfo(Vector3.zero, 10f);
Console.WriteLine(g == h); // true — 필드 값 비교 (record class)
Console.WriteLine(ReferenceEquals(g, h)); // false — 서로 다른 힙 객체
| 타입 | == 기본 동작 |
boxing 발생 |
|---|---|---|
| struct | 미정의 (컴파일 오류) | Equals() 사용 시 발생 |
| class | 참조 비교 | 없음 |
| record struct | 필드 값 비교 (자동 생성) | 없음 |
| record class | 필드 값 비교 (자동 생성) | 없음 |
비교 로직에서 GC 압박을 없애려면 struct에 ==를 직접 구현하거나, record struct를 사용하는 것이 가장 깔끔합니다.
버전별 특징 — struct는 계속 진화했다
C# 버전별 struct 관련 변화 요약
| 버전 | 추가 기능 |
|---|---|
| C# 7.2 | readonly struct, ref struct, in 매개변수 |
| C# 8.0 | readonly 인스턴스 멤버 (struct 내 특정 메서드만 readonly) |
| C# 10 | record struct, readonly record struct |
| C# 12 | 기본 생성자(Primary Constructor) — struct에도 적용 |
readonly struct (C# 7.2)
방어적 복사 문제의 근본 원인은 컴파일러가 struct의 불변성을 보장받지 못한다는 점입니다. readonly struct는 구조체가 변경 불가능(Immutable)함을 컴파일러 수준에서 선언하여, 컴파일러가 in 전달 시 방어적 복사 없이 원본 참조를 그대로 사용할 수 있게 만듭니다.
public readonly struct ImmutablePoint
{
public float X { get; }
public float Y { get; }
public ImmutablePoint(float x, float y)
{
X = x;
Y = y;
}
public float Length() => MathF.Sqrt(X * X + Y * Y); // 읽기만 하므로 안전
}
모든 인스턴스 필드와 프로퍼티가 반드시 readonly여야 하며, 이를 어기면 컴파일 오류가 발생합니다.
// 일반 struct — in 전달 시 방어적 복사 발생 가능
public struct NormalPoint { public float X; public float Y; }
// readonly struct — 방어적 복사 없음, 성능 우위
public readonly struct ReadonlyPoint { public float X { get; } public float Y { get; } }
void Process(in NormalPoint p) { /* 컴파일러가 복사본을 만들 수 있음 */ }
void Process(in ReadonlyPoint p) { /* 복사 없음, 참조 그대로 사용 */ }
IL을 보면 방어적 복사가 사라진 것을 확인할 수 있습니다.
// 일반 struct — in 전달, 비-readonly 메서드 호출 시 방어적 복사
.locals init ([0] valuetype NormalPoint)
IL_0000: ldarg.0 // in NormalPoint 주소 로드, static 메서드의 첫 번째 매개변수
IL_0001: ldobj NormalPoint // ← 방어적 복사: 값 전체를 임시 변수에 복사
IL_0006: stloc.0
IL_0007: ldloca.s 0
IL_0009: call instance void NormalPoint::SomeMethod() // 복사본에서 호출
// readonly struct — 방어적 복사 없음
IL_0000: ldarg.0 // in ReadonlyPoint 주소 로드 — static 메서드의 첫 번째 매개변수
IL_0001: call instance float32 ReadonlyPoint::Length() // 원본 참조로 직접 호출
Unity의 Vector3, Quaternion, Color 같은 내장 struct는 이 패턴의 실용적인 예시입니다.
ref struct (C# 7.2)
Span<T>는 배열, 스택 메모리, 관리되지 않는 메모리를 통일된 인터페이스로 다루는 타입입니다. 그런데 Span<T> 내부에는 메모리의 시작 주소(포인터)와 길이가 들어 있습니다. 이 타입이 힙에 boxing되면 GC가 힙을 재배치할 때 포인터가 dangling(참조 대상이 사라진 포인터)될 수 있습니다. ref struct는 힙에 절대 올라갈 수 없음을 컴파일러가 강제하여 이 문제를 원천 차단합니다.
public ref struct SpanWrapper
{
public Span<byte> Data;
}
// ref struct는 클래스 필드로 넣거나 boxing할 수 없음
// → Span<T>, ReadOnlySpan<T> 등이 ref struct인 이유
public class Container
{
// public SpanWrapper Wrapper; // ❌ CS8345: ref struct는 클래스 필드 불가
}
IL을 보면 ref struct는 항상 initobj로 스택에만 초기화되며, box 명령이 생성되지 않습니다.
// ref struct 인스턴스 — 스택에만 존재
.locals init ([0] valuetype SpanWrapper)
IL_0000: ldloca.s 0
IL_0001: initobj SpanWrapper // 스택에만 초기화
// box 명령이 생성되지 않음 — 컴파일러가 원천 차단
// GC 추적 불필요, Unity Burst 컴파일러 최적화 가능
Unity의 Burst 컴파일러나 고성능 데이터 처리 시 Span<T>와 함께 자주 등장합니다.
record struct (C# 10)
일반 struct로 데이터 컨테이너를 만들려면 Equals(), GetHashCode(), ToString(), == / != 연산자를 모두 직접 구현해야 했습니다. record struct는 컴파일러가 이 보일러플레이트(boilerplate, 반복적으로 작성해야 하는 상용 코드)를 자동으로 생성해 주는 struct 변형입니다.
// 선언 한 줄로 완성
public record struct DamageInfo(int Amount, DamageType Type);
// 아래 모든 것을 자동 생성:
// - 생성자
// - 각 프로퍼티 (get; set; — 기본적으로 가변)
// - Equals(), GetHashCode(), ToString()
// - == / != 연산자 (값 비교)
// - with 표현식 지원
with 표현식 — 비파괴적 변경
var original = new DamageInfo(100, DamageType.Fire);
var modified = original with { Amount = 50 }; // 원본 유지, Amount만 바꾼 새 복사본
Console.WriteLine(original.Amount); // 100 (변경 없음)
Console.WriteLine(modified.Amount); // 50
with 표현식은 struct를 값 복사한 뒤 지정 필드만 수정합니다. IL로 보면 이 과정이 명확합니다.
// var modified = original with { Amount = 50 }
IL_0008: ldloc.0 // original 로드 (struct이므로 스택에 값 복사됨)
IL_0009: stloc.1 // modified 위치에 복사본 저장
IL_000a: ldloca.s 1 // modified의 주소 로드
IL_000c: ldc.i4.s 50
IL_000e: call instance void DamageInfo::set_Amount(int32) // 복사본의 Amount만 50으로 변경
// original.Amount는 여전히 100 — 원본 불변
record struct vs record class 가변성 차이
// record class — 위치 매개변수 시 기본이 init-only (불변)
public record class Position(float X, float Y); // X, Y는 init only
// record struct — 위치 매개변수여도 기본이 가변 (읽기/쓰기 가능)
public record struct Position(float X, float Y); // X, Y는 get; set;
// 불변 record struct를 원하면:
public readonly record struct Position(float X, float Y); // X, Y는 get; init;
Unity에서 struct와 class를 사용할 때의 유의점
Unity 내장 struct 활용
Vector2, Vector3, Quaternion, Color, Rect, RaycastHit 등 Unity의 핵심 값 타입들은 모두 struct입니다. 매 프레임 수백 번 생성·소멸되는 데이터이므로 GC 부담 없이 스택에서 처리됩니다.
void Update()
{
// Vector3는 struct — 스택에서 생성, GC 부담 없음
Vector3 direction = transform.forward * Speed * Time.deltaTime;
transform.position += direction;
}
// Vector3 연산 — newobj 없음, 모두 스택에서 처리
IL_0000: call instance valuetype [UnityEngine]UnityEngine.Vector3 Transform::get_forward()
IL_0005: ldfld float32 PlayerController::Speed
IL_000a: call float32 [UnityEngine]UnityEngine.Time::get_deltaTime()
IL_000f: mul
IL_0010: call valuetype [UnityEngine]UnityEngine.Vector3 Vector3::op_Multiply(valuetype Vector3, float32)
// 결과 Vector3는 스택에만 존재, GC 대상 없음
RaycastHit — 배열 캐싱과 NonAlloc API
RaycastHit은 약 56바이트 크기의 struct입니다. 레이캐스트 결과를 담는 배열을 매 프레임 new로 생성하면 힙 할당이 반복되어 GC 압박이 누적됩니다.
// ❌ 나쁜 예: 매 프레임 배열 new → GC 발생
void Update()
{
RaycastHit[] hits = new RaycastHit[10]; // 매 프레임 힙 할당
Physics.RaycastNonAlloc(transform.position, transform.forward, hits, 100f);
}
// ✅ 좋은 예: 배열 한 번 할당 후 캐싱, in으로 복사 방지
private RaycastHit[] _hits = new RaycastHit[10]; // 한 번만 힙 할당
void Update()
{
int count = Physics.RaycastNonAlloc(transform.position, transform.forward, _hits, 100f);
for (int i = 0; i < count; i++)
ProcessHit(in _hits[i]); // in 키워드: 56바이트 복사 없이 참조 전달
}
void ProcessHit(in RaycastHit hit) { ... }
// ❌ 나쁜 예: 매 프레임 newarr → GC 대상
IL_0001: ldc.i4.s 10
IL_0003: newarr [UnityEngine]UnityEngine.RaycastHit // ← 매 프레임 힙 할당
// ✅ 좋은 예: 캐싱된 필드 로드 — 힙 할당 없음
IL_0001: ldarg.0
IL_0002: ldfld valuetype[] PlayerController::_hits // 기존 배열 참조만 로드
Physics.RaycastAll()은 결과 배열을 매번 새로 생성해 반환합니다. Physics.RaycastNonAlloc()은 미리 준비한 배열에 결과를 채워 넣으므로 GC 할당이 없습니다.
ContactPoint — GetContacts 버퍼 패턴
Collision.contacts 프로퍼티는 접근할 때마다 내부 배열을 복사해 반환합니다. GetContacts()를 미리 캐싱한 버퍼와 함께 사용하면 이 복사를 막을 수 있습니다.
// ❌ 나쁜 예: contacts 프로퍼티 접근 → 내부 배열 복사 GC
void OnCollisionEnter(Collision col)
{
ContactPoint first = col.contacts[0]; // 배열 복사 발생
}
// ✅ 좋은 예: GetContacts로 캐싱된 버퍼에 채우기
private ContactPoint[] _contactBuffer = new ContactPoint[8];
void OnCollisionEnter(Collision col)
{
int count = col.GetContacts(_contactBuffer); // GC 없음
for (int i = 0; i < count; i++)
{
Vector3 normal = _contactBuffer[i].normal;
ProcessContact(in _contactBuffer[i]);
}
}
이벤트 데이터를 struct로 전달
이벤트 인자를 class로 만들면 이벤트가 발생할 때마다 힙 할당이 생깁니다. 데이터가 작고 상속이 필요 없다면 struct와 Action<T>를 조합하면 GC 없이 전달할 수 있습니다.
// ❌ 나쁜 예: 이벤트 인자로 class 사용 → 발생마다 힙 할당
public class HitEventArgs : EventArgs
{
public Vector3 Point;
public float Damage;
}
event EventHandler<HitEventArgs> OnHit;
void FireEvent()
{
OnHit?.Invoke(this, new HitEventArgs { Point = hitPoint, Damage = 10f }); // 힙 할당
}
// ✅ 좋은 예: struct + Action<T> 조합 → GC 없음
public struct HitData
{
public Vector3 Point;
public float Damage;
}
event Action<HitData> OnHit;
void FireEvent()
{
OnHit?.Invoke(new HitData { Point = hitPoint, Damage = 10f }); // 스택 할당
}
// ❌ 나쁜 예: new HitEventArgs() — 힙 할당
IL_0001: newobj instance void HitEventArgs::.ctor() // GC 대상
// ✅ 좋은 예: new HitData — 스택 초기화
IL_0001: ldloca.s 0
IL_0002: initobj HitData // 스택에만 존재, GC 없음
정리
| 항목 | struct | class |
|---|---|---|
| 메모리 기본 위치 | 스택 | 힙 |
| 할당 비용 | 매우 저렴 | 상대적으로 비쌈 |
| GC 부담 | 없음 | 있음 |
| 복사 비용 | 크기에 비례 | 항상 포인터 크기(4/8 byte) |
| 상속 | 불가 | 가능 |
| null 기본 지원 | 불가 | 가능 |
| 불변 보장 | readonly struct |
readonly 필드 조합 |
| 데이터 표현 특화 | record struct (C# 10) |
record class (C# 9) |
| 적합한 크기 | 16~24 byte 이하 | 제한 없음 |
Unity에서 struct와 class의 선택은 단순히 문법의 문제가 아닙니다. Vector3가 struct인 이유, MonoBehaviour가 class인 이유 모두 이 원칙에서 출발합니다. 핫패스(매 프레임 또는 초당 수백 번 실행되는 성능 민감 경로)에서 GC 할당을 줄이고 싶다면 struct를 적극 활용하되, 크기가 커지면 오히려 독이 된다는 사실을 기억하세요.
'C# 심화' 카테고리의 다른 글
| AI 에이전트로 C# 개념 심화 블로그를 자동으로 만드는 방법 (0) | 2026.03.09 |
|---|
