EveryDay.DevUp

struct vs class — 무엇을 언제 선택하는가 본문

카테고리 없음

struct vs class — 무엇을 언제 선택하는가

EveryDay.DevUp 2026. 4. 1. 20:43

struct vs class — 무엇을 언제 선택하는가

structclass는 비슷하게 생겼지만 메모리에서 완전히 다르게 동작한다. 이 차이를 모르면 의도치 않은 복사, 성능 저하, 논리적 버그를 만든다.

문제 제기 — 왜 선택이 중요한가

Unity 모바일 게임에서 전장의 안개(Fog of War) 시스템을 만들고 있다. 맵의 각 셀에 가시 여부와 밝기를 저장해야 한다.

C#
// class로 구현
class FogCell
{
    public bool IsVisible;
    public float Brightness;
}

FogCell[,] fogGrid = new FogCell[256, 256];  // 65,536개 객체

프로파일러를 열면 65,536개의 힙 할당이 발생하고, GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)가 이 객체들을 추적하느라 프레임이 끊긴다.

C#
// struct로 변경
struct FogCell
{
    public bool IsVisible;
    public float Brightness;
}

FogCell[,] fogGrid = new FogCell[256, 256];  // 힙 할당 1회 (배열만)

struct로 바꾸면 힙 할당이 배열 1회로 줄고, 65,536개의 데이터가 메모리에 연속으로 배치되어 CPU 캐시 효율도 극대화된다. 같은 데이터를 담는데 structclass냐에 따라 성능이 수십 배 달라질 수 있다.

하지만 struct가 항상 정답은 아니다. 크기가 크면 복사 비용이 폭발하고, 가변(mutable) struct는 찾기 어려운 버그를 만든다. 언제 struct를 쓰고 언제 class를 써야 하는지, 그 기준을 IL 분석과 함께 파헤친다.


개념 정의 — 값 타입과 참조 타입의 본질

택배 비유로 이해하기

  • struct (값 타입) = 실물 택배. 변수에 데이터 자체가 담겨 있다. 다른 사람에게 건네면 복사본을 새로 만들어 보낸다. 상대방이 받은 것을 수정해도 내 것은 변하지 않는다.
  • class (참조 타입) = 공유 문서 링크. 변수에는 주소(참조)만 들어 있고, 실제 데이터는 힙에 있다. 링크를 보내면 같은 문서를 공유한다. 한 쪽이 수정하면 다른 쪽에도 반영된다.
struct vs class — 메모리 할당 비교

타입 선언의 차이

C#
// struct — System.ValueType을 상속
public struct PointStruct
{
    public int X;
    public int Y;
    public PointStruct(int x, int y) { X = x; Y = y; }
}

// class — System.Object를 상속
public class PointClass
{
    public int X;
    public int Y;
    public PointClass(int x, int y) { X = x; Y = y; }
}
IL
// struct — ValueType을 상속하는 sealed 타입
.class public sequential ansi sealed beforefieldinit PointStruct
    extends [System.Runtime]System.ValueType        // ValueType 상속!
{
    .field public int32 X
    .field public int32 Y
}

// class — Object를 상속하는 타입
.class public auto ansi beforefieldinit PointClass
    extends [System.Runtime]System.Object            // Object 상속
{
    .field public int32 X
    .field public int32 Y
}

IL에서 핵심 차이가 명확하다:

  • struct: System.ValueType을 상속하고 sealed(상속 불가). sequential은 필드가 선언 순서대로 메모리에 배치됨을 의미한다.
  • class: System.Object를 상속하고 상속 가능. 메서드 테이블, 동기화 블록 등 객체 헤더 오버헤드가 추가된다.

내부 동작 — 복사와 참조의 IL 증거

struct의 값 복사

