본문 바로가기
Lecture

자바 디자인 패턴: 데코레이터 패턴, 콤포지트 패턴, 어댑터 패턴

by Renechoi 2023. 7. 5.

 

1.  데코레이터 패턴 Decorator Pattern 

 

 

데코레이터 패턴이란? 

- 장식과 실제 내용물을 동일시 -> 이질적인 객체들을 동일한 방식으로 핸들링할수 있다. 

- 객체에 동적으로 책임을 추가한다. 

- 자바 I/O stream에서도 사용한다.

 

 

기존 클래스에 +알파를 할때, 상속을 생각하기 쉬운데, Is-A 관계가 아니라 결합도를 보다 낮추면서도 유연하게 하는 방식 

 

 

 

 

의도와 동기 

- 상속을 사용하지 않고 기능의 유연한 확장이 가능한 패턴

-> 복잡한 hierarchy를 회피한다.

- 객체에 동적으로 새로운 서비스를 추가 할 수 있음

- 전체가 아닌 개별적인 객체에 새로운 기능을 추가 할 수 있음

 

 

예를 들어서 어떤 클래스에는 이것이 필요하고 어떤 클래스에는 이것이 필요 없을 때 상속을 사용하면 필요 없는 것들까지도 다 받게 된다. 데코레이터 패턴을 사용하면 필요한 부분만 데코레이트 해주면 된다. 

 

 

클래스 다이어그램 

상위 클래스로 Component로 정의 

 

- Component : 동적으로 추가할 서비스를 가질 수 있는 객체 정의 -> 실제 I/O를 하는 객체 or 커피 

- ConcreteComponent : 추가적인 서비스가 필요한 실제 객체

- Decorator : Component의 참조자를 관리하면서 Component에 정의된 인터페이스를 만족하도록 정의

- ConcreteDecorator : 새롭게 추가되는 서비스를 실제 구현한 클래스로 addBehavior()를 구현한다.

 

 

 

클라이언트 쪽에서 쓸 때는 실제 Object가 무엇인지 알 수 없다. 

여러가지 데코레이터들은 혼자 돌아가지 않고 반드시 또 다른 컴포넌트를 포함을 해야 기능을 한다. 

 

 

 

예시 

 

 

예를 들어서 커피점을 구현한다고 해보자. 여러가지 커피 객체들을 만들어야 한다. 

 

이때, 기본 커피가 있고, 커피에 추가되는 milk, mocha, syrup 등등이 있을 수 있다. 

 

 

먼저 추상 클래스로서 커피를 만들어보자. 

 

public abstract class Coffee {
   
   public abstract void brewing();
}

 

 

종류에 따라 다음과 같은 커피들이 있을 수 있다. 

public class KenyaAmericano extends Coffee{

   @Override
   public void brewing() {
      System.out.print("KenyaAmericano ");
   }

}
public class EtiopiaAmericano extends Coffee{

   @Override
   public void brewing() {
      System.out.print("EtiopiaAmericano ");
   }

}

 

데코레이터를 구현해보자.  데코레이터는 커피를 가지고 있어야 한다. 

 

public abstract class Decorator extends Coffee{

   Coffee coffee;
   public Decorator(Coffee coffee){
      this.coffee = coffee;
   }
   
   @Override
   public void brewing() {
      coffee.brewing();
   }

}

 

여기서 생성자 파라미터로 들어오는 애는 데코레이터거나 다른 Object이다. 

 

기본적으로 상위 객체가 하는 일을 해준다. 

 

만약 I/O 클래스라면 어떨까? 

파일 스트림이 들어왔다면 파일 I/O가 일어난다. 

 

 

 

이제 구체적인 데코레이터 구현 클래스를 작성해보자. 

 

public class Latte extends Decorator{

	public Latte(Coffee coffee) {
		super(coffee);
	}
	
	public void brewing() {
		super.brewing();
		System.out.print("Adding Milk ");
	}
}
public class Mocha extends Decorator{

   public Mocha(Coffee coffee) {
      super(coffee);
   }

   public void brewing() {
      super.brewing();
      System.out.print("Adding Mocha Syrup ");
   }
}

 

public class WhippedCream extends Decorator{

   public WhippedCream(Coffee coffee) {
      super(coffee);
   }

