데코레이터 패턴이란?
어떤 객체의 메서드에 대하여 기능 추가/변경/제거를 해야 할 때,
같은 메서드를 각기 다르게 구현한 객체의 결합을 통해 메서드를 호출하는 부분을 변경하지 않고 진행할 수 있는 패턴이다.
데코레이터 패턴 사용 시기
- 메서드 호출의 반환값 중간에 변경을 줘야 할 때
- 객체 책임과 행동이 동적으로 빈번하게 추가/삭제 되는 경우
- 객체의 결합을 통해 기능이 생성될 수 있는 경우
- 객체에서 메서드를 호출하는 코드 부분을 변경하지 않고, 런타임에 객체에 추가 동작을 할당해야 하는 경우
- 상속으로 해결할 수 없거나 상속 보다 유연한 객체의 기능 확장과 변경이 필요할 때
데코레이터 패턴 사용시의 장점
- 데코레이터를 사용하면 상속을 사용하여 서로 다른 기능을 하는 자식 클래스들을 만들때보다 훨씬 더 유연하게 기능을 확장할 수 있다.
- 데코레이터를 한개만 사용하는 것이 아니라 여러개 래핑하여 여러 동작을 결합할 수 있다.
- 컴파일 타임이 아닌 런타임에 동적으로 기능을 변경할 수 있다.
- 각 장식자 클래스마다 고유의 책임을 가져 단일 책임 원칙(SRP)을 준수
- 기능 확장이 필요하면 장식자 클래스를 추가하면 되니 클라이언트의 코드를 수정하지 않아도 된다. 개방 폐쇄 원칙(OCP)을 준수
- 개발 시 구현체 클래스가 아닌 인터페이스를 바라봄으로써 의존 역전 원칙(DIP)준수
데코레이터 패턴 사용시의 단점
- 데코레이터를 조합하는 객체 동적 할당한는 부분이 밉게 생겼다.
- new A(new B(new C(new D())))
- 어느 장식자를 먼저 데코레이팅 하느냐에 따라 데코레이터가 호출되는 호출 스택이 결정되어 데코레이팅 순서를 잘 고려해야 한다.
- new A(new B(new C())) 와 new A(new C(new B())) 는 다르게 동작하는 A객체가 된다.
프록시 패턴이랑 비슷한데?
데코레이터 패턴을 사용하는 목적이 프록시 패턴과 어느정도 교집합이 있지만 명확한 차이점이 존재한다.
- 프록시 패턴 : 제어의 흐름 변경과 별도의 로직 처리를 목적으로 하며, 대부분의 경우 클라이언트에게 전달되는 함수의 반환값은 변경되지 않음(내부적으로는 다른 로직이 실행되었을 것)
- 데코레이터 패턴 : 클라이언트에게 전달되는 함수의 반환값을 변경(장식해주기)하는 것이 주 목적
예제로 알아보는 데코레이터 패턴
package Decorator;
public class praymain {
public static void main(String[] args) {
IPerson nonReligiousSBL = new SBL();
nonReligiousSBL.haveAMill();
IPerson christianSBL = new ChristianDecorator(new SBL());
christianSBL.haveAMill();
}
}
무교(nonReligious)인 서병렬과 기독교(christian)인 서병렬이 식사를 한다. 그런데 christianSBL 객체를 할당할 때 보면 기독교 데코레이터가 마치 SBL 객체를 감싸고 있다고 보인다.
데코레이터 패턴 적용시 아래의 4개의 요소가 필요하다.
Component (Interface) : 원본 객체와 장식된 객체 모두를 묶는 역할
ConcreteComponent : 원본 객체 (데코레이팅 할 객체)
Decorator : 추상화된 장식자 클래스, 원본 객체를 합성(composition)한 wrappee 필드와 인터페이스의 구현 메소드를 가지고 있다.
ConcreteDecorator : 구체화된 장식자 클래스, 부모 클래스가 감싸고 있는 하나의 Component를 호출하면서 호출 전/후로 부가적인 로직을 추가할 수 있다.
위의 예제에서는 4가지 요소 중 Decorator 빼고 등장했다.
Component : IPerson
ConcreteComponent : SBL
ConcreteDecorator : ChristianDecorator
실제 객체와 데코레이터 객체가 구현하는 인터페이스
interface IPerson{
void haveAMill();
}
실제 객체 클래스
class SBL implements IPerson{
@Override
public void haveAMill() {
System.out.println("식사 하기");
}
}
추상화된 데코레이터 클래스
abstract class Decorator implements IPerson{
IPerson wrappee; // 싸여질 원본 객체라는 뜻으로 wrappee
Decorator(IPerson wrappee){
this.wrappee = wrappee;
}
public void haveAMill(){
wrappee.haveAMill(); // 들고 있는 원본 객체가 수행하도록 위임하는 구조
}
}
구체화된 데코레이터 클래스
class ChristianDecorator extends Decorator{
ChristianDecorator(IPerson wrappee){
super(wrappee); // 부모클래스의 생성자 호출로
// wrappee 원본 객체를 들고있도록 함
}
@Override
public void haveAMill() {
pray(); //데코레이터 클래스만의 추가적인 메서드 실행
super.haveAMill(); // 원본 객체의 해당 메서드를 실행
}
private void pray(){
System.out.println("기도 하기");
}
}
IPerson christianSBL = new ChristianDecorator(new SBL());
christianSBL.haveAMill();
위 코드의 실행 흐름
1. ChristianDecorator의 생성자에 매개변수로 IPerson 인터페이스의 구현체 객체인 SBL 객체를 전달.
2. ChristianDecorator의 부모클래스의 생성자가 호출되어 싸여지는 원본 객체를 데코레이터가 알게되었다.
3. christianSBL 객체의 haveAMill() 함수를 실행하여 데코레이터만이 가지고 있는 pray() 함수가 호출 되었다.
4. ChristianDecroator의 부모클래스의 haveAMill() 함수가 실행되어 싸여진 원본 객체(wrappee)의 haveAMill() 함수가 호출되었다.
무심결에 사용했던 BufferedReader 데코레이터 클래스
백준 문제를 풀든, 어떤 입력을 받아 처리하는 간단한 프로그램을 만들든, Java로 이를 수행했다면 99%는
BuferedReader 객체를 사용했을 것이다.
어떻게 BufferedReader를 편리하게 사용하고 있었을까?
BufferedReader 객체를 생성하는 시점부터 다시 생각해보자
FileReader fr = new FileReader(new File("내파일경로"));
BufferedRader br = new BufferedReader(new FileReader(new File("내파일경로")));
fr.read()
br.read()
BufferedReader 객체가 기존에 사용하던 FileReader 객체를 감쌌을 뿐인데,
사용자로서는 동일하다고 느껴질 read 인터페이스의 내부 동작이 변경되어 빠르게 입력을 받을 수 있고,
lines() 함수 등 추가된 기능도 있다.
아까 데코레이터 클래스와 실제 클래스 모두 같은 인터페이스의 구현체여야 했는데, BufferedReader 객체와 얘가 감싸고 있는 FileReader는 어떤 인터페이스의 구현체일까?
FileReader -> InputStreamReader -> Reader(abstract)
BufferedReader -> Reader(abstract)
결국 두 클래스는 모두 Reader 라는 추상 클래스를 상속받아 구현하고 있으니까 데코레이터 패턴이 잘 적용되는 것이다.