EveryDay.DevUp

값 타입과 참조 타입 — C#은 왜 둘로 나눴는가 본문

C# 심화

값 타입과 참조 타입 — C#은 왜 둘로 나눴는가

EveryDay.DevUp 2026. 3. 29. 23:09

값 타입과 참조 타입 — 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)가 일어난다.

C#
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() { }
}
IL
.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~001eldloc.0stloc.1이 복사 과정이다. Pointint 2개(8바이트)이므로, 8바이트가 통째로 복사된다.

참조 타입 — 위치(주소)만 품는다

쉬운 설명: 변수는 데이터가 있는 곳의 주소만 갖고 있다. 다른 변수에 대입하면 주소만 복사되므로, 두 변수가 같은 데이터를 바라본다.

기술 정의: 참조 타입은 System.Object를 직접 상속하는 타입으로, class, interface, delegate, string 등이 해당한다. 변수에는 관리되는 힙(managed heap)에 할당된 객체의 참조(메모리 주소)가 저장되며, 대입 시 참조만 복사되는 참조 의미론(reference semantics)을 따른다.

C#
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() { }
}
IL
.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~0010ldloc.0stloc.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가 자동으로 회수한다.

스택 (Stack)

"값 타입 = 스택"은 틀렸다

스택 (Stack) DamageInfo dmg Amount: 50 | Type: 1 (8바이트) Enemy enemy 참조(주소) 힙 (Heap) Enemy 객체 Health: 100 (int, 4바이트) LastDamage (인라인 배치) Amount: 30 | Type: 2 (8바이트) 핵심 규칙 지역 변수 → 스택 클래스 필드 → 힙 (객체 안에 인라인) 배열 요소 → 힙 (배열 안에 인라인)

많은 입문서가 "값 타입은 스택에, 참조 타입은 힙에 할당된다"고 가르친다. 반만 맞는 말이다.

정확한 규칙은 이렇다:

타입 저장 위치 이유
참조 타입 인스턴스 항상 크기와 수명이 동적이므로
값 타입 — 지역 변수/매개변수 스택 (또는 CPU 레지스터) 메서드 수명과 같으므로
값 타입 — 클래스의 필드 클래스 객체와 함께 클래스 객체 안에 인라인 배치
값 타입 — 배열의 요소 배열 객체와 함께 배열은 참조 타입이므로
값 타입 — 박싱(Boxing) 시 값을 참조 타입으로 감싸므로

핵심: 값 타입의 저장 위치는 "선언된 곳"이 결정한다.

C#
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() { }
}
IL
// 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에서 DamageInfoinitobj로 스택에서 초기화되고 newobj가 전혀 없다. 반면 StructInsideClass에서는 Enemy 객체가 newobj로 힙에 생성되고, DamageInfoIL_002a: stfld로 그 힙 객체 안에 직접 기록된다.

참조 타입의 숨겨진 비용 — Object Header

struct (값 타입) int Value 4바이트 총 4바이트 vs class (참조 타입) Sync Block Index 8바이트 (오버헤드) Method Table Pointer 8바이트 (오버헤드) int Value 4바이트 (실제 데이터) 패딩 4바이트 (8바이트 정렬) 총 24바이트 (6배!) 16B

참조 타입 객체가 힙에 할당될 때, 실제 데이터 외에 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(값 타입)이다. 이유는 단순하다 — 매 프레임 수없이 생성되고 사라지는 작은 데이터이기 때문이다.

만약 Vector3class였다면, transform.position += new Vector3(1, 0, 0) 한 줄이 매 프레임 힙에 새 객체를 할당하고, 기존 객체는 쓰레기가 되어 GC의 부담을 키운다. struct이므로 스택에서 생성되고 메서드가 끝나면 자동으로 사라진다. GC는 관여할 필요가 없다.

❌ Before: 매 프레임 힙 쓰레기를 만드는 코드

C#
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: 힙 할당 없는 코드

C#
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 분석에서 확인했듯이, structnewinitobj(스택 초기화)이고 classnewnewobj(힙 할당)이다. 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)이다.

스택
C#
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() { }
}
IL
// ❌ 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
}

BoxingExampleIL_0005: box가 핵심이다. int 4바이트를 object에 넣기 위해 힙에 24바이트짜리 새 객체를 만든다. GenericExample에서는 List<int>가 내부적으로 int[] 배열을 쓰므로 box가 전혀 없다.

Unity에서 이게 왜 위험한가: Update()에서 박싱이 매 프레임 발생하면 초당 60개의 힙 쓰레기가 쌓인다. GC가 이를 정리하려 할 때 프레임이 튀는 GC 스파이크가 발생한다.

함정 2: 인터페이스 매개변수에 struct를 넘기면 숨겨진 boxing