   public void brewing() {
      super.brewing();
      System.out.print("Adding WhippedCream ");
   }
}

 

 

이제 이 데코레이터들을 하나씩 사용해보자. 

 

먼저 가장 기본이 되는 커피를 만들어보자.

Coffee kenyaAmericano = new KenyaAmericano();
kenyaAmericano.brewing();
System.out.println();

 

 

케냐 아메리카노를 라떼로 먹는다고 하면 

 

Coffee kenyaLatte = new Latte(kenyaAmericano);
kenyaLatte.brewing();
System.out.println();

 

 

여기다가 데코레이터를 추가해보자. 

 

Mocha kenyaMocha = new Mocha(new Latte(new KenyaAmericano()));
kenyaMocha.brewing();
System.out.println();
WhippedCream etiopiaWhippedMocha =
   new WhippedCream(new Mocha(new Latte( new EtiopiaAmericano())));
etiopiaWhippedMocha.brewing();
System.out.println();

 

 

 

마찬가지로 자바에서 소켓을 사용하는 예시도 비슷하게 살펴보자. 

 

Socket socket = new Socket();
socket.getInputStream();

 

이렇게 했을 때 inputStream을 불러오면 영어로만 통용되기 때문에 이를 감싸주어야 한다. 

 

new InputStreamReader(socket.getInputStream());

 

이를 bufferedReader로도 감쌀 수 있다. 

 

new BufferedReader(new InputStreamReader(socket.getInputStream()));

 

 

 

 

결론 

 

- 단순한 상속보다 설계의 융통성을 증대

- Decorator의 조합을 통해 새로운 서비스를 지속적으로 추가할 수 있다 !

- 필요없는 경우 Decorator를 삭제하면 됨 

- Decorator와 실제 컴포넌트는 동일한 것이 아님

- 작은 규모의 객체들이 많이 생성될 수 있음 -> 플라이 웨이트 패턴 

- e.g. 자바의 I/O 스트림 클래스

 

 

 

 

 

 

 

2.  콤포지트 패턴 Composite Pattern 

 

 

콤포지트 패턴이란? 

- 그릇과 내용물을 동일시 한다. 

   -> 어떤 객체는 다른 객체를 포함해서 구현될 때가 있다. 포함 관계를 멤버 변수로 둘 것이냐, 동일시할 것이냐의 문제. 

   -> 디렉토리 안에 디렉토리가 포함된 구조 

   -> 재귀적인 구조가 되는데 각각을 전부 코딩을 하기 보다는 한꺼번에 같은 타입으로 핸들링하는 것이 편할 경우가 있다. 

- 트리 구조로 구성하여 전체-부분 계층을 나타내는 패턴.

 

 

 

 

의도와 동기 

- 부분과 전체에 대한 복합 객체의 트리구조

   ->  개별 객체는 "Leaf"라고도 불리며, 복합 객체는 "Composite"라고 불린다. 

- 클라이언트가 개별 객체와 복합 객체를 동일하게 다룰 수 있는 인터페이스 제공 

   -> 개별 객체와 복합 객체를 구별하지 않고 사용하게 한다. 

- 재귀적인 구조 

 

 

 

 

클래스 다이어그램 

 

 

복합객체 오퍼레이션들은 또 다른 컴포넌트를 자식들을 포함하고 있다.

즉, 디렉토리 안에 디렉토리가 있을 수도 있고 파일이 있을 수도 있음.

 

 

 

Component
  - 전체와 부분 객체에서 공통적으로 사용할 인터페이스 선언
  - 전체와 부분 객체에서 공통으로 사용할 기능 구현
  -전체 클래스가 부분요소들을 관리하기 위해 필요한 인터페이스 선언


Leaf
  - 집합 관계에서 다른 객체를 포함할 수는 없고 포함되기만 하는 객체로 가장 기본이 되는 기능을 구현


Composite
  - 여러 객체를 포함하는 복합 객체에 대한 기능 구현
  - 포함한 여러 객체를 저장하고 관리하는 기능을 구현


Client
  - Component에 선언된 인터페이스를 통하여 부분과 전체를 동일하게 처리

 

 

 

 

예시 

 

제품의 카테고리를 계층 구조를 통해 구현하는 것을 생각해보자. 

 

Product를 Leaf가 되고, Category가 Composite가 됨.

 

 

