C#value-typereference-typestructclassboxingCLRGC

값 타입 vs 참조 타입 — 대입 한 줄이 복사인가 참조인가

1. 문제 제기

같은 모양의 코드인데 결과가 정반대다.

// (A) struct
PointS a = new PointS { X = 7 };
PointS b = a;
a.X = 9;
Console.WriteLine(b.X);   // 7

// (B) class
PointC a = new PointC { X = 7 };
PointC b = a;
a.X = 9;
Console.WriteLine(b.X);   // 9

b = a라는 똑같은 한 줄인데, struct일 때는 b가 안 바뀌고 class일 때는 b도 같이 바뀐다. a를 건드렸을 뿐인데 왜 b가 따라 바뀌나? 이 한 줄의 정체를 모르면 "왜 내 좌표 수정이 반영이 안 되지?", "왜 여기서 GC가 튀지?" 같은 버그를 평생 디버거로만 쫓게 된다. 대입 한 줄이 복사인지 참조 공유인지, 그 갈림길에서 모든 것이 갈린다.

2. 개념 정의

비유 (Analogy)

Jon Skeet은 이 차이를 문서 공유에 비유한다.

b = a는 둘 다 "가진 것을 복사"한다. 다만 값 타입은 문서 자체를, 참조 타입은 주소 쪽지를 복사할 뿐이다.

다이어그램 (Diagram)

대입 한 줄: 값 복사 vs 참조 복사 ① 값 타입 (struct) — PointS b = a; STACK a = {X:9, Y:0} b = {X:7, Y:0} 필드 단위 복사 → 완전히 독립 a.X=9 해도 b.X=7 그대로. 힙 할당 0. ② 참조 타입 (class) — PointC b = a; STACK a → 0x1000 b → 0x1000 HEAP @0x1000 PointC {X:9, Y:0} 주소만 복사 → 같은 객체 공유 a.X=9 하면 b.X도 9. newobj로 힙 할당 1회.
대입 시 값 복사 vs 참조 복사

정의 (Plain → Technical)

"The fundamental difference between value types and reference types is how they are handled in memory." — C# 12 in a Nutshell

3. 내부 동작

대입이 실제로 하는 일

값 타입과 참조 타입의 대입 규칙을 CLR은 이렇게 정의한다.

"When you assign a value type variable to another value type variable, a field-by-field copy is made. When you assign a reference type variable to another reference type variable, only the memory address is copied." — CLR via C# (Jeffrey Richter)

이 문장을 IL로 직접 확인해 보자. 아래는 ilspycmd로 디컴파일한 실제 IL이다(Release 빌드).

값 타입 대입 (AssignStruct) — 힙 할당이 없다:

.locals init ([0] valuetype PointS)   // 스택의 로컬 슬롯 하나
IL_0000: ldloca.s  0         // 로컬 a의 '주소'를 스택에
IL_0002: initobj   PointS    // 0으로 초기화 (new 아님, 힙 할당 없음)
IL_0008: ldloca.s  0
IL_000a: ldc.i4.7
IL_000b: stfld     int32 PointS::X   // a.X = 7
IL_0010: ldloc.0             // ★ a의 '값 전체'를 복사해서 push (= b)
IL_0011: ldloca.s  0
IL_0013: ldc.i4.s  9
IL_0015: stfld     int32 PointS::X   // a.X = 9  (a만 바꿈)
IL_001a: ldfld     int32 PointS::X   // 앞서 복사해 둔 b의 X를 읽음 → 7
IL_001f: ret

핵심은 IL_0010: ldloc.0이다. b = a 시점에 값 전체가 복사되어 스택에 얹힌다. 그 뒤 a.X = 9는 로컬 슬롯의 a만 건드리므로, 이미 떠 있던 복사본 b.X는 7로 남는다. newobj도 box도 없다 — 힙은 전혀 건드리지 않는다.

참조 타입 대입 (AssignClass) — 힙 할당이 있다:

.locals init ([0] class PointC)
IL_0000: newobj   instance void PointC::.ctor()   // ★ 힙에 객체 할당
IL_0005: dup                 // 주소 복제 (초기화용 + 저장용)
IL_0006: ldc.i4.7
IL_0007: stfld    int32 PointC::X   // X = 7
IL_000c: dup                 // 다시 주소 복제
IL_000d: stloc.0             // b = 같은 주소 (스택엔 objref 하나 남음)
IL_000e: ldc.i4.s 9
IL_0010: stfld    int32 PointC::X   // 같은 객체의 X = 9
IL_0015: ldloc.0             // b(= 같은 주소)를 다시 push
IL_0016: ldfld    int32 PointC::X   // b로 읽으면 → 9
IL_001b: ret

newobj가 딱 한 번 힙에 객체를 만들고, 이후 a와 b는 같은 주소를 공유한다. 그래서 한쪽으로 고치면 다른 쪽에서도 보인다.

왜 힙 객체는 비싼가

참조 타입이 힙에 올라갈 때 CLR은 순수 데이터 외에 오버헤드 두 개를 얹는다.

"Every object on the heap requires some additional members—called the type object pointer and the sync block index." — CLR via C#

64비트에서 이 오버헤드만 16바이트다. int 하나(4바이트)를 힙에 올리면 실제로는 최소 24바이트를 먹는다. 값 타입은 이 오버헤드가 전혀 없다 — 자기 필드만 있다.

"값 타입 = 스택"은 반쪽 진실 — 박싱

