C#

C# 객체지향 문법 [C#의 클래스 확장 - 멤버 유형 확장]

devrabbit22 2025. 7. 26. 00:17
  • 클래스에서 기본으로 제공되는 멤버 유형은 필드와 메서드다.
  • 프로퍼티는 메서드의변형이고, 델리게이트는 중첩 클래스의 변형이다.
  • C#의 기본적인 필드, 메서드 외에 다양한 구성요소가 존재한다.

읽기 전용 필드

  • 프로퍼티를 이용하면 필드의 값을 읽기만 가능하도록 외부에 노출할 수 있다.
  • 클래스 내부에서도 읽기만 가능하도록 만들고 싶다면? 또는 한 번만 값을 사용한 후 다시 값을 설정하지 못하게 만들고 싶을 수도 있다. 이런 경우에 readonly 예약어를 사용해 읽기 전용 필드(read - only field)를 정의하면된다.
public class Scheduler
{
    readonly int second = 1;	//읽기 전용 필드 정의 및 값을 대입
    readonly string name;	//읽기 전용 필드 정의
    
    public Scheduler()
    {
        this.name = "일정관리";	//읽기 전용 필드는 생성자에서도 대입 가능
    }
    public void Run()
    {
        this.Second = 5;	//컴파일 오류 발생(일반 메서드에서 값을 대입할 수 없다.)
    }
}
  • 읽기 전용 필드는 변수를 정의할 때와 생성자 내부를 제외하고는 그 값을 바꾸는 시도를 할 수 없다.
  • 기본적으로 모든 필드는 값이 변할 수 있다. → 객체의 상태가 변할 수 있다.
  • 이런 객체를 가변 객체(mutable object)라고 한다. 
  • 반면 객체의 상태가 한번 지정되면 다시 바뀔 수 없는 경우 이를 구분해서 특별히 불변 객체(immutable object)라고 한다.

ex) 다음의 Point 클래스는 불변 타입에 속한다.

public class Point
{
    int x, y;
    
    public int X{ get { return x; } }
    public int Y{ get { return y; } }
    
    public Point(int x, int y)
    {
        this.x = x;
        this.y = y;
    }
}

이 클래스를 사용하는 코드를 보면 이해할 수 있다.

Point pt = new Point(5, 10);
Point pt2 = new Point(pt.x + 1, pt.y + 1);

기존 pt 값에서 X, Y 방향으로 1씩 증가한 상태를 얻고 싶은데, 내부 값을 변경할 수 없으므로 새롭게 별도의 Point 객체를 만들어야 한다. 

즉, 불변이므로 객체의 내부 값을 변경할 수 없다.

  • 불변 타입을 만들 때 readonly 예약어가 도움 될 수 있다. Point 클래스가 외부적으로는 불변이라고 약속될 수 있지만, 내부적으로 보면 불변 상태가 보장되는 것이 아니기 때문이다.
  • 코드를 유지보수하다보면 실수할 수도 있고, x, y에 값을 쓰는 작업을 포함할 수 있다. 이를 미연에 방지하기 위해 readonly 예약어를 x, y 필드에 각각 적용할 수 있다.

상수

  • 상수(Constant)를 간단하게 표현하면 리터럴에 식별자를 붙인 것이라고 할 수 있다.
  • 변하는 값을 고정된 식별자로 가리키는 것이 변수라면 상수는 변하지 않는 값인 리터럴을 식별자로 재사용할 수 있게 만들어준다.
  • 프로그래밍하다보면 중복되는 리터럴을 쓰는 경우가 빈번하게 발생한다.
int x = 5;
int y = 10;

Console.WriteLine("x 변수의 값: " + x);
Console.WriteLine("y 변수의 값: " + y);

"x 변수의 값", "y 변수의 값"이 사용되고 있는데, "변수의 값: "이라는 문자열이 중복된다.

만약 요구사항이 변경되어 문자열을 "변수 값 =="이라고 변경해야 하면 문자열이 사용된 모든 코드를 찾아서 수정해야 한다.

