본문 바로가기
Programming

디자인패턴 시리즈 3. 데코레이터 패턴 (Decorator Pattern)

by LeeJ1Hyun 2023. 1. 13.

데코레이터 패턴 (Decorator Pattern)

서브클래스를 만들 때보다 훨씬 유연하게 기능을 확장할 수 있다. 객체에 추가 요소를 동적으로 더 할 수 있다.

 

코드에 적용해보기

샌드위치 가게에서 주문을 한다고 하자. 예를 들면 토핑 주문 지옥이라는 서브웨이. 모든 샌드위치들은 Sandwich 추상 클래스의 서브클래스가 된다. price() 메소드는 추상 메소드이고 서브클래스는 이 메소드를 구현해야 한다. name 인스턴수 변수는 서브 클래스에서 정해지고 getName() 메소드를 통해서 호출해서 정해진 이름을 알 수 있다.

 

구현을 통한 샌드위치 주문

 

햄 샌드위치, 참치 샌드위치, blt 샌드위치에서는 가격을 반환하는 price() 메소드를 구현해야 한다. 하지만 햄, 참치 등은 기본 재료이고 여기에 에그마요, 베이컨, 양상추 많이 등을 더하고 싶을 수도 있다. 각각의 추가 토핑마다 가격도 상이할 것이다. 그래서 에그마요를 추가한 햄샌드위치, 양상추를 추가한 참치 샌드위치, 베이컨과 양상추를 추가한 햄샌드위치 등과 같이 서브클래스를 만들었다. 하지만 이런식이라면 금방 수천개의 클래스가 탄생하게 된다.

 

 

그렇다면 어떻게 각각의 토핑들을 조합한 가격을 알 수 있는 주문 과정을 설계할 수 있을까. 인스턴스 변수와 슈퍼클래스를 상속하여 토핑을 관리해보자.

 

인스턴스 변수와 슈퍼클래스를 상속을 이용한 리팩토링

 

eggmayo, bacon. lecttuce 변수는 boolean 타입이다. 유무를 true, false로 나타낸다. price() 메소드를 추상 클래스로 정의하지 않고 각 토핑에 해당하는 가격까지 계산할 수 있도록 구현할 것이다. 오버라이드는 여전히 해야하지만 원가격에 토핑값을 추가할 수 있게 되었다. 아까 수천개의 클래스를 만들던 때보다 훨씬 발전했다는 것에 의미를 두자. getter, setter 메소드를 통해선 토핑을 추가하고 존재 유무를 확인한다.

 

public class Sandwich {
    double eggmayoPrice = 0.4;
    double baconPrice = 0.5;
    double lecttucePrice = 0.2;
    
    // 각 토핑에 대한 getter, setter 메소드 존재 (생략)
    
    public double price() {
    	double topping = 0.0;
        
        if(hasEggmayo()) {
        	topping += eggmayoPrice;
        }
        
        if(hasBacon()) {
        	topping += baconPrice;
        }
     
        if(hasLecttuce()) {
        	topping += lecttucePrice;
        }
        
        return topping;
    }
}

 

public class HamSandwich extends Sandwich {
	
    public HamSandwich() {
    	name = "햄 샌드위치";
    }
    
    public double price() {
    	return 4.5 + super.price();
    }
}

 

서브 클래스의 price() 메소드에서는 샌드위치의 가격을 계산하고 슈퍼 클래스에서 구현한 price()를 호출해서 토핑 비용을 더한다. 하지만 토핑의 가격이 바뀌면 Sandwich 클래스를 변경해야 한다. 또 토핑이 추가되면 메소드를 추가해야 한다. 상속을 사용해서 토핑의 가격을 더하고 주문을 하는 방법은 좋지 않은 설계이다.

 

이때 데코레이터 패턴을 이용할 수 있다. 말 그대로 이제 샌드위치 객체를 토핑들로 장식(데코레이트) 할 것이다. 이제는 토핑의 가격을 계산하는 것은 각 토핑 객체들에게 대신 하게 한다.

 

데코레이터 패턴

 

HamSandwich는 Sandwich로부터 상속받기 때문에 price() 메소드를 가지고 있다. 이를 감싸고 있는 데코레이터는 Lecttuce, Eggmayo는 장식하고 있는 객체와 같은 형식을 갖는다. 그래서 price() 메소드를 가지고 있는 것이다.

 

  1. 가장 바깥의 데코레이터인 Eggmayo의 price() 메소드를 호출
  2. Eggmayo는 Lecttuce의 price() 메소드를 호출
  3. Lecttuce는 HamSandwich의 price() 메소드를 호출
  4. HamSandwich는 4.5를 반환
  5. Lecttuce는 4.5에 0.2를 더해 반환
  6. Eggmayo는 4.7에 0.4를 더해 반환 (최종)

 

