관리 메뉴

나구리의 개발공부기록

템플릿 메서드 패턴과 콜백 패턴, 전략패턴(시작/예제), 템플릿 콜백 패턴(시작/예제/적용) 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 핵심원리 - 고급편

템플릿 메서드 패턴과 콜백 패턴, 전략패턴(시작/예제), 템플릿 콜백 패턴(시작/예제/적용)

소소한나구리 2024. 11. 6. 16:51

출처 : 인프런 - 스프링 핵심 원리 - 고급편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 전략 패턴 - 시작

1) 예제 만들기

(1) ContextV1Test 생성

  • 템플릿 메서드 패턴에서 만들었던 완전히 동일한 테스트 코드로 클래스 이름과 패키지만 변경해서 사용
  • 패키지는 test하위 경로에 strategy 패키지를 생성하고 테스트 코드의 메서드명을 strategyV0로 변경
package hello.advanced.trace.strategy;

@Slf4j
public class ContextV1Test {

    @Test
    void strategyV0() {
        logic1();
        logic2();
    }
    
    // 나머지 코드는 동일
    
}

2. 전략패턴 - 예제

1) 전략패턴으로 문제 해결

(1) GOF 디자인 패턴의 전략 패턴

  • 알고리즘 제품군을 정의하고 각각을 캡슐화 하여 상호 교환 가능하게 만드는 전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.
  • 템플릿 패턴이 부모 클래스에 변하지 않는 템플릿을 두고 변하는 부분을 자식 클래스에 두어 상속을 통해 문제를 해결했다면, 전략 패턴은 변하지 않는 부분을 Context라는 곳에 두고 변하는 부분을 Strategy라는 인터페이스를 만들고 해당 인터페이스를 구현해서 문제를 해결함
  • 즉, 상속이 아니라 위임으로 문제를 해결함

(2) 전략 패턴 구조

2) 코드

(1) Strategy 인터페이스 생성

package hello.advanced.trace.strategy.code;

public interface Strategy {
    void call();
}

 

(2) StrategyLogic1, 2 생성

  • Strategy 인터페이스의 구현체 클래스 생성
package hello.advanced.trace.strategy.code;

@Slf4j
public class StrategyLogic1 implements Strategy {
    @Override
    public void call() {
        log.info("비즈니스 로직1 실행");
    }
}

package hello.advanced.trace.strategy.code;

@Slf4j
public class StrategyLogic2 implements Strategy {
    @Override
    public void call() {
        log.info("비즈니스 로직2 실행");
    }
}

 

(3) ContextV1 생성

  • 필드에 전략을 보관하는 방식으로 구현
  • 변하지 않는 로직을 가지고 있는 템플릿 역할을 하는 코드이며 전략 패턴에서는 이것을 컨텍스트(문맥)이라고 함
  • 컨텍스트(문맥)은 크게 변하지 않지만 그 문맥 속에서 strategy를 통해 일부 전략이 변경된다고 이해하면 됨
  • Context는 내부에 Strategy strategy필드를 가지고 있으며 이 필드에 변하는 부분인 Strategy의 구현체를 주입하면 됨
  • 전략 패턴의 핵심은 Context가 Strategy 인터페이스에만 의존한다는 점인데 덕분에 Strategy의 구현체를 변경하거나 새로 만들어도 Context코드에는 영향을 주지 않음 (스프링에서 의존관계 주입에서 사용하는 방식이 바로 전략 패턴임)
package hello.advanced.trace.strategy.code;

/**
 * 필드에 전략을 보관하는 방식
 */
@Slf4j
public class ContextV1 {

    // 외부에서 받은 전략을 보관하는 필드
    private Strategy strategy;

    public ContextV1(Strategy strategy) {
        this.strategy = strategy;
    }

    public void execute() {
        long startTime = System.currentTimeMillis();

        strategy.call(); // 인터페이스의 call()메서드를 호출

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("resultTime={}", resultTime);
    }
}

 

(4) ContextV1Test - 테스트 추가

  • Strategy의 구현체 클래스인 StrategyLogic1, StrategyLogic2의 클래스를 생성하고, ContextV1객체를 생성시 전략을 주입
  • 각 전략을 주입하여 생성한 객체를 참조하는 변수로 execute()메서드를 호출하여 실행해보면 정상적으로 로그가 동작하는 것을 확인할 수 있음
  • 즉, 구현클래스들이 단순한 구조인 인터페이스에만 의존하고있고 변경하지 않는 코드의 묶음은 Context클래스에 별도로 관리함으로 써 만약에 Context의 코드가 변경된다고 하더라도 인터페이스와 그 인터페이스를 구현한 클래스들은 전혀 영향을 받지 않음
  • 템플릿 메서드 패턴으로 구현했을 때 발생되는 상속의 단점을 해결하게 됨