이럴 경우 상수를 사용해 표현하면 변경해야 할 문자열이 한 군데에 있으므로 소스코드를 유지보수하기가 더 쉬워진다.

class 클래스_명
{
    접근_제한자 const 상수타입 식별자 = 값;
}

상수는 readonly 변수와 유사하지만 차이점이 있다.

  1. 상수는 static 예약어가 허용되지 않는다.(의미상으로는 이미 static에 해당한다.)
  2. '기본 자료형'에서 다룬 형식에 대해서만 상수 정의가 허용된다.
  3. 반드시 상수 정의와 함께 값을 대입해야 한다. 즉, 생성자에서 접근할 수 없다.
  4. 상수는 컴파일할 때 해당 소스코드에 값이 직접 치환되는 방식으로 구현된다.

기본 자료형의 숫자 형식은 그것들이 표현할 수 있는 수의 상한값과 하한값에 대해 MaxValue, MinValue라는 공통된 상수를 제공한다.

숫자형 상수는 서로 연관된 것들끼리 모아서 enum 타입으로 정리할 수 있다.

//개별적인 상수로 표현
const int Sunday = 0;
const int Monday = 1;
const int Tuesday = 2;
const int Wednesday = 3;
const int Thursday = 4;
const int Friday = 5;
const int Saturday = 6;

//상수를 enum 타입으로 묶어서 표현
enum Days
{
    Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday
}

이벤트

델리게이트는 메서드를 가리킬 수 있는 타입의 간편 표기법이다.

이벤트(event)도 '간편 표기법'의 하나인데, 다음조건을 만족하는 정형화된 콜백 패턴을 구현하려고 할 때 event 예약어를 사용하면 코드를 줄일 수 있다.

  1.  클래스에서 이벤트(콜백)를 제공한다.
  2. 외부에서 자유롭게 해당 이벤트(콜백)를 구독하거나 해지하는 것이 가능하다.
  3. 외부에서 구독/해제는 가능하지만, 이벤트 발생은 오직 내부에서만 가능하다.
  4. 이벤트(콜백)의 첫 번째 인자로는 이벤트를 발생시킨 타입의 인스턴스이다.
  5. 이벤트(콜백)의 두 번째 인자로는 해당 이벤트에 속한 의미 있는 값이 제공된다.

클래스에서 이벤트 성격의 콜백 수단을 제공하는 것이 목적이므로 기존의 델리게이트를 사용해도 동일하게 구현할 수 있다.

단지 그것이 위에 패턴에 부합하다면 event 예약어로 코드를 적게 사용하는 것이 해당 클래스를 만드는 개발자뿐 아니라 그 클래스를 사용하는 개발자에게도 편리한 방법이 된다.

ex) 소수(prome number) 생성기를 구현한다.

1부터 n까지 값을 진행하는 소수라고 판정될 때마다 콜백을 발생시키는 클래스를 event 예약어의 도움을 받지 않고 델리게이트만으로 구현하면 다음과 같다.

//소수 생성기 : 소수가 발생할 때마다 등록된 콜백 메서드를 호출
class PrimeGenerator
{
    //콜백을 위한 델리게이트 타입 정의
    public delegate void PrimeDelegate(object sender, CallbackArg arg);
    //콜백 메서드를 보관하는 델리게이트 인스턴스 필드
    PrimeDelegate callbacks;

    //콜백 메서드를 추가
    public void AddDelegate(PrimeDelegate callback)
    {
        callbacks = Delegate.Combine(callbacks, callback) as PrimeDelegate;
    }
    //콜백 메서드를 삭제
    public void RemoveDelegate(PrimeDelegate callback)
    {
        callbacks = Delegate.Remove(callbacks, callback) as PrimeDelegate;
    }
    //주어진 수까지 루프를 돌면서 소수가 발견되면 콜백 메서드 호출
    public void Run(int limit)
    {
        for (int i = 2; i <= limit; i++)
        {
            if(IsPrime(i) == true && callbacks != null)
            {
                //콜백을 발생시킨 측의 인스턴스와 발견된 소수를 콜백 메서드에 전달
                callbacks(this, new PrimeCallbackArg(i));
            }
        }
    }
    //소수 판정 메서드
    private bool IsPrime(int candidate)
    {
        if((candidate & 1) == 0)
        {
            return candidate == 2;
        }
        for(int i = 3; (i * i) <= candidate; i += 2)
        {
            if ((candidate % i) == 0) return false;
        }
        return candidate != 1;
    }
}

