인앱(in-app) 구독 결제 관리를 위한 서버 알림(Server Notification) 활용

SangminKim
Spoonlabs
Published in
16 min readJun 4, 2024

인앱(in-app) 결제는 모바일 앱 내에서 소모품, 비소모품 및 구독 상품을 결제하기 위해, 해당 플랫폼(Platform)의 자체 결제 시스템을 사용하는 것을 말합니다. 플랫폼의 자체 결제 시스템이란, 안드로이드(Android)의 구글플레이(GooglePlay), IOS의 앱스토어(AppStore)를 말합니다.

스푼 라디오에도 인앱 결제를 통해 서비스 내 재화인 스푼을 구매하여 후원하거나, 구독을 구매하여, 좋아하는 DJ를 응원할 수 있습니다. 인앱 결제는 사용자에게 간편한 결제 방법을 제공하여, 결제 접근성을 높일 수 있다는 이점이 있습니다.

이번 포스팅에서는, 최근 스푼 라디오에 오픈한 구독 서비스를 개발하면서 경험한 인-앱 구독 결제, 그중에서도 인-앱 구독 서버 알림에 대한 전반적인 내용과 스푼 라디오에서 이를 처리하기 위해 어떻게 서비스를 구성하였는지 공유합니다.

이후 인앱 결제를 제공하는 구글플레이, 앱스토어와 같은 플랫폼을 통칭하여 ‘플랫폼’이라 명명하겠습니다.

# 인앱 구독 서버 알림

구독 결제는 특정 서비스나 콘텐츠를 일정 기간 이용하기 위해, 정해진 주기마다 금액을 지불하는 결제 방식을 말합니다. 최초 사용자가 결제 수단을 등록하고, 구독 상품을 구매한 이후, 일정 주기마다 사용자의 별도 인가 없이 결제가 수행됩니다.

애플리케이션은 구독 구매의 대가로 사용자에게 그에 맞는 서비스를 제공해야 합니다. 사용자는 계속해서 구독을 갱신하거나, 다른 상품으로 변경 또는 해지하여 구독 상태를 변경할 수 있기 때문에, 애플리케이션은 사용자의 현재 구독 상태를 계속해서 추적해야 합니다.

그러나 이러한 구독 상태 변경은, 애플리케이션을 거치지 않고, 플랫폼에서 단독으로 수행될 수 있습니다. 즉 애플리케이션은 구독 상태 변경을 인지하지 못하고 잘못된 서비스를 제공할 수 있습니다. 예를 들면 이미 구독을 해지하여 만료된 사용자에게 무료로 서비스를 제공하고 있을 수 있습니다.

플랫폼 구독 상태 변경에 따른 구독 알림 발생

이러한 문제를 해결하기 위해, 각 플랫폼에서 제공하는 구독 서버 알림(Subscription Notification)을 사용할 수 있습니다. 애플리케이션은, 플랫폼에서 발행한 구독 서버 알림을 수신하여, 구독 상태 변화를 실시간으로 추적할 수 있습니다. 플랫폼 마다 아래의 구독 서버 알림 기능을 제공하고 있습니다.

이후 구독 서버 알림을 줄여서 ‘구독 알림’이라 명명하겠습니다.

구글플레이 구독 알림은 구글 클라우드 PUB/SUB 기능을 기반으로 동작합니다. 그에 따라 메시지 전송 방식이나 재시도 정책 또한 구글 PUB/SUB과 내용을 공유합니다.

구독 알림 전송 방식

구독 알림은 크게 풀(Pull)과 푸시(Push) 두 가지 방식이 있습니다. 구글플레이는 두 가지 방식을 모두 지원하는 반면, 앱스토어는 푸시 방식만을 지원합니다.

푸시 방식은, 구독 알림을 받을 엔드포인트(End Point) URL을 플랫폼 콘솔에 설정하면, 플랫폼은 해당 URL로 구독 알림 정보를 담은 HTTP 요청을 전송합니다. 플랫폼은 응답 상태 값에 따라, 전송 실패 여부를 판단하고, 각 플랫폼 정책에 따라 재시도를 수행합니다. 자세한 재시도 정책은 이후 다시 언급하겠습니다.

