반응형

[PART1.C# 런타임과 .NET 플랫폼 기초(8/11)] 값 타입 vs 참조 타입 — 스택과 힙의 첫 인상

struct와 class는 왜 다른 곳에 살까 / 대입하면 복사일까 참조일까 / 핫패스에서 왜 class가 문제가 되는가


1. 왜 C#은 타입을 둘로 나누었는가

Unity에서 transform.positionVector3입니다. Vector3struct(값 타입)입니다. 반면 GameObject, List<T>, Transform은 모두 class(참조 타입)입니다. 왜 같은 "데이터"인데 어떤 것은 struct이고 어떤 것은 class일까요?

이 질문을 가볍게 넘기면 Unity에서 심각한 문제를 만납니다.

C#
// 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, 배열. 변수는 힙에 있는 객체의 주소만 들고 있음.

시각화: 변수는 무엇을 들고 있는가

값 타입 vs 참조 타입의 메모리 배치

기본 동작을 코드로 확인

아래 예시는 struct와 class가 대입할 때 어떻게 다르게 동작하는지를 보여줍니다. 같은 모양의 타입이지만 결과가 정반대입니다.

C#
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로 어떻게 변환되는지 봅시다.

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 해설 (핵심만):

  1. initobj vs newobj: struct는 initobj로 스택 메모리를 0으로 초기화할 뿐 힙 할당이 없습니다. class는 newobj가 반드시 필요하고 이 명령이 힙 할당을 수행합니다. Update 루프에서 newobj가 호출되는 만큼 GC 압박이 쌓입니다.
  2. ldloca vs ldloc: struct를 다룰 때는 주소(ldloca)로 접근해 제자리에서 필드를 씁니다. class는 참조 자체가 주소이므로 ldloc으로 로드해 그대로 씁니다.
  3. stloc.0stloc.1: IL_001e~001f에서 v1을 로드해 v2에 저장하는 두 줄이 struct 전체의 비트 복사입니다. struct가 커질수록 이 복사 비용이 커집니다.
  4. .locals init 차이: struct 변수는 valuetype PointV로 선언되어 스택 프레임 안에 필드 공간 전체가 확보됩니다. class 변수는 class PointR로 선언되어 참조(8바이트) 자리만 확보됩니다.

3. 내부 동작 — 변수가 선언된 곳이 곧 저장 위치

"struct는 무조건 스택"이라는 오해

이 문장은 반만 맞습니다. 정확한 규칙은 이렇습니다.

값 타입은 자신이 선언된 곳에 저장된다.
  • 로컬 변수 · 메서드 파라미터로 선언 → 스택
  • 클래스의 필드로 선언 → 그 클래스 객체 안에 = 힙
  • 배열의 원소 → 배열이 힙에 있으므로 = 힙
  • 박싱될 때 → 별도 Box 객체 안에 = 힙

Unity에서 자주 보는 패턴입니다.

C#
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는 주소 접근

IL
.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 해설:

  1. ldflda: "필드의 주소를 로드"하는 명령입니다. 값 타입 필드를 수정할 때 컴파일러는 힙 내부의 주소를 계산해서 그 자리에 직접 쓰기를 합니다. 별도 복사본을 만들어 수정한 뒤 되돌려 놓는 게 아닙니다.
  2. 임시 값 타입 복사 없음: 만약 e.Position 전체를 가져와 수정한 뒤 다시 대입했다면 ldfld + 임시 변수 + stfld가 필요했을 것입니다. 컴파일러는 ldflda로 한 단계에 끝냅니다.

대입 vs 파라미터 전달

함수로 값을 넘길 때도 대입과 같은 규칙이 적용됩니다. C#은 기본적으로 by-value 전달입니다. 다만 "값"이 무엇이냐가 타입마다 다릅니다.

  • 값 타입 전달: 데이터 전체가 호출 스택에 복사됨
  • 참조 타입 전달: 참조(주소)가 복사됨 — 객체 자체는 공유
C#
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)에 넘긴 pv복사본입니다. 메서드 안에서 p.X를 올려도 메서드 밖의 v는 그대로입니다. MoveR(r)에 넘긴 p는 같은 객체를 가리키는 또 다른 명찰입니다. 메서드 안에서 객체를 고치면 메서드 밖 r로 봐도 고쳐진 값이 보입니다.

