Integrating a New Payment API Seamlessly into Existing Logic(Eng)

yboy·2024년 8월 25일
0

Learning Log 

목록 보기
40/41
post-thumbnail

Situation

I took on a task with adding Naver Pay to the existing payment methods to enhance user convenience. Given that new payment methods are likely to be added in the future, I started thinking about how to incorporate them into the code in a way that is both readable and scalable. I decided to document this process and my approach to solving this challenge.

Solution1

The initial challenge was how to add new features to the existing payment system in a scalable way.

        if (PayType.KAKAOPAY.equals(payType)) {
			kakaopay(dto);
        } else {
			inicispay(dto);
        }

First, the existing code is as follows. It handles Inicis Pay and Kakao Pay by distinguishing them with if statements according to each PayType enum. If Naver Pay is added to this code, it would look like the following below.

        if (PayType.KAKAOPAY.equals(payType)) {
			kakaopay(dto);
        } else if (PayType.NAVERPAY.equals(payType)) {
       		naverpay(dto);
        } else {
            inicispay(dto);
        }

What are the issues with the code above?

  • As more payment methods are added, the number of conditional branches increases.
  • The logic for various payment methods is concentrated in a single service, making it difficult to maintain as payment methods are added and complexity grows.

So, how can this problem be solved?
We can use the Strategy Pattern.

Strategy Pattern

The Strategy Pattern, also known as the Policy Pattern, is a behavioral software design pattern that enables the selection of an algorithm at runtime. The Strategy Pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable within that family.

The definition of the Strategy Pattern is as explained above. However, it might be difficult to fully grasp just by reading, so let's take a look at it through some code examples.

1. Abstraction

Let's start by creating an interface that defines the two essential functions: payment and refund, since a payment method will always include these two features by default.

public interface PayService {
    OutDto pay(final InDto InDto);
    OutDto cancel(final InDto InDto);
}

Next, let's create a concrete class that implements the interface.

@Service
@RequiredArgsConstructor
public class InicisPayService implements PayService{

    @Override
    public OutDto pay(final InDto InDto) {
        .... 
    }

    @Override
    public OutDto cancel(final InDto InDto) {
        ....
    }

}

@Service
@RequiredArgsConstructor
public class KakaoPayService implements PayService{

    @Override
    public OutDto pay(final InDto InDto) {
        .... 
    }

    @Override
    public OutDto cancel(final InDto InDto) {
        ....
    }

}

@Service
@RequiredArgsConstructor
public class NaverPayService implements PayService{

    @Override
    public OutDto pay(final InDto InDto) {
        .... 
    }

    @Override
    public OutDto cancel(final InDto InDto) {
        ....
    }

}

In the concrete class, let's write the logic for the pay and cancel methods to fulfill each function accordingly.

2. Leveraging polymorphism through Dependency Injection (DI)

Since we've abstracted the payment logic, let's now utilize polymorphism. To leverage polymorphism through Spring, we'll create a class responsible for injecting the specific implementations of PayService into our application using Dependency Injection (DI).

@Component
public class PayServiceContext {

    private final Map<PayType, PayService> services;

    public PayServiceContext(final List<PayService> services) {
        this.services = services.stream()
                .collect(Collectors.toMap(PayService::getPayType, v -> v));
    }

    public PayService getService(final PayType payType) {
        return services.get(pgServiceType);
    }

}

When using this class, the getService() method can retrieve the appropriate PayService by looking it up in a Map that was populated with the services during bean creation. Although using a List is also possible, we chose a Map because it provides O(1) lookup performance, which is more efficient than a List for finding elements.

3. Applying this approach to an existing if-else logic

before

        if (PayType.KAKAOPAY.equals(payType)) {
			kakaopay(dto);
        } else if (PayType.NAVERPAY.equals(payType)) {
       		naverpay(dto);
        } else if{
            inicispay(dto);
        }
        .... 

after

        final PayService payService = payServiceContext.getService(payType);
        final PgResultInfo pgResultInfo = payService.pay(dto);

It’s definitely cleaner now. With the refactored logic, there’s no need to modify the code when new payment methods are added. In the future, as new payment methods are introduced, you simply need to create a new concrete class that implements the PayService and write the corresponding logic in that class. This approach ensures that the system is easily extendable without altering existing code.

Solution2

The next challenge I've considered is how to improve the readability of the code when integrating with external APIs within the concrete classes that implement PayService.