데코레이터의 슈퍼 클래스는 장식하고 있는 객체의 슈퍼 클래스와 같다. 여러 개의 데코레이터로 감쌀 수도 있다. 가장 중요한 점은 데코레이터는 장식하고 있는 객체에게 어떤 행동(예를 들면 price())을 대신 하게 하는 것 말고도 추가 메소드를 만들 수도 있다.

 

데코레이터 패턴을 이용한 샌드위치 주문 설계

 

각 샌드위치의 종류마다 구성 요소를 나타내는 구상 클래스를 만들고, 토핑들은 데코레이터로 작용한다. price()와 getName() 메소드도 구현해야 한다. 이제는 그냥 햄 샌드위치가 아닌, 양상추를 추가한 햄 샌드위치 라고 부를 것이기 때문이다.

 

데코레이터 패턴에서는 상속 대신 구성을 사용한다고 했는데 UML을 보면 ToppingDecorator에서 Sandwich 클래스를 확장하고 있으므로 상속하고 있다. 그러나 데코레이터의 형식이 장식하는 객체의 형식과 같다는 점이 중요하다. 형식을 맞추기 위해 상속을 사용하는 것이기 때문이다. 행동을 물려받는 게 아니니 일반적인 상속의 목적과는 다르다고 볼 수 있다.

 

상속만 써야 했다면 새로운 행동을 추가할 때 기존 코드를 손대야 할 수 밖에 없지만 데코레이터를 통해 마음껏 행동의 추가가 가능해졌다. 인스턴수 변수로 다른 객체를 저장하는 방식을 쓰고 있어서 언제든 샌드위치에 토핑을 추가해도 유연성을 잃지 않기 때문이다. 이제 코드로 살펴보자.

 

public abstract class Sandwich {
	String name = "미정";
    
    public String getName() {
    	return name;
    }
    
    public abstract double price();
}

 

추상 구성 요소 Sandwich 클래스

 

public abstract class ToppingDecorator extends Sandwich {
	Sandwich sandwich;
    public abstract String getName();
}

 

추상 데코레이터 ToppingDecorator 클래스

 

public class HamSandwich extends Sandwich {
	
    public HamSandwich() {
    	name = "햄 샌드위치"; // Sandwich로부터 상속 받은 name 인스턴수 변수
    }
    
    public double price() {
    	return 4.5;
    }
}

 

구상 구성 요소 HamSandwich 클래스

 

public class Eggmayo extends ToppingDecorator {
	
    public Eggmayo(Sandwich sandwich) {
    	this.sandwich = sandwich;
    }
    
    public String getName() {
    	return "에그마요를 추가한, " + sandwich.getName();
    }
    
    public double price() {
    	return sandwich.price() + 0.4;
    }
}

 

구상 데코레이터 Eggmayo 클래스

 

Sandwich 클래스를 확장하는 ToppingDecorator를 확장하는 Eggmayo 클래스 형태이다.

 

public class OrderSandwich {
	
    public static void main(String[] args) {
    	Sandwich sandwich = new HamSandwich();
        
        sandwich = new Eggmayo(sandwich);
        System.out.println(sandwich.getName()); // 에그마요를 추가한, 햄 샌드위치
        System.out.println(sandwich.price()); // 4.9
    }
}

 

물론 가장 처음보단 코드가 간결해지긴 했지만 Sandwich sandwich = new HamSandwich(); 이런 식으로 객체를 만들 때 더 나은 방법이 존재한다. 이는 팩토리, 빌더 패턴에서 소개할 예정이다.

 

데코레이터 패턴의 정의

 

Component : 구성 요소 (그냥 사용하거나 데코레이터로 감싸서 사용), Decorator와 ConcreteComponent 사이의 인터페이스.

Decorator : 장식할 구성 요소와 같은 추상 클래스(또는 인터페이스) 구현, Component 객체가 들어있다.

ConcreteComponent : 기본 기능을 구현한 클래스로, 여기에 새로운 행동을 동적으로 추가.

ConcreteDecorator : 생성자에서 Component를 변수로 받는다.

 

Decorator가 새로운 메소드를 추가할 수도 있지만 일반적으로 새로운 메소드를 추가하는 대신 Component에 원래 있던 메소드를 처리하여 새로운 기능을 추가한다고 한다.

 

 

 

 

 

* 아래의 자료들을 참고하였습니다.

에릭 프리먼 · 엘리자베스 롭슨 · 케이시 시에라 · 버트 베이츠, 『헤드퍼스트 디자인패턴 개정판』, 서환수 옮김, 한빛미디어(2022), p114-140.

댓글