TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

965

こんにちは、技術戦略部CTOブロックの塩崎です。 当社ZOZOには1人あたり月額200ドルの基準のもと、Claude CodeやGemini CLIをはじめとした各種AI開発ツールを利用可能にする制度を2025年7月にスタートさせました。 corp.zozo.com 現在ではこの制度を用いて数百名という非常に多くの社員がClaude Codeを利用しています。このような中で組織全体のAI活用を推進するためには、それぞれの社員や部署のClaude Codeの利用状況をモニタリングすることが重要です。そのためにClaude CodeのOpenTelemetry機能を利用して、全社員のClaude Code利用状況を収集したので、本記事ではその手法を紹介します。 ccusageを使った利用情報の収集の課題 Claude CodeのOTel機能の紹介 作ったものの全体像紹介 利用情報を送信する部分 利用情報を受け取る部分 利用情報を分析する部分 利用情報の活用事例 まとめ ccusageを使った利用情報の収集の課題 Claude Codeの利用情報を収集する方法と言いますと、まずccusageを思い浮かべる人が多いかと思います。 ccusage.com 当社でも最初はこのccusageを利用しようとしましたが、課題に遭遇しました。まず利用者にccusageを実行してもらうという点が課題でした。ccusageはコマンド一発で利用状況を出力でき、プログラムから扱いやすい構造化されたJSON出力もサポートしています。そういう意味で非常に便利なツールではあるものの、数百名の社員から漏れなくccusageの出力結果を回収しようとすると手間がかかります。さらにこの作業は1回だけ実施すればOKというものではなく、継続的なモニタリングのためには都度ccusageを回収する必要もあります。 実際に全社員からccusageを集めるということを1回実施してみましたが、これを定期的に実施することは運用負荷が高いという結論になりました。数名から十数名の組織であれば定期的なccusageの収集が十分現実的に実施できるかもしれませんが、ZOZOの規模感では厳しい結果になりました。 Claude CodeのOTel機能の紹介 ccusageの代わりに注目した機能が、Claude CodeのOpenTelemetry出力機能です。 code.claude.com LLM APIのコールやユーザーのプロンプト入力などのイベントを設定したエンドポイントに対してOpenTelemetry仕様で送信する機能です。なお、入力したプロンプトは、プライバシーを考慮して文字数のみを取得して本文は取得していません。 この機能を用いてClaude Codeの利用情報を収集すれば、前述した課題が解決できると考えました。以降では収集するための仕組みを解説します。 作ったものの全体像紹介 まずは構築した仕組みの概要を紹介します。 Claude Codeから送信された利用情報はGoogle Cloudで動作しているCloud Runに送られ、最終的にBigQueryに格納されます。上の図からも分かるように利用情報を送信する部分・受け取る部分・分析する部分という3つのコンポーネントからなっているため、順番に解説していきます。 利用情報を送信する部分 まずは、利用情報を送信する部分を解説します。 各自の環境で動いているClaude CodeにOpenTelemetryの設定を入れています。全社員に対して設定を入れるように依頼をしたとしても、どうしても漏れが生じてしまうため、そのような依頼ベースの手法に頼らず、ファイルを配布することを考えます。ZOZOはMDMツールとしてIntuneを利用しているため、Intuneの仕組みを使って以下のパスにJSONファイルを配置しました。 Windows: C:\Program Files\ClaudeCode\managed-settings.json macOS: /Library/Application Support/ClaudeCode/managed-settings.json この場所に配置したJSON設定ファイルはManaged settingsと呼ばれ、優先順位が最も高い設定ファイルとして認識されます。 code.claude.com そのため、以下のような内容のファイルを配布し、全社員のClaude CodeにOpenTelemetryの設定を追加しています。基本的には公式ドキュメントの通りの設定なので詳細な解説は省略しますが、Resource Attributeだけは少々工夫をしました。AWS Bedrockをモデルプロバイダーとして利用している時に利用者のメールアドレスが取得できなかったため、Resource Attributeにメールアドレスを入れるような設定を追加しています。また、OpenTelemetry情報を受け取るサーバーに認証を設定しているため、そのための認証トークンも埋め込んでいます。 { " env ": { " CLAUDE_CODE_ENABLE_TELEMETRY ": " 1 ", " OTEL_METRICS_EXPORTER ": " otlp ", " OTEL_LOGS_EXPORTER ": " otlp ", " OTEL_EXPORTER_OTLP_PROTOCOL ": " http/protobuf ", " OTEL_EXPORTER_OTLP_ENDPOINT ": " https://<OpenTelemetry エンドポイント> ", " OTEL_EXPORTER_OTLP_HEADERS ": " Authorization=Bearer <認証トークン> ", " OTEL_RESOURCE_ATTRIBUTES ": " user.email=<会社メールアドレス> ", " OTEL_METRICS_INCLUDE_VERSION ": " true " } } 利用情報を受け取る部分 次にOpenTelemetry情報を受け取る部分を説明します。Cloud Runの周りのアーキテクチャ図をより詳細に書くとこのようになります。 図からGoogle Cloudをメインにした構成であることが分かります。ZOZOは分析基盤としてBigQueryを活用しており、最終的にBigQueryに情報を格納すると便利なため、Google Cloudをメインとしています。AWSやSnowflakeなどに分析基盤を持っている方は、それらの中にClaude Codeの利用情報も入れると既存のアセットをうまく活用できます。AWSの上で似たような仕組みを構築する場合は、以下のドキュメントなどが参考になるかと思います。 github.com Claude Codeから送信されたOpenTelemetry情報はCloud Load Balancingで受け取ってからCloud Runに転送しています。Cloud Runで直接受け取る構成にもできますが、独自ドメインの対応やCloud Armorとの統合などを考慮してCloud Load Balancingを挟む構成にしています。この部分のTerraformのコードを以下に貼ります。 resource "google_dns_record_set" "otel_collector" { name = "<Domain of OTel Collector>" type = "A" ttl = 300 managed_zone = google_dns_managed_zone.coding_ai.name rrdatas = [ google_compute_global_address.otel_collector.address ] } resource "google_compute_global_address" "otel_collector" { name = "otel-collector-ip" } resource "google_compute_global_forwarding_rule" "otel_collector" { name = "otel-collector-forwarding-rule" target = google_compute_target_https_proxy.otel_collector.id port_range = "443" ip_address = google_compute_global_address.otel_collector.id load_balancing_scheme = "EXTERNAL_MANAGED" } resource "google_compute_managed_ssl_certificate" "otel_collector" { name = "otel-collector-cert" managed { domains = [ "<Domain of Otel Collector>" ] } } resource "google_compute_ssl_policy" "otel_collector" { name = "otel-collector-ssl-policy" profile = "MODERN" min_tls_version = "TLS_1_2" } resource "google_compute_target_https_proxy" "otel_collector" { name = "otel-collector-https-proxy" url_map = google_compute_url_map.otel_collector.id ssl_certificates = [ google_compute_managed_ssl_certificate.otel_collector.id ] ssl_policy = google_compute_ssl_policy.otel_collector.id } resource "google_compute_url_map" "otel_collector" { name = "otel-collector-url-map" default_service = google_compute_backend_service.otel_collector.id } resource "google_compute_backend_service" "otel_collector" { name = "otel-collector-backend" protocol = "HTTPS" load_balancing_scheme = "EXTERNAL_MANAGED" backend { group = google_compute_region_network_endpoint_group.otel_collector.id } log_config { enable = true sample_rate = 1 . 0 } } resource "google_compute_region_network_endpoint_group" "otel_collector" { name = "otel-collector-neg" region = "asia-northeast1" network_endpoint_type = "SERVERLESS" cloud_run { service = google_cloud_run_v2_service.otel_collector.name } } resource "google_artifact_registry_repository" "otel_collector" { location = "asia-northeast1" repository_id = "otel-collector" description = "OpenTelemetry Collector images" format = "DOCKER" } resource "google_secret_manager_secret" "otel_auth_token" { secret_id = "otel-collector-auth-token" replication { auto {} } } resource "google_secret_manager_secret_iam_member" "otel_collector_secret_accessor" { secret_id = google_secret_manager_secret.otel_auth_token.secret_id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:$ { google_service_account.otel_collector.email } " } resource "google_cloud_run_v2_service" "otel_collector" { name = "otel-collector" location = "asia-northeast1" ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" invoker_iam_disabled = true template { scaling { min_instance_count = 1 max_instance_count = 10 } service_account = google_service_account.otel_collector.email containers { image = "$ { google_artifact_registry_repository.otel_collector.location } -docker.pkg.dev/$ { local.project_id } /$ { google_artifact_registry_repository.otel_collector.repository_id } /otel-collector:latest" ports { container_port = 4318 } resources { limits = { cpu = "1" memory = "1Gi" } } env { name = "GCP_PROJECT_ID" value = local.project_id } env { name = "OTEL_AUTH_TOKEN" value_source { secret_key_ref { secret = google_secret_manager_secret.otel_auth_token.secret_id version = "latest" } } } } timeout = "300s" } traffic { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" percent = 100 } lifecycle { ignore_changes = [ scaling ] } depends_on = [ google_secret_manager_secret_iam_member.otel_collector_secret_accessor ] } Cloud Runの中にはOSSのOpenTelemetry Collectorが動いています。 github.com 以下のような設定で動いており、受け取った情報をCloud LoggingとCloud Metricsに転送していることが分かります。 extensions : bearertokenauth : token : ${env:OTEL_AUTH_TOKEN} receivers : otlp : protocols : http : endpoint : 0.0.0.0:${env:PORT} auth : authenticator : bearertokenauth processors : batch : timeout : 10s send_batch_size : 1024 transform : error_mode : ignore log_statements : - context : log statements : - 'set(body, {"message": body}) where IsString(body)' - 'merge_maps(attributes, resource.attributes, "upsert")' - 'merge_maps(body, attributes, "upsert")' exporters : googlecloud : project : ${env:GCP_PROJECT_ID} metric : prefix : "custom.googleapis.com/claude_code" log : default_log_name : "claude-code-telemetry" service : extensions : [ bearertokenauth ] pipelines : metrics : receivers : [ otlp ] processors : [ batch ] exporters : [ googlecloud ] logs : receivers : [ otlp ] processors : [ batch, transform ] exporters : [ googlecloud ] traces : receivers : [ otlp ] processors : [ batch ] exporters : [ googlecloud ] telemetry : logs : level : info YAMLには基本的な設定しか書いていませんが、transformの部分がやや特殊なので解説をします。Claude Codeが送信するログに含まれるResource AttributeをそのままCloud Loggingに送信したところ、その情報がCloud Loggingに保存されませんでした。そのため、Resource Attributeの情報を全て抜き出してLog Attributeにコピーしています。 また、Cloud Loggingの標準的なログの保持期限は30日ですので、保持期限を伸ばしています。 _Default ログバケットの保持期限を伸ばすと影響範囲が大きいため、Claude Code用のログバケットを新規に作成し、そちらに流れるようにLog Routerを設定しています。該当箇所のTerraformコードを以下に示します。 resource "google_logging_project_bucket_config" "claude_code_logs" { project = local.project_id location = "global" bucket_id = "claude_code_logs" retention_days = 3650 enable_analytics = true } resource "google_logging_project_sink" "claude_code_logs" { project = local.project_id name = "claude-code-logs-sink" destination = "logging.googleapis.com/projects/$ { local.project_id } /locations/global/buckets/$ { google_logging_project_bucket_config.claude_code_logs.bucket_id } " filter = "logName=\"projects/$ { local.project_id } /logs/claude-code-telemetry\"" unique_writer_identity = true } 利用情報を分析する部分 最後はCloud Loggingに格納されているClaude Codeの利用情報をBigQueryから参照できるようにする部分を解説します。 ここ数年でCloud LoggingとBigQueryはかなり高度に統合されています。特に以下の機能を使うとCloud Loggingに保存されたデータに対して直接BigQueryからクエリを実行できます。Cloud Loggingの中身はBigQueryそのものかと思えるほど統合されています。 cloud.google.com そのため、Cloud Loggingに情報を入れることとBigQueryに情報を入れることはほぼ等しくなっています。以下のようにLinked Datasetを作成すれば2つの世界がシームレスにつながり、BigQueryからのクエリを実行できます。 resource "google_logging_linked_dataset" "claude_code_logs" { bucket = google_logging_project_bucket_config.claude_code_logs.id link_id = "claude_code_logs_bq_link" description = "Linked dataset for querying Claude Code logs from BigQuery" } Claude Codeの利用情報は以下のようにJSON形式で半構造化されたデータが json_payload フィールドに格納されています。 ここに対していちいちJSONパースをするのは手間なので、パース後のVIEWをイベントに応じて作成しています。 SELECT -- Standard attributes JSON_VALUE(json_payload, ' $."session.id" ' ) AS session_id, CAST (JSON_VALUE(json_payload, ' $."event.sequence" ' ) AS INT64) AS event_sequence, JSON_VALUE(json_payload, ' $."service.name" ' ) AS service_name, JSON_VALUE(json_payload, ' $."service.version" ' ) AS service_version, JSON_VALUE(json_payload, ' $."app.version" ' ) AS app_version, JSON_VALUE(json_payload, ' $."organization.id" ' ) AS organization_id, JSON_VALUE(json_payload, ' $."user.account_uuid" ' ) AS user_account_uuid, JSON_VALUE(json_payload, ' $."user.id" ' ) AS user_id, JSON_VALUE(json_payload, ' $."user.email" ' ) AS user_email, JSON_VALUE(json_payload, ' $."host.arch" ' ) AS host_arch, JSON_VALUE(json_payload, ' $."os.type" ' ) AS os_type, JSON_VALUE(json_payload, ' $."os.version" ' ) AS os_version, JSON_VALUE(json_payload, ' $."terminal.type" ' ) AS terminal_type, -- Attributes JSON_VALUE(json_payload, ' $."event.name" ' ) AS event_name, TIMESTAMP (JSON_VALUE(json_payload, ' $."event.timestamp" ' )) AS event_timestamp, JSON_VALUE(json_payload, ' $.model ' ) AS model, CAST (JSON_VALUE(json_payload, ' $.cost_usd ' ) AS FLOAT64) AS cost_usd, CAST (JSON_VALUE(json_payload, ' $.duration_ms ' ) AS INT64) AS duration_ms, CAST (JSON_VALUE(json_payload, ' $.input_tokens ' ) AS INT64) AS input_tokens, CAST (JSON_VALUE(json_payload, ' $.output_tokens ' ) AS INT64) AS output_tokens, CAST (JSON_VALUE(json_payload, ' $.cache_read_tokens ' ) AS INT64) AS cache_read_tokens, CAST (JSON_VALUE(json_payload, ' $.cache_creation_tokens ' ) AS INT64) AS cache_creation_tokens FROM <Cloud LoggingとLinkされたデータセット> WHERE JSON_VALUE(json_payload, ' $."event.name" ' ) = ' api_request ' ZOZOのBigQueryは以下の仕組みでkintoneの情報をリアルタイムで取得できるようにしてあります。そのため、kintoneに格納されている組織図情報などとも組み合わせて、どの組織がClaude Codeをよく利用しているのかを分析できます。 techblog.zozo.com 利用情報の活用事例 OpenTelemetry機能を使って収集した利用情報の活用事例を1つ紹介します。 Claude Codeを利用するための課金体系はいくつかあります。Pro / Max / Teamプランのような費用が固定されるものもあれば、Anthropic API / AWS Bedrockなどのような従量課金のものもあります。Claude Codeの利用量が少ない人には、前者の方法はコストパフォーマンスが悪いため、後者の従量課金制の仕組みに移行してもらっています。この移行のために、 api_request イベントの cost_usd フィールドを集計して、各自に最も適したプランをアナウンスしています。 SELECT DATE (event_timestamp, " Asia/Tokyo " ) AS DATE , user_email, SUM (cost_usd) AS cost_usd, COUNT (*) AS api_call_count, FROM <APIリクエストログのVIEW> GROUP BY ALL まとめ Claude Codeの利用状況をOpenTelemetryで収集する仕組みを紹介しました。組織のAI活用を推進するためにはClaude CodeなどのAIツールの利用状況を集計・分析することが肝心です。同じような課題に直面している人の助けになると嬉しいです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.table-of-contents > li > ul { display: none; } はじめに こんにちは、データサイエンス部コーディネートサイエンスブロックの清水です。私たちのチームでは、WEARへ投稿されているコーディネート画像からVLM(Vision Language Model)で特徴を自動抽出するシステムを開発・運用しています。 プロンプト設計から推論パイプラインの構築、大規模推論まで、VLM・LLMを本番環境で活用する中、いくつかの運用課題に直面しました。本記事では、LLMOpsの全体像を整理した上で、観測基盤としてLangfuseを導入し、原因特定と改善の事例を紹介します。 目次 はじめに 目次 1. 直面した運用課題 モニタリングの不足 プロンプトとパラメーターの管理が分散 コスト管理の不透明さ 生成AIモデルのライフサイクルへの追従 2. LLMOpsの全体像とLangfuseの導入 LLMOpsとは Langfuseの選定理由 3. Langfuseの機能紹介 Tracing — モニタリングの不足を解決 Prompt Management — プロンプト管理の分散を解決 Cost Tracking — コスト管理の不透明さを解決 Tags・Session — モデルライフサイクルへの追従を支援 4. トレースによるエラー調査と改善事例 ダッシュボードによる問題の発見 ケース1:503エラー(APIの接続失敗) ケース2:Langfuseプロンプト取得のレイテンシー増加 ケース3:無限文字列の繰り返し出力 改善の全体的な効果 まとめ おわりに 1. 直面した運用課題 私たちは、小規模なデータを用いた実験や検証を経て、VLM・LLMの本番運用フェーズに移行しました。その中で、以下の4つの課題が浮かび上がりました。 モニタリングの不足 API呼び出し時のエラーや構造化出力のJSONパースエラーなど、想定されるエラーの監視が実行時のロギングのみに留まっていました。ログの粒度を細かく設定することで対処していましたが、推論対象のデータ数が増加するにつれ運用上の限界が顕在化し、生成AIの処理を体系的に記録・監視する仕組みの整備が求められていました。 プロンプトとパラメーターの管理が分散 運用中の特徴抽出プロンプトは10個を超えており、今後も増加が見込まれます。当時はプロンプトをExcel、パラメーター・configをGitHubで管理しており、バージョン管理が分散していました。プロンプト更新時にはGitHub側のパラメーター設定との整合性を都度確認する必要があり、一元的に管理する仕組みが整っていませんでした。 コスト管理の不透明さ APIの利用コストは請求画面上の合算値や日次の概算でしか把握できず、コスト急増時に原因となるリクエストや処理を特定することが困難でした。生成AIのモデルは世代ごとに料金体系が変動するため、日次推論の運用を見据えると、原因を追跡可能なコスト監視体制の構築が不可欠でした。 生成AIモデルのライフサイクルへの追従 生成AIモデルはライフサイクルが短く、迅速な更新サイクルへの追従が求められます。例えば私たちが利用しているGeminiでは、Stableモデルのリリースから概ね半年〜1年程度で提供終了を迎えるペースです 1 。モデル更新時には、データセットを用いた更新前後の精度比較やレイテンシーへの影響評価が不可欠です。 2. LLMOpsの全体像とLangfuseの導入 LLMOpsとは LLMOpsとは、大規模言語モデルの開発・運用・改善を体系的に管理するための一連のプラクティスです。従来のMLOpsがモデルの学習・デプロイ・監視を対象としているのに対し、LLMOpsではLLM特有の運用課題をカバーします。具体的には、プロンプトエンジニアリングやモデルの選択と更新、入出力のトラッキング、コスト管理などが含まれます。 IBM 2 、NVIDIA 3 、Databricks 4 、Dify 5 など各社のLLMOpsに関するドキュメントを調査しました。LLMOpsの全体像はDesign(設計)・Development(開発)・Operation(運用)の3フェーズに分類しました。特にDevelopmentフェーズではプロンプト管理や入出力のトレーシングと評価が重要です。Operationフェーズではエラー監視やコストトラッキングが中心的なプラクティスとして位置づけられています。 セクション1で挙げた4つの課題は、いずれもこのDevelopmentとOperationの領域に該当します。そこで、トレーシング・プロンプト管理・コスト監視を備えたLLMOpsツールを導入する方針としました。 Langfuseの選定理由 今回は観測基盤としてLangfuse 6 を採用しました。選定にあたってはLangSmith 7 やDify 8 を含む複数のツールを候補とし、以下の3軸で比較評価した結果、最も適していると判断しました。 セルフホスティングの可否 :社内のインフラ要件として、GCP上に自前でホスティングできることが重要でした。Langfuseはオープンソースで、この要件に最も合致しました。 既存の技術スタックとの統合のしやすさ :LangfuseはPython SDKを提供しています。私たちが利用しているVertex AI・LangChainなど主要フレームワークとの互換性もあり、既存のコードベースに自然に統合できました。 必要な機能の充足度 :Langfuseはトレーシング、プロンプト管理、コスト監視をワンストップで提供しており、マルチモーダル(画像入力のトレース)にも対応していることが決め手になりました。 3. Langfuseの機能紹介 ここからは、実際にLangfuseを導入した上で活用している主要な機能を、セクション1の課題との対応とあわせて紹介します 9 。 Tracing — モニタリングの不足を解決 Langfuseのトレーシングは、1回のリクエスト処理全体を Trace として記録し、その中の個々の処理ステップを Observation としてネストする階層構造をとります 10 。 上記の画像は、私たちの特徴抽出における実際のTrace画面です。左側のObservationツリーでは、1回の推論リクエスト全体が langfuse_gemini_request_with_retry というTraceとして記録されています。その配下に以下のObservationがネストされています。 fetch_langfuse_prompt (Span)— Langfuseからプロンプトを取得 append_feedback (Span)— フィードバック情報を付与 request_to_gemini (Generation, 8.36s, 4,986→302トークン、$0.000929)— Gemini APIの呼び出し validate_gemini_response (Span)— レスポンスの検証 parse_gemini_result (Span)— 結果のパース Observationには処理の期間を記録する Span と、LLM呼び出し特有の情報を記録する Generation の2種類があります。3番目の request_to_gemini がGenerationに該当し、実行時間・トークン数・コストといったLLM固有の情報が自動的に記録されます。右側のパネルでは入出力やメタデータも一覧表示され、1画面でリクエストの全容を把握できます。 従来のロギングでは個別のAPIコールしか追えず、エラー発生時にログを手動で突き合わせる必要がありました。Traceとして構造化することで、セクション1の「モニタリングの不足」を直接的に解決しました。導入も @observe デコレータを関数に付与するだけで済み、既存コードへの変更は最小限です。 Prompt Management — プロンプト管理の分散を解決 LangfuseのPrompt Management 11 は、プロンプトのバージョン管理・デプロイをLangfuse上で完結させる仕組みです。私たちが抱えていた「プロンプトはExcel、パラメーターはGitHub」という分散管理の課題に対して、以下の機能が直接的な解決策となりました。 バージョン管理とラベル 12 :プロンプトを更新するたびにバージョンが自動で作成され、変更履歴がイミュータブルに保持されます。各バージョンには production ・ staging などのラベルを付与でき、SDKからラベル指定で取得可能です。Diff表示機能もあり、バージョン間の差分をハイライトで確認できます。 Config 13 :プロンプトにモデル名・temperature・top_pなどのパラメーターを付与し、プロンプトと一緒にバージョン管理できます。コードの変更・再デプロイなしに、UI上でプロンプトとパラメーターをまとめて更新できるようになり、分散管理の解消に最も効いた機能です。 Traceとのリンク 14 :プロンプトをTraceに紐付けることで、どのバージョンがどの出力を生成したかを追跡できます。バージョンごとのレイテンシーやコストを比較でき、プロンプト改善の効果を定量的に測定可能です。 これにより、Excelとコードに分散していたプロンプトとパラメーターがLangfuse上に一元化されました。「どのバージョンが本番で動いているか」「何を変えたか」「変更の効果はどうか」を1つのツールで把握できます。 Cost Tracking — コスト管理の不透明さを解決 ダッシュボード 15 でモデルごとのコストやトークン数を時系列で可視化でき、運用時にコスト推移を一目で監視できます。セクション1で挙げた「コスト管理の不透明さ」について、従来は請求画面で合算値しか確認できませんでした。Langfuseの導入によりTrace単位・Generation単位で分解でき、異常なトークン消費の検知も容易になりました。 Tags・Session — モデルライフサイクルへの追従を支援 Langfuseでは、TraceにTags 16 やSession 17 といった属性を付与し、目的に応じてトレースデータを整理・フィルタリングできます。Tagsは任意の文字列をTraceやObservationに複数付与でき、アプリバージョン・LLM手法・実験IDなどの軸でUIやAPIからフィルタリング・グルーピングが可能です。Sessionは複数のTraceを1つのまとまりとしてグルーピングする仕組みで、 session_id を指定するだけで関連するTraceがセッション単位で集約されます。 私たちの運用では、評価実験やモデル更新のたびにTagsでTraceをグルーピングし、バージョン間の精度・レイテンシー・コストを比較しています。これにより、モデルのライフサイクルが短い環境でも、更新前後の品質を定量的に検証した上で移行でき、精度を担保した運用が可能になりました。 4. トレースによるエラー調査と改善事例 Langfuseを導入したことで、本番運用時に感じていた課題を解決できました。その中でも最も効果を実感したのはエラー調査と改善のフェーズです。ダッシュボードから問題を発見し、原因特定から改善まで行った実例を紹介します。 ダッシュボードによる問題の発見 日次での推論実行において、「早く実行が終わる日もあれば、非常に時間がかかる日もある」という現象が発生していました。実行ログからは、それぞれの推論対象となる入力データ数が大きく異なっていないことが事前に分かっていました。まずは原因を調査するためにLangfuseのダッシュボードを確認しました。 ダッシュボードで実行が完了したTrace数の推移を確認すると、最初は短時間で多くのAPIコールが成功するものの、その後に推論完了数が大幅に減少するパターンが確認できました。下図はその時に観測されたものです。 TraceやObservationを詳細に分析することで、以下の3つのケースを特定しました。 ケース1:503エラー(APIの接続失敗) 事象 :Geminiへの初回のAPIコールが503エラーで失敗し、その後に複数回の503エラーが起こった後にようやく成功するパターンが多発していました。 対策 :503エラーはAPI接続時のエラーであることから、API接続設定を調査しました。Vertex AIのPython SDKにはデフォルトで指数関数的バックオフ(Exponential Backoff)を利用したリトライ機構が備わっています 18 。私たちはこの仕組みを活かしつつも、システム全体が長時間ブロックされるのを防ぐため、リトライの上限回数(例:3回)やタイムアウト設定をクライアント側で適切にチューニングしました。結果として、一時的なエラーを許容しつつ、実行時のレイテンシー増加をコントロールできるようになりました。 ケース2:Langfuseプロンプト取得のレイテンシー増加 事象 :一部のTraceで、数時間〜最大10時間も処理がブロックされているケースが発生していました。Traceの実行時間を確認したところ、API呼び出しそのものではなく、Langfuseからのプロンプト取得処理のSpanに異常な時間がかかっていることが特定できました。 対策 :原因を調査した結果、プロンプト取得処理がリトライループの中に組み込まれていたことが判明しました。加えて、ネットワーク通信のタイムアウトが適切に設定されておらず、一時的な通信障害時に長時間プロセスがハングしていました。対策として、プロンプトの取得を最初の1回のみとし、オンメモリで保持するよう初期化処理を最適化しました。さらに、通信時のタイムアウト値を明示的に設定したことで、レイテンシーの異常な増加を根絶できました。 ケース3:無限文字列の繰り返し出力 Geminiの出力で特定の文字列が延々と繰り返され、構造化出力を想定していたJSONのパース処理で失敗してリトライが頻発しました。Trace Detail画面で出力内容がそのまま記録されていたため、無限に繰り返される文字列パターンを直接確認できました。あるTraceでは入力9,616トークンに対して出力64,999トークンという異常なトークン消費も記録されていました。 対策 :temperatureが0の場合、出力は決定的であるため、同じ入力に対してリトライしても同一の異常出力が再現されるだけで意味がありません。根本的な原因は特定の画像データとプロンプトの組み合わせにあると考えられます。しかし、膨大なコーディネート画像すべてのエッジケースを網羅する完璧なプロンプトの追求は困難です。そこで、エラー発生ごとにtemperatureを+0.1ずつインクリメントする実装を導入しました。temperatureを上げることで出力にランダム性が加わり、リトライ時に異なる出力が生成されるため、無限繰り返しから抜け出せる可能性が高まります。また max_tokens を明示的に指定し、万が一再発した場合でも異常な出力トークン数を制限できるようにしました。 改善の全体的な効果 それぞれ対策した結果、Traceのグラフも安定し、推論のスループットが一定で保たれるようになりました。Langfuse導入以前はVertex AIのログを手動で調査する必要があり、問題の全体像を把握するのに多大な時間を要していました。導入後は以下のような改善を実感しています。 エラー調査時間の短縮 :Trace単位で調査が完結するようになり、Trace一覧からエラーが起きていたAPI呼び出しが一目瞭然になった 入出力の精緻な監視 :各プロンプトの入力・出力・トークン数・コストを精緻に調査でき、異常検知が容易になった リトライ戦略の最適化 :リトライ回数や各リトライの出力がObservationとして記録され、定量的なデータに基づく改善が可能になった チーム内のコミュニケーション改善 :TraceのURLを共有するだけで、エンジニア間のエラー議論が具体的なデータに基づいて行えるようになった まとめ 本記事では、LLMの本番運用で直面した課題と、LangfuseによるLLMOps基盤の構築、トレースを活用したエラー調査と改善の事例を紹介しました。 Geminiのモデルライフサイクルに見られるように、Stableモデルのリリースから半年〜1年程度でRetirementを迎えるケースもあります。LLM特有の運用課題に対応するためには可観測性(Observability)の基盤を整えることが重要です。Langfuseは、トレーシング・プロンプト管理・コスト監視を統合的に提供するオープンソースのLLMOpsツールとして私たちの開発環境にフィットしました。特に、Traceの構造的な記録によってエラーの特定から対策実施までのサイクルを大幅に短縮できたことが最大の成果です。 今後は、Langfuseカスタムダッシュボードの活用、評価用データセットの構築とモデル更新時の自動評価パイプラインとの連携などに取り組んでいきます。さらなるLLMの安定した運用に活かしていきたいと考えております。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com Model versions and lifecycle ↩ LLMOpsとは ↩ LLM の手法をマスターする: LLMOps ↩ LLMOps ↩ What is Ops in LLMOps? ↩ Langfuse Documentation ↩ LangSmith Documentation ↩ Dify Documentation ↩ Why Langfuse? ↩ Tracing Overview - Langfuse ↩ Prompt Management Overview - Langfuse ↩ Prompt Versioning - Langfuse ↩ Prompt Config - Langfuse ↩ Link Prompts to Traces - Langfuse ↩ Model Usage & Cost Tracking - Langfuse ↩ Tags - Langfuse ↩ Sessions - Langfuse ↩ Vertex AI Generative AI inference API errors ↩
アバター
はじめに こんにちは、WEAR開発部 バックエンドブロックのaao4seyです。普段は WEAR というプロダクトのバックエンド開発を担当しています。WEARバックエンドシステムでは2025年夏頃からパフォーマンス課題が顕在化し、SLOの悪化や運用負荷の増大といった問題に直面しました。本記事ではこれらの課題に対し、チームとしてどのように改善サイクルを構築し継続的に取り組んできたかをご紹介します。 目次 はじめに 目次 WEARバックエンドシステムが抱えていたパフォーマンス課題 DB負荷上昇の要因 SLOへの影響 課題解決に向けたアプローチ 継続的な現状確認と課題の洗い出し SLO定例(バックエンドブロック全員 / 隔週) パフォーマンス定点観測(SRE + バックエンドブロック 各数名 / 隔週) 2つの定例の関係性 改善サイクルを加速する仕組み Database Monitoringの活用 パフォーマンス改善に特化したAgent Skills 取り組みの成果 定量的な改善 チームの意識変化 今後の展望 まとめ WEARバックエンドシステムが抱えていたパフォーマンス課題 WEARバックエンドシステムには大きく2種類のアクセスがあります。1つはWEARアプリやWebからのユーザーリクエスト(コーディネート検索など)です。もう1つは ZOZOTOWN や FAANS といった自社の他サービスや、自社EC連携企業などの外部システムからのAPIアクセスです。 2025年7月頃からRailsサーバのレスポンス悪化やAPIアクセスのエラー数増加が目立つようになり、監視アラートの発報頻度が増えてきました。また、定期バッチの失敗も以前より増える傾向にありました。 これらの問題に対処すべく調査したところ、リクエストを処理するDBのCPU使用率が徐々に上昇し始めていることがパフォーマンス悪化やエラーの原因の多くであることがわかりました。 DB負荷上昇の要因 調査した結果、シンプルにAPIリクエスト数が増加しつつあることがわかりました。WEARはtoCサービスに加え前述の通り自社の他サービスや外部システムへAPIを提供しています。ユーザーの行動によるリクエスト数の変化に加え、システム間連携のAPIのリクエスト数も増加していることがわかりました。また、この時期にリリースした機能にもパフォーマンスを悪化させる要因が含まれていそうであることもわかりました。 SLOへの影響 WEARではSLOを「最低限」と「理想」の2段階で設定し、7日・30日・90日の各期間でレイテンシを定期的に監視しています。DB負荷の上昇に伴い、最低限の目標値こそ達成できていたものの、理想値は明らかに悪化していました。 「最低限」と「理想」ともに、全リクエストの99%以上が目標レイテンシ内に収まることをしきい値として設定していますが、「理想」のSLOは7日間平均で80%前後まで落ち込むこともありました。 負荷が上がることでAPIのレスポンスタイムの悪化に加えて、バッチ処理の失敗といった悪い影響も出始めました。また、Sentryのアラートも増加する傾向にあり、対応に追われている状況でした。 これらからDBの負荷の軽減が急務となりました。 課題解決に向けたアプローチ 継続的な現状確認と課題の洗い出し パフォーマンス課題に継続的に取り組むために、現状を定期的に把握することが必要と感じ、まずはシステムの課題を抽出する時間を設けることにしました。2025年秋から2つの定例会を隔週で運営しています。 SLO定例(バックエンドブロック全員 / 隔週) SLO定例はバックエンドブロック全員が参加する場で、SLOの達成状況の共有と改善タスクの進捗確認・成果報告を目的としています。実はこの定例は以前から存在していたのですが、パフォーマンスが悪化し始めた時期の前後でさまざまな事情により開催が途絶えていました。状況の悪化を受けて再開した形です。 この定例には主に3つの役割があります。 役割 内容 チーム全体での課題感の共有 SLOダッシュボードを全員で確認し、どのAPIのレイテンシがどの程度悪化しているのかを目線合わせする 改善の知見共有 インデックス追加、クエリの書き換え、実行計画の制御など、各メンバーが取り組んだ改善の解法を発表しチーム内に知見を蓄積する Sentryエラーのトリアージ しきい値を超えたエラーについて対応方針を決め、担当者をアサインする 各回の事前準備として、担当者がDatadogのパフォーマンス定点観測ダッシュボードのスクリーンショットを取得し、Sentryのエラーを確認します。Sentryでは7日間で設定したしきい値以上発生しているエラーをピックアップし、GitHub Issuesに起票して優先的に対処する運用としています。 パフォーマンス定点観測(SRE + バックエンドブロック 各数名 / 隔週) パフォーマンス定点観測はSREチームとバックエンドブロックの合同で実施している定例です。SLO定例がチーム全体の状況共有に重きを置いているのに対し、こちらはDB周りの技術的な深掘りを行う場として機能しています。「DBのCPU負荷が高騰する前の2025年8月の状態に戻す」ことを目標に掲げています。 この定例には主に3つの役割があります。 役割 内容 DB周りのシステム状況の共有 SREがDatadog上のDB負荷やクエリパフォーマンスの直近の状況を共有する ストアドプロシージャ等の改善計画 DB上で動いている業務ロジックのパフォーマンス改善方針を議論し、バックエンドブロックでアサイン可能な状態にする クエリチューニングの相談 バックエンドブロック単独では解決困難なSQL Server特有の問題について、SREの知見を借りて解決策を検討する 各回では表に挙げた情報の共有に加え、具体的な改善方針を議論します。また、徐々に目先の課題だけでなく中長期的な方針について意見を出し合う場としても機能し始めています。 2つの定例の関係性 2つの定例は独立して運営しているわけではなく、相互に連携しています。 SLO定例はバックエンドブロックが主体となり、実際のコード変更を伴う改善を推進する場です。一方、パフォーマンス定点観測はSREと連携してシステムの詳細な状況を把握する場です。SLO定例で対処が難しい課題はパフォーマンス定点観測に持ち込み、SREの知見を借りて解決策を検討します。逆に、パフォーマンス定点観測で得られたシステム状況の知見はSLO定例にフィードバックされ、改善の優先度判断に活用されます。 例えば、クエリチューニングの方法として複数の選択肢がある場合、パフォーマンス定点観測で共有されたDBのリソース状況を踏まえて、どちらがより効果的かを判断できます。 改善サイクルを加速する仕組み 個々の改善をスピーディに進めるために以下のような仕組みを活用しています。 Database Monitoring の活用 パフォーマンス改善の起点となるのは「どのクエリが遅いのか」の特定です。WEARではDatadogの Database Monitoring (以下、DBM)を活用しています。DBMはSREチームが以前から導入してくれていたものですが、今回のパフォーマンス改善の取り組みをきっかけに、バックエンドブロックでもより積極的に利用するようになりました。 DBMを活用すると、遅いエンドポイントの発見から原因クエリの特定、実行計画の確認まで、ほとんどの場合Datadog上で完結します。具体的には以下の流れで調査を進められます。 APMで遅いエンドポイントを特定する そのエンドポイントから発行されているクエリの一覧をDBMで確認する 問題のクエリの実行計画をDBM上で直接確認する 特に有用なのは、実行計画の確認が容易な点です。DBMでは実行計画を常に取得できるわけではありませんが、取得できた場合にはインデックス追加やHINT句の付与といった改善の後、実行計画が想定通り変化したかをすぐに検証できます。SQL Serverは統計情報の更新タイミング次第で実行計画が不安定になることがあります。DBMで継続的に観測し、そうした変動も素早く検知できるようになりました。 パフォーマンス改善に特化したAgent Skills クエリチューニングの作業をさらに効率化するために、SQL Serverの実行計画の分析に特化したAgent Skillsを作成し、チーム内で共有しています。 WEARバックエンドブロックではAgent Skillsを共有するリポジトリを運用しています。その中にパフォーマンス改善向けのSkillsを追加しました。このSkillsは、実行計画のXMLやSentry IssueのURLを入力として受け取ります。MCP経由でSentryの情報も取得しながらタイムアウト箇所やボトルネックを特定し、インデックスの追加などの改善策を提案します。 SQL Serverの実行計画の読み解きには専門的な知識が求められます。Agent Skillsを活用することでチームメンバーの経験レベルに関わらず一定の品質で分析を進められるような環境作りに取り組んでいます。 取り組みの成果 まだ取り組みを始めたばかりであり道半ばではあるのですが、これらの取り組みを始めて徐々に成果が出始めています。 定量的な改善 一番根本的な課題となっていたDBのCPU使用率は、取り組みを始めてから少しずつ緩和される傾向にあります。リソースの使用率は外部環境にも依存するため、すべてが取り組みの成果とは言い切れません。しかし、少なくとも改善の兆しが見えつつある状況です。 また、SLOラベルが付与されたパフォーマンス改善PRの件数にも変化が現れています。定例再開前の2025年1月〜10月は月平均1.2件だったのに対し、再開後の2025年11月〜2026年2月は月平均6.0件と、約5倍に増加しました。 チームの意識変化 定量的な改善だけでなく、チーム全体の意識にも以下のような変化が出始めています。 早期検知 :定例でダッシュボードを定期的に確認する習慣が根付き、レイテンシ悪化やエラー増加に早い段階で気づけるようになった 影響把握の迅速化 :機能リリース後のパフォーマンス悪化を定例サイクルの中で早期に検知でき、原因特定から修正までのリードタイムが短縮された 知見の蓄積 :SLO定例での発表を通じてインデックス設計やHINT句の使い方、実行計画の読み方といった知見がチーム全体で共有されるようになった 今後の展望 現在の改善サイクルは順調に機能していますが、さらなる効率化に向けて以下のような取り組みも進めていきたいと考えています。 1つ目は、SentryやDatadogの通知を起点とした改善の自動化です。現在は定例で検知した課題をトリアージし、GitHub Issuesに起票しています。将来的にはこのプロセスを自動化し、エラーの検知から調査、改善PRの作成までをLLMで効率化したいと考えています。極力人手を介さず課題の解決にたどり着ける状態を目指します。 2つ目は、コンテキスト情報の自動収集です。クエリチューニングを行う際には、実行計画やテーブル定義、インデックス情報など多くのコンテキストが必要になります。これらの情報をLLMが自動で収集・整理できる環境を整備することで、改善の初動をさらに早めたいと考えています。例えば本記事内で紹介したDBM上のクエリの実行計画などにAI Agentが直接アクセスできるようにすることで、より素早く精度の良い結果を得られるのではと考えています。 まとめ 本記事では、WEARバックエンドシステムにおけるパフォーマンス課題と、その解決に向けたバックエンドブロックの取り組みを紹介しました。 パフォーマンス改善は一度やって終わりではなく、サービスの成長とともに継続的に取り組むべきテーマです。本記事が同様の課題に取り組むチームの参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.table-of-contents > li > ul { display: none; } こんにちは、MA部配信基盤ブロックの田島です。ZOZOTOWNではユーザへのコミュニケーション手段の1つとしてアプリへのPush通知を活用しており、配信にはFirebase Cloud Messaging(以降、FCM)を利用しています。 FCMではPush通知の送信先となるデバイスごとに「FCMトークン」と呼ばれる一意の識別子が発行され、このトークンを宛先としてFCMにリクエストを行うことで、特定のデバイスにPush通知が届きます。 FCMでは無効なトークンに対して UNREGISTERED エラーを返します。Firebaseの公式ドキュメントでは、このエラーが返されたトークンを無効として扱うことが推奨されています。しかし、我々の調査により、 一度 UNREGISTERED エラーを受けたトークンがその後復活し、再び有効になるケース の存在を確認しました。復活したトークンで配信すると正常にPush通知が届きクリックイベントも取得できることから、確実に有効なトークンであることを確認しています。 本記事では、このトークン復活の実態調査と、FCMの validate_only APIを活用したエラートークン管理の精緻化について紹介します。 目次 目次 背景と課題 FCMトークンとは 既存のエラートークン管理の問題 エラートークン復活の調査 調査内容 調査方法 調査結果 トークン復活に関する補足 方針の検討 方針1: 一定期間UNREGISTEREDが続いたトークンをエラー扱い 方針2: 即時エラー登録 + validate_onlyで定期解除 決定した方針 FCM validate_only フラグを利用したトークンの検証 validate_only フラグ 動作検証 エラートークンの収集と検証バッチの実装 テーブル設計 エラートークンテーブル(error_fcm_tokens) 再有効化テーブル(reactivated_fcm_tokens) エラートークンの収集・再検証ワークフロー 1. エラートークンの収集 2. 検証用一時テーブルの作成 3. エラートークンの再検証(並列処理) 検証対象トークンの取得 シャード単位の検証処理 FCM APIによるトークン検証 4. エラートークンテーブルの更新 パフォーマンス 既存の全トークンの再検証と本番リリース 初回実行:全期間のエラートークンを検証 通常運用の開始 まとめ 最後に 背景と課題 FCMトークンとは 最初にも紹介しましたが、FCMトークンとは、FCMがPush通知の送信先を識別するために、アプリがインストールされた各デバイスに対して発行する一意の識別子です。アプリの初回起動時にFCM SDKがこのトークンを生成し、このトークンを指定してFCMにリクエストを行うことで、特定のデバイスにPush通知が届きます。 配信フローとしては、サーバからFCMにメッセージリクエストが送られます。FCMはプラットフォーム固有の転送層(Androidの場合はATL、iOSの場合はAPNs)を経由して対象デバイスにメッセージを届けます。 FCMトークンは永続的でなく、以下のような理由で無効化や更新が発生します。 トークンがリフレッシュされた場合 トークンの保持期間を超過した場合 アプリがアンインストールされた場合 無効になったトークンを使ってFCMにリクエストを行うと、 UNREGISTERED エラーが返されます。 firebase.google.com 既存のエラートークン管理の問題 Firebaseの公式ドキュメントでは、 UNREGISTERED エラーが返されたトークンを無効として扱うことがベストプラクティスとして紹介されています。 firebase.google.com こちらに則り、 UNREGISTERED エラーが返されたトークンを無効として記録し、以降の配信対象から除外していました。しかし、 UNREGISTERED エラーを受けたトークンがその後再び有効になるケースの存在を確認しました。この場合、本来配信すべきユーザにPush通知が届かなくなってしまいます。 まずはユーザへの配信が確実にできることを優先し、エラートークンの登録処理を一時的に停止した上で、復活の頻度や傾向を正確に把握するための調査を実施しました。 エラートークン復活の調査 調査内容 エラートークンの管理方針を決めるにあたり、以下の点を調査しました。 SUCCESS → UNREGISTERED → SUCCESS が発生する頻度 UNREGISTERED が何回連続した後 SUCCESS へ復帰するケースがあるか UNREGISTERED がどれくらいの期間続いた後 SUCCESS へ復帰するケースがあるか SUCCESS に復帰後、どれくらいの回数成功が続くか 調査方法 約2.5か月分(2025年8月以降)の配信ログを対象に、同一トークンにおけるステータス遷移を分析しました。 分析に使用した push_logs テーブルは、Push通知の配信結果を1リクエストごとに記録したログテーブルです。主なカラムは以下の通りです。 カラム名 型 説明 token STRING 配信先のFCMトークン delivered_at TIMESTAMP 配信日時 status STRING 配信結果( SUCCESS , FAILED ) status_detail STRING 失敗時の詳細( UNREGISTERED など) fcm_message_id STRING FCMが発行したメッセージID 以下のクエリで、 SUCCESS と SUCCESS の間に UNREGISTERED が挟まるケースを抽出しています。 WITH base AS ( SELECT token, delivered_at, status, status_detail, fcm_message_id FROM `project.dataset.push_logs` WHERE TIMESTAMP_TRUNC(delivered_at, DAY) >= TIMESTAMP ( " 2025-08-01 " ) AND TIMESTAMP_TRUNC(delivered_at, DAY) <= TIMESTAMP ( " 2025-10-15 " ) AND token IS NOT NULL AND status IN ( ' SUCCESS ' , ' FAILED ' ) ), -- トークンごとに時系列でインデックスを付与 ordered AS ( SELECT token, delivered_at, status, status_detail, fcm_message_id, ROW_NUMBER() OVER (PARTITION BY token ORDER BY delivered_at, fcm_message_id) AS rn FROM base ), -- 累積のUNREGISTERED失敗数などを付与 ord AS ( SELECT o.*, SUM ( CASE WHEN o.status = ' FAILED ' AND o.status_detail = ' UNREGISTERED ' THEN 1 ELSE 0 END ) OVER (PARTITION BY o.token ORDER BY o.rn ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cum_unreg_failed, MIN ( IF (o.status != ' SUCCESS ' , o.rn, NULL )) OVER (PARTITION BY o.token ORDER BY o.rn ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS next_non_success_rn, COUNT (*) OVER (PARTITION BY o.token) AS total_rows FROM ordered o ), -- SUCCESS行から直前のSUCCESSとの関係を取得 success_pairs AS ( SELECT s.token, s.rn AS success_rn, s.delivered_at AS success_at, LAG(s.rn) OVER (PARTITION BY s.token ORDER BY s.rn) AS prev_success_rn, LAG(s.delivered_at) OVER (PARTITION BY s.token ORDER BY s.rn) AS prev_success_at FROM ord s WHERE s.status = ' SUCCESS ' ), -- 直前SUCCESS〜今回SUCCESSの間にあるUNREGISTERED失敗件数を算出 final AS ( SELECT sp.token, sp.prev_success_at, sp.success_at AS recover_success_at, (oc.cum_unreg_failed - COALESCE (op.cum_unreg_failed, 0 )) AS unreg_failed_between_successes, ( COALESCE (oc.next_non_success_rn, oc.total_rows + 1 ) - oc.rn) AS consecutive_success_count_after FROM success_pairs sp JOIN ord oc ON oc.token = sp.token AND oc.rn = sp.success_rn LEFT JOIN ord op ON op.token = sp.token AND op.rn = sp.prev_success_rn WHERE sp.prev_success_rn IS NOT NULL ) SELECT * FROM final WHERE unreg_failed_between_successes > 0 ORDER BY recover_success_at; 調査結果 項目 結果 SUCCESS → UNREGISTERED → SUCCESS の発生頻度 2.5か月で約230件 UNREGISTERED の最大の連続回数 約80回 UNREGISTERED が続く最大期間 約14日 SUCCESS 復帰後の成功回数 ケースにより異なる この結果から、 UNREGISTERED の返されたトークンが復活するケースは確かに存在することがわかりました。また、 UNREGISTERED の連続する回数・期間も把握できました。 トークン復活に関する補足 FCMの公式ドキュメントでは、 UNREGISTERED エラーが返されたトークンについて「it will never again be valid(二度と有効にはならない)」と明記されています。そのため、即座に削除することが推奨されています。 firebase.google.com ただし、FCMのエラーコードに関するドキュメントでは「This usually means that the token used is no longer valid and a new one must be used.(通常、使用されたトークンはもはや有効ではなく、新しいトークンを使用する必要があることを意味します)」という表現になっており、「usually」という留保がついています。 firebase.google.com 実際に復活したトークンを使って配信すると正常にPush通知が届き、クリックイベントも取得できることを確認しています。トークンがリフレッシュされて新しいものが発行されたわけでもなく、同一のトークンがそのまま再び有効になっていました。公式ドキュメントの記述と実際の挙動に乖離がある状況です。 なお、この挙動は2026年3月時点でも確認されています。将来的にFCM側で修正される可能性もあるため、最新の挙動については各自で検証されることをお勧めします。 方針の検討 調査により、トークンの復活は2.5か月で約230件と少数ながら確実に発生しており、最長で約14日間 UNREGISTERED が続いた後に復活するケースも確認されました。 この結果を踏まえると、エラートークンの管理には以下の2点を両立させる必要があります。 無効なトークンへの無駄な配信を早期に止めること 復活する可能性のあるトークンを誤って永久に除外しないこと これらを考慮し、以下の2つの方針を検討しました。 方針1: 一定期間UNREGISTEREDが続いたトークンをエラー扱い 1つめの方針は、1か月ずっと UNREGISTERED となっているトークンをエラー扱いにする方式です。調査結果から14日以上 UNREGISTERED が続くケースはなかったため、1か月の閾値で安全にエラートークンを判定できます。これにより、本当に無効となったトークンのみをエラートークンとして保持できます。 方針2: 即時エラー登録 + validate_onlyで定期解除 従来通り UNREGISTERED になったトークンをエラー扱いとしつつ、定期的に validate_only でトークンの有効性を再検証し、復活したトークンをエラーリストから除外する方式です。 validate_only については後ほど説明します。これにより、無効と判定したトークンを即時無効にしつつ、復活したトークンに対しても配信を継続できます。 決定した方針 両方針を比較した結果、以下の理由から 方針2 を採用しました。 方針1だと、一度 UNREGISTERED となったトークンが復活しない場合、1か月の間無効なトークンに配信し続けてしまう 初回の validate_only 検証を既存の全エラートークンに実施することで、これまでに蓄積したエラートークンを有効活用できる 既存のエラートークン登録フローを大きく変更する必要がない FCM validate_only フラグを利用したトークンの検証 validate_only フラグ FCMの messages.send API(FCMにPush通知送信を依頼するAPI)には validate_only フラグがあります。これを true に設定すると、実際にメッセージを配信せずにトークンの有効性のみを検証できます。 動作検証 validate_only (Firebase Admin SDKでは dry_run パラメータに対応)が本当に配信しないことを事前に検証しました。 dry_run=True の場合、レスポンスの message_id が fake_message_id となり、実際のメッセージ配信は行われません。これにより、安全にトークンの有効性を確認できることが実証されました。 dry_run の詳細な検証については、以下の記事にまとめています。 qiita.com エラートークンの収集と検証バッチの実装 ここからは、エラートークンの収集と検証の方法について紹介します。 テーブル設計 本施策では主に2つのテーブルを使用します。 エラートークンテーブル( error_fcm_tokens ) UNREGISTERED エラーが返されたトークンを記録するテーブルです。FCMトークンそのものをキーとして管理することで、トークンの有効性を直接的に判定できるようにしています。 カラム名 型 説明 fcm_token STRING エラーとなったFCMトークン first_errored_at TIMESTAMP 初めて UNREGISTERED エラーが発生した日時 registered_at TIMESTAMP エラートークンとして登録した日時 再有効化テーブル( reactivated_fcm_tokens ) 一度エラーとなったが、 validate_only による再検証で有効と判定されたトークンの履歴を記録するテーブルです。 カラム名 型 説明 fcm_token STRING 再有効化されたFCMトークン validated_at TIMESTAMP validate_only で検証した日時 reactivated_at TIMESTAMP エラートークンテーブルから削除し再有効化した日時 エラートークンの収集・再検証ワークフロー エラートークンの収集と再検証を日次で行うワークフロー( refresh_error_fcm_tokens )を作成しました。バッチ処理にはワークフローエンジンの Digdag を使用しています。Digdagのワークフロー定義は以下の通りです。Digdagでは + で始まるブロックがタスクを表し、上から順に実行されます。 timezone : Asia/Tokyo schedule : daily> : 00:00:00 # 毎日0時に実行 # ワークフロー全体で使う変数の定義 _export : # 検証結果を格納する一時テーブル名(実行日ごとに一意になるようにする) validated_fcm_tokens_temp_table_id : "project.temp.validated_fcm_tokens_temp_${moment(session_time).format('YYYYMMDD')}" # 並列処理のシャード数 total_shards : 50 # 1. 配信ログからUNREGISTEREDエラーのトークンを収集し、エラートークンテーブルに登録 +collect_fcm_error_tokens : py> : app.collect_fcm_error_tokens # 2. 検証結果を格納する一時テーブルを作成 +create_temp_table : py> : app.refresh_error_fcm_tokens.create_validation_temp_table # 3. エラートークンを50シャードに分割し、並列でFCM APIに検証リクエストを送信 # loop>: 0〜49のインデックス(${i})で繰り返し、_parallel: trueで全シャードを同時実行 +validate_fcm_tokens_parallel : _parallel : true loop> : ${total_shards} _do : +validate_shard : py> : app.refresh_error_fcm_tokens.validate_fcm_tokens_shard shard_index : ${i} total_shards : ${total_shards} # 4. 一時テーブルの検証結果をもとに、有効なトークンをエラートークンテーブルから削除し、 # 再有効化テーブル(reactivated_fcm_tokens)に記録 +update_error_and_reactivated_fcm_tokens : py> : app.refresh_error_fcm_tokens.update_error_and_reactivated_fcm_tokens 以下でそれぞれについて具体的に説明します。 1. エラートークンの収集 はじめに配信ログテーブルから UNREGISTERED エラーのトークンを以下のSQLで収集し、エラートークンテーブルに追加します。このワークフローは日次で実行されますが、対象期間を直近3日間としています。これは、ワークフローが2日連続で失敗した場合でも3日目の実行で未収集分をカバーできるようにするためです。 -- エラートークンの収集クエリ SELECT token AS fcm_token, MIN (delivered_at) AS first_errored_at, CURRENT_TIMESTAMP AS registered_at FROM `project.ma_batch.push_logs` AS push_logs LEFT OUTER JOIN `project.push.error_fcm_tokens` AS target ON push_logs.token = target.fcm_token WHERE status = " FAILED " AND status_detail = " UNREGISTERED " -- 日次実行だが、2日連続WF失敗時でも3日目に回復できるよう3日分のバッファを確保 AND DATE (delivered_at) >= DATE_ADD( CURRENT_DATE ( ' Asia/Tokyo ' ), INTERVAL -3 DAY) AND target.fcm_token IS NULL GROUP BY token 2. 検証用一時テーブルの作成 トークンの有効性の検証結果を格納するための一時テーブルを作成します。各シャードがFCM APIの検証結果をこのテーブルに書き込み、最後にまとめてエラートークンテーブルを更新します。 DROP TABLE IF EXISTS `{validated_fcm_tokens_temp_table_id}`; CREATE TABLE `{validated_fcm_tokens_temp_table_id}` ( fcm_token STRING NOT NULL , -- 検証対象のFCMトークン validated_at TIMESTAMP NOT NULL , -- 検証日時 valid BOOLEAN NOT NULL , -- 有効かどうか error_code STRING, -- 無効だった場合のエラーコード ); 3. エラートークンの再検証(並列処理) エラートークンテーブルに登録済みのトークンに対し、FCMの validate_only APIでトークンの有効性を再検証します。この処理は50シャードに分割して並列実行されます。 検証対象トークンの取得 各シャードが担当するトークンを取得するSQLは以下の通りです。 FARM_FINGERPRINT でトークンをハッシュ化し、シャード数で剰余を取ることで均等に分割しています。また、 first_errored_at が直近30日以内のトークンのみを対象とし、復活の見込みが低い古いトークンへの無駄な検証を避けています。この期間は、調査でわかった UNREGISTERED が続く最大期間の約14日に余裕をもたせて設定しています。 SELECT error_tokens.fcm_token, error_tokens.first_errored_at FROM `project.push.error_fcm_tokens` AS error_tokens LEFT JOIN `{validated_fcm_tokens_temp_table_id}` AS temp_tokens ON error_tokens.fcm_token = temp_tokens.fcm_token WHERE -- 直近30日以内に登録されたエラートークンのみを対象 error_tokens.first_errored_at >= TIMESTAMP_SUB( CURRENT_TIMESTAMP (), INTERVAL 30 DAY) AND -- FARM_FINGERPRINTでトークンをハッシュ化し、シャードに均等分割 MOD ( ABS (FARM_FINGERPRINT(error_tokens.fcm_token)), {total_shards}) = {shard_index} AND -- リトライ時に既に処理済みのトークンを除外 temp_tokens.fcm_token IS NULL シャード単位の検証処理 各シャードでは上記のSQLで取得したエラートークンに対し、FCMの dry_run ( validate_only に対応)でトークンの有効性を検証しています。検証対象のトークン数は数百万件に及ぶため、メモリ効率を考慮して5,000件ごとのバッチに分割して処理しています。検証結果は一時テーブルに書き込まれます。 def validate_fcm_tokens_shard (self, shard_index, total_shards, ...) -> None : # リトライ時に途中から再開できるよう、5,000件ずつ処理する BATCH_SIZE = 5000 # BigQueryからこのシャードが担当するエラートークンを取得 # (例: shard_index=0, total_shards=50 なら、全体の1/50を担当) result = self._bq_client.execute_bigquery_result( query_path= "get_error_fcm_tokens_shard.sql" , params={ "shard_index" : shard_index, "total_shards" : total_shards}, ) fcm_client = FCMClient(fcm_gcp_project) # 5,000件ずつFCM APIで検証し、結果を一時テーブルに書き込む for batch_tokens in self._create_batches(result, BATCH_SIZE): valid_tokens, invalid_tokens = fcm_client.validate_tokens_batch(batch_tokens) self._insert_validation_results(valid_tokens, invalid_tokens) FCM APIによるトークン検証 FCMトークンの実際の検証では、Firebase Admin SDKの messaging.send_each を dry_run=True で呼び出しています。実際にメッセージを配信せずにトークンの有効性のみを検証できます。 send_each は1リクエストあたり最大500件のため、500件単位で分割してリクエストを送信しています。 class FCMClient : BATCH_SIZE = 500 # send_eachの1リクエストあたりの最大件数 def validate_tokens_batch (self, tokens: List[ str ]) -> Tuple[List[ str ], List[Tuple[ str , str ]]]: valid_tokens = [] # 有効と判定されたトークンのリスト invalid_tokens = [] # 無効と判定されたトークンと、そのエラーコードのリスト # 500件ずつに分割してFCM APIにリクエスト for i in range ( 0 , len (tokens), self.BATCH_SIZE): batch = tokens[i:i + self.BATCH_SIZE] # 各トークンに対してダミーのメッセージオブジェクトを生成 messages = [ messaging.Message(token=token, data={ 'validation' : 'true' }) for token in batch ] # dry_run=True により実際の配信は行わず、トークンの有効性のみ検証 batch_response = messaging.send_each(messages, dry_run= True ) # レスポンスからトークンごとの有効/無効を判定 for idx, response in enumerate (batch_response.responses): token = batch[idx] if response.success: valid_tokens.append(token) else : error_code = response.exception.code if response.exception else "Unknown" invalid_tokens.append((token, error_code)) return valid_tokens, invalid_tokens 4. エラートークンテーブルの更新 全シャードの検証が完了した後、一時テーブルの結果をもとにエラートークンテーブルと再有効化テーブルをトランザクション内で一括更新します。有効と判定されたトークンを再有効化テーブルにMERGEし、エラートークンテーブルから削除しています。 BEGIN TRANSACTION; -- 一時テーブルから有効と判定されたトークンを重複排除して抽出 CREATE TEMP TABLE deduped_tokens AS SELECT DISTINCT fcm_token, MAX (validated_at) AS validated_at, CURRENT_TIMESTAMP () AS reactivated_at FROM `{validated_fcm_tokens_temp_table_id}` WHERE valid = TRUE GROUP BY fcm_token; -- 有効なトークンを再有効化テーブルに記録 MERGE `project.push.reactivated_fcm_tokens` AS target USING deduped_tokens AS source ON (target.fcm_token = source.fcm_token AND target.validated_at = source.validated_at) WHEN NOT MATCHED THEN INSERT (fcm_token, validated_at, reactivated_at) VALUES (source.fcm_token, source.validated_at, source.reactivated_at); -- 有効なトークンをエラートークンテーブルから削除 DELETE FROM `project.push.error_fcm_tokens` WHERE fcm_token IN ( SELECT DISTINCT fcm_token FROM `{validated_fcm_tokens_temp_table_id}` WHERE valid = TRUE ); COMMIT TRANSACTION; パフォーマンス 上記の処理がどれくらいの時間で完了するのか、パフォーマンス計測をした結果は以下の通りです。 対象件数 並列数 処理時間 10万件 1並列 約25分 約800万件(全量) 50並列 約50分 また、FCM APIのQuotaについても確認し、日中に実行しても問題ない余裕があることを確認しました。 既存の全トークンの再検証と本番リリース 初回実行:全期間のエラートークンを検証 初回実行では、過去に蓄積された全エラートークン(約754万件)を対象に検証しました。通常運用では直近30日以内のエラートークンのみを検証対象としていますが、初回は既存の全トークンの検証が必要でした。そのため、検証対象を取得するSQLの30日の条件を一時的にコメントアウトしてワークフローを実行しました。 WHERE -- 初回実行時は全期間のエラートークンを対象にするため一時的にコメントアウト -- error_tokens.first_errored_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY) AND MOD ( ABS (FARM_FINGERPRINT(error_tokens.fcm_token)), {total_shards}) = {shard_index} AND temp_tokens.fcm_token IS NULL 実行前のエラートークン数:約7,500,000件 エラートークン収集直後:約+70件(新規エラートークン) 再検証後:約ー170件(復活トークン) 約170件のトークンが validate_only で有効と判定され、エラートークンから解除されました。 通常運用の開始 初回実行後、1か月以内に登録されたエラートークンを対象とする通常運用を開始しました。 実行前のエラートークン数:約7,500,000件 エラートークン収集直後:約+6,500件(新規エラートークン) 再検証後:約ー10件(復活トークン) 日次で約10件のトークンが再有効化されていることが確認できました。 まとめ 本記事では、FCMエラートークンの管理を精緻化した取り組みについて紹介しました。 従来は UNREGISTERED エラーの返されたトークンを即時かつ永続的にエラー扱いとしていました。しかし調査の結果、一度無効になったトークンが復活するケースの存在を確認しました。そこでFCMの validate_only APIを活用した定期的な再検証の仕組みを導入し、復活したトークンを自動的にエラーリストから解除するようにしました。 この改善により、以下の効果が得られました。 無効トークンへの無駄な配信リクエストの削減によるコスト最適化 セグメントのボリューム把握の精度向上 トークン復活時の配信漏れ防止 FCMトークンの管理は、Push配信の品質とコストに直結する重要な要素です。同様の課題をお持ちの方の参考になれば幸いです。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.table-of-contents > li > ul > li > ul { display: none; } はじめに こんにちは、ZOZOTOWN開発本部 ZOZOTOWN開発1部 Android2ブロックの高橋です。普段はZOZOTOWNのAndroidアプリ開発を担当しています。 アプリ開発において、Google Analyticsなどのイベントトラッキング機能はプロダクトの改善のための重要な機能です。しかし、「正しいデータが送信されているか」だけでなく「正しいタイミングで送信されているか」の検証が難しいという課題もあります。ZOZOTOWNのAndroidアプリ開発においても課題となっていました。本記事では、この課題を解決するために開発したAndroid Studioプラグインと、その技術選定・設計についてご紹介します。 目次 はじめに 目次 背景・課題 1. 値の妥当性確認 2. 「どの操作で発火したか」の再現・確認 3. PRレビューでの説明 求められる解決策 解決アプローチ: Android Studioプラグインの開発 処理フロー 1. ログの取得とパース 2. イベントの保存とUI表示 3. 画面キャプチャの取得 4. ノードクリックによるDVR再生 イベントログの収集 イベントの統合・可視化 画面キャプチャ DVR:イベントとの同期 セッションのエクスポート・インポート PRレビューでの活用 実装について 効果 今後の展望 まとめ 背景・課題 イベントの実装時やPRレビュー時、以下のような作業に時間を取られていました。 1. 値の妥当性確認 これまでイベントのデバッグにはLogcatに出力されるログを使用していました。GAに関してはFirebase DebugViewでリアルタイムにイベントを確認できますが、GAと自社ログなど複数の送信先を横断的に確認できません。Logcatであれば送信先を問わず確認できますが、大量のログの中から目的のイベントログを見つけ出すには、タグやキーワードでフィルタリングしながら目視で探す必要がありました。GAや自社ログなど送信先ごとにフォーマットも異なるため、それぞれの形式を把握しておく必要もあります。 そのような環境で、必須パラメータの過不足や値の妥当性をテキストとして流れるログから目視で確認するのは手間がかかります。 2. 「どの操作で発火したか」の再現・確認 特に難しかったのが、イベント発火のタイミング確認です。 イベントが短期間で複数発生するケースでは、ログを見ただけでは「どの操作がどのイベントに対応するのか」を判別するのが困難でした。一定期間内に一度しか発火しないイベントであれば操作との対応は明らかですが、リスト画面でのスクロールや連続タップなど、似たイベントが立て続けに発火する場面では確認が難しくなります。 3. PRレビューでの説明 「このイベントは正しいタイミングで送信されていますか?」というレビュー指摘に対して、ログのテキストだけで説明するのは困難です。「画面Aから画面Bへ遷移したときに発火する」という仕様を操作と紐づけて証明する手段がありませんでした。 求められる解決策 これらの課題を解決するには、以下の要件を満たすツールが必要でした。 複数の送信先のイベントを統一的に可視化できる イベントの発火タイミングを実際の操作映像と紐づけて確認できる 確認した結果を他のメンバーに共有できる 開発フローに組み込みやすい(IDEとの統合) 解決アプローチ: Android Studioプラグインの開発 課題を解決するため、3つの機能を統合したAndroid Studioプラグインを開発しました。 機能 役割 イベントビューア ログをパースし、時系列のノード型UIで可視化 DVR(Digital Video Recorder)・アーカイブ再生 キャプチャ映像とイベントの同期記録・再生 セッションの共有 キャプチャしたセッションをファイルとして共有 イベントビューアは、Logcatから取得したログをパースし、イベントをノード型のUIで時系列に表示します。UIはFirebase DebugViewのイベント表示を参考にしています。GAや自社ログなど送信先が異なるイベントも統一的に可視化でき、各ノードにはイベント名と送信先が表示されます。ノードをクリックすると、画面右のパネルにイベントのパラメータが表示されます。 DVRおよびアーカイブ再生は、イベントビューアとキャプチャ映像を時刻ベースで結びつける機能です。ノードをクリックすると、そのイベントが発火する直前からの操作映像を再生できます。これにより「どの操作がどのイベントを発火させたか」を視覚的に検証できます。 イベント発火時のエフェクト 映像再生時のイベント到達エフェクト 新しいイベントが追加されると青白いグロー・アニメーションで視覚的に強調されるため、リアルタイムにどのイベントが発火したかを把握できます。また、映像の再生位置がイベントのタイムスタンプに到達すると、該当ノードが赤いグロー・アニメーションでハイライトされます。 キャプチャしたセッションはエクスポート・インポート可能になっており、他の開発者の環境でもセッションを視覚的に確認できます。 処理フロー プラグインの動作をイベント発火からDVR再生までの流れで説明します。 1. ログの取得とパース 端末と接続すると、ADB経由でLogcatストリームの取得を開始します。取得したログは、ログフォーマットに応じたパーサが自動選択・適用されます。 パースの結果、各ログ行はタイムスタンプ(デバイス時刻)やイベント名、パラメータなどを持つ構造化されたイベントオブジェクトに変換されます。 2. イベントの保存とUI表示 パースされたイベントはインメモリのイベントストアに蓄積されます。UIはリアクティブなデータフローを通じてイベント追加を検知し、画面を更新します。 3. 画面キャプチャの取得 ログ収集と並行して画面キャプチャが行われます。映像ストリームをデコードし、各フレームにタイムスタンプを付与しバッファに保存します。 4. ノードクリックによるDVR再生 ユーザーがタイムライン上のイベントノードをクリックすると、そのイベントのタイムスタンプを録画開始からの経過時間(相対時刻)に変換し、一定時間だけ巻き戻した位置から映像の再生を開始します。これにより、「どの操作がこのイベントを発火させたか」を視覚的に確認できます。 イベントログの収集 イベントログを収集する方法として、以下の選択肢を検討しました。 方式 概要 採用判断 adb reverse経由 アプリからホストへ直接送信 不採用 Logcat経由 ADBでLogcatストリームを取得 採用 adb reverse を使用すると、アプリから直接ホスト(開発マシン)にデータを送信できます。この方式であれば、Logcatのパースが不要になり、構造化されたデータをそのまま受け取れるメリットがあります。しかし、この方式には以下の課題がありました。 アプリ側の改修が必要:ログ送信用のHTTPクライアントやソケット通信の実装が必要 既存のログ出力との二重管理:Logcatへの出力と並行して別の送信処理を実装することになる 一方、Logcat経由の方式には以下の利点があります。 アプリ側の改修が不要:既存のログ出力をそのまま利用できる 既存資産の活用:すでにログ出力の仕組みが整っているアプリであれば、追加実装なしで利用可能 アプリ側を改修せずにプラグイン単体で動作させる設計方針を優先し、Logcat経由の方式を採用しました。 イベントの統合・可視化 複数の送信先のイベントを統一的に扱うため、パーサレジストリパターンを採用しました。各ログフォーマットに対応するパーサは、共通のインタフェースを実装します。 interface LogParser { val id: String val displayName: String fun canParse(rawLine: String ): Boolean fun parse(rawLine: String , deviceSerial: String ? = null ): LogEvent? } パーサレジストリはログを受け取ると登録されたパーサを順番にチェックし、最初にマッチしたパーサでパースを行います。 class ParserRegistry { private val parsers = mutableListOf<LogParser>() private var defaultParser: LogParser = DefaultLogcatParser() init { // 各フォーマット用パーサを登録(登録順にマッチングされる) register(GoogleAnalyticsLogParser()) register(InternalBusinessLogParser()) } fun register(parser: LogParser) { parsers.add(parser) } fun detectParser(rawLine: String ): LogParser { return parsers.firstOrNull { it.canParse(rawLine) } ?: defaultParser } } detectParser は登録されたパーサを順に試行し、最初にマッチしたパーサを返します。どのパーサにもマッチしない場合は defaultParser (標準のLogcat形式パーサ)にフォールバックする設計です。 この設計により、新しいログフォーマットへの対応はパーサクラスを1つ追加・登録するだけで完了します。 画面キャプチャ 端末の画面をキャプチャする方法として、以下の選択肢を検討しました。 方式 概要 採用判断 MediaProjection API Android APIによる画面キャプチャ 不採用 scrcpy USB/TCP経由のミラーリングツール 採用 MediaProjection API はAndroidアプリ内から画面をキャプチャできるAPIです。しかし、この方式を採用するとデバッグ対象のアプリ自体に手を加えるか、別途キャプチャ用アプリを用意する必要があります。 scrcpy はオープンソースのミラーリングツールで、以下の点で要件に合致しました。 リアルタイム性:ミラーリングと同程度の低遅延で画面を取得できる アプリ側の改修が不要:ADB経由で動作するため、デバッグ対象アプリへの変更が不要 組み込みやすさ:プロトコルが公開されており、プラグインへの統合が可能 上記の特性を考慮し、scrcpyを採用しました。 本プラグインではscrcpyのH.264ストリームを利用しています。受信したストリームをデコードしてRAW RGB形式のフレームを出力し、リアルタイムのミラーリング表示とDVR用の録画バッファの両方に供給しています。 DVR:イベントとの同期 本プラグインの核となる機能がDVRです。端末のキャプチャ映像とイベントを時刻ベースで同期し、後から任意の時点の映像を再生できます。 イベントと映像の同期を実現するうえで、2つの課題を解決する必要があります。 1つ目は、イベントと映像のタイムスタンプの基準を揃えることです。イベントのタイムスタンプはデバイスの絶対時刻で記録されますが、映像フレームのタイムスタンプはホスト側で録画開始からの経過時間(相対時刻)として付与されます。イベントのタイムスタンプも同じ相対時刻へ変換するために、録画開始時のデバイス時刻を記録しておき、イベントのタイムスタンプからこの値を引くことで録画開始からの経過時間を算出しています。 2つ目は、映像パイプラインの遅延です。scrcpyでキャプチャされたフレームは、デバイス上でのエンコード → ホストへの転送 → デコードという過程を経てホストに到達します。フレームのタイムスタンプはホスト受信時に付与するため、実際のキャプチャ時刻よりパイプライン遅延の分だけ遅れた値になります。この遅延を補正するために、録画開始から最初のフレームが到着するまでの時間をもとにパイプライン遅延を推定し、各フレームのタイムスタンプからその分を差し引いています。 これらの補正により、DVR再生時にイベントと映像のタイミングがある程度正確に一致するようになります。 セッションのエクスポート・インポート DVR機能により「操作映像とイベントの同期再生」が可能になりましたが、これだけではキャプチャした本人のマシンでしか確認できません。イベントトラッキング関連の不具合が発生した際に、その状況を他のメンバーに正確に伝えるには、キャプチャしたセッションそのものを共有できる仕組みが必要でした。そこで、イベントとキャプチャ映像をまとめてエクスポート・インポートできる機能を実装しました。 エクスポートされるファイルは .edb という拡張子を持つZIPアーカイブです。内部にはJSON形式のセッションメタデータとシリアライズされたイベント、映像フレーム群が格納されます。 セッションメタデータにはアーカイブ再生で必要な時刻同期パラメータ(録画開始時のデバイス時刻、映像パイプライン遅延の推定値など)を含めています。これにより、別のマシンでインポートした場合でも、元のキャプチャ環境と同じ精度でイベントと映像の同期再生が可能になります。 PRレビューでの活用 この機能により、以下のようなワークフローが可能になります。 開発者:イベント発火の再現手順を実行しながらキャプチャを行う 開発者:セッションを .edb ファイルとしてエクスポートし、PRやIssueに添付する レビュアー: .edb ファイルをインポートし、アーカイブ再生で操作映像とイベントの同期再生を確認する 従来は「ある操作をしたときに特定のイベントが発火しています」とテキストで説明するしかありませんでした。エクスポート・インポート機能により、レビュアーは自分の環境で操作映像を再生しながらイベントの発火タイミングを直接確認できます。端末やアプリの準備も不要なため、レビューの負荷も軽減されます。 実装について 本プラグインの実装は、その大部分をAIとの協業で進めました。開発者が利用可能な技術の候補を挙げ、そこから先の技術選定はAIとのディスカッションを通じて行いました。例えば画面キャプチャの方式では、候補となる技術をそれぞれ試作し、要件に合わないとわかれば別の方式に切り替えるというサイクルを短期間で回せました。設計面でもAIにアイデアを出してもらい、開発者が将来の機能追加などを考慮して判断するという進め方です。IntelliJ Platform SDKの使い方やscrcpyプロトコルの解析など、ドキュメントを読み込んで実装に落とし込む作業はAIが得意とする領域でした。 効果 イベントがノード型UIで可視化されたことで、Logcatに出力されるテキストを目で追う手間がなくなり、イベントの検証効率が向上しました。 また、DVR・アーカイブ再生機能により、これまで困難だった「イベント発火タイミングの検証」が可能になりました。具体的には以下のことが行えます。 イベントノードをクリックするだけで、発火時の操作映像を再生できる 「画面遷移時に発火」「ボタンタップ時に発火」といった仕様の検証が視覚的に行える セッションの共有機能により、操作映像を見せながら「この操作でこのイベントが発火しています」と説明できる 同種イベントが連続発火するケースでも、どの操作がどのイベントに対応するかを明確に示せるようになりました。 今後の展望 現状では「アプリがログを出力した」ことは確認できますが、「実際にサーバへ送信された」ことは確認できません。実際の通信ログも取り込めるようになれば、エンドツーエンドでの検証が可能になります。 まとめ 本記事では、イベントトラッキングのデバッグ課題を解決するために開発したAndroid Studioプラグインを紹介しました。本プラグインは社内利用を前提としているため現時点で一般公開の予定はありませんが、イベントトラッキングのデバッグに課題を感じている方の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.table-of-contents > li > ul > li > ul { display: none; } はじめに こんにちは。データシステム部・MA推薦ブロックの伊藤( @rabbit_x86 )です。私たちのチームでは、メール配信などのマーケティングオートメーション(MA)に関する推薦システムを開発・運用しています。 従来、ZOZOTOWNのMA施策における推薦システムでは、 開発リードタイムと推薦精度のトレードオフ が課題でした。この課題を解決するため、ユーザーとアイテムをベクトルで表現したEmbeddingとBigQuery Vector Searchを活用し、施策を横断して利用可能な 汎用推薦システム を開発しました。本システムにより、開発リードタイムを 約1/3 に短縮し、A/Bテストで 配信当たりのMA経由流入数・購入数の改善 を達成しました。 本記事では、このシステムの設計思想・アーキテクチャ・構築時の技術的な課題と工夫、そして実際の事例を紹介します。 目次 はじめに 目次 背景と課題 従来の推薦アプローチとそのトレードオフ 機械学習ベースの開発リードタイム ルールベースによる推薦の限界 システム要件 アプローチ 前提知識:EmbeddingとEmbedding基盤 汎用推薦システム 全体構成 1. セグメント抽出 2. Embedding取得 3. Vector Index作成 4. Vector Search 5. スコアブースト・フィルタリング 6. 評価・バリデーション 技術的な課題と工夫 Vector Indexの非同期構築への対処 Vector Searchのスロット消費問題 運用事例 開発リードタイムの短縮 推薦精度の向上 まとめ 今後の展望 最後に 背景と課題 従来の推薦アプローチとそのトレードオフ MA施策では、対象ユーザー(ユーザーセグメント)と対象アイテム(アイテムセグメント)を施策ごとに定義し、パーソナライズされた商品を提供しています。その推薦システムは、「ルールベース」と「機械学習ベース」の2つのアプローチで構築されています。ルールベースは閲覧したカテゴリの商品を推薦するなど、行動ログに基づくルールでスコアリングするアプローチです。機械学習ベースは行動ログを活用しつつ、モデルが学習した潜在的な嗜好をもとに推薦するアプローチです。 これらのアプローチには精度と開発リードタイムのトレードオフが存在し、ルールベースは 高速だが推薦精度が低く 、機械学習ベースは 精度が高い一方で開発リードタイムが長い という課題がありました。 ルールベース 機械学習ベース ロジック 閲覧履歴・お気に入りなどの行動ログに基づくヒューリスティクス 専用の推薦モデルが学習した潜在的な嗜好に基づくスコアリング 開発リードタイム 短い 長い 精度 低い 高い 機械学習ベースの開発リードタイム 機械学習ベースの推薦にはモデルを実装する必要があり、施策ごとに以下の一連の開発工程を繰り返します。 工程 所要期間 探索的データ分析 約2週間 モデルの設計・実装 約3週間 パイプラインの設計・実装 約2週間 実験・評価・チューニング 約3週間 この結果、 1施策あたり約10週間の開発リードタイム が必要となり、仮説検証のサイクルが遅くなっていました。 ルールベースによる推薦の限界 一方、ルールベースのロジックでは、閲覧履歴やお気に入りブランドなど、ユーザーの顕在的な嗜好に基づく推薦が中心です。たとえば、「ブランドAを閲覧したユーザーにブランドAの値下げ商品を推薦する」といったルールなどです。こうしたルールは設計が容易な反面、ユーザーが触れた商品のみを推薦し、ユーザーの潜在的な嗜好を考慮した推薦ができないという課題がありました。 システム要件 そこで、 高速なモデル構築と高い推薦精度を両立 する仕組みが必要でした。 要件をまとめると以下のとおりです。 要件 詳細 高速な推薦システム構築 推薦システムを短期間で構築できること 高い推薦精度 ユーザーの潜在的な嗜好を捉えた推薦ができること アプローチ 上記の要件を満たすため、社内のEmbedding基盤とBigQuery Vector Searchを活用した汎用推薦システムを開発しました。 前提知識:EmbeddingとEmbedding基盤 Embeddingとは、データを固定長のベクトルとして表現する手法です。社内のEmbedding基盤では、ユーザーの行動履歴をもとにTwo-Towerモデルを使い、ユーザーとアイテムの類似度が意味を持つように共通の次元数の埋め込み空間へそれぞれエンコードします。ベクトル間のコサイン類似度を計算することで、ユーザーの潜在的な嗜好に近いアイテムを特定できます。 Embedding基盤については、推薦基盤ブロックで執筆した以下の記事で詳しく紹介しています。 techblog.zozo.com 汎用推薦システム 本システムは1つのモデルで複数の施策に対応できる汎用的な仕組みです。セグメントを定義してEmbeddingを抽出し、BigQuery Vector Searchで類似度を計算することで、パーソナライズされた推薦結果を生成します。従来必要だった 特徴量作成やモデル学習が不要 になるため、開発リードタイムを短縮できます。 さらに、Embeddingを利用することで、ルールベースでは捉えられなかったユーザーの潜在的な嗜好を反映した 高い推薦精度 を実現します。施策の目的に応じて関連スコアの調整やフィルタリングなどの後処理も適用でき、細かなチューニングにも対応できます。 全体構成 本システムは、社内のMLパイプライン基盤であるVertex AI Pipelinesで実行されます。 パイプラインの主要ステップを以下の表にまとめます。 ステップ 処理内容 実行環境 1. セグメント抽出 ユーザーセグメント・アイテムセグメントをSQLで抽出 BigQuery 2. Embedding取得 セグメントに対応するEmbeddingをEmbedding基盤から取得 BigQuery 3. Vector Index作成 アイテムEmbeddingにTREE_AHインデックスを作成し、完了まで待機 BigQuery 4. Vector Search ユーザーEmbedding × アイテムEmbeddingの関連スコアを算出 BigQuery 5. スコアブースト・フィルタリング 関連スコアのブースト・ペナルティによるリランキング BigQuery 6. 評価・バリデーション 定量評価(Vertex AI Experiments)、ポリシーチェック BigQuery / Vertex AI 1. セグメント抽出 施策ごとに定義されたSQLクエリで、対象ユーザーと対象アイテムを抽出します。たとえば、「過去30日以内にアクティブなユーザー」や「特定カテゴリの新着アイテム」などです。このSQLを差し替えるだけで、さまざまな施策へ対応できる設計です。 2. Embedding取得 Embedding基盤から、抽出したユーザー・アイテムに対応するEmbeddingを取得します。 3. Vector Index作成 Vector Searchの高速化のため、アイテムEmbeddingテーブルへ CREATE VECTOR INDEX でインデックスを作成します。本システムでは大規模バッチ向けの TREE_AH (GoogleのScaNNアルゴリズムベース)を採用しています。Vector Indexの構築にまつわる課題と対処法は、後述の「技術的な課題と工夫」で説明します。 4. Vector Search BigQueryの VECTOR_SEARCH 関数を用いてユーザーEmbeddingとアイテムEmbeddingのコサイン類似度を計算し、ユーザーごとに関連スコアの高い上位N件のアイテムを取得します。 -- Vector Searchの実行例(簡略化) SELECT query.member_id, base.product_id, distance FROM VECTOR_SEARCH( ( SELECT * FROM candidate_embeddings), -- アイテムEmbedding ' embedding ' , ( SELECT * FROM query_embeddings), -- ユーザーEmbedding ' embedding ' , top_k => 100 , distance_type => ' COSINE ' ) 5. スコアブースト・フィルタリング Vector Searchで得られた関連スコアは、そのままでは施策の目的に最適化されていません。そこで、ブーストやペナルティによるリランキングとフィルタリングを行い、最終的な推薦結果を生成します。 生成された推薦結果はBigQueryのテーブルに保存され、MAの配信システムがこのテーブルを読み込むことで連携します。 6. 評価・バリデーション パイプラインの最終ステップとして、推薦結果の品質を評価・検証します。 評価種別 内容 記録先 定量評価 NDCG、Precision、Recall等の指標を記録 Vertex AI Experiments ポリシーチェック 推薦結果がセグメント条件を満たすか、1ユーザーあたりの推薦数が閾値以上かなどを検証 BigQuery 技術的な課題と工夫 Vector Indexの非同期構築への対処 BigQueryのVector Indexは 非同期で構築 されるため、実行直後にはインデックスが利用可能になりません。インデックスが未完成の状態でVector Searchを実行すると、ブルートフォース(全件スキャン)で計算するため、実行時間とスロット消費が膨大になります。 この問題に対処するのが、全体構成図における Index完了待ち のコンポーネントです。以下のクエリで INFORMATION_SCHEMA.VECTOR_INDEXES の coverage_percentage を定期的にポーリングし、インデックス構築の完了を確認しています。 SELECT table_name, coverage_percentage FROM `{project_id}.{dataset_id}`.INFORMATION_SCHEMA.VECTOR_INDEXES WHERE table_name IN UNNEST(@expected_tables) coverage_percentage が100%に達した後、Vector Searchステップへ進むことでブルートフォース計算を回避しています。 Vector Searchのスロット消費問題 もう1つの大きな課題は、 共有スロット の大量消費による他ジョブへの影響 でした。Vector Searchはユーザーとアイテムの全組み合わせに対してコサイン類似度を計算するため、1回の実行で大量のスロットを占有します。 社内ではBigQueryのジョブを共通の容量ベースプロジェクトで実行しています。そのため、Vector SearchがBigQueryの共有スロットを圧迫すると自チームの実行時間の増大やSLO超過だけでなく、他チームのクエリ遅延・タイムアウトを引き起こすリスクがありました。 また、今回のケースではBigQueryのスキャン量が少ないという特徴がありました。そこで、 オンデマンド課金用の専用プロジェクト を用意してVector Searchのみをそのプロジェクトで実行するようにしました。オンデマンド課金はスキャン量に対して課金されるため、コストを抑えつつ共有スロットへの影響を回避し、十分な計算リソースを確保できました。 運用事例 上記の汎用推薦システムを実際のMA施策に適用し、開発スピードと推薦精度の両面で効果を検証しました。 開発リードタイムの短縮 施策ごとに約10週間かかっていた推薦システムの構築が 約3週間 で完了し、 約1/3 に短縮されました。以下の表に、従来と汎用推薦システムの工程比較を示します。 工程 従来 汎用推薦システム 探索的データ分析 約2週間 不要(Embedding基盤を利用) モデルの設計・実装 約3週間 不要(Embedding基盤を利用) パイプラインの設計・実装 約2週間 約1週間(セグメント設定と既存パイプラインの利用) 実験・評価・チューニング 約3週間 約2週間(後処理によるチューニング) Embeddingを活用することで、探索的データ分析やモデルの設計・実装が不要になりました。また、パイプラインの設計・実装についても、セグメント抽出用のSQLを変更するだけで新しい施策に対応できるため、短期間で実装できるようになりました。さらに、実験・評価・チューニングではモデルのパラメータの調整が不要であり、過去の評価コンポーネントや実験の仕組みも再利用できるため、後処理のチューニングへ集中できるようになりました。 推薦精度の向上 従来のルールベースの推薦(Control)と汎用推薦システム(Treatment)のA/Bテストを実施し、以下の結果を得ました。 指標 有意差の有無 配信当たりのMA経由流入数 有意差ありの勝ち 配信当たりのMA経由購入数 有意差ありの勝ち 配信当たりのMA経由受注額 有意差なしの勝ち 主要KPIである配信当たりのMA経由流入数・購入数で統計的に有意な改善を確認したため、汎用推薦システムの本番導入に至りました。 まとめ 本記事では、ZOZOTOWNのMA施策向けに構築した汎用推薦システムについて紹介しました。 本システムは、EmbeddingとBigQuery Vector Searchを活用し、施策を横断して利用できる汎用的な推薦システムです。従来必要だった特徴量作成やモデル学習が不要になることで開発スピードを向上させつつ、Embeddingによりユーザーの潜在的な嗜好を反映した高い推薦精度を実現しています。 実際のMA施策への適用では、開発リードタイムを約10週間から約3週間に短縮しました。さらに、A/Bテストでは配信当たりのMA経由流入数・購入数の改善を確認し、本番導入に至っています。 今後の展望 今後は以下の取り組みを予定しています。 Rerankerの導入 : 現在のスコアブースト・フィルタリングはルールベースで煩雑なため、機械学習ベースのRerankerを導入し、MA施策に最適化されたチューニングを実現する セグメント設定の効率化 : セグメント定義をviewなどで共通管理し、パイプラインごとの再実装をなくす 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.entry-content ul > li > ul { display: none; } はじめに こんにちは。グローバルプロダクト開発本部SREブロックの纐纈です。 弊チームでは、Kubernetes上で動作する4つのサービス(ZOZOMAT、ZOZOGLASS、ZOZOMETRY、お試しメイク)のリリースを自動化しています。これまでにArgo CDによるGitOpsやArgo Rolloutsによるカナリアリリースを導入してきました。 techblog.zozo.com techblog.zozo.com リリースパイプラインの全体像については以下の記事で紹介しています。 techblog.zozo.com 本記事では、このリリースパイプラインのトリガー方式を見直した取り組みについて紹介します。改善にあたり、Argo EventsとArgo Workflowsを活用しました。Argo Eventsはイベント駆動型の自動化フレームワークで、EventSourceで様々なイベントを受信しSensorで後続処理をトリガーできます。Argo WorkflowsはKubernetes上でDAG形式のワークフローを実行するエンジンです。 argoproj.github.io argoproj.github.io 目次 はじめに 目次 リリースパイプラインの全体像 トリガー方式の変遷と課題 Phase 1: Argo CD PostSync Hook 課題: 不要なトリガーの発生 Phase 2: Argo EventsによるRollout監視 課題: HPAスケーリングによるリリースイベントの大量発生 Webhook EventSourceへの改善 Webhook方式の選定理由 改善後の流れ 実装の詳細 Argo CD NotificationsからWebhookを送信する Webhook EventSourceの作成 Sensorの簡素化 release-gate ClusterWorkflowTemplateの導入 release-gateの処理フロー 出力パラメータ ClusterWorkflowTemplateによるテンプレート共通化 パイプラインDAGの全体構成 スクリプトの簡素化 導入効果 パイプラインの可視化 不要なトリガーとイベントフラッディングの解消 Sensorの大幅な簡素化 マルチサービスへの横展開の容易さ まとめ おわりに リリースパイプラインの全体像 まず現在運用しているリリースパイプラインの全体像を説明します。 弊チームでは、アプリケーションコードを管理するサーバーリポジトリと、Kubernetesマニフェストを管理するKubernetesリポジトリを分離しています。サーバーリポジトリにPRがマージされると、GitHub Actionsがコンテナイメージをビルドし、ECRにプッシュします。Argo CD Image Updaterが新しいイメージを検知すると、KubernetesリポジトリにPRを自動作成します。イメージ更新PRがマージされるとArgo CDがステージング環境にデプロイし、リリースパイプラインが起動します。 リリースパイプラインでは、負荷試験、リリース用PRの作成、自動マージを行い、最終的に本番環境へデプロイします。 この「Argo CD Sync → リリースパイプライン」のトリガー方式が、今回の改善対象です。 トリガー方式の変遷と課題 これまでトリガー方式を2度見直してきました。以降では、各方式で明らかになった課題と改善の経緯を説明します。 Phase 1: Argo CD PostSync Hook 最初は、Argo CDのPostSync Hookを使用していました。Argo CD Syncが完了すると、PostSync Hookとして定義されたKubernetes Jobが自動的に起動する仕組みです。Sync Wavesを活用してJobの実行順序を制御していました。 argo-cd.readthedocs.io ArgoCD Sync完了 → PostSync Hook(Kubernetes Job) → 負荷試験 → リリースPR作成 → ... 課題: 不要なトリガーの発生 PostSync HookはすべてのArgo CD Sync完了時にトリガーされます。そのため、意図しないタイミングでパイプラインを起動する問題がありました。例えばConfigMapやSecretの変更時にも負荷試験が実行され、開発者の待ち時間を長くしていました。 Phase 2: Argo EventsによるRollout監視 PostSync Hookの課題を受けて、Argo Eventsを使ったRollout監視方式に移行しました。弊チームではArgo Rolloutsを利用しており、DeploymentではなくRolloutオブジェクトでPodを管理しています。RolloutはDeploymentを拡張したカスタムリソースで、カナリアリリースなどの高度なデプロイ戦略をサポートします。 この方式では、RolloutオブジェクトのステータスをKubernetes API Watchで直接監視します。イメージ更新によるロールアウト完了時のみパイプラインをトリガーする方式です。 ArgoCD Sync完了 → EventSource(Rollout監視) → Sensor → Workflow実行 また、この移行と同時にKubernetes JobからArgo Workflowsへ切り替えました。Sync Wavesによる順序制御では、各Jobの実行状況を把握しにくいという課題がありました。Argo Workflowsを採用することで、DAGによる柔軟な依存関係の定義やUIでの実行状況の可視化が可能になりました。 Sensorでは複雑なフィルタリングを行っていました。 updatedReplicas 、 replicas 、 availableReplicas を比較する式フィルタと、 NewReplicaSetAvailable を確認するLuaスクリプトの組み合わせです。 filters : data : - path : body.metadata.namespace type : string value : [ "${service}" ] - path : body.metadata.name type : string value : [ "api-server-rollout" ] exprs : - expr : updatedReplicas == replicas && updatedReplicas == availableReplicas && replicas > 0 fields : - name : updatedReplicas path : body.status.updatedReplicas - name : replicas path : body.status.replicas - name : availableReplicas path : body.status.availableReplicas script : |- local conditions = event.body.status.conditions if conditions == nil then return false end for i, cond in ipairs(conditions) do if cond.type == "Progressing" and cond.reason == "NewReplicaSetAvailable" then return true end end return false ConfigMap変更時の不要なトリガーは解消されましたが、別の課題が浮上しました。 課題: HPAスケーリングによるリリースイベントの大量発生 上記のSensorフィルタは、新しいバージョンのロールアウト完了を検知する想定で設計していました。 NewReplicaSetAvailable 条件とレプリカ数の一致で、新バージョンへの切り替え完了を判定しています。 しかし、HPAによるスケーリングでもRolloutオブジェクトの replicas や availableReplicas が更新されます。スケーリング完了時にレプリカ数が一致するため、フィルタ条件を満たしてしまいます。つまり、このフィルタでは「新バージョンのロールアウト完了」と「HPAスケーリング完了」を区別できませんでした。 その結果、この問題はステージング環境で障害として顕在化しました。HPAスケーリングを起点としたイベントフラッディングにより、負荷試験が3並列で実行され、以下の問題が発生しました。 DB CPU使用率が100%に到達 api-serverがレスポンス不能に CrashLoopBackOffが発生 この障害をきっかけに、トリガー方式を根本的に見直す必要があると判断しました。 Webhook EventSourceへの改善 Webhook方式の選定理由 新しいトリガー方式として、Argo CD NotificationsからWebhookでArgo Events EventSourceに通知する方式を採用しました。 ArgoCD Sync完了 → ArgoCD Notifications → Webhook → EventSource → Sensor → Workflow実行 この方式を選んだ理由は4つあります。 1. コミット単位でトリガーを制御できる Argo CD Notificationsの oncePer: revision により、同一コミットSHAに対して厳密に1回だけ発火します。Rollout監視方式のようにHPAスケーリングやPod再起動でイベントが大量発生する問題は構造上発生しません。 2. トリガーソースを識別できる Webhookペイロードにrevision(コミットSHA)が含まれるため、GitHub APIでそのコミットの変更内容を特定できます。PostSync HookやRollout監視方式ではこの情報が得られませんでした。 3. Sensorの大幅な簡素化 Rollout監視方式では、namespace、ステータス式、Luaスクリプトの3層フィルタリングが必要でした。一方、Webhook方式では body.app の単純な文字列マッチのみで済みます。 4. 既存基盤の活用 Slack通知で既に使用しているArgo CD Notificationsに、Webhookサービスを追加するだけで導入できます。そのため、新たなコンポーネントの導入が不要でした。 改善後の流れ 改善後の全体像は以下の通りです。 実装の詳細 Argo CD NotificationsからWebhookを送信する Argo CD NotificationsのConfigMapにWebhookサービスとトリガーを追加します。 # Webhookの定義 service.webhook.argo-events-sync : | url : http://argocd-sync-webhook-eventsource-svc.argo-events.svc.cluster.local:12000/sync headers : - name : Content-Type value : application/json # テンプレート: ペイロードの定義 template.app-sync-webhook : | webhook : argo-events-sync : method : POST body : | { "app" : "{{.app.metadata.name}}" , "revision" : "{{.app.status.operationState.syncResult.revision}}" } # トリガー: Sync成功時、同一revisionに対して1回のみ発火 trigger.on-sync-succeeded-webhook : | - when : app.status.operationState != nil and app.status.operationState.phase in [ 'Succeeded' ] oncePer : app.status.operationState.syncResult.revision send : [ app-sync-webhook ] oncePer がポイントです。同一のコミットSHAに対して一度しかWebhookが送信されないため、リリースパイプラインの重複実行を構造的に防止できます。 argo-cd.readthedocs.io 各環境のサブスクリプション設定で、対象のArgo CD Applicationにこのトリガーを紐付けます。 # サブスクリプション設定 defaultTriggers : | - recipients : - argo-events-sync triggers : - on-sync-succeeded-webhook Webhook EventSourceの作成 Argo CD NotificationsからのWebhookを受信するEventSourceを作成します。 apiVersion : argoproj.io/v1alpha1 kind : EventSource metadata : name : argocd-sync-webhook namespace : argo-events spec : service : ports : - port : 12000 targetPort : 12000 webhook : argocd-app-sync : port : "12000" endpoint : /sync method : POST 1つのEventSourceで全サービスのWebhookを受信します。サービスごとの振り分けはSensor側で行います。 Sensorの簡素化 Rollout監視方式の時代に必要だった複雑なフィルタリングが、 body.app の文字列マッチのみに簡素化されました。 apiVersion : argoproj.io/v1alpha1 kind : Sensor metadata : name : ${service}-release-pipeline namespace : argo-events spec : dependencies : - name : argocd-sync-completed eventSourceName : argocd-sync-webhook eventName : argocd-app-sync filters : data : - path : body.app type : string value : - ${service}-server-kubernetes triggers : - template : name : trigger-release-pipeline conditions : argocd-sync-completed k8s : operation : create source : resource : apiVersion : argoproj.io/v1alpha1 kind : Workflow metadata : generateName : ${service}-release-pipeline- namespace : ${service} spec : synchronization : mutexes : - name : ${service}-release-pipeline workflowTemplateRef : name : ${service}-release-pipeline arguments : parameters : - name : revision value : "" parameters : - src : dependencyName : argocd-sync-completed dataKey : body.revision dest : spec.arguments.parameters.0.value WebhookペイロードからコミットSHAを抽出し、Workflowのパラメータとして渡します。さらに synchronization.mutexes を設定し、同一サービスのパイプラインが並列実行されることを防止しています。 argo-workflows.readthedocs.io release-gate ClusterWorkflowTemplateの導入 改善前のリリースパイプラインでは、トリガーPRの特定や負荷試験の判定ロジックが各スクリプトに散在していました。これを release-gate ClusterWorkflowTemplateに集約し、パイプライン制御を整理しました。 release-gateの処理フロー release-gateは4つのステップで構成されています。 Step 1: リリース差分チェック GitHub APIで release...main ブランチを比較し、リリースすべき変更があるか確認します。差分がない場合はパイプラインを終了します。 Step 2: トリガーPR特定 mainブランチの最新マージコミットメッセージからPR番号を抽出します。「Merge pull request #42」のようなメッセージからPR番号を取得します。抽出に失敗した場合は、Deploymentのイメージタグ(コミットSHA)でPRを検索するフォールバックも用意しています。 Step 3: 負荷試験の判定 トリガーPRのラベルを確認します。 skip_load_test ラベルが付与されている場合は負荷試験をスキップし、それ以外は負荷試験を実行します。Image Updater PRは自動生成でラベルが付かないため、通常のイメージ更新では負荷試験が常に実行されます。 Step 4: auto-merge判定 リリースPR(main → release)に人間のコミットがあるか確認します。botコミット(argocd-image-updater、GitHub Actionなど)のみの場合は自動マージを有効にし、人間のコミットがある場合は無効にします。 出力パラメータ release-gateの出力は後続のステップで条件分岐に使用されます。 パラメータ 説明 run_load_test 負荷試験の実行判定(true/false) run_release リリースPR作成判定(true/false) run_auto_merge auto-merge判定(true/false) trigger_pr_number トリガーPR番号 deployment_image_tag 現在のDeploymentイメージタグ ClusterWorkflowTemplateによるテンプレート共通化 改善前は、 create-release-pr や auto-merge のJobを各サービスのリポジトリにそれぞれ定義していました。4サービス分のマニフェストを個別に管理する必要があり、メンテナンスコストが高くなっていました。 ClusterWorkflowTemplateを利用することで、テンプレートをインフラリポジトリで一元管理できるようになりました。各サービスはDAGから clusterScope: true で参照し、サービス固有の値( git-repository など)はパラメータで渡します。 # 各サービスのDAGからの参照例 - name : create-release-pr templateRef : name : create-release-pr template : create-release-pr clusterScope : true arguments : parameters : - name : git-repository value : "${service}-server-kubernetes" - name : trigger-pr-number value : "{{tasks.gate.outputs.parameters.trigger_pr_number}}" 新たに追加した共通テンプレートを含め、ClusterWorkflowTemplateの全体像は以下の通りです。 ClusterWorkflowTemplate 役割 release-gate リリース判定(差分チェック、トリガーPR特定、負荷試験の要否/auto-merge判定) create-release-pr リリースPRの自動作成 auto-merge PRの自動マージ load-test-pr-comment 負荷試験結果をリリースPRにコメント release-pipeline-summary パイプライン全体の結果をSlackに通知 パイプラインDAGの全体構成 最終的なパイプラインのDAG構成です。 spec : entrypoint : release-pipeline arguments : parameters : - name : revision value : "" templates : - name : release-pipeline dag : tasks : - name : release-gate templateRef : name : release-gate template : release-gate clusterScope : true arguments : parameters : - name : git-repository value : "${service}-server-kubernetes" - name : revision value : "{{workflow.parameters.revision}}" - name : deployment-name value : "${service}-server-deployment" - name : deployment-namespace value : "${service}" - name : load-test dependencies : [ release-gate ] when : "{{tasks.release-gate.outputs.parameters.run_load_test}} == true" # ... - name : create-release-pr templateRef : name : create-release-pr template : create-release-pr clusterScope : true dependencies : [ load-test ] when : "{{tasks.release-gate.outputs.parameters.run_release}} == true" # ... - name : load-test-pr-comment templateRef : name : load-test-pr-comment template : load-test-comment clusterScope : true dependencies : [ create-release-pr, load-test ] when : "{{tasks.release-gate.outputs.parameters.run_load_test}} == true && {{tasks.release-gate.outputs.parameters.run_release}} == true" # ... - name : auto-merge templateRef : name : auto-merge template : auto-merge clusterScope : true dependencies : [ create-release-pr, load-test-pr-comment ] when : "{{tasks.release-gate.outputs.parameters.run_auto_merge}} == true" # ... - name : release-pipeline-summary templateRef : name : release-pipeline-summary template : summary clusterScope : true dependencies : [ release-gate, load-test, create-release-pr, auto-merge ] when : "{{tasks.release-gate.outputs.parameters.run_release}} == true" # ... dependencies と when を組み合わせることで、各ステップの実行条件を柔軟に制御しています。 dependencies はタスクの依存関係(実行順序)を定義します。一方、 when はrelease-gateの出力パラメータに基づいてタスクの実行可否を判定します。 例えば create-release-pr は load-test に依存しつつ、 run_release == true の場合にのみ実行されます。負荷試験がスキップ(Omitted)された場合も依存関係は満たされるため、後続のタスクは実行されます。 スクリプトの簡素化 release-gateにロジックを集約したことで、 create-release-pr と auto-merge のスクリプトを大幅に簡素化できました。 削除した機能 移動先 トリガーPR特定ロジック release-gate 人間コミットチェック release-gate Slack通知 release-pipeline-summary 両スクリプトは TRIGGER_PR_NUMBER 環境変数をrelease-gateから受け取るだけのシンプルな実装になりました。 導入効果 パイプラインの可視化 以前は、PostSync HookとKubernetes Jobを使用していたため、パイプラインの進行状況を把握しにくい状態でした。Argo Workflowsに移行したことで、DAGの実行状況をArgo Workflows UIで視覚的に確認できるようになりました。 さらに、release-pipeline-summaryによるSlack通知でパイプライン全体の実行結果を一目で把握できます。負荷試験結果はリリースPRにもコメントされるため、手動マージ時の判断も容易です。 不要なトリガーとイベントフラッディングの解消 Phase 1の課題であった不要なトリガーについては、release-gateのリリース差分チェックで解消しました。Webhook方式ではすべてのSync成功時にパイプラインが起動しますが、release-gateが差分を判定し、リリースすべき変更がなければ早期終了します。 Phase 2の課題であったイベントフラッディングについては、 oncePer: revision により解消しました。HPAスケーリングやPod再起動に起因するイベントの大量発生を防げるようになりました。 Sensorの大幅な簡素化 3層フィルタリング(namespace + ステータス式 + Luaスクリプト)から、 body.app の文字列マッチのみに簡素化されました。これにより、Sensorの定義が大幅にシンプルになり、メンテナンス性が向上しました。 マルチサービスへの横展開の容易さ ClusterWorkflowTemplateとして共通ロジックを一元管理しているため、新しいサービスへの展開が容易です。Sensorの追加とDAGの定義、負荷試験用のWorkflowTemplateの作成だけで完了します。 まとめ リリースパイプラインのトリガー方式は、PostSync Hook → Rollout監視 → Webhook EventSourceと変遷してきました。今回Argo WorkflowsとArgo Eventsを活用し、Webhook EventSourceへの移行を実現しています。 各方式の課題を段階的に解消できたのは、Argo Eventsの柔軟なイベントソースのおかげです。特に、Argo CD Notificationsの oncePer 機能とWebhook EventSourceの組み合わせは、イベント駆動型パイプラインの制御に有効でした。 また、今回の改善を通じて、複数サービスで共通するパイプラインの変更を安全に進める方法を見直すきっかけにもなりました。改善の過程でパイプラインが検証通りに動作せず、リリースが停止するトラブルも発生しました。リリースパイプラインの変更は4サービスに同時に影響するため、慎重なアプローチが求められます。今後の改善においては、変更によるリスクを最小化する方法も検討していきたいと考えています。 本記事がArgo EventsやArgo Workflowsを活用したリリースパイプラインの構築を検討している方の参考になれば幸いです。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.table-of-contents > li > ul > li > ul { display: none; } はじめに こんにちは、データサイエンス部コーディネートサイエンスブロックの 大川 です。私たちは、WEARにおける「似合う」をユーザーに届けるため、LLMやマルチモーダルAIを活用してコーディネートの特徴抽出や似合うに関する独自の判定処理のR&Dを行っています。 LLMが台頭して以降、LLMに構造化出力を要求するタスクは増えています。数百件のテストでは問題なく動いていたシステムが、本番運用で10万件・100万件規模の推論を回すと思わぬエラーに直面することがあります。 本記事では、ファッション画像から柄の特徴を抽出するタスクを本番運用する過程で直面した課題と、その解決策を共有します。具体的には、エラー内容をプロンプトにフィードバックしてリトライする手法により、87%のエラー削減を達成しました。この手法はLLMの構造化出力タスク全般に応用可能です。 目次 はじめに 目次 サマリー 前提条件 発生した問題 問題1: 不正な出力(68件) 問題2: トークンが繰り返される出力(9件) 原因分析 原因1: 不正な出力 原因2: トークンが繰り返される出力 解決策 解決策1: バリデーション&リトライの追加 解決策2: プロンプトへのエラーフィードバック追加 ValueErrorの場合 JSONDecodeErrorの場合 GoogleAPIError(APIエラー)の場合 結果 エラー削減効果 性能への影響 まとめ おわりに サマリー LLMの構造化出力で発生する「不正な値の出力」と「トークン繰り返し」問題に対し、バリデーション+エラーフィードバックプロンプトで87%のエラー削減を達成(68件→9件) エラー内容だけでなく、リトライ回数とtemperatureもフィードバックに含めると効果が大きい(21件→9件) F1スコアへの影響は約0.02の低下にとどまり、安心して導入できる 前提条件 前提条件を揃えるため、タスク内容とLLMの仕様を共有します。 項目 内容 タスク ファッション画像から複数の柄の特徴を抽出するタスク(マルチラベル分類) 推論規模 約10万件の全身コーディネート画像 使用モデル gemini-2.5-flash-lite 出力形式 JSON(許可された値のリストから選択) リトライ 最大3回 構造化出力では、柄の種類( pattern_type )などの特徴に対して、事前定義された値のみを出力するようLLMに指示しています。例えば、ニュアンス柄( nuance_pattern )やグレンチェック柄( glen_check_pattern )などが定義済みの値です。この制約の実装にはGemini APIの response_schema パラメータを利用しています 1 。 ただし、 response_schema はJSONの 構文的な正しさ(型やフィールド名)は保証しますが、値の意味的な正しさは保証しません 。公式ドキュメントでも「最終的な出力は、使用する前に必ずアプリケーションコードで検証してください」と明記されています 2 。この仕様上の限界が、後述する「不正な出力」問題の背景にあります。 LLMにより画像から抽出されたpattern_typeのマルチラベル分類例 発生した問題 約10万件の画像を推論したところ、以下の2つの問題が発生しました。 問題1: 不正な出力(68件) 定義外の値が出力されるケースです。例えば pattern_type に対して、 logo 、 patchwork_pattern 、 graphic_pattern のような、あらかじめ指定したリストに含まれない値が返ってきました。 不正な値 件数 logo 26 patchwork_pattern 17 graphic_pattern 14 camouflage_pattern 6 その他 5 これらの不正な値は、いずれもファッション領域では実在する概念です。LLMが持つ一般知識から「もっともらしい値」を生成してしまったと考えられます。 問題2: トークンが繰り返される出力(9件) 同じトークンが無限に繰り返され、JSONパースに失敗するケースです。Gemini API公式ドキュメントでも「トークンの繰り返しに関する問題」として同様の事象が取り上げられています 3 。 実際のトークン繰り返しエラーのログ(Langfuse) この問題が厄介なのは、JSONパースエラーでリトライしても同様の事象が繰り返される点です。その結果、以下の影響が生じます。 出力が得られない : 3回リトライしても正常な結果を取得できない レイテンシーの悪化 : 1件あたり10分程度かかるケースも発生 コストの増加 : 無駄なトークンを大量に消費する この問題をスケールで考えると深刻さが分かります。10万件中9件の発生率(0.009%)は一見小さく見えますが、本番の全件推論で400万件を処理する場合、約360件でこの問題が発生する計算です。1件あたり10分の遅延とすると、トークン繰り返し問題だけで 約60時間(2.5日分)の遅延 が発生します。 原因分析 原因1: 不正な出力 不正な出力の原因は、出力値のバリデーションとリトライの仕組みが不十分だったことです。前述のとおり、Geminiの response_schema はJSONの構文を制約するものであり、enum値の完全な制約までは保証しません。従来の実装ではこれを検知してリトライする機能がなく、不正な出力がそのまま通過していました。 原因2: トークンが繰り返される出力 この問題の背景には、 再現性とトークン繰り返しのトレードオフ があります。分類タスクでは temperature=0 で出力を安定させたい一方、それがトークン繰り返し問題を引き起こします。実際、Gemini API公式のトラブルシューティングガイドでも、temperatureを低く設定すると「ループや性能劣化などの予期しない動作を引き起こす可能性がある」と警告されています 4 。 技術的には、 temperature=0 の貪欲デコーディングにより、特定の入力に対して同じ出力トークンが延々と選ばれ、適切にEOSトークンで終了できない状態に陥ります。この問題に対処するため、リトライ時にtemperatureを0.1ずつ増やす施策を導入していましたが、それだけでは完全には回避できませんでした。 解決策 2つのアプローチを組み合わせて改善を図りました。 解決策1: バリデーション&リトライの追加 不正な値が出力された際に、許可された値のリストと照合してバリデーションし、失敗時はリトライする機能を追加しました。 解決策2: プロンプトへのエラーフィードバック追加 単にリトライするのではなく、前回のエラー内容をプロンプトの末尾にフィードバックして再試行させることでLLMの注意を問題点に向けさせました。このとき、エラーの種類によってフィードバック内容を変えるように設計しました。 ValueErrorの場合 ValueErrorの場合、問題1(不正な出力)の発生が予想されます。どの値が不正で、どの値が許可されているかをエラーメッセージとしてそのままフィードバックするようにしました。 前回の推論で以下のようなエラーが発生しましたので注意してください。 ** 前回のConfig・エラー情報 ** - 試行: {N} 回目 - temperature: {current_temp} - 前回エラー: ValueError: invalid result for feature=pattern_type: 'logo' (allowed: ['ethnic_pattern', 'geometric_pattern', ...]) JSONDecodeErrorの場合 JSONDecodeErrorの場合、トークンが繰り返されている可能性が高いと判断し、通常のプロンプトの末尾に以下のフィードバックを追加しました。この問題は公式ドキュメントでも言及されており、「同じことを繰り返さないでください」という指示を追記することが推奨されています 5 。 前回の推論で以下のようなエラーが発生しましたので注意してください。 ** 前回の Config・エラー情報 ** - 試行: {N} 回目 - temperature: {current_temp} - 前回エラー: JSONDecodeError: ... 無限にトークンが繰り返される問題が発生している可能性があります。**同じことを繰り返さないでください。** GoogleAPIError(APIエラー)の場合 GoogleAPIError(APIエラー)の場合、レート制限やネットワークエラーが主な原因となるため、プロンプトを改善しても解決しません。この場合はフィードバックを追加せず、指数バックオフによるリトライのみとしました。 結果 エラー削減効果 解決策の効果を検証するため、不正な出力を起こした68件を評価データとして用い、施策前後での改善度合いを比較しました。なお、トークンが繰り返される問題については、エラーの再現ができなかったため今回は評価データから除外しています。 3つの条件を用意して比較実験を行いました。 解決策1: バリデーションのみを追加 解決策2-1: バリデーションとエラーフィードバック(エラー内容のみ) 前回の推論で以下のようなエラーが発生しましたので注意してください。 - 前回エラー: ValueError: invalid result for feature=pattern_type: 'logo' (allowed: ['ethnic_pattern', 'geometric_pattern', ...]) 解決策2-2: バリデーションとエラーフィードバック(エラー内容 + リトライ数 + temperature) 前回の推論で以下のようなエラーが発生しましたので注意してください。 ** 前回のConfig・エラー情報 ** - 試行: {N} 回目 - temperature: {current_temp} - 前回エラー: ValueError: invalid result for feature=pattern_type: 'logo' (allowed: ['ethnic_pattern', 'geometric_pattern', ...]) 施策 バリデーションの有無 エラーFBの有無 エラー件数 削減率 ベースライン ✗ ✗ 68件 — 解決策1 ✓ ✗ 40件 41% 解決策2-1 ✓ ✓ 21件 69% 解決策2-2 ✓ ✓ 9件 87% 実験結果として、 87%のエラー削減 (68件 → 9件)を達成しました。 重要な発見として、 エラー内容だけでなく、リトライ数とtemperatureも付与したほうが効果的である ことを確認しました(解決策2-1の21件→解決策2-2の9件)。これらの情報を付与することで、LLMが「何回目の試行で、どのような生成条件なのか」を把握でき、前回と異なる出力を生成しやすくなったと推察されます。 トークンが繰り返される問題についても、定量的な評価には至っていないものの、定性的には出現頻度の低下と出力の安定化を確認しています。 性能への影響 エラーフィードバックを追加することで性能への悪影響がないか検証しました。柄の評価データセットを用意し、エラーFBの有無で3回ずつ実行した平均値を比較しました。リトライ時にtemperatureを0.1ずつ増やす運用を想定し、temperature 0.0〜0.2の範囲で検証しています。 モデル temperature エラーFBの有無 F1スコア gemini-2.5-flash-lite 0.0 ✗ 0.8417 gemini-2.5-flash-lite 0.0 ✓ 0.8208 gemini-2.5-flash-lite 0.1 ✗ 0.8434 gemini-2.5-flash-lite 0.1 ✓ 0.8208 gemini-2.5-flash-lite 0.2 ✗ 0.8425 gemini-2.5-flash-lite 0.2 ✓ 0.8217 性能への大きな影響はないことを確認しました 。数値上ではF1スコアに約0.02の低下が見られますが、エラーフィードバックが適用されるのはバリデーション失敗時のリトライのみです。正常に出力された大多数のケースではフィードバックが付与されないため、システム全体への影響は軽微です。 まとめ 本記事では、LLMの構造化出力で発生するエラーを87%削減した手法を紹介しました。 本記事の貢献は以下のとおりです。 バリデーション+エラーフィードバック をプロンプトに含めることでエラー件数を87%削減できる エラー内容だけでなく、 リトライ数とtemperatureも付与すると効果が高い フィードバックを追加してもF1スコアへの大きな悪影響はなく、安心して導入できる この手法はGeminiに限らず、 LLMの構造化出力タスク全般 に応用可能 LLMの構造化出力や、Gemini APIの出力の安定化(トークン繰り返し問題の回避)に悩むエンジニアの方々にとって、本手法が何らかのヒントになれば幸いです。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com Generative AI on Vertex AI - 構造化出力 ↩ Gemini API - 構造化出力 ↩ トラブルシューティング ガイド - トークンの繰り返しに関する問題 ↩ 朗読に関する問題 ↩ トークンの繰り返しに関する問題 ↩
アバター
.table-of-contents > li > ul > li > ul { display: none; } はじめに こんにちは、検索基盤部の倉澤です。ZOZOTOWNの検索機能のバックエンドの開発を担当しています。検索基盤部の一部システムではGoを採用しています。 2026年2月21日(土)にGo Conference mini in Sendai 2026が開催されました。本記事では、会場の様子や個人的に印象に残ったセッション・LTについて紹介します。また、私もLT枠で登壇したため当日話しきれなかった内容もあわせて紹介します。 目次 はじめに 目次 Go Conference mini in Sendai 2026とは 会場の様子 セッション AI時代のGo開発2026 爆速開発のためのガードレール 個人的に気になった点 Go パッケージのサプライチェーン攻撃を防ぐ CI を作ってみた 個人的に気になった点 Go 1.26 で生まれ変わった go fix をプロダクト開発の運用に乗せる 個人的に気になった点 encoding/json/v2のUnmarshalはこう変わった ~内部実装で見る設計改善~ このテーマを選んだきっかけ さいごに Go Conference mini in Sendai 2026とは Go Conferenceは、プログラミング言語Goに関するカンファレンスです。今回は「東北から広がるGoコミュニティ」というテーマで仙台にて4年ぶりに開催されました。18セッション(20分)と12のLT(5分)によって構成され、Goに関するさまざまなテーマについて発表されました。参加者は117人と大盛況のうちに終わりました。 sendaigo.jp 会場の様子 会場は仙台市青葉区にあるアーバンネットビル仙台中央 カンファレンスルームでした。ワンフロアにスポンサーブースと2部屋のセッションルームがあり、同時に発表が行われました。 オープニングの様子 スポンサーブースでは、参加者向けのさまざまなコンテンツが用意されていました。 株式会社UPSIDERさんのブース 株式会社UPSIDERさんのブースでは、「Goの挑戦Goっそり教えて!」をテーマに意見が募られていました。「TinyGoを使って何かを作ってみたい」等の声があり、TinyGoへの関心の高さがうかがえました。 株式会社SODAさんのブース 株式会社SODAさんのブースでは、「あなたのやらかしエピソードや懺悔したいことを教えてください」をテーマに意見が募られていました。生成AIが書いたコードによってやらかしたエピソードが昨今の開発事情を表していて面白いなと思いました。また、SODAさんのブースではGopherの16タイプに分ける診断を実施しておりました。 snkrdunk.github.io 私は、「数学的な賢者」でした! Gopherの16タイプ診断 会場では参加者全員にステッカーなどのノベルティが配布されており、どれもとても可愛らしいデザインでした。 ノベルティのステッカー また、ネームタグの裏側には「すぐに使える仙台弁」が記載されており、参加者同士の会話のきっかけになっていました。仙台開催ならではの遊び心が感じられる演出でした。 ネームタグ さらに登壇者にはTシャツが配布され、登壇の良い記念になりました。運営の皆さまのお心遣いに感謝です。 登壇者用のTシャツ セッション AI時代のGo開発2026 爆速開発のためのガードレール www.docswell.com こちらのセッションでは、生成AIにおける開発の「速さ」と「治安(コード秩序やルール)」をいかに両立させるのかについて紹介されています。 課題 生成AIの発達・普及により実装速度が飛躍的に向上した一方で、アーキテクチャのルール違反などコードの治安が悪化しやすくなっている。 対策 Rules/Skillsのような非決定的な制約(ソフト制約)だけに頼るのではなく、決定的な制約(ハード制約)をガードレールとして整備することが重要。 紹介されているハード制約の例 Goの internal パッケージによるアクセス制御 depguard 等のLintによる依存ルールの強制 Fuzzing testやMutation testによるテスト品質の担保 個人的に気になった点 アーキテクチャの依存ルールを生成AIに守らせるという観点で、Goの internal パッケージを用いるというのは面白い発想だと思いました。一方で、ドメイン単位でパッケージを分割する Package by Feature だからこそ威力を発揮する一面もあるのかなと思いました。私が携わっているプロジェクトでは、アーキテクチャの技術的な役割(レイヤー)毎にパッケージを分割する Package by Layer を採用しています。 internal パッケージの配下に各レイヤーのパッケージを切る構成が一般的です。この場合、 internal が守れるのは外部モジュールからのアクセスであり、 internal 内部のレイヤー間の依存方向までは防げません。 発表後に登壇者の方へ質問したところ、 Package by Layer でも internal パッケージが活きるケースを共有していただきました。各層でしか使わない関数を他の層から使われないように守るという観点です。例えば、 presentation 層でレスポンスに対して処理する関数を internal に配置すれば、他の層からの誤った利用を防げるとのことでした。レイヤー間の依存方向の制御とは別に、各層の内部実装の隠蔽という観点で internal が有効に機能するというのは納得感がありました。 Go パッケージのサプライチェーン攻撃を防ぐ CI を作ってみた speakerdeck.com こちらのセッションでは、Goパッケージのサプライチェーン攻撃をCIで防ぐ取り組みについて紹介されています。 課題 typosquatting (タイプミスを狙った攻撃)や slopsquatting (AIのハルシネーションを狙った攻撃)により、悪意のあるパッケージの混入リスクがある 対策 Googleが公開している capslock を活用し、パッケージがアクセスし得る特権的操作(ファイルシステム操作、ネットワーク通信など)を静的解析で検知 PRで新しいパッケージが追加された際に、 main ブランチとのCapabilityの差分をCIで検出 その結果をClaude Code Actionに読み込ませることで、セキュリティリスクを診断する仕組みを構築 個人的に気になった点 こちらのセッションは、昨年開催されたGo Conference 2025の「 サプライチェーン攻撃に学ぶmoduleの仕組みとセキュリティ対策 」に続く内容だと感じました。昨年の発表では、Goのパッケージ管理システムを利用したサプライチェーン攻撃が3年以上見つからず、その根本的な対策も難しいという話がありました。本発表はLT枠で5分と短かったですが、昨年のGo Conferenceで発表された課題に対して対策を検討し、同じくGo Conferenceで発表するという流れにとても感心しました。 発表内容で気になったのは、新しく追加されたパッケージのCapabilityから悪意の有無をClaude Codeがどう判断しているかという点です。登壇者の方に質問したところ、依存先パッケージのメソッド名や周辺の実装をもとに判断していると考えられるとのことでした。また、サードパーティの公式パッケージを追加した際にも、依存先パッケージでCapabilityの警告が出るケースもあったそうです。ただし公式パッケージである以上、対処は難しく、まだ改善の余地があるとのことでした。 Go 1.26 で生まれ変わった go fix をプロダクト開発の運用に乗せる speakerdeck.com こちらのセッションでは、Go 1.26で大幅に刷新された go fix コマンドをプロダクト開発の現場にどう組み込むかについて紹介されています。 運用フローの設計 「検知」と「適用」を分けて考えるのがポイント 検知(毎PR): golangci-lint の modernize を有効化し、CIで古い書き方を常時警告する 適用(Goバージョン更新時): go fix ./... を2回実行して既存コードを一括変換する go fixに関する3つのアプローチと使い分け modernize :組み込みルールによるコードのモダン化。go fixを実行するだけ SuggestedFix :自作Analyzerに修正提案を追加し、プロジェクト固有のパターンを自動修正する go:fix inline :非推奨関数に //go:fix inline を付与し、利用者側でgo fixを実行するだけでAPI移行を自動化する 個人的に気になった点 先日公開された公式ブログ「 Using go fix to modernize Go code 」を読んでおり、最近私も go fix を実行した経験がありました。そのため、運用観点の話はとても興味深い内容でした。特に気になっていたのは、 go fix の「2回実行が必要」という点の仕組み化です。ある modernize ルールの適用が別のルールの適用機会を生むため、公式ブログでも2回の実行が推奨されていますが、これを仕組み化するのは難しいと感じていました。登壇者の方に質問したところ、以下のような回答をいただきました。 まだ完全な仕組み化はできていないが、 pre-commit フックでコミット前に go fix を実行する方法を検討している ただしpre-commitの導入はチームにより意見が分かれるため、Claude CodeのSkillsで実行させるのも有効ではないか 生成AIのSkillsは、こうした「毎回やるべきだが柔軟さも求められるルール」の適用に向いているという点に納得感がありました。また、 golangci-lint の modernize リンターについても質問しました。内部的にはgo fixと同じ modernize アナライザが動いているため、こちらも同様に複数回の実行が必要とのことでした。 encoding/json/v2のUnmarshalはこう変わった ~内部実装で見る設計改善~ speakerdeck.com 私も今回LT枠で登壇いたしました。このセッションでは、Go 1.25で実験的に追加された encoding/json/v2 パッケージの Unmarshal 関数を取り上げました。従来の encoding/json パッケージが抱えているパフォーマンス上の課題と、v2での改善点を内部実装の観点から紹介しました。 v1での課題点 パッケージの構成 :1つのパッケージに「JSONを解析する処理」と「Goの構造体に変換する処理」がすべて混在しており、変更時の影響範囲も広かった エラーメッセージ :JSONのパース(解析)に失敗したとき、どの項目でなぜ失敗したのかがエラーメッセージから読み取りにくかった メモリの使い方 :Unmarshalを呼ぶたびに内部で使うオブジェクト(Decoder)を毎回新しく作成しており、高頻度で呼び出すとメモリ確保やGC(ガベージコレクション)の負担が大きかった データの読み取り方 :JSONデータを読み取るたびに内部でコピーが発生しており、メモリ効率が悪かった v2での改善点 パッケージの分離 :「JSONの解析」を担う jsontext パッケージと「Goの型へのマッピング」を担う json パッケージに分離し、それぞれの役割を明確にした 構造化されたエラー :エラー情報にJSONのどの位置で、どんなJSON型が原因で失敗したかを含めるようにし、原因の特定が容易になった オブジェクトの再利用 :sync.Poolパッケージを使い、一度作った Decoder を使い回すことで、メモリ確保の回数とGCの負担を大幅に削減した 効率的なバッファ管理 :1つのバッファ(データを一時的に保管する領域)を論理的に分割して管理することで、データのコピーなしに必要な部分へアクセスできるようになった このテーマを選んだきっかけ 普段の業務ではREST APIを実装する機会が多く、 encoding/json パッケージを利用する場面も多くありました。しかし、 encoding/json には以前から課題が多く、 golang/go#71497 でも長期にわたって議論が続いています。そんな中、Go 1.25で実験的にv2が追加されました。 go-json-experiment/jsonbench のベンチマーク結果を見ると、v2の Unmarshal 関数は以下の点で大きく改善されていることがわかります。 大幅な速度改善 :具象型で2.7〜10.2倍、RawValue型では最大21.1倍と、v1から劇的に高速化されている 安全性を犠牲にしていない : unsafe パッケージを使用せず、UTF-8の検証や重複キーの拒否などRFC準拠の正確性も向上している ストリーミング対応 :v1では非対応だった Unmarshal のストリーミングにも設計当初から対応している 速度・正確性・安全性のいずれも改善されているという結果から、「なぜこれほど改善できたのか?」を内部設計から理解したいと思い、 アドベントカレンダーの記事 で調査しました。その調査がきっかけとなり、今回プロポーザルを提出しました。 さいごに 今回LT枠ではありますが、初めてGo Conferenceにプロポーザルを提出し、採択していただきました。発表後には「あと20分くらい聞きたかった」や「よく5分でまとめましたね」などとても温かいお声をいただきました。登壇を機に、さまざまな方と繋がれたことは非常に貴重な経験でした。アウトプットがきっかけで生まれる繋がりの大切さを改めて実感しました。また、登壇を機に初めて仙台へ行きました。牛タンやずんだ餅など仙台グルメも堪能でき、カンファレンスと合わせて充実した思い出となりました。 最後に、このような素晴らしい場を作ってくださった運営の皆さまに心から感謝いたします。準備から当日の進行まで、細やかな配慮が行き届いており、登壇者・参加者いずれの立場でも安心して楽しむことができました。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.entry .entry-content .table-of-contents > li > ul { display: none; } はじめに こんにちは、グローバルシステム部フロントエンドブロックの林です。 私が所属するチームでは ZOZOMETRY というBtoBサービスを開発しています。スマートフォンで身体を計測し、計測結果を3Dモデルやデータとして可視化・Web上で管理できるサービスです。 私たちのチームではAIにユニットテストを書かせ、マージまでの過程を改善する施策を実施しました。結果としては、2か月でテスト数が57%増え、カバレッジは約2倍になりました。 この取り組みはテストを増やすという面ではうまくいきましたが、AIが書いたコードを人間がどうレビューするかという点で、いくつかの壁にぶつかりました。 この記事では、以下の点を紹介します。 AIが書いたテストコードを素早くレビューするために、どのような仕組みを設計したのか 運用する中でどのような課題が見えてきて、どう対処したのか AIと協業する開発フローにおいて、人間が関与すべきポイントはどこだったのか 目次 はじめに 目次 背景と課題 テスト生成の仕組み Claude Codeコマンドの設計 統一フォーマット describeのネスト構造 テスト名と日本語コメント テスト対象ごとの実装パターン テストサマリの付与 成果 運用で見えた課題 AIの生成速度と人間のレビュー速度のミスマッチ 「ノールックでマージするのは怖い」 「インプットとアウトプットだけ見ればいい」仮説の崩壊 課題への対策 サマリの自動生成でレビューの入口のハードルを下げる 粒度の制御でレビュー1回あたりの負荷を下げる 目視確認のプロセス化 振り返り:AI協業における人間の関与ポイント AI生成コードのレビューで人間が見るべき範囲 生成速度とレビュー速度のバランス設計 導入コストを下げるアプローチ まとめ 背景と課題 私たちのチームでは、機能開発を優先するあまりテストが慢性的に不足しており、以下のような課題が続いていました。 品質管理はQAチームに大きく依存している状態 テスト作成の品質や粒度にばらつきがある テストの目的や内容を理解するためのドキュメントが十分に整備されておらず、「このテストは何を守っているのか」を説明しにくい 施策の開始時点でのテスト数は324件、カバレッジは4.72%でした。 この状況を改善するにあたって、いくつかの選択肢がありました。人手でテストを書くのが最も確実ですが、機能開発と並行して進めるリソースがありませんでした。AIにテストを生成させれば速度は出ますが、品質の保証は未知数です。 結果として、AIにテストコードを生成させ、人間がレビューする体制を選びました。とはいえ、最初からAIに品質を丸投げできるとは考えていませんでした。この実験にはもう1つの目的がありました。AIと協業するうえで、人間が関与すべきポイントはどこなのか。それを見出すための取り組みでもあったのです。 テスト生成の仕組み テスト生成の仕組みを以下の3点で構成しました。 Claude Codeコマンドによるテスト生成の定型化 統一フォーマットによるテスト構造の標準化 テストサマリの自動付与 Claude Codeコマンドの設計 Claude Codeのカスタムコマンド /create-unit-test を作成しました。このコマンドは対象ファイルのパスを受け取り、以下のワークフローを順に実行します。 対象ファイルの分析 :ファイルタイプ(フック / ユーティリティ / ストア / コンポーネント)を特定し、エクスポートされる関数の一覧や依存関係を把握する テスト設計書の作成 : docs/test-design/ にテスト設計書を生成し、テストケースを正常系・異常系・エッジケースに分類する テストファイルの作成 :設計書に基づいてテストコードを test/unit/ に配置する テスト実行と検証 : pnpm test でテストを実行し、カバレッジを確認する テストサマリの記録 : docs/test-summaries/test-summary.md にテスト内容を追記する # 実行例 /create-unit-test hooks/useClientData.ts /create-unit-test utils/detectGender.ts 各ステップでユーザーの承認を挟む設計にしています。AIに一気に生成させるのではなく、分析→設計→実装→検証の各段階で人間が判断する余地を残しました。 コマンドの設計で重視したのは再現性です。誰が実行しても同じ粒度・同じ構造のテストが生成されることで、レビューする側の認知負荷を一定に保つことを狙いました。 統一フォーマット 生成されるテストの構造を揃えるために、以下のルールを定めました。 describeのネスト構造 テスト対象の関数ごとに describe をグループ化し、その中を Success case / Error case / Edge cases に分類します。 describe ( 'useCreateClient' , () => { describe ( 'Success case' , () => { ... } ); describe ( 'Error case: Argument problems' , () => { ... } ); describe ( 'Error case: Response errors' , () => { ... } ); describe ( 'Edge cases' , () => { ... } ); } ); この構造が揃っていることで、レビュアは「このテストはどの分類のケースを見ているのか」をコードの構造から即座に判断できます。 テスト名と日本語コメント テスト名は should [期待される動作] の形式で統一しました。加えて、各 describe や it の前に日本語コメントを付けることで、テストの意図をコードを読み込まずとも把握できるようにしています。 // 性別判定機能のテスト describe ( 'detectGender' , () => { // 男性の場合、正しいメッセージを返すことを確認 it ( 'should return the correct message for MALE' , () => { expect (detectGender( 'MALE' )).toEqual( 'Male' ); } ); } ); テスト対象ごとの実装パターン 対象のファイルタイプに応じて、テストの書き方を使い分けています。テストケースが少ないフックには renderHook を使い、セットアップを簡潔に保ちます。テストケースが多いフックには直接呼び出しと describe のネストを組み合わせ、テストケースごとの独立性を確保します。ユーティリティ関数は入力と出力の対応を直接検証し、Zustandストアは act で状態更新をラップすることでReactの非同期性に対応しています。 この使い分けもコマンド側で自動的に判断するため、生成されたテストのパターンがばらつくことを防いでいます。 テストサマリの付与 テスト実行後、 docs/test-summaries/test-summary.md にサマリを追記する仕組みを導入しました。サマリには以下の情報を含めています。 テスト対象ファイルとタイプ テスト内容:関数シグネチャと、どの分類(正常系・異常系・エッジケース)をテストしたか テスト結果:成功数 / 全体数 以下は実際のサマリの例です。 ## ` utils/fileName.ts ` - 2025-12-04 14:28:00 **タイプ**: ユーティリティ **テストファイル**: ` test/unit/fileName.test.ts ` ### テスト内容 - ` getDisplayFileName(name, maxLength?, headLength?): string ` - 正常系(短い/長いファイル名、デフォルトパラメータ、境界値)、エッジケース(空文字列、日本語) - ` isValidFileName(name, maxLength?, includeExtension?): boolean ` - 正常系(英数字・日本語・記号)、異常系(不正な拡張子、長さ超過)、エッジケース(複数ドット、最小長) **結果**: ✅ 全テスト成功 (32/32) このサマリはPRのレビュー時にも参照します。レビュアはまずサマリを読んでテストの全体像を把握した後で、実際のコードに問題点がないかを確認するフローにしました。 成果 2か月の実施期間で、ユニットテスト数は324件から509件へ57%増加しました。カバレッジは4.72%から9.25%へ、約2倍に改善しています。 定量的な成果に加えて、以下の定性的な改善もありました。 テスト設計書とサマリが蓄積されたことで、テストの目的やカバー範囲をチーム全体で把握できるようになった テストの構造が統一されたことで、レビュー時に「何を見ればいいか」が明確になった 既存テストの品質を見直すきっかけにもなった 運用で見えた課題 成果は出ましたが、運用する中でレビュー面の課題が顕著になりました。課題の本質は「AIの出力品質」ではなく、正しいと判断するための「検証コスト」にありました。 AIの生成速度と人間のレビュー速度のミスマッチ AIによりPull Request(以下PR)の生成時間が大幅に短縮されたため、未レビューのPRが溜まるようになりました。PRを作った側にはレビュー依頼やリマインドへの心理的障壁が生まれました。レビューする側も次々と届くPRにプレッシャーを感じる状態でした。この状態でチームの生産性を最大化するのは難しいものでした。 「ノールックでマージするのは怖い」 AIが書いた、品質に直結する部分のコードをノールックでマージするのは怖いと感じました。チームで話し合った結果、人間が差分を目視で確認することにしました。 しかし目視確認にも課題が隠れていました。PRの粒度が大きくなりがちで、人間の認知負荷が増加したのです。 「インプットとアウトプットだけ見ればいい」仮説の崩壊 CI/CDで実行を管理しているので、変更されたコードを見なくてもインプット(プロンプト)とアウトプット(テスト実行結果)だけ確認すればいいのではないか。そういった仮説を立てました。 しかし現実には、インプットが本当に期待しているインプットなのかを判断するためのコンテキストが属人化していました。設計や詳細なコードを把握していないメンバーは自力で調査する時間が増え、かえって非効率になりました。この状態を改善しなければ、サービスの品質向上や本質的な改善は難しい状況でした。 課題への対策 これらの課題に対して、3つの施策で対処しました。 サマリの自動生成 AIにプランニングさせ粒度を制御する仕組み 人間が差分を目視で確認するプロセスを明示的に残す サマリの自動生成でレビューの入口のハードルを下げる テストされている箇所の設計や実装を把握していないメンバーでもレビューに入りやすくすることを目的としています。前述のサマリを活用したレビューフローを通じて、不慣れな領域でもテストの全体像をあらかじめ把握した状態でコードレビューへ臨めるようにしました。 これにより、不慣れな領域のレビューに対する心理的障壁を軽減し、迅速にレビューへ入れるようになりました。 粒度の制御でレビュー1回あたりの負荷を下げる コマンド実行時、どの範囲のテストを作成するかAIへプランニングさせる仕組みにしました。PRサイズは100行程度を目安に設定しています。 テストカバレッジを一度に大きく上げたくなりますが、レビューする側の認知負荷を超えないことでレビューに臨むハードルを下げることができました。 目視確認のプロセス化 「ノールックでマージしない」というチームの方針に基づき、人間が差分を目視で確認するプロセスを明示的に残しました。AIの出力を無条件に信頼するのではなく、品質の最終判断は人間が担う体制です。 これらの改善施策により、レビューまでのリードタイムが減りメンバーの心理的な負担も少なくなりました。 振り返り:AI協業における人間の関与ポイント この実験を通じて、AIと協業する開発フローにおけるいくつかの知見が得られました。 AI生成コードのレビューで人間が見るべき範囲 「インプットとアウトプットだけ見ればいい」という仮説は成立しませんでした。コンテキストの共有が前提条件として必要であり、それが属人化している状態では、コードの差分を目視で確認する以外に品質を担保する手段が見つかりませんでした。 チームが出した結論は「差分のコードを目視で確認するのは、やはり人間が担当すべき」というものです。レビューのコストが上がる課題は引き続き残りますが、品質の担保を優先しました。 生成速度とレビュー速度のバランス設計 AIの生成速度に人間が追いつけない構造的な問題に対しては、生成側で粒度を制御することが有効でした。レビュー側の運用を変えるのではなく、生成側の出力を調整するアプローチです。 導入コストを下げるアプローチ 完全に新しいプラクティスを一から導入するのはコストが高いため、現行の開発フローをコンポーネント化し、AIに任せられる部分だけを切り出すアプローチを取りました。大きく変えるのではなく、今あるものの一部を置き換えていく形です。 まとめ AIにテストコードを生成させる施策を通じて、テスト数を57%増やし、カバレッジを約2倍に改善しました。一方で、運用面の課題も見えてきました。AIの生成速度に人間のレビューが追いつかないこと、コンテキストの属人化によりインプット/アウトプットだけでは品質を担保できないことです。 これらの課題に対しては、サマリの自動生成と粒度の制御という仕組み側の改善で対処しました。しかし「人間が差分を目視で確認する」という部分は残しています。ここを自動化できる条件は、まだ見出せていません。 AIと協業する開発フローにおいて、人間が関与すべきポイントはどこなのか。この問いに対する私たちの暫定的な答えは、「コードの差分を確認し、品質を判断すること」です。この判断を下せるのは、コードを書いてきた経験の上に成り立つ審美眼があるからだと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.entry .entry-content .table-of-contents > li > ul { display: none; } はじめに こんにちは、WEAR開発部バックエンドブロックのブロック長を務めている伊藤です。普段は弊社サービスである WEAR のバックエンド開発・組織運営を担当しています。 WEARのバックエンドブロックは約10名のエンジニアで構成されています。組織としてはマトリックス型を採用しており、各メンバーはバックエンドブロックに所属しながら、複数の職種で構成されるスクラムチームにも1〜3名ずつ配置されています。スクラムチームにはPdM(プロダクトマネージャー)やデザイナー、フロントエンドエンジニア、QAなど他職種のメンバーが集まります。加えてリモートワークが基本の環境です。 この体制ではコードレビューのリードタイムが長期化しやすいという課題がありました。本記事では、PRオープンからマージまでの平均時間を約26時間から約11時間へと短縮した取り組みを紹介します。 目次 はじめに 目次 抱えていた課題 コンテキストの分断 レビューのボトルネック化 構造的に後回しになるレビュー 課題を解決したアプローチ 5名ずつの2グループ制 全体朝会+グループ朝会の二段構成 段階的にたどり着いた「もくもくレビュータイム」 Gatherを活用する理由 Findy Team+による指標の可視化と週次改善 コードレビューガイドラインとAIレビューの活用 効果と得られた知見 段階的な施策でリードタイムが半減する コンテキストの把握範囲を狭めることでレビュー速度が上がる 「仕組みだけ」では不十分、同期の時間が文化を変える メトリクスの可視化が「感覚」を「共通言語」に変える AIレビューは「人間のレビューの質」を高める おわりに 抱えていた課題 コンテキストの分断 マトリックス組織では、バックエンドエンジニアが複数のスクラムチームに分散して配置されます。WEARのバックエンドブロックでは約10名が1〜3名ずつ別々のチームに所属しており、隣のメンバーが何を開発しているかが見えにくい状態でした。 PRが作成されても、レビュアーにとってはまず「このPRの背景にある仕様は何か」を理解するところから始まります。コンテキストが共有されていないため、レビューの入口でつまずくことが頻繁に起きていました。 レビューのボトルネック化 WEARのバックエンドブロックでは品質担保のため、2名以上のApproveを必須としています。しかしコンテキストがない状態でのレビューは仕様理解から始まるため、1件あたりの負荷が大きくなります。 改善に取り組む前はレビュアーをランダムに2名アサインしていましたが、得意領域や所属チームがバラバラで、忙しさも人によって異なります。結果として、レビューが後回しになりやすく、PRオープンからマージまでに24時間を超えるケースが多々ありました。 構造的に後回しになるレビュー チーム全員がレビューの重要性は理解していました。しかし、自身のスクラムチームの開発タスクとレビュー依頼が常に競合する状態では、レビューは「割り込みタスク」として後回しにされがちです。 リモートワーク環境では、オフィスで自然に発生する「ちょっと見てほしい」という一声が生まれません。PRを出しても反応のないまま放置される状況が常態化していました。 これは個人の意識の問題ではなく、仕組みで解決すべき構造的な課題でした。 課題を解決したアプローチ 5名ずつの2グループ制 まず、10名を5名ずつの2グループに分けました。グループの編成にあたっては、以下の点を考慮しています。 同じマトリックスチームのメンバーを同一グループにまとめる 関連度の高いチーム(似た領域を触るチーム)やドメインが近い人を同じグループにする ベテラン社員が偏らないようにし、レビューや設計レビューの質にむらが出ないようにする 5名という規模は、全員の作業状況を把握できるギリギリのサイズです。この単位にすることで、「何の仕様に取り組んでいるか」が自然と共有される状態をつくりました。 各グループにはグループリーダーを立て、グループ単位でPDCAを自走できる体制にしています。リーダーがグループ内の課題を拾い、改善施策を回しています。そこで得られた知見はもう一方のグループにも共有し、チーム全体の底上げにつなげています。 全体朝会+グループ朝会の二段構成 毎日の朝会は、全体朝会(30分)とグループ朝会(30分)の二段構成で運用しています。 全体朝会(30分) では、バックエンドブロック全員が集まり、以下の内容を共有します。 小話やLT(チームの雑談・学びの共有) タスク共有(各メンバーの作業状況) 案件共有(お問い合わせ対応のアサインなど) 共有・相談(曜日ごとに担当者が議題を持ち寄る) グループ朝会(30分) では、各グループに分かれて以下を行います。 各スクラムチームから現在の作業状況を報告する チームメンバーのOpen PRを確認し、レビュー依頼をリマインドする 新規PRはPR作成者が画面共有しながらメンバーに内容を説明する 朝会後はそのまま「もくもくレビュータイム」としてレビューに取り組む(詳細は後述) 週1回、Findy Team+のチーム比較を確認し、1週間の振り返りと改善点を話し合う グループ朝会の司会は1週間交代で担当します。特定の誰かに運営が偏らないようにすることで、全員が主体的に関わる仕組みにしています。 ポイントは、グループ朝会で「未レビューのPR」を毎日確認する仕組みにしていることです。これにより、PRが誰の目にも触れずに放置されるという事態を構造的に防いでいます。 段階的にたどり着いた「もくもくレビュータイム」 実は、最初からレビュー専用時間を設けていたわけではありません。取り組みの初期はグループを分けて朝会でPRを確認するところから始めました。 それだけでもリードタイムは改善しましたが、新たな課題が見えてきました。朝会でPRの内容を共有しても、レビューに取り組む時間が仕組みとして確保されていなかったため、結局は各自の開発タスクが優先されがちでした。 そこで、グループ朝会の後にそのまま「もくもくレビュータイム」を設けることにしました。朝会が終わったらそのまま Gather (仮想オフィスツール)に残り、レビューに取り組みます。 もくもくレビュータイムの運用ルールは以下の通りです。 Gatherに集まり、各自が黙々とレビューする 必須でレビューしてほしい人がいる場合は、PR内でその人をメンションしておく メンションされたPRを優先的に確認し、メンションされた人のレビューは必須とする メンションは任意とし、各自の判断で行う(例:その機能に詳しい人へ仕様チェックを依頼したい場合など) この「朝会→もくもくレビュータイム」の流れを毎日のリズムとして定着させたことで、レビューが「空いた時間にやるタスク」から「毎日の習慣」に変わりました。 さらに、朝会後のレビュータイムとは別に、午後にも30分のもくもくレビュータイムを設けています。午前と午後の2回、同期的にレビューする接点をつくることで、1日を通してPRを素早くキャッチできるようになっています。 以下は、1日の流れを図にしたものです。 Gatherを活用する理由 もくもくレビュータイムにGatherの仮想オフィスを使っているのには明確な理由があります。 まず、レビュー中に聞きたいことがあればその場ですぐに声をかけられます。MTGをセットしたりSlackで非同期にやりとりしたりする必要がありません。さらに、他のメンバーが質問している内容も一緒に聞こえるので、自然と共通認識が形成されます。 リモートワークでは「ちょっと聞く」のハードルが高くなりがちですが、Gatherで同じ空間にいることで、オフィスの隣の席で気軽に質問するような感覚を再現できています。 Findy Team+による指標の可視化と週次改善 チームの開発パフォーマンスを Findy Team+ で継続的に計測しています。設定している目標値は以下の通りです。 PRオープン〜マージ:16時間以内 PRオープン〜1人目のレビュー:3時間以内 レビュー〜マージ:13時間以内 以下は実際にチームで確認しているレビューサマリの画面です。 週1回のグループ朝会で、2つのグループ間でリードタイムを比較し、「どこにボトルネックがあったか」を具体的に議論しています。以下はグループ間の比較画面です。 数値があることで「なんとなく遅い」ではなく「今週は1人目のレビューまでが遅かったのはなぜか」という建設的な振り返りができるようになりました。 Findy Team+の計測対象からの除外漏れがないかも毎週確認しています。具体的には、Dependabotによる自動PR、他部署の作業待ちが発生するPR、検証が必要でやむを得ずマージを保留するPRなど、チームのレビュープロセスの実態を反映しないものを除外しています。正確な数値を維持することで、指標の信頼性を保ち、チーム全体が同じデータを見て議論できる状態を担保しています。 グループ間の比較は健全な競争意識にもつながっています。「今週は相手グループの方がリードタイムを短縮できていた」という事実は、翌週の改善アクションを自発的に生み出す原動力になっています。この仕組みによって、改善が一時的な取り組みではなく、継続的に回り続けるサイクルとして定着しました。 コードレビューガイドラインとAIレビューの活用 レビュー観点を明文化したガイドラインを整備しました。以下の観点を体系的に定義し、レビュアーごとの品質のばらつきを低減しています。 Railsのベストプラクティス(RESTfulなAPI設計、Strong Parametersの適切な使用など) セキュリティ(SQLインジェクション対策、JWT認証、環境変数による機密情報の管理など) パフォーマンス(N+1クエリの検出、 nolock スコープによるロック回避、バッチ処理など) API設計(バージョニングの整合性、エラーレスポンスの統一フォーマットなど) テスト(RSpecのベストプラクティス、FactoryBotによる適切なテストデータ生成など) プロジェクト固有の規約(設計思想ドキュメントへの準拠、既存パターンとの一貫性など) 加えて、GitHub ActionsとClaude(Anthropic)を組み合わせたAIレビューの仕組みを導入しました。PRのコメントで @claude-review と呼びかけるだけで、上記ガイドラインに沿った自動レビューが実行されます。PRの差分を読み取り、インラインコメントと全体のまとめを日本語で返すため、人間のレビュアーが着手する前の一次スクリーニングとして機能しています。 実際のレビューでは、以下のフィードバックが返ってきます。 まとめコメント(抜粋) 🟡 Important N+1クエリ対策 : preload ではなく includes の使用を推奨 nolock スコープの使用 : 読み取り専用クエリでのパフォーマンス最適化 🟢 良い点 適切なバッチ処理: find_in_batches を使用してメモリ効率を考慮 充実したテストカバレッジ: 網羅的なテストケースで品質を担保 インラインコメント(抜粋) パラメータの型定義が既存のAPIと一貫していません。他のエンドポイントでは integer で定義されているため、一貫性を保つために型を変更することを推奨します。 注目すべきは、単に一般的なベストプラクティスを指摘するだけでなく、プロジェクト固有の設計思想ドキュメントや既存の実装パターンを踏まえた指摘をする点です。これは、AIレビューのプロンプトに「まずCLAUDE.mdと設計思想ドキュメントを読んでからレビューせよ」と指示しているためです。 また、PR作成前の段階でもClaude CodeやCursor、Codexなど、各メンバーがそれぞれのAIツールを使ってセルフレビューしています。AIのセルフレビュー → @claude-review を使った機械レビュー → 人間によるレビューという多段構成を取っています。これにより、人間のレビュアーが設計判断やビジネスロジックの妥当性に注力できる環境を整えています。 効果と得られた知見 段階的な施策でリードタイムが半減する 以下は、約2年間のリードタイム推移です。グループ制の導入(2024年4月)、生成AIによるPR数増加(2025年8月頃)、もくもくレビュータイム導入(2025年10月)の前後で変化が見て取れます。 各フェーズの平均時間は以下の通りです。 改善前(〜2024年3月) :約26時間 グループ制導入後(2024年4月〜) :約16時間まで短縮 生成AIによるコーディング普及後(2025年8月頃〜) :PR数が週4〜6件から週8〜12件へ約2倍に増加し、約18時間へ上昇 AIレビュー・もくもくレビュータイム導入後(2025年10月〜) :約11時間まで短縮 グループ制だけでも約10時間の改善がありましたが、生成AIの活用でPR数が約2倍に増えた際、一時的にリードタイムが上昇しました。そこにもくもくレビュータイムとAIレビューを組み合わせることで、PR数が増えた状態でもさらに短縮できています。 コンテキストの把握範囲を狭めることでレビュー速度が上がる チームを分けてレビューすることで、各メンバーが把握すべきコンテキストの範囲が大幅に狭まりました。10名全員の状況を追うのではなく、5名の動きだけ把握すればレビューに入れる状態をつくったことが、最も効果の大きかった施策です。 グループ朝会で毎日Open PRを確認する運用と組み合わせることで、「誰がどんなPRを出しているか」が常に頭に入っている状態になります。レビューに着手する際のコンテキストスイッチのコストが大幅に下がりました。 「仕組みだけ」では不十分、同期の時間が文化を変える グループ分けと朝会での情報共有だけでは、レビューのリードタイムは十分には改善しませんでした。転機となったのは「もくもくレビュータイム」の導入です。 情報を共有しても、レビューする「時間」が確保されていなければ結局後回しになります。午前と午後に同期的な接点を設け、「みんなが同じタイミングでレビューする」という習慣を作ったことで、レビューが日常のリズムの一部に変わりました。 重要なのは長い会議を増やすことではなく、短い同期時間を毎日の習慣として組み込むことです。 メトリクスの可視化が「感覚」を「共通言語」に変える Findy Team+の数値とグループ間比較により、改善が「個人の頑張り」ではなく「チームの仕組み」として回るようになりました。 特に週1回のFindy Team+チェックを定例化したことで、数値が悪化したときに早く気づき、翌週の改善アクションにつなげるサイクルが定着しています。ボトルネックを感覚ではなくファクトで議論できることが、継続的な改善を支えています。 AIレビューは「人間のレビューの質」を高める AIレビューの効果は、リードタイム短縮だけではありません。コーディング規約への準拠やN+1クエリの検出といった機械的に判断できる指摘をAIが担うことで、人間のレビュアーがそれらを一つひとつ確認する必要がなくなりました。その分、設計判断やビジネスロジックの妥当性といった、より本質的な観点へ集中できるようになっています。 また、PR作成者自身がAIツールでセルフレビューしてからPRを出すことで、レビュー時の指摘事項が減り、レビュー1件あたりの負荷が下がっています。結果として、レビューの「速度」と「質」を両立できる状態に近づいています。 おわりに レビューのリードタイム改善は、個人の意識改革ではなく仕組みの設計で実現できます。本記事で紹介した施策をまとめると、以下の4点に集約されます。 認知範囲の縮小 :グループを分けることで、把握すべきコンテキストを絞る 同期の接点の設計 :朝会でPRを共有し、もくもくレビュータイムで実行する。午前と午後に接点を分散させることで情報のキャッチアップを早める 指標の可視化 :Findy Team+で数値を計測し、週1回振り返る。数値で語れる文化をつくり、改善を仕組み化する AIによるレビュー品質の底上げ :AIレビューとセルフレビューで定型的な指摘を自動化し、人間は設計判断に集中する 私たちのチームも最初からうまくいったわけではありません。グループ分けだけでは足りず、レビュー専用時間の追加やFindy Team+での振り返りの定例化、AIレビューの導入など、段階的に改善を重ねてきました。結果として、PRオープンからマージまでの平均時間は約26時間から約11時間へと短縮しています。 マトリックス組織×リモートという環境は、コードレビューにとって不利な条件が揃いやすい構造です。しかし適切な単位でチームを分割し、同期と非同期のバランスを設計し、指標で振り返る仕組みを整えれば、質を落とさずに速度を上げることは十分に可能です。 約11時間まで短縮できましたが、改善の余地はまだあると考えています。AIレビューのプロンプトを磨いてレビュー精度を高めることや、AIレビューの品質向上を前提に2名Approveのルール自体を見直すことなど、取り組みたいテーマは尽きません。今後もチームの変化に合わせて仕組みをアップデートしていきます。 同様の課題を抱えるチームにとって、本記事が何かの参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは、新規事業部フロントエンドブロックの 池田 です。普段は ZOZOマッチ のアプリ開発を担当しています。ZOZOマッチは、ファッションの好みからZOZO独自のAIが「好みの雰囲気」の相手を紹介するマッチングアプリです。開発にはFlutterを採用しています。 フロントエンドブロックは2024年に発足したチームです。発足間もないチームゆえに、開発を進める中でさまざまな課題に直面しました。本記事では、私たちが「課題をチーム全体で認識し、解決していける文化」を築くために取り組んできたことを紹介します。発足間もないチームでチームビルディングに悩んでいる方や、メンバー間の連携・知見共有に課題を感じている方、新規事業部の取り組みに興味のある方の参考になれば幸いです。 目次 はじめに 目次 背景・課題 取り組み KPTによる改善サイクル KPTから生まれた改善施策 進捗・困りごとの可視化 AIエージェントツール知見共有の仕組みづくり PRレビュー速度の改善 まとめ 背景・課題 フロントエンドブロックは3名と外部パートナーで構成されるチームです。新規事業部では、市場の変化に素早く対応しながらプロダクトを成長させていく必要があります。そのため、少人数でもスピード感を持って開発を進められる体制と、走りながら改善していける柔軟性が求められます。しかし、発足から日が浅いこともあり、チームとして改善サイクルを回す文化がまだ根付いていませんでした。開発プロセスや技術的な課題に直面しても、その解決が個人の力量に左右される状況があり、課題が個人の中に閉じてしまっていました。 具体的には以下のような問題がありました。 メンバーの困りごとが見えにくい :各メンバーの抱える課題や悩みがチーム内で可視化されておらず、助け合いやサポートが難しい状態だった 改善を議論する場がない :課題を感じても、改善案を提案・議論する場が整備されていなかったため、ナレッジがチームに蓄積されなかった こうした状況を打開するには、まずチーム全体で課題を共有し、改善を積み重ねていける仕組みづくりが必要でした。 取り組み ここからは、これらの課題に対してチームで取り組んできた改善施策を紹介します。まず改善サイクルを回すための仕組みとしてKPTを導入し、その中で見えてきた具体的な課題に対して個別の施策を実施してきました。 KPTによる改善サイクル チームとして改善を回していく文化を根付かせるため、KPT形式の振り返り会を実施しています。KPTでは、Keep(良かったこと)・Problem(課題)・Try(次に試すこと)の3つの観点で振り返ります。案件でやって良かった取り組みや大変だったことを洗い出し、次の案件へ活かせるようにしています。 振り返りの流れ 振り返りにはMiroを活用しています。具体的な流れは以下のとおりです。 付箋の記入 :各メンバーがKeep・Problem・Tryの各エリアに付箋を貼る 投票 :特に議論したい項目に投票し、優先度を付ける 議論 :投票数の多い項目を中心に議論し、Problemに対しては具体的なTryを設定する 議論の際は、単に事象を共有するだけでなく、一歩踏み込んだ振り返りを意識しています。Keepについては「なぜうまくいったのか」を深掘りし、成功要因を言語化することで再現性を高めています。Problemについては「今後どうすればうまくいくか」をチームで話し合い、具体的な改善策を導き出すようにしています。次回の振り返りでは前回のTryの効果を検証し、継続するか改善するかを判断します。このサイクルを回すことで、個人の中で閉じていた課題がチーム全体で共有され、改善へつなげられるようになりました。 ツールと頻度の選定 振り返りツールにはいくつかの選択肢を試しました。当初は Findy Team+ のKPT振り返り機能や Parabol を使っていました。しかし、Miroに慣れているメンバーが多かったことと、付箋からJiraチケットへの変換が容易だったことから、最終的にMiroを採用しました。 頻度は隔週1時間程度で実施しています。 KPTを継続する中で、メンバー自らが課題を発見し改善策を提案するボトムアップの文化が根付いてきました。以降で紹介する施策も、その多くはKPTでメンバーから挙がった声がきっかけになっています。今後は付箋の数が減ってきたタイミングで、イベントタイムラインなどを取り入れることも検討していきたいと考えています。 KPTから生まれた改善施策 進捗・困りごとの可視化 KPTで「メンバーの困りごとが見えにくい」という課題が挙がりました。開発初期は特に実装するチケットが多く、誰がどのタスクを進めているのか、どこまで進んでいるのかが見えづらい状況でした。メンバーの進捗を日常的に把握するため、以下の取り組みを行っています。 朝のSlackスレッドでの共有 毎朝Slackのリマインダーが自動投稿されるので、出勤したらそのスレッドに今日やることを書くようにしています。テキストで残すことで、非同期でも状況を把握でき、困りごとがあればすぐにフォローできる体制が整いました。 スレッド内でのやり取りなので、気軽に質問を投げられるのもポイントです。業務に関する質問だけでなく、雑談や改善提案の話もそこから自然と生まれるようになりました。 夕会でのアクティブなスプリントの確認 フロントエンドブロックでは毎日夕会を実施し、今日やったタスクと困っていることを共有しています。 以前はメンバーがそれぞれやったことをConfluenceに書いて報告していました。しかし、この方法にはいくつかの課題がありました。 アサインされているチケットがどれくらいあるのか見えづらい レビュー待ちのチケットが溜まっているのか把握しにくい 書く人によってタスクの粒度が異なり、状況を正確に把握しづらい これらの課題を解決するため、Jiraのアクティブなスプリントを画面共有しながら進捗を確認する運用に変更しました。スクラムボードのアクティブなスプリントでは、現在進行中のタスクをステータス別(未着手・進行中・レビュー待ち・完了など)に並べカンバン形式で確認できます。 この変更により、各メンバーがアサインされているチケットやステータスが一目でわかるようになりました。ステータスが長く変わっていないチケットも把握できるため、困っていることがないか声をかけやすくなりました。また、報告のために文章を書く手間が減り、ボードを見ながら自然と議論が生まれるようにもなりました。 また、夕会では明日やることも共有しています。アサインされているチケットが前倒しで完了した人にはチケットが多い人から分配するといった、チーム内での負荷調整もこの時間で実施しています。 AIエージェントツール知見共有の仕組みづくり KPTでは「AI活用をチーム全体に広げたい」という声が挙がりました。ZOZOではClaude CodeやGitHub Copilotなど、さまざまな開発AIエージェントツールを利用できる環境が整っています 1 。新規事業部では、こうした新しい技術やツールを積極的に取り入れ、開発プロセスの改善にチャレンジできる文化があります。私たちのチームでは、執筆時点ではClaude Codeをメインに、実装からPR作成・レビュー・CI修正まで開発プロセス全体で活用しています。特定のツールに限定するルールは設けておらず、Codexなど他のツールで検証するメンバーもいますが、チーム全体としてはClaude Codeの利用が中心です。しかし、AIツールを使いこなしているメンバーとそうでないメンバーとの間に差があり、チーム全体で活用していくにはまだ改善の余地がある状態でした。 この状況を改善するため、チーム内で知見を蓄積・共有するための仕組みを整備しました。 AI関連の知見を集約するSlackチャンネル AI活用に関する知見を集約する専用のSlackチャンネルを開設しました。このチャンネルにはエンジニアだけでなく、PMやビジネスなどのメンバーも参加しており、日々の業務改善にAIを活用できないかざっくばらんに話し合っています。チャンネルでは以下のような情報を共有しています。 「こういう場面で使えた」という活用事例 ツールの設定方法やTips 勉強になった記事の共有 記事を共有する際には、チームとして取り組めそうな部分についても議論しています。チャンネルを開設したことで、メンバー全体のAI活用度が向上しました。また、AIに関する質問や相談が気軽にできるようになり、知見がチームへストックされるようになりました。 Claude Codeプラグインの共有リポジトリ Claude Codeにはプラグインとマーケットプレイスという機能があります。プラグインはClaude Codeの機能を拡張するための仕組みです。 公式ドキュメント では以下のように説明されています。 Plugins let you extend Claude Code with custom functionality that can be shared across projects and teams. プラグインには、再利用可能な命令セットである「スキル」、外部ツールと連携するための「MCPサーバー」、イベント駆動型の自動化を行う「フック」などのコンポーネントを含めることができます。マーケットプレイスは、これらのプラグインを配布・共有するためのカタログです。マーケットプレイスを追加すると、そこに登録されているプラグインを簡単にインストールできます。 私たちはこの機能を活用し、プロジェクト用の共有リポジトリを作成しました。このリポジトリは以下の目的で整備しています。 プロジェクト全体でAI活用できる環境を整備する チーム間でのAIの知見を共有できるようにする 車輪の再発明を防ぐ リポジトリには、各チームが必要なプラグインを追加していく運用にしています。現在はAtlassian MCP関連、Git関連、FlutterやWeb関連など、さまざまな用途のプラグインが集約されています。 リポジトリの構成は以下のようになっています。 plugins/ ├── atlassian-mcp/ ├── browser/ ├── flutter/ │ ├── .claude-plugin/ │ ├── skills/ │ └── .mcp.json ├── git/ │ └── .claude-plugin/ └── commands/ ├── branch/ ├── commit/ ├── issue/ └── pr/ └── help.md 各プラグイン( flutter/ 、 git/ など)の中には、 .claude-plugin/ ディレクトリやスキル、MCPサーバーの設定ファイルが含まれています。 commands/ ディレクトリには、ブランチ作成やコミット、PR作成などの汎用的なカスタムコマンドを集約しています。 運用としては、新しいコマンドやプラグインを追加したい場合はPRを出してもらい、レビュー後にマージする流れです。また、新規プラグインをメンバーがキャッチアップできるよう、リポジトリの更新内容を先述のAI知見共有チャンネルへ自動投稿するGitHub ActionsのWorkflowも導入しています。 共有リポジトリを整備した1つ目のメリットは、Claude Codeのマーケットプレイス機能を活用することで、導入の手間を大幅に削減できる点です。プロジェクトの .claude/settings.json に extraKnownMarketplaces を設定すると、メンバーがプロジェクトを開いた際にプラグインがインストール候補として提示されます 2 。この設定ファイルはGitで管理されるため、チーム全体で共有でき、新しいメンバーも特別な手順なしで利用を開始できます。また、マーケットプレイスの自動アップデート機能を有効にすると、プラグインに更新があった際に自動で最新バージョンに更新されます 3 。そのため、チーム全体で常に最新のプラグインを利用できます。 { " enabledPlugins ": { " flutter@xxxx-marketplace ": true , " git@xxxx-marketplace ": true } , " extraKnownMarketplaces ": { " team-tools ": { " source ": { " source ": " github ", " repo ": " org/claude-plugins " } } , " project-specific ": { " source ": { " source ": " git ", " url ": " https://github.com/org/claude-plugins.git " } } } } 2つ目のメリットは、アプリ・バックエンド・Webの各チームで個別に管理していたスキルを一元管理できるようになり、知見の共有が促進された点です。同じようなプラグインを各チームで作成する重複作業がなくなり、他のチームが活用している便利なスキルをキャッチアップしやすくなりました。 PRレビュー速度の改善 KPTでは「PRレビューが遅い」という課題も繰り返し挙がっていました。レビュー依頼からマージまでのリードタイムが長く、各自の実装タスクが優先されることでレビューが後回しになりがちでした。この状況を改善するため、以下の取り組みを行いました。 Findy Team+によるレビュー状況の可視化 Findy Team+を利用し、PRのレビュー時間やサイクルタイムを可視化しています。KPT振り返り会では、Findy Team+のサイクルタイム分析やレビュー分析を定期的に確認しています。全体の指標を俯瞰しつつ、数値が悪化している項目を重点的にチェックすることで、開発プロセスのどこにボトルネックがあるのかをチームで共通認識として持てるようになりました。 実際にこの分析を通じて、KPTで挙がっていた「PRレビューが遅い」という課題がデータでも裏付けられました。感覚的な指摘が数値として可視化されたことで、具体的な改善アクションへつなげられるようになりました。 Google Engineering Practices Documentationの輪読会 レビュー待ち時間が長いという課題を受けて、 Google Engineering Practices Documentation の輪読会を実施しました。このドキュメントでは、コードレビューが遅れることによる弊害として以下の点が挙げられています。 チーム全体の開発速度が低下する :新機能やバグ修正のリリースが遅延し、チーム全体のベロシティに影響を与える 開発者の不満が増加する :レビューの滞りは開発者のモチベーション低下を招き、チームの雰囲気にも悪影響を及ぼす コード品質が悪化する :レビューの遅れはPRの肥大化を招き、結果的にレビューの質も低下する悪循環に陥る 輪読会を通じて、これらの弊害をチームで共有しました。また、コードレビューはタスクの合間に行うものではなく、優先度の高い作業として扱うべきという認識を揃えることができました。さらに、輪読会はメンバー同士がコードレビューに対する考え方や心理的なハードルを知る機会にもなりました。「どこまで指摘すべきか迷う」「実装チケットを優先的に終わらせたい」といった悩みを共有することで、お互いの視点を理解し、レビューの進め方について建設的な議論ができました。 AI活用による効率化 レビュー時に「不具合の原因や実装背景が分かりづらい」「動作確認の手順が不明確」といった意見も挙がっていました。これらの課題についても、Claude Codeを活用して改善に取り組んでいます。具体的には、以下のようなカスタムスキルを整備しました。 スキル 用途 /pr-create 変更内容の要約に加え、不具合の原因や実装背景、動作確認の手順を含めたPRを作成する /pr-review コード規約やベストプラクティスに基づいたレビューコメントを生成する /pr-ci-fix CIの失敗原因を分析し、修正してコミットする /flutter-code-review ZOZOマッチアプリのコード規約をスキルとして登録し、実装やレビューの際に規約に沿った指摘ができるようにする これらのスキルにより、定型的な作業の時間を削減し、本質的なレビューへ集中できるようになりました。 さらに、Codexを活用したPRの自動レビューも導入しています。PRをオープンすると自動でCodexがコードレビューを実施するため、レビュアーはCodexの指摘を踏まえつつ、人間ならではの観点でレビューできるようになりました。 PRレビュー改善の成果 これらの取り組みの結果、Findy Team+の各分析で改善が確認できました。 サイクルタイム分析では、以下の改善が見られました。 PR作成数が約3倍に増加 :AI活用の促進やレビュープロセスの改善により、PRを細かく分割して作成する文化が浸透しました オープンからマージまでの平均時間を約70%改善 :レビュー待ち時間の可視化やCodexによる自動レビューの導入により、レビューのリードタイムが大幅に改善しました 一方で、グラフからはPR作成数が多い週ではマージまでの時間が増加する傾向も見て取れます。PRの量が増えるとレビュー負荷が高まり、結果としてリードタイムが延びてしまうという課題が残っています。今後は、レビュー体制の強化やさらなる自動化を通じて、PR数が増加してもマージまでの時間を維持・短縮できる仕組みづくりに取り組んでいきたいと考えています。 レビュー分析でも、Codexでの自動レビューとClaude Codeのスキル整備の前後で、各指標に改善が見られました。 指標 改善前 改善後 オープンからレビューまでの平均時間 10.3h 7.5h レビュー依頼からレビューまでの平均時間 10.1h 8.1h レビューからアプルーブまでの平均時間 17.1h 9.3h 特にレビューからアプルーブまでの平均時間は17.1hから9.3hへと約46%改善しました。Codexによる自動レビューでレビュアーの負担が軽減されたことに加え、輪読会を通じてレビューの優先度に対する意識が変わったことも、この改善に寄与していると考えています。 まとめ 本記事では、発足間もないチームが「課題をチーム全体で認識し、解決していける文化」を築くために取り組んできたことを紹介しました。KPTによる振り返りを起点に、進捗・困りごとの可視化、AIエージェントツールの知見共有、PRレビュー速度の改善といった施策を実施してきました。これらの取り組みを通じて、個人の中に閉じていた課題がチーム全体で共有され、継続的に改善を回せる体制を整えることができました。今後はDevinやFigma MakeといったAIツールも活用しながら、チーム内の改善にとどまらず、プロジェクト全体に対しても改善を働きかけていきたいと考えています。発足間もないチームでチームビルディングに悩んでいる方や、改善サイクルを回す仕組みづくりに課題を感じている方の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co 2026年1月時点におけるZOZOで利用可能な代表的なAIツールは「 ZOZOにおけるAI活用の現在 ~開発組織全体での取り組みと試行錯誤~ 」をご参照ください ↩ Configure team marketplaces - Claude Code ↩ Configure auto updates - Claude Code ↩
アバター
.entry .entry-content .table-of-contents > li > ul { display: none; } はじめに こんにちは、カート決済部カート決済基盤ブロックの林です。普段はZOZOTOWN内のカート機能や決済機能の開発、保守運用、リプレイスを担当しています。 ZOZOTOWNの購入フローは、セッションに強く依存したロジックが長年の改修により肥大化し、機能改善や保守の際の調査・改修コストが増大していました。この課題を解決するため、私たちのチームは2024年5月から約2年にわたる段階的なリプレイスプロジェクトを進めています。 ミッションクリティカルな購入フローを無停止で移行するため、私たちは3つのフェーズに分けた段階的なアプローチを採用しました。本記事では、その実践的な進め方と、実際に直面した課題について紹介します。 なお、同じチームの多田と三浦が、このリプレイスにおけるアーキテクチャ選択(モジュラモノリス)の背景と設計について別の記事で紹介していますので、併せてご覧ください。 techblog.zozo.com 目次 はじめに 目次 背景・課題 セッションに依存したロジックの肥大化 サービス無停止でのリプレイス要件 新システムの構成とセッション問題の解決 既存システムの問題点 新システムの構成 Shopping BFF + Redisによる解決 リプレイス戦略の全体像 なぜ段階的なアプローチを選んだか 3つのフェーズに分けたアプローチ フェーズの関係性 現在の進捗状況 フェーズ1:一部機能の比較フェーズ 比較対象の選定 並行稼働の仕組み 比較・検証方法 フェーズ1で得られた知見 フェーズ2:段階的な入れ替えフェーズ 入れ替え順序の工夫 切り替えの仕組み 二重開発の最小化 フェーズ2の成果 フェーズ3:全体リプレイス n%リリースによる段階的な移行 n%リリースの設計方針 各段階での検証項目 全体リプレイスにおける課題 課題への対応策 今回の課題と今後の改善点 今後の展望 まとめ 背景・課題 セッションに依存したロジックの肥大化 ZOZOTOWNの購入フローは、Classic ASP(以下、ASP)で実装されており、長年の改修によりセッション情報がいたるところで更新される構造になっていました。この結果、ロジック間の依存関係が複雑化し、機能改善や保守の際の調査コストが増大するとともに、部分的な入れ替えも困難な状態でした。 サービス無停止でのリプレイス要件 ZOZOTOWNの購入フローは、ECサイトの中核をなすミッションクリティカルな領域のため、サービスを停止できません。さらに、リプレイス期間中であっても、新規機能の追加や運用改善は継続的に発生し、それらは既存ロジック側とリプレイス側のどちらにも反映する必要がありました。 その結果、移行にあたっては以下の制約を考慮する必要がありました。 ビッグバン切替の回避 :一括での全面切り替えはリスクが高すぎる 二重開発の不可避 :新旧両システムを並走する以上、修正は両方に入れる必要がある 段階的検証の必須化 :各段階で十分に検証しながら、慎重に移行を進める必要がある これらの課題に対し、私たちは新システムの構築と段階的な移行戦略により解決を図りました。 新システムの構成とセッション問題の解決 既存システムの問題点 既存のASPシステムでは、購入フロー内のいたるところでセッション情報が更新される構造になっており、以下の問題がありました。 1. セッション更新の分散 どのタイミングで何が更新されるか追跡困難 機能改善時の影響範囲を特定しづらい デバッグやトラブルシューティングに時間がかかる 2. 部分的な入れ替えの困難さ セッション情報と密結合しているため、機能単位での切り出しが難しい 段階的なリプレイスを阻害する要因 3. 保守性の低下 新規メンバーがシステムを理解するのに時間がかかる セッション構造の変更が困難 新システムの構成 これらの問題を解決するため、責務を明確に分離した構成を採用しました。 既存システム(リプレイス前) ASP すべてのロジックとセッション管理が密結合 新システム(リプレイス後) 画面層(以下、Shopping BFF) ユーザーの入力値や購入フロー内の一時的な状態を管理 セッションで管理していた情報を専用のRedisで管理(購入フローに閉じた情報のみ) Java/Spring Boot ビジネスロジック層(以下、Shopping API) ビジネスロジックの中核を担う カート、注文、決済などのドメインロジックを実装 Java/Spring Boot、モジュラモノリス構成 Shopping BFF + Redisによる解決 新システムでは、Shopping BFFで状態管理を集約する構成にしました。 設計方針 購入フローに閉じた情報のみをBFFで管理 他のサービスと共有する必要のないデータ 注文プロセス中のみ必要な一時的な状態 Redisでの永続化 ZOZOTOWN共通のセッションではなく専用のRedisで管理 TTL(Time To Live)を設定し、不要なデータは自動削除 メリット 管理箇所の集約 :BFFに状態管理を集中させた結果、見通しが向上 独立性の確保 :各ドメイン(カート、注文、決済)の状態が明確に分離 状態管理をShopping BFFに集約し、ビジネスロジックをShopping APIに分離したことで、状態とロジックの責務を明確に切り分けました。これにより、セッション依存による密結合は解消され、機能単位での段階的な入れ替えが可能になりました。あわせて、システム全体の見通しと保守性が向上しました。 リプレイス戦略の全体像 なぜ段階的なアプローチを選んだか 段階的なアプローチを採用した理由は以下の通りです。 リスクの分散 :機能を小さく区切って検証し、問題発生時の影響範囲を限定する 二重開発の最小化 :新実装へ移行した範囲は旧実装への改修を減らせるため、早期リリースで並行開発の期間を短縮する 継続的なフィードバック :各フェーズで得られた知見を次のフェーズへ反映する チームのモチベーション維持 :小さな成功体験を積み重ね、長期プロジェクトでも前進感を保つ 3つのフェーズに分けたアプローチ これらの理由から、私たちは以下の3つのフェーズに分けてリプレイスを進めてきました。 フェーズ1:一部機能の比較フェーズ 既存ロジック(ASP)と新ロジックを並行稼働 結果を比較・検証し、差分を解消 フェーズ2:段階的な入れ替えフェーズ 比較で問題ないことを確認した機能から順次切り替え 二重開発の負担を最小化 フェーズ3:全体リプレイス 画面を含めた購入フロー全体を新システムに移行 n%リリースで段階的にトラフィックを切り替え フェーズ1とフェーズ2を機能単位で繰り返し、重要機能の切り替えが完了した段階でフェーズ3を実施します。 フェーズの関係性 フェーズ1・2ではビジネスロジック層のリプレイスを先行して進め、フェーズ3では画面層のリプレイスを実施する計画です。ビジネスロジック層を先に安定化させることで、画面リプレイス時には既に検証済みのAPIを使用できる体制を整えています。 現在の進捗状況 2026年2月時点で、フェーズ1・2は既にリリースが完了しており、本番環境で稼働しています。フェーズ3については開発が完了し、現在はリリースに向けた最終準備を進めている段階です。 次の章から、各フェーズの詳細について説明します。 フェーズ1:一部機能の比較フェーズ 比較対象の選定 リスクを最小限に抑えるため、 読み取り系の機能 を比較対象としました。具体的には以下の機能です。 お届け先一覧の取得 支払い方法一覧の取得 配送日時の指定一覧の取得 これらの機能を選んだ理由は以下の通りです。 読み取り専用 :データの更新を伴わないため、万が一の不具合でもユーザーへの影響が限定的 検証が容易 :結果の比較が容易で、差分の原因を特定しやすい 段階的な複雑化 :条件が少ないお届け先一覧から始め、徐々にロジックが複雑な機能へと比較対象を拡大 即時停止が可能 :比較モードを停止するだけで対応可能 並行稼働の仕組み 既存のASPから新しいShopping APIを呼び出す形で並行稼働を実現しました。以下がその仕組みです。 この仕組みの重要なポイントは以下の通りです。 フラグによる制御 :対象機能を柔軟に切り替えられ、トラフィックの切り替えも即座に実施できる ASP側での比較 :両方の結果をASP側で受け取り、差分をチェックする ユーザーへの影響なし :ユーザーには常にASPの結果を返すため、体験に影響を与えない 比較情報の保存 :各種情報をJSONで保存し、集計・分析できる 負荷の考慮 :比較は一部サーバーに限定して実施する 比較・検証方法 保存した比較情報を定期的にチェックし、以下の観点で検証しました。 一致率の確認 :どの程度の割合で結果が一致しているか 差分の原因分析 :不一致の場合、どのようなケースで発生しているか 優先度の判断 :ビジネスへの影響度から修正の優先順位を決定 特に重要だったのは、差分が発生した際の 根本原因の特定 です。多くの場合、以下のような原因がありました。 ASP側のロジックの暗黙的な仕様(ドキュメント化されていない挙動) データベースの参照タイミングによる差異 購入フローはミッションクリティカルな領域のため、これらの差分を1つずつ解消し、 100%一致するまで比較を継続 しました。 フェーズ1で得られた知見 比較フェーズを経て、以下の知見が得られました。 暗黙知の可視化 :長年のシステムに埋もれていた仕様が明らかになった テストケースの充実 :差分から得られた知見をテストケースに反映できた 段階的移行の有効性 :小さく始めるアプローチが、リスク管理に有効であることが明らかになった 次のフェーズでは、この比較で問題ないことを確認した機能から、実際に切り替えを進めていきます。 フェーズ2:段階的な入れ替えフェーズ 入れ替え順序の工夫 比較フェーズで十分に検証した機能から、段階的に切り替えを進めました。具体的な順序の例と、その選定理由をいくつか紹介します。 順序 機能 特徴 1 お届け先一覧の取得 条件が少なく、ロジックがシンプル 2 支払い方法一覧の取得 決済方法の選択可否の判定ロジックが複雑 3 配送日時の指定一覧の取得 在庫や配送条件との連携が必要 この順序で進めた理由は以下の通りです。 リスクの段階的な増加 :シンプルな機能から複雑な機能へ段階的に広げる 経験の蓄積 :各段階で得られた知見を次に活かす 影響範囲の管理 :問題発生時にトラフィックの切り替えをしやすい順序にする 切り替えの仕組み 比較フェーズで差分がないことを確認した機能については、以下のように切り替えました。 重要な変更点は以下の通りです。 比較処理を廃止 :検証済みのため、比較情報の保存は不要 API結果を直接返す :Shopping APIの結果をそのままユーザーに返す 旧ロジックの保守を停止 :切り替え済み機能はASP側を保守対象から除外 二重開発の最小化 このフェーズでは、 二重開発を最小化 できたことが大きな効果でした。 段階的リプレイスのメリット: 切り替え済みの機能に対する新規追加や修正は、新ロジック(Shopping API)のみに実施する 旧ロジック(ASP)への反映が不要になり、開発工数を削減できる ビッグバンリリースとの比較: ビッグバンの場合、切り替え完了まで旧ロジックにも改修が必要になる 開発期間中は変更を二重に実装する必要がある 一方、段階的リプレイスでは、切り替え済み範囲が増えるたびにこの期間を短縮できる 例えば、新しい決済方法の追加が必要になった場合の修正範囲は以下の通りになります。 段階的リプレイス(API層移行済み) :Shopping APIのみ修正 ビッグバンでのリプレイス :ASPとShopping APIの両方を修正 このように、切り替え済みの機能については旧ロジックへの反映が不要になり、長期プロジェクトにおける開発負担を大きく軽減できました。 フェーズ2の成果 フェーズ2を通じて以下の成果を得ました。 対象機能の完全移行 :読み取り系の主要機能をすべて新システムに移行 開発工数の削減 :二重開発の期間を最小化し、チームの生産性が向上 品質の向上 :段階的な検証により、問題を早期に発見・修正 次のフェーズでは、画面を含めた購入フロー全体を新システムに移行します。 フェーズ3:全体リプレイス フェーズ3では、画面を含めた購入フロー全体を新システムに移行する計画で進めています。 n%リリースによる段階的な移行 全体リプレイスでは、トラフィックを段階的に切り替える n%リリース を採用する計画です。同一ユーザーが途中で旧フロー・新フローを行き来しないよう、ユーザー単位で一度割り当てた割合を保持する形でルーティングする設計としています。 n%リリースの設計方針 段階的な切り替えにおいて、以下の方針で進める計画です。 段階 目的 検証期間 備考 1% 本番環境での動作確認と想定外の問題の早期発見 長期 注文から発送完了まで問題なく処理されることを検証 20% 1%で得られた知見を反映し、より大きなトラフィックでの検証 短期 問題がなければ次の段階へ 50% 本番に近い負荷での最終検証 中期 大規模なトラフィックでの安定性を確認 100% すべてのトラフィックを新システムに移行 - 万が一の問題に備え、即座にロールバックできる体制を維持 各段階での検証項目 各n%段階で、以下の項目を重点的に監視する計画です。 機能的な正常性 注文完了率 決済成功率 エラーログの監視 パフォーマンス レスポンスタイム スループット データベースの負荷 問題が発見された場合は、即座に前の割合に戻す体制を整えています。 全体リプレイスにおける課題 フェーズ3では、フェーズ1・2とは異なる課題に直面しています。 1. 機能改修とのコンフリクト 画面を含む全面リプレイスのため、リリースまで約1年半の開発期間が必要になる その間、既存システム(ASP)にも新規機能や改善が継続的に追加される 新旧両方のシステムに同じ変更を反映する 二重開発 が発生する 具体例としては以下が挙げられます。 新しい決済方法の追加 ユーザーインタフェースの改善 バグ修正や運用改善 これらをASPとShopping BFF/Shopping APIのすべてに実装する必要があり、工数が増加しています。 2. モチベーションの維持 長期にわたる開発により、チームのモチベーション維持が課題 「いつ終わるのか」という不安感 同じ機能を二重に実装する徒労感 課題への対応策 これらの課題に対し、以下のように対応しました。 1. フェーズ2の成果を活用 API層はフェーズ2で既に移行済みのため、Shopping APIへの修正のみで対応できる範囲が拡大 BFF層のみの修正で済むケースも多く、完全な二重開発を避けられた 2. 優先度の明確化 新規機能の開発は、極力リプレイスのリリース後に行う 緊急性の高い修正のみ二重開発で対応 今回の課題と今後の改善点 長期にわたるリプレイスプロジェクトにおいて、チームのモチベーション維持が課題として浮上しました。約1年半の開発期間中、同じ機能を二重に実装する必要があり、「いつ終わるのか」という不安感や徒労感がチームに蓄積しやすい状況でした。 今回の経験を踏まえると、次に同様のプロジェクトに取り組む際には、以下の点をあらかじめ検討しておくべきだと感じました。 プロジェクト初期段階でのマイルストーン設計 :長期プロジェクトでも前進感を保てるよう、細かいマイルストーンを設定 定期的な成果の可視化 :各段階での成果を可視化し、チーム全体で進捗を共有する仕組みの構築 チームメンバーのケア体制 :長期プロジェクトにおけるメンバーの心理的負担に配慮した体制づくり 今後の展望 リプレイスプロジェクトの完了後も、以下のような継続的な改善と発展を計画しています。 新システムでの運用安定化と監視体制の強化 リリース完了後は、新システムの安定稼働を最優先とし、監視体制の強化に取り組みます。具体的には、パフォーマンスメトリクスの継続的な収集・分析や、エラー検知の精度向上、アラート体制の整備などを進めていきます。また、運用ノウハウの蓄積と共有により、障害発生時の迅速な対応体制を構築します。 モジュラモノリスからマイクロサービスへの段階的な移行検討 現在のモジュラモノリス構成は、開発効率とシステムの見通しの良さを両立できていますが、将来的にはマイクロサービスへの移行も視野に入れています。ただし、マイクロサービス化はトレードオフを伴うため、ビジネス要件やチーム体制、技術的な成熟度を考慮しながら、慎重に検討を進める方針です。 さらなるパフォーマンス改善と開発体験の向上 新システムへの移行により開発効率は向上しましたが、さらなる改善の余地があります。レスポンスタイムの最適化やデータベースクエリのチューニング、開発ツールの整備など、継続的な改善を通じて、より快適な開発体験とユーザー体験を実現していきます。 得られた知見を他のシステムのリプレイスにも適用 本プロジェクトで得られた段階的リプレイスの手法や、並行稼働による検証のノウハウ、長期プロジェクトにおけるチーム運営の知見は、社内の他システムのリプレイスにも適用可能です。これらの知見を共有し、組織全体の技術力向上に貢献していきます。 まとめ 本記事では、ZOZOTOWNの購入フローにおける段階的リプレイスの実践について紹介しました。 セッションに強く依存した既存システムを、3つのフェーズに分けて段階的にリプレイスしています。 一部機能の比較フェーズ :並行稼働により、リスクを抑えながら新旧ロジックの差分を解消 段階的な入れ替えフェーズ :検証済みの機能から順次切り替え、二重開発の期間を最小化 全体リプレイス (進行中):n%リリースにより、画面を含む購入フロー全体を安全に移行する計画 また、Shopping BFF + Redisの構成により、セッション依存の問題を解消し、システムの見通しと保守性の向上を実現しています。 大規模ECにおける段階的リプレイスを検討している方や、ミッションクリティカルなシステムの無停止移行に取り組んでいる方の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.entry .entry-content .table-of-contents > li > ul, .table-of-contents li:nth-child(2) { display: none; } はじめに こんにちは、ECプラットフォーム部の権守です。普段はZOZOTOWNの会員基盤やID基盤の開発に携わっています。 本記事では、会員基盤で導入したデータベースへの書き込みを伴う処理のテスト手法について紹介します。この手法では実行前後のデータベースの差分に注目することで特定のレコードだけでなく、データベース全体への副作用を網羅的に検知することを目的とします。 目次 はじめに 目次 従来手法の課題 差分検証によるアプローチ Goによる差分検出ツールの実装 利用イメージ 差分抽出の実装 複数データベースへの対応 導入時の工夫点 非固定値の取り扱い 期待値の正規化 差分の除外 まとめ 従来手法の課題 データベースへの書き込みを伴う処理のテストでは、一般的に以下のように関数の返り値と処理対象である特定のレコードを検証することが多いと思います。 // 1. テスト対象の関数を実行 refundedPoints, err := usecase.CancelOrder(ctx, orderID) AssertEqual(t, nil , err) // 2. 返還ポイント(返り値)を検証 AssertEqual(t, 500 , refundedPoints) // 3. 特定のレコードの状態を検証 order, _ := orderRepo.FindByID(ctx, orderID) AssertEqual(t, "CANCELLED" , order.Status) しかし、これらのテストだけではデータベースへの「期待しない副作用」を防げないことに課題を感じていました。例えば、更新や削除の条件指定に誤りがあると想定外のレコードに影響を及ぼすことが考えられます。この場合には、処理対象である特定のレコードのみを検証したとしても、その他のレコードが破壊されていることに気づくことはできません。 差分検証によるアプローチ この課題を解決するには、データベースへの副作用を網羅的に検証する必要があります。そこで、データベースの実行前後の全レコードをキャプチャして比較し、その差分を検証するアプローチを採用しました。 このアプローチでは、特定のテーブル・レコード・カラムを見るのではなく、データベース全体への副作用をテスト対象の出力の1つとして捉えます。出力の期待値として差分を指定し、期待した副作用のみが存在することを検証することで、期待しない副作用が生じた際にそれを検知できます。 差分は以下の3種類としてそれぞれ抽出します。 作成 (Create):新規レコードのカラム全体の値を保持 更新 (Update):キー情報と、変更があったカラムの「変更後の値」を保持 削除 (Delete):レコードを特定するキー情報を保持 更新の差分の表現としては、更新前後の値を含める方が一般的ですが、本手法ではあえて更新後の値のみを保持しています。テストという観点では、更新前の値は事前条件の一部であり、テストデータのセットアップ内容と重複するためです。 Goによる差分検出ツールの実装 利用イメージ 会員基盤はGoで実装されているため、テストへの組み込みやすさを考慮して今回はGoのコード上で実装しました。 以下に、どのように差分をGoの構造体で表現し、利用するかのイメージを示します。 // 差分データの構造イメージ type Diff struct { C []Record // CREATE: 追加されたレコード群を指定。各レコードは全フィールド値を指定 U map [KeyHash]Record // UPDATE: 更新のあるレコード群を主キーのハッシュ値で指定。各レコードは更新後のフィールド値を指定 D []Record // DELETE: 削除されたレコード群を指定。各レコードは主キー値を指定(主キーが存在しない場合は全フィールド値) } type Diffs map [ string ]Diff // mapのキーはテーブル名 // 挿入時の差分検出の利用イメージ var result int diffs := DiffDB(ctx, db, func () { result = insertMember(member) }) AssertEqual(t, 1 , result) AssertRecords(t, Diffs{ "members" : { C: []Record{ { "id" : 1 , "age" : 20 , "nickname" : "taro" , }, }, }, }, diffs) // 更新時の差分検出の利用イメージ var result int diffs = DiffDB(ctx, db, func () { result = updateMemberName( 1 , "jiro" ) }) AssertEqual(t, 1 , result) AssertRecords(t, Diffs{ "members" : { U: map [KeyHash]Record{ HashKey({ "id" : 1 }): { "nickname" : "jiro" , }, }, }, }, diffs) // 削除時の差分検出の利用イメージ var result int diffs = DiffDB(ctx, db, func () { result = deleteMember( 1 ) }) AssertEqual(t, 1 , result) AssertRecords(t, Diffs{ "members" : { D: []Record{ { "id" : 1 , }, }, }, }, diffs) このようにデータベースに対する副作用を出力値として検証できるため、「レコードを取得して特定のカラムを検証する」という命令的な記述を繰り返す必要がなくなり、テストコードの可読性と保守性が向上します。 具体的には、Goで広く採用されているテーブル駆動テストのスタイルと親和性が高く、複数のテストケースを簡潔に記述できます。例えば、条件分岐で書き込むテーブルが変わる関数をテストする場合、従来の手法では、テストケースによって検証処理も分岐するか、テストケースの構造体に検証処理を持つ必要がありました。検証処理の分岐はテストコードの複雑化を招き、テストケースの構造体に検証処理を持たせることはテーブル駆動テストのメリットである宣言的な記述を損ないます。 しかし、今回導入した手法であれば宣言的な記述を維持できます。以下にそれぞれの手法の例を示します。 // 従来手法その1(検証処理の条件分岐) tests := [] struct { name string orderID string expectRefund bool // 返金テーブルを確認するかどうかのフラグ expectPointReset bool // ポイント更新を確認するかどうかのフラグ }{ { name: "クレジットカード決済のキャンセル(返金あり)" , orderID: "order_card" , expectRefund: true , expectPointReset: false , }, { name: "全額ポイント払いのキャンセル(ポイント還元あり)" , orderID: "order_point" , expectRefund: false , expectPointReset: true , }, } for _, tt := range tests { t.Run(tt.name, func (t *testing.T) { // 1. テスト対象の実行 err := usecase.CancelOrder(ctx, tt.orderID) AssertEqual(t, nil , err) // 2. 注文ステータスの検証(共通) order, err := orderRepo.FindByID(ctx, tt.orderID) AssertEqual(t, nil , err) AssertEqual(t, "CANCELLED" , order.Status) // 3. 条件分岐による個別テーブルのアサーション // テストケースが増えるたびに、この分岐ロジックのメンテナンスが必要になる if tt.expectRefund { refund, err := refundRepo.FindByOrderID(ctx, tt.orderID) AssertEqual(t, nil , err) AssertEqual(t, Refund{OrderID: tt.orderID, amount: 1000 }, refund) } if tt.expectPointReset { user, err := userRepo.FindByID(ctx, order.UserID) AssertEqual(t, nil , err) AssertEqual(t, 1500 , user.Points) } }) } // 従来手法その2(テストケースごとに検証処理を持つ) tests := [] struct { name string orderID string assertFunc func (t *testing.T, orderID string ) }{ { name: "クレジットカード決済のキャンセル(返金あり)" , orderID: "order_card" , assertFunc: func (t *testing.T, orderID string ) { // 注文ステータスの検証 order, err := orderRepo.FindByID(ctx, orderID) AssertEqual(t, nil , err) AssertEqual(t, "CANCELLED" , order.Status) // 返金テーブルの検証 refund, err := refundRepo.FindByOrderID(ctx, orderID) AssertEqual(t, nil , err) AssertEqual(t, Refund{OrderID: orderID, amount: 1000 }, refund) }, }, { name: "全額ポイント払いのキャンセル(ポイント還元あり)" , orderID: "order_point" , assertFunc: func (t *testing.T, orderID string ) { // 注文ステータスの検証 order, err := orderRepo.FindByID(ctx, orderID) AssertEqual(t, nil , err) AssertEqual(t, "CANCELLED" , order.Status) // ユーザーポイントの検証 user, err := userRepo.FindByID(ctx, order.UserID) AssertEqual(t, nil , err) AssertEqual(t, 1500 , user.Points) }, }, } for _, tt := range tests { t.Run(tt.name, func (t *testing.T) { // テスト対象の実行 err := usecase.CancelOrder(ctx, tt.orderID) AssertEqual(t, nil , err) // テストケース固有の検証処理を実行 tt.assertFunc(t, tt.orderID) }) } // 差分検出を用いた手法 tests := [] struct { name string orderID string expectedDiff Diffs }{ { name: "クレジットカード決済のキャンセル(返金あり)" , orderID: "order_card" , expectedDiff: Diffs{ "orders" : { U: map [KeyHash]Record{ HashKey({ "id" : "order_card" }): { "status" : "CANCELLED" , }, }, }, "refunds" : { C: []Record{ { "order_id" : "order_card" , "amount" : 1000 , }, }, }, }, }, { name: "全額ポイント払いのキャンセル(ポイント還元あり)" , orderID: "order_point" , expectedDiff: Diffs{ "orders" : { U: map [KeyHash]Record{ HashKey({ "id" : "order_point" }): { "status" : "CANCELLED" , }, }, }, "users" : { U: map [KeyHash]Record{ HashKey({ "id" : "user_1" }): { "points" : 1500 , }, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func (t *testing.T) { // 全テーブルの差分をキャプチャしつつ実行 var err error diffs := DiffDB(t.Context(), db, func () { err = usecase.CancelOrder(ctx, tt.orderID) }) // 返り値の検証 AssertEqual(t, nil , err) // 差分の検証 AssertRecords(t, tt.expectedDiff, diffs) }) } 差分抽出の実装 差分抽出は、以下の手順で行います。 全テーブルの主キー情報を含むスキーマ情報を取得 テスト対象の関数実行前に、対象データベースの全テーブルの全レコードを取得し、メモリ上に保存 テスト対象の関数を実行 関数実行後に、再度全テーブルの全レコードを取得 実行前後のレコードを比較し、差分を抽出 スキーマ情報の取得や全レコードの取得は、データベースの種類に依存するため、DBSourceインタフェースを定義し、各データベースに応じた実装を用意しました。 ここでは、スキーマ情報と全レコードの取得の実装については割愛し、差分抽出のコアロジックを示します。 func createDiff(source DBSource, before, after map [ string ] map [KeyHash]Record) Diffs { diffs := Diffs{} // 各テーブルごとに差分を抽出 for tableName := range before { diff := Diff{U: map [KeyHash]Record{}} // 各テーブルのスキーマ情報を取得 schema := source.schemata()[tableName] // レコードごとに差分を比較 // keyは各レコードの主キーのハッシュ値 for key, record := range before[tableName] { // 実行前にあったレコードが実行後にも存在する場合 if afterRecord, ok := after[tableName][key]; ok { updates := Record{} for k, v := range record { // 値が異なるカラムのみを抽出 if !reflect.DeepEqual(v, afterRecord[k]) { updates[k] = afterRecord[k] } } // 更新があった場合のみdiffに追加 if len (updates) != 0 { diff.U[key] = updates } // Createを抽出するために、afterから既存レコードを削除 delete (after[tableName], key) continue } keyValues := map [ string ]any{} for _, key := range schema.keys { keyValues[key] = record[key] } diff.D = append (diff.D, keyValues) } // 残ったafterのレコードは新規作成されたレコード for _, record := range after[tableName] { diff.C = append (diff.C, record) } // 期待値を書く際に差分がない場合は省略可能にするため、空スライスはnilに変換 if len (diff.U) == 0 { diff.U = nil } // テーブルに何らかの差分があった場合のみdiffsに追加 if len (diff.C) != 0 || len (diff.U) != 0 || len (diff.D) != 0 { diffs[tableName] = diff } } // 期待値を書く際に差分がない場合は省略可能にするため、空のDiffsはnilに変換 if len (diffs) == 0 { return nil } return diffs } 複数データベースへの対応 ZOZOTOWNではリプレイスを進めるにあたり、一時的に既存環境と新環境それぞれのデータベースに書き込むケースが存在します。それぞれで期待した差分があるかを検証できるように複数データベースにも対応しました。DiffExtractorにラベルを付けて複数設定することで、差分出力時にそれぞれどのデータベースで生じた差分かを判定できます。 // 複数データベースに対応した実装 type DiffExtractor struct { // 複数のデータベースを抽出対象とする // mapのキーはデータベースを特定するためのラベル sources map [ string ]DBSource } func NewDiffExtractor(sources map [ string ]DBSource) DiffExtractor { return DiffExtractor{sources: sources} } func (de DiffExtractor) Diff(ctx context.Context, f func ()) map [ string ]Diffs { diffs := map [ string ]Diffs{} before := map [ string ] map [ string ] map [KeyHash]Record{} for name, source := range de.sources { // テスト対象実行前の各データベースをキャプチャ before[name] = source.dump(ctx) } f() after := map [ string ] map [ string ] map [KeyHash]Record{} for name, source := range de.sources { // テスト対象実行後の各データベースをキャプチャ after[name] = source.dump(ctx) // 各データベースの差分抽出 diffs[name] = createDiff(source, before[name], after[name]) } return diffs } // 複数データベースで利用する場合のテストヘルパー例 func DiffDBForDoubleWrite(ctx context.Context, mysqlDB *sql.DB, mssqlDB *sql.DB, f func ()) map [ string ]Diffs { mysqlSource := dd.NewMySQLSource(ctx, mysqlDB) mssqlSource := dd.NewMSSQLSource(ctx, mssqlDB) extractor := dd.NewDiffExtractor( map [ string ]dd.DBSource{ "mysql" : mysqlSource, "mssql" : mssqlSource, }) return extractor.Diff(ctx, func () { f() }) } 導入時の工夫点 差分検出を導入するにあたり、テストの安定性を保つためにいくつか工夫しました。 非固定値の取り扱い 本手法では、特定のカラムだけでなくレコード全体を対象とするため、自動採番されたIDや現在時刻、乱数など実行のたびに値が変わるカラムについても常に考慮する必要があります。 IDの自動採番については、各テストケースの実行前にオートインクリメントなどのシーケンスをリセットするために、TRUNCATE文を実行することで対応しました。これにより、発行されるIDを固定し、期待値を固定できます。 // テスト実行前のセットアップ例 func SetupTestDB(t *testing.T, db *sql.DB) { t.Helper() // ユーザー定義された全テーブル名の取得 tables := GetTableNames(db) for _, table := range tables { _, err := db.Exec(fmt.Sprintf( "TRUNCATE TABLE %s" , table)) if err != nil { t.Fatal(err) } } } 実際にTRUNCATE文を実行するには外部キーの制約チェックを一時的に解除する、もしくはテーブルの処理順序を制御するといったことも必要になります。 現在時刻については、関数内で time.Now() を使わず、時刻を引数として渡すか、インタフェースを介して注入することでテスト内の時刻を固定しています。これにより、時刻に関する期待値も固定できます。 乱数については、乱数生成の箇所をインタフェース化して期待値を固定する方法などが考えられますが、それが難しい場合も考慮して、アサーション関数において値一致以外も可能にしました。具体的には文字列に対する期待値に *regexp.Regexp を指定した場合には正規表現マッチを行うようにしました。 // 乱数を含むフィールドの検証例 expectedDiffs := Diffs{ "orders" : { C: []Record{ { "order_id" : regexp.MustCompile( `\A[0-9a-f]{32}\z` ), // 乱数を正規表現で表現 "amount" : 10 , "order_at" : "2026-01-01T00:00:00Z" , }, }, }, } AssertRecords(t, expectedDiffs, actualDiffs) 現状は使うケースがなかったため用意していませんが、数値型の乱数を利用する場合にはそれぞれ専用の型を用意して、検証処理を切り替えることも検討しています。 期待値の正規化 会員基盤ではテストデータのセットアップにレコードデータではなくモデルデータを利用しているため、データベースから抽出した差分の値とテストケースの期待値とでは形式が異なることもあります。例えば、モデルデータではbool型のフィールドが、データベースからの出力時はint型の0もしくは1になるケースがあります。他にもモデルデータでは値オブジェクトとして定義されているフィールドが、データベースからの出力時はその値オブジェクトの内部の値になるケースもあります。 このような場合に、テストケースの期待値をデータベースからの出力に合わせた形式で記述するのは、テストケースの可読性を損なうため、アサーション関数内で比較時に正規化する方針としました。実装の詳細は割愛しますが、リフレクションを用いて reflect.ValueOf 関数で reflect.Value に変換した後、 Kind() メソッドで元となる型を判定して正規化を行っています。 差分の除外 データベースのトリガー処理による時刻の挿入などアプリケーション側から制御できない値や、もうアプリケーション上から利用していないカラムのような例外的に差分から除外したいケースが存在します。そこで、抽出した差分からカラムを指定して除外するための Ignore() メソッドを用意しました。また、用意されていない方法で特定のカラムを検証するために一旦、差分から取り除いた上で別途検証するという場合にも利用できます。 diffs := DiffDB(ctx, db, func () { someFunc() }) // hogeは廃止済みのカラムで期待値の管理対象外とする AssertRecords(t, expectedDiffs, diffs.Ignore( "hoge" )) また、特定のテストケースによらず、アプリケーション全体で除外したい条件があるような場合に対応するため、DiffExtractorに除外用の関数をオプションで設定できるようにしました。 var someIgnoreColumnFunc = func (tableName, columnName string ) bool { // 例えば、全テーブルのhogeカラムを常に除外する場合 return columnName == "hoge" } // 除外関数をオプションに設定したテストヘルパー例 func DiffDB(ctx context.Context, db *sql.DB, f func ()) dd.Diffs { source := dd.NewMySQLSource(ctx, db) source.WithIgnoreColumnFunc(someIgnoreColumnFunc) extractor := dd.NewDiffExtractor( map [ string ]dd.DBSource{ "mysql" : source, }) diffs := extractor.Diff(ctx, func () { f() }) return diffs[ "mysql" ] } まとめ 本記事では、Goを用いたデータベースのレコード差分検出によるテスト手法について紹介しました。 複雑なテストケースになるほど、データベースへの副作用を網羅的に検証することの重要性が増します。本手法を導入することで、期待しない副作用を検知しやすくなり、テストコードの可読性と保守性も向上しました。今後も、より良いテスト手法の模索と改善を続けていきたいと思います。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co
アバター
はじめに こんにちは。商品基盤部の藤本です。 私たちのチームでは、Spring Bootで実装したJavaアプリケーションの起動時間の短縮に取り組んでいます。今回の記事では、Class Data Sharing(以下、CDS)を本番で稼働しているアプリケーションに実際に適用した内容を紹介します。 導入時には、Datadog Java Agentとの両立という課題にも直面しました。そのため、トレースとメトリクスの送信をOpenTelemetryとMicrometerに置き換える対応もあわせて実施しました。 本記事では、CDSの概要、導入効果、導入手順、Datadogの問題とOpenTelemetryへの移行までを順に説明します。 環境 今回の取り組みは次の環境で実施しました。 Java 21 (Eclipse Temurin) Spring Boot 3.5 Class Data Sharing(CDS)とは CDSは、クラスメタデータを jsa (Java Shared Archive)ファイルとして保存し、起動時に再利用する仕組みです。クラスロード時の処理を省略できるため、起動時間とメモリ使用量の改善が期待できます。 CDSには次の2種類があります。 Default CDS JDK標準ライブラリのクラスを対象とする方式 Application CDS アプリケーションクラスも対象に含める方式 各ベンダーから配布されているJDKを使用する場合は、通常はDefault CDSが有効になっています。一方、 jlink で作成したカスタムJREでは jsa ファイルが含まれておらず、そのままではCDSが機能しません。今回の取り組みではこの点も考慮して、カスタムJRE向けのCDS生成も実施しました。 CDS導入の効果 実際にApplication CDSを導入し、本番環境で導入前後を比較しました。比較対象は起動時間と、クラスロードの影響でレイテンシが高くなりがちな初回のエンドポイント実行時間です。計測結果は次のようになりました。 指標 導入前 導入後 改善率 起動時間 6.440 s 3.354 s 47.92%短縮 初回のエンドポイント実行時間 371 ms 165 ms 55.53%短縮 この数値は本番環境で10回計測した値を平均したものです。この結果から、起動時間だけでなく初回アクセス時のレイテンシも改善できることを確認できました。 導入手順 ここからは、実際に適用した手順を説明します。 カスタムJRE向けのDefault CDSの生成 カスタムJREでCDSを使用するには、 jlink でJREを作成した後に -Xshare:dump を実行してCDSアーカイブを生成します。 jlink --add-modules java.base,java.compiler --output /javaruntime /javaruntime/bin/java -Xshare :dump -XX :+UseCompressedOops /javaruntime/bin/java -Xshare :dump -XX :-UseCompressedOops このコマンドを実行すると、 classes.jsa と classes_nocoops.jsa という名前でアーカイブが生成されます。実行環境によって参照先アーカイブが変わるため、 UseCompressedOops の有無で両方を生成しておくことをおすすめします。 Application CDS(Dynamic CDS archive)の生成 次に、アプリケーション終了時のロード済みクラス情報から jsa ファイルを生成します。以前のApplication CDSでは、手動でクラスリストを作成してからアーカイブを作る手順が必要でした。現在はDynamic CDS archiveという方式により、アプリケーションを終了した時点のロード済みクラスから jsa ファイルを自動生成できます。この方式はJDK 13で導入されています。 openjdk.org Spring Framework 6.1以降では、 -Dspring.context.exit=onRefresh を使ってアプリケーションを起動直後に終了できます。 java \ -XX :ArchiveClassesAtExit = /path/to/application.jsa \ -Dspring .context. exit= onRefresh \ -jar my-app.jar CDSが正しく有効になっているか確認するには、 -Xlog:cds オプションを使ってCDSの読み込み状況をログへ出力します。別の方法として、 -Xshare:on を使ってCDSの使用を強制できます。 -Xshare:on を指定すると、 jsa ファイルを正しく読み込めない場合はアプリケーションの起動が失敗する点に注意してください。 java \ -Xshare :on \ -XX :SharedArchiveFile = /path/to/application.jsa \ -jar my-app.jar Datadogの問題とOpenTelemetryへの移行 Application CDS導入時に最も大きかった課題は、Datadog Java Agentとの両立でした。Datadog Java Agentは実行時にクラスパスへクラスを追加するため、CDSが前提とする「生成時と実行時の整合性」が崩れてしまいます。 この挙動はGitHub上でもIssueとして報告されています。 github.com なお、このIssueは2026年1月にクローズされ、Datadog側の方針として「CDSをサポートする」方向ではなく「JEP 483(Ahead-of-Time Class Loading & Linking)の利用を推奨する」ことが示されています。コメントでは、Java 25以降で dd-java-agent と JEP 483 を組み合わせる利用方法が案内されています。 Datadog Java AgentがCDSに対応していないため、トレースとメトリクスの送信方式を次の構成に移行しました。 トレース:OpenTelemetry(OTLP)→ Datadog Agent メトリクス:Micrometer(DogStatsD)→ Datadog Agent 送信先をDatadog AgentではなくOpenTelemetry Collectorに変更することも検討しましたが、構成や運用の変更にかかるコストの観点から今回は見送りました。 OpenTelemetryによるトレース送信 OpenTelemetryでトレースを送信するために、アプリケーションへ opentelemetry-spring-boot-starter を導入しました。Spanの開始と終了は HandlerInterceptor で制御しながら、送信内容が重複しないよう spring-webmvc の自動計装は無効化しました。 otel : instrumentation : spring-webmvc : enabled : false exporter : otlp : endpoint : "http://${DD_AGENT_HOST:localhost}:4318" traces : exporter : otlp sampler : always_on Datadog Agent側では、OTLP受信を有効化します。 DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_HTTP_ENDPOINT = 0 . 0 . 0 .0:4318 DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT = 0 . 0 . 0 .0:4317 Micrometerによるメトリクスの送信 メトリクスは micrometer-registry-statsd を追加し、DogStatsD形式でDatadog Agentへ送信できるようにします。 management : statsd : metrics : export : enabled : true flavor : datadog host : ${DD_AGENT_HOST:localhost} port : 8125 Spanの属性のマッピング調整 OpenTelemetryで送ったトレースがDatadogで期待通りに絞り込めない場合は、属性名の差分が原因になることがあります。目的のトレースを検索できるようにするため、たとえば次のようなDatadog上の属性を付与する必要があります。Datadogのマッピング表を参照し、必要な属性をSpan属性とResource属性へ明示的に設定しました。 Span属性: http.method 、 http.url などのリクエストごとに変わる値 Resource属性: service.name 、 env 、 container.name などの共通値 docs.datadoghq.com メトリクスについても、ダッシュボードで計測している指標に合わせて、メモリやGCに関する収集項目を追加し、メトリクス名やタグを調整しました。 おわりに 本記事では、Application CDSを活用して起動時間を短縮する取り組みと、Datadog Java Agentとの両立課題への対応を紹介しました。実測では、起動時間と初回エンドポイント実行時間の両方で改善を確認できました。 CDSはオプション追加だけで始められますが、カスタムJRE、暖機処理、監視ライブラリの挙動まで含めて設計することで効果を最大化できます。Javaアプリケーションの起動時間に課題がある方は、まずは小さな範囲で計測しながら段階的に導入してみてください。 現在はJava 25とSpring Boot 4への移行を進めており、その後でAOT Cacheの導入を予定しています。引き続き、運用環境に即した形でパフォーマンス改善の取り組みを進めていきます。 ZOZOでは、一緒にサービスを盛り上げてくれる仲間を募集中です。興味のある方は以下の採用情報をご確認ください。 corp.zozo.com
アバター
.entry .entry-content .table-of-contents > li > ul { display: none; } はじめに こんにちは、新規事業部バックエンドブロックの三浦です。2025年6月にリリースされたマッチングアプリ「 ZOZOマッチ 」のバックエンド開発を担当しています。 ZOZOマッチでは、App StoreやGoogle Playの決済システムを利用したアプリ内課金を提供しており、定期購読(サブスクリプション)することで一部機能の制限解除や機能拡張が可能になります。アプリ内課金の実装には、アプリからの購入処理と購読のキャンセル・返金・自動更新といったライフサイクルイベントの同期処理が必要です。ZOZOマッチではこれらの処理をスクラッチで開発しました。 本記事では、特に開発が難航した、ライフサイクルイベントによって変更される課金ステータスをバックエンドに同期する仕組みについて紹介します。AppleとGoogleそれぞれが提供する通知の仕組みの違いや、同期処理の実装における課題と工夫についても解説します。 目次 はじめに 目次 ZOZOマッチにおけるアプリ課金 購読ステータス同期の必要性 購読開始までの流れ 取引IDの管理 レシート検証 購読情報の同期 (Apple) App Store Server Notifications ZOZOマッチでの同期方法 通知内容 通知の署名検証とデコード 課金ステータスの更新 同期処理における注意点 ASSNへのレスポンス返却 購読情報の同期 (Google) Real-time Developer Notifications ZOZOマッチでの同期方法 通知内容 通知の検証とデコード 課金ステータスの更新 同期処理における注意点 RTDNへのレスポンス返却 ASSNとの違い比較 開発・運用を通じて大変だったこと 開発時のテストの難しさ 運用面での課題 まとめ さいごに ZOZOマッチにおけるアプリ課金 はじめに、ZOZOマッチにおける課金の概要について説明します。前述の通り、ZOZOマッチではアプリ内課金を利用して 複数の定期購読プラン を提供しています。定期購読することで一部機能の制限解除や機能拡張が可能になり、いずれもサービスの利用体験に関わる内容となっています。 設計当初はストア以外での決済手段も検討しましたが、以下の理由からアプリ内課金のみを採用しました。 AppleおよびGoogleの規約上、ZOZOマッチで販売しているコンテンツはアプリ内課金を利用する必要がある Web決済をする場合はWebでも同様の機能を提供する必要がある(ZOZOマッチはアプリ専用サービスのため該当しない) 実装面では、開発工数の削減を目的にアプリ内課金の管理をサポートするSaaSの導入を検討しました。しかし、費用対効果の観点から最終的にはスクラッチ開発を選択しました。 購読ステータス同期の必要性 定期購読には、購読開始から更新・解約までのライフサイクルがあります。購読開始時はアプリ内で購入処理が行われ、ストア側で決済が完了すると購入情報がアプリに返されるため、バックエンドへの反映は比較的シンプルです。 一方で、以下のようなイベントはストア側で自動的に処理されるため、アプリを経由せず発生します。 自動更新:購読期間の終了時の自動課金 更新失敗:支払い方法の問題による課金失敗 解約:ユーザーによる自動更新の停止 これらのイベントをバックエンドへ正確に同期しなければ、「解約したのに有料機能が使える」「自動更新したはずなのに有料機能が使えなくなった」といった不整合が発生してしまいます。社内ではこのストア側の購読ステータス同期に関するノウハウが少なかったため、Apple/Googleそれぞれの公式ドキュメントを読み込み、チーム内で調査するところから始めました。 以降、購読開始の流れを簡単に説明した後、今回調査や実装が特に難航したストア側の購読ステータスの同期方法をAppleおよびGoogleに分けて解説していきます。 購読開始までの流れ 購読開始および購読再開の場合、ユーザーがアプリを通じてApp StoreまたはGoogle Play Storeで購入手続きを行います。 購入手続きが完了すると、ストア側からアプリにレシート(購読情報)が返されます。アプリはこのレシート内の取引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リクエストが送信されます。 下記は全体のアーキテクチャ図です。 ASSN通知受信Webhook自体はFargate上で稼働しており、API Gateway経由で通知を受け付けます。Appleからのみリクエストを受け付けるよう、WAFでIPアドレス制限をかけています。加えてWebhook側で署名検証を実施し、不正なリクエストを防止しています。 下記はASSN通知受信Webhookの全体の処理フローです。 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 " } エンコードされている取引情報の詳細は、下記公式ドキュメントを参照してください。 signedTransactionInfo signedRenewalInfo 通知の署名検証とデコード 以下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マッチでは、このような先祖返りが発生しないよう以下の対策を実施しています。 通知内のoriginalTransactionIdを基に、DBから既存の購読情報を取得 DB上の最新のレコードのトランザクション発生日時と受け取った通知内のトランザクション発生日時を比較 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からメッセージを取得して購読情報の同期処理を行います。 下記は全体のアーキテクチャ図です。 SubscriberはFargate上で稼働しているアプリケーションで、NAT Gateway経由でPub/Subに接続します。 下記はSubscriberの処理フローです。 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
アバター
.entry-content ul > li > ul { display: none; } tr td:first-child { white-space: nowrap; } .nowrap2+table tr td:nth-child(2) { white-space: nowrap; } td { text-align: left !important; } 目次 目次 はじめに この記事の対象読者 背景・課題 背景 課題 AI駆動開発ワークフローの概要 AIサービスごとの役割 Devin Playbook ユーザー起動のPlaybook(Slack → Devin) !ai_task(単一タスク実装) !ai_tasks(タスク分割&並列実装) !human_review(人間承認フロー) 人間レビューが必要なケース ワークフロー自動呼び出しのPlaybook !fix_ci_failure(CI失敗時の自動修正) !fix_review_comments(レビュー指摘の自動修正) !context_curation(AIコンテキストの週次更新) 使用技術 機能一覧 アーキテクチャ SlackからPR承認までの完全フロー 2つのワークフローの役割 フロー別の使い分け 実装 設定ファイル AI Task Implementation:Issueから実装までの自動化 AI Task Dispatcher(ゲートウェイ) 実装ワークフローの動作 ラベルによる動作の分岐 重複実行の防止の仕組み キャンセル時のクリーンアップ AI Review Orchestrator:レビューから承認までの自動化 レビュー・ワークフローの動作 統合レビュー・修正ループの詳細フロー ジョブ構成と責務 Phase 1: Gate(スキップ判定) Phase 2: Perspective Router(ハイブリッド分類方式) 分類フロー 事前分類ルール Phase 3: CI完了待機 CI完了を待つ理由 Phase 4: Claude Review Perspectiveラベルに基づくレビュー レビュープロンプトの構造 分類判定ルール 自動承認の対象外となる変更タイプ 指摘の重大度とblockingフラグ(Conventional Comments準拠) PR説明文の自動生成・更新 関連 Issue の自動取得 PRタイトル・説明文の自動生成 タイトル生成ルール Phase 5: Devin自動修正 Devin修正の種類 修正ループの詳細 ループカウントの管理 Perspectiveラベルの更新ルール ループ終了条件 自動承認の条件 データ連携方式 Claude → Devinのデータ渡し 構造化出力スキーマ 技術的なポイント 1. concurrencyによる並行実行の制御 2. Devinセッションの自動停止 trapハンドラー(即時対応) if: cancelled()バックアップステップ GitHub Actionsのキャンセルシーケンス 3. AWS BedrockによるClaude呼び出し 4. マクロ形式によるプロンプト管理 5. HEAD SHAの検証 使い方 AI Task Implementationの使い方(Slack → Issue → 実装) 起動方法 注意点 AI Review Orchestratorの使い方(PR → レビュー → 承認) 自動レビューの流れ Claudeへの質問・修正依頼 操作手順まとめ 導入効果 定量的な効果 定性的な効果 Renovate PRの自動レビュー・承認 自動承認の条件 AI開発の効果測定(週次レポート) 計測される KPI レポート例 AIコンテキストの自動育成 動作フロー 更新対象ファイル ドキュメントの階層構造 各ディレクトリの役割 残っている課題 運用上の注意点 スキップラベルの活用 人間のレビューが必要なケース ワークフロー別ラベル操作 トラブルシューティング まとめ 実現した効果 重要なポイント 今後の展望 最後に 参考リンク はじめに こんにちは、新規事業部 マネジメントポータルブロックの岡本です。 「PRを作ったけど、レビューまで時間がかかる」「忙しいときレビューが後回しになる」──この悩み、開発者なら誰もが経験したことがあるのではないでしょうか。 私たちのチームでは、ClaudeとDevinを組み合わせたAI駆動開発ワークフローを導入し、レビュー待ち時間ゼロ・CIエラーの自動修正・Issueからの即時の実装開始を実現しました。この記事では、その設計思想から実装詳細、導入効果までを解説します。 この記事の対象読者 GitHub Actionsを使ったCI/CDの経験がある方 AIコーディングアシスタント(Claude、Devinなど)に興味がある方 チーム開発の効率化を検討している開発リーダー・マネージャー 背景・課題 背景 私たちのチームはZOZOマッチ管理画面を少人数で運用しており、各メンバーがフロントエンド・バックエンド・LP作成と複数の領域を担当しています。そのため、コンテキストスイッチが頻繁に発生し、「PRを作成したが、レビュアーが別タスクに集中している」「CIエラーを修正しようとしたら、他の緊急対応が入った」といった状況が日常的に起きていました。 結果として、レビューやCI修正の待ち時間が発生しやすく、開発のリズムが途切れがちでした。そこで、「人間は創造的な作業に集中し、定型的な作業はAIに任せる」という方針のもと、Claude(レビュー・判断)とDevin(実装・修正)を組み合わせたワークフローを整備しました。 課題 従来の開発フローでは、次のような課題がありました。 課題 詳細 レビュー待ち時間 PR作成から人間のレビューまでに数時間〜1日かかることがあった レビュー品質のばらつき レビュアーの経験や専門分野によって指摘内容に差があった Issue着手の遅延 Issueが起票されてから実装開始まで時間がかかっていた ライブラリ更新の負担 Renovateが作成する大量の依存関係の更新PRのレビュー・マージ作業が開発者の負担になっていた これらの課題を解決するため、2つのGitHub Actionsワークフローを中心に自動化を構築しました。 AI駆動開発ワークフローの概要 AIサービスごとの役割 このワークフローでは、2つのAIサービスを組み合わせて活用しています。GitHub Actionsとの連携における実行方式の違いから、次のように役割を分けています。 Devin(実装・修正):Issueの実装、CIエラー修正、レビュー指摘の修正を担当。非同期でセッションを開始し、修正完了まで一貫した作業が可能。ブランチ操作からコミット・プッシュまでを単一セッションで実行でき、修正完了後にPRが更新されてワークフローが再トリガーされる設計とも相性が良い Claude Code(レビュー・判断):PRのレビュー、分類判定、自動承認の判断を担当。同期的に結果を返すため、「自動承認 / 人間レビュー依頼 / Devin修正起動」の分岐処理を同一ワークフロー内で完結できる。構造化出力(JSONスキーマ)により、後続の処理時に扱いやすい形式でレビュー結果を取得できる Devin Playbook Playbookは、Devinに対する再利用可能な指示セットです。タスクの種類に応じた判断ロジック(セキュリティチェック、タスクサイズ判定など)、GitHub連携処理、エラーハンドリングが定義されており、一貫した品質でタスクを実行できます。 Playbookには、Slackからユーザーが起動するものと、GitHub Actionsワークフローが自動呼び出しするものの2種類があります。今回作成したワークフローでは、Devin APIを使用し、macro形式(macro IDとpayloadをJSONで渡す)でPlaybookを呼び出しています。 ユーザー起動のPlaybook(Slack → Devin) Slackで @devin をメンションし、 ! で始まるコマンドを送信すると、対応するPlaybookが実行されます。 コマンド 用途 対象タスク !ai_task 単一タスク実装 Small〜Medium(半日以内) !ai_tasks タスク分割&並列実装 Large(1日以上、複数コンポーネント) !human_review 人間承認フロー セキュリティ関連、要確認タスク !ai_task (単一タスク実装) 指定されたタスクを直接実装し、PRを作成します。 !ai_tasks (タスク分割&並列実装) 複雑なタスクを分析し、最大5つのsub-issueに分割して並列実装します。 !human_review (人間承認フロー) セキュリティ関連や重要な変更など、人間の確認が必要なタスク向けです。 人間レビューが必要なケース 認証・認可の変更 個人情報(PII)の扱い シークレット・APIキーの変更 ビジネスロジックの大幅な変更 本番環境への影響が大きい変更 ワークフロー自動呼び出しのPlaybook 次のPlaybookはGitHub Actionsワークフローから自動的に呼び出されます。ユーザーが直接実行することはありません。 Playbook 用途 呼び出し元 !fix_ci_failure CI失敗時の自動修正 ai-review-orchestrator.yml (CI失敗時) !fix_review_comments レビュー指摘の自動修正 ai-review-orchestrator.yml (blockingの指摘があるとき) !context_curation AIコンテキストの週次更新 devin-context-refresh.yml (毎週月曜09:00 JST) !fix_ci_failure (CI失敗時の自動修正) CI(Lint、Typecheck、Test)が失敗したPRを自動修正します。 GitHub ActionsのCIログを解析してエラーを特定 エラー種別に応じて修正(Lint → pnpm run lint:fix 、Type → 型エラー修正、Test → テスト更新) 修正をコミット・プッシュしてセッション終了 ビジネスロジックの変更が必要な場合は修正せず人間にエスカレーション !fix_review_comments (レビュー指摘の自動修正) Claude Reviewからの指摘( blocking: true )を自動修正します。 Blocker / Major の指摘を必須の修正対象として処理 各指摘の suggestion に従って修正 修正をコミット・プッシュしてセッション終了 認証・認可に関わる変更は修正せず人間にエスカレーション !context_curation (AIコンテキストの週次更新) PRコメントから学習可能な知見を抽出し、AI関連ドキュメントを週次で自動更新します。 直近7日間のPRコメント(Bot除外)を収集 コメントから有用な変更点、落とし穴、ルール例外、命名・設計ガイドラインを抽出 AGENTS.md 、 docs/ai-context/ 、 docs/guidelines/ 、 docs/review-perspectives/ を更新 更新内容をPRとして作成(人間がレビュー・マージ) 使用技術 ワークフローは、次の技術・サービスを組み合わせて構成しています(ワークフロー上の使用順)。 技術・サービス 用途 Slack タスク起点のオペレーション(Devinへの指示) GitHub Issues/PR/Labels 進行管理とトリガー Devin API Issueの実装・CIエラー修正・レビュー指摘の修正 GitHub Actions ワークフロー実行(CI待機、ジョブ分岐、通知) AWS Bedrock Claude実行基盤(セキュリティ要件対応) Claude Code Action PRレビュー実行・分類判定・自動承認 機能一覧 機能 内容 主な効果 AI Task Implementation Slack/ラベル起点で Devinが実装しPRを作成 Issue着手の即時化 AI Review Orchestrator Claudeがレビューし、Devinが修正 レビュー待ちの解消と品質の均一化 CI自動修正 CI失敗時にDevinが修正を実行(最大3回) 手戻り削減 自動承認 AI_ONLYかつ指摘なしで自動承認 PR処理の高速化 週次メトリクス KPIを週次で自動計測しIssue化 効果測定の継続 ナレッジ更新 PRコメントからドキュメントを自動更新 レビュー品質の継続改善 アーキテクチャ SlackからPR承認までの完全フロー 2つのワークフローの役割 ワークフロー トリガー 役割 AI Task Implementation Issueに ai-task / ai-tasks ラベル追加 DevinがIssueの実装を担当し、PRを作成 AI Review Orchestrator PRの作成・更新 Claudeがレビュー、Devinが修正、条件を満たせば自動承認 フロー別の使い分け 各Playbookの詳細なフローは「 Devin Playbook 」セクションを参照してください。 シナリオ Slack コマンド 処理フロー 単一タスク(直接の実装が可能) @devin !ai_task タスク説明 Devin → Issue + PR → 自動レビュー → 承認 複雑なタスク(分割が必要) @devin !ai_tasks タスク説明 Devin → 親 Issue + sub-issue → 並列実装 → 自動レビュー 要承認タスク @devin !human_review タスク説明 Devin → Issue 作成 → 人間承認 → 自動実装 Claudeに質問・修正依頼 @znm-claude-code 依頼内容 Claudeが対応(PR/Issueコメント) 実装 設定ファイル ワークフローはGitHub Actionsの設定ファイルで定義しています。主要ファイルと役割は次のとおりです。 ファイル 役割 .github/workflows/ai-task-dispatcher.yml Issueラベル検知・条件判定のゲートウェイ(条件を満たす場合のみimplementationを起動) .github/workflows/ai-task-implementation.yml DevinによるIssue実装(dispatcher から呼び出し) .github/workflows/ai-review-orchestrator.yml PRレビュー・修正ループ・Renovate統合 .github/workflows/pr-perspective-router.yml Perspectiveラベルの再分類 .github/workflows/ai-renovate-review.yml Renovate PRの依存関係レビュー(orchestrator から呼び出し) .github/workflows/ai-metrics-report.yml 週次メトリクス生成 .github/workflows/devin-context-refresh.yml コンテキスト更新 AI Task Implementation:Issueから実装までの自動化 AI Task Dispatcher(ゲートウェイ) ai-task-dispatcher.yml は、Issueのラベル追加を検知し、条件を満たす場合のみ実装ワークフローを起動するゲートウェイです。 Dispatcherを分離している理由は3つあります。 条件判定の一元化:ラベル検知と条件チェックを単一ファイルに集約し、保守性を向上 不要なワークフロー発火の防止:条件を満たさない場合は早期終了し、リソースを節約 責務の分離:「いつ起動するか」(dispatcher)と「何をするか」(implementation)を明確に分離 実装ワークフローの動作 ai-task または ai-tasks ラベルがIssueに追加されると、dispatcher経由でDevinが自動的に実装を開始します。トリガー条件は次のとおりです。 Issueの labeled イベントで起動(dispatcherが検知) ai-task / ai-tasks ラベル付与時のみ対象 in-progress が付いていない場合だけ開始 起点は2パターンあります。 Playbook起動(Slackなど):PlaybookがIssueを作成 → ai-task / ai-tasks を付与 → このワークフローが起動 既存Issue起点:このワークフローが skip_issue_creation: true を渡してPlaybookを実行(Issue作成はスキップ) ラベルによる動作の分岐 ラベルに応じて対応するPlaybookが実行されます。各Playbookの詳細なフロー(セキュリティチェック、タスクサイズ判定、実装手順など)は「 Devin Playbook 」セクションを参照してください。 ラベル Playbook 動作 ai-task !ai_task 単一タスクとして実装 ai-tasks !ai_tasks タスク分割 → 並列実装 sub-issue作成には gh sub-issue 拡張が必要です(Playbook側 or Devinのリポジトリ初期設定で事前インストール)。 重複実行の防止の仕組み 同じIssueに対する重複実行を防ぐため、concurrency設定でIssue番号単位の同時実行を1つに制御し、進行中の実行があれば新しい実行でキャンセルします。 例えば、同じIssueに短時間でラベル追加が続いた場合、 labeled イベントが複数回発火します。この二重起動を防止します。 キャンセル時のクリーンアップ ワークフローがキャンセルされた場合、次のクリーンアップ処理を実行します。 キャンセル時にDevinセッションを停止 キャンセル時に in-progress ラベルを削除して再実行可能にする 失敗時も in-progress ラベルを削除して再実行可能にする AI Review Orchestrator:レビューから承認までの自動化 レビュー・ワークフローの動作 PRが作成・更新されると、 ai-review-orchestrator.yml が自動実行されます。このワークフローはPRの作成・更新をトリガーに発火し、PRの作成元は問いません。 AI Task Implementation経由のPR: DevinがPR作成 → このワークフローが発火 手動作成されたPR: 開発者の直接PR作成 → このワークフローが発火 なお、 ai-task-dispatcher.yml とこのワークフローは独立しており、直接の呼び出し関係はありません。 統合レビュー・修正ループの詳細フロー ジョブ構成と責務 ai-review-orchestrator.ymlは約1,500行の大規模ワークフローです。6つのフェーズに分かれています。 フェーズ ジョブ名 責務 依存関係 1 gate スキップ判定、PR情報取得、イテレーション管理 なし 2 perspective-router PR分類、perspective:* ラベル付与(初回のみ、Renovate以外) gate 3 wait-for-ci CI完了待機(最大20分、30秒間隔ポーリング) gate, perspective-router 4a devin-ci-fix CI失敗時のDevin自動修正 gate, wait-for-ci (CI失敗時) 4b-1 renovate-review Renovate PR専用のClaude依存関係レビュー( ai-renovate-review.yml 呼び出し) gate, wait-for-ci (CI成功かつRenovate PR時) 4b-2 claude-review CI成功/未検出時のClaudeレビュー実行(Renovate以外) gate, wait-for-ci (CI成功/未検出時) 4c devin-review-fix レビュー指摘ありの場合のDevin自動修正 gate, claude-review (指摘あり時) 4d auto-approve AI_ONLY + 指摘なしの場合の自動承認 gate, claude-review or renovate-review 4e needs-human-review NEEDS_HUMAN + 指摘なしの場合の人間レビュー依頼 gate, claude-review or renovate-review 4f vrt Visual Regression Testingの実行 auto-approve or needs-human-review 5 max-iterations-reached イテレーション上限到達時の通知 gate 6 ci-timeout CI待機タイムアウト時の通知 gate, wait-for-ci Phase 1: Gate(スキップ判定) 次の条件でワークフローをスキップします。 条件 判定方法 理由 Draft PR pull_request.draft == true 作業中のためレビュー不要 Bot作成PR(Devin・Renovate 以外) actor がbotかつDevin・Renovateでない 無限ループ防止 Fork PR pull_request.head.repo.fork == true シークレット/権限制約のためスキップ skip-ai-review ラベル ラベル存在チェック 明示的スキップ Renovate PR actor が renovate[bot] Renovate専用レビュー( renovate-review )へルーティング ai-auto-approved ラベル ラベル存在チェック 既に承認済み また、新しいpushがあった場合はAI関連ラベルを削除し、承認を取り消して再レビューします。ラベルの削除ルールはpush元によって異なります。 人間のpush: ai-auto-approved / needs-human-review / perspective:* を削除して再分類 Botのpush: ai-auto-approved / needs-human-review のみ削除して分類は維持 Phase 2: Perspective Router(ハイブリッド分類方式) PRの変更内容を分析し、適切なレビュー観点を決定します。キーワードベースの事前分類 + Claudeフォールバックのハイブリッド方式を採用しています。 分類フロー 事前分類ルール ラベル パスパターン キーワード perspective:workflow .github/workflows/ , .github/actions/ , action.yml workflow, ci, cd, pipeline, jobs, steps, runs-on perspective:security auth/ , login/ , session/ auth, token, secret, credential, password, encrypt, sanitize, xss, csrf, pii, vulnerability perspective:quality *.test.* , *.spec.* , __tests__/ , eslint , prettier , tsconfig test, spec, lint, format, accessibility, a11y, aria-, vitest, jest perspective:dependency package.json , pnpm-lock.yaml , yarn.lock , package-lock.json dependency, package, version, upgrade, license perspective:api /api/ , openapi , swagger , schema , .dto. api, endpoint, schema, openapi, graphql, rest perspective:perf (パスパターンなし) performance, optimize, cache, lazy, memo, bundle, split, prefetch perspective:business domain/ , business/ permission, role, billing, payment, validation, status, transition, rule perspective:general 上記に該当しない場合のみ - 複数パターンにマッチした場合、該当ラベルがすべて付与されます。 perspective:general は他のラベルと併用されません。 Phase 3: CI完了待機 他のCIジョブ(型チェック、lint、テストなど)の完了を待機します。待機の仕組みは次のとおりです。 自分自身のワークフローやVRTは待機対象から除外 部分一致の除外パターンも併用して精度を上げる 最大20分、30秒間隔でポーリング CI Runが一定時間見つからない場合(約5分)は ci_status=skipped としてClaudeレビューへ進みます。 success / neutral / skipped は成功扱いです。 CI完了を待つ理由 CIが失敗している状態でレビューしても意味がない CI失敗 → Devin修正 → またCI失敗 → またレビュー... という無駄なループを防ぐ CIが通った状態のコードに対してレビューすることで、品質の高いフィードバックが可能 Phase 4: Claude Review Claude Code Actionを使用して、PRを包括的にレビューします。 Perspectiveラベルに基づくレビュー Claude Reviewでは、Phase 2で付与された perspective:* ラベルに応じて、対応するレビュー観点ドキュメントを動的に読み込みます。 ラベル 対応ファイル 主なレビュー観点 perspective:workflow docs/review-perspectives/workflow.md GitHub Actions/CI/CD の設計・セキュリティ perspective:security docs/review-perspectives/security.md 認証・認可・入力検証・機密情報の管理 perspective:quality docs/review-perspectives/quality.md テスト・Lint・アクセシビリティ perspective:dependency docs/review-perspectives/dependency.md 依存関係の追加・更新・削除 perspective:api docs/review-perspectives/api.md API設計・スキーマ・エラーハンドリング perspective:perf docs/review-perspectives/perf.md パフォーマンス・キャッシュ・最適化 perspective:business docs/review-perspectives/business.md ビジネスロジック・権限・バリデーション perspective:general docs/review-perspectives/general.md 汎用的なコード品質(他に該当しない場合) レビュー実行時の読み込みフローは次のとおりです。 PRから perspective:* ラベル一覧を取得 対応する docs/review-perspectives/*.md を読み込み 取得内容をプロンプトに差し込みレビュー実行 たとえば、セキュリティ関連のPRには security.md の観点が、API関連のPRには api.md の観点が適用されるといった具合です。 レビュープロンプトの構造 レビュープロンプトは次のルールで構成されます。 Bedrock経由でClaudeを実行 Perspective指定と重大度定義をプロンプトに含める Blocker/Majorを blocking: true として扱う 分類判定ルール 判定 条件 自動承認 AI_ONLY テストコード、型定義、明確なバグ修正、リファクタリング 可能 NEEDS_HUMAN ドキュメント、スタイル、設定ファイル、セキュリティ関連 不可 自動承認の対象外となる変更タイプ 次の変更は必ず人間のレビューが必要です( NEEDS_HUMAN と判定)。 ドキュメント更新:README.md, *.mdファイル、docs/配下の変更 スタイル調整:CSS, Tailwindクラス、デザイン・レイアウト変更 設定ファイル変更:tsconfig, eslint, prettier, vite.config等 指摘の重大度とblockingフラグ(Conventional Comments準拠) 重大度 blocking 意味 修正要否 [Blocker] true 必ず修正が必要 必須 [Major] true 強く推奨される修正 必須 [Minor] false 推奨される改善 任意 [Nit] false 微細な指摘(スタイル等) 任意 [Question] false 仕様・意図の確認質問 回答必須(修正不要) [Praise] false 良いコードへの称賛 対応不要 blocking: true の指摘が1件以上あると、Devin自動修正が実行されます。 PR説明文の自動生成・更新 Claude Reviewでは、レビュー結果と同時にPR説明文を自動生成・更新する機能があります。 関連 Issue の自動取得 PR本文からclosing keywords( Closes #123 , Fixes #456 など)を自動的に抽出し、関連Issueを特定します。抽出されたIssue番号はレビュープロンプトに含まれ、Claudeが変更の意図を理解する際のコンテキストとして活用されます。 PRタイトル・説明文の自動生成 ClaudeはPR内の全てのコミットと変更ファイルを分析し、次の手順でタイトルと説明文を自動生成します。 PR内の全コミット一覧を取得(最大50件) 全ての変更ファイルを分析 PR全体を代表するタイトルを日本語で生成(Conventional Commits形式) .github/PULL_REQUEST_TEMPLATE.md に沿って説明文を生成 タイトル生成ルール 形式: {prefix}: {変更内容の要約} (例: feat: ユーザー認証機能の追加 ) prefix:feat / fix / docs / refactor / chore / testから変更種別に応じて選択 50文字以内で簡潔に日本語で記載 複数の変更がある場合は、主要な変更を代表するタイトルに 生成されたタイトルと説明文は、レビュー完了後に自動的にPRへ反映されます(それぞれ生成された場合のみ更新)。PR作成者がタイトルや説明文を適切に書けなかった場合でも、Claudeが内容を自動生成してくれます。最新のコミットだけでなくPRに含まれる全てのコミットを考慮するため、複数回のpushがあっても正確な内容が生成されます。 Phase 5: Devin自動修正 Claudeのレビューで指摘があった場合、またはCIが失敗した場合、Devinが自動修正します。 Devin修正の種類 ジョブ名 用途 参照 Playbook devin-ci-fix CI失敗の修正(CIログを解析して自動修正) fix-ci-failure.devin.md devin-review-fix レビュー指摘の修正(Claudeの指摘に基づいて自動修正) fix-review-comments.devin.md Devinへの修正指示は次の手順で行います。 blocking: true の指摘だけを抽出し、抽出結果をmacro payloadに組み込んでDevinに送信します。 修正ループの詳細 ループカウントの管理 イテレーションはCI修正 + レビュー修正の合計回数でカウントします。 Push種別 カウント動作 理由 人間がPR作成 0からスタート 新規PR Devinがpush カウント継続 自動修正ループ中 人間が修正push 0にリセット 人間の修正後は新規扱い 上限(3回)に達した後でも、人間が修正してpushすればカウントがリセットされ、レビューが再開されます。 Perspectiveラベルの更新ルール Push種別 Perspective Router ラベル動作 人間がPR作成 ✅ 実行 新規ラベル付与 Devinがpush ❌ スキップ 既存ラベル維持 人間が修正push ✅ 実行 既存ラベル削除 → 再分類 ループ終了条件 正常終了:CI成功 + レビュー指摘なし → 自動承認 上限到達: iteration_count >= MAX_ITERATIONS → 人間レビュー依頼 タイムアウト:CI待機が20分超過 → 人間への確認依頼 自動承認の条件 次のすべてを満たした場合のみ自動承認されます。 CI成功 Claudeレビュー完了 分類が AI_ONLY blockingな指摘がない HEAD SHAが変更されていない(レース条件対策) データ連携方式 Claude → Devinのデータ渡し Claudeのレビュー結果をDevinに渡す方式は、ジョブ出力による直接連携を採用しています。 構造化出力スキーマ Claudeに --json-schema オプションでレビュー結果の構造を指定し、次の項目を必須にしています。 classification (AI_ONLY / NEEDS_HUMAN) Blocker/Major/Minorの件数フラグとカウント fix_instructions (修正指示文) issues (指摘一覧:severity / blocking / file / messageなど) 技術的なポイント 1. concurrencyによる並行実行の制御 同一PRに対して1つのワークフローのみ実行( group で制御) cancel-in-progress: true のため、Devinが修正をpushした場合: 新しいワークフローが開始される 古いワークフローは自動的にキャンセルされる Devinセッションはtrapハンドラーにより自動停止される 新しいワークフローはCI完了を待機してからレビュー 2. Devinセッションの自動停止 ワークフローがキャンセルされた際、実行中のDevinセッションを確実に停止するため、二重のセーフティネットを実装しています。 trapハンドラー(即時対応) Devin API呼び出し中にシグナル(SIGINT/SIGTERM)を受信した場合、trapハンドラーが即座にセッションを停止します。SIGINT/SIGTERMを受けると即座にcleanupを実行し、 /tmp/devin_resp.json から session_id を取得して10秒以内にセッションを終了します。 if: cancelled() バックアップステップ API呼び出しが完了してからキャンセルされた場合に備えて、専用のクリーンアップステップを用意しています。 outputs または /tmp/devin_resp.json から session_id を取得してセッションを停止します。 GitHub Actionsのキャンセルシーケンス GitHub Actionsはキャンセル時に次の順序でシグナルを送信します。 SIGINT 送信 → 7.5秒待機 SIGTERM 送信 → 2.5秒待機 強制終了 trapハンドラーは最初の SIGINT でcleanupを実行するため、最大10秒の猶予内でDevinセッションを停止できます。 3. AWS BedrockによるClaude呼び出し セキュリティ要件を満たすため、Claude APIではなくAWS Bedrock経由でClaudeを呼び出しています。AWS認証情報を設定し、Claude Code Actionの use_bedrock を有効化しています。 設定項目 値 リージョン us-east-1 IAMロール <your-bedrock-access-role> モデル(レビュー) Claude(Bedrock Application Inference Profile) モデル(分類) Claude Haiku( anthropic.claude-3-haiku-20240307-v1:0 ) 4. マクロ形式によるプロンプト管理 Devinへの指示は、macro IDとpayloadをJSONとして渡すマクロ形式で標準化しています。Playbookのmacro IDと一対一で対応しており、一貫性のある指示が可能です。 5. HEAD SHAの検証 レース条件を防ぐため、承認前にHEAD SHAが変更されていないことを確認しています。実行開始時のHEAD SHAと現在のSHAを比較し、一致しない場合は自動承認をスキップします。 使い方 AI Task Implementationの使い方(Slack → Issue → 実装) SlackからDevinにタスクを依頼すると、Issue作成から実装、PR作成までを自動で行います。各Playbookの詳細なフローは「 Devin Playbook 」セクションを参照してください。 起動方法 方法 手順 動作 Slackから(推奨) @devin !ai_taskタスク説明 DevinがIssue作成 → ai-task ラベル付与 → ai-task-dispatcher.yml 発火 → 実装開始 Slackから(複雑なタスク) @devin !ai_tasksタスク説明 Devinが親Issue作成 → sub-issueに分割 → 並列実装 GitHub Issueから Issue に ai-task / ai-tasks ラベルを付与 ai-task-dispatcher.yml 発火 → 実装開始 注意点 in-progress ラベルがあるIssueはスキップされます(実装中のため) セキュリティ関連を検出した場合、 !human_review への切り替えを案内してセッション終了 タスクサイズが合わない場合、適切なPlaybookへの切り替えを案内してセッション終了 AI Review Orchestratorの使い方(PR → レビュー → 承認) PRが作成・更新されると、 ai-review-orchestrator.yml が自動起動し、Claudeがレビューします。 自動レビューの流れ CI完了待機(最大20分) Perspective分類(初回のみ、変更内容に応じてラベル付与) Claudeレビュー(Perspectiveに基づく観点でレビュー) 結果に応じた分岐: 指摘あり → Devin自動修正(最大3回) AI_ONLY + 指摘なし → 自動承認 NEEDS_HUMAN → 人間レビュー依頼 Claudeへの質問・修正依頼 PRやIssueに対してClaudeに質問や修正依頼をしたい場合は、 @znm-claude-code をメンションしてコメントします。 操作手順まとめ 入口を選ぶ。 @devin !ai_task (単一タスク)/ @devin !ai_tasks (分割タスク)/ @devin !human_review (人間の承認が前提)。Slackを使わない場合はIssueにラベルを付与する DevinがIssue/PRを作成し、 ai-review-orchestrator.yml が自動起動する CI完了後にClaudeがレビューし、指摘があればDevinが修正する(最大3回) AI_ONLY かつ指摘なしなら自動承認、 NEEDS_HUMAN は人間レビューへ。Claudeへの質問や修正依頼は @znm-claude-code を利用する 運用上の例外( skip-ai-review / renovate など)は「運用上の注意点」を参照してください。 導入効果 定量的な効果 指標 導入前 導入後 PR作成からレビュー開始まで 平均4時間 即時 CIエラー修正時間 平均30分/回 自動 単純なIssueの実装開始 平均1日 即時 RenovatePRの処理 手動確認・マージ 依存関係レビュー後に自動承認 定性的な効果 開発者が集中する時間の確保 定型的なエラー修正から解放され、創造的な作業に集中できるようになった レビュー品質の均一化 Perspectiveに基づくレビューで、一貫した品質のレビューが実現した レビュアーの経験や専門分野に依存しない指摘が可能になった 心理的安全性の向上 AIが最初にレビューすることで、人間のレビューでの「些細な指摘」が減った レビュアーは本質的な議論へ集中できるようになった ナレッジの蓄積 レビュー観点ドキュメント( docs/review-perspectives/ )が知識ベースとして機能 新しいメンバーのオンボーディングにも活用できる ライブラリ更新の自動化 Renovateが作成する依存関係の更新PRをClaudeがレビュー AI_ONLYかつ指摘なしなら自動承認 セキュリティパッチの迅速な適用に貢献 Renovate PRの自動レビュー・承認 Renovateが作成した依存関係の更新PRは、専用ワークフロー( ai-renovate-review.yml )で自動処理されます。 自動承認の条件 patch / minorの安全な更新のみ 破壊的変更なし Blocker / Major指摘が0件 AI開発の効果測定(週次レポート) ai-metrics-report.yml により、AI駆動開発の効果を週次で自動計測し、GitHub Issueとして報告します。 計測される KPI 指標 目標 説明 AI_ONLYレビュー通過率 60%以上 AIのみで承認可能と判定されたPRの割合 自動承認率 - 実際に自動承認されたPRの割合 Devinの修正成功率 70%以上 Devinが修正を試みたPRのうち、マージに至った割合 イテレーション上限到達率 10%以下 修正ループが上限(3回)に達したPRの割合 平均イテレーション回数 1.5回以下 PRあたりの平均の修正回数 CI失敗率 20%以下 CI失敗が発生したPRの割合 レポート例 ## AI 開発効果測定 週次レポート **集計期間**: 2025-12-15 ~ 2025-12-22 **対象 PR 数**: 25 件 ### 📊 主要指標(KPI) | 指標 | 実績 | 目標 | 状態 | |------|------|------|------| | AI _ ONLY レビュー通過率 | 72.0% | 60% 以上 | ✅ | | 自動承認率 | 68.0% | - | - | | Devin 自動修正成功率 | 85.7% | 70% 以上 | ✅ | | イテレーション上限到達率 | 4.0% | 10% 以下 | ✅ | | 平均イテレーション回数 | 1.2 回 | 1.5 回以下 | ✅ | | CI 失敗率 | 16.0% | 20% 以下 | ✅ | 🎉 すべての主要指標が目標を達成しています! このレポートは毎週月曜10:00 JSTに自動生成され、 ai-metrics ラベル付きのIssueとして作成されます。 AIコンテキストの自動育成 devin-context-refresh.yml により、PRコメントからのフィードバックを元にAIコンテキストドキュメントを週次で自動更新します。 動作フロー 更新対象ファイル カテゴリ ファイル ルート AGENTS.md docs/ai-context/ coding-standards.md , context.md , data-model.md , design-template.md , glossary.md , implementation-patterns.md , metrics.md , pr_review_classification.md , pr_review_comment_rules.md , role.md , task-assignment.md docs/guidelines/ architecture.md , environment-variables.md , error-handling.md , naming-conventions.md , validation.md docs/review-perspectives/ api.md , business.md , dependency.md , general.md , perf.md , quality.md , security.md , workflow.md ドキュメントの階層構造 AIエージェントがコンテキストを読み込む際、次の階層構造で参照が行われます。 CLAUDE.md:Claude Codeが最初に読み込むファイル。 @AGENTS.md でメインガイドを参照 AGENTS.md:AIエージェント向けメインガイド。最重要ルール(3-5個)と各ドキュメントへのパス参照を記載(300行未満を推奨) docs/:詳細情報は各サブディレクトリに委譲し、AGENTS.mdからパス参照のみ記載 この構造によってコンテキストの段階的読み込みが可能になり、トークン消費を抑えつつ必要な情報にアクセスできます。 各ディレクトリの役割 ディレクトリ 役割 使用タイミング CLAUDE.md Claude Codeのエントリーポイント。 @AGENTS.md でAGENTS.mdを参照 Claude Code起動時に自動読み込み AGENTS.md AIエージェント向けメインガイド。最重要ルール(3-5個)と各ドキュメントへの参照を記載 Devinがセッション開始時に読み込む docs/ai-context/ コーディング規約、実装パターン、用語集、データモデルなどのコンテキスト情報 Playbook内で参照先として指定。週次更新でPRコメントから学んだ知見を蓄積 docs/guidelines/ アーキテクチャ設計、エラーハンドリング、バリデーションなどの詳細な技術ガイドライン Playbook内で参照先として指定。週次更新で継続的に改善 docs/review-perspectives/ perspective:* ラベルに対応したレビュー観点ドキュメント Claude ReviewがPRのPerspectiveに応じて動的に読み込み( 詳細 ) PRレビューで蓄積されたナレッジが自動的にドキュメントに反映されるため、Claudeのレビュー品質も継続的に向上していきます。 残っている課題 AIでも対応が難しいドメイン固有の判断があり、最終的に人間の合意が必要になるケースが残る ワークフローが複雑になり、運用ルールの理解・周知コストが発生している AIによる正しい指摘でも、チームの合意形成に時間を要する場合がある 運用上の注意点 スキップラベルの活用 特定のPRでAIレビューをスキップしたい場合は、次のラベルを使用します。 skip-ai-review : AIレビューを完全にスキップ renovate : Renovate PR(専用ワークフローで処理) 人間のレビューが必要なケース 次の変更は NEEDS_HUMAN と判定され、必ず人間のレビューが必要です。 セキュリティ関連の変更 ドキュメント・READMEの更新 設定ファイルの変更 スタイル・デザインの変更 ワークフロー別ラベル操作 ワークフロー 付与するラベル 削除するラベル ai-task-dispatcher.yml -(条件判定のみ、ラベル操作なし) - ai-task-implementation.yml in-progress in-progress (失敗時/キャンセル時) ai-review-orchestrator.yml ci-failed , ai-iteration-{N} , ai-auto-approved , needs-human-review ci-failed , ai-auto-approved , needs-human-review , perspective:* , ai-iteration-{N} pr-perspective-router.yml perspective:* perspective:* (再分類時) ai-renovate-review.yml perspective:dependency , renovate - ai-metrics-report.yml ai-metrics , automated (Issueに付与) - トラブルシューティング 問題 対処法 Devinセッションが開始しない DEVIN_API_KEY の設定を確認 Claudeレビューが実行されない AWS IAMロールの権限を確認 イテレーション上限に達した 手動で修正し、 ai-iteration-* ラベルを削除 CIタイムアウト CIの実行時間を確認、 CI_WAIT_MINUTES の調整を検討 まとめ この記事では、Claude(レビュー・判断)× Devin(実装・修正)の役割分担によるAI駆動開発ワークフローについて解説しました。 実現した効果 指標 Before After PRレビュー待ち時間 平均4時間 即時(Claudeが自動レビュー) レビュー指摘の修正 手動対応 Devinが自動修正 CIエラー修正 手動対応 Devinが自動修正(最大3回リトライ) Issue実装開始 平均1日 Slackから即時着手 Renovate PR 手動確認・マージ Claudeが依存関係レビューし、AI_ONLYかつ指摘なしなら自動承認 効果測定 なし 週次レポートでKPIを自動計測 ナレッジ育成 なし PRコメントからドキュメントを週次で自動更新 重要なポイント 役割分担:Claudeはレビュー(判断)、Devinは実装(作業)という明確な役割分担 人間との協調:AIはあくまでサポートであり、最終判断は人間が行う設計 段階的な自動化:すべてを自動化するのではなく、信頼性の高い部分から自動化 透明性:すべての判断理由がPRコメントとして記録される セーフティネット:イテレーション上限、キャンセル時のクリーンアップ、HEAD SHA検証 今後の展望 レビュー観点の自動学習 MCP連携強化 Confluenceドキュメント読み込み Figma MCPからデザイン取得 → 実装まで一気通貫 Jiraチケット起点の連携追加(Slackと同様のフローで起動) 人間レビューコメント時の対応方針の定義(優先度・担当・再レビュー) CodeRabbitとの併用検討 Slack通知の拡充(開始/完了/要人間対応などを分かりやすく通知) 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 参考リンク anthropics/claude-code-action - Claude CodeのGitHub Action Devin API Documentation - Devin APIリファレンス
アバター
.entry .entry-content .table-of-contents > li > ul { display: none; } はじめに こんにちは、検索基盤部の 朝原 です。ZOZOTOWNの検索改善を担当しています。 日々の分析業務では、ユーザーの行動ログを集計するSQLクエリを頻繁に作成します。クエリ作成には定型的なパターンも多く、作業時間を短縮する手段として生成AIの活用を検討しました。しかし、社内固有のログ構造や前提条件が多く、生成AIを利用しても期待どおりにクエリを作成できないという課題がありました。 本記事では、Claude CodeのSkills機能やサブエージェント機能を活用してこの課題を解決した方法を紹介します。 目次 はじめに 目次 なぜ生成AIでログ分析のSQLを書くのが難しいのか 1. 社内固有のログ構造を参照できない 2. チームのSQL規約に準拠した出力が担保できない 3. 暗黙的に共有されているナレッジを生成AIが持っていない 解決策の全体像 技術的アプローチ 1. Claude Code向けログ定義書の整備 方針 Skillsの具体的な内容 2. 3段階エージェントによるコンテキスト最適化 エージェント定義ファイル 3. カスタムスラッシュコマンドによるワークフロー自動化 実行例と結果 プラグイン化 課題と今後の展望 より多くのテーブルへの対応 テーブルインデックスの自動更新 まとめ なぜ生成AIでログ分析のSQLを書くのが難しいのか 私たちは、検索体験におけるユーザー行動ログを、日々SQLで集計して指標化し、施策検証や課題発見に活用しています。具体的には、検索結果画面における商品およびサジェスト(キーワード検索の補完機能)のインプレッション、クリック、ならびに購買に至る一連のイベントを集計対象としています。生成AIの登場により多くのコーディング業務が自動化されました。しかし、社内のログ分析においては以下の課題により活用が進んでいませんでした。 1. 社内固有のログ構造を参照できない 生成AIは一般的なSQLの生成が可能です。しかし、社内固有のログのテーブル構造については前提知識がありません。ログのテーブル名やスキーマとクエリが同じリポジトリで管理されていないため、コーディングエージェントがそれらを参照できません。 2. チームのSQL規約に準拠した出力が担保できない インデントの数や予約語の取り扱いなど、チーム内で定められたコーディング規約があります。チーム内でのSQLクエリの共有を考えると、生成AIが作成するクエリも規約に準拠している必要があります。一方で、生成AIの標準的な出力ではこれらを完全に再現できません。 3. 暗黙的に共有されているナレッジを生成AIが持っていない どのIDで集計すべきか、どのタイムスタンプを基準にするべきかといったドキュメント化されていない知識が存在します。こうした暗黙知をコーディングエージェントは持っていません。 こうした要因が重なり、「生成AIにSQLを書かせても、結局手直しが必要で効率化にならない」という状況が続いていました。 解決策の全体像 これらの課題を解決するため、Claude Codeを活用してクエリを自動生成するフローを構築しました。 以下がSQLクエリを生成するフローの全体像です。 以下のようなコマンドを入力するだけで、ログの構造や規約を踏まえた実用的なSQLクエリが自動生成されます。 /make_sql 商品カテゴリ「スニーカー」の表示回数、クリック数、クリック率をプラットフォーム毎に調査するクエリを作成してください 技術的アプローチ LangGraph、Agent Development Kit、Strands Agents等のエージェント開発フレームワークを使うことでよりカスタマイズ性の高いものを構築できます。しかし、今回は「低コストかつ短期間に、社内展開しやすい形で実現する」ためClaude Codeを採用しました。 現在ZOZOではエンジニア社員に月額200米ドルの基準のもと、コーディングエージェントを導入しております。ご興味のある方は以下の記事もご参照ください。 corp.zozo.com 今回紹介する手法で用いるClaude Codeの設定ディレクトリ構造は以下の通りです。 .claude/ ├── agents/ # エージェント定義 │ ├── log-data-advisor.md # 要件定義&ログ情報取得 │ ├── query-builder.md # クエリ作成 │ └── query-validator.md # 品質管理 └── skills/ ├── make_sql/ # ワークフロー定義(スラッシュコマンド) │ └── SKILL.md ├── table-index/ # テーブル一覧・関係性 │ └── SKILL.md ├── table-details/ # テーブル詳細仕様 │ ├── SKILL.md │ ├── product_impressions.md # サポートファイル │ ├── product_clicks.md # サポートファイル │ └── ... └── quality-check/ # 品質チェック └── SKILL.md 以降で各要素について詳しく説明します。 1. Claude Code向けログ定義書の整備 方針 まずはClaude Code向けに社内のログ情報をドキュメント化するところから始めました。 ログは非常に多くのテーブルが存在するため、全ての情報を1つのファイルにまとめるとLLMに渡すコンテキストが肥大化し、必要な情報が埋もれて出力精度が低下しやすくなります。そこで、Claude CodeのSkills機能 1 をドキュメントの定義先として採用し、役割ごとに分割して管理することにしました。Skillsとは SKILL.md ファイルに指示を記述することでClaudeの機能を拡張する仕組みです。 .claude/skills/<skill-name>/SKILL.md というディレクトリ構造で作成します。 Skillsを採用した理由は、エージェントが必要なときに必要な情報だけを参照できる点にあります。すべてのログ情報を一度に読み込むとコンテキストが肥大化しますが、Skillsとして分離しておけば、エージェントは必要なスキルだけを選択的に参照できます。 用途ごとに、以下の3つのSkillsに分離しました。 Skills 用途 table-index ログテーブルの特徴、関係性を把握 table-details ログテーブルの詳細仕様を把握 quality-check チーム内SQL規約に基づいた品質チェック table-index スキルで必要なテーブルを特定し、 table-details スキルで詳細仕様を参照、 quality-check スキルでチーム内のSQL規約に従っているかチェックを行う流れです。ログテーブルの情報を2回に分けることで、エージェントがまずテーブル一覧で必要なテーブルを特定し、次に詳細仕様で実装に必要な情報を取得する、というコンテキスト効率の良い参照フローが実現できます。 Skillsの具体的な内容 以下は各スキルの具体例です。実際のログ情報とは異なります。 .claude/ ├── ... └── skills/ ├── table-index/ # テーブル一覧・関係性 │ └── SKILL.md ├── table-details/ # テーブル詳細仕様 │ ├── SKILL.md │ ├── product_impressions.md # サポートファイル │ ├── product_clicks.md # サポートファイル │ └── ... └── quality-check/ # 品質チェック └── SKILL.md .claude/skills/table-index/SKILL.md このスキルでは、ログテーブルの一覧とテーブル間の関係性を提供します。エージェントはまずこのスキルを参照して、要件に合うテーブルを特定します。テーブル名やカラム名だけでなく、各テーブルの説明やテーブル間の関係性なども自然言語で記載しています。このアプローチは、Tiger Data社の研究 2 で報告されている「セマンティックカタログ」と同様の効果を狙ったものです。同研究ではデータベースのメタデータに加え、ビジネスロジックなどを自然言語で記述したセマンティックカタログを用意することで、LLMによるSQL生成の精度が最大27%向上したと報告されています。 --- name: table-index description: 分析に使用可能なログテーブルの一覧と関係性を取得 --- # ログテーブル一覧   `` `yaml  tables:   product_impressions:   description: "商品が表示された時のログ"   product_clicks:   description: "商品がクリックされた時のログ"  relationships:   - from: product_impressions   to: product_clicks   join_key: [impression_id, platform] description: "プロダクトの表示時にimpression_idが発行され、クリック時に同じimpression_idが記録される"   ``` .claude/skills/table-details/SKILL.md このスキルでは、ログのテーブル定義の詳細を提供します。エージェントは table-index スキルを元に選定したログの詳細情報を、このスキルを参照して取得します。スキルディレクトリには SKILL.md に加えて、マークダウンドキュメントやスクリプトなどのサポートファイルを配置できます。 SKILL.md からサポートファイルを参照することで、エージェントは必要なログテーブルの詳細情報のみを読み込みます。これによりコンテキストを効率的に管理できます。 --- name: table-details description: 指定されたログテーブルの詳細仕様を取得 --- # テーブル詳細仕様 詳細な仕様は以下のサポートファイルを参照してください: - [ product _ impressions.md ]( product_impressions.md ) - [ product _ clicks.md ]( product_clicks.md ) .claude/skills/table-details/product_impressions.md(サポートファイル) このファイルでは、ログテーブルの実際の詳細仕様を記載しています。テーブルの詳細仕様は社内ドキュメントツールで各チームが管理しています。各ログのドキュメントURLを記載することで、エージェントがMCPを通じて最新のテーブル情報を取得します。これにより、ドキュメント側のテーブル構造が更新されても、Claude Code側の設定を変更することなく、最新の情報を参照できます。また、推奨されるJOINの条件やタイムスタンプの扱いなど、ドキュメント化されていない知識もここに記載しています。 # 商品表示ログ(product _ impressions) MCPを使用して以下のドキュメントを取得してください。 - テーブル仕様: https://docs.example.com/logs/product _ impressions ## 暗黙知・注意事項 - JOINする際は必ず ` impression_id ` と ` platform ` の両方で結合 - 期間絞り込みは ` event_timestamp ` を ` Asia/Tokyo ` でDATE変換して行う .claude/skills/quality-check/SKILL.md このスキルでは、SQLのコーディング規約を提供します。エージェントがこのスキルを参照し、生成されたクエリが社内のコード規約を遵守しているかのチェックと自動修正をします。 --- name: quality-check description: SQLコーディング規約のチェックと自動修正 --- # SQLコーディング規約 ## フォーマットルール - インデントは半角スペース2つ - 予約語は小文字(select, from, where等) - カンマは行頭に配置 - 1行の文字数は100文字以内 ## 命名規則 - エイリアスは意味のある短い名前(imp, click等) - CTEは処理内容がわかる名前(impressions, clicks等) 2. 3段階エージェントによるコンテキスト最適化 ドキュメントを整備しただけでもクエリの自動生成は可能でした。しかし、大量のログドキュメントを一度に処理すると、コンテキストの肥大化という問題が発生するため、3段階のサブエージェント 3 に分割し、各エージェントが必要最小限の情報だけを扱う設計にしました。サブエージェントは、特定の種類のタスクを処理する特化した エージェントです。各サブエージェントは、カスタムシステムプロンプト、特定のツールアクセス、および独立したコンテキストウィンドウで実行されます。 ステップ エージェント 役割 入力(コンテキスト) 出力 1 要件定義&ログ情報取得 自然言語からの要件定義、必要なテーブルを特定 table-index スキル 必要なテーブル情報、実装方針 2 クエリ作成 SQLクエリを生成 table-details スキル、ステップ1の出力 SQLクエリ 3 品質管理 規約チェックと自動修正 quality-check スキル、ステップ2の出力 SQL規約準拠済みのSQLクエリ この分割により、各エージェントが扱う情報量を最小限に抑え、コンテキストの肥大化を防いでいます。 エージェント定義ファイル 各エージェントは .claude/agents/ ディレクトリにマークダウンファイルとして定義します。 .claude/ ├── ... ├── agents/ # エージェント定義 │ ├── log-data-advisor.md # 要件定義&ログ情報取得 │ ├── query-builder.md # クエリ作成 │ └── query-validator.md # 品質管理 └── ... 各エージェントが誤って自身に関係のないSkillsを読み込むと、コンテキストが肥大化してしまうおそれがあります。そこで、各エージェントの定義ファイルでは、そのエージェントが参照できるSkillsを明確に指定しています。 .claude/agents/log-data-advisor.md --- name: log-data-advisor description: ユーザーの分析要件から必要なログテーブルを特定する skills: - table-index --- あなたはログデータアドバイザーです。ユーザーの分析要件を理解し、必要なテーブルを特定します。 詳細化が必要な場合は、追加の質問を行ってください。 ## 責務 1. ユーザーの分析目的を理解 2. 必要なログテーブルを特定 3. データの関連性と結合条件を明確化 ## 出力形式 - 使用するテーブル名のリスト - テーブル間の結合条件 - 実装方針の概要 .claude/agents/query-builder.md --- name: query-builder description: テーブル情報に基づいてSQLクエリを生成する skills: - table-details --- あなたはSQLクエリビルダーです。テーブル詳細仕様に基づいてBigQuery向けの最適なクエリを生成します。 ## 責務 1. /table-detailsのスキルを参照してテーブル詳細を取得 2. 暗黙知・注意事項を踏まえたクエリ作成 3. パフォーマンスを考慮した最適化 .claude/agents/query-validator.md --- name: query-validator description: 生成されたSQLがコーディング規約に準拠しているかチェックし、自動修正する skills: - quality-check --- あなたはSQL品質管理者です。生成されたクエリのコーディング規約チェックと自動修正を行います。 ## 責務 1. インデント、予約語の大文字小文字などの規約チェック 2. 規約違反の自動修正 3. 修正内容のレポート 3. カスタムスラッシュコマンドによるワークフロー自動化 ここまででもSQL自動生成は可能ですが、3つのエージェントを個別に呼び出すのは手間がかかるため、カスタムスラッシュコマンドで一連のワークフローとして自動化しました。 Skillsはスラッシュコマンドとしても機能します。 .claude/skills/<skill-name>/SKILL.md として作成すると、 /skill-name で呼び出せます。フロントマターで disable-model-invocation: true を指定すると、Claudeが自動的にスキルを適用するのを防げます。これにより、ユーザーがコマンドで明示的に呼び出した場合のみ実行されるよう制御できます。 .claude/ ├── ... └── skills/ ├── make_sql/ # ワークフロー定義(スラッシュコマンド) │ └── SKILL.md ├── ... ... .claude/skills/make_sql/SKILL.md に以下のような設定ファイルを作成します。 --- name: make _ sql description: "ユーザーの要件に基づいて、ログデータを分析し、最適なSQLクエリを生成します" disable-model-invocation: true allowed-tools: Task(log-data-advisor), Task(query-builder), Task(query-validator) --- # SQLクエリ生成ワークフロー このコマンドは、ユーザーの分析要件に基づいて、内部ログデータから最適なSQLクエリを生成する3段階のワークフローです。 ## ワークフローの流れ ### ステップ1: ログデータ要件の分析 @agent-log-data-advisor を使用して: - ユーザーの分析目的を理解 - 必要なログテーブルを特定 - データの関連性と結合条件を明確化 ### ステップ2: SQLクエリの生成 @agent-query-builder を使用して: - ステップ1で特定した要件に基づくクエリ作成 - パフォーマンスを考慮した最適化 ### ステップ3: 品質管理と規約検証 @agent-query-validator を使用して: - 生成されたクエリのコーディング規約チェック - 自動修正による品質保証 実行例と結果 商品の表示やクリックに関する分析クエリを生成する例を紹介します。なお、以下は説明用のサンプルで、実際のテーブル名・スキーマ構造とは異なります。 入力コマンド /make_sql 商品カテゴリ「スニーカー」の表示回数、クリック数、クリック率をプラットフォーム毎に調査するクエリを作成してください 生成されたSQLクエリ(一部省略) -- 商品の表示・クリック分析 with impressions as ( select platform , impression_id , product_category , event_timestamp from `example_project.search_logs.product_impressions` where true and date (event_timestamp, ' Asia/Tokyo ' ) >= ' 2025-09-01 ' and date (event_timestamp, ' Asia/Tokyo ' ) <= ' 2025-09-30 ' and product_category = ' スニーカー ' ) , clicks as ( select platform , impression_id from `example_project.search_logs.product_clicks` where true and date (event_timestamp, ' Asia/Tokyo ' ) >= ' 2025-09-01 ' and date (event_timestamp, ' Asia/Tokyo ' ) <= ' 2025-09-30 ' ) -- メインクエリ select imp.platform , count ( distinct imp.impression_id) as total_impressions , count ( distinct click.impression_id) as total_clicks , round ( safe_divide( count ( distinct click.impression_id) * 100.0 , count ( distinct imp.impression_id) ), 2 ) as click_through_rate from impressions as imp left join clicks as click on imp.impression_id = click.impression_id and imp.platform = click.platform group by imp.platform order by total_impressions desc このように自然言語で要件を伝えるだけで、社内のログを踏まえ、チームのコード規約に従った分析用のクエリが自動生成されました。 プラグイン化 本記事で紹介した一連のフローは、Claude Codeのプラグイン機能 4 を活用して全社展開しています。 プラグインは、Claude Codeの設定やSkills、サブエージェントなどを配布できる機能です。社内にマーケットプレイスリポジトリ 5 を構築してプラグインを公開しているため、社員であれば以下のコマンドで誰でもインストールして利用できます。 マーケットプレイスリポジトリをClaude Codeに登録する。 /plugin marketplace add { リポジトリ名 } マーケットプレイスリポジトリからプラグインをインストールする。 /plugin install { 登録したマーケットプレイス上のプラグイン名 } プラグインを利用する。 / { 登録したマーケットプレイス上のプラグイン名 } :make_sql { 要件 } プラグインの詳細については 公式ドキュメント を参照してください。 課題と今後の展望 より多くのテーブルへの対応 現在テーブルインデックスに記載しているログは、弊チームでよく使うテーブルに絞っています。しかし、社内にはより多くのログが存在しているため、単純に SKILL.md に定義を追加するだけでは非常に多くのコンテキストを消費してしまいます。そこで、要件に合うテーブルを自動で検索する仕組みを検討しています。 テーブルインデックスの自動更新 今回はMCPでテーブルの仕様を社内ドキュメントから取得し、エージェントにテーブルインデックスを生成させていました。しかし、ログの仕様が変わる毎にテーブルインデックスを更新する必要があるため、運用コストが高い状態です。そこで、長期的には自動更新する仕組みを検討する必要があります。 まとめ 本記事では、Claude CodeのSkills機能を活用したSQLを自動生成するフローについて紹介しました。近年AIエージェントの文脈では、コンテキストエンジニアリングが重要であると言われています。コーディングエージェントにおいても、今回のように社内固有の情報やエンジニアが持っている暗黙知を提供することで、より実用的なアウトプットが得られると実感しました。「生成AIは社内のログの知識を持っていない」という課題に対し、ドキュメント整備とエージェント設計で対処するアプローチは、SQL生成に限らず他の業務自動化にも応用できると考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co Claude Code Skills https://code.claude.com/docs/ja/skills ↩ TigerData. "The Database's New User: LLMs Need a Different Database" https://www.tigerdata.com/blog/the-database-new-user-llms-need-a-different-database ↩ Claude Code Sub-Agents https://code.claude.com/docs/ja/sub-agents ↩ Claude Code Plugins https://code.claude.com/docs/ja/plugins ↩ Claude Code Marketplace https://code.claude.com/docs/ja/plugin-marketplaces ↩
アバター
はじめに こんにちは、Developer Engagementブロックの @wiroha です。2月10日に「 ZOZO.swift #2 」をオンラインで開催しました。ZOZOのiOSエンジニアによるiOS特化のイベントです。昨年12月に第1回を開催しており、今回第2回目を開催できました。 イベントはオンライン開催でしたが、可能なメンバーはオフィスに集まって配信しました。その当日の雰囲気も含めてレポートします! 登壇内容まとめ ZOZOのエンジニア5名と、技術顧問の岸川さんが発表しました。 発表タイトル 登壇者 旅先で iPad + Neovim で iOS 開発・執筆した話 lap/らぷ ( @laprasDrum ) デザインもAIに任せる!iPhoneで行うiOS開発 イッセー / 上田 壱成 ( @15531b ) ZOZOTOWN、SceneDelegateへのお引越し つっきー / 續橋 涼 ( @tsuzuki817 ) LiDARが変えたARの"距離感" かっつん / 渡邊 魁優 Claude Code で画面の仕様書を作ろう だーはま / 濵田 悠樹 ( @ios_hamada ) 浮動小数の比較について(XCTestとswift-numerics、微妙な実装の違い) 岸川克己 ( @k_katsumi ) 当日の発表はYouTubeのアーカイブ動画をご覧ください。 www.youtube.com 旅先で iPad + Neovim で iOS 開発・執筆した話 speakerdeck.com らぷからは、旅行中にiPadとNeovimを使ってiOS開発を行った話について発表しました。iPadでもさまざまなツールを駆使して開発できており、楽しんでいる様子が伝わってきました。 デザインもAIに任せる!iPhoneで行うiOS開発 speakerdeck.com 上田からは、AIを活用し、デザインまで含めてiPhoneでiOS開発をした話について発表しました。PCなしで開発するという趣旨の発表が偶然2件続き、需要の高さを感じます。 ZOZOTOWN、SceneDelegateへのお引越し speakerdeck.com 續橋の発表では、AppDelegateからSceneDelegateへの移行についてお話ししました。影響範囲が広い中、イベントやリスクなど丁寧に分類してQAを進めていたそうです。 LiDARが変えたARの"距離感" speakerdeck.com 渡邊からは、LiDARセンサーを活用した計測について発表しました。鏡を写した場合は信頼度がLowとなるなど、さまざまなケースが想定されているのは興味深かったです。 Claude Code で画面の仕様書を作ろう speakerdeck.com 濵田からは、Claude Codeを使って画面の仕様書を自動生成する取り組みについて発表しました。実際のプロンプト例も紹介され、実用的な内容でした。 浮動小数の比較について(XCTestとswift-numerics、微妙な実装の違い) speakerdeck.com 岸川さんからは、Swiftにおける浮動小数点数の比較について発表いただきました。許容誤差に応じて適切なツールや実装を選択する必要があると分かりました。 最後に 今回のイベントでは、iOS開発を行う環境の多様化やAI活用など、さまざまなトピックについて共有しました。ご参加くださったみなさま、ありがとうございました。今後もZOZO.swiftを開催していきますので、ぜひご参加ください! ZOZOでは一緒に働くエンジニアを募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.entry .entry-content .table-of-contents > li > ul { display: none; } はじめに こんにちは。Developer Engagementブロック(略称DevEngブロック)の @wiroha です。ZOZO TECH BLOGの運営や、開発者向けイベントの企画・運営などを担当しています。 TECH BLOGの運営において、レビューには一定の工数がかかるため、効率化を進めています。その一環として、Claude CodeのAgent Skills(以下、スキル)を用いたレビュー支援の仕組みを整備しました。Claude Code上で記事のレビューを依頼すると、定義したルールに基づくレビュー結果を得られます。 以下は、スキルによるレビュー結果の抜粋です。 本記事では、このスキルを用いたTECH BLOGレビューの取り組みについて紹介します。 目次 はじめに 目次 背景・課題 解決の方針 スキルの設計 SKILL.md rules.md スキルの使用方法 実行例 導入効果 運用上の注意点 今後の展望 まとめ 付録:rules.md全文 背景・課題 ZOZO TECH BLOGは執筆したチーム内で技術的な正確性をレビューした後、DevEngブロックで文章表現・社内ルール準拠などをレビューして公開しています。DevEngブロックでは、現在2名体制で年間約100本の記事をレビューしており、次の課題があります。 担当者が少なく属人化しやすい 時間がかかってしまう 指摘が多いと漏れが発生しやすく、修正と再レビューの往復で進行が詰まりやすい 記事はGitHub上で管理しており、文章を校正する textlint のGitHub Actionsを動かしているため、ある程度のチェックは自動化されています。 1 ただ、それだけでは網羅できない観点が多くあるため、AIを活用してレビューの自動化をさらに推進することにしました。 解決の方針 AIを活用したレビューには複数の選択肢がある中で、Claude Codeのスキルを用いることにしました。Claude Codeはブラウザやエディタを介さず、ターミナル(CLI)で動作し、手元のファイルや外部サービスの情報を扱いながら作業を支援できるAIツールです。スキルは特定のタスクを実行するためのカスタムモジュールで、ドメイン知識やルールに基づいた処理を一貫した手順でAIに実行させられます。スキルの詳細はClaude Codeの公式ドキュメントをご覧ください。 code.claude.com 今回の場合、 claude コマンドでClaude Codeを起動し、「記事をレビューしてください」のように指示するだけで、スキルが呼び出されてレビューを実行できます。 AIレビューの手段として、ChatGPTのGPTsやGeminiのGemなど対話型AIのカスタマイズ、GitHub Actionsによる自動チェックも検討しました。最終的にスキルを採用した理由は次のとおりです。 ブラウザで使用するAIツールと異なり、記事本文をコピーしてAIにペーストする手間が省ける 同じ仕組みをDevEngブロック以外も使用でき、執筆者によるセルフレビューとしても使える 社内にはClaude Codeの利用者が多く、操作に慣れている レビュールールをGitHub上で管理し、誰でもPRを出して改善提案する運用にできる Claude Code GitHub Actions や Devin でPR上に自動コメントするよりは、任意利用から始めて少しずつAIの精度を高めたい 何度も実行しやすく、レビュールールを継続的に改善しやすい仕組みが重要だと考えました。 スキルの設計 Claude Codeの公式ドキュメント「 Claude をスキルで拡張する 」を参考に、TECH BLOGレビュー用のスキルを設計しました。 執筆時点でのスキルの内容を掲載します。スキル定義ファイルであるSKILL.mdと、TECH BLOGレビューのルールをまとめたrules.mdの2つのファイルで構成されています。 SKILL.md --- name: techblog-review description: "ZOZO TECH BLOGの記事をレビューする。「記事をレビュー」「テックブログをチェック」「entry.mdを確認」などのリクエストで起動" allowed-tools: Read, WebFetch --- ## テックブログ記事レビュー `entry.md` をZOZO TECH BLOGのルールに基づいてレビューします。 ### 手順 1. `entry.md` を読む 2. `rules.md` のルールに基づいてレビューする 3. 記事内のリンクをWebFetchで確認し、リンク切れがないかチェックする 4. 問題がある場合のみ、以下のルールで出力する(「問題なし」「確認のみ」といった項目は記載しない) ### 出力ルール **L{行番号}** - {観点}:{修正内容の要約} ```diff - {修正前の文} + {修正後の文} ``` ### 出力例 **L73** - 文法:「〜なこと」→「〜こと」 ```diff - ドキュメントが古いなことが原因で + ドキュメントが古いことが原因で ``` **L89** - 冗長表現:「〜というのは」→「〜のは」 ```diff - 自動化できるというのは大きな利点です。 + 自動化できるのは大きな利点です。 ``` descriptionには、スキルを起動する際のキーワードを記載しています。allowed-toolsには、スキルが使用できるツールを指定しています。Readは、rules.mdや記事原稿であるentry.mdを読むために必要です。記事内のリンク切れをチェックするために、WebFetchも許可しています。 rules.md 肝となるレビュールールは、DevEngブロックがこれまでに行ってきたレビューやSuggestコメントをもとにしています。Claude Codeに過去3年間のレビューコメントをGitHub CLI 2 で収集させ、ルール案を作成させました。PR上のレビュー履歴を資産として活用し、案は人手で精査してブラッシュアップしました。 以下では、rules.mdの一部を紹介します。全文は長くなるため、付録として記事の末尾に掲載します。 # ZOZO TECH BLOG レビュールール ## 1. 文体・表現 ### 1.1 文体の統一 - **である調とですます調を混在させない** - 基本的にですます調を使用する ### 1.2 文は短くする - **一文が長すぎると読みにくい** - 複数の情報を含む文は分割する - 目安:一文100文字以内 ### 1.3 口語表現の回避 - 口語表現は文語表現に置き換える | 口語 | 文語 | |------|------| | 〜なんですが | 〜ですが | | 無かったです | ありませんでした | | ですので | そのため / したがって | | ないですが | ありませんが | | 食わせる | 与える / 読み込ませる | ### 1.4 曖昧表現の回避 - 「〜と思います」より「〜です」を使う - 「〜ような」で曖昧にせず断定する - 断定できる場合は断定する ``` ❌ 複数の機能を一度にデプロイするようなリリースサイクルを採用していました。 ✅ 複数の機能を一度にデプロイするリリースサイクルを採用していました。 ``` これらの観点の一部は以前Zennの記事「 技術をわかりやすく伝えるテクニカルライティングのtips 」などにまとめて共有していましたが、そこに含まれない多くの観点を整理できました。なお、rules.mdに含まれる例文は、生成されたルール案をもとに手動で改変し、特定の記事が推測されないよう配慮しています。 スキルの使用方法 スキルはブログ執筆用リポジトリのルートに配置しているため、プロジェクトスキルとして自動的に有効になります。 Claude Code上で「今書いている記事をレビューしてください」などと指示すれば、スキルが起動してレビューを実行します。ローカルにある自分の記事をセルフレビューする場合の例は次のとおりです。 Use skill "techblog-review"? と聞かれるので「Yes」と答えると結果が返ってきます。 DevEngブロックのメンバーは、自分が書いていない記事もレビューします。ローカルにない記事もClaude Code上で「 <URL> の記事をレビューしてください」のように指示すれば、GitHub CLIを使って自動的に記事を取得し、レビューしてくれます。 手動でチェックアウトしてきたり、コピー&ペーストしたりする必要がなく、非常に便利です。 実行例 本記事を執筆しながら、実際にスキルを使ってレビューした際の結果を例として紹介します。 1. SKILL.mdでの指定どおり、リンク切れをチェックして有効である旨を表示しています。 2. ルールに書いてある、冗長表現を見つけて指摘しています。 3. ルールに書いていない観点でもチェックしてくれます。行頭に不要なスペースが入っている例です。 4. リンクを埋め込み形式で表示するための embed:cite が誤って入っているのも見つけています。 5. ルールにある「正式名称・表記」の観点で、サービス名の誤りを指摘しています。 6. 広く知られているサービス名だけではなく、ファイル名の誤りも発見できるのは驚きでした。 7. TODOと書いていなくても、消し忘れか対応漏れかもしれないコメントを指摘しており、細やかです。 8. ルールにある「文の流れ」の観点はやや厳密にも感じますが、たしかに読点を入れた方が読みやすいため修正しました。 9. 次の例ではURLの誤りを指摘しており、それ自体は適切であるものの、修正案が英語版ドキュメントのURLになってしまっていました。手動で日本語の方に修正しましたが、こういった誤りもあるため、自動適用はせず人が確認する運用にしています。 以上のように、表記・記法・リンクといった観点を中心に、幅広く指摘できました。 導入効果 実行例で示したとおり、さまざまな観点でチェックできており、修正案を考える時間を短縮できるようになりました。rules.mdで定義した観点に基づき、過去30件のPRのレビューコメントをAIで分類したところ、約75%をカバーできると推定されました。残り25%は「文章の圧縮・再構成」「表現の適切さ」「説明の追加・明確化」といった文脈依存の判断が必要なもので、ルールベースでの検出ができない点でした。 これまで暗黙知としてDevEngブロックメンバーの中で蓄積されていたチェック観点を、rules.mdとして明文化し、AIが担えるようになりました。その結果、レビュー観点が個人の経験やスキルに依存しにくくなり、再現性のあるチェックが行えるようになっています。 また、特定分野の技術を知っていないと見つけづらいタイポなど、自分では見落としていた点も検出でき、記事の品質向上に寄与しています。細部のチェックをAIに任せることで、全体の構成やわかりやすさといった観点に意識を向けやすくなり、読みやすい記事づくりにつながっています。 執筆者にはまだ展開したばかりで、セルフレビューで用いるのは必須とはしていません。ルールの精度を向上させ、執筆段階でセルフレビューとして使うケースが増えていけば、修正と再レビューの往復が減り、執筆者・レビュアー双方の負担軽減につながると考えています。 運用上の注意点 実行例の部分にも記述したとおり、現在の運用ではAIの指摘をそのまま採用せず、提案として扱っています。指摘に誤りが含まれる場合や、厳密すぎる場合があるためです。たとえばルールでは口語表現を回避するよう定めていますが、イベントレポート系の記事では口語の表現を残した方が感情を伝えやすい場合もあります。そのあたりのバランスは人が判断しています。 また、社外秘や推測可能な情報が混入していないかなども人が確認しています。 今後の展望 今後は、スキルの精度向上と機能拡張に継続して取り組みます。記事をレビューした結果をもとにフィードバックループを回し、改善を図ります。 執筆者からの意見次第では、Claude Code以外のAIツールへの対応も検討しています。rules.mdを整備したことで、同じ観点を他のツールにも転用しやすくなりました。Claude Codeを使用していない人でも利用できる導線を用意してもよいかもしれません。 また、本記事に反響があれば、スキルをオープンソース化し最新のルールを社外の方も利用できるようにすることも検討したいと考えています。各社で技術ブログの運用方法は異なるため、本取り組みを通じて、より良いレビューや運用方法について意見交換ができればと思います。 まとめ 本記事では、Claude CodeのAgent SkillsとしてTECH BLOGのレビュー観点を集約し、効率化した取り組みを紹介しました。AIの発展は目覚ましく、優れたツールが次々と登場しています。DevEngブロックは新しい技術を積極的に取り入れ、より良い執筆体験を提供できるよう努めていきます。今回の記事が生成AIをレビュー支援に取り入れる際の設計・運用のヒントになれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。技術記事の執筆が好きな方も大歓迎です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 付録:rules.md全文 # ZOZO TECH BLOG レビュールール ## 1. 文体・表現 ### 1.1 文体の統一 - **である調とですます調を混在させない** - 基本的にですます調を使用する ### 1.2 文は短くする - **一文が長すぎると読みにくい** - 複数の情報を含む文は分割する - 目安:一文100文字以内 ### 1.3 口語表現の回避 - 口語表現は文語表現に置き換える | 口語 | 文語 | |------|------| | 〜なんですが | 〜ですが | | 無かったです | ありませんでした | | ですので | そのため / したがって | | ないですが | ありませんが | | 食わせる | 与える / 読み込ませる | ### 1.4 曖昧表現の回避 - 「〜と思います」より「〜です」を使う - 「〜ような」で曖昧にせず断定する - 断定できる場合は断定する ``` ❌ 複数の機能を一度にデプロイするようなリリースサイクルを採用していました。 ✅ 複数の機能を一度にデプロイするリリースサイクルを採用していました。 ``` ### 1.5 体言止めの回避 - **体言止めは曖昧になりやすいため、技術記事では避ける** - 文として言い切る形にする - **ただし、箇条書きの場合は体言止めでも許容** ``` ❌ 先月、ついに新機能をリリース。 ✅ 先月、ついに新機能をリリースしました。 ``` ### 1.6 「〜たり」の用法 - 並列の「〜たり」は**2回以上繰り返して使う** ``` ❌ コードを書いたりレビューができます ✅ コードを書いたりレビューをしたりできます ``` ### 1.7 「〜になります」「〜となります」の回避 - 変化しない場合は「です」を使う ``` ❌ 本ツールはログ収集の定番ライブラリになります。 ✅ 本ツールはログ収集の定番ライブラリです。 ``` ### 1.8 丁寧すぎる表現の回避 - 物に対する過剰な丁寧表現は避ける ``` ❌ 環境変数を設定ファイルに追記してあげる必要があります。 ✅ 環境変数を設定ファイルに追記する必要があります。 ``` ### 1.9 敬称 - 社外の方(技術顧問、登壇者等)には「さん付け」を使う - 自社社員には敬称は不要とする ### 1.10 簡潔にできる表現(任意) 簡潔にしたい場合は、以下のように表現を変える - 「なぜ〜したか」→「〜した理由」 - 「どのように〜するか」→「〜する方法」 - 「どれくらい閲覧されたか」→「閲覧数」 --- ## 2. 受動態と能動態 ### 2.1 能動態を優先する - **受動態が多いと読みづらくなる** - 主語を明確にして能動態で書く ``` ❌ 複数の設定ファイルはバッチ処理から順次実行されることで、対象システムの状態を更新していきます。 ✅ バッチ処理が複数の設定ファイルを順次実行し、対象システムの状態を更新します。 ``` ### 2.2 受動態と能動態の混在を避ける ``` ❌ そのAPIに対してタイムアウトを設定されており ✅ そのAPIにはタイムアウトが設定されており ``` --- ## 3. 主語と述語 ### 3.1 主語を省略しない - 主語がないと何の話かわからない ``` ❌ この処理の流れの特徴として、決済完了のタイミングで管理画面からメールをユーザーへ同期的に送信します。 ✅ この処理の流れの特徴として、システムは決済完了のタイミングで管理画面の機能を介してメールをユーザーへ同期的に送信します。 ``` ### 3.2 主語と述語のねじれを避ける - 主語と述語が対応しているか確認する ``` ❌ 特に気になった変更点は、新しいログ出力機能が追加されました。 ✅ 特に気になった変更点は、新しいログ出力機能が追加されたことです。 ❌ 今回の会場は、東京国際フォーラムで開催されました。 ✅ 今回の会場は、東京国際フォーラムです。 ✅ 今回のイベントは、東京国際フォーラムで開催されました。 ❌ 結論はコストと移行の容易さからマネージドサービスを選定しました。 ✅ 結論として、コストと移行の容易さからマネージドサービスを選定しました。 ``` ### 3.3 「〜とは」の後に意味を書く - 「〇〇とは」と書いたら、その後に定義・意味を書く ``` ❌ マイクロサービスアーキテクチャとは、システムを独立した小さなサービス単位に分割し、開発スピードと拡張性を大幅に向上させることができます。 ✅ マイクロサービスアーキテクチャとは、システムを独立した小さなサービス単位に分割し、開発スピードと拡張性を大幅に向上させる設計手法のことです。 ``` --- ## 4. 助詞の使い方 ### 4.1 助詞の誤用 | 助詞 | 悪い例 | 良い例 | |------|--------|--------| | を | サーバーを起動を開始 | サーバーの起動を開始 | | を | パラメータを設定をしました | パラメータを設定しました | | が | テスト実行にかかる時間が、全体時間を占める割合が増えた | テスト実行にかかる時間の、全体時間に占める割合が増えた | ### 4.2 助詞を省略しない ``` ❌ 空レスポンス ✅ 空のレスポンス ❌ パフォーマンス影響が出た ✅ パフォーマンスに影響が出た ❌ この項目は必須でありません ✅ この項目は必須ではありません ``` ### 4.3 助詞と動詞の対応 ``` ❌ 設定値を環境間に複製 ✅ 設定値を環境間で複製 ❌ 2018年より会社を設立しました ✅ 2018年に会社を設立しました (「より」は継続の開始を示すため、一時点の出来事には「に」を使う) ❌ 会場内にノベルティが配布されました ✅ 会場内でノベルティが配布されました ❌ このツールには標準で自動リトライ機能を提供しています ✅ このツールは標準で自動リトライ機能を提供しています ``` ### 4.4 冗長な表現を避ける | 冗長 | 簡潔 | |------|------| | 〜について | 〜を | | 〜に対して | 〜に | | 〜に関して | 〜の / 〜を | | 記載をします | 記載します | | 〜というのは | 〜のは | | 〜ということで | 〜ため / 〜ので | ``` ❌ 運用コストを削減できるというのは大きなメリットでした。 ✅ 運用コストを削減できるのは大きなメリットでした。 ``` ### 4.5 逆接でない「〜が」を避ける - 逆接でない「〜が」を接続に使うことを避ける - **主格の「が」は問題ない** - 単なる文の接続に「〜が」を使うのは避ける ``` ❌ 本機能は外部APIを利用してデータを取得しますが、取得したデータはデータベースへ保存され、ユーザー画面に表示されます。 ✅ 本機能は外部APIを利用してデータを取得します。取得したデータはデータベースへ保存され、ユーザー画面に表示されます。 ``` --- ## 5. 漢字とひらがなの使い分け ### 5.1 ひらがなにする語 - 公用文作成の要領や記者ハンドブックでは、実質的な意味を持たない言葉をひらがなで書くよう定めており、概ねそれに則る。 1. 常用外漢字 2. 形式名詞 3. 接続詞 4. 補助動詞 5. 一部の動詞 6. 副助詞 7. 副詞 など 具体例 | 漢字 | ひらがな | 分類 | 備考 | |------|----------|------|------| | 殆ど | ほとんど | 常用外漢字・副詞 | | | 為 | ため | 形式名詞 | 「〜のため」 | | 事 | こと | 形式名詞 | 「〜すること」 | | 所 | ところ | 形式名詞 | 「改善したいところ」 | | 尚 | なお | 接続詞 | | | 但し | ただし | 接続詞 | | | 又 | また | 接続詞 | | | 下さい | ください | 補助動詞 | 「ご確認ください」 | | 頂く | いただく | 補助動詞 | 「教えていただく」 | | 居る | いる | 補助動詞 | 「動いている」※補助動詞の場合 | | 無い | ない | 補助動詞 | 「問題ない」※補助形容詞の場合 | | 有る | ある | 補助動詞 | 「設定してある」※補助動詞の場合 | | 出来る | できる | 補助動詞・動詞 | | | 迄 | まで | 副助詞 | | | 位 | くらい/ほど | 副助詞 | | | 是非 | ぜひ | 副詞 | | | 丁度 | ちょうど | 副詞 | | | 更に | さらに | 副詞 | | | 予め | あらかじめ | 副詞 | | | 様々 | さまざま | 慣習 | 公用文では漢字表記 | ### 5.2 送り仮名の確認 ``` ❌ 楽しく話ながら作業した ✅ 楽しく話しながら作業した ``` --- ## 6. 語順・修飾 ### 6.1 修飾語は被修飾語の近くに置く ``` ❌ 細かいアーキテクチャの説明は省略します ✅ アーキテクチャの細かい説明は省略します ``` ### 6.2 語順を整理する ``` ❌ 過去1年間のコミット数を開発状況を可視化するダッシュボードで確認してみたところ ✅ 開発状況を可視化するダッシュボードで過去1年間のコミット数を確認してみたところ ``` ### 6.3 同じ単語・名詞の繰り返しを避ける - ただし、並列構造の場合は同じ単語が続いても問題ない ``` ❌ フローが複雑化してリードタイム低下とならないよう、フローの再設計を行いました。 ✅ 複雑化によるリードタイム低下を避けるため、フローの再設計を行いました。 # 並列構造の例(問題なし) ✅ 2024年には初期段階としてフェーズ1をリリースし、翌年の2025年には計画どおりフェーズ2をリリースしました。 ``` --- ## 7. 見出し・構成 ### 7.1 タイトル - 「〜の話」で終わるタイトルは避ける - 主題と副題をダッシュ(──)で繋ぐ形式を推奨 ### 7.2 見出しの統一 - 見出しの文体・形式を統一する(体言止め or 文) - 見出しがバラバラだと読みにくい ### 7.3 前提を書く - 読者が知らない前提(社内ルールなど)は明記する ### 7.4 結論から書く - 前置きが長いと読者が離脱する - 結論→理由→詳細の順で書く ### 7.5 「後述します」の多用を避ける - 「後述」を多用すると読者が混乱する - 可能な限りその場で説明する ### 7.6 文の流れを意識する - 唐突に話が変わらないようにする - 前後の文脈をつなげる ``` ❌ 現在、チームのチャットツールは有料プランで運用しています。必要な情報を後から見返せる仕組みは、業務上欠かせないと考えています。しかし無料プランへ移行すると、過去ログの検索期間に制限がかかります。 ✅ 現在、チームのチャットツールは有料プランで運用しています。無料プランへ移行すると、過去ログの検索に制限がかかります。業務上、情報を後から見返せる仕組みは不可欠と考えています。 ``` ### 7.7 複雑なことは図示する - 文章だけで説明が難しい場合は図を追加する --- ## 8. 記号・フォーマット ### 8.1 全角スラッシュは使わない - 「/」(全角スラッシュ)は使わない - 「/」(半角スラッシュ)または「・」を使う ### 8.2 英語の頭文字をとった略語は大文字 - API(Application Programming Interface)など、英語の頭文字をとった略語は大文字で表記する - class名やファイル名などで小文字表記の場合は例外とする ### 8.3 引用記法 - 引用記法(`>`)は**実際の引用のときのみ**使用する ### 8.4 CustomPathはハイフンつなぎ - アンダースコアは使わない ``` ❌ CustomPath: my_article_path ✅ CustomPath: my-article-path ``` ### 8.5 ダッシュの表記 - タイトルの主題と副題を繋ぐ際は**─を2つ + 前後に半角スペース**を使用 ``` ❌ システム移行の課題にどう立ち向かうか - 私たちが実践した解決策 ✅ システム移行の課題にどう立ち向かうか ── 私たちが実践した解決策 ``` --- ## 9. 正式名称・表記 ### 9.1 サービス・製品名は正式名称を使用 | 誤 | 正 | 参考URL | |----|----|----| | Golang | Go | https://go.dev/ | | Go言語 | Go | https://go.dev/ | | Spring boot | Spring Boot | https://spring.io/projects/spring-boot | | Alloy DB | AlloyDB | https://cloud.google.com/alloydb | | Firebase app check | Firebase App Check | https://firebase.google.com/docs/app-check | | Secrets manager | Secrets Manager | https://aws.amazon.com/secrets-manager/ | | slack bot | Slackbot | https://slack.com/help/articles/202026038 | | Kintone | kintone | https://kintone.cybozu.co.jp/ (小文字始まり) | | docker | Docker | https://www.docker.com/ (大文字始まり) | | JS Nation | JSNation | https://jsnation.com/ (スペースなし) | | iosDC | iOSDC Japan | https://iosdc.jp/ | | ArgoCD | Argo CD | https://argo-cd.readthedocs.io/ (スペースあり) | ### 9.2 社名・サービス名の正式表記 - 社名やサービス名は公式サイトを確認し、正式名称を使用する - スペースの有無、大文字小文字を正確に記載する - ルールに記載されていない名称も都度検索して確認すること **ZOZOに関する表記** - 「ZOZO」はZOZOTOWNの略称ではない - 「ZOZO Yahoo!店」→「ZOZOTOWN Yahoo!店」 --- ## 10. はてなブログ固有のルール ### 10.1 太字記法 - はてなブログでは `**太字**` の前後に半角スペースは入れない ### 10.2 埋め込み記法 - URLは埋め込み形式にすると見栄えが良い - `[https://example.com/:embed:cite]` ### 10.3 キャプション記法 - 画像にはfigureタグでキャプションを付けられる ```html <figure class="figure-image figure-image-fotolife" title="タイトル"> [f:id:vasilyjp:20230101120000] <figcaption>キャプション</figcaption> </figure> ``` ### 10.4 Markdownの改行 - パラグラフをわけるときは空行を入れる - 同じパラグラフ内で改行する必要がある場合は `<br>` か ` `(半角スペース2つ)による強制改行を使う。ただし、デバイス依存の表示崩れやSEO・アクセシビリティの低下などを招くため、必要な場合のみに留める。 ``` 1行目 2行目(別のパラグラフになる) ``` ### 10.5 箇条書きの前後には空行が必要 - 箇条書き(`-` や `1.`)の前後には空行を入れる - 空行がないとレイアウトが崩れる場合がある ``` ❌ 下記の項目を記載しています。 - 依頼の概要 - 依頼部門の課題 ✅ 下記の項目を記載しています。 - 依頼の概要 - 依頼部門の課題 ``` ### 10.6 続きを読む記法 - `<!-- more -->` ははてなブログで「続きを読む」の区切りとして使用される - このコメントは削除しない --- ## 11. リンク・参照 ### 11.1 リンク切れの確認 - リンクが有効か確認する(404になっていないか) ### 11.2 リンク先の確認 - リンク先が正しいか確認する - リンク先が不適切・不確かな情報でないか確認する ### 11.3 リンクテキストの書き方 - 「こちら」だけでなく、リンク先が分かるようにテキストを明示する ``` ❌ 詳しくは[こちら](https://example.com)をご覧ください。 ✅ 詳しくは[公式ドキュメント](https://example.com)をご覧ください。 ✅ 詳しくは「[独自のアプリケーションの作成](https://example.com)」を参考にしてください。 ``` ### 11.4 採用リンク - 記事末尾の採用リンクは有効か確認する --- ## 12. その他 ### 12.1 ファイル末尾 - ファイル末尾の改行は必須ではない ### 12.2 機密情報 - 機密情報が含まれていないか確認する - 例示で使う数字はダミーにし、その旨を明記する ### 12.3 将来の機能・予定 - 未確定の将来計画は公開可否を確認する ### 12.4 引用・著作権 - 他サイトの画像・スクリーンショットを使用する場合は引用の要件を満たす - 引用元を明記する - 参考: [文化庁「著作権を学ぶ(教材・講習会)」](https://www.bunka.go.jp/seisaku/chosakuken/seidokaisetsu/index.html) ### 12.5 画像の掲載許諾 - 人物写真は掲載許諾を確認する - 他社ロゴ・アイコンは使用許可を確認する ### 12.6 追記の書き方 - 記事公開後に追記する場合は日付を明記する ``` ❌ 追記:〜 ✅ (2023/5/16追記)〜(追記ここまで) ``` 既存の仕組みの詳細は TECH BLOG執筆支援のためのCI/CD導入事例 をご覧ください。 ↩ GitHub公式のコマンドラインツール https://docs.github.com/ja/github-cli/github-cli/about-github-cli ↩
アバター