대규모 객체지향 시스템에서 객체를 취약하게 만드는 문제는 구현 상속(implementation inheritance)에서 빈번하게 발생합니다. 하위 클래스가 상위 클래스의 세부 구현 사항에 의존하게 되면 상위 클래스의 내용이 변경될 때마다 하위 코드의 내용이 깨지고 오작동하는 경우가 있을 수 있습니다. 즉, 하위 클래스의 캡슐화가 깨지게 됩니다.
‘이펙티브 자바’에서는 상위 객체를 상속받지 말고 해당 인스턴스 객체를 private
필드로 가지고 있는 조합(Composition)의 방법을 권장합니다. 기존 클래스를 상속의 방법으로 확장하는 대신 새로운 클래스에서 기존 클래스를 구성요소로 사용하는 방법입니다. 새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환하는 전달(forwarding)의 방법을 사용합니다.
코틀린에서는 이 패러다임을 적극적으로 반영합니다. 모든 클래스와 메서드는 default로 final
이 선언되어있습니다. 클래스를 상속받기 위해서는 open
키워드를 같이 써줘야하므로 어떤 클래스와 메서드가 상속이 가능한지 확인할 수 있습니다.
그렇다면 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야할 때는 어떻게 해야할까요? 기존에는 부모 객체를 상속하여 하위 객체를 만들어가면서 동작을 추가했습니다. 조합의 방법은 인스턴스 멤버를 감싸는 객체를 덧씌워서 행동을 추가합니다. 덧씌우는 객체를 래퍼 클래스라고 하고 이러한 방법을 데코레이터 패턴(Decorator Pattern)이라고 합니다.
데코레이터 패턴에서는 컴포지션과 전달의 방법으로 객체의 행동을 덧씌웁니다. 이 방법을 위임(delegation)이라고 부릅니다. 데코레이터 패턴은 주어진 상황 및 용도에 따라 어떤 객체에 책임(기능)을 동적으로 추가하는 패턴입니다.
Component
: ConcreteComponent
와 Decorator
에 공통적으로 제공될 기능이 정의된 클라이언트에서 호출하게 되는 역할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 중에서도 확장을 시킬 수 있습니다.
위의 예제에서는 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
이전 코드와 같이 CoffeeDecorator
에 Beverage
멤버및 메서드가 위임이 됩니다. 따라서 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
코틀린은 이러한 위임의 방식을 키워드로서 제공합니다. 이펙티브 자바와 모던 아키텍쳐, 디자인 패턴 등의 영향을 많이 받은 언어임을 알 수 있습니다.
[Design Pattern] 데코레이터 패턴(Decorator pattern)에 대하여