CodeGym /행동 /C# SELF /폴리모피즘과 추상 메서드의 관계

폴리모피즘과 추상 메서드의 관계

C# SELF
레벨 21 , 레슨 4
사용 가능

1. 소개

우리 동물원 예제로 다시 돌아가 보자. Animal (동물)이라는 기본 클래스가 있고, 그걸 상속받는 Dog (개), Cat (고양이), Fish (물고기)가 있어.

이전 강의에서 Animal에 virtual 메서드 MakeSound()를 추가했었지:

public class Animal
{
    public string Name { get; set; }
    public int Age { get; set; }

    public virtual void MakeSound() // 가상 메서드
    {
        Console.WriteLine("어떤 동물이 소리를 낸다."); // 기본 구현
    }

    public void Sleep() // 일반 메서드
    {
        Console.WriteLine($"{Name} 잠잔다.");
    }
}

public class Dog : Animal
{
    public override void MakeSound() // 개의 소리 재정의
    {
        Console.WriteLine("멍멍!");
    }
}

public class Cat : Animal
{
    public override void MakeSound() // 고양이 소리 재정의
    {
        Console.WriteLine("야옹!");
    }
}

이거 진짜 잘 돼! DogCat을 만들고 MakeSound()를 호출하면 각자의 소리가 들리지. 근데 만약 그냥 Animal을 만든다면?

Animal genericAnimal = new Animal();
genericAnimal.Name = "알 수 없는 존재";
genericAnimal.MakeSound(); // 출력: "어떤 동물이 소리를 낸다."

이치에 맞아 보여. 하지만 때로는 기본 구현이 전혀 의미가 없을 때가 있어. Animal이 구체적인 동물이 아니라 개념이라면 어떨까? "동물" 자체는 구체적인 소리를 내지 않고, 실제 소리는 각 동물 종류가 내는 거지. 또는 Shape (도형) 클래스가 있고 CalculateArea() (면적계산) 메서드가 있다고 해보자. 그냥 도형의 면적은 뭘까? 없어! 원이나 사각형엔 면적이 있지만, 추상적인 "도형"엔 없어.

이렇게 기본 클래스가 의미 있는 기본 구현을 제공할 수 없거나, 제공하면 안 되지만 모든 자식 클래스가 반드시 이 메서드를 구현해야 할 때, 추상 메서드추상 클래스가 등장해.

2. 추상 클래스: 설계도는 집이 아니다

네가 건축가라고 상상해봐. 표준 주택의 설계도를 만든다고 하자. 근데 그냥 집이 아니라 "컨셉 하우스"야. 공통점은 있지: 벽, 지붕, 기초. 하지만 아직 1층짜리인지, 고층 빌딩인지 몰라. 어떤 부분은 구체적이고(예: 1층 천장 높이), 어떤 부분은 아직 미정(예: "층수"는 나중에 결정).

C# 세계에서 이런 "컨셉 하우스"가 바로 추상 클래스야.
추상 클래스abstract 키워드로 표시된 클래스야.


public abstract class Animal // 이제 Animal은 추상 클래스
{
    // ...
}
추상 클래스 선언

추상 클래스의 핵심 특징:

  • 직접 인스턴스화할 수 없어. new Animal() 이렇게 쓸 수 없어. 왜냐면 Animal은 이제 뭔가 불확실한 개념이거든. "컨셉 하우스"는 실제로 지을 수 없고, 구체적인 집(예: 단독주택, 빌딩)만 지을 수 있지.
    만약 new Animal()을 시도하면, 컴파일러가 바로 에러를 내:
    Cannot create an instance of the abstract type or interface 'Animal'
    (추상 타입이나 인터페이스 'Animal'의 인스턴스를 만들 수 없음)
    이거 진짜 중요한 제한이야!
  • 추상 멤버를 가질 수 있어. 이게 진짜 꿀잼 포인트!

3. 추상 메서드: 구현 없는 계약

