[TIL] 데코레이터 패턴

KYJ의 Tech Velog·2024년 7월 9일
0

디자인 패턴

목록 보기
3/3

🤔 배경

객체지향 프로그래밍을 하면서 '상속'을 자주 활용하게 될 것입니다.

한 클래스로부터 파생해서 여러 새로운 클래스를 만들기 편하기 때문이죠.

간단히 예를 들어, 카페의 음료 메뉴를 코드로 구현한다고 해봅시다.

'음료'라는 클래스를 구현하고, 이 클래스로부터 다양한 종류의 음료를 구현할 수 있죠.

Beverage 클래스는 추상 클래스로 모든 음료는 이 클래스의 자식이 되겠죠.

cost() 메서드는 음료의 가격을 반환하는 추상 메서드로서 자식 클래스에서 새로 정의되게 됩니다.

description 변수는 각 자식 클래스에서 설정되고, getDescription() 메서드를 호출해서 얻을 수 있습니다.

💡 상속의 남발은 위험!

다만, 이렇게 구현하게 되면 문제가 생기게 됩니다.

어떠한 상황에서도 Beverage를 상속받은 자식 클래스를 활용해야 합니다.

  • 우유나 두유, 모카, 휘핑크림 등 옵션을 추가한 음료
  • 새로운 종류의 음료

옵션을 추가할 때마다 음료의 가격은 달라지기 때문에 새로이 정의를 해주어야 하고, 새로운 종류의 음료를 추가할 때는 새로이 자식 클래스를 만들어줘야 하죠.

정말 방대한 클래스가 필요할 것입니다.

만약 이러한 시스템을 직접 직면하게 된다면 저는 이런 생각부터 들 것 같습니다.

'협업의 기본이 안 되어 있구나...'

😨 클래스가 방대해진 시스템

클래스가 방대해진 시스템은 유지 보수하기가 굉장히 어려울 것입니다.

만약 위의 상황에서 음료 옵션을 하나만 추가하게 되어도 엄청나게 많은 자식 클래스를 정의해줘야 합니다.

이를 해결하기 위한 다자인 패턴이 바로 '데코레이터 패턴'입니다.


디자인 패턴

디자인 원칙

새로운 디자인 원칙이 하나 등장합니다.

바로 OCP(Open-Closed Priciple)입니다.

  1. 클래스는 확장에는 열려 있어야 하지만 변경에는 닫혀 있어야 한다.

객체지향의 SOLID 원칙 중 하나입니다.

우리의 목표는 기존 코드는 건드리지 않고 확장으로 새로운 행동을 추가하는 것입니다.

이러한 시스템을 만들 수 있다면 그 시스템은 아주 유연해지겠죠.

💡 주의할 점

다만, 무조건 OCP를 적용하는 것은 조심해야 합니다.

어떠한 시스템에서 확장해야 할 부분을 선택하는 것은 주의가 필요합니다.

OCP를 적용하기 위해 너무나 많은 리소스(시간, 인력...)가 소비된다면 배보다 배꼽이 커지는 것이고, 필요 이상으로 복잡하고 이해하기 힘든 코드가 만들어지게 될 수도 있습니다.

데코레이터 패턴

몇 가지 기반이 되는 음료를 자식 클래스로 정의하고, 첨가물로 그 음료들을 장식(decorate)한다고 생각해 봅시다.

어떤 고객이 모카와 휘핑크림을 추가한 다크 로스트 커피를 원한다면 다음과 같이 장식할 수 있습니다.

  1. DarkRoast 객체를 가져옵니다.
  2. Mocha 객체로 장식합니다. (데코레이터)
  3. Whip 객체로 장식합니다. (데코레이터)
  4. cost() 메서드를 호출합니다.
    이때, 첨가물의 가격을 계산하는 일은 해당 객체에 위임합니다.

