인터페이스보다 추상 클래스를 선호하는 이유

저는 인터페이스보다는 추상 클래스를 자주 사용하는데, 멤버를 감출 수 있다는 점이 가장 큰 이유입니다.

멤버를 감출 때 얻을 수 있는 장점들이 어떤 것인지는 이미 다들 아실텐데, 이 글은 "구현자의 편리성"을 높일 수 있다는 점을 살펴봅니다.

관련 예제를 하나 만들어 보았습니다. 예제는 아래의 링크에 있습니다.
BigSquareHasNoEdge/jslab.Utils (github.com)

예제는 모델 객체에 대한 검사를 진행하는 필터와 이 필터들을 파이프 라인으로 연결하는 파이프라인 빌더, 두 가지가 주축입니다. 용법은 아래와 같습니다.

빌더는 빌더 패턴으로 필터를 추가합니다.

PassfilterPipeline<Person> pl = new PassfilterPipeline<Person>()
       .Add<IsOver18>()
       .Add<NameContainsKim>()
       //.필요한 만큼 많이.
       .Build();

빌더의 Add<> 의 형식 매개 변수에 전달할, 필터의 구현 코드는 아래와 같이 통일성 있는 형태를 가집니다.

class IsOver18 : PassFilter<Person>
{
    protected override bool TestIfNotCancelled(Person p)
    {
        // 사용자 코드
        if (p.Age > 18) 
           return NextTest(p);
        else 
            return false;
    }
}

class NameContainsKim : PassFilter<Person>
{
    protected override bool TestIfNotCancelled(Person p)
    {
        // 사용자 코드.
        if (p.Name.Contains('김'))
           return NextTest(p);
        else 
            return false;
    }
}

별다른 보일러 코드 없이 “사용자 코드” 부분만 작성하면 되기에, 구현자의 손가락은 덜 바쁘겠죠?
PassFilter 는 아래와 같이 추상 클래스로 정의되었습니다.

public abstract class PassFilter<TContext>
{
    protected Predicate<TContext>? NextTest;
    protected CancellationTokenSource? TokenSource;

    internal void SetNext(Predicate<TContext> nextTest)
        => NextTest = nextTest;

    internal void SetTokenSource(CancellationTokenSource tokenSource)
        => TokenSource = tokenSource;

    public bool Test(TContext context)
    {
        if (TokenSource != null&& TokenSource.IsCancellationRequested)
            return false;

        return TestIfNotCancelled(context);
    }

    protected abstract bool TestIfNotCancelled(TContext context);
}

이 추상 클래스는 공개 메서드가 하나이기 때문에, 형태 상 일감은 인터페이스로 구현하고 싶어집니다. 이름도 IFilter 라고 붙이면 멋들어질 것 같습니다.

그러나, 인터페이스로 선언하지 않은 이유는, protected 필드와 internal 세터가 필요하기 때문입니다.
이들이 하는 역할은 구현자로 하여금, 아래와 같은 보일러 코드인 생성자 코드를 생략할 수 있도록 만드는 것입니다.

class IsOver18 : PassFilter<Person>
{
    public IsOver18(Predicate<Person> next,  CancellationTokenSource? cts)
          : base (next, cts) {}

   // ...
}

internal Setter 들은 파이프라인 빌더에 의해 호출하는데, 빌더의 전체 코드는 아래와 같고,

public class PassfilterPipeline<TContext>
{
    private readonly List<Type> _filters = new List<Type>();

    public CancellationTokenSource TokenSource { get; set; } = new CancellationTokenSource();

    Predicate<TContext> _predicate = DummyTest;
    public Predicate<TContext> Predicate => _predicate;

    public PassfilterPipeline<TContext> Add<TTester>() where TTester : PassFilter<TContext>
    {
        _filters.Add(typeof(TTester));
        return this;
    }

    public PassfilterPipeline<TContext> Remove<TTester>() where TTester : PassFilter<TContext>
    {
        var t = typeof(TTester);
        if(_filters.Contains(t))
            _filters.Remove(t);

        return Build();
    }

    public PassfilterPipeline<TContext> Build()
    {
        _predicate = Build(0);
        return this;
    }            

    private Predicate<TContext> Build(int filterIndex)
    {
        if (filterIndex < _filters.Count)
        {
            var nextTest = Build(filterIndex + 1);

            var filter = (PassFilter<TContext>)Activator.CreateInstance(_filters[filterIndex])!;

            filter.SetNext(nextTest);
            filter.SetTokenSource(TokenSource);

            return filter.Test;
        }
        else
        {
            return DummyTest;
        }
    }

    public bool Test(TContext context)
        => _predicate(context);

    private static bool DummyTest(TContext context)
        => true;

}

