[쇼핑몰 프로젝트] 외부 API 호출에서 보상트랜잭션 적용

대원·2025년 1월 22일

쇼핑몰

목록 보기
7/13

외부 API 호출시 보상트랜잭션 적용

배경

우리가 웹 상에서 결제를 한 번이라도 해봤으면 모두 알겠지만, 사용자가 요청하는 결제수단은 ‘무통장입금’, ‘휴대폰결제’, ‘XX페이’, ‘카드결제’ 등.. 수 많은 결제 수단이 존재합니다. 각각의 결제수단들에 대해서 하나의 쇼핑몰이 모든 것을 구현하는 것은 분명 비효율적입니다.

따라서 대개의 경우엔 흔히 말하는 PG사라고 불리우는 결제대행사 서버를 통해서 손쉽게 ‘결제 기능’을 구축할 수 있습니다. (물론 단순 기능구현 외에 실제 ‘PG사와의 계약’ 등은 다른 측면이 있으나 본 글의 내용의 범위가 넘어가는 것이기에 해당 부분은 제외하였습니다.)

구글링을 해보면 결제 대행을 수행하는 대행사는 아임포트, 토스 등이 있었고, 개인적으로 실 이용을 하고 친숙도가 높은 ‘토스 API’를 이용하여 결제를 구현하기로 결정하였습니다.

저번 Feign Client와 관련한 글에서 설명하였던 것처럼, 외부 API 서버를 호출하는 클라이언트 툴로서 ‘Feign’을 사용하였습니다.

이번 글에서는 결제 기능을 구현하면서 겪었던 문제상황과 해당 문제상황을 해결하기 위한 본인의 고민을 공유하는 시간을 가져보도록 하겠습니다.

토스 서버에 전달해 줘야 하는 값을 비롯하여 PG사에서 수행하는 로직은 본 글에서 설명하고자 하는 요지와 거리가 먼 관계로 해당 내용은 제외하였으니 참고해 주시기를 바랍니다.


서버 내에서 결제의 진행 과정

서버 내에서의 결제 흐름을 간략하게 표현하자면 다음과 같습니다.

  1. 서버는 사용자에게서 값을 전달받아 결제 요청을 받는다.
  2. 서버는 토스 서버에 값을 전달하며 결제를 요청한다.
  3. 토스 서버는 쇼핑몰 서버에게 결제 결과를 전달한다.
  4. 쇼핑몰 서버는 서버 내부에 결제 내역을 저장하고 사용자에게 결제 성공 유무를 응답해준다.

위와 같은 플로우를 토대로 세부적인 클래스를 시퀀스 다이어그램을 통해 표현하면 다음과 같습니다.

  • 시퀀스 다이어그램

복잡하게 여러가지 클래스가 존재하는 것 같지만, 실제로 결제를 수행하는데 결정적인 역할을 하는 클래스는 다음과 같습니다.

  • PaymentUsecase
package shoppingmall.web.api.payment.usecase;

import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import shoppingmall.common.dto.toss.TossPaymentConfirmRequest;
import shoppingmall.domainservice.domain.payment.dto.PaymentResponse;
import shoppingmall.domainservice.domain.payment.service.PaymentConfirmService;
import shoppingmall.domainservice.domain.payment.service.PaymentSearchService;
import shoppingmall.web.common.annotataion.Usecase;
import shoppingmall.web.common.validation.payment.TossPaymentConfirmValidator;

@Usecase
@RequiredArgsConstructor
public class PaymentUsecase {
    private final TossPaymentConfirmValidator tossPaymentConfirmValidator;
    private final PaymentConfirmService paymentConfirmService;
    private final PaymentSearchService paymentSearchService;



    @Transactional
    public PaymentResponse executePayment(TossPaymentConfirmRequest tossPaymentConfirmRequest) {
        // TossPaymentConfirmRequest Validation
        tossPaymentConfirmValidator.validate(tossPaymentConfirmRequest);


        return paymentConfirmService.confirm(tossPaymentConfirmRequest);
    }


    public PaymentResponse getPayment(Long paymentId) {
        return paymentSearchService.getPayment(paymentId);

    }
}