몇 가지 정보들을 정리해 보도록 하겠습니다.

  • 데코레이터의 상위 클래스는 자신이 장식하고 있는 객체의 상위 클래스와 같습니다.
  • 한 객체를 여러 개의 데코레이터로 감쌀 수 있습니다.
  • 데코레이터는 자신이 감싸고 있는 객체와 같은 상위 클래스를 가지고 있기에 원래 객체가 들어갈 자리에 데코레이터 객체를 넣어도 상관없습니다.
  • ✨데코레이터는 자신이 장식하고 있는 객체에 어떤 행동을 위임하는 일 말고도 추가 작업을 수행할 수 있습니다.
  • 객체는 언제든지 감쌀 수 있으므로 실행 중에 필요한 데코레이터를 마음대로 적용할 수 있습니다.

📖 정의

데코레이터 패턴(Decorator Pattern)으로 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터 패턴을 사용하면 자식 클래스를 만들 때보다 훨씬 유연하게 기능을 확장할 수 있습니다.

이때, 데코레이터에는 구성 요소(Component)의 레퍼런스를 포함한 인스턴스 변수가 있습니다.

데코레이터는 자신이 장식할 구성 요소와 같은 인터페이스 또는 추상 클래스를 구현합니다.

ConcreteDecorator에는 당연히 데코레이터가 감싸고 있는 Component 객체용 인스턴스 변수가 있겠죠. (Decorator의 자식이기 때문...)

이 구조를 보면, 데코레이터가 새로운 메서드를 추가할 수도 있습니다. 다만, 일반적으로는 Component에 원래 있던 메서드를 별도의 작업으로 처리해서 새로운 기능을 추가하죠.

Beverage 클래스 장식하기

데코레이터를 만들 때, 상위 클래스의 행동을 상속받는 것이 아니라 새로운 행동을 추가할 수 있습니다.

이것이 바로 중요한 포인트입니다. 상속의 목적은 행동을 상속받는 것이 아닙니다. 바로 데코레이터와 상위 클래스의 형식을 맞추기 위함입니다.

행동은 기본 구성 요소와는 다른 데코레이터 등을 인스턴스 변수에 저장하는 식으로 연결하는 것입니다.

이렇게 객체 구성(인스턴스 변수로 다른 객체를 저장하는 방식)을 이용하고 있기 때문에 음료에 첨가물을 추가하더라도 유연성을 잃지 않을 수 있습니다.

만약 상속만 활용한다면 행동이 컴파일 시에 정적으로 결정되어 버립니다. 기존에 상위 클래스에서 받은 것과 오버라이드한 것만 사용할 수 있다는 뜻이죠.

하지만 구성을 활용한다면 실행 중에 데코레이터를 마음대로 조합해서 사용할 수가 있습니다. 언제든지 데코레이터를 구현해서 새로운 행동을 추가할 수가 있습니다. 기존 코드를 바꾸지 않고 말이죠!

여기서 상위 클래스는 꼭 추상 클래스일 필요는 없습니다. 인터페이스일 수도 있죠. 음료 예시에서는 기존의 시스템이 추상 클래스로 되어 있는 상태에서 데코레이터 패턴을 적용한 시스템으로 바꾸는 것이기 때문에 추상 클래스를 그대로 활용한 것뿐입니다.

💻 커피 주문 시스템 코드 만들기

// 1. 추상 구성 요소
public abstract class Beverage
{
	string description = "제목 없음";
    
    public String GetDescription()
    {
    	return description;
    }
    
    public abstract double Cost();
}
// 1-1. 구상 구성 요소
// 1-2~. 같은 방식으로 HouseBlend, Decaf, DarkRoast도 설명과 가격을 다르게 설정해서 정의할 수 있겠죠.
public class Espresso : Beverage
{
	public Espresso()
    {
    	description = "에스프레소"; // Beverage에서 상속받는 정보
    }
    