푸시 방식에서 플랫폼이 주도적으로 알림을 전달 하는 것과 반대로, 풀 방식은 애플리케이션의 주도적인 요청으로 구독 알림이 전송됩니다. 일회성 API 요청-응답 또는 스트림 형식으로 양방향 연결을 유지하며 구독 알림을 수신할 수 있습니다. 자세한 내용은 다음 링크를 통해 확인 할 수 있습니다.

구글 Pub/Sub 푸시 & 풀 방식(reference)

등록한 엔드포인트로 실제 구독 알림을 수신하기 전에, 엔드포인트로 등록한 URL로 테스트 알림을 발행하여 동작을 확인할 수 있습니다.

  • 앱스토어: 테스트 알림 전송을 요청하는 Test NotificationAPI 를 호출하여 테스트 알림을 전송합니다.
  • 구글플레이: 구글플레이 콘솔에 Monetization -> Google Play BillingSend Test Notification 버튼을 클릭하여 테스트 알림을 전송합니다.
구글플레이, 테스트 알림 전송 화면

구독 알림 인증

푸시 방식으로 구독 알림을 수신하기 위해 등록한 URL은, 누구든 접근이 가능한 퍼블릭(Public) 엔드포인트입니다. 하여, 해당 URL로 수신된 구독 알림은 반드시 유효성을 검증하여 위변조된 구독 알림 처리를 방지해야 합니다. 그렇지 않으면 악성 사용자는 구독 결제 없이 고가에 고독 상품을 구독한 척, 위변조된 구독 알림을 전송하여 악용할 수 있습니다. 각 플랫폼은 자신이 보낸 구독 알림을 검증할 방법을 제공합니다.

구글플레이는 구독 알림 요청 HTTP 헤더에 아래와 같은 형태로 토큰(JWT)을 함께 전달합니다. 이 토큰은 구글에서 제공하는 JWKS(JSON Web Token Sets)를 통해 공개-키를 생성하고, 이를 통해 토큰의 서명(Sign)을 검증할 수 있습니다.

Authorization: Bearer {JWT}
# JWT Header Example
{"alg":"RS256","kid":"7d680d8c70d44e947133cbd499ebc1a61c3d5abc","typ":"JWT"}

# JWT Payload Example
{
"aud":"{ENDPOINT_URL}",
"azp":"113774264463038321964",
"email":"service-{PROJECT_NUMBER}@gcp-sa-pubsub.iam.gserviceaccount.com",
"sub":"113774264463038321964",
"email_verified":true,
"exp":1550185935,
"iat":1550182335,
"iss":"https://accounts.google.com"
}

토큰 서명 검증뿐만 아니라 위 코드 블록에 명시된 페이로드(Payload)의{ENDPOINT_URL}{PROJECT_NUMBER}를 추가로 검증하여, 다른 애플리케이션에서 발급된 구독 알림이 악용되는 것 또한 막을 수 있습니다.

앱스토어는 구독 알림 요청 HTTP 바디에 서명된 페이로드(signedPayload)를 전달합니다. 이는 JWS 형태의 데이터로 JWS 헤더에 포함된 x5c 에서 공개-키를 추출하여 검증할 수 있습니다. 서명된 페이로드 내부에는 동일한 방식으로 서명된 결제(signedTransactionInfo) 및 구독(signedRenewalInfo) 정보를 포함하고 있습니다. 이들 정보 또한 동일한 방식으로 검증 및 디코딩하여 사용할 수 있습니다.

{"signedPayload": {SIGNED_PAYLOAD}}

두 플랫폼 공통으로, 구독 알림을 받을 앤드-포인트 URL 등록 시, 추가 검증을 위해 임의의 키(Key)를 쿼리-파라메터(Query Parameter)로 함께 입력할 수 있습니다. 함께 등록된 쿼리-파라메터는 구독 알림과 함께 전송되어 애플리케이션에서 사전에 입력한 값과 비교하여 검증할 수 있습니다.

https://{URI}?key={KEY}   -- 자유로운 형식으로 쿼리-파라메터 등록 가능

구독 알림 메시지

구글플레이의 구독 알림 메시지는, 아래 코드 블록과 같이, 비교적 간결한 형태입니다. “어떤 구독 상품(subscriptionId)에서 어떤 액션(notificationType)이 발생하였다”는 최소한의 정보만은 담고 있습니다.

