Decorator Pattern과 Kotlin by 키워드

EP·2022년 8월 21일
1

Kotlin + Spring

목록 보기
5/9

Overview


대규모 객체지향 시스템에서 객체를 취약하게 만드는 문제는 구현 상속(implementation inheritance)에서 빈번하게 발생합니다. 하위 클래스가 상위 클래스의 세부 구현 사항에 의존하게 되면 상위 클래스의 내용이 변경될 때마다 하위 코드의 내용이 깨지고 오작동하는 경우가 있을 수 있습니다. 즉, 하위 클래스의 캡슐화가 깨지게 됩니다.

‘이펙티브 자바’에서는 상위 객체를 상속받지 말고 해당 인스턴스 객체를 private 필드로 가지고 있는 조합(Composition)의 방법을 권장합니다. 기존 클래스를 상속의 방법으로 확장하는 대신 새로운 클래스에서 기존 클래스를 구성요소로 사용하는 방법입니다. 새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환하는 전달(forwarding)의 방법을 사용합니다.

코틀린에서는 이 패러다임을 적극적으로 반영합니다. 모든 클래스와 메서드는 default로 final이 선언되어있습니다. 클래스를 상속받기 위해서는 open 키워드를 같이 써줘야하므로 어떤 클래스와 메서드가 상속이 가능한지 확인할 수 있습니다.

그렇다면 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야할 때는 어떻게 해야할까요? 기존에는 부모 객체를 상속하여 하위 객체를 만들어가면서 동작을 추가했습니다. 조합의 방법은 인스턴스 멤버를 감싸는 객체를 덧씌워서 행동을 추가합니다. 덧씌우는 객체를 래퍼 클래스라고 하고 이러한 방법을 데코레이터 패턴(Decorator Pattern)이라고 합니다.

Decorator Pattern


데코레이터 패턴에서는 컴포지션과 전달의 방법으로 객체의 행동을 덧씌웁니다. 이 방법을 위임(delegation)이라고 부릅니다. 데코레이터 패턴은 주어진 상황 및 용도에 따라 어떤 객체에 책임(기능)을 동적으로 추가하는 패턴입니다.

  • Component: ConcreteComponentDecorator에 공통적으로 제공될 기능이 정의된 클라이언트에서 호출하게 되는 역할
  • ConcreteComponent: Component를 상속받아 그 기능을 구현한 클래스
  • Decorator: Component의 인터페이스를 따르지만 Component를 래핑해서 새롭게 기능을 제공. ConcreteComponent 객체에 대한 참조를 하여 합성 관계를 표현
  • ConcreteDecorator: 개별적인 기능을 추가

위의 방법을 통해 OCP(Open-Closed Principle)를 만족하며 캡슐화를 지킬 수 있습니다. 데코레이터 패턴을 조합과 위임의 방법으로 구현한 개념을 코드로 확인해보겠습니다.

interface Component {
    val state: Int
    fun operation(text: String): String
}

class ConcreteComponent(
    override val state: Int
) : Component {
    override fun operation(text: String): String {
        return "${text}::${this.state}::operation"
    }
}

Component에서는 공통적으로 사용할 책임을 정의합니다. ConcreteComponent는 그 Component를 상속받아 구현 내용을 작성하죠.

abstract class Decorator(
    private val component: Component
) : Component {
    override fun operation(text: String): String {
        return component.operation(text)
    }
}

class ConcreteDecorator(
    component: Component
) : Decorator(component) {
    override val state = component.state
    override fun operation(text: String): String {
        return super.operation(text) + "::decorate"
    }
}

Decorator 는 그 Component의 인스턴스를 내부 필드로 포함하고 있습니다. Decorator 역시 Component 인터페이스를 구현하고 있고 그 Component의 메서드와 필드를 그대로 사용합니다. 기존 ConcreateComponent 인스턴스에게 요청을 그대로 전달(forwarding)하게 됩니다.

class SubConcreteDecorator(
    component: Component
) : Decorator(component) {
    override val state = component.state
    override fun operation(text: String): String {
        return super.operation(text) + "::sub"
    }
}