추상 클래스가 "컨셉 하우스"라면, 추상 메서드는 설계도에 "이거 해야 함"이라고만 적혀 있고, "어떻게"는 안 적힌 부분이야. 예를 들어 "기초를 쌓는다"는 필수지만, 구체적인 크기나 재료는 집 종류에 따라 다르지.

추상 메서드란:

  • abstract 키워드로 표시돼.
  • 몸체가 없어({} 코드 블록 없음). 세미콜론 ;으로 끝나.
  • 추상 클래스 안에서만 선언할 수 있어.

우리 MakeSound()를 추상 메서드로 만들어보자:


public abstract class Animal // Animal을 추상 클래스로 만들었어
{
    public string Name { get; set; }
    public int Age { get; set; }

    public abstract void MakeSound(); // 이게 추상 메서드! 몸체 없음!

    public void Sleep() // 이건 여전히 일반 메서드
    {
        Console.WriteLine($"{Name} 잠잔다.");
    }
}

MakeSound()가 어떻게 바뀌었는지 봐! 이제 중괄호도, 기본 구현도 없어. 그냥 "모든 동물은 반드시 소리를 낼 수 있어야 한다. 어떻게 낼지는 상속받는 쪽에서 정해라."라고만 말하지.

중요 규칙: 추상 클래스를 상속받은 클래스가 추상 클래스가 아니라면, 반드시 (즉, override로) 모든 추상 메서드를 구현해야 해. 이건 선택이 아니라 의무, 계약이야! C# 컴파일러는 이 부분에서 엄청 깐깐해. 까먹으면 바로 알려줘:

public class Dog : Animal // 일반, 추상 아님
{
    // 컴파일 에러!
    // 'Dog' does not implement inherited abstract member 'Animal.MakeSound()'
    // (클래스 'Dog'가 상속받은 추상 멤버 'Animal.MakeSound()'를 구현하지 않음)
    // 컴파일러는 override로 MakeSound()를 기대해!
}

에러를 없애려면 DogCat이 반드시 MakeSound()를 override해야 해:

public class Dog : Animal
{
    public override void MakeSound() // 반드시 override!
    {
        Console.WriteLine("멍멍!");
    }
}

public class Cat : Animal
{
    public override void MakeSound() // 여기도!
    {
        Console.WriteLine("야옹!");
    }
}

virtual, abstract, override 비교

특징 virtual 메서드 abstract 메서드 override 키워드
위치 일반 또는 추상 클래스 추상 클래스에서만 파생(자식) 클래스
메서드 몸체 몸체 있음(기본 구현) 몸체 없음(;로 끝남) 몸체 있음(새 구현)
목적 기본 구현 제공, 자식 클래스에서 변경 가능 계약 선언: 자식 클래스가 반드시 구현해야 함 기본 클래스의 virtual 또는 abstract 메서드에 대한 구체적 구현 제공
기본 클래스 인스턴스 생성? 가능(기본 클래스가 추상 아니면) 불가(기본 클래스가 추상이면) N/A(메서드에 해당, 클래스 아님)
자식 클래스의 의무 override(override)는 선택 override(override)는 필수(자식이 추상 아니면) N/A

4. 추상 메서드와 함께하는 폴리모피즘

이제 진짜 재밌는 부분! 폴리모피즘 덕분에 우리는 다양한 자식 클래스 객체를 기본 클래스 타입의 참조로 다룰 수 있지. 이건 기본 클래스가 추상이어도 똑같이 잘 돼!

Animal을 직접 인스턴스화할 수는 없지만(new Animal()은 에러), Animal 타입을 참조로 쓸 수 있어. 이게 진짜 강력해!

동물원이 아니라 농장이라고 해보자. 여러 동물이 살고 있고, 각 동물이 자기 소리를 내게 하고 싶어.


using System;

// 추상 클래스 Animal
public abstract class Animal
{
    public string Name;
    public int Age;
    public Animal(string name, int age) { Name = name; Age = age; }
    public abstract void MakeSound();
    public void Sleep() { Console.WriteLine($"{Name} 잠잔다."); }
}

