비지터 패턴
개요
텍스트와 이미지로 이루어진 문서에 출력, 통계, 검사 기능이 계속 붙는다고 하자. 각 노드에 기능별 메서드를 늘리는 대신, 연산 하나를 별도 객체로 모을 수 있다.
비지터(Visitor), 즉 방문자는 요소 종류별 처리 메서드를 가진 객체다. 요소가 방문자를 받아 자기 타입에 맞는 처리를 호출하므로, 방문자는 텍스트와 이미지를 다르게 다룰 수 있다.
UML
TextNode와 ImageNode가 IDocNode를 구현하고 방문자를 받는다. TextVisitor와 CountVisitor는 같은 IVisitor를 구현하지만 각각 출력과 집계를 맡는다.
역사
GoF는 1994년 Design Patterns에서 Visitor를 행위 패턴으로 정리했다. 객체 구조를 이루는 요소의 클래스와 그 위에서 수행하는 연산을 분리하는 구조다.1
왜 사용하는가?
새 출력이나 통계를 추가할 때 기존 요소의 코드를 매번 바꾸지 않고 방문자를 더할 수 있다. 한 연산에 필요한 요소별 처리가 방문자 하나에 모인다.
대신 새 요소 종류가 생기면 IVisitor와 기존 방문자들을 함께 고쳐야 한다. 요소 종류는 안정적이고 연산 종류가 자주 늘어나는 상황에 맞는다.
예제
문서의 각 요소를 TextVisitor로 출력한다.
using System;
using System.Collections.Generic;
using System.Linq;
IDocNode[] nodes = { new TextNode("hello"), new ImageNode("cover") };
var visitor = new TextVisitor();
foreach (IDocNode node in nodes) node.Accept(visitor);
foreach (string line in visitor.Lines) Console.WriteLine(line);
interface IDocNode { void Accept(IVisitor visitor); }
// 연산 하나에 필요한 요소별 처리를 방문자 한곳에 모은다.
interface IVisitor
{
void Visit(TextNode node);
void Visit(ImageNode node);
}
// 노드는 자기 종류에 맞는 방문자 메서드를 연결하며 연산 자체는 맡지 않는다.
sealed record TextNode(string Value) : IDocNode
{
public void Accept(IVisitor visitor) => visitor.Visit(this);
}
sealed record ImageNode(string Name) : IDocNode
{
public void Accept(IVisitor visitor) => visitor.Visit(this);
}
sealed class TextVisitor : IVisitor
{
public List<string> Lines { get; } = new();
public void Visit(TextNode node) => Lines.Add(node.Value);
public void Visit(ImageNode node) => Lines.Add("[image: " + node.Name + "]");
}
sealed class CountVisitor : IVisitor
{
public int Texts { get; private set; }
public int Images { get; private set; }
public void Visit(TextNode node) => Texts++;
public void Visit(ImageNode node) => Images++;
}
hello
[image: cover]호출부의 node는 IDocNode 타입이어서 Visit(node)로는 TextNode·ImageNode용 메서드를 고를 수 없다. 각 Accept()가 자기 구체 타입의 this를 Visit()에 전달해 해당 처리를 연결하므로 호출부가 요소마다 분기할 필요가 없다.
범위 넓힌 예제
요소 클래스는 그대로 두고 CountVisitor를 적용해 텍스트 노드와 이미지 노드 수를 센다.
using System;
using System.Collections.Generic;
using System.Linq;
IDocNode[] nodes = { new TextNode("hello"), new ImageNode("cover"), new TextNode("world") };
var visitor = new CountVisitor();
foreach (IDocNode node in nodes) node.Accept(visitor);
Console.WriteLine("text nodes: " + visitor.Texts);
Console.WriteLine("image nodes: " + visitor.Images);
interface IDocNode { void Accept(IVisitor visitor); }
// 연산 하나에 필요한 요소별 처리를 방문자 한곳에 모은다.
interface IVisitor
{
void Visit(TextNode node);
void Visit(ImageNode node);
}
// 노드는 자기 종류에 맞는 방문자 메서드를 연결하며 연산 자체는 맡지 않는다.
sealed record TextNode(string Value) : IDocNode
{
public void Accept(IVisitor visitor) => visitor.Visit(this);
}
sealed record ImageNode(string Name) : IDocNode
{
public void Accept(IVisitor visitor) => visitor.Visit(this);
}
sealed class TextVisitor : IVisitor
{
public List<string> Lines { get; } = new();
public void Visit(TextNode node) => Lines.Add(node.Value);
public void Visit(ImageNode node) => Lines.Add("[image: " + node.Name + "]");
}
sealed class CountVisitor : IVisitor
{
public int Texts { get; private set; }
public int Images { get; private set; }
public void Visit(TextNode node) => Texts++;
public void Visit(ImageNode node) => Images++;
}
text nodes: 2
image nodes: 1순회는 호출부의 foreach가 맡고, 방문자는 각 요소에 할 일을 맡는다. Composite 트리에 붙일 때도 자식 순회 책임을 어디에 둘지 먼저 정해야 중복 방문을 피할 수 있다.
TypeScript 예제
노드는 자기 종류를 방문자에게 연결하고, 방문자는 출력·집계 같은 연산을 맡는다. 새 연산을 넣으려고 모든 노드 클래스에 메서드를 늘리지 않도록 나눈다.
interface IDocNode { accept(visitor: IVisitor): void; }
// 연산 하나에 필요한 요소별 처리를 방문자 한곳에 모은다.
interface IVisitor {
visitText(node: TextNode): void;
visitImage(node: ImageNode): void;
}
// 노드는 자기 종류에 맞는 방문자 메서드를 연결하며 연산 자체는 맡지 않는다.
class TextNode implements IDocNode {
constructor(readonly text: string) {}
accept(visitor: IVisitor): void { visitor.visitText(this); }
}
class ImageNode implements IDocNode {
constructor(readonly caption: string) {}
accept(visitor: IVisitor): void { visitor.visitImage(this); }
}
class TextVisitor implements IVisitor {
readonly lines: string[] = [];
visitText(node: TextNode): void { this.lines.push(node.text); }
visitImage(node: ImageNode): void { this.lines.push("[image: " + node.caption + "]"); }
}
class CountVisitor implements IVisitor {
texts = 0;
images = 0;
visitText(node: TextNode): void { this.texts++; }
visitImage(node: ImageNode): void { this.images++; }
}
const nodes: IDocNode[] = [new TextNode("hello"), new ImageNode("cover"), new TextNode("world")];
const render = new TextVisitor();
const count = new CountVisitor();
for (const node of nodes) { node.accept(render); node.accept(count); }
for (const line of render.lines) console.log(line);
console.log("text nodes: " + count.texts);
console.log("image nodes: " + count.images);hello
[image: cover]
world
text nodes: 2
image nodes: 1TypeScript의 오버로드 선언은 타입 검사에 쓰이며 실제 함수 구현은 하나다. 여기서는 C#의 Visit(TextNode)·Visit(ImageNode)를 visitText·visitImage로 나누고 각 accept()에서 맞는 쪽을 부른다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.