Self-Dev/Design Patterns R&D

[34] 디자인 패턴 목록 - 행동 디자인 패턴 - 비지터 패턴(Visitor Patten)

Khadra 2024. 7. 25. 18:08

출처 : 디자인 패턴에 뛰어들기 - 알렉산더 슈베츠 도서


비지터 패턴(Visitor Patten)

알고리즘들을 그들이 작동하는 객체들로부터 분리할 수 있도록 하는 행동 디자인 패턴이다.



문제

당신의 팀이 하나의 거대한 그래프로 구성된 지리 정보를 사용해 작동하는 앱을 개발하고 있다고 가정해본다.

  • 그래프의 각 노드는 도시와 같은 복잡한 객체를 나타낼 수 있지만 산업들, 관광 지역들 등의 더 세부적인 항목들도 나타낼 수 있다.
  • 만약에 노드들이 나타내는 실제 객체들 사이에 도로가 있으면 노드들은 서로 연결된다.
  • 각 노드 유형은 자체 클래스지만 각 노드는 객체이다.


그래프를 XML 형식으로 내보내기 예시이다.

어느 날 당신은 그래프를 XML 형식으로 내보내는 작업을 구현하는 일을 맡았다.

  • 처음에는 일이 매우 간단해 보였다.
  • 각 노드 클래스에 내보내기 메서드를 추가한 다음 재귀를 활용하여 그래프의 각 노드에 작업하며 내보내기 메서드를 실행할 계획이었다.
  • 해결책은 간단하고 우아했다.
  • 다형성 덕분에 내보내기 메서드를 호출하는 코드를 노드들의 구상 클래스들에 결합하지 않았다.

불행히도 시스템의 설계자는 기존 노드 클래스들을 변경하는 것을 허용하지 않았다.

  • 그는 코드가 이미 프로덕션 단계에 있으며 당신이 제안한 변경 사항들이 오류를 일으킬 수 있으므로 코드가 손상되는 위험을 감수하고 싶지 않다고 말했다.


XML 내보내기 메서드는 모든 노드 클래스에 추가되어야 했으며, 이러한 변경과 함께 버그가 발생하면 전체 앱이 망가질 위험이 있었다는 예시이다.

또 시스템 설계자는 노드 클래스들 내에 XML 내보내기 코드를 넣는 것이 적절한지에 대한 의문을 제기했다.

  • 이 클래스들의 주 작업은 지리 데이터를 처리하는 것이므로, XML 내보내기 동작은 그곳에서 이상하게 보일 것이라고 했다.
  • 시스템 설계자의 거절에는 또 다른 이유도 있었다.
  • 위 기능이 구현된 후에도 마케팅 부서의 누군가가 데이터를 다른 형식으로 내보낼 수 있는 기능 또는 다른 기능을 요청할 가능성이 있다.
  • 그러면 당신은 다시 이 망가지기 쉬운 클래스들을 다시 한번 변경해야 한다는 것이다.


해결책

비지터 패턴은 새로운 행동을 기존 클래스들에 통합하는 대신 visitor(방문자)라는 별도의 클래스에 배치할 것을 제안한다.

  • 이제 행동을 수행해야 했던 원래 객체는 visitor의 메서드 중 하나에 인수로 전달된다.
  • 그러면 메서드는 원래 객체 내에 포함된 모든 필요한 데이터에 접근할 수 있다.

이제 그 행동이 다른 클래스들의 객체들에 대해 실행될 수 있다면 어떨지 생각해본다.

  • 예를 들어 XML 내보내기의 경우 실제 구현은 다양한 노드 클래스들에서 약간씩 다를 수 있다.
  • 따라서 비지터 클래스는 단일 메서드를 정의하는 대신 다음과 같이 메서드의 집합을 정의하여 각 메서드가 다른 유형의 인수를 받을 수 있도록 한다.
class ExportVisitor implements Visitor is
  method doForCity(City c) { ... }
  method doForIndustry(Industry f) { ... }
  method doForSightSeeing(SightSeeing ss) { ... }
  // …

그러나 우리는 이러한 메서드들을 정확히 어떻게 호출할까?

  • 특히 전체 그래프를 다룰 때 말이다.
  • 이 메서드들은 시그니처들이 다르므로 다형성을 사용할 수 없다.
  • 주어진 객체를 처리할 수 있는 적절한 비지터 메서드를 선택하려면 먼저 그 클래스를 확인해야 한다.
  • 그러면 너무 복잡하지 않을까 생각이 든다.
