[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 코드 한 줄로 시작합니다.
void Update()
{
Vector3 velocity = new Vector3(0, 0, speed);
transform.position += velocity * Time.deltaTime;
}
Vector3는 다행히 struct라 큰일이 안 나지만, 이 신입 개발자가 자기가 만든 클래스로 같은 패턴을 쓰면 어떻게 될까요?
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개" 같은 구조 정보가 적혀 있습니다.
public class Player
{
public string Name;
public int Health;
public int Mana;
}
이 코드는 아직 메모리에 아무것도 만들지 않습니다. 단지 "Player라는 청사진은 string 1개 + int 2개로 구성된다"는 메타데이터를 어셈블리에 박아 둘 뿐입니다.
실제 건물(인스턴스)은 new를 호출하는 순간 만들어집니다 — 청사진을 보고 시공팀(CLR)이 부지(힙)에 메모리를 잡고, 인테리어(필드 초기화)를 한 뒤 주소(참조)를 우리에게 돌려줍니다.
참조 타입의 진짜 의미 — 변수는 리모컨이다
class는 참조 타입(Reference Type)입니다. 변수 자체는 객체가 아니라 힙에 있는 객체를 가리키는 주소(리모컨)입니다.
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이 다음 다섯 단계를 차례로 수행합니다.

이 다섯 단계가 IL newobj 단 한 명령어에 모두 캡슐화되어 있습니다. C 언어의 malloc + memset + 함수 호출을 한 줄로 묶은 것과 같은 추상이라고 보면 됩니다.
새 객체와 옛 객체
new가 끝나는 순간, 이전에 그 변수가 가리키던 객체는 더 이상 그 변수와 무관해집니다.
Player p = new Player("Hero", 100); // 힙에 객체 A 생성, p → A
p = new Player("Mage", 80); // 힙에 객체 B 생성, p → B
// 객체 A는 누구도 안 가리킴 → GC 대상
객체 A는 사라지지 않고 힙에 그대로 남아 있다가, 다음 GC 사이클에서 회수됩니다. 메서드 안에서 만든 객체는 메서드가 끝나자마자 사라지지 않는다 — 이게 신입이 가장 헷갈리는 부분이고, 매 프레임 new가 위험한 이유이기도 합니다.
4. IL로 본 new — newobj 명령어
이론은 충분하니 IL을 봅시다.
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);
// 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 struct — newobj vs initobj
같은 new인데 클래스와 구조체에서 IL이 어떻게 갈리는지 비교해 봅니다.
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;
// 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이 같은 가독성 신문법
// 옛 방식
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_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 — 보일러플레이트 자동화
// 옛 방식
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을 그대로 사용. 본문에서만 쓰고 외부에 노출 안 할 수도 있습니다.
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 안의 객체 생성
// ❌ 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
}
핵심 두 가지:
- 수명이 한 프레임이고 데이터 위주라면 →
struct후보. GC 부담 0, 헤더 0. - 메서드와 상태가 모두 있는 객체라면 → 인스턴스 캐싱 후
Reset/Init메서드로 값만 갱신.
Unity 모바일 GC 특수성
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터, 한 번 돌면 모든 매니지드 스레드를 멈춘다)의 조합이라, 객체 한 번이 곧 5~15ms 프레임 스파이크로 이어질 수 있습니다.
Update/FixedUpdate/LateUpdate 안에서 new가 보이면 의심하시고, 객체 풀(Object Pool) 또는 인스턴스 캐싱으로 옮기는 게 60FPS의 출발점입니다.
// 객체 풀의 가장 단순한 형태
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 — 변수 대입은 객체 복사가 아니다
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 — 매개변수 없는 클래스 생성자가 자동 제공되지만, 하나라도 만들면 사라진다
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 참조 변수에 멤버 접근
Player p = null;
p.Name = "Hero"; // NullReferenceException!
참조 타입 변수는 객체 자체가 아니라 주소이므로 주소가 비어 있으면(null) 멤버 접근이 곧 예외입니다. 변수 선언만 하고 new로 초기화하지 않으면 null로 시작합니다.
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 결과를 변수에 안 받기
// ❌ 결과를 안 받음 — 객체가 만들어지자마자 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는 ILnewobj한 명령에 다섯 단계 캡슐화: 크기 계산 → 힙 할당 + zero-init → 객체 헤더 셋업 → 생성자 호출 → 참조 반환. - [ ] 모든 클래스 인스턴스는 객체 헤더 16바이트(64비트) 오버헤드 — 작은 객체일수록 비율 큼.
- [ ] 변수 대입은 주소 복사 — 두 변수가 같은 객체를 가리키며, 한쪽 수정이 다른 쪽에 보임.
- [ ]
new결과를 변수에 안 받으면 즉시 GC 후보 — 컴파일러 경고 없음, 직접 주의. - [ ]
new는 같은 IL이지만 class는 힙, struct는 스택 —extends ValueType여부로 갈림. - [ ]
default는initobj— 힙 할당 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에서 차례로 다룬다.