C# 중급 문법 (3) : 상속과 다형성

안녕하세요! C# 객체 지향 프로그래밍의 핵심 원칙들을 깊이 있게 탐구하고 있습니다. 지난번에는 클래스, 추상화, 인스턴스화, 생성자, 캡슐화에 대해 알아보았죠. 이번 포스팅에서는 객체 지향의 꽃이라고 할 수 있는 **상속(Inheritance)**과 상속과 뗄레야 뗄 수 없는 관계이자 객체 지향의 진정한 유연성을 제공하는 **다형성(Polymorphism)**에 대해 자세히 살펴보겠습니다. 이 두 가지 개념은 함께 작동하여 매우 강력한 시너지를 발휘합니다.

1. 상속(Inheritance) - 코드 재사용의 마법! 

상속은 말 그대로 '물려받는' 개념입니다. 객체 지향 프로그래밍에서 상속이란 어떤 클래스(부모 클래스 또는 기본 클래스)가 가진 속성과 메서드를 다른 클래스(자식 클래스 또는 파생 클래스)가 물려받아 사용할 수 있도록 하는 기능입니다.

이렇게 하면 자식 클래스는 부모 클래스의 기능을 처음부터 다시 구현할 필요 없이, 물려받은 기능을 그대로 사용하거나, 필요에 따라 자신에게 맞게 확장하거나 변경할 수 있습니다.

예시: '동물'과 '강아지', '고양이'

  • Animal (동물) 클래스: 모든 동물이 공통적으로 가질 수 있는 속성(예: 이름, 나이)과 행동(예: 잠자기, 먹기)을 정의합니다.
  • Dog (강아지) 클래스: Animal 클래스의 속성과 행동을 물려받으면서, 강아지에게만 해당하는 고유한 속성(예: 품종)이나 행동(예: 짖기)을 추가로 정의합니다.
  • Cat (고양이) 클래스: Animal 클래스의 속성과 행동을 물려받으면서, 고양이에게만 해당하는 고유한 속성(예: 털 색깔)이나 행동(예: 야옹하기)을 추가로 정의합니다.

이렇게 Dog와 Cat은 Animal이라는 공통된 조상으로부터 물려받았기 때문에, Animal 클래스의 코드를 중복해서 작성할 필요가 없어집니다.

상속의 장점:

  • 코드 재사용성 증가: 부모 클래스의 속성과 메서드를 자식 클래스에서 재사용하므로 코드 중복을 줄이고 개발 시간을 단축할 수 있습니다.
  • 유지보수 용이: 공통된 기능을 부모 클래스에 한 번만 정의하면 되므로, 수정이 필요할 때 부모 클래스만 변경하면 모든 자식 클래스에 변경 사항이 반영됩니다.
  • 확장성: 기존 클래스를 수정하지 않고도 새로운 기능을 추가하여 확장할 수 있습니다.
  • 계층 구조 구축: 클래스들 사이에 논리적인 관계(IS-A 관계: 'A는 B이다')를 형성하여 프로그램의 구조를 명확하게 만듭니다. (예: '강아지는 동물이다', '고양이는 동물이다')

C#에서 상속 구현하기:

C#에서는 : (콜론) 기호를 사용하여 상속 관계를 나타냅니다.

// 부모 클래스
public class Animal
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Animal(string name, int age)
    {
        Name = name;
        Age = age;
        Console.WriteLine($"[Animal] {Name} ({Age}세) 생성됨.");
    }

    // 자식 클래스에서 재정의할 수 있도록 virtual 키워드 사용
    public virtual void MakeSound()
    {
        Console.WriteLine($"{Name}이(가) 소리를 냅니다.");
    }

    public void Sleep()
    {
        Console.WriteLine($"{Name}이(가) 잠을 잡니다.");
    }
}

// 자식 클래스: Animal 클래스를 상속받는다.
public class Dog : Animal
{
    public string Breed { get; set; } // 강아지만의 추가 속성

