본문 바로가기
프로그래밍

[디자인패턴] 실무에서 사용해본 전략 패턴 (Strategy Pattern)

by kdohyeon (김대니) 2023. 2. 2.
반응형

신입 개발자일 때 배워서 여태 잘 써먹고 있는 디자인 패턴인 "전략 패턴 (Strategy Pattern)"을 소개해보려고 한다. 

전략패턴이란?

하나의 행동에 대해 수행 가능한 여러 전략을 만들고 자바의 다형성을 통해 전략을 선택해서 실행하는 디자인 패턴

전략 (Strategy) 인터페이스를 구현하는 여러 전략 구현체들 (ConcreteStrategies)

예시 소개

커머스 사이트에서는 많은 상품들이 존재하고, 그 상품들은 특정 기준에 따라 점수가 메겨지고 높은 순서가 상품 목록 상단에 위치하게 된다. 상품에 대한 점수화를 구현하기 위해 아래와 같은 요구사항이 들어왔다고 가정해보자.

1. 각 상품의 최종 점수는 다양한 기준에 따라 계산된 점수를 모두 합하여 계산한다.
2. 점수를 계산하는 알고리즘은 변동될 수 있으며 새로운 알고리즘이 추가, 기존 알고리즘이 삭제될 수 있다.
3. 상품의 유형에 따라 특정 알고리즘만 적용하여 계산한다.

상품에 대한 점수를 계산하는 알고리즘은 1) 구매 전환율 기준, 2) 상품 조회수 기준, 그리고 3) 광고비 기준으로 설정된다.

만약 디자인 패턴을 적용하지 않고 구현을 한다면?

public BigDecimal getTotalScore(Product product) {
    BigDecimal totalScore = new BigDecimal();

    if (product.getType() == ProductType.일반) {
        BigDecimal 구매전환율점수 = new 구매전환율계산클래스(product);
        return 구매전환율점수;
    } else if (product.getType() == ProductType.광고) {
        BigDecimal 구매전환율점수 = new 구매전환율계산클래스(product);
        BigDecimal 광고점수 = new 광고계산클래스(product);
        return 구매전환율점수.add(광고점수);
    } else if (product.getType() == ProductType.프레시) {
        BigDecimal 구매전환율점수 = new 구매전환율계산클래스(product);
        BigDecimal 조회수점수 = new 조회수계산클래스(product);
        return 구매전환율점수.add(조회수점수);
    }
    
    throw new InvalidProductTypeException(String.format("허용하지 않는 상품 유형입니다=%s", product.getType().name()));
}

아직까지는 간단한 요구사항이어서 충분히 if-else 로도 구현이 가능한 수준이다.

신규 요구사항

신규 요구사항이 들어왔다.

- 요구사항이 변경되어 "일반"과 "광고" 상품 유형에 대해 점수 계산 알고리즘을 수정해야 한다.

각 상품 유형에 대한 알고리즘을 수정하기 위해 getTotalScore(Product product) 를 수정해야 한다. 만약 알고리즘 로직이 더 복잡해져야 한다면 해당 메소드는 훨씬 더 길어질 것이고, 점점 유지보수 비용이 더 높아질 수 밖에 없다. 테스트 비용도 훨씬 더 많이 발생한다.

전략 패턴을 적용해보자

위 문제를 해결하기 위해서는 전략 패턴을 적용해볼 수 있다. ProductScoreCalculator 라는 전략 인터페이스를 생성하고 이를 구현하는 각 알고리즘의 구현체를 구현한다. 각 상품 유형에 대한 알고리즘의 업데이트가 필요하면 이제는 각 클래스 내부에서 업데이트가 이루어질 수 있다. 테스트 또한 용이하다.

// 전략 (Strategy) 인터페이스
public interface ProductScoreCalculator {
    BigDecimal calculateScore(Product product);
}

// 구현체 클래스 1 (Concrete Strategy 1)
public class 일반_ScoreCalculator implements ProductScoreCalculator {
    @Override
    public BigDecimal calculateScore(Product product) {
        BigDecimal 구매전환율점수 = new 구매전환율계산클래스(product);
        return 구매전환율점수;
    }
}

// 구현체 클래스 2 (Concrete Strategy 2)
public class 광고_ScoreCalculator implements ProductScoreCalculator {
    @Override
    public BigDecimal calculateScore(Product product) {
        BigDecimal 구매전환율점수 = new 구매전환율계산클래스(product);
        BigDecimal 광고점수 = new 광고계산클래스(product);
        return 구매전환율점수.add(광고점수);
    }
}