PaymentUsecase는 Web 모듈 내에서 Application Layer에 속합니다.
해당 Layer에서 Payment와 관련된 기능을 정의하였으며 결제를 수행하고 결제를 조회하는 메서드 등을 호출합니다.

한 가지 눈에 띄는 점은 결제를 실행하는 ‘executePayment’ 메서드가 @Transactional이 적용되어있다는 점입니다. 즉, 해당 메서드 내부의 메서드들이 호출하는 하나의 트랜잭션 단위로 묶여있다는 것입니다. 따라서 시퀀스 다이어그램 내에서 볼 수 있듯이 PaymentUsecase의 executePayment를 통해 이뤄지는 모든 행위는 트랜잭션의 원자성(Atomicity)에 의해서 커밋되거나 혹은 롤백되어야합니다.

  • PaymentConfirmService
  package shoppingmall.domainservice.domain.payment.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import shoppingmall.common.dto.toss.TossPaymentConfirmRequest;
import shoppingmall.common.dto.toss.TossPaymentConfirmResponse;
import shoppingmall.common.exception.ApiException;
import shoppingmall.common.exception.domain.PaymentErrorCode;
import shoppingmall.domainrdb.common.annotation.DomainRdbService;
import shoppingmall.domainrdb.order.domain.OrderDomain;
import shoppingmall.domainrdb.order.service.OrderRdbService;
import shoppingmall.domainrdb.payment.TossPaymentCancelEvent;
import shoppingmall.domainrdb.payment.TossPaymentDomain;
import shoppingmall.domainrdb.payment.service.PaymentRdbService;
import shoppingmall.domainredis.domain.dto.PaymentCacheDto;
import shoppingmall.domainredis.domain.payment.service.PaymentCacheService;
import shoppingmall.domainservice.domain.payment.mapper.PaymentConverter;
import shoppingmall.domainservice.domain.payment.dto.PaymentResponse;
import shoppingmall.tosspayment.feign.PaymentClient;


@DomainRdbService
@RequiredArgsConstructor
@Slf4j
public class PaymentConfirmService {
    private final PaymentRdbService paymentRdbService;
    private final PaymentClient paymentClient;
    private final PaymentCacheService paymentCacheService;
    private final OrderRdbService orderRdbService;
    private final PaymentConverter paymentConverter;


 
    public PaymentResponse confirm(final TossPaymentConfirmRequest tossPaymentConfirmRequest) {


        // TossPayment Confirm API 호출
        TossPaymentConfirmResponse tossPaymentConfirmResponse = paymentClient.confirmPayment(tossPaymentConfirmRequest);

        OrderDomain orderDomain = orderRdbService.findOrderDomainById(Long.parseLong(tossPaymentConfirmRequest.getOrderId()));
        TossPaymentDomain tosspaymentDomain = TossPaymentDomain.createForWrite(tossPaymentConfirmResponse, orderDomain);


 
            // PaymentRdbService에서 savePayment 메서드 호출
            TossPaymentDomain tossPaymentDomain = paymentRdbService.savePayment(tosspaymentDomain);

            // TossPaymentDomain을 PaymentCacheDto로 변환
            PaymentCacheDto paymentCacheDto = paymentConverter.from(tossPaymentDomain);


            // PaymentCacheService에서 savePaymentCache 메서드 호출
            paymentCacheService.savePaymentCache(tossPaymentDomain.getTossPaymentId().getValue(), paymentCacheDto);


            PaymentResponse paymentResponse = PaymentResponse.builder()
                    .paymentId(tossPaymentDomain.getTossPaymentId().getValue())
                    .orderId(orderDomain.getOrderId().getValue())
                    .tossPaymentStatus(tosspaymentDomain.getTossPaymentStatus())
                    .tossPaymentMethod(tosspaymentDomain.getTossPaymentMethod())
                    .amount(tosspaymentDomain.getAmount())
                    .build();

            return paymentResponse;


  


    }
}

PaymentConfirmService 클래스는 토스 외부서버를 호출하는 PaymentClient에게 confirmPayment 메서드를 실행하게끔 하고, OrderDomain 등을 이용하여 PaymentRdbService와 PaymentCacheService를 이용하여 결제 내역을 저장한 후 ResponseDTO를 생성하여 Return해주는 역할을 수행합니다.