IL로 증명 — 파라미터 전달의 차이

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 해설:

  1. ldloc.0call MoveV: struct 파라미터 전달은 로컬 변수의 값 전체를 호출 스택에 푸시합니다. struct가 큰 경우(예: 64바이트) 매 호출마다 이 복사 비용이 발생합니다. ref·in 키워드가 존재하는 이유입니다.
  2. ldarg.0 vs ldarga.s p: 참조 타입 파라미터는 ldarg로 그냥 주소를 가져옵니다. 값 타입 파라미터는 ldarga.s(파라미터의 주소)로 접근해 필드 단위로 조작합니다.
  3. 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 압박

C#
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> × 60
  • Enemy × 600

0세대 힙이 빠르게 차오르고 GC가 동작할 때마다 프레임이 튑니다.

After — 값 타입 + 버퍼 재사용, 할당 0

C#
// 값 타입으로 전환
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:

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:

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 해설:

  1. newobj 횟수 = 힙 할당 횟수: Before에는 newobj가 2개(List, Enemy)입니다. 핫패스에서 이 명령은 호출 수만큼 힙을 소모합니다. After는 newobj가 0개입니다.
  2. initobj + ldloca: After의 struct 생성은 스택 임시 변수를 초기화하고 필드에 값을 채워 넣은 뒤 stelem으로 배열 원소 자리에 복사합니다. 할당이 아닌 복사입니다.
  3. callvirt List<T>::Add: Before에는 가상 호출이 있고 내부적으로 필요하면 List 내부 배열을 또 힙에 재할당합니다. After는 고정 배열이라 재할당이 없습니다.
  4. Unity Profiler 관점: Before 쪽 newobj는 Profiler의 GC Alloc 컬럼에 표시되지만 After 쪽 initobj+stelem은 표시되지 않습니다. 핫패스 최적화의 가장 빠른 확인 방법입니다.

5. 함정과 주의사항

함정 1 — struct 프로퍼티를 통한 수정은 원본을 바꾸지 않는다

Unity에서 자주 마주치는 함정입니다.

C#
// ❌ 초보자가 흔히 쓰는 코드
transform.position.x = 10;  // 컴파일 에러!

transform.positionVector3 struct를 복사해서 반환하는 프로퍼티입니다. 반환된 복사본의 x를 수정해도 원본에는 영향이 없습니다. C# 컴파일러는 이 실수를 감지하고 에러로 막습니다. 올바른 방법은 이렇습니다.

C#
// ✅ 값 전체를 새로 만들어 대입
Vector3 p = transform.position;
p.x = 10;
transform.position = p;

쉬운 설명: struct 프로퍼티는 돌려준 순간 사진(복사본)입니다. 사진에 낙서를 해도 실물은 바뀌지 않습니다. 실물을 바꾸려면 사진을 고쳐서 다시 "이게 새 실물이다" 라고 건네주어야 합니다.

함정 2 — 큰 struct의 반복 복사

C#
// ❌ 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 복사
C#
// ✅ 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 객체를 할당하고 값을 복사하므로 스택의 장점이 사라집니다.

C#
public class Program
{
    public static void Main()
    {
        int i = 42;
        object o = i;   // 박싱: 힙에 Box 할당 + 복사
        int j = (int)o; // 언박싱: 타입 검증 + 복사
    }
}

IL:

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 — 캡처된 클로저는 값 타입이라도 힙으로 간다

C#
// ❌ 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는 이번 글의 연장선. 값 타입을 "힙으로 새지 않게" 만드는 도구들이며 후속 주제에서 다룬다.

다음 주제에서는 이번 글에서 예고만 한 박싱과 언박싱을 깊게 들여다봅니다. Vector3object에 넣는 순간, structIEnumerable로 캐스팅되는 순간, Dictionary<int, ...>의 키를 비교하는 순간 — 언제 박싱이 발생하고 IL에서 어떻게 보이며 어떻게 방어하는지를 다룰 예정입니다.

반응형

+ Recent posts