결제 로직에서 발생하는 예외 처리하기(feat.트랜젝션 의존 줄이기)
현재 진행중인 사이드 프로젝트 내에서 아이템을 구매할 때에는 서비스 재화인 포인트가 사용됩니다. 포인트는 게임 캐시처럼 직접 현금 결제하여 충전할 수 있습니다.현금 결제를 위해 Portone
e4g3r.tistory.com
예외 처리의 예외 처리 (feat. RabbitMQ 지연 큐)
결제 로직 에서 발생하는 예외 처리하기(feat.트랜젝션 의존 줄이기)현재 진행중인 사이드 프로젝트 내에서 아이템을 구매할 때에는 서비스 재화인 포인트가 사용됩니다. 포인트는 게임 캐시처
e4g3r.tistory.com
이전에 작성한 포스팅과 연결되어 계속해서 보완해나가는 과정입니다.
RabbitMQ -> AWS SNS + SQS 전환
기존에는 결제 도중 예외가 발생했을 때 Rabbit MQ에 메시지를 발행하여 유실을 방지하는데 사용했었습니다.
이후 프로젝트에서 이벤트를 발행해서 로직을 처리해야했고 메시지 브로커인 RabbitMQ를 이벤트 브로커처럼 사용해야했습니다.
예를 들어 결제가 이루어지고 이벤트를 발행해서 상품 판매량 통계 업데이트, 배송 정보 업데이트, 결제 알림 푸시 전송이 처리되어야 한다면
PaymentEvent 메시지를 발행하고 Fanout Exchange를 통해 각 큐로 메시지를 BraodCast 해야했습니다.
만약 후처리 해야할 로직이 3개가 아닌 6개라면 큐는 6개가 필요할 것이고 관리 포인트가 증가되는 단점이 있었습니다.
하지만 사이드 프로젝트 규모에서 이보다 많은 후처리는 없을 것 같았고 이벤트 브로커인 kafka를 도입하기에는 오버엔지니어링인것 같아
그냥 메시지 브로커를 통해 처리하고 있었습니다.
그러다가 우연히 위 방식과 유사하게 처리되는 AWS SNS + SQS 조합을 알게 되었습니다.
Queue는 SNS의 특정 토픽을 구독할 수 있었고 SNS에 토픽이 전달되면 메세지가 각 큐로 전송되는 것이였습니다.
RabbitMQ는 현재 cloudamqp라는 클라우드 서비스에서 프리티어 버전으로 사용중입니다.
반면에 AWS SNS + SQS는 ServerLess 서비스이기 때문에 서버의 사양에 영향을 받지 않는다는 장점이 있었고
무엇보다 콘솔에서 간단하게 SNS 토픽 설정 및 Queue 설정이 가능한 것이 큰 이점이였습니다.
또한 전환하는 것이 그렇게 오래걸릴 것 같지 않았고 RabbitMQ에서 AWS SNS + SQS로 전환하게 되었습니다.
애플리케이션 서버에서 특정 이벤트를 SNS로 발행하고 SQS로 전달이 완료되면 Queue의 메세지를 애플리케이션 서버가 처리하는
구조로 변경되었습니다.
RabbitMQ로 직접 구현하였던 지연 큐 재시도 로직도 유사하게 기본적으로 제공되고 있기에 문제없이 전환할 수 있을 것 같습니다.
그리고 특정 횟수 이상 재시도에 실패한다면 DLQ로 이동되게 하여 나중에 일괄처리 하는 방식으로 진행하였습니다.
메시지 / 이벤트 발행 보장이 중요하다
메시징 큐를 도입했던 이유는 예외가 발생했을 때 메시지를 발행하면 메시지가 소비되기 전까지 큐에 남아있기 때문에
예외 처리가 제대로 이루어짐을 보장받을 수 있기 때문이였습니다.
ex)결제 실패 시 이벤트를 발행하면 롤백 처리될 때 까지 큐에 보관 됨
따라서 DB 장애 / 외부 API 장애가 발생해도 큐에서 메시지가 사라지지 않고 유실되지 않기에 언젠가 처리 됨을 보장합니다.
이후 저는 문제가 없을 것 같다고 판단하였고 프로젝트의 다른 부분을 리팩토링 하고 있었는데요.
그러다 우연히 Transactional Outbox Pattern이란 개념을 알게 되었습니다.
Transactional Outbox Pattern은 이벤트 발행을 보장하는 방법 중 하나입니다.
Transactional Outbox Pattern을 알게되면서 메시지를 발행하지 못한 경우를 놓치고 있었단 걸 알게되었습니다.
@Transactional
public ChargePointPayment approve(PGPayment pgPayment) {
try {
ChargePointPayment chargePointPayment = chargePointPaymentRepository.findByPaymentIdWithLock(pgPayment.getPaymentId());
chargePointPayment.validatePayment(pgPayment.getAmount());
chargePointPayment.approve(pgPayment.getReceiptUrl());
return chargePointPaymentRepository.save(chargePointPayment);
} catch (CoreException coreException) {
throw coreException;
} catch (Exception unknownException) {
messageSender.sendMessage(MessageType.FAIL_CHARGE_POINT, FailChargePointPaymentMessage.create(pgPayment.getPaymentId()));
throw unknownException;
}
}
결제를 처리하던 기존 코드입니다. 결제 처리하는 approve 메서드에서 예외가 발생하면 결제 실패 메시지를 발행하게 됩니다.
메시지를 발행했다면 언젠가는 결국 처리 됨을 보장받을 수 있습니다.
하지만 네트워크 문제로 인해 메시지가 발행되지 않았다면 결제 실패의 예외 처리를 보장할 수 없습니다.
최악의 상황에서의 해결 책은 결국 로그 남기기
포인트 충전 결제 플로우는 아래와 같습니다.
1. 클라이언트가 PG API를 통해 실제 결제를 진행
2. 클라이언트가 결제 번호를 서버로 전달하여 포인트 충전 요청
3. 서버는 결제 번호 이용하여 결제 검증 + 포인트 충전 처리
보완해야하는 부분은 3번 과정에서 예외가 발생했을 때 결제 실패 이벤트가 발행이 되도록 보장하는 것입니다.
이 경우에는 Transactional Outbox Pattern가 해결책은 아니였습니다.
Transactional Outbox Pattern은 이벤트가 발행되지 않아 발생할 수 있는 데이터 정합성 문제를 해결하기 위한 것입니다.
예를 들어 회원 탈퇴 시 이벤트를 발행해서 유저가 작성한 게시글, 댓글, 리뷰를 삭제 처리한다면 회원 탈퇴는 처리되었지만
이벤트가 발행되지 않은경우 게시글, 댓글, 리뷰는 남아있게 되고 데이터 정합성 문제가 생깁니다.
따라서 (회원 탈퇴 로직과 이벤트 발행 로그를 저장하는 로직)을 하나의 트랜젝션으로 묶고, 이벤트 발행 로그 저장에 실패하면
회원 탈퇴를 롤백하는 것으로 데이터 정합성을 지킬 수 있습니다. 또한 이벤트 발행 로그 저장까지 성공했지만 이벤트 발행에
실패한다하더라도 데이터베이스에는 이벤트 발행 로그가 남아있기 때문에 발행에 성공할 때 까지 재시도 할 수 있습니다.
이제 결제 과정을 다시 보겠습니다.
결제 과정에서 발행되는 결제 실패 이벤트는 결제 도중 예외 상황이 발생했을 때 발행되는 이벤트로
위 예시의 회원 탈퇴처럼 어떠한 행위가 발생한 것은 아닙니다.
따라서 데이터 정합성이 어긋나는 문제가 아니므로 이벤트 발행 로그 저장에 실패한다 하더라도 롤백 할 행위/대상은 없기에 Transactional Outbox Pattern을 사용할 이유는 없고 단순히 결제 처리를 실패했다는 기록만 보존하면 됩니다.
그리고 기록을 통해 언젠가는 환불 처리가 되도록 보장하면 됩니다.
따라서 이벤트 발행이 실패하는 경우에는 애플리케이션 서버에 로그 파일을 남기고 나중에 일괄 처리 하는 방법뿐이었습니다.
@Transactional
fun approve(pgPayment: PGPayment): ChargePointPayment {
try {
val chargePointPayment = chargePointPaymentRepository.findByPaymentIdWithLock(pgPayment.paymentId)
chargePointPayment.validatePayment(pgPayment.amount)
chargePointPayment.approve(pgPayment.receiptUrl)
return chargePointPaymentRepository.save(chargePointPayment)
} catch (coreException: CoreException) {
throw coreException
} catch (unknownException: Exception) {
failChargePointPaymentEventProcessor.publishEvent(FailChargePointPaymentEvent(pgPayment.paymentId))
throw unknownException
}
}
결제 승인 과정에서 만약 예외가 발생한다면 failChargePointPaymentEventProcessor로 이벤트 발행을 하게 됩니다.
@Component
class FailChargePointPaymentEventProcessor(
private val snsClient: SnsClient,
) {
private val logger = LoggerFactory.getLogger("error-publish-fail-charge-point-event")
fun publishEvent(failChargePointPaymentEvent: FailChargePointPaymentEvent) {
try {
snsClient.publish(failChargePointPaymentEvent.toRequest())
} catch (e: Exception) {
logger.warn(failChargePointPaymentEvent.paymentId)
}
}
}
publishEvent 메서드를 보면 이벤트 발행 실패 시 logback을 통해 로그를 남기도록 하였습니다.
저는 추가 설정을 통해 발행 된 로그가 파일로 저장되도록 설정하였습니다.
// log
2025-03-09 23:36:38 | paymentId1
2025-03-09 23:37:38 | paymentId2
2025-03-09 23:38:38 | paymentId3
2025-03-09 23:39:38 | paymentId4
만약 이벤트 발행에 실패했다면 log 파일에 paymentId가 남아있기 때문에 언젠가는 해당 결제 건이 취소 처리 됨을 보장할 수 있습니다.
최악 최악의 상황
결제 기능을 구현하고 보완하면서 항상 최악의 경우의 수를 생각하는 습관을 가지게 되었는데요.
우연히 Transactional Outbox Pattern를 알게되어 이벤트 발행에 실패하면 어쩌지? 라는 생각을 할 수 있었고
그 덕분에 이벤트 발행에 실패했을 때 paymentId를 로그 파일로 남겨 예외 처리를 해야 할 결제를 유실시키지 않을 수 있었습니다.
그런데 문득 애플리케이션 서버 자체가 먹통이 된다면 log 파일을 남기는 로직 조차 실행되지 않을 수 있을 거란 생각이 들게되었습니다.
제 상식으로는 애플리케이션으로 처리 할 영역을 넘어선 것이라고 생각이 들게 되었고
어쩔수 없이 PG 서비스로부터 결제 내역을 불러와서 DB 데이터와 하나씩 비교 해 가며 처리해야하지 않을까 싶습니다.
계속해서 결제 로직에서 발생할 수 있는 예외 케이스들을 찾고 보완하는 과정을 이어나가야겠습니다.
'spring' 카테고리의 다른 글
Spring RabbitListener에서 예외가 발생하면? feat. 무한 루프 (0) | 2025.02.07 |
---|---|
예외 처리의 예외 처리 (feat. RabbitMQ 지연 큐) (1) | 2025.01.18 |
Mongo DB 조회 성능 개선기 (1) | 2024.12.27 |
복잡한 Join 탈출 - 결제 내역 조회 With Mongo DB (0) | 2024.12.16 |
결제 로직에서 발생하는 예외 처리하기(feat.트랜젝션 의존 줄이기) (0) | 2024.12.06 |