C++로 Adapter & Facade 패턴 이해하기

Kang Chang Hwan·2024년 5월 30일

CS - Design-Pattern

목록 보기
5/6

오리와 칠면조 이야기

int main() {
  Turkey* turkey = new WildTurkey();
  Duck* duckAdapter = new DuckAdapter(turkey);

  cout << "칠면조가 말하길" << endl;
  // 나는 칠면조지만 내 특징으로도 오리의 기능을 사용하고 싶어
  duckAdapter->quack();
  duckAdapter->fly();

  return 0;  
};

여기서 Client는 duckAdapter->quack()과 fly()부분이다.

그리고 Target 인터페이스는 Duck인데 왜냐하면 클라이언트가 호출한 quack과 fly메서드는 아래와 같이 Duck(Target) 인터페이스의 메서드기 때문이다.

class Duck{
public:
  virtual void quack();
  virtual void fly();
};

위 다이어그램에서 Target 인터페이스를 구현한 Adapter는 DuckAdapter이고 어댑터에 들어온 요청(quack, fly)는 Adaptee인 Turkey 즉, 위에서 야생 칠면조(WildTurkey 객체)가 요청을 받아서 이행한다. 해당 설명에 대한 코드는 아래에서 볼 수 있다.

class DuckAdapter : public Duck {
private:
  Turkey* turkey;
public:
  DuckAdapter(Turkey* turkey) { this->turkey = turkey; }
  void quack() {
    turkey->gobble();
  }
  void fly() {
    turkey->fly();
  }
};

이렇게 클라이언트가 Target(Duck)의 인터페이스를 통해 quack()과 fly()를 호출하면 Target을 구현한 Adapter(해당 예제에서 DuckAdapter)가 요청을 받고 그 요청은 결국 turkey에게 전달되므로 야생 칠면조가 gobble()과 fly()를 수행한다.

객체와 클래스 어댑터 이야기

어댑터에는 객체 어댑터(Object Adapter)와 클래스 어댑터(Class Adapter)가 있다.

이 둘의 차이는 Adapter를 만들 때 구성(Composition) vs 상속(Inheritance)을 사용하는 것이다.

코드 예제로 이해하기 위해 위에서 봤던 코드 구조를 복기해보자.

class DuckAdapter : public Duck {
private:
  Turkey* turkey;
public:
  DuckAdapter(Turkey* turkey) { this->turkey = turkey; }
  void quack() {
    turkey->gobble();
  }
  void fly() {
    turkey->fly();
  }
};

DUCK이라는 인터페이스를 상속하고 Adaptee인 Turkey를 component(인스턴스 변수)로 선언해 클라가 Duck의 인터페이스를 통해 요청하면 adapter가 받아서 turkey에게 요청을 위임한다.

하지만 상속을 통해 어댑터를 만들게 되면 다음과 같은 구조가 된다.

class ClassDuckAdapter : public Duck, Turkey {
public:
  void quack() {
    gobble();
  }
  void fly() {
    fly();
  }
};

Duck 인터페이스까지는 작동할 수 있으나 Adaptee의 경우에는 인터페이스를 활용할 수 없어서 concrete class를 상속해야 클라의 요청을 실제 구현체에 전달할 수 있게 되는 것이다.

따라서 만약에 수백개의 concrete class에 대한 adapter를 상속을 통해 만드려면 그에 대응되는 수백개의 어댑터를 만들어야 할 것이다.

반면에 객체 어댑터는 Adaptee를 인터페이스를 인스턴스 변수에 할당해 사용할 수 있기 때문에 그 인터페이스를 구현한 것이라면 하나의 어댑터로도 구현할 수 있을 것이다.

데코레이터와 어댑터 이야기

간단하게 데코레이터를 설명하기 위해서 아래 소스 코드를 작성했다. 간단하게 말해서, 기존의 코드를 변경하지 않고 새로운 책임을 추가할 수 있다.

예를 들어, 카페에 가서 키오스크에 유자티를 하나 추가하고 첨가물(Condiment)에 민트를 추가하는 상황이다. 이를 코드로 어떻게 구현할 수 있을까?

class Beverage {
public:
  virtual double cost();
};

class Sitrus : public Beverage {
public:
  Sitrus(Condiment* condiment){ this->condiment = condiment; }
  double cost(){
    return condiment->cost() + 3000;
  }
}

class CondimentDecorator : public Beverage {};

class Mint : public CondimentDecorator {
private:
  Beverage* beverage;
public:
  Mint(Beverage* beverage){ this->beverage = beverage; }
  double cost() override {
    return beverage->cost() + 500;
  }
};

int main() {
  Beverage* sitrus = new Sitrus();
  Mint mint = Mint(sitrus);
  
  // 3500원
  mint->cost();
  
  return 0;
};

CondimentDecorator가 beverage를 상속한 이유는 첨가물이 하나 더 생긴다고 해보자.

그럼 Mint class에서 보듯 Beverage 타입의 포인터를 프로퍼티로 가지고 있는데 첨가물(Condiment) 또한 Beverage타입이기 때문에 upcasting할 수 있으므로(CondimentDecorator가 Beverage를 상속받았기 때문) 첨가물을 더할 때 기존 코드를 변경하지 않아도 되는 유연성이 생긴다.

