개발놀이터

디자인패턴 (템플릿 메서드 패턴, 전략 패턴) 본문

Spring/Spring

디자인패턴 (템플릿 메서드 패턴, 전략 패턴)

마늘냄새폴폴 2022. 1. 13. 19:11

이 포스팅은 인프런 김영한 님의 스프링 핵심 원리 고급 편을 보고 각색한 포스팅입니다. 자세한 내용은 강의를 확인해주세요

 

 

오늘 알아볼 패턴들을 보기 전에 간단한 예시를 먼저 소개하겠다. 

 

비즈니스 로직을 실행하는데 있어서 어딘가에서 병목현상이 자꾸 벌어진다는 것을 확인했다. 어디서 병목현상이 발생하는지 알아보기 위해서 비즈니스 로직 앞뒤에 시간을 체크해 걸린 시간을 뽑아오라는 임무를 부여받았다. 

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

간단한 예제이다. 비즈니스 로직1 실행에 걸린 시간과 비즈니스 로직2 실행에 걸린 시간을 각각 계산해서 화면에 뿌려주는 역할을 한다. 

 

위의 예제에서 보아하니 일정한 패턴이 보인다. 비즈니스 로직을 실행하는 부분을 제외하면 모든것이 다 똑같다. 

 

 

GOF 디자인패턴에 템플릿 메서드 패턴이라는 디자인패턴이 있다. 이에 대해 살펴보자

 

그림만 보면 이해가 잘 안될것이다. AbstractTemplate에 execute와 call을 나눠서 변하지 않는 부분은 execute에 변하는 부분은 call에 넣어두는 것이다. 그럼 수백 수천개의 반복된 코드를 한곳에서 관리할 수 있게 된다.

 

AbstractTemplate.java

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

먼저 AbstractTemplate을 추상클래스로 만든다. 그리고 execute에 변하지 않는 부분(시간을 체크하는 로직)을 call에 변하는 부분(비즈니스 로직)을 넣는다. execute를 실행하면서 그때그때 call을 호출해줄 것이다. 그때그때 변하는 로직은 상속을 통해 풀어낼 것이다.

 

SubClassLogic1.java

@Slf4j
public class SubClassLogic1 extends AbstractTemplate{
    @Override
    protected void call() {
        log.info("비즈니스 로직 1 실행");
    }
}

SubClassLogic2.java

@Slf4j
public class SubClassLogic2 extends AbstractTemplate{
    @Override
    protected void call() {
        log.info("비즈니스 로직 2 실행");
    }
}

 

 

    /**
     * 템플릿 메서드 패턴 적용
     */
    @Test
    void templateMethodV1() {
        AbstractTemplate template1 = new SubClassLogic1();
        template1.execute();

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

template1은 SubClassLogic1에서 구현한 call이 호출될 것이고 template2는 SubClassLogic2에서 구현한 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("클래스 이름1={}", template2.getClass());
        template2.execute();
    }

 

GOF 다지안 패턴에서는 템플릿 메서드 패턴을 다음과 같이 정의했다.

 

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

 

풀어서 설명하면 다음과 같다.

 

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

 

하지만

템플릿 메서드 패턴은 상속을 사용한다. 따라서 상속에서 오는 단점들을 그대로 안고간다. 특히 자식 클래스가 부모 클래스와 컴파일 시점에 강하게 결합되는 문제가 있다. 이것은 의존관계에 대한 문제이다. 자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않는다.

 

SubClassLogic1, 2를 살펴보면 부모 클래스를 상속받고 있지만 부모 클래스의 그 어떠한 것도 이용하지 않는다. 

 

상속을 받는다는 것은 특정 부모 클래스를 의존하고 있다는 것이다. 따라서 부모 클래스의 기능을 사용하든 사용하지 않든간에 부모 클래스를 강하게 의존하게 된다. 여기서 강하게 의존한다는 뜻은 자식 클래스의 코드에 부모 클래스의 코드가 명확하게 적혀 있다는 뜻이다. 

 

자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않는데 부모 클래스를 알아야한다. 이것은 좋은 설계가 아니다. 그리고 이런 잘못된 의존관계 때문에 부모 클래스를 수정하면, 자식 클래스에도 영향을 줄 수 있다.

 

템플릿 메서드 패턴과 비슷한 역할을 하면서 상속의 단점을 제거할 수 있는 디자인 패턴이 바로 "전략 패턴"이다. 

 

 

이번에는 동일한 문제를 전략 패턴을 사용해서 해결해보겠다.

 

템플릿 메서드 패턴은 부모 클래스에 변하지 않는 템플릿을 두고, 변하는 부분을 자식 클래스에 두어서 상속을 문제를 해결했다.

 

전략 패턴은 변하지 않는 부분을 Context라는 곳에 두고, 변하는 부분을 Strategy라는 인터페이스를 만들고 해당 인터페이스를 구현하도록 해서 문제를 해결한다. 상속이 아니라 위임으로 문제를 해결하는 것이다.

 

전략 패턴에서 Context는 변하지않는 템플릿 역할을 하고 Strategy는 변하는 알고리즘 역할을 한다.

 

Strategy.java

public interface Strategy {

    void call();
}

StrategyLogic1.java

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

StrategyLogic2.java

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

 

