관리 메뉴

나구리의 개발공부기록

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

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

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

소소한나구리 2024. 11. 6. 12:11

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


1. 템플릿 메서드 패턴 - 시작

1) 템플릿 메서드 패턴의 필요성

(1) 로그 추적기 도입 시도

  • 지금까지 요구사항도 만족하고 쓰레드로컬을 도입하여 동시성문제도 제거한 로그추적기를 프로젝트에 도입하려고하는데, 개발자들의 반대에 부딪힘
  • 그 이유를 로그 추적기 도입전과 도입 후의 코드를 보고 분석해보기

(2) 로그 추적기 도입 전 코드 - V0

  • V0 버전인 Controller와 Service의 코드만 보면 매우 간단하게 비즈니스 로직을 수행하는 코드만 있음
@RestController
@RequiredArgsConstructor
public class OrderControllerV0 {

    private final OrderServiceV0 orderService;

    @GetMapping("/v0/request")
    public String request(String itemId) {
        orderService.orderItem(itemId);
        return "OK";
    }
}

@Service
@RequiredArgsConstructor
public class OrderServiceV0 {

    private final OrderRepositoryV0 orderRepository;

    public void orderItem(String itemId) {
        orderRepository.save(itemId);
    }
}

 

(3) 로그 추적기 도입 후 코드 - V3

  • 로그 추적기를 도입한 Controller와 Service의 코드를 보면 해당 V0에비해 코드가 많은 것을 볼 수 있음
  • 중요한것은 단순히 코드가 많은게 문제가아니라 핵심 기능보다 로그를 출력해야하는 부가 기능 코드가 훨씬 많고 더 복잡함
@RestController
@RequiredArgsConstructor
public class OrderControllerV3 {

    private final OrderServiceV3 orderService;
    private final LogTrace trace;

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

@Service
@RequiredArgsConstructor
public class OrderServiceV3 {

    private final OrderRepositoryV3 orderRepository;
    private final LogTrace trace;

    public void orderItem(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderService.orderItem()");
            orderRepository.save(itemId);
            trace.end(status);
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

 

(4) 핵심기능

  • 해당 객체가 제공하는 고유의 기능으로 orderService의 주문 로직등이 핵심로직으로 볼 수 있음
  • 메서드 단위로 보면 orderService.orderItem()의 핵심 기능은 주문 데이터를 저장하기 위해 리포지토리를 호출하는 orderRepository.save(itemId) 코드가 핵심 기능임

(5) 부가 기능

  • 핵심 기능을 보조하기위해 제공되는 기능으로 로그 추적, 트랜잭션 기능등이 있음
  • 부가기능은 단독으로 사용되지는 않고 핵심기능과 함께 사용되는데, 예를 들어 로그 추적 기능은 어떤 핵심 기능이 호출 되었는지 로그를 남기기 위해 사용하는 것처럼 핵심 기능을 보조하기위해서 존재함

(6) 도입 반대의 이유

  • V0는 핵심 기능만 깔끔하게 있지만 로그 추적기를 추가한 V3 코드는 핵심 기능과 부가 기능이 함께 섞여있음
  • 또한 부가기능의 코드가 핵심 기능의 코드보다 훨씬 많아져 배보다 배꼽이 더 큰 상황이며 만일 고쳐야하는 클래스가 수백개라면 일일이 전부 고쳐야 하는 상황임
  • V3 코드를 잘 살펴보면 부가 기능을 사용하는 코드의 구조는 모두 동일하고 중간에 핵심 기능을 사용하는 코드만 다르기 때문에 중복을 별도의 메서드로 뽑아내면 될 것 같지만 try ~ catch는 물론이고 핵심 기능 부분이 중간에 껴있어서 단순한 메서드 추출로 분리하기는 어려움

(7) 변하는 것과 변하지 않는 것을 분리하여 해결

  • 그래서 이를 도입하려면 효율적으로 처리할 수 있는 방법을 모색해야하는데 이런 문제를 해결하는 디자인 패턴이 템플릿 메서드 패턴(Template Method Pattern)임
  • 좋은 설계는 변하는 것과 변하지 않는 것을 분리하는 것인데, 핵심 기능 부분은 변하고 로그 추적기를 사용하는 부분은 변하지 않는 부분이므로 이 둘을 템플릿 메서드 패턴을 활용하여 분리해서 모듈화 해야 함

2. 템플릿 메서드 패턴 - 예제

1) 예제코드 만들기

  • 템플릿 메서드 패턴을 쉽게 이해하기 위한 단순한 예제코드 만들기

(1) TemplateMethodTest 생성

