C#

[C#] 프로퍼티, 레코드

sunlight-dby 2025. 4. 25. 20:45

 '이것이 C#이다 개정판'의 chapter 9를 정리한 글입니다.

 


 

프로퍼티 (Property)

class 클래스이름
{
    데이터형식 필드이름;
    접근한정자 대이터형식 프로퍼티이름
    {
        get
        {
            return 필드이름;
        }
        
        set
        {
            필드이름 = value;
        }
    }
}

 

프로퍼티 선언 문법에서 get { · · · }과 set { · · ·}을 일컬어 접근자(accessor)라고 합니다.

get 접근자는 필드로부터 값을 읽어오고 set 접근자는 필드에 값을 할당합니다.

set 접근자 안에 있는 value 키워드는 어디에서도 선언되지 않았지만, C# 컴파일러에서 암묵적으로 set 접근자의 매개변수로 간주합니다.

 

위의 코드를 Get / Set 메소드를 대체하여 사용한 코드는 아래와 같습니다.

class 클래스이름
{
    private 데이터형식 필드이름;
    
    public 데이터타입 GetMyField() { return 필드이름; }
    public void SetMyField( int NewValue ) { 필드이름 = NewValue; }
}

 

Get / Set 메소드를 사용했을 때는 클래스의 필드에 데이터를 저장할 때 클래스 객체를 생성하고 Set 메소드를 통해 값을 저장했어야만 합니다.

 

하지만 프로퍼티를 선언하면 직접 필드에 접근하여 데이터를 저장하고, 데이터를 읽어오는 것이 가능해집니다.

 

※ 읽기 전용으로 클래스를 사용하고 싶을 때는 Set 메소드를 사용하지 않았으면 됐습니다.

    프로퍼티를 사용할 때 역시 set 접근자를 구현하지 않으면, 해당 프로퍼티는 읽기 전용이 됩니다.

 

[예제]

using System;

namespace Property
{
    class BirthdayInfo
    {
        private string name;
        private DateTime birthday;

        public string Name
        {
            get
            {
                return name;
            }
            set
            {
                name = value;
            }
        }

        public DateTime Birthday
        {
            get
            {
                return birthday;
            }
            set
            {
                birthday = value;
            }
        }

        public int Age
        {
            get
            {
                return new DateTime(DateTime.Now.Subtract(birthday).Ticks).Year;
            }
        }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            BirthdayInfo birth = new BirthdayInfo();
            birth.Name = "서현";
            birth.Birthday = new DateTime(1991, 6, 28);

            Console.WriteLine($"Name : {birth.Name}");
            Console.WriteLine($"Birthday : {birth.Birthday.ToShortDateString()}");
            Console.WriteLine($"Age : {birth.Age}");
        }
    }
}

실행 결과

 


자동 구현 프로퍼티 (Auto-Implemented Property)

프로퍼티는 데이터의 오염에 대해선 메소드처럼 안전하고, 데이터를 다룰 때는 필드처럼 간결합니다.

하지만, 많은 경우에 중복된 코드를 작성하고 있다는 기분이 들게 하는 것도 사실입니다.

프로퍼티를 생성할 때, 필드를 단순히 읽고 쓰는 경우에 대해 더 그런 기분이 들게 됩니다.

 

이럴 때 자동 구현 프로퍼티를 사용해 대체할 수 있습니다. 그저 get 접근자와 set 접근자 뒤에 세미콜론만 붙여주면 됩니다.

public class 클래스이름
{
    접근한정자 데이터타입 프로퍼티이름
    {
        get; set;
    }
}

 

또한, C# 7.0부터는 자동 구현 프로퍼티를 선언함과 동시에 초기화를 수행할 수 있습니다.

덕분에 자동 구현 프로퍼티에 초깃값이 필요할 때 생성자에 초기화 코드를 작성하는 수고를 덜게 되었습니다.

 

다음은 그에 대한 예제입니다.

