💡 비지터 패턴이란
방문자와 방문 공간을 분리해, 방문 공간이 방문자를 맞이할 때 이후 행동을 방문자에게 위임하는 패턴
알고리즘들을 그들이 작동하는 객체로부터 분리(캡슐화) 할 수 있도록 하는 행동 디자인 패턴입니다.
"나는 동물원에 간다. 나는 ~를 한다"
'나'라는 객체가 '동물원' 이라는 객체를 입력받은 후 동물원에서 무언가 한다는 건
일반적인 OOP 추상화입니다. 반면 비지터 패턴은
"동물원에 내가 갔다. 내가 ~를 하게 한다"
'동물원' 이라는 객체가 '나'라는 객체를 입력 받은 후, '나'라는 객체의 행동을 호출하는 것입니다.
이 때 동물원에 대한 정보를 파라미터로 넘겨줍니다.
즉, 사용자는 방문자의 입장이 아니라 방문 공간의 입장에서 먼저 생각해보게 됩니다.
예를 들어 동물원에 있는 사자, 원숭이, 코끼리 등 여러 동물들과 이 동물들을 보러오는 사람들이 있습니다.
이를테면 동물학자, 동물원 직원, 일반 관람객 등이 있는데, 이들은 각각 동물들을 보는 목적이 다릅니다.
이렇게 동물들과 방문객들 사이에 여러 상호작용이 있는데 만약 동물들 코드와 방문객 코드가 한데 섞여있다면,
코드가 매우 복잡해집니다.
비지터 패턴을 사용하면 동물들은 자신의 정보만 가지고 있고, 방문객들이 올 때마다 자신의 정보를 전달합니다.
방문객들은 각자의 목적에 맞게 동물들의 정보를 활용합니다.
이렇게 되면 새로운 동물이나 방문객이 추가되어도 쉽게 대응할 수 있습니다.
💡 비지터 패턴 기본 구조
비지터 패턴은 데이터 구조와 데이터 처리를 분리해주는 패턴입니다.
방문자 객체는 이질적인 대상 클래스로부터 정보를 얻기 위해 이중 디스패치를 이용합니다.
이중 디스패치란?
main에서 어떤 구현체의 accept가 호출될지 매핑하는 첫 번째 디스패치와
그 accept에서 받은 Visitor 구현체중 어떤 visit을 호출할 지 매핑하는 두 번째 디스패치로 이루어집니다.
어떤 구현체의 visit을 호출할 지 매핑한 후 오버로딩 중 무엇을 호출할 지 결정하게 됩니다.
(자세한 설명은 아래 더보기)
디스패치란? 어떤 메소드를 호출할 지 결정하고 실행하는 과정
어떤 메소드를 호출할 지는 메소드를 호출당하는 객체로부터 정해집니다.
A a = new A();
a.method();
A a = new B();
a.method();
위 아래 메소드는 다른 것이 호출됩니다.
자바 내부적으로 런타임에 두 a는 다른 오브젝트가 할당되고, 메소드 호출 시 그것이 receive parameter라는
매개변수 인자로 넘어가게 됩니다. 즉, 런타임에 두 메소드는 다른 것을 호출할 것임이 결정됩니다.
이처럼 런타임에 어떤 메소드를 호출할 지 결정되는 것을 동적 디스패치라고 하며,
컴파일 타임에 결정되는 것을 정적 디스패치라고 합니다.
위 설명에 따르면 오버라이딩은 실행 시점에 메서드 호출이 결정되는 동적 디스패치임이 분명합니다.
하지만 오버로딩은 예를 들어 calculate(int a, int b)와 calculate(double a, double b) 메서드가 있다면,
컴파일러는 컴파일 시점에 매개변수 타입을 보고 어떤 메서드를 호출할 지 결정하므로 정적 디스패치입니다.
메서드 시그니처(method signature)는 메서드를 유일하게 식별할 수 있는 특징입니다.
메서드 시그니처는 메서드의 이름과 매개변수 목록(매개변수의 개수, 타입, 순서)으로 구성됩니다.
하지만 리턴 타입은 메서드 시그니처에 포함되지 않습니다.
아래의 두 메서드 반환 값이 다르지만 리턴 타입이므로 시그니처는 동일합니다.
자바에서는 한 클래스 내에 메서드 시그니처가 동일한 두 개의 메서드를 가질 수 없습니다.
이를 메서드 오버로딩(method overloading)의 규칙이라고 합니다.
만약 위의 두 메서드를 같은 클래스에 작성하면 컴파일 오류가 발생합니다.
그 이유는 매개변수만으로 메서드를 구분할 수 없기 때문입니다.
자바 컴파일러는 매개변수만 보고 어떤 메서드를 호출해야 할지 결정해야 하는데,
시그니처가 동일하면 구분할 수 없게 됩니다.
int calculate(int a, int b) { ... }
double calculate(int a, int b) { ... }
이 두 메서드의 시그니처는 서로 다르기 때문에 오버로딩이 허용됩니다.
따라서 자바에서 메서드 시그니처는 메서드 이름과 매개변수 목록으로만 결정되며, 리턴 타입은 포함되지 않습니다.
그래서 리턴 타입이 다르더라도 매개변수 목록이 동일하다면 메서드 오버로딩이 불가능합니다.
int calculate(int a, int b) { ... }
double calculate(double a, double b) { ... }
오버라이딩은 상위 클래스 메서드를 하위 클래스에서 재정의하는 것으로, 실행 시점 동적으로 메서드 결정합니다.
오버로딩은 같은 이름 메서드를 매개 변수가 다르게 정의하는 것으로 컴파일 시점 정적으로 메서드를 결정합니다.
더블 디스패치란?
1. Animal 클래스에 performAnimalTask(AnimalTask task) 메서드가 있다고 가정합니다.
2. AnimalTask는 인터페이스이며 FeedTask, CleanTask가 이를 구현합니다.
3. Dog, Cat 클래스에서 performAnimalTask를 오버라이딩 했습니다.
4. Dog, Cat 객체를 Animal 타입 변수에 할당하고 performAnimalTask(new FeedTask())를 호출하면
다음의 과정이 일어납니다.
1. 첫 번째 디스패치 : 실제 객체 타입 (Dog or Cat)에 따라 performAnimalTask 메서드 결정
2. 두 번째 디스패치 : performAnimalTask 내부에서 AnimalTask 객체의 실제 타입에 따라 작업 수행
비지터 패턴에서는 동물원에 있는 동물들이 객체 구조라면 각 동물 클래스 accept(Visitor visitor) 메서드에서
첫 번째 동적 디스패치가 발생합니다.
그리고 Visitor 인터페이스에 visit(Animal animal), visit(Dog dog), visit(Cat, cat) 등의 메서드에서
두 번째 동적 디스패치가 발생합니다.
1. Visitor
방문자 클래스의 인터페이스입니다.
visit(Element) 메서드를 공용 인터페이스로 Element는 방문 공간을 의미합니다.
2. Element
방문 공간 클래스의 인터페이스입니다.
accept(Visitor) 메서드를 공용 인터페이스로 Visitor는 방문자를 의미합니다.
3. Concrete Visitor
Visitor 를 구체적으로 구현한 클래스입니다.
해당 클래스들에 맞춤으로 작성된 같은 행동들의 여러 버전을 구현합니다.
4. Concrete Element
Element 를 구체적으로 구현한 클래스입니다.
반드시 accept 메서드를 구현해야 하며, 이 메서드의 목적은
호출을 현재 요소 클래스에 해당하는 적절한 Visitor 메서드로 redirect 하는 것입니다.
💡 비지터 패턴 구현 예제
아래 예시는 여행사에서 여러 교통 수단을 이용하는 여행 상품을 판매하고,
각 교통 수단 별 요금 계산 방식이 다른 상황입니다.
1. Visitor
// 비지터(Visitor) 인터페이스
interface TravelVisitor {
void visit(Airplane airplane);
void visit(Train train);
// 다른 교통수단 추가 가능
}
2. Element
// 교통수단 인터페이스
interface Transportation {
void accept(TravelVisitor visitor);
}
3. Concrete Visitor
// 구체적인 비지터 클래스
class PriceCalculator implements TravelVisitor {
private double totalPrice = 0.0;
@Override
public void visit(Airplane airplane) {
totalPrice += airplane.getPrice() * 1.1; // 비행기 요금에 10% 추가
}
@Override
public void visit(Train train) {
totalPrice += train.getPrice() * 0.9; // 기차 요금에 10% 할인
}
public double getTotalPrice() {
return totalPrice;
}
}
4. Concrete Element
// 구체적인 교통수단 클래스들
class Airplane implements Transportation {
private double price;
private String flightNumber;
public Airplane(double price, String flightNumber) {
this.price = price;
this.flightNumber = flightNumber;
}
@Override
public void accept(TravelVisitor visitor) {
visitor.visit(this);
}
public double getPrice() {
return price;
}
public String getFlightNumber() {
return flightNumber;
}
}
class Train implements Transportation {
private double price;
private String route;
public Train(double price, String route) {
this.price = price;
this.route = route;
}
@Override
public void accept(TravelVisitor visitor) {
visitor.visit(this);
}
public double getPrice() {
return price;
}
public String getRoute() {
return route;
}
}
5. Client
public class VisitorPatternDemo {
public static void main(String[] args) {
List<Transportation> transportations = Arrays.asList(
new Airplane(500.0, "ABC123"),
new Train(100.0, "Seoul to Busan"),
new Airplane(300.0, "XYZ456")
);
TravelVisitor calculator = new PriceCalculator();
for (Transportation transportation : transportations) {
transportation.accept(calculator);
}
PriceCalculator priceCalculator = (PriceCalculator) calculator;
System.out.println("Total travel cost: $" + priceCalculator.getTotalPrice());
}
}
비지터 패턴으로 새로운 교통 수단이 클래스나 새로운 비지터 클래스를 추가하기 쉽습니다.
이처럼 객체 구조와 객체에 대한 작업을 분리하여 기존 코드를 수정할 필요가 없어 유지 보수가 용이합니다.
💡 결론
Visitor로 바뀌는 로직을 캡슐화 했기 때문에 해당 부분의 유연성과 응집도가 높아집니다.
작업 대상(방문 공간)과 작업 항목(방문 공간에서 하는 일)을 분리 시킴으로써, 데이터 독립성이 높아집니다.
또한 생각지 못한 연산을 쉽게 추가하고 드물게 사용되는 연산을 외부에 정의하여 클래스가 작아집니다.
단, 새로운 작업 대상(방문 공간)이 추가될 때마다 작업 주체(방문자)도 이에 대한 로직을 추가해야 하고,
두 객체(방문자와 방문 공간)의 결합도가 높아집니다.
Visitor들은 함께 작업해야 하는 요소들의 비공개 필드 및 메서드들에 접근하기 위해
필요한 권한이 부족할 수 있음을 인지해야 합니다.