C#

[C#] 객체지향 프로그래밍 : 객체, 클래스

sunlight-dby 2025. 4. 15. 00:40

 

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

 


 

객체지향 프로그래밍 (Object Oriented Programming : OOP)

객체지향 프로그래밍은 OOP라고도 하는데, 코드 내의 모든 것을 객체(Object)로 표현하고자 하는 프로그래밍 패러다임을 뜻합니다.

객체지향 프로그래밍의 주요 개념은 아래와 같습니다.


[객체]

객체는 상태(state)와 행동(behavior)을 가진 실체입니다. 상태는 객체의 속성을 의미하고, 행동은 객체가 수행할 수 있는 함수나 메소드를 의미합니다.

string a = "123";이라는 구문에서, a는 string의 실체(Instance)라고 합니다.

 

※ 객체, 실체, 변수

위의 설명에 이어지는 글이 있습니다.

'a 또한 실제로 데이터를 담을 수 있는 실제 객체입니다. 그래서 객체를 인스턴스라고 부르기도 합니다.'

 

이 말도 맞는 말이지만, 더욱 자세하게 객체와 실체, 변수의 개념을 알아야 합니다.

string a = "123"

 

  • "123"
    • string 클래스의 인스턴스로, 힙에 있는 실제 객체
  • a
    • 객체를 참조하는 변수 (참조 변수)
    • 변수 자체는 객체가 아니고, 변수는 객체를 가리키는 '주소지'입니다.

하지만 책에서는 a를 객체라고 합니다. 이는 a가 가리키는 대상(실체)이 string의 인스턴스이기 때문입니다.엄밀히 말하자면, a가 가리키는 대상이 객체입니다.

 

[클래스]

클래스는 객체를 생성하기 위한 템플릿 또는 청사진입니다. 클래스는 속성과 메소드를 정의하며, 이를 바탕으로 여러 개의 객체를 생성할 수 있습니다.

 

[상속]

상속은 기존 클래스의 속성과 메소드를 새로운 클래스가 물려받는 개념입니다. 이를 통해 코드의 재사용성을 높이고, 계층 구조를 형성할 수 있습니다.

 

[다형성]

다형성은 같은 이름의 메소드가 서로 다른 클래스에서 다르게 동작할 수 있는 기능입니다.

한 인터페이스 또는 상위 클래스에서 정의된 메소드를 하위 클래스에서 오버라이딩하여 구현합니다.

 

[캡슐화]

캡슐화는 객체의 속성과 메소드를 하나의 단위로 묶고, 외부에서 직접 전근하지 못하도록 보호하는 개념입니다.

이를 통해 데이터의 무결성을 유지하고, 객체의 내부 구현을 숨길 수 있습니다.


클래스

클래스는 class 키워드를 이용해서 선언합니다. 

class 클래스_이름
{
    // 데이터와 메소드
}

 

[필드]

클래스 안에 선언된 변수들을 일컬어 필드(Field)라고 합니다.

 

[멤버]

필드와 메소드를 비롯하여 프로퍼티, 이벤트 등 클래스 내에 선언된 요소들을 일컬어 멤버(Memeber)라고 합니다.

 

[클래스의 객체 생성]

클래스는 청사진입니다. 실체(인스턴스)가 아니라는 것을 알고 있어야 합니다.

클래스를 실체(객체)로 만들기 위해서는 new 키워드를 생성하면 됩니다.

class TestClass
{
    // ...
}

class MainApp
{
    static MainApp
    {
        TestClass Test = new Test();
    
        // ...
     }
 }

객체 : 생성자와 종료자

객체가 생성할 때는 생성자(Constuctor)가 호출되고 소멸할 때는 종료자(Finalizer)가 호출됩니다.

 

[생성자]

생성자는 클래스와 이름이 같고 반환 형식이 없습니다.

생성자는 해당 형식(클래스)의 객체를 생성하는 것만 수행하면 되기 때문입니다.

 

[생성자의 선언 형식]

class 클래스_이름
{
    한정자 클래스_이름( 매개변수_목록 )  // 생성자
    {
        // ...
    }
    
    // 필드
    // 메소드
}

 

클래스를 선언할 때 명시적으로 생성자를 구현하지 않아도 컴파일러에서 생성자를 만들어줍니다.

이런 생성자를 기본 생성자(Default Constructor)라고 합니다.

 

기본 생성자가 있더라도 생성자를 따로 구현해야할 경우들이 존재합니다.

객체를 다루다 보면 객체를 생성하는 시점에 객체의 필드를 원하는 값으로 초기화하고 싶을 때가 있습니다.

매개변수를 입력받아 원하는 값으로 필드를 초기화할 수 있는 최적의 장소가 생성자입니다.

 

또한, 생성자도 여느 메소드와  마찬가지로 오버로딩이 가능합니다. 따라서 다양한 버전의 생성자를 준비해놓을 수 있습니다.

 

[예시]

class Cat
{
    public Cat()
    {
        Name = ""
        Color = ""
    }
    
    public Cat( string _Name, string _Color )
    {
        Name = _Name;
        Color = _Color;
    }
    
    public string Name;
    public string Color;
    
    // ...
}

class MainApp
{
    string void Main(String[] args)
    {
        Cat kitty = new Cat();    // Cat() 생성자 호출
        kitty.Name = "키티";
        kitty.Color = "하얀색";
        
        Cat nabi = new Cat( "나비", "갈색" );  // Cat( string _Name, string _Color ) 생성자 호출
        
        // ...
    }
}

[종료자]

종료자는 클래스 이름 앞에 '~'를 붙인 형태입니다.

 

종료자는 생성자와 달리 매개변수도 없고, 한정자도 사용하지 않습니다.

또한 여러 버전의 종료자를 만드는 오버로딩도 불가능하며 직접 호출할 수도 없습니다.

 

종료자는 CLR의 가비지 컬렉터가 객체가 소멸되는 시점을 판단해서 호출하면서 사용됩니다.

 

[종료자의 형태]

class 클래스_이름
{
    ~클래스_이름()  // 종료자
    {
        // ...
    }
}

 

종료자를 정리해 놓긴 했지만, 가급적 사용하지 않는 것이 좋습니다.

 

[종료자를 사용하지 않는 게 좋은 이유]

  • 가비지 컬렉션 성능 저하
    • 종료자가 있는 객체는 가비지 컬렉터가 더 오래 객체를 추적하게 되며, 메모리 회수가 지연되어 성능 저하가 발생할 수 있습니다.
      • 종료자 호출 대기열에 등록되어 바로 메모리 해제가 되지 않습니다.
      • 종료자가 실행된 후에야 메모리 해제가 가능해집니다.
    • 종료자를 명시적으로 구현하면 가비지 컬렉터는 클래스의 족보를 타고 올라가 객체로부터 상속받은 Finalize() 메소드를 호출합니다. 이렇게 되면 응용 프로그램의 성능 저하를 초래할 확률이 높아집니다.
  • 종료자의 실행 시점 불확실
    • 종료자가 언제 실행될지 정확히 예측할 수가 없습니다.
      가비지 컬렉터가 언제 종료자를 호출할지 알 수 없기 때문에 중요한 리소스 해제를 종료자에 의존하면 문제가 발생할 수 있습니다.
  • 리소스 누수 가능성
    • 종료자에 의존하면 리소스 해제가 지연되거나 누수될 위험이 있습니다.
  • 복잡성 증가
    • 종료자를 구현하면 코드가 복잡해지고, 예외 처리나 여러 스레드 환경에서 문제를 일으킬 수 있습니다.

정적 필드와 정적  메소드

static 한정자는 메소드나 필드가 클래스의 인스턴스가 아닌 클래스 자체에 소속되도록 지정하는 한정자입니다.

static 한정자를 사용하여 생성한 클래스는 정적 필드로 형성되게 됩니다.

 

클래스가 정적 필드로 형성이 되면, 클래스에 속한 변수들이 공용 변수가 되고,

객체가 생성되지 않아도 메모리에 한 번만 할당되며 클래스 이름을 통해 직접 접근할 수 있습니다.

 

또한, 클래스에 속한 메소드가 정적 메소드가 되어 객체를 생성하지 않고 클래스 이름으로 바로 호출할 수 있습니다.

 

[예시]

using System;
using static System.Console;

class Global
{
    public static int Count = 0;
}

class ClassA
{
    public static void PlusA()
    {
        Global.Count++;
    }
}

class ClassB
{
    public static void PlusB()
    {
        Global.Count++;
    }
}

class MainApp
{
    static void Main()
    {
        WriteLine($"Global.Count : {Global.Count}");
        // 인스턴스를 생성하지 않고 클래스의 이름을 통해 필드에 직접 접근합니다.

        ClassA.PlusA();  // 인스턴스를 생성하지 않고 클래스의 메소드에 직접 접근합니다.
        ClassA.PlusA();  // ""
        ClassB.PlusB();  // ""
        ClassB.PlusB();  // ""

        WriteLine($"Global.Count : {Global.Count}");
    }
}

실행 결과

 

※ 비(非) 정적 메소드를 일컫는 용어는 클래스에 소속되는 정적 메소드와 달리 인스턴스에 소속된다고 해서

    '인스턴스 메소드'라고 합니다.

class MyClass
{
    public void InstanceMethod()
    {
         // ...
    }
}

// ...
MyClass obj = new MyClass();
obj.InstanceMethod();  // 인스턴스를 만들어야 호출 가능

 


얕은 복사와 깊은 복사

얕은 복사(Shallow Copy)

[정의]

객체의 최상위 필드(멤버 변수)만 복사하고, 그 필드가 참조형 데이터일 경우 참조 주소만 복사합니다.
즉, 복사된 객체와 원본 객체가 동일한 참조형 데이터를 공유합니다.

 

[특징]

  • 값형 필드는 값 자체가 복사됩니다.
  • 성능적으로 '깊은 복사'와 비교하여 빠릅니다.
  • 참조형 필드는 객체의 주소(참조)만 복사되어, 원본과 복사본이 같은 인스턴스를 가리킵니다.
  • 따라서 참조형 필드 내부의 데이터가 변경되면 원본과 복사본 모두 영향을 받습니다.

[예시]

class MyClass
{
    public int MyField1;
    public int MyField2;
}

// ...
MyClass source = new MyClass();
source.MyField1 = 10;
source.MyField2 = 20;

MyClass target = source;  // 얕은 복사
target.MyField2 = 30;

Console.WriteLine( "{0} {1}", source.MyField1, source.MyField2 );
Console.WriteLine( "{0} {1}", target.MyField1, target.MyField2 );

// [실행 결과]
// 10 30
// 10 30

※ 클래스는 태생이 참조 형식이기 때문에 얕은 복사가 이루어집니다.


깊은 복사(Deep Copy)

[정의]

객체가 참조하는 모든 객체까지 재귀적으로 새로운 인스턴스를 생성하여 완전히 독립적인 복사본을 만듭니다.

즉, 원본 객체와 복사본이 어떤 참조형 필드도 공유하지 않습니다.

 

[특징]

  • 완전한 복제본을 만들어 원본과 복사본이 독립적으로 존재합니다.
  • 성능적으로 '얕은 복사'와 비교하여 느립니다.
  • 복사 과정이 복잡하고 비용이 많이 들 수 있습니다.
  • 직접 구현하거나, 직렬화/역직렬화 기법 등을 활용할 수 있습니다.

※ 직렬화(Serialization)와 역직렬화(Deserialization)

  • 직렬화
    • 객체를 바이트 스트림이나 텍스트 형태로 변환하는 과정입니다.
    • 변환된 데이터는 파일, 메모리, 네트워크 등으로 저장 또는 전송할 수 있습니다.
    • C#에서는 BinaryFormatter, DataContractSerializer, JsonSerializer 등 다양한 직렬화 방법이 있습니다.
  • 역직렬화
    • 직렬화된 데이터(바이트 스트림, JSON 등)를 다시 원래의 객체 형태로 복원하는 과정입니다.
  • 직렬화와 역직렬화를 깊은 복사에 활용하는 이유
    • 직렬화 시 객체 내부의 모든 데이터가 바이트 형태로 변환되므로, 참조하는 모든 객체까지 포함됩니다.
    • 역직렬화를 통해 직렬화된 데이터를 다시 읽어오면, 완전히 새로운 객체가 생성되어 원본과 독립된 복사본이 됩니다.
    • 따라서 직렬화/역직렬화를 이용하면 복잡한 객체 그래프를 한 번에 깊은 복사할 수 있습니다.

[예시]

class MyClass
{
    public int MyField1;
    public int MyField2;
    
    public MyClass DeepCopy()  // 객체를 힙에 새로 할당해 자신의 멤버를 일일이 복사해 넣습니다.
    {
        MyClass newCopy = new MyClass();
        newCopy.MyField1 = this.MyField1;
        newCopy.MyField2 = this.MyField2;
        
        return newCopy;
    }
}

 


얕은 복사와 깊은 복사 활용 예시

using System;

namespace DeepCopy
{
    class MyClass
    {
        public int MyField1;
        public int MyField2;

        public MyClass DeepCopy()  // 객체를 힙에 새로 할당해 자신의 멤버를 일일이 복사해 넣습니다.
        {
            MyClass newCopy = new MyClass();
            newCopy.MyField1 = this.MyField1;
            newCopy.MyField2 = this.MyField2;

            return newCopy;
        }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Shallow Copy");

            {
                MyClass source = new MyClass();
                source.MyField1 = 10;
                source.MyField2 = 20;

                MyClass target = source;  // 얕은 복사
                target.MyField2 = 30;

                Console.WriteLine($"{source.MyField1}, {source.MyField2}");
                Console.WriteLine($"{target.MyField1}, {target.MyField2}");
            }

            Console.WriteLine("\nDeep Copy");

            {
                MyClass source = new MyClass();
                source.MyField1 = 10;
                source.MyField2 = 20;

                MyClass target = source.DeepCopy();
                target.MyField2 = 30;

                Console.WriteLine($"{source.MyField1}, {source.MyField2}");
                Console.WriteLine($"{target.MyField1}, {target.MyField2}");
            }
        }
    }
}

