지난 포스팅에선 서브시스템의 일련의 인터페이스에 대한 통합된 인터페이스를 제공하는 퍼사드 패턴에 대해 알아보았다. 이번엔 알고리즘의 골격을 정의하여 일부 단계를 서브클래스에서 구현하게 하는 템플릿 메소드 패턴에 대해 복습한다.

정의

메소드에서 알고리즘의 골격을 정의한다. 알고리즘의 여러 단계 중 일부는 서브클래스에서 구현한다. 알고리즘의 구조는 그대로 유지하면서 서브클래스에서 특정 단계를 재정의할 수 있다. 템플릿 메소드 패턴은 알고리즘의 틀을 만들기 위해서 사용한다. 여기서 틀이란 그저 메소드에 불과하다.

여러 단계 가운데 하나 이상이 추상 메소드로 정의되며, 그 추상 메소드는 서브클래스에서 구현된다. 서브클래스에서 일부분을 구현할 수 있도록 하면서 알고리즘의 구조는 바꾸지 않게 할 수 있다.

다이어그램

AbstractClass는 전체적인 알고리즘의 툴을 제공한다. templateMethod() 메소드에서 기본 단계를 위해 primitiveOperation1(), primitiveOperation2() 메소드와 같은 추상 메소드를 사용하며 AbstractClass를 상속받은 서브클래스에서 구현한다. AbstractClass에서는 알고리즘의 전체적인 단계와 틀을 제공하고 일부 단계를 서브클래스가 구현하도록 하여 알고리즘의 구조를 일관되게 유지할 수 있다.

abstract class AbstractClass {
    final void templateMethod() {
        primitiveOperation1();
        primitiveOperation2();
        concreateOperation();
    }
    abstract void primitiveOperation1();
    abstract void primitiveOperation2();
    void concreateOperation() { };
}

위의 코드처럼 템플릿 메소드인 templateMethod() 메소드에서 추상메소드, 자기자신의 구상메소드와 같은 기본 단계를 사용한다. 서브클래스에서 알고리즘의 각 단계를 마음대로 건드릴 수 없게 하려면 final로 선언해야 한다.

각 알고리즘의 단계는 반드시 추상메소드일 필요는 없다. 기본적으로 아무 것도 하지 않는 구상 메소드를 정의할 수도 있다. 이러한 메소드를 **후크(hook)**라고 부른다. 후크는 서브클래스에서 오버라이드할 수도 있지만 반드시 그럴 필요는 없다.

예시

우리는 템플릿 메소드 패턴을 이용한 기능을 알게 모르게 사용하고 있다. 자바의 Arrays 클래스에서 제공하는 정렬기능이 대표적인 예이다. 우선 세 개의 메소드만 살펴보겠다. 이 세 개의 메소드를 합치면 정렬 기능을 제공할 수 있다.

sort() 메소드는 매개변수로 전달된 배열을 정렬하기 위한 메소드이다. legacyMergeSort() 메소드를 호출하는 부분을 살펴보자.

legacyMergeSort() 메소드는 배열의 복사본을 만들고 mergeSort()를 호출할 때 대상 배열을 전달해주기 위한 보조 메소드이다. mergeSort() 메소드에 복사본, 원본, 시작인덱스, 종료인덱스 등을 알려줘야 한다.

mergeSort() 메소드는 편의상 첫번째 반복 부분만 살펴보자. Comparable의 compareTo() 메소드를 이용해서 대소비교를 한 뒤 정렬한다. mergeSort가 템플릿 메소드, Comparable이 정렬 알고리즘의 기본 단계(PrimitiveOperation = compareTo)를 구현하는 클래스인 것이다.

우리가 만든 클래스를 배열에서 정렬이 가능한 클래스로 만들어 템플릿 메소드를 완성하기 위해서는 이 Comparable의 compareTo() 메소드를 구현해야만 한다. Comparable의 compareTo() 메소드를 이용해서 우리가 만든 클래스를 비교하는 방법을 알려줘야 하는 것이다.

의문점

Arrays가 템플릿 메소드를 제공하는 클래스라면 Arrays의 서브클래스를 만드는 식으로 할 수 없는데 어떻게 템플릿 메소드인 sort() 를 사용할 수 있는 것인가?

sort() 메소드를 디자인한 사람들은 모든 배열에서 사용할 수 있도록 하려고 고민했다. 이를 위해 sort()를 정적 메소드로 만들었으며 이는 수퍼클래스에 들어있는 것과 같이 생각하면 되므로 별 문제가 되지 않는다. 하지만, sort() 메소드 자체가 특정 수퍼클래스에 정의되어 있는 것이 아니기 때문에 sort() 메소드에서 해당 클래스가 compareTo() 메소드를 구현했는지를 알아낼 수 있는 방법이 필요하다. 이를 해결하기 위해 Comparable이라는 인터페이스의 구현을 도입했다.

Arrays.sort()가 정말 템플릿 메소드 패턴인가?

