개발 일기 2편 : 크레딧 검증 시스템은 필요가 없다?
개발 일기 2편
이전 내용 이어서 : 개발 일기 1편
[ 크레딧 검증 시스템은 필요가 없다?]
기존 Payment System 분석 및 나는 왜 크레딧 시스템을 설계했나?
- 위 내용은 access 토큰을 요청하는 등의 복잡한 변경 과정과 자세한 검증 과정에 따른 복잡한 단계를 제외한 간략한 다이어그램이다
일단 왜 검증 시스템이 필요하다고 생각하게 된 건 지에 대해서 설명을 해보면,
prepare의 경우 서버 단에서 결제에 사용할 merchant_uid를 만들어서 클라이언트에게 주는 과정이므로 넘어가고
핵심은 바로 [Phase 2]에 있다!
- 먼저 실제 결제 진행이 대행사를 통해서라는 것이다!
- 즉, 클라이언트 - portone 사이의 통신이지 실제 결제 진행 결과에 대해서 서버는 알 수 없다.
이게 문제가 왜 되냐면,
서버는 완료된 결제 기록에 대해
- 클라이언트에게 받는 verify
- portone에게 받는 webhook 수신
두 통신을 통해서, 최종 결제 데이터를 수신 받고, 이를 검증해서 문제가 확인되면, 취소 요청을 보내도록 만드는 구조이다. 해당 통신에 문제가 생긴다면 서버와 portone의 데이터는 달라진다.
그럼 절대적 데이터가 무엇인가? → 바로 portone의 실제 결제 기록!
이 문제의 또 다른 핵심은 Transaction에 있다.
payment-order와 event가 생성 또는 변경되는 방식이
- 결제 시작 - > 결제 성공 / 실패로 원자성 보장이 불가능하다
- 결국 portone이나 client에 의존하는 결과 전달 방식 때문에 부분적 원자성(특정 메소드 동작에 있어서 우리 시스템 내에서는 원자성을 보장)을 가질 수는 있을 지라도, 외부적 요소에 의존하기 때문에 전체 서비스의 원자성 보장은 불가능하다.
따라서 결제 플로우에는 전체 transcation을 걸 수 없으니, trascation을 걸어 원자성을 보장하는
- 결제 플로우와 상관없이 portone의 결제 기록을 저장
- 추가로 서비스의 크레딧 사용 기록까지 저장하는
절대 원장을 만들고
이를 매번 검증 과정에서 사용하는 과정은 자원 소모가 크니,
원장(=portone 기록)의 바로 전 상태를 일종의 캐싱 해놓는 credit을 만들자가 나의 의견이었다.
참고로 Payment는 부분 원자성을 가지도록 설계했다.
- PaymentProcessor라는 Transactional Service와
- PaymentReader라는 ReadOnly Transactional Service로 나눠서 설계했다.
부분적 원자성 보장
전체적으로 아래와 같이 원자성을 보장한다.
- 성공 시:
PaymentEvent(SUCCESS) 기록 +PaymentOrder상태 및 결제 수단 업데이트
- 실패/위조 시:
PaymentEvent(FAILED/FORGERY) 기록
- preparePayment (결제 준비)
paymentProcesser.saveOrder(payment)
⇒ “PaymentOrder에 대한 생성” 부분 원자성
- verifyPayment (결제 검증) + verifyRefund(환불 검증은 동일 로직이므로 생략)
paymentProcesser.processPaymentValidation()
⇒ “PaymentEvent INSERT + PaymentOrder UPDATE가 하나로 검증” 부분 원자성
- Success[handleSuccess]
- PaymentEvent INSERT : SUCCESS
- saveEvent()
- PaymentOrder UPDATE : status, paidAt, payMethod
- markAsCompleted()
- updatePaidAt()
- updatePayMethod()
- Failure[handleFailure]
- Forgery[handleForgery]
- synchronizePayment (웹훅 수신)
paymentProcesser.handleRefund()
⇒ “PaymentEvent INSERT + PaymentOrder UPDATE가 하나로 웹훅 처리” 부분 원자성
- status == "Cancelled" (환불로 내부서비스 상태 변경)
- PaymentEvent INSERT : REFUND SUCCESS
- saveEvent()
- PaymentOrder UPDATE : status
- markAsCancelled()
- status == “Success" (처리할 필요 X)
- markAsRefundByUserId (사용자 환불 요청) + bulkRefund (전체 환불도 유사 방법으로 생략)
PortOnePaymentService.markAsRefundByUserId()
- events.forEach { event -> paymentProcesser.saveEvent() }
- 환불 요청 event가 개별 동작 ( 전체 trasaction X )
2. Event를 Append-Only로 하면 원장이라고?
가장 이해가 되지 않는 부분이이었다….
앞에서 언급했다시피 전체 트랜잭션을 보장하는 것은 불가능하고,
트랜잭션이 보장되지 않은, 각 메소드마다 변경되는 status를 변경하는 기존 방식을
단순히 기록을 append-only한다고 해서 이게 원장 역할이라고 말할 수 있는가?
라는 의문점은 이를 이해하기까지 가장 오래 걸렸다.
칠판을 가득 채우는 장장 2시간 넘는 토론 끝에 가장 핵심이 되는 문장은 아래와 같았다.
우리 서비스 내에서의 기록에 대해서는 원장(잘못된 데이터가 들어올 수 없다)이다 (부분적 트랜잭션만 잘 지킨다면)
- 핵심은 부분적 원자성이지만 구조상 우리 시스템 내에서는 달라질 수 없다
- 즉, 한 트랜잭션으로 payment-order와 event를 묶음 = 원자성 보장
- event는 append only로 설계
두 조건이 성립한다면 event를 payment 원장으로 볼 수 있다는 것이다!
- 결과적으로 portone의 데이터(Source Of Truth)값과 달라질 수는 있지만
- 저 경우는 결국 서버가, verify도 / verify N번의 재요청 / webhook 모두 못 받는 상태로
- 서버 장애 : 언급한 api는 다 에러 상태로 판단했다면, 지금 해당 사용자가 소유한 credit은 장애가 발생한 지점 이전에 정상적인 process로 형성된 크레딧으로 문제가 없다.
- portone 장애: 추가로 webhook을 주는 portone이 서비스 장애가 발생했다면, 이후 결제 또한 안될 것이므로 서비스 장애고 이는 새로운 PG사를 연동한다는 등의 추가적인 조치가 필요한 것으로 판단하는것이 맞다
⇒ 서버 다운으로 가정한다
- 그렇다면 서버 에러(다운)문제는 어차피 복구 작업이 필요하다
단계별 상세 흐름 설명 (V1 기준)
Phase 1: 결제 준비 및 사전 검증 (Prepare)
이 단계는 악의적인 사용자가 브라우저(프론트엔드)에서 결제 금액을 조작하는 것을 막기 위한 필수 과정입니다.
- 결제 시작 요청 (Browser -> Server): 사용자가 구매 버튼을 누르면, 프론트엔드는 가맹점 서버에 "어떤 상품을 결제하겠다"고 알립니다. 서버는 DB에 주문 정보를 생성하고 고유한 주문번호(
merchant_uid)를 만듭니다.
- 토큰 발급 (Server <-> PortOne): 포트원 API를 호출하려면 권한이 필요합니다. 가맹점 서버는 관리자 콘솔에서 발급받은
imp_key와imp_secret을 포트원으로 보내 약 30분간 유효한access_token을 발급받습니다.
- 사전 검증 등록 (Server <-> PortOne): 발급받은 토큰을 사용해 포트원의
/payments/prepareAPI를 호출합니다. "앞으로merchant_uid1234번으로 50,000원의 결제가 올라올 거야" 라고 포트원 측에 미리 기록해 둡니다.
Phase 2: 실제 결제 진행
- 결제창 호출 (Browser <-> PortOne): 서버로부터
merchant_uid를 받은 프론트엔드는 포트원 SDK인IMP.request_pay()함수를 실행하여 사용자에게 실제 결제창을 띄웁니다.
- 결제 완료: 사용자가 카드사 앱 등을 통해 결제를 완료합니다. 이때 포트원은 앞서 서버가 등록한 '사전 검증 금액'과 '실제 결제 시도 금액'이 일치하는지 1차로 확인합니다.
Phase 3: 사후 검증(Verify)과 웹훅(Webhook)
결제가 완료되면 포트원은 결과를 가맹점에게 두 가지 경로로 알려줍니다. 이 두 가지는 거의 동시에 일어나며, 순서는 보장되지 않습니다.
- 경로 A: 브라우저 콜백 (6번, 8번 과정)
- 사용자의 결제창이 닫히면서 프론트엔드 자바스크립트 콜백 함수로
imp_uid(포트원 결제 고유번호)와 성공 여부가 전달됩니다. - 프론트엔드는 이
imp_uid를 가맹점 서버로 보내어 "결제가 성공한 것 같으니 확인해 줘"라고 요청(8번)합니다.
- 경로 B: 웹훅 (7번 과정)
- 포트원 서버가 가맹점 서버의 특정 URL(웹훅 엔드포인트)로
imp_uid와merchant_uid등을 직접 POST 요청으로 쏴줍니다. - 왜 필요할까요? 사용자가 결제 완료 직후 인터넷 창을 꺼버리거나, 네트워크가 끊기거나, '가상계좌 입금'처럼 나중에 결제가 완료되는 경우 프론트엔드 콜백이 실행되지 않을 수 있기 때문입니다.
Phase 4: 최종 결제 검증 (Verify)
- 결제 단건 조회 (Server <-> PortOne): 가맹점 서버는 프론트엔드(경로 A)나 웹훅(경로 B)을 통해
imp_uid를 수신합니다. 그러면 서버는 다시access_token을 사용하여 포트원의/payments/{imp_uid}API를 호출합니다. (이것이 사후 검증, verify 입니다.)
- 검증 로직 수행 (Server): 포트원이 응답한 '실제 결제된 금액'과 가맹점 DB에 저장된 '원래 결제되어야 할 금액'이 100% 일치하는지 비교합니다.
- 일치하면: DB의 주문 상태를 '결제 완료'로 변경하고 프론트엔드에 성공 응답을 줍니다.
- 불일치하면 (위변조 의심): 결제를 즉시 취소(환불) 처리하고 DB를 '결제 실패'로 처리합니다.
이전 코드
Gemini의 응답
아주 좋은 질문입니다! 결론부터 말씀드리면 flush와 clear는 서로를 대체하는 개념이 아니라, 벌크 연산이 실행되는 '시점(이전/이후)'을 기준으로 서로 다른 문제를 해결하기 때문입니다.
지금 구조에서 clearAutomatically = true (clear)를 필수적으로 사용해야 했던 이유는 벌크 연산 이후의 데이터 정합성을 맞추기 위해서입니다.
두 가지의 역할을 명확히 비교해 드릴게요.
1. clear (clearAutomatically = true) : 벌크 연산 **이후(After)**의 문제 해결
- 역할: 벌크 쿼리가 실행된 직후 영속성 컨텍스트(1차 캐시)를 완전히 비워버립니다.
- 사용 이유: 벌크
UPDATE는 1차 캐시를 무시하고 DB에 직접 쿼리를 날립니다. 따라서 DB의 상태는newStatus로 바뀌었지만, 1차 캐시에 남아있는 엔티티는 여전히currentStatus인 상태가 됩니다.
- 이 상태에서 해당 엔티티를 다시 조회하면, DB가 아닌 1차 캐시에서 옛날 데이터를 가져오는 치명적인 버그가 발생합니다. 이를 막기 위해 캐시를 비워서 다음 조회 시 무조건 DB에서 최신 데이터를 가져오게 만드는 것입니다.
2. flush (flushAutomatically = true) : 벌크 연산 **이전(Before)**의 문제 해결
- 역할: 벌크 쿼리가 실행되기 직전에, 영속성 컨텍스트에 쌓여있는 변경 사항들을 DB에 먼저 동기화(쓰기 지연 SQL 쿼리 발송)합니다.
- 사용 이유: 만약 코드 앞부분에서 영속성 컨텍스트를 통해 엔티티의 어떤 값을 수정해 두었는데, 아직 트랜잭션이 끝나지 않아 DB에 반영(commit/flush)되지 않았다고 가정해 보겠습니다. 이 상태에서 벌크 쿼리가 바로 실행되면, 벌크 쿼리는 아직 수정되지 않은 과거의 DB 데이터를 기준으로 실행되어 버립니다.
- 이를 막기 위해 벌크 쿼리를 날리기 전에 1차 캐시의 변경 사항을 DB에 밀어 넣는(flush) 역할을 합니다.