템플릿 콜백 패턴

바그다드·2023년 8월 14일
0

디자인 패턴

목록 보기
3/3

앞선 포스팅에서 전략 패턴에 대해서 알아보았다.
아래의 코드는 전략 패턴에서 봤던 Context이다.

@Slf4j
public class ContextV2 {

	// strategy 주입 로직 생략

    public void execute(Strategy strategy) {
        long startTime = System.currentTimeMillis();
        
        // Strategy에 비즈니스 로직을 위임
        strategy.call(); 
        // 비즈니스 로직 종료
        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("result={}",resultTime);
    }
}

여기서 ContextV2는 변하지 않는 템플릿 역할을 하고, 변하는 부분은 Strategy의 코드를 실행해서 처리한다.
이처럼 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 콜백(callback)이라고 한다.

  • 즉 변하는 부분과 변하지 않는 부분을 나누는 방법이다.

콜백 정의
프로그래밍에서 콜백(callback) 또는 콜애프터 함수(call-after function)는 다른 코드의 인수로서
넘겨주는 실행 가능한 코드를 말한다. 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도
있고, 아니면 나중에 실행할 수도 있다. (위키백과 참고)

callback은 코드가 호출(call)은 되는데, 코드를 넘겨준 쪽의 뒤(back)에서 실행된다는 뜻이다.

  • 여기서 콜백은 Strategy가 된다.
  • 클라이언트가 직접 Strategy를 실행하는 것이 아니라
    ContextV2.execute()에 Strategy를 넘겨주고, ContextV2에서 Strategy가 실행되는 것이다.
  • 즉 실행 가능한 코드 Strategy를 클라이언트가 실행하는 것이 아니라 이 코드를 Context에 넘겨,
    Context에서 실행하였기 때문에 callback이라고 한다.

자바에서의 콜백

자바에서 실행 가능한 코드를 인수로 넘기려면 객체가 필요하다.
자바 8이전에는 인터페이스를 구현하고, 주로 익명 내부 클래스를 활용해 콜백을 구현했다.
자바 8이후로 최근에는 주로 람다를 사용한다.

템플릿 콜백 패턴

스프링에서는 처음에 확인한 ContextV2 같은 방식을 템플릿 콜백 패턴이라고 한다.
Context가 템플릿 역할을 하고, Strategy가 콜백으로 넘어오는 것이다.
즉, 템플릿을 실행하는 시점에 콜백을 넘기는 방식을 말한다.

  • 참고로 템플릿 콜백 패턴은 GOF패턴은 아니고, 스프링에서 자주하는 패턴으로 스프링에서만 이렇게 부른다.

스프링에서는 JdbcTemplate , RestTemplate , TransactionTemplate , RedisTemplate등등 다양한 템플릿 콜백 패턴이 사용된다. 스프링에서 이름에 XXXTemplate이 있다면 템플릿 콜백 패턴으로 만들어졌다고 생각하면 된다.

그럼 앞서 탬플릿 메서드 패턴을 활용해 변경했던 로그 추적기에 템플릿 콜백 패턴을 적용해보자.

템플릿 콜백 패턴 적용

1. 템플릿 생성

public class TraceTemplate {

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

}
  • 변하지 않는 부분, 템플릿을 생성하자.

  • 여기서 템플릿의 파라미터로 변하는 부분, 콜백(TraceCallback, 핵심 로직)을 받는다는 것을 기억하자.

    • 또한 콜백(TraceCallback)이 추상화(interface)임을 기억하자.
    • 로그 추적기의 경우에도 메세지 출력의 경우 변하는 부분이므로 메서드의 파라미터 값으로 동적으로 받아준다.
  • 여기서 LogTrace는 인터페이스이며 구현체는 ThreadLocal을 활용하고 있다.

    • 따라서 TraceStatus는 쓰레드별로 고유한 TraceStatus를 유지한다.

2. 콜백 생성

public interface TraceCallback<T> {
    T call();
}
  • 각 계층에서 반환타입이 어떻게 될지 모르므로 제네릭을 활용하자.
  • 메서드가 1개이므로 람다를 활용할 수 있다.

3. 컨트롤러에 적용

@RestController
public class OrderControllerV5 {
    private final OrderServiceV5 orderService;
    private final TraceTemplate template;

    public OrderControllerV5(OrderServiceV5 orderService, LogTrace logTrace) {
        this.orderService = orderService;
        this.template = new TraceTemplate(logTrace);
    }

