디자인 패턴 공부 - 템플릿 메소드 패턴

이혁진·2023년 2월 5일

템플릿 메소드 패턴

템플릿 메소드 패턴이란 어떤 알고리즘 내에서 공통된 작업과 약간의 달라지는 부분이 있을 때, 달라지는 부분을 하위 클래스에 위임한다. 이를 통해서 기존 알고리즘의 구조는 바꾸지 않으면서도 일부분을 서브클래스에서 구현할 수 있게 된다. 스트레티지 패턴과 매우 유사하다.(템플릿 콜백은 완전 똑같다.) 역시나 그 목적이 살짝 다르다. 스트래티지 패턴은 전략의 유연한 전환이 목적이나, 템플릿 메소드 패턴은 기존의 틀을 유지한 채 일부를 쉽게 커스터마이징 할 수 있게 하는 것이 목적이다. 이 패턴도 역시 요긴하게 쓰인다고 한다. 코드 짜다보면 복붙하고 살짝만 바꾸는 경우가 있는데, 이 경우 템플릿을 쓰면 아주 좋다.

구현

문제 상황 - 여러 클래스에 중복된 코드

라면을 끓이는 클래스가 있는데, 살짝 요리 중간에 다른 부분이 있다. 대부분은 공통 로직이라서, 다른 부분을 위해 공통 로직을 매번 복붙해야 한다.

public class NormalRamenChief {	
	public void cook() {
    	System.out.println("냄비 꺼내기");
    	System.out.println("물 넣기");
    	System.out.println("물 끓이기");
    	System.out.println("스프 넣기");

		// 달라지는 부분
    	System.out.println("표고버섯 4개 꺼내기");
    	System.out.println("표고버섯 4개 4등분 썰기");
    	System.out.println("표고버섯 넣기");

    	System.out.println("물 계속 끓이기");

		// 달라지는 부분
    	System.out.println("라면 넣기");

    	System.out.println("5분간 끓이기");
	}
}

public class LuxuryRamenChief {
	public void cook() {
    	System.out.println("냄비 꺼내기");
    	System.out.println("물 넣기");
    	System.out.println("물 끓이기");
    	System.out.println("스프 넣기");

		// 달라지는 부분
    	System.out.println("송로버섯 3개 꺼내기");
    	System.out.println("송로버섯 3개 6등분 썰기");
    	System.out.println("송로버섯 넣기");

    	System.out.println("물 계속 끓이기");

		// 달라지는 부분
    	System.out.println("라면에 금가루 칠하기");
    	System.out.println("라면 넣기");

    	System.out.println("라면 5분간 끓이기");
	}
}

해결 방안1 - 공통 부분을 메서드로 바꾼다.

공통된 로직을 메소드로 추출하고, 그걸 호출하도록 한다. 이러면 달라지는 부분을 위해 새로 클래스 만들었을 때 복붙하는 양이 줄어들게 된다. 다만, 공통 로직이 여전히 메소드로 남아있다. 그래서 메소드의 복사는 어쩔 수 없이 일어난다.

public class NormalRamenChief {	
	public void cook() {
    
    	step1();

    	System.out.println("표고버섯 4개 꺼내기");
    	System.out.println("표고버섯 4개 4등분 썰기");
    	System.out.println("표고버섯 넣기");

    	step2();

    	System.out.println("라면 넣기");

    	step3();
	}
    
    public void step1() {
    	System.out.println("냄비 꺼내기");
    	System.out.println("물 넣기");
    	System.out.println("물 끓이기");
    	System.out.println("스프 넣기");
    }
    
    public void step2() {
		System.out.println("물 계속 끓이기");        
    }
    
    public void step3() {
    	System.out.println("5분간 끓이기");
    }
}

public class LuxuryRamenChief {
	public void cook() {
    	
        step1();

    	System.out.println("송로버섯 3개 꺼내기");
    	System.out.println("송로버섯 3개 6등분 썰기");
    	System.out.println("송로버섯 넣기");

		step2();

    	System.out.println("라면에 금가루 칠하기");
    	System.out.println("라면 넣기");

    	step3();
	}
    
    public void step1() {
    	System.out.println("냄비 꺼내기");
    	System.out.println("물 넣기");
    	System.out.println("물 끓이기");
    	System.out.println("스프 넣기");
    }
    
    public void step2() {
		System.out.println("물 계속 끓이기");        
    }
    
    public void step3() {
    	System.out.println("5분간 끓이기");
    }
}

