비지터 패턴

객체 구조와 알고리즘을 분리시키는 디자인 패턴

2023-04-23에 씀

문제

다양한 유형의 객체가 있을 때, 이 객체들에 대해 어떤 작업을 수행하는 요구사항이 추가됐다고 하자. 예를 들어, 어떤 트리 구조의 데이터가 존재하는데, 이 데이터를 XML 형식으로 내보내야 한다고 하자.

데이터 노드의 클래스 메서드로 XML 형식으로 내보내는 코드를 추가하면 다음과 같은 문제가 있다.

해결

Visitor 클래스를 만들고, 여기에 새로 추가될 동작을 정의한다. 처리해야 하는 객체 유형마다 메서드를 생성한다.

1interface ExportVisitor<T = any> {
2 exportPage(component: Page): T;
3 exportCompoundShape(component: CompoundShape): T;
4 exportRectangle(component: Rectangle): T;
5 exportCircle(component: Circle): T;
6}

비지터의 메서드를 올바르게 실행하기 위해서는 작업을 실행할 대상 객체의 클래스를 정확히 알아야 한다.

이를 위해 더블 디스패치 방법을 사용한다. 비지터의 어떤 메서드를 실행할지를 클라이언트 코드가 결정하는 것이 아니라 비지터의 인수로 전달되는 객체가 선택하도록 하는 것이다.

1export class Square extends Component {
2 public override accept(visitor: ExportVisitor) {
3 return visitor.exportPage(this);
4 }
5}
6
7const visitor = new XMLExportVisitor();
8const components = [new Square(), new Square(), new Circle()]
9components.map(component => component.accept(visitor))

이 방법을 사용하면 조건문을 사용하지 않고 적절한 메서드를 실행할 수 있게 된다.

결국 기존의 노드 클래스를 변경하긴 했지만, 이는 아주 작은 변경사항이고, 앞으로 새로운 변경사항이 생겼을 때 노드 클래스를 변경하지 않고도 새로운 행동을 추가할 수 있다.

장점

단점

구현

  1. 비지터 인터페이스를 생성하고 방문할 구상 클래스를 위한 비지터 메서드 생성
  2. 요소 인터페이스에 비지터 객체를 인수로 받는 추상 수락 메서드 추가
  3. 모든 구상 요소 클래스에서 수락 메서드 구현
  4. 모든 비지터 메서드 구현
  5. 클라이언트에서 비지터 객체를 만들고 수락 메서드를 통해 요소에 비지터를 전달

전체 구현 코드

as-is

1export class CompoundShape extends Component {
2 /* ... */
3
4 public exportAsXml(baseNode?: builder.XMLElement): string {
5 const result = (baseNode ?? builder.create('CompoundShape'))
6 this.children.forEach(x => {
7 x.exportAsXml(result.ele(x.getNodeName()))
8 })
9 return result.toString()
10 }
11}
12
13export class Circle extends Shape {
14 /* ... */
15
16 public exportAsXml(baseNode?: builder.XMLElement): string {
17 return (baseNode ?? builder.create(this.getNodeName()))
18 .att('centerX', this.position.x)
19 .att('centerY', this.position.y)
20 .att('radius', this.radius)
21 .att('area', Math.floor(Math.pow(this.radius, 2) * Math.PI))
22 .toString()
23 }
24}
25
26export class Rectangle extends Shape {
27 /* ... */
28
29 public exportAsXml(baseNode?: builder.XMLElement): string {
30 return (baseNode ?? builder.create(this.getNodeName()))
31 .att('left', this.position.x)
32 .att('top', this.position.y)
33 .att('right', this.position.x + this.width)
34 .att('bottom', this.position.y + this.height)
35 .att('width', this.width)
36 .att('height', this.height)
37 .att('area', this.width * this.height)
38 .toString()
39 }
40}
41
42// client code
43const compoundShape = new CompoundShape();
44compoundShape.add(new Rectangle({x: 40, y: 50}, 10, 50));
45compoundShape.add(new Circle({x: 80, y: 20}, 20));
46
47const result = compoundShape.exportAsXml()

to-be

