MSA 환경의 결제 서버 운영과 정기 결제 추가하기 — 2

hyunjoon Park
Riiid Teamblog KR
Published in
12 min readJul 21, 2022

🏷️ MSA 환경의 결제 서버 운영과 정기 결제 추가하기 — 1 의 문제를 막기 위해서 우리는 bill id 라는 개념을 차용하기로 했습니다. bill id 는 결제 정보를 식별하기 위한 id 입니다. 결제 정보가 통신상 오류로 유실되어도 주문과 결제를 매핑하기 위한 꼬리표와 같은 개념이라고 보시면 됩니다.

billId 를 이용하도록 변경한 3단계 정기 결제 플로우

기존 정기결제 플로우에서 bill_id 를 사용하도록 변경해보겠습니다.

1. 상품 조회 및 주문 생성

유저가 처음에 상품을 조회하고, 결제를 시도하려고 하는 시점의 플로우입니다.

이 때, 결제 정보를 생성할 때 bill_id 라는 임의의 문자열을 생성해 결제 정보에 저장합니다.

  1. 클라이언트가 도메인 서버에게 상품 정보를 요청합니다.
  2. 클라이언트가 도메인 서버로부터 상품 정보를 받아옵니다.
  3. 클라이언트가 도메인 서버에게 주문 정보를 생성합니다.
  4. 도메인 서버가 결제 서버에서 결제 정보를 생성합니다. 이 때, bill_id 라는 임의의 문자열을 생성해 결제 정보에 저장합니다.
  5. 결제 서버는 bill_id 가 포함된 결제 정보를 도메인 서버에게 반환합니다.
  6. 도메인 서버는 주문 정보를 생성합니다. 이후 bill_id가 포함된 주문 정보를 클라이언트에게 반환합니다.

2. 클라이언트의 실 결제 이후 결제 검증

유저가 주문 정보를 생성한 뒤 실제 결제가 일어나고 검증하는 플로우입니다.

  1. 클라이언트가 앱스토어에게 bill_id 를 설정해서 결제 요청을 합니다.
  2. 앱스토어에서 실제 결제한 이후에 발행된 bill_idtransaction_id 를 클라이언트에게 반환합니다.
  3. 클라이언트가 bill_idtransaction_id 를 도메인 서버에게 전달해 주문 검증을 요청합니다.
  4. 도메인 서버가 bill_id transaction_id 를 결제 서버에게 전달해 결제 검증을 요청합니다.
  5. 결제 서버가 transaction_id 라는 식별자를 통해 App Store에 질의합니다. 응답된 결과를 통해 기존 결제서버에 있는 정보와 결제 정보가 일치하는지 검증합니다.
  6. 검증에 성공했다면 bill_idtransaction_id 를 기존 결제 정보에 저장해 갱신한 뒤 반환합니다.
  7. 검증된 결제 정보를 통해 도메인에서 유저에게 권한을 부여합니다.
  8. 도메인 서버가 주문 정보를 클라이언트에게 반환합니다.

3. 이벤트 전송을 통한 결제 갱신 및 전파

유저의 상품 구매 이후, 갱신되는 이벤트를 결제 Provider 로부터 전달받아 이를 통해 결제 정보를 갱신하고, 도메인 서버에게 전파하는 플로우입니다. 정기 결제를 통해 발생되는 이벤트들을 수신하고, 갱신하는 역할을 하고 있습니다.

  1. App Store 측에서 유저의 결제 정보가 변경이 일어날 때마다 이벤트를 전송해줍니다. 이벤트의 종류들은 구매, 갱신, 결제 보류 등 다양합니다. 이 때, bill_idtransaction_id 를 포함해 전달해주기 때문에 결제 서버에서는 이를 통해 어떤 결제 정보가 갱신되었는지 식별할 수 있습니다.
  2. 이벤트를 수신한 결제 서버는 bill_idtransaction_id 를 통해 기존에 저장된 결제 정보를 검색합니다.
  3. 결제 정보를 찾았다면, 전송된 이벤트에 맞게 결제 정보를 갱신합니다.
  4. 이후 갱신된 결제 정보를 도메인에게 전파하기 위해 Rabbit MQ 에 메시지를 발행합니다.
  5. 도메인 서버에서 발행된 메시지를 구독합니다.
  6. 전달된 결제 정보를 통해서 상품을 갱신합니다.

4. 실패 케이스 방어 로직

이렇게 bill_id 를 사용하도록 플로우가 변경되면, 이전과 같은 실패 케이스에 대해서도 정상 동작이 가능합니다.

2–3번 과정에서 통신이 끊겼습니다. 그러나 App Store 로부터 전달받은 결제 응답 정보, transaction_id를 도메인 서버에게 전달하지 못했습니다.

그래도 괜찮습니다!

바로 App Store 에서 bill_id 가 포함된 이벤트를 전송해주기 때문입니다.