출력값

PrimeGenerator 타입은 소수가 발견될 때마다 콜백을 발생시키며, 외부에서 이 콜백에 관심이 있다면 구독하고, 필요 없어지면 다시 해지할 수 있는 수단을 제공한다.

event를 사용해 예제를 간결하게 수정하면 PrimeCallbackArg 타입이 상속받는 CallbackArg 타입이 필요없다. 여기에 대응되는 System.EventArgs라는 타입이 이미 .NET에서 제공되고 있으므로 곧바로 EventArgs에서 상속받는 것으로 처리할 수 있다.

class PrimeCallbackArg : EventArgs //콜백 값을 담는 클래스 정의
{
    public int Prime;
    
    public PrimeCallbackArg(int prime)
    {
        this.Prime = prime;
    }
}

콜백 메서드에 전달되는 인자의 기존의 CallbackArg에서 EventArg로 변경해야 한다.

static void PrintPrime(object sender, EventArgs arg)
{
    Console.Write((arg as PrimeCallbackArg).Prime + ", ");
}

static void SumPrime(object sender, EventArgs arg)
{
    Sum += (arg as PrimeCallbackArg).Prime;
}

PrimeGenerator 타입에 구현되어 있는 PrimeDelegate, AddDelegate, RemoveDelegate 멤버를 제거하고 다음의 한 줄로 정의하면 된다.

public event EventHandler PrimeGenerated;

callbacks 인자의 이름이 이벤트의 PrimeGenerated로 바뀌었으므로 Run 메서드의 코드도 변경된다.

public void Run(int limit)
{
    for(int i = 2; i< limit; i++)
    {
        if(IsPrime(i) == true && PrimeGenerated != null)
        {
            PrimeGenerated(this, new PrimeCallbackArg(i));
        }
    }
}

primeGenerator에서 이벤트를 제공하기 위한 코드의 전부다. 이렇게 제공되는 이벤트를 사용하는 측은 이전보다 더욱 간결하게 이벤트를 구독/해지할 수 있다.

PrimeGenerator gen = new PrimeGenerator();

gen.PrimeGenerated += PrintPrime;	//PrintPrime 메서드로 이벤트 구독
gen.PrimeGenerated += SumPrime;	//SumPrime 메서드로 이벤트 구독

gen.Run(10);
Console.WriteLine();
Console.WriteLine(Sum);

gen.PrimeGenerated -= SumPrime;	//SumPrime 메서드의 이벤트 해지
gen.Run(15);

최종 소스코드를 비교하면 delegate와 event의 차이점과 동시에 유사점을 볼 수 있다.

class PrimeCallbackArg : EventArgs//콜백 값을 담는 클래스 정의
{
    public int Prime;

    public PrimeCallbackArg(int prime)
    {
        this.Prime = prime;
    }
}
//소수 생성기 : 소수가 발생할 때마다 등록된 콜백 메서드를 호출
class PrimeGenerator
{
    public event EventHandler PrimeGenerated;

    //주어진 수까지 루프를 돌면서 소수가 발견되면 콜백 메서드 호출
    public void Run(int limit)
    {
        for (int i = 2; i <= limit; i++)
        {
            if(IsPrime(i) == true && PrimeGenerated != null)
            {
                //콜백을 발생시킨 측의 인스턴스와 발견된 소수를 콜백 메서드에 전달
                PrimeGenerated(this, new PrimeCallbackArg(i));
            }
        }
    }
    //소수 판정 메서드
    private bool IsPrime(int candidate)
    {
        if((candidate & 1) == 0)
        {
            return candidate == 2;
        }
        for(int i = 3; (i * i) <= candidate; i += 2)
        {
            if ((candidate % i) == 0) return false;
        }
        return candidate != 1;
    }
}
class Program
{
    //콜백으로 등록될 메서드
    static void PrintPrime(object sender, EventArgs arg)
    {
        Console.Write((arg as PrimeCallbackArg).Prime + ", ");
    }
    static int Sum;

