https://codenme.tistory.com/105
스프링 핵심원리-고급편 2 [쓰레드 로컬을 통한 동시성 문제해결]
https://codenme.tistory.com/104 스프링 핵심원리-고급편 1 [예제 생성 및 요구사항 이해] https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard 스프링 핵
codenme.tistory.com
위 글에 이어지는 내용입니다.
템플릿 메서드 패턴
where: 쓰레드 로컬로 동시성 문제 해결
로그 추적기에서 여전히 남은 문제점- Controller에서 로직과 관련없는, 로그 코드 너무 많다.( 배보다 배꼽이 크다.)
@GetMapping("/v3/request")
public String request(String itemId) {
TraceStatus status = null;
try {
status = trace.begin("OrderController.request()");
orderService.orderItem(itemId); //핵심 기능(나머지는 로그 추적기 기능임)
trace.end(status);
} catch (Exception e) {
trace.exception(status, e);
throw e; }
return "ok";
}
핵심 기능: 해당 객체가 제공하는 "고유의 기능"
부가 기능: 핵심 기능을 보조하는 기능( 트랜잭션, 로그 추적기 등등)
로그 추적기의 경우, 부가 기능이 아래와 같은 일관된 패턴으로 동작하게 된다.
TraceStatus status = null;
try {
status = trace.begin("message");
//핵심 기능 호출
trace.end(status);
} catch (Exception e) {
trace.exception(status, e);
throw e; }
좋은 설계는 변하는 것과 변하지 않는 것을 분리하는 것이다.
핵심 기능 부분은 변하고, 로그 추적기를 사용하는 부분은 변하지 않는 부분이다.
우리는 이러한 공통된 중복 코드를, 템플릿 메서드 패턴을 통해 해결할 수 있다.
템플릿 메서드 디자인 패턴의 목적
"작업에서 알고리즘의 골격을 정의하고 일부 단계를 하위 클래스로 연기합니다. 템플릿 메서드를 사용하면
하위 클래스가 알고리즘의 구조를 변경하지 않고도 알고리즘의 특정 단계를 재정의할 수 있습니다." [GOF]
풀어서 설명하면 다음과 같다.
부모 클래스에 알고리즘의 골격인 템플릿을 정의하고, 일부 변경되는 로직은 자식 클래스에 정의하는 것이다. 이렇게 하면 자식 클래스가 알고리즘의 전체 구조를 변경하지 않고, 특정 부분만 재정의할 수 있다. 결국 상속과 오버라이딩을 통한 다형성으로 문제를 해결하는 것이다.
템플릿 메서드 패턴- 예제
execute(): 변하지 않는 영역
call(): 변하는 영역.
추상 클래스를 통해 변하지 않는 영역을 구현해놓고, 변하는 곳은 선언만 하여 자신을 상속받는 클래스가 구현하게 함으로써
중복되는 부분은 추가 구현 없이 가져오고, 변경되는 부분은 상속받은 뒤 구현하는 것.
만약 새로운 로직 3 가 나온다면, AbstractTemplate을 상속받은 뒤, call() 부분에 새로운 로직을 overRide하기만하면, 공통된 execute() 부분은 추가적인 구현 없이 사용 가능.
우선, 템플릿 메서드 패턴이 없는 기본적인 예제 로직을 살펴보자.
private void logic1() {//핵심 기능, 부가 기능이 섞여있다.
long startTime = System.currentTimeMillis();
//비즈니스 로직 실행
log.info("비즈니스 로직1 실행");
//비즈니스 로직 종료
//이 아래의 시간을 측정하는 로직은, 변하지 않는, 중복된 로직이다.
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
위와 같이, 비즈니스 로직은 변하는 로직이고 시간 측정 로직은 변하지 않는 로직이다.
이제 템플릿 메서드 패턴을 사용해보자.
추상 클래스
변하지 않는 execute()는 구현하고, 변하는 call() 부분은 선언만 한다.
@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() 부분은 구현한다.
@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()는 추가 구현 없이, 변화하는 call() 부분만 구현 가능하다.
이제 테스트 해보자.
@Test
void templateMethodV1() {
AbstractTemplate template1 = new SubClassLogic1();
template1.execute();
AbstractTemplate template2 = new SubClassLogic2();
template2.execute();
}
하지만, 이렇게 템플릿 메소드만 사용하면,
SubClassLogic1 , SubClassLogic2,...... 처럼 로직을 추가할 때마다 클래스를 계속해서 새로 만들어야하는 단점이 있다.
하지만 템플릿 메소드 패턴에 익명 내부 클래스를 응용하면 이런 단점을 보완 가능하다.
익명 내부 클래스: 객체 인스턴스를 생성함과 동시에 해당 클래스를 상속 받은 자식 클래스를 정의할 수 있다.
SubClassLogic1와 같이 이름을 지정하지 않고, 클래스 내부에 선언되는 클래스라 익명 내부 클래스라 한다.
익명 내부 클래스를 사용하면 객체 인스턴스를 생성하면서 동시에 생성할 클래스를 상속 받은 자식 클래스를 정의할 수 있다.
이를 사용해서, 추상 클래스를 상속 받아 call() 을 구현하는 클래스를 추가하는 대신, 사용하는 곳에서 그때 그때
정의하여 사용할 수 있다.
@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();
}
}
이제, 별도로 SubClassLogic1 , SubClassLogic2,.... 처럼 클래스를 미리 정의할 필요 없이, 익명 클래스로 사용하는 곳에서
그때 그때 입맛대로 정의해서 사용하면 된다.
실행 결과를 보면 자바가 임의로 만들어주는 익명 내부 클래스 이름은 TemplateMethodTest$1 , TemplateMethodTest$2 인 것을 확인할 수 있다.
이제 템플릿 메소드 패턴을 로그 추적기에 적용해보자.
로그 추적기- 템플릿 메소드 패턴 적용
제네릭을 사용해서, call() 이라는 각기 다른 메서드들의 리턴 타입이 고정적이지 않게 만들어준다.
예를 들어, A 로직은 String 을 리턴해야할 경우, 제네릭을 통해 String 리턴 값으로 사용하고,
B로직은 int 리턴을 해야할 경우, 제네릭을 통해 int 리턴 값으로 사용할 수 있다.
execute는 각기 다른 로직을 공통적으로 사용해야하는 try-catch 문으로 감싸는 역할을 수행한다.
따라서 execute, call은 각 로직의 리턴 타입에 맞게 제네릭 리턴 타입을 사용했다.
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) {
trace.exception(status, e);
throw e;
}
}
protected abstract T call();
}
- AbstractTemplate 은 템플릿 메서드 패턴에서 부모 클래스이고, 템플릿 역할을 한다.
- 객체를 생성할 때 내부에서 사용할 LogTrace trace 를 전달 받는다.
- 로그에 출력할 message 를 외부에서 파라미터로 전달받는다.
- 템플릿 코드 중간에 call() 메서드를 통해서 변하는 부분을 처리한다.
- abstract T call() 은 변하는 부분을 처리하는 메서드이다. 이 부분은 상속으로 구현해야 한다.
이제, call() 메서드를, 익명함수를 사용하는 것으로 간단하게 템플릿 메서드 패턴을 사용할 수 있게 되었다.
기존 코드
@GetMapping("/v3/request")
public String request(String itemId) {
TraceStatus status = null;
try {
status = trace.begin("OrderController.request()");
orderService.orderItem(itemId); //핵심 기능(나머지는 로그 추적기 기능임)
trace.end(status);
} catch (Exception e) {
trace.exception(status, e);
throw e; }
return "ok";
}
템플릿 메서드, 익명 함수를 사용한 새로운 코드
@RestController
@RequiredArgsConstructor
public class OrderControllerV4 {
private final OrderServiceV4 orderService;
private final LogTrace trace;
@GetMapping("/v4/request")
public String request(String itemId) {
AbstractTemplate<String> template = new AbstractTemplate<>(trace) {
@Override
protected String call() {
orderService.orderItem(itemId);
return "ok";
}
};
return template.execute("OrderController.request()");
}
}
기존에는 모든 컨트롤러 마다 try-catch를 중복해서 구현해야 했지만,
1) 템플릿 메소드 패턴을 사용하는 것으로 중복 코드 문제를 해결했고
2) 익명 함수를 통해 각 컨트롤러마다(각 컨트롤러는 서로 다른 로직, call()을 사용) AbstractTemplate에 대한 새로운 구현 클래스를 만들어야 하는 문제를 해결했다.
**참고: 제네릭에서 반환 타입이 필요한데, AbstractTemplate<Void>와 같이 반환할 내용이 없으면 Void 타입을 사용하고 null 을 반환하면 된다. 참고로 제네릭은 기본타입인 void,int 등을 선언할 수 없다.
여기서 void 와 Void는 엄연히 다르다.
void -> return;
Void -> return null;
리턴 타입이 없을 때, 사용 예시( 리포지 토리 영역 )
public void save(String itemId) {
AbstractTemplate<Void> template = new AbstractTemplate<>(trace) {
@Override
protected Void call() {
//저장 로직
if (itemId.equals("ex")) {
throw new IllegalStateException("예외 발생!");
}
sleep(1000);
return null;
}
};
template.execute("OrderRepository.save()");
}
이와 같이 템플릿 메소드를 적용하는 것으로, 불변 코드와 가변 코드를 명확히 분리하여 핵심 기능에 집중할 수 있게 되었다.
좋은 설계란?
좋은 설계라는 것은 무엇일까? 수 많은 멋진 정의가 있겠지만, 진정한 좋은 설계는 바로 변경이 일어날 때 자연스럽게 드러난다.
지금까지 로그를 남기는 부분을 모아서 하나로 모듈화하고, 비즈니스 로직 부분을 분리했다. 여기서 만약 로그를 남기는 로직을 변경해야 한다고 생각해보자. 그래서 AbstractTemplate 코드를 변경해야 한다 가정해보자. 단순히 AbstractTemplate 코드만 변경하면 된다.
템플릿이 없는 V3 상태에서 로그를 남기는 로직을 변경해야 한다고 생각해보자. 이 경우 모든 클래스를 다 찾아서 고쳐야 한다.
단일 책임 원칙(SRP)
이는 단순히 템플릿 메서드 패턴을 적용해서 소스코드 몇줄을 줄인 것이 전부가 아니다.
로그를 남기는 부분에 단일 책임 원칙(SRP)을 지킨 것이다. 변경 지점을 하나로 모아서 변경에 쉽게 대처할 수 있는 구조를 만든 것이다.
하지만
템플릿 메서드 패턴은 상속을 사용한다. 따라서 상속에서 오는 단점들을 그대로 안고간다.
특히 자식 클래스가 부모 클래스와 컴파일 시점에 강하게 결합되는 문제가 있다.
이것은 의존관계에 대한 문제이다. 자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않는다.
이번 글에서 지금까지 작성했던 코드를 떠올려보자. 자식 클래스를 작성할 때 부모 클래스의 기능을 사용한 것이 있었던가?
그럼에도 불구하고 템플릿 메서드 패턴을 위해 자식 클래스는 부모 클래스를 상속 받고 있다(extends).
상속을 받는 다는 것은 특정 부모 클래스를 의존하고 있다는 것이다. 자식 클래스의 extends 다음에 바로 부모 클래스가 코드상에 지정되어 있다. 따라서 부모 클래스의 기능을 사용하든 사용하지 않든 간에 부모 클래스를 강하게 의존하게 된다. 여기서 강하게 의존한다는 뜻은 자식 클래스의 코드에 부모 클래스의 코드가 명확하게 적혀 있다는 뜻이다. UML에서 상속을 받으면 삼각형 화살표가 자식 -> 부모 를 향하고 있는 것은 이런 의존관계를 반영하는 것이다.
자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않는데, 부모 클래스를 알아야한다. 이것은 좋은 설계가 아니다. 그리고 이런 잘못된 의존관계 때문에 부모 클래스를 수정하면, 자식 클래스에도 영향을 줄 수 있다.
추가로 템플릿 메서드 패턴은 상속 구조를 사용하기 때문에, 별도의 클래스나 익명 내부 클래스를 만들어야 하는 부분도 복잡하다.
요약하자면 추상 클래스인 AbstactTemplate을 extends하게 되면, 자식 클래스는 상속(extends)한 AbstactTemplate의 기능을
사용하지 않는데도 불구하고, AbstactTemplate와 강한 결합이 생기고, AbstactTemplate의 모든 코드를 알게되는 문제가 생긴다.
지금까지 설명한 이런 부분들을 더 깔끔하게 개선하려면 어떻게 해야할까?
템플릿 메서드 패턴과 비슷한 역할을 하면서 상속의 단점을 제거할 수 있는 디자인 패턴이 바로 전략 패턴 (Strategy Pattern)이다.
전략 패턴에 들어가기 전에:
자바 상속은 다음과 같은 단점을 가지고 있다.
- 다중 상속 불가능: 자바는 단일 상속만을 지원하므로 하나의 클래스에서만 상속을 받을 수 있습니다. 이로 인해 여러 개의 부모 클래스로부터 상속받아야 하는 경우에는 상속의 한계가 발생합니다. 다중 상속이 필요한 경우 인터페이스를 사용하여 일부 기능을 구현할 수는 있지만, 클래스의 모든 특성을 상속받는 것은 불가능합니다.
- 상속의 불필요한 결합: 상속은 부모 클래스와 자식 클래스 간에 강한 결합을 생성합니다. 자식 클래스는 부모 클래스의 내부 구현에 의존하며, 부모 클래스의 변경이 자식 클래스에도 영향을 미칠 수 있습니다. 이로 인해 클래스 간의 결합도가 높아지고 유연성이 저하될 수 있습니다.
- 상속의 제한된 확장성: 상속은 정적인 관계를 형성하기 때문에 실행 시간에 동적으로 동작을 변경하기 어렵습니다. 새로운 기능을 추가하거나 기존 기능을 수정하려면 상속 계층 구조 전체를 변경해야 할 수도 있습니다. 이는 소프트웨어의 확장성을 제한할 수 있습니다.
이러한 문제점들은 객체 지향 설계 원칙 중 하나인 "의존성 역전 원칙(Dependency Inversion Principle)"을 통해 완화될 수 있다.
상속을 대체하는 구성(Composition)과 인터페이스를 활용하여 유연하고 확장 가능한 코드를 작성하는 것이 좋다.
DIP: 추상화(인터페이스, 추상 클래스)에 의존해야 하며, 구체화(구현 클래스)에 의존해서는 안된다.
DIP를 준수하여 자바 상속의 문제를 해결하면서 기존의 중복 코드와 관심사 분리를 해결하기 위해 사용하는 패턴이 바로
전략 패턴이다.
전략 패턴
전략 패턴- 예제
탬플릿 메서드 패턴: 부모 클래스에 변하지 않는 템플릿을 두고, 변하는 부분을 자식 클래스에 두어서 상속을 사용해서 문제를 해결했다.
전략 패턴: 변하지 않는 부분을 Context 라는 곳에 두고, 변하는 부분을 Strategy 라는 인터페이스를 만들고 해당 인터페이스를 구현하도록 해서 문제를 해결한다. 상속(extends)이 아니라 위임(implements)으로 문제를 해결하는 것이다.
![](https://blog.kakaocdn.net/dn/M4hz2/btsjmvgHuNs/FuiAUABeeIAdiBQ7lsjqK0/img.png)
전략 패턴에서 Context 는 변하지 않는 템플릿 역할을 하고, Strategy 는 변하는 알고리즘 역할을 한다.
GOF 디자인 패턴에서 정의한 전략 패턴의 의도는 다음과 같다.
- 알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들자. 전략을 사용하면 알고리즘을
사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.
전략패턴을 사용해서 템플릿 메서드 패턴을 대체 해보자.
인터페이스
public interface Strategy {
void call();
}
비즈니스 로직 1
@Slf4j
public class StrategyLogic1 implements Strategy {
@Override
public void call() {
log.info("비즈니스 로직1 실행");
}
}
비즈니스 로직 2
@Slf4j
public class StrategyLogic2 implements Strategy {
@Override
public void call() {
log.info("비즈니스 로직2 실행");
}
}
Context
strategy 인터페이스를 주입받고, execute() 메서드로 공통 부분을 구현한 뒤,
stretegy.call()을 통해 인터페이스의 구현체를 실행한다.
@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 를 통해 일부 전략이 변경된다 생각하면 된다.
Context 는 내부에 Strategy strategy 필드를 가지고 있다. 이 필드에 변하는 부분인 Strategy 의 구현체를 주입하면 된다.
전략 패턴의 핵심은 Context 는 Strategy 인터페이스에만 의존한다는 점이다.
덕분에 Strategy 의 구현체를 변경하거나 새로 만들어도 Context 코드에는 영향을 주지 않는다.
-> 스프링 의존관계 주입에서도 전략 패턴을 사용한다!
이제 위와 같은 전략 패턴을 사용하는 코드를 테스트 코드로 작성해보자.
@Test
void strategyV1() { //생성자로 Stretegy인터페이스의 구현체를 주입받아서 사용한다.
//컨텍스트는, 공통 로직을 처리하는데, 가변적인 영역을 이 Stretegy인터페이스의 구현체를 동적으로 받는 것으로 처리한다.
StrategyLogic1 strategyLogic1 = new StrategyLogic1();
ContextV1 context1 = new ContextV1(strategyLogic1); //컨텍스트에, 가변적인 stretegy 로직을 넣어서 생성한다.
context1.execute();
StrategyLogic2 strategyLogic2 = new StrategyLogic2();//컨텍스트에, 가변적인 stretegy 로직을 넣어서 생성한다.
ContextV1 context2 = new ContextV1(strategyLogic2);
context2.execute();
}
1. Context 에 원하는 Strategy 구현체를 주입한다.
2. 클라이언트는 context 를 실행한다.
3. context 는 context 로직을 시작한다.
4. context 로직 중간에 strategy.call() 을 호출해서 주입 받은 strategy 로직을 실행한다.
5. context 는 나머지 로직을 실행한다.
Context( 공통 부분) 이 바뀌더라도, Context만 바꾸면 되고, 가변적인 부분은 인터페이스(추상화)에 의존 하는 것으로 DIP도 지켜진다.
상속 -> 위임 사용을 통해 상속의 고질적인 단점을 해결함.
전략 패턴- 익명 내부 클래스 사용
이번엔 위의 전략 패턴을 익명 내부 함수를 통해 단순화하고, 불필요한 변수를 제거, 람다식 적용을 통해 더욱 심플하게 만들어보자.
1) 익명 함수 적용하기
@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 = () -> log.info("비즈니스 로직2 실행");//대체된 람다식
ContextV1 context2 = new ContextV1(strategyLogic2);
log.info("strategyLogic2={}", strategyLogic2.getClass());
context2.execute();
}
2) 굳이 Stretegy 변수를 만들지 말고, Context의 파라미터에 곧바로 할당하여 단순화하는 것이 좋다.
인텔리제이의 인라인 변수 합치는 (단축키 opt + alt + N )
@Test
void strategyV3() {
ContextV1 context1 = new ContextV1(new Strategy() { //이런식으로, 굳이 Strategy를 변수에 할당하지 않고 구현해도 된다.
@Override
public void call() {
log.info("비즈니스 로직1 실행");
}
});
context1.execute();
ContextV1 context2 = new ContextV1(new Strategy() {
@Override
public void call() {
log.info("비즈니스 로직2 실행");
}
});
context2.execute();
}
3) 람다식 사용해서 단일 추상 메서드를 가진 함수형 인터페이스를 간단하게 바꾸기
Stretegy는 call() 이라는 메서드를 1개만 보유한 함수형 인터페이스이기에, 람다식으로 간단하게 축약 가능하다.
단일 추상화 메서드를 보유한 함수형 인터페이스가 람다식으로 축약되는 이유
- new Stretegy -> Context의 파라미터 타입이 Stretegy이기에 타입 추론이 가능하고, 생략 가능
- Stretegy는 call() 이라는 메소드만 보유하기에, 굳이 Override call() {~~~ } 를 작성하지 않고 생략 가능.
@Test
void strategyV4() {
ContextV1 context1 = new ContextV1(() -> log.info("비즈니스 로직1 실행"));
context1.execute();
ContextV1 context2 = new ContextV1(() -> log.info("비즈니스 로직2 실행"));
context2.execute();
}
전략 패턴 정리 (선 조립, 후 실행)
여기서 이야기하고 싶은 부분은 Context 의 내부 필드에 Strategy 를 두고 사용하는 부분이다.
이 방식은 Context 와 Strategy 를 실행 전에 원하는 모양으로 조립해두고, 그 다음에 Context 를 실행하는 선 조립, 후 실행 방식에서 매우 유용하다.
Context 와 Strategy 를 한번 조립하고 나면 이후로는 Context 를 실행하기만 하면 된다.
우리가 스프링으로 애플리케이션을 개발할 때 애플리케이션 로딩 시점에 의존관계 주입을 통해 필요한 의존관계를 모두 맺어두고 난 다음에 실제 요청을 처리하는 것 과 같은 원리이다.
전략 패턴의 단점은 Context 와 Strategy 를 조립한 이후에는 실시간으로 전략을 변경하기가 번거롭다는 점이다.
물론 Context 에 setter 를 제공해서 Strategy 를 넘겨 받아 변경하면 되지만, Context 를 싱글톤으로 사용할 때는 동시성 이슈 등 고려할 점이 많다.
그래서 전략을 실시간으로 변경해야 하면 차라리 이전에 개발한 테스트 코드 처럼 Context 를 하나더 생성하고 그곳에 다른 Strategy 를 주입하는 것이 더 나은 선택일 수 있다.
ex
ContextV1 context1 = new ContextV1(() -> log.info("비즈니스 로직1 실행"));
context1.execute();
//전략 추가 시에, 새로운 컨텍스트 객체를 만들어서 사용
ContextV1 context2 = new ContextV1(() -> log.info("비즈니스 로직2 실행"));
context2.execute();
이렇게 먼저 조립하고 사용하는 방식보다 더 유연하게 전략 패턴을 사용하는 방법은 없을까?
전략 패턴- 개선
이번에는 전략 패턴을 조금 다르게 사용해보자. 이전에는 Context 의 필드에 Strategy 를 주입해서
사용했다. 이번에는 전략을 실행할 때 직접 파라미터로 전달해서 사용해보자.
//private Strategy strategy; //전략을 파라미터로 전달 받는 방식이기에, 더이상 필드를 가질 필요 없다.
@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);
}
}
이제 위에 대한 테스트 코드를 작성한 뒤,
익명 함수 적용 -> 람다식 적용 순으로 적용하여 단순화 해보겠다.
@Test
void strategyV1() {
ContextV2 context = new ContextV2();
context.execute(new StrategyLogic1());
context.execute(new StrategyLogic2());
}
/**
* 전략 패턴 익명 내부 클래스
*/
@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 실행");
}
});
}
/**
* 전략 패턴 익명 내부 클래스2, 람다
*/
@Test
void strategyV3() {
ContextV2 context = new ContextV2();
context.execute(() -> log.info("비즈니스 로직1 실행"));
context.execute(() -> log.info("비즈니스 로직2 실행"));
}
이제 ContextV2 는 전략을 필드로 가지지 않는다. 대신에 전략을 execute(..) 가 호출될 때 마다 항상 파라미터로 전달 받는다.
파라미터로 Stretegy를 전달할 때, 타입 추론이 가능하기에 new Stretegy를 생략 하고, 메소드도 call() 하나 뿐이기에,
@Override call{}; 을 생략 후, 단순히 call에서 수행할 로직만 작성하면 된다.
![](https://blog.kakaocdn.net/dn/NELSJ/btsjPMieU0l/hzzkRF7euwIg2OGllS5kU0/img.png)
1. 클라이언트는 Context 를 실행하면서 인수로 Strategy 를 전달한다.
2. Context 는 execute() 로직을 실행한다.
3. Context 는 파라미터로 넘어온 strategy.call() 로직을 실행한다.
4. Context 의 execute() 로직이 종료된다.
정리
ContextV1 은 필드에 Strategy 를 저장하는 방식으로 전략 패턴을 구사했다.
장점: 한번 조립 한 뒤, 실행만 하면 된다. -> 선 조립, 후 실행 방법에 적합하다.
- Context 를 실행하는 시점에는 이미 조립이 끝났기 때문에 전략을 신경쓰지 않고 단순히 실행만 하면
된다.
단점: 실시간으로 전략을 바꿔야할 경우, 복잡해진다.
ContextV2 는 파라미터에 Strategy 를 전달받는 방식으로 전략 패턴을 구사했다.
장점: 실행할 때 마다 전략을 유연하게 변경할 수 있다.
단점: 실행할 때 마다 전략을 계속 지정해주어야 한다
템플릿
지금 우리가 해결하고 싶은 문제는 변하는 부분과 변하지 않는 부분을 분리하는 것이다.
변하지 않는 부분을 템플릿이라고 하고, 그 템플릿 안에서 변하는 부분에 약간 다른 코드 조각을 넘겨서 실행하는 것이 목적이다.
ContextV1 , ContextV2 두 가지 방식 다 문제를 해결할 수 있지만, 어떤 방식이 조금 더 나아 보이는가?
지금 우리가 원하는 것은 애플리케이션 의존 관계를 설정하는 것 처럼 선 조립, 후 실행이 아니다.
단순히 코드를 실행할 때 변하지 않는 템플릿이 있고, 그 템플릿 안에서 원하는 부분만 살짝 다른 코드를 실행하고 싶을 뿐이다.
따라서 우리가 고민하는 문제( 로그 추적기)는 실행 시점에 유연하게 파라미터로 Stretegy로 실행 코드 조각을 전달하는 ContextV2 가 더 적합하다.
전략 패턴의 범위?
앞서도 말했지만, GOF 디자인 패턴에서 정의한 전략 패턴의 의도는 다음과 같다.
- 알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들자. 전략을 사용하면 알고리즘을
사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.
파라미터를 사용하던, 필드를 통해 의존관계 주입으로 사용하던, 위와 같은 의도는 충족되기에, 모두 전략 패턴으로 볼 수 있다.
템플릿 콜백 패턴
ContextV2 는 변하지 않는 템플릿 역할을 한다.
그리고 변하는 부분은 파라미터로 넘어온 Strategy 의 코드를 실행해서 처리한다. 이렇게 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 콜백(callback)이라 한다.
ex
context.execute(
new Strategy() { // 콜백
@Override// 콜백
public void call() {// 콜백
log.info("비즈니스 로직1 실행");// 콜백
}// 콜백
}// 콜백
);
context.execute(
() -> log.info("비즈니스 로직1 실행")// 콜백
);
콜백 정의
프로그래밍에서 콜백(callback) 또는 콜애프터 함수(call-after function)는 다른 코드의 인수로서
넘겨주는 실행 가능한 코드를 말한다. 콜백을 넘겨받는 코드(ContextV2)는 이 콜백(Stretegy)을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다.
쉽게 말해서, callback은 코드의 call이 되지만, 코드를 넘겨준 곳(back)에서 실행된다는 뜻이다.
자바 언어에서 콜백
자바 언어에서 실행 가능한 코드를 인수로 넘기려면 객체가 필요하다. 자바8부터는 람다를 사용할 수 있다.
자바 8 이전에는 보통 하나의 메소드를 가진 인터페이스를 구현하고, 주로 익명 내부 클래스를 사용했다. 최근에는 주로 람다를 사용한다.
템플릿 콜백 패턴
스프링에서는 ContextV2 와 같은 방식의 전략 패턴을 템플릿 콜백 패턴이라 한다.
전략 패턴에서 Context 가 템플릿 역할을 하고, Strategy 부분이 콜백으로 넘어온다 생각하면 된다.
참고로 템플릿 콜백 패턴은 GOF 패턴은 아니고, 스프링 내부에서 이런 방식을 자주 사용하기 때문에, 스프링 안에서만 이렇게 부른다.
전략 패턴에서 템플릿과 콜백 부분이 강조된 패턴이라 생각하면 된다.
스프링에서는 JdbcTemplate , RestTemplate , TransactionTemplate , RedisTemplate 처럼 다양한 템플릿 콜백 패턴이 사용된다. 스프링에서 이름에 XxxTemplate 가 있다면 템플릿 콜백 패턴으로 만들어져 있다 생각하면 된다.
쉽게 정리하자면, 파라미터에 람다식, 익명 함수등으로 함수를 전달하고, 전달된 곳에서 파라미터로 전달받은 callback() 메소드를 수행하는 것이다.
앞서서 구현한 ContextV2를 이용한전략 패턴에서는 공통부분(ContextV2)에 가변적인 Stretegy를 파라미터로 전달하면서 구현하기에
콜백 패턴이기도 하면서 전략 패턴이기도 하다고 볼 수 있는 것이다.
템플릿 콜백 패턴 - 예제 작성
우리가 처음에 고민했던 로그 추적기는 실행 시점에 유연하게 파라미터로 Stretegy로 실행 코드 조각을 전달하는 ContextV2 가 더 적합하다. 따라서 이를 템플릿 콜백 패턴을 구현해볼 것이다.
실제 구현에 앞서 간단하게 템플릿 콜백 패턴을 구현해보자.
ContextV2 와 내용이 같고 이름만 다르므로 크게 어려움은 없을 것이다.
기존의 Stretegy 인터페이스가 아닌, Callback() 으로 단일 메소드를 가진 함수형 인터페이스를 만들어주자.
public interface Callback {
void call();
}
템플릿 메소드 패턴에서 위와 같이 함수형 인터페이스를 만드는 이유는 뭘까?
1) 타입 안정성(Type safety): 함수형 인터페이스를 사용하면 콜백 메서드의 시그니처를 명시적으로 지정할 수 있습니다. 즉, call() 메서드의 반환 타입과 매개 변수를 인터페이스에 명시함으로써 컴파일러가 타입 검사를 수행할 수 있습니다. 이는 컴파일 시점에서 오류를 잡아내고 안정성을 보장하는 데 도움이 됩니다.
ex: 파라미터에 int 타입을 리턴하는 함수를 전달하고, 이 결과 값이 유저의 나이가 된다고 생각해보자. 만약 파라미터로 들어온 함수가
String 타입이라면, 컴파일 에러를 도출하여 미리 문제를 찾아낼 수 있다.
2) 명시적인 의도 전달: 함수형 인터페이스를 사용하면 코드의 가독성이 향상됩니다. 인터페이스를 정의함으로써 콜백의 역할이 명확하게 전달되며, 이는 코드의 이해와 유지보수를 용이하게 만듭니다.
3) 다중 메서드 지원: 함수형 인터페이스를 사용하면 단일 추상 메서드뿐만 아니라 여러 개의 디폴트 메서드를 추가로 정의할 수 있습니다. 이는 콜백을 구현하는 클래스에서 다양한 동작을 정의할 수 있게 합니다.
함수를 직접 파라미터로 전달하는 것도 가능하지만, 함수형 인터페이스를 사용하는 것은 가독성과 타입 안정성을 높이는 장점이 있습니다. 함수형 인터페이스는 함수형 프로그래밍 패러다임과 자바 8부터 추가된 람다식을 효과적으로 활용하는 데 도움을 주는 도구입니다.
이제 TimeLogTemplate은, 파라미터로 callBack() 메소드 하나만 보유한 CallBack 인터페이스의 구현체를 전달받아서 수행할 수 있다.
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);
}
}
아래는 실제 사용시에, TimeLogTemplate에 call() 을 구현하여 파라미터로 전달하는 것으로 템플릿 콜백 메서드를 사용한 예시이다.
이번에도 익명 내부 클래스를 먼저 만들고, 람다식으로 간략화했다.
/**
* 템플릿 콜백 패턴 - 익명 내부 클래스
*/
@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 실행"));
}
별도의 클래스를 만들어서 전달해도 되지만, 콜백을 사용할 경우 익명 내부 클래스나 람다를 사용하는 것이 편리하다.
물론 여러곳에서 함께 사용되는 경우 재사용을 위해 콜백을 별도의 클래스로 만들어도 된다.
템플릿 콜백 패턴 - 로그 추적기에 적용
이제 템플릿 콜백 패턴을 사용한 로그 추적기를 애플리케이션에 적용해보자.
우선, 콜백 인터페이스를 작성해보자.
public interface TraceCallback<T> {
T call();
}
반환 타입이 그때 그때 다를 수 있기에, 제네릭으로 만들어준다.
ex: 어떤 서비스 로직은 void로 db에 무언가를 반영하기만 하고, 어떤 로직은 Long 타입의 유저 id를 반환해야 될 수 있다.
이제 콜백 메서드를 받아서, 공통 기능과 함께 수행할 템플릿을 만들어보자.
public class TraceTemplate {
private final LogTrace trace; //로그 추적 로직 begin(), end(), exception() 이 들어있는 인터페이스
//템플릿은 LogTrace 로직(공통 로직)을 생성자로 전달 받는다.
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;
}
}
}
이제 위와 같이 생성한 템플릿, 콜백을 Controller-Service-Repository에 걸쳐 사용해보자
컨트롤러
@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";
}
});
}
}
템플릿을 사용할 때마다
new TraceTemplate(trace)
를 통해 템플릿을 생성하는 것은, 의미가 없다. (로그 출력 로직은 공통, 고정된 로직이기에)
따라서 생성자로 템플릿의 생성자 파라미터로 로그 로직(공통 로직) 을 전달하여 템플릿을 각 클래스마다 1회씩만 생성한다.
public OrderControllerV5(OrderServiceV5 orderService, LogTrace trace) {
this.orderService = orderService;
this.template = new TraceTemplate(trace);
}
위와 같이 필요한 Trace 템플릿을 미리 생성한다.
@Controller 가 OrderControllerV5 는 컴포넌트 스캔으로 스프링 컨테이너에 등록되고, 이 빈은싱글톤으로 유지 된다.
싱글톤인 OrderControllerV5 는 1개의 인스턴스만 존재하기에, 생성자도 1회만 되고, 클래스당 1번만 trace 템플릿이 생성된다
문제점: Controller-Service-Repository 각각에서는 trace템플릿이 1개 씩 존재한다. ( 3개는 미미하긴하다. )
or
TraceTemplate 를 아예 스프링 빈으로 등록하고 DI 받아도 된다.
하지만, 이럴 경우 단점이 있다.
Test Code 작성 시, TraceTemplate 에대한 mock 도 추가로 작성해줘야한다. ( 외부 의존성이기에...)
결국 취향차이다.
조금더 깊게 들어가보자.
TraceTemplate 를 아예 스프링 빈으로 등록하고 DI 받을 경우, 우리는 아래와 같은 장, 단점을 얻을 수 있다.
장점:
코드의 가독성과 유지보수성: TraceTemplate을 스프링 빈으로 등록하면 코드에서 생성과 의존성 주입을 명시적으로 표현할 수 있습니다. 이는 코드의 가독성을 향상시키고 유지보수성을 향상시킵니다. 또한 TraceTemplate의 생성과 관련된 로직을 중앙화하여 중복을 제거할 수 있습니다.
테스트 용이성: 스프링 빈으로 등록된 TraceTemplate은 DI를 통해 주입할 수 있기 때문에 테스트 시에 모의 객체(Mock)를 주입하여 테스트할 수 있습니다. 이는 테스트의 제어력을 높이고 의존성을 격리시키는 데 도움을 줍니다. 예를 들어, LogTrace를 Mock으로 만들어서 특정 시나리오를 시뮬레이션하고 로그를 확인할 수 있습니다.
단점:
빈 생성 및 관리 부하: 스프링 빈으로 등록하면 런타임 시에 빈을 생성하고 관리해야 합니다. 이는 약간의 오버헤드를 발생시킬 수 있습니다. 작은 규모의 애플리케이션에서는 미미한 차이이지만, 대규모 애플리케이션에서는 고려해야 할 부분입니다.
객체 의존성 증가: 스프링 빈으로 등록하면 객체 간의 의존성이 증가합니다. OrderControllerV5가 TraceTemplate을 직접 생성하는 대신 DI를 통해 주입받는다면, OrderControllerV5가 TraceTemplate에 의존성을 가지게 됩니다. 이는 객체 간의 결합도를 증가시킬 수 있으며, 일부 개발자들에게는 의존성 관리의 어려움을 초래할 수도 있습니다.
이제, 실제 템플릿 콜백 패턴을 어떻게 적용했는지 보자.
람다식을 통한 템플릿 콜백 패턴 사용
return template.execute("OrderController.request()", () -> {
orderService.orderItem(itemId);
return "ok";
});
람다식 x 익명 내부 함수 사용( 람다식이 이해가 되지 않는다면, 이해를 돕기 위해 확인하세요)
return template.execute("OrderController.request()", new TraceCallback<>() {
@Override
public String call() {
orderService.orderItem(itemId);
return "ok";
}
});
서비스
@Service
public class OrderServiceV5 {
private final OrderRepositoryV5 orderRepository;
private final TraceTemplate template;
public OrderServiceV5(OrderRepositoryV5 orderRepository, LogTrace trace) {
this.orderRepository = orderRepository;
this.template = new TraceTemplate(trace);
}
public void orderItem(String itemId) {
template.execute("OrderService.orderItem()", () -> {
orderRepository.save(itemId);
return null;
});
}
}
서비스 코드는 특별한 사항이 없기에 넘어가겠다.
리포지토리
@Repository
public class OrderRepositoryV5 {
private final TraceTemplate template;
public OrderRepositoryV5(LogTrace trace) {
this.template = new TraceTemplate(trace);
}
public void save(String itemId) {
template.execute("OrderRepository.save()",
() -> {
//저장 로직
if (itemId.equals("ex")) {
throw new IllegalStateException("예외 발생!");
}
sleep(1000);
return null;
});
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
역시 모두 유사하다.
마지막으로, 로그 추적기와는 관련 없지만, 템플릿 콜백 패턴을 활용하여 okHttp3 의 비동기 이벤트 처리에 대한 코드를 예시로 올려보겠다.
private static void asyncRequestAndResponse(OkHttpClient client, Request request) {
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
log.error("PUSH Alert ERROR \n" + e.toString());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try (ResponseBody responseBody = response.body()) {
if (!response.isSuccessful()) {
throw new RuntimeException("Unexpected code " + response);
}
responseBody.string();
}
response.body().close();
}
});
}
위와 같이, 비동기 처리에도 요긴하게 사용하니 참고하자.
정리
지금까지 우리는 변하는 코드와 변하지 않는 코드를 분리하고, 더 적은 코드로 로그 추적기를 적용하기 위해 고군분투 했다.
템플릿 메서드 패턴, 전략 패턴, 그리고 템플릿 콜백 패턴까지 진행하면서 변하는 코드와 변하지 않는 코드를 분리했다. 그리고 최종적으로 템플릿 콜백 패턴을 적용하고 콜백으로 람다를 사용해서 코드 사용도 최소화 할 수 있었다.
템플릿 메소드: 부모 클래스(추상 클래스)에 템플릿을 넣어넣고 자식 클래스에서 변하는 부분을 구현해서 사용한다.
상속 고유의 문제 -> 부모가 바뀌면 자식에 영향을 받는다.
자식은 부모의 코드를 전혀 쓰지 않아도 부모에 의존한다. ( 부모- 자식 강한 결합 발생)
전략: 이제 Context( 공통 불변 ) stretegy(가변, 로직 코드 ) 사용. 인터페이스를 통해 추상화에 의존하여 템플릿 메소드 패턴의 문제 해결
한번에 조립하고, 이후엔 실행만하는 상황에서 좋다.
템플릿 콜백: 전략이 실시간으로 계속, 많이 변할 때 좋다. (전략 패턴과 유사하지만, 파라미터로 콜백 메서드를 전달하여 template에서 해당 콜백 메서드를 통해 공통적인 부분은 기존에 가지고 있던 라인으로 수행하고, 가변적인 영역을 받아온 콜백 메서드를 통해 수행한다.
한계
그런데 지금까지 설명한 방식에는 한계가 있다.
아무리 최적화를 해도 결국 로그 추적기를 적용하기 위해서 원본 코드를 수정해야 한다는 점이다.
(결국 Controller-Service-Repository 내의 코드를 수정해야 로그 추적기를 사용할 수 있다.)
클래스가 수백개이면 수백개를 더 힘들게 수정하는가 조금 덜 힘들게 수정하는가의 차이가 있을 뿐, 본질적으로 코드를 다 수정해야 하는 것은 마찬가지이다.
개발자의 게으름에 대한 욕심은 끝이 없다. 수 많은 개발자가 이 문제에 대해서 집요하게 고민해왔고, 여러가지 방향으로 해결책을 만들어왔다. 다음글에선 원본 코드를 손대지 않고 로그 추적기를 적용할 수 있는 방법을 알아보자. 그러기 위해서 프록시 개념을 먼저 이해해야 한다.
미리보기
프록시는 가짜 객체이며, 실제 객체를 호출하고, 실제 객체 종료 시 다시 프록시로 돌아온 뒤 종료한다.
이곳에서 핵심로직외의 로직을 수행한다면, 핵심 로직이 구현된 곳에선 핵심 로직만 넣어둘 수 있지 않을까?
**참고
- 지금까지 설명한 방식은 실제 스프링 안에서 많이 사용되는 방식이다. XxxTemplate(콜백 패턴이다.) 를 만나면 이번에
학습한 내용을 떠올려보면 어떻게 돌아가는지 쉽게 이해할 수 있을 것이다.
+ @
콜백 메서드 패턴은 비동기적인 작업이나 이벤트 기반 시스템에서도 자주 사용되는 패턴이다.
콜백은 어떤 이벤트가 발생했을 때 호출되는 함수로, 이벤트 처리기에 의해 실행된다.
콜백 함수는 주로 비동기 작업의 결과를 처리하거나 이벤트에 대한 특정 동작을 정의하는 데 사용된다.
'Spring boot' 카테고리의 다른 글
스프링 핵심원리 5 [동적 프록시 기술] (0) | 2023.06.16 |
---|---|
스프링 핵심원리 4 [프록시 패턴과 데코레이터 패턴] (0) | 2023.06.15 |
자바 제네릭(generic) (0) | 2023.06.11 |
자바 익명함수와 람다식 (0) | 2023.06.11 |
스프링 핵심원리 2 [쓰레드 로컬을 통한 동시성 문제해결] (0) | 2023.06.10 |