결제 서비스 개발기

결제 서비스 개발기

어떻게 해야 도메인을 보호하면서도 유연한 구조를 만들수 있을까?


이 포스팅에서 나온 코드의 이름들은 제가 실무에서 사용한 이름과 다릅니다.


🙄 무엇을 개발해야 했는가?


조금 더 있으면 2021년이 끝남과 동시에 개발자로서 만 1년차가 된다.

이런저런 정리를 하고 있는 와중인데, 이번 포스팅에서는 최근에 진행한, 재미있었던 신규 개발건에 대해 기록하려고 한다.

아주 간단하게 설명하자면 우리 회사 앱에서 내가 만들어야 할 서비스에 결제 요청을 보내면 이를 처리하고 적절한 응답을 반환하면 되는 것이었다.

조금 머리가 아팠던 부분은 수 많은 결제 벤더사를 연동해야하는 부분이었다.

결제 트랜잭션은 다음과 같았다.


  1. 고객이 외부 벤더사에 가입되어있는지 알아야한다
  2. 연동된 모든 벤더사를 순회하며 가입된 벤더사가 있는지 조회한다
  3. 가입된 벤더사가 없다면 히스토리를 남기고 결제 트랜잭션을 종료한다
  4. 가입된 벤더사가 있다면 결제 요청을 보낸다
  5. 결제 요청에 대한 결과를 히스토리로 남기고 결제 트랜잭션을 종료한다


✨ 개발 포인트 ?


내가 생각하기에 주요 포인트는 다음과 같았다.


  • 결제 트랜잭션이 종료되는 시나리오는 총 3개다
    • 조회 실패, 결제 실패, 결제 성공
  • 멀지 않은 미래에 외부 벤더사가 수십개가 될수도 있다
    • 다형성을 최대한 활용하면 좋은 구조를 만들 수 있다
  • 각 벤더사별로 요청 & 응답 파라미터가 천차만별이다

  • 각 벤더사별로 프로세스가 모두 다르다
    • 러프하게 봤을 때 조회, 결제는 모두 동일하나 세세한 프로세스가 다르다
    • 예를 들면 어떤 벤더사는 조회 요청을 보내기 전 인증 토큰을 먼저 발급받아야 된다던가 🙄
  • 각 벤더사에 요청을 보낼때 요금이 발생하며 이는 각 벤더사마다 다르다
    • 따라서 요금이 저렴한 순서대로 조회를 해야한다
  • 가장 최근에 결제에 성공한 이력이 있다면 어떤 벤더사에 가입되었는지 일일이 조회해보지 않아도 될 수도 있다
    • 조회를 스킵하고 가장 마지막에 결제에 성공했던 벤더사로 결제 요청을 바로 보내서 성공하면 결과적으로 조회를 위한 순회를 스킵할수 있다
    • 이는 비용 절감으로 이어진다.
  • JPA를 사용하되, 네이티브 쿼리를 완벽하게 배제하면 DB 벤더에 종속되지 않을 수 있다

  • 영속성(Persistence) 인터페이스를 정의하고 이를 구현한 콘크리트 클래스가 JpaRepository를 의존한다면 특정 외부 저장소에 종속되지 않을 수 있다
    • 즉, 미래에 파일로 저장하든, RDBMS에 저장하든, NoSQL에 저장하든, 미래에 생길 알지못할 외부 저장소에 저장하든 얼마든지 쉽게쉽게 바꿀 수 있게 된다
    • 이는 Mybatis에서 JPA로 마이그레이션하며 느꼈던 지옥같은 고통에서 해방됨을 의미한다.


📜 시나리오


결제 트랜잭션이 종료되는 시나리오는 총 3가지다.


❌ 조회 실패



image


❌ 결제 실패



image


✅ 결제 성공



image


🤔 어떻게 개발할까?


주요 포인트를 쭉 뽑아보니 어떤 구조로 만들어야 할 지 대략 감이 잡혔다.

이는 후술하도록 하고,


우선 최초 결제 요청이 들어왔을 때 유효한 거래 객체를 만들어내야만 한다.

이후 이 거래 객체의 상태를 변경해가며 프로세스를 진행한다.

이때, 거래 객체를 불변 객체로 설계하고 상태가 변경되는 시점마다 깊은 복사를 통해 새로운 객체를 반환하도록 구성했다.

