값 타입 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)
정의 (Plain → Technical)
- 값 타입(value type): 변수가 데이터 자체를 담는다.
struct,enum, 기본 내장 값 타입(int,float,bool,char)이 여기 속한다.System.ValueType에서 파생된다. - 참조 타입(reference type): 변수가 데이터가 있는 힙 주소를 담는다.
class,interface,delegate,array, 그리고string이 여기 속한다.System.Object에서 파생된다.
"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#
- MethodTable 포인터(type object pointer): 타입 정보/가상 메서드 테이블
- Object Header(sync block index):
lock·해시코드 등에 쓰이는 헤더
64비트에서 이 오버헤드만 16바이트다. int 하나(4바이트)를 힙에 올리면 실제로는 최소 24바이트를 먹는다. 값 타입은 이 오버헤드가 전혀 없다 — 자기 필드만 있다.
"값 타입 = 스택"은 반쪽 진실 — 박싱
값 타입이 항상 스택에 있는 건 아니다. 대표적으로 박싱(boxing) — object나 인터페이스로 변환할 때다.
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로 할까
서버에서도 판단 기준은 같다.
- struct가 유리: 작고(대략 16~24바이트 이하) 수명이 짧으며 값처럼 다루는 데이터 — 좌표, 방향 벡터, 단순 이벤트, 패킷 헤더. 힙 할당을 피해 GC 수집 빈도와 지연(latency)을 낮춘다.
- 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 |
readonly struct+in: 값 타입을 참조로 넘기되 수정은 막아, 큰 struct의 복사 비용과 컴파일러의 방어적 복사(defensive copy)를 동시에 없앤다.record struct: 값 타입의 "데이터가 같으면 같다"는 성질을 코드 한 줄로 표현한다.ref struct/Span<T>: 값 타입을 "절대 힙으로 새지 않는" 영역으로 격리해, 네트워크 버퍼를 무할당으로 다룬다(Part 1-8·PART 14에서 상세히).
7. 정리
b = a한 줄은 값 타입이면 필드 단위 복사(독립), 참조 타입이면 주소 복사(공유)다. IL의ldloc/복사 vsnewobj/dup로 눈으로 확인된다.- 값 타입은 힙 오버헤드(MethodTable 포인터 + Object Header, 64비트 16B)와 GC 부담이 없다. 그래서 Unity의
Vector3도, 서버의 작은 패킷도 struct다. - "값 타입 = 스택"은 반쪽 진실이다. 박싱, 클래스 필드 포함, 배열 요소, 클로저 캡처 시 값 타입도 힙에 올라간다 —
box한 명령이 GC Alloc을 만든다. - 실전 3대 함정: struct 프로퍼티 수정 미반영, 참조 복사를 깊은 복사로 착각, 인터페이스로 struct를 받아 생기는 박싱.
- 판단 기준: 작고 짧고 값처럼 쓰면 struct, 크고 오래 살고 공유·다형성이 필요하면 class.