헤드 퍼스트 디자인 패턴을 읽고 정리한 글입니다.
템플릿 메소드 패턴은 알고리즘의 골격을 정의한다. 템플릿 메소드를 사용하면 알고리즘의 일부 단계를 서브클래스에서 구현할 수 있으며, 알고리즘의 구조는 그대로 유지하면서 알고리즘의 특정 단계를 서브클래스에서 재정의할 수도 있다.
- 서브클래스에서 언제든 필요할 때마다 알고리즘을 가져다가 쓸 수 있도록 캡슐화 해보자
- 할리우드 디자인 원칙을 알아보자
커피와 홍차는 둘 다 카페인을 가지고 있고, 가장 중요한 점은 매우 비슷한 방법으로 만들어진다는 것이다.
커피 만드는 법
홍차 만드는 법
이제 커피와 홍차를 만드는 클래스를 준비해 보자.
public class Coffee {
public void prepareRecipe() {
boilWater();
brewCoffeeGrinds();
pourInCup();
addSugarAndMilk();
}
private void boilWater() {
System.out.println("물 끓이는 중");
}
private void brewCoffeeGrinds() {
System.out.println("필터로 커피를 우려내는 중");
}
private void pourInCup() {
System.out.println("컵에 따르는 중");
}
private void addSugarAndMilk() {
System.out.println("설탕과 우류를 추가하는 중");
}
}
public class Tea {
public void prepareRecipe() {
boilWater();
steepTeaBag();
pourInCup();
addLemon();
}
private void boilWater() {
System.out.println("물 끓이는 중");
}
private void steepTeaBag() {
System.out.println("필터로 커피를 우려내는 중");
}
private void pourInCup() {
System.out.println("컵에 따르는 중");
}
private void addLemon() {
System.out.println("설탕과 우류를 추가하는 중");
}
}
조금 전에 Coffee 클래스에서 구현했던 방법과 비슷한 것을 느낄 수 있다.
두 번째와 네 번째 단계가 조금 다르지만 기본적으로 같다고 할 수 있다. 이렇게 공통적으로 코드가 중복된다면 디자인 수정을 고려해보자.
혹시 Coffee와 Tea 클래스는 거의 같으니까 두 클래스의 공통된 부분을 추상화해서 베이스 클래스로 만드는 것을 어떨까??
CaffeineBeverage
서브 클래스(Coffee, Tea)
제조법을 다시 살펴보면 커피와 홍차 제조법의 알고리즘이 똑같다는 사실을 알 수 있다.
뜨거운 물을 사용해서 커피 또는 찻잎을 우려낸다.
각 음료에 맞는 첨가물을 추가한다.
public abstract class CaffeineBeverage {
public void prepareRecipe() {
boilWater();
brew();
pourInCup();
addCondiments();
}
protected abstract void addCondiments();
protected abstract void brew();
private void boilWater() {
System.out.println("물 끓이는 중");
}
private void pourInCup() {
System.out.println("컵에 따르는 중");
}
}
public class Coffee extends CaffeineBeverage {
@Override
protected void brew() {
System.out.println("필터로 커피를 우려내는 중");
}
@Override
protected void addCondiments() {
System.out.println("설탕과 우류를 추가하는 중");
}
}
------------------------------------------------------------------
public class Tea extends CaffeineBeverage{
@Override
protected void brew() {
System.out.println("필터로 커피를 우려내는 중");
}
@Override
protected void addCondiments() {
System.out.println("설탕과 우류를 추가하는 중");
}
}
조금 다른 방식으로 구현해야 하는 부분이 있긴 하지만, 만드는 방법이 사실상 똑같으므로 제조법을 일반화해서 베이스 클래스에 넣었다.
그 후 베이스 클래스에서 전체 처리 과정을 관리하며, 첫 번째와 세 번째 단계는 직접 처리하고 두 번째와 네 번째 단계는 Tea와 coffee 서브클래스에 의존한다.
지금까지 Coffee와 Tea 클래스에 템플릿 메소드
패턴을 적용했다고 할 수 있다. 템플릿 메소드는 CaffeinBeverage 클래스에 들어있다.
즉 템플릿 메소드는 알고리즘의 각 단계를 정의하며, 서브 클래스에서 일부 단계를 구현할 수 있도록 유도한다.
이제 패턴의 정의와 특징을 자세히 알아보자.
💡 템플릿 메소드 패턴은 알고리즘의 골격을 정의한다. 템플릿 메소드를 사용하면 알고리즘의 일부 단계를 서브클래스에서 구현할 수 있으며, 알고리즘의 구조는 그대로 유지하면서 알고리즘의 특정 단계를 서브클래스에서 재정의할 수도 있다.간단하게 말하면 템플릿 메소드 패턴은 알고리즘의 템플릿(틀)을 만든다. 템플릿이란 일련의 단계로 알고리즘을 정의한 메소드이다. 여러 단계 가운데 하나 이상의 단계가 추상 메소드로 정의되며, 그 추상 메소드는 서브 클래스에서 구현된다. 이러면 서브 클래스가 일부분의 구현을 처리하게 하면서도 알고리즘의 구조는 바꾸지 않아도 된다.
후크(hook)
라고 부른다.후크는 추상 클래스에서 선언되지만 기본적인 내용만 구현되어 있거나 아무 코드도 들어있지 않은 메소드이다.
이러면 서브 클래스는 다양한 위치에서 알고리즘에 끼어들 수 있다. 물론 그냥무시하고 넘어갈 수도 있다. 후크는 다양한 용도로 사용된다. 한 가지 사용법의 예시를 알아보자.
public abstract class CaffeineBeverage {
public void prepareRecipe() {
boilWater();
brew();
pourInCup();
if (customerWantsCondiments()) {
addCondiments();
}
}
protected abstract void addCondiments();
protected abstract void brew();
private void boilWater() {
System.out.println("물 끓이는 중");
}
private void pourInCup() {
System.out.println("컵에 따르는 중");
}
private boolean customerWantsCondiments() { // hook
return true;
}
}
후크를 사용하려면 서브 클래스에서 후크를 오버라이드해야 한다.
위의 예제에서는 알고리즘의 특정 부분을 처리할지 말지를 결정하는 용도로 후크를 사용했다.
즉, 음료에 첨가물을 추가할지 말지를 결정하는 메소드다.
public class CoffeeWithHook extends CaffeineBeverage { // 후크를 오버라이드해서 원하는 기능을 추가
@Override
protected void brew() {
System.out.println("필터로 커피를 우려내는 중");
}
@Override
protected void addCondiments() {
System.out.println("설탕과 우류를 추가하는 중");
}
@Override
protected boolean customerWantsCondiments() {
String answer = getUserInput();
if (answer.toLowerCase().startsWith("y")) {
return true;
}
return false;
}
private String getUserInput() {
String answer = null;
System.out.println("커피에 우유와 설탕을 넣을까요? (y/n)? ");
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
try {
answer = br.readLine();
} catch (IOException exception) {
System.out.println("io exception");
}
if (answer == null) {
return "no";
}
return answer;
}
}
public class BeverageTestDrive {
public static void main(String[] args) {
CoffeeWithHook coffeeWithHook = new CoffeeWithHook();
System.out.println("커피 준비 중");
coffeeWithHook.prepareRecipe();
}
}
===================================================================
커피 준비 중
물 끓이는 중
필터로 커피를 우려내는 중
컵에 따르는 중
커피에 우유와 설탕을 넣을까요? (y/n)?
y
설탕과 우류를 추가하는 중
서브 클래스가 알고리즘의 특정 단계를 제공해야만 한다면 추상 메소드를 써야 한다. 알고리즘의 특정 단계가 선택적으로 적용된다면 후크를 쓰면 된다. 후크를 쓰면 서브클래스에서 필요할 때 후크를 구현할 수도 있지만, 꼭 구현해야 하는건 아니기 때문이다.
여러 가지 용도로 쓰인다. 알고리즘에서 필수적이지 않은 부분을 서브클래스에서 구현하도록 만들고 싶을 때 후크를 쓸 수 있다. 또한 템플릿 메소드에서 앞으로 일어날 일이나 막 일어난 일에 서브클래스가 반응할 수 있도록 기회를 제공하는 용도로도 쓰일 수 있다.
예를 들면, 내부적으로 특정 목록을 재정렬한 후에 서브 클래스에서 특정 작업을 수행하도록 싶을 때, justReOrderedList() 같은 이름을 가진 후크 메소드를 쓸 수도 있다. 또한 앞에 예제에서 봤듯이 서브클래스가 추상 클래스에서 진행되는 작업을 처리할지 말지 결정하게 하는 기능을 부여하는 용도로 후크를 쓸 수도 있다.
그렇다. 모든 서브클래스에서 모든 추상 메소드를 정의해야 한다.
즉, 템플릿 메소드에 있는알고리즘의 단계 중에서 정의되지 않은 부분을 모두 채워 줘야 한다.
맞다. 템플릿 메소드를 만들 때는 그 점을 꼭 생각해 봐야 한다.
알고리즘의 단계를 너무 잘게 쪼개지 않는 것도 한 가지 방법이 될 수 있다. 하지만 알고리즘을 큼직한 몇 가지 단계로만 나누어 놓으면 유연성이 떨어진다는 단점도 있으니 잘 생각해서 결정해야 한다.
그리고 모든 단계가 필수는 아니라는 점도 기억하자. 필수가 아닌 부분을 후크로 구현하면 그 추상 클래스의 서브 클래스를 만들 때 부담이 조금 줄어들 것이다.
디자인 원칙 중 할리우드 원칙이 있다. 이 원칙은 보통 다음과 같이 정의될 수 있다.
💡 먼저 연락하지 마세요. 저희가 연락드리겠습니다.할리우드에서 배우들과 연락하는 것과 비슷하게, 슈퍼 클래스에서 모든 것을 관리하고 필요한 서브클래스를 불러서 써야 한다는 원칙이다. 이런 할리우드 원칙을 활용하면 의존성 부패(dependency rot)
를 방지할 수 있다.
어떤 고수준 구성 요소가 저수준 구성 요소에 의존하고, 그 저수준 구성 요소는 다시 고수준 구성 요소에 의존하고, 그 고수준 구성 요소는 다시 또 다른 구성 요소에, 그 다른 구성 요소는 또 저수준 구성 요소에 의존하는 것과 같은 식으로 의존성이 복잡하게 꼬여있는 상황을 의존성이 부패
했다고 부른다. 이렇게 의존성이 부패하면 시스템 디자인이 어떤 식으로 되어 있는지 아무도 알아볼 수 없다.
할리우드 원칙을사용하면, 저수준 구성 요소가 시스템에 접속할 수는 있지만 언제, 어떻게 그 구성 요소를 사용할지는 고수준 구성 요소가 결정한다.
즉 고수준 구성 요소가 저수준 구성 요소에게 “먼저 연락하지 마세요. 제가 먼저 연락 드리겠습니다.” 라고 이야기 하는 것과 같다.
할리우드 원칙과 템플릿 메소드 패턴의 관계는 쉽게 알 수 있다. 템플릿 메소드 패턴을 써서 디자인 하면 서브클래스에게 “우리가 연락할 테니까 먼저 연락하지마”라고 얘기하는 구조이기 대문이다.
디자인을 다시 한번 살펴보자
CaffeineBeverage
는 고수준 구성 요소이다.CaffeineBeverage
클래스의 클라이언트는 Tea, Coffee 같은 구상 클래스가 아닌 CaffeineBeverage
에 추상화되어 있는 부분에 의존한다. 이러면 전체 시스템의 의존성을 줄일 수 있다.당하기
전까지는 추상 클래스를 직접 호출하지 않는다.의존성 뒤집기 원칙은 될 수 있으면 구상 클래스 사용을 줄이고 추상화된 것을 사용해야 한다는 원칙이다. 할리우드 원칙은 저수준 구성 요소가 컴퓨테이션에 참여하면서도 저수준 구성 요소와 고수준 계층 간 의존을 없애도록 프레임워크는 구성 요소를 구축하는 기법이다.
따라서 두 원칙은 객체를 분리한다는 하나의 목표를 공유하지만, 의존성을 피하는 방법에 있어서 의존성 뒤집기 원칙이 훨씬더 강하고 일반적인 내용을 담고 있다.
할리우드 원칙은 저수준 구성요소를 다양하게 사용할수 있으면서도 다른 클래스가 구성 요소에 너무 의존하지 않게 만들어주는 디자인 구현 기법을 제공한다.
그렇지 않다. 사실 저수준 구성 요소에서도 상속 계층구조 위에 있는 클래스가 정의한 메소드를, 상속으로 호출하는 경우도 빈번하게 있다. 하지만 저수준 구성 요소와 고수준 구성 요소 사이에 순환 의존성이 생기지 않도록 해야한다.
바로 Arrays.sort 메소드이다.
private static void legacyMergeSort(Object[] a) {
Object[] aux = a.clone();
mergeSort(aux, a, 0, a.length, 0);
}
private static void mergeSort( // 템플릿 메소드
Object[] src,
Object[] dest,
int low,
int high,
int off
) {
// Insertion sort on smallest arrays
if (length < INSERTIONSORT_THRESHOLD) {
for (int i=low; i<high; i++)
for (int j=i; j>low &&
((Comparable) dest[j-1]).compareTo(dest[j])>0; j--) // 템플릿 메소드를 완성하려면 comapreTo() 메소드를 구현해야 한다.
swap(dest, j, j-1); // Arrays 클래스에 이미 정의되어 있는 구상 ㅇ메솓,
return;
}
// ...
}
만약 배열 속 오리 클래스들을 정렬해야 한다면 Arrays에 있는 정렬용 템플릿 메소드에서 알고리즘을 제공하지만, 오리 비교 방법은 comapreTo() 메소드로 구현해야 한다.
하지만 템플릿 메소드 패턴을 배울 때 서브 클래스에서 일부 단계를 구현한다고 배웠는데, 해당 예제에서는 서브 클래스를 만들지 않고 있다.
sort() 메소드는 정적 메소드이고 정적 메소드 자체는 크게 문제가 되지 않는다. 슈퍼 클래스에 들어있다고 생각하면 되기 때문이다. 하지만 sort() 자체가 특정 슈퍼클래스에 정의되어 있는게 아니므로 sort() 메소드가 우리가 comapreTo() 메소드를 구현했는지 알아낼 수 있는 방법이 필요하다는 점이다.
이러한 문제를 해결하기 위해 Comaprable 인터페이스가 도입되었다. 이제 해당 인터페이스를 구현하기만 하면 문제가 해결 된다.
public class Duck implements Comparable<Duck> {
private final String name;
private final int weight;
public Duck(String name, int weight) {
this.name = name;
this.weight = weight;
}
public String getName() {
return name;
}
public int getWeight() {
return weight;
}
@Override
public int compareTo(Duck otherDuck) {
return Integer.compare(this.weight, otherDuck.getWeight());
}
}
Arrays.sort() 메소드는 분명 템플릿 메소드 패턴의 정의의 방법을 사용하지 않고 있지만 실전에서 패턴을 적용하는 방법이 책에 나와 있는 방법과 완전히 같을 수는 없다. 주어진 상황과 구현ㄴ상 제약조건에 맞게 고쳐서 적용해야 한다.
일반적으로 자바에서는 배열의 서브클래스를 만들 수 없지만, 어떤 배열에서도 정렬 기능을 사용할 수 있도록 만들어야 했다. 그래서 sort() 메소드를 정적 메소드로 정의한 다음, 대소를 비교하는 부분은 정렬될 객체에서 구현되도록 만든 것이다.
온전한 템플릿 메소드라고 할 순 없겠지만 템플릿 메소드 패턴의 기본 정신을 충실히 따르고 있다. 또한 서브클래스를 만들어야 한다는 제약 조건을 없앰으로써 오히려 더 유연하면서 유용한 정렬 메소드를 만들었다.
전략 패턴에서 객체 구성을 사용하니까 어떻게 보면 일리가 있지만 전략 패턴에서는 구성할 때 사용하는 클래스에서 알고리즘을 완전히 구현한다.
Arrays 클래스에서 사용하는 알고리즘은 불완전한다. comapreTo() 를 다른 클래스에서 제공해 줘야 하기 때문이다. 따라서 템플릿 메소드 패턴이 적용되었다고 볼 수 있다.
두 가지 모두 같은 요구사항을 구현할 수 있지만 템플릿 메소드 패턴은 알고리즘의 개요를 정의하는 역할을 한다. 진짜 작업 중 일부는 서브클래스에서 처리하며 각 단계마다 다른 구현을 사용하면서도 알고리즘 구조 자체는 그대로 유지할 수 있다. 따라서 알고리즘을 더 강하게 제어할 수 있고, 코드 중복도 거의 없다. 만약 알고리즘이 전부 똑같고 코드 한 줄씩만 다르다면 템플릿 메서드 패턴을 사용한 클래스가 전략 패턴을 사용한 클래스보다 효율적일 수 있다.
하지만 전략 패턴은 상속이 아닌 객체 구성을 사용하기 때문에 상속에서 오는 단점들이 없고 훨씬 더 유연하다는 장점이 있다. 부모 같이 어떤 것에도 의존하지 않고 알고리즘을 전부 알아서 구현할 수 있기 때문이다.