CodeGym /행동 /C# SELF /폴리모피즘과 추상화의 문제들

폴리모피즘과 추상화의 문제들

C# SELF
레벨 25 , 레슨 3
사용 가능

1. 폴리모피즘이 항상 마법은 아냐

처음 예제만 보면, C#에서의 폴리모피즘은 그냥 상속하고, 오버라이드하고, 기본 타입으로 호출하면 다 잘 되는 것처럼 보여. 근데 실제로는 미묘한 부분들이 있어. 같이 좀 더 자세히 알아보자.

메서드 숨김과 new 키워드

기본 클래스랑 파생 클래스가 있고, 둘 다 같은 이름의 메서드를 선언했는데 override 키워드 없이 만들었다고 해보자. 파생 클래스에서 같은 이름의 메서드를 새로 정의했지만 override 없이 하면, 그건 기본 메서드를 오버라이드하는 게 아니라 그냥 숨기는 거야. 컴파일러가 바로 경고를 띄우고 new를 명시적으로 쓰라고 해:

class Animal
{
    public void Speak()
    {
        Console.WriteLine("동물이 소리를 낸다.");
    }
}

class Cat : Animal
{
    public new void Speak()
    {
        Console.WriteLine("야옹!");
    }
}

// 사용 예시
Animal animal = new Cat();
animal.Speak(); // 출력: "동물이 소리를 낸다."

와우! Cat 타입 객체를 Animal 타입 변수에 넣어도, 호출되는 건 기본 클래스의 오리지널 메서드야. 왜냐면 메서드가 virtual로 선언되지 않았기 때문! 첫 번째 함정: 폴리모피즘을 쓰고 싶으면 virtual이랑 override 키워드를 꼭 써야 해. new는 진짜 일부러 숨기고 싶을 때만 쓰는 거고, 사실 거의 쓸 일 없어.

생성자 호출과 폴리모피즘

또 하나 헷갈릴 수 있는 게 있는데, 생성자는 virtual이 아니야. 기본 클래스에 생성자가 있고, 파생 클래스에 또 생성자가 있으면, 이건 폴리모픽하게 동작하지 않아. 예시 볼게:

class Animal
{
    public Animal()
    {
        Console.WriteLine("Animal 생성자");
    }
}

class Cat : Animal
{
    public Cat()
    {
        Console.WriteLine("Cat 생성자");
    }
}

// 사용 예시
Animal animal = new Cat();
// 출력:
// Animal 생성자
// Cat 생성자

근데 만약 기본 클래스 생성자에서 오버라이드될 수 있는 메서드를 호출하면, 파생 클래스가 초기화되기 전에 virtual 메서드가 호출될 수도 있어! 그래서 생성자에서 virtual/abstract 메서드는 호출하지 않는 게 좋아.

override에서 캡슐화 깨지는 문제

virtual 메서드는 좋긴 한데, 만약 기본 클래스에서 어떤 메서드가 특정 방식으로 동작해야 한다고 생각했는데, 파생 클래스에서 그걸 오버라이드해서 로직을 깨버리면, 예기치 않은 버그가 생길 수 있어.

class Animal
{
    public virtual void Eat()
    {
        Console.WriteLine("동물이 먹는다.");
    }

    public void Live()
    {
        Eat(); // 어떤 오버라이드된 버전이든 호출될 수 있음!
    }
}

class Cat : Animal
{
    public override void Eat()
    {
        Console.WriteLine("고양이가 생선을 먹는다.");
    }
}

Animal a = new Cat();
a.Live(); // 출력: "고양이가 생선을 먹는다."

기본 클래스 Animal에서 Eat()이 "동물이 먹는다"를 출력했는데, 파생 클래스에서 Eat()에 위험한 걸 추가하면 전체 클래스 동작이 깨질 수 있어. 이걸 리스코프 치환 원칙 위반(Liskov Substitution Principle, LSP)이라고 해. 설계할 때 항상 파생 클래스의 동작이 기본 클래스와 논리적으로 맞는지 생각해봐.

캐스팅과 타입 변환 문제

폴리모피즘 덕분에 여러 객체를 한 "묶음"에 넣을 수 있어: 예를 들어, Animal을 상속받는 Dog, Cat을 Animal 리스트에 넣을 수 있지. 근데 뭔가 특정한 걸 호출하고 싶으면:

List<Animal> pets = new List<Animal> { new Cat(), new Dog() };

foreach (var pet in pets)
{
    if (pet is Cat cat)
    {
        cat.Purr();
    }
}

타입 체크 없이 무작정 캐스팅하면 InvalidCastException 같은 에러가 나와. 이런 게 많아지면 코드가 복잡해지고, 설계가 잘못됐다는 신호일 수도 있어.

2. 추상화의 문제들

추상화는 객체를 쉽게 다루고 내부 상태를 감추는 데 좋은 도구야. 근데 여기에도 함정이 있어!

추상화 레벨 과다 (Over-Abstraction)

초보(그리고 고수도 가끔) 개발자들이 "OOP답게" 하려고 추상 클래스, 인터페이스, 계층을 너무 많이 쌓는 경우가 있어. 그러면 본인도 구조를 이해하기 힘들어져.

interface IAnimal
{
    void Speak();
}

abstract class Feline : IAnimal
{
    public abstract void Speak();
}

class Cat : Feline
{
    public override void Speak()
    {
        Console.WriteLine("야옹!");
    }
}

