スクラッチ開発で挑んだZOZOマッチのアプリ内課金同期

スクラッチ開発で挑んだZOZOマッチのアプリ内課金同期

はじめに

こんにちは、新規事業部バックエンドブロックの三浦です。2025年6月にリリースされたマッチングアプリ「ZOZOマッチ」のバックエンド開発を担当しています。

ZOZOマッチでは、App StoreやGoogle Playの決済システムを利用したアプリ内課金を提供しており、定期購読(サブスクリプション)することで一部機能の制限解除や機能拡張が可能になります。アプリ内課金の実装には、アプリからの購入処理と購読のキャンセル・返金・自動更新といったライフサイクルイベントの同期処理が必要です。ZOZOマッチではこれらの処理をスクラッチで開発しました。

本記事では、特に開発が難航した、ライフサイクルイベントによって変更される課金ステータスをバックエンドに同期する仕組みについて紹介します。AppleとGoogleそれぞれが提供する通知の仕組みの違いや、同期処理の実装における課題と工夫についても解説します。

目次

ZOZOマッチにおけるアプリ課金

はじめに、ZOZOマッチにおける課金の概要について説明します。前述の通り、ZOZOマッチではアプリ内課金を利用して複数の定期購読プランを提供しています。定期購読することで一部機能の制限解除や機能拡張が可能になり、いずれもサービスの利用体験に関わる内容となっています。

設計当初はストア以外での決済手段も検討しましたが、以下の理由からアプリ内課金のみを採用しました。

  • AppleおよびGoogleの規約上、ZOZOマッチで販売しているコンテンツはアプリ内課金を利用する必要がある
  • Web決済をする場合はWebでも同様の機能を提供する必要がある(ZOZOマッチはアプリ専用サービスのため該当しない)

実装面では、開発工数の削減を目的にアプリ内課金の管理をサポートするSaaSの導入を検討しました。しかし、費用対効果の観点から最終的にはスクラッチ開発を選択しました。

購読ステータス同期の必要性

定期購読には、購読開始から更新・解約までのライフサイクルがあります。購読開始時はアプリ内で購入処理が行われ、ストア側で決済が完了すると購入情報がアプリに返されるため、バックエンドへの反映は比較的シンプルです。

一方で、以下のようなイベントはストア側で自動的に処理されるため、アプリを経由せず発生します。

  • 自動更新:購読期間の終了時の自動課金
  • 更新失敗:支払い方法の問題による課金失敗
  • 解約:ユーザーによる自動更新の停止

これらのイベントをバックエンドへ正確に同期しなければ、「解約したのに有料機能が使える」「自動更新したはずなのに有料機能が使えなくなった」といった不整合が発生してしまいます。社内ではこのストア側の購読ステータス同期に関するノウハウが少なかったため、Apple/Googleそれぞれの公式ドキュメントを読み込み、チーム内で調査するところから始めました。

以降、購読開始の流れを簡単に説明した後、今回調査や実装が特に難航したストア側の購読ステータスの同期方法をAppleおよびGoogleに分けて解説していきます。

購読開始までの流れ

購読開始および購読再開の場合、ユーザーがアプリを通じてApp StoreまたはGoogle Play Storeで購入手続きを行います。

ZOZOマッチアプリ内課金購入の流れ

購入手続きが完了すると、ストア側からアプリにレシート(購読情報)が返されます。アプリはこのレシート内の取引IDをバックエンドに送信し、バックエンド側でレシートを検証します。

取引IDの管理

レシート情報には、ストア側で管理している取引IDが含まれています。AppleとGoogleでは取引IDの体系が異なるため、それぞれの仕様を理解する必要があります。

プラットフォーム プロパティ 内容
Apple originalTransactionId 購読の一生を通じた親識別子。同一の購読契約(同一 Apple ID/同じ購読系列)であれば解約、再購読しても変わらない
Apple transactionId 各トランザクションの個別識別子。新規購読、自動更新、返金など課金イベントごとに発行される
Google purchaseToken 購読の取引識別子。購読〜更新〜解約まで変わらない。有効期限切れ後の再購読やプラン変更時に再発行される
Google linkedPurchaseToken プラン変更や再購読時に設定される、1つ前の購読の取引識別子。新旧の契約を紐付けるために使用される