public class NameCard
{
    public string Name{ get; set; } = "Unknown";
    public string PhoneNumber{ get; set; } = "000-0000";
}

 

[예제]

using System;

namespace AutoImplementedProperty
{
    class BirthdayInfo
    {
        public string Name { get; set; } = "UnKnown";
        public DateTime Birthday { get; set; } = new DateTime(1, 1, 1);
        public int Age
        {
            get
            {
                return new DateTime(DateTime.Now.Subtract(Birthday).Ticks).Year;
            }
        }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            BirthdayInfo birth = new BirthdayInfo();
            Console.WriteLine($"Name : {birth.Name}");
            Console.WriteLine($"Birthday : {birth.Birthday.ToShortDateString()}");
            Console.WriteLine($"Age : {birth.Age}");

            birth.Name = "서현";
            birth.Birthday = new DateTime(1991, 6, 28);

            Console.WriteLine($"Name : {birth.Name}");
            Console.WriteLine($"Birthday : {birth.Birthday.ToShortDateString()}");
            Console.WriteLine($"Age : {birth.Age}");
        }
    }
}

실행 결과


자동 구현 프로퍼티 뒤에서 일어나는 일

비주얼 스튜디오와 함께 제공되는 .NET 디어셈블리 도구인 ildasm.exe로 AutoImplementedProperty 예제를 열어보면, 아래의 이미지처럼 볼 수 있습니다.

AutoImplementedProperty 예제를 열어본 ildasm.exe

 

코드로 선언한 인스턴스들을 확인할 수 있습니다.마름모 아이콘은 필드를 의미하는데, <Birthday>k_BackingField와 <Name>k_BackingField를 확인할 수 있습니다.

 

이 필드는 예제 코드에서 따로 선언을 하지 않았는데도 구현이 되어있습니다.이 필드는 C# 컴파일러가 자동으로 구현을 한 것입니다. 이 두 필드는 각각 Birtday 프로퍼티를 위해, Name 프로퍼티를 위해 컴파일러가 물밑에서 선언해준 것입니다.


ILDASM

ILDASM은 Intermediate Language Diassembler의 약자입니다.

마이크로 소프트에서 제공하는 도구로, .NET 어셈블리(EXE 또는 DLL 파일)에 포함된 중간 언어(IL, Intermediate Language)를 역어셈블(disassemble)하여 사람이 읽을 수 있는 형태로 보여줍니다.

 

C# 소스 코드는 컴파일되면 중간 언어(IL)로 변환됩니다.

IL은 .NET CLR에서 실행되는 저수준 코드이며, .NET의 모든 언어는 IL로 컴파일됩니다.

ILDASM은 IL 코드를 분석하고 이해하기 쉽게 출력하며, 내부 구조와 동작 방식을 파악하는 데 도움을 줍니다.

 


프로퍼티와 생성자

객체를 생성할 때 각 필드를 초기화하는 방법 중 프로퍼티를 이용한 초기화가 있습니다.

클래스이름 인스턴스 = new 클래스이름()
{
    프로퍼티1 = 값,
    프로퍼티2 = 값,
    프로퍼티3 = 값
};

 

이와 같이 객체를 생성할 때 <프로퍼티 = 값> 목록에 객체의 모든 프로퍼티가 올 필요는 없습니다.

초기화하고 싶은 프로퍼티만 넣어서 초기화해도 됩니다.

 

[예제]

using System;

namespace ConstructorWithProperty
{
    class BirthdayInfo
    {
        public string Name { get; set; }
        public DateTime BirthDay { get; set; }
        public int Age
        {
            get
            {
                return new DateTime(DateTime.Now.Subtract(BirthDay).Ticks).Year;
            }
        }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            BirthdayInfo birth = new BirthdayInfo()
            {
                Name = "서현",
                BirthDay = new DateTime(1991, 6, 28)
            };

