행동 관련 : 방문자 패턴
- 디자인 패턴
- 2023. 12. 10.
들어가기 전
이 글은 백기선님의 GOF 디자인 패턴 강의를 복습하며 작성한 글입니다.
방문자 패턴
- GOF : 기존 코드를 변경하지 않고 새로운 기능을 추가하는 방법
- Double Dispatch를 활용할 수 있음.
- Double Dispatch는 구체적인 타입을 찾아가는데 두 번의 Dispatch가 발생하는 것을 의미함.
- Double Dispathc를 활용해서 복잡한 조건 / 타입 체크 코드를 줄여줄 수 있음. (실수할 여지가 더 적어짐)
- Component
- Element : 변하지 않는 것 (기능이 변경되지 않는 지점)
- Visitor : 추가하고 싶은 로직을 담는 곳 (기능이 추가되는 지점)
- Element는 거의 변하지 않는 코드이며 Visitor에는 새로운 기능이 추가될 수 있다.
- 구체적인 방법
- Element 클래스에 accept(Visitor visitor) 메서드가 반드시 추가되어야 함.
- accept() 안에서 visitor.visit(this) 형태로 자신의 타입을 넘겨줌 → 첫번째 Dispatch
- Visitor 클래스의 visit()는 지원할 각 Element를 파라미터로 받는 메서드가 오버라이딩 됨. → Visitor 내에서 두번째 Dispatch.
- Visitor 패턴은 Double Distpach를 활용해 기존 코드를 건드리지 않고 새로운 기능을 추가하는 방법을 제안한다.
- 일반적으로는 기존 클래스에 기능을 추가함. 그러기를 원하지 않는 경우 / 넣지 못하는 경우 사용해 볼 수 있음.
- SRP를 지키고 싶을 경우에는 기존 클래스에 기능을 추가하지 않을 수도 있음.
- SRP + OCP를 제공하지만, Element 추가 / 변경 시 OCP가 깨질 여지가 존재함.
- Element가 추가되면 모든 Visitor에 새로운 메서드가 추가되어야 함.
디자인 패턴이 필요한 코드
public class Circle implements Shape {
@Override
public void printTo(Device device) {
if (device instanceof Phone) {
System.out.println("print Circle to phone");
} else if (device instanceof Watch) {
System.out.println("print Circle to watch");
}
}
}
존재하는 클래스는 다음과 같다.
- Shape(Interface) → Circle / Rectangle / Triangle (Concrete Class)
- Device(Interface) → Phone / Watch / Pad
이 때 Shape는 각 Device에서 출력될 수 있는데, Shape마다 Device에서 출력되는 문구가 다른 상태다. 위의 코드를 보면 의미를 명확히 알 수 있다.
그런데 이 코드에서는 어떤 문제점이 있을까? 예를 들어 Device 인터페이스의 Concrete Class로 IPhone이 추가되었다고 가정해보자. 그러면 printTo() 메서드 내에서 if 문을 추가하고 타입을 체크해야한다. 그런데 이 부분은 위험하다. 왜냐하면 개발자가 이 부분을 깜빡해서 추가되지 않더라도, 문구가 출력되지는 않지만 어떠한 에러도 발생하지 않기 때문에 문제를 모르고 넘어간다. 즉, 안전하지 않은 코드다.
뿐만 아니라 출력 자체를 Device 인터페이스가 아니라 Shape 인터페이스에서 하고 있는데 '책임이 적절한 클래스'에 부여되지도 않은 것 같다.
특정 타입이 추가되었을 때, If 절로 체크되지 않았더라도 '빠른 실패'를 할 수 있는 코드가 나오면 좋을텐데 'Visitor 패턴'을 이용한 구현할 수 있게 된다.
만약 이렇게 구현한다면?
public interface Shape {
void printTo(Device device);
void printTo(Watch watch);
void printTo(Phone phone);
}
Double Dispatch를 이용하기 귀찮아서 Shape 인터페이스에 이렇게 구현하면 되지 않느냐라고 생각할 수 있다. 이렇게 하면 If문을 사용하지 않아도 되는 것처럼 보인다. 그러나 이 코드는 원하는 것처럼 동작하지 않는다.
Device device = new Phone(); // void printTo(Device device); 호출됨
Phone phone = new Phone(); // void printTo(Phone phone); 호출됨
Phone 생성자를 이용해서 동일한 타입의 객체를 만들었지만, 이 파라미터를 이용했을 때는 런타임에서는 서로 다른 메서드가 호출된다.
- 첫번째 코드 → printTo(Device device)
- 두번째 코드 → printTo(Phone phone)
컴파일 타임에는 Device 타입이 어떤 구체적인 타입을 가지는지 알 수 없다. 따라서 자바는 Phone 타입의 객체라도 Device 타입으로 선언되어있으면, Device 타입의 파라미터를 받는 메서드로 디스패치를 한다.
디자인 패턴을 적용한 코드
Double Dispatch를 이용해 Visitor 쪽의 확장성을 늘리기 위해서는 다음과 같이 작성한다.
public class Circle implements Shape {
@Override
public void printTo(Device device) {
device.print(this);
}
}
앞의 예시에서도 보았지만 Element쪽에서는 추상화된 타입(Shape)가 아니라, 구체적인 타입(Circle)을 넘겨주어야 된다. 따라서 device 객체의 print() 메서드를 호출할 때 'this'를 넘겨줘서 구체적인 타입을 전달한다.printTo(Device device)를 호출할 때 추상적인 타입에 의해 첫번째 Distpach가 발생한다.
public class Phone implements Device {
@Override
public void print(Circle circle) {
System.out.println("print circle phone.");
}
@Override
public void print(Rectangle rectangle) {
System.out.println("print rectangle phone.");
}
@Override
public void print(Triangle triangle) {
System.out.println("print triangle phone.");
}
}
이 때 Device 객체로 넘어오는 타입은 '구체적인 타입'이다. 왜냐하면 This를 넘기는데 이 때, Circle / Rectangle / Triangle중 하나의 구체적인 타입으로 넘어오기 때문이다. 컴파일 타임에도 this를 넘길 때, 내가 어떤 구체적인 클래스인지 알기 때문에 타입에 의해서 Second Dispatch가 발생한다.
이렇게 작성된 코드는 몇 가지 장점이 생긴다.
- print() 메서드 안에 타입 점검하는 If ~ Else 코드가 없어짐.
- 새로운 Visitor로 Pad 클래스 같은 것들이 추가되어도 기존 코드에 영향을 주지 않음.
- Element는 추상화 된 Visitor 인터페이스에만 의존하기 때문임.
Visitor 패턴의 장단점
- 장점
- 기존 코드를 수정하지 않고 새로운 기능을 추가할 수 있음.
- 단점
- Double Dispatch의 흐름을 이해하기가 어려움.
- Element가 추가되거나 없어지면 많은 변경이 필요해짐.
새로운 Visitor가 추가되는 경우에는 기존 코드에 영향을 주지 않지만, Element가 추가 / 변경되는 경우에는 오히려 많은 코드 변경이 수반될 수 있다. 따라서 Element가 절대로 변하지 않는 경우라면 사용을 고려해 볼 수 있다.
만약 Element로 새로운 도형인 타원(Elipse)가 추가되는 경우라면, Visitor 인터페이스에 Double Dispatch를 위한 메서드가 추가되어야 한다. 그리고 그 변화는 Concrete VIsitor로 모두 반영되어야 한다. 따라서 변경 범위가 커진다는 것이다.
파이썬에서의 Double Distpach
파이썬에서는 functools 라이브러리의 singledistpach 데코레이터를 이용해서 Visitor 패턴을 구현해 볼 수 있다.
import abc
from functools import singledispatch
from abc import abstractmethod
class Shape(abc.ABC):
@abstractmethod
def print_to(self, device):
pass
class Circle(Shape):
def print_to(self, device):
device.print_hello(self)
class Rectangle(Shape):
def print_to(self, device):
device.print_hello(self)
먼저 추상화 된 인터페이스로 Shape를 선언해주고 (추상 클래스), 자식 클래스 Circle, Rectangle이 추상 메서드인 print_to()를 구현하도록 만든다. 여기까지가 Element의 선언 부분이다.
class Device:
@abstractmethod
def print_hello(self, x):
pass
class Phone(Device):
def print_hello(self, x):
print_dispatch(x)
@singledispatch
def print_dispatch(x):
raise NotImplementedError
@print_dispatch.register(Circle)
def _x(x):
print('Circle Called')
@print_dispatch.register(Rectangle)
def _x(x):
print('Rectangle Called')
Visitor는 다음과 같이 구현한다. single_disptach는 첫번째 인자의 타입에 따라 Dispatch를 하는데 클래스, 인스턴스 메서드로 존재하게 되면 항상 cls / self를 받아야 하기 때문에 원하는대로 동작하지 않는다. 따라서 스크립트 외부에 @singledispatch용 메서드를 선언해두고, 그 메서드에 delegation 하는 형태로 구현했다.
파이썬은 타입을 강하게 제한하는 언어가 아니고, 자바처럼 컴파일 시점에 디스패치되지 않기 때문에 이런 형태로 구현해야했다. 자바와 동일하게 컴파일 시점에 강제로 구현하도록 바꿀 수는 없지만, 지원되지 않는 타입이 들어올 경우 NotImplementedError를 던져서 처리하도록 구현했다.
'디자인 패턴' 카테고리의 다른 글
객체 생성 : 추상 팩토리 패턴 (Abstract Factory Pattern) (0) | 2023.12.10 |
---|---|
행동 관련 : 책임 연쇄 패턴 (0) | 2023.12.10 |
구조 관련 : 브릿지 패턴 (0) | 2023.12.07 |
구조 관련 : 데코레이터 패턴 (0) | 2023.12.04 |
행동 관련 : 이터레이터 패턴 (Iterator Pattern) (0) | 2023.12.02 |