반응형

[PART7.클래스와 객체 입문(1/21)] 클래스 선언과 객체 생성 — new 한 줄에 숨은 다섯 단계

class는 참조 타입 / new는 IL newobj 한 명령에 힙 할당·zero-init·헤더 셋업·생성자 호출·참조 반환이 캡슐화 / 변수는 객체가 아니라 객체로 가는 리모컨 / 객체 헤더 16바이트(64비트) 오버헤드 / Update 안 매 프레임 new는 GC 폭탄 / target-typed new는 IL이 같은 신택스 슈가 / class vs struct = 힙 vs 스택 = newobj(헤더 있음) vs initobj(헤더 없음)


1. 한 줄짜리 함정

신입 개발자가 처음 작성한 Unity 코드 한 줄로 시작합니다.

C#
void Update()
{
    Vector3 velocity = new Vector3(0, 0, speed);
    transform.position += velocity * Time.deltaTime;
}

Vector3는 다행히 struct라 큰일이 안 나지만, 이 신입 개발자가 자기가 만든 클래스로 같은 패턴을 쓰면 어떻게 될까요?

C#
public class MoveCommand
{
    public float X, Y, Z;
    public MoveCommand(float x, float y, float z) { X = x; Y = y; Z = z; }
}

void Update()
{
    MoveCommand cmd = new MoveCommand(0, 0, speed);   // ❌ 매 프레임 힙 할당
    Apply(cmd);
}

60FPS면 1초에 60번 새 객체, 1분이면 3,600번. 매 객체가 객체 헤더 16바이트 + 필드 12바이트 = 28바이트이니 1분 약 100KB가 가비지로 쌓입니다. Unity 모바일에서는 곧 GC 스파이크로 이어지죠.

이게 왜 그런지 — new 한 줄이 IL에서 어떤 일을 하는지 이 글에서 한 단계씩 풀어 보겠습니다.


2. 클래스란 무엇인가

비유 — 청사진과 실제 건물

class 선언은 청사진입니다. 청사진 자체는 종이 한 장이라 메모리를 거의 쓰지 않지만, "방 3개, 주방 1개, 욕실 1개" 같은 구조 정보가 적혀 있습니다.

C#
public class Player
{
    public string Name;
    public int Health;
    public int Mana;
}

이 코드는 아직 메모리에 아무것도 만들지 않습니다. 단지 "Player라는 청사진은 string 1개 + int 2개로 구성된다"는 메타데이터를 어셈블리에 박아 둘 뿐입니다.

실제 건물(인스턴스)new를 호출하는 순간 만들어집니다 — 청사진을 보고 시공팀(CLR)이 부지(힙)에 메모리를 잡고, 인테리어(필드 초기화)를 한 뒤 주소(참조)를 우리에게 돌려줍니다.

참조 타입의 진짜 의미 — 변수는 리모컨이다

class참조 타입(Reference Type)입니다. 변수 자체는 객체가 아니라 힙에 있는 객체를 가리키는 주소(리모컨)입니다.

C#
Player a = new Player();      // 힙에 객체 1개 생성, a는 그 주소
Player b = a;                 // ⚠️ 객체 복사가 아니라 주소 복사 — 리모컨이 두 개
b.Health = 0;                 // 같은 객체를 가리키므로
Console.WriteLine(a.Health);  // 0 — a로 봐도 변경된 값

b = a객체를 새로 만들지 않습니다. 두 변수가 같은 힙 객체를 가리키게 될 뿐. 이 사실을 모르고 "객체를 복사했으니 독립이겠지"라고 생각하면 디버깅이 어려운 버그가 시작됩니다.

struct는 정반대 — 대입이 곧 값 복사라 두 변수가 독립합니다. PART 7 #12에서 따로 다루지만, 이 한 줄이 클래스와 구조체의 가장 큰 차이입니다.


3. new가 하는 다섯 단계

new Player(...) 한 줄을 호출하면 CLR이 다음 다섯 단계를 차례로 수행합니다.