            Console.WriteLine($"Name : {birth.Name}");
            Console.WriteLine($"Birthday : {birth.BirthDay.ToShortDateString()}");
            Console.WriteLine($"Age : {birth.Age}");
        }
    }
}

 

실행 결과


초기화 전용(Init-Only) 자동 구현 프로퍼티

읽기 전용 프로퍼티를 선언할 때 init 접근자를 통해 간편하게 선언이 가능합니다.

 

init 접근자는 set 접근자처럼 외부에서 프로퍼티를 변경할 수 있지만, 객체 초기화를 할 때만 프로퍼티 변경이 가능하다는 점이 다릅니다. init 접근자는 자동 구현 프로퍼티를 선언하면서 set 접근자 대신 init 접근자를 명시하면 됩니다.

 

public class 클래스이름
{
    접근한정자 데이터타입 프로퍼티이름
    {
        get; init;
    }
}

 

초기화를 한 차례 한 후 변경되면 안되는 데이터들을 '초기화 전용 자동 구현 프로퍼티'를 활용하면 좋습니다.

 

[예제]

using System;

namespace InitOly
{
    class Transaction
    {
        public string From   { get; init; }
        public string To     { get; init; }
        public int    Amount { get; init; }

        public override string ToString()
        {
            return $"{From,-10} -> {To,-10} : ${Amount}";
        }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            Transaction tr1 = new Transaction { From = "Alice", To = "Bob", Amount = 100 };
            Transaction tr2 = new Transaction { From = "Bob", To = "Chalie", Amount = 50 };
            Transaction tr3 = new Transaction { From = "Charlie", To = "Alice", Amount = 50 };

            Console.WriteLine(tr1);
            Console.WriteLine(tr2);
            Console.WriteLine(tr3);
        }
    }
}

 

실행 결과

 

해당 코드에서 객체 초기화가 이루어진 후에 초기화 전용 자동 구현 프로퍼티를 수정하면 컴파일 에러가 발생합니다.

Transaction tr1 = new Transaction { Form = "Alice", To = "Bob", Amount = 100 };
Transaction tr2 = new Transaction { Form = "Bob", To = "Chalie", Amount = 50 };
Transaction tr3 = new Transaction { Form = "Charlie", To = "Alice", Amount = 50 };

tr1.Amount = 30;  // 컴파일 에러 발생

 

init 접근자는 초기화 이후에 발생하는 프로퍼티 수정을 허용하지 않기 때문에 발생됩니다.

발생되는 컴파일 에러 메시지


레코드 형식으로 만드는 불변 객체

불변 객체는 내부 상태(데이터)를 변경할 수 없는 객체입니다.

상태를 변경할 수 없다는 특성 때문에 불변 객체에서는 데이터 복사와 비교가 많이 이뤄집니다.

새로운 상태를 표현하려고 기존 상태를 복사한 뒤 이 중 일부를 수정해서 새로운 객체를 만들고, 상태를 확인하기 위해 객체 내용을 자주 비교합니다.

 

레코드(Record)는 불변 객체에서 빈번하게 이뤄지는 이 두가지 연산을 편리하게 수행할 수 있도록 C# 9.0에서 도입된 형식입니다.

즉, 데이터의 불변성(immutable)과 값 기반 비교(value-based equality)를 쉽게 지원하는 특수한 참조 타입입니다.

주로 데이터를 담는 객체를 간결하고 안전하게 표현하기 위해 사용됩니다.

 

레코드 형식은 값 형식처럼 다룰 수 있는 불변 참조 형식으로, 참조 형식의 비용 효율과 값 형식이 주는 편리함을 모두 제공합니다.

 

[주요 특징]

  • 불변성 지원
    • 기본적으로 프로퍼티가 init 접근자를 사용해 초기화 후 변경이 불가능하여 불변성을 지원합니다.
  • 값 기반 동등성 비교
    • 객체의 참조가 아닌, 내부 데이터 값이 같으면 동일하다고 판단합니다.
  • 간결한 선언 문법
    • 선언과 동시에 생성자, 프로퍼티, Equals(), GetHashCode()를 자동 생성합니다.

