본문 바로가기
디자인 패턴

클린 소프트웨어 책에서 말하는 VISITOR PATTERN이란?

by simplify-len 2020. 11. 8.

이 책에서 가장 서두에 쓰여져 있는 VISITOR PATTERN을 쓰기 위해서 문제를 제기하는데, 그 문제는 바로 이것입니다.

"클래스 계층 구조에 새로운 메소드를 추가할 필요가 있지만, 그렇게 하는 작업은 고통스럽거나 설계를 해치게 된다."

이런 문제는 흔하게 발생합니다. 이전 포스트했던 클린 소프트웨어 책에서 말하는 어탭터패턴은 무엇인가?에서도 마찬가지의 문제로 어탭터 패턴을 통해서도 해결방안을 찾았다면 이번에는 비지터 패턴을 활용해서 해결하는 방법을 살펴봅시다.

 우리는 흔히 SOLID 원칙이라 불리는 객체지향 원칙이 있습니다. 디자인패턴은 이런 SOLID 원칙을 보다 더 SOLID 하게 만들기 위한 하나의 패턴입니다.

비지터는 변경해야 할 계층 질서에 새로운 파생형을 자주 추가할 필요가 없는 프로그램에 효과적이다.

다시 VISITOR PATTERN으로 돌아가겠습니다.

VISITOR 집합으로는 아래와 같습니다.

  • 일반적인 VISITOR
  • 비순환 VISITOR
  • 데코레이터(DECORATOR)
  • 확장 객체(EXTENSION OBEJCT)

먼저 일반적인 VISITOR를 살펴보겠습니다.

이전 어탭터 패턴에서 썼었던 Modem 클래스 다이어그램을 다시 가져오겠습니다.

모뎀 클래스 다이어그램

지금부터 이전의 어탭터는 잊고, 위와 같은 상황에서 기능을 추가하고 싶다면, Moden Interface에 추가하지 않고도 어떻게 위 모뎀들을 특정 환경에 맞쳐 환경 설정을 할 수 있을까요?

"Zoom Modem에서만 특별한 메소드를 추가하고 싶다면?"
"Ernie Modem에서만 동작되는 메서드를 구현하게 된다면?"

 사실 Zoom,Ernie 클래스 내에서 필요한 함수를 구현한다고 해서 어떤 문제가 일어나는 것은 아닙니다. 더욱이, 그런 코드는 우리는 흔하게 작성하게 됩니다. 그러나, 위 요구사항을 충족시키기 위해 각각의 모뎀의 독립적인 기능들을 추가하면, LSP를 위반할 잠재적인 요소들을 포함되고, 추후 새로운 Modem이 추가될 경우, 복잡한 설계가 만들어 질수 있는 요소가 다분해집니다.

이럴 때 쓸 수 있는 것으로 VISITOR PATTERN이 있고, 비지터 패턴은 이중 디스패처(Dual dispatch)라는 것을 사용하게 되는데, 이 것이 바로 VISITOR PATTERN의 핵심입니다.

VISITOR PATTERN의 핵심은 이중 디스패처라고 했습니다. 이는 무슨 말일까요?

이중 디스패처를 설명하기 위해 아래 클래스 다이어그램 하나를 살펴보겠습니다.

이중 디스패처를 위한 예시용 클래스 다이어그램

위 클래스 다이어그램을 설명해보면, Modem Interface를 상속받은 파생형(Hayes, Zoom, Ernie) 마다 메소드가 하나씩 비지터 계층 구조에 존재하게 됩니다.

위 클래스 다이어그램을 사용하는 테스트 코드를 먼저 살펴보면

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class ModemVisitorTest {

  private UnixModemConfigurator v;
  private HayesModem h;
  private ZoomModem z;
  private ErnieModem e;

  @BeforeEach
  void setUp() {
    v = new UnixModemConfigurator();
    h = new HayesModem();
    z = new ZoomModem();
    e = new ErnieModem();
  }

  @Test
  void hayesForUnix() {

    h.accept(v);
    z.accept(v);
    e.accept(v);

    assertEquals("A", h.configurationString);
    assertEquals("ZoomModem", z.configurationString);
    assertEquals("C is too slow", e.configurationString);
  }
}

각각의 요소들은 accept(Visitor v) 를 갖습니다.

UnixModemConfigurator class의 인스턴스를 하나 만든 다음 이 인스턴스를 Modem의 Accpet 함수에 전달합니다. 그러면 해당 Modem 파생형은 UnixModemConfigurator의 기반 클래스 ModemVisitor의 visit 메소드에 자신을 인자로 넘겨 visit(this)로 호출합니다. 

public class ErnieModem implements Modem {

  ...
  
  @Override
  public char recv() {
    return 0;
  }

  @Override
  public void accept(ModemVisitor v) {
    v.visit(this);
  }
  String configurationString = null;

}
public interface ModemVisitor {
    void visit(HayesModem modem);
    void visit(ZoomModem modem);
    void visit(ErnieModem modem);
}

 파생형 ErnieModem에서 visit(this)을 호출 할 때, UnixModemConfigurator의 visit(Hayes)함수가 실행되는데, 이 함수에서 Hayes 모뎀을 유닉스용으로 환경 설정하면 됩니다.

아, 여기서 이중 디스패치가 어디인지 이해하셨나요?

 이중 디스패처란, 다형성을 이용해서 어떤 메소드 본체를 부를지 결정하는 작업(디스패처)을 두번 수행합니다. 첫번째는 Accept()함수에서, 그 다음 Visitor 계층에서 어떤 종류인지 파악해서 그 객체에 해당하는 Accept 메소드 본체를 호출합니다. 

총 2번의 Accpet가 일어납니다. 이 때문에, 리플렉션과 같은 방식의 실행 속도보다 비지터가 휠씬 더 빠릅니다.

비순환 비지터란?

 위의 ModemVisiter 에서 각 비지터마다 하나의 메소드를 가지고 있다는 사실을 주목하면, 모든 파생형(모든 Modem)이 모두 의존 관게 순환에 빠지게 될 수 있습니다. 이러면 구조를 점진적으로 컴파일하거나 방문 대상인 계층 구조에 새로운 파생행을 추가하기가 매우 어려워집니다. 지금은 Scope가 크기 않기 때문에, 알아 차리기 힘들지만, 엔터프라이즈급 프로젝트에서는 발견하기도 쉽지 않을 수 있습니다.

그러므로, 이런 의존 순환구조를 끊어내는 것 그것이 바로 비순환 비지터입니다. 그렇다면 어떻게 끊을 수 있을까요?

이 때 VISITOR 계층에 ModemVisitor와 Modem 파생형 클래스와의 의존관계가 끊어져 있습니다. 이를 코드로 살펴보겠습니다.

먼저 ModemVisitor부터 살펴보겠습니다.

//마크 인터페이스
public interface ModemVisitor {

}

ModemVisitor를 보면 아무런 메소드가 없는 것을 알 수 있습니다. 즉, 마크인터페이스입니다. 

다음으로 UnixModemConfigurator는 

public class UnixModemConfigurator implements HayesModemVisitor, ZoomModemVisitor, ErnieModemVisitor {


  @Override
  public void visit(ErnieModem m) {
    System.out.println("ErnieModem");
  }

  @Override
  public void visit(HayesModem m) {

  }

  @Override
  public void visit(ZoomModem m) {

  }
}
public interface HayesModemVisitor {

  void visit(HayesModem m);
}
import visiterpattern.ModemVisitor;

public class HayesModem implements Modem {


  @Override
  public void dial() {

  }

  @Override
  public void send() {

  }

  @Override
  public void hangup() {

  }

  @Override
  public char recv() {
    return 0;
  }

  @Override
  public void accept(ModemVisitor v) {
    try {
      HayesModemVisitor hv = (HayesModemVisitor) v;
      hv.visit(this);
    } catch (ClassCastException e) {
    }
  }
  String configurationString = null;
}

 

이런 식으로, 위 클래스다이어그램을 표현했습니다. 이로써 각 파생형클래스가 하나의 VISITOR를 가지기 때문에 의존관계를 끊은 형태의 비지터를 만들 수 있었습니다. 의존성을 끊었으므로, 새로운 Modem 파생형을 추가하거나 점진적 컴파일을 하기로 쉬워졌습니다. 그러나, 일반적인 VISITOR에 비해 복잡해지는 단점이 있습니다.


 이번에는 VISITOR를 사용하는 목적에서 좀 더 실무적인 활용 예시를 살펴보겠습니다. 개인적으로 이런 디자인패턴을 보고 실무에 적용할 수 있겠다 라고 생각했습니다.

 역시나 마찬가지로 클래스 다이어그램을 먼저 살펴보겠습니다.

보고서 생성 프로그램에 비지터 사용하기

코드는 아래와 같습니다.

public interface Part {

  String getPartNumber();
  String getDescription();
  void accept(PartVisitor partVisitor);

}
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;

public class Assembly implements Part {

  private final List<Part> itsParts = new LinkedList();
  private final String itsPartNumber;
  private final String itsDescription;

  public Assembly(String itsPartNumber, String itsDescription) {
    this.itsPartNumber = itsPartNumber;
    this.itsDescription = itsDescription;
  }

  @Override
  public void accept(PartVisitor v) {
    v.visit(this);
    Iterator<Part> i = getParts();
    while (i.hasNext()){
      Part p = i.next();
      p.accept(v);
    }
  }

  public void add(Part part){
    itsParts.add(part);
  }

  public Iterator<Part> getParts() {
    return itsParts.iterator();
  }

  @Override
  public String getPartNumber() {
    return itsPartNumber;
  }

  @Override
  public String getDescription() {
    return itsDescription;
  }
}
public class PiecePart implements Part {

  private final String itsPartNumber;
  private final String itsDescription;
  private final double itsCost;

  public PiecePart(String itsPartNumber, String itsDescription, double itsCost) {
    this.itsPartNumber = itsPartNumber;
    this.itsDescription = itsDescription;
    this.itsCost = itsCost;
  }

  @Override
  public void accept(PartVisitor v) {
    v.visit(this);
  }

  @Override
  public String getPartNumber() {
    return itsPartNumber;
  }

  @Override
  public String getDescription() {
    return itsDescription;
  }

  public double getItsCost() {
    return itsCost;
  }
}
public interface PartVisitor {

  void visit(Assembly assembly);
  void visit(PiecePart piecePart);

}

 

여기까지는 위에서 봤던 일반적인 VISITOR 패턴과 다를게 없습니다. 그러나, ExplodedCostVisitor와 PartCountVisitor 의 코드를 보면 VISITOR PATTERN을 사용하는 또다른 예시를 발견할 수 있습니다.

import java.util.HashMap;

public class PartCountVisitor implements PartVisitor {

  private int itsPieceCount = 0;
  private HashMap<String, Integer> itsPieceMap = new HashMap();

  private PartReportGenerator partReportGenerator;
  //새로운 자료구조 만들어 활용할 수 있다.


  @Override
  public void visit(PiecePart piecePart) {
    itsPieceCount++;
    String partNumber = piecePart.getPartNumber();
    int partNumberCount = 0;
    if (itsPieceMap.containsKey(partNumber)) {
      partNumberCount = itsPieceMap.get(partNumber);
    }
    partNumberCount++;
    itsPieceMap.put(partNumber, partNumberCount);

    partReportGenerator.add(partNumberCount);
  }

  @Override
  public void visit(Assembly assembly) {

  }

  public int getPieceCount() {
    return itsPieceCount;
  }

  public int getPartNumberCount() {
    return itsPieceMap.size();
  }

  public int getCountForPart(String partNumber) {
    int partNumberCount = 0;
    if (itsPieceMap.containsKey(partNumber)) {
      Integer carrier = itsPieceMap.get(partNumber);
      partNumberCount = carrier;
    }
    return partNumberCount;
  }
}

PartCounterVisitor 클래스는 Part의 파생형 객체들을 활용하는 visitor에서 getCountForPart를 주의깊게 볼 필요가 있습니다. PartCountVisitor는 ItsPeiceMap 이라는 자료구조를 통해서 또다른 메소드를 실행시키고 있습니다. 이는 비지터의 또다른 용도라 말할 수 있습니다. 

"
비지터를 사용하게 되면 방문 대상인 자료 구조 자체와 그 자료 구조가 사용되는 용도가 독립적이게 됩니다. 비지터를 사용하면 기존 자료 구조를 재컴파일하거나 재배치하지 않고도 이미 자료 구조가 설치된 곳에 새로운 비지터를 만들어 배치하거나 기존 비지터를 변경한 다음 재배치할 수 있으며, 이것이 바로 비지터의 힘입니다.   - 클린 소프트웨어 515p
"

데코레이터 패턴

기존 계층 구조를 바꾸지 않고도 메소드를 추가할 수 있는 패턴 중에 하나입니다.
예를 들어 모뎀에 있는 다이얼 버튼을 누르는데, 누군가는 소리를 나게하고, 누군가는 소리가 나지 않도록 하고 싶다면, 코드에서 모뎀이 다이얼하는 곳마다 사용자 환경 설정에서 정보를 읽어오는 방법으로 데코레이터 패턴을 활용해서 구현합니다.

비록 아래와 같은 코드로 소리가 나지 않게 할 수도 있을 것입니다.

...
Modem m = user.getModem();
if(user.wantsLoudDIal())
  	m.setVolume(11); //10보다 하나 크다.
m.dial(...);
...

또는 템플릿 메소드 패턴을 활용해서

...
public abstract class Modem {
  private boolean wantsLoudDial = false;
  
  public void dial(...) {
    if (wantsLoudDial) {
      setVolume(11);
    }
    dialForReal(...)
  }
  
  public abstract void dialForReal(...);
}

나아지긴 했지만, 여전히 일시적으로 나아졌다고 말할 수 있습니다. 그 이유를 잘 생각해보면

"Modem이 다이얼 소리의 크기에 대해 알아야 할 이유가 있는가? 그렇다면 사용자가 다른 이상한 요청을 하면(예를 들어, 연결을 끊기 전 로그를 남겨야 한다든가) 그때마다 Modem이 또 변경되어야 하는가?"

여기에 패키지 원칙 중 공통폐쇄원칙이 다시 한 번 역할을 수행합니다. 변경 이유가 다른 것들은 분리해야만 합니다. Modem의 진짜 기능과 다이얼 소리를 크게하는 기능은 아무런 상관이 없으며, 따라서 이 기능은 Modem의 일부분이 아닙니다.

이를 해결하기 위한 방법으로 데코레이터 패턴을 적용합니다.

LoudDialModem 라는 것을 만들어 해결합니다.

데코레이터 패턴 설명을 위한 다이어그램

TestCode부터 살펴보면,

class ModemDecoratorTest {

  @Test
  void createHayes() {
    Modem m = new HayesModem();
    assertEquals(null, m.getPhoneNumber());

    m.dial("1234");
    assertEquals(m.getPhoneNumber(),"1234");
    assertEquals(m.getSpeakerVolume(), 0);
    m.setSpeakerVolume(10);
    assertEquals(10, m.getSpeakerVolume());
  }

  @Test
  void applyLoudDialModem() {
    Modem m = new HayesModem();
    Modem d = new LoudDialModem(m);
    assertEquals(d.getPhoneNumber(),null);
    assertEquals(m.getSpeakerVolume(), 0);
    d.dial("1234");
    assertEquals(m.getPhoneNumber(), "1234");
    assertEquals(m.getSpeakerVolume(), 10);
  }
}
public interface Modem {

  void dial(String pno);
  void setSpeakerVolume(int volume);
  String getPhoneNumber();
  int getSpeakerVolume();
}
public class HayesModem implements Modem {

  private String itsPhoneNumber;
  private int itsSpeakerVolume;

  @Override
  public void dial(String pno) {
    itsPhoneNumber = pno;
  }

  @Override
  public void setSpeakerVolume(int volume) {
    itsSpeakerVolume = volume;
  }

  @Override
  public String getPhoneNumber() {
    return itsPhoneNumber;
  }

  @Override
  public int getSpeakerVolume() {
    return itsSpeakerVolume;
  }
}
public class LoudDialModem implements Modem {

  private Modem itsModem;

  public LoudDialModem(Modem m) {
    itsModem = m;
  }

  @Override
  public void dial(String pno) {
    itsModem.setSpeakerVolume(10);
    itsModem.dial(pno);
  }

  @Override
  public void setSpeakerVolume(int volume) {
    itsModem.setSpeakerVolume(volume);
  }

  @Override
  public String getPhoneNumber() {
    return itsModem.getPhoneNumber();
  }

  @Override
  public int getSpeakerVolume() {
    return itsModem.getSpeakerVolume();
  }
}

 

이런식으로 코드를 작성해, 데코레이터 패턴을 구현합니다.

확장 객체 패턴(Extension Object Pattern)

이 패턴은 간단히 클래스 다이어그램만 살펴보고, 넘어가도록 하겠습니다.

확장 객체 패턴 그림1

 계층 구조를 변경핮 않고도 기능을 추가하는 한 가지 방법으로 다른 패턴에 비해, 더 복잡하지만, 휠씬 더 강력하고 유연합니다. 

 

결론

 앞서 언급한 패턴들은 분면 SOLID 원칙을 지키기 위해서 많은 도움이 되는 패턴들입니다. 특히 OCP를 원칙으로써, 리스코프 치원원칙을 지킴으로써, 좋은 코드를 설계합니다. 그러나, 사실 이렇게 까지 하지 않아도 우리가 받아들이는 요구사항에 있어서 적용하지 않아도 될 수 있습니다. 

이 패턴들은 종류가 다른 기능들을 분리하기 위한 매커니즘도 제공하는데, 그럼으로써 클래스가 많은 기능으로 어지럽혀지는 일을 막을 수 있습니다. 
그러나, 비지터 패턴들은 유혹적이라서 남용되기 쉽다. 이 패턴들이 도움이 된다면 사용하되, 이들의 필요성이 대해 합리적인 회의주의적 태도를 유지해야 합니다. 비지터로도 해결할 수 있지만 더 간단하게 해결할 수 있는 경우도 많이 있기 때문이다. -클린 소프트웨어 p532

 

댓글