우리가 웹 상에서 결제를 한 번이라도 해봤으면 모두 알겠지만, 사용자가 요청하는 결제수단은 ‘무통장입금’, ‘휴대폰결제’, ‘XX페이’, ‘카드결제’ 등.. 수 많은 결제 수단이 존재합니다. 각각의 결제수단들에 대해서 하나의 쇼핑몰이 모든 것을 구현하는 것은 분명 비효율적입니다.
따라서 대개의 경우엔 흔히 말하는 PG사라고 불리우는 결제대행사 서버를 통해서 손쉽게 ‘결제 기능’을 구축할 수 있습니다. (물론 단순 기능구현 외에 실제 ‘PG사와의 계약’ 등은 다른 측면이 있으나 본 글의 내용의 범위가 넘어가는 것이기에 해당 부분은 제외하였습니다.)
구글링을 해보면 결제 대행을 수행하는 대행사는 아임포트, 토스 등이 있었고, 개인적으로 실 이용을 하고 친숙도가 높은 ‘토스 API’를 이용하여 결제를 구현하기로 결정하였습니다.
저번 Feign Client와 관련한 글에서 설명하였던 것처럼, 외부 API 서버를 호출하는 클라이언트 툴로서 ‘Feign’을 사용하였습니다.
이번 글에서는 결제 기능을 구현하면서 겪었던 문제상황과 해당 문제상황을 해결하기 위한 본인의 고민을 공유하는 시간을 가져보도록 하겠습니다.
토스 서버에 전달해 줘야 하는 값을 비롯하여 PG사에서 수행하는 로직은 본 글에서 설명하고자 하는 요지와 거리가 먼 관계로 해당 내용은 제외하였으니 참고해 주시기를 바랍니다.
서버 내에서의 결제 흐름을 간략하게 표현하자면 다음과 같습니다.

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

복잡하게 여러가지 클래스가 존재하는 것 같지만, 실제로 결제를 수행하는데 결정적인 역할을 하는 클래스는 다음과 같습니다.
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)에 의해서 커밋되거나 혹은 롤백되어야합니다.
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에서 하나의 트랜잭션으로 묶여있다고 해서 과연 트랜잭션이 적절하게 원하는 의도대로 동작한다고 할 수 있을까요?
모든 코드에서 예외가 발생하지 않으면 당연히 정상적으로 위의 코드는 동작할 것입니다.
그러나 만약 중간에 어떤 부분에서 예외가 발생한다면 어떨까요?
예외가 발생할때 문제가 되거나 고려해야하는 포인트는 무엇이 있을까요?
제가 생각했던 현재 코드에서 예외가 발생할 경우는 다음과 같습니다.
첫 번째 경우는 지금 현재 코드 상에서 문제가 되지 않지만 두 번째의 경우가 큰 문제라고 생각하였습니다.
두번째와 같은 상황에서 문제가 되는 근본적인 이유는 외부 API 서버(토스 API 서버)를 호출하는 작업이 서버에서 물리적으로도 논리적으로도 트랜잭션을 걸어주거나 보장할 수 없기때문입니다.
즉 외부 API의 호출이 내부 서버에서 관리할 수 있는 트랜잭션 경계에 포함되지 않으며 원자성이 보장될 수 없습니다.
코드 상으로는 외부 API를 호출하여 응답을 받아오는 로직과 쇼핑몰 서버 내부에서 쓰기작업을 수행하는 것이 하나의 코드로 묶여있지만 실질적으로는 하나의 트랜잭션을 보장할 수 없는 ‘분산 트랜잭션’ 상황이라고 할 수 있습니다.
위와 같은 문제 상황에서 제가 생각해본 해결책은 다음과 같았습니다.
정해져있는 대답이 명확히 있지는 않지만, 단순하게 외부 API 호출을 뒤로 순서로 변경하는 것은 무언가 찝찝하였습니다. 기본적으로 로직을 전반적으로 변경해야하기도 했거니와 (왜냐하면 Toss 외부 API 서버에서 응답받은 객체로 쇼핑몰 서버 내에서 객체를 생성해서 Entity를 만들고 저장하는 로직으로 구성하였습니다.) 우회로를 이용하는 것 같다는 느낌이 들었습니다.
또한 ‘보상 트랜잭션’ 개념을 어찌되었든 적용해본 경험도 없고 전술하였듯 첫번째 선택지는 상황을 우회하는 듯한 인상을 받았기 때문에 보상 API를 통해 ‘결제 취소’라는 원활한 비즈니스 로직을 구현하고자 하였습니다.
보상 API를 구현하는 방식에 있어서도 고민이 되는 포인트가 있었습니다.
현재 보상 API가 구현하고자 하는 비즈니스 로직은 ‘결제 취소’였습니다.
결제 취소를 구현하고자 할때 위의 선택 지 중에서 저는 2번에서 언급한 Spring Event를 사용하기로 하여 보상 API를 구현하고자 하였습니다.
제 선택의 근거는 다음과 같습니다.
CF) 현재는 단일 서버를 구성하기도 하고 구현 난이도를 고려하여 Spring Event를 이용하였지만, 추후 Kafka 등을 도입할 수 있습니다.
Spring의 Event통신을 이용하기로 했기때문에 간략하게 Spring의 이벤트 통신을 짚고 넘어가겠습니다.
Spring이 지원하는 이벤트 기반 통신은 하나의 어플리케이션 컨텍스트 내에서 지원합니다. 따라서 하나의 모듈 내에서 여러 패키지로 구분될떄 혹은 하나의 컨텍스트이지만 여러 모듈로 분리되었을때 이를 이용할 수 있습니다.
만약 MSA와 같이 각각의 어플리케이션 컨텍스트로 구분되고 이를 이벤트 기반으로 통신하기 위해서는 Kafka, Redis Pub/Sub 구조, RabbitMQ등을 이용해야합니다.
Spring Event는 Event 자체인 ‘Event’, 이벤트를 발행하는 주체인 ‘publisher’, 이벤트를 처리하는 ‘listener’가 있고 큰 틀에서는 다음과 같은 컴포넌트들이 존재합니다.
스프링 이벤트는 기본적으로 동기방식으로 동작하지만, 옵션을 줘서 비동기처리도 충분히 가능합니다.
이벤트 통신의 주요 컴포넌트
위의 내용을 토대로 코드를 수정하면 다음과 같습니다.
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를 적용하여 만약 트랜잭션 내부에서 롤백이 실행될 경우 결제취소 이벤트를 전달합니다.
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 호출 시간과 응답에 대한 부담을 최소화할 수 있습니다.
향후에는 메시지 큐 기반의 이벤트 아키텍처로 확장하거나, 도메인 간 의존성이 더욱 복잡해질 때에도 유연하게 대처할 수 있다는 가능성이 열려 있습니다.