타입을 고치지 않고 타입마다 다른 일을 시킨다
Esc

    타입을 고치지 않고 타입마다 다른 일을 시킨다

    –Unity

    아이템이 세 종류 있습니다. 무기, 방어구, 소모품.

    여기에 기능을 붙입니다. 세이브 파일에 쓰기, 툴팁 문구 만들기, 상점 판매가 계산하기, 인벤토리 정렬 기준 뽑기. 앞으로도 계속 늘어날 예정입니다.

    가장 먼저 떠오르는 방법은 각 클래스에 메서드를 넣는 것입니다.

    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 로 되는지부터 본다.