# Google-Play Notification Data Format
{
"version": string,
"notificationType": int,
"purchaseToken": string,
"subscriptionId": string
}

그러나 실제 애플리케이션 로직을 처리하다 보면 더 많은 정보를 요구하게 됩니다. 필요한 추가적인 구독 정보를 알기 위해, 구독 알림에 명시된 purchaseToken 를 포함한 purchases.subscriptionsv2API 호출이 필요합니다. API 응답에는 누가 언제 어떤 상품을 구독 하였는지, 현재 구독 상태는 어떤지, 현재의 구독 상태가 된 이유는 무엇인지 등, 구독 알림 처리를 위해 필요한 다양한 정보를 담고 있습니다.

반대로 앱스토어의 구독 알림은 알림 메시지 스스로가 구독에 필요한 모든 정보를 담고 있습니다. 다만 앞서 ‘구독 알림 인증’에서 언급하였듯이, 필요 정보들이 JWS 형태로 인코딩되어 있어, 데이터 사용을 위해 추가적인 디코딩을 수행해야 합니다.

구독 알림 메시지 커스텀 필드

구독 알림을 이용하여 애플리케이션 내부 구독 로직을 처리하기 위해서는, 해당 구독 알림이 애플리케이션 내부에 어떤 데이터와 대응하는지 식별할 수 있어야 합니다. 기본적으로 구독 알림은, 앱스토어의 originalTransactionIdtransactionId 그리고 구글플레이의 purchaseTokenorderId 와 같이 구독/결제 정보를 식별할 수 있는 값을 제공하고 있지만, 애플리케이션 로직을 처리하다 보면 그 이상의 값이 필요한 경우가 있습니다.

이러한 요구 사항을 만족하기 위해, 플랫폼은 애플리케이션 내부에서 정의한 식별자를 구독 알림에 주입할 수 있는 방법을 제공합니다. 주입된 식별자는 연관된 구독 알림 발생 마다 함께 전송됩니다.

스푼 라디오에서는 구독을 수행한 애플리케이션 유저 ID를 커스터 필드에 저장하여 사용하였습니다.

구글플레이Developer payload 를 통해 앱에서 구독 결제 시 커스텀한 값을 설정할 수 있습니다. 이 값은 최대 64자에 자유로운 형식의 문자열로, 임의의 정보를 담을 수 있습니다. 설정된 값은 purchases.subscriptionsv2API 응답에 externalAccountIdentifiers 에서 확인할 수 있다.

앱스토어applicationUserName 를 통해 구독 결제 시 앱에서 설정할 수 있습니다. 이 값은 UUID 형태로 제한되나, UUID 형식만 따른다면 중복이 발생하여도 상관없으며, 자유롭게 문자열을 구성할 수 있습니다. 설정된 값은 구독 알림에 appAccountToken에서 확인할 수 있습니다.

UUID 형식으로도 다양한 정보를 표현할 수 있습니다. 예를 들어 유저ID 123456 과 상품 ID 201 정보 표현한다면 00000000-0000-0000-0201–000000123456 으로 표현할 수 있습니다. (더 넓은 범위의 값을 활용하고 싶다면 16진수를 기반으로 표현할 수도 있습니다.)

구독 알림 재시도 정책

외부에서 HTTP 요청 형태로 전달되는 구독 알림은 어떠한 이유로든 유실될 가능성이 있습니다. 이러한 이유로, 플랫폼은 구독 알림 요청에 200 상태 응답이 아닌 모든 경우를 전송 실패로 간주합니다. 전송에 실패한 구독 알림은 각 플랫폼 정책에 따라 재시도 됩니다.

구글플레이 재시도 정책은, 즉시 재시도 또는 지수-백오프(Exponential-Backoff) 방식을 지원하며 DLQ(Dead-Letter-Queue)를 설정하여 처리에 실패한 구독 알림을 큐에 보관할 수 있는 등, 재시도 정책에 자유도가 높은 편입니다.

구글 클라우드 콘솔 Pub/Sub 재시도 설정 화면

반면 앱스토어 재시도 정책은, 최초 실패로부터 1시간 이후 첫 번째 재시도가 수행되고 이어서 12, 24, 48, 72시간 간격으로 총 5번의 재시도만이 수행됩니다.