レシート検証

ZOZOマッチでは、これらの取引IDを履歴としてDBに保持し、以下の観点でレシートを検証します。

  • リプレイ攻撃対策:同じレシートが複数回使用されていないか
  • 不正利用チェック:他ユーザーのレシートを流用していないか
  • 有効性検証:取引IDを基にストアAPIから取得したレシート情報が有効であるか

すべての検証が完了したら、ユーザーの購読ステータスを更新し、購読対象のサービスが利用可能になります。

購入時も後述するストア側からの通知(Apple Server Notifications / Google RTDN)が送信されます。ただし、アプリ経由でレシートを取得しているため、通知による同期処理はスキップされます。

購読情報の同期 (Apple)

App Storeにおけるアプリ内課金では、自動更新や解約などのステータス変更はApple側で管理されています。これらの情報をバックエンドへ同期するために、AppleではApp Store Server Notificationsというサービスが提供されています。

App Store Server Notifications

App Store Server Notifications(以下ASSN)は、Appleが提供するサーバー間通知サービスです。App Storeで発生した課金イベントをApple側から直接指定のエンドポイントに通知する仕組みです。この仕組みによって、定期購読の自動更新などユーザーがアプリを開かずに発生する課金イベントも同期できます。

通知先のエンドポイントはApp Store Connect上で設定します。購読の開始、更新、解約などの課金イベントが発生すると、このエンドポイントに対してHTTP POSTリクエストで購読情報が送信されます。

ASSNにはV1とV2の2つのバージョンが存在しますが、V1は非推奨となっており、V2の利用が推奨されています。ZOZOマッチでもV2を利用しています。

ZOZOマッチでの同期方法

ZOZOマッチでは、ASSNからの通知を受け取るためのWebhookエンドポイント(以下ASSN通知受信Webhook)を用意しています。課金イベントが発生すると、このエンドポイントに対してHTTP POSTリクエストが送信されます。

下記は全体のアーキテクチャ図です。 ZOZOマッチASSN通知アーキテクチャ図 ASSN通知受信Webhook自体はFargate上で稼働しており、API Gateway経由で通知を受け付けます。Appleからのみリクエストを受け付けるよう、WAFでIPアドレス制限をかけています。加えてWebhook側で署名検証を実施し、不正なリクエストを防止しています。

下記はASSN通知受信Webhookの全体の処理フローです。 ZOZOマッチASSN通知の流れ ASSNからはoriginalTransactionIdを含む通知がHTTPリクエストで送信されます。originalTransactionIdを基にDBから既存の購読情報を特定し、通知内容に応じてユーザーの課金ステータスを更新します。更新後はレスポンスとして処理の成否をASSNへ返却します。

通知内容

ASSNから受け取る通知のペイロードは、JWS(JSON Web Signature)形式で署名されたJSONデータです。

ZOZOマッチのバックエンドはJava + Spring Bootを採用しています。そのため、JWS署名付きペイロードの検証とデコードにはAppleが公式で提供しているapp-store-server-library-javaというオープンソースのライブラリを使用します。

以下はAppleで公開されているペイロードのサンプルです。実際に送られてくるペイロードは通知全体の情報、その中の取引情報(signedTransactionInfo/signedRenewalInfo)で二重にJWS署名されているため、各々検証が必要です。

{
  "notificationType": "SUBSCRIBED",
  "subtype": "INITIAL_BUY",
  "version": 2,
  "data": {
    "environment": "Production",
    "bundleId": "co.oceanjournal",
    "appAppleId": 1231451896,
    "bundleVersion": 1,
    "signedTransactionInfo": "ewogICAgInRyZ...eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.AReJJaUWG8fc-Y8n8YHj…",
    "signedRenewalInfo": "ewogICAgInRyYW5z...eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ARcVInaJJG8fG-8t5TY8n8YHj…"
  },
  "signedDate": "1767229200"
}