@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();
}

3) 전략 패턴에서 익명 내부 클래스를 사용

(1) strategyV2() 추가

  • 템플릿 메서드 패턴과 마찬가지로 각 전략을 클래스로 구현하는 것이 아니라 인터페이스를 생성하여 익명 내부 클래스로 전략을 구현하여 사용할 수 있음
/**
 * 익명 내부 클래스 사용
 */
@Test
void strategyV2() {
    Strategy strategyLogic1 = new Strategy() {
        @Override
        public void call() {
            log.info("비즈니스 로직1 실행");
        }
    };
    ContextV1 context1 = new ContextV1(strategyLogic1);
    context1.execute();

    // 동일한 코드 구조이므로 생략, 비즈니스 로직2 실행
}

 

(2) strategyV3() 추가

  • Strategy인터페이스를 변수로 생성하지말고, ContextV1 객체를 생성할 때 인수값으로 인터페이스를 생성하면서 익명 내부 클래스로 전략을 정의하면 조금 더 깔끔해짐
@Test
void strategyV3() {
    ContextV1 context1 = new ContextV1(new Strategy() {
        @Override
        public void call() {
            log.info("비즈니스 로직1 실행");
        }
    });
    context1.execute();

    // 동일한 코드 구조이므로 생략, 비즈니스 로직2 실행
}

 

(3) strategyV4() 추가

  • 익명클래스를 람다식으로 사용하면 훨씬 깔끔하게 적용할 수 있음
  • 람다로 변경하려면 인터페이스에 메서드가 1개만 있으면 되는데 Strategy 인터페이스는 메서드가 1개만 있기때문에 람다식으로 사용할 수 있음
@Test
void strategyV4() {
    ContextV1 context1 = new ContextV1(() -> log.info("비즈니스 로직1 실행"));
    context1.execute();

    ContextV1 context2 = new ContextV1(() -> log.info("비즈니스 로직2 실행"));
    context2.execute();
}

 

 

(4) 선 조립, 후 실행

  • 지금의 전략 패턴은 변하지 않는 부분을 Context에 두고 변하는 부분을 Strategy인터페이스를 구현하여 만든 뒤 Context의 내부 필드에 Strategy를 주입해서 사용하는 일반적인 전략 패턴을 적용하는 방법임
  • Context의 내부 필드에 Strategy를 두고 사용하는 부분은 Context와 Strategy를 실행전에 원하는 모양으로 조립해두고 그 다음에 Context를 실행하는 선 조립, 후 실행 방식에서 매우 유용함
  • 우리가 스프링으로 애플리케이션을 개발할 때 애플리케이션 로딩 시점에 의존관계 주입을 통해 필요한 의존관계를 모두 맺어두고 난 다음에 실제 요청을 처리하는 것과 같은 원리임
  • 이 방식의 단점은 모두 조립한 이후에는 전략을 변경하기가 번거롭다는 점인데, setter를 제공해서 Strategy를 넘겨 받아 변경할 수 있지만 Context를 싱글톤으로 사용할 때는 동시성 문제 등의 고려사항이 많음
  • 그래서 전략을 실시간으로 전략을 변경해야하면 테스트 코드들처럼 new 연산자로 Context를 하나 더 생성하고 그곳에 다른 Strategy를 주입하는 것이 더 나은 선택일 수 있음

4) 유연하게 사용하는 전략 패턴

  • 전략을 실행할 때 직접 파라미터로 전달해서 사용하는 방법

(1) ContextV2 추가

  • ContextV2에 인스턴스 변수가 없이 execute()메서드의 파라미터로 Strategy를 전달받음
@Slf4j
public class ContextV2 {

    public void execute(Strategy strategy) {
    // 이하 코드 동일 
      
    }
}

 

(2) ContextV2Test 추가

  • Context와 Strategy를 선 조립 후 실행하지않고 Context를 실행할 때마다 메서드의 인수로 전략을 전달하는 방식을 적용
  • 클라이언트는 Context를 실행하는 시점에 원하는 Strategy를 전달할 수 있어 이전 방식과 비교하여 원하는 전략을 더욱 유연하게 변경할 수 있음
  • 테스트코드를 보면 하나의 Context만 생성되었고 그 Context가 실행되는 시점에 여러 전략을 인수로 전달하고 있음
@Slf4j
public class ContextV2Test {

    /**
     * 전략 패턴 적용
     */
    @Test
    void strategyV1() {
        ContextV2 context = new ContextV2();
        context.execute(new StrategyLogic1());
        context.execute(new StrategyLogic2());
    }
}

(3) 익명 내부클래스, 람다식 적용

  • V1버전과 마찬가지로 익명 내부 클래스와 람다식을 적용하여 코드를 더 간단하게 작성할 수 있음