카테고리는 ProductCategory 타입의 리스트를 가짐 -> 프로덕트를 갖거나 또 다른 카테고리를 갖게 된다. 

 

자기 하위의 product를 카운팅 할 수 있다. 이때, product인 경우 자신이므로 1로 반환하지만, 카테고리라면 자기 밑의 카운트를 또 세어야 한다. 

 

 

먼저 다음과 같이 카테고리를 정의한다.

 

public abstract class ProductCategory {

   int id;
   String name;
   int price;
   
   public ProductCategory(int id, String name, int price) {
      this.id = id;
      this.name = name;
      this.price = price;
   }
   
   public abstract void addProduct(ProductCategory product);
   public abstract void removeProduct(ProductCategory product);
   public abstract int getCount();
   public abstract int getPrice();
   public abstract int getId();
   
}

 

Leaf 객체가 되는 Product를 만든다. 

 

public class Product extends ProductCategory{

   @Override
   public int getCount() {
      return 1;
   }

   @Override
   public String getName() {
      return name;
   }

   @Override
   public int getPrice() {
      return price;
   }

   @Override
   public int getId() {
      return id;
   }
   
   public Product(int id, String name, int price) {
      super(id, name, price);
   }

   @Override
   public void addProduct(ProductCategory product) {
      
   }

   @Override
   public void removeProduct(ProductCategory product) {
      
   }

}

 

 

 

카테고리는 List로서 productCategory를 갖는다. 

 

public class Category extends ProductCategory{
   

   ArrayList<ProductCategory> list;
   
   public Category(int id, String name, int price) {
      super(id, name, price);
      list = new ArrayList<ProductCategory>();
   }
   
   @Override
   public void addProduct(ProductCategory productCategory) {
      list.add(productCategory);
   }

   @Override
   public void removeProduct(ProductCategory productCategory) {
      
      for(ProductCategory temp : list) {
         if(temp.getId() == productCategory.getId()) {
            list.remove(temp);
            return;
         }
      }
      System.out.println("카테고리가 없습니다.");
   }

   @Override
   public int getCount() {
      int count = 0;
      
      for(ProductCategory temp : list) {
         count += temp.getCount(); 
      }  
      return count;
   }

   @Override
   public String getName() {
      return list.toString();
   }

   @Override
   public int getPrice() {
      int price = 0;
      
      for(ProductCategory temp : list) {
         price += temp.getPrice(); 
      }
      
      return price;
   }

   @Override
   public int getId() {
      return 0;
   }
}

 

 

 

클라이언트에서 실제 사용 예시를 살펴보자. 

 

먼저 남자와 여자 카테고리를 설정 

ProductCategory womanCategory = new Category(1234, "Woman", 0);
ProductCategory manCategory = new Category(5678, "Man", 0);

 

각각에 세부 카테고리를 넣어준다. 

 

ProductCategory clothesCategoryW = new Category(2345, "Clothes", 0);
ProductCategory bagCategoryW = new Category(3456, "Bag", 0);
ProductCategory shoesCategoryW = new Category(9876, "Shoes", 0);

womanCategory.addProduct(clothesCategoryW);
womanCategory.addProduct(bagCategoryW);
womanCategory.addProduct(shoesCategoryW);

ProductCategory clothesCategoryM = new Category(23450, "Clothes", 0);
ProductCategory bagCategoryM = new Category(34560, "Bag", 0);
ProductCategory shoesCategoryM = new Category(98760, "Shoes", 0);

manCategory.addProduct(clothesCategoryM);
manCategory.addProduct(bagCategoryM);
manCategory.addProduct(shoesCategoryM);

 

이제 해당 카테고리에 적절한 제품들을 추가해보자. 

 

ProductCategory shoes1 = new Product(121, "Nike", 100000);
ProductCategory shoes2 = new Product(122, "ADIDAS", 200000);
ProductCategory shoes3 = new Product(123, "GUCCI", 300000);
ProductCategory shoes4 = new Product(124, "BALENCIA", 400000);
ProductCategory shoes5 = new Product(125, "PRADA", 500000);
ProductCategory shoes6 = new Product(126, "BALLY", 600000);

shoesCategoryW.addProduct(shoes1);
shoesCategoryW.addProduct(shoes2);
shoesCategoryW.addProduct(shoes3);