foreach (Node node in graph)
  if (node instanceof City)
    exportVisitor.doForCity((City) node)
  if (node instanceof Industry)
    exportVisitor.doForIndustry((Industry) node)
  // …
}

여기서 당신은 메서드 오버로딩을 사용하는 게 어떻겠냐고 제안할지도 모른다.

  • 메서드 오버로딩은 다른 매개변수들의 집합들을 지원하더라도 모든 메서드에 같은 이름을 지정하는 방식이다.
  • 사용하는 프로그래밍 언어가 자바나 C#처럼 메서드 오버로딩을 지원한다고 가정하더라도, 그건 우리에겐 도움이 되지 않을 것이다.
  • 노드 객체의 정확한 클래스를 사전에 알 수 없으므로, 오버로딩 메커니즘은 실행해야 할 올바른 메서드가 무엇인지 판단할 수 없고, 따라서 디폴트(기본값)로 기초 Node 클래스의 객체를 받는 메서드를 선택하게 된다.

그러나 비지터 패턴에서는 이 문제를 더블 디스패치라는 방법을 사용하여 해결한다.

  • 이 방법은 번거로운 조건문 없이 객체에 적절한 메서드를 실행하는 것을 돕는다.
  • 클라이언트가 호출할 메서드의 적절한 버전을 선택하도록 하는 대신 이 선택권을 비지터에게 인수로 전달되는 객체에게 위임한다.
  • 이러한 객체들은 자신의 클래스들을 알고 있으므로 비지터에 대한 적합한 메서드를 더 쉽게 선택할 수 있다.
  • 그들은 비지터를 '수락'하고 어떤 비지터 메서드가 실행되어야 하는지 알려준다.
// Client code
foreach (Node node in graph)
  node.accept(exportVisitor)

// City
class City is
  method accept(Visitor v) is
    v.doForCity(this)
  // …

// Industry
class Industry is
  method accept(Visitor v) is
    v.doForIndustry(this)
  // …

결국 노드 클래스들을 변경해야 했다.

  • 그러나 최소한 변경 사항들은 사소했으며, 이제 코드를 다시 변경하지 않고도 다른 행동들을 추가할 수 있다.

이제 모든 비지터에 대한 공통 인터페이스를 추출하면 기존의 모든 노드가 당신이 앱에 도입하는 모든 비지터와 함께 작동할 수 있다.

  • 노드와 관련된 새로운 행동을 도입하려면 새 비지터 클래스를 구현하기만 하면 된다.


실제상황 적용


좋은 보험 대리인은 항상 다양한 유형의 조직들에 적절한 보험을 판매할 준비가 되어 있다는 예시이다.

새로운 고객을 확보하고 싶어 하는 노련한 보험 대리인을 가정해본다.

  • 그는 근방의 모든 건물을 방문하여 만나는 모든 사람에게 보험을 판매하려고 한다.
  • 그는 방문한 건물에 있는 회사 또는 조직의 유형에 따라 맞춤형 전문 보험 정책들을 제공할 수 있다.
    • 주거용 건물을 방문할 때는 의료 보험을 판매한다.
    • 은행을 방문할 때는 도난 보험을 판매한다.
    • 커피숍을 방문할 때는 화재 및 홍수 보험을 판매한다.


구조

  • 1.비지터 인터페이스는 객체 구조의 구상 요소들을 인수들로 사용할 수 있는 비지터 메서드들의 집합을 선언한다.

    • 이러한 메서드들은 (앱이 오버로딩을 지원하는 언어로 작성된 경우) 같은 이름을 가질 수 있지만 그들의 매개변수들의 유형은 달라야 한다.
  • 2.각 구상 비지터는 다양한 구상 요소 클래스들에 맞춤으로 작성된 같은 행동들의 여러 버전을 구현한다.


  • 3.요소 인터페이스는 비지터를 '수락'하는 메서드를 선언한다.

    • 이 메서드에는 비지터 인터페이스 유형으로 선언된 하나의 매개변수가 있어야 한다.
  • 4.각 구상 요소는 반드시 수락 메서드를 구현해야 한다.

    • 이 메서드의 목적은 호출을 현재 요소 클래스에 해당하는 적절한 비지터 메서드로 리다이렉트하는 것이다.
    • 기초 요소 클래스가 이 메서드를 구현하더라도 모든 자식 클래스들은 여전히 자신들의 클래스들 내에서 이 메서드를 오버라이드해야 하며 비지터 객체에 적절한 메서드를 호출해야 한다.
  • 5.클라이언트는 일반적으로 컬렉션 또는 기타 복잡한 객체(예: 복합체 트리)를 나타낸다.

    • 일반적으로 클라이언트들은 해당 컬렉션의 객체들과 어떠한 추상 인터페이스를 통해 작업하기 때문에 모든 구상 요소 클래스들을 인식하지 못한다.