  • logic1과 logic2는 비즈니스 로직이 수행되는 시간을 체크하는 로직이 포함되어있는 기능이라고 가정
  • 두 메서드를 비교해보면 비즈니스 로직을 수행하는 코드를 제외하고 완전히 똑같은 구조임을 확인할 수 있음
package hello.advanced.trace.template;

@Slf4j
public class TemplateMethodTest {

    @Test
    void templateMethodV0() {
        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);
    }
}

테스트 실행 결과

2) 템플릿 메서드 패턴 구현

(1) 템플릿 메서드 패턴 구조

  • 특정 틀에 변하지 않는 로직들을 모두 모아놓은 메서드를 만들어 놓고, 자식클래스에서 해당 메서드를 오버라이딩해서 사용하는 방식

 

(2) AbstractTemplate

  • test하위에 위치해야함
  • 템플릿 메서드 패턴은 이름 그대로 템플릿을 사용하는 방식이며 템플릿은 기준이 되는 거대한 틀이라고 생각하면됨
  • 템플릿이라는 틀에 변하지 않는 부분을 모두 모아두고 일부 변하는 부분을 별도로 호출해서 해결
  • AbstractTemplate 추상 클래스의 execute()메서드에 기존의 TemplateMethodTest의 logic()메서드에서 변하지 않는 부분이였던 시간 측정 로직을 몰아두었으며 이것이 하나의 템플릿이 됨
  • 그리고 그 템플릿 안에서 변하는 부분인 call() 메서드를 호출해서 처리함
  • 템플릿 메서드 패턴은 부모 클래스에 변하지 않는 템플릿 코드를 두고 변하는 부분은 자식 클래스에 두고 상속과 오버라이딩을 사용해서 처리함
package hello.advanced.trace.template.code;

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

 

(3) SubClassLogic1, 2 생성

  • 추상클래스인 AbstractTemplate 클래스를 상속받는 자식 클래스를 각각 만들어서 call()메서드를 구현하여 비즈니스 로직을 구현
  • 비즈니스 로직이 간단한 출력문이라고 가정
package hello.advanced.trace.template.code;

@Slf4j
public class SubClassLogic1 extends AbstractTemplate {

    @Override
    protected void call() {
        log.info("비즈니스 로직 1 실행");
    }
}

package hello.advanced.trace.template.code;

@Slf4j
public class SubClassLogic2 extends AbstractTemplate {

    @Override
    protected void call() {
        log.info("비즈니스 로직 2 실행");
    }
}

 

(4) TemplateMethodTest - templateMethodV1() 추가

  • 기존에 생성했던 테스트 코드에 템플릿 메서드 패턴을 적용한 메서드들을 테스트
  • 다형성을 이용하여 조상 타입으로 자손타입의 객체를 생성한뒤 추상클래스의 execute()메서드를 호출하여 각각의 자손객체에서 구현한 call()메서드가 실행되도록 코드를 작성
  • 실행해보면 기존의 V0 버전의 테스트 결과와 완전 동일한 결과가 출력되었지만 테스트코드(클라이언트코드)에 존재했던 logic1, logic2를 구현했던 코드가 없어 매우 깔끔해진 것을 확인할 수 있음
@Test
void templateMethodV1() {
    AbstractTemplate template1 = new SubClassLogic1();
    template1.execute();

    AbstractTemplate template2 = new SubClassLogic2();
    template2.execute();
}

테스트 실행 결과

(5) 템플릿 메서드 패턴 인스턴스 호출 그림

  • template1.execute()를 호출하면 템플릿 로직인 AbstractTemplate.execute()를 실행
  • 여기서 중간에 call() 메서드를 호출하는데 이 부분이 오버라이딩 되어있으므로 현재 인스턴스인 SubClassLogic1 인스턴스의 SubClassLogic1.call() 메서드가 호출됨
  • 템플릿 메서드 패턴은 이렇게 다형성을 사용해서 변하는 부분과 변하지 않는 부분을 분리하는 방법임

3) 익명 내부 클래스 사용

(1) 템플릿 메서드 패턴의 단점과 보완

  • 템플릿 메서드 패턴은 SubClassLogic1, SubClassLogic2처럼 클래스를 계속 만들어야하는 단점이 있음
  • 이런 문제는 익명 내부 클래스를 사용하면 이런 단점을 보완할 수 있음
  • 익명 내부 클래스를 사용하면 객체 인스턴스를 생성하면서 동시에 생성할 클래스를 상속 받은 자식 클래스를 정의할 수 있음
  • SubClassLogic1 처럼 직접 지정하는 이름이 없고 클래스 내부에서 선언되는 클래스여서 익명 내부 클래스라고 하며 익명 내부 클래스에 대한 자세한 내용은 자바 기본 문법을 참고