    public double Cost()
    {
    	return 1.99d;
    }
}
// 2. 추상 데코레이터
// Beverage 객체가 들어갈 자리에 들어갈 수 있어야 하므로 Beverage 클래스 상속
public abstract class CondimentDecorator : Beverage
{
	Beverage beverage;
    public abstract string GetDescription();
}
// 2-1. 구상 데코레이터
// 2-2~. 마찬가지로 다른 첨가물들도 설명과 가격을 바꿔서 정의할 수 있습니다.
public class Mocha : CondimentDecorator
{
	// 인스턴스 변수를 감싸고자 하는 객체로 설정하는 생성자
	public Mocha(Beverage beverage)
    {
    	this.beverage = beverage; // 감싸고자 하는 음료를 저장하는 인스턴스 변수
    }
    
    public string GetDescription()
    {
    	// 장식하고 있는 객체 설명을 가져오는 작업을 위임하고 그 결과에 모카를 더한 값을 반환
    	return beverage.GetDescription() + ", 모카";
    }
    
    public double Cost()
    {
    	// 가격을 구하는 작업을 장식하고 있는 객체에 위임해서 음료값을 구하고 모카 가격을 더하여 그 합을 반환
    	return beverage.Cost() + 0.20d;
    }
}
// 주문용 테스트 코드
// 위에서 구상 구성 요소와, 구상 데코레이터를 전부 구현하고 와야 다음 코드가 정상적으로 실행될 것입니다.
public class StarbuzzCoffee
{
	public static void Main(string[] args)
    {
    	Beverage beverage = new Espresso();
        Console.WriteLine(beverage.GetDescription() + " $" + beverage.Cost());
        
        Beverage beverage2 = new DarkRoast();
        beverage2 = new Mocha(beverage2);
        beverage2 = new Mocha(beverage2);
        beverage2 = new Whip(beverage2);
        Console.WriteLine(beverage2.GetDescription() + " $" + beverage2.Cost());
        
        Beverage beverage3 = new HouseBlend();
        beverage3 = new Soy(beverage3);
        beverage3 = new Mocha(beverage3);
        beverage3 = new Whip(beverage3);
        Console.WriteLine(beverage3.GetDescription() + " $" + beverage3.Cost());
    }
}

📕 마무리

지금까지 데코레이터 패턴을 활용해서 커피 주문 시스템을 간단하게 구현해 보았습니다.

간이 시스템인 것은 제쳐두더라도 이 시스템에는 여러 가지 문제가 생길 것입니다.

만약 특별 할인 같은 작업을 한다고 해봅시다. 특정 구상 구성 요소(HouseBlend, Espresso 등...)에 할인을 적용해야 한다고 하면, 우리는 어떤 데코레이터로 감싸진 객체가 어떤 구상 구성 요소로 되어 있는지 알아야 합니다.

추상 구성 요소로 돌아가는 시스템이라면 괜찮겠지만 위처럼 구상 구성 요소로 돌아가는 시스템이라면 데코레이터 패턴을 사용하는 것을 재고해 봐야 합니다.

또한, 데코레이터를 빼먹는 실수를 할 수도 있습니다. 객체가 줄긴 했지만, 어찌되었든 데코레이터가 늘어남에 따라 관리해야 할 객체는 늘어나게 됩니다. 그렇다면 여러 가지 실수를 할 가능성도 높아지겠죠. 하지만, 실제로는 팩토리나 빌더 같은 다른 패턴으로 데코레이터를 만들고 사용한다고 합니다. 이러한 패턴들을 배우다 보면 데코레이터로 장식된 구상 구성 요소는 캡슐화가 잘 되어 있어서 제기한 문제는 별로 걱정하지 않아도 된다고 하네요:)

마지막으로 데코레이터가 적용된 대표적인 예시에는 java.io 패키지가 있습니다. 데코레이터 패턴을 바탕으로 만들어졌다고 하는데, 궁금하신 분들은 직접 열어보시는 것도 좋을 것 같네요.

0개의 댓글