[PART1.C# 런타임과 .NET 플랫폼 기초(8/11)] 값 타입 vs 참조 타입 — 스택과 힙의 첫 인상
struct와 class는 왜 다른 곳에 살까 / 대입하면 복사일까 참조일까 / 핫패스에서 왜 class가 문제가 되는가
목차
1. 왜 C#은 타입을 둘로 나누었는가
Unity에서 transform.position은 Vector3입니다. Vector3는 struct(값 타입)입니다. 반면 GameObject, List<T>, Transform은 모두 class(참조 타입)입니다. 왜 같은 "데이터"인데 어떤 것은 struct이고 어떤 것은 class일까요?
이 질문을 가볍게 넘기면 Unity에서 심각한 문제를 만납니다.
// Unity 모바일 게임의 흔한 코드
void Update() {
List<Enemy> nearby = FindNearbyEnemies(transform.position, 10f);
foreach (var e in nearby) { DrawMarker(e); }
}
이 코드는 매 프레임 new List<Enemy>()로 힙에 새 객체를 만듭니다. 60 FPS 게임이라면 초당 60번, 한 시간 플레이에 21만 번의 힙 할당이 쌓입니다. 어느 순간 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)가 동작하면서 프레임이 튑니다. 모바일에서 이건 게임 품질을 결정하는 문제입니다.
반대로 같은 데이터를 struct로 다루면 힙 할당이 0회가 됩니다. 왜 그럴까요? 답은 "값 타입과 참조 타입이 메모리 어디에 사는가"에 있습니다.
이번 글에서는 두 타입이 왜 다른 공간에 살고, 대입할 때 무엇이 복사되며, IL(Intermediate Language, C# 컴파일러가 생성하는 중간 언어) 레벨에서 어떤 명령어로 번역되는지를 처음부터 살펴봅니다. 이후 주제에서 다룰 박싱, readonly struct, ref struct의 토대가 되는 내용입니다.
2. 개념 정의 — 스택과 힙이라는 두 공간
비유: 책상 서랍과 창고
프로그램이 메모리를 쓰는 방식은 두 가지 공간으로 나뉩니다.
- 스택(Stack): 책상 서랍입니다. 메서드를 호출할 때 열고, 메서드가 끝나면 통째로 닫습니다. 정리가 자동이고 매우 빠릅니다. 대신 공간이 작고, 메서드 밖으로 가지고 나갈 수 없습니다.
- 힙(Heap): 큰 창고입니다. 아무 때나 공간을 빌릴 수 있고, 얼마든지 큰 물건을 둘 수 있습니다. 대신 누가 쓰는지 추적해야 해서 관리인(GC)이 주기적으로 청소를 옵니다. 청소하는 순간에는 다른 일이 잠시 멈춥니다.
C#은 이 두 공간을 상황에 맞게 나눠 쓰기 위해 타입을 두 종류로 만들었습니다.
- 값 타입(Value Type):
struct,enum,int·float·bool등 기본 타입. 변수 자체가 데이터 그 자체. - 참조 타입(Reference Type):
class,interface,delegate,string, 배열. 변수는 힙에 있는 객체의 주소만 들고 있음.
시각화: 변수는 무엇을 들고 있는가

기본 동작을 코드로 확인
아래 예시는 struct와 class가 대입할 때 어떻게 다르게 동작하는지를 보여줍니다. 같은 모양의 타입이지만 결과가 정반대입니다.
public struct PointV
{
public int X;
public int Y;
}
public class PointR
{
public int X;
public int Y;
}
public class Program
{
public static void Main()
{
// 값 타입 대입
PointV v1 = new PointV { X = 10, Y = 20 };
PointV v2 = v1; // 값 복사
v2.X = 100;
// 참조 타입 대입
PointR r1 = new PointR { X = 10, Y = 20 };
PointR r2 = r1; // 참조 복사
r2.X = 100;
}
}
쉬운 설명: v2.X = 100을 해도 v1.X는 여전히 10입니다. v2 = v1 한 순간 두 값은 남이 됐기 때문입니다. 반면 r2.X = 100을 하면 r1.X도 100이 됩니다. r2 = r1은 같은 객체를 가리키라고 알려준 것뿐이기 때문입니다.
기술 정의: 값 타입 대입은 데이터 복사(value copy, 모든 필드가 비트 단위로 복제), 참조 타입 대입은 참조 복사(reference copy, 힙 객체의 주소만 복제)입니다.
IL로 증명 — 대입은 실제로 다르다
위 C# 코드의 Main 메서드가 IL로 어떻게 변환되는지 봅시다.
.method public hidebysig static
void Main () cil managed
{
.maxstack 3
.entrypoint
.locals init (
[0] valuetype PointV, // v1 (스택에 struct 본체)
[1] valuetype PointV, // v2 (스택에 struct 본체)
[2] class PointR, // r1 (스택에 참조)
[3] class PointR, // r2 (스택에 참조)
[4] valuetype PointV // 컴파일러 임시 변수
)
// --- 값 타입: v1 = new PointV { X=10, Y=20 } ---
IL_0001: ldloca.s 4 // 임시 변수의 "주소" 로드 (값 타입 특징)
IL_0003: initobj PointV // 힙 할당 없이 메모리를 0으로 초기화
IL_0009: ldloca.s 4
IL_000b: ldc.i4.s 10
IL_000d: stfld int32 PointV::X
IL_0012: ldloca.s 4
IL_0014: ldc.i4.s 20
IL_0016: stfld int32 PointV::Y
IL_001b: ldloc.s 4 // 완성된 struct 값을 스택에 로드
IL_001d: stloc.0 // v1에 값 전체를 저장
// --- v2 = v1 (값 복사!) ---
IL_001e: ldloc.0 // v1의 "값"을 로드
IL_001f: stloc.1 // v2에 저장 = 필드 단위로 완전 복사
// --- v2.X = 100 ---
IL_0020: ldloca.s 1 // v2의 주소
IL_0022: ldc.i4.s 100
IL_0024: stfld int32 PointV::X // v2의 X만 변경 (v1과 무관)
// --- 참조 타입: r1 = new PointR { X=10, Y=20 } ---
IL_0029: newobj instance void PointR::.ctor() // 힙에 할당! (GC 대상)
IL_002e: dup
IL_002f: ldc.i4.s 10
IL_0031: stfld int32 PointR::X
IL_0036: dup
IL_0037: ldc.i4.s 20
IL_0039: stfld int32 PointR::Y
IL_003e: stloc.2 // r1에 "참조"(주소)만 저장
// --- r2 = r1 (참조 복사) ---
IL_003f: ldloc.2 // r1의 참조를 로드 (주소값)
IL_0040: stloc.3 // r2에 같은 주소 저장
// --- r2.X = 100 ---
IL_0041: ldloc.3 // r2가 가진 주소
IL_0042: ldc.i4.s 100
IL_0044: stfld int32 PointR::X // r1이 가리키는 객체와 동일 — r1.X도 100
IL_0049: ret
}
IL 해설 (핵심만):
initobjvsnewobj: struct는initobj로 스택 메모리를 0으로 초기화할 뿐 힙 할당이 없습니다. class는newobj가 반드시 필요하고 이 명령이 힙 할당을 수행합니다. Update 루프에서newobj가 호출되는 만큼 GC 압박이 쌓입니다.ldlocavsldloc: struct를 다룰 때는 주소(ldloca)로 접근해 제자리에서 필드를 씁니다. class는 참조 자체가 주소이므로ldloc으로 로드해 그대로 씁니다.stloc.0후stloc.1: IL_001e~001f에서 v1을 로드해 v2에 저장하는 두 줄이 struct 전체의 비트 복사입니다. struct가 커질수록 이 복사 비용이 커집니다..locals init차이: struct 변수는valuetype PointV로 선언되어 스택 프레임 안에 필드 공간 전체가 확보됩니다. class 변수는class PointR로 선언되어 참조(8바이트) 자리만 확보됩니다.
3. 내부 동작 — 변수가 선언된 곳이 곧 저장 위치
"struct는 무조건 스택"이라는 오해
이 문장은 반만 맞습니다. 정확한 규칙은 이렇습니다.
값 타입은 자신이 선언된 곳에 저장된다.
- 로컬 변수 · 메서드 파라미터로 선언 → 스택
- 클래스의 필드로 선언 → 그 클래스 객체 안에 = 힙
- 배열의 원소 → 배열이 힙에 있으므로 = 힙
- 박싱될 때 → 별도 Box 객체 안에 = 힙
Unity에서 자주 보는 패턴입니다.
public struct Point
{
public int X;
public int Y;
}
public class Entity
{
public Point Position; // Entity 객체 안에 함께 저장됨 (힙)
public int Health;
}
public class Program
{
public static void Main()
{
Entity e = new Entity();
e.Position.X = 10; // 힙 안의 Point.X에 직접 쓰기
e.Health = 100;
}
}
시각화: 클래스 필드로 박힌 struct

Point는 분명 struct이지만 Entity 객체가 힙에 만들어진 순간 Position 필드는 힙 안에 있습니다. 값 타입이 스택에 간다는 규칙은 "로컬 변수일 때"만 유효합니다.
IL로 증명 — 필드 안의 struct는 주소 접근
.method public hidebysig static
void Main () cil managed
{
.locals init (
[0] class Entity
)
IL_0001: newobj instance void Entity::.ctor() // 힙에 Entity 할당
IL_0006: stloc.0
// e.Position.X = 10
IL_0007: ldloc.0 // Entity 참조 로드
IL_0008: ldflda valuetype Point Entity::Position // Position 필드의 "주소" 로드
IL_000d: ldc.i4.s 10
IL_000f: stfld int32 Point::X // 힙 내부의 Point.X에 직접 쓰기
// e.Health = 100
IL_0014: ldloc.0
IL_0015: ldc.i4.s 100
IL_0017: stfld int32 Entity::Health
IL_001c: ret
}
IL 해설:
ldflda: "필드의 주소를 로드"하는 명령입니다. 값 타입 필드를 수정할 때 컴파일러는 힙 내부의 주소를 계산해서 그 자리에 직접 쓰기를 합니다. 별도 복사본을 만들어 수정한 뒤 되돌려 놓는 게 아닙니다.- 임시 값 타입 복사 없음: 만약
e.Position전체를 가져와 수정한 뒤 다시 대입했다면ldfld+ 임시 변수 +stfld가 필요했을 것입니다. 컴파일러는ldflda로 한 단계에 끝냅니다.
대입 vs 파라미터 전달
함수로 값을 넘길 때도 대입과 같은 규칙이 적용됩니다. C#은 기본적으로 by-value 전달입니다. 다만 "값"이 무엇이냐가 타입마다 다릅니다.
- 값 타입 전달: 데이터 전체가 호출 스택에 복사됨
- 참조 타입 전달: 참조(주소)가 복사됨 — 객체 자체는 공유
public struct PointV
{
public int X;
public int Y;
}
public class PointR
{
public int X;
public int Y;
}
public class Program
{
// 값 타입 파라미터 — 값 전체가 복사되어 전달됨
static void MoveV(PointV p)
{
p.X += 1;
}
// 참조 타입 파라미터 — 참조만 복사되어 전달됨
static void MoveR(PointR p)
{
p.X += 1;
}
public static void Main()
{
PointV v = new PointV { X = 10, Y = 20 };
MoveV(v); // 원본 v 변경 없음 (v.X == 10)
PointR r = new PointR { X = 10, Y = 20 };
MoveR(r); // 원본 r이 가리키는 객체가 변경됨 (r.X == 11)
}
}
쉬운 설명: MoveV(v)에 넘긴 p는 v의 복사본입니다. 메서드 안에서 p.X를 올려도 메서드 밖의 v는 그대로입니다. MoveR(r)에 넘긴 p는 같은 객체를 가리키는 또 다른 명찰입니다. 메서드 안에서 객체를 고치면 메서드 밖 r로 봐도 고쳐진 값이 보입니다.
IL로 증명 — 파라미터 전달의 차이
.method private hidebysig static
void MoveV (
valuetype PointV p // p는 struct 값 전체 (필드 공간 포함)
) cil managed
{
IL_0001: ldarga.s p // 값 타입 파라미터의 "주소"를 로드
IL_0003: ldflda int32 PointV::X
IL_0008: dup
IL_0009: ldind.i4
IL_000a: ldc.i4.1
IL_000b: add
IL_000c: stind.i4 // 복사본 p의 X만 수정
IL_000d: ret
}
.method private hidebysig static
void MoveR (
class PointR p // p는 참조(주소)
) cil managed
{
IL_0001: ldarg.0 // 참조 파라미터는 그대로 ldarg
IL_0002: dup
IL_0003: ldfld int32 PointR::X
IL_0008: ldc.i4.1
IL_0009: add
IL_000a: stfld int32 PointR::X // 힙에 있는 원본 객체의 X를 수정
IL_000f: ret
}
// --- Main 쪽 호출 지점 ---
IL_001d: ldloc.0 // v의 "값 전체"를 로드 (복사)
IL_001e: call void Program::MoveV(valuetype PointV) // 스택에 복사본 푸시 후 호출
IL_003a: ldloc.1 // r의 참조(주소)만 로드
IL_003b: call void Program::MoveR(class PointR) // 참조만 푸시
IL 해설:
ldloc.0→call MoveV: struct 파라미터 전달은 로컬 변수의 값 전체를 호출 스택에 푸시합니다. struct가 큰 경우(예: 64바이트) 매 호출마다 이 복사 비용이 발생합니다.ref·in키워드가 존재하는 이유입니다.ldarg.0vsldarga.s p: 참조 타입 파라미터는ldarg로 그냥 주소를 가져옵니다. 값 타입 파라미터는ldarga.s(파라미터의 주소)로 접근해 필드 단위로 조작합니다.callvirt안 나옴: 정적 메서드라call입니다. 인스턴스 메서드라면callvirt가 등장하는데, 이 구분은 후속 주제에서 다룹니다.
4. 실전 적용 — Unity 핫패스에서의 Before/After
Unity의 Update, FixedUpdate, LateUpdate 메서드는 매 프레임 실행되는 핫패스(Hot Path)입니다. 60 FPS라면 초당 60번, 120 FPS라면 초당 120번 실행됩니다. 여기서 힙 할당이 쌓이면 일정 시점에 GC가 동작하며 GC 스파이크(GC spike, 프레임이 순간적으로 길어지는 현상)가 발생합니다. 모바일에서는 수십 ms 단위의 프레임 드랍이 눈에 띕니다.
이 문제를 만드는 주범 두 가지가 바로 매 프레임 class 할당과 struct를 잘못 다뤄서 박싱 발생입니다. 후자(박싱)는 후속 주제에서 다루고, 여기서는 전자를 봅니다.
Before — 참조 타입 남용, 매 프레임 GC 압박
using System.Collections.Generic;
public class Enemy
{
public float X;
public float Y;
public float Z;
public float Health;
}
public class BeforeSystem
{
// Update 루프에서 매 프레임 호출됨
public List<Enemy> FindNearby()
{
var result = new List<Enemy>(); // 매번 List 힙 할당
result.Add(new Enemy { X = 1, Y = 2, Z = 3, Health = 100 }); // Enemy도 힙 할당
return result;
}
}
60 FPS · 주변 적 10마리가 있다고 가정하면 초당:
List<Enemy>× 60Enemy× 600
0세대 힙이 빠르게 차오르고 GC가 동작할 때마다 프레임이 튑니다.
After — 값 타입 + 버퍼 재사용, 할당 0
// 값 타입으로 전환
public struct EnemyInfo
{
public float X;
public float Y;
public float Z;
public float Health;
}
public class AfterSystem
{
private EnemyInfo[] _buffer = new EnemyInfo[64]; // 한 번만 할당
// Update 루프에서 매 프레임 호출됨 — 힙 할당 0
public int FindNearby()
{
_buffer[0] = new EnemyInfo { X = 1, Y = 2, Z = 3, Health = 100 };
return 1; // 사용된 슬롯 개수 반환
}
}
EnemyInfo는 struct →new를 해도 힙 할당 없음_buffer는 생성자에서 한 번만 할당, 이후 재사용- 반환값은
int(값 타입) → 컬렉션 객체를 반환하지 않음
같은 기능인데 매 프레임 힙 할당이 0입니다.
IL로 증명 — newobj 횟수를 세어보면 답이 나온다
Before 쪽 IL:
.method public hidebysig
instance class [System.Collections]System.Collections.Generic.List`1<class Enemy> FindNearby () cil managed
{
IL_0001: newobj instance void class [System.Collections]System.Collections.Generic.List`1<class Enemy>::.ctor() // ① List 할당
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: newobj instance void Enemy::.ctor() // ② Enemy 할당
IL_000d: dup
IL_000e: ldc.r4 1
IL_0013: stfld float32 Enemy::X
IL_0018: dup
IL_0019: ldc.r4 2
IL_001e: stfld float32 Enemy::Y
IL_0023: dup
IL_0024: ldc.r4 3
IL_0029: stfld float32 Enemy::Z
IL_002e: dup
IL_002f: ldc.r4 100
IL_0034: stfld float32 Enemy::Health
IL_0039: callvirt instance void class [System.Collections]System.Collections.Generic.List`1<class Enemy>::Add(!0)
IL_003e: nop
IL_003f: ldloc.0
IL_0040: stloc.1
IL_0043: ldloc.1
IL_0044: ret
}
After 쪽 IL:
.method public hidebysig
instance int32 FindNearby () cil managed
{
.locals init (
[0] valuetype EnemyInfo, // struct 지역 변수 — 힙 아님
[1] int32
)
IL_0001: ldarg.0
IL_0002: ldfld valuetype EnemyInfo[] AfterSystem::_buffer // 이미 할당된 버퍼 로드
IL_0007: ldc.i4.0
IL_0008: ldloca.s 0
IL_000a: initobj EnemyInfo // 힙 할당 없이 스택 메모리 초기화
IL_0010: ldloca.s 0
IL_0012: ldc.r4 1
IL_0017: stfld float32 EnemyInfo::X
IL_001c: ldloca.s 0
IL_001e: ldc.r4 2
IL_0023: stfld float32 EnemyInfo::Y
IL_0028: ldloca.s 0
IL_002a: ldc.r4 3
IL_002f: stfld float32 EnemyInfo::Z
IL_0034: ldloca.s 0
IL_0036: ldc.r4 100
IL_003b: stfld float32 EnemyInfo::Health
IL_0040: ldloc.0
IL_0041: stelem EnemyInfo // 배열 원소 자리에 값 복사
IL_0046: ldc.i4.1
IL_0047: stloc.1
IL_004a: ldloc.1
IL_004b: ret
}
IL 해설:
newobj횟수 = 힙 할당 횟수: Before에는newobj가 2개(List, Enemy)입니다. 핫패스에서 이 명령은 호출 수만큼 힙을 소모합니다. After는newobj가 0개입니다.initobj+ldloca: After의 struct 생성은 스택 임시 변수를 초기화하고 필드에 값을 채워 넣은 뒤stelem으로 배열 원소 자리에 복사합니다. 할당이 아닌 복사입니다.callvirt List<T>::Add: Before에는 가상 호출이 있고 내부적으로 필요하면 List 내부 배열을 또 힙에 재할당합니다. After는 고정 배열이라 재할당이 없습니다.- Unity Profiler 관점: Before 쪽
newobj는 Profiler의GC Alloc컬럼에 표시되지만 After 쪽initobj+stelem은 표시되지 않습니다. 핫패스 최적화의 가장 빠른 확인 방법입니다.
5. 함정과 주의사항
함정 1 — struct 프로퍼티를 통한 수정은 원본을 바꾸지 않는다
Unity에서 자주 마주치는 함정입니다.
// ❌ 초보자가 흔히 쓰는 코드
transform.position.x = 10; // 컴파일 에러!
transform.position은 Vector3 struct를 복사해서 반환하는 프로퍼티입니다. 반환된 복사본의 x를 수정해도 원본에는 영향이 없습니다. C# 컴파일러는 이 실수를 감지하고 에러로 막습니다. 올바른 방법은 이렇습니다.
// ✅ 값 전체를 새로 만들어 대입
Vector3 p = transform.position;
p.x = 10;
transform.position = p;
쉬운 설명: struct 프로퍼티는 돌려준 순간 사진(복사본)입니다. 사진에 낙서를 해도 실물은 바뀌지 않습니다. 실물을 바꾸려면 사진을 고쳐서 다시 "이게 새 실물이다" 라고 건네주어야 합니다.
함정 2 — 큰 struct의 반복 복사
// ❌ 64바이트 struct를 매 호출마다 복사
public struct BigData
{
public float A, B, C, D, E, F, G, H;
public float I, J, K, L, M, N, O, P;
}
void Process(BigData data) { /* ... */ } // 호출마다 64B 복사
// ✅ in 키워드로 읽기 전용 참조 전달 (C# 7.2+)
void Process(in BigData data) { /* ... */ } // 8B 참조만 전달
in— 읽기 전용 참조 파라미터 (readonly reference parameter) 값 타입을 참조로 전달하되 메서드 안에서 수정하지 못하게 막는다. 큰 struct의 복사 비용을 없애면서 변경 불가 의미를 함께 보장한다.
예시:void Process(in BigData data) { ... }— 호출자는Process(big)처럼 그대로 호출하지만 내부는 복사 없이 참조로 받음
일반 지침: struct 크기가 16바이트를 넘으면 복사 비용을 의심해야 합니다.
함정 3 — 박싱 (깊이는 후속 주제에서)
값 타입을 object·인터페이스·ArrayList·Hashtable 등 참조 타입 자리에 넣으면 박싱(Boxing)이 발생합니다. 힙에 Box 객체를 할당하고 값을 복사하므로 스택의 장점이 사라집니다.
public class Program
{
public static void Main()
{
int i = 42;
object o = i; // 박싱: 힙에 Box 할당 + 복사
int j = (int)o; // 언박싱: 타입 검증 + 복사
}
}
IL:
.method public hidebysig static
void Main () cil managed
{
.locals init (
[0] int32,
[1] object,
[2] int32
)
IL_0001: ldc.i4.s 42
IL_0003: stloc.0
IL_0004: ldloc.0
IL_0005: box [System.Runtime]System.Int32 // ← 박싱 발생 (힙 할당)
IL_000a: stloc.1
IL_000b: ldloc.1
IL_000c: unbox.any [System.Runtime]System.Int32 // ← 언박싱 (복사)
IL_0011: stloc.2
IL_0012: ret
}
box 명령어는 힙에 새 객체를 할당하고 값을 그 안으로 복사합니다. 이번 글에서는 존재만 기억하고, 어떤 상황에서 자주 발생하는지(LINQ·foreach·Dictionary 등)와 방어책(Equals 오버라이드, 제네릭 사용 등)은 "박싱과 언박싱" 주제에서 자세히 다룹니다.
함정 4 — 캡처된 클로저는 값 타입이라도 힙으로 간다
// ❌ Update 안에서 로컬 struct를 람다가 캡처
void Update() {
Vector3 pos = transform.position; // struct
Invoke(() => Move(pos)); // pos가 클로저에 캡처됨 → 힙 할당!
}
컴파일러는 캡처된 변수를 담을 숨은 class(<>c__DisplayClass)를 생성해 힙에 할당합니다. 이 class의 필드로 pos가 복사돼 들어갑니다. struct라고 방심하면 안 됩니다.
해결: 캡처를 피하거나, Action 대신 제네릭 상태 파라미터를 쓰거나, 람다를 미리 static 필드로 만들어 재사용합니다.
6. C# 버전별 변화
값 타입과 참조 타입이라는 구분 자체는 C# 1.0부터 존재하는 기본 설계입니다. 변화는 "값 타입을 얼마나 더 안전하고 빠르게 쓸 수 있는가" 쪽에서 일어났습니다. 아래 항목은 이번 글에서 이름만 예고합니다. 각각 별도 주제로 깊이 다룹니다.
| 버전 | 기능 | 의미 |
|---|---|---|
| C# 1.0 | struct / class |
값·참조 타입 구분의 기본 |
| C# 2.0 | Nullable<T> |
값 타입에 null 허용 |
| C# 7.0 | ref 반환 / ref 로컬 |
값 타입을 참조로 돌려주기 |
| C# 7.2 | in 파라미터 |
값 타입을 읽기 전용 참조로 전달 |
| C# 7.2 | readonly struct |
불변 값 타입 — 방어적 복사 제거 |
| C# 7.2 | ref struct |
스택 전용 타입 (Span<T>) |
| C# 10 | record struct |
값 타입에 값 시맨틱스 자동 구현 |
| C# 12 | 인라인 배열 | 고정 크기 struct 배열 |
요지: C#은 값 타입을 점점 더 "힙에 흘러들지 않도록" 잠그는 방향으로 발전했습니다. Span<T>, ReadOnlySpan<T>, stackalloc, Memory<T> 같은 도구가 모두 이 흐름의 결과입니다.
7. 정리
이번 글의 핵심 7가지입니다. Unity 신입 개발자가 실무에서 가장 먼저 마주치는 내용입니다.
- [ ] 값 타입은 데이터 그 자체, 참조 타입은 힙 객체의 주소.
struct·int·enum은 전자,class·string·array는 후자. - [ ] "struct는 스택"은 반만 맞다. 로컬·파라미터면 스택, 클래스 필드·배열 원소·박싱되면 힙.
- [ ] 값 타입 대입은 데이터 복사, 참조 타입 대입은 주소 복사. 대입 후 한쪽을 수정했을 때 다른 쪽이 바뀌면 참조, 안 바뀌면 값.
- [ ] IL에서
newobj는 힙 할당의 유일한 증거. struct는initobj+ldloca+stfld로 해결되고newobj가 나오지 않는다. - [ ] 메서드 파라미터 전달도 대입과 같은 규칙. 값 타입은 값 전체 복사, 참조 타입은 주소만 복사.
- [ ] Unity 핫패스의 첫 번째 규칙 —
Update에서new class금지. 버퍼·리스트는 멤버 필드로 한 번 할당하고Clear()로 재사용. - [ ] 박싱·
readonly struct·ref struct는 이번 글의 연장선. 값 타입을 "힙으로 새지 않게" 만드는 도구들이며 후속 주제에서 다룬다.
다음 주제에서는 이번 글에서 예고만 한 박싱과 언박싱을 깊게 들여다봅니다. Vector3를 object에 넣는 순간, struct가 IEnumerable로 캐스팅되는 순간, Dictionary<int, ...>의 키를 비교하는 순간 — 언제 박싱이 발생하고 IL에서 어떻게 보이며 어떻게 방어하는지를 다룰 예정입니다.