실행 결과

 


this 키워드

this 객체는 자신을 지칭할 때 사용하는 키워드입니다. 객체 내부에서 자신의 필드나 메소드에 접근할 때 this 키워드를 사용합니다.

class Employee
{
    private string Name;  // 해당 Name -> A
    
    public void SetName( string Name )  // 해당 Name -> B
    {
        this.Name = Name;  // 순서대로 Name -> C, D
    }
}

 

이러한 코드가 있을 때, SetName() 메소드의 매개변수의 이름이 Name인데 Employee 클래스 필드의 변수 이름도 Name입니다.

 

이런 모호한 경우에 this 키워드를 사용하여, 'C' Name이 'A' Name을 지칭하게 하면 이런 모호성을 풀 수 있습니다.
그렇다면 자연스레 'D' Name은 'B' Name으로 볼 수 있습니다.


this( ) 생성자

아래의 코드는 3개의 생성자를 오버로딩하는 클래스의 코드입니다.

이 클래스는 int 형식의 필드 a, b, c를 갖고 있으며 세 개의 생성자는 입력받는 매개변수에 따라 이들 필드를 초기화합니다.

class MyClass
{
    int a, b, c;
    
    public MyClass()
    {
        this.a = 5425;
    }
    
    public MyClass(int b)
    {
        this.a = 5425;
        this.b = b;
    }
    