이번에는 전략을 필드에 보관하는 방식과 파라미터로 넘기는 두가지 방식을 살펴볼 것이다.

ContextV1

/**
 * 필드에 전략을 보관하는 방식
 */
@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);
    }
}
    @Test
    void strategyV1() {
        StrategyLogic1 strategyLogic1 = new StrategyLogic1();
        ContextV1 contextV1 = new ContextV1(strategyLogic1);
        contextV1.execute();

        StrategyLogic2 strategyLogic2 = new StrategyLogic2();
        ContextV1 contextV2 = new ContextV1(strategyLogic2);
        contextV2.execute();
    }

이 방식 어쩐지 익숙하지 않은가? 인터페이스의 다형성을 이용해 원하는 로직을 주입받아 사용하는 것

 

그렇다 바로 스프링에서 의존관계를 주입할 때 사용하는 방식이다. 

https://coding-review.tistory.com/83

 

스프링을 사용해서 DI 구현하기

이 포스팅은 인프런 김영한 님의 스프링 핵심 원리 기본 편을 보고 각색한 포스팅입니다. 자세한 내용은 강의를 확인해주세요 순수한 자바를 이용해서 DI를 구현해봤다. https://coding-review.tistory.co

coding-review.tistory.com

 

템플릿 메서드 패턴때와 마찬가지로 익명 내부 클래스를 사용해서 풀어낼 수도 있다.

    @Test
    void strategyV2() {
        Strategy strategyLogic1 = new Strategy() {

            @Override
            public void call() {
                log.info("비즈니스 로직 1 실행");
            }
        };
        ContextV1 contextV1 = new ContextV1(strategyLogic1);
        contextV1.execute();

        Strategy strategyLogic2 = new Strategy() {

            @Override
            public void call() {
                log.info("비즈니스 로직 2 실행");
            }
        };
        ContextV1 contextV2 = new ContextV1(strategyLogic2);
        contextV2.execute();
    }

익명 내부 클래스를 선언한 뒤 주입하는게 아니라 선언하자마자 바로 주입받는 방법도 있다.

    @Test
    void strategyV3() {
        ContextV1 contextV1 = new ContextV1(new Strategy() {

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

        ContextV1 contextV2 = new ContextV1(new Strategy() {

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

익명 내부 클래스를 자바8부터 제공하는 람다로 변경할 수 있다. 람다로 변경하려면 인터페이스에 메서드가 한개만 있으면 되는데, 여기에서 제공하는 Strategy 인터페이스는 메서드가 한개만 있으므로 람다로 사용할 수 있다.

    @Test
    void strategyV4() {
        ContextV1 contextV1 = new ContextV1(() -> log.info("비즈니스 로직 1 실행"));
        contextV1.execute();

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

 

 

전략 패턴을 그림으로 설명하면 다음과 같다.

1. Context에 원하는 Strategy 구현체를 주입한다.

2. 클라이언트는 Context를 실행한다.

3. Context는 Context로직을 실행한다.

4. Context로직 중간에 strategy.call()을 호출해서 주입받은 strategy로직을 실행한다.

5. Context는 나머지 로직을 실행한다.

 

 

이번에는 주입받아 사용하는게 아니라 파라미터로 strategy를 넘겨받아 사용하는 방법에 대해 알아보자

ContextV2.java

/**
 * 전략을 파라미터로 전달받는 방식
 */
@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);
    }
}

ContextV2는 전략을 필드로 가지지 않는다. 대신에 전략을 execute가 호출될 때 마다 항상 파라미터로 전달 받는다.

    /**
     * 전략패턴 사용
     */
    @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 실행");
            }
        });
    }

    @Test
    void strategyV3() {
        ContextV2 context = new ContextV2();
        context.execute(() -> log.info("비즈니스 로직 1 실행"));
        context.execute(() -> log.info("비즈니스 로직 2 실행"));
    }

 

지금까지 우리는 변하는 코드와 변하지 않는 코드를 분리하고 더 적은 코드로 적용하기위해 고군분투했다. 

 

그런데 지금까지 설명한 방식의 한계는 아무리 최적화를 해도 결국 원본 코드를 수정해야 한다는 점이다. 클래스가 수백개면 수백개를 더 힘들게 수정하는가 덜 힘들게 수정하는가의 차이가 있을 뿐 본질적으로 코드를 다 수정해야 하는 것은 마찬가지이다. 

 

수많은 개발자들이 이 문제에 대해서 집요하게 고민해왔고, 여러가지 방향으로 해결책을 만들어왔다. 지금부터 원본 코드를 손대지 않고 코드를 적용할 수 있는 방법을 알아보자. 그러기 위해선 프록시의 개념을 이해해야한다.

 

다음 포스팅은 프록시와 프록시패턴, 데코레이터 패턴에 대해서 풀어보는 시간을 갖도록 하겠다.

'Spring > Spring' 카테고리의 다른 글

동적 프록시  (0) 2022.01.18
디자인패턴 (프록시 패턴, 데코레이터 패턴)  (0) 2022.01.17
동시성문제와 스레드 로컬  (0) 2022.01.10
스프링 MVC 구조 파악하기  (0) 2022.01.07
MVC 프레임워크 만들기  (0) 2022.01.06