1// 기존에 exportAsXml로 구현했던 로직을 Visitor로 이동
2export class XmlExportVisitor implements ExportVisitor {
3 public exportCompoundShape(compound: CompoundShape) {
4 const result = builder.create('CompoundShape')
5 let child = ''
6 compound.getChildren().forEach(x => child += x.accept(this))
7 return result.raw(child).toString()
8 }
9
10 public exportCircle(circle: Circle) {
11 return builder.create('Circle')
12 .att('centerX', circle.getPosition().x)
13 .att('centerY', circle.getPosition().y)
14 .att('radius', circle.radius)
15 .att('area', Math.floor(Math.pow(circle.radius, 2) * Math.PI))
16 .toString()
17 }
18
19 public exportRectangle(rect: Rectangle) {
20 const position = rect.getPosition();
21 return builder.create('Rectangle')
22 .att('left', position.x)
23 .att('top', position.y)
24 .att('right', position.x + rect.width)
25 .att('bottom', position.y + rect.height)
26 .att('width', rect.width)
27 .att('height', rect.height)
28 .att('area', rect.width * rect.height)
29 .toString()
30 }
31}
32
33export abstract class Component {
34 public abstract getChildren(): Set<Component> | null;
35 public abstract add(component: Component): void;
36 public abstract remove(component: Component): void;
37 // 더블 디스패치 - Visitor의 어떤 메서드를 사용할지를 결정
38 public abstract accept(visitor: ExportVisitor): void;
39}
40
41export class CompoundShape extends Component {
42 /* ... */
43
44 public override accept(visitor: ExportVisitor) {
45 return visitor.exportCompoundShape(this);
46 }
47}
48
49export class Circle extends Shape {
50 /* ... */
51
52 public override accept(visitor: ExportVisitor) {
53 return visitor.exportCircle(this);
54 }
55}
56
57export class Rectangle extends Shape {
58 /* ... */
59
60 public override accept(visitor: ExportVisitor) {
61 return visitor.exportRectangle(this);
62 }
63}
64
65// client code
66const visitor = new XmlExportVisitor();
67
68const compoundShape = new CompoundShape();
69compoundShape.add(new Rectangle({x: 40, y: 50}, 10, 50));
70compoundShape.add(new Circle({x: 80, y: 20}, 20));
71
72const result = compoundShape.accept(visitor)

만약에 XML 외에도 JSON 형식으로 내보낼 수 있어야 한다면, 새로운 비지터를 만들어서 쉽게 구현할 수 있다.

1export class JsonExportVisitor implements ExportVisitor<object> {
2
3 exportCompoundShape(component: CompoundShape) {
4 const children: object[] = [];
5 component.getChildren().forEach(x => children.push(x.accept(this)))
6 return { children }
7 }
8
9 exportPage(component: Page) {
10 const children: object[] = [];
11 component.getChildren().forEach(x => children.push(x.accept(this)))
12 return { children }
13 }
14
15 exportCircle(component: Circle) {
16 return {
17 center: component.getPosition(),
18 radius: component.radius
19 }
20 }
21
22 exportRectangle(component: Rectangle) {
23 return {
24 position: component.getPosition(),
25 width: component.width,
26 height: component.height
27 }
28 }
29}
30
31// client code
32const visitor = new JsonExportVisitor();
33
34const compoundShape = new CompoundShape();
35compoundShape.add(new Rectangle({x: 40, y: 50}, 10, 50));
36compoundShape.add(new Circle({x: 80, y: 20}, 20));
37
38const result = compoundShape.accept(visitor)

기존 코드는 전혀 건드리지 않고, JsonExportVisitor만 추가해서 기능을 구현했다!

사례

Babel은 자바스크립트 코드를 AST로 변환한 후 순회하기 위해 비지터 패턴을 사용한다. (공식 문서)

1// my-babel-plugin.ts
2module.exports = function () {
3 return {
4 visitor: {
5 Identifier(path) {
6 console.log('identifier');
7 },
8 BlockStatement(body) {
9 console.log('Block')
10 },
11 ReturnStatement(path, state) {
12 console.log('return')
13 }
14 },
15 };
16};
17
18// index.ts
19function square(n) {
20 return n * n;
21}
22
23/**
24 * 위 코드는 아래와 같은 트리 구조로 변환됨
25 - FunctionDeclaration
26 - Identifier (id)
27 - Identifier (params[0])
28 - BlockStatement (body)
29 - ReturnStatement (body)
30 - BinaryExpression (argument)
31 - Identifier (left)
32 - Identifier (right)
33*/
34
35// 출력 결과
36identifier // enter 시작
37identifier
38Block
39return
40identifier
41identifier

관련 패턴

참고 자료

프로필 사진

조예진

이전 포스트
반복자 패턴
다음 포스트
내 블로그의 최신 글을 깃허브 프로필에 자동으로 등록하기