그러나 PaymentUsecase에서 하나의 트랜잭션으로 묶여있다고 해서 과연 트랜잭션이 적절하게 원하는 의도대로 동작한다고 할 수 있을까요?

하나의 트랜잭션 내에서 DB접근과 외부 API 호출이 묶여있는 경우의 문제

모든 코드에서 예외가 발생하지 않으면 당연히 정상적으로 위의 코드는 동작할 것입니다.
그러나 만약 중간에 어떤 부분에서 예외가 발생한다면 어떨까요?

예외가 발생할때 문제가 되거나 고려해야하는 포인트는 무엇이 있을까요?

제가 생각했던 현재 코드에서 예외가 발생할 경우는 다음과 같습니다.

  • 외부 API 콜이 실패하면 해당 로직 이하는 모두 실행되지 않는다.
    • 외부 API에서 예외값을 던져주면 Feign을 통해 정의한 ErrorDecoder에 의해 예외가 발생합니다.
    • 예외가 발생하면 외부 API를 실행하는 로직 이하의 DB 저장 등의 로직은 실행되지 않으므로 롤백 자체가 필요 없다.
  • 외부 API 콜은 정상적으로 동작했지만, DB에 저장하는 로직 등에서 예외가 발생하는 경우
    • 외부 API는 성공했기 때문에, 실제 결제를 수행하는 Toss Server에서는 결제이력이 남지만, 본인의 서버 DB에는 결제이력이 남지 않는 데이터 정합성 문제가 발생한다.

첫 번째 경우는 지금 현재 코드 상에서 문제가 되지 않지만 두 번째의 경우가 큰 문제라고 생각하였습니다.

두번째와 같은 상황에서 문제가 되는 근본적인 이유는 외부 API 서버(토스 API 서버)를 호출하는 작업이 서버에서 물리적으로도 논리적으로도 트랜잭션을 걸어주거나 보장할 수 없기때문입니다.

즉 외부 API의 호출이 내부 서버에서 관리할 수 있는 트랜잭션 경계에 포함되지 않으며 원자성이 보장될 수 없습니다.

코드 상으로는 외부 API를 호출하여 응답을 받아오는 로직과 쇼핑몰 서버 내부에서 쓰기작업을 수행하는 것이 하나의 코드로 묶여있지만 실질적으로는 하나의 트랜잭션을 보장할 수 없는 ‘분산 트랜잭션’ 상황이라고 할 수 있습니다.

고려한 선택지

위와 같은 문제 상황에서 제가 생각해본 해결책은 다음과 같았습니다.

  1. 외부 API 호출 순서를 뒤로 수정한다.
    • 이 경우엔 서버 내 DB 쓰기 작업에서 예외가 발생해도 실제 결제를 수행하는 외부 API 호출은 발생하지 않는다.
    • 그러나 현 상황에서 DB 스키마(TossPayment 객체는 Entity이다.) 등의 변경이 불가피하다.
  2. 외부 API 호출 순서를 현재와 같이 유지하되, DB 쓰기작업 실패시에 보상 API를 구현해준다.

정해져있는 대답이 명확히 있지는 않지만, 단순하게 외부 API 호출을 뒤로 순서로 변경하는 것은 무언가 찝찝하였습니다. 기본적으로 로직을 전반적으로 변경해야하기도 했거니와 (왜냐하면 Toss 외부 API 서버에서 응답받은 객체로 쇼핑몰 서버 내에서 객체를 생성해서 Entity를 만들고 저장하는 로직으로 구성하였습니다.) 우회로를 이용하는 것 같다는 느낌이 들었습니다.

또한 ‘보상 트랜잭션’ 개념을 어찌되었든 적용해본 경험도 없고 전술하였듯 첫번째 선택지는 상황을 우회하는 듯한 인상을 받았기 때문에 보상 API를 통해 ‘결제 취소’라는 원활한 비즈니스 로직을 구현하고자 하였습니다.

보상 API의 구현 방식

