개발 일기 3편 : 결제 데이터 재조정!
개발 일기 3편
이전 내용 이어서 : 개발 일기 2편
[ 잘못된 데이터를 어떻게 수정할까? ]
<Reconciliation in “결제!”>
네트워크, 서버 장애 등 다양한 원인으로 DB에 정상 데이터가 입력되지 않을 수 있다!
하지만 이전에 언급한 것처럼 부분적 트랜잭션을 보장하는 구조에서는
고려해야 할 결과는 한정적이다
오로지 paymentEvent와 PG사의 데이터 기록만이 서로 다른 status를 가질 때
Reconciliation을 하면 된다!
예를 들어
우리 데이터는 PENDING인데 PG사 데이터 기록은 PAID인 경우
PG사 데이터 기반으로 PENDING → SUCCESS인 데이터를 append해보자
[ 크레딧까지 결과 전파? ]
< 1. 크레딧은? >
이전에 논의했듯이, credit은 단순히 카운트에 불과하지만
우리는 credit의 공급 및 사용에 대한 원장 데이터를 기록하기 때문에
Event를 새로 append 하게 되면
paymentEventstatus가 바뀐 새로운 ROW append
paymentOrder의 status 변경
creditAccount의 발급 또는 발급 취소로 인한 개수 변경
creditLedger에 새로운 ROW append
가 순차적으로 진행된다.
기본적인 전제조건은
- (paymentEvent와 paymentOrder) / (creditAccount와 creditLedger) 각각은 같은 트랜잭션!
- payment가 credit에 영향을 주는 거는 success 와 refund 뿐!
< 2. 어떻게? >
제외했던 방법
- Transaction Outbox Pattern : 인프라적 비용 문제로 이후에 채팅 서비스에서 많관부 ㅎㅎ
- 비동기 이벤트 통신 : MQ 등을 활용하기 때문에 비용적 문제 + 크레딧이 비동기 적으로 들어오는건 좀 아니지 않나?
일단 크게 두 가지 선택 사항이 있다.
- Transaction을 묶는 방법
- 각각을 transcation으로 놓고 크레딧 실패시 Retry 로직 또는 스케줄러
무조건적으로 트랜잭션을 거는게 좋은 방법인거 같지만, 중요한 문제가 있다
결제 성공 상황을 조작하는 HandleSuccess에서 한 트랜잭션으로 크레딧을 발급을 묶으면,
크레딧 발급이 실패하는 상황이 발생 했을 때,
트랜잭션 rollback 과정에서 결제 성공 → 대기 상태로 변경 되는 문제가 있다.
⇒ 서버 자체적으로는 부분적 원자성을 보장한다!
⇒ 단, 결과적으로 DB의 상태와 Portone의 상태가 CreditService의 성공 여부에 따라 달라진다 ㅠㅠ
두번째 각각을 트랜잭션으로 놓게 되면,
크레딧 발급 성공/실패와는 상관 없이 결제 부분과 portone 데이터가 서버 장애 등 문제 제외, 정상 상태가 보장된다
하지만, 크레딧 발급 과정의 실패 + 결제는 성공이라는 부분적 원자성이 깨진다.
⇒ 결제와 portone의 데이터의 정합성을 보장한다!
⇒ 단, 서버 자체적으로는 부분적 원자성은 깨진다 ㅠㅠ
< 3. 각각에 대한 보정 >
먼저 두번째 케이스에 대해서 생각을 해보면,
부분적 원자성을 되찾기 위해서
결제 성공 데이터를 기반으로 무조건 적으로 크레딧 발급을 시도해야한다
결국 성공했다면 크레딧을 무조건 넣어야한다 creditAccount + creditLedger 둘다!
아무리 retry로직을 넣는다고 해도, 서비스가 성공한다는 보장이 없고,
- 이미 부분적 원자성이 깨진 상태가 발생한다는게 큰 리스크라고 생각한다!
즉 아무리 retry를 하더라도 결과적으로 db상태가 payment와 credit에서 이상 현상이 발생할 수 있다
그에 반해 첫번째 케이스의 경우는
중요! : Reconciliation에 동작에 대한 정의 (이후 내용 참고!)
Reconciliation을 실행해서, N회차 재시도를 하는 과정에서
- 결과적으로 실패를 하더라도 부분적 원자성이 보장된다.
- 더 나아가, 주기적으로 배치를 이용해서 Reconciliation하기도 하고,
기회가 더 많은 느낌? + 기존에 있는 로직을 적용하는 방식이기 때문에 transactional outbox의 CDC/Relay와 같은 추가 인프라 요소가 없다!
[ Reconciliation는 어떤 동작을? ]
가장 큰 의문점
1안 : DB에 직접 Write
⇒ Batch를 통해 ItemReader/ItemWriter를 활용하여 DB단에서 PaymentEvent에 올바른 status 주입
이 동작은 <3.보정>페이지의 두번째 로직과 유사한 결과를 도출한다.
- 무조건 참(PG)이 되도록 DB데이터 Append
- PG사와 paymentEvent를 동기화 할 수는 있으나, 그럼 credit은?
- 그럼 또 retry 로직을?
2안 :
⇒ Batch를 통해서 ItemReader 기반 paymentHandler 동작
이 동작은 <3.보정>페이지의 첫번째 로직과 유사한 결과를 도출한다.
- PG데이터를 기반으로 DB데이터를 동기화하는 과정인데 이 과정이 실패가 있어도 되나?
- Reconciliation은 마지막 최후의 보루인데 이것조차 실패할 수 있다는 게 맞나?
[ Event 기반 이상적인 로직? ]
일단 내가 생각하는 가장 이상적인 로직은
Reconciliation 2안 즉 Batch의 ItemWriter 동작 전에 handler를 실행하고 이를 기반으로 SpringEvent로 동기 처리하는 로직이다!
예를 들어
- paymentEvent : PENDING
- PG : PAID 일 때
SuccessHandler가 동작하면
- paymentEvent (Pending → Success) [append]
- paymentOrder (status : 결제 성공)
까지 1 Transaction
SpringEvent 동기 처리 = 인메모리 이벤트 처리
@EventListener
- creditAccount
- creditLedger
까지 2 Transcation이되,
EventListener를 통해 1 Tx 2 Tx 가 사실상 하나의 트랜잭션으로 묶인다.
단점은 강한 결합
즉, 리스너에서 에러가 발생하면, 메인 로직의 작업도 함께 롤백(Rollback) 된다는 것인데,
- 이는 Reconciliation을 재시도 하는 등으로 보정할 수 있는 추가 기회를 가질 수 있고 (DB의 부분적 원자성이 보장되기 때문!)
- 인 메모리에 저장된 이벤트가 삭제되더라도 결국 Reconciliation의 로직 자체가 HandleSuccess 부터 재시도기 때문에 인메모리 이벤트를 재발행!
결국
“Reconciliation은 마지막 최후의 보루인데 이것조차 실패할 수 있다는 게 맞나? “
라는 문제점은 남음 ㅠㅠ
[ Reconciliation의 케이스를 분석해보자! ]
portone data status
ready(미결제)- 결제 대기 상태
paid(결제 완료)- 일반 결제가 성공적으로 승인
cancelled(결제 취소)- 관리자 콘솔이나 API를 통해 결제가 취소(환불)된 상태
failed(결제 실패)- 결제 승인에 실패했거나
- 고객이 결제창을 닫아 이탈
paymentEventStatus
READY 미결제(결제창 진입) e.g. 브라우저 창 이탈, 가상계좌 발급 완료 등
SUCCESS 결제 승인 성공 (포트원 paid) e.g. 가상계좌 결제 입금, 예약 결제 시도 등
FAILED 결제 승인 실패 (포트원 failed) e.g. 신용카드 한도 초과, 체크카드 잔액 부족, 브라우저 창 종료 또는 취소 버튼 클릭
FORGERY 결제 위변조 감지
REFUND_REQUESTED 환불 시도 (Pending)
REFUND_FAILED 환불 거절, e.g. 한도 초과/기간 만료, 중복 환불, PG사/은행 점검
REFUND_SUCCESS 환불 완료 = 결제 취소(포트원 cancelled) e.g. 관리자 콘솔 결제 취소
ALL
케이스 고민에 대한 설명
- [”?”]의 경우 PG사의 상태
- PaymentEventStatus : status1 → status2의 경우
- status2와 PG사의 [?]가 일치하는게 맞는데
- status1의 상태일 경우 reconciliation 실행을 통해 status2로 변경 (Handler 동작)
케이스 고민
- READY → SUCCESS [PAID] : 성공 무시
- SUCCESS → READY [READY] : 이 케이스가 가능한가?
- READY → FAILED : [FAILED] : 실패 무시
- REFUND_REQUESTED → REFUND_SUCCESS [CANCELLED?] : 환불 성공 무시?
- REFUND_REQUESTED → REFUND_FAILED [PAID] : 환불 실패 무시?