C#
public static void CopyStruct()
{
    PointStruct p1 = new PointStruct(10, 20);
    PointStruct p2 = p1;  // 값 전체 복사
    p2.X = 100;           // p1은 변하지 않음
}
IL
IL_0001: ldloca.s 0                                    // p1의 스택 주소 로드
IL_0003: ldc.i4.s 10
IL_0005: ldc.i4.s 20
IL_0007: call instance void PointStruct::.ctor(int32, int32)  // 스택에 직접 초기화
IL_000c: ldloc.0                                       // p1의 값 전체를 스택에 로드
IL_000d: stloc.1                                       // p2에 값 전체를 복사 — 8바이트 memcpy!
IL_000e: ldloca.s 1                                    // p2의 주소 로드
IL_0010: ldc.i4.s 100
IL_0012: stfld int32 PointStruct::X                    // p2.X만 수정 — p1은 무관

ldloc.0 + stloc.1이 값 복사의 핵심이다. p1모든 필드가 p2로 바이트 단위로 복사된다. newobj 없이 스택에서 직접 초기화되므로 힙 할당이 전혀 없다.

class의 참조 복사

C#
public static void CopyClass()
{
    PointClass c1 = new PointClass(10, 20);
    PointClass c2 = c1;  // 참조만 복사
    c2.X = 100;          // c1.X도 100으로 변함!
}
IL
IL_0001: ldc.i4.s 10
IL_0003: ldc.i4.s 20
IL_0005: newobj instance void PointClass::.ctor(int32, int32)  // 힙에 객체 생성!
IL_000a: stloc.0                                               // c1 = 힙 주소
IL_000b: ldloc.0                                               // c1의 주소를 로드
IL_000c: stloc.1                                               // c2에 같은 주소 저장 — 4/8바이트만 복사
IL_000d: ldloc.1                                               // c2의 주소로
IL_000e: ldc.i4.s 100
IL_0010: stfld int32 PointClass::X                             // 힙 객체의 X를 수정 — c1도 영향!

결정적 차이는 newobj다. class는 힙에 객체를 할당하고, 스택에는 참조(주소)만 저장한다. c2 = c1은 주소값 복사이므로 두 변수가 같은 힙 객체를 가리킨다.

배열 순회 — 캐시 효율의 차이

배열 메모리 레이아웃 — struct[] vs class[]
C#
// struct 배열 순회
public static int SumStructArray(PointStruct[] arr)
{
    int sum = 0;
    for (int i = 0; i < arr.Length; i++)
        sum += arr[i].X + arr[i].Y;
    return sum;
}
IL
// struct 배열 — ldelema로 요소 주소에 직접 접근
IL_000a: ldelema PointStruct                           // 배열 요소의 주소를 직접 로드
IL_000f: ldfld int32 PointStruct::X                    // 주소에서 X 필드 직접 읽기
IL_0016: ldelema PointStruct                           // 같은 요소의 주소 다시 로드
IL_001b: ldfld int32 PointStruct::Y                    // Y 필드 직접 읽기
C#
// class 배열 순회
public static int SumClassArray(PointClass[] arr)
{
    int sum = 0;
    for (int i = 0; i < arr.Length; i++)
        sum += arr[i].X + arr[i].Y;
    return sum;
}
IL
// class 배열 — ldelem.ref로 참조를 먼저 가져온 후 필드 접근
IL_000a: ldelem.ref                                    // 배열에서 참조(주소)를 로드
IL_000b: ldfld int32 PointClass::X                     // 참조를 따라가서 X 읽기
IL_0012: ldelem.ref                                    // 다시 참조 로드
IL_0013: ldfld int32 PointClass::Y                     // 참조를 따라가서 Y 읽기

핵심 차이:

  • struct 배열: ldelema — 배열 메모리 안에서 요소의 주소를 직접 계산한다. 데이터가 연속으로 배치되어 있어 CPU 캐시 라인(64바이트) 하나에 여러 요소가 들어간다.
  • class 배열: ldelem.ref — 배열에서 참조를 꺼낸 뒤 힙의 다른 위치로 따라간다. 객체가 힙 곳곳에 흩어져 있어 캐시 미스(Cache Miss)가 빈번하다.

수만 개 요소를 순회할 때 이 차이는 수 배의 성능 차이로 나타난다.


실전 적용 — 올바른 선택 기준

Microsoft 공식 가이드라인

struct를 사용하려면 아래 4가지를 모두 만족해야 한다:

조건 설명
1. 단일 값 논리적으로 하나의 값을 표현 (예: 좌표, 색상, 점수)
2. 16바이트 이하 인스턴스 크기가 16바이트를 넘지 않음
3. 불변 생성 후 필드가 변경되지 않음
4. 박싱 없음 object나 인터페이스로 빈번하게 캐스팅되지 않음
왜 16바이트인가? x64에서 참조 크기는 8바이트다. struct를 메서드에 전달할 때 값 전체가 복사되므로, 크기가 16바이트를 넘으면 참조(8바이트)를 복사하는 것보다 비싸진다. 16바이트는 int 4개, long 2개에 해당한다.

❌ Before — class로 만든 작은 데이터

C#
// 좌표 데이터를 class로 만듦 — 불필요한 힙 할당
class Damage
{
    public int Amount;
    public int Type;
}

void ApplyDamage(Damage dmg) { /* ... */ }

// 호출할 때마다 힙 할당!
ApplyDamage(new Damage { Amount = 50, Type = 1 });

✅ After — readonly struct로 만든 작은 데이터

C#
// 8바이트, 불변, 단일 값 — struct가 적합
readonly struct Damage
{
    public readonly int Amount;
    public readonly int Type;
    public Damage(int amount, int type) { Amount = amount; Type = type; }
}

void ApplyDamage(in Damage dmg) { /* ... */ }  // in으로 복사도 방지

// 스택에서 생성, 힙 할당 없음, 복사도 없음
ApplyDamage(new Damage(50, 1));
in 매개변수 값 타입을 참조로 전달하되 읽기 전용으로 만드는 한정자다. 큰 struct의 복사 비용을 없애면서도 원본 수정을 방지한다.
예시: void Process(in LargeStruct data) — data는 복사되지 않고 참조로 전달되며, 메서드 안에서 수정 불가

🎮 Unity에서의 선택 기준

Unity 환경에서는 추가로 고려할 사항이 있다:

  • Vector3, Quaternion, Color — Unity 내장 타입은 모두 struct다. 매 프레임 수천 번 생성/폐기되어도 GC 부담이 없다.
  • MonoBehaviour는 반드시 class — Unity의 컴포넌트 시스템이 참조로 관리하기 때문이다.
  • Job System / BurstIJob, IJobParallelForstruct만 허용한다. NativeArray도 unmanaged struct 전용이다.
  • ECS(Entity Component System) — 모든 컴포넌트가 struct 기반이다. 데이터 지향 설계의 핵심.

함정과 주의사항

❌ 함정 1 — List<struct> 인덱서로 수정 불가

C#
public static void ListStructTrap()
{
    var list = new List<PointStruct> { new PointStruct(1, 2) };
    // list[0].X = 10;  // 컴파일 오류! 인덱서가 복사본을 반환하기 때문
}
IL
IL_0017: callvirt instance !0 List<PointStruct>::get_Item(int32)  // 복사본 반환!
IL_001c: stloc.1                                                   // 로컬 변수에 저장
IL_001f: ldc.i4.s 10
IL_0021: stfld int32 PointStruct::X                                // 로컬 복사본의 X를 수정
IL_0026: ldloc.0
IL_0027: ldc.i4.0
IL_0028: ldloc.1
IL_0029: callvirt instance void List<PointStruct>::set_Item(int32, !0)  // 수정된 복사본을 다시 넣기

get_Item이 struct의 복사본을 반환하기 때문에, 직접 필드를 수정할 수 없다. 꺼내서 → 수정 → 다시 넣는 3단계가 필요하다.

C#
// ✅ 올바른 방법
PointStruct temp = list[0];
temp.X = 10;
list[0] = temp;

❌ 함정 2 — 방어적 복사 (Defensive Copy)

방어적 복사 (Defensive Copy) readonly 필드나 in 매개변수에 담긴 mutable struct의 메서드를 호출할 때, 컴파일러가 원본을 보호하기 위해 몰래 복사본을 만드는 동작이다. 개발자 눈에 보이지 않아 성능 저하를 찾기 어렵다.
C#
public struct MutablePoint
{
    public int X;
    public int Y;
    public int Sum() { return X + Y; }  // 상태를 바꿀 수도 있는 메서드
}