그리고 이 객체는 주소 참조를 통해 각 메서드들이 공유할 수 있도록 하였다.

이 거래 객체의 이름은 Transaction이라고 하자.

이 객체의 책임은 거래의 상태와 중요 데이터들을 관리하고 기록하는 것인데, 문제는 데이터의 수가 너무 많았다.

일단 이를 다 Transaction에 몰아넣어 만들고 보니 결과적으로 클래스 멤버의 수가 너무 많아 복잡도가 크게 올라가고, 떠돌이 데이터가 많아지는 결과가 나왔다.


한참을 고민하다 도메인 객체와 자료구조의 경계를 명확하게 구분하기로 결정했다.

이는 스프링 배치의 도메인에서 영감을 얻어 차용했다.

도메인 객체는 Transaction이며, 이 객체는 TransactionContributes라는 객체를 의존하도록 만들었다.

그리고 존재하는 모든 데이터는 TransactionContributes가 관리하도록 하였다.

결과적으로 Transaction은 자신의 상태와 데이터베이스에 기록되어야만 하는 정말 중요한 데이터들만을 집중적으로 관리하면 되게 되었다.

그리고 이 중요한 데이터들은 모두 객체로 포장하고 각종 유효성 검사와 업무 규칙을 처리하도록 해주었다.


마지막으로 결제 트랜잭션이 종료되는 3가지 시나리오에 대해 TransactionTransactionContributes의 모든 데이터를 TransactionEntity로 컨버팅하여 데이터베이스에 히스토리를 남긴 후 트랜잭션을 종료하도록 최종 구성했다.


👏 다형성을 이용하자


우선 각 벤더사에서 응답하는 데이터가 천차만별이라, 이 모든 경우의 수를 따져 내가 필요한 데이터들을 골라내야만 했다.

이 때, 벤더사에서 요구하는 데이터도 천차만별이었지만, 이는 결국 추상화할 수 없어 Trasnaction 의 도움을 받아 매번 적절한 요청 객체를 만들어야만 했고, 이 작업은 정말 지루했지만 딱히 좋은 방법이 생각나질 않았다.

다행스럽게도 응답 객체만큼은 추상화를 할 수 있어서 이를 SearchResponse라는 인터페이스로 정의하고 필요한 데이터들을 반환하도록 추상메서드를 선언했다.


// file: 'src/main/java/payment/application/domain/command/response/SearchResponse.java'
public interface SearchResponse {
    
    boolean isFound();

    String getA();
    
    String getB();

    BigDecimal getC();
    
    LocalDateTime getD();
    
    // ...

}


결제 요청에 대한 응답인 PaymentResponse라는 인터페이스도 정의해주었다.


// file: 'src/main/java/payment/application/domain/command/response/PaymentResponse.java'
public interface PaymentResponse {
    
    boolean isSuccess();

    String getA();
    
    String getB();

    BigDecimal getC();
    
    LocalDateTime getD();
    
    // ...

}


🔗 커맨드 패턴


각 벤더사별로 미묘하게 프로세스가 다르지만, 러프하게 보면 조회결제라는 프로세스는 모두 동일했다.

예를 들면 이렇다.


  • A사는 조회를 바로하면 된다
  • B사는 인증토큰을 발급받은 후 조회를 해야 한다


이 부분은 커맨드 패턴을 이용하면 딱 좋을 것 같았다.

우선 간략하게 SearchCommand라는 인터페이스를 하나 정의했다고 가정하자.


// file: 'src/main/java/payment/application/domain/command/SearchCommand.java'
public interface SearchCommand {

    SearchResponse execute(Transaction transaction);

}


조회에 대한 두가지 커맨드 객체를 생성한다.


// file: 'src/main/java/payment/application/domain/command/ASearchCommand.java'
@RequiredArgsConstructor
public class ASearchCommand {
    
    private final AClient client;
    
    // 별도의 처리 없이 바로 조회
    @Override
    public SearchResponse execute(Transaction transaction){
        ASearchRequest searchRequest = createSearchRequest(transaction);
        return client.search(searchRequest);
    }
    
    private ASearchRequest createSearchRequest(Transaction transaction){
        // do something...
        return request;
    }

}