エンコードされている取引情報の詳細は、下記公式ドキュメントを参照してください。

通知の署名検証とデコード

以下ASSN通知受信Webhook内の署名検証とデコードの実装例です。apple-app-store-server-sdk-javaのSignedDataVerifierクラスを利用して、ASSNから送られてきたJWS署名付きペイロードの検証・デコードをします。

SignedDataVerifierは初期化時にEnvironment(SANDBOX/PRODUCTION)を指定する必要があります。これは環境によって検証時に利用する公開鍵が切り替わるためです。ZOZOマッチでも本番環境とテストやアプリ公開へ向けた審査時に利用されるSandbox環境の両方が存在しているため、環境ごとにSignedDataVerifierのBeanを分けて定義しています。

// SignedDataVerifierのBean定義
@Bean("signedDataVerifierForPrd")
public SignedDataVerifier signedDataVerifierForPrd(final AppStoreProperty property) {
  return createSignedDataVerifierClient(property, Environment.PRODUCTION);
}

@Bean("signedDataVerifierForSandbox")
public SignedDataVerifier createSignedDataVerifierClientForSandbox(final AppStoreProperty property) {
  return createSignedDataVerifierClient(property, Environment.SANDBOX);
}

private SignedDataVerifier createSignedDataVerifierClient(final AppStoreProperty property, final Environment environment) {
  return new SignedDataVerifier(
      getRootCertificates(),
      property.getBundleId(),
      property.getAppAppleId(),
      environment,
      property.getEnableOnlineCheck()
  );
}

署名の検証処理では、まず本番環境用のSignedDataVerifierで検証を試みます。環境の不一致によるエラー(Sandbox環境からの通知)が発生した場合は、Sandbox環境用のSignedDataVerifierで再検証します。

@Qualifier("signedDataVerifierForSandbox")
private final SignedDataVerifier signedDataVerifierForSandbox;

@Qualifier("signedDataVerifierForPrd")
private final SignedDataVerifier signedDataVerifierForPrd;

public AppStoreNotification decodedNotificationInfo(@NonNull AppStoreSignedPayload signedPayload)
    throws PurchasePlatformServerException {
  try {
    // ペイロード全体の署名検証とデコードを行う
    final var decodedNotification = signedDataVerifierForPrd.verifyAndDecodeNotification(signedPayload.value());

    // デコードしたペイロードからトランザクション情報を取得し、トランザクション情報の署名検証とデコードを行う
    final var decodedTransactionInfo = TrustedAppStoreSubscription.of(
        signedDataVerifierForPrd.verifyAndDecodeTransaction(decodedNotification.getData().getSignedTransactionInfo())
    );

    return AppStoreNotification.of(decodedNotification, decodedTransactionInfo);

  } catch (VerificationException e) {
    if (e.getStatus() == VerificationStatus.INVALID_ENVIRONMENT) {
      return fallbackToSandbox(signedPayload);
    }

    throw new PurchasePlatformServerException(
        "AppStoreNotificationV2から取得した署名付きペイロードの検証に失敗しました。", e);
  }
}

課金ステータスの更新

デコードした通知情報には発生した課金イベントの種類を示すnotificationTypeが含まれています。この値に応じて、ユーザーの課金ステータスを更新します。主な通知タイプは以下の通りです。

通知タイプ 内容
SUBSCRIBED ユーザが新しくサブスクリプションを購入した・あるいはユーザがサブスクリプションを再購入した
DID_RENEW サブスクリプションが正常に更新された
DID_FAIL_TO_RENEW 課金の問題によりサブスクリプションが更新に失敗
REFUND サブスクリプション課金の払い戻しがされた
REFUND_REVERSED 顧客の異議申し立てにより、以前に払い戻されたサブスクリプション課金を取り消した(REFUND処理の取り消し)
DID_CHANGE_RENEWAL_STATUS 顧客が自身でサブスクリプションの自動更新を有効/無効化した
TEST テスト用の通知