public class Dog : Animal
{
    public Dog(string name, int age) : base(name, age) { }
    public override void MakeSound() { Console.WriteLine("멍멍!"); }
}

public class Cat : Animal
{
    public Cat(string name, int age) : base(name, age) { }
    public override void MakeSound() { Console.WriteLine("야옹!"); }
}

public class Fish : Animal
{
    public Fish(string name, int age) : base(name, age) { }
    public override void MakeSound() { Console.WriteLine("뻐끔뻐끔"); }
}

class Program
{
    static void Main()
    {
        Animal[] animals = {
            new Dog("샤릭", 3),
            new Cat("무르직", 5),
            new Fish("네모", 1)
        };

        foreach (Animal animal in animals)
        {
            Console.WriteLine($"\n안녕, 나는 {animal.Name}, 나이는 {animal.Age}살이야.");
            animal.MakeSound();
            animal.Sleep();
        }
    }
}

이 코드에서 무슨 일이 일어나?

  1. Animalabstract class로 선언했어. 이건 컴파일러에게 "이 클래스는 템플릿이고, 직접 만들 수 없지만 상속은 가능하다"는 뜻이야.
  2. public abstract void MakeSound();Animal 안에 선언했지. 이건 "누구든 Animal을 상속받으면(그리고 추상 클래스가 아니면) 반드시 MakeSound()를 구현해야 한다"는 계약이야!
  3. Dog, Cat, Fish는 이 계약을 잘 지키고, 각자 MakeSound()를 자기 방식대로 override해. 하나라도 빼먹으면 컴파일러가 통과 안 시켜줘.
  4. Main 메서드에서 Animal[] 배열을 만들어. Animal이 추상이어도, 배열은 참조로 자식 클래스 객체(Dog, Cat, Fish)를 담을 수 있어. 왜냐면 걔네는 모두 Animal이니까!
  5. foreach로 배열을 돌면서 animal.MakeSound()를 호출하면, 폴리모피즘 덕분에 C#이 "지금 이 참조가 가리키는 실제 객체 타입"에 맞는 MakeSound()를 호출해. Dog.MakeSound(), Cat.MakeSound(), Fish.MakeSound() 중에서 실제 객체에 맞는 게 실행돼. 이게 바로 폴리모피즘의 매력!
  6. animal.Sleep()기본 클래스 Animal의 구현을 그대로 호출해. 왜냐면 이 메서드는 virtual이나 abstract로 표시되지 않았고, 자식 클래스에서 override하지 않았으니까.

5. 이게 실제로 왜 필요해?

"동물원, 동물... 근데 내가 은행이나 쇼핑몰 앱 만들 때 이게 왜 필요해?" — 라고 궁금할 수 있지. 추상 클래스와 메서드는 유연하고 확장 가능한 시스템 설계에 엄청 강력한 도구야.

계약 구현 강제: 이게 제일 큰 장점이야. 예를 들어 결제 시스템 프레임워크를 만든다고 해보자. 기본 abstract class PaymentProcessor (결제 처리기)가 있어. 그리고 모든 결제 처리기는 ProcessPayment() (결제 처리), RefundPayment() (환불), CheckStatus() (상태 확인)를 반드시 할 수 있어야 해. 근데 PayPal, 신용카드, Bitcoin마다 구현은 완전 다르지.
이런 메서드들을 PaymentProcessor에서 abstract로 선언하는 거야.


public abstract class PaymentProcessor
{
    public abstract bool ProcessPayment(decimal amount, string currency, string cardNumber);
    public abstract bool RefundPayment(string transactionId);
    public abstract string CheckStatus(string transactionId);
    // ... 다른 메서드는 구체적으로, 예: 로그 남기기
    public void LogTransaction(string message)
    {
        Console.WriteLine($"[LOG]: {message}");
    }
}