// in + mutable struct → 방어적 복사 발생!
public static int DefensiveCopyProblem(in MutablePoint p)
{
    return p.Sum();
}
IL
// in + mutable struct — 방어적 복사 발생!
IL_0001: ldarg.0
IL_0002: ldobj MutablePoint                            // 전체 struct를 복사! (defensive copy)
IL_0007: stloc.0                                       // 로컬 변수에 저장
IL_0008: ldloca.s 0                                    // 복사본의 주소로
IL_000a: call instance int32 MutablePoint::Sum()       // 복사본에서 Sum() 호출

ldobj MutablePoint가 방어적 복사의 증거다. 컴파일러는 Sum()이 struct의 상태를 변경할 수 있다고 판단해, 원본을 보호하기 위해 전체 복사를 수행한다.

C#
public readonly struct ImmutablePoint
{
    public readonly int X;
    public readonly int Y;
    public ImmutablePoint(int x, int y) { X = x; Y = y; }
    public int Sum() { return X + Y; }
}

// in + readonly struct → 방어적 복사 없음!
public static int NoDefensiveCopy(in ImmutablePoint p)
{
    return p.Sum();
}
IL
// in + readonly struct — 방어적 복사 없음!
IL_0001: ldarg.0                                       // 참조를 그대로 사용
IL_0002: call instance int32 ImmutablePoint::Sum()     // 원본에서 직접 호출 — ldobj 없음!

readonly struct로 선언하면 컴파일러가 상태 변경이 불가능함을 알기 때문에 ldobj 없이 원본 참조에서 직접 메서드를 호출한다. struct가 클수록 이 차이가 극적이다.

❌ 함정 3 — Boxing이 발생하는 상황

박싱 (Boxing) 값 타입(struct)을 참조 타입(object나 인터페이스)으로 변환할 때, 값을 힙에 새 객체로 포장하는 과정이다. 힙 할당이 발생하므로 GC 압박의 원인이 된다.
C#
public static void BoxingExample()
{
    ScoreData score = new ScoreData(42);
    object boxed = score;                     // boxing!
    IComparable<ScoreData> comp = score;      // boxing!
}
IL
IL_000a: ldloc.0
IL_000b: box ScoreData                                 // 힙에 ScoreData 복사 — boxing 1회
IL_0010: stloc.1
IL_0011: ldloc.0
IL_0012: box ScoreData                                 // 또 boxing — 힙 할당 2회!
IL_0017: stloc.2

box 명령어가 2번 등장한다. object로 캐스팅해도, 인터페이스로 캐스팅해도 boxing이 발생한다.

C#
// ✅ 제네릭으로 boxing 회피
public static void NoBoxingGeneric(ScoreData a, ScoreData b)
{
    int result = a.CompareTo(b);  // IComparable<T> 제네릭 — boxing 없음
}
IL
IL_0001: ldarga.s a
IL_0003: ldarg.1
IL_0004: call instance int32 ScoreData::CompareTo(valuetype ScoreData)  // 직접 호출 — box 없음!

IComparable<ScoreData>CompareTo를 struct 타입에서 직접 호출하면 인터페이스 디스패치 없이 직접 호출된다. box 명령어가 전혀 없다.

❌ 함정 4 — Unity transform.position 직접 수정 불가

C#
// ❌ 컴파일 오류! position은 struct(Vector3)를 반환하는 프로퍼티
transform.position.x = 10f;

// ✅ 새 Vector3를 만들어 통째로 할당
transform.position = new Vector3(10f, transform.position.y, transform.position.z);

transform.positionVector3(struct)의 복사본을 반환하는 프로퍼티다. 복사본의 x를 수정해도 원본에 반영되지 않으므로 컴파일러가 차단한다.

❌ 함정 5 — foreach에서 struct 수정

C#
var enemies = new List<EnemyData>();  // EnemyData가 struct일 때

foreach (var enemy in enemies)
{
    // enemy.Hp -= 10;  // 복사본이므로 원본에 반영되지 않음!
}

