현재 진행중인 사이드 프로젝트 내에서 아이템을 구매할 때에는 서비스 재화인 포인트가 사용됩니다.
포인트는 게임 캐시처럼 직접 현금 결제하여 충전할 수 있습니다.
현금 결제를 위해 Portone이라는 솔루션을 이용하여 처리하고 있습니다.
이번 포스팅은 기존에 작성되었던 결제 로직을 리팩토링 하면서 고민했었던 내용입니다.
결제 플로우
충전 할 금액을 선택 하면 PortOne 모듈을 통해 위와 같은 결제 창이 뜨게 됩니다.
사용자가 결제를 마무리하면 PortOne에서는 Webhook을 통해 사전에 지정한 서버 주소로 처리 된 PaymentId, Status 등 정보를
전송해줍니다. Webhook을 수신 한 애플리케이션 서버는 결제 검증, 처리 후 포인트 충전 로직을 수행합니다.
- PG 모듈을 이용하여 결제 완료
- WebHook을 통해 애플리케이션 서버에 결제 승인 요청
- 애플리케이션 서버는 결제 검증 및 승인
- 포인트 충전
위와 같은 플로우로 처리 됩니다.
트랜젝션 in 결제 프로세스
@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가 포함되는 것이 좋지 않습니다.
// paymentService
public void paymentProcess(String paymentId) {
PortonePayment portonePayment = portoneAPI.getPaymentResult(paymentId);
paymentProcessor.approveAndCharge(portonePayment);
}
// paymentProcessor
@Transactional
public void approveAndCharge(PortonePayment portonePayment) {
ChargePointPayment chargePointPayment = chargePointPaymentProcessor.payment(portonePayment);
userPointManager.chargePoint(
chargePointPayment.getUserId(),
chargePointPayment.getChargePointType()
);
}
위 코드처럼 approveAndCharge라는 함수로 분리후 해당 함수에 트랜젝션 처리를 한다면
외부 API 요청 로직은 트랜젝션 범위에 포함되지 않기 때문에 트랜젝션 자원을 효율적으로 사용하게 됩니다.
예외 in 결제 프로세스
제가 결제 로직에서 트랜젝션을 사용한 이유는 결제와 충전은 항상 같이 성공해야하고 같이 실패해야한다는 이유였습니다.
예를 들어 결제가 성공하였더라도 포인트 충전이 실패했다면 결제 성공이란 DB 데이터를 롤백시키기 위함이였습니다.
따라서 결제 로직에서 발생할 수 있는 예외부터 살펴보았습니다.
- CustomException 발생
- InvalidPaymentException 발생
- UnknownException 발생
결제 프로세스에서 예외가 발생하는 경우는 크기 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번 케이스 CustoemException은 비즈니스 로직 수행 도중 올바르지 않은 경우 발생시키는 예외입니다.
결제 처리 과정 중에서는 이미 처리 된 결제이거나 올바르지 않은 주문 번호일 경우 발생합니다.
private void validateAmount(PGPayment pgPayment) {
if (chargePointType.getAmount() != pgPayment.getAmount()) {
throw new InvalidPaymentException(pgPayment);
}
}
// InvalidPaymentException 발생 시 Response
{
"message": "결제 금액 오류"
}
2번 케이스 InvalidPaymentException은 결제 금액과 상품 금액이 다른 경우 발생하는 예외입니다.
결제 금액은 프론트엔드에서 설정합니다. 따라서 악의적인 사용자가 100만원 짜리 물건을
100원만 결제 한 후 결제 승인 요청을 할 수 있기 때문에 결제 금액 검증은 필수입니다.
3번 케이스 UnknownException은 검증 로직에서 의도적으로 발생하는 예외가 아니고 코드 적인 문제 혹은
예상치 못한 시스템 적 예외입니다. 예를 들어 DB 서버 문제, 네트워크 문제 등입니다.
예외 처리 In 결제 프로세스
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 메서드를 정의하였습니다.
로직 처리 중 발생할 수 있는 예외 상황을 try catch로 처리하고 있습니다.
CustomException이 발생하는 상황은 올바르지 않은 주문번호, 이미 처리 된 결제 두 가지 상황입니다.
해당 경우는 따로 처리해줄 작업이 없기 때문에 그대로 throw 해주고 있습니다.
하지만 InvalidPaymentException, UnknownException은 별도의 후처리 작업을 진행하는데요.
결제 금액 오류 및 시스템 오류는 결제 자체는 유효하나 오류가 발생하여 마무리가 되지 못한 경우입니다.
따라서 예외를 던지기 전에 사용자의 결제를 취소처리 해야합니다.
따라서 해당 catch문에서는 예외를 던지기 전에 failHandler라는 함수를 호출합니다.
public void failHandler(String paymentId) {
ChargePointPayment chargePointPayment = chargePointPaymentRepository.findByPaymentId(paymentId);
chargePointPayment.fail();
chargePointPaymentRepository.save(chargePointPayment);
pgAPI.cancel(chargePointPayment.getPaymentId());
}
failHandler 내부에서는 결제 취소 처리 및 로깅 로직이 수행됩니다.
@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 메서드를 정의하였습니다.
해당 메서드는 결제 완료 이후 Service Layer에서 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으로만 처리 할 상황이 아님
트랜젝션은 일반적으로 예외가 발생하였을 때 이전에 처리 된 작업을 Rollback 시키기 위해 사용되는데요.
결제 승인 성공 이후 예상치 못한 예외로 포인트 충전에 실패했다고 가정해보겠습니다.
(결제 승인으로 인해 Not Paid -> Paid로 상태 변경)
예외가 발생하고 나면 트랜젝션으로 인해 결제 상태는 Not Paid 처리가 되고 마무리 될 것 입니다. 하지만 예외가 발생했을 때 결제 상태를
Not Paid로 변경하고 마무리 짓기보단 Fail로 처리하여 로깅 처리를 하고 비즈니스 로직 상 PG API를 호출하여 결제 취소 처리를 해야하는
상황이 올 수도 있습니다. 결제 프로세스에선 단순히 트랜젝션으로 묶어서 처리하기 보단 예외가 발생할 수 있는 부분에서 직접 처리하는
것이 더 세부적인 처리가 가능했던 것 같습니다.
'spring' 카테고리의 다른 글
예외 처리의 예외 처리 (feat. RabbitMQ 지연 큐) (1) | 2025.01.18 |
---|---|
Mongo DB 조회 성능 개선기 (1) | 2024.12.27 |
복잡한 Join 탈출 - 결제 내역 조회 With Mongo DB (0) | 2024.12.16 |
리뷰 기능 평점 통계 개선기 (1) | 2024.12.06 |
알고 쓰자 - ContentCachingRequestWrapper (1) | 2024.12.06 |