    //콜백으로 등록될 메서드2
    static void SumPrime(object sender, EventArgs arg)
    {
        Sum += (arg as PrimeCallbackArg).Prime;
    }

    static void Main(string[] args)
    {
        PrimeGenerator gen = new PrimeGenerator();

        //PrintPrime 콜백 메서드 추가
        gen.PrimeGenerated += PrintPrime;

        //SumPrime 콜백 메서드 추가
        gen.PrimeGenerated += SumPrime;

        //1~10까지 소수를 구하고
        gen.Run(10);
        Console.WriteLine();
        Console.WriteLine(Sum);

        //SumPrime 콜백 메서드를 제거한 후 다시 1~15까지 소수를 구하는 메서드 호출
        gen.PrimeGenerated -= SumPrime;
        gen.Run(15);

    }
}

결국 이벤트는 델리게이트의 사용 패턴을 좀 더 일반화해서 제공하는 것으로 간단하게 구문이 요약된다.

class 클래스_명
{
    접근_제한자 event EventHandler 식별자
}
// 클래스의 멤버로 이벤트를 정의한다. 이벤트는 외부에서 구독/해지가 가능하고 내부에서 이벤트를 발생시키면 
// 외부에서 다중으로 이벤트에 대한 콜백이 발생할 수 있다.

이벤트는 그래픽 사용자 인터페이스(GUI: Graphic User Interface)를 제공하는 응용 프로그램에서 매우 일반적으로 사용된다.

ex) 윈도우에 포함된 버튼이 있고, 버튼을 눌렀을 때 파일을 생성하는 작업을 한다고 가정하면, Button 클래스 제작자는 당연히 Click이라는 이벤트를 구현해 둘 것이고, 버튼을 이용하는 개발자는 Click 이벤트를 구독하는 메서드 내에서 파일 작업을 수행하는 코드를 작성하면 된다.

인덱서

배열의 요소에 접근할 때 다음과 같은 구문을 사용할 수 있다.

int[] intArray = new int[5];
intArray[0] = 6;	//0번째 요소 접근

intArray[0]과 같이 배열의 0번째 요소에 접근할 때 대괄호 연산자를 사용하는데, 배열이 아닌 일반 클래스에서 이런 구문을 사용하려면 어떻게 해야하는가?

대 괄호 연산자는 사용자가 직접 정의할 수 없다. 이를 보완하기 위해 C# 언어에서는 this 예약어를 이용한 인덱서(indexer)라고 하는 특별한 구문을 제공한다.

class 클래스_명
{
    접근_제한자 반환타입 this[인덱스타입 인덱스식별자]
    {
        접근_제한자 get
        {
            //_[코드]_
            return 반환타입과_일치하는_유형의_표현식;
        }
        접근_제한자 set
        {
            //인덱스식별자로 구분되는 값에 value를 대입
        }
    }
}
// 인덱서를 이용하면 클래스의 인스턴스 변수에 배열처럼 접근하는 방식의 대괄호 연산자를 
// 사용할 수 있다. 

// 프로퍼티를 정의하는 구문과 유사하며, 단지 프로퍼티명이 this로 대체된다는 점과 인덱스로 
// 별도의 타입을 지정할 수 있다는 점이다.

클래스 내부에 인덱서를 제공하면 배열을 접근할 때의 대괄호 연산자 사용을 클래스의 인스턴스에 대해서도 동일하게 사용할 수 있다.

