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가 분산되지 않습니다.