해결 방안2 - 바뀌는 부분을 하위클래스에 위임한다. (템플릿 메서드 패턴)

이렇게 하면, 공통 부분에서 절대 중복이 발생하지 않는다. 다만 상속으로 인해서 여러 문제가 있을 수 있다.

public abstract class RamenChiefTemplate {	
	public void cook() {
    	step1();
		putMushRoom();
    	step2();
		putRamen();
    	step3();
	}
    
    private void step1() {
    	System.out.println("냄비 꺼내기");
    	System.out.println("물 넣기");
    	System.out.println("물 끓이기");
    	System.out.println("스프 넣기");
    }
    
    private void step2() {
		System.out.println("물 계속 끓이기");        
    }
    
    private void step3() {
    	System.out.println("5분간 끓이기");
    }
    
    protected abstract void putMushRoom();
    
    protected abstract void putRamen();
}

public class NormalRamenChief extends RamenChiefTemplate {
	@Override
	protected void putMushRoom() {
    	System.out.println("표고버섯 4개 꺼내기");
    	System.out.println("표고버섯 4개 4등분 썰기");
    	System.out.println("표고버섯 넣기");
	}

	@Override
	protected void putRamen() {
    	System.out.println("라면 넣기");
	}
}

public class LuxuryRamenChief extends RamenChiefTemplate {
	@Override
	protected void putMushRoom() {
    	System.out.println("송로버섯 3개 꺼내기");
    	System.out.println("송로버섯 3개 6등분 썰기");
    	System.out.println("송로버섯 넣기");
	}

	@Override
	protected void putRamen() {
    	System.out.println("라면에 금가루 칠하기");
    	System.out.println("라면 넣기");
	}
}

public class Main {
	public static void main(String[] args) {
    	// 익명 클래스
    	RamenChiefTemplate ramenChiefTemplate = new RamenChiefTemplate() {
        	@Override
        	protected void putMushRoom() {
            	System.out.println("송로버섯 3개 꺼내기");
            	System.out.println("송로버섯 3개 6등분 썰기");
            	System.out.println("송로버섯 넣기");
        	}

        	@Override
        	protected void putRamen() {
            	System.out.println("라면에 금가루 칠하기");
            	System.out.println("라면 넣기");
        	} 
    	};
        
        // 그냥 클래스 넘기기
        ramenChiefTemplate ramenChiefTemplate = new LuxuryRamenChief();

    	ramenChiefTemplate.cook();
	}
}

해결 방안3 - 바뀌는 부분을 캡슐화한다. (템플릿 콜백 패턴)

템플릿 콜백 패턴은 달라지는 부분을 하위클래스가 아닌 새로 캡슐화된 인터페이스에 위임한다. 단순히 템플릿 메서드 패턴을 컴포지션으로 바꾼 것이다. 이에 따라 슈퍼클래스의 접근 제한자가 잘 되어있지 않더라도 상속보다 캡슐화를 덜 깨뜨릴 수 있다.

보면 전략 패턴이랑 거의 똑같은데, 똑같다고 봐도 될 것 같다. 다만 전략 패턴이 전략 인터페이스에 여러 메소드를 다 몰아넣었다면, 템플릿 콜백에서는 인터페이스 당 하나의 추상메소드만 있어야 한다고 한다. 모든 패턴이 그렇듯 사용자 마음이지만 생각해보면 전자는 전략? 알고리즘?을 전환하는 것이고, 후자는 세부적인 부분만 바꾸는 것이므로 인터페이스를 더 쪼개는게 각자의 목적에 부합하는 것 같다.

아무튼 위에 코드가 이렇게 바뀐다. 구현체는 템플릿 메소드 예시랑 같다

public final class RamenChiefTemplate {	
	public void cook(MushRoomCallback mushRoomCallback, RamenCallback ramenCallback) {
    	step1();
		mushRoomCallback.putMushRoom();
    	step2();
		RamenCallback.putRamen();
    	step3();
	}
    
    private void step1() {
    	System.out.println("냄비 꺼내기");
    	System.out.println("물 넣기");
    	System.out.println("물 끓이기");
    	System.out.println("스프 넣기");
    }
    
    private void step2() {
		System.out.println("물 계속 끓이기");        
    }
    
    private void step3() {
    	System.out.println("5분간 끓이기");
    }
}