이벤트를 수신한 결제 서버는 transaction_id 를 통해서 결제 정보를 찾지 못해도, bill_id 를 통해서 기존에 저장된 결제 정보를 검색합니다.

이제 도메인 서버에게 좋은 정보를 알려줄 수 있습니다. 그 유저가 결제 실패한 줄 알았는데, 아니었어! 그 상품 열어주자 처럼요. 도메인 서버에서 그 정보를 전달 받아서 상품 권한을 유저에게 부여하면 됩니다.

도메인에게 MQ로 이벤트 전파

결제 서버 측에서는 App Store 를 통해 이벤트를 수신해 결제 정보를 수신할 수 있지만, 도메인 서버는 그렇지 않기 때문입니다. 따라서 갱신된 정보를 도메인 서버에게 까지 전파시켜 일관된 상태로 정보를 유지해야 합니다.

이 때 해당 정보를 전파하기 위한 방법으로는 Message Queue, MQ 를 사용하기로 결정했습니다. 결제 서버에서 직접 도메인 서버를 호출하는 방법도 있겠지만, 서버 간의 강결합 또는 양방향 의존 관계를 끊기 위해 비동기 메시징 구조를 구축하고자 했습니다. 단방향 구조를 통해 하위 서비스가 최대한 보편적이고 중립적으로 구현되어 확장성이 높아진다고 판단했기 때문입니다.

그래서 MQ 를 써야하는데, 미들웨어로 뭘 쓸 것인가 하는 고민에 빠졌습니다.

다음은 각 미들웨어를 사내에서 정리한 비교 표입니다.

그래서 고민 끝에 RabbitMQ 를 사용해보기로 결정했습니다. SQS 대비 RabbitMQ를 사용해서 얻을 수 있는 이점인 AMQP의 exchange를 통한 라우팅 기능과 이를 통한 확장의 용이함이 클 것이라고 판단했기 때문입니다.

