비지터 패턴
"오늘의AI위키"의 AI를 통해 더욱 풍부하고 폭넓은 지식 경험을 누리세요.
1. 개요
비지터 패턴은 객체 구조에 새로운 연산을 추가할 때 유용하게 사용되는 디자인 패턴이다. 이 패턴은 객체 구조와 연산을 분리하여 코드의 유연성과 재사용성을 높이며, 객체의 동적 타입과 비지터의 동적 타입 모두에 기반하여 실행될 메서드를 결정하는 더블 디스패치를 사용한다. 비지터 패턴은 C++, Java, C# 등 다양한 프로그래밍 언어에서 구현될 수 있으며, 객체 구조의 변경 없이 새로운 연산을 추가할 수 있게 해준다. CAD 시스템 설계, pretty-printer 구현 등 다양한 분야에서 활용되며, 상태 유지를 통해 이전 액션에 의존하는 액션을 수행할 수 있다. 반복자 패턴 및 처치 인코딩과 관련이 있다.
더 읽어볼만한 페이지
- 소프트웨어 디자인 패턴 - 모델-뷰-컨트롤러
모델-뷰-컨트롤러(MVC)는 소프트웨어 디자인 패턴으로, 응용 프로그램을 모델, 뷰, 컨트롤러 세 가지 요소로 분리하여 개발하며, 사용자 인터페이스 개발에서 데이터, 표현 방식, 사용자 입력 처리를 분리해 유지보수성과 확장성을 높이는 데 기여한다. - 소프트웨어 디자인 패턴 - 스케줄링 (컴퓨팅)
스케줄링은 운영 체제가 시스템의 목적과 환경에 맞춰 작업을 관리하는 기법으로, 장기, 중기, 단기 스케줄러를 통해 프로세스를 선택하며, CPU 사용률, 처리량 등을 기준으로 평가하고, FCFS, SJF, RR 등의 알고리즘을 사용한다.
비지터 패턴 |
---|
2. 역사
비지터 패턴[1]은 재사용 가능한 객체 지향 소프트웨어 설계를 위한 23가지 잘 알려진 디자인 패턴 중 하나이다. 이 패턴은 객체 구조를 이루는 클래스들을 변경하지 않고도 새로운 연산을 추가해야 할 필요성에서 비롯되었다.
새로운 연산이 자주 필요하거나 객체 구조가 복잡할 때, 각 연산을 해당 클래스들에 직접 추가하는 방식은 시스템을 이해하고 유지보수하기 어렵게 만든다.[1] 비지터 패턴은 이러한 문제를 해결하기 위해 제안되었다. GoF는 비지터 패턴을 "객체 구조의 요소에 대해 수행할 작업을 나타냅니다. 방문자를 사용하면 작동하는 요소의 클래스를 변경하지 않고도 새 작업을 정의할 수 있습니다."라고 정의했다.
3. 구조
객체 구조의 요소에 대해 수행할 작업을 나타냅니다. 방문자를 사용하면 작동하는 요소의 클래스를 변경하지 않고도 새 작업을 정의할 수 있습니다.
비지터 패턴의 핵심 아이디어는 객체 구조를 구성하는 요소(클래스)들과 해당 요소들에 대해 수행할 연산(작업)을 분리하는 것이다. 이를 위해 객체 구조의 각 요소에 대해 수행할 연산을 별도의 '방문자(Visitor)' 객체에 구현한다.
클라이언트는 객체 구조를 순회하면서 각 요소에 대해 방문자 객체를 인수로 전달하며 `accept(visitor)` 메서드를 호출한다. 그러면 해당 요소의 `accept()` 메서드는 전달받은 방문자 객체의 `visit()` 메서드를 호출하면서 자기 자신(요소 객체)을 인수로 넘긴다. 이 과정을 통해 방문자 객체는 해당 요소를 "방문"하고 정의된 연산을 수행하게 된다.
이러한 방식은 다음과 같은 주요 구성 요소로 이루어진다.
이 패턴은 단일 디스패치를 지원하는 일반적인 객체 지향 프로그래밍 언어(예: C++, Java, Smalltalk, Objective-C, Swift, JavaScript, Python, C#)에서 효과적으로 이중 디스패치를 구현하는 방법이다. 즉, 실제로 실행될 연산(`visit` 메서드)이 방문자(Visitor)의 동적 타입과 요소(Element)의 동적 타입 모두에 의해 결정된다. Common Lisp와 같이 다중 디스패치를 지원하는 언어에서는 함수 오버로딩 등을 통해 더 간단하게 구현될 수도 있다.
이 구조 덕분에 기존의 요소 클래스 코드를 변경하지 않고도 새로운 연산을 정의한 새로운 방문자 클래스를 추가하여 기능을 확장할 수 있다.[2] 자세한 인터페이스 정의와 클래스 간의 관계는 아래의 Visitor 인터페이스, Element 인터페이스, 클래스 다이어그램 섹션에서 확인할 수 있다.
3. 1. Visitor 인터페이스
Visitor 인터페이스는 방문할 각 Element 클래스 타입에 대해 하나씩, 해당 Element 객체를 인수로 받는 `'visit()'` 메서드를 선언한다. 예를 들어, 만약 `Wheel`, `Engine`, `Body`라는 세 가지 종류의 Element 클래스가 있다면, Visitor 인터페이스는 각각에 해당하는 `'visit()'` 메서드를 정의해야 한다.
이 인터페이스를 구현하는 구체적인 Visitor 클래스는 각 `'visit()'` 메서드 내에서 해당 Element에 대해 수행할 특정 작업을 정의한다. 이를 통해 객체 구조를 순회하면서 각 요소에 필요한 연산을 적용할 수 있다. Visitor 객체는 이러한 `'visit()'` 메서드들을 통해 주된 기능을 수행하며, 이는 함수 객체와 유사한 역할을 한다고 볼 수도 있다.[2]
아래는 Java로 작성된 Visitor 인터페이스의 간단한 예시이다.
```java
interface Visitor {
void visit(Wheel wheel); // Wheel 객체를 방문했을 때 호출될 메서드
void visit(Engine engine); // Engine 객체를 방문했을 때 호출될 메서드
void visit(Body body); // Body 객체를 방문했을 때 호출될 메서드
// 필요에 따라 복합 객체나 다른 Element 타입을 위한 visit 메서드를 추가할 수 있다.
void visitCar(Car car);
void visitVehicle(Vehicle vehicle);
}
```
이처럼 각기 다른 Element 타입을 처리하는 `'visit()'` 메서드를 여러 개 정의하는 것은 Visitor 패턴이 이중 디스패치를 효과적으로 구현하는 방식의 핵심이다. 즉, 실행될 연산(Visitor의 `'visit'` 메서드)이 Visitor 객체의 타입과 Element 객체의 타입 모두에 따라 결정된다.
3. 2. Element 인터페이스
'엘리먼트(Element)'는 비지터 패턴에서 방문의 대상이 되는 객체를 나타내는 인터페이스 또는 추상 클래스이다. 이 인터페이스는 방문자(Visitor) 객체를 인수로 받는 'accept' 메서드를 선언하는 핵심적인 역할을 수행한다.
각각의 구체적인 엘리먼트 클래스(예: 자동차를 구성하는 '휠(Wheel)', '엔진(Engine)', '바디(Body)' 등)는 이 엘리먼트 인터페이스를 상속받아 구현한다. 구체적인 엘리먼트 클래스는 'accept' 메서드를 구현할 때, 인수로 전달받은 방문자 객체의 'visit' 메서드를 호출한다. 이 과정에서 자기 자신('this')을 'visit' 메서드의 인수로 넘겨주어, 방문자가 현재 방문 중인 엘리먼트의 정확한 타입을 파악하고 그에 맞는 작업을 수행할 수 있도록 한다.
예를 들어, 'Wheel' 클래스의 'accept(Visitor v)' 메서드는 내부적으로 'v.visit(this)'를 호출하며, 이때 'this'는 'Wheel' 객체를 가리킨다. 마찬가지로 'Engine' 클래스의 'accept(Visitor v)' 메서드는 'v.visit(this)'를 호출하며, 여기서 'this'는 'Engine' 객체를 의미한다.
만약 엘리먼트가 다른 여러 엘리먼트를 자식으로 포함하는 컴포지트 구조(예: '자동차(Car)' 객체가 '엔진', '바디', 여러 개의 '휠' 객체를 포함하는 경우)를 가지고 있다면, 해당 컴포지트 엘리먼트의 'accept' 메서드는 일반적으로 자신이 포함하고 있는 각 자식 엘리먼트의 'accept' 메서드를 차례대로 호출한다. 이를 통해 방문자는 객체 구조 전체를 효과적으로 순회하며 작업을 수행할 수 있다.
3. 3. 클래스 다이어그램
UML 클래스 다이어그램은 비지터 패턴의 구조를 시각적으로 보여준다. 이 패턴의 핵심 아이디어는 객체 구조(Element)와 해당 구조의 요소에 대해 수행할 연산(Visitor)을 분리하는 것이다.
클라이언트는 객체 구조를 순회하면서 각 요소에 대해 `accept(visitor)` 메서드를 호출한다. 그러면 해당 요소의 `accept()` 메서드는 전달받은 방문자 객체의 적절한 `visit()` 메서드를 호출한다. 예를 들어, `ElementA` 객체의 `accept(visitor)`가 호출되면, 이 메서드는 내부적으로 `visitor.visitElementA(this)`를 호출한다. 이처럼 실제 처리될 연산이 Element의 타입과 Visitor의 타입 두 가지에 의해 결정되므로, 이를 더블 디스패치라고 부른다.[4]
이 방식을 통해 기존의 객체 구조 코드를 수정하지 않고도 새로운 연산(새로운 ConcreteVisitor 클래스)을 쉽게 추가할 수 있다. Visitor 패턴은 객체 구조와 연산을 분리하여 시스템의 확장성을 높이는 데 유용하다.
4. 상세
비지터 패턴은 GoF가 정의한 23가지 디자인 패턴 중 하나로[1], 객체 구조를 이루는 요소들에 대해 수행할 작업을 표현하는 방법을 제공한다. 이 패턴의 핵심은 작업을 수행할 요소의 클래스를 변경하지 않고도 새로운 작업을 정의할 수 있게 해준다는 점이다.[2] GoF는 이를 다음과 같이 설명한다.
객체 구조의 요소에 대해 수행할 작업을 나타냅니다. 방문자를 사용하면 작동하는 요소의 클래스를 변경하지 않고도 새 작업을 정의할 수 있습니다.
비지터 패턴은 특히 공개 API에 적용하기 좋은데, 클라이언트가 기존 클래스의 소스 코드를 수정하지 않고도 '방문' 클래스를 통해 새로운 작업을 추가할 수 있기 때문이다.[2] 이 패턴을 구현하기 위해서는 단일 디스패치를 지원하는 프로그래밍 언어가 필요하며, C++, Java, Smalltalk, Objective-C, Swift, JavaScript, Python, C#과 같은 대부분의 객체 지향 언어가 이 조건을 만족한다.
비지터 패턴은 주로 다음과 같은 요소들로 구성된다.
- Visitor (비지터): 각 엘리먼트 클래스에 대해 해당 엘리먼트를 인수로 받는 `visit` 메서드를 선언하는 인터페이스이다.
- ConcreteVisitor (구체적인 비지터): Visitor 인터페이스를 구현하며, 객체 구조를 순회하며 수행할 실제 알고리즘의 일부를 `visit` 메서드 안에 구현한다. 알고리즘의 상태는 이 구체적인 비지터 클래스 내에 저장된다.
- Element (엘리먼트): 비지터를 인수로 받아들이는 `accept` 메서드를 선언하는 인터페이스이다.
- ConcreteElement (구체적인 엘리먼트): Element 인터페이스를 구현하며, `accept` 메서드 안에서 전달받은 비지터의 `visit` 메서드를 호출한다. 이때 자기 자신(this)을 인수로 넘겨준다. 만약 엘리먼트가 컴포지트 구조(예: 트리 구조)를 가진다면, `accept` 메서드는 일반적으로 하위 엘리먼트들을 순회하며 각 하위 엘리먼트의 `accept` 메서드를 호출한다.
- Client (클라이언트): 객체 구조(엘리먼트들의 집합)를 생성하고, 구체적인 비지터를 인스턴스화한다. 특정 작업을 수행하기 위해 객체 구조의 최상위 엘리먼트의 `accept` 메서드를 호출하며 비지터를 전달한다.
프로그램 실행 중 엘리먼트의 `accept` 메서드가 호출되면, 어떤 `accept` 메서드 구현이 실행될지는 엘리먼트의 동적 타입(실제 객체의 타입)과 비지터의 정적 타입(코드 상에 명시된 타입)에 따라 결정된다(단일 디스패치). `accept` 메서드 내부에서 비지터의 `visit` 메서드를 호출할 때는, 비지터의 동적 타입과 엘리먼트의 동적 타입(accept 메서드 내에서는 이미 알려져 있음) 모두에 기반하여 적절한 `visit` 메서드 구현이 선택된다.
결과적으로, 최종적으로 실행되는 `visit` 메서드는 엘리먼트의 동적 타입과 비지터의 동적 타입이라는 두 가지 요소에 의해 결정된다. 이는 효과적으로 이중 디스패치(Double Dispatch)를 구현하는 방식이다. 컴파일 시점에 비지터가 주어진 엘리먼트 타입을 처리할 수 없는 경우, 컴파일러가 오류를 감지할 수도 있다.
Common Lisp처럼 다중 디스패치를 네이티브로 지원하는 언어나, DLR을 통해 C#에서 다중 디스패치를 사용하는 경우에는 비지터 패턴 구현이 훨씬 간단해질 수 있다. 단순히 함수 오버로딩만으로도 다양한 엘리먼트 타입에 대한 방문 처리를 구현할 수 있기 때문이다(동적 비지터). 이러한 동적 비지터 방식은 공개된 데이터에 대해서만 작동할 경우, 기존 구조를 수정하지 않으므로 개방/폐쇄 원칙을 준수하고, 비지터 로직을 별도 컴포넌트로 분리하므로 단일 책임 원칙도 잘 지킬 수 있다.
요약하자면, 비지터 패턴은 객체 구조를 순회하는 알고리즘(주로 `accept` 메서드에 구현됨)과 각 요소에 대해 수행할 작업(주로 `visit` 메서드에 구현됨)을 분리하는 방법이다. 이를 통해 객체 구조는 그대로 유지하면서 다양한 종류의 비지터를 만들어 새로운 기능을 쉽게 추가할 수 있다. 비지터는 여러 개의 특화된 `visit` 메서드를 가진 일종의 함수 객체(Functor)로 볼 수 있으며, `accept` 메서드는 이 함수 객체를 각 요소에 적용하는 역할을 한다.
5. 장점
비지터 패턴의 주요 장점은 객체 구조의 클래스를 변경하지 않고도 새로운 연산을 정의할 수 있다는 점이다.[1] 이는 새로운 연산이 자주 필요할 때 특히 유용하며, 각 클래스에 새로운 메서드를 추가하거나 하위 클래스를 만드는 방식보다 유연하다.
방문자 패턴은 다음과 같은 상황에서 장점을 발휘한다:
- 객체 구조에 대해 서로 관련 없는 다양한 연산이 필요한 경우: 연산 로직을 방문자 클래스로 분리하여 객체 구조를 단순하게 유지하고, 코드의 이해와 유지보수를 용이하게 한다.[1] 이는 관심사의 분리 원칙을 강화하는 효과를 가져온다.
- 새로운 연산을 자주 추가해야 하는 경우: 기존 클래스 코드를 수정하지 않고 새로운 방문자 클래스를 추가하는 것만으로 새로운 기능을 구현할 수 있다.
- 알고리즘이 객체 구조 내의 여러 클래스와 관련되지만, 해당 로직을 한 곳에서 관리하고 싶을 경우: 방문자 클래스에 알고리즘을 집중시켜 관리의 효율성을 높인다.
- 알고리즘이 여러 독립적인 클래스 계층 구조에 걸쳐 작동해야 할 경우: 방문자를 통해 다양한 객체 구조를 일관된 방식으로 처리할 수 있다.
또한, 방문자 패턴은 상태를 가질 수 있다는 장점이 있다. 이는 연산 수행 중에 필요한 정보를 방문자 객체 내에 저장하고 활용할 수 있게 해주어, 단순한 다형성 메서드 호출만으로는 구현하기 어려운 복잡한 로직 처리를 가능하게 한다. 예를 들어, 컴파일러나 인터프리터가 코드를 보기 좋게 출력(prettyprinting)하는 기능을 구현할 때, 현재 들여쓰기 수준과 같은 상태 정보를 방문자 내에서 관리하며 처리할 수 있다.
6. 단점
비지터 패턴의 주요 단점은 객체 구조를 이루는 클래스 계층의 확장을 어렵게 만든다는 점이다. 새로운 종류의 요소(Element) 클래스를 추가해야 할 경우, 기존의 모든 방문자(Visitor) 클래스에 해당 새로운 클래스를 처리하기 위한 'visit' 메서드를 추가해야 할 수 있다. 이는 특히 방문자 클래스가 많을 경우 상당한 수정 작업을 요구하게 된다.
7. 응용
비지터 패턴은 특정 상황에서 특히 유용하게 사용될 수 있다.
- 객체 구조에 대해 서로 관련 없는 다양한 연산이 필요할 때
- 객체 구조를 이루는 클래스들이 이미 정해져 있고 크게 변하지 않을 것으로 예상될 때
- 새로운 연산을 자주 추가해야 할 필요가 있을 때
- 알고리즘이 객체 구조 내 여러 클래스에 걸쳐 있지만, 이를 한 곳에서 관리하고 싶을 때
- 알고리즘이 서로 독립적인 여러 클래스 계층 구조에 걸쳐 작동해야 할 때
예를 들어, 2차원 컴퓨터 지원 설계(CAD) 시스템을 생각해 볼 수 있다. 이 시스템은 원, 선, 호와 같은 기본적인 도형들을 다루며, 이 도형들은 레이어별로 정리된다. 최상위에는 이러한 레이어 목록과 추가 속성을 가진 드로잉 객체가 있다.
이 CAD 시스템의 기본적인 기능 중 하나는 드로잉을 파일로 저장하는 것이다. 각 도형 클래스에 저장 메서드를 직접 추가하는 방법도 있지만, 여러 다른 파일 형식(예: PNG, SVG 등)으로 저장하는 기능이 필요해지면 문제가 복잡해진다. 각 파일 형식에 맞는 저장 메서드를 모든 도형 클래스에 추가하다 보면, 원래의 순수한 기하학적 데이터 구조가 금방 복잡해지고 관리하기 어려워진다.
이를 해결하기 위한 단순한 방법은 각 파일 형식마다 별도의 저장 함수를 만드는 것이다. 이 함수는 드로잉 데이터를 받아 순회하며 특정 파일 형식으로 변환한다. 하지만 이 방식은 새로운 파일 형식을 추가할 때마다 비슷한 코드가 중복해서 만들어지는 단점이 있다. 예를 들어, 원 도형을 어떤 종류의 래스터 이미지 형식으로 저장하든 코드는 매우 유사할 것이며, 다른 도형(선, 다각형 등)을 저장하는 코드와는 다를 것이다. 결과적으로 코드는 객체 구조를 순회하는 큰 반복문 안에 객체 유형을 확인하는 복잡한 조건문이 들어가는 형태가 되기 쉽다. 또한, 새로운 도형이 추가되었을 때 특정 파일 형식의 저장 함수에서 이를 누락하거나, 일부 파일 형식에 대해서만 구현하는 등 코드 확장 및 유지보수에 어려움이 발생하기 쉽다.
이런 문제를 해결하기 위해 비지터 패턴을 적용할 수 있다. 비지터 패턴은 파일 저장과 같은 논리적인 연산을 '방문자(Visitor)' 클래스(예: `Saver`)로 분리한다. 이 방문자 클래스는 객체 구조를 순회하는 공통 로직을 가지며, 각 도형 타입별(원, 사각형 등) 실제 저장 로직은 가상 메서드(예: `save_circle`, `save_square`)를 통해 구현된다. 특정 파일 형식(예: PNG)을 위한 구체적인 저장 방식은 방문자 클래스를 상속받은 하위 클래스(예: `SaverPNG`)에서 구현한다.
이 방식을 사용하면, 객체 순회 로직과 각 도형 및 파일 형식별 저장 로직이 분리되어 코드 중복이 제거된다. 또한, 새로운 도형 타입이 추가되었을 때 모든 방문자 클래스(각 파일 형식 저장 클래스)에 해당 도형을 처리하는 메서드를 추가해야 하므로, 컴파일러가 특정 도형에 대한 처리 누락을 오류로 알려줄 수 있어 유지보수가 용이해진다.
하지만 비지터 패턴은 클래스 계층 구조를 확장하기 어렵게 만드는 단점도 있다. 새로운 종류의 도형 클래스를 추가하려면, 기존의 모든 방문자 클래스에 해당 도형을 처리하는 새로운 `visit` 메서드를 추가해야 하기 때문이다.
7. 1. 반복 작업
비지터 패턴은 이터레이터 패턴과 유사하게 컨테이너와 같은 자료 구조를 반복하는 데 사용될 수 있지만, 기능에는 일부 제한이 있다.[3] 예를 들어, 디렉토리 구조를 탐색하며 각 파일이나 폴더에 특정 작업을 수행해야 할 때, 전통적인 루프 방식 대신 비지터 패턴을 활용할 수 있다. 이는 각기 다른 작업을 수행하는 여러 '방문자(visitor)' 클래스를 만들어 두고, 디렉토리 구조를 순회하면서 필요한 방문자를 호출하는 방식이다.이 방식의 장점은 반복 로직(디렉토리 순회 방법)과 각 항목에 대한 실제 처리 로직(방문자의 작업 내용)을 분리하여 코드의 재사용성을 높일 수 있다는 점이다. 즉, 디렉토리 구조를 순회하는 코드는 한 번만 작성하고, 다양한 종류의 정보(예: 파일 크기 합계, 특정 확장자 파일 목록 등)를 얻기 위한 방문자 클래스만 추가하면 된다. 이러한 방식은 Smalltalk 시스템에서 널리 사용되었고 C++에서도 찾아볼 수 있다.
하지만 단점도 존재한다. 일반적인 루프문처럼 특정 조건에서 반복을 중간에 빠져나오거나, 두 개의 다른 자료 구조를 동시에 같은 인덱스(예: 변수 i 하나로 두 리스트를 동시에 접근하는 방식)로 접근하며 반복하는 것이 까다롭다. 이러한 기능을 구현하려면 비지터 패턴 자체에 추가적인 로직을 구현해야 하는 번거로움이 있다.
비지터 패턴의 핵심 아이디어는 다음과 같다. 처리해야 할 각기 다른 종류의 요소(예: 파일, 폴더)를 나타내는 클래스들이 있고, 이 클래스들은 자신을 방문할 '방문자' 객체를 받아들이는 `accept` 메서드를 가진다. '방문자'는 인터페이스로 정의되며, 방문할 수 있는 모든 종류의 요소 각각에 대해 `visit` 메서드를 가진다. 실제 처리를 수행하는 구체적인 방문자 클래스들은 이 인터페이스를 구현한다. 이때, 특정 `visit` 메서드는 단순히 하나의 클래스에 속한 메서드가 아니라, '특정 방문자'와 '특정 요소 클래스'라는 두 객체의 조합에 따라 실행되는 메서드로 볼 수 있다. 이런 특징 때문에 비지터 패턴은 더블 디스패치 메커니즘을 Java, Smalltalk, C++와 같은 일반적인 객체 지향 프로그래밍 언어에서 흉내 내는 방법으로 여겨진다. (더블 디스패치와 함수 오버로딩의 차이에 대해서는 영어 위키백과의 'Double dispatch is more than function overloading' 문서를 참조할 수 있다.) Java에서는 리플렉션 기술을 사용하여 비지터 패턴 구현을 더 간결하게 만드는 방법들이 연구되기도 했다. (https://web.archive.org/web/20160304045900/http://www.cs.ucla.edu/~palsberg/paper/compsac98.pdf `accept` 메서드 제거 기법, http://www.javaworld.com/javaworld/javatips/jw-javatip98.html `visit` 메서드 중복 제거 기법)
비지터 패턴은 객체 구조를 어떻게 반복할 것인지를 정의한다. 가장 기본적인 형태에서는, 각 요소의 `accept` 메서드가 방문자의 해당 `visit` 메서드를 호출하고, 동시에 방문자 객체를 자신의 자식 요소들에게 전달하여 재귀적으로 순회가 이루어지도록 한다.
방문자 객체는 주로 `visit`라는 이름의 메서드를 여러 개 가지며 (각 요소 타입별로 하나씩), 각 메서드가 특정 요소 타입에 대한 실제 작업을 정의한다. 이런 점에서 방문자는 함수 객체 또는 펑터와 유사하다고 볼 수 있다. 반면, 요소의 `accept` 메서드는 마치 특정 종류의 객체들을 순회하며 각 요소에 함수(방문자의 `visit` 메서드)를 적용하는 방법을 알고 있는 '이터레이터'나 '매퍼'와 같은 역할을 한다고 볼 수 있다. Common Lisp와 같이 다중 디스패치를 직접 지원하는 언어에서는 비지터 패턴을 더 간결하게 구현할 수 있지만, 다중 디스패치가 비지터 패턴 자체를 대체하는 것은 아니다.
8. 예제
비지터 패턴은 객체 지향 프로그래밍에서 널리 사용되며, 다양한 프로그래밍 언어로 구현될 수 있다. 각 언어의 특징에 따라 구현 방식에 차이가 있을 수 있다.
Smalltalk에서는 객체 스스로가 스트림에 자신을 출력하는 책임을 가지는 방식으로 구현될 수 있다. 아래는 Smalltalk 예제 코드이다.
"클래스를 생성하는 구문은 없습니다. 클래스는 다른 클래스에 메시지를 보내서 생성됩니다."
WriteStream 서브클래스: #ExpressionPrinter
인스턴스변수이름: ''
클래스변수이름: ''
패키지: '위키백과'.
ExpressionPrinter>>write: anObject
"객체에 작업을 위임합니다. 객체는 특별한 클래스일 필요가 없습니다. #putOn: 메시지를 이해할 수만 있으면 됩니다."
anObject putOn: self.
^ anObject.
Object 서브클래스: #Expression
인스턴스변수이름: ''
클래스변수이름: ''
패키지: '위키백과'.
Expression 서브클래스: #Literal
인스턴스변수이름: 'value'
클래스변수이름: ''
패키지: '위키백과'.
Literal 클래스>>with: aValue
"Literal 클래스의 인스턴스를 생성하기 위한 클래스 메서드"
^ self new
value: aValue;
yourself.
Literal>>value: aValue
"value에 대한 Setter"
value := aValue.
Literal>>putOn: aStream
"Literal 객체는 자신을 어떻게 출력할지 알고 있습니다"
aStream nextPutAll: value asString.
Expression 서브클래스: #Addition
인스턴스변수이름: 'left right'
클래스변수이름: ''
패키지: '위키백과'.
Addition 클래스>>left: a right: b
"Addition 클래스의 인스턴스를 생성하기 위한 클래스 메서드"
^ self new
left: a;
right: b;
yourself.
Addition>>left: anExpression
"left에 대한 Setter"
left := anExpression.
Addition>>right: anExpression
"right에 대한 Setter"
right := anExpression.
Addition>>putOn: aStream
"Addition 객체는 자신을 어떻게 출력할지 알고 있습니다"
aStream nextPut: $(.
left putOn: aStream.
aStream nextPut: $+.
right putOn: aStream.
aStream nextPut: $).
Object 서브클래스: #Program
인스턴스변수이름: ''
클래스변수이름: ''
패키지: '위키백과'.
Program>>main
| expression stream |
expression := Addition
left: (Addition
left: (Literal with: 1)
right: (Literal with: 2))
right: (Literal with: 3).
stream := ExpressionPrinter on: (String new: 100).
stream write: expression.
Transcript show: stream contents.
Transcript flush.
Go 언어는 메서드 오버로딩을 지원하지 않기 때문에, 각 요소 타입을 위한 `visit` 메서드는 서로 다른 이름을 가져야 한다. 일반적인 Go에서의 방문자 인터페이스는 다음과 같은 형태를 띤다.
type Visitor interface {
visitWheel(wheel Wheel) string
visitEngine(engine Engine) string
visitBody(body Body) string
visitCar(car Car) string
}
Python 역시 메서드 오버로딩을 지원하지 않으므로, Go와 유사하게 각기 다른 요소 타입을 처리하는 "visit" 메서드는 고유한 이름을 가져야 한다.
Common Lisp와 같이 다중 디스패치를 지원하는 언어나, DLR을 통해 C#에서 다중 디스패치를 사용하는 경우, 비지터 패턴의 구현은 각 요소 타입에 대한 함수 오버로딩을 통해 크게 단순화될 수 있다.
C++, Java, C# 등 다른 주요 객체 지향 언어에서의 비지터 패턴 구현 예제는 아래 하위 섹션에서 자세히 다룬다.
8. 1. C++ 예제
아래는 비지터 패턴을 C++ 언어로 구현한 예제 코드이다. 이 코드는 요소(`Element`) 계층 구조와 방문자(`Visitor`) 계층 구조를 정의하고, 각 요소가 방문자를 받아들여(`accept`) 방문자가 해당 요소를 방문(`visit`)하도록 하는 더블 디스패치 메커니즘을 보여준다.먼저, 모든 구체적인 요소 클래스가 상속받을 `Element` 추상 클래스를 정의한다. 이 클래스는 순수 가상 함수인 `accept` 메서드를 선언하여, 모든 하위 클래스가 이를 구현하도록 강제한다. `accept` 메서드는 `Visitor` 객체를 인자로 받는다.
```cpp
#include
#include
using namespace std;
// Forward declaration
class Visitor;
// 1. 요소(Element) 계층 구조에 accept(Visitor) 메서드 추가
class Element
{
public:
virtual void accept(Visitor &v) = 0;
virtual ~Element() = default; // 가상 소멸자 추가 (메모리 누수 방지)
};
class This : public Element
{
public:
/*virtual*/ void accept(Visitor &v) override; // override 키워드 사용 권장
string thiss() // 함수 이름을 this에서 thiss로 변경 (this는 키워드)
{
return "This";
}
};
class That : public Element
{
public:
/*virtual*/ void accept(Visitor &v) override;
string that()
{
return "That";
}
};
class TheOther : public Element
{
public:
/*virtual*/ void accept(Visitor &v) override;
string theOther()
{
return "TheOther";
}
};
```
다음으로, 방문자(`Visitor`) 인터페이스(추상 클래스)를 정의한다. 이 클래스는 각 구체적인 요소 타입(`This`, `That`, `TheOther`)에 대응하는 `visit` 메서드를 순수 가상 함수로 선언한다.
```cpp
// 2. 각 요소 타입에 대한 visit() 메서드를 가진 Visitor 기반 클래스 생성
class Visitor
{
public:
virtual ~Visitor() = default; // 가상 소멸자 추가
virtual void visit(This *e) = 0;
virtual void visit(That *e) = 0;
virtual void visit(TheOther *e) = 0;
};
```
각 구체적인 요소 클래스(`This`, `That`, `TheOther`)는 `accept` 메서드를 구현한다. 이 메서드는 전달받은 `Visitor` 객체의 `visit` 메서드를 호출하면서, 자기 자신(`this`)을 인자로 전달한다.
```cpp
// 각 Element 클래스의 accept 메서드 구현
/*virtual*/ void This::accept(Visitor &v)
{
v.visit(this); // 방문자의 visit 메서드 호출 (자신을 인자로 전달)
}
/*virtual*/ void That::accept(Visitor &v)
{
v.visit(this);
}
/*virtual*/ void TheOther::accept(Visitor &v)
{
v.visit(this);
}
```
이제, 요소들에 대해 수행할 실제 작업을 정의하는 구체적인 방문자 클래스(`UpVisitor`, `DownVisitor`)를 생성한다. 각 방문자 클래스는 `Visitor` 인터페이스를 상속받아 모든 `visit` 메서드를 구현한다. 이 예제에서는 각 요소에 대해 "Up" 또는 "Down" 작업을 수행하는 것을 시뮬레이션하여 콘솔에 출력한다.
```cpp
// 3. 요소에 수행할 각 작업에 대한 Visitor 파생 클래스 생성
class UpVisitor : public Visitor
{
public: // public 접근 지정자 추가
/*virtual*/ void visit(This *e) override // override 키워드 사용 권장
{
cout << "do Up on " + e->thiss() << '\n'; // thiss()로 변경된 함수 호출
}
/*virtual*/ void visit(That *e) override
{
cout << "do Up on " + e->that() << '\n';
}
/*virtual*/ void visit(TheOther *e) override
{
cout << "do Up on " + e->theOther() << '\n';
}
};
class DownVisitor : public Visitor
{
public: // public 접근 지정자 추가
/*virtual*/ void visit(This *e) override
{
cout << "do Down on " + e->thiss() << '\n'; // thiss()로 변경된 함수 호출
}
/*virtual*/ void visit(That *e) override
{
cout << "do Down on " + e->that() << '\n';
}
/*virtual*/ void visit(TheOther *e) override
{
cout << "do Down on " + e->theOther() << '\n';
}
};
```
마지막으로, `main` 함수에서는 요소 객체들을 생성하고(원본 코드에서는 C-스타일 배열과 raw 포인터 사용), `UpVisitor`와 `DownVisitor` 객체를 생성한다. 그 후, 각 요소 객체의 `accept` 메서드를 호출하여 방문자를 전달한다. 이를 통해 각 요소에 대해 방문자가 정의한 작업이 수행된다.
```cpp
int main()
{
Element *list[] =
{
new This(), new That(), new TheOther()
};
UpVisitor up; // 4. 클라이언트는 Visitor 객체를 생성하고
DownVisitor down; // 각 요소의 accept() 메서드에 전달한다.
for (int i = 0; i < 3; i++)
{
list[i]->accept(up); // 각 요소가 UpVisitor를 받아들임
}
for (int i = 0; i < 3; i++) // 변수 i 재선언 오류 수정 (C++11 이상에서는 for 루프 내 선언 가능)
{
list[i]->accept(down); // 각 요소가 DownVisitor를 받아들임
}
// 메모리 누수 방지를 위해 할당된 메모리 해제
for (int i = 0; i < 3; ++i) {
delete list[i];
}
return 0; // main 함수 반환 타입 명시 및 값 반환
}
```
실행 결과:```text
do Up on This
do Up on That
do Up on TheOther
do Down on This
do Down on That
do Down on TheOther
```
이 예제는 비지터 패턴을 사용하여 객체 구조(Element)와 해당 구조에 적용될 작업(Visitor)을 분리하는 방법을 보여준다. 이를 통해 새로운 작업을 추가할 때 기존 요소 클래스를 수정하지 않고 새로운 방문자 클래스만 추가하면 되므로 개방-폐쇄 원칙(OCP)을 만족시키는 데 도움이 된다.
8. 2. Java 예제
다음은 자바 언어로 작성된 비지터 패턴 예제이다. 이 코드는 자동차의 각 부품(`Wheel`, `Engine`, `Body`, `Car`)을 나타내는 클래스와, 이 부품들을 방문하여 특정 작업을 수행하는 방문자(`CarElementVisitor`) 인터페이스를 정의한다.interface CarElementVisitor {
void visit(Wheel wheel);
void visit(Engine engine);
void visit(Body body);
void visit(Car car);
}
interface CarElement {
void accept(CarElementVisitor visitor); // 각 자동차 부품은 accept() 메서드를 제공해야 한다.
}
class Wheel implements CarElement {
private String name;
public Wheel(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public void accept(CarElementVisitor visitor) {
visitor.visit(this); // 방문자의 visit(Wheel) 메서드를 호출한다.
}
}
class Engine implements CarElement {
public void accept(CarElementVisitor visitor) {
visitor.visit(this); // 방문자의 visit(Engine) 메서드를 호출한다.
}
}
class Body implements CarElement {
public void accept(CarElementVisitor visitor) {
visitor.visit(this); // 방문자의 visit(Body) 메서드를 호출한다.
}
}
class Car implements CarElement{
CarElement[] elements;
public CarElement[] getElements() {
return elements.clone(); // 참조 배열의 복사본을 반환한다.
}
public Car() {
// 자동차를 구성하는 부품들을 생성한다.
this.elements = new CarElement[]
{ new Wheel("front left"), new Wheel("front right"),
new Wheel("back left") , new Wheel("back right"),
new Body(), new Engine() };
}
public void accept(CarElementVisitor visitor) {
// 모든 부품에 대해 accept 메서드를 호출한다.
for(CarElement element : this.getElements()) {
element.accept(visitor);
}
// 마지막으로 자동차 자체에 대한 visit 메서드를 호출한다.
visitor.visit(this);
}
}
// 각 부품의 이름을 출력하는 방문자 구현
class CarElementPrintVisitor implements CarElementVisitor {
public void visit(Wheel wheel) {
System.out.println("Visiting "+ wheel.getName() + " wheel");
}
public void visit(Engine engine) {
System.out.println("Visiting engine");
}
public void visit(Body body) {
System.out.println("Visiting body");
}
public void visit(Car car) {
System.out.println("Visiting car");
}
}
// 각 부품에 특정 동작을 수행하는 방문자 구현 (예: 시동 걸기)
class CarElementDoVisitor implements CarElementVisitor {
public void visit(Wheel wheel) {
System.out.println("Kicking my "+ wheel.getName() + " wheel");
}
public void visit(Engine engine) {
System.out.println("Starting my engine");
}
public void visit(Body body) {
System.out.println("Moving my body");
}
public void visit(Car car) {
System.out.println("Starting my car");
}
}
// 메인 실행 클래스
public class VisitorDemo {
static public void main(String[] args){
Car car = new Car();
// PrintVisitor를 사용하여 각 부품 이름을 출력한다.
car.accept(new CarElementPrintVisitor());
// DoVisitor를 사용하여 각 부품에 대한 동작을 수행한다.
car.accept(new CarElementDoVisitor());
}
}
이 예제는 자동차(`Car`) 객체를 생성하고, 두 종류의 방문자(`CarElementPrintVisitor`, `CarElementDoVisitor`)를 사용하여 자동차의 각 부품에 대해 서로 다른 작업을 수행하는 방법을 보여준다. `CarElementPrintVisitor`는 각 부품의 이름을 출력하는 작업을 수행하고, `CarElementDoVisitor`는 각 부품에 대해 "발로 차기", "시동 걸기" 등의 다른 작업을 수행한다.
각 부품 클래스(`Wheel`, `Engine`, `Body`, `Car`)는 `accept` 메서드를 구현하여 외부의 방문자 객체를 받아들인다. 방문자 객체는 `visit` 메서드를 통해 전달받은 부품 객체의 타입에 따라 적절한 작업을 수행한다. 이를 통해 기존 부품 클래스의 코드를 수정하지 않고도 새로운 작업을 추가할 수 있다.
위 코드의 실행 결과는 다음과 같다.
```text
Visiting front left wheel
Visiting front right wheel
Visiting back left wheel
Visiting back right wheel
Visiting body
Visiting engine
Visiting car
Kicking my front left wheel
Kicking my front right wheel
Kicking my back left wheel
Kicking my back right wheel
Moving my body
Starting my engine
Starting my car
8. 3. C# 예제
아래는 C#을 사용한 비지터 패턴의 예시이다. 이 예제는 수식(Expression)을 표현하고, 이를 방문하여 계산하거나 출력하는 방법을 보여준다.핵심 구성 요소는 다음과 같다.
- `Visitor` 인터페이스: 방문할 각 구체적인 요소(Literal, Addition)에 대한 `Visit` 메서드를 선언한다.
- `Expression` 추상 클래스: 모든 수식 요소의 기반 클래스이다. `Accept` 메서드와 `GetValue` 메서드를 추상 메서드로 선언하여 하위 클래스에서 구현하도록 강제한다.
- `Literal`: 숫자를 나타내는 구체적인 요소 클래스이다. `Accept` 메서드에서 전달받은 `Visitor`의 `Visit(Literal)` 메서드를 호출한다.
- `Addition`: 덧셈 연산을 나타내는 구체적인 요소 클래스이다. 왼쪽(Left)과 오른쪽(Right) 피연산자를 `Expression` 타입으로 가진다. `Accept` 메서드에서는 왼쪽과 오른쪽 피연산자의 `Accept` 메서드를 재귀적으로 호출한 후, 자신의 `Visit(Addition)` 메서드를 호출한다.
- `ExpressionPrintingVisitor`: 구체적인 방문자 클래스이다. `Visitor` 인터페이스를 구현하며, 각 `Visit` 메서드에서 해당 요소를 콘솔에 출력하는 로직을 담당한다.
이 예제는 출력을 담당하는 별도의 `ExpressionPrintingVisitor` 클래스를 선언한다. 새로운 연산(예: 다른 형식으로 출력, 타입 검사 등)을 추가하려면, `Visitor` 인터페이스를 구현하는 새로운 구체적인 방문자 클래스를 만들고 각 `Visit` 메서드에 대한 새로운 구현을 제공하면 된다. 기존의 `Expression` 관련 클래스들(`Literal`, `Addition`)은 수정할 필요가 없다. 이는 개방-폐쇄 원칙을 잘 보여주는 예이다.
```csharp
using System;
namespace Wikipedia;
// 방문자 인터페이스: 방문할 각 요소 타입에 대한 Visit 메서드를 정의한다.
public interface Visitor
{
void Visit(Literal literal); // Literal 요소를 방문하는 메서드
void Visit(Addition addition); // Addition 요소를 방문하는 메서드
}
// 구체적인 방문자: 수식의 구조와 값을 콘솔에 출력한다.
public class ExpressionPrintingVisitor : Visitor
{
// Literal 요소를 방문했을 때 호출된다.
public void Visit(Literal literal)
{
// 리터럴 값을 콘솔에 출력한다.
Console.WriteLine(literal.Value);
}
// Addition 요소를 방문했을 때 호출된다.
public void Visit(Addition addition)
{
// 왼쪽 피연산자, 오른쪽 피연산자, 그리고 덧셈 결과를 계산한다.
double leftValue = addition.Left.GetValue();
double rightValue = addition.Right.GetValue();
var sum = addition.GetValue(); // GetValue는 내부적으로 왼쪽과 오른쪽의 GetValue를 호출하여 합산한다.
// 계산된 값을 형식에 맞춰 콘솔에 출력한다.
Console.WriteLine("{0} + {1} = {2}", leftValue, rightValue, sum);
}
}
// 요소의 추상 클래스: 모든 수식 요소의 공통 기반이다.
public abstract class Expression
{
// 방문자를 받아들이는 추상 메서드. 하위 클래스에서 구현해야 한다.
public abstract void Accept(Visitor v);
// 수식의 값을 계산하는 추상 메서드. 하위 클래스에서 구현해야 한다.
public abstract double GetValue();
}
// 구체적인 요소: 숫자 리터럴을 나타낸다.
public class Literal : Expression
{
public Literal(double value)
{
this.Value = value;
}
public double Value { get; set; } // 리터럴이 나타내는 숫자 값
// 방문자를 받아들인다. 방문자의 Visit(Literal) 메서드를 호출하여 자신을 처리하도록 한다.
public override void Accept(Visitor v)
{
v.Visit(this);
}
// 리터럴의 값을 반환한다.
public override double GetValue()
{
return Value;
}
}
// 구체적인 요소: 덧셈 연산을 나타낸다.
public class Addition : Expression
{
public Addition(Expression left, Expression right)
{
Left = left; // 왼쪽 피연산자
Right = right; // 오른쪽 피연산자
}
public Expression Left { get; set; }
public Expression Right { get; set; }
// 방문자를 받아들인다. 왼쪽과 오른쪽 피연산자를 먼저 방문하게 하고, 그 다음 자신을 방문하도록 한다.
public override void Accept(Visitor v)
{
Left.Accept(v); // 왼쪽 피연산자가 방문자를 받아들이도록 한다.
Right.Accept(v); // 오른쪽 피연산자가 방문자를 받아들이도록 한다.
v.Visit(this); // 자기 자신(덧셈 연산)이 방문자에 의해 처리되도록 한다.
}
// 덧셈 결과를 계산하여 반환한다. 왼쪽과 오른쪽 피연산자의 GetValue()를 재귀적으로 호출한다.
public override double GetValue()
{
return Left.GetValue() + Right.GetValue();
}
}
// 프로그램 실행 예제
public static class Program
{
public static void Main(string[] args)
{
// 수식 트리 생성: (1 + 2) + 3
var e = new Addition( // 최상위 덧셈 노드
new Addition( // 왼쪽 피연산자 (또 다른 덧셈 노드)
new Literal(1), // 덧셈 노드의 왼쪽 피연산자 (리터럴)
new Literal(2) // 덧셈 노드의 오른쪽 피연산자 (리터럴)
),
new Literal(3) // 최상위 덧셈 노드의 오른쪽 피연산자 (리터럴)
);
// 출력 방문자 인스턴스 생성
var printingVisitor = new ExpressionPrintingVisitor();
// 생성된 수식 트리의 루트 노드에서 Accept 메서드를 호출하여 방문 시작
e.Accept(printingVisitor);
// Accept 메서드 호출 순서 (깊이 우선 탐색과 유사):
// 1. Addition((1+2)+3).Accept(visitor) 호출
// 2. Addition(1+2).Accept(visitor) 호출
// 3. Literal(1).Accept(visitor) 호출 -> visitor.Visit(Literal(1)) 실행 (출력: 1)
// 4. Literal(2).Accept(visitor) 호출 -> visitor.Visit(Literal(2)) 실행 (출력: 2)
// 5. visitor.Visit(Addition(1+2)) 실행 (출력: 1 + 2 = 3)
// 6. Literal(3).Accept(visitor) 호출 -> visitor.Visit(Literal(3)) 실행 (출력: 3)
// 7. visitor.Visit(Addition((1+2)+3)) 실행 (출력: 3 + 3 = 6)
Console.ReadKey(); // 사용자가 콘솔 출력을 확인할 수 있도록 잠시 대기
}
}
9. 상태 유지
비지터 패턴은 관심사의 분리를 더욱 발전시키는 것 외에도, 방문자 객체 내부에 상태를 가질 수 있다는 장점이 있다. 이는 객체에 대해 실행되는 작업이 이전에 수행된 작업의 결과에 영향을 받는 경우에 매우 유용하다.
예를 들어, 프로그래밍 언어의 컴파일러나 인터프리터에서 사용되는 pretty-printer를 구현할 때 비지터 패턴을 활용할 수 있다. pretty-printer는 파싱된 데이터 구조(예: 프로그램 트리)의 노드를 순회하면서 프로그램 코드의 텍스트 표현을 생성하는 역할을 한다. 이때 생성된 코드를 사람이 읽기 쉽게 만들기 위해서는 문장이나 표현식의 들여쓰기를 적절하게 조절해야 한다.
비지터 패턴을 사용하면, 방문자 객체(pretty-printer)가 '현재 들여쓰기 폭'과 같은 정보를 내부 상태로 유지하고 관리할 수 있다. 방문자가 트리를 순회하면서 노드의 깊이에 따라 들여쓰기 폭을 조절하고, 이를 다음 노드를 처리할 때 사용하는 방식이다. 만약 단순한 다형적 함수 호출을 사용한다면, 들여쓰기 폭과 같은 상태 정보를 함수의 매개변수로 계속 전달해야 하거나, 캡슐화 원칙을 어기고 외부에서 상태를 관리해야 할 수도 있다. 하지만 비지터 패턴은 이러한 상태 정보를 방문자 객체 내부에 자연스럽게 포함할 수 있어 코드를 더 깔끔하게 유지하는 데 도움이 된다.
10. 관련 디자인 패턴
참조
[1]
서적
Design Patterns: Elements of Reusable Object-Oriented Software
https://archive.org/[...]
Addison Wesley
[2]
웹사이트
Visitor pattern real-world example
https://coreycoogan.[...]
[3]
서적
An introduction to object-oriented programming
https://www.worldcat[...]
Addison-Wesley
1997
[4]
웹사이트
The Visitor design pattern - Structure and Collaboration
http://w3sdesign.com[...]
2017-08-12
[5]
서적
API design for C++
https://www.worldcat[...]
Morgan Kaufmann
2011
[6]
웹인용
The Visitor design pattern - Structure and Collaboration
http://w3sdesign.com[...]
2017-08-12
본 사이트는 AI가 위키백과와 뉴스 기사,정부 간행물,학술 논문등을 바탕으로 정보를 가공하여 제공하는 백과사전형 서비스입니다.
모든 문서는 AI에 의해 자동 생성되며, CC BY-SA 4.0 라이선스에 따라 이용할 수 있습니다.
하지만, 위키백과나 뉴스 기사 자체에 오류, 부정확한 정보, 또는 가짜 뉴스가 포함될 수 있으며, AI는 이러한 내용을 완벽하게 걸러내지 못할 수 있습니다.
따라서 제공되는 정보에 일부 오류나 편향이 있을 수 있으므로, 중요한 정보는 반드시 다른 출처를 통해 교차 검증하시기 바랍니다.
문의하기 : help@durumis.com