Strategy Pattern: if-else를 인터페이스로 대체하기

1. 개요

길고 긴 if-else 또는 switch 문으로 타입에 따라 동작을 선택하는 것은 코드가 곧 유지보수하기 어려워질 신호입니다. 새로운 타입을 추가할 때마다 같은 함수를 수정해야 하므로 Open/Closed Principle을 위반합니다. 이 글에서는 Strategy Pattern을 사용하여 각 동작을 독립적인 클래스로 분리하고, 같은 인터페이스를 구현하게 한 후, if-else를 직접 작성하는 대신 Spring이 적절한 strategy를 자동으로 선택하도록 하는 방법을 보여줍니다. 이 글을 읽고 나면, 복잡한 수수료/할인 계산 서비스를 직접 리팩토링할 수 있게 될 것입니다.

2. 문제: 시간이 지나면서 커지는 if-else 블록

결제 방식에 따라 거래 수수료를 계산하는 PaymentService가 있다고 가정해봅시다:

public class PaymentService {

    public double calculateFee(String paymentType, double amount) {
        if (paymentType.equals("CREDIT_CARD")) {
            return amount * 0.03;
        } else if (paymentType.equals("BANK_TRANSFER")) {
            return amount * 0.01;
        } else if (paymentType.equals("E_WALLET")) {
            return amount * 0.015;
        } else {
            throw new IllegalArgumentException("Unsupported type: " + paymentType);
        }
    }
}

문제점:

  • 새로운 결제 방식(예: CRYPTO)을 추가할 때마다 이 메서드를 직접 수정해야 합니다.
  • 각 타입의 수수료 계산 로직이 한 함수에 뒤섞여 있어서 개별적으로 테스트하기 어렵습니다.
  • String으로 비교하면 오타가 나기 쉽고, 컴파일 타임에 검사할 수 없습니다.

3. 해결책: Strategy 인터페이스 정의하기

핵심 아이디어: “타입에 따라 변하는 동작”을 인터페이스로 추출하고, 각 타입을 별도의 구현으로 분리하는 것입니다.

public interface FeeStrategy {
    double calculateFee(double amount);
}

이제 각 strategy는 독립적인 클래스이므로 읽기 쉽고 테스트하기 쉬워집니다:

public class CreditCardFeeStrategy implements FeeStrategy {
    @Override
    public double calculateFee(double amount) {
        return amount * 0.03;
    }
}
public class BankTransferFeeStrategy implements FeeStrategy {
    @Override
    public double calculateFee(double amount) {
        return amount * 0.01;
    }
}
public class EWalletFeeStrategy implements FeeStrategy {
    @Override
    public double calculateFee(double amount) {
        return amount * 0.015;
    }
}

PaymentService는 이제 FeeStrategy를 받아서 단순히 호출하기만 하면 되며, 내부 로직을 알 필요가 없습니다:

public class PaymentService {

    private final FeeStrategy feeStrategy;

    public PaymentService(FeeStrategy feeStrategy) {
        this.feeStrategy = feeStrategy;
    }

    public double calculateFee(double amount) {
        return feeStrategy.calculateFee(amount);
    }
}
  • feeStrategy는 생성자를 통해 주입되므로, PaymentService는 CreditCardFeeStrategy인지 다른 타입인지 알 필요가 없습니다.
  • 새로운 결제 방식 추가 = FeeStrategy를 구현하는 새 클래스 추가, PaymentService 수정 불필요.

4. strategy를 올바르게 선택하기: 다른 곳으로 if-else 옮기지 않기

자주 있는 실수: PaymentService에서 if-else를 제거했지만 서비스를 호출하는 곳에 다시 만드는 것입니다. 이를 방지하려면 Map<String, FeeStrategy>를 레지스트리로 사용하여 초기화 지점(factory)에서 strategy를 키에 따라 등록하고, 코드 전체에 if-else를 분산시키지 않아야 합니다.

public class FeeStrategyFactory {

    private final Map<String, FeeStrategy> strategies;

