문제
다양한 유형의 객체가 있을 때, 이 객체들에 대해 어떤 작업을 수행하는 요구사항이 추가됐다고 하자. 예를 들어, 어떤 트리 구조의 데이터가 존재하는데, 이 데이터를 XML 형식으로 내보내야 한다고 하자.
데이터 노드의 클래스 메서드로 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}67const visitor = new XMLExportVisitor();8const components = [new Square(), new Square(), new Circle()]9components.map(component => component.accept(visitor))
이 방법을 사용하면 조건문을 사용하지 않고 적절한 메서드를 실행할 수 있게 된다.
결국 기존의 노드 클래스를 변경하긴 했지만, 이는 아주 작은 변경사항이고, 앞으로 새로운 변경사항이 생겼을 때 노드 클래스를 변경하지 않고도 새로운 행동을 추가할 수 있다.
장점
- 예상치 못한 새로운 동작이 필요할 때도 쉽게 추가할 수 있다
- 복합체와 함께 사용될 경우, 복합체를 순회하며 누적된 상태를 토대로 작업할 수 있다
- 비지터가 수행하는 기능과 관련된 코드를 한 곳에 모을 수 있다
단점
- 복합체가 비지터에게 열려 있을 때, 비지터가 복합체의 요소를 순회하며 복합체를 훼손할 수 있다
- 요소 계층 구조와 비지터 간에 결합이 생겨, 계층 구조가 변경될 때마다 비지터를 변경해야 한다
구현
- 비지터 인터페이스를 생성하고 방문할 구상 클래스를 위한 비지터 메서드 생성
- 요소 인터페이스에 비지터 객체를 인수로 받는 추상 수락 메서드 추가
- 모든 구상 요소 클래스에서 수락 메서드 구현
- 모든 비지터 메서드 구현
- 클라이언트에서 비지터 객체를 만들고 수락 메서드를 통해 요소에 비지터를 전달
as-is
1export class CompoundShape extends Component {2 /* ... */34 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}1213export class Circle extends Shape {14 /* ... */1516 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}2526export class Rectangle extends Shape {27 /* ... */2829 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}4142// client code43const compoundShape = new CompoundShape();44compoundShape.add(new Rectangle({x: 40, y: 50}, 10, 50));45compoundShape.add(new Circle({x: 80, y: 20}, 20));4647const 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 }910 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 }1819 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}3233export 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}4041export class CompoundShape extends Component {42 /* ... */4344 public override accept(visitor: ExportVisitor) {45 return visitor.exportCompoundShape(this);46 }47}4849export class Circle extends Shape {50 /* ... */5152 public override accept(visitor: ExportVisitor) {53 return visitor.exportCircle(this);54 }55}5657export class Rectangle extends Shape {58 /* ... */5960 public override accept(visitor: ExportVisitor) {61 return visitor.exportRectangle(this);62 }63}6465// client code66const visitor = new XmlExportVisitor();6768const compoundShape = new CompoundShape();69compoundShape.add(new Rectangle({x: 40, y: 50}, 10, 50));70compoundShape.add(new Circle({x: 80, y: 20}, 20));7172const result = compoundShape.accept(visitor)
만약에 XML 외에도 JSON 형식으로 내보낼 수 있어야 한다면, 새로운 비지터를 만들어서 쉽게 구현할 수 있다.
1export class JsonExportVisitor implements ExportVisitor<object> {23 exportCompoundShape(component: CompoundShape) {4 const children: object[] = [];5 component.getChildren().forEach(x => children.push(x.accept(this)))6 return { children }7 }89 exportPage(component: Page) {10 const children: object[] = [];11 component.getChildren().forEach(x => children.push(x.accept(this)))12 return { children }13 }1415 exportCircle(component: Circle) {16 return {17 center: component.getPosition(),18 radius: component.radius19 }20 }2122 exportRectangle(component: Rectangle) {23 return {24 position: component.getPosition(),25 width: component.width,26 height: component.height27 }28 }29}3031// client code32const visitor = new JsonExportVisitor();3334const compoundShape = new CompoundShape();35compoundShape.add(new Rectangle({x: 40, y: 50}, 10, 50));36compoundShape.add(new Circle({x: 80, y: 20}, 20));3738const result = compoundShape.accept(visitor)
기존 코드는 전혀 건드리지 않고, JsonExportVisitor만 추가해서 기능을 구현했다!
사례
Babel은 자바스크립트 코드를 AST로 변환한 후 순회하기 위해 비지터 패턴을 사용한다. (공식 문서)
1// my-babel-plugin.ts2module.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};1718// index.ts19function square(n) {20 return n * n;21}2223/**24 * 위 코드는 아래와 같은 트리 구조로 변환됨25 - FunctionDeclaration26 - Identifier (id)27 - Identifier (params[0])28 - BlockStatement (body)29 - ReturnStatement (body)30 - BinaryExpression (argument)31 - Identifier (left)32 - Identifier (right)33*/3435// 출력 결과36identifier // enter 시작37identifier38Block39return40identifier41identifier
관련 패턴
- 커맨드 패턴과 전략 패턴은 주로 클라이언트 코드가 어떤 동작을 할 지를 결정하게 된다. 이 경우 객체마다 어떤 동작을 할 지에 대해 결정하기 어려워 정교한 추상화를 할 수 없는 반면, 비지터는 방문 동작과 수행 동작을 분리해 이 문제를 해결한다
- 비지터 패턴을 사용해 복합체 패턴 트리 전체를 대상으로 작업을 수행할 수 있다
- 반복자 패턴과 함께 사용해 복잡한 데이터 구조를 순회하며 작업을 실행할 수 있다