    // 자식 클래스 생성자: base 키워드를 사용하여 부모 클래스 생성자 호출
    public Dog(string name, int age, string breed) : base(name, age)
    {
        Breed = breed;
        Console.WriteLine($"[Dog] 품종: {Breed}인 {Name}이(가) 생성됨.");
    }

    // 강아지만의 추가 행동 (메서드)
    public void Bark()
    {
        Console.WriteLine($"{Name}이(가) 멍멍 짖습니다!");
    }

    // 부모 클래스의 메서드를 재정의 (오버라이딩)
    // override 키워드를 사용하려면 부모 메서드가 virtual로 선언되어야 함
    public override void MakeSound() // 여기서 MakeSound는 Dog에 맞게 재정의됩니다.
    {
        Console.WriteLine($"{Name}이(가) 멍멍 짖습니다.");
    }
}

public class Cat : Animal
{
    public string FurColor { get; set; } // 고양이만의 추가 속성

    public Cat(string name, int age, string furColor) : base(name, age)
    {
        FurColor = furColor;
        Console.WriteLine($"[Cat] 털 색깔: {FurColor}인 {Name}이(가) 생성됨.");
    }

    public void Meow()
    {
        Console.WriteLine($"{Name}이(가) 야옹야옹 웁니다.");
    }

    // Cat도 MakeSound를 재정의할 수 있습니다.
    public override void MakeSound() // 여기서 MakeSound는 Cat에 맞게 재정의됩니다.
    {
        Console.WriteLine($"{Name}이(가) 야옹야옹 웁니다.");
    }
}

2. 다형성(Polymorphism): '하나의 형태'로 '여러 가지'를 다루다! 

다형성은 객체 지향 프로그래밍의 가장 강력한 특징 중 하나로, *'여러 가지 형태를 가질 수 있다'*는 의미입니다. 프로그래밍에서는 하나의 인터페이스나 부모 클래스 타입을 통해 여러 다른 형태의 객체(자식 클래스의 인스턴스)를 다룰 수 있는 능력을 말합니다.

다형성은 주로 두 가지 방식으로 나타납니다:

  1. 메서드 오버로딩 (Method Overloading):
    • 하나의 클래스 내에서 같은 이름의 메서드를 여러 개 정의하되, 매개변수의 개수나 타입이 다르게 하는 것입니다. (이는 상속과 직접적인 관련은 없지만 다형성의 한 형태입니다.)
    • 예: Add(int a, int b)와 Add(double a, double b)
  2. 메서드 오버라이딩 (Method Overriding):
    • 부모 클래스에서 정의된 virtual 메서드를 자식 클래스에서 override 키워드를 사용하여 자신에게 맞게 재구현하는 것입니다. 그리고 부모 클래스 타입의 참조 변수로 자식 클래스 객체를 가리킬 때, 실제 객체의 타입에 따라 재정의된 메서드가 호출되는 것이 다형성의 핵심입니다.

다형성의 핵심 원리: 부모 타입으로 자식 객체 참조

C#에서 다형성을 구현하는 가장 일반적인 방법은 부모 클래스 타입의 참조 변수에 자식 클래스의 인스턴스를 할당하는 것입니다.

Animal myDog = new Dog("바둑이", 4); // Animal 타입 변수에 Dog 객체 할당
Animal myCat = new Cat("나비", 2); // Animal 타입 변수에 Cat 객체 할당

 

위 코드에서 myDog와 myCat은 모두 Animal 타입으로 선언되었지만, 실제로는 Dog와 Cat 객체를 참조하고 있습니다. 이때 MakeSound() 메서드를 호출하면 어떤 일이 벌어질까요?

myDog.MakeSound(); // 출력: 바둑이이(가) 멍멍 짖습니다.
myCat.MakeSound(); // 출력: 나비이(가) 야옹야옹 웁니다.

분명 Animal 타입의 변수인데, 실제 호출되는 메서드는 Dog와 Cat에서 재정의된 MakeSound() 메서드입니다. 이것이 바로 다형성입니다! 컴파일 시점에는 Animal 타입으로 보이지만, 런타임 시점에는 실제 객체의 타입(Dog, Cat)에 따라 적절한 메서드가 동적으로 호출되는 것이죠.