public class PayPalProcessor : PaymentProcessor
{
    public override bool ProcessPayment(decimal amount, string currency, string cardNumber)
    {
        // 여기서 PayPal API와 복잡한 로직
        Console.WriteLine($"PayPal: {amount} {currency} 처리 중...");
        return true;
    }
    public override bool RefundPayment(string transactionId) { /* ... */ return true; }
    public override string CheckStatus(string transactionId) { /* ... */ return "완료됨"; }
}

public class CreditCardProcessor : PaymentProcessor
{
    public override bool ProcessPayment(decimal amount, string currency, string cardNumber)
    {
        // 여기서 은행 결제 로직
        Console.WriteLine($"CreditCard: {amount} {currency} 카드 {cardNumber.Substring(0,4)}XXXX...");
        return true;
    }
    public override bool RefundPayment(string transactionId) { /* ... */ return true; }
    public override string CheckStatus(string transactionId) { /* ... */ return "처리 중"; }
}

이제 누가 새로운 결제 처리기(BitcoinProcessor 등)를 만들고 싶으면, 반드시 이 세 메서드를 구현해야 해. RefundPayment()를 깜빡할 수가 없어! 컴파일러가 막아주니까. 이게 시스템의 일관성을 보장해.

유연성과 확장성: PaymentProcessor로 코드를 짜면, 실제 어떤 구현이 들어올지 몰라도 돼. 예를 들어 쇼핑몰 장바구니 코드에서 그냥 currentProcessor.ProcessPayment()만 호출하면, 시스템이 알아서 결제 방식을 골라줘. 내일 새로운 결제 방식이 추가돼도, 새로운 클래스를 만들고 PaymentProcessor를 상속해서 추상 메서드만 구현하면 돼. 기존 코드는 손댈 필요 없어!

빈 구현 피하기: virtual 메서드를 썼다면, 기본 구현을 비워둬야 했을 거야. 이건 misleading(헷갈릴 수 있음). abstract는 "여기엔 구현이 없고, 있을 수도 없다 — 상속받는 쪽에서 꼭 채워라!"라고 명확히 말해줘.

코드 아키텍처 개선: 추상 클래스는 공통 로직과 개별 로직을 명확히 분리해줘. 공통(예: LogTransaction in PaymentProcessor)은 기본 클래스에, 개별(ProcessPayment 등)은 자식에. 코드가 더 읽기 쉽고, 유지보수와 테스트도 쉬워져. 프레임워크나 라이브러리 설계자들이 "확장 포인트"를 명확히 지정할 수 있게 해줘.

6. 흔한 실수와 팁

실수 1: 추상 클래스 인스턴스 생성 시도.
이건 항상 컴파일 에러야. 추상 클래스는 개념이지, 실제 객체가 아니야. 참조 타입으로는 쓸 수 있지만 new Animal()은 안 돼.

실수 2: 추상 메서드 구현 깜빡함.
자식 클래스가 추상이 아니면, 부모의 모든 abstract 메서드를 반드시 구현해야 해. 아니면 컴파일 에러. 유일한 예외는 자식도 추상 클래스로 만드는 건데, 보통 그럴 일은 별로 없어.

실수 3: abstractvirtual 헷갈림.
abstract는 반드시 구현해야 하고, 몸체가 없어. virtual은 기본 구현이 있고, 원하면 override할 수 있어. abstract에 몸체를 붙이거나, virtual에 몸체가 없으면 문법 에러야.

실수 4: new 대신 override 안 씀.
override를 안 쓰고, 그냥 같은 이름의 메서드를 만들면, override가 아니라 숨김이 돼. 이러면 폴리모픽 호출에서 기본 클래스 메서드가 호출돼서, 예상과 다르게 동작할 수 있어.


public class Base
{
    public void DoSomething() { Console.WriteLine("Base"); }
}

public class Derived : Base
{
    public new void DoSomething() { Console.WriteLine("Derived"); }
}

Base obj = new Derived();
obj.DoSomething(); // 출력: Base
1
설문조사/퀴즈
폴리모피즘의 개념, 레벨 21, 레슨 4
사용 불가능
폴리모피즘의 개념
폴리모피즘이랑 메서드 오버로딩
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION