타입을 고치지 않고 타입마다 다른 일을 시킨다
아이템이 세 종류 있습니다. 무기, 방어구, 소모품.
여기에 기능을 붙입니다. 세이브 파일에 쓰기, 툴팁 문구 만들기, 상점 판매가 계산하기, 인벤토리 정렬 기준 뽑기. 앞으로도 계속 늘어날 예정입니다.
가장 먼저 떠오르는 방법은 각 클래스에 메서드를 넣는 것입니다.
public class Weapon : IItem
{
public string ToSaveString() { ... }
public string BuildTooltip() { ... }
public int GetSellPrice() { ... }
public int GetSortKey() { ... }
}
Armor 와 Potion 에도 똑같이 넣습니다. 동작 하나를 추가할 때마다
세 파일을 열어 메서드를 하나씩 씁니다. 종류가 열 개면 열 파일입니다.
그리고 Weapon 클래스에는 이제 무기와 아무 상관 없는 것들이 섞입니다.
세이브 형식, UI 문구, 상점 정책. 무기를 고치려고 열었는데 UI 코드가 나옵니다.
두 번째로 떠오르는 방법은 타입을 검사하는 것입니다.
string BuildTooltip(IItem item) => item switch
{
Weapon w => $"공격력 {w.Attack}",
Armor a => $"방어력 {a.Defense}",
_ => "",
};
동작별로 한 파일에 모이는 것은 좋아졌습니다.
그런데 아이템 종류를 하나 추가하면 이런 switch 를 전부 찾아 고쳐야 하고,
하나 빠뜨려도 컴파일이 됩니다. 상태 편에서 본 것과 같은 문제입니다.
두 방향으로 늘어난다
여기서 알아야 할 것은 확장에 두 방향이 있다는 사실입니다.
| 타입 추가가 쉬운가 | 동작 추가가 쉬운가 | |
|---|---|---|
| 클래스마다 메서드 | 쉽다. 파일 하나 새로 만든다 | 어렵다. 모든 클래스를 연다 |
타입 검사 switch |
어렵다. 모든 switch 를 찾는다 |
쉽다. 함수 하나 새로 쓴다 |
| 비지터 | 어렵다. 모든 비지터를 고친다 | 쉽다. 클래스 하나 새로 만든다 |
보통의 다형성은 타입을 늘리기 쉽게 만들어져 있습니다. 비지터는 정확히 반대로, 동작을 늘리기 쉽게 만듭니다.
그래서 판단 기준은 하나입니다. 타입이 더 자주 늘어나는가, 동작이 더 자주 늘어나는가. 타입이 자주 늘면 비지터는 최악의 선택입니다.
비지터는 이렇게 생겼다
동작 하나를 클래스 하나로 만듭니다. 그 클래스가 타입별 처리를 전부 들고 있습니다.
public interface IItemVisitor
{
void Visit(Weapon weapon);
void Visit(Armor armor);
void Visit(Potion potion);
}
public class TooltipVisitor : IItemVisitor
{
public string Result { get; private set; }
public void Visit(Weapon w) => Result = $"공격력 {w.Attack}";
public void Visit(Armor a) => Result = $"방어력 {a.Defense}";
public void Visit(Potion p) => Result = $"회복 {p.HealAmount}";
}
아이템 쪽에는 딱 한 줄만 추가합니다.
public interface IItem
{
void Accept(IItemVisitor visitor);
}
public class Weapon : IItem
{
public int Attack;
public void Accept(IItemVisitor v) => v.Visit(this); // 이 한 줄이 전부
}
이제 새 동작은 IItemVisitor 를 구현한 클래스 하나입니다.
Weapon 도 Armor 도 열지 않습니다.
Accept 는 왜 있어야 하나
여기가 이 패턴에서 가장 헷갈리는 부분입니다.
visitor.Visit(item) 을 그냥 부르면 안 되는지 궁금해집니다. 안 됩니다.
같은 이름의 메서드 중 어느 것을 부를지는 컴파일할 때 변수의 타입만 보고 정해집니다.
그래서 IItem 변수를 넘기면 Visit(Weapon) 이 골라지지 않습니다.
반면 item.Accept(...) 는 실행할 때 담긴 것을 보고 정해집니다.
Weapon 의 Accept 안으로 들어가면 그때부터 this 는 Weapon 입니다.
그 시점에 v.Visit(this) 를 부르면 정확한 것이 골라집니다.
타입을 두 번 물어보는 것이라 두 번 나눠 보낸다는 이름이 붙었습니다.
Accept 한 줄의 존재 이유가 이것뿐입니다.
대가
타입을 하나 추가하면 모든 비지터를 고쳐야 합니다. Accessory 를 추가하는 순간
IItemVisitor 에 Visit(Accessory) 가 늘고, 그 인터페이스를 구현한 클래스 전부가
컴파일 에러를 냅니다.
이건 나쁘기만 한 건 아닙니다. 컴파일러가 빠뜨린 곳을 전부 알려 줍니다.
switch 방식이었으면 조용히 통과했을 것들입니다. 대신 고칠 파일이 많습니다.
비지터가 남의 속을 알아야 합니다. Visit(Weapon w) 안에서 w.Attack 을 읽으려면
그 필드가 열려 있어야 합니다. 감춰 두려고 만든 것을 다시 열게 됩니다.
코드가 장황합니다. 인터페이스 하나, 아이템마다 Accept 한 줄,
동작마다 클래스 하나. 결과를 돌려받는 방법도 어색합니다.
위 예제에서 Result 프로퍼티를 쓴 것이 그 흔적입니다.
유니티에서 쓸 일이 있나
솔직히 말하면 드뭅니다. 이 시리즈에서 가장 덜 쓰이는 패턴이고, 마지막에 둔 이유입니다.
잘 맞는 자리는 트리를 훑는 일입니다. 노드 종류는 거의 안 바뀌는데 그 트리로 하는 일은 계속 늘어나는 구조입니다.
- 비헤이비어 트리: 노드 종류는 고정, 실행과 검증과 에디터 그리기가 각각 필요
- 대화 스크립트나 퀘스트 조건: 문법은 고정, 해석과 검사와 미리보기가 각각 필요
- 수식 파서: 연산자는 고정, 계산과 출력과 최적화가 각각 필요
에디터 툴에서도 자주 나옵니다. 같은 데이터에 검사, 통계, 일괄 수정 같은 것을 계속 붙이게 되는데, 그때마다 런타임 클래스를 열고 싶지는 않으니까요.
그리고 요즘 C# 이면 대안이 있습니다.
string BuildTooltip(IItem item) => item switch
{
Weapon w => $"공격력 {w.Attack}",
Armor a => $"방어력 {a.Defense}",
Potion p => $"회복 {p.HealAmount}",
};
패턴 매칭 switch 는 비지터가 하려던 일을 훨씬 짧게 합니다.
타입을 빠뜨렸을 때 컴파일러가 경고해 주는 것도 어느 정도 됩니다.
Accept 를 심을 수 없는 남의 타입에도 쓸 수 있습니다.
비지터를 쓰기 전에 패턴 매칭으로 되는지부터 봅니다. 타입이 정말 고정이고, 동작이 계속 늘고, 빠뜨리는 것을 컴파일러가 확실히 잡아 주기를 바랄 때 비지터가 남습니다.
정리
- 동작을 추가할 때마다 모든 클래스를 여는 것도, 모든
switch를 찾는 것도 문제다. - 확장에는 두 방향이 있다. 타입이 느는 쪽과 동작이 느는 쪽이다.
- 보통의 다형성은 타입 추가가 쉽고, 비지터는 동작 추가가 쉽다. 정확히 반대다.
- 판단 기준은 무엇이 더 자주 늘어나는가 하나뿐이다.
Accept한 줄이 필요한 이유는 C# 이 인자의 실제 타입을 보고 오버로드를 고르지 않아서다.- 타입을 추가하면 모든 비지터가 컴파일 에러를 낸다. 번거롭지만 빠뜨리지는 않는다.
- 비지터가 대상의 속을 알아야 해서 캡슐화가 약해진다.
- 유니티에서는 트리를 훑는 일과 에디터 툴 말고는 쓸 일이 드물다.
- 패턴 매칭
switch로 되는지부터 본다.