[디자인 패턴/Java] 03. 템플릿 메소드 패턴(Template Method Pattern)

JJ·2024년 2월 10일

Design Pattern

목록 보기
4/4
post-thumbnail

이 글은 헤드 퍼스트 디자인 패턴(개정판) - 에릭 프릭먼 외 4 | 한빛미디어 를 참고하여 작성되었습니다.



정의

템플릿 메소드 패턴(Template Method Pattern)은 알고리즘의 골격, 즉 알고리즘의 템플릿을 정의하는 패턴이다. 이를 사용하면 알고리즘의 일부 단계를 서브클래스에서 구현할 수 있으며, 알고리즘의 구조는 그대로 유지하면서 알고리즘의 특정 단계를 서브클래스에서 재정의할 수도 있다.

즉, 알고리즘의 일부 단계를 서브 클래스로 캡슐화하여 골격만 정의해두기 때문에 특정 단계에서 수행하는 내용을 바꾸기 용이하다. 공통 기능은 상위 클래스에서 정의하고, 확장/변화가 필요한 일부만 서브 클래스에서 구현하는 식으로 응용이 가능하다.


이는 실전에서도 많이 쓰이는 패턴인데, 단순한 상위/서브 클래스 형태를 그대로 유지하기보단 변형해서 쓰이는 경우가 훨씬 많다. 또한 템플릿 메소드 패턴을 남발하면 추상 메소드가 너무 많아질 수 있기 때문에 주의해야 한다.

🚨 서브클래스에서 추상 메소드를 모두 구현해야 하기 때문에 코드가 복잡해질 수 있기 때문이다. 그렇다고 해서 알고리즘을 너무 큰 단위로만 나눠 놓으면 유연성이 떨어질 수 있기 때문에 이 부분을 잘 고려해야 한다!
물론 이 부분은 훅(hook)을 사용해서 해결할 수도 있다.




예제로 살펴보기

템플릿 메소드를 가장 간단하게 만드는 방법은 상위 추상 클래스를 만들고, 이를 구현하는 서브 클래스를 만들어 사용하는 것이다. 이 방식으로 구현한 예제의 다이어그램을 살펴보자.

  • AbstractClass
    • 템플릿 메소드가 들어있음
    • abstract 메소드로 선언된 메소드들이 템플릿 메소드에서 활용됨
  • ConcreteClass
    • 여러 개가 있을 수 있음
    • 각 클래스는 템플릿 메소드에서 요구하는 모든 단계를 제공해야 함
  • templateMethod()
    • 알고리즘을 구현할 때 primitiveOperation 을 활용
    • 알고리즘은 이 단계들의 구체적인 구현으로부터 분리되어 있다.

위 예제의 상위 클래스인 AbstractClass 를 코드로 구현해보면 다음과 같다.

abstract class AbstractClass {

    final void templateMethod() {
		primitiveOperation1();
		primitiveOperation2();
		concreteOperation();
		hook();
	}

	abstract void primitiveOperation1();

	abstract void primitiveOperation2();

	final void concreteOperation() {
		//concreateOperation() 메소드 코드
	}

	void hook() {}
}

위 클래스의 메소드를 살펴보면 다음과 같다.

  • templateMethod
    • 서브클래스가 알고리즘의 각 단계를 마음대로 건드리지 못하게 final 로 선언
    • 각 단계를 순서대로 정의하고, 메소드로 표현함
  • abstract void primitiveOperation1();
    • 이런식으로 기본 단계 중 필요한 부분을 구상 서브클래스에서 구현하도록 만들 수 있음
  • concreteOperation
    • 추상 클래스 내에 구상 메소드로 정의된 단계도 존재
  • hook
    • 아무것도 하지 않는 구상 메소드를 정의할 수 있는데, 이런 메소드를 hook 이라고 부름
    • 서브클래스에서 오버라이드할 수 있음(필수X)



훅(hook)

앞서 정의를 살펴볼 때도 그렇고, 위 예제에서도 계속해서 등장하는 특이한 메소드가 있다. 바로 훅(hook)인데, 이는 추상 클래스에서 선언되지만 기본적인 내용만 구현되어 있거나 아무 코드도 들어있지 않은 메소드를 말한다.


