
로직 2개를 실행하고 실행 시간을 출력하는 간단한 테스트 코드를 작성해보자.
package com.example.advanced_spring.trace.template;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
public class TemplateMethodTest {
@Test
void templateMethod() {
logic1();
logic2();
}
private void logic1() {
long startTime = System.currentTimeMillis();
log.info("비즈니스 로직1 실행");
long endTime = System.currentTimeMillis();
long resultTime = System.currentTimeMillis();
log.info("resultTime={}", resultTime);
}
private void logic2() {
long startTime = System.currentTimeMillis();
log.info("비즈니스 로직2 실행");
long endTime = System.currentTimeMillis();
long resultTime = System.currentTimeMillis();
log.info("resultTime={}", resultTime);
}
}
생각해보면 약간 찝찝하다. 왜냐하면 지금 비즈니스 로직을 실행하는 작업과 걸린 시간을 측정하는 작업이 뒤섞여 있기 때문이다. 좋은 설계란 변하는 것과 변하지 않는 것을 분리하는 것이다. 여기 보면 비즈니스 로직만 변하고, 시간을 측정하는 부분은 변하지 않는다. 따라서 이 둘을 분리해야 하는데, 이때 사용되는 것이 템플릿 메서드 패턴(Template Method Pattern)이다.
템플릿 메서드 패턴은 보통 상위에 추상 템플릿을 두고 이 곳에 변하지 않는 로직들(execute())을 몰아 넣는다. 그리고 변하는 로직을 수행할 메서드(call())를 자식 클래스에서 오버라이딩 하도록 구성하는 것이다.

바로 코드로 구현해보자.
package com.example.advanced_spring.trace.template.code;
import lombok.extern.slf4j.Slf4j;
@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);
}
protected abstract void call();
}
코드를 보면 변하지 않는 부분인 시간 측정 로직을 몰아두고 그 안에 변하는 부분인 call() 메서드를 호출해서 처리하는 것을 볼 수 있다. 이제 원하는 만큼 자식 클래스에서 call()을 오버라이딩 해서 사용하면 된다.
package com.example.advanced_spring.trace.template.code;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class SubClassLogic1 extends AbstractTemplate {
@Override
protected void call() {
log.info("비즈니스 로직1 실행...");
}
}
package com.example.advanced_spring.trace.template.code;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class SubClassLogic2 extends AbstractTemplate {
@Override
protected void call() {
log.info("비즈니스 로직2 실행...");
}
}
근데 위와 같은 방식은 자식 클래스를 필요할 때마다 계~속 만들어야 한다. 이때 익명 내부 클래스를 사용해서 객체 인스턴스를 생성하면서 동시에 생성할 클래스를 상속 받은 자식 클래스를 정의하면 된다. 간단하게 살펴보고 넘어가자.
/**
* 익명 클래스 사용
*/
@Test
void templateMethodV2() {
AbstractTemplate template1 = new AbstractTemplate() {
@Override
protected void call() {
log.info("비즈니스 로직1 실행...");
}
};
template1.execute();
AbstractTemplate template2 = new AbstractTemplate() {
@Override
protected void call() {
log.info("비즈니스 로직2 실행...");
}
};
template2.execute();
}
GOF 디자인 패턴에서의 템플릿 메서드 패턴은 아래와 같이 정의되고 있다.
“작업에서 알고리즘의 골격을 정의하고 일부 단계를 하위 클래스로 연기한다. 템플릿 메서드를 사용하면 하위 클래스가 알고리즘의 구조를 변경하지 않고도 알고리즘의 특정 단계를 재정의할 수 있다.”

