스프링에서 사용되는 디자인 패턴인 템플릿 메서드 패턴, 전략 패턴, 템플릿 콜백 패턴에 대해 알아보려고 합니다.
이들을 적용하는 이유는 좋은 설계를 위해서입니다. 좋은 설계란 요구 부가 기능(핵심 기능의 보조 기능)을 변경하지 않고 핵심 기능만 변경하게 설계하는 것입니다.(중복된 로직을 변경하지 않고, 변하는 것(비즈니스 로직)만 순수하게 바꾸어주는 것)
우선 좋은 설계에 한 발짝 나아갈 수 있는 템플릿 메서드 패턴에 대해 설명드리고 이와 비슷한 기능을 하고 있지만, 템플릿 메서드 패턴의 단점을 보완할 수 있는 전략 패턴, 전략 패턴에서의 템플릿과 콜백 부분을 강조한 템플릿 콜백 패턴까지 설명드리려고 합니다.
GOF 템플릿 메서드 패턴 정의
부모 클래스에서의 템플릿을 정의하고, 일부 변경되는 로직을 자식 클래스에 정의하는 것입니다. 쉽게 설명드리면 templateMethod()는 변하지 않는 메서드(핵심 기능)이고, primitive1(), primitive2()는 변하는 메서드(부가 기능)입니다. 이들을 분리할 수 있다는 장점이 있지만, 부모 클래스에 강하게 의존한다는 단점도 존재합니다. 코드로 설명드리겠습니다.
@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();
}
코드를 보시면, 변하지 않은 부분인 execute 메서드에 코드가 밀집되어 있고, 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 실행");
}
}
execute() 메서드를 호출하면, 템플릿 로직인 AbstractTemplate.execute()를 실행하고, 중간에 call() 메서드를 호출하게 됩니다. 결론적으로 역할과 구현을 구분할 수 있고, 다형성을 이용해 비즈니스 로직과 부가 기능을 구분할 수 있게 됩니다.
익명 내부 클래스 적용
익명 내부 클래스란, 클래스를 직접 만들지 않고, 클래스 내부에 선언되는 클래스로서 객체 인스턴스를 생성하면서 동시에 생성할 클래스를 상속받은 자식 클래스를 정의할 수 있습니다(인스턴스 내부에서 메서드 재정의가 가능해집니다). 더불어 여러 클래스를 만들어야 하는 단점을 보완할 수 있습니다. 클래스 이름은 클래스이름 $1로 자바에서 임의로 만들어줍니다.
@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());
template2.execute();
}
하지만, 템플릿 메서드 패턴은 상속을 사용하기에 부모 클래스의 기능을 사용하지 않더라도, 만들어준 자식 클래스에서 반드시 부모 클래스의 메서드를 호출해야 하는 단점이 있습니다.
이러한 의존관계에 대한 문제점, 상속의 단점을 보완해줄 수 있는 디자인 패턴은 전략 패턴(Strategy Pattern)입니다.
전략 패턴(Strategy Pattern)
이는 인터페이스에만 의존하고 스프링 DI에서 사용하는 방식입니다. 전략 패턴은 변하지 않는 부분을 Context라는 곳에 두고, 변하는 부분을 Strategy라는 인터페이스를 만들고 해당 인터페이스를 구현해서 문제를 해결합니다. 템플릿 메서드 패턴과 달리 상속이 아닌 위임으로 좋은 설계를 할 수 있게 됩니다.
// interface
public interface Strategy {
void call();
}
// implements
@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 실행");
}
}
// Test1
@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);
}
// Test2
@Test
void strategyV1() {
StrategyLogic1 strategyLogic1 = new StrategyLogic1();
ContextV1 context1 = new ContextV1(strategyLogic1);
context1.execute();
StrategyLogic2 strategyLogic2 = new StrategyLogic2();
ContextV1 context2 = new ContextV1(strategyLogic2);
context2.execute();
}
// Test3
@Test
void strategyV2() {
Strategy strategyLogic1 = new Strategy() {
@Override
public void call() {
log.info("비즈니스 로직1 실행");
}
};
ContextV1 context1 = new ContextV1(strategyLogic1);
log.info("strategyLogic1={}",strategyLogic1.getClass());
context1.execute();
Strategy strategyLogic2 = new Strategy() {
@Override
public void call() {
log.info("비즈니스 로직2 실행");
}
};
ContextV1 context2 = new ContextV1(strategyLogic2);
log.info("strategyLogic2={}",strategyLogic2.getClass());
context2.execute();
}
}
전략 패턴에서는 템플릿 코드가 Context가 되고, 변하는 부분은 Strategy가 됩니다. Strategy의 구현체 주입을 통해 설계하였기에, Context 코드는 아무런 영향을 주지 않게 됩니다. 스프링으로 애플리케이션을 개발할 때 애플리케이션 로딩 시점에 의존관계 주입을 통해 필요한 의존관계를 모두 맺어두고 난 다음에 실제 요청을 처리하는 것과 같은 원리입니다.
이 방식의 단점은 Context와 Strategy를 조립한 이후는 전략을 변경하기가 어렵다는 점입니다. 스프링 DI에서 생성자 주입과 같은 맥락으로 바라보시면 좋을 것 같습니다. setter를 이용해서 Strategy를 넘겨받아 변경하면 되지만, Context를 싱글톤으로 사용할 경우에는 동시성 문제를 피할 수 없게 됩니다.
그래서 이러한 우려사항들을 막기 위해, 위의 Test2 코드와 같이 Context를 하나 더 생성하고 다른 Strategy를 주입 받는 것이 더 나은 방안이 될 수 있습니다.
전략을 필드로 같지 않고, execute(Strategy strategy)와 같이, 파라미터로 전달받는 경우도 있습니다. 이전에 필드로 갖는 경우와 비교해 보면, Client는 Context를 실행하는 시점에 원하는 Strategy를 전달할 수 있기에 더욱 유연하게 전략을 변경할 수 있게 됩니다.
템플릿 콜백 패턴(Template Callback Pattern)
프로그래밍에서 콜백은 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 의미합니다. 콜백을 받은 코드는 필요에 따라 실행하거나 나중에 실행할 수도 있습니다.(wiki)
위에 설명드린 strategy를 필드로 받는 경우가 아닌, 파라미터로 받는 경우를 템플릿 콜백 패턴으로 바라볼 수 있습니다. Context가 템플릿, Strategy는 콜백의 관점으로 바라볼 수 있습니다.
Spring에서는 JdbcTemplate, RestTemplate, RedisTemplate 들은 모두 템플릿 콜백 패턴으로 만들어졌습니다.
@RestController
public class OrderControllerV5 {
private final OrderServiceV5 orderService;
private final TraceTemplate template;
public OrderControllerV5(OrderServiceV5 orderService, LogTrace trace) {
this.orderService = orderService;
this.template = new TraceTemplate(trace);
}
@GetMapping("/v5/request")
public String request(String itemId) {
return template.execute("OrderController.request()", new TraceCallback<>() {
@Override
public String call() {
orderService.orderItem(itemId);
return "ok";
}
});
}
}
@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);
}
}
// 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 실행"));
}
}
trace 의존관계 주입을 받으면서 템플릿을 생성한다는 점에서 이전의 디자인 패턴들과 차이가 있습니다. 테스트시에 템플릿 실행시 콜백을 인수에 전달받아 시행하는 것을 볼 수 있습니다. callbackV2처럼 람다식을 적용하기 위해서는 인터페이스의 메서드가 1개만 존재해야 합니다.
다음과 같이 스프링에서 자주 사용되고, 좋은 설계(변하지 않는 영역과 변하는 영역을 구분)를 위한 디자인 패턴을 알아보았습니다. 다음 포스팅에서는 프록시 패턴과 데코레이터 패턴에 대해 알아보도록 하겠습니다.
<참고자료>
[스프링 핵심원리 - 고급편]
'Java > Spring' 카테고리의 다른 글
[Spring] Spring AOP (0) | 2023.08.15 |
---|---|
[Spring] Proxy Pattern, Decorator Pattern (0) | 2023.08.15 |
[Spring] DI, IoC 컨테이너 (0) | 2023.08.03 |
[Spring] SOLID 원칙에 대해 알아보자 (0) | 2023.08.03 |
[Spring] CORS(Cross-Origin Resource Sharing) 에러 해결 (2) | 2023.05.17 |