(2) TemplateMethodTest - templateMethodV2()추가

  • AbstractTemplate 타입으로 객체를 생성하면서 익명 클래스로 추상 메서드인 call()메서드를 별도의 클래스 생성없이 바로 구현하여 완성된 추상클래스의 메서드를 사용할 수 있음
  • 코드가 길어진 듯 보이지만 클래스파일을 계속 생성하지 않아도되기에 편리한 점이 존재함
  • 내부클래스의 이름을 한번 찍어보면 TemplateMethodTest$1, TemplateMethodTest$2 처럼 현재 실행하고 있는 클래스 + $ + 숫자로 표시되어있음을 확인할 수 있음 
@Test
void templateMethodV2() {
    AbstractTemplate template1 = new AbstractTemplate() {
        // 미리 구현해둔 call()메서드를 구현하는 것이 아니라 객체를 생성하는 곳에서 구현
        @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("클래스 이름1 ={}", template2.getClass());  
    template2.execute();
}


3. 템플릿 메서드 패턴 - 적용

1) 애플리케이션에 템플릿 메서드 패턴 적용

  • 로그 추적기를 템플릿 메서드 패턴을 사용하여 적용해보기

(1) AbstractTemplate 생성

  • main하위 template 패키지를 생성하여 AbstractTemplate클래스를 추상클래스로 지정하고 제네릭 타입을 타입변수 T로 지정
  • 해당 클래스에서 작성된 메서드들의 반환타입을 바로 지정하지 않고 메서드를 호출할 때 타입을 지정할 수 있도록 특정 타입을 지정하지않고 제네릭의 타입변수를 활용함
  • 해당 클래스는 템플릿 메서드 패턴에서 부모 클래스이며 템플릿 역할을 하고 객체를 생성할 때 내부에서 사용할 LogTrace trace를 전달 받음
  • 로그에 출력할 message를 외부에서 파라미터로 전달받고 템플릿 코드 중간에 call()메서드를 통해서 변하는 부분을 처리함
  • abstract T call()메서드는 변하는 부분을 처리하는 메서드이므로 상속으로 구현
package hello.advanced.trace.template;

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

 

(2) v3 -> v4 복사

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

(3) OrderControllerV4

  • 컨트롤러에 AbstractTemplate을 생성하고 제네릭타입을 String으로 설정하여 AbstractTemplate의 반환타입은 String이 됨
  • 익명 내부 클래스로 추상클래스인 call()을 정의하여 Controller에서 수행할 비즈니스 로직을 구현하여 별도의 자식 클래스를 만들지 않아도 편리하게 템플릿 메서드 패턴을 적용할 수 있음
  • 생성한 참조변수로 execute()메서드에 메세지를 담아서 호출하여 반환하면 Controller의 코드에 비즈니스 로직만 담겨있고 로그 추적기를 실행하는 코드 부분은 execute() 메서드의 내부에서 실행되어 핵심기능과 부가 기능이 확실히 분리 되었음
package hello.advanced.app.v4;

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

 

(4) OrderServiceV4

  • Controller와 템플릿 메서드 패턴을 적용하는 과정은 동일하지만 반환타입을 Void로 지정해주어야함(기본타입인 void가아닌 Void 클래스를 입력해주어야함)
  • 기존 비즈니스 로직에서 반환하는 값이 없었으므로 자바 언어 특성상 제네릭에는 반환타입을 입력해 주어야 하는데 반환타입이 없을 때는 Void 클래스로 입력해주고 return문에는 null로 반환해주면 됨
package hello.advanced.app.v4;

@Service
@RequiredArgsConstructor
public class OrderServiceV4 {

    private final OrderRepositoryV4 orderRepository;
    private final LogTrace trace;

    public void orderItem(String itemId) {
        // 기존 비즈니스 로직에서 반환타입이 없었으므로 반환타입을 Void로 지정하고 return은 null을 해주면됨
        AbstractTemplate<Void> template = new AbstractTemplate<>(trace) {
            @Override
            protected Void call() {
                orderRepository.save(itemId);
                return null;
            }
        };
        template.execute("OrderService.orderItem()");
    }
}

 

(5) OrderRepositoryV4

  • Service의 코드와동일하게 적용
package hello.advanced.app.v4;

@Repository
@RequiredArgsConstructor
public class OrderRepositoryV4 {

    private final LogTrace trace;

    public void save(String itemId) {
        AbstractTemplate<Void> template = new AbstractTemplate<Void>(trace) {

            @Override
            protected Void call() {
                if (itemId.equals("ex")) {
                    throw new IllegalStateException("예외 발생");
                }
                sleep(1000);
                return null;
            }
        };
        template.execute("OrderRepository.save()");
    }

    // sleep()메서드 정의는 기존과 동일
}

 