To implement payment functionality, it's essential to interact with external APIs such as KakaoPay, NaverPay, Inicis, and others. For this purpose, various libraries can be used, including RestTemplate, FeignClient, or WebClient. In this case, I'll explain the approach using WebClient.

    public ReservePayResponseDto reservePay(final ReservePayRequest request) {
        Response response = null;
        try {
            response = webClient.mutate()
                    .defaultHeader(CLIENT_ID_DEFAULT_HEADER_KEY, clientId)
                    .defaultHeader(CLIENT_SECRET_DEFAULT_HEADER_KEY, clientSecret)
                    .defaultHeader(CHAIN_ID_DEFAULT_HEADER_KEY, chainID)
                    .defaultHeader(IDEMPOTENCY_KEY_DEFAULT_HEADER_KEY, makeIdempotencyValue())
                    .baseUrl(baseUrl)
                    .build()
                    .post()
                    .uri(PAY_RESERVE_URL)
                    .contentType(MediaType.APPLICATION_JSON)
                    .bodyValue(request)
                    .retrieve()
                    .onStatus(HttpStatus::is4xxClientError, clientResponse -> clientResponse.bodyToMono(NaverPayErrorResponse.class)
                            .flatMap(naverPayErrorResponse -> Mono.error(NaverPayWebClientException.create(clientResponse.statusCode(), naverPayErrorResponse))))
                    .onStatus(HttpStatus::is5xxServerError, clientResponse -> clientResponse.bodyToMono(NaverPayErrorResponse.class)
                            .flatMap(naverPayErrorResponse -> Mono.error(NaverPayWebClientException.create(clientResponse.statusCode(), naverPayErrorResponse))))
                    .bodyToMono(NaverPaySuccessResponse.class)
                    .block();
        } catch (NaverPayWebClientException e) {
            throw new RuntimeException("외부 연동 실패");
        }

        ...

        return dto;
    }

To integrate with external services, you often need to use multiple external APIs. For example, with Naver Pay, you need to use two APIs for payments: one for payment reservation and another for payment approval. The try-catch logic required to handle these APIs can become repetitive.

To effectively separate and manage this repetitive logic across different APIs with varying request and response formats, you can apply the Template Method Pattern.

Template Method Pattern

The Template Method Pattern is one of the object-oriented design patterns that separates the skeleton of an algorithm (the template) from its actual implementation.

The dictionary definition is as follows. Let's explore it through code.

1. Template the repetitive pattern

    private <T> T post(final String url, Object body, final Executable<T> executable) {
        Response response = null;
        try {
            response = webClient.mutate()
                    .defaultHeader(CLIENT_ID_DEFAULT_HEADER_KEY, clientId)
                    .defaultHeader(CLIENT_SECRET_DEFAULT_HEADER_KEY, clientSecret)
                    .defaultHeader(CHAIN_ID_DEFAULT_HEADER_KEY, chainID)
                    .defaultHeader(IDEMPOTENCY_KEY_DEFAULT_HEADER_KEY, makeIdempotencyValue())
                    .baseUrl(baseUrl)
                    .build()
                    .post()
                    .uri(url)
                    .contentType(MediaType.APPLICATION_JSON)
                    .bodyValue(body)
                    .retrieve()
                    .onStatus(HttpStatus::is4xxClientError, clientResponse -> clientResponse.bodyToMono(NaverPayErrorResponse.class)
                            .flatMap(naverPayErrorResponse -> Mono.error(NaverPayWebClientException.create(clientResponse.statusCode(), naverPayErrorResponse))))
                    .onStatus(HttpStatus::is5xxServerError, clientResponse -> clientResponse.bodyToMono(NaverPayErrorResponse.class)
                            .flatMap(naverPayErrorResponse -> Mono.error(NaverPayWebClientException.create(clientResponse.statusCode(), naverPayErrorResponse))))
                    .bodyToMono(NaverPaySuccessResponse.class)
                    .block();
        } catch (NaverPayWebClientException e) {
            throw new RuntimeException("Fail Connecting to external API");
        }

        return executable.execute(response);
    }

First, template the repetitive logic that occurs when integrating with external APIs. After the repetitive try-catch logic, implement the API-specific handling using functional interfaces in each method.

2. Separate the actual implementation using Functional Interface

@FunctionalInterface
public interface Executable<T> {
    T execute(final Response response);
}

First, define functional interfaces to separate the logic that needs to be handled differently for each API.

    public ReservePayResponseDto reservePay(final NaverPayReserveRequest request) {
        return post(PAY_RESERVE_URL, request, (response) -> {
            ...
            return dto;
        });
    }

    public ApprovePayResponseDto approvePay(final NaverPayApproveRequest request) {
        return post(PAY_APPROVE_URL, request, (response) -> {
            ...
            return dto;
        });
    }

    public CancelPayResponseDto cancelPay(final NaverPayCancelRequest request) {
        return post(PAY_CANCEL_URL, request, (response) -> {
            ...
            return dto;
        });
    }

Next, for each method handling a different API, implement the Executable functional interface using lambda expressions to match the specific logic for each API.

By applying the Template Method Pattern with lambdas and generics, repetitive logic and non-repetitive logic are cleanly separated, resulting in more readable and maintainable code. For example, in the templated method, you can add logic such as logging common information, which will only need to be added to the post() method.

Conclusion

It was a valuable time to consider how to enhance readability and maintainability while adding new features to existing logic. As a developer, it's important to go beyond just implementing functionality and continuously strive to become one who thinks more deeply about these aspects. I will continue to work towards becoming such a developer.

0개의 댓글