    public MyClass(int b, int c)
    {
        this.a = 5425;
        this.b = b;
        this.c = c;
    }
}

 

해당 코드의 문제는 있지 않지만, 세 개의 MyClass() 생성자 안에 똑같은 코드가 중복되어 들어가 있습니다.

이러한 코드를 방지할  수 있는 것이 this()입니다.

 

this가 객체 자신을 지칭하는 키워드인 것처럼, this()는 자기 자신의 생성자를 가리킵니다.

this()는 생성자에서만 사용될 수 있습니다.

 

[예시]

class MyClass
{
    int a, b, c;
    
    public MyClass()
    {
        this.a = 5425;
    }
    
    public MyClass(int b) : this()  // this()는 MyClass()를 호출합니다.
    {
        this.b = b;
    }
    
    public MyClass(int b, int c) : this(b)  // this(b)는 MyClass(int b)를 호출합니다.
    {
        this.a = 5425;
        this.b = b;
        this.c = c;
    }
}

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

[C#] 구조체, 변경 불가능 객체, 튜플  (0) 2025.04.22
[C#] 객체지향 프로그래밍 : 은닉성, 상속성, 다형성  (0) 2025.04.20
[C#] 메소드  (0) 2025.04.11
[C#] Switch, 반복문, 점프문  (0) 2025.04.10
[C#] 연산자  (2) 2025.04.07