템플릿 메소드 패턴은 알고리즘을 구현하고, 일부 단계는 서브클래스에서 구현한 것을 사용하여 처리한다. Arrays의 sort()는 분명 그런 방법을 사용하지 않지만 실전에서 패턴을 적용하는 방법이 교재의 정의와 완전히 똑같을 수는 없다. 주어진 상황과 구현상의 제약조건에 맞게 고쳐서 적용해야 할 수도 있다.

Arrays.sort() 의 제약사항을 살펴보자면, 일반적으로 자바 배열의 서브클래스를 만들 수 없다는 문제가 있다. 하지만 어떤 배열에서도 정렬 기능을 사용할 수 있도록 만들어야 했기 때문에 정적 메소드를 정의한 다음 알고리즘에서 대소비교를 하는 부분은 정렬될 객체에서(Comparable을 구현한 객체)에서 구현하도록 만든다.

교과서적인 템플릿 메소드라고 할 수는 없지만 sort() 메소드의 구현 자체는 템플릿 메소드 패턴의 기본 정신을 충실하게 따르고 있다. 반대로 생각해보면 이 알고리즘을 쓰기 위해서 Arrays의 서브클래스를 만들어야 한다는 제약사항을 없앰으로써 오히려 더 유연하면서 유용한 정렬 메소드가 만들어졌다고 볼 수 있다.

그렇다면 알고리즘(행동)을 캡슐화시킨(Comparable를 구성요소로) 스트래티지 패턴에 가깝다고 볼 수 있지 않은가?

객체 구성을 사용하는 스트래티지 패턴과 충분히 헷갈릴 수 있다. 하지만, 스트래티지 패턴은 구성을 위해 사용하는 클래스에서 알고리즘을 기본 단계 구현이 아닌 완전한 구현을 한다. Arrays 클래스에서 사용하는 알고리즘은 compareTo()를 다른 클래스에서 제공해줘야 하므로 불완전하다.

자바 API에서 템플릿 메소드를 사용한 다른 예시들은 무엇이 있는가?

여러가지가 있지만 가장 쉽게 접하는 예시는 java.io의 InputStream클래스이다. 해당 클래스에 있는 read() 메소드는 서브클래스에서 구현해야하며 read(byte b[], int off, int len) 템플릿 메소드에서 쓰인다.

헐리우드 원칙(Hollywood Principle)

헐리우드 원칙

의존성 부패를 방지하기 위한 헐리우드 원칙은 템플릿 메소드 패턴에서 등장했다. 어떤 고수준 구성요소가 저수준 구성요소에 의존하고 그 저수준 구성요소는 다시 고수준 구성요소에 의존하고 그 고수준 구성요소는 다시 또 다른 저수준 구성요소에 의존하는 것과 같이 의존성이 복잡하게 꼬여있는 것을 의존성 부패(dependency rot)라고 한다. 헐리우드 원칙을 사용하면 저수준 구성요소에서 시스템에 접속할 수는 있다. 하지만, 언제 어떤 식으로 그 구성요소들을 사용할지는 고수준 구성요소에서 결정한다.

할리우드 원칙과 템플릿 메소드 패턴의 관계는 무엇일까? 커피와 차를 파는 카페 예시를 살펴보자. 카페에서는 음료를 만들기 위해 prepareRecipe() 라는 템플릿 메소드를 제공한다. 음료를 만드는 알고리즘의 골격은 다음과 같다.

  1. 물을 끓인다: boilWater()
  2. 우려낸다: abstract brew()
  3. 음료를 컵에 따른다: pourInCup()
  4. 첨가물을 추가한다: abstract addCondiments()

4단계 중 우려내기와 첨가물 추가는 PrimitiveOperation 으로 서브클래스에서 구현해야하는 알고리즘의 기본 단계이다. 커피와 차를 우려내는 방법과 첨가물을 추가하는 방법이 각각 다르기 때문이다. 커피와 차는 Caffe를 상속받아 각각의 알고리즘을 구현하면 된다.

다시 본론으로 돌아가 헐리우드 원칙과의 관계를 살펴보자. Caffe는 알고리즘을 장악하는 고수준 구성요소이며 각각 서브클래스는 시스템에 참여하는 저수준 구성요소이다. 고수준 구성요소인 Caffe는 메소드 구현이 필요한 상황에서만 메소드를 호출하여 서브클래스를 불러낸다. 저수준 구성요소인 Coffe와 Tea는 호출 당하기 전까지는 절대로 추상 클래스를 직접 호출하지 않는다. 고수준 구성요소에서 저수준 구성요소에게 일방적으로 먼저 연락하는 셈이된다.

카페 시스템을 이용하는 클라이언트에서는 Tea나 Coffe와 같은 구상 클래스가 아닌, Caffe라는 고수준 구성요소에 추상화 되어있는 부분에 의존하게 된다. 이렇게 함으로써 전체 시스템의 의존성을 줄여 의존성 부패를 방지할 수 있게 된다.

후크(hook)