    public FeeStrategyFactory() {
        this.strategies = Map.of(
            "CREDIT_CARD", new CreditCardFeeStrategy(),
            "BANK_TRANSFER", new BankTransferFeeStrategy(),
            "E_WALLET", new EWalletFeeStrategy()
        );
    }

    public FeeStrategy resolve(String paymentType) {
        FeeStrategy strategy = strategies.get(paymentType);
        if (strategy == null) {
            throw new IllegalArgumentException("Unsupported type: " + paymentType);
        }
        return strategy;
    }
}
  • strategies는 불변 맵이며, factory 초기화 시 한 번만 구성됩니다.
  • resolve()는 유일한 “조건 분기” 장소이지만, 맵만 조회할 뿐 비즈니스 로직은 포함하지 않습니다. 실제 로직은 각 strategy에 있습니다.

시도해보기:

FeeStrategyFactory factory = new FeeStrategyFactory();
FeeStrategy strategy = factory.resolve("E_WALLET");
PaymentService service = new PaymentService(strategy);

double fee = service.calculateFee(1_000_000);
System.out.println(fee);
15000.0

주의: strategy 목록이 고정적이고 거의 변하지 않으면(2–3개), 간단한 if-else도 괜찮습니다. Strategy Pattern은 타입 수가 증가하거나 각 타입의 로직이 복잡해서 개별 테스트가 필요할 때 가치를 발휘합니다.

5. Spring 활용하기: 컨테이너가 자동으로 strategy 목록 주입하게 하기

Spring Boot에서는 Map을 직접 작성할 필요가 없습니다. 각 strategy에 @Component를 표시하면 Spring이 같은 인터페이스를 구현하는 모든 bean을 자동으로 List에 수집합니다.

public interface FeeStrategy {
    String getPaymentType();
    double calculateFee(double amount);
}

각 strategy가 어느 타입을 처리하는지 자체적으로 선언하도록 getPaymentType() 메서드를 추가합니다:

@Component
public class CreditCardFeeStrategy implements FeeStrategy {

    @Override
    public String getPaymentType() {
        return "CREDIT_CARD";
    }

    @Override
    public double calculateFee(double amount) {
        return amount * 0.03;
    }
}

이제 Factory는 Spring이 주입한 List<FeeStrategy>를 받아서 생성자에서 map을 구성합니다:

@Component
public class FeeStrategyFactory {

    private final Map<String, FeeStrategy> strategies = new HashMap<>();

    public FeeStrategyFactory(List<FeeStrategy> strategyList) {
        for (FeeStrategy strategy : strategyList) {
            strategies.put(strategy.getPaymentType(), strategy);
        }
    }

    public FeeStrategy resolve(String paymentType) {
        FeeStrategy strategy = strategies.get(paymentType);
        if (strategy == null) {
            throw new IllegalArgumentException("Unsupported type: " + paymentType);
        }
        return strategy;
    }
}
  • Spring은 FeeStrategy를 구현하는 모든 bean을 초기화 순서대로 strategyList에 넣습니다.
  • 생성자는 FeeStrategyFactory가 생성될 때 map을 한 번만 구성합니다 — strategy 선택 전체 흐름에 if-else가 없습니다.
  • 새로운 CryptoFeeStrategy 추가: 클래스를 만들고 @Component를 붙이기만 하면 Spring이 자동으로 인식합니다. FeeStrategyFactory를 다시 빌드할 필요가 없습니다.

PaymentService는 @Service로 이 factory를 사용합니다:

@Service
public class PaymentService {

    private final FeeStrategyFactory feeStrategyFactory;

    public PaymentService(FeeStrategyFactory feeStrategyFactory) {
        this.feeStrategyFactory = feeStrategyFactory;
    }

    public double calculateFee(String paymentType, double amount) {
        FeeStrategy strategy = feeStrategyFactory.resolve(paymentType);
        return strategy.calculateFee(amount);
    }
}

6. 각 strategy와 factory에 대한 테스트 작성하기