ex) Int32 정수형 데이터의 특정 자릿수를 인덱서를 사용해 문자(char 데이터로 다루는 예제

출력값

위의 코드에 사용된 인덱서 구문에는 index 변수의 타입이 int로 되어 있는데, 프로그램에서 필요하면 다른 타입으로 지정하는 것이 가능하다. 또한 프로퍼티처럼 set 구문을 제거하면 읽기 전용으로 만드는 것도 가능하다.

인덱서는 클래스를 직관적으로 배열처럼 다룰 수 있을 때 사용하기 쉽도록 제공되는 구문이다.

많은 개발자는 클래스가 배열처럼 다뤄질 수 있다는 사실을 직관적으로 받아들이지는 않는다. 이 때문에 편리한 구문임에도 인덱서를 잘 사용하지 않는 경향이 있다고 한다.

 

인덱서를 언제 구현해야 하는가?

  • 구현하려는 클래스에 배열과 같은 식으로 접근할 필요가 있을 때 제공하는 것이 바람직하다.
  • 클래스를 사용하는 입장에서는 직관적으로 인덱스를 사용하는 것이 좋겠다고 판단되지 않는다면 구현 노력에 비해 사용 빈도가 다소 떨어진다.

정리

클래스는 C# 언어의 전유물이 아니다. C++ 자바, 파이썬 등의 객체지향 언어에는 모두 클래스라는 개념이 있으며, 예약어까지도 class로 동일하다. 

클래스의 탄생 배경

  • 현실 세계의 객체를 프로그래밍 세계에 표현한다는 객체지향 개념을 토대로 그것을 프로그래밍 언어 차원에서 지원하기 위해 만든 일반적인 개념이 클래스다.
  • 모든 클래스 문법은 구현해야 할 현실 세계의 문제를 프로그램으로 적절하게 표현해 낼 수 있는 도구이다.
  • 기법을 현란하게 남용하지도 말고, 쉬운 문법을 몰라서 필요 이상으로 어렵게 구현하는 것도 바람직하지 않다.

예약어 정리

예약어 using, namespace
class, interface, struct, enum
private, protected, public, internal
return
this, base
typeof
delegate, event
virtual, override
as, is
sealed, abstract
operator, implicit, explicit
static, const, readonly
ref, out
문맥 예약어 get, set, value
  • 특이하게 get/set/value를 문맥 예약어(contextual keywords)로 분류했다. 이것은 C# 언어의 또 다른 특징으로, 일반 예약어와 한 가지 차이점이 있다.
  • 기본적으로 일반 예약어는 C#으로 작성한 모든 영역의 코드에서 식별자로 사용할 수 없지만, 문맥 예약어는 특정한 상황을 제외하고는 식별자로 사용하는 것이 가능하다.
  • '특별한 상황'이라는 문맥 조건이 적용되기 때문에 그런 이름이 붙은 것이다. 
  • get/set/value 예약어는 오직 프로퍼티 구문에서만 예약어로 처리된다. 그 밖의 코드에서는 여전히 식별자로 사용할 수 있다.
int set = 5;
int get = 6;
int value = set + get;

이 코드가 Main 메서드 내에 있다면 정상적으로 컴파일되지만 프로퍼티 구문의 get/set 내에 사용되면 예약어가 변수의 식별자로 사용되었으므로 컴파일 오류가 발생한다.

이런 문맥 예약어를 만든 이유?

  • C# 5.0에 추가된 await 문맥 예약어를 C# 4.0으로 이미 만들어 놓은 기존 프로그램에서 변수로 사용한다고 가정하면, 문맥 예약어의 개념이 없었다면 C# 5.0에서 기존의 4.0 소스를 컴파일하면 오류가 발생한다.
  • 4.0 소스코드에서 awati라는 이름의 변수는 C# 5.0에서 예약어로 변경되었기 때문이다.
  • C#에서는 이런 문제를 방지하기 위해 문맥 예약어라는 특수한 키워드를 추가했고 C# 2.0부터 이런 식으로 확장해서 하위 버전의 소스코드를 문제 없이 컴파일하도록 돕고 있다.

Reference

시작하세요! C# 12 프로그래밍 기본 문법부터 실전 예제까지