클린 소프트웨어 - 템플릿 메소드와 스트래터지 패턴: 상속과 위임

템플릿 메소드와 스트래터지 패턴: 상속과 위임

90년대 초반에 개발자들은 상속inheritance의 개념에 매료되어 있었다. 상속을 사용하여 차이에 의한 프로그래밍program by difference을 할 수 있었기 때문이다. 어떠한 일을 하는 특정 클래스가 이미 있다면, 이것을 상속받고 일부분을 수정하는 것으로 코드를 재사용할 수 있었기 때문이다. 각 단계가 그 상위 단계의 코드를 재사용하는 소프트웨어 구조의 전체 분류 체계를 만들 수 있었기 때문에, 상속을 통해 프로그래밍 분야에 완전히 새로운 세계가 열렸다.

하지만 상속이 너무 과도하게 사용되면 매우 비싼 대가를 치르게 되는 것이 드러났다. 클래스 상속보다는 차라리 복합composition이 더 낫다라고 강조되기까지 했다. 그래서 상속을 사용한 부분을 잘라내고, 경우에 따라 복합이나 위임delegation으로 대체했다.

템플릿 메소드template method 패턴과 스트래티지strategy 패턴은 구체적인 내용으로부터 일반적인 알고리즘을 분리하는 문제를 해결하기 위해 사용되지만, 템플릿 메소드 패턴은 상속을, 스트래티지 패턴은 위임을 사용한다.

구체적인 내용으로부터 일반적인 내용을 분리하는 문제는 소프트웨어 설계에서 자주 발견된다. DIP를 따르게 하기 위해서는 일반적인 알고리즘이 구체적인 구현에 의존하지 않으며, 일반적인 알고리즘과 구체적인 구현이 추상화에 의존하도록 해야 한다.

템플릿 메소드 패턴

setup()
while !isDone {
doSomething()
}
cleanup()

어떠한 프로그램은 위와 같은 구조로 동작할 것이다.

  1. 애플리케이션을 초기화한 후 메인 루프에 들어가 프로그램이 요구하는 작업을 수행한다.
  2. 작업 수행이 완료되면, 메인 루프를 빠져나가 정리 작업을 수행한다.

위의 일련의 작업을 하나의 클래스에 몰아 넣을 수 있을 것이다. 이 클래스는 재사용 가능하게 된다.

var isDone = false
while !isDone {
let fahrenheit = readLine()!
if fahrenheit.isEmpty {
isDone = true
} else {
let fahrenheitValue = Double(fahrenheit)!
let celciusValue = 5.0 / 9.0 * (fahrenheitValue - 32)
print("F=\(fahrenheitValue),C=\(celciusValue)")
}
}
print("exit")

템플릿 메소드 패턴을 적용하면 위의 구조를 프로그램에서 분리할 수 있다.

class Application {
var isDone = false

func setup() {}
func doSomething() {}
func cleanup() {}

func run() {
setup()
while !isDone {
doSomething()
}
cleaup()
}
}

class FToCTemplateMethod: Application {
override func setup() {}

override func doSomething() {
let fahrenheit = readLine()!
if fahrenheit.isEmpty {
isDone = true
} else {
let fahrenheitValue = Double(fahrenheit)!
let celciusValue = 5.0 / 9.0 * (fahrenheitValue - 32)
print("F=\(fahrenheitValue),c=\(celciusValue)")
}
}

override func cleanup() {
print("exit")
}
}

Application().run()

Application 클래스는 일반적인 메인 루프 애플리케이션을 나타낸다. run() 메소드가 템플릿 메소드의 역할을 하여, 메인 알고리즘을 포함한다. 구체적인 작업은 setup(), doSomething(), cleanup()에 달려 있으며, 하위 클래스에서 구현한다.

알고리즘의 구조를 변경하지 않고, 알고리즘의 특정 단계들을 다시 정의할 수 있게 해준다.

패턴 오용

위와 같이 패턴을 적용하는 것은 패턴 오용의 좋은 예가 된다. 패턴을 적용하는 데 드는 비용이 결과적으로 생기는 이익보다 더 적기 때문이다.

버블 정렬

class BubbleSorter {
static func sort(array: inout [Int]) {
// 버블 정렬 알고리즘 구현
}
}

BubbleSorter 클래스는 버블 정렬 알고리즘을 사용하여 Int 타입 배열을 정렬하는 방법을 알고 있다. 템플릿 메소드 패턴을 사용하여 버블 정렬 알고리즘을 따로 떼낼 수 있다.

class BubbleSorter {
func sort() {
// 버블 정렬 알고리즘 구현
// swap(at:) / isOutOfOrder(at:) 메소드를 호출
}

// 해당 인덱스의 요소와 다음 인덱스의 요소를 서로 바꿈.
func swap(at index: Int) {}
// 해당 인덱스의 요소와 다음 인덱스의 요소가 오름차순 정렬되어 있는지 확인함.
func isOutOfOrder(at index: Int) -> Bool
}

위와 같은 구조를 가진다면, 어떤 종류의 객체든 정렬할 수 있는 파생 클래스를 만들 수 있을 것이다.

class IntBubbleSorter: BubbleSorter {
private var array = [Int]()

override func swap(at index: Int) { ... }
override func isOutOfOrder(at index: Int) { ... }
}

class DoubleBubbleSorter: BubbleSorter {
private var array = [Double]()

override func swap(at: index: Int) { ... }
override func isOutOfOrder(at index: Int) { ... }
}

일반적인 알고리즘은 기반 클래스에 있고, 다른 구체적인 내용은 하위 클래스에서 구현된다.