shoesCategoryM.addProduct(shoes4);
shoesCategoryM.addProduct(shoes5);
shoesCategoryM.addProduct(shoes6);


ProductCategory bag1 = new Product(121, "HERMES", 500000);
ProductCategory bag2 = new Product(122, "LOUISVUITTON", 500000);
ProductCategory bag3 = new Product(123, "GUCCI", 500000);
ProductCategory bag4 = new Product(124, "BALENCIA", 500000);
ProductCategory bag5 = new Product(125, "PRADA", 500000);
ProductCategory bag6 = new Product(126, "MULBERRY", 500000);

bagCategoryW.addProduct(bag1);
bagCategoryW.addProduct(bag2);
bagCategoryW.addProduct(bag3);

bagCategoryM.addProduct(bag4);
bagCategoryM.addProduct(bag5);
bagCategoryM.addProduct(bag6);

 

 

 

맨 위에 최상위로 Woman과 Man이 있고 하위로 옷, 가방, 신발 카테고리가 있고, 맞는 카테고리에 shoes와 bag을 넣어주었다. 

 

W - Clothes 

     - Bag          - 3개 추가 

     - Shoes      - 3개 추가 

 

M - Clothes

     - Bag          - 3개 추가 

     - Shoes      - 3개 추가 

 

 

System.out.println(womanCategory.getCount());
System.out.println(womanCategory.getPrice());
System.out.println(manCategory.getCount());
System.out.println(manCategory.getPrice());

 

 

카테고리에서 카테고리로 들어가서 recursive한 호출을 해서 위와 같은 결과를 리턴한다. 

 

@Override
public int getPrice() {
   int price = 0;
   
   for(ProductCategory temp : list) {
      price += temp.getPrice(); 
   }
   
   return price;
}

 

이렇게 product와 category를 한꺼번에 핸들링 하는 것은 전체를 포함할 수 있는 동일한 객체로 만들었기 때문이다. 

 

 

 

 

결론 

 

- 기본 객체는 복합 객체에 포함이 되고, 복합 객체 역시 또 다른 복합 객체에 포함될 수 있다.

- 클라이언트 코드는 기본객체와 복합객체에 대한 일관된 프로그래밍을 할 수 있다.

- 기본 객체가 증가하여도 전체 객체의 코드에 영향을 주지 않는다.

- 새로운 요소의 추가가 편리하고 범용성 있는 설계가 가능하다.

 

 

 

 

 

3.  어댑터 패턴 Adapter Pattern 

 

 

어댑터 패턴이란? 

- 서로 다른 인터페이스를 중간에서 연결해주는 패턴 -> 호환 역할 

- 이미 사용중이거나 정의된 인터페이들을 중간에서 맞춰서 적용할 수 있다. 

- e.g. 안드로이드 ListView Adapter  -> 뷰의 종속성을 낮춰주는 예시 

 

 

 

 

 

 

 

의도와 동기 

- 2개짜리가 필요한 데 3개짜리가 제공되면 -> 바꿔줘야 한다.

-이때 클라이언트에서 사용하던 방식대로 호출하여 사용할 수 있도록 조정해주는 기능
- 서로 일치하지 않는 인터페이스를 변경하지 않고 중간에서 호출하여 사용하는 것이 핵심 
- Wrapper

 

 

 

클래스 다이어그램 

 

두 가지 방식을 생각해볼 수 있다 -> 상속 or 합성 

 

- 상속을 활용하여 구현하는 Adapter

 

 

- 객체 합성의 방법으로 구현하는 Adapter

 

합성에서는 Adapter가 adaptee를 갖는다. 

 

합성의 장점은 adaptee 밑의 다른 서브 클래스가 있다고 할 때, 해당하는 클래스들도 유연하게 접근할 수 있다는 장점이 있다. 

(상속 -> 클래스 간의 결합도가 높아지기 때문에 재사용 관점에서 접근xx) 

 

- Target : 클라이언트가 사용할 인터페이스를 정의 하고 있는 클래스
- Client : Target 인터페이스를 사용하는 객체
- Adaptee : 실제의 새롭거나 적용될 기능이 제공되는 클래스
- Adapter : Target 인터페이스에 Adaptee의 인터페이스를 맞춰주는 클래스

 

 

 

 