fun main() {
    val concreteComponent = ConcreteComponent(1)
    println(concreteComponent.operation("EP")) // EP::1::operation

    val concreteDecorator = ConcreteDecorator(ConcreteComponent(1))
    println(concreteDecorator.operation("EP")) // EP::1::operation::decorate

    val subConcreteDecorator = SubConcreteDecorator(ConcreteDecorator(ConcreteComponent(1)))
    println(subConcreteDecorator.operation("EP")) // EP::1::operation::decorate::sub
}

데코레이터를 추가하면 기존 기능에 다른 기능을 덧씌울 수 있습니다. 데코레이터 패턴은 기존 코드를 수정하지 않고 행동을 확장시킬 수 있으며 Runtime 중에서도 확장을 시킬 수 있습니다.

Kotlin by 키워드


위의 예제에서는 Component의 필드와 메서드가 많지 않아 Decorator 코드 작성이 어렵지 않아보였습니다. 하지만 만일 Component의 멤버가 많을 경우에 어떻게 될까요? Component를 상속받아야하는 Concrete 구현체와 모든 Decorator가 일일이 메서드와 필드에 위임하는 코드를 작성해줘야 합니다. 이른바 보일러플레이트 코드가 상당히 많이 생기게 됩니다.

코틀린은 이런 보일러플레이트 코드를 최소화하기 위한 by 키워드를 제공합니다.

The Delegation pattern has proven to be a good alternative to implementation inheritance, and Kotlin supports it natively requiring zero boilerplate code. - kotlinlang.org

interface Beverage {
    val name: String
    val description: String
    val capacity: Int
    val price: Int
    fun menu(): String
}

class Coffee(
    override val name: String = "Coffee",
    override val description: String = "Coffee Description",
    override val capacity: Int = 350,
    override val price: Int = 3000
) : Beverage {
    override fun menu(): String = "${this.name} : ${this.price} - ${this.description}"
}

abstract class CoffeeDecorator(
    private val coffee: Coffee
) : Beverage {
    override val name = this.coffee.name
    override val description = this.coffee.description
    override val capacity = this.coffee.capacity
    override val price = this.coffee.price
    override fun menu() = this.coffee.menu()
}

class LatteDecorator(
    coffee: Coffee
) : CoffeeDecorator(coffee) {
    override val name = "Latte ${super.name}"
    override val description = "Latte ${super.description}"
    override val capacity = super.capacity
    override val price = super.price + 1000
    override fun menu() = "${this.name} : ${this.price} - ${this.description}"
}

fun main() {
    val coffee = Coffee()
    println(coffee.menu()) // Coffee : 3000 - Coffee Description

    val latte = LatteDecorator(Coffee())
    println(latte.menu()) // Latte Coffee : 4000 - Latte Coffee Description
}

커피의 예제로 바꿔서 표현하면 위와같은 코드가 작성이 됩니다. 코틀린은 by 키워드를 통해 인터페이스에 대한 구현을 다른 객체에 위임중이라는 사실을 명시할 수 있습니다. 아래의 코드와 같이 표현이 됩니다.

abstract class CoffeeDecorator(
    private val coffee: Coffee
) : Beverage by coffee

이전 코드와 같이 CoffeeDecoratorBeverage 멤버및 메서드가 위임이 됩니다. 따라서 CoffeeDecorator의 인스턴스도 Coffee 구현체의 멤버 및 메서드를 사용할 수 있습니다.

인터페이스는 다중 상속이 가능합니다. 따라서 by 키워드로 여러 객체를 위임받을 수 있습니다.

interface Bean {
    val bean: String
}

class ColumbiaBean(
    override val bean: String = "Columbia"
) : Bean

abstract class CoffeeDecorator(
    private val coffee: Coffee,
    private val coffeeBean: Bean
) : Beverage by coffee, Bean by coffeeBean

코틀린은 이러한 위임의 방식을 키워드로서 제공합니다. 이펙티브 자바와 모던 아키텍쳐, 디자인 패턴 등의 영향을 많이 받은 언어임을 알 수 있습니다.

Reference


[Design Pattern] 데코레이터 패턴(Decorator pattern)에 대하여

이펙티브 자바 3/E - 교보문고

토비의 스프링 3.1 Vol 1: 스프링의 이해와 원리 - 교보문고

코틀린 공식 홈페이지

profile
Hello!

0개의 댓글