詳細な通知タイプについてはApple公式のドキュメントを参照してください。

同期処理における注意点

ASSNでは通知される順番の保証がされていません。例えば「定期購読の更新に失敗」→「成功」の順でイベントが発生しても、「更新成功」の通知が先に届く可能性があります。この場合、後から届いた「更新失敗」の通知で課金ステータスを上書きしてしまうと、実際には有効な購読が無効として扱われてしまいます。

ZOZOマッチでは、このような先祖返りが発生しないよう以下の対策を実施しています。

  1. 通知内のoriginalTransactionIdを基に、DBから既存の購読情報を取得
  2. DB上の最新のレコードのトランザクション発生日時と受け取った通知内のトランザクション発生日時を比較
  3. DBのトランザクション発生日時 >= 通知内のトランザクション発生日時の場合は、古い情報で上書きする可能性があると判断。更新処理は行わずエラーを投げる

下記はサブスクリプションが自動更新された場合(DID_RENEW)の通知サンプルです。取引情報の一部を抜粋しています。

{
  "transactionId": "2000001120654880",
  "originalTransactionId": "2000001120642345",
  "appStoreBundleId": "jp.test",
  "appStoreProductId": "monthly_subscription_02",
  "purchaseDate": 1767229200,
  "expiresDate": 1769907600,
  "signedDate": 1767229500,
  "price": 980,
  "transactionReason": "RENEWAL"
}

比較するトランザクション発生日時は通知内のpurchaseDateまたはsignedDateを利用します。signedDateは通知全体の署名の発行日時を表しています。購入/再購入時はpurchaseDateが明示的に含まれますが、自動更新や解約など他のイベント単位の日付のパラメータは存在しないため、代替としてsignedDateを使用しています。

また、ネットワークの問題などにより、同一の通知が複数回届くこともあります。ZOZOマッチでは通知に含まれる一意の識別子notificationUUIDを記録し、同じ通知が再度処理されないようにしています。

前述の通りASSN通知受信Webhookでは前段のWAFでASSNの通知元IPアドレスからの通信のみを許可しています。IP制限を入れる場合、App Store Serverが使うIPブロック17.0.0.0/8からの通信を許可する必要があります。このIPブロックでSandbox環境とProduction環境の両方をカバーしています。

ASSNへのレスポンス返却

ASSN通知受信Webhookはリクエストを受け取った後、ASSNに対してHTTPレスポンスを返却する必要があります。ASSNに対してHTTPステータス200を返却した場合は、ASSN側でも通知が正常に受信されたとみなされ処理は完了となります。200系以外のステータスコードを返却した場合は、ASSN側は配信失敗とみなし再送を試みます。しかし再送回数には最大5回までと制限があり、5回全て失敗した場合は以降の再送は行わないため注意が必要です。

対策として、Appleが提供するApp Store Server APIで通知履歴を取得できます。

 POST https://api.storekit.itunes.apple.com/inApps/v1/notifications/history

公式ドキュメント:Get Notification History

このAPIでは本番環境で過去180日分の通知履歴を取得できます。リクエストボディで特定の期間、失敗した通知のみ、特定のtransactionIdに紐づく通知のみなど、様々な条件で絞り込みが可能です。これにより、失敗した通知を定期的にチェックし、バックエンド側で再処理する仕組みの構築も可能です。

購読情報の同期 (Google)

Google Playにおけるアプリ内課金でも、自動更新や解約などのステータス変更はGoogle側で管理されています。これらの情報をバックエンドへ同期するために、GoogleではReal-time Developer Notificationsという機能が提供されています。

Real-time Developer Notifications

Real-time Developer Notifications(以下RTDN)はGoogleが提供する通知サービスで、アプリ内課金の状態変化をリアルタイムで通知します。AppleのASSNがHTTP POSTで直接通知を送信するのに対し、RTDNはGoogle Cloud Pub/Subをベースにした非同期メッセージングで通知します。

Google Play Console上でRTDN用のPub/Subトピックを設定し、メッセージの受信側としてSubscriberを用意します。これにより、RTDNの通知をPub/Sub経由で受信できるようになります。