(6) 실행 결과

  • 실행해보면 아무 문제없이 기존과 동일하게 로그 추적기가 동작하여 로그 결과는 정상적으로 출력됨
  • 익명 내부 클래스로 구현되어있어서 완전히 깔끔하진 않지만 Service, Controller, Repository의 코드가 V3코드에비해 깔끔하며 비즈니스 로직만 작성되어있기 때문에 가독성도 좋아짐

2) 작성한 코드를 비교

(1) V0, V3, V4를 비교

  • V0는 핵심기능만 있음
  • V3는 핵심 기능과 부가 기능이 함께 섞여 있음
  • V4는 핵심 기능과 템플릿을 호출하는 코드가 섞여있음
  • V4에서는 템플릿 메서드 패턴을 사용한 덕분에 핵심 기능에 좀 더 집중할 수 있게 되었음

(2) 좋은 설계란

  • 좋은 설계라는 것은 수 많은 정의가 있겠지만 진정한 좋은 설계는 바로 변경이 일어날 때 자연스럽게 드러남
  • 로그를 남기는 부분을 모아서 하나로 모듈화 하고 비즈니스 로직 부분을 분리가 된 현재 코드에서 로그를 남기는 로직을 변경해야 한다고 생각해보면 AbstractTemplate 코드만 변경하면 모든 설계에 적용이 됨
  • 만약 템플릿이 없는 V3 상태에서 로그남기는 로직을 변경해야한다고 생각해보면 모든 클래스를 다 찾아서 수정해야하는데 수백개의 클래스가 있다면 모두 찾아서 수정해야하는 매우 어려운 유지보수를 하게 됨

(3) 단일 책임 원칙(SRP)

  • V4는 단순히 템플릿 메서드 패턴을 적용해서 소스코드를 몇줄을 줄인 것이 핵심이 아님
  • 로그를 남기는 부분에 단일 책임 원칙을 지켜 변경 지점을 하나로 모아 변경에 쉽게 대처할 수 있는 구조를 만든것이 V4의 핵심 포인트임
  • 고칠일이 없는 애플리케이션은 망하지 않는이상 없기에 대부분의 애플리케이션은 유지보수하면서 변경해나가야하기 때문에 이런 설계들이 중요해지는 것임

4. 템플릿 메서드 패턴 - 정의

1) GOF 디자인 패턴의 템플릿 메서드 패턴 정의

(1) GOF 도서의내용

  • 템플릿 메서드 디자인 패턴의 목적은 다음과 같습니다
  • "작업에서 알고리즘의 골격을 정의하고 일부 단계를 하위 클래스로 연기합니다. 템플릿 메서드를 사용하면 하위 클래스가 알고리즘의 구조를 변경하지 않고도 알고리즘의 특정 단계를 재정의할 수 있습니다"

(2) 설명

  • 풀어서 설명하면 부모 클래스에 알고리즘의 골격인 템플릿을 정의하고 일부 변경되는 로직은 자식 클래스에 정의하는 것임
  • 이렇게 정의하면 자식 클래스가 알고리즘의 전체 구조를 변경하지 않고 특정 부분만 재정의할 수 있으며 결국 상속과 오버라이딩을 통한 다형성으로 문제를 해결하는 것임

(3) 상속의 한계

  • 그러나 템플릿 메서드 패턴은 상속을 사용하기에 상속에서 오는 단점들을 그대로 떠안게됨
  • 특히 자식 클래스가 부모클래스와 컴파일 시점에 강하게 결합되면서오는 의존관계에 대한 문제임
  • 자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않음에도 불구하고 템플릿 메서드 패턴을 위해서 자식 클래스는 부모 클래스를 상속받고 있어 부모클래스를 의존하고 있음
  • 즉, 자식 클래스에서는 부모 클래스의 기능을 사용하든 사용하지 않든간에 부모 클래스를 강하게 의존하고 있어 부모 클래스가 수정이 되었을 때 자식 클래스에도 영향을 줄 수 있게됨
  • 예를들어 부모 클래스에서 추상메서드가 하나라도 추가되면 이를 상속받은 자식클래스는 전부 구현해야하는 문제가 발생
  • 이렇게 자식 클래스 입장에서 부모의 클래스의 기능을 전혀 사용하지 않는데 부모 클래스를 알아야하는 설계는 좋은 설계가 아니며, 템플릿 메서드 패턴은 상속 구조를 사용하기 때문에 별도의 자식클래스나 익명 내부 클래스를 만들어야하는 번거롭고 복잡한 부분이 존재함
  • 지금까지 설명한 개선을 위해서 템플릿 메서드 패턴과 비슷한 역할을 하면서 상속의 단점을 제거할 수 있는 디자인 패턴이 바로 전략 패턴(Strategy Pattern)임