안녕하세요. J4J입니다.
이번 포스팅은 데코레이터 (Decorator) 패턴에 대해 적어보는 시간을 가져보려고 합니다.
Decorator 패턴이란?
decorator 패턴은 객체를 동적으로 유연하게 확장하여 새로운 기능들을 추가하도록 도와주는 디자인 패턴입니다.
decorator 패턴에 대해서 가장 쉽게 얘기해 볼 수 있는 것은 특정 객체 자체에는 변화가 없고 단순히 새로운 장식들을 추가해 줘야 되는 상황이라고 말할 수 있습니다.
일상에서 쉽게 생각해볼 수 있는 예시로 햄버거를 구매할 때를 말해보겠습니다.
햄버거를 구매하기 위해 우리가 많이 아는 프랜차이즈 집으로 가게 될 경우 기본적인 햄버거 구성품들이 나열되어 있는 것을 확인할 수 있습니다.
하지만 구매자에 따라 기본 구성이 이루어진 햄버거를 주문할 때 추가 구성에 대해서도 주문을 해볼 수 있습니다.
어떤 사람은 소스를 더 추가할 수도 있고, 또 다른 사람은 야채를 더 추가할 수도 있습니다.
그리고 이렇게 추가가 된 상품들은 모두 서로 다른 객체로써 활용될 수 있고, 이를 코딩을 하는 관점에서 구현해야 된다고 하면 decorator 패턴은 확장성 있는 개발을 할 수 있도록 도와주는 선택지가 될 수 있습니다.
decorator 패턴의 구조는 다음과 같습니다.
diagram에서 확인되는 용어는 다음과 같이 설명드릴 수 있습니다.
- Component → 구성하려고 하는 객체의 추상 클래스나 인터페이스를 의미하며 객체와 decorator를 묶는 역할을 수행
- Decorator → 정의되어야 하는 객체를 감싸는 역할을 수행하는 decorator의 추상 클래스나 인터페이스를 의미
구조를 확인하시면 알 수 있는 것처럼 구성되어야 하는 객체와 decorator는 동일한 인터페이스를 바라보고 있습니다.
그리고 decorator와 객체 모두 상속을 통해 확인할 수 있는 동일한 메서드를 정의하고 decorator를 이용하여 객체를 감싸더라도 객체와 각 decorator가 가지고 있는 역할이 모두 동작될 수 있는 결과를 제공해 줍니다.
이와 같은 구조를 통해 기본 객체에 필요한 decorator만 원하는 만큼 유연하게 확장시킬 수 있고 재 사용성이 높은 코드를 가져갈 수 있게 됩니다.
Decorator 패턴 특징
decorator 패턴의 특징은 다음과 같습니다.
[ 장점 ]
- 원하는 만큼 decorator를 추가하여 유연한 확장이 가능
- 새로운 객체 및 decorator를 생성하더라도 기존 사용되고 있는 소스 코드에 영향을 주지 않음
- 코드 재 사용성을 높일 수 있음
[ 단점 ]
- 필요한 decorator 만큼 클래스 파일을 생성해야 됨
- 많은 수의 decorator를 추가하는 경우 코드를 읽기 어려워질 수 있음
- decorator의 순서를 고려해야 되는 경우가 발생하면 side effect를 경험할 수 있음
특징들을 살펴보면 decorator 패턴도 여러 solid 원칙을 준수하는 것을 볼 수 있습니다.
각각의 객체와 decorator들은 모두 각 기능에 대해서만 정의를 하며 사용될 수 있기 때문에 srp 원칙에 준수되는 것을 볼 수 있습니다.
또한 추상 클래스 또는 인터페이스를 통해 기능 정의를 수행하기 때문에 확장에는 열려있지만 수정에는 닫혀있기 때문에 ocp 원칙도 준수되는 것을 볼 수 있습니다.
그리고 client 입장에서 바라보는 것은 구현체가 아닌 인터페이스를 바라보기 때문에 dip 원칙도 준수되는 것도 확인 가능합니다.
그 외에도 oop 관점에서도 바라보면 상속, 추상화, 다형성 등도 모두 활용되고 있습니다.
Decorator 패턴 예시 (1) - Before (기본 구조)
이번에는 decorator 패턴을 사용하는 예제 코드를 작성해 보겠습니다.
예시는 위에서 얘기했던 햄버거 추가 구성과 관련된 것으로 해보겠습니다.
먼저 어떠한 것도 고려하지 않고 단순하게 코드를 작성해 보면 다음과 같습니다.
// burger
package com.jforj.decorator.beforebase;
import java.util.List;
import java.util.stream.Stream;
public class Burger {
private String type;
private long cost;
private List<String> ingredients;
public Burger(String type, long cost, List<String> ingredients) {
this.type = type;
this.cost = cost;
this.ingredients = ingredients;
}
public void addIngredient(String ingredientType) {
switch (ingredientType) {
case "sauce": {
this.cost += 500;
this.ingredients = Stream.concat(this.ingredients.stream(), List.of("Sauce").stream()).toList();
break;
}
case "lettuce": {
this.cost += 1000;
this.ingredients = Stream.concat(this.ingredients.stream(), List.of("Lettuce").stream()).toList();
break;
}
}
}
public long getCost() {
return this.cost;
}
public List<String> getIngredients() {
return this.ingredients;
}
}
// main
package com.jforj.decorator.beforebase;
import java.util.List;
public class Main {
public static void main(String[] args) {
// 치즈버거 기본
Burger cheeseBurger = new Burger("cheeseBurger", 1500, List.of("Patty", "Bread", "Cheese"));
System.out.println("cost= " + cheeseBurger.getCost() + ", ingredients= " + cheeseBurger.getIngredients());
// 치즈버거 소스 추가
Burger addSaucecheeseBurger = new Burger("cheeseBurger", 1500, List.of("Patty", "Bread", "Cheese"));
addSaucecheeseBurger.addIngredient("sauce");
System.out.println("cost= " + addSaucecheeseBurger.getCost() + ", ingredients= " + addSaucecheeseBurger.getIngredients());
// 불고기버거 기본
Burger bulgogiBurger = new Burger("bulgogiBurger", 2500, List.of("Patty", "Bread", "Bulgogi"));
System.out.println("cost= " + bulgogiBurger.getCost() + ", ingredients= " + bulgogiBurger.getIngredients());
// 불고기버서 양상추, 소스 추가
Burger addLettuceAndSauceBulgogiBurger = new Burger("bulgogiBurger", 2500, List.of("Patty", "Bread", "Bulgogi"));
addLettuceAndSauceBulgogiBurger.addIngredient("lettuce");
addLettuceAndSauceBulgogiBurger.addIngredient("sauce");
System.out.println("cost= " + addLettuceAndSauceBulgogiBurger.getCost() + ", ingredients= " + addLettuceAndSauceBulgogiBurger.getIngredients());
}
}
매번 햄버거 객체를 생성할 때마다 동일한 정보가 들어가는 생성자를 입력하는 것을 볼 수 있습니다.
또한 재료 추가 기능에 대해서는 코드 변경 없이 기능을 사용하는 것에는 문제가 없겠지만 만약 재료가 없어지거나 재료가 추가돼야 하는 상황이 발생하면 모든 객체에 기능 상 문제가 없는지 검토가 필요합니다.
이런 부분은 유연한 확장을 하는데 방해가 될 수 있으며 반복적인 코드를 생산하기 때문에 코드 개선이 필요하다고 느낄 수 있습니다.
Decorator 패턴 예시 (2) - Before (상속)
위의 코드에서 개선을 하고 싶었던 것 중 하나로 동일한 정보를 매번 생성자에 입력하는 것이 있습니다.
코드 개선을 위해 햄버거 객체를 상속받아 햄버거의 기본 구성을 설정해 줄 수 있는 객체들을 모두 생성해 보면 다음과 같이 코드를 변경할 수 있습니다.
// burger
package com.jforj.decorator.beforeextend;
import java.util.List;
import java.util.stream.Stream;
public abstract class Burger {
private long cost;
private List<String> ingredients;
protected Burger(long cost, List<String> ingredients) {
this.cost = cost;
this.ingredients = ingredients;
}
protected void addIngredient(String ingredientType) {
switch (ingredientType) {
case "sauce": {
this.cost += 500;
this.ingredients = Stream.concat(this.ingredients.stream(), List.of("Sauce").stream()).toList();
break;
}
case "lettuce": {
this.cost += 1000;
this.ingredients = Stream.concat(this.ingredients.stream(), List.of("Lettuce").stream()).toList();
break;
}
}
}
protected long getCost() {
return this.cost;
}
protected List<String> getIngredients() {
return this.ingredients;
}
}
// cheese burger
package com.jforj.decorator.beforeextend;
import java.util.List;
public class CheeseBurger extends Burger {
public CheeseBurger() {
super(1500, List.of("Patty", "Bread", "Cheese"));
}
}
// bulgogi burger
package com.jforj.decorator.beforeextend;
import java.util.List;
public class BulgogiBurger extends Burger {
public BulgogiBurger() {
super(2500, List.of("Patty", "Bread", "Bulgogi"));
}
}
// main
package com.jforj.decorator.beforeextend;
public class Main {
public static void main(String[] args) {
// 치즈버거 기본
Burger cheeseBurger = new CheeseBurger();
System.out.println("cost= " + cheeseBurger.getCost() + ", ingredients= " + cheeseBurger.getIngredients());
// 치즈버거 소스 추가
Burger addSaucecheeseBurger = new CheeseBurger();
addSaucecheeseBurger.addIngredient("sauce");
System.out.println("cost= " + addSaucecheeseBurger.getCost() + ", ingredients= " + addSaucecheeseBurger.getIngredients());
// 불고기버거 기본
Burger bulgogiBurger = new BulgogiBurger();
System.out.println("cost= " + bulgogiBurger.getCost() + ", ingredients= " + bulgogiBurger.getIngredients());
// 불고기버서 양상추, 소스 추가
Burger addLettuceAndSauceBulgogiBurger = new BulgogiBurger();
addLettuceAndSauceBulgogiBurger.addIngredient("lettuce");
addLettuceAndSauceBulgogiBurger.addIngredient("sauce");
System.out.println("cost= " + addLettuceAndSauceBulgogiBurger.getCost() + ", ingredients= " + addLettuceAndSauceBulgogiBurger.getIngredients());
}
}
치즈 버거와 불고기 버거를 위한 객체를 구성하면서 반복적으로 발생되는 코드를 제거해 볼 수 있습니다.
하지만 여전히 재료 추가 기능에 대한 유연한 확장이 불가능한 것이 남아 있습니다.
이 또한 개선하기 위해 decorator 패턴을 적용해 보겠습니다.
Decorator 패턴 예시 (3) - After
위의 코드에서 decorator 패턴을 적용해 보면 다음과 같이 코드를 변경할 수 있습니다.
// burger
package com.jforj.decorator.after;
import java.util.List;
public interface Burger {
long getCost();
List<String> getIngredients();
}
// cheese burger
package com.jforj.decorator.after;
import java.util.List;
public class CheeseBurger implements Burger {
@Override
public long getCost() {
return 1500;
}
@Override
public List<String> getIngredients() {
return List.of("Patty", "Bread", "Cheese");
}
}
// bulgogi burger
package com.jforj.decorator.after;
import java.util.List;
public class BulgogiBurger implements Burger {
@Override
public long getCost() {
return 2500;
}
@Override
public List<String> getIngredients() {
return List.of("Patty", "Bread", "Bulgogi");
}
}
// burger decorator
package com.jforj.decorator.after;
public abstract class BurgerDecorator implements Burger {
protected Burger burger;
public BurgerDecorator(Burger burger) {
this.burger = burger;
}
}
// sauce decorator
package com.jforj.decorator.after;
import java.util.List;
import java.util.stream.Stream;
public class Sauce extends BurgerDecorator {
public Sauce(Burger burger) {
super(burger);
}
@Override
public long getCost() {
return super.burger.getCost() + 500;
}
@Override
public List<String> getIngredients() {
return Stream.concat(super.burger.getIngredients().stream(), List.of("Sauce").stream()).toList();
}
}
// lettuce decorator
package com.jforj.decorator.after;
import java.util.List;
import java.util.stream.Stream;
public class Lettuce extends BurgerDecorator {
public Lettuce(Burger burger) {
super(burger);
}
@Override
public long getCost() {
return super.burger.getCost() + 1000;
}
@Override
public List<String> getIngredients() {
return Stream.concat(super.burger.getIngredients().stream(), List.of("Lettuce").stream()).toList();
}
}
// main
package com.jforj.decorator.after;
public class Main {
public static void main(String[] args) {
// 치즈버거 기본
Burger cheeseBurger = new CheeseBurger();
System.out.println("cost= " + cheeseBurger.getCost() + ", ingredients= " + cheeseBurger.getIngredients());
// 치즈버거 소스 추가
Burger addSaucecheeseBurger = new Sauce(new CheeseBurger());
System.out.println("cost= " + addSaucecheeseBurger.getCost() + ", ingredients= " + addSaucecheeseBurger.getIngredients());
// 불고기버거 기본
Burger bulgogiBurger = new BulgogiBurger();
System.out.println("cost= " + bulgogiBurger.getCost() + ", ingredients= " + bulgogiBurger.getIngredients());
// 불고기버서 양상추, 소스 추가
Burger addLettuceAndSauceBulgogiBurger = new Sauce(new Lettuce(new BulgogiBurger()));
System.out.println("cost= " + addLettuceAndSauceBulgogiBurger.getCost() + ", ingredients= " + addLettuceAndSauceBulgogiBurger.getIngredients());
}
}
decorator 패턴을 적용해보면 기능 확장을 해줘야 하는 장식들을 위한 클래스 파일이 추가적으로 생성되지만 재료 추가 기능에 대한 유연한 확장이 가능해진 것을 볼 수 있습니다.
또한 client 입장에서도 필요한 decorator 만큼 지속적으로 추가해 줄 수 있기 때문에 재 사용성을 높이며 코드 개발을 가능하도록 도와줍니다.
하지만 무분별하게 사용이 된다면 단점으로 말했던 것처럼 클래스 파일의 개수가 점점 늘어나게 될 것이고 코드를 읽기 어려워지는 상황이 발생할 수 있게 됩니다.
이상으로 데코레이터 (Decorator) 패턴에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'설계 > 디자인패턴' 카테고리의 다른 글
[디자인패턴] 어댑터(Adapter) 패턴 이해하기 (0) | 2024.05.03 |
---|---|
[디자인패턴] 옵저버(Observer) 패턴 이해하기 (0) | 2024.04.30 |
[디자인패턴] 추상 팩토리(Abstract Factory) 패턴 이해하기 (0) | 2024.04.28 |
[디자인패턴] 템플릿 메서드(Template Method) 패턴 이해하기 (0) | 2024.04.22 |
[디자인패턴] 팩토리 메서드(Factory Method) 패턴 이해하기 (0) | 2024.04.22 |
댓글