ZOZOマッチでの同期方法

ZOZOマッチでは、RTDNの通知を取得するためのSubscriberアプリケーションを用意しています。Pub/SubにはPush型とPull型の2つの方式があります。Push型ではSubscriptionがSubscriberに対してメッセージを送信し、Pull型ではSubscriberがSubscriptionからメッセージを取得しにいきます。ZOZOマッチではPull型を採用し、Subscriptionからメッセージを取得して購読情報の同期処理を行います。

下記は全体のアーキテクチャ図です。 ZOZOマッチRTDN通知アーキテクチャ図 SubscriberはFargate上で稼働しているアプリケーションで、NAT Gateway経由でPub/Subに接続します。

下記はSubscriberの処理フローです。

ZOZOマッチRTDN通知の流れ

RTDNの通知内容はPub/SubのTopic経由でSubscriptionに送信されます。SubscriberアプリケーションはこのSubscriptionからメッセージを取得します。

DB内の購読情報はpurchaseTokenまたはlinkedPurchaseTokenを基に特定します。purchaseTokenは固定値ではなく、購読プランの変更などで新しい値が発行される場合もあります。その際、前回のpurchaseTokenがlinkedPurchaseTokenとして紐付けられるため、既存の購読情報を特定する際はこちらを優先して使用します。

ただし、RTDNの通知には最小限の情報しか含まれておらず、purchaseTokenのみが通知されます。linkedPurchaseTokenを取得するには、purchaseTokenを使って下記のGoogle Play Developer APIを呼び出し、購読の詳細情報を参照する必要があります。

GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token} 

詳細なAPI仕様はGoogleの公式ドキュメントを参照してください。

DBから取得した内容と通知内容に応じてユーザーの課金ステータスを更新します。最後に更新の成否を該当のSubscriptionに送信して処理が完了します。

通知内容

RTDNの通知はJSONデータで、base64エンコードされたdataフィールドに購読イベントの詳細情報が含まれています。以下はGoogleが公開しているペイロードのサンプルです。

{
  "message": {
    "attributes": {
      "key": "value"
    },
    "data": "eyAidmVyc2lvbiI6IHN0cmluZywgInBhY2thZ2VOYW1lIjogc3RyaW5nLCAiZXZlbnRUaW1lTWlsbGlzIjogbG9uZywgIm9uZVRpbWVQcm9kdWN0Tm90aWZpY2F0aW9uIjogT25lVGltZVByb2R1Y3ROb3RpZmljYXRpb24sICJzdWJzY3JpcHRpb25Ob3RpZmljYXRpb24iOiBTdWJzY3JpcHRpb25Ob3RpZmljYXRpb24sICJ0ZXN0Tm90aWZpY2F0aW9uIjogVGVzdE5vdGlmaWNhdGlvbiB9",
    "messageId": "136969346945"
  },
  "subscription": "projects/myproject/subscriptions/mysubscription"
}

dataフィールドのbase64デコード後の内容は以下の通りです。

{
  "version": "1.0",
  "packageName": "com.some.thing",
  "eventTimeMillis": 1767229220,
  "oneTimeProductNotification": {},
  "subscriptionNotification": {},
  "voidedPurchaseNotification": {},
  "testNotification": {}
}

Notification で終わるフィールドは購読イベントの種類によって該当するフィールドのみがレスポンスに含まれます。そしてこのフィールドの中に詳細のイベント内容を表すnotificationTypeというプロパティが含まれています。取引IDであるpurchaseTokenもこの中に入っています。

プロパティ名 内容
oneTimeProductNotification 1回だけの単発購入に関する通知
subscriptionNotification サブスクリプション(更新、解約など)に関する通知
voidedPurchaseNotification システム側でサブスクリプションを無効化した場合や、返金を行った場合などの通知
testNotification Google Play Consoleから手動で送信するテスト用の通知

通知毎の内容の詳細はGoogle公式のドキュメントを参照してください。

通知の検証とデコード