// ✅ for 루프 + 인덱서 사용
for (int i = 0; i < enemies.Count; i++)
{
    EnemyData temp = enemies[i];
    temp.Hp -= 10;
    enemies[i] = temp;
}

foreach의 반복 변수는 복사본이다. struct의 필드를 수정해도 컬렉션의 원본은 변하지 않는다.


C# 버전별 변화

C# 7.2 — readonly struct, in, ref struct

C#
// Before (C# 7.1 이하) — mutable struct의 방어적 복사 문제를 알기 어려움
struct Point { public int X, Y; }

// After (C# 7.2+) — readonly struct로 불변성 강제 + 방어적 복사 제거
readonly struct Point { public readonly int X, Y; }

readonly struct는 모든 필드를 readonly로 강제하여 방어적 복사를 원천 차단한다. in 매개변수와 ref struct도 C# 7.2에서 도입되었다.

C# 10 — record struct

C#
// Before — 직접 Equals, GetHashCode, ToString 구현
readonly struct Damage : IEquatable<Damage>
{
    public readonly int Amount;
    public readonly int Type;
    public bool Equals(Damage other) => Amount == other.Amount && Type == other.Type;
    public override int GetHashCode() => HashCode.Combine(Amount, Type);
    public override string ToString() => $"Damage({Amount}, {Type})";
}

// After (C# 10) — record struct가 자동 생성
readonly record struct Damage(int Amount, int Type);
// Equals, GetHashCode, ToString, ==, !=, with 표현식 모두 자동 생성

record struct는 값 기반 동등성, ToString, with 표현식을 컴파일러가 자동 생성한다. 불변 데이터 구조를 만들 때 코드가 극적으로 줄어든다.

C# 10 — struct 매개변수 없는 생성자

C#
// C# 10+ — struct에 매개변수 없는 생성자 허용
struct Counter
{
    public int Value { get; set; }
    public Counter() { Value = 1; }  // C# 9 이하에서는 불가능
}

// 주의: default(Counter)는 생성자를 호출하지 않는다!
Counter c1 = new Counter();     // Value = 1 (생성자 호출)
Counter c2 = default;           // Value = 0 (모든 필드 0 초기화)

default는 항상 모든 필드를 0/null/false로 초기화하며 생성자를 호출하지 않는다. 이 차이를 인지하지 못하면 버그가 생긴다.


정리

선택 기준 의사결정 트리

질문 답변 선택
상속이 필요한가? class
16바이트를 초과하는가? class (또는 in/ref와 함께 struct)
null이 될 수 있어야 하는가? class (또는 Nullable<T>)
가변 상태가 필요한가? class
작고, 불변이며, 단일 값인가? readonly struct
Unity Job/Burst에서 쓰는가? struct (필수)

핵심 체크리스트

  • [ ] struct는 스택에, class는 힙에 할당된다 — struct는 GC 대상이 아니므로 GC 압박을 줄인다
  • [ ] struct 할당은 값 전체 복사, class 할당은 참조만 복사 — 의도치 않은 공유/독립 문제에 주의
  • [ ] struct는 16바이트 이하로 유지 — 초과하면 복사 비용이 참조 전달보다 비싸진다
  • [ ] mutable struct는 방어적 복사를 유발한다readonly struct로 선언하여 원천 차단
  • [ ] in 매개변수로 큰 struct의 복사를 피한다 — 단, readonly struct가 아니면 방어적 복사 발생
  • [ ] struct를 object나 인터페이스로 캐스팅하면 boxing — 제네릭으로 회피
  • [ ] List<struct>의 인덱서는 복사본을 반환한다 — 꺼내서 수정 후 다시 넣어야 한다
  • [ ] foreach의 반복 변수는 복사본 — struct 필드 수정이 원본에 반영되지 않는다
  • [ ] Unity의 Vector3, Quaternion 등은 structtransform.position.x = 10은 안 되고 통째로 할당
  • [ ] record struct(C# 10)는 불변 데이터의 최적 표현 — Equals, GetHashCode, with가 자동 생성