결제 수단의 확장과 축소를 고려하여 전략 패턴과 팩토리 패턴 사용 - Genie-Uss/genieus GitHub Wiki

[PaymentCommandService]
      |
      | 
      v
[PaymentStrategyFactory] ---+--> [TossPayStrategy]
                            +--> [KakaoPayStrategy]
                            +--> [...]
package shop.genieus.payment.application.out.strategy;

    @PostMapping("/process")
    Object processPayment(@RequestBody ProcessPaymentRequest processPaymentRequest) {
        PaymentProcessorResult paymentProcessorResult =
                paymentCommandService.processPayment(ProcessPaymentRequest.toCommand(processPaymentRequest));

        return switch (paymentProcessorResult.resultType()) {
            case JSON -> ResponseEntity.ok(ApiResponse.ok(paymentProcessorResult.payload()));
            case REDIRECT -> new RedirectView(paymentProcessorResult.payload().toString());
        };
    }
  • 우리 서비스엔 프론트가 없기 때문에 결제 API 연동 테스트를 위해 SSR 도 필요하였음
    • API 를 분리하는 게 일반적이겠지만, 단일 엔드포인트로 유연한 처리를 위하여 전략 패턴 도입
  • 전략에서 결과 타입을 명시해줬기 때문에, 호출하는 컨트롤러는 switch로 결과만 처리함
package shop.genieus.payment.application.out.strategy;

import shop.genieus.payment.domain.model.entity.Payment;

public interface PaymentStrategy {

    PaymentProcessorResult process(Payment payment);
}
  • 결제 방식별 로직의 공통 인터페이스
  • 각 결제 방식(TossPay, KakaoPay 등)이 이 인터페이스를 구현함
package shop.genieus.payment.application.out.strategy;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import shop.genieus.payment.domain.model.vo.PaymentMethod;

import java.util.Map;

@Service
@RequiredArgsConstructor
public class PaymentStrategyFactory {

    private final Map<String, PaymentStrategy> strategies;

    public PaymentStrategy getStrategy(PaymentMethod paymentMethod) {
        PaymentStrategy paymentStrategy = strategies.get(paymentMethod.name());

        if (paymentStrategy == null) {
            throw new IllegalArgumentException(paymentMethod + "는 지원하지 않는 결제 수단입니다.");
        }

        return paymentStrategy;
    }
}
  • Map<String, PaymentStrategy>를 통해 스프링이 DI(의존성 주입)으로 자동 구성해줌
  • key는 Bean 이름인데, 보통 @Component("KAKAO_PAY") 이런 식으로 명시하거나, 클래스명이 그대로 사용됨
  • 클라이언트 코드가 전략 구현체에 직접 의존하지 않고, PaymentMethod enum 값을 기반으로 전략을 선택할 수 있게 해줌
package shop.genieus.payment.application.out.strategy;

public record PaymentProcessorResult(
        ResultType resultType,
        Object payload) {

    public static PaymentProcessorResult returnRedirect(String url) {
        return new PaymentProcessorResult(ResultType.REDIRECT, url);
    }

    public static PaymentProcessorResult returnJson(Object body) {
        return new PaymentProcessorResult(ResultType.JSON, body);
    }

    public enum ResultType {
        REDIRECT,
        JSON
    }
}
  • 결과를 JSON 또는 REDIRECT로 명시적으로 표현
  • 단순 Object가 아니라 결과 타입과 페이로드를 함께 반환해서, 결과 처리 분기를 쉽게 만들어줌

📝 적용 이유

Strategy Pattern (전략 패턴) - 예: TossPay, KakaoPay, NaverPay 등  - 모든 결제 수단을 하나의 if-else나 switch에 몰아넣으면 유지보수가 어려움- 새로운 결제 방식 추가 시 코드 변경 없이 클래스만 추가하면 됨
Factory Pattern (전략 팩토리) - 클라이언트(Controller)는 내부 구현을 몰라도 됨.- PaymentMethod라는 enum 값만 주면, 해당 전략이 자동으로 매핑됨- 전략과 클라이언트 간의 결합도 최소화
Map<String, PaymentStrategy> + Spring DI - @Component 또는 @Service로 등록된 구현체들이 자동으로 주입됨- 새로운 전략을 추가해도 팩토리 코드를 건드릴 필요 없음
PaymentProcessorResult + ResultType - 모든 전략이 JSON 혹은 REDIRECT로 반환값을 표준화- Controller에서는 ResultType을 기준으로 일관되게 처리- 결과 처리 책임을 컨트롤러로 이동시켜 역할 분담이 명확해짐
OCP(개방 폐쇄 원칙) 준수 - 새 결제 수단이 추가되어도 기존 코드(Controller, Factory)는 변경되지 않음- SRP와 함께 유지보수와 테스트가 쉬운 구조가 될 수 있음

💡 개선된 점

OCP 원칙 새로운 결제 방식이 추가되어도 기존 코드 수정 없이 클래스만 추가하면 됨
SRP 원칙 컨트롤러는 단순 요청 처리, 전략은 결제 처리, 팩토리는 전략 선택 등으로 관심사 분리됨
테스트 용이성 각 전략은 독립적으로 테스트 가능. 팩토리도 MockMap으로 테스트 가능
유연성 Enum이나 문자열로 전략을 선택하는 구조라 다양한 입력에 대응 가능
표현력 PaymentProcessorResult로 결과를 명확히 구분할 수 있어, 프론트 대응이 쉬워짐

⚠️  단점 및 향후 개선 방향

  1. 전략 Bean 명시화 (@Component("TOSS_PAY"))
    • 현재는 Map<String, PaymentStrategy> 사용 시 Bean 이름이 key가 되는데,
    • @Component("TOSS_PAY") 같이 명시하면 enum과의 맵핑이 명확해짐
  2. Enum 기반 맵핑 개선
    • 현재는 .name()으로 문자열 맵핑하는데, 실수 가능성 있음
    • Map<PaymentMethod, PaymentStrategy> 으로 바꾸면 더 안전함
  3. 결과 타입을 제네릭으로 개선
    • Object payload는 타입 안정성이 없으므로, PaymentProcessorResult<T>(ResultType resultType, T payload) 이런 식으로 제네릭을 쓸 수도 있음