SubscriberからPub/Subへの接続にはSpring Cloud GCPを利用しています。具体的には、Spring IntegrationベースのアダプターPubSubInboundChannelAdapterを使用しています。

以下の設定をしたPubSubInboundChannelAdapterをBeanとして登録することで、Pub/Subからのメッセージを指定したMessageChannelで受け取れるようになります。

設定値 内容
pubSubTemplate Pub/Sub操作用のヘルパー(接続情報等を持つ)
subscriptionName メッセージ取得先のSubscription名
ackMode メッセージの処理結果を自動で返すか、アプリ側で明示的に返すかの設定
outputChannel 取得したメッセージの出力先チャンネル
@Configuration
@RequiredArgsConstructor
public class PubSubConfig {

  @Value("${rtdn-subscriber.subscription-name}")
  private String subscriptionName;

  /**
   * Pub/Subサブスクリプションのメッセージを受け取るアダプターを生成する.                                                                                                                                                        
   */
  @Bean
  public PubSubInboundChannelAdapter messageChannelAdapter(@Qualifier("pubsubInputChannel") MessageChannel inputChannel, PubSubTemplate pubSubTemplate) {

    final var adapter = new PubSubInboundChannelAdapter(pubSubTemplate, subscriptionName);
    adapter.setAckMode(AckMode.MANUAL);
    adapter.setOutputChannel(inputChannel);
    return adapter;
  }

  @Bean
  public MessageChannel pubsubInputChannel() {
    return new DirectChannel(); // 同期処理。メッセージを受け取ったスレッドでそのまま処理
  }
}          

下記はSubscriber側の実装例です。 @ServiceActivator アノテーションを付与することで、前述のPubSubInboundChannelAdapter経由で受け取ったメッセージを処理できます。ペイロードをデシリアライズして通知情報を取得し、購読イベントの詳細情報を取得します。購読ステータスの更新処理が完了した後にSubscriptionに対してレスポンスを送信します。レスポンス値については後続で説明します。

private final RtdnMessageConverter converter;
private final SubscriptionNotificationUseCase subscriptionNotificationUseCase;


@ServiceActivator(inputChannel = "pubsubInputChannel")
public void messageReceiver(
    @Header(GcpPubSubHeaders.ORIGINAL_MESSAGE) BasicAcknowledgeablePubsubMessage message,
    @Payload String payload) {
  var shouldAck = false;
  try {

    final var notificationMessage = converter.convertFromPayloadToNotificationMessage(payload);
    shouldAck = subscriptionNotificationUseCase.handleNotification(
        converter.convertFromRtdnMessageToNotificationInfo(notificationMessage,
            message.getPubsubMessage().getMessageId())
    );

  } finally {
    try {
      if (shouldAck) {
        message.ack();
      } else {
        message.nack();
      }
    } catch (Exception e) {
      log.error("ACK/NACKの送信に失敗しました。", e);
    }
  }
}

課金ステータスの更新

通知情報にはどのような課金イベントが発生したかを示すnotificationTypeプロパティが含まれています。主な通知タイプは以下の通りです。

通知内容を含むフィールド名 通知タイプ 内容
subscriptionNotification SUBSCRIPTION_PURCHASED ユーザが新しく購読した
subscriptionNotification SUBSCRIPTION_RENEWED サブスクリプションの自動更新が正常に更新された
subscriptionNotification SUBSCRIPTION_IN_GRACE_PERIOD サブスクリプションの自動更新の猶予期間に入った
subscriptionNotification SUBSCRIPTION_EXPIRED サブスクリプションが期限切れになった
subscriptionNotification SUBSCRIPTION_CANCELED サブスクリプションがキャンセルされた
oneTimeProductNotification ONE_TIME_PRODUCT_PURCHASED 1回だけの単発購入が行われた
oneTimeProductNotification ONE_TIME_PRODUCT_CANCELED 1回だけの単発購入がキャンセルされた

詳細な通知タイプに関してはGoogle公式のドキュメントを参照してください。

同期処理における注意点