앞서 말했듯이 부모 클래스(AbstractClass)가 알고리즘의 골격인 템플릿을 정의하고, 일부 변경되는 로직은 자식 클래스(SubClass1)에 정의하는 것이었다. 결국 상속과 오버라이딩을 통한 다형성으로 문제를 해결하는 방식인데… 여기에는 치명적인 문제가 있다. 바로 자식 클래스가 부모 클래스에 컴파일 시점에 강하게 의존한다는 것이다. 현재 자식 클래스 입장에서는 꼴랑 비즈니스 로직 한 줄 들고 있는데 사용하지도 않는 부모 클래스의 기능을 전부 끌고 있는 것이다. 이를 좋은 설계라고 하기에는 분명 무리가 있다. 이를 더 깔끔하게 개선할 수는 없을까?
기존 템플릿 메서드 패턴에서 만들었던 아래 코드를 전략 패턴을 사용해서 해결해보자.
package com.example.advanced_spring.trace.strategy;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
public class ContextV1Test {
@Test
void strategyV0() {
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);
}
}
템플릿 메서드 패턴이 변하는 부분을 오버라이딩 했다면, 전략 패턴은 변하는 부분을 인터페이스로 빼는 것이다. 더 자세히 말하면, 변하지 않는 부분을 Context라는 곳에 두고, 변하는 부분을 Strategy라는 인터페이스를 만들고 해당 인터페이스를 구현하는 방식으로 문제를 해결한다.
GOF 디자인 패턴에서 정의한 전략 패턴의 의도는 아래와 같다.
“알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.”

약간 예전에 객체지향적 설계를 공부할 때, 역할을 정의하고 상황에 맞게 구현체를 끼워 넣는 방식과 굉장히 유사하다. 코드로 직접 구현해보자.
package com.example.advanced_spring.trace.strategy.code.strategy;
public interface Strategy {
void call();
}
package com.example.advanced_spring.trace.strategy.code.strategy;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class StrategyLogic1 implements Strategy {
@Override
public void call() {
log.info("비즈니스 로직1 실행...");
}
}
package com.example.advanced_spring.trace.strategy.code.strategy;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class StrategyLogic2 implements Strategy {
@Override
public void call() {
log.info("비즈니스 로직2 실행...");
}
}
일단 위와 같이 Strategy 인터페이스를 정의하고, 변하는 알고리즘은 인터페이스를 구현한 구현체에 정의한다. 이제 나머지 변하지 않는 로직들을 Context에 넣어두면 된다.
package com.example.advanced_spring.trace.strategy.code;
import com.example.advanced_spring.trace.strategy.code.strategy.Strategy;
import lombok.extern.slf4j.Slf4j;
/**
* 필드에 전략을 보관하는 방식
*/
@Slf4j
public class ContextV1 {
private final 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);
}
}
보다시피 strategy 필드를 하나 두고, 생성자를 통해 구현체를 박아주는 것이다. 이러면 Context는 Strategy 인터페이스에만 의존하고 있기 때문에 상황에 맞게 언제든지 구현체를 변경할 수 있다. 이거이거… 스프링에서 의존관계 주입에서 사용하는 생성자 주입이랑 판박이다. 맞다. 그게 전략 패턴이다.
/**
* 전략 패턴 적용
*/
@Test
void strategyV1() {
Strategy strategyLogic1 = new StrategyLogic1();
ContextV1 contextV1 = new ContextV1(strategyLogic1);
contextV1.execute();
/**
* 19:33:29.600 [Test worker] INFO com.example.advanced_spring.trace.strategy.code.strategy.StrategyLogic1 -- 비즈니스 로직1 실행...
* 19:33:29.601 [Test worker] INFO com.example.advanced_spring.trace.strategy.code.ContextV1 -- resultTime: 2
*/
Strategy strategyLogic2 = new StrategyLogic2();
ContextV1 contextV2 = new ContextV1(strategyLogic2);
contextV2.execute();
/**
* 19:33:29.602 [Test worker] INFO com.example.advanced_spring.trace.strategy.code.strategy.StrategyLogic2 -- 비즈니스 로직2 실행...
* 19:33:29.602 [Test worker] INFO com.example.advanced_spring.trace.strategy.code.ContextV1 -- resultTime: 0
*/
}