C#
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 없음
    }
}
IL
// 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
}

MainIL_0015: box Tile이 문제다. IDamageable 매개변수에 Tile(struct)을 넘기면 자동으로 boxing이 발생한다. 반면 ApplyDamageGenericIL_0003: constrained. !!T는 CLR에게 "이 타입이 값 타입이면 boxing 없이 직접 메서드를 호출하라"고 지시하는 접두사다.

Unity 실전 팁: 데미지 시스템 같은 핫패스(매 프레임 또는 자주 호출되는 경로)에서 인터페이스로 struct를 받는 패턴은 피하고, 제네릭 제약(where T : struct, IInterface)을 활용하라.

함정 3: struct 변이와 참조 착각

C#
// 값 타입의 복사 의미론을 잊으면 생기는 버그
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은 값 타입의 참조를 반환하여 복사를 완전히 제거한다.

C#
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() { }
}
IL
// ❌ 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
}

GetCopyldelem은 배열 요소 전체(16바이트)를 스택에 복사한다. GetRefldelema(load element address)는 요소의 주소만 반환하므로 크기에 관계없이 8바이트(포인터)만 전달된다.

Unity 실전: 오브젝트 풀에서 큰 struct(파티클 데이터, 타일맵 셀 등)를 꺼낼 때 ref return을 쓰면 불필요한 복사를 제거할 수 있다.

C# 7.2 — readonly struct

일반 structreadonly 필드에 저장하면, 컴파일러는 "혹시 메서드가 필드를 수정할 수도 있다"고 판단해 방어적 복사(defensive copy)를 만든다.

C#
// ❌ 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() { }
}
IL
// ❌ 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에서 ldsfldstloc.1_mutable 필드 전체를 지역 변수로 복사하는 방어적 복사다. readonly로 선언된 필드의 struct에서 메서드를 호출할 때, "이 메서드가 필드를 수정할 수 있으므로" 원본을 보호하려고 복사본을 만든다.

ReadImmutable에서는 ldsflda로 필드의 주소를 직접 넘긴다. readonly struct이므로 컴파일러가 "이 struct는 절대 변이하지 않는다"는 것을 보장받아 방어적 복사가 필요 없다.

Unity 실전: 커스텀 수학 struct(FixedPoint, HexCoord 등)를 만들 때 readonly struct로 선언하면 불필요한 복사를 원천 차단할 수 있다.

C# 7.2 — in 매개변수

in 키워드는 struct를 읽기 전용 참조로 전달한다. 값 복사 없이 원본을 직접 읽되, 수정은 불가능하다. 단, inreadonly struct함께 써야 방어적 복사가 완전히 제거된다.

C#
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() { }
}
IL
// ❌ 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에서 ldobjstloc.0이 방어적 복사다. in으로 참조를 전달했지만, 컴파일러는 Length()가 필드를 수정할 수 있다고 판단해 복사본을 만든다. LengthReadonly에서는 readonly struct이므로 변이 불가능이 보장되어 ldarg.0으로 참조를 직접 넘긴다.

in 매개변수는 반드시 readonly struct와 함께 써야 효과가 있다. 일반 struct에 in을 쓰면 오히려 방어적 복사가 숨어들어 성능이 나빠질 수 있다.

C# 7.2 — Span<T>과 stackalloc

Span<T>는 연속된 메모리 영역을 힙 할당 없이 안전하게 참조하는 구조체다. 배열의 일부분을 잘라내거나(Slice), stackalloc으로 스택에 할당한 메모리를 다룰 때 사용한다.

C#
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#
// 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, 타입 메타데이터를 실행 시점에 조회하는 메커니즘)으로 모든 필드를 하나씩 찾아가며 비교한다. 이 과정에서 세 가지 성능 문제가 발생한다:

  1. boxing: Equals(object obj) 시그니처 때문에 비교 대상 struct가 object로 boxing된다 — 힙 할당 발생
  2. 리플렉션 필드 탐색: 컴파일 타임에 어떤 필드가 있는지 모르므로, 매 호출마다 런타임이 타입 메타데이터를 조회하여 필드 목록을 가져온다
  3. 개별 필드 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 자동 생성

정리

개념

  • [ ] 값 타입은 데이터 자체, 참조 타입은 데이터의 주소를 담는다
  • [ ] 값 타입 대입 = 전체 복사(ldlocstloc), 참조 타입 대입 = 주소만 복사
  • [ ] 값 타입의 newinitobj(스택), 참조 타입의 newnewobj(힙)

내부 동작

  • [ ] "값 타입 = 스택"은 단순화된 설명. 선언 위치가 저장 위치를 결정한다
  • [ ] 참조 타입은 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 쓰레기를 만든다