// 구현체 클래스 3 (Concrete Strategy 3)
public class 프레시_ScoreCalculator implements ProductScoreCalculator {
    @Override
    public BigDecimal calculateScore(Product product) {
        BigDecimal 구매전환율점수 = new 구매전환율계산클래스(product);
        BigDecimal 조회수점수 = new 조회수계산클래스(product);
        return 구매전환율점수.add(조회수점수);
    }
}

// 전략을 호출하는 곳, Context
public class ProductScoreCalculatorFactory {
    public BigDecimal getTotalScore(Product product) {
        BigDecimal totalScore = new BigDecimal();

        if (product.getType() == ProductType.일반) {
            return new 일반_ScoreCalculator().calculateScore(product);
        } else if (product.getType() == ProductType.광고) {
            return new 광고_ScoreCalculator().calculateScore(product);
        } else if (product.getType() == ProductType.프레시) {
            return new 프레시_ScoreCalculator().calculateScore(product);
        }
    
        throw new InvalidProductTypeException(String.format("허용하지 않는 상품 유형입니다=%s", product.getType().name()));
    }
}

새로운 상품 유형이 추가된다면?

if-else 의 조건문이 지속적으로 생길 수 밖에 없고, 상품 유형이 많아지면 많아질 수록 ProductScoreCalculatorFactory 의 복잡도도 올라갈 수 밖에 없다. 또한, 신규 상품 유형에 대한 ScoreCalculator 가 생기면 ProductScoreCalculatorFactory 도 함께 수정해주어야 하기 때문에 OCP (Open-Close Principle) 도 따르지 못하고 있다.

이를 해결하기 위해서는 스프링의 핵심 개념을 활용하면 된다.

스프링이 적용된 전략 패턴

스프링에서는 자바 객체를 빈(Bean) 으로 등록을 해두면 ApplicationContext 에서 주입받아 사용할 수 있다. 각 전략 구현체를 하나의 Bean 으로 등록하고 Context 에서 이를 List 형태로 불러와 사용할 수 있다. 아래 코드를 참고하자.

// 전략 (Strategy) 인터페이스
public interface ProductScoreCalculator {
    BigDecimal calculateScore(Product product);
    boolean isTarget(ProductType productType); // 추가
}

// 구현체 클래스 1 (Concrete Strategy 1)
public class 일반_ScoreCalculator implements ProductScoreCalculator {
    @Override
    public BigDecimal calculateScore(Product product) {
        ...
    }
    
    @Override
    public boolean isTarget(ProductType productType) {
        return productType == ProductType.일반;
    }
}

// 구현체 클래스 2 (Concrete Strategy 2)
public class 광고_ScoreCalculator implements ProductScoreCalculator {
    @Override
    public BigDecimal calculateScore(Product product) {
        ...
    }
    
    @Override
    public boolean isTarget(ProductType productType) {
        return productType == ProductType.광고;
    }
}

// 구현체 클래스 3 (Concrete Strategy 3)
public class 프레시_ScoreCalculator implements ProductScoreCalculator {
    @Override
    public BigDecimal calculateScore(Product product) {
        ...
    }
    
    @Override
    public boolean isTarget(ProductType productType) {
        return productType == ProductType.프레시;
    }
}

// 전략을 호출하는 곳, Context
public class ProductScoreCalculatorFactory {
    @Autowired // 간략한 코드를 위해 @Autowired 사용. 실무에서는 "생성자 기반의 의존성 주입"을 추천
    private List<ProductScoreCalculator> calculators;

    public BigDecimal getTotalScore(Product product) {
        return calculators.stream()
                    .filter(calculator -> calculator.isTarget(product.getType()))
                    .findAny()
                    .orElseThrow(() -> new InvalidProductTypeException(String.format("허용하지 않는 상품 유형입니다=%s", product.getType().name())))
                    .calculate(product);
}

전략 패턴의 주의사항

어떻게 보면 if-else 조건문을 대신할 수 있는 디자인 패턴처럼 보이기는 하지만 몇 개 안되는 조건문을 없애기 위해 수많은 클래스를 생성하는 것은 가독성도 떨어지고, 프로젝트 구조 자체의 복잡성도 올라가기 때문에 불필요하다고 생각한다. 만약 전략의 개수도 많아질 수 있고, 각 전략별 변경도 잦은 경우라면 전략 패턴을 충분히 고려해볼 수 있다고 생각한다

반응형

댓글