후크는 추상 클래스에서 선언되는 메소드이긴 하지만 기본적인 내용만 구현되어 있거나 아무 코드도 들어있지 않은 메소드이다. 후크는 다양한 용도로 쓰일 수 있다. 카페 시스템을 다시 살펴보자.

첨가물을 추가하는 부분에서 customerWantsCondiments() 라는 후크 메소드를 이용한다. 손님이 첨가물을 넣어달라고 했을 때만 addCondiments() 메소드를 호출하기 위한 후크이다.

public abstract class Caffe {
    void prepareRecipe() {
        boilwater();
        brew();
        pourInCup();
        if(customerWantsCondiments()) {
        	addCondiments();
        }
    }

    abstract void brew();
    abstract void addCondiments();
    
    void boilWater() { System.out.println("물 끓이는 중"); };
    void pourInCup() { System.out.println("컵에 따르는 중"); };
    boolean customerWantsCondiments() { return true; };
}

여기서 customerWantsCondiments() 후크 메소드는 별 내용이 없는 기본 메소드를 구현해 놓았다 그냥 true를 반환할 뿐 다른 로직은 추가하지 않았으며 서브클래스에서 필요에 따라 오버라이드할 수 있는 후크를 제공한다. 후크를 활용한 커피클래스를 살펴보자.

public class Coffe extends Caffe {
    public void brew() { System.out.println("필터로 커피를 우려내는 중"); };
    public void addCondiments() { System.out.println("우유와 설탕을 추가하는 중"); };

    public boolean customerWantsCondiments() {
        String answer = getUserInput();
        if(answer.toLowerCase().startsWith("y")) {
        	return true;
        } else {
        	return false;
        }
    }
    
    public String getUserInput() {
        Scanner sc = new Scanner(System.in);
        String answer = null;
        System.out.println("커피에 우유와 설탕을 넣어 드릴까요? (y/n) ");
        answer = sc.nextLine();
        return answer == null ? "no" : answer;
	}
}

손님에게 직접 첨가물을 넣을 지 여부를 처리할 때 손님에게 물어보는 getUserInput() 메소드를 호출하고 대답에 따라 boolean을 반환하는 후크를 사용해서 처리하게 했다.

언제 후크를 써야 하는가?

서브클래스에서 알고리즘의 특정 단계를 제공해야만 하는 경우에는 추상 메소드를 써야 하지만 알고리즘의 특정 부분이 선택적으로 적용된다든가 하는 경우에는 후크를 쓰면 된다.

후크의 정확한 용도는?

앞서 얘기한 선택적인 부분에 사용할 수 있겠으며, 템플릿 메소드에서 앞으로 일어날 일이나 막 일어난 일에 대해 서브클래스에서 반응할 기회를 제공하기 위한 용도로 쓰일 수 있다. 예를 들어, 내부적으로 어떤 목록을 재정렬한 후에 서브클래스에서 화면상에 표시되는 내용을 다시 보여줄 수 있다. 어떤 작업을 수행하도록 하고 싶은 경우에 justReOrderedList() 메소드와 같은 이름을 가진 후크 메소드를 사용할 수도 있다.

템플릿 메소드 패턴 VS 스트래지티 패턴

스트래티지 패턴은 일련의 알고리즘군을 정의하고 그 알고리즘들을 서로 바꿔가면서 쓸 수 있게 해준다.

각 알고리즘이 캡슐화되어 있기 때문에 클라이언트에서 손쉽게 서로 다른 알고리즘을 사용할 수 있다.

템플릿 메소드 패턴은 알고리즘의 개요를 정의하며 실제 작업 중 일부는 서브클래스에서 처리한다.

각 단계마다 다른 구현을 사용하면서도 알고리즘 구조 자체는 그대로 유지할 수 있다.

템플릿 메소드 패턴은 알고리즘에 서브클래스가 개입하지만 스트래티지 패턴은 알고리즘을 구현할때 객체 구성을 통해서 알고리즘을 선택할 수 있게 해준다.

템플릿 메소드 패턴은 알고리즘을 더 꽉 잡고 있고 코드 중복이 거의 없다.

템플릿 메소드 패턴은 알고리즘이 전부 똑같고 한 줄만 다르다면 스트래티지 패턴보다 효율적이다. 중복되는 코드는 전부 수퍼클래스에 들어있으므로 서브클래스에서 공유가 가능하기 때문이다.

스트래티지 패턴은 객체 구성을 사용하기 때문에 더 유연하다.

스트래티지를 사용하는 클라이언트에서 다른 스트래티지 객체를 사용하기만 하면 알고리즘을 변경할 수 있다.

템플릿 메소드 패턴은 서브클래스에서 일부 행동을 지정할 수 있게 해주면서도 코드를 재사용할 수 있게 해주는 기본 메소드를 제공한다. 이는 프레임워크를 만드는데 있어 매우 효율적이지만 보다 의존성이 크다.

스트래티지 패턴은 어떤 것에도 의존하지 않고 알고리즘을 전부 알아서 구현할 수 있다.

템플릿 메소드 패턴 예제