// 익명 내부 클래스 사용
@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 실행"));
}

 

(4) 정리

  • ContextV1은 필드에 Strategy를 저장하는 방식으로 전략 패턴을 구사하여 선 조립, 후 실행 방법에 적합함
  • Context를 실행하는 시점에는 이미 조립이 끝났기 때문에 전략을 신경쓰지 않고 단순히 실행만 하면 됨
  • ContextV2는 파라미터에 Strategy를 전달받는 방식으로 전략 패턴을 구사하여 실행할 때 마다 전략을 유연하게 변경할 수 있음
  • 단점 역시 실행할 때 마다 전략을 계속 지정해하는 것임

(5) 템플릿

  • 우리가 해결하고 싶은 문제는 변하는 부분과 변하지 않는 부분을 분리하는 것임
  • 변하지 않는 부분을 템플릿이라고하고 그 템플릿 안에서 변하는 부분에 약간 다른 코드 조각을 넘겨서 실행하는 것이 목적임
  • ContextV1, ContextV2 두 가지 방식 다 문제를 해결할 수 있지만 예제의 상황에서는 단순히 코드를 실행할 때 변하지 않는 템플릿이 있고 그 템플릿 안에서 원하는 부분만 살짝 다른 코드를 실행하고 싶을 뿐이기 때문에 유연하게 실행 코드 조각을 전달하는 ContextV2가 적합하다고 볼 수 있음
  • 만일 애플리케이션 의존관계를 설정하는 것처럼 먼저 조립이 다 되어있고 실행이 되야하는 상황이라면 ContextV1 방식으로 적용하면 됨

3. 템플릿 콜백 패턴

1) 시작

(1) 콜백 정의

  • ContextV2는 변하지 않는 템플릿 역할을 하고 변하는 부분은 파라미터로 넘어온 Strategy의 코드를 실행해서 처리하는데 이처럼 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 콜백(callback)이라고 함
  • 프로그래밍에서 콜백(callback)또는 콜애프터 함수(call-after function)는 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말함
  • 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있음

(2) 예제로 설명

  • 쉽게 이야기하면 callback은 코드가 호출(call)은 되는데 코드를 넘겨준 곳의 (back)에서 실행된다는 뜻임
  • ContextV2 예제에서 콜백은 Strategy이고 클라이언트가 ContextV2.execute()를 실행할 때 인수로Strategy를 넘겨주고 ContextV2 뒤에서 Strategy가 실행됨

(3) 자바 언어에서의 콜백

  • 자바 언어에서 실행 가능한 코드를 인수로 넘기려면 객체가 필요한데 자바 8부터는 람다를 사용할 수 있음
  • 자바 8 이전에는 보통 하나의 메서드를 가진 인터페이스를 구현하고 주고 익명 내부 클래스를 사용했으나 최근에는 주로 람다를 사용함

(4) 템플릿 콜백 패턴

  • 스프링 에서는 ContextV2와 같은 방식의 전략 패턴을 템플릿 콜백 패턴이라고하며 Context가 템플릿, Strategy가 콜백으로 넘어온다고 생각하면 됨
  • 템플릿 콜백 패턴은 GOF패턴은 아니고 스프링 내부에서 이런 방식을 자주 사용하기 때문에 스프링 안에서만 이렇게 부름
  • 즉, 전략 패턴에서 템플릿과 콜백 부분이 강조된 패턴이라고 생각하면됨
  • 스프링에서는 JdbcTemplate, RestTemplate, TransactionTemplate, RedisTemplate처럼 다양한 템플릿 콜백 패턴이 사용되며, 스프링에서 이름에 xxxTemplate가 있으면 템플릿 콜백 패턴으로 만들어져 있다고 생각하면 됨

2) 예제

  • ContextV2와 내용이 같고 이름만 다르기때문에 어려움은 없음
  • Context -> Template
  • Strategy -> Callback

(1) Callback - 인터페이스

package hello.advanced.trace.template.template;

public interface Callback {
    void call();
}

 

(2) TimeLogTemplate 

  • execute의 메서드의 파라미터로 Callback을 넘김
package hello.advanced.trace.template.template;

@Slf4j
public class TimeLogTemplate {

    public void execute(Callback callback) {
        // 메서드의 파라미터의 이름만 다르지 완전히 기능은 동일함
    }   
}

 

(3) TemplateCallbackTest

  • 익명클래스와 람다식으로 Callback을 구현하여 테스트를 수행
  • ContextV2Test와 완전히 동일한 구조임
package hello.advanced.trace.template;

@Slf4j
public class TemplateCallbackTest {

