* 이 글은 Kotlin 문법에 익숙하다는 것을 전제로 작성했다. interface, inline function, 각종 함수형 패러다임 지원을 위한 문법을 사용할 수 있다면 어렵지 않을 것이다.
문제상황
자식 클래스가 아주 다양하고, 이들이 다들 비슷한 처리를 요구할 때가 있다. Android의 AudioEffect 같은 경우가 그렇다.
처리를 변경할 때 마다 일일이 메서드를 찾아다니기는 무리여서, 자식 클래스들을 효율적으로 관리할 수 있는 방법이 필요했다.
이런 과일 클래스가 있다고 생각해보자. 각 과일은 Fruit로부터 상속받은 여러 메서드를 구현하고 있다. 이 클래스는 더 이상 바꿀 수 없다고 가정하자.
interface Fruit {
fun buy()
fun wash()
fun dry()
fun wrap()
}
class Apple(): Fruit {...} // 구현생략
class Pear(): Fruit {...}
class Lemon(): Fruit {...}
class Strawberry(): Fruit {...}
우리가 하고 싶은 건 이 과일들을 사고팔 수 있는 과일가게 클래스를 만드는 것이다.
과일을 팔려면 진열대에 과일을 예쁘게 포장해서 올려놔야 한다. 이 작업을 코드로 표현하면 아래와 같다.
class FruitShop(
private val fruits: ArrayList<Fruit> = arrayListOf<Fruit>()
) {
fun putFruits() {
fruits.add(Apple().buy().wash().dry().wrap())
fruits.add(Pear().buy().wash().dry().wrap())
fruits.add(Lemon().buy().wash().dry().wrap())
fruits.add(Strawberry().buy().wrap()) // 딸기는 씻으면 맛이 없다
}
}
아주 지저분하다. 생성할 클래스의 종류를 배열로 받아서 처리하면 훨씬 깔끔할 거라는 생각이 든다.
그러려면 생성할 클래스를 지정해야 하는데, 이 방법에 세 가지 정도가 있다.
1. Reflection
class FruitShop(
private val fruits: ArrayList<Fruit> = arrayListOf<Fruit>()
) {
fun putFruitsUsingReflection() {
arrayOf(
Apple::class.java, Pear::class.java, Lemon::class.java
).forEach { fruits.add(prepareFruit(it)) }
fruits.add(Strawberry().buy().wrap()) // 딸기는 씻으면 맛이 없다
}
private fun <T: Fruit> prepareFruit(type:Class<T>): Fruit {
// Reflection을 이용하여 객체 생성
return type.getConstructor().newInstance().buy().wash()
}
}
Kotlin은 런타임에 클래스 자체를 객체의 인자로 전달 할 수 있다.
여기서 Refelction을 이용하면 전달받은 클래스의 생성자(constructor)에 접근할 수 있으며, 인스턴스를 생성할 수도 있게된다. 이 방법은 평소엔 거의 안쓰지만, Annotation processor를 구현할 때처럼 런타임에 클래스에 반드시 접근해야 하는 경우에는 필수로 사용한다.
장점 : 직관적이다.
단점 : Reflection은 클래스 자체에 대한 참조를 얻어야 하기 때문에 아래 두 방법에 비해 컴퓨팅 부하가 크다.
2. Conditional branching
class FruitShop(
private val fruits: ArrayList<Fruit> = arrayListOf<Fruit>()
) {
fun putFruitsUsingBranching() {
arrayOf(
FruitName.APPLE, FruitName.PEAR, FruitName.LEMON, FruitName.STRAWBERRY
).forEach { fruits.add(prepareFruit(it)) }
}
private fun prepareFruit(name: FruitName): Fruit {
return when (name) {
FruitName.APPLE -> Apple().buy().wash().dry().wrap()
FruitName.PEAR -> Pear().buy().wash().dry().wrap()
FruitName.LEMON -> Lemon().buy().wash().dry().wrap()
FruitName.STRAWBERRY -> Strawberry().buy().wrap() // 딸기는 씻으면 맛이 없다
}
}
enum class FruitName {
APPLE, PEAR, LEMON, STRAWBERRY
}
}
enum class를 하나 만들어 과일 종류를 표현하게 하고, 이를 함수의 인자로 받아 과일별로 다른 처리를 하게 만드는 함수를 만든다.
이 함수에서만 Fruit 클래스를 쓴다면 괜찮은 방법이지만, 다른 클래스에서 이 함수를 쓰기가 어렵다. 복붙을 하면 과일 종류가 하나라도 바뀌었을 때 일일이 수정해줘야 하고, 이 함수 하나 쓰자고 그 클래스에게 FruitShop 객체를 제공하기엔 클래스의 역할에 알맞지 않다.
장점 : 컴퓨팅 부하가 적고 가독성이 좋다.
단점 : 해당 클래스에 종속성이 너무 강하다. 다른 클래스에서 같은 기능을 써야할 때 쓰기 불편하다.
3. Strategy pattern
interface FruitWashStrategy { // Strategy
fun prepareFruit(fruit: Fruit): Fruit {}
}
class HardFruitWashStrategy : FruitWashStrategy {
override fun prepareFruit(fruit: Fruit): Fruit {
return fruit.buy().wash().dry().wrap()
}
}
class SoftFruitWashStrategy : FruitWashStrategy {
override fun prepareFruit(fruit: Fruit): Fruit {
return fruit.buy().wrap()
}
}
class FruitShop(
private val fruits: ArrayList<Fruit> = arrayListOf<Fruit>(),
private val hardFruitStrategy: FruitWashStrategy = HardFruitWashStrategy(),
private val softFruitWashStrategy: FruitWashStrategy = SoftFruitWashStrategy(),
) {
fun putFruitsUsingStrategy() {
arrayOf(
Apple(), Pear(), Lemon()
).map { fruit -> hardFruitStrategy.prepareFruit(fruit) }
.forEach { washedFruit -> fruits.add(washedFruit) }
arrayOf(
Strawberry()
).map { fruit -> softFruitWashStrategy.prepareFruit(fruit) }
.forEach { washedFruit -> fruits.add(washedFruit) }
}
}
이번 스텝은 단순히 배열을 사용하는 것 보다는, 과일을 어떻게 처리할지에 대한 변경을 위주로 보자.
과일을 준비하는 역할을 하는 부분만 개별 클래스로 분리했다.
prepareFruit() 메서드를 다양하게 구현하고, FruitShop에게 어떤 전략을 선택할 건지 결정하게 했다.
여기 같은 경우는 FruitShop을 초기화할 때 부드러운 과일 / 단단한 과일에 대해 적용할 전략을 고르고 수행하도록 했다.
장점 : 다른 클래스에서 과일을 준비하는 전략을 재사용할 수 있다. FruitShop 코드 수정 없이도 과일 씻는 전략을 변경할 수 있다.
단점 : 클래스 생성이 많아진다. 하지만 추후 재사용할 수 있음을 고려하면 이 단점은 치명적이지는 않다.
전략 패턴이 뭔지 더 알아보면, 정의는 아래와 같다.
동일 계열 알고리즘군을 정의하고, 각 알고리즘을 캡슐화하여 이들 간의 상호교환이 가능하게 한다. 알고리즘을 사용하는 클라이언트와 상관없이 독립적으로 알고리즘을 다양하게 변경할 수 있게 한다.
주목해야 할 단어는 '동일 계열 알고리즘' 그리고 '독립적인 변경'이다. 여러 클래스가 비슷한 행동을 반복하고 있다면 이걸 Strategy 클래스로 묶어주고, 행동의 변경이 필요할 때 Strategy를 변경하는 식으로 구현된다.
이 패턴을 사용할 때는 항상 아래처럼 3가지 이상의 클래스가 필요하다.
위 그림만 알면 기존 기능을 전략패턴으로 손쉽게 바꿀 수 있다.
1. Context 역할의 클래스에서 비슷한 코드가 계속 반복되는 것을 발견한다.
2. Context에서 반복되는 코드의 공통적인 특징을 잡아 함수명을 떠올린다.
3. 그 함수를 Strategy Interface에 선언한다. 구현은 아직이다.
4. Strategy를 상속받는 클래스를 여러 개 만들고, 반복 코드를 잘라다가 새로 만든 클래스의 함수에 붙여넣는다.
5. 반복 코드가 사라진 자리를 Strategy 타입 객체의 함수 호출로 대체한다.
왜 굳이 이런식의 캡슐화를 하는걸까?
공통 부분을 캡슐화하면 기능이 달라져도 Context의 코드를 바꾸지 않고 대응하는게 가능해지기 때문이다.
보기 좋고 편리한 측면도 있지만, 외부 개발자가 만든 클래스는 수정을 못하게 막아놓은 경우가 많아 이런 구현 패턴을 적용하는게 중요하다.
Strategy가 있으면 StrategyImpl 하나 새로 만들어서 생성자에 전달하면 될 일이, 코드가 사용자 코드에 그대로 박혀있으면 울며 겨자먹기로 사용자 코드를 갈아엎어야한다. 수정 과정에서 발생하는 각종 오류와 숨겨져있는 기능 고장은 덤이다.
또 내가 남겨놓은 StrategyImpl을 나중에 다시 사용할 수도 있게 된다.
요약
Fruit 클래스를 다루는 FruitShop 클래스를 구현하기 위해 Reflection, Enum class와 Branch, Strategy 패턴을 적용해보았다.
이 중 Strategy 패턴은 꼭 자식 클래스를 다루는 때만이 아니더라도, 비슷한 알고리즘이 반복될 때, 특히 그 코드가 프로젝트 전역에서 빈번하게 사용될 때 유용하게 사용할 수 있다.
다만 코드 규모가 아주 작고 재사용이 전혀 없으며, 다른 개발자가 절대 사용하지 않을 것이라 예상될 때에는 차라리 그 코드의 범위를 사용하는 클래스 내부로 제한하는 것이 더 알맞다. 아예 constructor에서 전처리를 해 주는 식으로..
내 코드 규모와 기능의 재사용 범위에 따라 셋 중 적절한 전략을 사용하도록 하자.
참고자료
Erich Gamma 외 3인. GoF의 디자인 패턴. 프로텍미디어. 2015 (p407-418)