[Java] 데코레이터 패턴(Decorator Pattern)이란? - 개념 및 예제
데코레이터 패턴(Decorator Pattern)
데코레이터 패턴(Decorator Pattern)은 객체 지향 디자인 패턴 중 하나로 객체에 동적으로 기능을 추가하여 확장할 수 있는 구조 패턴이다. 이 패턴은 상속을 통해 클래스를 확장하는 대신, 객체를 감싸는 방식을 사용하여 기능을 추가하거나 변경한다. 따라서 기존 코드를 수정하지 않고도 새로운 기능을 추가하거나 수정할 수 있게 된다.
데코레이터(Decorator)라는 단어의 의미인 장식자, 꾸미는 사람으로 접근하면 데코레이터 패턴의 역할을 쉽게 이해할 수 있을 것이다.
데코레이터 패턴의 장단점
장점
- 유연한 확장 : 새로운 기능을 추가하거나 기존 기능을 수정할 때 동적으로 기능을 추가하거나 변경할 수 있다.
- 코드 재사용 : 기존 코드를 수정하지 않고도 새로운 기능을 추가할 수 있기 때문에 코드 재사용이 용이하다. 기존 구성 요소를 변경하지 않고 여러 조합으로 새로운 객체를 만들 수 있다.
- 단일 책임 원칙(SRP) 준수 : SRP 원칙을 따르며, 각 데코레이터는 특정 기능의 추가 또는 수정에만 집중한다.
- 개방 폐쇄 원칙(OCP) 준수 : 기존 코드를 변경하지 않고도 새로운 데코레이터 클래스를 추가함으로써 기능을 쉽게 확장할 수 있다.
- 의존성 역전 원칙(DIP) 준수 : 컴포넌트와 데코레이터가 모두 동일한 인터페이스나 추상 클래스를 공유하므로 데코레이터가 컴포넌트에 의존하지 않는다.
단점
- 복잡성 증가 : 객체를 여러 층으로 감싸기 때문에 클래스의 수가 많아지고 복잡성이 증가할 수 있다.
- 순서 의존성 : 데코레이터를 적용하는 순서에 따라 최종적으로 생성되는 객체의 동작이나 상태가 달라질 수 있다.
- 불필요한 복사 : 모든 데코레이터는 원본 객체의 인스턴스를 가지고 있기 때문에 여러 레벨의 데코레이터를 사용할 경우 불필요한 객체 복사가 발생할 수 있다.
데코레이터 패턴은 기존 객체의 기능을 동적으로 추가 또는 수정할 때 유용하게 사용할 수 있는 패턴으로 상속을 통한 확장이 적절하지 않을 때 사용한다. 그러나 무분별하게 많이 사용하면 코드를 복잡하게 만들 수 있으므로 신중하게 적용해야 한다.
데코레이터 패턴의 구조
데코레이터 패턴의 구조는 다음과 같다.
- Component : 기본 기능을 정의하는 인터페이스로 데코레이터와 구체적인 컴포넌트가 이를 구현한다. 따라서 컴포넌트는 원본 객체와 데코레이터를 묶는 역할을 한다.
- ConcreteComponent : 기본 기능을 실제로 구현하는 구체적인 컴포넌트로 데코레이터 패턴의 시작점이 된다.
- Decorator : 컴포넌트를 감싸는 추상 클래스나 인터페이스로 새로운 기능을 추가하거나 기존 기능을 수정하기 위한 메서드를 선언한다. 데코레이터는 Component를 멤버 변수로 가진다.
- ConcreteDecorator : 새로운 기능이나 기존 기능을 수정하는 역할을 하는 구체적인 클래스다. 데코레이터 인터페이스를 구현한다.
데코레이터 패턴 예제
커피 주문 애플리케이션에서 아메리카노를 주문할 때 설탕 추가, 샷 추가 등의 옵션들을 선택할 수 있다고 가정해 보자.
Coffee.java
public interface Coffee {
String getCoffee();
int price();
}
- Coffee 인터페이스는 주문한 커피와 가격을 나타내는 메서드를 구현하도록 한다.
Americano.java
public class Americano implements Coffee {
@Override
public String getCoffee() {
return "아메리카노";
}
@Override
public int price() {
return 2000;
}
}
- Americano 클래스는 Coffee 인터페이스의 구현체로 주문한 커피는 아메리카노를 나타내며, 가격은 2000원이다.
데코레이터 패턴을 사용하지 않는 경우
AmericanoAddShot.java
// 아메리카노에 샷 추가
public class AmericanoAddShot implements Coffee {
private Coffee americano;
public AmericanoAddShot(Coffee americano) {
this.americano = americano;
}
@Override
public String getCoffee() {
return americano.getCoffee() + " 샷추가";
}
@Override
public int getPrice() {
return americano.getPrice() + 500;
}
}
AmericanoAddSugar.java
// 아메리카노에 설탕 추가
public class AmericanoAddSugar implements Coffee {
private Coffee americano;
public AmericanoAddSugar(Coffee americano) {
this.americano = americano;
}
@Override
public String getCoffee() {
return americano.getCoffee() + " 설탕추가";
}
@Override
public int getPrice() {
return americano.getPrice() + 500;
}
}
- Coffee 인터페이스를 구현하여 샷을 추가한 아메리카노와 설탕을 추가한 아메리카노를 구현하고 있다.
Main.java
public class DecoratorMain {
public static void main(String[] args) {
Coffee americano = new Americano();
// 아메리카노 샷 추가
Coffee americanoAddShot = new AmericanoAddShot(americano);
System.out.println(americanoAddShot.getCoffee());
System.out.println(americanoAddShot.getPrice());
// 아메리카노 샷, 설탕 추가
Coffee americanoAddSugar = new AmericanoAddSugar(americano);
System.out.println(americanoAddSugar.getCoffee());
System.out.println(americanoAddSugar.getPrice());
}
}
// 출력
아메리카노 샷추가
2500
아메리카노 설탕추가
2500
- 아메리카노에 샷 추가 또는 설탕 추가한 클래스를 구현하고 있다.
- 이 경우 샷 추가된 아메리카노에 설탕까지 추가하려면 새로운 클래스가 필요할 것이다.
- 뿐만 아니라 아메리카노가 아닌 라떼인 경우 똑같이 각각의 클래스가 필요하게 된다.
- 따라서 옵션 추가를 유연하게 하지 못한다.
데코레이터 패턴을 사용하는 경우
Coffee 인터페이스를 데코레이터 패턴을 통해 추가 옵션을 정의하고 이를 구현하여 동적으로 기능을 추가하거나 수정하도록 구현할 수 있다.
CoffeeDecorator.java
// Decorator
public abstract class CoffeeDecorator implements Coffee {
private Coffee coffee;
public CoffeeDecorator(Coffee coffee) {
this.coffee = coffee;
}
@Override
public String getCoffee() {
return coffee.getCoffee();
}
@Override
public int getPrice() {
return coffee.getPrice();
}
}
- CoffeeDecorator는 데코레이터 역할을 하는 추상 클래스로 Coffee 인터페이스를 멤버 변수로 가진다. 이는 데코레이터가 감싸고 있는 실제 컴포넌트를 나타낸다.
- Coffee 인터페이스의 메서드를 오버라이딩하여 coffee의 메서드를 호출한다. 이로써 CoffeeDecorator를 구현한 구현체들이 Coffee의 메서드를 호출하여 그 동작을 수정하거나 확장할 수 있게 한다.
다음은 데코레이터를 상속하는 ConcreteDecorator 클래스로 샷 추가와 설탕 추가 옵션에 대한 기능이다.
ShotDecorator.java
// ConcreteDecoratorA
public class ShotDecorator extends CoffeeDecorator {
public ShotDecorator(Coffee coffee) {
super(coffee);
}
@Override
public String getCoffee() {
return super.getCoffee() + " 샷추가";
}
@Override
public int getPrice() {
return super.getPrice() + 500;
}
}
- 샷을 추가하는 데코레이터로 부모 클래스의 생성자와 메서드를 호출한다.
- getCoffee() 메서드에는 CoffeeDecorator의 getCoffee() 메서드를 호출하고 샷추가를 덧붙여서 반환한다.
- getPrice() 메서드에는 CoffeeDecorator의 getCoffee() 메서드를 호출하고 500원을 더하여 반환한다.
SugarDecorator.java
// ConcreteDecoratorB
public class SugarDecorator extends CoffeeDecorator {
public SugarDecorator(Coffee coffee) {
super(coffee);
}
@Override
public String getCoffee() {
return super.getCoffee() + " 설탕추가";
}
@Override
public int getPrice() {
return super.getPrice() + 500;
}
}
- 설탕을 추가하는 데코레이터로 부모 클래스의 생성자와 메서드를 호출한다.
- getCoffee() 메서드에는 CoffeeDecorator의 getCoffee() 메서드를 호출하고 설탕추가를 덧붙여서 반환한다.
- getPrice() 메서드에는 CoffeeDecorator의 getCoffee() 메서드를 호출하고 500원을 더하여 반환한다.
작성한 코드는 다음과 같이 사용할 수 있다.
public class Main {
public static void main(String[] args) {
Coffee americano = new Americano();
Coffee americanoShotAdd = new ShotDecorator(americano);
Coffee americanoShotSugarAdd = new SugarDecorator(americanoShotAdd);
// 아메리카노 샷 추가
System.out.println(americanoShotAdd.getCoffee());
System.out.println(americanoShotAdd.getPrice());
// 아메리카노 샷, 설탕 추가
System.out.println(americanoShotSugarAdd.getCoffee());
System.out.println(americanoShotSugarAdd.getPrice());
}
}
// 출력
아메리카노 샷추가
2500
아메리카노 샷추가 설탕추가
3000
- 기본 아메리카노 객체를 생성하여 ShotDecorator와 SugarDecorator를 통해 샷과 설탕을 추가하는 동작을 수행할 수 있다.
- 데코레이터 패턴을 사용하여 컴포넌트(아메리카노)에 동적으로 기능(샷, 설탕 추가)을 추가하고, 동일한 인터페이스를 사용하여 다양한 커피를 주문할 수 있도록 한다.
- 만약 라떼와 같은 커피 종류가 추가되더라도 이미 구현된 데코레이터를 통해 똑같이 사용할 수 있게 된다.
정리
데코레이터 패턴은 기존 객체를 수정하지 않고도 새로운 기능을 추가하거나 변경할 수 있도록 하는 패턴이다. 클라이언트 코드는 데코레이터와 컴포넌트를 동일한 인터페이스로 다룰 수 있어 코드의 일관성을 유지하면서도 다양한 기능을 적용할 수 있다.
추가로 앞서 설명한 예제의 구조를 살펴보면, 단일 책임 원칙(SRP), 개방 폐쇄 원칙(OCP)과 의존성 역전 원칙(DIP)을 준수하고 있다.