이번 포스팅에서는 스프링에서 자주 사용되는 디자인 패턴 중 템플릿 메서드 패턴, 전략 패턴, 템플릿 콜백 패턴에 대해서 알아보도록 하겠습니다.
미리 간단하게 설명하자면 위 3가지 디자인 패턴은 변하는 부분과 변하지 않는 부분을 분리하는 것이 목적입니다.
void logic() {
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);
}
위 예제 코드를 보면 logic1()
과 logic2()
는 시간을 측정하는 부분과 비즈니스 로직을 실행하는 부분이 함께 존재하고 있습니다.
변하는 부분 → 비즈니스 로직
변하지 않는 부분 → 시간 측정
예시 코드를 3가지 디자인 패턴을 활용하여 변하는 부분과 변하지 않는 부분을 분리하는 과정을 통해 각각 디자인 패턴에 대해서 설명하겠습니다.
GOF 디자인 패턴에서 정의한 템플릿 메서드 디자인 패턴의 의도는 다음과 같습니다.
작업에서 알고리즘의 골격을 정의하고 일부 단계를 하위 클래스로 연기합니다. 템플릿 메서드를 사용하면 하위 클래스가 알고리즘의 구조를 변경하지 않고도 알고리즘의 특정 단계를 재정의할 수 있습니다.
이를 풀어서 설명하면 부모 클래스에 알고리즘의 골격인 템플릿을 정의하고, 일부 변경되는 로직은 자식 클래스에 정의하는 것입니다.
이렇게 하면 자식 클래스가 알고리즘의 전체 구조를 변경하지 않고, 특정 부분만 재정의할 수 있습니다. 상속과 오버라이딩을 통한 다형성으로 문제를 해결하는 것입니다.
이제 템플릿 메서드 패턴을 사용하여 변하는 부분과 변하지 않는 부분을 분리해 보겠습니다.
@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();
}
AbstractTemplate
추상 클래스에서 변하지 않는 부분인 시간 측정 로직을 몰아둔 것을 확인할 수 있습니다. 이제 이것이 하나의 템플릿이 되고 템플릿 안에서 변하는 부분은 call()
메서드를 호출해서 처리합니다.
@Slf4j
public class SubClassLogic1 extends AbstractTemplate {
@Override
protected void call() {
log.info("비즈니스 로직1 실행");
}
}
@Slf4j
public class SubClassLogic2 extends AbstractTemplate {
@Override
protected void call() {
log.info("비즈니스 로직2 실행");
}
}
변하는 부분인 비즈니스 로직 1, 비즈니스 로직 2를 처리하는 자식 클래스에서 템플릿이 호출하는 대상인 call()
메서드를 오버라이딩 합니다.
다음과 코드처럼 템플릿 메서드 패턴을 적용할 경우 구조는 다음과 같습니다.
다음과 같이 템플릿 메서드 패턴으로 구현한 코드로 logic 메서드를 리팩토링하면 다음과 같이 리팩토링할 수 있습니다.
void logic() {
AbstractTemplate template1 = new SubClassLogic1();
template1.execute();
AbstractTemplate template2 = new SubClassLogic2();
template2.execute();
}
template1.execute()
메서드를 호출하면 템플릿 로직인 AbstractTemplate.execute()
를 실행합니다. 여기서 중간에 call()
메서드를 호출하는데, 이 부분이 오버라이딩 되어있기 때문에, 현재 인스턴스인 SubClassLogic1
인스턴스의 SubClassLogic1.call()
메서드가 호출됩니다.
템플릿 메서드 패턴은 이렇게 다형성을 사용해서 변하는 부분과 변하지 않는 부분을 분리하는 방법입니다.
하지만
템플릿 메서드 패턴은 상속을 사용하기 때문에, 상속에서 발생하는 단점을 수반할 수 밖에 없습니다.
특히 자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않지만 부모 클래스와 컴파일 시점에 강하게 결합되는 문제가 있습니다.
부모 클래스의 알고리즘 구조를 재사용하기 위해 상속을 받지만, 실제로 부모 클래스의 다른 메서드나 속성은 자식 클래스에서 사용되지 않는다는 뜻입니다.
또한, 만약 부모 클래스가 변경된다면 자식 클래스 모두를 변경해야 되는 상황도 올 수 있습니다.
이러한 상속에 대한 단점을 없애고, 템플릿 메서드 패턴과 유사한 장점을 가질 수 있는 패턴이 바로 전략 패턴입니다.
탬플릿 메서드 패턴은 부모 클래스에 변하지 않는 템플릿을 두고, 변하는 부분을 자식 클래스에 두어서 상속을 사용해서 문제를 해결했습니다.
전략 패턴의 경우, 변하지 않는 부분을 Context
라는 곳에 두고, 변하는 부분을 Strategy
라는 인터페이스를 만들고 해당 인터페이스를 구현하도록 해서 문제를 해결합니다. 템플릿 메서드 패턴과 다르게 상속이 아닌 위임으로 문제를 해결하는 것입니다.
전략 패턴에서 Context
는 변하지 않는 템플릿 역할을 하고, Strategy
는 변하는 알고리즘 역할을 합니다.
GOF 디자인 패턴에서 정의한 전략 패턴의 의도는 다음과 같습니다.
알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들자. 전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.
동일한 예제 코드를 전략 패턴을 사용하여 변하는 부분과 변하지 않는 부분을 분리해 보겠습니다.
public interface Strategy {
void call();
}
변하는 알고리즘의 역할을 하는 Strategy
인터페이스를 생성합니다.
@Slf4j
public class StrategyLogic1 implements Strategy {
@Override
public void call() {
log.info("비즈니스 로직1 실행");
}
}
@Slf4j
public class StrategyLogic2 implements Strategy {
@Override
public void call() {
log.info("비즈니스 로직2 실행");
}
}
변하는 알고리즘은 Strategy
인터페이스를 StrategyLogic1
StrategyLogic2
클래스에서 구현합니다.
@Slf4j
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);
}
}
ContextV1
은 변하지 않는 로직을 가지고 있는 템플릿 역할을 하는 코드입니다. 전략 패턴에서는 이것을 컨텍스트라고 합니다.
쉽게 이야기해서 컨텍스트는 크게 변하지 않지만, 그 문맥 속에서 strategy
를 통해 일부 전략이 변경된다 생각하면 됩니다. 전략 패턴을 사용한 구조는 다음과 같습니다.
void logic() {
StrategyLogic1 strategyLogic1 = new StrategyLogic1();
ContextV1 context1 = new ContextV1(strategyLogic1);
context1.execute();
StrategyLogic2 strategyLogic2 = new StrategyLogic2();
ContextV1 context2 = new ContextV1(strategyLogic2);
context2.execute();
}
Context
는 내부에 Strategy strategy
필드를 가지고 있다. 이 필드에 변하는 부분인 Strategy
의 구현체를 주입하면 됩니다.
전략 패턴의 핵심은 Context
는 Strategy
인터페이스에만 의존한다는 점입니다. 덕분에 Strategy
의 구현체를 변경하거나 새로 만들어도 Context
코드에는 아무런 영향을 주지 않습니다.
스프링의 DI(의존관계 주입)에서 사용하는 방식이 바로 전략 패턴입니다.
전략 패턴의 실행과정을 그림으로 나타내면 다음과 같습니다.
Context
에 원하는 Strategy
구현체를 주입합니다.
클라이언트는 Context
를 실행합니다.
Context
는 execute()
메서드를 호출합니다.
Context
의 execute()
메서드 중간에 strategy.call()
메서드를 호출해서 주입 받은 strategy
의 로직을 실행합니다.
Context
는 나머지 로직을 실행합니다.
위는 전략 패턴은 전략을 Context의 필드에 선언하여, Context 클래스가 생성될 때 초기화되도록 하였습니다. 이를 선 조립, 후 실행 이라고도 합니다.
이는 필요한 전략이 실행 시점에 반드시 존재한다는 것을 보장받을 수 있습니다. 스프링 프레임워크가 실행 시점에 모든 빈 의존관계를 설정하는 것과 같은 원리입니다.
하지만, 지금 우리가 원하는 것은 애플리케이션 의존 관계를 설정하는 것처럼 선 조립, 후 실행이 아니라 단순히 코드를 실행할 때 변하지 않는 템플릿이 있고, 그 템플릿 안에서 원하는 부분만 살짝 다른 코드를 실행하는 것입니다.
어떻게 실행 시점에 유연하게 전략을 변경할 수 있을까?
Context
에 setter
메서드를 제공해서Strategy
를 넘겨받아 변경하면 되지만, Context
를 싱글톤으로 사용할 때는 동시성 이슈 등 고려할 점이 많고, Context
객체를 새로 생성하여 전략을 바꾸는 것은 비용이 커진다는 문제가 있습니다.
위 고민은 전략의 위치를 Context
의 필드가 아닌, 실제로 사용하는 메서드의 파라미터로 변경을 통해 해결할 수 있습니다.
@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);
}
}
void logic() {
ContextV2 context = new ContextV2();
context.execute(new StrategyLogic1());
context.execute(new StrategyLogic2());
}
위 코드는 Context
와 Strategy
를 선 조립 후 실행하는 방식이 아니라 Context
를 실행할 때마다 전략을 인수로 전달합니다.
이와 같이 코드를 수정하면, 클라이언트는 Context
를 실행하는 시점에 원하는 Strategy
를 전달할 수 있습니다. 따라서 이전 방식과 비교해서 원하는 전략을 더욱 유연하게 변경할 수 있습니다.
또한, 하나의 Context
만 생성하고 하나의 Context
에 실행 시점에 여러 전략을 인수로 전달해서 유연하게 실행하는 것을 확인할 수 있습니다.
실행과정을 그림으로 나타내면 다음과 같습니다.
클라이언트는 Context
를 실행하면서 인수로 Strategy
를 전달합니다.
Context
는 execute()
메서드를 실행합니다.
Context
는 파라미터로 넘어온 Strategy
의 call()
메서드를 실행합니다.
Context
의 execute()
메서드가 종료됩니다.
앞서 설명했던 두 패턴은 SubClassLogic1
SubClassLogic2
혹은 StrategyLogic1
StrategyLogic2
처럼 구현 클래스를 계속 만들어야 하는 단점이 있습니다.
하지만 익명 내부 클래스를 사용하면 이런 단점을 보완할 수 있습니다.
익명 내부 클래스를 사용하면 객체 인스턴스를 생성하면서 동시에 생성할 클래스를 상속 받은 자식 클래스를 정의할 수 있습니다.
void logic() {
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 실행");
}
});
}
전략 패턴의 인터페이스의 메서드가 1개만 있으면, 익명 내부 클래스를 자바 8부터 제공하는 람다로 변경할 수 있습니다.
void logic() {
ContextV2 context = new ContextV2();
context.execute(() -> log.info("비즈니스 로직1 실행"));
context.execute(() -> log.info("비즈니스 로직2 실행"));
}
ContextV1
은 필드에 Strategy
를 선언하는 방식으로 전략 패턴을 구현했습니다.
선 조립, 후 실행 방법에 적합합니다.
Context
를 실행하는 시점에는 이미 조립이 끝났기 때문에 전략을 신경쓰지 않고 단순히 실행만 하면됩니다.
ContextV2
는 파라미터에 Strategy
를 전달받는 방식으로 전략 패턴을 구현했습니다.
실행할 때 마다 전략을 유연하게 변경할 수 있습니다.
실행 시점에 이 전략이 존재하는지에 대해서 보장받을 수는 없습니다.
사실 템플릿 콜백 패턴은 이미 앞서 설명한 전략 패턴의 변형된 형태로, 전략 패턴 + 익명 내부 클래스를 의미합니다.
전략 패턴에서는 변화하는 부분을 구현하기 위해 각각의 알고리즘에 대해 구체 클래스를 생성하고, 이를 외부에서 주입받아 사용했습니다.
그러나 템플릿 콜백 패턴에서는 변화하는 부분을 독립된 클래스로 만들 필요 없이 익명 내부 클래스를 사용하여 필요한 로직을 구현합니다. 이를 통해 클래스를 별도로 정의하지 않고도 간단하게 로직을 변경할 수 있습니다.
스프링 프레임워크에서는 ContextV2
와 유사한 방식의 전략 패턴을 템플릿 콜백 패턴이라고 합니다. 여기서 Context
가 템플릿 역할을 하고, Strategy
부분이 콜백으로 전달됩니다.
스프링에서 제공하는 여러 템플릿 클래스(JdbcTemplate
RestTemplate
TransactionTemplate
RedisTemplate
등)는 템플릿 콜백 패턴을 기반으로 만들어졌습니다. 따라서, 이름에 XxxTemplate
가 포함되어 있다면 템플릿 콜백 패턴으로 구현되었음을 의미합니다.
아래는 템플릿 콜백 패턴의 예시 코드입니다:
public interface Callback {
void call();
}
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 실행");
}
});
}
위 코드에서 TimeLogTemplate
클래스는 변하지 않는 템플릿 로직을 정의하고, Callback
인터페이스를 통해 변하는 부분을 받아 실행합니다. 익명 내부 클래스를 사용하여 Callback
인터페이스를 구현함으로써 비즈니스 로직을 간단하게 변경할 수 있습니다.
템플릿 콜백 패턴은 전략 패턴의 ContextV2
와 내용이 같고, 아래와 같이 단지 이름만 다를 뿐입니다.
Context
→ Template
Strategy
→ Callback
템플릿 역할
변하지 않는 로직을 정의하는 부분입니다. 이 부분에서는 고정된 알고리즘의 구조를 정의하고, 중간에 콜백 메서드를 호출하여 변하는 부분을 실행합니다.
콜백 역할
변하는 로직을 정의하는 부분입니다. 템플릿 메서드 내에서 호출되며, 필요한 구체적인 로직을 구현합니다.
스프링에서 자주 사용되는 JdbcTemplate
을 통해 템플릿 콜백 패턴의 구조를 설명하겠습니다.
public class JdbcTemplate {
public <T> T execute(ConnectionCallback<T> action) throws DataAccessException {
try {
// 데이터베이스 연결 생성
Connection connection = createConnection();
// 콜백 메서드 호출
return action.doInConnection(connection);
} catch (SQLException ex) {
throw new DataAccessException("DB error", ex);
}
}
}
public interface ConnectionCallback<T> {
T doInConnection(Connection connection) throws SQLException;
}
JdbcTemplate jdbcTemplate = new JdbcTemplate();
String result = jdbcTemplate.execute(new ConnectionCallback<String>() {
@Override
public String doInConnection(Connection connection) throws SQLException {
// 데이터베이스 로직 구현
return "Query Result";
}
});
위 코드에서 JdbcTemplate
은 템플릿 역할을 하며, ConnectionCallback
은 콜백 역할을 합니다. JdbcTemplate
의 execute
메서드는 고정된 템플릿 로직을 실행하며, 중간에 ConnectionCallback
의 doInConnection
메서드를 호출하여 변하는 부분을 처리합니다.
익명 내부 클래스를 사용하여 ConnectionCallback
인터페이스를 구현함으로써 필요한 로직을 간단하게 전달할 수 있습니다.
이처럼 템플릿 콜백 패턴을 사용하면 코드의 재사용성을 높이고, 변하는 부분과 변하지 않는 부분을 명확히 분리할 수 있습니다.
이 세 가지 디자인 패턴은 공통적으로 변경되는 부분과 변경되지 않는 부분을 분리하여 코드의 유연성과 재사용성을 높이는 데 중점을 두고 있습니다.
템플릿 메서드 패턴은 상속을 통해 알고리즘의 구조를 정의하고, 세부 단계는 하위 클래스에서 구현합니다.
전략 패턴은 알고리즘을 인터페이스로 캡슐화하여 다양한 구현체를 주입받아 실행 시점에 동적으로 변경할 수 있습니다.
템플릿 콜백 패턴은 전략 패턴과 익명 내부 클래스를 결합하여 구현 클래스를 따로 만들 필요 없이 간편하게 로직을 정의할 수 있습니다.