구조체 vs 클래스
핵심 요약 비교
| 특징 | 구조체 (Struct) | 클래스 (Class) |
|---|
| 타입 분류 | 값 타입 (Value) | 참조 타입 (Reference) |
| 저장 방식 | 값 자체 저장 | 참조 저장 |
| 메모리 위치 | 지역 변수면 Stack, 배열이나 클래스 내부면 Heap | Heap (참조하는 주소값만 스택에 할당) |
| GC | 대상 아님 (Stack : 스코프 종료시 즉시 해제) | 대상 (메모리 정리 시 오버헤드 발생) |
| 기본 생성 | 0 or false | null |
| 복사 | 값 자체 복사 | 얕은 복사 |
| 상속 | X | O |
| null 가능 | X | O |
언제 무엇을 선택해야 할까
- 구조체를 선택할 때
- 데이터의 크기가 작을 때
- 논리적으로 단일값을 나타낼 때
- 상속 구조가 필요 없을 때
- 불변성을 유지하는 데이터일 때
- Cache Hit를 극대화해야할 때
메모리 할당과 라이프 사이클
- 구조체
- 메소드 내부나 지역 변수로 구조체를 생성하면 스택 메모리에 할당
- 선언된 스코프를 벗어나는 순간 메모리에서 제거
- 클래스
- 실제 데이터는 힙 메모리에 할당되고, 스택에는 데이터를 가리키는 주소(참조값)만 저장
- 아무도 이 객체를 참조하지 않게되면 메모리 제거
할당과 복사
- 구조체
StructB = StructA
- StructA가 가진 모든 데이터가 복사되어 StructB로 들어간다
- 둘 중 하나를 수정해도 다른 하나에는 영향을 미치지 않는다
ref, in 으로 참조 전달 가능
- 클래스
ClassB = ClassA
- 실제 데이터가 복사되는 것이 아닌 힙 메모리의 주소값이 복사
- ClassB의 값을 수정하면 ClassA의 값도 동일하게 변경
Boxing & Unboxing
- 개념
- Boxing : 값 타입을 참조 타입으로 암시적 변환하는 과정
- Unboxing : 박싱되어 힙에 있는 객체를 다시 원래의 값 타입으로 명시적 변환하는 과정
- 내부 동작
- 박싱이 발생하면 데이터가 힙 영역으로 통째로 복사 및 할당
- 원본 값 타입을 참조하는 것이 아니라 힙에 복사본 객체를 새로 생성
- GC의 감시 대상으로 전환
배열 생성시 메모리 연속성과 Cache Hit
- 구조체 배열의 연속성
- 구조체 배열을 생성하면 배열 자체가 힙에 할당되더라도, 그 내부에 있는 실제 구조체 데이터들이 메모리상에 빈틈업이 연속적으로 할당
- CPU는 메모리를 블록 단위로 읽어오기 때문에, 다음 데이터를 읽을 때 Cache Hit가 발생하여 처리 속도가 빠르다
- 클래스 배열의 파편화
- 클래스 배열을 생성하면 배열 안에는 힙 메모리의 주소들만 연속해서 나열되고, 실제 데이터들은 힙 영역에 흩어져서 할당
- CPU가 배열을 순회할 때, 주소를 읽고 다시 힙의 위치로 점프해서 데이터를 가져와야 하므로 Cache Miss 발생하여 속도가 느리다
구조체
구조체와 인터페이스
struct Monster : IDamageable
{
public void GetDamaged(int damage);
}
IDamageable monster = new Monster();
Monster monster = new Monster();
monster.GetDamaged(10);
void Hit<T>(ref T damageable) where T : IDamageable
{
damageable.GetDamaged(10);
}
- 구조체는 값 타입인 반면 인터페이스는 참조 타입처럼 객체를 다룬다
- 구조체가 인터페이스를 구현한 뒤, 이 구조체를 인터페이스 타입의 변수에 대입하거나, 인터페이스를 매개변수로 받으면 Boxing 발생
- 원인 : 인터페이스 참조 변수는 힙 영역에 존재하는 객체의 메모리 주소를 가리키도록 설계
- 해결 : Generic으로 박싱 회피 가능
- 주의
- 구조체가 인터페이스를 구현하는 것 자체는 Boxing이 아니다
- 인터페이스 타입으로 변환되는 순간 Boxing이 발생한다
구조체의 방어적 복사
구조체의 null
- 구조체의 한계
- 구조체 변수는 실제 데이터 그 자체를 담는 상자
- 변수 자체가 데이터를 포함하기 때문에 값 없음 상태를 표현할 수 없다
- 클래스의 null
- 클래스 변수는 힙 메모리의 주소를 담는 상자
- 아무 주소도 없을 때 null
- Nullable
T?
- 스택 메모리에 null 포인터가 들어가는 것이 아닌 Nullable 구조체가 생성
- 구조체 Property의 함정
- position은 변수가 아닌 프로퍼티이며, 반환되는 Vector3가 구조체이기 때문
- 과정
- Get 호출
- 값 복사
- 값 변경 : 복사본의 값만 변경. 원본은 변함 없음
- C# 컴파일러에서 에러 처리
- 그럼 Vector3를 클래스로 만들면 ?
- 정상적으로 작동
- 하지만 수많은 Vector3 생성/복사 시 GC 최소화를 위해 구조체로 설계
Memory Padding
public struct structA
{
public bool A;
public int B;
public bool C;
}
public struct structB
{
public int B;
public bool A;
public bool C;
}
- CLR은 특정 크기의 데이터를 정렬된 주소에서 읽을 때 가장 효율적
- 정렬을 맞추기 위해 Padding을 추가
- 필드의 정렬 순서에 따라 구조체 크기가 달라질 수 있다
LayoutKind.Sequential
- C# 구조체의 기본 설정
- 개발자가 코드를 작성한 순서대로 메모리에 올린다
- 정렬을 위한 padding이 삽입될 수 있다
[StructLayout(LayoutKind.Auto)]
- CLR이 런타임 최적화를 위해 필드 순서 자동 조절 가능
ref struct
- 스택 전용 구조체
- 불가능 조건
- 클래스의 필드 변수로 선언
- 배열로 생성
- 박싱
- 인터페이스 구현
- 코루틴 내부에서 사용 불가
클래스
- 구조체의 무결성
- int 2개를 가진 구조체는 메모리상에서 정확히 8 바이트만 차지
- 클래스의 오버헤드
- 클래스로 객체를 생성하면 선언한 필드 데이터 외에도 CLR이 객체를 관리하기 위해 암시적으로 덧붙이는 정보
- SyncBlock, TypeHandle
- 보통 16 바이트 내외
클래스는 힙에 올라가는데 그럼 MonoBehaviour는 ?
MonoBehaviour(UnityEngine.Object) 는 이중 구조로 메모리에 적재
- C# 메모리와 C++ 본체
- 유니티 엔진은 C++로 작성되어 있고, C# 스크립트는 그 위에서 돌아간다
- C++ Native Memory
- 유니티 엔진의 핵심 영역에 실제 데이터 할당
- 게임 오브젝트의 Transform 정보, 물리 엔진 상태, 엔진 내부 콜백(Update, OnTriggerXXX …)
- GC의 관리를 받지 않고 엔진이 직접 통제
- C# Managed Heap
- C# 환경(Mono / IL2CPP)의 힙 메모리 영역에 MonoBehaviour 객체 할당
- 실제 데이터가 아닌 C++ 네이티브 메모리에 있는 Instance ID를 가리킨다
- GC의 관리 대상
new MonoBehaviour() 를 사용할 수 없는 이유
- new 키워드는 C# Managed Heap에 껍데기만 생성할 뿐, C++ 엔진 측에 진짜 본체를 만들어달라고 요청하지 않기 때문
- ⇒
AddComponent<T>()
Destroy 와 GC의 엇박자 : Fake Null
object obj = myMonoBehaviour;
bool isObjectNull = (obj == null);
bool isMonoNull = (myMonoBehaviour == null);
- Destroy를 호출했을 때
- C++ 메모리
- Unity 엔진이 프레임 종료 시점에 C++ 네이티브 객체를 파괴하고 메모리 회수
- C# 힙 메모리
- C#에있는 껍데기 객체는 본체가 사라졌으므로 참조를 잃은 빈 껍데기가 되고, GC가 돌아갈 때까지 그대로 메모리 점유