훅은 서브클래스에서 오버라이드할 수도 있고 그냥 넘어갈 수도 있다. 오버라이드하지 않으면 추상 클래스에서 기본으로 제공한 코드가 실행되고, 이를 이용해 서브클래스는 다양한 위치에서 알고리즘에 끼어들 수 있다.

때문에 알고리즘에서 선택적인 부분만 서브클래스에서 구현하도록 만들고 싶은 경우 훅을 사용하면 편리하게 구현 가능하다. 또는 서브 클래스가 추상 클래스에서 진행되는 작업을 처리할지 말지 결정하게 하는 기능을 부여하고 싶을 때도 훅을 사용할 수 있다.


그렇다면 훅은 정확히 어떻게 사용하는 걸까? 예시를 통해 살펴보자. 다음은 커피를 추출하는 클래스를 구현한 코드이다.

public abstract class CaffeinBeverageWithHook {

		final void prepareRecipe() {
			boilWater();
			brew();
			pourInCup();
			if (customerWantsCondiments()) {
					addCondiments();
			}
		}

		abstract void brew();

		abstract void addCondiments();

		void boilWater() {
			System.out.println("boiling...");
		}

		void pourInCup() {
			System.out.println("pouring...");
		}

		boolean customerWantsCondiments() {
			return true;
		}
}

위 코드에서 customerWantsCondiments() 메소드는 별 내용이 없는 기본 메소드이다. 즉, 서브 클래스에서 필요할 때 오버라이드하여 구현할 수 있기 때문에 훅(hook)이라고 할 수 있다.

만약 서브 클래스에서 훅을 오버라이드하면 다음과 같이 사용할 수 있을 것이다.

public class CoffeeWithHook extends CaffeineBeverageWithHook {

		...

		public boolean customerWantsCondiments() {
				String answer = getUserInput();

				if(answer.toLowerCase().startsWith("y")) 
						return true;
				else
						return false;
		}

		...
}

🤔 추상 메소드랑 별로 다를 것도 없는 것 같은데?

훅의 역할과 형태를 보면 추상 메소드와 동일하다. 다만 훅과 추상 메소드는 강제성에서 차이점을 갖는다.

추상 메소드의 경우, 서브 클래스가 알고리즘의 특정 단계를 제공해야 하는 경우 사용하기 때문에 반드시 서브 클래스에서 구현해야 한다.

이와 달리 훅은 알고리즘의 특정 단계가 선택적으로 적용되는 경우 사용하기 때문에 필수는 아니다. 서브 클래스는 필요한 경우에만 구현하면 된다.




의존성 부패(dependency rot)

그렇다면 템플릿 메소드는 어떤 경우에 활용할 수 있을까? 대표적으로는 의존성 부패를 해결하기 위해 사용하는 방법을 들 수 있다.


먼저 의존성 부패가 무엇인지 정확히 알아보자. 의존성이 부패했다는 것은, 말 그대로 의존성이 복잡하게 꼬여있는 상황을 말한다. 의존성이 부패하게 되면 시스템 디자인이 어떤 식으로 되어 있는지 알아보기 어렵기 때문에, 클린 아키텍처를 위해선 의존성을 잘 관리할 수 있도록 디자인해야 한다.


이러한 의존성 부패를 막기 위해서는 저수준 모듈을 언제/어떻게 호출할지를 고수준 모듈에서 결정하도록 설계해야 한다.

  • 고수준 모듈: 인터페이스와 같은 객체의 형태나 추상적 개념
  • 저수준 모듈: 실제 구현된 객체

즉, 저수준 모듈과 고수준 모듈의 관계를 설계할 때 템플릿 메소드 패턴을 사용할 수 있다.

📌 추가로 이후에 살펴볼 팩토리 메소드 패턴, 옵저버 패턴 등을 이용해서도 해결이 가능하다.


예시를 통해 더 자세히 살펴보자. 아래는 앞서 살펴봤던 커피 클래스와 구상 클래스의 다이어그램이다.