※ 참조 형식끼리 내용 비교는 프로그래머가 직접 비교 코드를 작성해야 합니다.
    보통은 이 때 object로부터 상속하는 Equals() 메소드를 오버라이딩합니다.

  • 복사 기능 지원
    • with 표현식을 통해 객체를 쉽게 복사하면서 일부 속성만 변경하는 것이 가능합니다.
  • 참조 타입
    • 클래스 기반이며, 상속과 인터페이스 구현이 가능합니다.

 

레코드는 record 키워드와 초기화 전용 자동 구현 프로퍼티를 함께 이용해서 선언합니다.

이 때 한가지 주의할 점이 있는데, 레코드에는 초기화 전용 자동 구현 프로퍼티뿐만 아니라 쓰기 가능한 프로퍼티와 필드도 자유롭게 선언해 넣을 수 있다는 것입니다.

 

record 레코드이름
{
    접근한정자 타입형식 필드이름 { get; init; }
}

 

이렇게 선언한 레코드로 인스턴스를 만들면 불변 객체가 만들어집니다.

 

[예제]

using System;

namespace Record
{
    record RTransaction
    {
        public string From   { get; init; }
        public string To     { get; init; }
        public int    Amount { get; init; }

        public override string ToString()
        {
            return $"{From,-10} -> {To,-10} : ${Amount}";
        }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            RTransaction tr1 = new RTransaction { From = "Alice", To = "Bob", Amount = 100 };
            RTransaction tr2 = new RTransaction { From = "Alice", To = "Charlie", Amount = 100 };

            Console.WriteLine(tr1);
            Console.WriteLine(tr2);
        }
    }
}

실행 결과


with을 이용한 레코드 복사

C# 컴파일러는 레코드 형식을 위한 복사 생성자를 자동으로 작성합니다.

단, 이 복사 생성자는 protected로 선언되기 때문에 명시적으로 호출할 수는 없고, with 식을 이용해야 합니다.

RTransaction tr1 = new RTransaction { From = "Alice", To = "Bob", Amount = 100};
RTransaction tr2 = with tr1 { To = "Charlie" };

 

위의 코드에서, tr1의 모든 상태를 복사한 다음 To 프로퍼티 값만 "Charlie"로 수정하여 tr2라는 새로운 객체를 생성합니다.

 

with 식이 없었다면, RTransaction 인스턴스를 새로 할당하면서 To를 제외한 tr1의 모든 프로퍼티를 입력해줘야 했을 겁니다.

with 식은 객체 상태(프로퍼티가)가 다양할수록 유용합니다.

 

[예제]

using System;

namespace WithExp
{
    record RTransaction
    {
        public string From   { get; init; }
        public string To     { get; init; }
        public int    Amount { get; init; }

        public override string ToString()
        {
            return $"{From,-10} -> {To,-10} : ${Amount}";
        }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            RTransaction tr1 = new RTransaction { From = "Alice", To = "Bob", Amount = 100 };
            RTransaction tr2 = tr1 with { To = "Charlie" };
            RTransaction tr3 = tr2 with { From = "Dave", Amount = 30 };

            Console.WriteLine(tr1);
            Console.WriteLine(tr2);
            Console.WriteLine(tr3);
        }
    }
}

실행 결과


레코드 객체 비교하기

컴파일러는 레코드의 상태를 비교하는 Equals() 메소드를 자동으로 구현합니다.

레코드는 참조 형식이지만 값 형식처럼 Equals() 메소드를 구현하지 않아도 상태를 비교하는 것이 가능합니다.

 

Equals() 메소드

A_객체.Equals(B_객체)

 

모든 클래스는 object 클래스를 상속받으며, object.Equals의 기본 구현은 참조 비교(reference equality)입니다.

즉, 두 객체가 동일한 메모리 주소를 가리키는지 여부를 판단합니다.

 