의사코드

이 예시에서의 비지터 패턴은 기하학적 모양들의 클래스 계층구조에 XML 내보내기 지원을 추가한다.


비지터 객체를 통해 다양한 유형의 객체들을 XML 형식으로 내보내기 예시이다.

// 요소 인터페이스는 기초 방문자 인터페이스를 인수로 받는 
// `accept` 메서드를 선언합니다.
interface Shape is
  method move(x, y)
  method draw()
  method accept(v: Visitor)

// 각 구상 요소 클래스는 요소의 클래스에 해당하는 비지터의 메서드를 
// 호출하는 방식으로 `accept` 메서드를 구현해야 합니다.
class Dot implements Shape is
  // …

  // 참고로 우리는 현재 클래스 이름과 일치하는 `visitDot`를 호출하고 있습니다. 
  // 그래야 비지터가 함께 작업하는 요소의 클래스를 알 수 있습니다.
  method accept(v: Visitor) is
    v.visitDot(this)

class Circle implements Shape is
  // …
  method accept(v: Visitor) is
    v.visitCircle(this)

class Rectangle implements Shape is
  // …
  method accept(v: Visitor) is
    v.visitRectangle(this)

class CompoundShape implements Shape is
  // …
  method accept(v: Visitor) is
    v.visitCompoundShape(this)


// 비지터 인터페이스는 요소 클래스들에 해당하는 방문 메서드들의 집합을 선언합니다.
// 방문 메서드의 시그니처를 통해 비지터는 처리 중인 요소의 정확한 클래스를 
// 식별할 수 있습니다.
interface Visitor is
  method visitDot(d: Dot)
  method visitCircle(c: Circle)
  method visitRectangle(r: Rectangle)
  method visitCompoundShape(cs: CompoundShape)

// 구상 비지터는 모든 구상 요소 클래스와 작동할 수 있는 같은 
// 알고리즘의 여러 버전을 구현합니다.
//
// 비지터 패턴은 복합체 트리와 같은 복잡한 객체 구조와 함께 사용할 때 
// 가장 큰 이득을 볼 수 있습니다. 
// 그러면 비지터의 메서드들을 구조의 다양한 객체 위에서 실행하는 동안
// 알고리즘의 어떤 중간 상태를 저장하는 것이 도움이 될 수 있습니다.
“class XMLExportVisitor implements Visitor is
  method visitDot(d: Dot) is
    // 점의 아이디와 중심 좌표를 내보냅니다.

  method visitCircle(c: Circle) is
    // 원의 아이디, 중심 좌표 및 반지름을 내보냅니다.

  method visitRectangle(r: Rectangle) is
    // 사각형의 아이디, 왼쪽 상단 좌표, 너비 및 높이를 내보냅니다.

  method visitCompoundShape(cs: CompoundShape) is
    // 모양의 아이디와 그 자식들의 아이디 리스트를 내보냅니다.


// 클라이언트 코드는 요소의 구상 클래스들을 파악하지 않고도 
// 모든 요소 집합 위에서 비지터의 작업들을 실행할 수 있습니다.
// `accept` 작업은 비지터 객체의 적절한 작업으로 호출을 전달합니다.
class Application is
  field allShapes: array of Shapes

  method export() is
    exportVisitor = new XMLExportVisitor()

    foreach (shape in allShapes) do
      shape.accept(exportVisitor)

이 예시에서 accept 메서드가 필요한 이유가 궁금하다면 제 설명글 Visitor and Double Dispatch에서 이 주제를 자세히 다루고 있다.



적용

비지터 객체는 복잡한 객체 구조(예: 객체 트리)의 모든 요소에 대해 작업을 수행해야 할 때 사용하세요.

  • 비지터 패턴은 비지터 객체가 모든 대상 클래스들에 해당하는 같은 작업의 여러 변형들을 구현하도록 함으로써 다양한 클래스들을 가진 여러 객체의 집합에 작업을 실행할 수 있도록 해준다.