다형성 활용 예시:

public class Program
{
    public static void Main(string[] args)
    {
        // Animal 타입의 배열에 다양한 자식 클래스 객체를 저장
        Animal[] animals = new Animal[3];
        animals[0] = new Dog("바둑이", 4);
        animals[1] = new Cat("나비", 2);
        animals[2] = new Dog("메리", 7); // 또 다른 Dog 객체

        Console.WriteLine("--- 모든 동물들이 소리를 냅니다! ---");
        foreach (Animal animal in animals)
        {
            // 각 Animal 객체의 실제 타입에 맞는 MakeSound()가 호출됨 (다형성)
            animal.MakeSound();
        }
        /*
        출력:
        바둑이이(가) 멍멍 짖습니다.
        나비이(가) 야옹야옹 웁니다.
        메리이(가) 멍멍 짖습니다.
        */

        Console.WriteLine("\n--- 특정 행동은 타입 캐스팅 필요 ---");
        // Animal 타입 변수로는 자식 클래스 고유의 메서드(Fetch, Climb)를 직접 호출할 수 없음
        // animals[0].Fetch(); // 컴파일 에러!

        // 자식 클래스 고유의 메서드를 호출하려면 명시적 타입 캐스팅이 필요
        if (animals[0] is Dog) // animals[0]이 Dog 타입인지 확인
        {
            Dog dog = (Dog)animals[0]; // Dog 타입으로 캐스팅
            dog.Fetch(); // 바둑이이(가) 공을 물어옵니다.
        }

        if (animals[1] is Cat cat) // C# 7.0 이상에서 패턴 매칭 (is Cat cat)
        {
            cat.Climb(); // 나비이(가) 나무를 탑니다.
        }
    }
}

이 예시에서 animals 배열은 모두 Animal 타입으로 선언되었지만, 실제로는 Dog와 Cat 객체를 담고 있습니다. foreach 루프에서 animal.MakeSound()를 호출할 때, 각 객체의 실제 타입에 따라 적절한 MakeSound() 메서드가 호출되는 것이 바로 다형성의 핵심입니다. 이는 새로운 종류의 동물이 추가되더라도 foreach 루프의 코드를 수정할 필요가 없게 만들어, 코드의 확장성을 크게 높여줍니다.


3. 상속과 다형성의 관계 🤝

  • 상속은 다형성의 전제 조건입니다. 다형성이 가능하려면 클래스들 사이에 상속 관계가 존재해야 합니다. 자식 클래스가 부모 클래스의 기능을 물려받고 재정의할 수 있어야만, 부모 타입으로 자식 객체를 다루면서도 각 객체의 특성에 맞는 행동을 할 수 있기 때문입니다.
  • 다형성은 상속의 활용 가치를 극대화합니다. 상속만으로는 코드 재사용의 이점만 얻을 뿐, 여러 다른 객체를 하나의 추상화된 방식으로 다루는 유연성은 얻기 어렵습니다. 다형성이 상속된 클래스들을 더욱 효율적이고 유연하게 관리할 수 있도록 돕습니다.

마무리하며: 객체 지향의 꽃, 상속과 다형성! 🌸

상속은 클래스 간의 계층 관계를 통해 코드 재사용을 가능하게 하고, 다형성은 이 상속 관계를 바탕으로 하나의 추상화된 인터페이스를 통해 다양한 형태의 객체를 유연하게 다룰 수 있게 합니다.

이 두 가지 개념을 잘 이해하고 활용하면, 여러분은 더욱 유연하고 확장 가능하며 유지보수가 쉬운 C# 프로그램을 설계하고 구현할 수 있을 것입니다. 이는 객체 지향 프로그래밍의 진정한 힘을 경험하는 중요한 단계입니다.

virtual, override 키워드의 중요성, 그리고 부모 타입으로 자식 객체를 참조하는 방식에 대해 더 궁금한 점이 있으신가요? 언제든지 댓글로 질문해주세요! 다음 포스팅에서는 객체 지향의 또 다른 중요한 개념인 추상 클래스와 인터페이스에 대해 알아보겠습니다.