다만, struct나 enum과 같은 값 타입에서는 기본적으로 필드 값들을 비교하는 값 비교(value equality)를 수행하도록 Equals()가 재정의되어 있습니다.

 

레코드에서의 Equals() 역시 자동으로 값 기반 비교가 되도록 구현되어 있습니다.

레코드 내부의 모든 프로퍼티 값을 비교하여 두 객체가 같은지 판단합니다.

 

반환형은 bool 타입입니다.

 


무명 형식 (Anonymous Type)

C#에는 이름이 없는 형식, 즉 무명 형식이 존재합니다.

무면 형식은 형식의 선언과 동시에 인스턴스를 할당합니다. 이 때문에 인스턴스를 만들고 다시는 사용하지 않을 때 무명 형식이 요긴합니다.

 

무명 형식의 선언은 중괄호 사이에 임의의 프로퍼티 이름을 적고 값을 할당하면 그대로 새 형식의 프로퍼티가 됩니다.

var myInstance = new { Name = "홍길동", Age = "20" };

 

이와 같이 선언한 무명 형식의 인스턴스는 여느 객체처럼 프로퍼티에 접근하여 사용할  수 있습니다.

Console.WriteLine( myInstance.Name, myInstance.Age );

 

[주의할 점]

무명 형식의 프로퍼티에 할당된 값은 변경이 불가능합니다.

한 마디로 무명 형식의 인스턴스가 만들어지고 난 다음에는 읽기만 할 수 있다는 말입니다.

 

[예제]

using System;

namespace AnonymousType
{
    class MainApp
    {
        static void Main(string[] args)
        {
            var a = new { Name = "홍길동", Age = 20 };
            Console.WriteLine($"Name : {a.Name}, Age : {a.Age}");

            var b = new { Subject = "수학", Scroes = new int[] { 90, 80, 70, 60 } };

            Console.Write($"Subject : {b.Subject}, Scores : ");
            foreach (var score in b.Scroes)
                Console.Write($"{score} ");

            Console.WriteLine();
        }
    }
}

실행 결과


인터페이스의 프로퍼티

인터페이스는 메소드뿐만 아니라 프로퍼티와 인덱서도 가질 수 있습니다.

프로퍼티나 인덱서를 가진 인터페이스를 상속하는 클래스가 반드시 해당 프로퍼티와 인덱서를 구현해야 하는 것은 물론입니다.

그리고 인터페이스에 들어가는 프로퍼티는 구현을 갖지 않는 것도 당연합니다.

 

여기에 한 가지 주의할 점이 있는데, 인터페이스의 프로퍼티 선언은 클래스의 자동 구현 프로퍼티 선언과 그 모습이 동일하다는 것입니다.

interface 인터페이스_이름
{
    public 형식 프로퍼티_이름
    {
        get; set;
    }
    
    // ...
}

 

[예제]

using System;

namespace PropertiesInInterface
{
    interface INamedValue
    {
        // 자동 구현 프로퍼티처럼 구현이 없지만, 
        // C# 컴파일러는 인터페이스의 프로퍼티에 대해서는 자동으로 구현해주지 않습니다.
        // 인터페이스는 어떤 구현도 가지지 않기 때문입니다.
        string Name
        {
            get;
            set;
        }

        string Value
        {
            get;
            set;
        }
    }

    // INamedValue 인터페이스를 상속하는 NamedValue 클래스는 반드시 Name과 Value를 구현해야 합니다.
    // 이때는 자동 구현 프로퍼티를 이용하는 것도 가능합니다.
    class NamedValue : INamedValue
    {
        public string Name
        {
            get;
            set;
        }

        public string Value
        {
            get;
            set;
        }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            NamedValue name = new NamedValue() { Name = "이름", Value = "홍길동" };

            NamedValue height = new NamedValue() { Name = "키", Value = "182cm" };

