객체 지향 프로그래밍의 핵심 원칙 중 하나는 캡슐화 (Encapsulation)입니다. 이는 객체의 데이터를 외부에서 함부로 접근하거나 변경하지 못하도록 보호하는 것을 의미합니다. C#에서는 이러한 캡슐화를 우아하게 구현할 수 있는 강력한 기능인 프로퍼티 (Property)를 제공합니다.
이 글에서는 C# 프로퍼티가 무엇인지, 왜 사용해야 하는지, 그리고 다양한 형태의 프로퍼티 선언 및 사용 방법을 소스에 기반하여 자세히 살펴보겠습니다.
프로퍼티는 클래스의 필드 (Field)에 접근하기 위한 특별한 멤버입니다. 마치 공개 필드처럼 보이지만, 실제로는 Get 접근자와 Set 접근자를 통해 필드의 값을 읽거나 쓰는 로직을 포함할 수 있습니다.
value
라는 암묵적인 매개변수를 사용하여 할당받은 값을 내부 필드에 저장하는 로직을 수행합니다.과거에는 필드에 접근하기 위해 Get...()
및 Set...()
과 같은 메서드를 사용하는 방식이 일반적이었습니다 [3, 5 (상단)]. C# 프로퍼티는 이러한 Get/Set 메서드 쌍을 하나의 멤버로 묶어, 코드 가독성을 높이고 필드 접근과 유사한 자연스러운 문법을 제공합니다 [1, 5 (중단)].
예를 들어, private int myField;
라는 필드가 있을 때, 이를 위한 Get/Set 메서드는 다음과 같습니다 [3, 5 (상단)].
public int GetMyField() { return myField; }
public void SetMyField(int newValue) { myField = newValue; }
이를 프로퍼티로 바꾸면 다음과 같이 간결해집니다 [5 (중단)].
public int MyField
{
get { return myField; }
set { myField = value; }
}
프로퍼티는 필드처럼 obj.MyField = 3;
또는 Console.WriteLine(obj.MyField);
와 같이 사용할 수 있습니다 [5 (하단)].
만약 Get 접근자에서 단순히 필드 값을 반환하고 Set 접근자에서 단순히 value
를 필드에 할당하는 등 특별한 로직이 필요 없다면, 자동 구현 프로퍼티를 사용할 수 있습니다. 이 경우 Get/Set 접근자 본문을 생략하고 get; set;
과 같이 선언합니다.
public string Name { get; set; }
public string PhoneNumber { get; set; }
컴파일러가 자동으로 비공개(private) 백킹 필드를 생성해줍니다. 이는 코드를 매우 간결하게 만들어 줍니다.
객체를 생성함과 동시에 프로퍼티에 값을 할당하는 것을 객체 이니셜라이저 (Object Initializer)를 통해 수행할 수 있습니다.
ClassName instance = new ClassName()
{
Property1 = value1, // 세미콜론(;)이 아닌 쉼표(,)입니다
Property2 = value2,
// ...
};
예시 코드를 보면 BirthdayInfo birth = new BirthdayInfo() { Name = "서현", Birthday = new DateTime(1991, 6, 28) };
와 같이 객체 이니셜라이저를 사용하여 객체 생성 시 프로퍼티 값을 설정하고 있습니다.
C# 9.0부터는 init
접근자를 사용할 수 있습니다. init
접근자는 set
접근자와 유사하게 값을 할당하지만, 오직 객체 초기화 시에만 값을 할당할 수 있습니다. 즉, 객체 생성 후에는 해당 프로퍼티의 값을 변경할 수 없습니다.
public string From { get; init; }
public string To { get; init; }
public int Amount { get; init; }
이렇게 선언된 프로퍼티는 생성자나 객체 이니셜라이저에서만 값을 설정할 수 있으며, 이후에 값을 변경하려 하면 컴파일 오류가 발생합니다. 이는 객체의 불변성 (Immutability)을 높이는 데 유용합니다.
C# 11부터는 required
키워드를 프로퍼티 앞에 붙여 해당 프로퍼티가 객체 생성 시 반드시 초기화되어야 함을 강제할 수 있습니다. required
프로퍼티는 생성자나 객체 이니셜라이저를 통해 초기화되지 않으면 컴파일 오류가 발생합니다.
public required string Name { get; set; }
public required DateTime Birthday { get; init; }
required
키워드는 특히 init
전용 프로퍼티와 함께 사용하여 객체가 생성될 때 필수적인 데이터가 누락되지 않도록 보장하는 데 효과적입니다.
레코드 형식 (Record Types)은 주로 데이터를 저장하는 객체를 위해 설계되었습니다. 레코드는 불변성을 기본으로 하며, 프로퍼티 선언을 간결하게 할 수 있는 문법을 제공합니다.
record RTransaction
{
public string From { get; init; }
public string To { get; init; }
public int Amount { get; init; }
}
위 예시처럼 레코드 내에 프로퍼티를 선언하면 자동으로 init
전용 접근자가 생성됩니다.
레코드 형식은 객체를 복사할 때 with
식을 사용할 수 있습니다. with
식은 원본 레코드 객체의 모든 상태를 복사한 후, 지정된 프로퍼티의 값만 변경하여 새로운 레코드 객체를 생성합니다. 이는 원본 객체의 불변성을 유지하면서 데이터를 쉽게 변경할 수 있게 해줍니다.
RTransaction tr1 = new RTransaction { From = "Alice", To = "Bob", Amount = 100 };
RTransaction tr2 = tr1 with { To = "Charlie" }; // tr1을 복사하고 To 프로퍼티만 변경하여 새 객체 tr2 생성
또한, 레코드 형식은 값 기반 동등성 비교를 기본으로 제공합니다. 두 레코드 객체의 모든 프로퍼티 값이 같으면 Equals
메서드나 ==
연산자로 비교했을 때 true
를 반환합니다. 이는 클래스가 기본적으로 참조 기반 동등성 비교를 수행하는 것과 대조됩니다.
무명 형식 (Anonymous Types)은 var
키워드와 함께 사용하여 컴파일러가 형식을 유추하게 하는 방식으로 객체를 생성할 때 사용됩니다.
var myInstance = new { Name = "박상현", Age = 17 };
무명 형식으로 생성된 객체는 선언 시 지정된 프로퍼티를 가지며, 이 프로퍼티들은 읽기 전용입니다. 무명 형식은 임시로 데이터를 묶어서 사용할 때 유용합니다.
인터페이스는 멤버의 선언만 가질 수 있으므로, 인터페이스에서 프로퍼티를 선언할 때는 Get 또는 Set 접근자만 명시하거나 둘 다 명시합니다.
interface IProduct
{
string ProductName { get; set; } // get, set 접근자 모두 필요
}
interface INamedValue
{
string Name { get; set; }
string Value { get; set; }
}
인터페이스를 구현하는 클래스는 인터페이스에 선언된 모든 프로퍼티를 반드시 구현해야 합니다. 인터페이스 프로퍼티는 자동 구현 프로퍼티처럼 선언할 수 없습니다.
추상 클래스에서도 프로퍼티를 선언할 수 있습니다. 추상 클래스는 구현이 있는 일반 프로퍼티와 추상 프로퍼티 모두를 가질 수 있습니다.
abstract
키워드를 사용하여 선언하며, Get/Set 접근자에 구현 코드가 없습니다. 추상 프로퍼티를 포함하는 추상 클래스를 상속받는 파생 클래스는 반드시 해당 추상 프로퍼티를 재정의 (Override)하여 구현해야 합니다.abstract class Product
{
private static int serial = 0;
public string SerialID { get { return String.Format("{0:d5}", serial++); } } // 구현이 있는 일반 프로퍼티
public abstract DateTime ProductDate { get; set; } // 구현이 없는 추상 프로퍼티
}
class MyProduct : Product
{
public override DateTime ProductDate { get; set; } // 파생 클래스에서 반드시 구현 (재정의) 해야 함
}