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.
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?
So, how can this problem be solved?
We can use the 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.
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.
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.
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.
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.
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.
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.
@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.
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.