CaffeineBeverage 는 고수준 구성 요소이다. 즉, 전체적인 알고리즘을 장악하고 있고 메소드 구현이 필요한 상황에만 서브클래스를 호출한다. 서브클래스는 구체적인 메소드 구현을 제공하는 용도로만 사용된다.

CaffeineBeverage 클래스의 클라이언트는 TeaCoffee 와 같은 구상 클래스가 아닌 CaffeineBeverage 에 추상화되어 있는 부분에 의존하게 되므로, 결과적으로 전체 시스템의 의존성을 줄일 수 있는 것이다.


사실 저수준 구성 요소에서도 상속 계층구조 위에 있는 클래스가 정의한 메소드를 상속으로 호출하는 경우도 빈번하게 있다! 따라서 저수준 구성 요소에서도 고수준 구성 요소에 있는 메소드를 호출할 수는 있다. 다만 이런 경우 저수준 구성 요소와 고수준 구성 요소 사이에 순환 의존성이 생기지 않도록 주의해야 한다.

📌 의존성 역전 원칙(Dependency Inversion Principle)

위 내용을 공부하다 보면 의존성 역전 원칙이 떠오를 수 있다. 실제로 두 조건 모두 객체를 분리한다는 동일한 목표를 공유하기 때문이다.

다만 의존성을 피하는 방법에 있어서는 의존성 뒤집기 원칙이 훨씬 더 강하고 일반적인 내용을 담고 있다. 위의 방법은 저수준 구성 요소를 다양하게 사용할 수 있으면서도 다른 클래스가 구성 요소에 너무 의존하지 않게 만들어주는 디자인 구현 기법을 제공하기 때문이다.




자바 API 속 템플릿 메소드 패턴

앞서 템플릿 메소드 패턴은 여러 형태로 변형되어 다양하게 사용된다고 했는데, Java의 API 속에도 이를 활용한 인터페이스들이 상당히 많다.


Comparable 인터페이스의 compareTo() 메소드

알고리즘 문제를 풀 때 자주 사용하는 compareTo() 를 떠올리면 자동적으로 함께 떠오르는 메소드가 있는데, 바로 sort() 이다.

Arrays 클래스의 정적 메소드인 sort() 는 그 자체가 특정 슈퍼 클래스에 정의되어 있지 않기 때문에 sort()compareTo() 의 구현 여부를 알아낼 수 있는 방법이 필요하다. compareTo() 를 제대로 구현하지 않는 경우, 객체 비교 시 정렬 알고리즘이 제대로 작동하지 않기 때문이다.

위와 같은 문제를 해결하기 위해 Comparable 인터페이스가 도입되었다. sort() 템플릿 메소드가 동작하는 과정을 더 자세히 알아보자.


  • Arrays의 sort() 템플릿 메소드 동작 과정
    1. 객체 배열 생성
      Coffee[] coffee = {new Coffee("Latte", 2), ...};
    2. Arrays 클래스에 있는 sort() 템플릿 메소드 호출 후 객체 배열을 파라미터로 전달
      Arrays.sort(coffee);
      이후 sort() 메소드와 그 보조 메소드인 mergeSort() 에서 정렬 작업을 처리한다.
    3. sort() 는 두 객체를 비교할 때 compareTo() 메소드에 의존한다. 한 객체의 compareTo() 메소드를 호출할 때 비교해야 할 다른 객체를 매개변수로 전달한다.
      coffee[0].compareTo(coffee[1]);
    4. 비교한 결과 순서가 맞지 않으면 Arrays에 들어있는 swap() 구상 메소드를 이용하여 두 객체를 맞바꾼다. 이후 다시 비교하교 객체 순서를 바꾸는 작업을 반복한다.

다만 위 형태는 템플릿 메소드 패턴의 정석적인 형태는 아닌데, 이런 것도 템플릿 메소드 패턴이라고 할 수 있을까?