각 strategy가 독립적인 클래스이므로 mock 없이 각각을 따로 테스트할 수 있습니다:

class CreditCardFeeStrategyTest {

    private final CreditCardFeeStrategy strategy = new CreditCardFeeStrategy();

    @Test
    void shouldCalculate3PercentFee() {
        double fee = strategy.calculateFee(1_000_000);

        assertThat(fee).isEqualTo(30_000.0);
    }
}

Factory에 대한 테스트는 “strategy 선택”이 올바르게 작동하고 잘못된 if-else로 떨어지지 않음을 보장합니다:

class FeeStrategyFactoryTest {

    private final FeeStrategyFactory factory = new FeeStrategyFactory(
        List.of(new CreditCardFeeStrategy(), new EWalletFeeStrategy())
    );

    @Test
    void shouldResolveCorrectStrategy() {
        FeeStrategy strategy = factory.resolve("E_WALLET");

        assertThat(strategy).isInstanceOf(EWalletFeeStrategy.class);
    }

    @Test
    void shouldThrowWhenTypeNotSupported() {
        assertThatThrownBy(() -> factory.resolve("CRYPTO"))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("CRYPTO");
    }
}
  • shouldResolveCorrectStrategy는 factory가 키에 따라 올바른 타입의 strategy를 반환하는지 확인합니다.
  • shouldThrowWhenTypeNotSupported는 strategy를 찾을 수 없을 때도 명확한 오류를 반환함을 보장하며, 기존 else 동작과 같지만 한 곳에 집중되어 있습니다.

7. String 키 대신 Enum 사용하기 (더 안전한 선택지)

String 키에서 오타를 걱정한다면, 맵 키로 enum을 사용하여 컴파일러가 오류를 조기에 잡을 수 있습니다:

public enum PaymentType {
    CREDIT_CARD, BANK_TRANSFER, E_WALLET
}
public interface FeeStrategy {
    PaymentType getPaymentType();
    double calculateFee(double amount);
}

이 방식은 위의 전체 구조를 유지하면서 Map<PaymentType, FeeStrategy>의 키 타입만 변경합니다. IDE가 enum 값을 완전히 제안하므로 문자열을 잘못 입력하지 않습니다.

8. Strategy Pattern을 사용하지 말아야 할 때

  • 로직 분기가 2개뿐이고 확장 가능성이 거의 없다면 — 2–3개의 클래스를 추가로 만드는 것보다 간단한 if-else가 읽기 더 쉽습니다.
  • 각 분기의 로직이 90% 같고 몇 가지 매개변수만 다르다면 — strategy를 사용하면 거의 중복되는 많은 클래스가 생깁니다. 이 경우 구성 필드(예: 수수료율)가 있는 enum이 더 간결합니다.
  • 동작이 고정된 “타입”이 아니라 복잡한 런타임 조건(여러 기준 조합)에 따라 선택된다면 — 이 경우 Strategy Pattern만으로는 부족하고, Chain of Responsibility나 rule engine을 고려해야 합니다.

주의: pattern이 “더 전문적으로 보인다”고 해서 적용하지 마세요. 분기 수가 시간이 지나면서 증가할 것 같거나 각 분기의 로직이 충분히 복잡해서 개별 테스트가 필요할 때만 사용하세요.

9. 결론

Strategy Pattern은 길고 긴 if-else 체인의 문제를 올바르게 해결합니다: 타입에 따라 변하는 동작을 독립적인 클래스로 분리하고 하나의 인터페이스를 구현합니다. Spring Boot에서는 List<Bean> 주입 메커니즘을 활용하여 자동으로 strategy를 수집하고 registry를 직접 관리할 필요가 없습니다. 결과적으로 새로운 타입을 추가할 때 기존 코드를 건드리지 않고, 각 strategy를 독립적으로 테스트하기 쉬우며, “strategy 선택”이 단 한 곳 — 맵 조회 — 으로만 존재하여 코드베이스 전체에 if-else가 분산되지 않습니다.

design-patternjavaspring-bootclean-code