.
아래는 빌더의 코드 중, internal 세터를 이용하여, protected 필드를 초기화하는 부분만 발췌한 것입니다.
주석으로, 표시한 부분 때문에, 추상 클래스는 생성자를 정의하지 않아도 되고, 그로 인해, 모든 구현코드도 생성자 코드가 생략되는 것입니다.

    private Predicate<TContext> Build(int filterIndex)
    {
        if (filterIndex < _filters.Count)
        {
            var nextTest = Build(filterIndex + 1);

            var filter = (PassFilter<TContext>)Activator.CreateInstance(_filters[filterIndex])!;

            // 어셈블리 내부에서 세터를 호출하여, 필드 초기화
            filter.SetNext(nextTest);
            filter.SetTokenSource(TokenSource);

            return filter.Test;
        }
        else
        {
            return DummyTest;
        }
    }

인터페이스는 public 멤버 밖에 갖지 못하기 때문에, 이와 같이 비 생성자 주입 코드를 사용할 수 없고, 그로 인해, 구현 코드에 보일러 코드가 자동으로 많아지는 것입니다.

마지막으로 링크된 깃허브에 예제 파일도 있으니, 관심 있으신 분들은 한번 보시기 바랍니다.
데이터 분석할 때, 많이 사용했던 패턴이기도 합니다.

추가: 깃허브에는 리펙토링으로 인해 이 글에 나타난 식별자와 다르게 보일 수도, 상속의 단계가 늘어 나 있을 수도 있습니다. 그러나 전체적인 맥락은 같습니다.

7개의 좋아요

흡사 책을 보는듯한 아티클 감사합니다
추상 클래스가 메모리 관리에도 좋다고 듣긴 했어요

3개의 좋아요

의견

  1. Abstract 키워드는 추상화를 해당 클래스에 귀속 시킵니다. 추상화를 재사용하길 원한다면 Interface 키워드가 적절합니다. 해서 선호보다 선택적 요소인것 같습니다. 또한 다형성을 제공할 수 없습니다.
2개의 좋아요

추상 클래스는 추상의 콘텍스트를 좁힌다(=좀더 구체화한다)는 점을 말씀하시는 것 같군요. 저도 동의하는 바입니다.

그런데, 사용자 입장에서는 추상의 범위가 넓은 것보다는 그나마 활용되는 컨텍스트에 대한 정보가 있는 것이 더 이해하기 편하지 않을까요? 예를 들어, 단순한 “올리다” 보다는 "가격을 올리다"가 사용자 입장에서는 더 명확하죠.

물론, 광역적 추상화도 필요합니다. 그러나, 그런 추상화는 긴 시간 동안 충분한 고민과 통찰력을 바탕으로 설계되어야 한다고 생각합니다. 개인적으로 그러한 추상화는 이미 닷넷에서 충분히 제공하고 있지 않나하는 생각을 하고 있습니다.

재사용성 정도는 추상클래스냐 인터페이스냐 보다는 닷넷 제공 객체의 활용정도, 제너릭 적용여부에 따라 결정된다고 생각합니다.

예를 들어, 본문에 있는 PassFilter 구조로 작성된 추상클래스를 다양한 프로젝트에서 활용하고 있습니다. 이렇게 재사용이 가능한 이유는 닷넷 객체인 Predicate와 Func<T, bool> 를 노출하고 있기 때문입니다.

이와 반대로, 내가 작성한 객체를 입력받거나 출력하도록 정의한다면, 다른 프로젝트에서 재사용될 확률은 무척 떨어질 것입니다. 이를 인터페이스로 바꿔 본들, 낮은 재사용성이 개선되지는 않을 것입니다.

본문에 나타난 필터들(IsOver18, NameHasKim)이 저 마다의 방식으로 PassFilter.Test를 구현하고 있습니다. 이것이 다형성이 아니라고 하는데는 전혀 동의가 되지 않군요.

2개의 좋아요

아마도 @SangHyeon.Kim 님의 다형성은 동일한 추상이 아니라 각자 다른 기능 관점의 다형성을 말씀하신 것 같습니다.

예를 들어,

동일한 추상 클래스를 상속한 다른 동작을 하는 구현 클래스가 각각 있을 때 이것을 추상 클래스로 바라보고 동작은 다르게 되는 것을 다형성이라고 말할 수 있겠고

클래스가 구현한 다양한 인터페이스, 즉, 다른 관점의 기능들에 의해 각각 다르게 바라보고 동작될 수 있다는 것을 다형성으로 말할 수 있습니다.

첫번째는 다른 인스턴스를 동일한 형태로 바라보느냐에 대한 다형성

두번째는 같은 인스턴스를 다양항 형태로 바라보느냐에 대한 다형성입니다.

4개의 좋아요

객체의 근본 속성 중 하나는 정의가 하나라는 점입니다.
이를 단형성(Monomorphism)이라고 하죠.

다형성은 단형성을 보충하기 위한 것으로, "상속"이라는 장치만 사용하면 달성할 수 있습니다.

이에 반해, 추상 클래스와 인터페이스가 가능하게 하는 것은 "미확정적(추상적) 정의"입니다.
추상적 정의의 목적은 정의의 확정의 시기를 지연시키거나, 확정의 책임을 다른 객체에게 전가시키기 위한 것입니다.

추상적 정의는 반드시 상속이 수반되어야 하기에, 다형성은 저절로 달성됩니다.