            NamedValue width = new NamedValue() { Name = "몸무게", Value = "90kg" };

            Console.WriteLine($"{name.Name} : {name.Value}");
            Console.WriteLine($"{height.Name} : {height.Value}");
            Console.WriteLine($"{width.Name} : {width.Value}");
        }
    }
}

실행 결과


추상 클래스의 프로퍼티

추상 클래스는 클래스처럼 구현된 프로퍼티를 가질 수 있는 한편, 인터페이스처럼 구현되지 않은 프로퍼티도 가질 수 있습니다.

추상 클래스에서는 이것을 추상 프로퍼티 (Abstract Property)라고 합니다.

 

추상 메소드가 그랬던 것처럼, 추상 프로퍼티 역시 인터페이스의 프로퍼티와 다를 것이 없습니다.

파생 클래스가 해당 프로퍼티를 구현하도록 강제하는 것일 뿐입니다.

 

추상 프로퍼티는 인터페이스처럼 구현을 비워놓은 것만으로는 추상 프로퍼티를 만들 수 없습니다.

그렇게 되면, C# 컴파일러가 자동 구현 프로퍼티로 간주하고 구현을 자동으로 채워 넣을 것입니다.

 

추상 프로퍼티는 abstract 한정자를 이용해서 선언합니다.

abstract class 추상클래스_이름
{
    abstract 데이터형식 프로퍼티_이름
    {
        get;
        set;
    }
}

 

[예제]

using System;

namespace PropertiesInAbstractClass
{ 
    abstract class Product
    {
        private static int serial = 0;
        public string SerialID
        {
            get { return String.Format("{0:d5}", serial++); }
        }

        abstract public DateTime ProductDate
        {
            get;
            set;
        }
    }

    class MyProduct : Product
    { 
        public override DateTime ProductDate
        {
            get;
            set;
        }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            Product product_1 = new MyProduct() { ProductDate = new DateTime(2018, 1, 10) };

            Console.WriteLine("Product : {0}, Product Date : {1}", product_1.SerialID, product_1.ProductDate);

            Product product_2 = new MyProduct() { ProductDate = new DateTime(2018, 2, 3) };

            Console.WriteLine("Product : {0}, Product Date : {1}", product_2.SerialID, product_2.ProductDate);
        }
    }
}

실행 결과


연습 문제

다음 코드에서 NameCard 클래스의 GetAge(), SetAge(), GetName(), SetName() 메소드들을 프로퍼티로 변경해 작성하세요.

using System;

namespace EX9_1
{
    class NameCard
    {
        private int age;
        private string name;

        public int GetAge() { return age; }
        public void SetAge(int vlaue) { age = vlaue; }
        public string GetName() { return name; }
        public void SetName(string value) { name = value; }
    }

    class MainApp
    {
        public static void Main()
        {
            NameCard MyCard = new NameCard();

            MyCard.SetAge(24);
            MyCard.SetName("길동");

            Console.WriteLine("나이 : {0}", MyCard.GetAge());
            Console.WriteLine("이름 : {0}", MyCard.GetName());
        }
    }
}

 

[답안]

using System;

namespace EX9_1
{
    class NameCard
    {
        public int Age { get; set; }
        public string Name { get; set; }

    }

    class MainApp
    {
        public static void Main()
        {
            NameCard MyCard = new NameCard()
            {
                Age = 20,
                Name = "길동"
            };

            Console.WriteLine("나이 : {0}", MyCard.Age);
            Console.WriteLine("이름 : {0}", MyCard.Name);
        }
    }
}

'C#' 카테고리의 다른 글

[C#] 컬렉션, 인덱서  (1) 2025.05.03
[C#] 배열  (0) 2025.05.02
[C#] 인터페이스  (0) 2025.04.24
[C#] 구조체, 변경 불가능 객체, 튜플  (0) 2025.04.22
[C#] 객체지향 프로그래밍 : 은닉성, 상속성, 다형성  (0) 2025.04.20