// file: 'src/main/java/payment/application/domain/command/BSearchCommand.java'
@RequiredArgsConstructor
public class BSearchCommand {
    
    private final BClient client;
    
    // 먼저 인증 토큰을 발급받은 후 조회
    @Override
    public SearchResponse execute(Transaction transaction){
        BTokenRequest tokenRequest = createTokenRequest();
        String token = client.getToken(tokenRequest);
        
        BSearchRequest searchRequest = createSearchRequest(transaction);
        return client.search(token, searchRequest);
    }
    
    private BTokenRequest createTokenRequest(){
        // do something...
        return request;
    }

    private BSearchRequest createSearchRequest(Transaction transaction){
        // do something...
        return request;
    }

}


결제 요청도 위와 비슷한 방식으로 구성하였다.

이렇게 하니 모든 벤더사들을 나름 깔끔하게 커버할 수 있는 구조가 만들어졌다.


🍗 도우미 클래스


주요 처리를 유즈케이스 클래스에서 처리하면 코드가 너무 장황해지고, 의존대상이 너무 늘어나기 때문에 우선 이를 분리했다.

우선 조회에 성공하면 해당 벤더사를 통해 결제 커맨드를 가져올 수 있게끔 동시성 일급 컬렉션을 만들었다.


// file: 'src/main/java/payment/application/usecase/PaymentCommandConcurrentMap.java'
@Component
public class PaymentCommandConcurrentMap {

    private static final Map<Vendor, PaymentCommand> commandMap = new ConcurrentHashMap<>();
  
    @Autowired
    public PaymentCommandConcurrentMap(
            APaymentCommand aCommand, 
            BPaymentCommand bCommand
    ) {
        commandMap.put(Vendor.A, aCommand);
        commandMap.put(Vendor.B, bCommand);
    }
  
    public PaymentCommand findByVendor(Vendor vendor) {
        if (commandMap.containsKey(vendor)) {
            return commandMap.get(vendor);
        }
        throw new IllegalArgumentException("Could not find command for " + vendor);
    }

}


또한, 여기서 각 벤더사의 API 요금에 따라 API가 호출되는 순서를 결정할 수 있게해줬다.


// file: 'src/main/java/payment/application/usecase/VendorAdapterComposite.java'
@Component
public class VendorAdapterComposite {

    private final List<SearchCommand> searchCommandList;
    private final PaymentCommandConcurrentMap paymentConcurrentMap;

    @Autowired
    public VendorAdapterComposite(
            PaymentCommandConcurrentMap paymentConcurrentMap,
            ASearchCommand aCommand, 
            BSearchCommand bCommand
    ) {
        this.paymentConcurrentMap = paymentConcurrentMap;
        
        // API 호출 요금이 저렴한 순서대로 리스트에 삽입한다
        this.searchCommandList = new ArrayList<>();
        searchCommandList.add(aCommand);
        searchCommandList.add(bCommand);
    }

    public SearchResponse search(Transaction transaction) throws NotFoundVendorException {
        for (SearchCommand command : searchCommandList){
            SearchResponse response = command.execute(transaction);
            if(response.isFound()){
                return response;
            }
        }
        throw new NotFoundVendorException();
    }

    public PaymentResponse payment(Transaction transaction) {
        TransactionContributes contributes = transaction.getContribute();
        Vendor vendor = contributes.getVendor();
        return paymentConcurrentMap.findByVendor(vendor)
                .execute(transaction);
    }

}


🍔 유즈케이스


모든 벤더사에 대한 처리를 테스트코드와 함께 구현하고 보니 이후 작업은 생각보다 쉬웠다.

조회 -> 결제순으로 처리하되 트랜잭션이 종료되어야만 하는 3가지 케이스에 집중하고, 몇가지 예외처리를 해주면 되었다.


// file: 'src/main/java/payment/application/usecase/PaymentUseCaseImpl.java'
@Slf4j
@UseCase
@RequiredArgsConstructor
public class PaymentUseCaseImpl implements PaymentUseCase {
    
    private final VendorAdapterComposite adapter;
    private final TransactionRepository transactionRepository;

    @Override
    public Transaction paymentUseCase(Transaction transaction) {
        info(log, "Create new transaction!", transaction.getContribute());
        transaction = search(transactionRepository.save(transaction));
        
        if(transaction.isNotFound()){
            return transaction;
        }
        
        return payment(transaction);
    }