    /**
     * 템플릿 콜백 패턴 - 익명 내부 클래스
     */
    @Test
    void callbackV1() {
        TimeLogTemplate timeLogTemplate = new TimeLogTemplate();
        timeLogTemplate.execute(new Callback() {
           // 구현 로직은 동일하므로 생략
        });
        timeLogTemplate.execute(new Callback() {
           // 구현 로직은 동일하므로 생략        });
    }
    
    /**
     * 템플릿 콜백 패턴 - 람다
     */
    @Test
    void callbackV2() {
        TimeLogTemplate timeLogTemplate = new TimeLogTemplate();
        timeLogTemplate.execute(() -> log.info("비즈니스 로직1 실행"));
        timeLogTemplate.execute(() -> log.info("비즈니스 로직2 실행"));
    }
}

3) 적용

  • 템플릿 콜백 패턴을 애플리케이션에 적용

(1) TraceCallback 인터페이스 생성

  • 반환타입이 그때그때 다를 수 있어서 제네릭을 사용
package hello.advanced.trace.callback;

public interface TraceCallback<T> {
    T call();
}

 

(2) TraceTemplate 생성

  • 템플릿 역할을 하는 TraceTemplate 클래스 생성
  • execute()메서드의 파라미터로 message데이터와 콜백인 TraceCallback callback을 전달 받고 제네릭을 사용하여 반환타입을 정의
package hello.advanced.trace.callback;

public class TraceTemplate {

    private 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;
        }
    }
}

 

(3) v4 -> v5 복사

  • v5 패키지 생성 후 v4 클래스들 복사 하여 V5로 변경
  • 내부 의존관계 클래스를 V5로 변경하고 GetMapping 정보를 /v5/request 로 변경
  • TraceTemplate을 사용하도록 코드를 변경

(4) OrderControllerV5

  • 롬복의 @RequiredArgsConstructor로 의존관계를 주입하지 않고, 직접 생성자를 만들고 생성자의 파라미터로 LogTrace trace를 전달받아 TraceTemplate를 생성하도록 작성
  • 생성자가 하나이기 때문에 @Autowired를 생략할 수 있어 싱글톤 스프링빈으로 등록됨
  • 참고로 TraceTemplate을 처음부터 스프링 빈으로 등록하고 주입받아도 되고 지금처럼 생성자로 주입받아도 됨
  • 생성자로 TraceTemplate을 생성하도록 하며 테스트코드를 짤 때 trace만 Mock으로 만들어서 전달하면 되기 때문에 조금 더 수월함
  • execute() 메서드를 실행할 때 콜백을 전달하고 익명클래스로 콜백을 구현함(람다식으로 구현해도 됨)
package hello.advanced.app.v5;

@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";
            }
        });
    }
}

 

(5) OrderServiceV5

  • 동일한 방식으로 코드를 수정하고 콜백을 람다로 전달하면 훨씬 코드가 깔끔해짐
@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;
        });
    }
}

 

(6) OrderRepositoryV5

  • 마찬가지로 람다를 사용하여 콜백을 넘김
package hello.advanced.app.v5;

@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;
        });
    }

    // sleep 메서드는 동일하므로 생략
}

 

(7) 실행

  • 코드를 적용해서 실행해보면 정상적으로 로그 추적기가 동작함
  • 전략 패턴을 사용하여 템플릿 메소드 패턴의 단점을 없애고 람다식을 적용함으로써 코드를 더욱 깔끔하게 동일한 기능을 수행하는 코드를 작성하게 되었음

4) 템플릿 메서드 패턴과 콜백 패턴 정리

  • 지금까지 변하는 코드와 변하지 않는 코드를 분리하고 더 적은 코드로 로그 추적기를 적용하기위해 템플릿 메서드 패턴, 전략 패턴, 템플릿 콜백 패턴까지 진행하면서 개선을 하였고, 콜백을 람다를 적용하며 코드를 최소화 하기도 하였음
  • 그러나 지금까지 설명한 방식의 명확한 한계는 아무리 최적화를 해도 결국 로그 추적기를 적용하기 위해서 원본 코드를 수정해야한다는 점임
  • 더 힘들게 수정할 것인가, 덜 힘들게 수정할 것인가의 차이가 존재할 뿐 본질적으로 수백개의 클래스에 존재하는 코드를 수정해야하는 것은 마찬가지임
  • 수 많은 개발자가 이 문제에 대해서 집요하게 고민해왔고 여러가지 방향으로 해결책을 만들어왔는데, 이 방법을 적용하기 위해서는 프록시 개념을 먼저 이해해야함

** 참고

  • 지금까지 설명한 방식은 실제 스프링 안에서 많이 사용되는 방식이므로 xxxTemplate을 본다면 이번에 학습한 내용을 기반으로 생각해보면 쉽게 이해할 수 있을 것임