    @GetMapping("/v5/request")
    public String request(String itemId) {

        return template.execute("OrderController.request()", () -> {
                orderService.orderItem(itemId);
                return "ok";
        });


    }
}
  • 생성자에서 파라미터로 LogTrace를 주입받아 template을 생성한다.

  • 이제 클라이언트는 템플릿을 실행하는데, 이 실행 시점에 콜백(핵심 로직)을 파라미터로 넘긴다.

    • 콜백을 파라미터로 넘기는 방법에는 3가지가 있다.
      여기서는 람다를 이용해 콜백을 넘겼다.
      1. 콜백 인터페이스의 구현 클래스를 정의하고, 구현체를 넘기는 방법
      2. 익명 내부 클래스를 생성해 넘기는 방법
      3. 람다를 사용해 넘기는 방법
    • 항상 1번처럼 구현 클래스를 작성하고, 구현체를 넘기는 방법을 사용해서 머리에 잘 남질 않았는데,
      2번째 3번째 방법은 파라미터로 인스턴스를 생성하는 코드 자체를 넘긴다고 생각하자.
  • 만약 익명 내부 클래스로 넘긴다고 한다면 아래처럼 넘길 수 있다.

        return template.execute("OrderController.request()", new TraceCallback<String>() {
            @Override
            public String call() {
                orderService.orderItem(itemId);
                return "ok";
            }
        });

4. 서비스에 적용

@Service
public class OrderServiceV5 {

    private final OrderRepositoryV5 orderRepository;
    private final TraceTemplate template;

    public OrderServiceV5(OrderRepositoryV5 orderRepository, LogTrace logTrace) {
        this.orderRepository = orderRepository;
        this.template = new TraceTemplate(logTrace);
    }

    public void orderItem(String itemId) {

        template.execute("OrderService.orderItem()", () -> {
            orderRepository.save(itemId);
            return null;
        });

    }
}

5. 리포지토리에 적용

@Repository
public class OrderRepositoryV5 {

    private final TraceTemplate template;

    public OrderRepositoryV5(LogTrace logTrace) {
        this.template = new TraceTemplate(logTrace);
    }

    // 저장 로직
    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) {
            throw new RuntimeException(e);
        }
    }
}

로그 추적기 적용 로직 변화

로그 추적기를 이용해 메서드의 병목현상을 추적하는 부가기능을 구현하였고, 이를 어플리케이션에 적용시켰다.
그 과정에서 어플리케이션의 로직은 적용 방법에 따라서 변화하였는데, 변경사항을 한번 정리하고 가자,

	// 1. 핵심 로직만 존재
    @GetMapping("/v0/request")
    public String request(String itemId) {
        orderService.orderItem(itemId);
        return "ok";
    }
        
    // 2. 로그 추적 기능 추가
    @GetMapping("/v3/request")
    public String request(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderController.request()");
            orderService.orderItem(itemId);
            trace.end(status);
            return "ok";
        } catch (Exception e) {
            trace.exception(status, e);
            //예외를 꼭 다시 던져줘야 한다.
            //로그는 어플리케이션 흐름에 영향을 줘선 안된다.
            throw  e;
        }

    }
    
    // 3. 템플릿 메서드 패턴 적용
    @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()");

    }
    
    // 4. 템플릿 콜백 패턴 적용
    @GetMapping("/v5/request")
    public String request(String itemId) {
        return template.execute("OrderController.request()", new TraceCallback<String>() {
            @Override
            public String call() {
                orderService.orderItem(itemId);
                return "ok";
            }
        });
    }
  1. 핵심 로직만 적용되어 있다.
  2. 로그 추적기를 추가한 상태
    • 하지만 핵심 로직과 부가 로직이 한군데 섞여 있다.
  3. 템플릿 메서드 패턴 적용
    • 템플릿에 반복되는 코드를 정의하고, 핵심 로직은 추상 메서드로 상속 객체에서 정의하도록 하는 방법
    • 템플릿 메서드 패턴을 활용해 반복되는 코드를 줄일 수 있다.
    • 단순히 코드를 줄이는 것이 아니라 SRP를 준수하여 변경으로 인한 영향을 최소화하였다.
    • 하지만 자식 클래스가 부모 클래스를 강하게 의존한다는 문제가 있다.
  4. 템플릿 콜백 패턴 적용
    • 템플릿에서 반복되는 로직을 처리하고, 핵심 로직을 인터페이스에 위임하는 방법
    • 람다식을 활용해 익명 내부 클래스를 활용한 로직보다 코드를 더 줄일 수 있다.
    • 템플릿(전략 패턴에서 Context)이 실햄 시점에 콜백(핵심 로직, 전략 패턴에서 Strategy)을 파라미터로 받아
      콜백 로직을 템플릿에서 실행한다.
    • 템플릿 메서드 패턴과 달리 템플릿에서 핵심 로직을 인터페이스에 의존하고,
      구현체는 인터페이스에만 의존하므로 강한 의존 관계를 피할 수 있게 된다.

한계

하지만 결국 아무리 많은 코드를 줄여도 원본 코드를 수정해야 하는 것에는 변함이 없다.
부가 기능을 사용하는 클래스를 얼마나 더 많이 수정하냐 덜 수정하냐의 차이만 있을 뿐이다.

이에 대한 해결책으로 제시된 것이 프록시이다. 다음 포스팅에서는 프록시에 대해 알아보자.

출처 : 김영한 - 스프링 핵심 원리 고급편

profile
꾸준히 하자!

0개의 댓글