new Player(

이 다섯 단계가 IL newobj 단 한 명령어에 모두 캡슐화되어 있습니다. C 언어의 malloc + memset + 함수 호출을 한 줄로 묶은 것과 같은 추상이라고 보면 됩니다.

새 객체와 옛 객체

new가 끝나는 순간, 이전에 그 변수가 가리키던 객체는 더 이상 그 변수와 무관해집니다.

C#
Player p = new Player("Hero", 100);  // 힙에 객체 A 생성, p → A
p = new Player("Mage", 80);          // 힙에 객체 B 생성, p → B
                                     // 객체 A는 누구도 안 가리킴 → GC 대상

객체 A는 사라지지 않고 힙에 그대로 남아 있다가, 다음 GC 사이클에서 회수됩니다. 메서드 안에서 만든 객체는 메서드가 끝나자마자 사라지지 않는다 — 이게 신입이 가장 헷갈리는 부분이고, 매 프레임 new가 위험한 이유이기도 합니다.


4. IL로 본 newnewobj 명령어

이론은 충분하니 IL을 봅시다.

C#
public class Player
{
    public string Name;
    public int Health;
    public Player(string name, int hp) { Name = name; Health = hp; }
}

public static Player MakePlayer() => new Player("Hero", 100);
IL
// MakePlayer 메서드
IL_0000: ldstr "Hero"                                          // 인자 1: "Hero" 푸시
IL_0005: ldc.i4.s 100                                          // 인자 2: 100 푸시
IL_0007: newobj instance void Player::.ctor(string, int32)     // ← 다섯 단계 모두 캡슐화
IL_000c: ret                                                   // 새 Player의 참조 반환

// Player의 .ctor (생성자) 내부
IL_0000: ldarg.0                                               // this 로드
IL_0001: call instance void System.Object::.ctor()             // 부모(Object) 생성자 자동 호출
IL_0008: ldarg.0
IL_0009: ldarg.1                                               // name
IL_000a: stfld string Player::Name                             // this.Name = name
IL_000f: ldarg.0
IL_0010: ldarg.2                                               // hp
IL_0011: stfld int32 Player::Health                            // this.Health = hp
IL_0016: ret

new Player("Hero", 100) C# 한 줄이 newobj IL 한 줄로 컴파일됩니다 — 매우 깔끔하지만 그 뒤에 다섯 단계가 모두 들어 있죠.

생성자 내부의 첫 두 줄(ldarg.0 + call System.Object::.ctor())은 컴파일러가 자동으로 삽입한 부모 생성자 호출입니다. C#에서 : base()를 명시하지 않아도 모든 클래스 생성자에 들어가며, 이게 클래스 인스턴스가 항상 객체 헤더를 갖는 근본 이유입니다.

객체 헤더 16바이트 — 모든 클래스 인스턴스의 고정 비용

64비트 시스템에서 모든 클래스 인스턴스는 16바이트 오버헤드를 갖습니다.

영역 크기 (64비트) 용도
타입 객체 포인터 8바이트 어떤 클래스의 인스턴스인지 (메서드 테이블 주소)
동기화 블록 인덱스 8바이트 lock·GetHashCode·Wait/Pulse 등에서 사용

Player 인스턴스가 필드 12바이트밖에 안 되어도 실제 힙 점유는 12 + 16 = 28바이트. 작은 객체일수록 헤더 비율이 커서 수만 개를 만들면 헤더만으로 메모리가 출렁입니다. 이 함정 때문에 작고 자주 만드는 데이터는 class보다 struct(헤더 0)가 적합합니다.


5. class vs structnewobj vs initobj

같은 new인데 클래스와 구조체에서 IL이 어떻게 갈리는지 비교해 봅니다.

C#
public class PlayerC { public int X, Y; public PlayerC(int x, int y) { X = x; Y = y; } }
public struct PlayerS { public int X, Y; public PlayerS(int x, int y) { X = x; Y = y; } }

public static PlayerC MakeC() => new PlayerC(1, 2);
public static PlayerS MakeS() => new PlayerS(1, 2);
public static PlayerS Default() => default;
IL
// MakeC — class
IL_0000: ldc.i4.1
IL_0001: ldc.i4.2
IL_0002: newobj instance void PlayerC::.ctor(int32, int32)    // ← 힙에 28바이트 할당
IL_0007: ret

// MakeS — struct (인자 있는 생성자)
IL_0000: ldc.i4.1
IL_0001: ldc.i4.2
IL_0002: newobj instance void PlayerS::.ctor(int32, int32)    // ← 같은 newobj지만 힙 X
IL_0007: ret                                                  // — 호출자의 스택 슬롯에 직접 박힘

// Default — struct (default 키워드)
IL_0000: ldloca.s 0                                           // 지역 변수 주소
IL_0002: initobj PlayerS                                      // ← 힙 할당 0, CPU memset 수준
IL_0008: ldloc.0
IL_0009: ret

핵심 비교:

동작 class (PlayerC) struct (PlayerS)
new 1회 비용 힙 할당 + 16바이트 헤더 + 생성자 스택 슬롯 + 생성자 (헤더 없음)
default null (변수만 0) initobj — 모든 필드 0
변수 대입 주소 복사 (공유) 값 복사 (독립)
메서드가 끝난 후 GC가 회수해야 함 자동 소멸

같은 newobj IL이 클래스에선 힙, 구조체에선 스택을 의미하는 이중성이 처음에는 헷갈립니다. CLR이 호출자의 컨텍스트(extends ValueType 여부)를 보고 메모리 위치를 결정하기 때문입니다.

자세한 struct 사용법과 함정은 PART 7 #12에서 다룹니다.


6. C# 9 Target-typed new — IL이 같은 가독성 신문법

C#
// 옛 방식
Player p1 = new Player("Hero", 100);

// C# 9 — target-typed new
Player p2 = new("Hero", 100);          // 왼쪽 타입에서 추론
List<Order> orders = new() { o1, o2 }; // 컬렉션 초기화에서 특히 유용
private readonly List<Item> _items = new();  // 필드 초기화

IL을 비교하면 한 글자도 다르지 않습니다.

IL
// 둘 다 같은 IL
IL_0001: ldstr "Hero"
IL_0006: ldc.i4.s 100
IL_0008: newobj instance void Player::.ctor(string, int32)

var가 "변수 타입을 생략"한다면 new()는 "생성 타입을 생략"하는 대칭 신문법입니다. 런타임 비용은 0이고, 가독성만 향상됩니다 — 특히 private readonly Dictionary<string, List<Player>> _index = new(); 같은 긴 제네릭 타입에서 빛납니다.

언제 쓰는가:

  • 필드·프로퍼티 선언 (new())
  • 컬렉션 초기화 (List<T> x = new() { ... };)
  • 메서드 인자·반환 (return new(path);)

언제 피하는가:

  • 문맥에서 타입 추론이 모호할 때 (오버로드된 메서드 인자 등)
  • 코드 리뷰에서 "이게 무슨 타입이지?"를 한 번 더 봐야 한다면 그냥 명시

7. C# 12 Primary Constructor — 보일러플레이트 자동화

C#
// 옛 방식
public class Player
{
    public string Name { get; }
    public int Health { get; }

    public Player(string name, int health)
    {
        Name = name;
        Health = health;
    }
}

// C# 12 — Primary Constructor
public class Player(string name, int health)
{
    public string Name { get; } = name;
    public int Health { get; } = health;
}

클래스 이름 옆 괄호 안의 매개변수가 클래스 본문 어디서든 사용 가능해집니다. 컴파일러가 자동으로 생성자를 만들어 매개변수를 백킹 필드로 캡처합니다.

매개변수를 직접 필드/프로퍼티로 노출하려면 위 코드처럼 = name 식으로 대입하거나, 본문에서 name을 그대로 사용. 본문에서만 쓰고 외부에 노출 안 할 수도 있습니다.

C#
public class Logger(ILoggerFactory factory)
{
    private readonly ILogger _log = factory.CreateLogger("App");
    public void Info(string msg) => _log.LogInformation(msg);
}

DI(Dependency Injection) 코드에서 보일러플레이트를 크게 줄여 주는 패턴입니다. 자세한 동작과 함정은 PART 7 #11에서 다룹니다.


8. Unity 핫패스 — 매 프레임 new를 0으로

Before/After: Update 안의 객체 생성

C#
// ❌ Before — 매 프레임 새 객체 (60FPS면 초당 60개 GC 후보)
public class EnemyController : MonoBehaviour
{
    void Update()
    {
        AttackPlan plan = new AttackPlan(target, weapon, damage);
        ExecutePlan(plan);
    }
}

// ✅ After 1 — 인스턴스 캐싱 + 재사용
public class EnemyController : MonoBehaviour
{
    private readonly AttackPlan _plan = new();   // 인스턴스 필드, 한 번만 alloc

    void Update()
    {
        _plan.Reset(target, weapon, damage);     // 같은 객체에 값만 갱신
        ExecutePlan(_plan);
    }
}

// ✅ After 2 — struct로 변경 (작고 데이터 위주면)
public struct AttackPlan
{
    public Target Target;
    public Weapon Weapon;
    public int Damage;
}

void Update()
{
    AttackPlan plan = new AttackPlan { Target = target, Weapon = weapon, Damage = damage };
    ExecutePlan(plan);                            // struct는 스택, GC 부담 0
}

핵심 두 가지:

  1. 수명이 한 프레임이고 데이터 위주라면 → struct 후보. GC 부담 0, 헤더 0.
  2. 메서드와 상태가 모두 있는 객체라면 → 인스턴스 캐싱 후 Reset/Init 메서드로 값만 갱신.

Unity 모바일 GC 특수성

Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터, 한 번 돌면 모든 매니지드 스레드를 멈춘다)의 조합이라, 객체 한 번이 곧 5~15ms 프레임 스파이크로 이어질 수 있습니다.