전략 패턴도 템플릿 메서드 패턴과 마찬가지로 익명 내부 클래스를 사용할 수도 있고 람다를 사용할 수도 있다. 아래 코드를 가볍게 참고하자.
/**
* 익명 내부 클래스 사용
*/
@Test
void strategyV2() {
Strategy strategyLogic1 = new Strategy() {
@Override
public void call() {
log.info("비즈니스 로직1 실행...");
}
};
log.info("strategyLogic1 = {}", strategyLogic1.getClass());
ContextV1 contextV1 = new ContextV1(strategyLogic1);
contextV1.execute();
/**
* 19:44:17.506 [Test worker] INFO com.example.advanced_spring.trace.strategy.ContextV1Test -- strategyLogic1 = class com.example.advanced_spring.trace.strategy.ContextV1Test$1
* 19:44:17.507 [Test worker] INFO com.example.advanced_spring.trace.strategy.ContextV1Test -- 비즈니스 로직1 실행...
* 19:44:17.508 [Test worker] INFO com.example.advanced_spring.trace.strategy.code.ContextV1 -- resultTime: 1
*/
Strategy strategyLogic2 = new Strategy() {
@Override
public void call() {
log.info("비즈니스 로직2 실행...");
}
};
log.info("strategyLogic2 = {}", strategyLogic2.getClass());
ContextV1 contextV2 = new ContextV1(strategyLogic2);
contextV2.execute();
/**
* 19:44:17.508 [Test worker] INFO com.example.advanced_spring.trace.strategy.ContextV1Test -- strategyLogic2 = class com.example.advanced_spring.trace.strategy.ContextV1Test$2
* 19:44:17.508 [Test worker] INFO com.example.advanced_spring.trace.strategy.ContextV1Test -- 비즈니스 로직2 실행...
* 19:44:17.508 [Test worker] INFO com.example.advanced_spring.trace.strategy.code.ContextV1 -- resultTime: 0
*/
}
/**
* 익명 내부 클래스를 변수에 담아두지 말고, 생성하면서 바로 ContextV1에 전달
*/
@Test
void strategyV3() {
ContextV1 contextV1 = new ContextV1(new Strategy() {
@Override
public void call() {
log.info("비즈니스 로직1 실행...");
}
});
contextV1.execute();
ContextV1 contextV2 = new ContextV1(new Strategy() {
@Override
public void call() {
log.info("비즈니스 로직2 실행...");
}
});
contextV2.execute();
}
/**
* 람다 사용도 가능
*/
@Test
void strategyV4() {
ContextV1 contextV1 = new ContextV1(() -> log.info("비즈니스 로직1 실행..."));
contextV1.execute();
ContextV1 contextV2 = new ContextV1(() -> log.info("비즈니스 로직2 실행..."));
contextV2.execute();
}
기억하자. 변하지 않는 부분은 Context, 변하는 부분은 Strategy를 구현하고 스프링 생성자 주입마냥 Context 내부 필드에 Strategy 구현체를 주입해서 사용하자.
하지만 위와 같이 Context와 Strategy를 한번 조립하고 Context를 실행하는 방식은 조립한 이후에는 전략을 변경하기가 번거롭다. 더 유연하게 전략 패턴을 사용할 방법은 없을까? 이번에는 전략을 실행할 때 직접 파라미터로 전달해서 사용해보자.
package com.example.advanced_spring.trace.strategy.code;
import com.example.advanced_spring.trace.strategy.code.strategy.Strategy;
import lombok.extern.slf4j.Slf4j;
/**
* 전략을 파라미터로 전달 받는 방식
*/
@Slf4j
public class ContextV2 {
public void execute(Strategy strategy) { // 파라미터로 전달
long startTime = System.currentTimeMillis();
// 비즈니스 로직 실행 (위임)
strategy.call();
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime: {}", resultTime);
}
}
보다시피 전략을 필드에 주입하는게 아니라 전략을 호출(execute())할 때마다 파라미터로 전달하도록 하는 것이다.
package com.example.advanced_spring.trace.strategy;
import com.example.advanced_spring.trace.strategy.code.ContextV2;
import com.example.advanced_spring.trace.strategy.code.strategy.Strategy;
import com.example.advanced_spring.trace.strategy.code.strategy.StrategyLogic1;
import com.example.advanced_spring.trace.strategy.code.strategy.StrategyLogic2;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
public class ContextV2Test {
@Test
void strategyV1() {
ContextV2 context = new ContextV2();
context.execute(new StrategyLogic1());
context.execute(new StrategyLogic2());
/**
* 20:00:52.108 [Test worker] INFO com.example.advanced_spring.trace.strategy.code.strategy.StrategyLogic1 -- 비즈니스 로직1 실행...
* 20:00:52.110 [Test worker] INFO com.example.advanced_spring.trace.strategy.code.ContextV2 -- resultTime: 2
* 20:00:52.110 [Test worker] INFO com.example.advanced_spring.trace.strategy.code.strategy.StrategyLogic2 -- 비즈니스 로직2 실행...
* 20:00:52.111 [Test worker] INFO com.example.advanced_spring.trace.strategy.code.ContextV2 -- resultTime: 1
*/
}
/**
* 익명 클래스 사용
*/
@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 실행..."));
}
}
보다시피 Context를 실행하는 시점에 원하는 전략을 파라미터로 전달할 수 있어 더욱 유연하게 전략을 변경할 수 있다. 현재 테스트 코드만 봐도 Context 하나만 생성하고 실행 시점에 여러 전략을 파라미터로 전달해서 아주 간편하게 전략을 변경하는 것을 확인할 수 있다.