App Store 측에서 던져주는 결제 이벤트의 종류는 단순히 구매, 갱신, 만료와 같은 결제 정보 갱신이 필요한 이벤트도 있지만, 구독 가격 상승, 프로모션 코드 입력, 결제 정보 이상 등의 이벤트도 있습니다. (참고 링크 : https://developer.apple.com/documentation/appstoreservernotifications/notificationtype)

이런 이벤트들은 도메인 서버의 상품 업데이트가 아닌 앱 푸쉬에만 사용될 이벤트이기 때문에 라우팅을 통해서 비즈니스 로직과 쉽게 분리할 수 있을 것이라고 판단했습니다.

예를 들어서 다음과 같이 수신되는 이벤트 타입에 따라서 binding key 를 설정함으로써, 메시지가 이동하는 방식을 제어할 수 있게 되었습니다. 이처럼 다양한 이벤트 타입에 따라 요구하는 비즈니스 로직을 대응할 수 있도록, 정기 결제의 메시지를 유연하게 라우팅할 수 있도록 Rabbit MQ 를 선정하게 되었습니다.

마주쳤던 문제점들

App Store 의 original_transaction_id 중복 발급 문제

App Store 에서는 치명적인 문제가 있었습니다. 동일 상품 재결제시 동일한 original_transaction_id가 발급된다는 것이었습니다. (위에서 언급했던 것처럼 transaction_id 라고 일컫겠습니다)

예를 들어서 다음과 같은 일이 발생하게 됩니다.

  1. 유저가 A 상품 구매. (order_id = 1, status = ACTIVE, transaction_id = 123)
  2. 결제 서버에서 notification 수신 (transaction_id = 123, event_type = RENEW) → transaction_id 를 이용해 결제 정보를 찾아서 업데이트 성공.
  3. 유저가 A 상품 다시 구매 (order_id = 2, status = ACTIVE, transaction_id = 123)
  4. 결제 서버에서 notification 수신 (transaction_id = 123, event_type = RENEW) → transaction_id 를 이용해 결제 정보를 찾으려고 했으나, 동일한 transaction_id 를 가진 결제 정보가 있어서 어떤 걸 업데이트 해야 하는지 결정하지 못하는 하는 상황이 오게 됩니다.

이렇게 되면 어떤 문제가 생기냐면, 결제 서버에서는 transaction_id 를 통해서 결제 정보를 식별할 수 없게 됩니다. transaction_id = 123 이 order_id 1 에 붙어 있는건지, order_id 2 에 붙어 있는건지 구분할 수 없게 됩니다.

이를 해결하기 위해는 다음과 같은 방법들이 있었습니다.

  1. 가장 최신의 order_id 에 대해서만 노티피케이션을 처리한다.

이렇게 하면 데이터 일관성이 깨질 가능성이 높습니다. App Store 측에서는 결제 이벤트 웹훅을 실패했을 경우 일정 시간이 지나면 다시 전송하고 있습니다. 만약 첫 시도에 웹훅이 처리되지 않았고 두 번째 웹훅이 오기 전에 새로운 주문을 했다면 기존 주문이 업데이트 되는 대신 새로운 주문이 업데이트 될 것입니다.

2. iOS 만을 위한 플로우를 기획/디자인에서 정의한다.

iOS 에서는 주문이 만료되어도, 새로운 주문을 생성하는 것이 아니라 이전 주문을 다시 복원해서 사용하는 플로우를 구현하자는 것이었습니다. 그렇게 되면 order_id 를 동일한 original_transaction_id 에서 동일하게 사용하는 것이 가능해집니다.

그런데 이렇게 되면 이에 따른 플로우를 프론트나 기획, 백엔드 쪽에서 새롭게 만들어줘야 합니다. 유지보수가 힘들 뿐더러 더 많은 공수가 들어갈 것으로 예상되었습니다.

3. bill_id 를 통해서 order_id 를 구분하자.

따라서 두 번째 방법을 선택하기로 했습니다. 바로 다음과 같은 플로우로 bill_id 를 통해서 order 를 구분할 수 있도록 하는 것입니다.

  1. 유저가 A 상품 구매. (order_id = 1, product_id = 1, ACTIVE, transaction_id = 123, bill_id=”abcd”)
  2. 결제서버에서 notification 수신 (transaction_id = 123, bill_id=”abcd”, event_type = RENEW) → bill_id 를 이용해 결제 정보를 찾아서 업데이트 성공.
  3. 유저가 A 상품 다시 구매 (order_id = 2, product_id = 1, ACTIVE, original_transaction_id = 123, bill_id=”efgh”) SubscriptionPayment (id=2, order_id =2, bill_id = “efgh”)
  4. 결제서버에서 notification 수신 (transaction_id = 123, bill_id=”efgh”, event_type = RENEW) → bill_id 를 이용해 결제 정보를 찾아서 업데이트 성공!

즉, bill_id 를 통해서 동일한 주문을 구분할 수 있게 되었습니다.

처리되지 못하는 상태의 이벤트

이렇게 다양한 케이스에 대해 방어를 해도, 문제 상황이 발생할 수 있습니다. 스토어로부터 이벤트를 수신했으나, 이에 해당하는 결제 정보가 존재하지 않을 경우, 수신한 이벤트를 JSON 상태로 저장해 둡니다. (ex: 결제 생성 요청이 누락되거나 지연되는 경우).

그리고 쌓여진 이벤트는 is_processed 필드를 통해 처리 여부를 구분하며, 배치잡을 통해 주기적으로 처리하도록 합니다.

순서가 보장되지 않는 문제

정기 결제의 도달한 이벤트와 결제 정보의 현재 상태값이 다르다면 무엇을 기준으로 처리해야 할까요?

예를 들어서 RENEWED 이벤트가 왔는데, 결제 정보를 조회하면 EXPIRED 상태인 경우가 있습니다. 이벤트 수신을 실패해서 재시도를 통해 수신하면서 순서가 변경되었을 수도 있고, 이벤트를 발생시키는 결제사 쪽의 문제일 가능성도 있습니다. 따라서, 이렇게 순서가 잘못 오는 경우에는 결제사 쪽의 현재 상태 조회를 single truth of source 로 사용하기로 했습니다.

예를 들어서 다음과 같이 업데이트 시킵니다.

SubscriptionPayment(id = 1, paid_amount = 3000 , purchase_price = 3000, status = ACTIVE)

인 상태에서 RENEW 에 대한 이벤트가 도달했습니다. 그렇지만 결제 정보를 조회했을 때 EXPIRED 된 상태입니다. (이 경우는 EXPIRED 에 대한 노티피케이션을 받지 못한 경우겠죠) 이런 경우에는 RENEW 에 대한 이벤트는 쌓되, 결제 건에 대한 status 는 EXPIRED 로 업데이트하도록 만들었습니다.

SubscriptionPayment(id = 1, paid_amount = 6000 , purchase_price = 3000, status = EXPIRED)

SubscriptionPaymentEventHistory(id = 1, subscription_payment_id = 1, amount = 3000, event_type = RENEW)

마치며…

이처럼 저희는 결제 서버를 구축하면서 발생했던 다양한 문제들을 해결했습니다. Apple 의 환상적인 문서, 정기 결제를 도입하니 코드를 변경하지 않은 단건 결제에서 깨지는 문제, App Store 의 IP 대역 설정 등 다양한 난관들이 많았지만 많은 분들의 도움 덕에 개발할 수 있었습니다.

뤼이드에서는 신뢰성 높은 결제 정보를 유저에게 전달하도록 고민합니다. 또한 MSA 환경에서의 이벤트 전파, 이 과정에서 일관된 데이터를 유지하는 방법에 대해 고민하고 있습니다.

앞으로도 할 일이 많이 남아있습니다. 결제 고도화, 정산 시스템 등에 대해 함께 고민하며 성장하실 분들을 모시고 있습니다 (채용페이지 링크)

감사합니다.

References

--

--