스프링 AOP : 템플릿 메서드 패턴, 전략 패턴, 템플릿 콜백 패턴 적용

    이 포스팅은 인프런의 김영한님 강의를 듣고 복습하며 정리한 내용입니다. 

     


    템플릿 메서드 패턴, 전략 패턴, 템플릿 콜백 패턴의 적용 필요

    앞선 게시글(https://ojt90902.tistory.com/696)에서 쓰레드 로컬까지 활용해서 로그 추적기를 적용했다. 그런데 한 가지 문제가 있었다. 바로 로그 추적기를 사용하기 위해서 실제 사용되는 코드 영역에서 핵심 기능을 위한 코드보다, 로그 추적기 구현을 위한 코드가 더 많다는 점이다. 배보다 배꼽이 큰 상황이다. 

     

    클래스가 몇 개 안된다면 아무 문제가 없으나, 수백 개가 될 경우 문제가 된다. 손이 많이 가고, 정확도도 떨어질 수 있다. 그렇다면 이런 문제를 어떻게 해결해야할까? 먼저 앞서 작성된 코드를 한번 살펴보자. 

    좌 : Controller / 우 : Service

    앞서 작성된 코드를 한번 들여다보면 유독 공통되는 부분이 있다는 것을 볼 수 있다. try, catch 구문이 있고, Trace처리하는 부분이 공통으로 작성된 것이 보인다. 다른 부분은 실제 비즈니스 로직을 실행하는 부분만 다르다는 것을 볼 수 있다. 이 비즈니스 로직은 Try ~ Catch 사이에 있기 때문에 메서드로 뽑을 수가 없다.

    위 상황을 다시 한번 살펴보자. 부가 기능을 적용하기 위해서 '변하지 않는 부분'과 '변하는 부분'으로 나눌 수 있다. 변하지 않는 부분은 '부가 기능'이고, 변하는 부분은 '핵심 기능'이다. 이렇게 나누어진 두 부분을 분리해서 모듈화해야한다. 모듈화 된 두 부분을 필요한 형태로 조립해서 사용한다면 위의 문제를 해결할 수 있게 된다.

     

    위에서 볼 수 있듯이 변하지 않는 부분, 변하는 부분을 모듈화해서 조립화해서 사용하는 디자인 패턴들에는 '템플릿 메서드 패턴', '전략 패턴', '템플릿 콜백 패턴'이 있다. 

     


    템플릿 메서드 패턴

    GOF에서 이야기 하는 템플릿 메서드 패턴의 목적은 다음과 같다.

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

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

    구조는 위와 같다.

    • 부모 클래스를 추상 클래스로 만든다.
      • 변하지 않는 부분(부가 기능)을 추상 클래스에서 구현한다. (Execute 메서드 구현)
      • 추상 클래스에 변하는 부분(핵심 기능)을 실행하도록 정의한다 (Call 메서드 선언)
      • 부모 클래스에 변하는 부분을 abstract Method로 정의한다. (Call 메서드 선언)
    • 자식 클래스를 만든다.
      • Abstract Method를 오버라이딩 한다. (Call 메서드 구현)
      • 자식 클래스를 실행시킨다. (Execute 실행)

     

    단점 

    템플릿 메서드 패턴은 상속을 사용한다. 따라서 상속에서 오는 단점을 그대로 안고 간다. 가장 큰 단점은 자식 클래스가 부모 클래스와 강하게 결합된다는 문제가 있다. 강하게 결합되어있다는 말은 의존한다는 의미인데, 자식 클래스 extends 부모 클래스가 명시되어있어 빼박으로 강한 결합이 있다는 것을 알 수 있다. 

    강하게 결합되어있으면 변경에서 자유로울 수 없다. 부모 클래스가 조금이라도 수정되면, 이것은 바로 자식 클래스에 영향을 미치게 된다. 예를 들어 부모 클래스의 특정 필드가 추가 되었다고 하면, 자식 클래스에서는 하다 못해 생성자가 강제로 수정이 되게 된다. 즉, 부모 클래스의 코드 변화가 자식 클래스에도 코드 변화를 강요한다는 점이다. 

    그렇다면 이런 강한 결합을 어떻게 깨야할까? '상속'보다 '위임'을 이라는 말이 있다. 즉, '상속'을 사용하지 않고 인터페이스를 전달해서 '위임'하고 구현하는 방식으로 이 강한 결합을 다소 해소할 수 있다. 이런 디자인 패턴은 아래에서 알아볼 '전략 패턴(Strategy Pattern)'이라고 한다. 

     


    전략 패턴(Strategy Pattern)

    GOF에서 이야기 하는 템플릿 메서드 패턴의 의도는 다음과 같다.

    "알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들자. 전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다. "

    실제 클래스 다이어그램은 다음과 같이 된다. 기존 템플릿 메서드 패턴이 변하지 않는 부분(부가 기능)을 부모 클래스로 만들어두고, 변하는 부분(핵심 기능)을 상속받은 자식 클래스가 사용하며 구현이 되었다. 전략 패턴은 이와는 대조적이다. 

    전략 패턴은 변하지 않는 부분(부가 기능)을 Context라는 클래스를 만들어 구현해둔다. 그리고 변하는 부분은 Strategy라는 Interface를 만들고, 필요한 형태의 구현체를 원하는 만큼 만들어둔다. 이 때, 우리가 원하는 기능의 구현은 변하지 않는 부분이 변하는 부분을 '참조'하면서 이루어진다. 이 때 참조는 Interface를 참조, 역할에만 집중하기 때문에 결합은 약해진다.

     

    전략 패턴의 두 가지 구현 방법

    앞서 이야기 했던 전략 패턴을 구현하는 두 가지 방법이 있다. 

    1. Context의 Field에 Strategy를 필드로 가짐

    public class ContextV1 {
    	
        // Strategy 참조로 가짐. 
        private final Strategy strategy;
    
        public ContextV1(Strategy strategy) {
            this.strategy = strategy;
        }
    
        public void execute() {
            long startTimeMs = System.currentTimeMillis();
    
            strategy.call();
    
            long lastTimeMs = System.currentTimeMillis();
            long resultTime = lastTimeMs - startTimeMs;
            log.info("resultTime = {}", resultTime);
    
        }
    
    
    }
    • 처음 Context가 만들어지는 시점에 Strategy를 전달받아 조립이 끝나는 방식이다.
    • Spring에서 DI를 받는 것과 아주 유사한 형태다. 

     

    2. Context 실행 시, 파라메터로 Strategy를 넘겨줌. 

    @Slf4j
    public class ContextV2 {
    
    	// 실행 시점에 Strategy를 인자로 전달 받음. 
        public void execute(Strategy strategy) {
            long startTimeMs = System.currentTimeMillis();
    
            strategy.call();
    
            long lastTimeMs = System.currentTimeMillis();
            long resultTime = lastTimeMs - startTimeMs;
            log.info("resultTime = {}", resultTime);
    
        }
    
    
    }
    • 사용 시점에 인자를 통해서 Strategy를 전달받는다.
    • 전달받는 시점에 사용은 되지 않고, 후에 사용된다. 따라서 CallBack()이라고 불리기도 한다. 

     


    Template CallBack 패턴

    Template CallBack 패턴은 GOF 디자인 패턴에서 정의하지 않는다. Strategy 패턴에서 인자로 strategy를 전달하는 형태를 Template CallBack 패턴이라고 하고, 스프링에서 주로 이런 형태의 패턴이 구성되기 때문에 스프링에서 사용하는 Template CallBack 패턴이라고 이야기 한다. 

     

    이해를 돕기 위해 풀어서 설명하면 공통되는 부분(변하지 않는 부분)인 Template에 변하는 부분인 Call 함수를 전달한다. 그런데 이 함수는 나중에(Back) 실행되기 때문에 Template CallBack 패턴이라고 명명된다. 

     


    템플릿 메서드 패턴 테스트 코드로 살펴보기

    noTemplate 테스트

    먼저 위와 같은 테스트 코드가 있다고 가정해보자. 이 테스트에서 logic1, logic2 메서드를 호출하는데 둘다 공통된 부분이 많고, 단지 '비즈니스 코드1'을 출력하는지, '비즈니스 코드2'를 출력하는지에 대한 차이만 보여준다.

    @Slf4j
    public class TemplateMethodTest {
    
    
        @Test
        void noTemplate() {
    
            logic1();
            logic2();
    
        }
    
        private void logic1() {
            long startTimeMs = System.currentTimeMillis();
    
            log.info("비즈니스 로직1 실행");
    
            long lastTimeMs = System.currentTimeMillis();
            long resultTime = lastTimeMs - startTimeMs;
    
            log.info("resultTime = {}",resultTime);
        }
    
    
        private void logic2() {
    
            long startTimeMs = System.currentTimeMillis();
    
            log.info("비즈니스 로직2 실행");
    
            long lastTimeMs = System.currentTimeMillis();
            long resultTime = lastTimeMs - startTimeMs;
            log.info("resultTime = {}",resultTime);
        }

     

    실행 결과는 아래와 같다.

    위 결과를 살펴보면, 실행 시간을 찍는 로직이 아주 똑같다는 것을 알 수 있다. 따라서 변하는 부분과 변하지 않는 부분을 나눌 수 있다. 이 부분을 템플릿 메서드 패턴을 적용해서 나눈다. 

     

    변하지 않는 부분, 부모 클래스 구현

    @Slf4j
    public abstract class AbstractTemplate {
    	// 추상 클래스
        public void execute() {
    
            long startTimeMs = System.currentTimeMillis();
    
    		// 핵심 기능
            call();
    
            long lastTimeMs = System.currentTimeMillis();
            long resultTime = lastTimeMs - startTimeMs;
            log.info("resultTime = {}", resultTime);
        }
        
        // 자식 클래스에서 구현 (추상 메서드)
        protected abstract void call();
    }
    • 추상 클래스를 만든다
      • 공통되는 부분은 메서드로 구현한다. 그리고 그 메서드 내에 변하는 부분이 실행될 수 있도록 선언해준다.
      • 자식 클래스에서 구현할 부분을 추상 메서드로 선언한다. 

     

    자식 클래스 구현 : 추상 메서드의 오버라이딩

    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 실행");
        }
    }
    • 부모 클래스를 상속 받은 자식 클래스를 구현한다.
    • Call() 함수만 오버라이딩 하면 되고, 여기서 다른 로직이 실행되었다고 작성한다. 

     

    템플릿 메서드 적용 테스트 V1 

    @Test
    void templateMethodV1() {
        AbstractTemplate subClassLogic1 = new SubClassLogic1();
        AbstractTemplate subClassLogic2 = new SubClassLogic2();
    
        subClassLogic1.execute();
        subClassLogic2.execute();
    }
    • 각 자식 클래스를 작성한다.
    • 각 자식 클래스를 execute()를 통해 실행해준다. 
      • execute() 내부에는 call()이 있고, 각 자식 클래스에서 오버라이딩 된 call()이 실행된다. 

     

    템플릿 메서드 적용 테스트 V2 : 익명 클래스를 전달 

    @Test
    void templateMethodV2() {
    
        AbstractTemplate subclassLogic1 = new AbstractTemplate() {
            @Override
            protected void call() {
                log.info("비즈니스 로직1 실행");
            }
        };
    
        AbstractTemplate subclassLogic2 = new AbstractTemplate() {
            @Override
            protected void call() {
                log.info("비즈니스 로직2 실행");
            }
        };
    
        subclassLogic1.execute();
        subclassLogic2.execute();
    }
    • 자식 클래스를 매번 필요할 때마다 만들면 상당히 손이 많이 갈 수 있다.
    • 따라서 익명 클래스를 전달해서, 템플릿 메서드 패턴을 구현할 수도 있다. 

     

    템플릿 메서드 적용 테스트 V3 : 익명 클래스를 람다함수로 작성.

    @Test
    void templateMethodV3() {
    
        AbstractTemplate subclassLogic1 = new AbstractTemplate() {
            @Override
            protected void call() {
                log.info("비즈니스 로직1 실행");
            }
        };
    
        AbstractTemplate subclassLogic2 = new AbstractTemplate() {
            @Override
            protected void call() {
                log.info("비즈니스 로직2 실행");
            }
        };
    
        subclassLogic1.execute();
        subclassLogic2.execute();
    }
    • 자바8부터는 람다식을 지원한다.
    • 람다는 추상 클래스를 함수형 인터페이스를 통해서 익명 클래스를 만들 수 있게 한다. 

     

    실행 결과 및 요약도

    위의 코드는 다음과 같이 실행된다.

    1. 클라이언트가 execute()를 한다.
    2. 부모 클래스의 execute()가 실행된다. 
    3. 부모 클래스의 call() 함수를 실행한다
    4. call()함수가 오버라이딩 된 것을 알고 오버라이딩된 값을 실행한다. 

    실행 결과는 아래와 같다.

     


    로그 추적기 V4 만들기 : 템플릿 메서드 패턴 적용

    부모 클래스를 추상 클래스로 먼저 만들고, 자식 클래스가 상속받아 그것을 오버라이딩 하도록 작성한다. 

     

    부모 클래스 구현 : 템플릿 클래스 

    public abstract class TraceTemplate<T> {
    
        private final LogTrace trace;
    
        public TraceTemplate(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();
    }
    • 먼저 부모 클래스의 타입이 어떻게 될 지 모르니 제네릭을 사용해준다 <T>
    • 공통되는 부분을 execute()에 몰아넣어준다.
    • execute()에 변경될 부분(핵심 기능)을 선언해준다(call 함수)
    • call 함수를 추상 메서드로 선언해준다. 

     

    위처럼 부모 클래스를 만들어 주면, 로그 추적기에 적용할 준비가 완료되었다. 앞서 보았듯이, 자식 클래스를 실제로 구현할 필요 없이 익명 클래스를 넣어주면 되기 때문이다. 

     

    OrderControllerV4에 적용하기

    @RestController
    @Slf4j
    @RequiredArgsConstructor
    public class OrderControllerV4 {
    
        private final OrderServiceV4 orderService;
        private final LogTrace trace;
    
    
        @GetMapping("/v4/request")
        public String request(String itemId) {
    
            TraceTemplate<String> template = new TraceTemplate<>(trace){
                @Override
                protected String call() {
                    orderService.orderItem(itemId);
                    return "ok";
                }
            };
    
            return template.execute("orderController");
        }
    }
    • 템플릿을 만들고, execute()를 해주면 코드 리팩토링이 끝난다.
    • 이 때 템플릿은 call()함수의 오버라이딩을 위해서 익명 클래스를 생성해서 제공하는 방식으로 문제를 해결할 수 있다. 

     

    템플릿 메서드 패턴 적용 후기

    템플릿 메서드 패턴을 적용해서 기존 Try ~ Catch 코드를 좀 더 간결하게 작성할 수 있었다. 이것은 변하는 부분과 변하지 않는 부분을 나누어 모듈화를 했기 때문에 가능했다. 단순히 코드를 줄인 것이 아니다. 왜냐하면 단일 책임 원칙(SRP)를 구현했기 때문이다.

    기존에는 컨트롤러가 너무 많은 일을 했다. 핵심 비즈니스 로직도 실행했으며, 로그까지 찍는 코드가 들어갔었다. 너무 많은 책임이 있었다. 그런데 템플릿 메서드 패턴을 통해 템플릿에서 로그를 남기도록 했다. 즉, 템플릿은 로그만 찍고 컨트롤러는 핵심 로직만 실행하는 단일 책임 원칙을 준수하게 된 것이다. 

     

     

    전략 패턴 테스트 코드 작성

    전략 패턴은 변하는 부분은 Strategy라는 인터페이스를 만들고, 변하지 않는 부분은 Context라는 클래스를 만들어, Context가 Strategy를 참조하는 형태로 이루어진다. 

     

    Strategy 인터페이스 작성

    public interface Strategy {
        void call();
    }
    • Strategy는 한 가지 핵심 기능만 수행할 것이기 때문에 void Call()함수를 선언함. 

     

    Strategy 구현체 작성

    @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 출력");
        }
    }
    • Strategy 인터페이스를 구현한 StrategyLogic1,2를 작성함.
    • Call()함수를 오버라이딩 해서, 비즈니스 로직 1,2가 각각 출력되도록 함. 

     

    Context 클래스 구현(변하지 않는 부분, 템플릿 작성 → Strategy를 필드로 가지고 있음. )

    @Slf4j
    public class ContextV1 {
    
        private final Strategy strategy;
    
    
        public ContextV1(Strategy strategy) {
            this.strategy = strategy;
        }
    
        public void execute() {
            long startTimeMs = System.currentTimeMillis();
    
            strategy.call();
    
            long lastTimeMs = System.currentTimeMillis();
            long resultTime = lastTimeMs - startTimeMs;
            log.info("resultTime = {}", resultTime);
    
        }
    
    
    }
    • Context는 Strategy를 필드로 가지고 있음.
    • 생성 시점에 생성자를 통해 Context과 Strategy가 조립되어 사용됨. → 스프링의 DI와 유사.
    • 변하지 않는 부분은 실행 시간을 측정하는 것임. 

    실행은 다음과 같이 이루어진다.

     

    Context 클래스 구현(변하지 않는 부분, 템플릿 작성 → Strategy를 실행 시점에 전달받아 실행)

    @Slf4j
    public class ContextV2 {
    
    
        public void execute(Strategy strategy) {
            long startTimeMs = System.currentTimeMillis();
    
            strategy.call();
    
            long lastTimeMs = System.currentTimeMillis();
            long resultTime = lastTimeMs - startTimeMs;
            log.info("resultTime = {}", resultTime);
    
        }
    
    
    }
    • Context는 Strategy를 execute() 실행 시점에 인자로 전달 받음. 
    • 템플릿 콜백 메서드 패턴과 동일함. 

    실행은 다음과 같이 이루어진다. 

     

    필드로 Strategy를 가지는 Context의 테스트 코드

    @Test
    void strategyV3() {
    
        ContextV1 context1 = new ContextV1(new Strategy() {
            @Override
            public void call() {
                log.info("비즈니스 로직1 출력");
            }
        });
        ContextV1 context2 = new ContextV1(new Strategy() {
            @Override
            public void call() {
                log.info("비즈니스 로직2 출력");
            }
        });
    
        context1.execute();
        context2.execute();
    }
    • 익명 클래스를 통해 Context가 생성되는 시점에 Strategy를 주입해주었다.
    • 각 Strategy를 받은 Context를 실행하며 Strategy 패턴을 구현했다. 

     

    실행 시점에 Strategy를 전달하는 전략패턴의 테스트 코드(템플릿 콜백 패턴과 동일) 

    @Test
    void strategyV2() {
    
    
        ContextV2 contextV2 = new ContextV2();
    
        contextV2.execute(new Strategy() {
            @Override
            public void call() {
                log.info("비즈니스 로직 출력1");
            }
        });
        contextV2.execute(new Strategy() {
            @Override
            public void call() {
                log.info("비즈니스 로직 출력2");
            }
        });
    
    
    }
    • Template 실행 시점에 Strategy를 익명 클래스로 생성해서 전달한다. 
    • 따라서 Context는 하나만 있으면 된다. 

    템플릿 콜백 패턴(실행 시점에 Strategy를 전달하는 전략 패턴)으로 로그 추적기 구현하기

    콜백 인터페이스 코드

    public interface CallBack<T> {
        public T call();
    }
    
    • 콜백 인터페이스는 실행 시점에 전달되어 나중에 실행될 함수를 위한 인터페이스다.
    • 어떤 형태의 값이 전달될지 모르기 때문에 제네릭으로 선언한다. 

     

    템플릿 클래스 코드

    public class TemplateTrace<T> {
    
        private final LogTrace trace;
    
        public TemplateTrace(LogTrace trace) {
            this.trace = trace;
        }
    
        public T execute(String message, CallBack<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;
            }
        }
    
    }
    • 템플릿 클래스는 로그를 찍는다. 따라서 내부적으로 LogTrace를 가지게 한다.
    • LogTrace는 생성자를 통해 DI 받도록 한다. 
    • 인자로 CallBack 함수를 전달받는다. 그리고 execute() 내부에 CallBack.call()을 작성하여 콜백 함수가 실행될 수 있도록 한다. 

     

    OrderControllerV5 적용

    public class OrderControllerV5 {
    
        private final OrderServiceV5 orderService;
        private final TemplateTrace<String> templateTrace;
    
        public OrderControllerV5(OrderServiceV5 orderService, LogTrace trace) {
            this.orderService = orderService;
            this.templateTrace = new TemplateTrace(trace);
        }
    
        @GetMapping("/v5/request")
        public String request(String itemId) {
    
            return templateTrace.execute("orderController", new CallBack<String>() {
                @Override
                public String call() {
                    orderService.orderItem(itemId);
                    return "ok";
                }
            });
    
        }
    }
    • OrderController는 내부적으로 Template을 참조할 수 있도록 한다. 생성자를 통해 Template을 DI한다. 
    • 생성된 Template을 실행하는 시점에 익명 클래스를 생성해서 CallBack 함수를 전달해서 템플릿 콜백 패턴을 구현한다. 

     

     

    정리

    각 컨트롤러에서 Template을 가지게 되는데, 각 Template에서 생성하는 형태로 코드가 작성되었다. 그런데 조립하는 부분이 크게 의미가 없고, 실제 실행하는 시점에 콜백 함수가 전달되기만 하면 되기 때문에 Template을 스프링 빈으로 등록해서 DI를 받아도 괜찮다. 

     


    전체 정리

    이번 포스팅에서는 변하는 코드(핵심 코드)와 변하지 않는 코드(부가 기능 코드)를 분리하고 모듈화를 했다. 그리고 최종적으로 템플릿 콜백 패턴을 적용하고, 콜백으로 람다를 사용해서 코드를 최소화했다. 그렇지만 한계에 봉착했다. 

    이런 패턴을 적용하게 되면, 로그 추적기 적용을 위해서 원본 코드에 반드시 손을 대야한다는 것이다. 더 쉽게 수백개를 고치나, 어렵게 수백개를 고치나 수백개를 고쳐야하는 것은 매한가지다. 이런 한계를 어떻게 극복할 수 있을까? 바로 '프록시'개념을 도입해서 해결할 수 있다. 

    댓글

    Designed by JB FACTORY