지금까지 템플릿 메서드 패턴과 전략 패턴을 살펴봤는데, 핵심을 놓치지 말자. 바로 변하는 부분과 변하지 않는 부분을 분리하는 것이다. 변하지 않는 부분을 템플릿이라고 하는 것이고, 그 템플릿 안에서 변하는 부분에 약간 다른 코드 조각을 넘겨서 실행하는 것이 목적이다.
위의 ContextV2에서 변하는 부분은 파라미터로 넘어온 Strategy 코드를 실행해서 처리하고 있다. 이렇게 다른 코드의 파라미터로 넘겨주는 실행 가능한 코드를 콜백(Callback)이라고 한다. 콜백의 정확한 정의는 아래와 같다.
“다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말한다. 콜백을 넘겨받는 코드는 해당 콜백을 필요에 따라 즉시 실행할 수도, 나중에 실행할 수도 있다.”
쉽게 말하면, 콜백은 코드가 호출(call)은 되는데 코드를 넘겨준 곳의 뒤(back)에서 실행된다는 말이다. 기존 코드를 예시로 들면 Strategy가 콜백이고, 클라이언트가 직접 Strategy를 실행하는 것이 아니라 클라이언트가 ContextV2.execute()를 실행할 때 Strategy를 넘겨주고, ContextV2 뒤에서 Strategy가 실행되는 것이다.
스프링에서는 ContextV2와 같은 방식의 전략 패턴을 템플릿 콜백 패턴이라고 한다. 실제로 JdbcTemplate, RestTemplate, TransactionTemplate, RedisTemplate 등 많은 부분에 템플릿 콜백 패턴이 사용되고 있다.

간단하게 템플릿 콜백 패턴을 구현해보자. ContextV2와 큰 차이는 없고, 단지 Context가 Template으로, Strategy가 Callback으로 명칭만 달라졌다고 생각하면 된다.
package com.example.advanced_spring.trace.strategy.code.template;
public interface Callback {
void call();
}
변하지 않는 부분은 TimeLogTemplate에 넣어두었다.
package com.example.advanced_spring.trace.strategy.code.template;
import lombok.extern.slf4j.Slf4j;
@Slf4j
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);
}
}
package com.example.advanced_spring.trace.strategy;
import com.example.advanced_spring.trace.strategy.code.template.Callback;
import com.example.advanced_spring.trace.strategy.code.template.TimeLogTemplate;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
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 실행..."));
}
}
하지만 지금까지 알아본 방식들도 한계는 존재한다. 결국 원본 코드를 수정해야 한다는 점이다. 이제 프록시에 대해 알아보도록 하자.