현재 진행중인 사이드 프로젝트 내에서 아이템을 구매할 때에는 서비스 재화인 포인트가 사용됩니다.
포인트는 게임 캐시처럼 직접 현금 결제하여 충전할 수 있습니다.
현금 결제를 위해 Portone이라는 솔루션을 이용하여 처리하고 있습니다.
이번 포스팅은 기존에 작성되었던 결제 로직을 리팩토링 하면서 고민했었던 내용입니다.
충전 할 금액을 선택 하면 PortOne 모듈을 통해 위와 같은 결제 창이 뜨게 됩니다.
사용자가 결제를 마무리하면 PortOne에서는 Webhook을 통해 사전에 지정한 서버 주소로
처리 된 PaymentId, Status 등 정보를 전송해줍니다.
Webhook을 수신 한 애플리케이션 서버는 결제 검증, 처리 후 포인트 충전 로직을 수행합니다.
위와 같은 플로우로 처리 됩니다.
@Transactional
public void paymentAndCharge(String paymentId) {
PortonePayment portonePayment = portoneAPI.getPaymentResult(paymentId);
ChargePointPayment chargePointPayment = chargePointPaymentProcessor.payment(portonePayment);
userPointManager.chargePoint(
chargePointPayment.getUserId(),
chargePointPayment.getChargePointType()
);
}
리팩토링을 하기 전 코드입니다.
결제 처리와 포인트 충전을 하나로 묶기 위해 외부 API 요청 함수까지 트랜젝션 범위로 묶인 상태입니다.
만약 외부 API 장애로 인해 지연 시간이 발생된다면 그 시간만큼 트랜젝션 자원을 소모하는 것이기에
일반적으로 트랜젝션 범위 내에는 외부 API가 포함되는 것이 좋지 않습니다.
public void paymentProcess(String paymentId) {
PortonePayment portonePayment = portoneAPI.getPaymentResult(paymentId);
approveAndCharge(portonePayment);
}
@Transactional
public void approveAndCharge(PortonePayment portonePayment) {
ChargePointPayment chargePointPayment = chargePointPaymentProcessor.payment(portonePayment);
userPointManager.chargePoint(
chargePointPayment.getUserId(),
chargePointPayment.getChargePointType()
);
}
위 코드처럼 approveAndCharge라는 함수로 분리후 해당 함수에 트랜젝션 처리를 한다면
외부 API 요청 로직은 트랜젝션 범위에 포함되지 않기 때문에 비교적 트랜젝션 자원을 효율적으로 사용하게 됩니다.
하지만 저는 결제 승인과 포인트 충전이 꼭 하나의 트랜젝션으로 묶어야 하는가에 대한 고민을 하였습니다.
결제 프로세스에서 예외가 발생하는 경우는 위 3가지 케이스입니다.
// 이미 처리 된 결제일 경우 예외 발생
private void validateStatus() {
if (!chargePointPaymentStatus.equals(ChargePointPaymentStatus.ORDERED)) {
throw new CustomException(Error.ALREADY_PROCESSED_PAYMENT);
}
}
// 올바르지 않은 주문 번호일 경우
public ChargePointPayment getChargePointPaymentForApprove(String paymentId) {
ChargePointPaymentEntity resultEntity =
jpaQueryFactory.select(chargePointPaymentEntity)
.from(chargePointPaymentEntity)
.where(
chargePointPaymentEntity.paymentId.eq(paymentId),
chargePointPaymentEntity.chargePointPaymentStatus.eq(ChargePointPaymentStatus.ORDERED))
.fetchFirst();
if (resultEntity == null) {
throw new CustomException(Error.NOT_FOUND);
}
return resultEntity.toModel();
}
1번 케이스는 애플리케이션 서버에서 발생시키는 예외입니다.
CustomException의 경우는 이미 처리 된 결제이거나 올바르지 않은 주문 번호일 경우 발생합니다.
private void validateAmount(PGPayment pgPayment) {
if (chargePointType.getAmount() != pgPayment.getAmount()) {
throw new InvalidPaymentException(pgPayment);
}
}
// InvalidPaymentException 발생 시 Response
{
"message": "결제 금액 오류"
}
2번 케이스도 애플리케이션 서버에서 발생시키는 예외입니다.
InvalidPaymentException는 처리 된 결제가 비정상적인 경우(결제 금액과 상품 금액이 다른 경우) 발생하는 예외입니다.
결제 금액은 프론트엔드에서 설정합니다. 따라서 악의적인 사용자가 100만원 짜리 물건을
100원만 결제 한 후 결제 승인 요청을 할 수 있기 때문에 결제 금액 검증은 필수입니다.
3번 케이스는 애플리케이션 서버에서 발생시키는 예외가 아닌 시스템 문제로 인해 발생할 수 있는 예상치 못한 예외입니다.
예를 들어 DB 서버 문제, 네트워크 문제 등입니다.
예외 케이스를 정리 한 후 예외를 처리하는 로직을 작성하며 리팩토링을 진행하였습니다.
현금 결제를 하였지만 포인트가 충전되지 않는 상황을 방지하기 위해 신경을 썼습니다.
public ChargePointPayment approve(String paymentId) {
try {
PGPayment pgPayment = pgAPI.getPayment(paymentId);
ChargePointPayment chargePointPayment = chargePointPaymentRepository.getChargePointPaymentForApprove(paymentId);
chargePointPayment.approve(pgPayment);
return chargePointPaymentRepository.save(chargePointPayment);
} catch (CustomException customException) {
throw customException;
} catch (Exception unknownException) {
chargePointPaymentFailHandler.failHandler(paymentId);
throw unknownException;
}
}
포인트 충전 결제를 처리하는 approve 메서드를 정의하였습니다.
CustomException의 경우는 이미 처리 된 결제이거나 올바르지 않은 주문 번호일 경우 발생합니다.
위에서 언급되었던 것 처럼 CustomException 예외의 경우는 이미 처리 된 결제이거나
올바르지 않은 주문 번호일 경우 발생되는 예외이기 때문에 단순히 해당 예외를 그대로 던져주고 있습니다.
다음으로 Exception을 catch 하고 있는데요.
따라서 해당 catch 블록은 CustomException을 제외 한 모든 예외를 처리하게 됩니다.
InvalidPaymentException는 처리 된 결제가 비정상적인 경우(결제 금액과 상품 금액이 다른 경우) 발생하는 예외입니다.
InvalidPaymentException의 경우는 비정상적인 결제이기 때문에 결제 취소 요청이 필요합니다.
또한 예상치 못한 Exception의 경우도 결제 취소 요청을 해주지 않는다면 사용자 입장에서는
포인트 충전이 제대로 이루어지지 않은 채 오류가 발생했다는 응답만 받을 것 입니다.
public void failHandler(String paymentId) {
ChargePointPayment chargePointPayment = chargePointPaymentRepository.findByPaymentId(paymentId);
chargePointPayment.fail();
chargePointPaymentRepository.save(chargePointPayment);
pgAPI.cancel(chargePointPayment.getPaymentId());
}
failHandler 내부에서는 결제 취소 처리 및 로깅 로직이 수행됩니다.
위처럼 approve메서드는 2개의 catch 블럭을 이용하여 예외를 처리하였습니다.
@EventListener
public void chargePointEvent(ChargePointEvent chargePointEvent) {
try {
userPointManager.chargePoint(chargePointEvent.getUserId(), chargePointEvent.getChargePointType());
} catch (Exception e) {
chargePointPaymentFailHandler.failHandler(chargePointEvent.getPaymentId());
throw new CustomException(Error.PAYMENT_ERROR);
}
}
// ------------------------------------------------------------------------------ //
public void chargePoint(Long userId, ChargePointType chargePointType) {
UserPoint userPoint = userPointReader.getUserPoint(userId);
userPoint.charge(chargePointType.getAmount());
userPointRepository.save(userPoint);
}
다음으로는 포인트 충전을 담당하는 chargePointEvent 메서드를 정의하였습니다.
userPointManager.chargePoint 같은 경우는 매우 단순한 로직이며
애플리케이션 서버에서 의도적으로 발생시키는 예외는 없기 때문에
예외가 발생했다면 예상치 못한 예외일 것입니다.
따라서 chargePointEvent가 수행되는 동안 예외가 발생했다면
사용자 입장에서는 현금 결제를 하였지만 포인트 충전이 되지 않는 상황이 발생합니다.
따라서 위 catch 블럭에서도 failHandler를 통해 결제 취소 처리 및 로깅 로직을 수행합니다.
@PostMapping("/charge/payment")
public DefaultResponse payment(@RequestBody ChargePointPaymentRequest request) {
chargePointPaymentService.approvePayment(request.getPayment_id());
return DefaultResponse.success();
}
// ------------------------------------------------------------------------------------ //
public void approvePayment(String paymentId) {
ChargePointPayment chargePointPayment = chargePointPaymentApprover.approve(paymentId);
applicationEventPublisher.publishEvent(ChargePointEvent.from(chargePointPayment));
}
최종적으로 webhook 요청이 수신되면 approvePayment 메서드를 호출하여
결제 승인 처리 로직을 실행하고 ChargePointEvent 이벤트를 발생시켜 포인트 충전 로직을 수행합니다.
하지만 저는 결제 승인과 포인트 충전이 꼭 하나의 트랜젝션으로 묶어야 하는가에 대한 고민을 하였습니다.
결론적으로 제 코드는 결제 승인과 포인트 충전이 하나의 트랜젝션으로 묶지 않았습니다.
@Transactional
public void 결제_승인() {
결제_승인_작업_1();
결제_승인_작업_2();
결제_승인_작업_3();
결제_승인_작업_4();
}
@Transactional
public void 포인트_충전() {
포인트_충전1();
포인트_충전2();
포인트_충전3();
포인트_충전4();
}
결제 후 포인트 충전이 보장되어야 하는 것은 서비스 이용자 입장이라고 생각합니다.
트랜젝션 관점에서는 트랜젝션의 단위가 결제 승인과 관련 된 DB 작업, 포인트 충전과 관련된 DB 작업으로
분리되어야 한다고 생각이 들었습니다.
따라서 결제 승인과 포인트 충전은 별도의 트랜젝션으로 처리해야하며
사용자를 위해 결제 후 포인트 충전 보장은 애플리케이션 단에서 직접 처리 방향으로 진행했습니다.
저는 각 로직에서 try catch를 통해 예외 핸들링을 처리하였지만
MSA처럼 트랜젝션 처리가 어려운 분산 서비스의 경우 보상 트랜젝션 및 재시도 매커니즘을 통해
여러 로직의 모든 성공을 보장하는 방법이 있다는 것을 알게 되었고
제 프로젝트에서는 재시도 매커니즘 도입은 가능할 것 같아 추후에 도입해볼 예정입니다.
만약 재시도 매커니즘을 도입한다면 예외 발생 시 바로 결제 취소를 하지 않기 때문에 서비스 입장에선
매출을 올릴 수 있을 것 같습니다.
트랜젝션은 일반적으로 예외가 발생하였을 때 이전에 처리 된 작업을 Rollback 시키기 위해 사용되는데요.
결제 승인 성공 이후 예상치 못한 예외로 포인트 충전에 실패했다고 가정해보겠습니다.
(결제 승인으로 인해 Not Paid -> Paid로 상태 변경)
예외가 발생하고 나면 트랜젝션으로 인해 결제 상태는 Not Paid 처리가 되고 마무리 될 것 입니다.
하지만 예외가 발생했을 때 결제 상태를 Not Paid로 변경하고 마무리 짓기보단
Fail로 처리하여 로깅 처리를 하고 비즈니스 로직 상 PG API를 호출하여
결제 취소 처리를 해야하는 상황이 올 수도 있습니다.
결제 프로세스에선 단순히 트랜젝션으로 묶어서 처리하기 보단 예외가 발생할 수 있는 부분에서
직접 처리하는 것이 더 세부적인 처리가 가능했던 것 같습니다.