    private Transaction search(Transaction transaction) {
        try {
            SearchResponse response = adapter.search(transaction);
            return transaction.setFoundVendor(response);
        } catch (NotFoundVendorException e) {
            transaction = transaction.setNotFoundVendor();
            TransactionContributes contribute = transaction.getContribute();
            error(log, contribute.getStatusMessage(), contribute);
            return transactionRepository.update(transaction);
        }
    }

    private Transaction payment(Transaction transaction) {
        PaymentResponse response = adapter.payment(transaction);
        
        if (response.isSuccess()) {
            transaction = transaction.setPaymentSuccess(response);
            TransactionContributes contribute = transaction.getContribute();
            info(log, contribute.getStatusMessage(), contribute);
            return transactionRepository.update(transaction);
        }

        transaction = transaction.setPaymentFail();
        TransactionContributes contribute = transaction.getContribute();
        error(log, contribute.getStatusMessage(), contribute);
        return transactionRepository.update(transaction);
    }

}


🚀 웹 어댑터


마지막으로 웹 어댑터를 작성했다.

이 객체는 외부의 요청을 받아 도메인 객체를 만들어낸 후 유즈케이스 클래스에 처리를 위임한다.

그리고 유즈케이스 클래스가 반환한 도메인을 받아 외부에 적절한 응답을 해준다.


// file: 'src/main/java/payment/adapter/http/PaymentApiController.java'
@RestController
@RequiredArgsConstructor
public class PaymentApiController {
    
    private final PaymentUseCase useCase;
    
    @PostMapping
    public ResponseEntity<Data<?>> payment(@RequestBody PaymentRequest request){
        transaction = useCase.paymentUseCase(TransactionFactory.convert(request));
        
        if(transaction.isNotFound()){
            return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(Data.of(HttpStatus.NOT_FOUND, transaction.exposure()));
        }

        if(transaction.isPaymentFail()){
          return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                  .body(Data.of(HttpStatus.INTERNAL_SERVER_ERROR, transaction.exposure()));
        }
  
        return ResponseEntity.status(HttpStatus.OK)
                .body(Data.of(HttpStatus.OK, transaction.exposure()));
    }  
    
    @Value
    @JsonInclude(Include.NON_NULL)
    private static class Data<T> {
        int code;
        String message;
        T data;

        private static <T> Data<T> of(HttpStatus httpStatus, T data) {
          return Data.of(httpStatus.value(), httpStatus.getReasonPhrase(), data);
        }
  
        private static <T> Data<T> of(int code, String message, T data) {
          return new Data<>(code, message, data);
        }
    }
  
}


😊 후기


이번 신규 개발건은 그동안 공부한 객체지향과 디자인 패턴에 대해 심도깊게 고민해보고 실무에 적용해볼 수 있는 아주 좋은 기회였다고 생각한다.

최근 📕 클린 아키텍처📕 만들면서 배우는 클린 아키텍처 두 책을 감명깊게 읽었는데, 이 책들에서 본 내용들을 실무에 적용해보고 많은 부분을 깨닫고 체화시킬 수 있었어서 정말 좋았다.

확실히 주먹구구식으로, 절차지향적으로 코드를 작성하던 몇달전과 달리 상대적으로 구조가 깔끔하게 잡히는 것이 매우 흡족했다.

최대한 인터페이스에 대고 코딩하도록 하고, 각 클래스가 변경되어야 하는 이유를 최대한 적게 가져가려고 노력하였는데, 이렇게 하고 보니 테스트 코드를 작성하는것이 매우 수월해짐을 느낄 수 있었다.

테스트코드를 작성하는게 수월해지니 과감한 리팩토링도 계속 시도해볼 수 있었고, 갈수록 코드에서 나쁜 냄새들이 없어지는 긍정적인 연쇄효과가 일어났다.

실제 코드는 훨씬 더 복잡하고 분량이 많았지만, 생각보다 힘들지 않게 요약해서 정리할 수 있게 된것을 보니 기존의 스타일에 비해 복잡도가 상대적으로 많이 낮다는 생각이 들었다. 즉, 구조가 나쁘지 않은 것 같다.



© 2022. All rights reserved.