RemoteConfigを使ってABテスト振り分けしていくのをやめた話

f:id:hoshitocat:20210709173512p:plain

こんにちは。mikanでサーバーサイドエンジニアをしております。星(@hoshitocat)です。 最近ハマっているアニメは「不滅のあなたへ」です。先週はこれを観てうるっときてしまいました... OP曲の宇多田ヒカルさんの曲もよくて、おすすめです!

さて、mikanではGrowthのための施策やプロダクトの方向性を決める際にABテストを行っています。 これまでABテストの振り分けにはRemoteConfigを使っておりましたが、 最近振り分けロジックを変更したので、それについてご紹介したいと思います。 なぜRemoteConfigをやめたのか?みたいな話から、これから使おうと思っている方やすでに使っていて、課題感ある方に少しでも参考になればと思っております。

RemoteConfigを使った振り分け

RemoteConfigは、Firebaseのコンソールなどからあらかじめパラメータを設定しておいて、アプリ起動時などにその設定値を取得し、 それに応じてアプリの挙動などを制御することができるようなサービスになります。(ざっくり理解、詳細は公式ページをご参照ください。)

この設定値は、RemoteConfigで振り分けて返すことができ、以下のように50%のユーザには true を返したいけど、残り50%は false にしたいなどの分類が可能です。 これまでmikanでは、この機能を使ってABテストの振り分けを行っていました。

f:id:hoshitocat:20210709173518p:plainf:id:hoshitocat:20210709173516p:plain
remoteconfigのconditionsとparameters

ABテストを実施する際もそのまま使うだけという導入コストの低さゆえに、mikanではRemoteConfigを昔から使っていました。 また、分析にはログをAthenaで分析1し、ABテストの結果などを判断しているのですが、そのログに ab_flags という、RemoteConfigから取得された設定値を入れることで、 あるユーザがABテストのどのABフラグを持っているかで判断しています。

次にRemoteConfigを使ってよかったメリットとデメリットを示します。

メリット

  • 管理が楽
    • リッチなコンソール
    • GUIで設定項目を簡単に編集できる
  • 非常に導入が簡単
    • SDKを入れるだけ

デメリット

  • ヒューマンエラーを防ぐためには専用の仕組みを作る必要がある
    • コンソールから設定しておりましたが、 レビューの体制を作りたいと思い、 管理用のリポジトリを用意し、コードで管理していました。
    • 自動化にはRemoteConfigのAPIを叩いて実装していました。
  • 通信環境が悪いユーザのバイアス
    • 通信環境が悪いとRemoteConfigから値を取得できないため、 その場合はABの振り分けがデフォルト値にアサインされてしまい、 ABテストの結果に影響してしまうことになります。
  • サーバーサイドのAB振り分けロジックには利用できない
    • RemoteConfigはあくまでクライアントから利用することを想定されているため、 クライアントSDKを使った振り分けは可能だが、 サーバーサイドのAPIなどで、ユーザのAB振り分けを行うことはできません。

上記にあげた以外に、RemoteConfigにはキャッシュがあり (デフォルトだと12時間詳細はこちら) 一度値を取得し振り分けられたら、キャッシュが消えるまではそのままの値が保持されます。 これは便利なのですが、値を変更しても最大で12時間たたないとその変更後の値にはならないため注意が必要です。 (もちろん取得時間などは調整可能です)

RemoteConfigを使わずに実装した

デメリットであげたもので、特に以下2つ解消するためにABの振り分けロジックを考えました。

  • 通信環境が悪いユーザのバイアス
  • サーバーサイドでも使える振り分けロジック

前提として、ABテストの結果の分析にはAthenaを使っているので、Athenaで分析可能な振り分けを行う必要がありました。

結果として以下のようなアルゴリズムで実装しました。

  1. Firebase AuthのUIDとABテスト固有の文字列を連結
  2. ①の文字列をハッシュ化
  3. ②から先頭4バイト取得
  4. 10進数に変換
  5. ④の絶対値を取得し、100で割った余りを使いABテストの振り分けを行う

サーバーサイドとクライアントと分析基盤で同じ値が取得できることを確認するためにそれぞれで実装を書いてみました。

サーバーサイドで利用するGo言語で実装

package main

import (
    "bytes"
    "encoding/binary"
    "fmt"

    "crypto/sha1"
)

