저는 인터페이스보다는 추상 클래스를 자주 사용하는데, 멤버를 감출 수 있다는 점이 가장 큰 이유입니다.
멤버를 감출 때 얻을 수 있는 장점들이 어떤 것인지는 이미 다들 아실텐데, 이 글은 "구현자의 편리성"을 높일 수 있다는 점을 살펴봅니다.
관련 예제를 하나 만들어 보았습니다. 예제는 아래의 링크에 있습니다.
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 멤버 밖에 갖지 못하기 때문에, 이와 같이 비 생성자 주입 코드를 사용할 수 없고, 그로 인해, 구현 코드에 보일러 코드가 자동으로 많아지는 것입니다.
마지막으로 링크된 깃허브에 예제 파일도 있으니, 관심 있으신 분들은 한번 보시기 바랍니다.
데이터 분석할 때, 많이 사용했던 패턴이기도 합니다.
추가: 깃허브에는 리펙토링으로 인해 이 글에 나타난 식별자와 다르게 보일 수도, 상속의 단계가 늘어 나 있을 수도 있습니다. 그러나 전체적인 맥락은 같습니다.