| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 프레임워크
- 2D Camera
- 최적화
- unity
- TextMeshPro
- ui
- Custom Package
- 직장인공부
- base64
- RSA
- 암호화
- Tween
- Dots
- 직장인자기계발
- DotsTween
- 패스트캠퍼스
- 환급챌린지
- 오공완
- 패스트캠퍼스후기
- sha
- Framework
- job
- AES
- C#
- 샘플
- 게임개발
- 가이드
- Job 시스템
- adfit
- Unity Editor
- Today
- Total
EveryDay.DevUp
값 타입과 참조 타입 — C#은 왜 둘로 나눴는가 본문
값 타입과 참조 타입 — C#은 왜 둘로 나눴는가
스택과 힙의 역할 / 복사 의미론 vs 참조 의미론 / 언제 어디에 할당되는가
목차
왜 이걸 알아야 하는가
Unity에서 매 프레임 Update()가 호출될 때, Vector3 하나를 만들면 메모리 어디에 생기는 걸까? new Enemy()와 new DamageInfo()는 같은 new인데 왜 성능 영향이 다를까?
C# 코드 한 줄의 성능 차이가 60fps와 30fps를 가르는 모바일 게임에서, 데이터가 메모리 어디에 살고, 어떻게 복사되는지를 모르면 프로파일러 숫자를 읽을 수도, 최적화를 할 수도 없다.
결론부터 말하면 — C#에는 두 종류의 타입이 있다:
- 값 타입(Value Type): 데이터 자체를 품고 있다. 넘기면 통째로 복사된다.
- 참조 타입(Reference Type): 데이터가 있는 위치(주소)만 품고 있다. 넘기면 주소만 복사된다.
이 차이 하나가 메모리 할당, GC(Garbage Collector, 사용하지 않는 메모리를 자동으로 회수하는 런타임 구성요소) 스파이크, 캐시 효율, 버그 패턴 전부를 결정한다.
개념 정의 — 값과 참조, 무엇이 다른가
택배 상자와 사물함 열쇠
일상에서 친구에게 무언가를 전달하는 두 가지 방법을 떠올려 보자.
방법 A — 택배 상자: 내 물건과 똑같은 복제품을 상자에 넣어 보낸다. 친구가 받은 물건을 부숴도 내 원본은 멀쩡하다. 이것이 값 타입이다.
방법 B — 사물함 열쇠: 물건은 역 사물함에 넣어두고, 열쇠 복사본만 친구에게 건넨다. 친구가 사물함을 열어 물건을 바꾸면 나도 바뀐 물건을 보게 된다. 이것이 참조 타입이다.
값 타입 — 데이터 자체를 품는다
쉬운 설명: 변수 안에 데이터가 직접 들어 있다. 다른 변수에 대입하면 데이터가 통째로 복사되어 서로 완전히 독립된다.
기술 정의: 값 타입은 System.ValueType을 상속하는 타입으로, struct, enum, 그리고 int, float, bool 같은 기본 숫자/논리 타입이 해당한다. 변수에 할당할 때 값 의미론(value semantics)을 따라 비트 단위 복사(bitwise copy)가 일어난다.
public struct Point
{
public int X;
public int Y;
}
public class Program
{
public static void ValueTypeCopy()
{
Point a = new Point { X = 10, Y = 20 };
Point b = a; // a의 데이터(10, 20)가 b로 통째로 복사
b.X = 99; // b만 변경 — a는 영향 없음
// a.X == 10, b.X == 99
}
public static void Main() { }
}
.method public hidebysig static
void ValueTypeCopy () cil managed
{
.locals init (
[0] valuetype Point, // a
[1] valuetype Point, // b
[2] valuetype Point // 초기화용 임시 변수
)
IL_0001: ldloca.s 2 // 임시 변수의 주소를 로드
IL_0003: initobj Point // Point를 0으로 초기화 (힙 할당 없음!)
IL_0009: ldloca.s 2
IL_000b: ldc.i4.s 10 // X = 10
IL_000d: stfld int32 Point::X
IL_0012: ldloca.s 2
IL_0014: ldc.i4.s 20 // Y = 20
IL_0016: stfld int32 Point::Y
IL_001b: ldloc.2
IL_001c: stloc.0 // a에 저장
IL_001d: ldloc.0 // a의 값을 스택에 로드
IL_001e: stloc.1 // b에 저장 — 8바이트 전체가 복사됨
IL_001f: ldloca.s 1 // b의 주소
IL_0021: ldc.i4.s 99
IL_0023: stfld int32 Point::X // b.X만 99로 변경
IL_0028: ret
}
핵심은 IL_0003: initobj다. 참조 타입이 newobj(힙 할당)를 쓰는 것과 달리, 값 타입은 initobj로 스택 위의 메모리를 0으로 초기화할 뿐이다. 힙 할당이 전혀 없다.
IL_001d~001e의 ldloc.0 → stloc.1이 복사 과정이다. Point는 int 2개(8바이트)이므로, 8바이트가 통째로 복사된다.
참조 타입 — 위치(주소)만 품는다
쉬운 설명: 변수는 데이터가 있는 곳의 주소만 갖고 있다. 다른 변수에 대입하면 주소만 복사되므로, 두 변수가 같은 데이터를 바라본다.
기술 정의: 참조 타입은 System.Object를 직접 상속하는 타입으로, class, interface, delegate, string 등이 해당한다. 변수에는 관리되는 힙(managed heap)에 할당된 객체의 참조(메모리 주소)가 저장되며, 대입 시 참조만 복사되는 참조 의미론(reference semantics)을 따른다.
public class Player
{
public int Health;
}
public class Program
{
public static void ReferenceTypeCopy()
{
Player p1 = new Player { Health = 100 };
Player p2 = p1; // p1이 가리키는 주소만 p2로 복사
p2.Health = 50; // p1과 p2는 같은 객체 → p1.Health도 50
}
public static void Main() { }
}
.method public hidebysig static
void ReferenceTypeCopy () cil managed
{
.locals init (
[0] class Player, // p1 — 힙 객체의 주소를 저장
[1] class Player // p2 — 역시 주소만 저장
)
IL_0001: newobj instance void Player::.ctor() // 힙에 Player 객체 생성
IL_0006: dup // 같은 참조를 스택에 복제
IL_0007: ldc.i4.s 100
IL_0009: stfld int32 Player::Health // Health = 100
IL_000e: stloc.0 // p1에 참조 저장
IL_000f: ldloc.0 // p1의 참조를 스택에 로드
IL_0010: stloc.1 // p2에 저장 — 주소(8바이트)만 복사
IL_0011: ldloc.1
IL_0012: ldc.i4.s 50
IL_0014: stfld int32 Player::Health // p2를 통해 Health 변경
IL_0019: ret
}
IL_0001: newobj — 이것이 힙 할당이다. initobj와 달리 newobj는 GC가 관리하는 힙에 새 객체를 만든다. IL_000f~0010의 ldloc.0 → stloc.1은 값 타입 복사와 IL 명령어가 똑같아 보이지만, 복사되는 것은 데이터가 아니라 8바이트짜리 참조(주소)다.
한눈에 비교
| 구분 | 값 타입 | 참조 타입 |
|---|---|---|
| 예시 | struct, enum, int, float, bool |
class, interface, delegate, string |
| 변수에 담기는 것 | 데이터 자체 | 힙 객체의 주소 |
| 대입 시 동작 | 데이터 전체 복사 | 주소만 복사 |
| IL 생성 명령어 | initobj (힙 할당 없음) |
newobj (힙 할당) |
| 기본값 | 0, false 등 |
null |
| 상속 | System.ValueType |
System.Object |
내부 동작 — 스택과 힙, 그리고 메모리 레이아웃
스택과 힙의 역할
CLR(Common Language Runtime, C# 코드를 실행하는 .NET의 런타임 엔진)은 프로그램 실행 시 메모리를 크게 두 영역으로 나누어 사용한다.
스택(Stack): 접시를 쌓듯이 위로 쌓이고 위에서 꺼내는(LIFO) 구조다. 메서드가 호출되면 해당 메서드의 지역 변수를 위한 공간(스택 프레임)이 쌓이고, 메서드가 끝나면 그 프레임이 통째로 제거된다. 포인터 하나만 이동하면 되므로 할당과 해제가 극도로 빠르다. GC가 관여하지 않는다.
힙(Heap): 크기와 수명이 정해지지 않은 데이터를 위한 넓은 공간이다. new로 참조 타입 객체를 생성하면 CLR이 힙의 빈 공간을 찾아 할당하고, 더 이상 참조하는 변수가 없으면 GC가 자동으로 회수한다.
"값 타입 = 스택"은 틀렸다
많은 입문서가 "값 타입은 스택에, 참조 타입은 힙에 할당된다"고 가르친다. 반만 맞는 말이다.
정확한 규칙은 이렇다:
| 타입 | 저장 위치 | 이유 |
|---|---|---|
| 참조 타입 인스턴스 | 항상 힙 | 크기와 수명이 동적이므로 |
| 값 타입 — 지역 변수/매개변수 | 스택 (또는 CPU 레지스터) | 메서드 수명과 같으므로 |
| 값 타입 — 클래스의 필드 | 클래스 객체와 함께 힙 | 클래스 객체 안에 인라인 배치 |
| 값 타입 — 배열의 요소 | 배열 객체와 함께 힙 | 배열은 참조 타입이므로 |
| 값 타입 — 박싱(Boxing) 시 | 힙 | 값을 참조 타입으로 감싸므로 |
핵심: 값 타입의 저장 위치는 "선언된 곳"이 결정한다.
public struct DamageInfo
{
public int Amount;
public int Type;
}
public class Enemy
{
public int Health;
public DamageInfo LastDamage; // 값 타입이 클래스 내부에 선언됨
}
public class Program
{
public static void StructOnStack()
{
// DamageInfo가 지역 변수 → 스택에 할당
DamageInfo dmg = new DamageInfo { Amount = 50, Type = 1 };
int amount = dmg.Amount;
}
public static void StructInsideClass()
{
// Enemy는 참조 타입 → 힙에 할당
// Enemy 안의 LastDamage도 Enemy 객체와 함께 힙에 인라인 배치
Enemy enemy = new Enemy();
enemy.Health = 100;
enemy.LastDamage = new DamageInfo { Amount = 30, Type = 2 };
}
public static void Main() { }
}
// StructOnStack — 지역 변수이므로 스택 할당
.method public hidebysig static
void StructOnStack () cil managed
{
.locals init (
[0] valuetype DamageInfo, // 스택에 8바이트 확보
[1] int32,
[2] valuetype DamageInfo
)
IL_0001: ldloca.s 2
IL_0003: initobj DamageInfo // 스택 메모리를 0으로 초기화 — newobj 없음!
IL_000b: ldc.i4.s 50
IL_000d: stfld int32 DamageInfo::Amount
IL_001c: ldloc.0
IL_001d: ldfld int32 DamageInfo::Amount // 스택에서 직접 필드 읽기
IL_0022: stloc.1
IL_0023: ret
}
// StructInsideClass — 클래스 필드이므로 힙에 인라인 배치
.method public hidebysig static
void StructInsideClass () cil managed
{
.locals init (
[0] class Enemy, // 힙 객체 참조
[1] valuetype DamageInfo // 임시 변수 (스택)
)
IL_0001: newobj instance void Enemy::.ctor() // 힙에 Enemy 할당
IL_0006: stloc.0
IL_000a: stfld int32 Enemy::Health
IL_0010: ldloca.s 1
IL_0012: initobj DamageInfo // 임시로 스택에서 DamageInfo 초기화
IL_001c: stfld int32 DamageInfo::Amount
IL_0024: stfld int32 DamageInfo::Type
IL_0029: ldloc.1
IL_002a: stfld valuetype DamageInfo Enemy::LastDamage // 힙의 Enemy 안에 복사
IL_002f: ret
}
StructOnStack에서 DamageInfo는 initobj로 스택에서 초기화되고 newobj가 전혀 없다. 반면 StructInsideClass에서는 Enemy 객체가 newobj로 힙에 생성되고, DamageInfo는 IL_002a: stfld로 그 힙 객체 안에 직접 기록된다.
참조 타입의 숨겨진 비용 — Object Header
참조 타입 객체가 힙에 할당될 때, 실제 데이터 외에 CLR이 관리에 필요한 추가 메모리가 붙는다:
| 영역 | 크기 (64비트) | 역할 |
|---|---|---|
| Sync Block Index | 8바이트 | 락(lock) 등 동기화에 사용 |
| Method Table Pointer | 8바이트 | 타입 정보, 가상 메서드 테이블 주소 |
| 실제 데이터 | 가변 | 필드 값 |
int 필드 하나만 가진 클래스도 최소 24바이트(헤더 16 + 데이터 4 + 패딩 4)를 차지한다. 같은 데이터를 값 타입(struct)으로 만들면 4바이트면 충분하다.
작은 데이터를 참조 타입으로 만들면 실제 데이터보다 오버헤드가 더 클 수 있다. Unity에서 수천 개의 타일 정보를 class로 만들면 헤더 오버헤드만 수십 KB가 된다.
실전 적용 — Unity에서 어떻게 쓰는가
Vector3가 struct인 이유
Unity의 Vector3, Quaternion, Color, RaycastHit는 모두 struct(값 타입)이다. 이유는 단순하다 — 매 프레임 수없이 생성되고 사라지는 작은 데이터이기 때문이다.
만약 Vector3가 class였다면, transform.position += new Vector3(1, 0, 0) 한 줄이 매 프레임 힙에 새 객체를 할당하고, 기존 객체는 쓰레기가 되어 GC의 부담을 키운다. struct이므로 스택에서 생성되고 메서드가 끝나면 자동으로 사라진다. GC는 관여할 필요가 없다.
❌ Before: 매 프레임 힙 쓰레기를 만드는 코드
public class Enemy
{
public int Health;
}
public class Program
{
// Update()에서 매 프레임 호출된다고 가정
public static void SpawnDamageInfo()
{
// class이므로 매번 힙에 할당 → GC 부담
Enemy enemy = new Enemy();
enemy.Health = 100;
}
public static void Main() { }
}
✅ After: 힙 할당 없는 코드
public struct DamageInfo
{
public int Amount;
public int Type;
}
public class Program
{
// Update()에서 매 프레임 호출된다고 가정
public static void ProcessDamage()
{
// struct이므로 스택에 할당 → GC 부담 없음
DamageInfo dmg = new DamageInfo { Amount = 50, Type = 1 };
int amount = dmg.Amount;
}
public static void Main() { }
}
앞서 IL 분석에서 확인했듯이, struct의 new는 initobj(스택 초기화)이고 class의 new는 newobj(힙 할당)이다. Update()에서 60fps로 호출되면 newobj는 초당 60번 힙에 쓰레기를 만든다.
struct를 쓸지 class를 쓸지 판단 기준
| 조건 | struct | class |
|---|---|---|
| 크기가 16바이트 이하인가? | ✅ | — |
| 만든 후 값이 변하지 않는가(불변)? | ✅ | — |
| 고유한 정체성(identity)이 필요한가? | — | ✅ |
| 상속이 필요한가? | — | ✅ |
| 여러 곳에서 같은 인스턴스를 공유해야 하는가? | — | ✅ |
Unity에서의 실용적 분류:
- struct:
DamageInfo,PathNode,TileData— 데이터 묶음 자체로 의미가 있고, 신원이 중요하지 않은 것 - class:
EnemyAI,PlayerController,UIManager— 고유한 생명주기와 상태를 가지며, 다른 시스템에서 참조해야 하는 것.MonoBehaviour자체가class다.
함정과 주의사항
함정 1: 인터페이스에 struct를 넘기면 박싱이 발생한다
박싱(Boxing)이란 값 타입을 object나 인터페이스 타입으로 변환할 때, CLR이 힙에 새 객체를 만들어 값을 복사하는 과정이다. 반대로 다시 값 타입으로 꺼내는 것이 언박싱(Unboxing)이다.
public class Program
{
// Before: boxing 발생
public static void BoxingExample()
{
int score = 42;
object boxed = score; // boxing — 힙에 24바이트 할당
int unboxed = (int)boxed; // unboxing — 힙에서 값 추출
}
// After: 제네릭으로 boxing 제거
public static void GenericExample()
{
System.Collections.Generic.List<int> scores = new();
scores.Add(42); // boxing 없음 — int로 직접 저장
int first = scores[0];
}
public static void Main() { }
}
// ❌ BoxingExample — box와 unbox.any가 보인다
.method public hidebysig static
void BoxingExample () cil managed
{
.locals init (
[0] int32,
[1] object,
[2] int32
)
IL_0001: ldc.i4.s 42
IL_0003: stloc.0 // score = 42
IL_0004: ldloc.0
IL_0005: box [System.Runtime]System.Int32 // 💥 힙에 새 객체 생성!
IL_000a: stloc.1 // boxed에 힙 참조 저장
IL_000b: ldloc.1
IL_000c: unbox.any [System.Runtime]System.Int32 // 힙에서 값 추출
IL_0011: stloc.2
IL_0012: ret
}
// ✅ GenericExample — box/unbox 없음
.method public hidebysig static
void GenericExample () cil managed
{
.locals init (
[0] class [System.Collections]System.Collections.Generic.List`1<int32>,
[1] int32
)
IL_0001: newobj instance void class List`1<int32>::.ctor() // List 자체 한 번만 힙 할당
IL_0006: stloc.0
IL_0008: ldc.i4.s 42
IL_000a: callvirt instance void class List`1<int32>::Add(!0) // int → int 직접 저장 — box 없음
IL_0012: callvirt instance !0 class List`1<int32>::get_Item(int32) // int 직접 반환
IL_0017: stloc.1
IL_0018: ret
}
BoxingExample의 IL_0005: box가 핵심이다. int 4바이트를 object에 넣기 위해 힙에 24바이트짜리 새 객체를 만든다. GenericExample에서는 List<int>가 내부적으로 int[] 배열을 쓰므로 box가 전혀 없다.
Unity에서 이게 왜 위험한가: Update()에서 박싱이 매 프레임 발생하면 초당 60개의 힙 쓰레기가 쌓인다. GC가 이를 정리하려 할 때 프레임이 튀는 GC 스파이크가 발생한다.
함정 2: 인터페이스 매개변수에 struct를 넘기면 숨겨진 boxing
using System;
public interface IDamageable
{
void TakeDamage(int amount);
}
public struct Tile : IDamageable
{
public int Hp;
public void TakeDamage(int amount) { Hp -= amount; }
}
public class Program
{
// ❌ Before: IDamageable로 받으면 Tile이 boxing됨
public static void ApplyDamageBoxing(IDamageable target, int amount)
{
target.TakeDamage(amount);
}
// ✅ After: 제네릭 제약으로 boxing 제거
public static void ApplyDamageGeneric<T>(ref T target, int amount) where T : struct, IDamageable
{
target.TakeDamage(amount);
}
public static void Main()
{
Tile tile = new Tile { Hp = 100 };
ApplyDamageBoxing(tile, 10); // boxing 발생
ApplyDamageGeneric(ref tile, 10); // boxing 없음
}
}
// Main에서의 호출부
.method public hidebysig static
void Main () cil managed
{
IL_0014: ldloc.0
IL_0015: box Tile // 💥 Tile → IDamageable 변환 시 boxing!
IL_001a: ldc.i4.s 10
IL_001c: call void Program::ApplyDamageBoxing(class IDamageable, int32)
IL_0022: ldloca.s 0 // Tile의 주소를 직접 전달
IL_0024: ldc.i4.s 10
IL_0026: call void Program::ApplyDamageGeneric<valuetype Tile>(!!0&, int32)
IL_002c: ret
}
// ✅ ApplyDamageGeneric 내부 — constrained.로 boxing 방지
.method public hidebysig static
void ApplyDamageGeneric<valuetype .ctor (IDamageable) T> (!!T& target, int32 amount) cil managed
{
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: constrained. !!T // 🔑 값 타입이면 boxing 없이 직접 호출
IL_0009: callvirt instance void IDamageable::TakeDamage(int32)
IL_000f: ret
}
Main의 IL_0015: box Tile이 문제다. IDamageable 매개변수에 Tile(struct)을 넘기면 자동으로 boxing이 발생한다. 반면 ApplyDamageGeneric의 IL_0003: constrained. !!T는 CLR에게 "이 타입이 값 타입이면 boxing 없이 직접 메서드를 호출하라"고 지시하는 접두사다.
Unity 실전 팁: 데미지 시스템 같은 핫패스(매 프레임 또는 자주 호출되는 경로)에서 인터페이스로 struct를 받는 패턴은 피하고, 제네릭 제약(where T : struct, IInterface)을 활용하라.
함정 3: struct 변이와 참조 착각
// 값 타입의 복사 의미론을 잊으면 생기는 버그
public struct EnemyData
{
public int Hp;
}
public class Program
{
public static void Main()
{
EnemyData[] enemies = new EnemyData[3];
enemies[0].Hp = 100;
// ❌ 복사본을 수정하는 실수
EnemyData target = enemies[0]; // 값이 복사됨!
target.Hp -= 30; // 복사본만 수정
// enemies[0].Hp는 여전히 100 — 의도와 다르다
// ✅ 배열 요소를 직접 수정
enemies[0].Hp -= 30; // 원본 직접 수정 — Hp가 70이 됨
}
}
이 코드는 IL 없이도 핵심이 명확하다. target = enemies[0]에서 EnemyData가 통째로 복사되므로, target.Hp를 바꿔도 배열의 원본은 변하지 않는다. 이것은 가장 흔한 값 타입 버그다.
C# 버전별 변화
값 타입과 참조 타입의 근본적인 구분은 C# 1.0부터 존재했지만, 이후 버전에서 값 타입을 더 안전하고 효율적으로 쓸 수 있는 기능들이 추가되었다.
C# 7.0 — ref return과 ref local
C# 7.0 이전에는 배열이나 컬렉션에서 큰 struct를 꺼내면 무조건 전체가 복사되었다. ref return은 값 타입의 참조를 반환하여 복사를 완전히 제거한다.
public struct LargeStruct
{
public float X, Y, Z, W;
}
public class Program
{
private static LargeStruct[] _pool = new LargeStruct[100];
// ❌ Before: 배열에서 꺼내면 16바이트 전체 복사
public static LargeStruct GetCopy(int index)
{
return _pool[index];
}
// ✅ After (C# 7.0): ref return으로 복사 없이 참조 반환
public static ref LargeStruct GetRef(int index)
{
return ref _pool[index];
}
public static void Main() { }
}
// ❌ GetCopy — ldelem으로 값 전체를 복사
.method public hidebysig static
valuetype LargeStruct GetCopy (int32 index) cil managed
{
IL_0001: ldsfld valuetype LargeStruct[] Program::_pool
IL_0006: ldarg.0
IL_0007: ldelem LargeStruct // 💥 16바이트 전체를 스택에 복사
IL_000c: stloc.0
IL_000f: ldloc.0
IL_0010: ret
}
// ✅ GetRef — ldelema로 요소의 주소만 반환
.method public hidebysig static
valuetype LargeStruct& GetRef (int32 index) cil managed
{
IL_0001: ldsfld valuetype LargeStruct[] Program::_pool
IL_0006: ldarg.0
IL_0007: ldelema LargeStruct // 🔑 주소만 반환 — 복사 없음
IL_000c: stloc.0
IL_000f: ldloc.0
IL_0010: ret
}
GetCopy의 ldelem은 배열 요소 전체(16바이트)를 스택에 복사한다. GetRef의 ldelema(load element address)는 요소의 주소만 반환하므로 크기에 관계없이 8바이트(포인터)만 전달된다.
Unity 실전: 오브젝트 풀에서 큰 struct(파티클 데이터, 타일맵 셀 등)를 꺼낼 때 ref return을 쓰면 불필요한 복사를 제거할 수 있다.
C# 7.2 — readonly struct
일반 struct를 readonly 필드에 저장하면, 컴파일러는 "혹시 메서드가 필드를 수정할 수도 있다"고 판단해 방어적 복사(defensive copy)를 만든다.
// ❌ Before: 일반 struct — readonly 필드에서 방어적 복사 발생
public struct MutableVector
{
public float X;
public float Y;
public float Length() => (float)System.Math.Sqrt(X * X + Y * Y);
}
// ✅ After: readonly struct — 방어적 복사 제거
public readonly struct ImmutableVector
{
public readonly float X;
public readonly float Y;
public ImmutableVector(float x, float y) { X = x; Y = y; }
public float Length() => (float)System.Math.Sqrt(X * X + Y * Y);
}
public class Program
{
private static readonly MutableVector _mutable = new MutableVector();
private static readonly ImmutableVector _immutable = new ImmutableVector(1, 2);
public static void ReadMutable()
{
float len = _mutable.Length(); // 방어적 복사 발생
}
public static void ReadImmutable()
{
float len = _immutable.Length(); // 복사 없이 직접 호출
}
public static void Main() { }
}
// ❌ ReadMutable — 방어적 복사가 보인다
.method public hidebysig static
void ReadMutable () cil managed
{
.locals init (
[0] float32,
[1] valuetype MutableVector // 방어적 복사본!
)
IL_0001: ldsfld valuetype MutableVector Program::_mutable
IL_0006: stloc.1 // 💥 전체 복사 발생 — 필드 값을 지역 변수로
IL_0007: ldloca.s 1 // 복사본의 주소에서 Length() 호출
IL_0009: call instance float32 MutableVector::Length()
IL_000e: stloc.0
IL_000f: ret
}
// ✅ ReadImmutable — 복사 없이 필드 주소를 직접 사용
.method public hidebysig static
void ReadImmutable () cil managed
{
.locals init (
[0] float32
)
IL_0001: ldsflda valuetype ImmutableVector Program::_immutable // 🔑 주소 직접 참조
IL_0006: call instance float32 ImmutableVector::Length()
IL_000b: stloc.0
IL_000c: ret
}
ReadMutable에서 ldsfld → stloc.1은 _mutable 필드 전체를 지역 변수로 복사하는 방어적 복사다. readonly로 선언된 필드의 struct에서 메서드를 호출할 때, "이 메서드가 필드를 수정할 수 있으므로" 원본을 보호하려고 복사본을 만든다.
ReadImmutable에서는 ldsflda로 필드의 주소를 직접 넘긴다. readonly struct이므로 컴파일러가 "이 struct는 절대 변이하지 않는다"는 것을 보장받아 방어적 복사가 필요 없다.
Unity 실전: 커스텀 수학 struct(FixedPoint, HexCoord 등)를 만들 때 readonly struct로 선언하면 불필요한 복사를 원천 차단할 수 있다.
C# 7.2 — in 매개변수
in 키워드는 struct를 읽기 전용 참조로 전달한다. 값 복사 없이 원본을 직접 읽되, 수정은 불가능하다. 단, in과 readonly struct를 함께 써야 방어적 복사가 완전히 제거된다.
public struct MutableVec
{
public float X, Y, Z, W;
public float Length() => (float)System.Math.Sqrt(X * X + Y * Y + Z * Z + W * W);
}
public readonly struct ReadOnlyVec
{
public readonly float X, Y, Z, W;
public ReadOnlyVec(float x, float y, float z, float w) { X = x; Y = y; Z = z; W = w; }
public float Length() => (float)System.Math.Sqrt(X * X + Y * Y + Z * Z + W * W);
}
public class Program
{
// ❌ in + 일반 struct → 방어적 복사 발생
public static float LengthMutable(in MutableVec v)
{
return v.Length();
}
// ✅ in + readonly struct → 복사 완전 제거
public static float LengthReadonly(in ReadOnlyVec v)
{
return v.Length();
}
public static void Main() { }
}
// ❌ LengthMutable — in이지만 일반 struct라 방어적 복사
.method public hidebysig static
float32 LengthMutable ([in] valuetype MutableVec& v) cil managed
{
.locals init (
[0] valuetype MutableVec, // 방어적 복사본!
[1] float32
)
IL_0001: ldarg.0
IL_0002: ldobj MutableVec // 💥 참조에서 값 전체를 꺼내 복사
IL_0007: stloc.0 // 지역 변수에 저장
IL_0008: ldloca.s 0 // 복사본 주소에서 Length() 호출
IL_000a: call instance float32 MutableVec::Length()
IL_0013: ret
}
// ✅ LengthReadonly — readonly struct라 복사 없이 직접 호출
.method public hidebysig static
float32 LengthReadonly ([in] valuetype ReadOnlyVec& v) cil managed
{
.locals init (
[0] float32
)
IL_0001: ldarg.0 // 🔑 참조를 그대로 사용
IL_0002: call instance float32 ReadOnlyVec::Length() // 복사 없이 직접 호출
IL_000b: ret
}
LengthMutable에서 ldobj → stloc.0이 방어적 복사다. in으로 참조를 전달했지만, 컴파일러는 Length()가 필드를 수정할 수 있다고 판단해 복사본을 만든다. LengthReadonly에서는 readonly struct이므로 변이 불가능이 보장되어 ldarg.0으로 참조를 직접 넘긴다.
in매개변수는 반드시readonly struct와 함께 써야 효과가 있다. 일반 struct에in을 쓰면 오히려 방어적 복사가 숨어들어 성능이 나빠질 수 있다.
C# 7.2 — Span<T>과 stackalloc
Span<T>는 연속된 메모리 영역을 힙 할당 없이 안전하게 참조하는 구조체다. 배열의 일부분을 잘라내거나(Slice), stackalloc으로 스택에 할당한 메모리를 다룰 때 사용한다.
using System;
public class Program
{
// ❌ Before: 부분 배열이 필요하면 새 배열 할당
public static int[] SliceCopy(int[] source, int start, int length)
{
int[] result = new int[length]; // 힙 할당!
Array.Copy(source, start, result, 0, length);
return result;
}
// ✅ After: Span으로 힙 할당 없이 슬라이스
public static ReadOnlySpan<int> SliceSpan(int[] source, int start, int length)
{
return source.AsSpan(start, length); // 힙 할당 없음
}
// ✅ stackalloc + Span: 스택에서 임시 버퍼 사용
public static int SumSmallBuffer()
{
Span<int> buffer = stackalloc int[4]; // 스택에 16바이트 할당 — GC 무관
buffer[0] = 10;
buffer[1] = 20;
buffer[2] = 30;
buffer[3] = 40;
int sum = 0;
for (int i = 0; i < buffer.Length; i++)
sum += buffer[i];
return sum;
}
public static void Main() { }
}
Span<T>는 IL 레벨에서 특이사항 없음 — 핵심은 new int[length](힙 할당)가 AsSpan(기존 배열 참조)이나 stackalloc(스택 할당)으로 대체되어 GC 부담이 사라진다는 점이다.
Unity 실전: 임시 계산 버퍼가 필요할 때 stackalloc + Span<T>를 쓰면 new 없이 스택에서 처리할 수 있다. 단, Unity의 IL2CPP 환경에서는 Span<T> 지원 범위를 확인해야 한다 (Unity 2021.2+에서 지원).
C# 10 — record struct
record struct는 값 의미론을 유지하면서 Equals, GetHashCode, ToString을 자동 생성해주는 값 타입이다.
// C# 10: record struct — 값 의미론 + 편의 기능 자동 생성
public record struct DamageRecord(int Amount, int Type);
public class Program
{
public static void Main()
{
var a = new DamageRecord(50, 1);
var b = new DamageRecord(50, 1);
// Equals 자동 생성 — 필드 값 비교
bool equal = a == b; // true (값이 같으므로)
// ToString 자동 생성
string s = a.ToString(); // "DamageRecord { Amount = 50, Type = 1 }"
}
}
일반 struct에서 Equals를 직접 구현하지 않으면, 기본 구현인 ValueType.Equals가 호출된다. 이 메서드는 런타임에 리플렉션(Reflection, 타입 메타데이터를 실행 시점에 조회하는 메커니즘)으로 모든 필드를 하나씩 찾아가며 비교한다. 이 과정에서 세 가지 성능 문제가 발생한다:
- boxing:
Equals(object obj)시그니처 때문에 비교 대상 struct가object로 boxing된다 — 힙 할당 발생 - 리플렉션 필드 탐색: 컴파일 타임에 어떤 필드가 있는지 모르므로, 매 호출마다 런타임이 타입 메타데이터를 조회하여 필드 목록을 가져온다
- 개별 필드 boxing: 참조 타입 필드가 없는 단순 struct는
memcmp(메모리 바이트 비교)로 최적화되지만, 참조 타입 필드가 하나라도 있으면 각 필드를 개별적으로 boxing하여 비교한다
record struct는 컴파일러가 모든 필드를 직접 비교하는 Equals 코드를 자동 생성하므로, boxing이나 리플렉션 없이 빠르게 동작한다.
버전별 요약
| 기능 | 버전 | 효과 |
|---|---|---|
ref return, ref local |
C# 7.0 | 값 타입을 복사 없이 참조로 전달/반환 |
readonly struct |
C# 7.2 | 방어적 복사 제거 |
in 매개변수 |
C# 7.2 | 큰 struct를 읽기 전용 참조로 전달 (readonly struct와 함께 사용) |
Span<T>, stackalloc 개선 |
C# 7.2 | 힙 할당 없는 메모리 슬라이스 |
record struct |
C# 10 | 값 의미론 + Equals/ToString 자동 생성 |
정리
개념
- [ ] 값 타입은 데이터 자체, 참조 타입은 데이터의 주소를 담는다
- [ ] 값 타입 대입 = 전체 복사(
ldloc→stloc), 참조 타입 대입 = 주소만 복사 - [ ] 값 타입의
new는initobj(스택), 참조 타입의new는newobj(힙)
내부 동작
- [ ] "값 타입 = 스택"은 단순화된 설명. 선언 위치가 저장 위치를 결정한다
- [ ] 참조 타입은 Object Header(16바이트)가 추가로 붙는다
함정
- [ ] 인터페이스에 struct를 넘기면
box→ 힙 할당 → GC 부담. 제네릭 제약(where T : struct)으로 방지 - [ ] struct를 변수에 대입하면 복사본이 생긴다 — 복사본 수정은 원본에 영향 없음
- [ ] struct에
Equals를 구현하지 않으면 리플렉션 + boxing이 발생한다
C# 버전별 개선
- [ ]
ref return(C# 7.0)으로 큰 struct의 불필요한 복사를 제거할 수 있다 - [ ]
readonly struct(C# 7.2)로 방어적 복사를 제거할 수 있다 - [ ]
in매개변수(C# 7.2)는 반드시readonly struct와 함께 써야 효과가 있다 - [ ]
Span<T>+stackalloc(C# 7.2)으로 힙 할당 없는 임시 버퍼를 만들 수 있다 - [ ]
record struct(C# 10)로 boxing/리플렉션 없는Equals를 자동 생성할 수 있다
Unity 실전
- [ ] 핫패스에서는 struct 선호:
DamageInfo,PathNode등 작고 불변인 데이터 - [ ]
Update()에서 참조 타입new는 매 프레임 GC 쓰레기를 만든다
'C# 심화' 카테고리의 다른 글
| var — 타입 추론의 원리와 한계 (0) | 2026.03.30 |
|---|---|
| null 처리 연산자 — ??, ?., ??= (0) | 2026.03.30 |
| null이란 무엇인가 — null의 두 얼굴 (0) | 2026.03.30 |
| boxing과 unboxing — 값 타입이 힙에 올라가는 순간 (0) | 2026.03.30 |
| Claude Code로 C# 블로그와 유튜브 채널을 자동으로 운영하는 방법 (0) | 2026.03.29 |