중간 추상 클래스가 아무것도 안 한다면 굳이 만들 필요가 있을까? 추상화를 위한 추상화는 유지보수만 어렵게 하고 구조만 꼬이게 해.

비논리적인 계층 구조

만약 어떤 동작을 계층 맨 위에 올렸는데, 실제로는 모든 객체에 맞지 않는다면 어떻게 될까:

abstract class Animal
{
    public abstract void Fly();
}
class Cat : Animal
{
    public override void Fly()
    {
        throw new NotImplementedException("고양이는 날지 못해!");
    }
}

파생 클래스마다 예외 던지는 더미 메서드를 넣거나, 인터페이스가 현실을 반영하지 못하는 걸 감수해야 해. 이건 전형적인 "잘못된 계층 구조" 안티패턴이야. 이런 경우엔 IFlyable 같은 별도 인터페이스로 빼는 게 좋아.

팁: 모든 상황에 맞는 추상화를 만들려고 하지 마.

추상 클래스와 API 변경 문제

추상 클래스가 프로덕션에 들어가고, 그걸 상속하는 클래스가 생기면, 이후에 변경이 엄청 위험해져. 새로운 추상 메서드를 추가하면 모든 파생 클래스가 그걸 구현해야 해서, 코드가 컴파일조차 안 돼. 그래서 라이브러리나 공개 API 유지보수가 힘들어져.

이럴 때를 위해 Default Interface Methods(기본 인터페이스 메서드, 116강 참고)가 도입됐어: 인터페이스를 확장해도 기존 코드를 바로 고칠 필요가 없어.

추상화에서 캡슐화 깨지는 문제

클래스를 추상 클래스로 만들면, 파생 클래스가 접근할 수 있게 protected로 멤버를 선언하는 경우가 많아. 근데 이러면 원래 감춰야 할 내부 로직이 노출돼서, 파생 클래스가 내부 데이터나 동작을 건드려서 기본 클래스의 일관성이 깨질 수 있어.

3. 실전에서 자주 나오는 실수들

이런 게 숙제에서만 나오는 게 아니라, 실제로도 많이 일어나. 심지어 경력자도 종종 이런 실수 해.

virtual로 안 만든 메서드 예시

예를 들어, 우리가 로그를 남기는 학습용 앱을 확장한다고 해보자(24일차 참고). 기본 로거가 있어:

class BaseLogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

class FileLogger : BaseLogger
{
    public void Log(string message)
    {
        // 파일에 기록
        Console.WriteLine("파일에: " + message);
    }
}

// 사용 예시:
BaseLogger logger = new FileLogger();
logger.Log("Hello!"); // 기대: "파일에: Hello!", 실제: "Hello!"

FileLogger 구현자는 오버라이드했다고 생각했지만, override를 안 썼고, 기본 메서드도 virtual이 아니었어. 그래서 호출되는 건 기본 버전.

추천: 오버라이드할 메서드는 항상 기본 클래스에 virtual, 파생 클래스에 override를 붙여.

잘못된 추상화 예시: "유연한" 동물들

동물 얘기 계속 해보자! IFlyable 인터페이스를 만들어서 모든 동물이 Fly 메서드를 구현하게 강요하지 않게 해보자:

interface IFlyable
{
    void Fly();
}

class Bird : IFlyable
{
    public void Fly() => Console.WriteLine("새가 난다!");
}

class Cat
{
    // 고양이는 IFlyable 구현 안 함
}

이제 "날 수 있는" 애들만 다루는 함수를 만들 수 있어:

void MakeItFly(object creature)
{
    if (creature is IFlyable flyingThing)
    {
        flyingThing.Fly();
    }
    else
    {
        Console.WriteLine("이 동물은 날지 못해.");
    }
}

이런 식이면 쓸데없는 추상 메서드로 구조를 망치지 않아도 돼.

확장성 없는 "딱딱한" 추상 클래스 문제

예를 들어, 이런 추상 클래스를 가진 라이브러리를 배포했다고 해보자:

public abstract class Creature
{
    public abstract void DoAction();
}

사용자들이 이걸 상속해서 자기 클래스를 만들었어. 1년 뒤에 API를 확장하려고 이렇게 추가했어:

public abstract class Creature
{
    public abstract void DoAction();
    public abstract void Sleep(); // 새 메서드!
}

이제 모든 사용자 클래스가 새 추상 메서드를 구현해야 해서 컴파일이 안 돼. 그래서 추상화 설계할 때는 진짜 신중해야 하고, 가능하면 기본 메서드가 있는 인터페이스를 쓰는 게 좋아.

4. 흔한 실수 피하는 팁

앱이 체조선수처럼 유연하길 바라지만, 다리 부러뜨리진 말자!

  • 상속 남발하지 마: 컴포지션(객체를 다른 객체에 넣는 것)으로 해결할 수 있으면 그게 더 좋아.
  • 정말 오버라이드가 필요할 때만 메서드를 virtual로 만들어.
  • 쓸데없는 추상 클래스 만들지 말고, "미래 대비" 계층 구조도 만들지 마.
  • 메서드 오버라이드할 때 기본 클래스의 규칙(불변성)이 깨지지 않는지 꼭 확인해.
  • 라이브러리 공개 후엔 기본 클래스나 인터페이스에 새 추상 메서드 추가하지 마.
  • 프로그램 각 부분을 느슨하게 연결하려면 인터페이스를 써.
  • API 확장할 땐 Default Interface Methods를 활용해.
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION