다양한 디자인 패턴 - (2) 행동 패턴

귀엽다

 

행동 패턴?

 행동 패턴은 '객체 사이의 상호작용 또는 객체의 책임 할당'과 밀접한 관련이 있는 디자인 패턴이다. 한 객체가 수행할 수 없는 작업을 여러개의 객체로 분배하는 등의 작업을 통해서 객체 사이의 결합도를 최소화하는데 중점을 두는 패턴이다.

 

 

템플릿 메서드 패턴

 부모 클래스에서 골격을 정의하면 자식 클래스가 알고리즘의 특정 메소드를 오버라이딩하여 사용할 수 있도록 한다. 다시 말하자면, 부모 클래스에 변하지 않는 템플릿을 두고, 변하는 부분을 메소드의 형태로 자식 클래스에 두어 상속을 사용하여 문제를 해결한다.

 

 예를 들어 다음과 같이 메소드 실행에 대한 로깅을 한다고 가정하자. 

 

이해를 위한 코드이므로 불편해하지 말자

 

핵심 비즈니스 로직의 시작과 끝에 로깅을 위한 코드가 추가되었다. 이는 개발자가 핵심 코드에 집중할 수 없게 할 뿐만 아니라, SRP원칙에도 위배된다. 위와 같은 방법으로 로깅 시스템을 구축했다가는 로깅 메세지 한글자를 바꾸려고 전체 서비스코드를 수정해야 하는 불상사가 발생할 수 있다.

 

 

 그래서 대신 추상 클래스를 사용하고, call()이라는 추상 메소드는 자식 클래스 객체 생성시 구현하여 사용하도록 했다. 이렇게 템플릿 메서드 패턴을 통해서 상속과 오버라이딩을 통한 다형성을 이용하여 전체 구조는 건드리지 않으면서 특정 부분만 재정의할 수 있게 된다.

 

하지만

 이러한 템플릿 메소드 패턴은 상속을 사용한다. 상속과 합성에 대한 글에서도 말했듯이, 상속은 자식 클래스에서 부모 클래스의 구현법에 대한 지식을 필요로 하고, 따라서 부모 객체의 캡슐화를 약화시킨다.

 상속은 재사용이 아니라 대체(is-a)관계에서만 사용해야 한다고 했던 적이 있다. 위 코드에서 우리는 재사용을 위해서 상속을 사용했고, 그 결과 자식 클래스는 상속받는 부모 클래스의 다른 기능을 사용하지 않음에도 부모 클래스에 강하게 의존하고 있다.

 

이와 같은 문제를 해결하기 위해서 밑에 나올 전략 패턴을 사용할 수 있다.

 

 

전략 패턴

 공통 부분을 클래스(Context)에 두고, 변하는 부분을 인터페이스(Strategy)로 만들어 해당 인터페이스를 구현하도록 한다. 공통 부분을 클래스에서 직접 상속받는 템플릿 메서드 패턴과 다르게 인터페이스를 활용한다는 점에서 차이가 존재한다.

 

옛날에 상속 및 합성에 관한 포스팅에서 사용한 예시를 가져왔다.

 

// 인터페이스
interface Brain {
  void work();
}

// 구현체
class BrainV1 implements Brain {
  @Override
  void work() {
    System.out.println("1번 뇌 일하는 중");
  }
}

 

 

방법 1) 생성자 주입

public class Animal {
  private Brain brain;

  public Animal(Brain brain) {
    this.brain = brain; // 의존성 주입
  }
  
  public void think() {
    brain.work(); // 인터페이스에 의존
  }
}
Animal myComposedAnimal = new Animal(new BrainV1()); // 생성자를 이용한 의존성 주입

myComposedAnimal.think(); // BrainV1에 구현한 think메소드 동작
myComposedAnimal.think();
myComposedAnimal.think();

 

 

방법 2) 파라미터 주입

public class Animal {
  
  public void think(Brain brain) { // 파라미터로 구현체 주입
    brain.work(); // 인터페이스에 의존
  }
}
Animal myComposedAnimal = new Animal();

myComposedAnimal.think(new BrainV1()); // 파라미터를 이용한 의존성 주입
myComposedAnimal.think(new BrainV2());
myComposedAnimal.think(new BrainV3());

 

 

 첫 번째 방법은 생성자를 이용하여 Animal 내의 Brain 인터페이스 타입 필드에 구현체를 저장해놓고 이용하는 방식이고, 아래는 메소드 실행시에 파라미터로 Brain타입 구현체를 전달하는 방법이다.

 

사용 예시에서 보이듯이 전자는 이미 생성해놓은 객체의 메소드를 반복 호출해도 동일한 동작을 하지만, 다른 구현체를 이용하려면 다른 Animal객체를 다시 만들어주어야 하는 반면, 후자는 하나의 Animal객체로도 여러개의 구현체를 받아 사용할 수 있으나 메소드 호출시마다 파라미터로 구현체를 넘겨주어야 한다.

 

아마도 개발자가 판단하여 사용하려는 의도와 상황에 따라서 적절하게 사용하면 될 것 같다. 참고로 2번째 방법의 경우 스프링에서는 '템플릿 콜백 패턴'이라는 이름으로 불리기도 한다.

 

 

옵저버 패턴

 관찰자를 의미하는 옵저버(observer)에서 알 수 있듯이, 옵저버 패턴이란 관찰자가 관찰하고 있는 대상의 상태 변화를 관찰자에게 알리는 행동 패턴이다. 

고객이 새로운 상품이 입고되었는지를 확인하려고 한다고 하자. 고객은 상품을 팔고 있는 가게에 직접 접근하여 상품의 재고를 확인할 수 있지만, 상품이 존재하지 않는다면 불필요한 접근이 계속 이루어질 것이다. 혹은 가게에서 상품이 입고되면 모든 잠재 고객들에게 입고에 관련된 알림을 보낼 수도 있다. 하지만 이 경우도 상품에 구매 의향이 없는 고객들에게도 불필요한 알림이 간다는 문제가 있다.

 

https://refactoring.guru/ko/design-patterns/observer

 

 이를 해결하기 위한 방법이 옵저버 패턴이다. Kafka나 Redis의 pub-sub, Azure의 Service bus등을 사용해 보았다면 그와 유사한 구조를 가진다고 생각하면 된다. (차이가 존재하기는 한다. 사실 위와 같이 메시지 브로커를 사용하는 비동기방식의 처리 프로세스는 옵저버 패턴보다는 Pub-Sub 패턴에 가깝다.)

 

 

public class StoreImpl implements Store {

    private List<Client> subscribers = new LinkedList<>();

    @Override
    public void registerClient(Client client) {
        subscribers.add(client);
    }

    @Override
    public void removeClient(Client client) {
        subscribers.remove(client);
    }

    @Override
    public void informClients() {
        for (Client subscriber : subscribers) {
            subscriber.inform();
        }
    }
}
public class Client {

    private String name;

    public Client(String name) {
        this.name = name;
    }

    public void inform() {
        System.out.println("notice to "+name);
    }
}

 

Store는 Client를 등록할 수 있고, informClients 메서드를 실행시 등록된 클라이언트에게만 메시지가 간다.