public class Main {
	public static void main(String[] args) {
    	RamenChiefTemplate ramenChiefTemplate = new RamenChiefTemplate();
    	ramenChiefTemplate.cook(
        	new MushRoomCallback() {
            	@Override
            	public void putMushRoom() {
                	System.out.println("송로버섯 3개 꺼내기");
                	System.out.println("송로버섯 3개 6등분 썰기");
                	System.out.println("송로버섯 넣기");
            	}
        	},
        	new RamenCallback() {
            	@Override
            	public void putRamen() {
                	System.out.println("라면에 금가루 칠하기");
                	System.out.println("라면 넣기");
            	}
        	}
    	);

    	ramenChiefTemplate.cook(
        	    new NormalMushRoomCallback(),
            	new RamenCallback() {
                	@Override
                	public void putRamen() {
                    	System.out.println("라면에 금가루 칠하기");
                    	System.out.println("라면 넣기");
                	}
            	}
    	);

    	ramenChiefTemplate.cook(
            	new LuxuryMushRoomCallback(),
            	new PigRamenCallback()
    	);
	}
}

이런 느낌이다.

장점

코드를 재사용하고 중복 코드를 줄일 수 있다. 다만 세부적으로 변화하는 부분이 많아지면 어디를 위임해야 하는지 정하기 어려워지고, 이미 정했으면 기존 템플릿이 변하기 쉽다는 단점이 있다. 그래도 기존 코드를 안건드리고 조금만 바꿀 수 있다는 장점 때문에, 엄청 많이 쓰인다.

예시1 - JDBC 템플릿

자바에서 JDBC 쓰면 막 커넥션 받아오고 fetch하고 close하고 엄청 코드가 길다. 중복되는 부분이 많고 실제 바뀌는 부분은 거의 쿼리밖에 없는데, JDBC 템플릿에서는 템플릿 메소드 패턴을 활용해서 중복되는 부분을 줄일 수 있게 한다.

이렇게 쓸 수 있는데

Sql String 매개변수로 오버로드된 execute()가 먼저 받아서 그걸로 PreparedStatement 콜백 만들고 사진에 있는 execute()에 넘겨준다. 여기 안에 보면 커넥션 얻어오기같은 공통 작업이 있는 것을 확인할 수 있다.

상속보다는 컴포지션을 활용하라

보통 상속보다는 구성(composition)을 활용하라는데, 도무지 이해가 안되었다. 여러 문제를 찾아보았는데, 공통적인 것은 캡슐화를 깨뜨리기 때문이라는 것이었다.

왜 캡슐화가 깨지냐면, 슈퍼클래스의 구현이 서브클래스에 드러나기 때문이다. 무슨 말이냐면 슈퍼클래스의 public/protected 필드에 서브클래스가 직접 접근할 수 있고 (캡슐화된 메소드를 사용하지 않고), 드러나서는 안되는 public/protected 메소드를 서브클래스에서 호출할 수도 있기 때문이다. 전자의 경우는 필드 수정 시 굉장한 변경의 전파가 일어나고 후자도 해당 메소드들을 호출하는 자식 클래스의 여러 메소드들에 변경의 전파를 발생시킨다.

또한, 슈퍼클래스의 public 메소드들을 서브클래스에서도 접근할 수 있게 된다는 것이다. 예를 들어서 Vector 클래스와 그것을 상속하는 Stack이 있다고 하자. Vector는 원하는 위치에 CRUD가 가능하다. 반면 Stack은 top에서만 CRUD가 가능하다. Stack이 Vector을 상속하면, Vector의 CRUD함수를 사용할 수 있어야 하지만(push와 pop 구현을 위해) Stack에서는 그 CRUD함수에 접근하면 안된다.(Stack 자체가 top에서만 CRUD가 가능하니까)

이러한 문제를 컴포지션으로 해결할 수 있다. 슈퍼클래스를 서브클래스의 private 필드로 놓는 것이다.

이전의 서브 클래스(래핑클래스가 되었다.)는 슈퍼클래스의 public이 아닌 필드에 반드시 메소드로 접근해야 하고, public/protectected 메소드를 더이상 래핑클래스에서 접근할 수 없다. 또한, 원래는 외부에서 서브클래스를 통해 슈퍼클래스의 public 메소드들을 접근할 수 있었는데,(Vector Stack 처럼) 슈퍼클래스를 래핑한 경우에는 따로 기능을 구현하지 않으면 접근이 불가능하다.

맨 밑에줄 무슨 말이냐면, 학생 정보를 관리하는 클래스가 있고 출력만 가능하다고 해보자.

<상속>
public class StudentManager extends ArrayList {
	public void printAll() {
    	// 다 출력하기
    }
}