보상 API를 구현하는 방식에 있어서도 고민이 되는 포인트가 있었습니다.

  1. 단순 try-catch로 잡아서 paymentClinet에게 취소 요청을 보낸다.
  2. Transaction Phase가 Rollback일 경우, 특정 이벤트를 생성해서 eventPublisher가 이벤트를 발행한다.
    • event를 소비하는 eventHandler가 로직을 구성한다.
    • 결제 실패 후 보상API 로직(결제 취소 요청)에 대하여 비동기 처리가 가능하다.

현재 보상 API가 구현하고자 하는 비즈니스 로직은 ‘결제 취소’였습니다.

결제 취소를 구현하고자 할때 위의 선택 지 중에서 저는 2번에서 언급한 Spring Event를 사용하기로 하여 보상 API를 구현하고자 하였습니다.

제 선택의 근거는 다음과 같습니다.

  1. WAS 내에서 결제는 실패하였고, 저장하지 못한 결제내역에 대해서 실제 결제를 수행하게 되는 대행사 서버(외부 API)에게 결제실패로 변경해달라는 것이 반드시 동기적일필요는 없다.
  2. 외부 API호출을 비동기적으로 처리할 경우에 응답대기를 최소화할 수 있다. 무엇보다 외부 서버에 결제 취소를 요청하는 것에서 데이터 정합성을 맞추는 것에서 대기할 필요가 없다.
  3. 즉각적 사용자 피드백이 불 필요한 영역이고, 외부 API와 데이터 정합성을 맞추는 과정이다. 즉, 서버 DB와 토스 서버 간의 정합성 유지가 핵심이다.
  4. 또한 추후 알림과 같은 도메인 구성 시에, 해당 도메인 역시 현재 상황에서 비동기처리로 하는 것이 더 깔끔하고 적절하다고 판단되다. 또한 단순 비동기 처리가 아니어도, 결합도 측면에서도 Event등의 Layer를 두어서 분리하는 것이 더 적절하다.

CF) 현재는 단일 서버를 구성하기도 하고 구현 난이도를 고려하여 Spring Event를 이용하였지만, 추후 Kafka 등을 도입할 수 있습니다.

Spring의 이벤트 기반 통신 구조

Spring의 Event통신을 이용하기로 했기때문에 간략하게 Spring의 이벤트 통신을 짚고 넘어가겠습니다.

Spring이 지원하는 이벤트 기반 통신은 하나의 어플리케이션 컨텍스트 내에서 지원합니다. 따라서 하나의 모듈 내에서 여러 패키지로 구분될떄 혹은 하나의 컨텍스트이지만 여러 모듈로 분리되었을때 이를 이용할 수 있습니다.
만약 MSA와 같이 각각의 어플리케이션 컨텍스트로 구분되고 이를 이벤트 기반으로 통신하기 위해서는 Kafka, Redis Pub/Sub 구조, RabbitMQ등을 이용해야합니다.

Spring Event는 Event 자체인 ‘Event’, 이벤트를 발행하는 주체인 ‘publisher’, 이벤트를 처리하는 ‘listener’가 있고 큰 틀에서는 다음과 같은 컴포넌트들이 존재합니다.

스프링 이벤트는 기본적으로 동기방식으로 동작하지만, 옵션을 줘서 비동기처리도 충분히 가능합니다.

이벤트 통신의 주요 컴포넌트

  • ApplicationEventPublisher
    • 이벤트 객체를 생성하기 위해 상속받아야 하는 클래스
    • publishEvent 메서드를 통해 이벤트 생성
    • ApplicationContext가 해당 클래스를 구현한다. (즉 ApplicationEventPulisher가 ApplicationContext보다 더 상위 인터페이스이다.)
    • 이벤트를 발행해야하는 로직의 클래스에서 이를 필드로 둔다.
  • ApplicationEvent
    • 이벤트 객체를 생성하기 위해 상속받아야하는 클래스
    • Spring 4.2부터는 CustomEventClass가 상속하지 않아도 된다.
  • ApplicationListener
    • 이벤트를 리스닝하기 위해 구현해야하는 클래스
    • CustomHandler가 해당 클래스를 구현하여 onApplicationEvent(E)메서드를 구현한다.
      • Spring 4.2부터 CustomHadler가 메서드를 구현하지 않고, @EventListener 어노테이션을 메서드 레벨에 붙여서 작성한다.
    • 해당 메서드는 ApplicationEventPublisher에서 특정 이벤트를 publish하면 자동으로 실행된다.
  • TransactionalEventListner
    • 스프링 4.2 이후부터 @EventListener를 확장하여 사용할 수 있다.
    • Transaction의 Phase별로 옵션을 적용해서 이벤트를 발행시킬 수 있다.