비지터 패턴을 사용하여 보조 행동들의 비즈니스 로직을 정리하세요.

  • 이 패턴은 앱의 주 클래스들의 주 작업들을 제외한 모든 다른 행동들을 비지터 클래스들의 집합으로 추출함으로써 그들이 주 작업에 더 집중하도록 만들 수 있게 해준다.

이 패턴은 행동이 클래스 계층구조의 일부 클래스들에서만 의미가 있고 다른 클래스들에서는 의미가 없을 때 사용하세요.

  • 이 행동을 별도의 비지터 클래스로 추출한 후 관련 클래스들의 객체들을 수락하는 비지터 메서드들만 구현하고 나머지는 비워둔다.


구현방법

  • 1.프로그램에 존재하는 각 구상 요소 클래스당 하나씩 '비지터(방문)' 메서드를 만드세요.

    • 이 메서드들의 집합으로 비지터 인터페이스를 선언하세요.
  • 2.요소 인터페이스를 선언하세요.

    • 기존 요소 클래스 계층구조와 작업하는 경우 계층구조의 기초 클래스에 추상 수락 메서드를 추가하세요.
    • 이 메서드는 비지터 객체를 인수로 받아들여야 한다.
  • 3.모든 구상 요소 클래스들에서 수락 메서드들을 구현하세요.

    • 이러한 메서드들은 단순히 비지터 메서드에 대한 호출을 들어오는 비지터 객체에 리다이렉트해야 한다.
    • 이 들어오는 비지터 객체는 현재 요소의 클래스와 일치한다.
  • **4.요소 클래스들은 비지터 인터페이스를 통해서만 비지터와 작동해야 한다.

    • 그러나 비지터들은 비지터 메서드들의 매개변수 유형들로 참조된 모든 구상 요소 클래스들에 대해 알고 있어야 한다.
  • 5.요소 계층구조 내에서 구현할 수 없는 각 행동의 경우, 새로운 구상 비지터 클래스를 만들고 모든 비지터 메서드들을 구현하세요.

    • 비지터가 요소 클래스의 일부 비공개 필드들 또는 메서드들에 접근해야 할 상황이 발생할 수 있다.
    • 이럴 때 이러한 필드들 또는 메서드들을 공개하여 요소의 캡슐화를 위반하거나, 비지터 클래스를 요소 클래스에 중첩할 수 있다.
    • 중첩 옵션의 경우 중첩 클래스들을 지원하는 프로그래밍 언어를 사용할 때만 가능하다.
  • 6.클라이언트는 비지터 객체들을 만들고 '수락' 메서드들을 통해 그것들을 요소들에 전달해야 한다.



장단점

장점

  • 개방/폐쇄 원칙
    • 당신은 다른 클래스를 변경하지 않으면서 해당 클래스의 객체와 작동할 수 있는 새로운 행동을 도입할 수 있다.
  • 단일 책임 원칙
    • 같은 행동의 여러 버전을 같은 클래스로 이동할 수 있다.
  • 비지터 객체는 다양한 객체들과 작업하면서 유용한 정보를 축적할 수 있다.
    • 이것은 객체 트리와 같은 복잡한 객체 구조를 순회하여 이 구조의 각 객체에 비지터 패턴을 적용하려는 경우에 유용할 수 있다.

단점

  • 당신은 클래스가 요소 계층구조에 추가되거나 제거될 때마다 모든 비지터를 업데이트해야 합니다.
  • 비지터들은 함께 작업해야 하는 요소들의 비공개 필드들 및 메서드들에 접근하기 위해 필요한 권한이 부족할 수 있습니다.


다른 패턴과의 관계

  • 비지터 패턴은 커맨드 패턴의 강력한 버전으로 취급할 수 있다.

    • 비지터 패턴의 객체들은 다른 클래스들의 다양한 객체에 대한 작업을 실행할 수 있다.
  • 비지터 패턴을 사용하여 복합체 패턴 트리 전체를 대상으로 작업을 수행할 수 있다.


  • 비지터 패턴과 반복자 패턴을 함께 사용해 복잡한 데이터 구조를 순회하여 해당 구조의 요소들의 클래스들이 모두 다르더라도 이러한 요소들에 대해 어떤 작업을 실행할 수 있다.