C# 클래스와 객체: 주 생성자·required·개체 이니셜라이저 (.NET 10)

C# 클래스와 객체의 기본을 .NET 10에서 다시 검증했습니다. 필드와 속성, C# 12 주 생성자의 함정(CS9124), C# 11 required, 개체 이니셜라이저와 객체 크기 실측까지.

2026년 11월 보강: 2021년에 쓴 클래스 강좌의 예제를 .NET 10.0.14(C# 14)에서 다시 컴파일했습니다. 코드는 그대로 동작했지만 새 프로젝트에서는 warning CS8618이 3개 나오고, 원문의 "계속하려면 아무 키나 누르십시오" 줄은 Visual Studio가 붙이던 것이라 dotnet run에서는 나오지 않습니다. 여기에 2021년 이후 생긴 주 생성자(C# 12)·required(C# 11)·init(C# 9)와 객체 하나가 실제로 차지하는 바이트 수 실측을 더했습니다.

이 글은 C# 클래스를 처음 배우는 분, 그리고 예전 문법은 알지만 class Counter(string label)이나 required 같은 코드를 처음 본 분을 위한 글입니다. 결론부터 말하면 클래스는 설계도, 객체는 new로 찍어 낸 실물이고, 변수에 들어가는 것은 객체 자체가 아니라 참조입니다. 새로 생긴 문법은 전부 "객체를 만들 때 값을 어떻게 확실히 채우느냐"를 다룹니다.

객체와 클래스: 상태와 행동

현실의 대상을 프로그램으로 옮길 때는 상태와 행동으로 나눠 생각합니다. 전화기라면 색상·배터리 잔량이 상태이고, 전화 걸기·끊기가 행동입니다. C#에서 상태는 필드(또는 속성)로, 행동은 메서드로 표현합니다. 이 묶음을 정의한 것이 클래스이고, 클래스로 실제로 만든 하나하나가 객체(인스턴스)입니다. 붕어빵 틀 하나로 붕어빵을 여러 개 찍어 내는 관계와 같습니다.

필요한 부분만 남기고 나머지를 버리는 것을 추상화라고 합니다. 개를 프로그램으로 옮긴다면 이름·성별·주인 이름과 "짖는다" 정도만 남기면 됩니다.

2021년 글에 있던 "객체 = 상태 + 행동" 원형 그림은 외부 자료의 그림이라 이번 개정에서 빼고, 아래에 직접 그린 그림으로 바꿨습니다.

2021년 예제를 .NET 10에서 다시 실행하기

원문의 Dog 예제를 네임스페이스와 using까지 그대로 새 콘솔 프로젝트(dotnet new console, net10.0)에 넣었습니다.

class Dog {
    public string name;       // 이름
    public string gender;     // 성별
    public string ownernames; // 주인 이름
    public void Bark() {
        Console.WriteLine("{0} : 멍멍!", name);
    }
}
// Main
Dog dog = new Dog();
dog.name = "칸";
dog.gender = "남";
dog.ownernames = "철수";
dog.Bark();
Console.WriteLine("개의 이름: {0}, 개의 성별: {1}, 개의 주인이름: {2}", dog.name, dog.gender, dog.ownernames);
칸 : 멍멍!
개의 이름: 칸, 개의 성별: 남, 개의 주인이름: 철수

출력은 2021년과 같습니다. 다만 빌드할 때 경고가 3개 나옵니다.

Program.cs(7,23): warning CS8618: null을 허용하지 않는 필드 'name'은(는) 생성자를 종료할 때 null이 아닌 값을 포함해야 합니다. 'required' 한정자를 추가하거나 필드을(를) nullable로 선언하는 것이 좋습니다.

.NET 6부터 새 프로젝트 템플릿에 <Nullable>enable</Nullable>이 들어가서, "이 string은 null이 아니다"라고 선언해 놓고 값을 안 넣었다는 지적입니다. 해결 방법은 세 가지입니다. 기본값을 준다(= ""), null을 허용한다(string?), 아니면 경고 문구가 권하는 대로 required를 붙인다. 마지막 방법은 아래에서 다룹니다.

new Dog()는 "Dog 설계도로 객체를 하나 만들고, 생성자 Dog()를 호출하라"는 뜻입니다. C# 9부터는 왼쪽에 형식이 있으면 Dog dog = new();로 줄여 쓸 수 있습니다(LangVersion 8에서는 error CS8400: 'target-typed object creation'이 나는 것을 확인했습니다). 생성자 자체는 생성자와 소멸자에서 자세히 다룹니다.

변수에는 객체가 아니라 참조가 들어간다

클래스는 참조 형식입니다. 변수를 다른 변수에 대입하면 객체가 복사되는 것이 아니라 같은 객체를 가리키는 참조가 하나 더 생깁니다.

var a = new Dog { Name = "Kan", Owner = "Min" };
var b = a;
b.Name = "Bori";
Console.WriteLine($"a.Name={a.Name} same={ReferenceEquals(a, b)}");
a.Name=Bori same=True

b를 고쳤는데 a도 바뀌었습니다. 객체는 하나뿐이기 때문입니다. 값 자체가 복사되는 형식이 필요하면 구조체(struct)를 씁니다.

도식. 필드 Name, 속성 Owner, 메서드 Bark()를 가진 클래스 Dog에서 new라고 적힌 화살표 두 개가 Name이 Kan인 객체 1과 Name이 Bori인 객체 2로 이어진다. 객체 1에서 b = a라고 적힌 점선 화살표가 변수 a와 b가 같은 객체를 가리킨다는 상자로 이어진다.
클래스 하나로 객체를 여러 개 만든다. new 할 때마다 힙에 별도 객체가 생기고, 변수끼리 대입하면 참조만 복사된다.

필드와 속성, 한눈에

2021년 예제는 public 필드를 썼지만, 지금 C# 코드에서 바깥에 공개하는 데이터는 대부분 속성입니다.

class Dog
{
    public string Name = "";            // 필드: 변수 그 자체
    public string? Owner { get; set; }  // 속성: get/set 접근자를 거친다
    public void Bark() => Console.WriteLine($"{Name}: Woof!");
}

사용하는 쪽 코드는 dog.Name, dog.Owner로 똑같아 보이지만, 속성은 나중에 검증 로직을 넣거나 init으로 읽기 전용에 가깝게 만들 수 있고, 데이터 바인딩·직렬화도 속성을 기준으로 동작합니다. 내부 상태는 private 필드, 외부 공개는 속성이 기본이라고 기억하면 충분합니다. 자동 구현 속성, 읽기 전용 속성 등 자세한 내용은 속성 기초와 속성 심화에 정리했습니다. public·private 같은 키워드는 접근 제한자와 this에서 다룹니다.

2021년 이후 추가된 것: 주 생성자, required, init

주 생성자 (C# 12)

클래스 이름 뒤에 바로 매개변수를 쓰는 문법입니다. 매개변수는 클래스 본문 어디서든 쓸 수 있습니다.

class Counter(string label)
{
    private int count;
    public void Add() => count++;
    public override string ToString() => $"{label}={count}";
}
// var c = new Counter("clicks"); c.Add(); c.Add(); Console.WriteLine(c);
clicks=2

여기서 자주 하는 오해가 두 가지 있습니다. 첫째, label은 속성이 아닙니다. record와 달리 공개 멤버가 생기지 않아서 바깥에서 c.label로 읽으면 이렇게 됩니다.

error CS1061: 'Counter'에는 'label'에 대한 정의가 포함되어 있지 않고, 'Counter' 형식의 첫 번째 인수를 허용하는 액세스 가능한 확장 메서드 'label'이(가) 없습니다.

둘째, 매개변수로 속성을 초기화하면서 메서드 안에서도 매개변수를 쓰면 값이 두 벌이 됩니다. 컴파일러가 경고를 주지만, 실제로 어떤 일이 생기는지 확인해 봤습니다.

class Dog(string name)
{
    public string Name { get; set; } = name;
    public void Bark() => Console.WriteLine($"{name}: Woof! (Name={Name})");
}
// var d = new Dog("Kan"); d.Name = "Bori"; d.Bark();
warning CS9124: 매개 변수 'string name'은(는) 둘러싸는 유형의 상태로 캡처되며 해당 값은 필드, 속성 또는 이벤트를 초기화하는 데에도 사용됩니다.
Kan: Woof! (Name=Bori)

Name을 "Bori"로 바꿨는데 Bark()는 여전히 "Kan"을 출력합니다. 캡처된 name과 속성 Name이 각자 따로 저장되기 때문입니다. 아래 실측에서 보듯 객체도 8바이트 커집니다. 규칙은 간단합니다. 속성을 만들었으면 메서드에서는 속성을 쓴다.

required 멤버 (C# 11)와 init (C# 9)

"이 값은 객체를 만들 때 반드시 넣어라"를 생성자 없이 강제합니다.

class User
{
    public required int Id { get; init; }
    public required string Email { get; init; }
    public string Nick { get; set; } = "(none)";
}
var u = new User { Id = 7, Email = "a@example.com" };   // 7 a@example.com (none)

Email을 빠뜨리거나, 만든 뒤에 init 속성을 바꾸려 하면 컴파일이 막힙니다.

error CS9035: 필수 구성원 'User.Email'은(는) 개체 이니셜라이저 또는 특성 생성자에서 설정해야 합니다.
error CS8852: 초기값 전용 속성 또는 인덱서 'User.Id'은(는) 개체 이니셜라이저 또는 인스턴스 생성자나 'init' 접근자의 'this' 또는 'base'에만 할당할 수 있습니다.

{ Id = 7, Email = ... } 부분이 개체 이니셜라이저입니다. 생성자가 끝난 직후 속성에 값을 넣는 문법이며, 주 생성자와 함께 쓸 수도 있습니다: new Pet("Kan") { Owner = "Min" }.

"C# N에서 추가"를 직접 확인

글에 적은 버전이 맞는지 -p:LangVersion을 낮춰 빌드했습니다.

기능 낮춘 버전 실제 오류
주 생성자(클래스) 11 CS9058: 'primary constructors' is not available in C# 11.0. Please use language version 12.0 or greater.
required 멤버 10 CS8936: … 'required members' … 11.0 or greater.
init 접근자 8 CS8400: … 'init-only setters' … 9.0 or greater.
new() (형식 생략) 8 CS8400: … 'target-typed object creation' … 9.0 or greater.
판단 도식. 첫 질문: 호출하는 쪽이 값을 반드시 넣어야 하는가? 아니면 기본값이 있는 선택 속성을 개체 이니셜라이저로 설정한다. 그렇다면 두 번째 질문: 이름으로 설정하게 할지, 위치로 넘기게 할지. 이름이면 required init 속성, 위치면 생성자나 주 생성자.
새 객체에 값을 넣는 방법 고르기: required 멤버, 생성자(주 생성자), 개체 이니셜라이저로 설정하는 선택 속성.

실측: 객체 하나는 몇 바이트인가

클래스 객체에는 필드 말고도 헤더가 붙습니다. 100만 개를 만들어 배열에 담고 GC.GetAllocatedBytesForCurrentThread()의 증가분을 개수로 나눴습니다(Release 빌드, .NET 10.0.14, Apple Silicon Mac, 3회 모두 같은 값).

클래스 객체당 할당
new object() 24 B
빈 클래스 class Empty { } 24 B
int 필드 1개 24 B
2021년 Dog (string 필드 3개) 40 B
Counter(string label) + int count 32 B
Dog(string name) + Name 속성, 메서드에서 name도 사용 (CS9124) 32 B
Dog(string name) + Name 속성만 24 B

64비트에서 객체 헤더와 형식 포인터가 16바이트이고 최소 크기가 24바이트라서, 빈 클래스도 int 하나짜리도 24바이트입니다. 참조 필드는 하나에 8바이트씩 늘어납니다. CS9124 경고가 난 클래스는 같은 문자열 참조를 두 번 들고 있어서 24→32바이트가 됐습니다. 객체 수십만 개를 메모리에 올리는 앱이라면 이 경고는 무시할 대상이 아닙니다.

정리: 언제 무엇을 쓰나

상황 추천
내부 상태 private 필드
바깥에 공개하는 값 속성 ({ get; set; }, { get; init; })
꼭 넣어야 하는 값, 이름으로 채우는 편이 읽기 쉬움 required + init
꼭 넣어야 하는 값, 개수가 적고 순서가 분명 생성자 / 주 생성자
넣어도 되고 안 넣어도 되는 값 기본값 있는 속성 + 개체 이니셜라이저
주 생성자 매개변수를 바깥에 공개 속성을 직접 선언하고, 메서드에서는 속성을 사용

클래스 멤버에 붙이는 public/private과 this는 접근 제한자와 this, 객체가 만들어지고 사라지는 순서는 생성자와 소멸자에서 이어집니다. 메서드 자체(오버로딩, ref, params)는 C# 메서드를 참고하세요.

테스트 환경: .NET SDK 10.0.403 / 런타임 10.0.14, C# 14(net10.0 기본값), Apple Silicon Mac의 macOS.

자주 묻는 질문

C# 12 주 생성자 매개변수는 속성(property)이 되나요?

아닙니다. class Counter(string label)의 label은 클래스 안에서만 보이는 매개변수이고, 바깥에서 c.label로 접근하면 error CS1061이 납니다. record와 달리 공개 속성이 자동으로 생기지 않으므로, 밖에 보여야 하면 public string Label { get; } = label; 처럼 직접 선언합니다.

required와 생성자 중 무엇을 써야 하나요?

둘 다 '반드시 값을 넣게' 강제합니다. 호출하는 쪽이 이름으로 값을 채우는 편이 읽기 쉬우면 required + init 속성(C# 11), 값이 적고 순서가 분명하면 생성자나 주 생성자가 맞습니다. required를 빠뜨리면 error CS9035로 컴파일이 막힙니다.

2021년 예제를 그대로 빌드하면 경고가 나오는 이유는?

.NET 6 이후 새 프로젝트는 Nullable이 기본으로 켜져 있어서, 초기화하지 않은 public string name; 필드마다 warning CS8618이 나옵니다. 기본값을 주거나(= ""), string?로 바꾸거나, required를 붙이면 사라집니다.

  • #C#
  • #클래스
  • #객체
  • #주 생성자
  • #required

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