위의 내용을 토대로 코드를 수정하면 다음과 같습니다.

  • PaymentConfirmService
package shoppingmall.domainservice.domain.payment.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import shoppingmall.common.dto.toss.TossPaymentConfirmRequest;
import shoppingmall.common.dto.toss.TossPaymentConfirmResponse;
import shoppingmall.common.exception.ApiException;
import shoppingmall.common.exception.domain.PaymentErrorCode;
import shoppingmall.domainrdb.common.annotation.DomainRdbService;
import shoppingmall.domainrdb.order.domain.OrderDomain;
import shoppingmall.domainrdb.order.service.OrderRdbService;
import shoppingmall.domainrdb.payment.TossPaymentCancelEvent;
import shoppingmall.domainrdb.payment.TossPaymentDomain;
import shoppingmall.domainrdb.payment.service.PaymentRdbService;
import shoppingmall.domainredis.domain.dto.PaymentCacheDto;
import shoppingmall.domainredis.domain.payment.service.PaymentCacheService;
import shoppingmall.domainservice.domain.payment.mapper.PaymentConverter;
import shoppingmall.domainservice.domain.payment.dto.PaymentResponse;
import shoppingmall.tosspayment.feign.PaymentClient;


@DomainRdbService
@RequiredArgsConstructor
@Slf4j
public class PaymentConfirmService {
    private final PaymentRdbService paymentRdbService;
    private final PaymentClient paymentClient;
	private final EventPublisher<TossPaymentCancelEvent> eventPublisher;
    private final PaymentCacheService paymentCacheService;
    private final OrderRdbService orderRdbService;
    private final PaymentConverter paymentConverter;


    /**
     * 외부 API 호출 후 DB 저장시에 예외 발생시 보상 API 호출 가능하게끔 보상 트랜잭션 적용
     * <p>
     * TossPaymentCancelEvent정의하여 paymentKey, cancelReason을 넘겨준다.
     *
     * @param tossPaymentConfirmRequest
     * @return
     */

    public PaymentResponse confirm(final TossPaymentConfirmRequest tossPaymentConfirmRequest) {


        // TossPayment Confirm API 호출
        TossPaymentConfirmResponse tossPaymentConfirmResponse = paymentClient.confirmPayment(tossPaymentConfirmRequest);
        OrderDomain orderDomain = orderRdbService.findOrderDomainById(Long.parseLong(tossPaymentConfirmRequest.getOrderId()));
        TossPaymentDomain tosspaymentDomain = TossPaymentDomain.createForWrite(tossPaymentConfirmResponse, orderDomain);


        try {
            // PaymentRdbService에서 savePayment 메서드 호출
            TossPaymentDomain tossPaymentDomain = paymentRdbService.savePayment(tosspaymentDomain);

            // TossPaymentDomain을 PaymentCacheDto로 변환
            PaymentCacheDto paymentCacheDto = paymentConverter.from(tossPaymentDomain);


            // PaymentCacheService에서 savePaymentCache 메서드 호출
            paymentCacheService.savePaymentCache(tossPaymentDomain.getTossPaymentId().getValue(), paymentCacheDto);


            PaymentResponse paymentResponse = PaymentResponse.builder()
                    .paymentId(tossPaymentDomain.getTossPaymentId().getValue())
                    .orderId(orderDomain.getOrderId().getValue())
                    .tossPaymentStatus(tosspaymentDomain.getTossPaymentStatus())
                    .tossPaymentMethod(tosspaymentDomain.getTossPaymentMethod())
                    .amount(tosspaymentDomain.getAmount())
                    .build();

            return paymentResponse;


        } catch (Exception e) {
            // DB 저장 중 오류 발생시 결제 취소 이벤트 발행
       
            TossPaymentCancelEvent cancelEvent = new TossPaymentCancelEvent(tossPaymentConfirmRequest.getPaymentKey(), "서버 내부 DB 저장 중 오류 발생");
            paymentEvent.publishEvent(cancelEvent);
            log.debug("결제 취소 수행", e);
            throw new ApiException(PaymentErrorCode.FAIL_PAYMENT);

        }


    }
}