이 글은 추상적 정의(로 인한 다형성 달성)를 가능하게 하는 두 가지 방법 중, 추상클래스를 선호하며, 왜 그런지를 설명하고 있습니다.

1개의 좋아요

@BigSquare 좋은 내용 감사합니다!!

좋은 글에 제 의견도 추가해봤습니다. :rofl:

추상 클래스와 인터페이스는 비슷한 듯 보이지만, 실제로는 서로 다른 세계관을 가지고 있죠.

추상 클래스는 추상 메서드와 기본 구현을 필수로 두기 땜에 이를 통해 가독성을 향상시키고 사용자가 해당 메서드가 어떻게 작동할 것인지 까지도 예상할 수 있게 해주죠… (호기심 유발)

반면 인터페이스는 그냥 구현에 대한 규약만 정의하죠. (구현 여부는 당신의 선택…)
그리고 구현하기로 맘 먹었다면 인터페이스에서 정의된 모든 메서드를 구현해야 하지만, 필요에 따라 원하는 데로 구현을 할 수 있죠.


그리고 추상 클래스를 사용하면 강제성을 부여하게 되어 상속 클래스에서의 개입이 더 심해질 수 있습니다. 하지만 이는 상속 클래스와의 연결이 강화되어 일정 수준의 코드 재사용성과 일관성을 얻을 수 있겠네요! (제가 추상화를 좋아하는 부분)

결론적으로, 추상 클래스와 인터페이스는 서로 다른 목적과 특성을 가지고 있다고 생각해요.

상황과 요구 사항에 따라 적절한 선택이 필요하겠죠.

  • 추상 클래스는 기본 구현과 강제성에 특화
  • 인터페이스는 유연성과 다양한 구현 가능성을 제공
4개의 좋아요

@BigSquare 님의 관점은 공통 모듈의 재사용과 재정의 관점에서
상속과 파생을 통해 다형성을 지원하는 것에 더 중점을 둔 설명으로 보입니다.

그런데 인터페이스추상클래스 는 그 쓰임이나 성격이 좀 다르죠.

공통 모듈의 재사용과 재정의 관점에서 다형성을 제공해야하는 상황을 고려한다면 당연히 추상클래스가 정답입니다.

하지만 추상클래스는 다중 상속이 불가능하고 각 모듈의 재사용이 의미가 없는 상황 + 특정 상속관계가 아닌 멤버 구현 여부만을 타입의 기준으로 삼는 상황이라면 인터페이스가 정답입니다.

다른 글에 제가 잠깐 찌끄린 게 있는데

여기서 제가 강조했던 건 단순히 다형성을 제공 목적 그 자체가 아니라
쓰임과 용도가 다른 두 대상,
특히 인터페이스의 경우 Duck Typing 에 더 중점을 두고 있다… 라는 거였죠.

추상클래스를 이럴 때 사용하는 게 좋아~ 라는 차원의 설명이라면, 충분히 따따봉 인 설명이었는데

인터페이스추상클래스 를 동일 선상에 놓고
둘 중 하나를 선택해야할 근거 처럼 설명하신 부분이 논란의 여지를 준거 같슴다.

제 생각엔 둘은 동일 선상에 놓고 비교 선택할 대상은 아닌 걸로 보여요. ㅇㅅㅇ!

3개의 좋아요

우리가 코딩하면서, IEnumerable 패밀리와 같은 다중 상속 개념의 다층적 인터페이스를 정의할 일이 얼마나 될까요?

아마 프레임워크를 설계하는 경우 빼곤 거의 없지 싶습니다.

내가 선언한 인터페이스를 소비하는 장소가 내 프로젝트 밖에 없다면, 결국은 내 프로젝트의 도메인 내부에서만 의미 있는 추상이라는 의미인데, 이 경우, 추상 클래스와 인터페이스는 동일 선상에서 선택할 문제로 볼 수 있지 않을까요?

그리고, 이 글과 링크된 두 글에서 예제 없는 주장만 하셨는데, 예제 없는 주장이 논란의 원인이 아닌지요?

저는 그러한 논란을 피하기 위해, 이러한 논쟁에는 거의 대부분 예제를 제공하여 주장을 뒷받침하고 있습니다. 특히, 링크된 글에는 반대되는 의견을 가지신 분과 동일한 분량의 예제를 남겼죠.

저의 주장을 논란이라고 치부하신다면, 이 글에 말씀하신, 추상 클래스가 정답인 경우와, 인터페이스가 정답인 경우에 대한 합당한 예제 를 제공해주시면, 예제를 꾸미느라 소비한 정성과 시간이 헛되지 않고 새로운 지식으로 보답 받을 것 같습니다.

2개의 좋아요

건설적인 토론을 넘어서서 과열되는 양상이 있어 이 스레드를 닫겠습니다. 이 스레드에 참여하신 모든 분들께 부탁드립니다. 서로가 주장하는 바를 피력하는 것은 좋지만, 과격한 표현을 사용하는 것은 건전한 포럼 이용 문화에 부합하지 않습니다.

커뮤니티 행동 규범 의 내용을 확인해주시면 감사하겠습니다.

2개의 좋아요