func main() {
    salt := "cdf3fa31-4cb5-4663-91a8-9274f722e232" // ABフラグ
    uid := "00wOT95NyMZvnprfjN4uU0AJ67C2"          // Firebase Auth UID(開発環境のもの)

    // saltとuidをくっつけた文字列をバイト配列に変換し、SHA1でハッシュ化
    value := sha1.Sum([]byte(salt + uid))

    // ハッシュ化されたバイト配列から先頭4バイト取得(32bit符号付き整数へ変換するため)
    // 先頭4バイトからint32へ変換
    var intValue int32
    buf := bytes.NewReader(value[:4])
    binary.Read(buf, binary.BigEndian, &intValue)

    fmt.Println(intValue) // ABテストの振り分けに利用する10進数
}

iOSアプリで利用するSwiftの実装

import UIKit
import CommonCrypto

let salt = "cdf3fa31-4cb5-4663-91a8-9274f722e232" // ABフラグ
let uid = "00wOT95NyMZvnprfjN4uU0AJ67C2"          // Firebase Auth UID(開発環境のもの)
let str = salt + uid
var hash: [UInt8] = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
let strLen = CC_LONG(str.lengthOfBytes(using: String.Encoding.utf8))
CC_SHA1(str, strLen, &hash)
let raw = hash[0...3].reduce(0) { $0 << 8 + Int32($1) }
print(raw)

Androidで利用するKotlinの実装

package org.kotlinlang.play

import java.nio.ByteBuffer
import java.security.MessageDigest

fun main() {
    val salt = "cdf3fa31-4cb5-4663-91a8-9274f722e232" // ABフラグ
    val uid = "00wOT95NyMZvnprfjN4uU0AJ67C2"          // Firebase Auth UID(開発環境のもの)

    val target = salt + uid
    val targetBytes = target.toByteArray()
    val sha1 = MessageDigest.getInstance("SHA-1")
    val sha1Result = sha1.digest(targetBytes)

    // 分析基盤の処理上、4byteまでしか受けつけれないため、揃えるために先頭4byteを取得するようにしてます
    val sliceResult = sha1Result.slice(0..3).toByteArray()
    val result = ByteBuffer.wrap(sliceResult).int
    println(result)
}

分析に使うAthenaでの実装

SELECT 
from_big_endian_32(
  SUBSTR(
    SHA1(
      CAST(
        'cdf3fa31-4cb5-4663-91a8-9274f722e232'      -- ABフラグ
        ||
        '00wOT95NyMZvnprfjN4uU0AJ67C2' AS VARBINARY -- Firebase Auth UID(開発環境のもの)
      )
    ),
    1,
    4
  )
)

メリット

  • オフラインでもクライアント側に振り分けロジックを記載すれば振り分けを正確に行うことが可能
  • サーバーサイドでも同じようなロジックでABテスト可能

デメリット

  • 振り分け時にABテスト時に割合を徐々に拡大する必要がある場合に対応できない
    • ABテストの比率50%:50%でテストしたいものだったので、 今回はクライアント側で振り分けロジックの実装をしました。 しかし、5%からはじまり徐々に拡大していく必要のあるABテストの場合はサーバー側で比率を調整し、 それをクライアントから取得するといったように実装しないと、アプリのアップデートありきの割合調整 になるため、比率調整が難しくなってきます。
    • 比率だけRemoteConfigから取得することもできましたが、 その場合どうしても、オンラインで通信環境が良いユーザのバイアスが含まれてしまいます。
  • 集計が複雑になる
    • UIDからハッシュ化などでユーザごとに振り分けがどちらなのか計算する必要があるため、集計が少々複雑になります。 (分析用ログに振り分けに利用したABフラグを付与することで回避できます。)

まとめ

今回紹介した方法では、RemoteConfigを使ったABテストのバイアスがかかってしまう問題を解消できました。 また、共通のロジックを使いサーバーサイドでもABテストができるようになりました。

しかし、管理側で比率をいつでも調整できないことは、今後の課題になります。 RemoteConfigだと通信環境が悪いユーザをABテストしづらいです。 特にオフラインのユーザはサーバーから比率を取得したり、ABフラグを取得することができないためどうしようもないのですが、 通信環境が悪い程度なら、通信コストをもっと抑えた実装をすることで、ABテストを実施できるかもしれません。 ここは調査していきたいです。

最後になりますが、mikanでは現在積極採用中です!

ABテストの分析やその機構の実装に興味がある方、英語が好き、日本の英語教育を変えたいと思っている方は、ぜひ一緒に働きましょう!!

mikan.link


  1. 弊社で分析してくれている石塚がこれまでの分析基盤の歴史を書いてくれています。また、これらのためのインフラ構成図もあります。