값 타입이 항상 스택에 있는 건 아니다. 대표적으로 박싱(boxing) — object나 인터페이스로 변환할 때다.

박싱: object o = 42; (값 타입이 힙으로) STACK int n = 42 IL: box int32 HEAP (GC 관리 대상) Object Header (sync block) MethodTable Ptr value = 42 (복사됨) 새 힙 객체 1개 할당 + 데이터 복사 → GC Alloc 발생. 64비트에서 int 하나를 담는 데 헤더 16B + 값 = 최소 24B 소비. 핫패스에서 반복되면 GC 스파이크의 근원이 된다.
박싱: 값 타입이 힙으로

int n = 42;
object o = n;   // 박싱
IL_0000: ldc.i4.s 42
IL_0002: box      [System.Runtime]System.Int32   // ★ 힙에 박스 객체 생성 + 복사
IL_0007: ret

box 명령 하나가 힙에 새 객체를 만들고 값을 복사한다. 반대로 꺼낼 때는 unbox.any가 나온다. 값 타입이 힙에 올라가는 경우는 박싱 말고도 참조 타입의 필드로 포함될 때(클래스 인스턴스 일부로 힙에), 배열 요소일 때(배열은 참조 타입), static 필드, async 상태 머신·클로저에 캡처될 때가 있다.

4. 실전 적용

Unity — 왜 Vector3는 struct인가

Vector3, Quaternion, Color는 전부 struct다. 이유는 명확하다. 초당 60~120회(프레임마다 한 번) 도는 Update()에서 벡터 연산은 수없이 일어난다.

void Update()
{
    Vector3 dir = (target.position - transform.position).normalized;
    transform.position += dir * speed * Time.deltaTime;
}

Vector3가 class였다면 저 임시 벡터들이 매 프레임 힙에 할당되고, 수천 마리 몬스터가 이걸 돌리면 할당이 누적되며 GC가 돌 때 프레임이 뚝뚝 끊긴다(stuttering). struct이기 때문에 임시 벡터는 스택에서 계산되고 프레임이 넘어가면 그냥 사라진다 — GC Alloc 0.

여기에 값 타입은 데이터 지역성 이점도 크다. Vector3[]는 실제 데이터가 힙에 연속으로 늘어서 CPU 캐시 히트율이 높지만, Monster[](참조 배열)는 주소만 모여 있어 실제 객체를 따라갈 때 캐시 미스가 잦다.

게임서버 — 패킷·DTO를 struct로 할까 class로 할까

서버에서도 판단 기준은 같다.

핵심은 object identity vs value equality의 관점 차이다. 값 타입은 대입마다 복사되므로 "같은 인스턴스"라는 개념 자체가 없고 데이터가 같으면 같은 것이다. 참조 타입은 데이터가 같아도 힙 위치가 다르면 다른 것으로 본다. 그래서 "이 두 좌표가 같은가"는 값 동등성이고, "이 두 세션이 같은 접속인가"는 참조 동일성이다 — 이 둘을 섞으면 버그가 난다.

5. 함정과 주의사항

함정 1 — struct 프로퍼티를 수정하면 반영이 안 된다

가장 흔히 밟는 지뢰다.

public class Enemy { public Vector3 Position { get; set; } }

enemy.Position.x = 10;   // 컴파일 에러 (또는 무의미)

enemy.Position은 게터가 struct의 복사본을 돌려준다. 그 복사본의 .x를 고쳐 봐야 원본 필드에는 반영되지 않는다(그래서 C#은 아예 컴파일 에러로 막는다). 올바른 방법은 통째로 다시 대입하는 것이다.

var p = enemy.Position;
p.x = 10;
enemy.Position = p;      // 전체를 다시 대입

함정 2 — 참조 복사를 "복사"로 착각

var original = new List<int> { 1, 2, 3 };
var copy = original;     // 참조 복사! 같은 리스트
copy.Add(4);
// original.Count == 4  (같이 바뀜)

깊은 복사가 필요하면 new List<int>(original)처럼 명시적으로 새 객체를 만들어야 한다.

함정 3 — 큰 struct의 숨은 복사 비용

값 타입은 대입·인자 전달마다 전체가 복사된다. 64바이트가 넘는 큰 struct를 핫패스에서 값으로 넘기면 복사 비용이 누적된다. 이때는 in 매개변수로 읽기 전용 참조를 넘겨 복사를 피한다(Part 1-3에서 상세히 다룬다).

함정 4 — 인터페이스로 struct를 받으면 박싱

struct Damage : IEvent { }
IEvent e = new Damage();   // 박싱! 힙 할당

값 타입을 인터페이스 변수에 담는 순간 box가 나간다. 핫패스에서 반복되면 GC 스파이크의 근원이 된다(Part 1-4·1-5에서 상세히 다룬다).

6. 버전별 변화

값 타입의 약점(복사 비용, 가변성, 박싱)을 언어가 버전을 거치며 보강해 왔다.

버전 기능 무엇을 해결하나
C# 7.2 readonly struct, in 매개변수 struct를 불변으로 강제하고, 읽기 전용 참조 전달로 복사 비용 + 방어적 복사 오버헤드 제거
C# 7.2 ref struct (Span<T>) 스택에만 존재하도록 강제 → 버퍼를 힙 할당 없이 슬라이싱·파싱
C# 9 record (class) 참조 타입에 값 동등성과 불변성을 손쉽게 부여
C# 10 record struct 상용구 없이 값 동등성·with 표현식을 갖춘 경량 DTO

7. 정리