서로 닮아 보이는 Decorator, Composite Pattern - 1편
디자인 패턴을 처음 학습할 때 구조적으로 비슷한 다이어그램을 가진 패턴들이 서로 다른 이름과 목적을 가지고 있어 혼란스러웠던 경험이 있습니다. 이러한 혼란은 단순히 "어떻게 구현되는가?"에 초점을 맞출 때 발생하곤 했습니다.
요즘 다시 이전에 디자인 패턴을 펼쳐보고 "왜 이런 패턴이 필요한가?", "이 패턴이 어떤 상황에 써라고 만든 것인가?"라는 질문을 던지며 패턴의 의미와 어떻게 문제 해결했는지 다시 한번 살펴보고 있습니다.
특히, 데코레이터(Decorator)와 컴포짓(Composite) 패턴은 겉보기에는 매우 유사해 보이지만, 각각의 목적과 활용 방식에서 큰 차이가 있습니다. 이번 글에서는 두 패턴의 구현과 의도를 비교하고 다른 개발 선배들에게 얻은 해석하는 Tip 등을 공유해보고자 합니다.
Decorator Pattern
데코레이터 패턴(Decorator Pattern)은 기존 객체에 새로운 기능을 동적으로 추가할 수 있도록 설계된 구조적 패턴입니다. 상속을 사용하지 않고 위임(Composition)을 통해 유연하게 런타임에 부가적인 기능을 추가할 수 있습니다. 이것을 통해 기존의 객체의 수정하지 않고 다양한 기능을 조합할 수 있습니다.
위임?
위임은
- 한 객체가 다른 객체를 포함하거나 참조하여 (직접 수행하지 않고) 그 객체에게 작업을 위임(Delegate)하는 방식을 말합니다.
- 이렇게 하면 상속 없이 객체 간의 협력을 통해 기능을 구현할 수 있습니다.
- "has-a" 관계를 나타냅니다. (상속은 "is-a")
주요 특징
- 상속 대신 객체를 감싸는 래퍼(wrapper) 객체를 사용해서 기능을 확장합니다.
- 런타임에 동적으로 새로운 옵션을 추가할 수 있습니다.
예시: 커피 주문 시스템
다양한 옵션(우유, 설탕, 바닐라시럽)을 추가할 수 있는 커피 주문 시스템을 상속과 데코레이터 방식으로 비교해 보며 구현해 봅시다.
class Coffee { // 기본 커피 클래스 public String getDescription() { return "Default Coffee"; } public BigDecimal getCost() { return new BigDecimal(4000); } }
public class Customer { // 클라이언트 코드 public static void main(String[] args) { Coffee coffee = new Coffee(); System.out.println(coffee.getDescription() + ": " + coffee.getCost()) + "원"; } }
문제점: 상속의 한계
기존 클래스에 기반으로 새로운 기능을 더하기 위해 상속을 사용해봅시다.
Milk, Sugar, Vanilla시럽과 같은 다양한 옵션을 제공하기 위해 간단히 클래스 상속을 통해 각 옵션에 맞는 기능을 구현해 보면 다음과 같습니다.
// Vanila 시럽이 추가된 커피 class VanilaCoffee extends Coffee { @Override public String getDescription() { return super.getDescription() + " + Vanila"; } @Override public BigDecimal getCost() { return super.getCost().add(new BigDecimal(2000)); } } // Milk가 추가된 커피 class MilkCoffee extends Coffee { @Override public String getDescription() { return super.getDescription() + " + Milk"; } @Override public BigDecimal getCost() { return super.getCost().add(new BigDecimal(2000)); } } // Sugar가 추가된 커피 class SugarCoffee extends Coffee { @Override public String getDescription() { return super.getDescription() + " + Sugar"; } @Override public BigDecimal getCost() { return super.getCost().add(new BigDecimal(500)); } } class MilkSugarCoffee extends MilkCoffee { @Override public String getDescription() { return super.getDescription() + " + Milk + Sugar"; } @Override public BigDecimal getCost() { return super.getCost().add(new BigDecimal(2500)); } } ... class VanilaSugarCoffee extends MilkCoffee { /* ...생략... */ } class VanilaMilkCoffee extends MilkCoffee { /* ...생략... */ } ... // Vanila, Milk, Sugar가 모두 추가된 커피 ← 뭔가 잘못된 느낌이 듭니당. class VanilaMilkSugarCoffee extends MilkCoffee { @Override public String getDescription() { return super.getDescription() + " + Vanila + Milk + Sugar"; } @Override public BigDecimal getCost() { return super.getCost().add(new BigDecimal(2500)); } } ... // 만약 Vanila 시럽 두 펌프를 원한다면..? // class VanilaVanilaCoffee extends MilkCoffee { /* ...생략... */ }
이런 커피숍은 나중에 옵션이 추가될 가능성이 있습니다. 그러면 옵션이 늘어날수록 만들어둬야 할 클래스가 기하급수적으로 증가합니다.
이 코드를 사용하는 개발자는 어떨까요? (클라이언트가 주문하는 부분을 개발하고 있다고 해봅시다)
// 클라이언트 코드 public class Customer { public static void main(String[] args) { // MilkCoffee만 추가 가능 Coffee coffee = new MilkCoffee(); System.out.println(coffee.getDescription() + ": " + coffee.getCost() + "원"); // 유저가 Sugar 추가할 것을 요청했다면? // Sugar 추가하려면 새로운 객체를 만들어야 함 coffee = new SugarCoffee(); System.out.println(sugarCoffee.getDescription() + ": " + sugarCoffee.getCost() + "원"); // Milk, Sugar 둘 다 추가할 것을 원한다면? // Milk + Sugar 조합은 동적으로 생성할 수 없음 // 새로운 MilkSugarCoffee 클래스를 미리 정의해야 함 coffee = new MilkSugarCoffee(); System.out.println(milkSugarCoffee.getDescription() + ": " + milkSugarCoffee.getCost() + "원"); // order coffee 등 수행 } }
동적으로 생성이 불가능하니 미리 원하는 기능을 갖춘 클래스를 만들어둬야 합니다.
현재 보이는 문제점들은 다음과 같네요!
- 옵션이 추가될 때마다, 모든 조합을 커버하는 클래스를 추가해야 하므로 만들어야 하는 클래스의 개수가 엄청나게 늘어납니다.
- 새로운 옵션 조합(Milk + Sugar)이 필요하면 MilkSugarCoffee와 같이 새로운 클래스를 만들어둬야 한다.
- 모든 조합은 컴파일 타임에 결정됩니다. 이 코드를 쓰는 입장에서 Coffee 객체에 Milk를 추가한 뒤 Sugar를 동적으로 추가할 수 없어서 새로운 조합이 추가될 때마다 매번 클라이언트 코드를 수정해야 합니다.
왜 이런 문제가 일어났을까요?
위임을 활용하면 이런 컴파일 타임에 조합이 결정되는 것을 피할 수 있습니다. “데코레이터 패턴”을 적용한 코드를 보며 한번 비교해 봅시다.
해결책: 데코레이터 패턴

데코레이터 패턴은 다음과 같이 생겼습니다.
위의 "커피"에 새로운 옵션을 추가하더라도 기존 코드는 그대로 두고 새로운 옵션만 추가할 수 있도록 데코레이터 패턴을 이용해서 코드를 수정해 보겠습니다.
(1) 원본 객체에 대한 인터페이스 추출합니다.
우리는 커피를 베이스로 할 것이기 때문에 먼저 Coffee에 관한 작업들을 인터페이스로 추출해 봅시다.
interface Coffee { String getDescription(); BigDecimal getCost(); }
이 부분이 Component입니다. 앞으로의 기본적인 커피 객체와 이것에 기능을 추가하기 위한 커피 데코레이터 객체들 모두 이 인터페이스를 구현합니다.
(2) 기본 커피 클래스를 구현합니다.
방금 만든 Component 인터페이스의 Concrete를 만듭니다.
가장 기본이 되는 값을 가진 Concrete 한 커피를 구현합니다. 일단은 DefaultCoffee를 하나 만들겠습니다.
class DefaultCoffee implements Coffee { @Override public String getDescription() { return "Default Coffee(Americano)"; } @Override public BigDecimal getCost() { return new BigDecimal(4000); } }
실질적인 데이터(가격, 설명)를 가진 베이스가 되는 커피를 만들면 됩니다.
Espresso도 하나 만들까요?
class Espresso implements Coffee { @Override public String getDescription() { return "Espresso"; } @Override public BigDecimal getCost() { return new BigDecimal(2000); } }
정의된 기본 행동들은 밑에서 만들 데코레이터들이 변경할 수 있습니다.
(3) 데코레이터 추상 클래스 정의합니다.
옵션을 추가하기 위한 데코레이터는 기본 커피 객체를 감싸서 기능을 확장하는 방식으로 구현됩니다. 이전에는 커피를 상속받고 추가적인 부분을 오버라이딩해서 했지만 지금은 위임을 활용할 겁니다.
감싼다? 랩핑? Wrapping한다?
감싼다? 랩핑? Wrapping한다?
모두 같은 말입니다.
기본 커피 객체를 감싼다는 것은 대상 객체와 연결된 새로운 객체를 생성한다는 의미입니다.
래퍼는 감싸는 대상 객체(wrappee)와 동일한 메서드를 포함하고 이 메서드에 전달된 요청을 모두 대상 객체에 위임합니다. 이때, 단순히 위임만 하는 것이 아니라, 요청을 대상 객체(wrappee)로 전달하기 전후에 추가 동작을 수행하여 새로운 기능을 제공할 수 있습니다.
데코레이터는 자신이 처리하는 모든 요청을 대상 객체(wrappee)에 위임해야 한다고 했습니다. 그래서 데코레이터는 Coffee 인터페이스를 구현하는 동시에 다른 Coffee 객체를 내부적으로 참조하도록 해야 합니다.
이것을 위해 래핑 된 객체를 참조하는 wrappee를 둡니다. wrappee의 타입은 구상 컴포넌트와 데코레이터를 모두 포함할 수 있도록, Component 인터페이스로 선언합니다.
abstract class CoffeeDecorator implements Coffee { protected Coffee coffee; // 이 녀석이 바로 wrappee입니다. public CoffeeDecorator(Coffee coffee) { this.coffee = coffee; } @Override public String getDescription() { return coffee.getDescription(); // wrappee에게 모두 위임! } @Override public BigDecimal getCost() { return coffee.getCost(); // wrappee에게 모두 위임! } }
(4) 커피에 첨가될 수 있는 옵션들을 구현합니다.
이제 위에 선언한 데코레이터를 상속받아서 실제 커피들에 추가적으로 첨가될 수 있는 옵션들을 구현해 봅시다.
우유를 추가하는 데코레이터를 만들어봅시다.
class MilkDecorator extends CoffeeDecorator { public MilkDecorator(Coffee coffee) { super(coffee); } @Override public String getDescription() { return super.getDescription() + " + 우유"; } @Override public BigDecimal getCost() { return super.getCost().add(new BigDecimal(2000)); } }
비슷하게 설탕을 추가하는 데코레이터를 구현해 봅시다.
class SugarDecorator extends CoffeeDecorator { public SugarDecorator(Coffee coffee) { super(coffee); } @Override public String getDescription() { return super.getDescription() + "+ 설탕"; } @Override public BigDecimal getCost() { return super.getCost().add(new BigDecimal(500)); } }
(5) 사용해 보기
클라이언트 입장해서 이 클래스들을 사용해 본다면 어떨까요?
처음에 데코레이터 패턴의 강력한 점은 런타임에 동적으로 기능을 추가할 수 있다고 했습니다.
이제 클라이언트는 원하는 첨가물을 조합하여 유연하게 커피 객체를 생성할 수 있습니다.
public class Main { public static void main(String[] args) { // 기본 커피 생성 Coffee coffee = new DefaultCoffee(); // 옵션 선택에 따라 데코레이터로 기능 추가 boolean addMilk = true; boolean addSugar = true; boolean addVanilla = false; if (addMilk) { coffee = new MilkDecorator(coffee); } if (addSugar) { coffee = new SugarDecorator(coffee); } if (addVanilla) { coffee = new VanillaDecorator(coffee); } // 결과 출력 System.out.println(coffee.getDescription() + ": " + coffee.getCost() + "원"); } }
boolean 값을 유저로부터 받아오도록 하면 기본 커피에 원하는 옵션을 런타임에 선택적으로 추가할 수 있게 됩니다.
미리 여러 조합의 옵션이 적용된 커피를 만들 필요 없이 동적으로 추가할 수 있어서 편안합니다.
클라이언트는 다양한 옵션을 자유롭게 조합할 수 있고 새로운 요구사항이 추가될 때도 기존 코드에 최소한의 변경만 가하면 되게 되었습니다.
(-) 더 개선한다면?
위 상황에서 한 걸음 더 나아가, "옵션 선택에 따라 데코레이터로 기능 추가"하는 부분을 클라이언트 코드 외부로 분리하면, 클라이언트 코드의 수정 없이도 기능을 확장할 수 있도록 할 수도 있습니다. 이렇게까지 한다면, 클라이언트는 데코레이터의 구현이나 조합 방식을 알 필요 없이 원하는 기능을 사용할 수 있게 됩니다.
클라이언트가 옵션 조합을 처리하는 책임을 가지고 있어서 발생한 문제이니 이것을 빼면 될 것 같습니다.
예를 들어서, 인터넷에 다른 사람들은 어떻게 했는지 찾아보고 혼자 생각을 해본 것들을 적어봅니다.
방법 1. 데코레이터 조합을 처리하는 팩토리 만들기
옵션 조합을 처리하는 책임을 별도의 팩토리 클래스에 위임하여 클라이언트 코드와 분리해 보겠습니다.
import java.util.List; public class CoffeeDecoratorFactory { public static Coffee createCoffee(List<String> options) { Coffee coffee = new BasicCoffee(); // 기본 커피 생성 <- 이 부분 아직 아쉽긴합니다. // 옵션 목록에 따라 데코레이터 적용 for (String option : options) { switch (option.toLowerCase()) { case "milk": coffee = new MilkDecorator(coffee); break; case "sugar": coffee = new SugarDecorator(coffee); break; case "vanilla": coffee = new VanillaDecorator(coffee); break; default: throw new IllegalArgumentException("Unknown option: " + option); } } return coffee; } }
팩토리는 그에 맞는 데코레이터 객체를 생성합니다.
이제 클라이언트는 단순히 옵션 목록을 전달하기만 하면 됩니다.
import java.util.Arrays; import java.util.List; public class Customer { public static void main(String[] args) { // 외부에서 옵션 목록을 받아옵니다. List<String> options = Arrays.asList("milk", "sugar"); // 팩토리에서 커피 객체 생성합니다. Coffee customizedCoffee = CoffeeDecoratorFactory.createCoffee(options); System.out.println(customizedCoffee.getDescription() + ": " + customizedCoffee.getCost() + "원"); } }
커피 객체의 생성 로직은 팩토리 클래스가 처리하기 때문에 이제는 새로운 옵션이 추가되더라도 클라이언트 코드에는 전혀 영향을 미치지 않습니다.
방법 2. (Spring 버전) 외부 설정 기반으로 빈 생성하기
지금은 어울리지 않지만 아예 외부 설정으로 부가 기능을 어떤 것을 추가할지 뺄 수도 있습니다.
스프링에서 application.properties 기반으로 빈을 생성하여 데코레이터를 동적으로 적용해 보도록 하겠습니다.
1. application.properties 파일을 만듭니다.
coffee.options=milk,sugar
2. 데코레이터 빈 생성하는 코드를 작성합니다.
Spring의 @Configuration과 @Bean을 활용해, 애플리케이션 프로퍼티에서 옵션 목록을 읽어 데코레이터를 조합하는 메서드를 만들어보겠습니다.
import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.List; @Configuration public class CoffeeConfig { @Value("#{'${coffee.options}'.split(',')}") // 프로퍼티에서 옵션 목록 읽기 private List<String> coffeeOptions; @Bean public Coffee coffee() { // 기본 커피 객체 생성 Coffee coffee = new BasicCoffee(); // 프로퍼티에 지정된 옵션에 따라 데코레이터 추가 for (String option : coffeeOptions) { switch (option.trim().toLowerCase()) { case "milk": coffee = new MilkDecorator(coffee); break; case "sugar": coffee = new SugarDecorator(coffee); break; case "vanilla": coffee = new VanillaDecorator(coffee); break; default: throw new IllegalArgumentException("Unknown coffee option: " + option); } } return coffee; } }
그러면 클라이언트 코드에서는 @Autowired로 이미 생성된 coffee 빈을 사용하면 됩니다.
이렇게 해도 애플리케이션 프로퍼티에 따라 데코레이터가 동적으로 조합되므로 클라이언트 코드를 수정할 필요가 없어집니다.
@Component public class CoffeeOrderService { private final Coffee coffee; @Autowired public CoffeeOrderService(Coffee coffee) { this.coffee = coffee; } public void printOrderDetails() { System.out.println(coffee.getDescription() + ": " + coffee.getCost() + "원"); } }
클라이언트 코드에서 복잡한 로직을 없애고 책임을 분리하려는 시도 해봤는데 이게 에시로 연급 겸 구현해 보는 것이라 계속 끝까지 억지로 없애려 한 것 같아서 이상하네요. 좀 더 고민해 봐야겠습니다~!
당연한 말이지만 실전에서는 역시 상황에 맞는 적절하게 책임 분배를 하면 될 것 같습니다.
장단점
장점은 계속 얘기해 왔습니다. 런타임에 동적으로 기능을 조합할 수 있고 기존 코드를 수정하지 않고 확장이 가능하다는 겁니다.
디자인 패턴을 처음 공부하고 써먹고 싶어서 무작정 도입하려다 보면 나중에 크게 후회하게 될 수 있습니다. 저도 학교 프로젝트를 친구랑 하다가 데코레이터를 남발했다가 아주 크게 데인적이 있습니다....
단점은 뭘까요?
단점 1. 이 패턴을 모르는 사람들은 이해하기 어려워할 수 있습니다.
일단 데코레이터 패턴은 객체를 래핑 하는 구조로 인해 중첩할 수 있게 하는데 이걸 모르는 입장에서 보면 이해가 어려울 수 있습니다.
단점 2. 중첩되고 복잡해지면 디버깅하기가 어려워집니다.
데코레이터 패턴은 런타임에 객체를 동적으로 조합할 수 있다는 점에서 매우 유연하지만, 동작을 예측하기 어려워서 복잡한 상황에서 디버깅하기가 상당히 어려워질 수 있습니다.
저는 인턴십에서 경험해 봤습니다. 회사 초기부터 유지되어 온 레거시 코드를 다룰 일이 생겼습니다. 해당 코드는 회사 거의 초기부터 있었다고 들었고 확장하기가 어려워서 리팩터링하기로 결정했습니다. 그 코드는 한 데코레이터 클래스였는데 처음에는 괜찮았는데 시간이 지남에 따라 기능도 추가되고 기존 코드가 명확한 책임 분리가 이루어지지 않은 상태가 됐습니다.
코드 앞단에서 시그니처를 백엔드와 맞춰서 검증하기 위한 데코레이터였는데 나중에 빠르게 기능을 추가하려고 if문으로 계속 하드코딩되어서 여러 책임이 얽혀 있어서 복잡해졌습니다. 그리고 어느 순간부터 조합 순서가 중요해졌는데 문서나 명확한 기준이 없었습니다.
이러한 상황에서 레거시 코드를 리팩터링하기 위해 200개 이상의 테스트 케이스를 작성하며 조합 순서를 분석하며 리팩터링을 진행했습니다. 하지만 예상하지 못한 조합으로 특정 상황에서 동작이 실패했고 결국 롤백할 수밖에 없었습니다. 이게 시간이 흐르면서 사실 버그인데 지금은 기능으로 나둬야 하는 경우도 있고 그래서 더 복잡하긴 했습니다만 값도 예측하기 어렵고 테스트하기 정말 어렵습니다.
하드코딩된 로직과의 통합은 패턴의 장점을 살리지 못한다는 것을 몸소 느꼈습니다.
단점 3. 부가적인 기능을 데코레이터로 분리하면 클래스 수가 늘어납니다.
근데 처음에 상속을 이용한 안 좋은 케이스는 여러 기능을 추가하려면 점점 더 많은 클래스를 만들게 돼서 기능이 간단한 경우만 아니라면 이것 때문에 피할 이유는 없다고 생각합니다.
* 글이 길어져서 컴포짓 패턴부터 이어서 2편에서 다루겠습니다.
댓글
이 글 공유하기
다른 글
-
IntelliJ 자주 쓰는 단축키 모음
IntelliJ 자주 쓰는 단축키 모음
2024.10.23 -
[Java] 람다식과 익명 클래스
[Java] 람다식과 익명 클래스
2024.02.21 -
[Java] Annotation (feat. Reflection)
[Java] Annotation (feat. Reflection)
2024.02.19
댓글을 사용할 수 없습니다.