RTDNでも通知の到着順序は保証されていません。そのため、ASSNと同様にDB上の最新トランザクション発生日時と通知のトランザクション発生日時を比較します。

下記はサブスクリプションが自動更新された場合(SUBSCRIPTION_RENEWED)の通知サンプルです。

{
  "version": "1.0",
  "packageName": "jp.test",
  "eventTimeMillis": "1770889888958",
  "subscriptionNotification": {
    "version": "1.0",
    "notificationType": 2,
    "purchaseToken": "a1b2c3d4e5f6g7h8i9j0k",
    "subscriptionId": "monthly_subscription_01"
  }
}

RTDNの場合は通知内のeventTimeMillisをトランザクション発生日時として比較します。eventTimeMillisは対象の課金イベントが発生した日時をミリ秒単位で表しています。

RTDNでも同様にDB上のトランザクション発生日時 >= 通知内のトランザクション発生日時の場合は、古い情報で上書きする可能性があると判断し、更新処理は行わずエラーを投げます。

RTDNへのレスポンス返却

Google Cloud Pub/SubにはACK(Acknowledgement)とNACK(NegativeAcknowledgement)という仕組みがあります。これはSubscriberがメッセージを正しく受信・処理したことをSubscriptionに伝えるためのものです。Subscriberは処理完了時にACKをPub/Subに送信することで、Subscription側では対象メッセージの処理が完了したとみなし削除します。NACKを送信した場合や一定時間内にACKが送信されなかった場合、Pub/Sub側ではメッセージは「未確認」扱いとなり一定時間が経過した後に再送が行われます。

ASSNとの違い比較

最後に、AppleのASSNとGoogleのRTDNの違いを簡単にまとめます。

Apple(ASSN) Google(RTDN)
通信方式 Push型(HTTP POST) Push型 / Pull型(Pub/Sub)
ペイロードの署名 JWS署名あり なし
リトライ制御 Apple側で最大5回 Pub/Subの設定で制御可能

開発・運用を通じて大変だったこと

開発時のテストの難しさ

開発当時はアプリ内課金のスクラッチ開発に関する参考情報が少なく、特に動作確認に苦労しました。

Appleの課金テストをする際はApple側で提供しているアプリ内課金やApple Payトランザクションを無料でテストできるSandbox環境を利用します。ドキュメントを確認しながらの手探りでのテストだったため、以下のような点で苦労しました。

  • 事前にApple Store Connect上でテスト用のユーザーアカウントの作成が必要だった
  • Sandbox環境だとサブスクリプションの更新頻度が短いなど本番とは異なる挙動があった

また、AppleやGoogleの公式ドキュメントに記載されている購読ステータスの各条件を、実際にテスト端末で1つずつ再現して確認しました。通知が正しく処理されているかはログやDBの状態から確認する必要があり、地道な検証作業が続きました。中でも返金に関するテストは特に情報が少なく、再現手順の調査から始める必要がありました。

運用面での課題

運用開始後は、経理部門へ渡す売上データの作成も必要になりました。こちらもスクラッチで開発しましたが、売上データとしてどのような情報が必要かを経理側の要件と公式ドキュメントを照らし合わせながら調査し、レシート内のどのフィールドが該当するかを特定していきました。

また、運用を続ける中でストア側の仕様変更を即座にキャッチできないことがありました。SaaSを利用していれば、こうした仕様変更への追従やサポートを受けられるかもしれません。

課金周りの実装や知見が属人化してしまっているのも課題の1つです。今後新しい課金プランの追加や仕様変更が発生した際にも対応できるよう、ドキュメント整備やナレッジ共有を進めていきたいと考えています。

まとめ

本記事ではZOZOマッチにおけるアプリ内課金の同期方法について紹介しました。アプリ内課金の導入を検討している方がいれば、ぜひ参考にしてみてください。

さいごに

ZOZOでは、ZOZOTOWNの一本足打法からの脱却を狙い、新規事業にも果敢に取り組んでいます。このような挑戦を一緒に楽しめる仲間を募集しています。ご興味のある方は、採用ページをご覧ください。

hrmos.co

カテゴリー