Retty Tech Blog

実名口コミグルメサービスRettyのエンジニアによるTech Blogです。プロダクト開発にまつわるナレッジをアウトプットして、世の中がHappyになっていくようなコンテンツを発信します。

コードで書かれた運用もSlack WFとDevinで実質自動化! 小さなAIワークフローでの業務効率改善事例

はじめに

Rettyアプリチームリードエンジニア今泉 @imaizume です。 9月にiOSDCが終わり、その後に主催したSwiftイベントも終わって一段落しております。

今回はiOS/Androidアプリ向けサーバーに関するお話になります。 このサーバーにはアプリのホーム画面に表示するバナーを配信するがあるのですが、これをAIワークフローで再定義し実質全自動化を実現しました。 結果として、最短1日以上かかっていた作業が30分程度で済むようになり、企画側もエンジニア側も作業負荷を大幅に落とすができました。

今回はRettyのアプリホームバナーにおける課題について解説し、要件の再定義からワークフローの再構築に至るまでの過程や実装の詳細について触れるとともに、Slack Workflow(以下WF)とDevinのコラボレーションによるAIワークフロー実現可能性についてご紹介します。 定型的なコード変更をワークフロー化する事例として、皆様の業務改善の役に立つような内容となれば幸いです。

Rettyアプリのホームバナーについて

まず今回の主題であるRettyアプリのホームバナーについて軽く紹介します。

Rettyアプリのホーム画面に表示されるバナー

iOS/Androidのホーム画面には、キャンペーンやお知らせを伝えるためのバナーエリアが存在します。 全ユーザーが通る場所であるため、キャンペーンの促進や広報活動に大きく寄与している機能です。

この種のコンテンツ配信を管理する方法として、一般的にCMSやAdjust、Firebaseといった外部ツールや専用システムを構築するなどの手法があるかと思います。 しかしRettyアプリでは、なんと...古き良き(?)サーバーでのハードコーディングとデプロイによる配信をしているのです!

ホームバナー自体は昔から存在していたものの、昨年末に要件を変えてリニューアルした際、工数観点で暫定的な実装としたものがそのまま改修の機会を得ることなく今に至っています。 現在のバナーには以下のような属性があり、これらの組み合わせによって表示制御を行っています。

  • バナーID
  • 表示期間(開始日時・終了日時)
  • 優先度(並び順)
  • 表示条件(ユーザーの属性、アプリバージョンなど)
  • 画像URL
  • タップ時の遷移先

リニューアル時の実装では、これらの条件をIF文で分岐させ、表示すべきバナーを決定していました。

