코드는 핵심 기능과 부가 기능으로 나뉩니다. 핵심 기능은 해당 객체가 제공하는 고유의 기능! 입니다. 그리고 부가 기능은 핵심 기능을 보조하기 위해 제공되는 기능입니다.
핵심 기능과 부가 기능이 함께 섞여 있으면 구분이 잘 안되고 코드가 많이 길어질 가능성이 있습니다. 좋은 설계는 변하는 것과 변하지 않는 것을 분리하는 것입니다. 이런 것이 바로 템플릿 메서드 패턴입니다.
@Slf4j
public class TemplateMethodTest{
@Test
void templateMethodV0(){
logic1();
logic2();
}
private void logic1() {
long startTime = System.currentTimeMillis();
// 비즈니스 로직 실행 -> 여기서 비즈니스 로직 실행
log.info("비즈니스 로직 1 실행");
// 비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
private void logic2() {
long startTime = System.currentTimeMillis();
// 비즈니스 로직 실행 -> 여기서 비즈니스 로직 실행
log.info("비즈니스 로직 2 실행");
// 비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
위 예제에서는 시간을 측정하는 부분과 비즈니스 로직을 실행하는 부분이 혼재함을 확인할 수 있습니다. 즉, '변하는 부분'인 비즈니스 로직과, '변하지 않는 부분'인 시간 측정 부분이 섞여있는 것입니다. 템플릿 메서드 패턴을 이용하여 변하는 부분과 변하지 않는 부분을 분리해봅시다.
템플릿 메서드 패턴은 '템플릿을 사용하는 방식'입니다. 템플릿은 기준이 되는 거대한 틀로서, 템플릿이라는 틀에 변하지 않는 부분을 몰아두고, 일부 변하는 부분을 별도로 호출해서 해결하도록 합니다.
예제의 구조도는 아래와 같습니다.
템플릿의 역할을 하는 AbstractTemplate
의 코드를 살펴보면
@Slf4j
public abstract class AbstractTemplate {
public void execute(){
long startTime = System.currentTimeMillis();
// 비즈니스 로직 실행 -> 여기서 비즈니스 로직 실행
call(); // 상속
// 비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
// 변하는 부분을 call 메소드를 호출해서사용한다.
// 자식 클래스에서 상속과 오버라이딩을 이용해서 구현
protected abstract void call();
}
변하지 않는 부분인 시간 측정 로직을 몰아놓고, 변하는 부분인 비즈니스 로직을 call()
메서드로 호출해서 처리함을 확인할 수 있습니다. 즉, 부모 클래스에 변하지 않는 템플릿 코드를 두고, 변하는 부분은 자식 클래스에 두고 '상속'과 '오버라이딩'을 사용해서 처리하면 됩니다.
자식 클래스에서 오버라이딩한 예제는 아래와 같습니다.
@Slf4j
public class SubClassLogic1 extends AbstractTemplate{
@Override
protected void call() {
log.info("비즈니스 로직1 실행");
}
}
아래의 코드를 통해 실행할 수 있습니다.
@Test
void templateMethodV1(){
AbstractTemplate template1= new SubClassLogic1();
template1.execute();
AbstractTemplate template2 = new SubClassLogic2();
template2.execute();
}
구조도를 확인하면 다음과 같습니다.
위의 템플릿 메서드 패턴은 SubClassLogic1
처럼 클래스를 계속 만들어야하는 단점이 있습니다. 따라서 익명 내부 클래스를 사용하면 이런 단점을 보완할 수 있습니다. 익명 내부 클래스를 사용하면 객체 인스턴스를 생성하면서 동시에 생성할 클래스를 상속 받은 자식 클래스를 정의할 수 있습니다.
@Test
void templateMethodV3(){
AbstractTemplate template1 = new AbstractTemplate() {
@Override
protected void call() {
log.info("비즈니스 로직1 실행");
}
};
log.info("클래스 이름1={}", template1.getClass());
template1.execute();
AbstractTemplate template2 = new AbstractTemplate() {
@Override
protected void call() {
log.info("비즈니스 로직1 실행");
}
};
log.info("클래스 이름2={}", template2.getClass());
template2.execute();
}
람다식으로 변경할 수도 있습니다.
// 템플릿메서드 패턴 + 익면 내부 클래스 사용
@Test
void templateMethodV2(){
AbstractTemplate template1 = new AbstractTemplate(){
// 객체 생성하면서 구현체 만들기
@Override
protected void call(){
log.info("비즈니스 로직1 실행");
}
};
log.info("클래스이름1={}",template1.getClass());
template1.execute();
AbstractTemplate template2 = new AbstractTemplate(){
// 객체 생성하면서 구현체 만들기
@Override
protected void call(){
log.info("비즈니스 로직2 실행");
}
};
log.info("클래스 이름2={}",template2.getClass());
template1.execute();
}
앞선 강의에서 '로그 추적기'를 구현했었는데, 여기에 해당 템플릿 패턴을 적용해보며 이해해보려고 합니다.
AbstractTemplate
는 템플릿 메서드 패턴에서의 '템플릿' 역할을 합니다.
public abstract class AbstractTemplate<T> {
private final LogTrace trace;
public AbstractTemplate(LogTrace trace) {
this.trace = trace;
}
public T execute(String message){
TraceStatus status = null;
try{
status = trace.begin(message);
// 로직 호출 -> 변하는 부분 처리
T result = call();
trace.end(status);
return result;
}catch(Exception e){
// status를 받을 수 있는 방법이 없음
trace.exception(status, e);
// 이렇게 되면 예외를 얘가 먹어버린다! -> 밖으로 나가지 않는다.
throw e; // 예외를 꼭 다시 던져주어야한다.
}
}
protected abstract T call(); // 변하는 부분을 처리하는 메서드 -> 상속으로 구현하기
}
아래는 로그 추적기에 템플릿 메서드 패턴을 적용한 예제입니다.
@Service
@RequiredArgsConstructor
public class OrderServiceV4 {
private final OrderRepositoryV4 orderRepository;
private final LogTrace trace;
public void orderItem(String itemId){
AbstractTemplate<Void> template = new AbstractTemplate<>(trace) {
@Override
protected Void call() {
orderRepository.save(itemId);
return null;
}
};
template.execute("OrderService.orderItem()");
}
}
로그를 출력하는 템플릿 역할을 하는 변하지 않는 코드는 모두 AbstractTemplate
에 담아두고, 변하는 코드는 자식 클래스를 만들어서 분리했습니다. 즉, 로그를 남기는 부분을 모아서 하나로 모듈화하고, 비즈니스 로직 부분을 분리한 것입니다. 여기서 만약 로그를 남기는 로직을 변경해야한다면, 우리는 AbstractTemplate
코드를 변경하면 됩니다.(클래스를 하나하나 찾아서 변경하지 않아도 됩니다.) 즉, 로그를 남기는 부분에 단일 책임 원칙(SRP)를 지켜서, 변경에 쉽게 대처할 수 있는 구조를 만든 것입니다.
GOF 디자인 패턴에서는 템플릿 메서드 패턴을 다음과 같이 정의합니다.
템플릿 메서드 디자인 패턴의 목적은 "작업에서 알고리즘의 골격을 정의하고 일부 단계를 하위 클래스로 연기합니다. 템플릿 메서드를 사용하면 하위 클래스가 알고리즘의 구조를 변경하지 않고도 알고리즘의 특정 단계를 재정의할 수 있습니다.
즉, 부모 클래스에 알고리즘의 골격인 템플릿을 정의하고, 일부 변경되는 로직은 자식 클래스에 정의하는 것입니다. 이렇게 되면 알고리즘의 전체 구조를 변경하지 않고 특정 부분만 재정의할 수 있게 됩니다.
하지만, 상속을 사용하므로 상속에서 오는 단점을 그대로 안고 갑니다. 특히, 자식 클래스가 부모 클래스와 컴파일 시멎에 강하게 결합됩니다. (의존관계의 문제) 자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않습니다. 그럼에도 불구하고 템플릿 메서드 패턴을 위해 자식 클래스는 부모 클래스를 상속받고 있습니다. 이것은 좋은 설계가 아닙니다.
이를 해결하기 위해 등장한, 템플릿 메서드 패턴과 비슷한 역할을 하면서 상속의 단점을 제거하는 디자인 패턴이 바로 전략 패턴 입니다.
전략 패턴은 변하지 않는 부분을 Context
라는 곳에 두고, 변하는 부분을 Strategy
라는 인터페이스를 만들고 해당 인터페이스를 구현하도록 해서 문제를 해결합니다. 상속이 아닌 '위임'으로 문제를 해결하는 것입니다.
GOF 디자인 패턴에서 정의한 전략 패턴의 의도는 다음과 같습니다.
알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들자. 전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.
ㅋ
Strategy 인터페이스
public interface Strategy{
void call();
}
StrategyLogic1
public class StrategyLogic1 implements Strategy{
@Override
public void call(){
log.info("비즈니스 로직1 실행");
}
}
ContextV1
public class ContextV1{
private Strategy strategy;
public ContextV1(Strategy strategy){
this.strategy = strategy;
}
public void execute(){
long startTime = System.currentTimeMillis();
strategy.call(); // 위임
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
}
사용 예제
@Test
void strategyV1(){
Strategy strategyLogic1 = new StrategyLogic1();
ContextV1 context1 = new ContextV1(strategyLogic1);
context1.execute();
}
전략 패턴도 익명 내부 클래스를 사용할 수 있습니다.
ContextV1Test
void strategyV2(){
Strategy strategyLogic1 = new Strategy(){
@Overrid
public void call(){
log.info("비즈니스 로직1 실행");
}
};
ContextvV1 context1 = new ContextV1(strategyLogic1);
context1.execute();
}
그냥 한 번에 합쳐도 됩니다.
void strategyV3() {
ContextV1 context1 = new ContextV1(new Strategy() {
@Override
public void call() {
log.info("비즈니스 로직1 실행");
}
});
context1.execute();
}
이제 정리를 해보자면, 전략 패턴에서는 변하지 않는 부분을 Context에 두고, 변하는 부분을 Strategy를 구현해서 만듭니다. 그리고 Context의 내부 필드에 Strategy를 주입해서 사용했습니다. Context 내부 필드에 Strategy를 두고 사용하는 이 방식은, Context와 Strategy를 실행 전에 원하는 모양으로 조립해두고 그 다음에 Context를 실행하는 선 조립, 후 실행방식에서 매우 유용합니다. 조립 이후에는 Context를 실행하기만 하면 되기 때문입니다.
하지만 이런 방식의단점은, Context와 Strategy를 조립한 이후에는 전략을 변경하기가 번거롭다는 점입니다. Setter를 설정해서 Strategy를 넘겨받도록 해도 되지만, Context를 싱글톤으로 사용할 때는 동시성 이슈 등 고려해야할 부분이 많습니다. 그래서 전략의 실시간 변경이 필요하다면, 이전에 개발한 테스트 코드처럼 Context를 하나 더 생성하고 그곳에 다른 Strategy를 주입하는 것이 더 나은 선택일 수 있습니다.
전략 패턴을 조금 다르게 써보는 예제입니다. Context의 필드에 Strategy를 주입해서 사용하는 것이 아니라, 전략을 실행할 때 직접 파라미터로 전달해서 사용하는 방식입니다.
ContextV2
public class ContextV2{
public void execute(Strategy strategy){
long startTime = System.currentTimeMillis();
strategy.call();
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;}
}
이렇게 되면 execute()가 호출될 떄마다 항상 파라미터로 전달받을 수 있습니다.
실행 예제는 다음과 같습니다.
public class ContextV2Test{
@Test
void strategyV1(){
ContextV2 context = new ContextV2();
context.execute(new StrategyLogic1());
}
}
실행 과정은 다음과 같습니다.
1. 클라이언트는 Context를 실행하면서 인수로 strategy를 전달합니다.
2. Context는 excute()로직을 실행합니다.
3. Context는 파라미터로 넘어온 strategy.call()로직을 실행합니다.
4. Context의 execute() 로직이 종료됩니다.
익명 내부 클래스도 사용할 수 있고, 람다를 사용할 수도 있습니다.
@Test
void strategyV2() {
ContextV2 context = new ContextV2();
context.execute(new Strategy() {
@Override
public void call() {
log.info("비즈니스 로직1 실행");
}
});
context.execute(new Strategy() {
@Override
public void call() {
log.info("비즈니스 로직2 실행");
}
});
}
@Test
void strategyV3() {
ContextV2 context = new ContextV2();
context.execute(() -> log.info("비즈니스 로직1 실행"));
context.execute(() -> log.info("비즈니스 로직2 실행"));
}
ContextV2
public class ContextV2{
public void execute(Strategy strategy){
long startTime = System.currentTimeMillis();
strategy.call();
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;}
}
ContextV2는 변하지 않는 템플릿 역할을 하고, 변하는 부분은 파라미터로 넘어온 strategy의 코드를 실행해서 처리합니다. 이렇게 다른 코드의 "인수"로서 넘겨주는 "실행가능한 코드"를 "콜백"이라고 합니다.
즉, 코드가 call되는데 코드를 넘겨주는 곳의 뒤(back)에서 실행된다는 뜻입니다.
ContextV2에서의 콜백은 Strategy입니다. 클라이언트가 직접 Strategy를 실행하는 것이 아니라, 클라이언트가 ContextV2.excute()를 실행할 때 Strategy를 넘겨주고, ContextV2 뒤에서 Strategy가 실행됩니다.
위와 같은 방식의 전략 패턴을 스프링에서는 템플릿 콜백 패턴이라고 합니다. 이는 GOF 패턴은 아니고, 스프링 내부에서 그냥 자주 사용하는 패턴입니다. 전략 패턴에서 템플릿과 콜백 부분이 강조된 패턴이라 생각하면 됩니다.
Callback 인터페이스
: 콜백 로직을 전달할 인터페이스
public interface Callback{
void call();
}
TimeLogTemplate
public class TimeLogTemplate {
public void execute(Callback callback) {
long startTime = System.currentTimeMillis();
//비즈니스 로직 실행
callback.call(); //위임
//비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
}
TemplateCallbackTest
public class TemplateCallbackTest{
@Test
void callbackV1(){
TimeLogTemplate template = new TimeLogTemplate();
template.execute(new Callback() {
@Override
public void call() {
log.info("비즈니스 로직1 실행");
}
});
template.execute(new Callback() {
@Override
public void call() {
log.info("비즈니스 로직2 실행");
}
});
}
/**
* 템플릿 콜백 패턴 - 람다
*/
@Test
void callbackV2() {
TimeLogTemplate template = new TimeLogTemplate();
template.execute(() -> log.info("비즈니스 로직1 실행"));
template.execute(() -> log.info("비즈니스 로직2 실행"));
}
}
TraceCallback 인터페이스
: 콜백을 전달하는 인터페이스입니다.
public interface TraceCallback<T>{
T call();
}
TraceTemplate
public class TraceTemplate {
private final LogTrace trace;
public TraceTemplate(LogTrace trace) {
this.trace = trace;
}
public <T> T execute(String message, TraceCallback<T> callback) {
TraceStatus status = null;
try {
status = trace.begin(message);
//로직 호출
T result = callback.call();
trace.end(status);
return result;
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
}
로그 추적기를 더 작은 코드로 최적화하기 위해 위와같은 과정을 거쳤습니다. 변하는 코드와 변하지 않는 코드를 분리한 것입니다. 하지만 결국, 우리는 로그 추적기를 적용하기 위해서 원본 코드를 수정해야한다는 점에서 한계가 존재합니다. 원본 코드를 손대지 않고 로그 추적기를 적용할 수 있는 방법을 알아보기 위해선 프록시 개념을 먼저 이해해야합니다.