Update/FixedUpdate/LateUpdate 안에서 new가 보이면 의심하시고, 객체 풀(Object Pool) 또는 인스턴스 캐싱으로 옮기는 게 60FPS의 출발점입니다.

C#
// 객체 풀의 가장 단순한 형태
private readonly Queue<AttackPlan> _pool = new();

AttackPlan Rent() => _pool.Count > 0 ? _pool.Dequeue() : new AttackPlan();
void Return(AttackPlan plan) { plan.Clear(); _pool.Enqueue(plan); }

Unity 6 이후에는 UnityEngine.Pool.ObjectPool<T> 같은 표준 풀 API도 제공됩니다.


9. 함정과 주의사항

함정 1 — 변수 대입은 객체 복사가 아니다

C#
Player a = new Player("A", 100);
Player b = a;                          // ⚠️ 주소 복사
b.Health = 0;
Console.WriteLine(a.Health);           // 0 — 같은 객체!

신입이 가장 자주 만드는 버그. 객체를 정말로 복사하려면 클래스에 복사 생성자나 Clone 메서드를 직접 만들거나, record(C# 9, PART 7 #18)의 with 표현식을 사용합니다.

함정 2 — 매개변수 없는 클래스 생성자가 자동 제공되지만, 하나라도 만들면 사라진다

C#
public class Player
{
    public string Name;
    // 생성자 정의 없음 → 컴파일러가 매개변수 없는 기본 생성자 자동 제공
}
new Player();   // ✅ OK

public class Enemy
{
    public string Name;
    public Enemy(string name) { Name = name; }   // 매개변수 있는 생성자만 정의
}
new Enemy();    // ❌ 컴파일 오류 — 자동 기본 생성자가 사라졌다

커스텀 생성자를 하나라도 만들면 자동 기본 생성자가 사라집니다. 둘 다 필요하면 직접 public Enemy() { }를 추가해야 합니다. 이 동작은 C# 1.0부터 그대로이며 가장 헷갈리는 함정 중 하나입니다.

함정 3 — null 참조 변수에 멤버 접근

C#
Player p = null;
p.Name = "Hero";   // NullReferenceException!

참조 타입 변수는 객체 자체가 아니라 주소이므로 주소가 비어 있으면(null) 멤버 접근이 곧 예외입니다. 변수 선언만 하고 new로 초기화하지 않으면 null로 시작합니다.

C#
Player p;            // ⚠️ 컴파일러가 사용 시 "할당되지 않은 지역 변수" 오류
Player p = null;     // ✅ 명시적 null
Player p = new();    // ✅ target-typed new로 초기화

C# 8 이후 nullable 참조 타입(#nullable enable)을 켜면 컴파일러가 null이 들어갈 수 있는 자리를 경고해 줍니다.

함정 4 — 매 프레임 새 객체 (앞서 본 GC 폭탄)

이미 다뤘지만 한 번 더 강조 — Update/FixedUpdate/LateUpdate 안의 new는 거의 항상 의심 대상입니다. 진짜로 매 프레임 새 객체가 필요한 경우는 거의 없으며, 대부분 인스턴스 캐싱이나 객체 풀로 옮길 수 있습니다.

함정 5 — new 결과를 변수에 안 받기

C#
// ❌ 결과를 안 받음 — 객체가 만들어지자마자 GC 대상
new Player("Hero", 100);

// ✅ 변수로 받거나
Player p = new("Hero", 100);
// ✅ 메서드에 직접 넘기거나
Register(new("Hero", 100));

new는 부수 효과가 없는 표현식이라, 결과를 안 받으면 객체가 즉시 가비지가 됩니다. 컴파일러가 경고를 주지 않는 자리라 더 위험합니다.


10. C# 버전별 변화

버전 변화 비고
1.0 class, new, 생성자, : base() 기본
1.0 자동 기본 생성자 (커스텀 생성자 없을 때만)  
3.0 객체 초기화자 new Player { Name = "A" } PART 7 #9
9.0 target-typed new Player p = new("A", 100)
9.0 init 접근자 + record PART 7 #5·#18
11.0 required 멤버 PART 7 #6
12.0 Primary Constructor PART 7 #11

new의 본질(IL newobj)은 1.0부터 변하지 않았고, 신문법은 모두 가독성·보일러플레이트 감소를 위한 컴파일러 변환입니다. 런타임 비용은 동일.


11. 정리

  • [ ] class 선언은 청사진, 메모리는 new 시점에 할당.
  • [ ] new는 IL newobj 한 명령에 다섯 단계 캡슐화: 크기 계산 → 힙 할당 + zero-init → 객체 헤더 셋업 → 생성자 호출 → 참조 반환.
  • [ ] 모든 클래스 인스턴스는 객체 헤더 16바이트(64비트) 오버헤드 — 작은 객체일수록 비율 큼.
  • [ ] 변수 대입은 주소 복사 — 두 변수가 같은 객체를 가리키며, 한쪽 수정이 다른 쪽에 보임.
  • [ ] new 결과를 변수에 안 받으면 즉시 GC 후보 — 컴파일러 경고 없음, 직접 주의.
  • [ ] new는 같은 IL이지만 class는 힙, struct는 스택extends ValueType 여부로 갈림.
  • [ ] defaultinitobj — 힙 할당 0, CPU memset 수준. struct에서만 의미.
  • [ ] C# 9 target-typed new(Player p = new("A", 100))는 IL이 동일한 가독성 신문법.
  • [ ] C# 12 Primary Constructor(class Player(...))는 보일러플레이트 자동화 — DI 코드에서 특히 유용.
  • [ ] 자동 기본 생성자는 커스텀 생성자가 없을 때만 제공 — 하나라도 만들면 사라짐, 둘 다 필요하면 명시적으로 추가.
  • [ ] Unity Update/FixedUpdate 안의 new는 의심 대상 — IL2CPP + Boehm GC 환경에서 객체 한 번이 5~15ms 프레임 스파이크.
  • [ ] 핫패스 패턴: 인스턴스 캐싱 + Reset/Init 메서드, 또는 struct 변환, 또는 객체 풀(Queue<T> / UnityEngine.Pool.ObjectPool<T>).
  • [ ] 더 깊은 클래스 설계(필드·접근자·생성자 체이닝·정적 멤버)는 PART 7 #2~#11에서 차례로 다룬다.
반응형

+ Recent posts