fun exec(now: OffsetDateTime): List<HomePromotionalBanner> {
 
    // 日付条件

    val j202510300000 = ZonedDateTime.of(LocalDate.of(2025, 10, 30), LocalTime.of(0, 0, 0), japanZoneId)
            
    val j202511071200 = ZonedDateTime.of(LocalDate.of(2025, 11, 7), LocalTime.of(12, 0, 0), japanZoneId)
    ...
    
    // バナー定義

    private val banner1 = HomePromotionalBanner(bannerName = "banner1", imageUrl = "...",  searchCondition = SearchCondition(...), url = null) 

    private val banner2 = HomePromotionalBanner(bannerName = "banner2", imageUrl = "...", searchCondition = SearchCondition(...), url = "https://retty.me/...") 
    ...

    // IF文での条件判定
    
    return if (j202510300000 <= now && now < j202511141200) {
      listOf(banner1, banner2, ...)
    } else if (j202511141200 <= now && now < j202511211200 && os == "iOS") {
      listOf(banner2, ...)
    } else if (...) {
      listOf(banner1, ...)
    }

当初は配信されるバナーの数・頻度が限定的で、この実装でもひとまず問題は起きませんでした。 しかし2025年の年末が近づき、キャンペーンや広報関連のバナー依頼が急激に増えてきたことで、様々な課題が顕在化していきます。

抱えていた課題

以前のバナー更新フロー

可読性の低下

まず当たり前の話ですが、IFでの条件分岐は

  • どの条件で
  • どのバナーが
  • どの順序で表示されるのか

が非常に見通しづらい構造をしています。

期間や対象OSなど掲載条件の異なるのバナーが複数存在すると条件の組み合わせも急激に増加し、コードをひと目見ただけでは、特定条件下でのバナーの表示順番を把握することは極めて困難です。

検証効率の悪化

そして仕様通りの動作を保証するためのテストコードも存在していましたが、期間や内容が変わればテストコードも追加・変更する必要があり、やりたいことに対して実装が過剰かつ非効率でした。 さらに、企画側は開発用の環境を用意できないため、ステージング環境にデプロイして確認するしかなく、修正のたびに追加のPull Requestマージとデプロイの時間がかかるため、デプロイせずに確実に掲載状態を確認できるような手段が求められていました。

長いリードタイム

従来バナーの追加・変更は以下のような手順で行われおり、リリースまでのリードタイムが長いのも課題でした。

  1. 企画側からGitHub Issueでバナー追加・変更を依頼
  2. エンジニアがIssueを確認し、コードを修正
  3. Pull Requestを作成し、レビュー
  4. ステージング環境での動作確認
  5. マージ後、次回リリース(週1回)を待つ

依頼から実際の反映まで、最短でも数日、長ければ1週間近くかかっていましたし、Issueを用意する企画側の負担も大きかったのでした。 また内容にミスや変更があったときには、修正のためにリードタイムがさらに伸びてしまっていました。

コミュニケーションミス

企画側では、掲載中のバナーの並び順や優先度をリアルタイムに把握することが難しく、依頼時にエンジニアへ順序を正確に伝えられないケースがありました。 例えば「このバナーを現在表示中のバナーの3番目に表示してほしい」といった依頼がされたとき、リリース時点ではすでに他のバナーが取り下げられるなどして基準が変わってしまっていて「3番目」の定義が変わるという問題がありました。

結果として、実際のバナー掲載状態の管理はエンジニアが行わざるを得ず、都度最新のコードの状態をエンジニアが確認しながら追加・更新をする必要がありました。

解決に向けたアプローチ

バナー掲載要件の再整理

この状況が続くと開発リソースの消費が大きいうえ、機動的な掲載内容の変更ができずビジネス面でも芳しくないと判断した私は、現在の運用を一度止めて仕組みをリファクタリングすることにしました。 その際、私は以下の点を重視しました。

  1. 掲載順序や条件の管理・反映は、なるべく企画側で完結できるようにする
  2. バナー更新のための開発・確認工数を最大限エンジニアから剥がす
  3. トータルでかかる作業・時間コストを削減する

そこで手始めに、バナーの掲載ロジックを整理するため、改めて必要な要件を洗い出しました。

  • 優先度: 条件を満たすバナーの中での表示順序
  • 表示期間: 現在日時が開始日時〜終了日時の範囲内か
  • OS: クライアントのOSバージョン
  • ログイン状態: ログインしているかどうか
  • タップ時のアクション: 指定条件での検索またはURLをWebViewで開く

すると、ある時点における掲載可能なバナーの順序は「全バナーから宣言順に、リクエスト元の環境条件でfilterした値」で表現できることに気づきました。

この気づきをもとに、定義を定数化したのが以下のコードです。

// 型定義
data class Banner(
    val id: String,
    val imageUrl: String,
    val displayPeriod: DisplayPeriod,
    val targetOS: OSType? = null,
    val targetLoginStatus: Boolean?,
    val tapAction: TapAction,
) {
    sealed class TapAction {
        data class Search(val searchCondition: SearchCondition) : TapAction() { data class SearchCondition(...) }
        data class Url(val url: String) : TapAction() 
    }
}

// 定数
private val banner1 = Banner(id = "banner1", imageUrl = "...", ... )
private val banner2 = Banner(id = "banner2", imageUrl = "...", ... )
...

// 有効なすべてのバナー
val all: List<Banner> by lazy {
    listOf(banner1, banner2, ...)
}

そして、表示ロジックは単純なfilter処理として実装できるようになりました。

fun exec(now: OffsetDateTime, client: OSType?, isLoggedIn: Boolean): List<HomePromotionalBanners.Banner> {
    // 有効な全バナーを取得
    val all = HomePromotionalBanners.all

    // 掲載期間でフィルタ
    val filteredByPeriod = all.filter { banner ->
        val period = banner.displayPeriod
        val japanZoneId = ZoneId.of("Asia/Tokyo")
        val start = ZonedDateTime.of(period.startDateTime, japanZoneId).toOffsetDateTime()
        val end = ZonedDateTime.of(period.endDateTime, japanZoneId).toOffsetDateTime()
        val dateTimeInRange = now in start..end

        // 日次の周期が指定されている場合のみ、現在時刻がその範囲内にあるかを確認
        val timeInRange = period.dailyTimeRange?.let {
            val currentTime = now.toLocalTime()
            it.startTime <= currentTime && currentTime <= it.endTime
        } ?: true
        dateTimeInRange && timeInRange
    }

    // OSでフィルタ
    val filteredByOS = filteredByPeriod.filter { banner ->
        banner.targetOS == null || banner.targetOS == client
    }

    // ログイン状態でフィルタ
    val filteredByLoginStatus = filteredByOS.filter { banner ->
        when (banner.targetLoginStatus) {
            null -> true
            true -> isLoggedIn
            false -> !isLoggedIn
        }
    }
    return filteredByLoginStatus
}

このアプローチにより、バナーの追加・変更は「定数リストへの追加・編集」のみで完結するようになり、表示ロジック部分への変更が不要になりました。

管理画面や専用のシステムを組むべきでは?

そもそもコードベースの実装ではなく、CMSや管理画面構築などの専用システム化するのが エンジニアリングとしては 正しいようにも思います。 確かに仕様が固まっており長期的な運用が想定される場合は、専用システムを構築することが最も理想的だと自分も思います。

しかしこのバナーは過去数年でたびたび仕様が変わっており、現行仕様も冒頭でも述べた通り1年前にできたばかりのものです。 そして今後また仕様変更が起きる可能性があります。 加えてこの仕様は現状アプリ固有ですが、将来的にはWebに展開されることもあるかもしれず、その場合には再構築される可能性もあります。

したがって、現行のバナーに対するビジネス要求、エンジニアリングリソースの投資対効果と捨てやすさを考えると、コードベースの実装を維持しつつも人間の手間を極力省くようなやり方が現状最適であると判断しました。

Spreadsheet駆動でのコード生成

バナーの表示ロジックを整理したうえで、次の課題は「定義をどう管理・更新するか」でした。 先程述べたように、極力管理・運用の責務は企画サイドに寄せ、エンジニアからは更新作業と責務を剥がすことが必要です。 コード上で管理する限り、どうしてもエンジニアから管理責務を剥がすことができません。

そこでGoogle Spreadsheetを使ってバナーの要件を管理しつつ、シートからコードを出力できるようにしました。

Spreadsheetでのバナー管理設計

まず以下のような列定義で掲載するバナー要件を記載します。

バナー掲載要件定義シート

  • 名称/ID
  • 画像URL
  • 掲載期間(日時)
  • 表示条件(OS/ログイン)
  • 押下時の動作(検索/URL)
  • (シートの記載順序がそのまま掲載順序に)

これにより、企画側はSpreadsheet上でバナーの並び順や表示条件を一覧で確認・編集できるようになりました。

掲載シミュレーションシート

また、特定の条件におけるバナー表示をシミュレートするシートも別途用意し、エンジニア抜きで検証できるようにしました。

セル内でのコード生成

そしてシートに記載された内容をもとに、コードとなる文字列を結合してKotlinのコードを出力するシートを作ります。

コード生成シート

若干力技ではあるものの、型定義は固定でありIF関数を組み合わせるだけなので、式自体は比較的簡単に組むことができます。 各行=バナーごとの定数がセルに出力できたら、最後にすべてのバナー定義を結合したセルに落とし込みます。 こうすると、セル1つをコピーすればコード全体が手に入る仕組みになっています。

細かい点ですが、定義側のシート並び替えたときにコード側のシートが参照する先を維持されると並び順が反映されず困るため、importrange関数を入れたシートを挟むことで参照を切っています。

Slack WF + Devinによる自動適用

Slack WFとDevinを使った現在のバナー更新フロー

Slack WFの全体フロー

Spreadsheetでバナー定義のコードを生成できるようになりましたが、これをエンジニアが手動コピーしてPull Requestを作成するのでは自動化としては不十分です。 そこで今度はSlack WFとDevinに着目しました。

RettyのSlackにはDevinが常駐するチャンネルがあり、Slack WFからもこのチャンネルへメッセージを投げることができます。 そこでSlack WFが前述のSpreadsheetから結合したコードを取得し、Devinに変更を依頼するようにしてみました。

こうすることでボタンクリックだけでSlack WFがDevinへ変更を指示し、企画側主導でバナー更新を完結できる仕組みとしたのです。

バナー掲載依頼~コード生成のSlack WF

Slack WFは全体で以下のような流れになっています。

  1. 企画: Spreadsheet上で掲載条件を入力。シート上の並びがそのまま掲載順序になる
  2. 企画: Slack上でワークフローを実行(ボタンクリック)。内容のレビュー依頼が飛ぶ
  3. 企画(リーダー): 掲載内容や順序に問題がなければボタンをクリック。エンジニアにコード変更依頼が飛ぶ
  4. エンジニア: 掲載内容に不足・不備がないかを確認しボタンをクリック
  5. Slack WF: Spreadsheetから結合済みコードの入ったセルを参照。取得したコードとプロンプトで、Slackチャンネル経由でDevinへ作業依頼
  6. Devin: コードを生成しPull Requestを作成
  7. エンジニア: コードをレビューしマージ。CIにより自動的にステージング環境へデプロイされる。デプロイしたらボタンを押して通知
  8. 企画: ステージング環境で掲載状態を確認。問題がなければボタンでエンジニアに通知
  9. エンジニア: GitHub Releaseを作成して本番デプロイし作業完了

このフローにより、企画側が主体的にバナー管理を行い、エンジニアは最終的な確認とデプロイのみを担当する形になりました。

Devinへの指示内容

Slack WFからは、以下のような定型文とコードでプロンプトをDevinに送信します。

https://github.com/RettyInc/.../Banners.kt

@Devin
上記のファイルで、対応する部分を以下のコードで書き換えたPull Requestを生成してください。
ただし、セミコロン ; を改行に置換してください。
またそのうえで、なるべく1行が120文字を超えないよう、適度に改行を入れてください。
Pull Requestのreviewerには @app_team をアサインし、タイトルと本文は日本語で記述してください。

(ここにシートから取得したコードを挿入)

もちろん、Devinが作成したPull Requestは、定型であってもエンジニアがレビューし、Spreadsheetの内容と比較して問題ないことを確認してからマージしています。 最後の承認は人間が行うので「実質的」な全自動化であり、またSpreadsheet自体のメンテナンス責務はエンジニア側で持つことになる点には注意が必要ですが、それでも以前と比べるとほとんどノータッチで更新ができるようになりました。

AIワークフロー導入の効果

リードタイムの劇的な短縮

従来は依頼から本番反映まで最短でも数日、長ければ1週間近くかかっていたバナー更新が、この仕組みの導入により最短30分程度で完了するようになりました。

項目 BEFORE AFTER
リードタイム 数日〜1週間 30分〜数時間
企画側作業 Issue作成・エンジニアへの確認 (半日~1日+ミス多) Spreadsheet編集・ボタンクリック(数分)
エンジニア側作業 コード修正・レビュー・デプロイ (1〜2時間) レビュー・デプロイ (10〜15分)

特に急ぎのキャンペーンや、掲載内容の微調整が必要な場合でも、企画側が主体的に対応し、かつ機動的に変更できるようになったことは大きな成果でした。

また、使っているSlack WF自体は非エンジニアでも編集できるため、仕組みさえ知っていれば企画側で掲載までの手順が変更がしやすくなった点も導入効果の一部と言えるでしょう。

並び順の可視化と管理のしやすさ

Spreadsheet上でバナーの並び順や表示条件が一覧で確認できるようになったことで、「今どのバナーが掲載されているのか」「新しいバナーをどこに入れるべきか」が直感的にわかるようになりました。 従来行っていたエンジニアとのコミュニケーションコストやミスもなくなり、将来の時点での掲載状態もシミュレーションシートで楽にできるようになりました。

また「期間によって掲載順序を変えたい」という場合も、同一のバナーを期間に応じて複数行で定義することで表現できるようになったほか、Spreadsheetのタイムライン機能を使って、視覚的に掲載状態を知ることもできています。

タイムラインによる可視化

エンジニア作業の簡素化とリードタイム向上

既に述べたように、エンジニアの作業はAIの作成したコードのレビューとSpreadsheet自体の管理のみになり、マージ・デプロイまでのリードタイムが大幅に向上しました。 またレビューの内容自体も、従来のようなロジックの正しさの確認ではなく、定義されたデータの不備を確認する程度の簡素なものになったため、負荷が大きく軽減されました。

さらに、従来は複雑なIF分岐ロジックとそれに対応する大量のテストコードがありましたが、変更後はフィルタリングロジックに対するテストコードのみで良くなり、掲載内容そのものに対するテストはなくなりました。 こうして安全・簡単・高速なデプロイが可能となったわけです。

所感

今回個人的に最も新鮮だったのは「Slack WFからAIを呼び出せる」という点でした。

これまでSlack WFは、主に人間どうしのコミュニケーションを定型化したり、Spreadsheetからのデータ取得をするのには使っていましたが、Devinのような開発AIと組み合わせたのは初めてでした。 Difyなどに代表されるような高度なAIワークフローではないものの、定型的なコードと業務フローに落とし込めれば、自動化できる業務の幅が増えることを実感しました。

この発見は、今後の業務効率化の可能性を広げるものだと感じていて、今回のアプローチがバナー管理以外にも応用できる可能性があります。 ハードコードで変更が必要な定型処理や専用のシステムを組むほどの余裕がないような領域でも、Spreadsheet + Slack WF + Devinという組み合わせが使える可能性があります。

課題・改善点

ここまでは成果を述べてきましたが、このフローには課題や改善の余地もあります。

データ・コードの構造変化に弱い

現状ではSpreadsheetの関数を複数繋いでコード生成しているため、この関数自体のメンテナンス性が高いとは言えない点は課題です。

具体的には、今後バナーの掲載条件が変化した場合にはコードの変更が発生するため、当然Spreadsheet側の修正も必要になってきます。 例えば以下のような変更が将来的に発生するかもしれません。

  1. 掲載期間の表現パターンの拡張: 現状は開始と終了の日時、またはその中での開始・終了時刻という周期で表現できていますが、他の周期パターン(例: 火曜日だけ表示)に対応する必要が出てきた場合。
  2. バナー押下時の動作パターンの追加: 現状はURLを開く、または特定条件での検索という動作に限定されていますが、新たな動作パターンを追加する場合。

Slackでの文字数制限とプロンプトの改善

現時点では、Slackに投稿できるメッセージの文字数は4,000文字と規定されています。 加えて、現在は生のコードをそのままプロンプトに送っていてやや非効率です。

現在は文字数に収まっているので問題はありませんが、上限に達した場合はプロンプトを改善して短くするなどの対応が必要になります。 また生のコードではない短いプロンプトやDevinのKnowledgeに代替できれば、前述のデータ構造への変化にも柔軟に対応ができるようになりそうです。

まとめ

今回は、Rettyアプリのホームバナー更新を、Slack WF + Devinを活用して自動化した事例を紹介しました。

  • IF分岐での表出ロジックを、フィルタリングを軸にしたロジックにリファクタリング
  • Spreadsheetによるバナー管理で企画側に管理・運用責務を移譲し、状態をわかりやすく可視化
  • コードをテンプレートで生成し、Slack WF経由でDevinへ修正を依頼することでエンジニアの責務が大幅に削減
  • リリースまでのリードタイムが1週間から最短30分に短縮

AIワークフローを実現するSaaSや他の有料ツールもありますが、Slack WFとDevinを使ったフローの作成事例として何かしらの参考になれば幸いです。