쇼핑몰 서버 내에서 쓰기작업을 수행하는 것들을 try-catch로 묶어줬습니다.
catch문 내부에서 예외를 잡아서 Event를 정의한 이후 이벤트를 발행하도록 구성한 이후에 다시 Custom 예외를 던져주는 형식으로 작성하였습니다.

package shoppingmall.domainservice.common.handler;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import shoppingmall.domainrdb.payment.TossPaymentCancelEvent;
import shoppingmall.tosspayment.feign.PaymentClient;

@Component
@RequiredArgsConstructor
@Slf4j
public class PaymentCompensationEventHandler {
    private final PaymentClient paymentClient;


    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
	@Async	
    public void issueCancelPaymentEvent(TossPaymentCancelEvent tossPaymentCancelEvent) {
        paymentClient.cancelPayment(tossPaymentCancelEvent.getPaymentKey(), tossPaymentCancelEvent.getCancelReason());


    }


}

결제와 관련하여 보상을 적용하는 핸들러입니다. @TransactionalEventListener를 적용하여 만약 트랜잭션 내부에서 롤백이 실행될 경우 결제취소 이벤트를 전달합니다.

  • PaymentClient
package shoppingmall.tosspayment.feign;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import shoppingmall.common.dto.toss.TossPaymentConfirmRequest;
import shoppingmall.common.dto.toss.TossPaymentConfirmResponse;

@FeignClient(name = "paymentClient", url = "${spring.payment.base-url}", configuration = PaymentConfiguration.class)
public interface PaymentClient {

    @PostMapping(value = "/confirm", consumes = MediaType.APPLICATION_JSON_VALUE)
    TossPaymentConfirmResponse confirmPayment(@RequestBody TossPaymentConfirmRequest tossPaymentConfirmRequest);

    @PostMapping(value = "/{paymentKey}/cancel")
    void cancelPayment(@PathVariable("paymentKey") String paymentKey, @RequestBody String cancelReason);


}

Feign Client를 이용하여 토스 서버에서 정의한 결제취소 규격에 맞춰서 구현한 결제취소 요청입니다.


결론

외부 API 호출과 내부의 트랜잭션이 함께 뒤섞여 있는, 위와 같은 분산 트랜잭션에서 고려해야할 중요한 포인트는 내부의 트랜잭션이 롤백되었을때 이를 어떻게 풀어낼 수 있느냐 하는 것입니다.

저는 이 방법으로서 스프링 이벤트를 사용하였고 보상 트랜잭션 개념을 도입하여, 롤백 시점에 결제 취소 이벤트를 비동기로 발행함으로써 외부 결제 서버와 내부 DB 간의 정합성을 최대한 유지할 수 있습니다.

즉, 결제 로직이 정상적으로 처리되면 모든 로직이 커밋되고, 중간에 예외가 터지면 트랜잭션이 롤백된 뒤 보상 API가 동작하여 외부 서버에 결제 취소를 요청합니다.

이 같은 설계는 비즈니스 흐름을 단순화하고, 장애 상황에서의 복구 로직을 구조적으로 분리한다는 이점이 있습니다.

또한 스프링 이벤트와 비동기 방식(예: @Async)을 활용함으로써, 외부 API 호출 시간과 응답에 대한 부담을 최소화할 수 있습니다.

향후에는 메시지 큐 기반의 이벤트 아키텍처로 확장하거나, 도메인 간 의존성이 더욱 복잡해질 때에도 유연하게 대처할 수 있다는 가능성이 열려 있습니다.

profile
고민하고 공부하는 사람

0개의 댓글