본문 바로가기

Spring

[Spring] 디자인 패턴 - 전략 패턴(Strategy Pattern)

이전글에서 다뤘던 템플릿 메서드 패턴은 핵심기능과 부가기능을 분리해주기는 하지만 상속을 사용하기 때문에 부모, 자식 클래스 간에 강한 연관 관계가 생기거나, 핵심기능 클래스를 계속 생성해주어야 하는 등 여러가지 단점이 있었다. 전략 패턴은 상속이 아닌 위임을 사용하여 이러한 단점을 해결하면서 핵심기능과 부가기능을 분리한다.

 

위임이란 특정 클래스가 다른 클래스의 객체를 멤버로 갖고 있는 형태로 스프링의 의존성 주입 방식이 위임을 사용한 예시이다.

전략 패턴은 아래와 같이 변하지 않는 부분을 Context 클래스에 두고 변하는 부분을 Strategy 인터페이스로 만들어 해당 인터페이스를 구현하여 주입해주는 패턴이다. 이렇게 하면 Context 클래스가 인터페이스에만 의존하기 때문에 구현체를 변경하거나 새로 만들어도 Context 클래스에 영향을 미치지 않는다.

 

문제 코드

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

 

위 코드에서 logic1()과 logic2()는 아래와 같이 중복되는 코드가 존재한다. 해당 코드는 부가 기능으로 두 logic에서 동일한 기능을 수행한다. 이러한 설계는 만약 부가 기능을 사용하는 logic이 100개가 존재할 때 이 부가 기능을 수정하려면 100개의 logic을 모두 수정해주어야 하기 때문에 좋은 방법이 아니다.

 

만약 공통된 코드를 추출하여 하나의 클래스로 관리할 수 있다면 부가 기능을 수정할 때 해당 클래스만 수정해주면 되기 때문에 유지보수하기 쉬워진다. 전략 패턴을 적용하여 문제를 해결해보자.

 

long startTime = System.currentTimeMillis();

...

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

전략 패턴 적용

Strategy

public interface Strategy {
    void call();
}

 

먼저 Context 클래스에 주입할 인터페이스를 생성한다. call() 메서드가 변화하는 코드인 핵심 로직이다.

StrategyLogic

@Slf4j
public class StrategyLogic1 implements Strategy {

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

 

Strategy 인터페이스를 구혆나 핵심 로직이다.

Context

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

 

중복되는 코드를 따로 추출한 Context 클래스이다. 다형성을 사용하여 Strategy 인터페이스를 필드에 선언한 뒤 주입받는다. 주입할 때는 인터페이스를 구현한 클래스를 주입해주며 Context 로직 중간에 strategy.call() 메서드를 호출하여 주입받은 strategy 로직을 실행해준다.

테스트코드

StrategyV1

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

 

Strategy 인터페이스를 구현한 StrategyLogic1() 객체 인스턴스를 생성한 뒤 Context 클래스에 주입했다.

 

StrategyV2

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

    ContextV1 context1 = new ContextV1(strategyLogic1);
    context1.execute();

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

    ContextV1 context2 = new ContextV1(strategyLogic2);
    context2.execute();
}

 

Strategy 인터페이스를 따로 클래스를 만들어 구현하지 않고 Strategy 인스턴스를 생성할 때 익명 내부 클래스를 사용하여 주입했다.

 

StrategyV3

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

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

 

StrategyV2 처럼 Strategy 인터페이스를 따로 클래스를 만들어 구현하지 않았다. 또한 익명 내부 클래스를 따로 Strategy 변수에 담아두지 않고 Context 인스턴스를 생성할 때 바로 주입했다. 

 

StrategyV4

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

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

 

Strategy 인터페이스의 메서드가 하나만 있는 경우 위와 같이 Lambda 함수를 사용하여 주입할 수 있다.

 

위 테스트 방법들은 모두 전략 패턴을 사용하였으며 공통적으로 Context 클래스에 중복되는 로직을 작성하고 Strategy에 비즈니스 로직을 작성하여 주입해주었다. 이러한 방식을 선 조립, 후 실행 방식이라고 하며 애플리케이션 실행 시점에 의존관계 주입을 통해 필요한 의존관계를 모두 맺어두고 난 다음에 실제 요청을 처리한다. 

 

하지만 이러한 방식은 조립한 이후에는 Strategy를 변경하기가 까다롭다는 단점이 있다. setter를 사용하여 변경할 수 있지만 setter를 사용하면 Context 클래스를 싱글톤으로 사용할 때 동시성 이슈가 발생할 수 있으며 내가 아닌 다른 누군가에 의해 Strategy 전략이 수정될 위험이 있다. 

 

이를 해결하기 위해 Context 클래스 필드에 Strategy 전략을 주입하는 방법이 아닌 execute() 파라미터로 method를 넘겨주는 방식이 있다.

ContextV2

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

 

ContextV1 클래스와 달리 Strategy 전략을 execute() 메서드의 파라미터로 넘겨준뒤 strategy.call()을 호출하여 핵심로직을 실행시켰다. 실행할 때는 아래와 같이 execute()를 호출할 때 Strategy를 넘겨주면 된다.

 

@Slf4j
public class ContextV2Test {

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

    /**
     * 전략 패턴 익명 내부 클래스2, 람다
     */
    @Test
    void strategyV3() {
        ContextV2 context = new ContextV2();
        context.execute(() -> log.info("비즈니스 로직1 실행"));
        context.execute(() -> log.info("비즈니스 로직2 실행"));
    }
}

 

이러한 패턴을 템플릿 콜백 패턴이라고 하며 템플릿 콜백 패턴은 전략 패턴의 일종이다.

 

ContextV1 클래스와 ContextV2 클래스는 아래와 같은 장단점이 있다.

 

ContextV1

  • Context 를 실행하는 시점에는 이미 조립이 끝났기 때문에 전략을 신경쓰지 않고 단순히 실행만 하면 된다.
  • 조립이 끝난 후에는 전략을 수정하기 까다롭다.

ContextV2

  • 실행할 때마다 유연하게 전략을 수정할 수 있다.
  • 실행할 때마다 유연하게 전략을 정해주어야 하기 때문에 불편하다.

강의에서는 ContextV1 방법 보다 ContextV2 방법이 더 적합하다고 소개하고 있다. 그 이유는 현재 우리가 해결하고자 하는 문제가 핵심기능과 부가기능을 단일 책임 원칙을 지키며 분리하는 것이 목적인데 두 가지 방법 다 해당 문제를 해결할 수는 있지만 분리한 핵심 기능을 더 유연하게 사용할 수 있는 ContextV2가 더 목적에 부합하기 때문이다. 다만 두 가지 방법 모두 장단점이 있으니 상황에 맞게 사용하는 연습이 필요할 것 같다.