Typescript로 다시 쓰는 GoF - Visitor

아홉번째태양·2023년 9월 17일
0

Visitor 패턴이란?

어떤 데이터의 구조를 나타내는 객체가 있고 이 객체로 수행하는 처리작업을 별도의 객체로 분리할 때 사용할 수 있는 것이 Visitor 패턴이다. 즉, 데이터의 구조와 처리를 분리하는 패턴이다.

예를들어, 어떤 사각형을 나타내는 객체 Square가 있고 이 객체는, 사각형의 폭과 넓이를 나타내는 속성 widthheight를 가진다. 이때, 이 사각형을 그려주는 메소드 drawSquare 안에 구현을 해도 가능하지만, 주어진 도형을 그리는 작업만을 담당하는 Drawer라는 객체를 만들 수도 있다. 이렇게 처리 객체를 분리함으로서, 유사한 객체에 같은 처리가 필요할 경우 같은 코드를 중복으로 작성하지 않고 같은 그룹의 객체는 일괄적으로 관리를 할 수 있게 된다.

이때, 어떤 객체를 처리할지 대상을 정하는 행위를 Visitor 패턴에서는 visit방문이라고 표현하며, 외부에서 방문을 했을 때 방문한 객체에 자기자신을 매개변수로 전달해주는 행위를 accept수용이라고 부른다.



Visitor 구현

여러 종류의 도형을 그리는 프로그램을 Visitor 패턴을 이용해 만들어보자.

Visitor 패턴에는 다음의 네가지 객체가 필요하다.

  1. Visitor 방문자
    visit의 인터페이스가 정의되며, 방문할 대상 객체가 무엇인지를 기술하게 된다.
  2. ConcreteVisitor 구체적인 방문자
    Visitor에서 정의한 visit 인터페이스를 구현한다. 즉, 대상 객체를 방문했을 때 해당 객체를 통해 하게될 행동을 실질적으로 기술한다.
  3. Element 요소
    Visitor가 방문하는 대상이며, 여기에는 Visitor가 방문했을 때 visit 메소드에 자기 자신을 매개변수로 전달하는 accept 메소드가 정의된다.
  4. ConcreteElement 구체적인 요소
    대상이되는 객체를 구현하고 더불어서 accept를 통해 어떻게 자기 자신을 전달할지 구현한다.

또한, Composite 패턴처럼 다른 집합적인 패턴과 함께 쓰이는 경우 ObjectStructure 오브젝트 구조라고 불리는 역할의 객체가 함께 등장할 수도 있다. 하지만 예제에서는 단순하게 Visitor 패턴 자체만 집중하기 위해 다른 패턴과 연계하면서 생기는 구조는 생략한다.


Element

먼저, 프로그램에서 다루는 대상이 되는 객체, 즉 도형들이 Element가 된다. 그리고 Element는 accept라는 메소드를 가진다.

interface Element {
  accept(visitor: Visitor): void;
}

ConcreteElement

Element에 해당하는 구현체들을 작성한다.

class Square implements Element {
  accept(visitor: Visitor): void {
    visitor.visit(this);
  }
}

class Circle implements Element {
  accept(visitor: Visitor): void {
    visitor.visit(this);
  }
}

class Triangle implements Element {
  accept(visitor: Visitor): void {
    visitor.visit(this);
  }
}

만약 위의 경우처럼 구현하는 accept 메소드가 동일하다면 Element를 추상객체로 만드는 방법도 가능하다.

abstract class Element {
  accept(visitor: Visitor) {
    visitor.visit(this);
  };
}

class Square extends Element {}

class Circle extends Element {}

class Triangle extends Element {}

Visitor

앞서 작성한 각각의 도형들에는 도형을 그리는 메소드는 존재하지 않는다. 어떤 도형을 어떻게 그릴지는 Visitor에서 정의한다.

interface Visitor {
  visit(square: Square): void;
  visit(circle: Circle): void;
  visit(triangle: Triangle): void;
}

본 예제에서는 function overload를 사용하여 하나의 메소드에 여러 오버로드를 겹쳐놓았는데, 어차피 ConcreteVisitor에서 메소드를 구현할 때 타입스크립트에서는 같은 이름의 메소드를 중복으로 생성하지 못하기 때문에 visitSquare, visitCircle, visitTriangle로 만들어도 무방하다.


ConcreteVisitor

마지막으로, 도형을 그리는 동작을 직접 구현하는 객체 Drawer를 만든다.

class Drawer implements Visitor {
  visit(shape: Element) {
    switch (shape.constructor) {
      case Square:
        this.drawSquare(shape);
        break;
      case Circle:
        this.drawCircle(shape);
        break;
      case Triangle:
        this.drawTriangle(shape);
        break;
      default:
        throw new Error('Unknown shape');
    }
  }

  drawSquare(square: Square) {
    console.log(`Drawing ${square.constructor.name}`);
  }
  drawCircle(circle: Circle) {
    console.log(`Drawing ${circle.constructor.name}`);
  }
  drawTriangle(triangle: Triangle) {
    console.log(`Drawing ${triangle.constructor.name}`);
  }
}

앞서 Visitor에서 오버로드를 사용해 visit 메소드를 정의하였지만, 타입스크립트에서는 각각의 오버로드에 대해 개별적으로 메소드를 작성할 방법이 없다. 따라서, 적절한 분기와 해당 분기에 따른 처리문을 구분하던가, 아니면 애초에 visit 메소드를 케이스에 따라 다른 이름으로 작성한다.


사용

이제 작성한 코드를 테스트해본다.

const square = new Square();
const circle = new Circle();
const triangle = new Triangle();

const drawer = new Drawer();
square.accept(drawer);
circle.accept(drawer);
triangle.accept(drawer);
Drawing Square
Drawing Circle
Drawing Triangle


Visitor의 한계

Visitor는 어떤 객체 집단 전체에 적용할 수 있는 새로운 처리객체를 만들 때 유용하게 사용할 수 있다. 에를들어, 앞선 예제에서 도형을 지우는 인터페이스를 담은 Remover라는 다른 Visitor 객체를 추가한다면, 각각의 도형들 객체는 변형할 필요가 없이 새로 만든 Visitor를 accept 메소드에 전달하기만 하면 된다.

const remover = new Remover();
square.accept(remover);

즉, 새로운 Visitor를 추가하는 것은 Visitor 패턴에서 매우 간단한 일이다.

하지만 반대로 새로운 Element를 추가하는 것은 매우 번거롭다. 새로운 ConcreteElement 객체를 만들고, 기존에 만들어져있는 모든 Visitor와 ConcreteVisitor에 새로 만든 Element에 대한 처리를 추가해야하기 때문이다.




참고자료

Java언어로 배우는 디자인 패턴 입문 - 쉽게 배우는 Gof의 23가지 디자인패턴 (영진닷컴)

KirillVasiltsov - Visitor Pattern Typescript

Refactoring Guru - Visitor in Typescript

0개의 댓글