예시 

 

프린트를 하는 기능을 어댑터를 통해 하는 것으로 살펴보자. 

 

먼저 인터페이슬 Print를 선언한다. 클라이언트 쪽에서 Print를 쓰는데, 이를 Banner를 통해 사용한다. 

public interface Print {
    void printWeak();
    void printStrong();
}

 

어떤 스트링이 들어오면 출력을 하되, 괄호를 넣거나 별표를 같이 출력하는 Banner 클래스 

 

public class Banner {
    private String string;
    public Banner(String string) {
        this.string = string;
    }
    public void showWithParenthesis() {
        System.out.println("(" + string + ")");
    }
    public void showWithAster() {
        System.out.println("*" + string + "*");
    }
}

 

 

Print와 Banner를 연결해주는 어댑터를 만든다. 

 

합성의 방식으로 구현하면 다음과 같다. 

 


public class PrintBanner implements Print {
    private Banner banner;  //Composition
    public PrintBanner(String string) {
        this.banner = new Banner(string);
    }
    public void printWeak() {
        banner.showWithParenthesis();
    }
    public void printStrong() {
        banner.showWithAster();
    }
}

 

 

Banner를 그냥 호출해주면 되는데, 이때 Banner 하위 객체들이 있다면 해당하는 객체들로 초기화할 수도 있다. 

 

public PrintBanner(String string) {
    this.banner = new Banner(string);
}

 

사용하는 예시는 다음과 같다. 

 

public class AdapterMain {
   public static void main(String[] args) {
      Print p = new PrintBanner("Hello");
      p.printWeak();
      p.printStrong();
   }
}

 

 

-> Adapter의 기능은 다른 객체를 포함하거나 상속해서, 원래 제공하는 인터페이스 형식으로 변환해서 다양하게 제공 

 

 

 

 

 

 

결론 

 

- Adpter를 사용함으로써 클라이언트가 사용하는 방식은 동일하면서 여러 기능이 제공될 수 있다.

- 안드로이드의 예시 :

  -> 여러 View (ListView 나 GridView)를 구현할 때 이에 대한 Item들을 직접 View에 올리지 않고, Adapter를 이용하여 Adapter에서 Item을 관리하고 그리는 방식을 정하도록 한다.

 -> 실제 보여주는 부분과 데이타를 분리하여 데이타가 다양한 방식의 View에 활용

 -> 이 때 중간에서 사용되는 여러 Adapter (BaseAdapter, ListAdapter)등이 데이터와 View를 연결해준다.

 

다음과 같은 안드로이드 예제를 보자. 

 

 

 

protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        ListView listview = (ListView)findViewById(R.id.listview);
        selected_item_textview = (TextView)findViewById(R.id.selected_item_textview);
 
        List<String> list = new ArrayList<>();  //데이터 저장용
 
        //...

 

String을 리스트에 넣어 관리한다고 하면 어댑터를 만들 때 리스트를 넣어줌 -> 어댑터가 리스트를 관리한다. 

 

        ArrayAdapter<String> adapter = new ArrayAdapter<>(this,android.R.layout.simple_list_item_1, list);  //마지막 인자로 list
        listview.setAdapter(adapter);  //adapter지정

 

이후 데이터를 add 할 때, 결국 이 리스트가 adapter의 인자로 붙어 있기 때문에 listview는 adpater만을 본다. 

 

        listview.setOnItemClickListener(new AdapterView.OnItemClickListener() {
 
            @Override
            public void onItemClick(AdapterView<?> adapterView,
                                    View view, int position, long id) {
 
                String selected_item = (String)adapterView.getItemAtPosition(position);
                selected_item_textview.setText(selected_item);
            }
        });
         
        list.add("사과");
        list.add("배");
        list.add("귤");
        list.add("바나나");
      
    }

 

즉, 핵심은 뷰에다가 직접 데이터를 뿌리는 것이 아니라 중간에 어댑터를 두고 해당하는 어댑터를 통해 연결함. 

 

어댑터가 바뀌면 같은 리스트 뷰에도 보이는 데이터가 바뀔 수 있음 

 


참고자료

- 패스트 캠퍼스 (박은종의 객체지향 설계를 위한 디자인패턴 with 자바)

반응형