# 스푼 라디오 구독 알림 처리 서비스

스푼 라디오 구독 알림 처리 구성도

위 그림은 스푼 라디오에서 구독 알림을 처리하기 위해 구성한 인프라를 간략화한 그림입니다. 위 구성도는 크게 4개의 부분으로 구분될 수 있습니다.

  • 플랫폼 서버(Platform Server)
  • 플랫폼 구독 알림 리시버(Platform Subscription Notification Receiver)
  • 플랫폼 구독 알림 파서(Platform Subscription Notification Parser)
  • 스푼 구독 서비스(Spoon Subscription Service)

위 나열된 부분에 대해서 하나씩 살펴보도록 하겠습니다.

플랫폼 서버

구독 알림을 발생 시키는 구글플레이, 앱스토어와 같은 외부 서버입니다. 실제는 플랫폼 마다 그리고 같은 플랫폼에서도 기능 마다 다른 물리적인 서버로 운영 되겠지만, 이후 언급될 모든 외부 플롯폼에 의존성 있는 부분은 ‘플랫폼 서버’로 통칭하겠습니다.

플랫폼 구독 알림 리시버

플랫폼에서 전송되는 구독 알림을 수신하는 부분입니다. 구글플레이와 앱스토어 구독 알림 수신부에 동일한 인터페이스를 적용하기 위해, 구독 알림은 푸시 방식으로 동일하게 적용하였습니다. 플랫폼 콘솔에 푸시 방식을 위한 엔드포인트 URL은 API-게이트웨이(Gateway) 주소로 설정된 상태입니다. 즉 발생한 모든 푸시 구독 알림은 API-게이트웨이에 전달됩니다.

앞서 ‘구독 알림 전송 방식’에서 언급하였듯이, 앱스토어는 푸시 방식만을 지원합니다.

이후 API-게이트웨이로 수신된 구독 알림은, 람다(Lambda)를 통해 우선 SQS에 적재됩니다. 적재된 구독 알림은 실재 애플리케이션의 처리 여부와 상관없이 200 상태 값으로 플랫폼 서버에 응답합니다. 200 상태 응답을 받은 플랫폼 서버는 해당 구독 알림이 정상적으로 처리되었다고 인지합니다. 반대로 SQS 적재에 실패한 구독 알림에 대해서는 에러 응답을 반환하여 각 플랫폼 재시도 정책에 의해 재시도 됩니다. 그러나 수신한 구독 알림을 그대로 SQS에 적재만을 하는 간단한 람다 코드가 실패할 경우는 AWS 서버리스(Serverless) 서비스 장애 상황과 같이 매우 드문 케이스입니다.

이렇게 구독 알림을 안전하게 우선 내부 큐(Queue)에 적재함으로써, 각 플랫폼에 구독 알림 전송 정책과 상관없이, 아래와 같은 동일한 내부 정책을 적용할 수 있습니다.

  • SQS의 Visibility Timeout 을 통해 재시도 간격을 조정할 수 있습니다.
  • SQS의 DLQ를 통해 최대 재시도 횟수를 조정하며 최종 실패한 메시지를 별도 큐에 보관할 수 있습니다.
  • SQS의 Retention Period설정을 통해 메시지 최대 보관 기간을 조정할 수 있습니다. 내부 서비스 장애로 인해 메시지를 처리할 수 없더라도, 충분한 시간 동안 메시지를 보관하여, 장애 복구 후 메시지를 유실 없이 처리할 수 있습니다.

`플랫폼 구독 알림 리시버`를 통해 외부 구독 알림 ‘전송 정책’과 의존성을 제거하였습니다.

앱스토어에서 실패한 구독 알림에 가장 빠른 재시도는 1시간 뒤입니다. 개발 당시, 이는 서비스를 운영하는 입장에서 허용하기 어려운 스펙으로 판단되었습니다. 그러나 위와 같이 내부 큐에 구독 알림을 우선 적재함으로써 내부 정책에 따라 유연하게 재시도를 수행할 수 있습니다.

플랫폼 구독 알림 파서

