Implementation of a Flexible Payment Module Using the Strategy Pattern

Implementation of a Flexible Payment Module Using the Strategy Pattern

As we began implementing the payment module, the first concern we faced was not simply 'how to call the payment approval API.' The core issue was 'whether we could provide a consistent payment experience without disrupting the service's payment flow, regardless of which payment gateway (PG) might be added in the future.'

The initial MVP goal was to integrate with T***Payments, but in the future, there may be situations where multiple PGs need to be supported simultaneously depending on payment methods, settlement conditions, support for overseas payments (such as Stripe), and customer requirements. Therefore, we have set the following goals that go beyond simple API integration.

  • It consolidates various PG integration codes to prevent fragmentation.

  • Secures structural scalability that allows easy addition of new PGs.

  • It provides ease of tracking and debugging by storing payment data in the internal database.

1. Limitations of if-else branching and the introduction of the strategy pattern

You can also directly implement various PG integrations into the payment service logic. However, as Stripe, K****Pay, and foreign PGs are added, the payment flow becomes filled with conditional statements as follows.

if (pgProvider.equals("T***Payments")) {
    // T*** 승인 로직
} else if (pgProvider.equals("Stripe")) {
    // Stripe 승인 로직
} else if (pgProvider.equals("SomeOtherPg")) {
    // 다른 PG 승인 로직}

This approach has a critical drawback that the core business flow of payment becomes strongly coupled with the external integration details as more payment gateways (PGs) are added. The payment flow should focus only on the broad flow of "amount verification → PG approval → success/failure processing," and there is no need to understand the authentication headers or response parsing methods of each PG. To separate these, we applied the Strategy Pattern.

2. Design of Common Interface (PgProxy) and Implementation

First, what all PG companies must implement in commonPgProxyI have defined the interface. From the perspective of the payment flow, regardless of the type of PG company, we only need to send the same message requesting to "approve the payment."

public interface PgProxy {
    String getPgProviderType();
    WebClient getWebClient();
    PgPaymentResponse approvePayment(String paymentKey, String orderId, Money amount);
    PgPaymentResponse getPgPaymentResponse(String orderId);
}

The implementation of the 토* payments inherits the above interface and is implemented as follows.

@Configuration
@ConditionalOnProperty(value = "pg.t***.enabled", havingValue = "true")
public class T***PgProxy implements PgProxy {
    @Value("${pg.t***.secret-key}")
    private String secretKey;
    @Override
    public PgPaymentResponse approvePayment(String paymentKey, String orderId, Money amount) {
        T***Payment response = confirmPayment(paymentKey, orderId, amount);
        return new PgPaymentResponse(response);
    }
    @Override
    public String getPgProviderType() {
        return "T***Payments";
    }
}

Here @ConditionalOnPropertyUsing annotations, we controlled the dynamic registration of only the necessary PG implementations as Spring beans according to the configuration value(pg.t***.enabled=true). In the future, StripePgProxy can also be prepared to implement the same interface.

3. Routing factory (PgProxyFactory) that finds the appropriate strategy

Now we configure the PgProxyFactory that finds the implementation corresponding to the requested PG type.

@Component
public class PgProxyFactory {
    private final Map<String, PgProxy> pgProxies;

    public PgProxyFactory(List<PgProxy> pgProxies) {
      this.pgProxies = pgProxies.stream()
                .collect(Collectors.toMap(PgProxy::getPgProviderType, Function.identity()));
    }
    public PgProxy getPgProxy(String pgProvider) {
      PgProxy pgProxy = pgProxies.get(pgProvider);
      if (pgProxy == null) {
          throw new NoSuchElementException("지원하지 않는 결제 대행사입니다: " + pgProvider);        }
      return pgProxy;
    }
}

When the Spring container injects all beans of the PgProxy type as a list, the factory converts this into a Map and holds it in memory. Later, when the business logic passes the PG identifier (like Enum), it returns the appropriate implementation immediately. While the class name is Factorykaka, it serves more as a strategy manager that selects and routes the 'strategy object' suitable for the runtime environment rather than the 'creation' of the object.

4. Simplification of core business logic

The actual payment service logic using the strategy pattern is completely liberated from the details of external communication.

@Service
@Transactional
@RequiredArgsConstructor
public class PaymentNeutFlow {
    private final PaymentTask paymentTask;
    private final PgProxyFactory pgProxyFactory;
    private final PaymentLogic paymentLogic;

    public String requestPayment(String paymentId, BigDecimal amount, String pgPaymentKey) {
      paymentTask.updatePgPaymentKey(paymentId, pgPaymentKey);
      try {
          Payment payment = paymentLogic.findPayment(paymentId);
          paymentTask.validateAmount(paymentId, amount);
          // 외부 PG 통신: 팩토리에서 구현체를 가져와 승인 요청만 위임
          PgPaymentResponse pgPaymentResponse = pgProxyFactory
               .getPgProxy(payment.getPgProvider())
               .approvePayment(pgPaymentKey, payment.getId(), payment.getAmount());
          paymentTask.markSuccess(pgPaymentResponse.getOrderId());
          return pgPaymentResponse.getOrderId();
      } catch (Exception e) {
          paymentTask.markFailed(paymentId);
          throw e;
      }
   }
}

Now PaymentNeutFlowdoes not know the API URL or response object format of a specific PG. It focuses solely on its fundamental business responsibilities such as storing the payment key, validating amounts, delegating approval through factories, and handling success/failure states.

5. Open structure that is open for extension (Achieving the OCP principle)

The true value of this structure is revealed when adding a new PG (e.g., N***Pay). The necessary tasks are just two.

  1. PgProvider In Enum N***PayAdding a constant.

  2. PgProxyinherited from N***PgProxyCreate a new class.

Existing payment flow(PaymentNeutFlow)na factory(PgProxyFactory) The code is No line needs to be modified.This is because Spring automatically detects and injects the new implementation. Since the scope of changes is extremely minimized, the probability of bugs occurring in existing functionalities (regression bugs) is greatly reduced.

6. Implementation Results and Conclusion

The benefits gained by combining the strategy pattern and the adapter concept in the payment module are as follows.

  • Simplifying the payment flow:The service layer does not need to know the details of each PG company, but focuses solely on the payment business flow.

  • High cohesion:Communication timeouts, header settings, response parsing, and other code that is dependent on specific PGs are safely isolated within each Proxy object.

  • Flexible operation settings: @ConditionalOnPropertyYou can freely turn specific PG implementations on and off according to the local, development, operational, and other environments.

  • Ensuring Testability:Mocking the external communication module makes it much easier to write unit tests for the payment flow itself.

The payment system is inevitably connected to the external world, which is often prone to failures and changes. A robust payment module needs to have barriers that prevent external changes from spilling over into the core logic of the system. The strategy pattern was the most effective and practical design for constructing that solid boundary.

chnsik

Site footer