public class Main {
	public static void main(String[] args) {
    	StudentManager sm = new StudentManager();
        sm.add(~); // OK
        sm.get(0);  // OK
        sm.printAll();
	}
}

<구성>
public class StudentManager {
	List arr = new ArrayList();
    
    public void printAll() {
    	// 다 출력하기
    }
}

public class Main {
	public static void main(String[] args) {
    	StudentManager sm = new StudentManager();
        // sm.add(~); Fail
        // sm.get(0); Fail
        sm.printAll();
	}
}

이런 느낌이다. 물론 따로 저 add와 get 구현을 외부에 열고 싶다면 메소드 따로 열면 되기는 하다.

잘 생각해보면 저 위에 있는 말이 아다리가 안맞는다. 접근 제한자를 잘 제어하지 못한 경우(public/protected)를 전제로 하여

public/protected 필드에 서브클래스가 직접 접근할 수 있고 ...
public/protected 메소드를 서브클래스에서 호출할 ...

이런 문장 때문이다. 결국 private으로 제한하면 되는 것이다. 또한, 외부에서 슈퍼클래스 메소드 접근할 수 있는 것도 애초에 슈퍼클래스를 protected로 선언했으면 된다.

By the same argument, the whole language breaks encapsulation by allowing you the freedom to declare implementation details public. What the quote above is saying is not that inheritance breaks encapsulation per se but that inexperienced or incompetent programmers can break encapsulation if they don't understand what access control is about and the need to isolate your implementation details from clients (including derived classes). Load of rubbish. In the hands of an incompetent programmer, almost any horror can be perpetrated. Now, "non-member non-friend functions increase encapsulation" - that's a topic to discuss.

찾아보니까 사실 상속의 문제는 접근제한자를 제대로 못 써서 그런거라고 한다. 그니까 접근제한자를 잘 쓰면 상속이라도 캡슐화를 깨뜨리지 않는다는 것이다.

그렇다면 왜 컴포지션을 써야할까 고민을 해보았다. 두 가지정도의 이유가 생각났다.

  1. 슈퍼클래스의 수정이 불가능한 경우
    '접근이 잘 제한된 슈퍼클래스'여야지만 캡슐화가 깨지지 않는다고 했다. public/protected가 남발되어있고 굉장히 결합도가 강한 클래스(몇 백개의 클래스와 결합?)를 상속하는 경우를 생각해보자. 슈퍼클래스의 접근 제어자를 바꿀 수가 없으니 캡슐화는 그대로 깨져버린다. 컴포지션을 쓰면 슈퍼클래스의 접근 제어에 관한 전제가 없다. 그냥 슈퍼클래스를 래핑해버리면 캡슐화를 유지한 채로 확장이 가능하다.

  2. 복잡한 접근 제어자에 일일이 신경 쓸 필요가 없다
    접근 제어자를 일일이 신경쓰기는 어렵다. 더군다나 자바는 protected가 있어서 시스템이 크면 훨씬 복잡해질 수 있다. 물론 어느 정도는 제한을 해야겠지만, 컴포지션은 이러한 단계를 생략할 수 있게 된다.

  3. 인터페이스를 통해 상속처럼 유연성을 부여할 수 있다.
    그냥 슈퍼클래스를 필드로 가지면(연관관계) 강한 결합이 된다. 상속 관계에서는 다형성을 통해서 슈퍼클래스 객체가 여러 서브클래스 객체로 존재할 수 있는데, 그냥 컴포지션하면 다형성을 못쓴다. 근데 아래처럼 역시 공통 인터페이스를 추출하면 활용할 수 있기 때문에, 상속에 비해 꿀릴 것이 없다.

    public class Main() {
    	public static void main(String[] args) {
        	// concrete class based composition
        	MyClass1 myclass = new MyClass1(
            	new MyClass()
            );
            myclass.opration(); // ok
            
            MyClass2 myclass = new MyClass2(
            	new MyClass()
            );
            myclass.operation(); // fail
            
            // use interface
            IMyClass myclass = new MyClass1(
            	new MyClass()
            );
            myclass.opration(); // ok
            
            IMyClass myclass = new MyClass2(
            	new MyClass()
            );
            myclass.operation(); // ok
        }
    }

아무튼 이상의 이유로 객체가 자명하게 is A 관계가 아니면 컴포지션을 쓰는게 좋을 것 같다. 이펙티브 자바에도 해당 주제를 다루는 부분이 있다고 했는데, 나중에 한번 봐야 할 것 같다.

profile
한양대학교 정보시스템학과 22학번 이혁진 입니다

0개의 댓글