하지만 이 기법은 비용을 수반한다. 상속은 아주 강한 관계를 맺게 하여, 파생 클래스가 필연적으로 기반 클래스에 묶이게 되기 때문이다.

예를 들어 구체 클래스의 isOutOfOrder(at:)swap(at:) 메소드는 다른 정렬 알고리즘에도 사용될 수 있겠으나, 이들을 재사용할 방법이 없다. IntBubbleSorterDoubleBubbleSorterBubbleSorter를 상속받아 서로 단단하게 묶이게 되었다.

스트래티지 패턴은 다른 선택지를 제공한다.

스트래티지 패턴

스트래티지 패턴은 일반적인 알고리즘과 구체적인 구현 사이의 의존성 반전 문제를 완전히 다른 방식으로 풀어낸다.

  • A 클래스
    • 일반적인 알고리즘을 포함한다.
    • B 인터페이스를 구현한 C 클래스의 인스턴스를 넘겨받는다.
  • B 인터페이스
    • 일반적인 알고리즘이 호출해야 하는 메소드를 정의한다.
  • C 클래스
    • B 인터페이스를 구현한다.

위와 같은 구조로, A 클래스는 B 인터페이스에 위임한다.

class ApplicationRunner {
private var application: Application!

init(application: Application) {
self.application = application
}

func run() {
application.setup()
while !application.isDone {
application.doSomething()
}
application.cleanup()
}
}

protocol Application {
var isDone { get set }
func setup()
func doSomething()
func cleanup()
}

class FToCStrategy: Application {
var isDone = false

func setup() { ... }
func doSomething() { ... }
func cleanup() { ... }
}

위의 구조는 템플릿 메소드 패턴과 비교하여 이익과 비용 면에서 더 낫다.

ApplicationRunner 내부의 위임 포인터(application)은 상속과 비교하여 실행 시간과 데이터 공간 측면에서 좀 더 많은 비용을 초래한다. 하지만 일반적인 알고리즘과 그것이 제어하는 구체적인 부분 사이의 결합도를 감소시켜준다.

다시 정렬하기

스트래티지 패턴을 사용하여 위의 버블 정렬 구현을 다시 작성할 수 있다.

class BubbleSorter {
private var sortHandle: SortHandle!

init(handle: SortHandle) {
sortHandle = handle
}

func sort(array: inout [Any]) {
// 버블 정렬 알고리즘 구현
}
}

protocol SortHandle {
func swap(at: Int)
func isOutOfOrder(at: Int) -> Bool
func setArray(_: [Any])
var count: Int { get }
}

class IntSortHandle: SortHandle {
private var array = [Int]()

func swap(at index: Int) { ... }
func isOutOfOrder(at index: Int) -> Bool { ... }
func setArray(_ array: [Any]) { ... }
var count: Int { ... }
}

BubbleSorter는 일반적인 알고리즘을 구현하고, 이것이 호출해야 하는 메소드를 SortHandle에 위임한다.

IntSortHandle 클래스는 BubbleSorter에 대해 알지 못한다. 버블 정렬 구현에 의존하지 않는다. 그러므로 IntSortHandleBubbleSorter가 아닌 다른 정렬 방법 구현에서도 사용될 수 있다. 하지만 템플릿 메소드 패턴에서, 이 둘은 직접적인 연관 관계를 가지고 있었다.

  • 템플릿 메소드 패턴은, 특정한 일반적인 알고리즘이 많은 구체적인 구현을 조작할 수 있게 해준다.
  • 스트래티지 패턴은, 각각의 구체적인 구현이 다른 많은 일반적인 알고리즘에 의해 조작될 수 있게 해준다.
    • DIP를 준수하기 때문이다.

결론

템플릿 메소드 패턴과 스트래티지 패턴은 상위 단계의 알고리즘을 하위 단계의 구체적인 부분으로부터 분리해주는 역할을 하여, 상위 단계의 알고리즘이 구체적인 부분과 독립적으로 재사용될 수 있게 해준다.

스트래티지 패턴은 DIP를 준수하여, 구체적인 부분이 상위 단계의 알고리즘으로부터 독립적으로 재사용될 수 있게 해준다.


추가

템플릿 메소드 패턴은 동작 상의 알고리즘의 프로그램 뼈대를 정의한다. 알고리즘의 구조를 변경하지 않고 알고리즘의 특정 단계를 다시 정의할 수 있게 한다.

일반적인 알고리즘을 정의한 상위 클래스와, 알고리즘의 각 단계를 정의한 하위 클래스가 상속 관계로 단단하게 묶여 있어, 알고리즘의 각 단계에 대한 구현을 재사용할 수 없다.

class AbstractClass {
func templateMethod() {
...
primitive1()
...
primitive2()
...
}

func primitive1() {}
func primitive2() {}
}

class Subclass1: AbstractClass {
override func primitive1() { ... }
override func primitive2() { ... }
}

스트래티지 패턴은 런타임에서 알고리즘을 선택할 수 있게 한다. 특정 계열의 알고리즘을 정의하고, 각 알고리즘을 캡슐화하며, 해당 계열 안에서 상호 교체가 가능하게 한다.

스트래티지는 알고리즘을 사용하는 클라이언트와는 독립적으로 다양하게 만들어질 수 있다.

protocol Strategy {
func execute()
}

class Context {
weak var strategy: Strategy!

init(strategy: Strategy) {
self.strategy = strategy
}

func run() {
strategy.execute()
}
}

class Strategy1: Strategy {
func execute() { ... }
}

class Strategy2: Strategy {
func execute() { ... }
}