플랫폼별 서로 다른 형태의 구독 알림을 스푼 라디오 내부 서비스에서 사용될 하나의 통일된 메시지 형태로 변환하는 부분입니다. 이렇게 변환된 메시지를스푼 구독 이벤트’라고 명명하겠습니다. 이기종의 구독 알림을 하나의 ‘스푼 구독 이벤트’로 변환하기 위해 ‘플랫폼 구독 알림 파서’는 아래의 역할을 수행합니다.

  • 이기종의 서로 다른 방식의 인증을 수행하여 위변조된 구독 알림을 필터 합니다.
  • 구독 알림을 해석하기 위해 추가적인 외부 API를 호출하여, 완성된 구독 정보를 수집합니다.
  • 이기종의 구독 알림에서 동일한 성격의 데이터를 추출하고 변환하여 하나의 통일된 형태의 메시지(스푼 구독 이벤트)를 생성합니다.
  • 생성된 메시지를 카프카 이벤트로 발행합니다.

위와 같이 플랫폼 구독 알림 → 스푼 구독 이벤트로 변환하여 카프카 이벤트 발행까지 성공한 경우, SQS에서 구독 알림을 제거합니다. 실패한 경우, 구독 알림은 SQS에 그대로 남아 내부 정책에 따라 처리됩니다.

‘플랫폼 구독 알림 파서’를 통해 스푼 라디오 내부 구독 서비스와 외부 구독 알림 ‘메시지’와 의존성을 제거하였습니다.

스푼 구독 서비스

‘스푼 구독 이벤트’를 소비하여 내부 비니지니스 로직을 처리합니다. 앞서 설명된 ‘플랫폼 구독 알림 리시버’와 ‘플랫폼 구독 알림 파서’를 통해 ‘스푼 구독 서비스’는 외부 구독 알림과 의존성을 거의 가지고 있지 않습니다. 예를들면 플랫폼의 구독 알림 스펙(전송 방식, 전송 정책, 메시지 형식 등)의 변화, 또는 심지어 제3의 새로운 플랫폼이 추가되더라도 ‘스푼 구독 서비스’에 영향도는 크지 않을 것 입니다.

덕분에 ‘스푼 구독 서비스’는, 개발 진행 당시에도, 스푼 내부 구독 정책에 집중하여 독립적으로 개발 진행을 할 수 있었습니다.

스푼 구독 이벤트 발행 이후 부터는 스푼 구독 정책 도메인 영역으로 한정된다.

물론 ‘스푼 구독 이벤트’는 플랫폼 구독 알림을 기반으로 생성된 메시지로, 플랫폼의 구독 알림으로부터 완전히 독립적일 수는 없지만, 플랫폼 구독 알림과 느슨한 연결을 유지하기 위한 목적으로 사용되었습니다.

# 마치며

지금까지 스푼 라디오에서 인앱 구독 서버 알림을 처리하기 위해 어떻게 서비스를 구성하였는지 살펴보았습니다. 이번 서비스 구성에 핵심은, 복잡하고 이원화된 외부 구독 도메인을 내부 구독 서비스 도메인으로부터 분리하는 것이었습니다.

크게 보면 두 플랫폼이 지원하는 구독 정책은 매우 유사합니다. 그러나 같은 내용이더라도 표현하는 방식이 크게 달라 플랫폼별 구독 알림을 1:1로 대응시켜, 공통된 하나의 메시지를 생성한다는 건 쉽지 않은 작업이었습니다. 또한 플랫폼은 구독 알림에 대하여 많은 문서를 제공하고 있지만, 실제 테스트를 하기 전까지 모호한 부분도 많았고, 이를 해소하기 위한 테스트 과정도 쉽지 않았습니다.

일회성 결제와 다르게 구독 결제는 연속성과 상태를 가지고 있어 많이 케이스가 존재하고 테스트 수행에 따른 결과를 얻기 전까지 비교적 많은 시간이 소요되었습니다.

복잡하게 얽힌 구글플레이 구독 상태 관계도

개발이 완료된 현시점에도 구현된 시스템이 구독 알림에서 발생할 수 있는 다양한 케이스를 모두 커버하였는지 의문이 남기도 합니다. 한동안 지속적인 모니터링을 통해 좀 더 안정적인 구독 서비스를 구축하기 위한 노력이 필요할 것으로 보입니다.

--

--