확장성과 가독성있게 외부 API 연동해보기

yboy·2024년 7월 13일
0

Learning Log 

목록 보기
31/41

Situation

사용자의 편의성을 위해 기존에 존재하던 결제방식에 네이버페이를 추가하는 잡을 맡게 되었다. 앞으로도 새로운 결제방식이 새롭게 추가될 가능성이 농후하고 새로운 결제 방식을 코드에 추가하면서 어떻게 하면 좀 더 가독성있고 확장성 있게 기능을 추가할 지를 고민하게 되었다. 그 기록을 글로 남기고자 한다.

Solution1

처음에 맞닥드린 문제는 기존 결제 방식에 어떻게 하면 확장성있게 새로운 기능을 추가할지에 대한 것이였다.

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

우선 기존의 코드는 위와 같다. 각 PayType enum에 따라 if문으로 구분하여 inicis 페이와 kakao 페이를 처리하고 있다. 이 코드에 naver 페이를 추가하면 아래와 같다.

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

위 코드의 문제점이 무엇일까?

  • 결제 방식이 추가됨에 따라 분기문이 늘어난다.
  • 하나의 서비스에 다양한 결제 방식 로직들이 모여 있어 결제 방식이 추가되고 복잡해지면 유지보수하기가 어려워 진다.

그럼 어떻게 이 문제를 해결할 수 있을까?
전략패턴을 사용할 수 있다.

전략패턴(Strategy Pattern)

전략 패턴 또는 정책 패턴은 실행 중에 알고리즘을 선택할 수 있게 하는 행위 소프트웨어 디자인 패턴이다. 전략 패턴은 특정한 계열의 알고리즘들을 정의하고 각 알고리즘을 캡슐화하며 이 알고리즘들을 해당 계열 안에서 상호 교체가 가능하게 만든다.

전략패턴의 사전적 정의는 위와 같다. 글로 읽으면 잘 이해가 안될테니 코드를 통해 알아보도록 하자.

1. 추상화

우선 결제방식은 기본적으로 결제와 환불 두 가지 기능을 무조건 포함하고 있을 것이기 때문에 두 가지 기능을 정의하는 interface를 생성하자.

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

이어서 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) {
        ....
    }

}

구체 클래스에서는 각각의 기능에 부합하도록 pay와 cancel 로직을 작성해주자.

2. DI를 통해 다형성 활용

결제로직을 추상화 했으니 이제 다형성을 이용해 보자. 스프링을 통한 다형성을 이용하기 위해 PayService의 각 구체 클래스를 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);
    }

}

다형성을 활용하기 위해 스프링 빈이 생성되는 시점에 @Component 에노테이션을 이용해 PayServiceContext를 세팅한다. PayService에는 각 구체클래스들을 구분하기 위해 getPayType() 메서드를 추가로 정의해줘야 한다.
해당 클래스를 사용하는 쪽에서는 getService() 메서드를 통해 빈 생성 시점에 세팅된 PayService들을 담은 map을 통해 적절한 PayService를 찾을 수 있다.

사실 Map이 아닌 List를 사용해도 무방하지만 find 성능은 list보다 map이 O(1)로 더 효율적이므로 map을 선택했다.

3. 기존 if-else 로직에 적용

이제 기존 로직과 리펙토링한 로직을 비교해보자.

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);

확실히 깔끔해졌다. 리펙토링된 로직은 다른 결제방식이 추가되어도 코드 수정을 할 필요가 없다. 앞으로 다른 결제방식이 추가되더라도 Payservice를 구현하는 구체클래스를 만들고 해당 클래스에서 이에 맞는 로직만 작성해 주면 된다.

Solution2

이어서 고민한 문제는 PayService를 구현하는 구체클래스에서 외부API 연동을 하면서 코드 가독성을 어떻게 하면 높일 수 있을지에 대한 것이다.

우선 결제기능을 구현하려면 필수불가결하게 외부 API를 사용해야 한다. 카카오페이, 네이버페이, 이니시스 등등...

외부 API연동을 위해서는 RestTemplate, FeighClient, WebClient 등등의 라이브러리를 이용할 수 있지만 여기서는 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;
    }

외부 연동을 하기위해선 외부API를 여러 개 사용해야 한다. 예를 들어 네이버페이 같은 경우는 결제를 하기 위해서 결제예약, 결제승인 두 가지 API를 사용해야 한다. 외부API를 사용하기 위해선 위와 같은 try-catch 로직이 반복적으로 발생하게 된다.
서로 다른 Request와 Response를 가지는 API들에서 중복적으로 발행하는 위 로직을 어떻게 효과적으로 분리해 낼 수 있을까?

템플릿 메서드 패턴을 적용해 볼 수 있다.

템플릿 메서드 패턴(Template Method Pattern)

템플릿 메서드 패턴은 객체지향 디자인 패턴 중 하나로, 기능의 뼈대(템플릿)와 실제 구현을 분리하는 패턴이다.

사전적 정의는 위와 같다. 코드를 통해 알아보도록 하자.

1. 반복되는 패턴 템플릿화 하기

    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("외부 연동 실패");
        }

        return executable.execute(response);
    }

우선 위와 같이 외부 API를 연동할 때 반복적으로 발생하는 로직을 템플릿화 한다. 반복되는 try-catch 로직 이후에는 실제 각 API 별로 다르게 처리해야 할 로직을 funtional interface를 이용해 각 메서드에서 구현해준다.

2. Funtional Interface를 통해 실제 구현 분리

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

우선 API별로 다르게 처리해야 할 로직을 분리하기 위해 functional interface를 정의한다.

    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;
        });
    }

다음으로 API를 처리하는 각각의 메서드별로 functional interface인 Executable을 람다를 통해 각각의 로직에 맞게 구현해준다.

템플릿 메서드 패턴을 람다와 제네릭을 이용해 적용하여 반복되는 로직과 그렇지 않은 로직을 깔끔하게 분리하여 가독성 있고 유지보수 하기 쉬운 코드가 되었다. 예를 들어 템플릿화된 메서드에서는 이후 공통처리될 로그를 남긴다던지 하는 로직을 해당 post() 메서드에만 추가하면 된다.

Conclusion

기존에 로직에 새로운 기능을 추가하면서 어떻게 하면 가독성을 높이고 유지보수성을 높일 수 있을지 고민해 볼 수 있는 시간이였다. 기능만 구현할 줄 아는 개발자가 아닌 기능을 넘어 더 많은 것을 고민하는 개발자기 되기 위해 앞으로도 계속 노력해야 겠다.
Let's go for it

0개의 댓글