우선 일반적으로 자바에서는 배열의 서브클래스를 만들 수 없다. 그러나 객체 배열 등을 정렬할 필요도 있기 때문에 Arrays 클래스는 sort() 를 정적 메소드로 정의하고 세부 비교 조건은 정렬된 객체에서 구현하는 방식을 사용한 것이다.

이러한 방식은 템플릿 메소드의 온전한 구현 방법이라고 할 순 없지만 전반적인 구현 원리가 템플릿 메소드 패턴의 기본을 충실히 따르고 있다. 또한 Arrays의 서브클래스를 만들어야 한다는 제약 조건을 제거함으로써 더욱 유연한 정렬 메소드가 되는 것이다.


🤔 보다보니까 전략 패턴에 더 가까운 것 같은데요?

전략 패턴에서는 구성할 때 사용하는(객체 레퍼런스에 의해 참조되는) 클래스에서 알고리즘을 완전히 구현한다. 반면 Arrays 클래스에서 사용하는 알고리즘은 compareTo() 를 다른 클래스에서 제공해줘야 하기 때문에 불완전하다.

따라서 전략 패턴보다는 템플릿 메소드 패턴이 적용되었다고 보는 것이 알맞다.


read(byte b[], int off, int len)

java.io의 InputStream에 있는 read() 메소드는 서브클래스에서 구현해야 한다. 이 때 read(byte b[], int off, int len) 템플릿 메소드가 read() 메소드를 사용하게 된다.


JFrame의 paint()

paint() 메소드는 기본적으로 hook 메소드이기 때문에 아무 일도 하지 않는다. 해당 메소드를 오버라이드하면 화면 영역에 특정 내용을 표시하는 JFrame 알고리즘에 사용자가 원하는 그래픽을 추가할 수 있다.

간단한 예시를 살펴보자. 아래는 JFrame을 이용해 간단한 화면을 띄우는 코드이다.

public class MyFrame extends JFrame {

		public MyFrame(String title) {
				super(title);
				this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		
				this.setSize(300,300);
				this.setVisible(true);
		}

		public void paint(Graphics graphics) {
				super.paint(graphics);
				String msg = "여기가 화면";
				graphics.drawString(msg, 100, 100);
		}

		public static void main(String[] args) {
				MyFrame myFrame = new MyFrame("new Frame");
		}
}

위 코드로 만든 화면은 다음과 같다.


AbstractList의 subList()

ArrayList, LinkedList 등 자바의 리스트 컬렉션은 AbstractList 클래스를 확장한 것이다. 그 중 subList() 는 AbstractList에 있는 템플릿 메소드로, get()size() 추상메소드에 의존한다. 따라서 AbstractList를 확장해 커스텀 리스트를 만드려면 get()size() 를 구현해야 한다.

아래는 AbstractList를 확장한 커스텀 리스트를 만드는 예제 코드이다.

public class MyStringList extends AbstractList<String> {
		private String[] myList;
		MyStringList(String[] strings) {
				myList = strings;
		}

		public String get(int index) {
				return myList[index];
		}

		public int size() {
				return myList.length;
		}

		public String set(int index, String item) {
				String oldString = myList[index];
				myList[index] = item;
				return oldString;
		}
}

이렇게 만든 커스텀 리스트는 다음과 같이 사용할 수 있다.

String[] coffee = {"Latte", "Americano", "Espresso"};
MyStringList coffeeList = new MyStringList(coffee);
List coffeeSubList = coffeeList.subList(2,3);



템플릿 메소드 패턴과 전략 패턴

앞서 템플릿 메소드 패턴의 예시를 살펴보다가 전략 패턴과 유사한 것 같다는 의문이 들 수 있다. 실제로 두 패턴은 유사한 형태를 갖고 있으며, 하는 역할 또한 비슷하다. 다만 두 패턴은 구현하는 방식 및 활용에 있어 많은 차이점을 갖고 있다.

아래는 전략 패턴과 템플릿 메소드 패턴을 구분한 표이다. 차이점을 확실히 기억해두자.

🙋‍♀️ 전략 패턴이 잘 기억나지 않는다면 [디자인 패턴/Java] 02. 전략 패턴 (Strategy Pattern)을 참고하자!

0개의 댓글