즉, 위에서 Mint에 시럽을 추가한다고 해보자. 만약에 Beverage를 상속받지 않았다면 시럽은 민트를 추가하기 위해 Mint 인스턴스를 프로퍼티로 선언해야 할 것이다. 그럼 해당 시럽은 민트만을 위한 첨가물이 되버리기 때문에 유연성이 떨어진다.

따라서 데코레이터가 감쌀 객체의 타입을 몰라도(여기서 Mint는 자기가 감싸는 객체가 Sitrus인지 모름) 그 객체의 기능을 사용할 수 있게 해준다.

어댑터도 객체를 감싸는 건 같지만 인터페이스를 변경해준다는 점에서 다르다.

위 오리와 칠면조 이야기에서 볼 수 있듯이,

오리의 quack, fly 메서드(인터페이스)를 호출하지만 실제로는 Turkey의 gobble(), fly(인터페이스)가 호출된다.
즉, 인터페이스의 변환이 일어난다.

Pacade pattern 이야기

Pacade pattern은 복잡한 인터페이스를 단순화하는 것을 목적으로 하는데, 예를 들어 집에서 영화를 보려면 다음과 같은 작업이 필요하다고 해보자.

  1. 팝콘 기계 켜기
  2. 팝콘 기계 작동하기
  3. 불 끄기
  4. 스크린 내리기
  5. 프로젝터 키기
  6. 앰프 켜기
  7. 앰프의 입력을 streaming 플레이어로 설정하기
  8. 앰프 볼륨 맞추기
  9. 스트리밍 플레이어 키기
  10. 영화 재생하기

이는 "영화 키기" 라는 하나의 행동을 하기 위해 필요한 것들이다. 이를 코드화한다고 생각해보면, 이를 모두 클라에서 팝콘 기계, 전등, 빔프, 앰프, 스트리밍 플레이어의 인터페이스를 통해 하나하나 기억해서 호출해야 된다.

이 때 파사드 패턴이 등장한다.

즉, 영화를 보기 위해 필요한 객체들을 인스턴스 변수에 할당하여 선언하고 클라가 필요한 행동만 추상화해서 메서드를 만든다.

그리고 그 안에 각 행위(영화 보기, 끄기 등..)마다 필요한 객체들을 활용해 로직을 구성하여 단순화하는 것이다.

이를 통해 파사드 패턴을 통해 인터페이스를 단순화 + 클라로부터 구성 요소(앰프, 빔프 등)으로 부터 분리시킬 수 있다.

최소 지식의 원칙(Principle of Least Knowledge)

"진짜 친구만 불러..."

최소 지식 원칙의 비유인데 ㅋㅋ 자세한 내용은 다음과 같다.

최소 지식의 원칙에 따르면 다음 4가지를 통해 메서드를 호출하라고 한다.

  1. 객체 자체
  2. 메소드의 파라미터로 전달된 객체
  3. 메소드가 생성하거나 인스턴스화하는 객체

    다른 메소드에 의해 반환된 객체들의 메소드를 호출하지 말라고 한다.

  4. 객체의 component

    이때 Component는 인스턴스 변수(instance variable)에 의해 참조(Refernce)되는 객체를 의미한다. 즉, HAS-A 관계에 있는 것을 말함.

그럼 아래 예시에서 최소 지식의 원칙을 위배하는 것은 무엇이 있을까?

class House {
public:
  WeatherStation station;
  
  float getTemp(){
    return station.getThermometer().getTemperature();
  }
};

class House {
public:
  WeatherStation station;
  
  float getTemp(){
    Thermometer thermometer = station.getThermometer();
    return getTempHelper(thermometer);
  }
  
  float getTempHelper(Thermometer thermomter){
    return thermometer.getTemperature();
  }
};
  1. station.getThermometer().getTemperature();
    내가 생각하기엔 이 메서드 호출 방식은 위 원칙을 위배하는 것이라 생각한다. 왜냐하면 station.getThermometer()는 클래스의 component(station)의 메소드를 호출하지만 그렇게 반환된 객체의 메서드를 호출하고 있기 때문(getTemperature())이다.

  2. 아래는 다 괜찮다고 했다.
    왜냐하면 이유는 다음과 같다.
    1) Thermometer thermometer = station.getThermometer();
    원칙 3에 의해 메소드에서 인스턴스화(thermometer) + 클래스 내 구성요소의 메서드를 호출하기 때문(station.getThermometer();)
    2) return getTempHelper(thermometer);

    • 클래스 내 다른 메서드(getTempHelper)라 괜찮.
      3) getTempHelper는 thermometer를 인자로 받아 해당 인자의 메서드를 호출하기 때문에 원칙 2. 메소드의 인자로 전달된 객체 이므로 괜찮.

내가 해석한 바로는 최대한 클래스 안에 있는 객체를 통해 메소드를 호출하라는 것인데, 그 이유는 메소드를 호출한 결과로 리턴받은 객체에 들어있는 메소드를 호출하면 그 객체와 직접적으로 알고 지내는 것이기 때문이다.

profile
아쉬움 없이 살자. 모든 순간을 100%로!

0개의 댓글