TECH PLAY

株式会社スタンバイ

株式会社スタンバイ の技術ブログ

64

プロダクト部User Groupの藤澤です。 今回は、我々のプロダクト「 スタンバイ 」で, Nuxtアップデートをきっかけに顕在化した bfcache(Back/Forward Cache)による計測課題と、 それに対する対応・改善の取り組みについて共有します。 今回の改善により、これまでより正確な数値が取得できるようになり、社内指標の明確化が進みました。 この記事のポイント Nuxtのマイナーアップデートをきっかけに、起きるはずのないPVの変動が発生 原因は、bfcacheから復元した際のログ送信が不安定だったこと UXを維持するため、bfcacheの無効化は採用せず、bfcacheからの復元時にもログを送信することで解決 UXと正確な分析を両立し、既存で拾えていなかった約15%の「本来あるべきPV」も取り戻せた 1. はじめに: Nuxtアップデートで露見した「PVのブレ」 「 スタンバイ 」では、脆弱性対応のためにNuxt v3.12.2からv3.12.4へのマイナーアップデートを行いました。 しかし、アップデートした後である「異変」に気づきました。 Google Analytics (GA) の page_view や社内分析基盤のログとして計測している PV(ページビュー)が、わずかに減少していた のです。 PVは我々のプロダクトにおける最重要ビジネス指標(KPI)の1つです。理由もなく変動している状態は好ましくありません。 調査の結果、Nuxtアップデートにより bfcacheから復元されるページの割合が変化したことが、PVのブレの主因であると判明しました。 これは、ブラウザ側の仕様であるbfcacheの動きをコード側でコントロール出来ていないことを意味します。 2. bfcacheとは? ブラウザの「高速復元機能」とPVのブレの関係 ここでbfcacheについて簡単に解説します。 2-1. bfcache(Back/Forward Cache)とは bfcache(Back/Forward Cache)は、ブラウザがページ遷移時に直前のページ全体をメモリ上にまるごと保持しておき、ユーザーが「戻る」「進む」操作をしたときにその状態を即座に復元する仕組みです。 通常のキャッシュ(HTTPキャッシュ)は「静的リソース(HTML, JS, CSS, 画像など)」を保存するのに対し、bfcacheは JavaScriptの実行状態・DOMツリー・スクロール位置などページの状態 をそのままメモリに残します。 つまり、ユーザーが「戻る」ボタンを押した瞬間、ネットワーク通信なし・レンダリングなしで完全な状態を再現できるのです。 これにより、ユーザーは「戻る」ボタンを押した瞬間、読み込み時間ゼロで前のページに戻れて、 ユーザー体験(UX)が劇的に向上 します。 bfcacheの恩恵が分かる動画 この動画では、bfcacheを使用した場合と未使用の場合の「戻る」操作の速度を比較しています。 bfcacheが有効な場合、ページ復元が瞬時に行われ、ユーザーは待ち時間なく前のページに戻れます。 一方、bfcacheが無効な場合は、通常の再読み込みが発生し、明らかに表示が遅くなります。 実際の挙動を動画で確認することで、bfcacheのUX向上効果を直感的に理解できます。 たとえば、検索結果一覧から求人詳細ページを開き、再び「戻る」操作をした際、bfcacheによって 検索条件やスクロール位置が完全に保持された状態で即座に復帰 します。 これにより、ユーザーは「検索をやり直す」「再描画を待つ」といった手間から解放されます。 (※この高速復元はブラウザ側の機能として実現されています) 参考資料: bfcache - web.dev (Google) 2-2. bfcacheのブラウザ依存性 bfcacheは ブラウザ実装側の機能 であり、ブラウザやバージョンによって挙動やサポート状況が異なります。 たとえば、Chrome・Firefox・Safariはいずれもbfcacheをサポートしていますが、復元のタイミングや保持条件(Service Workerやイベントリスナーの有無など)には微妙な差異があります。 つまり、「 同じページでも、ブラウザによってbfcache復元が発生したりしなかったりする 」ことがあるのです。 これが今回起きたPVのブレを生み出していました。 多くのモダンブラウザではbfcacheは デフォルトで有効化 されており、開発者が特別に無効化しない限り、ユーザーの操作に応じて自動的に適用されます。 2-3. なぜPVログにブレが生じたのか? 問題は、 bfcacheからの復元は、通常のページロードとは異なる 点です。 通常のページロードでは、 DOMContentLoaded や load といったイベントが発火します。 VueやNuxtの場合、コンポーネントが再マウントされます。その際に onMounted フックが実行されます。 しかし、bfcacheから復元された場合、これらの イベントやライフサイクルフックは一切実行されません 。ページはメモリから「解凍」されるだけです。 我々のプロダクトでは、GAや社内分析基盤のログのPVの多くを onMounted フック内で送信していました。 つまり、bfcacheから復元されると、 onMounted は実行されず、ログ送信処理が丸ごとスキップされていた のです。 これは、Googleの web.dev でも、アナリティクス実装における一般的な落とし穴として言及されています。 bfcache は、分析の実装方法に影響を与える可能性があります。 ...bfcache からの復元は新しいページ読み込みとしてカウントされないため、 onload イベントに依存している分析ライブラリでは、これらの「復元」が計測されません。 出典: bfcache - アナリティクスと閲覧の測定 改善前の実装例とその問題点 実際に、従来は以下のような実装でPVログを送信していました。 // 各ページの <script setup> 内 import { onMounted } from 'vue' ; // 既存のログ送信関数(例) const sendPageLog = () => { console . log ( 'PVログを送信しました' ) ; } ; onMounted (() => { sendPageLog () ; }) ; この実装では、bfcacheから復元された場合は onMounted が発火しないため、PVログが送れていませんでした。 3. bfcache問題への2つのアプローチ この問題に対し、我々は2つの選択肢がありました。 選択肢A: Cache-Control: no-store でbfcacheを無効化する サーバーのレスポンスヘッダーに Cache-Control: no-store を設定し、bfcacheをプロダクト全体で強制的に無効化する対応です。 メリット: 常に通常のページロードが発生するため onMounted は必ず実行され、ログ未送信は即座に解消できる デメリット: bfcacheによる高速なUXの恩恵をユーザーから奪ってしまう ブラウザバック・フォワード時に常にリロードが発生するため、PV以外の社内指標にも悪影響が出てしまう 選択肢B: bfcacheを有効のまま、復元時のログ未送信を個別に対策する bfcacheは有効なまま(UXを維持)にし、bfcacheからの復元時にもログが送信されるよう、根本的な改修をする。 メリット: 高速なUXと、正確なログ分析を両立できる デメリット: ログ送信はページごとに個別実装されている箇所が多く、 影響範囲の調査と修正の工数が非常に大きい 我々は 高速UXを維持しつつ、従来欠損していたPVを正確に取得することを重視し、 「選択肢B」を採用しました 。 4. 解決策: pageshow イベントで復元を検知する 「選択肢B」を実現する鍵が、 pageshow イベントです。 pageshow イベントは、ページが表示されるたび(通常のロード時も、bfcacheからの復元時も)に発火します。 そして、イベントオブジェクトの event.persisted プロパティ を見ることで、bfcacheから復元されたかどうかを判別できます。 event.persisted === true : bfcacheから復元された event.persisted === false : 通常のページロード この仕組みを使い、以下のように実装を修正しました。 // 各ページの <script setup> 内 import { onMounted } from 'vue' ; // 既存のログ送信関数(例) const sendPageLog = () => { console . log ( 'PVログを送信しました' ) ; } ; onMounted (() => { // 1. 通常のページ遷移・リロード時のログ送信 sendPageLog () ; // 2. bfcacheからの復元時にログを送信するためのリスナーを登録 // NOTE: ブラウザバック・フォワード時にログを送信 window . addEventListener ( "pageshow" , ( event ) => { // bfcacheから復元された場合(persisted: true)のみ、ログを送信 if ( event . persisted ) { sendPageLog () ; } }) ; }) ; この修正により、我々はbfcacheからの復元時にも確実にログを送信できるようになりました。 5. 導入プロセスと成果:ブレがあったPVに正確さを取り戻す 以前から我々のプロダクトでは、bfcacheは有効でした。 しかし復元時の対応をしていなかった為、ブラウザバックやフォワード操作でPVがブレやすい状況になっていました。 今回の対応により、従来計測できなかった復元時のPVも正確に取得可能になり、結果として全体PVが増えたように見えます。 そのため、以下の手順で慎重に導入を進めました。 Step 1. ブレの解消によって増加するPVの規模を把握( no-store テスト) 一度「選択肢A( Cache-Control: no-store )」を本番環境で短時間リリースしました。 これにより、bfcacheを無効化した場合にPVがどれだけ「増える」か(=これまで、どれだけブレによりPVを取りこぼしていたか)を計測しました。 結果、 約15% のPVがbfcacheによって取りこぼしていたことを把握できました。 Step 2. 本対応( pageshow 対応)と関係者連携 次に、 no-store の設定を元に戻し、本命の「選択肢B( pageshow 対応)」を行いました。 その際、事前にPdMやデータ分析チームに対し、「今回の対応でPVが約15%増加する。これは バグが修正され、正しい値が計測されるようになる ためである」という周知を徹底しました。 Step 3. 成果 リリース後、PVは事前の試算通り約15%増加し、安定しました。 これは、Nuxtアップデートで顕在化したbfcacheによるログの取りこぼしを解消し、 PVを「正しい状態」に引き直した ことを意味します。 分析面: ビジネスKPIであるPVを、bfcacheの挙動に左右されず、正確に計測できるようになった。 UX面: bfcacheは有効なままであるため、UXを一切損ねていない。むしろ、Nuxtアップデートによってbfcacheの復元率が上がったことで、プロダクト全体の「戻る」「進む」体験は以前より向上した。 6. まとめ:Nuxtアップデートが気づかせてくれたbfcache対応の重要性 今回の経験から我々が得た学びは以下の通りです。 マイナーアップデートで「隠れた負債」を暴き出すことがある: ログの取りこぼしという問題は以前から存在していたが、Nuxtアップデートがbfcacheの挙動を変えたことで、その影響が無視できないレベルで顕在化した。 「正しい数字」を追うことの重要性: PVは重要なビジネス指標である。bfcacheによる意図しないPVのブレを防ぎ、正確な分析結果を取り戻すことができた。 また、安易な回避策( no-store )に頼らず、根本原因である pageshow 未対応に正面から取り組む事が出来ました。それによりUXと分析を両立でき、プロダクトの品質向上に大きく貢献できたと感じています。 この提案を承認して、リソース調整や実装面でサポートしてくれたチームやプロダクトオーナーにも恵まれていたなと、改めて感じました。 注意: bfcacheの挙動や pageshow イベントのサポート状況は、主要ブラウザ(Chrome, Firefox, Safari等)で異なる場合があります。特にSafariはbfcacheの仕様や復元タイミングが他ブラウザと異なることがあるため、実装時は各ブラウザの公式ドキュメントや挙動検証を推奨します。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
プロダクト部 AppグループでAndroidアプリ開発を担当している山越です。 現在、Appグループでは既存アプリのUIをXMLレイアウトからJetpack Composeへ段階的に移行しています。 本記事では、「Compose化」を進める背景や目的、実際の進め方、そして移行の中で直面した課題・クラッシュ事例についてご紹介します。 「Compose化」とは? Jetpack Composeとは、Android向けの新しい宣言的UIフレームワークです。 従来のXMLレイアウトでは、UIの見た目と動作を別々のファイルで管理する必要があり状態更新のたびにViewを直接操作する必要がありました。 一方のComposeでは、UIをKotlinコード上で直接定義することとなり、アプリの状態(State)に応じて自動的にUIを再描画することが可能となります。 「Compose化」とは、こうしたComposeの考え方を既存アプリにも取り入れて、XMLやViewBindingを使っていた画面を段階的にComposeへ移行する取り組みを指します。 スタンバイでは、UIの保守性・再利用性・開発効率の向上を目的として、既存画面のCompose化に着手しました。 移行作業の進め方 今回の移行作業では、全画面を一気に置き換えるのではなく、段階的に移行する方針を取りました。 新規機能は最初からComposeで実装して、既存画面は影響度の低いところからCompose化を進めています。 これにより影響範囲を最小限に抑えながらComposeへの知見を積み重ねることができました。 XMLレイアウトのリソースをComposeへ 移行の第一歩として、既存のXMLレイアウトをCompose化しました。 画面全体を一気にCompose化するのではなく、ButtonやCardといった汎用的なUIコンポーネント単位での置き換えから始めています。 Compose化した画面は、Fragment上でComposeViewを利用して組み込む方法をとっています。 class SampleFragment: Fragment() { // ViewModel参照 private val viewModel: SampleViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View = ComposeView(requireContext()).apply { setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnLifecycleDestroyed( viewLifecycleOwner ) ) setContent { // Compose化した画面 SampleScreen( uiState = viewModel.uiState, onClickButton = { viewModel.onClickButton() } ) } } } こうすることで、FragmentやViewModelの構造は活かしつつ部分的にComposeを導入することで移行のリスクを抑えています。 既存Viewとの共存 Compose化の途中では、XMLとComposeが混在する期間は避けられません。 そのため、以下のような点を意識して両者を共存させました。 テーマおよびカラー定義をMaterialThemeとXMLスタイルで統一 ViewModelは共通で利用して、StateFlowを collectAsState() で監視 この結果、既存プロダクトの安定性を保ちながらCompose化を進めることができました。 つまづいたところ 汎用コンポーネントの対応を経て画面単位のCompose化を行なっていましたが、スムーズに進まないケースがいくつか発生しました。 共通して直面した課題に対してどのような手法をとって対応してきたかをご紹介します。 テーマ設定やレイアウト崩れ Compose化を進める中で、既存テーマとの整合性でいくつか課題がありました。 既存アプリでは themes.xml や styles.xml で定義したカラーやフォントサイズを利用していました。 一方、Compose側では MaterialTheme を経由する必要があるため、色味や余白が一致しないケースもありました。 スタンバイでは、Figma上にデザイン定義が整理されているため細かい調整は不要でしたが、実装上ではXMLリソースをComposeに寄せる過程で小さなずれが多く発生しました。 特にTextStyleの設定はMaterialTheme配下に再定義し、colorResourceを活用して既存リソースとの不整合を解消しました。 また、再利用可能なUI部品は designsystem パッケージにまとめる構成を採用しました。 このパッケージではボタンやテキストフィールドなどの共通UIコンポーネントを定義して、各画面ではそれらを呼び出してUIを構築しています。 この構成によりアプリ全体のデザインを統一しつつ、デザイン修正を一箇所で反映できるようになりました。 また、Figma上のデザイン定義とComposeコードの対応関係も整理され、Compose化によるUIのばらつきを防ぐことができています。 プレビューとの乖離 Jetpack Composeにはプレビュー機能があり、コンポーネント単位で描画されるUIを即座に確認できます。 Composeのプレビュー機能は非常に便利な反面、実際のAndroid端末上の挙動と異なる場合があります。 特にViewModel経由で状態を受け取る画面や、 LocalContext・MaterialTheme に依存するComposableでは、 プレビュー上でスタイルが反映されなかったり、クラッシュしたりするケースがありました。 そのためプレビュー用にダミーのUiStateを渡す「@Preview関数」を用意し、テーマも本番と同じAppThemeを適用して確認するよう意識しました。 それでも最終的な見た目や動作はエミュレータおよび実機確認で差分を吸収するようにしています。 思わぬクラッシュの発生 Compose化完了後の画面を実機またはエミュレータで動かすと、クラッシュすることがありました。 Android Studio上では警告やエラーなどの問題は表示されていません。 今回、Appグループが直面したものから2点のクラッシュを抜粋して対処方法も合わせてご紹介します。 Columnの入れ子構造による高さ非制限 とある画面のCompose化を進めていく中で、Columnの入れ子構造を持つ画面表示の際にクラッシュが発生しました。 この画面では親要素が Column 、その中に LazyVerticalGrid を配置しており wrapContentHeight() を指定していました。 Column { LazyVerticalGrid( columns = GridCells.Fixed( 2 ), modifier = Modifier .fillMaxWidth() .wrapContentHeight(), // コンテンツのサイズに応じた高さにしたい verticalArrangement = Arrangement.spacedBy( 8 .dp), ) { items(items = targetItems, key = { it.id ?: it.hashCode() }) { item -> // 子要素のComposable } } } この場合、 Column は縦方向に無限スクロール可能とみなされます。 そして、内部の LazyVerticalGrid も高さを計算しようとして「無限サイズを要求する形」になり、 描画時に下記の例外が発生しました。 IllegalStateException : Vertically scrollable component was measured with an infinite height constraints これは、親と子の両方がスクロール可能(または高さ非制限)な構成になっていたことが原因です。 対応として、内部の LazyVerticalGrid に Modifier.heightIn(max = 200.dp) を付与して高さの上限を明示することで無限再帰的な計測を防ぎました。 Column { LazyVerticalGrid( columns = GridCells.Fixed( 2 ), modifier = Modifier .fillMaxWidth() .wrapContentHeight() .heightIn(max = 200 .dp), // 高さの上限指定を追加 verticalArrangement = Arrangement.spacedBy( 8 .dp), ) { items(items = targetItems, key = { it.id ?: it.hashCode() }) { item -> // 子要素のComposable } } } heightIn() を使うことでComposeの測定制約を明確にし、描画処理が安定するようになりました。 バックグラウンド復帰時の例外発生 もうひとつのクラッシュは、アプリのバックグラウンド復帰時に発生したものでした。 とある状態を保持するためにScreen内で rememberSaveable を利用していましたが、保持していたオブジェクトが画面再生成時に正しく復元できず IllegalStateException: MutableState cannot be saved が発生しました。 val type = rememberSaveable { mutableStateOf<SampleType?>( null )} rememberSaveable では保存できる型が限られており、ParcelableまたはSerializable、もしくはSaverで明示的に変換できるものだけが対象です。 今回のケースでは、保存対象のデータクラスがParcelableを実装しておらず、復帰時に型不一致として例外がスローされていました。 対応として、Saverを実装して保存対象を明示することで解決しました。 // SampleTypeを保持するためのSaver private val SampleTypeSaver = Saver<SampleType, Map < String , String >>( // 保存するときはMap<String, String>型に変換 save = { sampleType -> when (sampleType) { is SampleType.TypeA -> mapOf( "type" to "A" ) is SampleType.TypeB -> mapOf( "type" to "B" ) is SampleType.TypeC -> mapOf( "type" to "C" , "key" to sampleType.value ) else -> mapOf() } }, // 復元するときはSampleTypeに変換 restore = { map -> when (map[ "type" ]) { "A" -> SampleType.TypeA "B" -> SampleType.TypeB "C" -> SampleType.TypeC(map[ "key" ].toString()) else -> null } } ) ~ // stateSaverに作成したSampleTypeSaverを割り当てる val type = rememberSaveable(stateSaver = SampleTypeSaver) { mutableStateOf<SampleType?>( null )} このように、Composeは状態のスコープが明確である一方で、 Activity再生成やプロセスキル時に永続化対象を誤るとクラッシュしやすいため注意が必要です。 完全な「Compose化」までの課題点 Compose化は着実に進んでいますが、完全な移行にはまだいくつかの課題が残っています。 現在の主な課題は「DeepLink経由での画面遷移処理」と「トップ画面におけるボトムナビゲーションによるタブ遷移」です。 現状、これらの部分は既存のFragmentをベースに動作しており、Compose Navigation への置き換えにはさらなる検討が必要です。 特にルーティング管理やデータの受け渡し、DeepLink起動時にの初期タブの制御やバックスタックの再構築など、 既存のナビゲーション構造とCompose側の遷移管理をどう共存させるかが課題となっています。 ボトムナビゲーションについても、画面再生成やタブ切り替え時の状態保持をCompose側でどこまで担うかを整理する必要があります。 今後はこれらの遷移処理をCompose Navigationに統一し、アプリ全体を完全なComposeベースへ移行することを目標としています。 まとめ Compose化を進めていく中で、開発効率やUI設計の柔軟性といった多くのメリットを実感できました。 特に、UIをKotlinコード上で完結できる点や、状態管理をViewModelと密に連携できる点は大きな強みです。 また、共通UIを designsystem というパッケージを作成してまとめることで画面ごとの見た目の統一や再利用性も向上しました。 一方で、導入初期はCompose特有のレイアウト制約や状態保持の仕組みによるクラッシュなど、これまでのXMLベースとは異なる観点でのトラブルシューティングが必要でした。 また、部分的なCompose導入ではXMLとの共存コストも発生し、完全な移行にはまだ時間がかかると感じています。 それでも、UI実装のシンプルさや開発体験の改善は大きく、長期的に見ればCompose化のメリットが明確に上回ると実感しています。 機会があれば今後の改善や完全移行についてもご紹介させていただければと存じます。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
はじめに こんにちは。Clientグループ所属エンジニアの井上です。 本記事では、広告ログを集計するアーキテクチャの一部を刷新した「広告ログ基盤リアーキテクチャ」について以下の内容を中心に紹介します。 新旧広告ログ基盤の概要 広告ログ基盤リアーキテクチャの進め方で良かった方針 なお、以下の内容については本記事では特筆せず別の機会に譲ります。 詳細な技術選定やその方針 リアーキテクチャの効果 本記事で紹介する方針の考え方はどのようなシステムに対しても応用できると考えていますので、本記事が読者の皆様にとって少しでも役立つと幸いです。 リアーキテクチャの背景 旧広告ログ基盤の概要 求人検索エンジン「スタンバイ」では、求人情報をご提供いただくお客様が求人広告を管理できます。 求人広告とは、求人情報のうちお客様が広告として設定した求人のことです。 株式会社スタンバイでは、お客様が求人広告を管理するための「広告管理画面」や、そのほか関連するアプリケーションを開発・運用しています。 広告管理画面の機能は、複数のアプリケーションが連携して実現しています。 その代表的な機能は、広告ログの集計結果や請求データを提供するレポート機能です。 広告ログとは、求人広告に対する求職者のさまざまな行動ログのことです。 レポート機能を実現するには、例えば以下のようなアプリケーションが必要になります。 レポート機能を提供するアプリケーション ログデータや請求データを保持するDB データを更新するアプリケーション 実際に私たちの広告管理画面の裏側でも広告ログや請求データを集計する仕組みが動いており、これらの仕組みを私たちは「広告ログ基盤」と呼んでいます。 以下の図は、リアーキテクチャ前の広告ログ基盤(旧広告ログ基盤)の簡単なアーキテクチャです。 (実際のアーキテクチャはより複雑ですが、本記事では説明を簡単にするため、最小限の構成で表現しています。) 旧アーキテクチャの特徴と課題 旧広告ログ基盤は歴史的な背景により、主に以下のような特徴がありました。 広告ログを集計する経路が3つ存在する(データ基盤、バッチ処理A、バッチ処理B)。 バッチ処理AとBはそれぞれ異なる種類の広告ログを集計する。 3つの経路はそれぞれ異なる技術で実装されている。 一部のアプリケーション(データ基盤など)は別グループが管轄している。 社内からは、データ基盤とバッチ処理で集計されたデータを利用できる。 広告管理画面は、バッチ処理で集計されたデータを利用する。 しかしながら、スタンバイのサービスとしての成長、全社共通の技術選定ガイドラインの更新、人員の増減など、さまざまな変化に伴って旧広告ログ基盤のアーキテクチャの一部が課題として認識されるようになりました。 特に、下記のような状態に起因して開発・運用・保守のコストが高い状態でした。 広告ログを集計する経路が複数存在する 広告ログデータを2重に管理する(データ基盤、広告ログ基盤) バッチ処理AとBに共通の仕様が実装されている バッチ処理Bに多機能かつ複雑で重要な仕様が実装されている 社内共通の技術選定ガイドラインの更新や人員の増減に伴いドメイン知識やスキル習得が必要 例えば、広告ログ集計の仕様を改善したい要望が発生した際、出力されるデータの整合性を全社として保つためには複数経路の開発が必要になるため開発コストが高い状態でした。 他には、バッチ処理Bには請求業務に使用するような重要な処理が含まれているため、バッチ処理Bに依存している複数のアプリケーション群に特段配慮する必要があり、開発効率・運用性・保守性が低い状態でした。 新広告ログ基盤 広告ログ基盤リアーキテクチャとしては、新旧の広告ログ基盤の並行稼働を前提としつつ、2つのフェーズに分けて進めることにしました。 並行稼働の影響や2つのフェーズに分けた理由については「良かった方針」にて後述しますが、主なコンセプトとしては以下を考えました。 広告ログの集計経路の統一 広告ログデータの一元管理 コンセプトをもとに、各フェーズのゴールを以下のように定めました。 フェーズ1:プロジェクト完了時の理想状態(ToBe)の骨格を作る バッチ処理Bを廃止すること バッチ処理Bが集計していたログデータを更新する、バッチ処理Xをリリースすること バッチ処理Bが集計していた請求データを更新する、バッチ処理Yをリリースすること フェーズ2:プロジェクト完了時の理想状態(ToBe)を作る バッチ処理Aを廃止すること バッチ処理Aが集計していたログデータを、バッチ処理Xが集計すること 以下の図は、リアーキテクチャ後の広告ログ基盤(新広告ログ基盤)の簡単なアーキテクチャです。 (「データ基盤」が「新データ基盤」に変わっていることについては、後述します。) 良かった方針 当初、広告ログ基盤リアーキテクチャを進めるにあたって Design Doc などを作成していくうちに、主に以下の理由でこのプロジェクトは長期的な取り組みになると想定されました。 一般的に、リアーキテクチャは長期的な取り組みである リアーキテクチャに関連するコンポーネント(アプリケーションやDBなど)が多い グループ間またはプロジェクト間の連携が必要である 請求データを扱うため、リアーキ前後の品質管理が重要である そのため、長期的な取り組みにおいても方向性を見失わず、着実かつ安全に広告ログ基盤リアーキテクチャを完遂できるように、リアーキテクチャの方針を(紆余曲折しながら)決めました。 以下の3つは、広告ログ基盤リアーキテクチャにおいて特に良かったと感じた方針です。 原則として仕様を変えなかったこと スコープを限定して段階的にリリースしたこと 全体最適を意識してアプローチしたこと 各方針について詳しくご紹介します。 原則として仕様を変えなかったこと 広告ログ基盤リアーキテクチャの前後で原則仕様を変えないことを方針として決めました。 リアーキテクチャを実施する上で、この方針をあえて強調する必要がないと感じる方もいらっしゃるかもしれません。 しかしながら、この方針をあえて強調したことは結果としてとても良かったと感じました。 エンジニアの方であれば共感いただけると思いますが、作業や調査を進めるうちに「もっと効率的に実装できそう」「ついでに修正しよう」といった風に、改善点を発見することがあります。 広告ログ基盤リアーキテクチャも例外ではなく、改善が可能な(改善したくなる)仕様がいくつか存在し、実際に改善を提案する声も上がっていました。 こういった姿勢は課題解決に積極的で良いことだと思いますが、広告ログ基盤リアーキテクチャにおいてはこの姿勢を取ることによるリスクを事前に想定できました。 具体的には以下のようなリスクが考えられました。 各対応の意思決定を都度実施することでプロジェクトが長期化する リアーキテクチャ前後でデータ品質(正確性、完全性、一貫性などの)検証が複雑化する 広告ログ基盤リアーキテクチャとしては、このようなリスクを事前に理解した上で原則として仕様を変えなかったことで、意思決定の質とスピードが下がらないように工夫しました。 また、新旧の広告ログ基盤を並行稼働したこともあり、データ品質検証の実施しやすさと検証の単純さを維持して進めることができました。 スコープを限定して段階的にリリースしたこと ここでいうスコープとは、広告ログ基盤リアーキテクチャ対象のコンポーネントを指します。 前述の通り、新旧の広告ログ基盤を並行稼働することを前提として、広告ログ基盤リアーキテクチャ対象のコンポーネントを限定して段階的にリリースすることにしました。 この方針には主に以下の狙いがありました。 ROIを最大化する。具体的には、広告ログ基盤リアーキテクチャの効果を最大化するコンポーネントから着手する。 認知負荷を下げる。具体的には、可能な限り少ないコンポーネントの作業に集中してリアーキテクチャに必要な情報を限定する。 リソースを集中する。具体的には、可能な限り少ないコンポーネントの作業に開発リソースを集中させて効果的に活用する。 影響範囲を限定する。具体的には、リアーキテクチャによって影響を受けるコンポーネントが限定されることでリスク管理をシンプルにし、リリース時の影響範囲を最小限にする。 知見を深める。具体的には、複数のフェーズで段階的に進めることで、フェーズごとに知見を活用できる状態を作る。 こちらの方針も狙い通りの効果を得ることができました。 特に、段階的なリリースを不具合なく終えることができたのはこの方針が大きく影響していると感じます。 全体最適を意識してアプローチしたこと ここでいう全体最適とは、広告ログ基盤リアーキテクチャのスコープを超えた全社としての優先度の話です。 広告ログ基盤リアーキテクチャを進める傍ら、別のプロジェクト「データ基盤リアーキテクチャ」が進行していました。 データ基盤リアーキテクチャとは、アーキテクチャの図に記載している「データ基盤」を「新データ基盤」に移行するプロジェクトです。 各グループの立場としてはお互いにプロジェクトをできるだけ早く進めたいため、当初はプロジェクト間の調整が難航することもありましたが、ステークホルダーと協働して全体最適を意識して進め方を整理しました。 最終的には、新データ基盤のデータパイプラインで広告ログが集計されるアーキテクチャの方針にあわせて、2つのリアーキテクチャを同時に効率良く実現することを意識して「新データ基盤の初期は広告ログを優先的に実装していく」方針としました。 プロジェクト単位の優先度がある中でも Win-Win となるような折衷案で進めたことで、システムと運用として、プロジェクトとしてだけではなく、気持ちの面に対しても良い影響がありました。 まとめ 本記事では、広告ログ基盤リアーキテクチャの概要と良かった進め方の方針について紹介しました。 広告ログ基盤のような多数のアプリケーションで構成されるアーキテクチャを変えることは長期的な取り組みになりますが、着実で安全に取り組むための方針を明確にしその方針を共通認識とすることで、あらゆるリスクを軽減しつつ効率的に開発・運用・保守の課題を改善できました。 本記事の内容が読者の皆様に少しでも役立つと幸いです。ありがとうございました。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
はじめに こんにちは。プロダクト部SEOグループの伊田です。 スタンバイに入社して2ヶ月が経ち、徐々に業務にも慣れてきています! 最近、社内の技術カンファレンスで登壇する機会をいただきました。その発表の中で紹介した取り組みの1つが、 Devinを使ったPRレビューの自動化 です。 今回は、その仕組みを導入した背景と実際の運用方法について紹介します。 導入した背景 私たちSEOGでは、日々の開発でGitHubのPull Request(以下、PR)を使ってコードレビューしていますが、 機械的に確認できる細かい項目も多く、レビュアーの負担になっていると感じていました。 そこで考えたのが、「PRのレビューの初期段階をAIに任せられないか?」ということでした。 基本的なコーディング規約のチェック 明らかなバグやtypoの指摘 テストカバレッジの確認 セキュリティ上の問題点の洗い出し これらをDevinが自動で行ってくれれば、人間のレビュアーはより本質的な部分(ドメイン知識が必要な部分や、設計の妥当性など)に集中でき、レビュアーの負荷軽減とレビュー時間の短縮が期待できそうです。 そこで、 PR作成と同時にDevinにレビューさせる仕組み を構築しました。 仕組みの概要 実際には2種類の起動方法を用意しています。 PR作成をトリガーにするパターンと、PRコメントをトリガーにするパターンがあります。 パターン1: PR作成時の自動レビュー PR作成と同時に自動的にDevinがレビューを開始します。日常的なレビューフローへの組み込みが目的となります。 トリガー: pull_request.opened イベント デフォルトプロンプト: /devin !prr (PRレビュー用のカスタムコマンド) パターン2: PRコメントによるカスタムタスク PRコメントで /devin の後にカスタムプロンプトを入力することで、任意のタスクを実行できます。用途としては、レビュー以外の観点や、特定の用途で使用します。 例: /devin このPRのテストカバレッジを改善してください /devin セキュリティ上の問題がないか確認してください /devin パフォーマンスの最適化案を提案してください トリガー: issue_comment.created イベント( /devin で始まるコメント) リクエスト者情報も記録され、Slackに表示 実装コード 実装には2つのワークフローファイルが必要です。 呼び出し元ワークフロー(devin-slack-code-actions.yml) name : Devin Slack Code Actions on : issue_comment : types : [ created ] pull_request : types : [ opened ] workflow_dispatch : inputs : pr_number : description : 'Pull Request number to review' required : false type : number jobs : parse-devin-prompt : runs-on : ubuntu-latest # Run on: PR opened, issue comments starting with /devin, or manual workflow dispatch if : | github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/devin' )) outputs : pr_number : ${{ steps.extract.outputs.pr_number }} custom_prompt : ${{ steps.extract.outputs.custom_prompt }} comment_author : ${{ steps.extract.outputs.comment_author }} steps : - name : Extract information id : extract run : | if [ "${{ github.event_name }}" == "pull_request" ] ; then PR_NUMBER=${{ github.event.pull_request.number }} CUSTOM_PROMPT="/devin !prr " COMMENT_AUTHOR=" ${{ github.event.pull_request.user.login }} " elif [ " ${{ github.event_name }} " == " issue_comment" ]; then PR_NUMBER=${{ github.event.issue.number }} COMMENT="${{ github.event.comment.body }} " CUSTOM_PROMPT=$(echo " $COMMENT" | sed 's|^/devin[[:space:]]*||' | xargs) COMMENT_AUTHOR="${{ github.event.comment.user.login }} " else PR_NUMBER=${{ inputs.pr_number }} CUSTOM_PROMPT="" COMMENT_AUTHOR="" fi echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT echo "custom_prompt=$CUSTOM_PROMPT" >> $GITHUB_OUTPUT echo "comment_author=$COMMENT_AUTHOR" >> $GITHUB_OUTPUT call-devin-slack : needs : parse-devin-prompt uses : stanby-inc/stanby-github-actions-common/.github/workflows/devin-slack-code-action.yml@master secrets : inherit with : pr_number : ${{ fromJSON(needs.parse-devin-prompt.outputs.pr_number) }} custom_prompt : ${{ needs.parse-devin-prompt.outputs.custom_prompt }} comment_author : ${{ needs.parse-devin-prompt.outputs.comment_author }} repository : ${{ github.repository }} 共通ワークフロー(devin-slack-code-action.yml) name : Devin Slack Code Action on : workflow_call : inputs : pr_number : description : 'Pull Request number' required : true type : string custom_prompt : description : 'Custom prompt for Devin' required : false type : string default : '' comment_author : description : 'Author of the comment that triggered this workflow' required : false type : string default : '' repository : description : 'Repository name (owner/repo)' required : true type : string jobs : slack-notification : runs-on : ubuntu-latest steps : - name : Checkout code uses : actions/checkout@v4 with : repository : ${{ inputs.repository }} fetch-depth : 0 - name : Get PR Information id : pr_info run : | PR_NUMBER=${{ inputs.pr_number }} # Extract PR info using GitHub API PR_DATA=$(gh pr view $PR_NUMBER --repo ${{ inputs.repository }} --json title,author,url 2>/dev/ null || echo '{}' ) if [ "$PR_DATA" = "{}" ] ; then echo "Error: Failed to fetch PR #$PR_NUMBER data" exit 1 fi PR_TITLE=$(echo $PR_DATA | jq -r .title) PR_AUTHOR=$(echo $PR_DATA | jq -r .author.login) PR_URL=$(echo $PR_DATA | jq -r .url) echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT echo "pr_author=$PR_AUTHOR" >> $GITHUB_OUTPUT echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT env : GH_TOKEN : ${{ secrets.GITHUB_TOKEN }} - name : Get Changed Files id : changed_files run : | # Get list of changed files using gh command CHANGED_FILES=$(gh pr diff ${{ inputs.pr_number }} --repo ${{ inputs.repository }} --name-only | head -20) # Store as plain text, not JSON escaped echo "files<<EOF" >> $GITHUB_OUTPUT echo "$CHANGED_FILES" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT env : GH_TOKEN : ${{ secrets.GITHUB_TOKEN }} - name : Send Slack Message to Devin id : slack_message env : SLACK_BOT_TOKEN : ${{ secrets.DEVIN_SLACK_BOT_TOKEN }} SLACK_CHANNEL_ID : ${{ secrets.DEVIN_SLACK_CHANNEL_ID }} DEVIN_BOT_USER_ID : ${{ secrets.DEVIN_BOT_USER_ID }} run : | # Construct the message with @Devin mention if [ -n "${{ inputs.custom_prompt }}" ] ; then # Custom prompt from /devin comment # Properly escape the custom prompt for JSON ESCAPED_PROMPT=$(echo "${{ inputs.custom_prompt }}" | jq -Rs .) REVIEW_REQUEST="<@$DEVIN_BOT_USER_ID> PR #${{ steps.pr_info.outputs.pr_number }} - $(echo $ESCAPED_PROMPT | jq -r .)" REQUESTER_INFO="_Requested by : ${{ inputs.comment_author }}_" else # Default request REVIEW_REQUEST="<@$DEVIN_BOT_USER_ID> Please check PR #${{ steps.pr_info.outputs.pr_number }} at ${{ steps.pr_info.outputs.pr_url }}" REQUESTER_INFO="" fi # Prepare changed files text for JSON CHANGED_FILES_TEXT=$(echo "${{ steps.changed_files.outputs.files }}" | jq -Rs .) # Send message using Slack API RESPONSE=$(curl -X POST https://slack.com/api/chat.postMessage \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json" \ -d @- <<EOF { "channel" : "$SLACK_CHANNEL_ID" , "text" : "$REVIEW_REQUEST \n\n PR: ${{ steps.pr_info.outputs.pr_url }} \n Title: ${{ steps.pr_info.outputs.pr_title }}" , "blocks" : [ { "type" : "section" , "text" : { "type" : "mrkdwn" , "text" : "$(echo " $REVIEW_REQUEST" | jq -Rs . | jq -r .)" } } , { "type" : "section" , "text" : { "type" : "mrkdwn" , "text" : "*PR #${{ steps.pr_info.outputs.pr_number }}: $(echo " $ {{ steps.pr_info.outputs.pr_title }} " | jq -Rs . | jq -r .)* \n Author: ${{ steps.pr_info.outputs.pr_author }} \n URL: ${{ steps.pr_info.outputs.pr_url }}$([ -n " $REQUESTER_INFO" ] && echo " \n $REQUESTER_INFO" || echo "" )" } } , { "type" : "section" , "text" : { "type" : "mrkdwn" , "text" : "*Changed Files:* \n \`\`\`$(echo $CHANGED_FILES_TEXT | jq -r .)\`\`\`" } } , { "type" : "divider" } , { "type" : "section" , "text" : { "type" : "mrkdwn" , "text" : "Please check the PR for full diff." } } , { "type" : "actions" , "elements" : [ { "type" : "button" , "text" : { "type" : "plain_text" , "text" : "View PR on GitHub" } , "url" : "${{ steps.pr_info.outputs.pr_url }}" } ] } ] } EOF ) # Check if message was sent successfully SUCCESS=$(echo $RESPONSE | jq -r .ok) if [ "$SUCCESS" != "true" ] ; then echo "Failed to send Slack message" echo "Response: $RESPONSE" exit 1 fi echo "Slack message sent successfully!" TIMESTAMP=$(echo $RESPONSE | jq -r .ts) echo "timestamp=$TIMESTAMP" >> $GITHUB_OUTPUT - name : Comment on PR if : success() run : | if [ -n "${{ inputs.custom_prompt }}" ] ; then COMMENT_BODY="🤖 Devin has been notified about this PR via Slack. Custom request : _${{ inputs.custom_prompt }}_ Requested by : @${{ inputs.comment_author }} Slack message sent at : $(date -u +"%Y-%m-%d %H:%M:%S UTC")" else COMMENT_BODY="🤖 Devin has been notified about this PR via Slack. Slack message sent at : $(date -u +"%Y-%m-%d %H:%M:%S UTC") Please check your Slack channel for Devin's response." fi gh pr comment ${{ inputs.pr_number }} --repo ${{ inputs.repository }} --body "$COMMENT_BODY" env : GH_TOKEN : ${{ secrets.GITHUB_TOKEN }} 処理の流れ それぞれのパターンの全体の処理の流れについて解説します。 パターン1: PR作成時の自動レビュー トリガーイベント 開発者がGitHubでPRを作成すると、 pull_request.opened イベントがGitHub Actionsをトリガーします。 PR情報の取得 GitHub Actionsが起動し、GitHub CLIを使用してPRの詳細情報を取得します: # PR情報の詳細取得 gh pr view $PR_NUMBER --json title,author,url # 変更ファイル一覧の取得(最大20ファイル) gh pr diff $PR_NUMBER --name-only | head -20 デフォルトプロンプトの設定 デフォルトプロンプト /devin !prr を使用し、レビュー用Playbook(カスタムコマンド)を呼び出します。 Slackへのメッセージ投稿 取得した情報を使って、Slack APIでDevinにメンションを送ります。 Devinによるレビュー実行 Slackでメンションを受けたDevinが以下を実行します: PRのURLからGitHubにアクセス 変更内容を取得・分析 Playbookに従ってコードレビューする レビュー結果をSlackスレッドに投稿 PRへのコメント追加 GitHub Actionsが自動的にPRにコメントを追加します: 🤖 Devin has been notified about this PR via Slack. Slack message sent at: 2025-10-08 07:48:08 UTC Please check your Slack channel for Devin's response. PRにDevinのレビュー指摘をそのまま書き戻してしまうと、PRのコメントが長くなり、見通しが悪くなると感じたため、DevinとのやりとりはSlack上でやらせるようにしています。 パターン2: PRコメントによるカスタムタスク トリガーイベント 開発者がPRコメントで /devin [カスタムプロンプト] と入力すると、 issue_comment.created イベントがGitHub Actionsをトリガーします。 ワークフロー起動と情報抽出 GitHub Actionsが起動し、以下の情報を抽出します: PR番号 コメント本文からカスタムプロンプトを抽出( /devin の後ろの文字列) コメント作成者情報 Slackへのメッセージ投稿 カスタムプロンプトを含めてDevinにメンションを送ります: Devinによるタスク実行 Slackでメンションを受けたDevinが以下を実行します: カスタムプロンプトの内容を理解 PRのコードを取得・分析 指示されたタスク(テストカバレッジ改善、セキュリティチェックなど)を実施 実行結果をSlackスレッドに投稿 PRへのコメント追加 GitHub Actionsが自動的にPRにコメントを追加します: 🤖 Devin has been notified about this PR via Slack. Custom request: このPRのテストカバレッジを改善してください Requested by: @yuto-ida Slack message sent at: 2025-09-15 10:30:00 UTC こちらも同様に、DevinとのやりとりはSlack上でやらせるようにしています。 Slack経由で呼び出すメリット この仕組みでは、Devin APIを直接呼び出すのではなく、Slack経由でDevinにメンションを送る方式を採用しています。これには以下のようなメリットがあります。 どのプランでも利用できる Devin APIを使用する場合、APIアクセスは特定のプラン(Teamプラン、Enterpriseプラン)に限定されます。 しかし、Slack経由でDevinを呼び出す方式では、どのDevinプランでも動作するため、プランに縛られず利用できます。 コミュニケーションの可視化 Slackのスレッド上でDevinとやり取りが行われるため、PRコメントのやりとりが長くなることなく、 チーム全体でDevinの作業状況やレビュー結果を確認しやすくなります。 Devinのレビュープロセス Devinによるレビューの品質を保つため、専用のPlaybook(カスタムコマンド)を作成しています。 Devin PRレビュー用Playbook # Devin PRレビュー用Playbook ## Procedure 1. step 1: コンテキスト確認 - PRの概要・目的・関連するIssueやチケットを確認する - 変更範囲(diff)と影響範囲(サービス・モジュール・依存関係)を把握する 2. step 2: コード品質チェック - コーディング規約(命名・フォーマット・Lintルール)に沿っているか確認 - 複雑すぎる処理やネストを避け、読みやすさを保っているか - 冗長なコードや重複がないか 3. step 3: 設計・アーキテクチャ確認 - 責務の分離ができているか(関数・クラスの粒度) - 再利用性・拡張性を妨げる設計になっていないか - 既存の設計原則やプロジェクト方針に従っているか 4. step 4: 動作確認ポイントの提示 - ユニットテストや統合テストが適切に書かれているか - 主要なエッジケース(例外処理・null/undefined・エラーリカバリ)がカバーされているか - 手動確認が必要な場合、その範囲を明記する 5. step 5: セキュリティ・パフォーマンス確認 - セキュリティリスク(SQLインジェクション、XSS、認証認可の漏れ)がないか - 不要に重い処理やボトルネックになりそうな実装がないか 6. step 6: フィードバック作成 - 指摘は「なぜ」改善が必要か背景を添える - 修正案や参考リンクを提示する - 承認・修正要望・ブロッカーを明確に区分する ## Advice & Pointers - 「Good first」コメントも添える:良い実装や工夫された点は積極的に褒める - プロジェクトのガイドラインやスタイルガイドを引用すると説得力が増す - コメントは簡潔に、かつ具体的に(例:「変数名をuserListにすると用途が明確になります」) - 優先度をつけて伝える(must / should / nice-to-have) ## Forbidden actions - 人格批判や感情的なコメントをしない - 抽象的すぎる指摘(例:「よくない」だけ)は禁止。必ず理由と代替案を添える - 過剰な修正依頼(プロジェクト方針と無関係な個人的好み)はしない - テストを無視してレビュー承認しない - セキュリティリスクの見落としを放置しない 運用して分かったこと 実際に運用を始めてから、Devinのレビューには以下のような効果があると感じています。 レビューの質の向上 Devinの指摘で特に役立っているのは以下です: 見落としがちな基本的なミスの検出 変数名のtypo 未使用のimport文 エラーハンドリングの漏れ テストケースの不足 セキュリティ関連の指摘 SQL injectionの可能性 機密情報のログ出力 XSS脆弱性のリスク パフォーマンスに関する提案 不要なループ処理 メモリ効率の改善案 コーディング規約の統一 人間だとつい見逃してしまう細かい規約違反も、Devinは一貫して指摘してくれます。これにより、コードベース全体の品質が均一に保たれるようになりました。 レビュアーの負担軽減 細かいチェック項目をDevinが担当してくれるため、人間のレビュアーはより高度な判断が求められる部分に対して集中できるようになりました。 設計思想の妥当性 ビジネスロジックの正確性 アーキテクチャとの整合性 限界と人間レビューの重要性 もちろん、Devinにも限界があります: ビジネス要件との整合性 :ドメイン知識が必要な判断は難しい コンテキストの理解 :PRの背景や意図を完全に理解するのは難しい そのため、 Devinのレビューはあくまでも一次チェック と位置づけ、最終的な承認は必ず人間のレビュアーが行っています。 Devinと人間の役割分担を明確にすることで、両者の強みを活かした効率的なレビューフローが実現できていると感じています。 まとめ 今回、PR作成と同時にDevinを起動する仕組みを導入したことで、 レビュアーの負担が軽減 され、開発者は本質的な部分に対して集中できるようになったと感じています。 重要なのは、 Devinはあくまでもレビューをサポートするツールであり、人間のレビュアーを置き換えるものではない という点です。 AIと人間がそれぞれの強みを活かし、協力してレビューする体制を構築できたことが、この取り組みの成功要因だと考えています。 今後も運用を続けながら改善を重ね、より効率的で質の高い開発プロセスを目指していきたいです。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
株式会社スタンバイ QAグループ(Quality Assurance Group)の樽井です。 「自動テストは広範囲で失敗しているが、手動テストではどの機能が影響を受けているのかわからない…」 リグレッションテストの際、こんな状況に陥ったことはありませんか? 私達のチームでは、この自動テストと手動テストの結果が分断されていることが、問題発見の遅れや、不具合の見逃しリスクにつながっていました。 本記事では、MagicPod Web APIを使ってどのように解決したのか、その具体的な取り組みをご紹介します。 MagicPod導入について興味のある方は、過去記事の「 スタンバイ QAのテスト自動化導入(MagicPod) 」をご確認下さい。 techblog.stanby.co.jp なぜテスト結果を統合するのか モバイルアプリ(以降App)のリグレッションテストは、MagicPodによる自動テストとQA担当者による手動テストを併用しています。 リグレッションテストの各項目には、MagicPodシナリオ(以降シナリオ)のIDが紐づけられており、どのシナリオがどの項目を担っているかを把握するに留まっていました。 ここでのリグレッションテスト(回帰テスト・退行テスト)は、ソフトウェアの機能追加や不具合修正、環境変更などのプログラム変更によって、それまで正常に動作していた機能に新たな不具合が発生していないか確認するテストを指します。 API利用以前のリグレッションテスト 問題は、シナリオ一括実行で複数シナリオが失敗した際、影響範囲の特定に多大な時間がかかっていたことでした。 手動テストは一項目ずつ実施するため、リグレッションテストの項目上で、どの機能がNGであったかすぐに分かります。しかし、MagicPodの一括実行結果は、ツール上でどのシナリオが失敗したか分かりますが、それが具体的にどの機能への影響を意味するのか、即座に判断するのは困難でした。 特に広範囲で失敗している場合、それがインフラの問題なのか、共通コンポーネントのデグレなのか、はたまたテスト環境の不調なのか…。原因を切り分けるために、ひとつひとつの実行結果ログを追い、開発者にヒアリングし… という非効率な調査が発生していました。 この調査の遅れは、不具合の発見が遅れるリスクに直結します。自動テストと手動テストが独立しすぎているこの状況をなんとかしたいと考え、両者のテスト結果を統合し、一目で比較できるようにすることを決めました。 MagicPod Web APIの利用 スタンバイでは、リグレッションテストをスプレッドシートで管理しているため、MagicPodヘルプセンターの「 MagicPodが提供するWeb APIの使用方法 」を参考に、シナリオ一括実行結果をスプレッドシートへ出力することにしました。 MagicPod Web APIをどのように活用していくのかはまだ検討段階のため、所々既存システムを利用し、最小限のコード作成としています。 システム概要図 リグレッションテストでは、シナリオIDをキーとして、手動テスト結果と紐づけを行いました。 MagicPod Web APIで取得した一括実行結果一覧 一括実行結果と結合したリグレッションテスト テスト結果の統合で得られた3つの変化 テスト結果を統合したことで、嬉しい変化がありました。 変化1:影響範囲特定までの時間短縮 以前は数時間をかけ、シナリオ一括実行結果をすべて確認することで影響範囲を特定していましたが、スプレッドシートを見るだけの数分で完了するようになりました。「問題がどこで起きているのか」を項目単位で把握できるため、不具合起票時の再現確認にも役立ちます。 変化2:デグレ調査の高速化 過去のシナリオ一括実行結果を一覧化しているため、「これは前回から起きていた不具合」「これは今回の変更による新しいデグレ」といった問題の切り分けが早くなりました。更には問題が解決した後、影響範囲が正しかったかの答え合わせとしても活用できます。 変化3:手動テストの優先付け 手動テスト担当者も自動テストの失敗箇所をリアルタイムで把握できるため、「この機能は問題があるかもしれないので、優先的にテストしよう」といった先回り対応が可能になり、チーム全体で効率的に不具合を発見できるようになりました。 終わりに MagicPod Web APIの活用はまだ始まったばかりですが、自動・手動という2つのテスト結果を繋ぐことで、いくつかの改善効果が得られました。 もし、皆様の現場でもテスト結果が点在し、非効率な調査が発生しているようでしたら、この記事が解決のヒントになれば幸いです。 以上、ここまでお付き合いいただきありがとうございました! スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
こんにちは、スタンバイでインフラを担当している勝俣です。 今回はEKSを日々運用していく中で直面した技術的な課題のうち、スタンバイのEKSクラスタに導入している「Reloader」というアプリケーションについて紹介します。 Reloaderとは KubernetesのConfigMapやSecretの変更を検知し、該当するPodを自動的に再起動してくれる便利なツールです。 スタンバイでは、AWS Parameter Store に 機密情報を保存しており、Secret に反映してます。 Parameter Store に保存している機密情報を変更すると Secret も変更されるので、そのために Reloader を使用してます。 https://github.com/stakater/Reloader?tab=readme-ov-file#-what-is-reloader 課題 これまでは、アプリケーションは1つのネームスペース内で稼働させていました。 Reloaderはこのアプリケーションのネームスペースに配置され、このネームスペースのみ監視している状態でした。 スタンバイでは、規模拡大に合わせ新規アプリケーションの構築やリアーキテクチャが行われており、アプリケーションのワークロードや特性ごとに複数のネームスペースに分けたいという要望があがってきました。 そのため、今後構築される新規のネームスペースも同様にReloaderに監視させる必要があり、稼働中のEKSクラスタに対して設定変更が必要でした。 設定変更概要 現状のReloaderは以下のような設定になっていました。 既存アプリケーションと同じネームスペースに配置され、そのネームスペースのみを監視している その中で annotation が設定されている pod が監視対象 対して、新規追加したネームスペースも監視できるよう以下のような方針で設定変更する事を決めました。 Reloader専用のネームスペースに配置し、全ネームスペースを監視する その中で annotation が設定されている pod が監視対象 結果的に、以下のようなステップで設定変更を実施しました。 Reloader専用ネームスペースを作成 Reloaderの設定を変更し、全てのネームスペースを監視するように変更 Reloader専用ネームスペースにReloaderを再配置 それぞれ解説していきます。 1.Reloader専用ネームスペースを作成 Reloaderを他のアプリケーションリソースと分離することで、他のアプリケーションリソースとの混在を防ぎ、クラスタに対するオペレーション時のメンテナンス性を向上させる為、新しくReloader専用のネームスペースを作成します。 前提として、スタンバイはEKSクラスタと内部のコンポーネントをterraform+helm、アプリケーションをArgoCDで構成しています。 ネームスペースに関してはterraformを使用して構築しており、以下が実際のコードです。 resource "kubernetes_namespace" "reloader" { metadata { name = "reloader" } } 2.Reloaderの設定を変更し、全てのネームスペースを監視するように変更 Reloader自体はterraform+helmで実装されています。 ここでは、 reloader.watchGlobally の値を false から true に変更します。 これにより、Reloaderが配置されているネームスペースのみを監視している状態から、配置されているネームスペースを問わず全ネームスペースを監視する状態になります。 values.yaml の中の以下の設定を変更します。 reloader: reloadStrategy: annotations watchGlobally: true # ここを変更 3.Reloader専用ネームスペースにReloaderを再配置 以下が実際のコードで、 1. で構築した専用のネームスペースを指定しています。 配置ネームスペースの変更により、helmチャートのreplaceが走り、deploymentの削除→作成が行われます。 locals { helm_reloader = { repository = "https://stakater.github.io/stakater-charts" version = "1.X.X" } } resource "helm_release" "reloader" { name = "reloader" chart = "reloader" repository = local.helm_reloader.repository version = local.helm_reloader.version namespace = kubernetes_namespace.reloader.id # ここを変更 values = [ templatefile("_files/reloader/values.yaml", { env = var.env version = local.helm_reloader.version }) ] } テストや検証について 設定自体は至ってシンプルですが、既に稼働している環境への設定変更となるので、検証・調査に多くの時間を割きました。 起こり得るシナリオを列挙したラフを作成し、チーム内でブラッシュアップを重ね、テストケースに落とし込みました。 正常系の動作確認方法の一部ですが、以下のような方法を実施しました。 Reloaderのログレベル reloader.loglevel をdebugに変更 reloader: logLevel: debug 適当なアプリケーションのConfigMap/Secretを以下のように手動で変更 ### ConfigMap $ kubectl patch configmap hoge-staging --type merge -p '{"data":{"HOGE":null}}' ### Secret $ kubectl label secret hoge-staging hoge/hoge=hoge --overwrite 対象のpodsが再起動されている事を確認 $ kubectl get pods | grep hoge Reloaderのログからも変更が検知されている事を確認 $ kubectl logs reloader-6fc54b7755-b2stv | tail -n1 time="2025-06-11T09:09:12Z" level=info msg="Changes detected in 'hoge-staging' of type 'SECRET' in namespace 'A'; updated 'hoge-staging' of type 'Deployment' in namespace 'A'" Reloaderの監視対象に変更があった際に付与している※¹hash値が変更前後に変わっていない事も確認。 上記に加え、異常系等の様々なパターンのテストを実施し、既存のpodに影響が無いことを確認できたので、特に不安要素は無く安心してリリースできました。 ※¹ reloader.reloadStrategy で、どこにhashを記録しそれをトリガーにするかを選択が可能。 スタンバイにおいては annotations を使用しており、以下のようなhash値が付与されていました。 $ kubectl describe deployment reader-api-staging | grep -iA1 reloaded reloader.stakater.com/last-reloaded-from: {"type":"CONFIGMAP","name":"reader-api-staging","namespace":"A","hash":"stanbytechblognisanjoucbeb27969f21cd937800632c8602f737d","conta... まとめ リリース前後で特に問題も無く安定稼働しており、数あるEKS運用課題のうちの1つを解決する事が出来ました。 これに限らず、まだまだEKS運用の改善点は多いので、継続的な改善を続けていきます。 最後までご覧くださり、ありがとうございました。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
はじめに こんにちは、クオリティ部の三上と申します。 あなたは最後にいつ、求人を検索しましたか? その時、思い通りの結果は表示されたでしょうか。 「なんか違うな」と感じたことはありませんでしたか? そんな「なんか違う」を見つけて、改善していく。それが私の仕事、Search Quality(SQ)です。 しかし、この職種には1つ大きな矛盾があります。 検索を改善するのが仕事なのに、私たちはコードを書きません。 検索エンジンを開発するわけでも、UIを設計するわけでもないのです。 今回は、そんな 技術の隣にいるけれど、開発者ではない職種 としてのSQのご紹介と、そこに込めている我々の誇りや葛藤について書いていきます。 SQは何をしているのか SQは、最良の検索体験を重視し、検索品質を保つための評価・改善・提案を行います。 我々が担っているのは、具体的には以下のような業務になります。 検索品質指標と定性的観点に基づいたA/Bテストの評価分析 検索ランキングやUI等のさまざまな改善施策がどのような影響をもたらすかを、定量と定性の両面から検証。検索品質指標からの定量的な分析と、実際に返ってくる検索結果を評価スケールに従って1件1件人の目で評価する定性評価の二軸で確認。 検索辞書の作成・運用 求人側の表現とユーザーの検索語句のズレを埋めるための辞書整備。同音異義語や同義語等、意味が近いものを適切にマッチさせることで、検索結果の網羅性と適合性を高める。 検索品質向上にまつわるオペレーション業務 機能の精度評価、大規模な競合調査、データクレンジングなど、泥臭く見えるが重要な日々の地道な調査やメンテナンスもSQの範疇となる。 開発職でも企画職でもないこの立場だからこそ、「検索体験」そのものに専念できる──それがこの仕事の価値だと感じています。 何をもってして“よい検索”と言えるのか? SQという職種で仕事に携わっていると、たびたび「よい検索とはなにか?」という問いに向き合います。 検索エンジンの世界では、nDCG(Normalized Discounted Cumulative Gain)やMRR(Mean Reciprocal Rank)のようなランキング評価指標や、A/Bテストの勝敗で「良し悪し」を判断するのが一般的です。それらは当然、大事な判断材料です。 ただ、我々はそうした指標に加えて、 検索が「ユーザーにとって良かったかどうか」 という、やや抽象的で主観的な問いにも常に向き合っています。 なぜなら、「よい検索」とは単なる数値の良し悪しではなく、「 体験として意味のある結果(求めていた仕事)が返ってきたかどうか 」に根ざしているからです。 SQは、以下に挙げる「RCFPTSD」の軸を総合的に見ながら、「よい検索」を定義しようと試みています。 Relevancy ― 関連性 まず、検索体験の根幹となるのが 関連性 です。 ユーザーが「事務 週3」と検索したときに、上位に表示されるのは「週3日勤務の事務職の求人」であるべきですし、この際に「アルバイト」とは打っていないのに、雇用形態がアルバイトの求人を勝手に増やすような過剰な推測も避けるべきでしょう。 その人が思ってもいなかったような出会いが演出されるのもスタンバイというサービスで提供できる1つの価値と考えますが、検索エンジンが求職者の意図を過剰に推測して拡張してしまうと、「入力したキーワードと無関係なものが出ている」という違和感につながりかねません。 だからこそ、 入力されたクエリに忠実であること を重視しています。 Comprehensiveness ― 網羅性 一方で、「忠実である」ことだけを優先しすぎると、ヒット件数が極端に少なくなることがあります。ここで登場するのが 網羅性 という視点です。 たとえば「リモートワーク」と検索された場合、「完全在宅」や「テレワーク」などの表現ゆれに対応できていないと、求職者は求める求人に出会えず終わってしまいます。 広くあまねく求人票がindexされたうえで、取りこぼされることなく表示されるのがベストです。 Freshness ― 更新性 求人情報は 更新性 が命です。すでに募集が終了している求人や、情報が古いまま残っている求人が出てくると、それだけで求職者のがっかり度合いは高まります。 定期的なデータ更新、期限切れの判定、求人情報の提供元との同期精度──こうした地道な積み上げで更新性を担保することも非常に大事な要素です。 Presentation ― 閲覧性 検索品質は結果の並び順だけではなく、 結果の見え方 にも影響されます。 その求人がどんな業務内容で、どのエリアで、給与はいくらなのか。それらが視認性の高い形で整然と並んでいるか。 SQはUI・UXの専門家ではありませんが、検索結果に並ぶ情報が 構造的に表示されているこ とや、 応募判断に必要な情報だけが適切に露出されていること も重視しています。 それにより、ユーザーが求人を“読む”のではなく、“一目で把握できる”状態に近づけていきます。 Trust ― 信頼性 求人検索には 信頼性 も欠かせません。 怪しい副業勧誘、架空の求人、誘導目的の空求人──残念ながら、世の中にはそうした“信頼を裏切るコンテンツ”が存在しており、それらが混ざり得てしまうことがあります。 検索とは「 真実に近づくための手段 」でもあるべきで、職務上関われる領域としては限られていますが、SQとしても安心して使える検索体験の維持に努めています。 Speed ― 表示速度 検索体験における体感品質の中で、意外に見落とされがちなのが 速度 です。 検索ボタンを押したあと、コンマ数秒で画面が切り替わるし、求人詳細ページをクリックした瞬間に表示される──こうした速度の積み重ねが、ユーザーのストレスを最小化します。 逆に、レスポンスが遅いだけで、検索体験全体が「もっさりしている」「使いづらい」と評価されてしまうのです。 SQは 体感上の待ち時間を最小限にすること も、検索品質の一環として捉えています。 Diversity ― 多様性 その職種で検索しているわけではないのに、検索結果の上位に同じような職種の求人が並びすぎていないか? 特定のブランドやエリアだけが強調されていないか? SQは、関連する選択肢を過不足なく届けるだけでなく、ユーザーの視野を狭めないことも重要だと考えています。 そのために、 職種・雇用形態・勤務地等の多様性 にも目を配っています。 “体験”としての検索品質を、どう捉えるか ここまで紹介してきた各要素は、いずれも直接的な数値で表しにくく、それゆえに議論が難しい側面もあります。 ましてや、SQはスタンバイのプロダクト体験全体を代表する立場でもありません。 ですが、検索という機能において、「 ユーザーが仕事に出会うまでの橋渡し 」を担っているという自覚があります。 その橋が、崩れていないか? 狭すぎて通れない人がいないか? 誰かにとって怖い道になっていないか? こうした問いを、自分たちなりの“ものさし”で考え続ける──それが、私たちSQの役割なのだと思っています。 SQという職種は珍しい? さて、ここからはSQという職種について書いていきます。 検索サービスの品質管理というと、GoogleやLINEヤフーのような大規模プラットフォームにしか存在しない印象を持つかもしれません。 実際、検索体験の質にフォーカスした専任職は、全世界を見渡してもありふれたものではないようです。しかもそれらの多くは、検索そのものが事業のコアであり、かつリソースに余裕のある超大企業です。 そんな中で、スタンバイのような 比較的小規模な事業会社が、検索品質だけに特化した専任職を設けている のは、ある意味ユニークです。 開発を担わず、直接プロダクト機能を生み出すわけでもない職種に対して、リソースを割くことは、短期的な視点では非合理に見えるでしょう。 仮に置くとしても少なくとも今のフェーズではない。 普通ならば、1つでも機能を開発するために、エンジニアの数を増やす経営判断をするはずです。 しかしスタンバイはあえて、この職種を配置しています。 それは、「 求人検索体験の質 」 こそが、サービスの生命線である という強い哲学があるからと私は思っています。 「検索結果が正確である」 「意図に沿ったものが返ってくる」 ──これは、どれだけ華やかなUIや機能があっても欠けてはならない土台です。 そしてその土台を、数字の外側にある“肌感覚の違和感”まで含めて丁寧に保つために、SQというロールが存在しています。 ロールモデルの不在とキャリアの見えなさ SQの意外な悩みとしては、 明確なロールモデルがほとんどいないこと も挙げられます。 先述したように、SQは世の中にありふれた職種ではありません。 近しいものを挙げるとするなら世間一般的にはデータサイエンティストやリサーチャーですが、それとてSQの経験をそっくりそのまま外に持っていくのも難しいのです。だからこそ…… 何をもって「優秀」とされるのか? どんなスキルを深めていけばいいのか? どんなキャリアパスがありうるのか? これらが曖昧で、自分で自分の仕事の価値を定義しなければならない瞬間が多くあります。 SQはまだ“未定義の職種”なのです。 これは孤独でもありますが、同時に「自分で形を作れる」という自由でもあります。 「好き勝手に言っているだけ」に見えてしまうときもある 一方で、開発をしない立場として、エンジニアやプランナーと接する中で感じる“距離”はたしかにあります。 たとえば、検索体験を改善するためのを提案しても、 「実装コスト重くない?」 「全体の優先度的に今ではないんじゃない?」 「それって本当に意味あるの?」 という反応が返ってくることがあります。 もちろん議論は健全なことです。 しかし、 自分たちが創り出していない分、発言が軽く受け取られてしまう 感覚を時として抱くこともあります。 「現場の苦労もわからず好き勝手に言っているだけ」 「自分で実装しないのに、このタイミングでそれを言うのか」 ──そう思われるリスクを常に感じながら、それでも提案を続ける場面もあります。 この立場には、 信頼されなければならないけれど、信頼を築くための 「 見える成果 」 が得にくい という矛盾が常につきまといます。 それでも、言わなきゃいけないことがある 開発をしていなくても、数値に表れなくても、「これが気になる」「これはユーザー体験として違う気がする」という直感を大事にしたい。 そしてそれを仮説に変え、検証できる形に持ち込み、誰かと協働して実際の改善に結びつけていく── そのプロセスこそが、SQという職種の本質 なのだと考えます。 時には耳の痛いことを言わなければならない。ときには嫌われ役になることもある。 でも、それがユーザー体験のためになるのであれば、我々は甘んじてその役割を引き受けたいと思っています。 おわりに SQという職種は、技術と非技術の“あいだ”に立っています。 エンジニアのようにコードを書かず、PdMのようにロードマップを握るわけでもない。 だからこそ、見えるものがあります。 だからこそ、言えることがあります。 この未定義な職種で、日々悩みながら働いている人間がここにいます。 もし、あなたのチームにも「コードを書かないけれど、体験の質を守ってくれている人」がいるなら、その人の存在に少しだけ思いを巡らせてもらえたら嬉しいです。 そして、もしこの記事を読んで「こういう立場で検索体験に向き合ってみたい」と感じていただけたなら── スタンバイでは、 SQという少し変わった職種に挑戦してみたい仲間を募集しています。 未定義だからこそ、まだまだ形にできることがたくさんあります。ぜひ一緒に、検索という体験を育てていきましょう。 ↓SQが気になった方はこちらかどうぞ サーチクオリティ/Search Quality(SQ) スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
プロダクト部UserGの熊本です。 2025年3月24日〜28日で認定スクラムマスター(CSM®)研修を受講してきました! この研修はとても学びが多く有意義であったので、振り返りも兼ねて研修で学んだことを紹介できればと思います。 認定スクラムマスター(CSM®)研修とは 今回私が受講した 認定スクラムマスター(CSM®)研修 は株式会社Odd-e Japanが主催しているものでした。 この研修の受講状況に応じて適性が認められた場合に認定試験の受験資格が与えられ、その試験に合格すると 認定スクラムマスター の認定証が発行されます。 私は5日間、合計30時間のオンライン開催の研修に参加しました。 研修の内容は、初日に講師の方からお題が出され、そのお題に対して参加者全員で議論するというものです。 その議論を進める中でスクラムマスターとして必要な考え方・振る舞いが求められるので、より具体的な事象を元にスクラムについて学ぶことができます。 私は普段 Developerとしてスクラムに参加しており、この研修を通してのスクラムマスターの考え方はもちろん、スクラムメンバーの一員としてスクラムの理念を実践的に学ぶことができたのがとても貴重な経験だったなと感じます。 研修を受講しようと思ったきっかけ 私達のチームではスクラムを用いて開発しています。ただ私自身スクラムというものを深く理解しないまま日々の開発を行っていました。 あるときチーム内の振り返りで、スクラムで行っている活動をもう少し効果的にできるのではないか?と疑問に持ったものの、そのときどのように改善するのが適切なのかわかりませんでした。 そのため研修の中でスクラムの理論から実践まで体験することで、スクラムをもう少し上手く活用できるのでは、と思い受講しました。 研修で学んだこと 今回の研修で特に私が学びになったと考えたことは以下のような事項でした。 研修で学んだことリスト スクラムは現状を把握するためのフレームワーク スクラムは計画駆動であり、活動は分析できる状態でなければならない 計画は綿密かつ複数立てることが定石 レンジを意識した能力開発 スクラムは千回伝えるゲーム スクラムは現状を把握するためのフレームワーク 研修を受ける前はスクラムは、とりあえず前に進めながら日々改善していくフレームワークだと思い込んでいました。ただスクラムにおける目的は「現状を把握する」ということにあります。 スクラムガイド にも以下の記載がある通り、スクラムでは現状を把握するために4つのイベントが設けられていて、その中で問題を可視化し表面化された問題に1つずつ向き合って適応しようとする活動が重要になってきます。 スクラムでは、検査と適応のための 4 つの正式なイベントを組み合わせている。それらを包含 するイベントは「スプリント」と呼ばれる。これらのイベントが機能するのは、経験主義のス クラムの三本柱「透明性」「検査」「適応」を実現しているからである。 スクラムとは、スクラムの価値を理解して現状を把握するために努めることが重要で、スクラムを導入したからと言って自動的に何等かの問題が改善されることはないということです。 スクラムは計画駆動であり、活動は分析できる状態でなければならない まずスクラムは計画駆動で考える必要があります。 何かしらの活動を「とりあえず」や「一旦」のように始めてしまうと、その活動が分析ができず、スクラムにおける検査、適応ができなくなってしまいます。 とりあえず行動に移すということはスクラムに反することで、その行動に移す前に何らかの狙いを持っておく必要があります。この狙いは必ずしも目的である必要はないようです。 目的がないことは分析できないことと同義ではなく、目的はなくとも狙いを持っていればその狙いに対して分析できます。 例えば、チーム内での関係性の構築・維持活動として、雑談の機会を設けるという活動をするとします。その場合何等かチーム内で合意するなどの目的はないにしても、親睦を深める、安心感・信頼感を醸成するなどの狙いを持つことができます。このような狙いを持って活動に移ることで、活動後本当に各チームメンバーの親睦は深まったのか、もっとその機会を増やすべきなのか、またもう少し違う活動として改善すべきなのか、など分析することができます。 ここで研修前の私のスクラムについての理解であった「とりあえず前に進めながら日々改善していくフレームワーク」は改めて間違っていたと認識することができました。 ただ単にやるのではなく、何かしらの活動をする前に計画を立ててその活動が分析できる状態にしておく、それが「現状を把握する」ことに繋がるということだと理解しました。 計画は綿密かつ複数立てることが定石 先ほどスクラムは計画駆動というお話をしました。 スクラムは計画駆動であるため、何かしらの活動をする前に必ず計画を立てて、計画通り活動を進める必要があります。 なぜ計画通りに進める必要があるのかというと、以下の2点があげられます。 スクラムチーム全体で共通理解を持つことができる => スクラムの理論にある「透明性」を実現 不確実性の高い状況でも着実に価値を生み出すことができる => 安定的なインクリメントを実現 つまり計画通りに進めることは、共通理解を持つことで透明性を体現しつつ、インクリメントを安定的に出すことに繋がります。 しかし現実世界では計画通りに物事が進むとは限らないと思います。その中でスクラムでは計画を変更することなく、計画通りに進める必要があります。 ここで重要なのは、 計画は1つではなく、複数立てるべき だということです。 そのため計画を立てる段階であらゆる可能性を見通し複数のサブプランを用意して準備しておくことが重要になってきます。 予め用意しておいたサブプランに変更することは計画の変更ではなく、計画通りであると捉えることができます。 なので、スクラムでは計画は綿密かつ複数立てるということを意識する必要があります。 実際にスクラムを運用する中で、スプリントの途中で計画を立て直すこともNGになります。ただ計画は常に絶対的なものではないので、スプリントレビューなどを通してその妥当性を検証し、適応に繋げて行く必要があります。 レンジを意識した能力開発 スクラムマスターは能力開発を支援する中でチーム、各メンバーにあったレンジを意識する必要があります。 チームに対して高い目標を設定して、そのための活動を行ってもチームが安定したアウトプットを出せるとは限りません。 そのためチーム状況に応じて最適なレンジに向かうような活動に導く必要があります。 自分自身もチームに対して安易に〇〇ができてないのは良くないのではないか、と疑問に持つことがあったのですが、本当にそれが現状のチームにとって必要なことか、意識する必要があるのだなと思いました。 また能力開発にあたっては、活動指標に着目することも重要だと知りました。 活動指標とは、直接成果に結びつくものではないものの成果に近づくために重要となる日々の取り組みです。 例えば、野球選手がホームランを打つためには筋トレが必要ですが、筋トレをしたからといってホームランが必ず打てるようになるとは限りません。しかしホームランを打てるようになるためには必要な取り組みです。これを推進していくのがスクラムマスターの腕の見せ所です。 安定させたからといって結果が出るわけではないが、それでもやる。そしてその活動の分析するということを徹底する必要があります。 活動に対して評価できる指標を予め定めておいて、活動後必ず分析する。ここでもスクラムは計画駆動であることが分かりますね。 スクラムは千回伝えるゲーム スクラムを行う上で必要なFB力の1つに「繰り返し伝える」というものがあります。 「以前にも言った気がするんですが」や「ここに書いてありますよね」など1回で理解してもらえるという勝手な思い込みで発言してしまったことがあるかもしれません。 ただ人はそれぞれ異なる背景を持ち、情報を受け取るタイミングや状況も異なることは当たり前で、一度聞いただけで全てを正確に理解できるとは限りません。 スクラムでは情報を伝える側は100回でも1000回でも繰り返す必要があります。その姿勢こそが Scrum Valuesにある「尊敬」となり、スクラムの価値が根付いた強いチームに近づくのかなと思いました。 まとめ 今回受講した認定スクラムマスター研修は、正直かなりハードなものでした。 研修の中で日々スクラムマスターの考え方・振る舞いを学び、学んだことは忠実に再現することが求められます。 ただかなり負荷が高い研修ではあったものの、実践的にスクラムの価値に触れることでより解像度が高い状態で学ぶことができるので非常に貴重な経験だったなと思います。 学んだこととしては今回紹介した内容以外にもかなり多くのことがあったので、今後上手くチームに還元していければと思います。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
はじめに こんにちは、スタンバイでプロダクト開発をしている荒巻です。 スタンバイのテックブログでは、日々の技術的な挑戦や学びを発信しています。今回は少し趣向を変えて、先日公開した記事『AI Co-Pilotと作る!1200件のSQL書き換えを乗り越えた社内ツール開発秘話』を、 AIアシスタント(本記事では主にチャットインターフェースのGeminiと、エディタ上のGitHub Copilotの両方を指します)とどのように協力して執筆したのか 、そのプロセスと学びについてご紹介します。 この記事の主な目的は、テックブログ執筆のコストを削減し、情報発信のハードルを下げることです。AIを活用することで、より多くの方が気軽に情報発信できるようになるのでは、という期待がありました。 この記事は、以下のような方に特におすすめです。 AI/LLMを活用したコンテンツ作成、ライティング効率化に関心のある方 テックブログの運営や執筆プロセス改善に興味がある方 AIアシスタントとの具体的な協働プロセスや、そのメリット・デメリットを知りたい方 テックブログ執筆の一般的な課題(とAIへの期待) テックブログを書く、というのは意外と大変な作業です。 時間の確保: 通常業務と並行して、まとまった執筆時間を確保するのが難しい。 構成力: 伝えたいことはあっても、読者にわかりやすく、論理的な流れで構成するのが難しい。 表現力: 技術的な正確さを保ちつつ、平易で読みやすい文章にするのが難しい。 推敲・レビュー: 客観的な視点での推敲や、レビューに十分な時間を割くのが難しい。 トーン&マナー: ブログ全体の雰囲気や文体を統一するのが難しい。 今回、AIアシスタントを活用することで、これらの課題、特に時間的なコストや構成・表現に関する部分を効率化できるのではないかと考えました。 AIとの記事執筆:実践ワークフロー 実際に私とAI(主にチャットのGeminiとエディタのCopilot)が記事を完成させるまでに行ったプロセスを、フェーズごとに紹介します。各フェーズでの実際のやり取り(プロンプト)も交えながら見ていきましょう。 フェーズ1:コンテキストのインプット (Geminiへ) 目的: AIに記事のテーマ(SQL Converter開発)、背景(なぜ作ったか)、技術詳細(どう作ったか)、そして我々のテックブログが持つ固有のトーン&マナーを理解してもらうことです。 実践内容: まずはチャットインターフェース(Gemini)に対して「こういう記事を書きたい」という意図を伝え、関連情報を段階的に提供しました。 最初のプロンプト (Geminiへ): 自社のテックブログを執筆したい。 まずは記事を提供するので、トンマナのインプットをしてください。 [ トンマナ学習用の既存記事ファイルをアップロード ] ▲Geminiに最初の指示と関連ファイルを提供しているチャット画面 その後、同様にプロジェクトの背景資料(PDF)、SQL ConverterツールのREADMEやソースコードなどを順次提供し、AIに必要な情報をインプットさせました。 追加のコンテキスト投入プロンプト (Geminiへ): > 次に記事を管理するレポジトリのREADMEを提供するので読み込んでください。 > 次に今回作成したアプリケーションについての説明資料を添付するので読み込んでください。 > 次に今回作成したアプリケーションの主要ファイルを添付するので読み込んでください。 課題と対策: AIは、Web上のURLを直接解釈することや、社外秘情報を含む可能性のあるドキュメントを扱うことを苦手とする場合があります。そのため、必要な情報はファイルとしてアップロードしたり、場合によっては手動で情報を整理してテキストで渡したりする工夫が必要でした。このコンテキスト投入は少し手間がかかる部分です。 フェーズ2:対話による情報抽出と論点整理 (Geminiと) 目的: AIからの質問を通じて、記事に必要な情報や、書き手自身が当初意識していなかった重要な論点を明確にすることです。 実践内容: 十分な情報がインプットされたと判断した段階で、Geminiに質問を促しました。 質問要求プロンプト (Geminiへ): これまでの情報から記事を作成するために必要な情報を得るため、私へ質問していってください。 これに対し、Geminiは記事の目的、読者層、背景、技術選択理由、開発プロセス、学びといった、記事構成上の重要要素について具体的な質問を投げかけてきた。これらの質問にチャットで答えていくことで、自然と記事の骨子が固まっていました。 ▲Geminiが質問し、それに対して回答することで論点を明確化しているチャット画面 効果: この 対話プロセスは非常に有効 でした。AIは壁打ち相手のように機能し、多角的な視点からの質問によって、自分だけでは気づかなかった記事のポイントや、読者が知りたいであろう情報を整理できました。思考がクリアになり、記事の構成を考える上での大きな助けとなりました。 フェーズ3:構成案(アウトライン)の作成と合意 (Geminiと) 目的: 本文執筆前に、記事全体の流れ・含めるべきセクション・各セクションの主要内容についてAIと認識を合わせ、手戻りを防ぎます。 実践内容: フェーズ2で整理された情報をもとに、Geminiへ構成案(アウトライン)の作成を依頼し、提案された内容を確認・修正しました。 構成案作成依頼 (Geminiへ、AI側提案を受けて): (Gemini: ...次のステップとして、これらの情報をもとにブログ記事の構成案(アウトライン)を作成し、ご確認いただくのはいかがでしょうか?) お願いします [ Geminiが構成案を提示 ] 構成案への修正指示 (Geminiへ): 良い。 開発の話をする前に、プロジェクトを進める上での課題分析から、効率化を実現するために必要な要求、要件の策定 実際に考えた結果、自分でも作れるのでは?というところからシステム開発の話に入っていく流れで作ってください。 ▲Geminiが提案した構成案と、それに対する修正指示のチャット画面 効果: いきなり記事全体を生成させるのではなく、 先に構成案ですり合わせを行うことで、大きな方向性のズレを防ぎ、後の修正コストやレビューコストを大幅に削減 できました。これは効率的なAIとの協働において重要なステップだと感じました。 フェーズ4:本文執筆と反復的な改善 (GeminiとCopilotで) 目的: 合意した構成案に基づき、AIに記事本文のドラフトを生成させ、人間がレビューと具体的な修正指示を繰り返して記事の品質を高めます。 実践内容: まずGeminiに合意した構成案をもとに本文執筆を依頼します。生成されたドラフトをレビューし、大きな修正や情報追加はGeminiへの指示で行いました。その後、エディタ上でGitHub Copilotを使いながら、細かな表現の調整や言い回しの変更などを行いました。 本文執筆依頼 (Geminiへ): 良い。内容について追加で、「記事詳細をいきなり作成するのではなく、構成案を提示してアウトラインをすり合わせることで、手戻りも防げて、確認コストも少なくなった」というポイントも入れ込んでください。 記事詳細を実際に作成してください。 [ Geminiが本文ドラフトを生成 ] Geminiへの具体的な修正指示例: (hx)の表記は削除してください。 もうちょっと肉付けしたい。(追加情報提供)...を添付して紹介したい。上から、image_1.png...という形式で呼び出すように文章のmarkdownを作成してください。 コードブロックを2重に使うとうまく表示されません。どうしたらいいですか? (AI生成コードの検証に関する追記内容)...という内容も盛り込んでください。 ▲Geminiに対して、文章の肉付けやフォーマット修正など具体的な指示を出しているチャット画面 ▲Geminiに対して、タイトル案の候補を提示させている様子 このように、Geminiで大枠を作り、Copilotで細部を整える、といった使い分けも有効でした。タイトル案出しなどもGeminiに依頼しました。 フェーズ5:レビューとLinter対応 (Gemini/Copilotで) 目的: 生成された記事に対して、人間の目によるレビューと、ツールによる機械的なチェックを行い、最終的な品質を担保します。 実践内容: スタンバイのテックブログでは、記事の品質を保つために、GitHub Actions上で reviewdog を利用し、 textlint による日本語のスタイルや表現に関するチェックを自動で行っています。 今回、AIが生成したドラフトに対してもこのチェックを実行したところ、いくつかの指摘事項が検出されました。これらの指摘に対し、GeminiやCopilotに修正案を考えてもらいました。 Linter指摘修正依頼プロンプト (Gemini/Copilotへ): review dogというパッケージでarticle.mdの文章のlintチェックをしました。 どのように変更するべきか提案してください。 実際の文章と比較したいので、該当箇所の文章の変更内容をdiff形式で表示して提案してください。 [lintチェックの結果を貼り付け] ▲reviewdog/textlintの指摘をCopilotで修正している様子 効果: AIは Linter の指摘内容を理解し、具体的な修正案を diff 形式などで提示してくれました。これにより、修正作業が大幅に効率化され、機械的なチェックへの対応コストも削減できました。AIはコーディングだけでなく、文章校正やレビュー支援においても有効なパートナーとなり得ることを実感しました。 AIとの記事執筆から得られた学びと実践ポイント 今回の経験を通して、AIとの共同作業について多くの学びがありました。 執筆効率と品質向上への効果 時間創出: AIが構成案作成やドラフト執筆といった時間を要する作業を肩代わりしてくれるため、人間はより 内容の推敲やレビュー、最終的な品質向上に時間を集中 させることができた。結果的に、執筆全体のリードタイム短縮と品質向上の両立に繋がったと感じる。 得意分野の活用: ブログのトーン&マナー維持や、キャッチーなタイトル案のブレインストーミング、さらには Linter 指摘への対応など、AIが得意とする部分をうまく活用できた。 効果的なAIとの協働Tips ① 目的とコンテキストを明確に: 最初に「何について書きたいのか」「誰に読んでほしいのか」「どのような情報があるのか」を具体的に伝えることが、後のプロセスをスムーズに進める鍵。 ② 対話による思考整理: AIからの質問は、自身の考えを整理し、記事の論点を深める絶好の機会である。積極的に対話を活用(特にGeminiのようなチャットAI)。 ③ アウトラインでの事前合意: 本文執筆前に構成案で認識を合わせることで、大幅な手戻りを防ぎ、レビューコストを削減。 ④ 反復的な改善が前提: AIの生成物はあくまで「ドラフト」と捉え、人間がレビューし、具体的な指示を与えて改善していくプロセスが不可欠である。一発での完成は期待せず、対話を繰り返す。 ⑤ 具体的な指示を心がける: 「もっと良くして」ではなく、「この部分の表現を、〇〇の視点を加えて書き直して」「このLinterエラーを修正する提案をdiff形式で出して」のように、具体的な修正指示を出すことが精度向上に繋がる。 ⑥ AIの限界も理解する: 最新情報やWeb上の情報の直接的な解釈、複雑すぎる指示の理解などは苦手な場合がある。ファイル提供や段階的な指示で補う。 ⑦ ツール(Gemini/Copilot)の使い分け: チャットAI(Gemini)は構成案作成や長文ドラフト、質問応答など、大きな流れを作るのに向いている。エディタ連携AI(Copilot)は、コードや文章の細かな修正、補完などに便利である。目的に応じて使い分けるとより効率的。 他の記事執筆にも応用できそうなポイント 上記のTips、特に「コンテキスト提示→対話→構成→執筆→改善→レビュー支援」というプロセスは、技術記事に限らず汎用性が高い。企画書、報告書、メールなど、様々な種類の文章作成に応用できると感じました。 再利用可能なプロンプトテンプレート案 今回の経験から、AIとの記事執筆プロセスで使えそうなプロンプトのテンプレートを考えてみました。(主にチャットAI向け) テンプレート案:AIによる論点整理・質問要求 # 目的 [記事の主題、達成したいこと] に関するテックブログ記事を作成したい。 # 想定読者 [読者のペルソナ、技術レベルなど] # 提供済み情報 以下の資料・背景情報をインプット済みです。 * [資料/情報の概要1] * [資料/情報の概要2] * [その他、特筆すべきコンテキスト] # トーン&マナー [既存記事ファイル名など、参考資料を提示] を参考に、[例:技術的、課題解決志向、具体的]なトーンで記述してください。 # タスク 上記の情報に基づき、質の高い記事を作成するために、私に追加で確認すべき点や深掘りすべき論点を洗い出し、具体的な質問を複数してください。記事の構成案を考える前段階として、論点の抜け漏れを防ぎ、内容を深めることを目的とします。 これらのテンプレートを出発点として、具体的な状況に合わせてカスタマイズして活用できます。 今回の取り組みのまとめ 今回のAI(GeminiとCopilot)との共同執筆プロセスは、以下のステップで進みました。 コンテキスト投入 (Gemini): 記事のテーマ、背景、関連資料、トーン&マナーのインプット。 対話 (Gemini): AIからの質問に答えることで、論点の整理と深掘り。 構成案作成 (Gemini): AIが提案したアウトラインを人間がレビュー・修正し、合意。 本文執筆 (Gemini): 合意した構成案に基づきAIがドラフトを作成。 反復修正 (Gemini/Copilot): 人間がレビューし、具体的な指示を与えながらAIと共に記事を改善。チャットでの指示とエディタ上での修正を併用。 Linter対応 (Gemini/Copilot): 自動チェックツールの指摘箇所修正もAIに支援を依頼。 このプロセスを経て、無事に『AI Co-Pilotと作る!1200件のSQL書き換えを乗り越えた社内ツール開発秘話』の記事を完成させることができました。AIを活用することで、記事執筆のハードルが下がり、より多くの有益な情報をタイムリーに発信できる可能性を感じています。 おわりに AIアシスタントとの協働によるコンテンツ作成は、今後ますます重要になっていくと考えられる。AIは単なるツールではなく、壁打ち相手であり、思考を整理・加速させてくれるパートナーにもなり得ます。 もちろん、最終的な内容の正確性や品質に対する責任は人間が負うべきですが、AIとの上手な付き合い方を身につけることで、コンテンツ制作の効率と質を飛躍的に向上させられる可能性を秘めています。 皆さんは、AIをどのように活用されていますか? もし面白い活用法があれば、ぜひ教えてください。 最後に、スタンバイでは、常に新しいアイデアや技術を調査し、試しています。この記事で紹介したようなAI活用や、開発プロセス改善に興味がある方、私たちと一緒にサービスをより良くしていくことに挑戦したい方は、ぜひ 採用ページ をご覧ください!お待ちしています! スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
はじめに こんにちは、スタンバイでプロダクト企画をしている荒巻です。 スタンバイでは、日々サービスを改善するために様々な技術的挑戦をしています。今回はその中でも、求人データ保管・配信システムの刷新プロジェクトに伴って発生した大きな課題に対し、課題分析から要件定義、そして生成AIの力を借りて「自分で作ってみよう!」と思い立ち、社内ツール開発に至った経緯とそのプロセスをご紹介します。 開発したのは「SQL Converter」という、既存のSQLクエリを新しいデータ構造に合わせて自動変換するツールです。最大で約1200件ものクエリ書き換え作業を効率化するために、AI Co-Pilot(GitHub Copilot / Google Gemini)とAWS Bedrock (Claude) を活用しました。 この記事は、以下のような方に特におすすめです。 AI/LLMを活用した開発や業務効率化に関心のあるエンジニア・非エンジニアの方 SQLやデータ移行、データ基盤の刷新に関心のある方 課題発見から解決策の具体化、ツール開発までのプロセスに興味がある方 大規模なシステム改修における課題解決の事例を知りたい方 この記事を通して、身近な課題を分析し、生成AIなどの新しい技術を活用することで、専門家でなくても解決策を形にできる可能性を感じていただければ幸いです。 プロジェクトの背景と課題分析 スタンバイでは、求人データを保管・配信する既存システム(通称: job-store)のパフォーマンスとコストに課題があり、システム構成を刷新するプロジェクトが進行しています。その過程で、新しいデータ構造を持つシステム(通称: jphub)を導入するため、データ基盤へ反映されるデータ構造も新しくなります。 これは大きな前進でしたが、同時に新たな課題が発生しました。スタンバイでは、営業、企画、エンジニアなど、様々な職種のメンバーがデータ分析やモニタリングのために、Redash等でSQLクエリを利用していました。データ構造が変わるということは、これらの既存クエリ(その数、 約1200件! )を新しい構造に合わせて書き換える必要がある、ということです。 実際に調査・分析を進めると、この書き換え作業にはいくつかの大きな困難(ペインポイント)がありました。 マッピングの複雑性: 旧データ構造(RDBライク)から新データ構造(NoSQLライク)への変更であり、単純なテーブル名・カラム名の置換だけでは対応できません。データ構造自体の変更を理解し、適切にSQLを書き換える必要がありました。これは、特にSQLに習熟していないメンバーにとっては高いハードル。 膨大な作業量: 対象クエリが約1200件と非常に多く、すべてを手作業で書き換えるには膨大な工数が必要。 限られた時間: プロジェクトのスケジュール上、書き換え作業に割ける期間は半年もなく、人的リソースの確保も難しい状況。 このまま手作業で進めるのは非現実的であり、何らかの効率化策が急務でした。 効率化への道筋:要件定義 課題分析の結果、「人手で行う作業コストを低減する仕組み」の導入が不可欠であるという結論に至りました。ただし、完全に自動化するのではなく、最終的な結果の確認は必ず人手で行う方針としました。 そこで、SQL書き換え作業を支援するツールの開発を検討し始めました。そのツールが満たすべき主要な要件を以下のように定義しました。 マッピング情報の利用: 新旧データ構造のマッピング情報を読み込み、それに基づいて変換できること。 SQL変換機能: ユーザーが入力した旧SQLを、マッピング情報に沿って新SQLに変換し、結果を提示できること。 エラー対応: 変換後のSQLを実行してエラーが出た場合に、そのエラー情報を考慮して再度変換を試みられること。 Redash連携: Redashから既存クエリの情報を取得できること。 シンプルさ: 様々な職種のメンバーが利用するため、直感的に使えるシンプルなインターフェースであること。 (非機能要件) セキュリティ(VPN接続必須)、コスト効率(低利用時は停止)なども考慮。 「これ、AIで作れるかも?」 - 開発への決意 これらの要件を定義していく中で、特に「マッピング情報に基づいてSQLを変換する」というコア機能は、近年の生成AI技術、特に大規模言語モデル(LLM)が得意とする領域ではないか?と考え始めました。 複雑なルール(マッピング情報)と入力(旧SQL)を理解し、それに基づいて新しい出力(新SQL)を生成するタスクは、まさにLLMの能力が活かせる場面です。さらに、GitHub CopilotのようなAI Co-Pilotを使えば、Webアプリケーションの骨組みや定型的なコードも効率的に生成できるため、「プログラミング経験がなくても、AIの力を借りれば自分でこのツールを作れるのではないか?」という思いが強くなりました。 こうして、「AIを活用したSQL変換ツール」の具体的な開発がスタートしました。 解決策:AIによるSQL変換ツール「SQL Converter」 この課題と要件、そして「AIで作れるかも」という発想から生まれたのが、社内ツール「SQL Converter」です。旧データ構造に基づいたSQLを入力すると、新データ構造に対応したSQL候補を自動で生成してくれるWebアプリケーションとして実現しました。 ▲SQL Converterのメイン画面 ▲実際にSQLを変換している様子(ダミーデータによる動作例) 主な機能は以下の通りです。 SQL入力: 変換したいSQLを直接入力、またはRedashに保存されているクエリのIDを指定して読み込むことができる AIによる変換: 入力されたSQLと、事前に定義された新旧マッピング情報をもとに、AI(AWS Bedrock - Claude)が新しいSQLを生成。変換時にエラーが発生した場合は、そのエラー情報を追加して再度変換を試みることも可能 差分表示: 変換前後のSQLの差分(Diff)を並べて表示し、変更点を視覚的に確認できる ツールの技術構成 ツールの技術スタックは比較的シンプルに構成しました。 バックエンド: FastAPI (Python)。社内での導入事例もあり学習コストが低いと考え選定しました。 フロントエンド: HTML, CSS (Tailwind CSS), JavaScript。Tailwind CSSはメジャーなフレームワークであるため採用しました。 AIモデル: AWS Bedrock (Claude)。社内での利用実績があったため採用しました。 設定・変換ロジック: mapping_info.yml : 新旧のテーブル・カラム対応関係を定義したマッピングファイル。 prompt.txt : Bedrockに渡す指示(プロンプト)のテンプレート。 特別なフレームワークやライブラリへの依存を極力減らし、基本的なWeb技術とAIサービスを組み合わせることで、迅速な開発とメンテナンス性の確保を目指しました。 AI Co-Pilot と共に歩んだ開発プロセス 今回のツール開発では、GitHub CopilotやGoogle GeminiといったAI Co-Pilotを全面的に活用しました。これにより、プログラミング経験がほとんどない自分でも効率的に開発を進めることができました。 開発の進め方:「輪郭から詳細へ」 開発は、まるで絵を描くように、まず大まかな輪郭(UI)から描き始め、徐々に詳細(機能)を加えていくアプローチを取りました。 フロントエンド作成: まずHTMLとCSS(Tailwind)で基本的な画面構成を作成。 モックAPI作成: フロントエンドからのリクエストを受け付け、ダミーデータを返す簡単なFastAPIサーバーを作成。 フロントエンド動作確認: モックAPIを使って、ボタンクリックや表示切り替えなど、フロントエンド(JavaScript)の動作を実装・確認。 バックエンド機能実装: 実際のバックエンドに、Redash連携、Bedrock連携などの機能を 1つ ずつ追加。ここでもモックAPIを参照しながら、期待通りの動作をするか確認しつつ進めました。 本番API連携: 最後に、バックエンドと実際のAWS Bedrock、Redash APIを連携させ、全体の動作を確認。 この段階的なアプローチにより、手戻りを最小限に抑えつつ、着実に開発を進めることができました。 モックサーバーの重要性 特に重要だったのが、ステップ2で作成した モックサーバーの存在 です。AI Co-Pilotは非常に強力ですが、常に完璧なコードを生成してくれるわけではありません。実際にAIが生成したコードには、越えなければならない「3つのチェックのハードル」があると感じています。 見た感じ良さそうか?: ブラウザ画面で見て意図通りの変更になっているか。 ちゃんと動きそうか?: 実際に動かしてみて、エラーなく実行できるか。 意図通り正しく動いているか?: 実行できても、期待した通りの結果や内部状態になっているか。 AIは最初のハードルは超えてくることが多いですが、2番目、特に3番目のハードルを一発で超えることは稀です。そのため、何度も「生成→試行→修正」というループを回す必要がありました。 ここでモックサーバーが真価を発揮します。AIが生成したコードを「さっと見られる環境」「すぐに試せる環境」を提供してくれます。 迅速な動作確認: フロントエンドやバックエンドのロジックが期待通りに動きそうか(ハードル2)を、実際のAPI連携や外部サービスの接続状態を気にせず、手元ですぐに確認できる。これにより、フィードバックループが非常に高速になる。 関心事の分離: 例えばフロントエンドのテスト中はUIの挙動だけに集中でき、バックエンドAPI側の問題を切り分けて考えることができる。これにより、問題の特定と修正が容易になる。 AI Co-Pilotとのペアプログラミングにおいて、この「すぐに試して、すぐにフィードバックを得られる環境」は、開発効率と試行錯誤の質を大幅に向上させる鍵となりました。 プロンプトエンジニアリングの試行錯誤 AIにSQL変換を正確に行わせるためには、Bedrockに渡す指示、つまりプロンプトの質が非常に重要です。ここでも試行錯誤を繰り返しました。 最初は、「このSQLをマッピング情報に基づいて変換して」といった非常にシンプルな指示から始めました。しかし、これだけでは以下のような問題が発生しました。 元のSQLに含まれる改行やインデント、コメントは無視されてしまう。 変換の精度は十分でない場合がある。 ツールが出力する説明メッセージのフォーマットは扱いにくい。 そこで、実際にツールを動かして問題点を洗い出し、AI Co-Pilotにも相談しながらプロンプトを改善していきました。「フォーマットやコメントはそのまま保持してほしい」「説明文の改行はHTMLの <br> タグにしてほしい」「結果はJSON形式で、'sql'と'message'というキーで返してほしい」といった具体的な指示を追加していったのです。 試行錯誤の結果、最終的に以下のようなプロンプト ( prompt.txt ) に落ち着きました。 以下のSQLを、提供されるマッピング情報に基づいて新しいデータベース(テーブル)に対応するように変換してください。 **重要な指示:** * 入力SQL内の **改行、インデント、空白** を可能な限り **忠実に保持・再現** してください。自動的なコード整形は行わないでください。 * 入力SQLに含まれるコメント (`--` から始まる行や行末コメント) も **そのままの位置で保持** してください。 * マッピング情報に従って、テーブル名やカラム名を適切に置換してください。 * message属性の値である説明文中の改行には、改行文字(\n)ではなく、必ずHTMLの<br>タグを使用してください。 **入力SQL:** ```sql {input_sql} ``` マッピング情報はこちらです。 ```yaml {mapping_info} ``` 結果のアウトプットは以下のjson形式に沿って出力してください。回答はjsonのみ出力してください。 {{ "sql": "ここに変換したSQLを出力", "message": "ここに変換についての説明を出力" }} このように、具体的な指示を細かく与えることで、AIの出力精度と使い勝手を大きく向上させることができました。 AIアシスタントの実践的な使い方 開発全体を通して、AI Co-Pilotは様々な場面で役立ちました。 定型コードの生成: FastAPIのエンドポイントの雛形、CSVからYAMLへの変換スクリプトなど、定型的な処理はAIへ任せることで時間を節約できた。 コードの理解促進: AIが生成したコードへ付与されるコメントが、処理内容の理解を助けた。 エラー解決のヒント: 行き詰まった際、エラーメッセージを入力すると、解決策の候補を示した。 ドキュメント作成の効率化: 実は、このツールのREADMEへ記載したシステム構成図やシーケンス図(Mermaid記法)は、ソースコードを元としてAI Co-Pilotが生成したものである。コーディングだけでなく、ドキュメント作成作業も効率化できた点は大きな発見であった。 ▲AI Co-Pilotが生成したシステム構成図 (Mermaid) ▲AI Co-Pilotが生成したシーケンス図 (Mermaid) AI Co-Pilotを使う上で学んだこともあります。それは 一度に多くのことを依頼しない ことです。例えば「この機能全体を作って」のような大きな依頼は避けるべきです。「このボタンがクリックされたらこのAPIを呼び出す処理を追加して」といった具体的で小さな単位で依頼する方が効果的です。その方が意図に近いコードを得やすく修正も容易になります。一度に多くの要素を生成させると問題が生じがちです。各要素は微妙に正しくても組み合わせると意図しない動作になることがありました。 ツール導入の結果と学び 開発された「SQL Converter」は、jphubへのデータ構造変更に伴うSQL書き換え作業において、大きな助けとなりそうです。完全に自動で完璧なSQLが出力されるわけではありませんが、AIが生成したSQL候補をベースに人間が確認・修正することで、ゼロから書き換える場合に比べて 大幅な工数削減 が期待できます。 主な学び このプロジェクトを通して、私は多くの学びを得ました。 AIによる開発の民主化: 生成AIとAI Co-Pilotを活用することで、必ずしもプログラミングの専門家でなくても、アイデアを形にし、実用的なツールを開発できる可能性を実感した。 反復的アプローチとモックの重要性: AI支援開発においては、細かく試行錯誤を繰り返すことが重要であり、そのサイクルを高速化するためにモックサーバーのような「すぐに試せる環境」が非常に有効であった。AIが生成するコードの「3つのハードル」を効率的に越えるためにも不可欠である。 プロンプトエンジニアリングの価値: AIの能力を最大限に引き出すためには、的確な指示(プロンプト)を与える技術が重要になる。試行錯誤を通じてプロンプトを改善していくプロセスは、AI開発の核心の1つと言えるだろう。 AIとの協働のコツ: AIは万能ではない。得意なこと(定型コード生成、アイデア出し、ドキュメント補助)を任せ、人間は最終的な判断や、AIが苦手な複雑な要求の分解・指示出しを行う、といった役割分担が効果的である。小さな単位で依頼し、結果を確認しながら進めることが成功の鍵である。 まとめ 今回はデータ構造変更に伴うSQL書き換え課題に対し、課題分析から要件定義、そしてAIによる「SQL Converter」開発プロセスとその結果をご紹介しました。 この取り組みは、AIが単なる作業の自動化だけでなく、開発プロセスそのものを変革し、これまで技術的な壁によってアイデアを実現できなかった人々にも開発の門戸を開く可能性を示唆しています。コーディングからドキュメント作成まで、AIは開発の様々なフェーズで強力なパートナーとなり得ます。 もちろん、AIが生成したものをそのまま信じるのではなく、人間が適切にレビューをして、最終的な品質に対して責任を持つことは依然として重要です。しかし、AIと上手に付き合う方法を模索すれば、私たちはより速く、そして創造的な形で課題解決へ対応できるようになるでしょう。 最後に スタンバイでは、常に新しいアイデアや技術を調査し、試しています。この記事で紹介したようなAI活用や、開発プロセス改善に興味がある方、私たちと一緒にサービスをより良くしていくことに挑戦したい方は、ぜひ 採用ページ をご覧ください!お待ちしています! スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
クオリティ部ユーザーサポートグループの岸本です。 スタンバイでは、開発方針にユーザーファーストを掲げ、ユーザー視点に立ち、ユーザーのための機能開発の実現を目指しており、FY23よりVOCに触れる機会の増加を目的とした取り組みを開始しています。まだ道半ばではありますが、ここまでの経緯と得られた効果について、紹介します。 (開発方針の記事紹介) techblog.stanby.co.jp 1. はじめに VOCとは 「Voice of Customer=顧客の声」の略で、「企業の製品やサービスを利用する顧客の正直な意見・感想」を指す言葉です。スタンバイには、仕事を検索されている方々から、問い合わせフォームなどを通じてサービスに対する様々なご意見やご要望が集まっています。 VOCの重要性 私たちが提供する「スタンバイ」を利用するユーザーの中にはアルバイトの仕事を探されている方やシニアの方も多く、必ずしも社内のメンバーと一致するわけではありません。実際のユーザーは私たちとは異なる視点や価値観を持ち、異なる環境でサービスを利用しています。そのため、社内の感覚だけで判断してしまうと、ユーザーの本当のニーズとかけ離れた機能や改善を進めてしまうリスクがあります。 こうしたズレを防ぐため、VOCを定期的に収集し、誰もが日常的にユーザーの声に触れられる環境を整えることを目指しました。ユーザーの生の声をもとに課題を特定し、解決策を考えることこそ、スタンバイの提供価値の向上につながると考えています。 VOCの取り組みを始めた背景 スタンバイでは、FY23以前からユーザーからの問い合わせ対応を行い、プロダクト開発組織に対して定期的にレポーティングを実施していました。しかし、以下のような課題があり、VOCの活用には至っていませんでした。 「前月の問い合わせ内容」といった過去の「点」の情報しか提供されていなかった 問い合わせしたユーザーの属性が不明であり、サンプル数も少なかった この課題を解決するため、ユーザーの声を最も直接受け取るカスタマーサポート(CS)部門をプロダクト部門内のクオリティ部に統合し、VOCをより深くプロダクト改善に活かせる組織体制へと進化させました。 2. モニタリングと分析運用の開始 ① モニタリングと分析運用の開始 VOCのモニタリング基盤を整えるため、以下の3つのチャネルを整理しました。 問い合わせ窓口:ユーザーからの相談や質問を受け付ける 求人情報への問題報告:問題のある求人を報告し、対応する ユーザー満足度アンケート:検索結果に対する評価を収集する 当初、プロダクト部門に共有されていたのは「問い合わせ窓口」からのVOCのみで、それ以外のデータは他部門が必要に応じて確認する運用でした。しかし、これらのVOCはすべて連動しているため、全体を包括的にモニタリングできるよう、蓄積データを分類し、月ごとのVOC傾向を可視化するための分析基盤を構築しました。 ②FAQのアップデート 実際に声として集まってくる情報だけでなく、FAQ(ヘルプページ)のアクセス数もVOCの一環としてモニタリングを開始しました。当初立ち上げ段階であったFAQのコンテンツを充実させながら、VOCチャネルとしての運用も兼ねられるよう、アクセス分析基盤も整備しました。 FAQのどの記事にユーザーからのアクセスが集まっているかという情報を基にどんな問題を抱えるユーザーが増減しているのかをモニタリングし、他のチャネルの傾向と連動している問題の発見に活用しています。 ③ユーザー満足度アンケートの分析 「問い合わせ窓口」と「求人情報への問題報告」は、その後のオペレーションまで運用されている一方で、「ユーザー満足度アンケート」は必要なタイミングで改善すべき問題を抽出するための情報源として活用していました。 毎月5,000件程度の投稿があり、アンケートの回答/コメントが検索時のログとセットで蓄積されていたため、まずは目の前にあるデータの分析から着手し、アンケートへの回答から読み取れる問題についてのフィードバックを開始しました。  ↓ 3. 収集方法のリニューアル ①ユーザーが意図通りの回答を選択できるフォームにリニューアル ユーザー満足度アンケートのフィードバックを継続する中で、ユーザーの選択された項目とコメントに書かれている内容の一致率が50%を下回っていることに気づきました。そのため、集計の際に1点ずつ回答とコメントを目視で確認して、集計する作業が生じていましたし、そもそも、このままのデータを基にモニタリングを行うと、誤った解釈でVOCが共有される懸念がありました。 そこで、過去のアンケート結果により、アンケートへの回答の傾向は把握できていたため、 ボリュームの多い回答を細分化して、より具体的な回答を選択できるアンケートフォームへとリニューアルを行いました。 ②集計ログの追加 ユーザーがどのようなクエリで検索して、どのような検索結果を見て、回答しているのかまで追跡したい場合に備えて、集計ログの追加実装を行いました。 例えば、 ・検索クエリに対して、表示された求人件数 ・流入時から回答するまでの検索回数 ・検索時に表示されていた求人内容 等、どのような状況から回答しているのかを深掘りするためのデータを増やしました。 直近では、ABテスト時のバケットごとにアンケートの回答傾向をモニタリングして、社内でフィードバックしています。 4. 社内へのVOC共有の強化 VOCを全社的に浸透させるため、プロダクト部門全員が参加する全体会議でVOCを共有する場を設けました。 目的 全員が毎月必ずVOCに触れる機会を確保し、ユーザー視点を維持する 経営層にも共有し、意思決定にユーザーの声を反映する 重要な課題は繰り返し伝え、認識を深める また、Slackに「VOCチャンネル」を設立し、リアルタイムで最新のVOCを共有する運用を開始。現在ではほぼ全員がチャンネルに参加し、投稿に対してリアクションが集まっています。不満の声だけでなく、好意的な評価をいただいたVOCも発信することで、メンバーのモチベーション向上にも一定寄与できています。 こうした取り組みによって、プロダクトに関わる全メンバーが、日常的にユーザーの声を意識する環境を構築できました。実際に、社内からは以下のような声が寄せられています。 「スタンバイのユーザーが何に困っているのかを想像しやすくなりました。」 「思考のネタになり、視野が広がるので助かります!」 「自分の業務がユーザーにどう結びついているのかイメージしやすくなった!」 5. 今後の展望 VOCを活用したデータドリブンな意思決定へ VOCのモニタリングを継続的に行い、ユーザーの声から読み取れる主要な課題を一通り把握することはできました。また、インシデント発生時や新機能リリース後のネガティブな反応を即座に検知し、プロダクト部門へフィードバックする体制も整いました。 しかし、今後はVOCを単なるフィードバックとしてではなく、定量的に評価し、ROI(投資対効果)を示せる形で活用することが求められます。 クエリ分類ごとの課題を明確化 スタンバイのユーザー層は多岐にわたり、それぞれ異なる課題を持っていることが挙げられます。そのため、VOC全体の傾向だけでは、要因の特定が難しい場合が多いです。より具体的な要因を探るために、ユーザー属性ごとの違いを把握し、どの課題が最も影響力が大きいのかを明確にする必要があります。 VOCとユーザー指標を結びつけた分析 VOCとユーザー指標の関連性を調査し、VOCに現れた課題が実際の利用データやKPIにどのような影響を与えているのかを分析することが不可欠です。これにより、VOCが示す問題が、ビジネスにおいてどれほどのインパクトを持つのかを具体的に数値で示せるようになり、課題の大きさと重要度を測れます。 また、これらをスピーディに分析するために、グループ内にデータ分析を行えるメンバーをアサインしました。分析作業を他部署に都度依頼していると、スピード感が失われます。議論を進めながら仮説立てから分析をスピーディに運用できる体制をとっています。 6. おわりに これまでの取り組みを通じて、ユーザーの声を可視化し、意思決定に活かす基盤を整えてきました。 今後はさらに一歩進め、あらゆる機能やサービスのリリース後の反応をVOCから素早く読み取れる体制の構築を目指します。これにより、ユーザーのリアルな反応を即座にキャッチし、改善サイクルをより迅速に回すことで、プロダクトの価値向上につなげることが可能になります。 VOCは、重要なユーザーフィードバックであり、プロダクト改善の原動力です。 今、ユーザーが何に困っていて、何を求めているかについて、VOCを通じて継続的に収集・分析し、ユーザーにとって本当に価値のあるサービスを提供することを目指します。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
こんにちは、Searchグループで検索エンジンの開発、運用を担当する小野です。 今回は、検索エンジンVespaの Parent/Child機能 を活用して広告配信を改善した取り組みについて紹介します。 スタンバイの検索連動型広告 スタンバイの検索結果画面には以下の2種類の枠があります。 無料掲載枠: 検索条件との一致度に基づいて求人票を掲載 広告枠: 検索条件との一致度+キャンペーンの入札金額に基づいて求人票を掲載 広告主はキャンペーンを設定することで、広告枠に自社の求人票を表示できます。 キャンペーンには、ターゲットとなる検索条件や入札金額、予算、配信期間などを指定できます。 無料枠と同様に検索エンジンにはVespaを採用しており、求人票とキャンペーン情報をインデックス化し検索処理を行っています。 関連記事 - スタンバイの広告表示におけるロジックについて - 検索エンジンをVespaへ移行しています キャンペーン更新時の課題 広告配信用のVespaにはデータ構造の影響でFeed処理に時間がかかるという課題がありました。 当初は求人票の単一のスキーマで構成されており、キャンペーン情報は求人票に埋め込まれていました。 # 求人票のスキーマ例 { "document_id": "id:default:campaign:1234", "job_title": "hoge", { "campaign_id": "1234", "start" : "2025/01/01", ... } ... } 一方、Feed処理は以下のケースで行われます。 求人票の更新 キャンペーンの設定情報の更新 キャンペーンの予算切れによる更新 冗長なデータ構造によりキャンペーン情報を更新するたびに、関連するすべての求人票を更新する必要がありました。 例えば、1キャンペーンに1万件の求人票が紐づいている場合、100件のキャンペーンを更新すると 100万件の求人票 を更新することになります。 10万件の求人票を更新するのに3分かかるとすると、全体の更新は 30分 かかってしまいます。 VespaのParent/Childを活用 VespaのParent/Child機能を利用すると、親子関係のドキュメントを階層化できます。 子ドキュメントに親ドキュメントのIDを指定することで、検索時に親の情報を参照できます。 また、親ドキュメントは全コンテンツノードに複製されるため、検索パフォーマンスを維持しつつ、効率的なFeed処理が可能になります。 Vespa.ai Parent/Child スタンバイでは、キャンペーンを親ドキュメントとして定義し、求人票のスキーマには親キャンペーンのドキュメントIDを参照する形に変更しました。 # Parent-Childを使ったデータ構造例 ## キャンペーン(親) { "document_id": "id:default:campaign:1234", "campaign_id": "1234" "start" : "2025/01/01" ... } ## 求人票(子) { "document_id": "id:default:job:abcd", "campaign_ref": "id:default:campaign:1234", import campaign_ref.start as campaign_start ... } この変更により、 キャンペーンの更新時はキャンペーンのみ更新 求人票の更新時は求人票のみ更新 といった最小限の更新処理が可能になり、キャンペーンの更新にかかるFeed処理時間を大幅に短縮できました。 先程の100件のキャンペーンの更新する例では、処理時間を 数秒 まで短縮されます。 導入時の検証と課題 検索時のレイテンシ検証 Feed処理の効率化は確認できましたが、検索時のレイテンシに影響がないか検証しました。 直接参照から間接参照になったことで少なからず検索パフォーマンスが悪化する懸念されましたが、負荷試験の結果、レイテンシ悪化はほぼ発生しませんでした。 Vespa公式ブログ では親ドキュメントと子ドキュメントの件数比が小さいほど、パフォーマンスへの影響が大きいと言及されています。 今回は、キャンペーンと求人票の件数比がとても大きく(約1:10,000)、importする項目も少なかったため影響は軽微でした。 圧倒的なFeedの効率化に対して、検索パフォーマンスの悪化がないことは大きなメリットでした。 複数スキーマ移行による課題 キャンペーン用のスキーマを追加したことで以下の問題が発生しました。 検索ヒット件数の変動 スキーマごとのリソース指定エラー リソース使用率の増加 Vespaでは、デフォルトで全スキーマに対して検索が行われるため、複数スキーマになると意図しない挙動が起こり得ます。 例えば、求人票(job)の検索でRankProfileを指定しているとキャンペーン(campaign)のスキーマを追加したことで以下のエラーが発生します。 # エラー例 $ vespa query "select job_title from job where true" ranking=PiyoRankProfile → エラー発生 Source 'job': 4: Invalid query parameter: schema 'campaign' does not contain requested rank profile 'PiyoRankProfile' 検索対象のスキーマを限定するには、 restrict パラメータを利用します。 # 修正版 $ vespa query "select job_title from job where true" ranking=PiyoRankProfile restrict=job → OK 特にエラーがない場合でも、restrict指定がないと無駄なリソースを使用してしまうため適切な指定が必要です。 まとめ VespaのParent/Child機能を導入することで、キャンペーン更新時のFeed処理時間を大幅に短縮できました。 また、データ構造がシンプルになったことで開発効率も向上しました。 今後もVespaの機能を活用し、さらなる改善を進めていきたいです。 検索エンジンの開発に興味を持たれた方は、 採用サイト よりお気軽にお問い合わせください。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
はじめに こんにちは、プロダクト部SearchGの小野です。 スタンバイでは昨年末アドベントカレンダー※を開催し合計25本の記事を投稿しました! 本記事では、アドベントカレンダーを運営した経験をもとにスムーズに進めるための工夫や改善点を紹介します。 これからアドベントカレンダーを運営してみたい方や、社内での技術発信を活性化させたい方の参考になれば幸いです。 📌 スタンバイ Advent Calendar 2024 ※ アドベントカレンダーとは? 毎年12月1日から25日までの期間限定で展開される記事投稿イベントです。 クリスマスまでの日数をカウントダウンするアドベントカレンダーの慣習にもとづいて、様々なテーマのカレンダーを埋めていく形で記事を投稿します。 📖参考: https://qiita.com/advent-calendar/2024 アドベントカレンダーの位置づけ スタンバイでは、普段からテックブログで業務に関わる取り組みや学びについて発信しています。 アドベントカレンダーもその一環ですが、通常のテックブログとは異なり、 業務と直接関係しない個人的な学び などの発信も推奨しています。 これにより、執筆のハードルを下げ、多くの人がアウトプットを通じた学びや楽しさを体験できることを目指しました。 アドベントカレンダー運営の流れ アドベントカレンダーの準備から公開までの流れは、以下のようになります。 📌 全体の流れ 準備(11月上旬): 運営チームの結成 参加者募集: 全体告知+個別勧誘 執筆・レビュー: 進捗管理 公開(12月1日〜25日): 記事を共有 1. 準備(11月上旬) アドベントカレンダーは年に一度のイベントのため、前年の知見が失われやすいです。 そのため、 ナレッジを引き継ぎやすくする仕組み を整えることが重要です。 📌 運営チームの役割 - 昨年からの引き継ぎ - スケジュールの確認 - アドベントカレンダーの作成 - 管理シートの作成 - レビュアーの選定・依頼 ✅Tips1: 運用マニュアルの作成 運営チームがスムーズに動けるよう、以下の情報をまとめた運用マニュアルを作成しました。 参加者の集め方のTips 告知の文例 説明文のテンプレート作成 これにより、担当者の交代があった場合でも円滑に運営を引き継げるようになりました。 2.参加者募集 記事を投稿してくれる参加者を集めます。 全体告知に加えて、運営チームから個別に勧誘することがポイントです。 📌 募集の工夫 執筆のハードルを下げる:量や難易度は問わない 周囲を巻き込む:「みんなで一緒に書こう!」という雰囲気を作る 書くテーマの幅を広げる:技術だけでなく学びや経験談も歓迎 3. 執筆・レビュー 参加者がそれぞれ記事を作成し、公開前にレビューを依頼します。 運営側は執筆やレビューが余裕を持ったスケジュールで進むように進捗管理をします。 ✅ Tips2: 進捗管理の自動化 元々はスプレッドシートを運営が目で見て進捗確認を個別にしていました。 締切管理リマインダーをGoogle App Script(GAS)を使って自動化しました。 📌 Googleスプレッドシートした項目 投稿予定日 担当者 記事の作成状況 レビュー状況 🔔 GASを活用したリマインドの仕組み 記事公開の2週間前に、進捗状況に応じたリマインドを自動でSlackに通知 例:25日が公開日なら11日に「記事の準備状況はどうですか?」とリマインド 運営から「まだですか?」と催促するのは精神的にもきついですが、自動化のおかげで催促は不要になりました。 スタンバイではこの他にも多くの業務をGASで効率化しています。 関連記事: Google Apps Script を TypeScript に移行した話 4. 公開(12月1日〜25日) 記事は12月1日から順番に自動で公開されます。 スタンバイでは、Slackの RSS Readerを利用し、新しい記事が自動投稿されるようにしました。 📌 盛り上げの工夫 自動投稿に加えて、(今回はできませんでしたが)日替わりで運営メンバーや他の執筆者が記事を紹介すると、より活発な交流が生まれそうです。 次回に向けて 2024年のアドベントカレンダーは、多くの人の協力のおかげで無事に完走しました。 しかし、初めての運営ということもあり記事を埋めることに精一杯だった面もあります。 例えば、後半日程でレビュアーの負担が大きかったり、記事公開が始まってからの盛り上がりが足りなかったりと課題も見つかりました。 今後に向けて、 持続的に開催できる仕組み や 更に盛り上げるための仕掛け を整えていきたいと考えています。 📌 今後取り組みたいこと ✅ ピアレビューの導入(レビュアーの負担を軽減) ✅ リレー形式の紹介(執筆者同士で記事を紹介し合う) おわりに アドベントカレンダーの運営を通じて組織としての外部発信を推進する貴重な経験ができました。 年に一度のイベントではありますが、運営の負担を減らしつつ組織として更に盛り上げていきたいです。 まと、本記事がアドベントカレンダーの運営を考えている方の参考になれば幸いです! これからもテックブログなどのスタンバイからの発信をご期待ください🎉 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
プロダクト部の高原です。 今年度の上半期に、私たちプロダクト部門のマネージャー全員で「マネジメントポリシー」なるものを作成して部門内に宣言するという活動をしました。 この活動の、背景、ねらい、プロセス、作成後のこれまで、これから、などについてお話ししたいと思います。 マネジメントポリシーとは Management Policy というと「経営方針」「会社と社員の約束事」「組織の行動指針」などなど、そこそこ振れ幅がある言葉になりますが・・・ 私たちが作った「マネジメントポリシー」の位置づけは、「マネージャーたちがメンバーに対して守りたい約束事」という表現が適切かなと思います。 他社の先行事例を参考にして作成したもので、例えば amazon 社の Leadership Principles などを参考にしました。 プロダクト部門のマネージャーが、自分が管掌しているグループだけでなく部門全体の組織運営に取り組むスタンスを示すものとして、次の7項目を掲げています。 1人1人がスタンバイの未来を語れる組織へ 強みを引き出し可能性を拡げる環境づくり 役割遂行のサポート 誰もが自分らしく意見を出せる仕組みづくり 意思決定の透明性向上 全社を横断するマネジメント意識を持つ 共に事業をつくる仲間を増やす 7つの約束事それぞれに具体的なアクションを示すサブテキストを添えていて、主語は全て「マネージャーは」に揃えています。 以降では、私たちがなぜこのようなマネジメントポリシーを作ったのか?、また、このマネジメントポリシーをどう使おうとしているのか?などをお伝えできればと思います。 前提状況 まず、お話の舞台となるプロダクト部門の組織構造について共有しておきたいと思います。 現在の開発組織体制 当社のプロダクト部門は、扱う技術ドメインまたはビジネスドメインによる線引きによって開発グループを分割しています。 チームトポロジーの考え方に近い、認知負荷を考慮した組織体制を採用しています。 以前の記事 (プロダクト開発体制のこれまでとこれから - Stanby Tech Blog) で書いていた「技術ドメインの構成イメージ」と同じ方針で継続しています 図の機能群よりも開発グループのほうが細分化されています さらに今年度から、これらの開発グループのマネージャー全員が兼務所属する「組織運営グループ」という組織を設けて、グループや部門のマネジメントを協力して行う体制をとっています。 ※メンバー全員が兼務で成り立っている組織のため、部門内で「バーチャル組織」と呼んでいます この組織運営グループのミッションには次のようなものを掲げています。 事業フェーズの変化に対応して成果を最大化できる組織の仕組みづくり プロダクトメンバーの満足度向上 なお、今期(2024年度)の注力ポイントは次のようになっています。 ここでお話しする内容は、プロダクト部門のマネージャー全員の活動の記録であり、それはすなわち組織運営グループの活動の記録ということになります。 マネジメントポリシーを作った経緯 なぜ作ったのか? 上記のように今期から組織運営グループというバーチャル組織で動き出しましたが、当初はグループ間で情報共有を促してもほとんど何も出てきませんでした。また、グループ間を横断する共通課題について話していても、自分ごと化(自分たちごと化?)できている割合が低そうに感じていました。 いろいろな理由を考えました。 自グループの組織とプロダクト開発のマネジメントで手一杯だから 自グループや他グループのことを話すことでメリットがあると思えないから マネージャーまで登ってきた自負があるので他人にとやかく言われたくないから 各マネージャーの育ってきた環境が違うから 特に「各マネージャーの育ってきた環境が違う」ことについては、ちょっと考えたら分かることなのに、これまであまり意識できていなかったことに気付いてハッとしました。 私も含め、マネージャーのほとんどが2020年のジョイント・ベンチャー発足後の入社で、それまでは各々がそれぞれのキャリアを経るなかでリーディングやマネジメントの経験を積み重ねてきています。ゆえに、それぞれのマネージャーが持っている成功/失敗の定義とか理想像の認識とかにけっこう差異があるかもしれない・・ということに思い至りました。 中途入社者がミドルマネジメントを担っているのはスタートアップならどこも似た状況でしょうし、べつにマネージャーだけに特化した話でもないと思いますが、なかでも「マネジメント」文脈で文化や認識の差異を埋めるようなオンボーディング施策を行えているかというと配慮が薄かったことに気付きました。 つまり、事業やプロダクトに直結する「ビジネスドメイン」や「技術ドメイン」のコンテキストを合わせる目的でのオンボーディング施策は意識的に行えていそうですが、それに比べると、組織マネジメント文脈でコンテキストを合わせる活動はあまり意識的に行えていないのでは(少なくとも自分の意識は弱かった)ということへの気づきがありました。 (組織マネジメントがど真ん中の役割を預かっておきながら・・・お恥ずかしいですが) 共有できる目標、共通言語がほしい! こうして「各マネージャーの育ってきた環境が違う」この「違い」に気づいたことで・・ チーム一丸となってパフォーマンスを高めていくためには「チームのあるべき姿」や「チームの目標」を共有することが鍵になる という、これまでスクラムマスターとして何度も伝えてきていたことが自分に跳ね返ってきた感じがしました。 そして、マネージャー間で ToBe や目標といった共通言語づくりを目指すことにしました。 それ以外の3つの理由に該当していたとしても、ToBeや目標といった共通言語を作ることが改善の鍵になると考えました。 自グループの組織とプロダクト開発のマネジメントで手一杯だから 自グループや他グループのことを話すことでメリットがあると思えないから マネージャーまで登ってきた自負があるので他人にとやかく言われたくないから どうやって作ったのか この共通言語づくりを、目標成果物に「マネジメントポリシー」を置いて進めたという経緯です。 その過程を端的に表現すると・・ マネージャ全員でプロダクト組織ビジョン「自立型組織」の因子を洗い出して、それらを実現するために、我々マネージャーに求められる行動や姿勢を言語化した。 ということになります。 もうすこし、辿ってきたプロセスを詳しく説明すると次のようになります。 <構成要素の洗い出しフェーズ> Step1 - ToBeである組織ビジョン「自立型組織」の認識合わせ Step2 - ToBeが実現している状態のイメージを具体化 <共通言語化フェーズ> Step3 - Gapの洗い出し Step4 - Gapを埋めていく登り方を検討 以下、Stepごとに行った作業イメージの紹介を試みます。 ※本文中何度となく登場するプロダクト組織ビジョン「自立型組織」は、以前の記事 (プロダクト開発体制のこれまでとこれから - Stanby Tech Blog) でご紹介しています Step1 - ToBeである組織ビジョン「自立型組織」の認識合わせ 問い 自立型組織を形づくる因子(要素)にはどんなものがあるか? 進め方 週次の定例ミーティングを何週か使って 個人ワーク→全体共有という流れを何度か行き来して進めました。 各マネージャーの個人ワークで「自立型組織」の定義を読み直してもらい、「自立型組織を形づくる因子」を洗い出してもらってから、全員で樹形図のようにマッピングすることで MECE になるよう整理を試みました。 Step2 - ToBeが実現している状態のイメージを具体化 問い 自立型組織が実現したらどういう状態になっているだろうか? 進め方 週次の定例ミーティングを何週か使って 個人ワーク→全体共有という流れで行いました。 Step1で洗い出した因子ごとに『自立型組織が実現した状態』を想像して言語化してもらうことで、より解像度の高い ToBe 状態を共有することを目指しました。 Step3 - Gapの洗い出し 問い 私が自立型組織を実現したい理由は? 自立型組織の実現に向けて、私たちが担うべき役割は? 進め方 いつものオフィスを離れて開催したロングミーティング(1日合宿)の前半で 個人ワークで行いました。 先ず「リストーリー」ワークとして、マネージャー1人1人が自立型組織を目指したい理由や得られるメリットを各自の価値観に沿って言語化してもらったうえで(図の左側)、その状態までの Gap をどう埋めていくとよいか(埋めていきたいか)を言語化するワークを行いました。 資料では明示していませんが、主語を、先ず「私」で考えたあと「私たち」で協力して進んでいくイメージへと広げる意識で進めました。 Step4 - Gapを埋めていく登り方を検討 問い 「自立型組織」の Asis / Tobe の Gap をどう埋めるかの議論と言語化 進め方 いつものオフィスを離れて開催したロングミーティング(1日合宿)の後半 4人ひと組くらいに班分けをしてグループワーク →全員で文言の統合と洗練を、週次の定例ミーティングを 2-3回かけて Step3で言語化した「自立型組織への Gap をどう埋めていくとよいか」を持ち寄って、グループワークでメッセージラインを作成しました。 これらはすなわちマネージャーがとるべき行動だろうということで、マネージャー全員で集約し、文言をブラッシュアップして、最終形を「マネジメントポリシー」として完成させました。 これらのプロセスを一緒に通り抜けてきたことで、目指す理想像の認識が合ってきたり、共通課題の自分ごと化が進んだり、というねらった効果を生み出せたように思います。 マネジメントポリシーをどう使いたいか こうして作った「マネジメントポリシー」をどう使っていきたいと考えていて、実際どう使ってきているかのあたりのお話しをさせていただきたいと思います。 for マネージャー 『マネージャーの約束事』として明示することで・・ マネージャーとしての動きに迷った際に「あるべき姿」に立ち返る指針にできる マネージャー間でマネジメントポリシーという共通言語をベースに議論ができる メンバーから指摘を受けて我がふりを直すことができる for マネージャー以外のメンバー 『マネージャーの約束事』として明示されていることで・・ マネージャーの行動や振る舞いの背景を知れる マネージャーの行動や振る舞いに対して疑問を感じた場合に指摘しやすい ポリシー自体が自分が期待していることや組織方針の理解とズレていると感じた場合は、マネージャーに説明を求めたり変更の提案ができる for これからマネージャーになる人【追加】 社内でこれからマネージャーになる人・マネージャーの仕事に関心がある人にとって マネージャーはどういう行動が求められるかを垣間見れる マネージャーを目指す際の学習や行動の具体的なイメージを持てる ※これは当時なりたてのマネージャーからもらったコメントに気づきを得て、後から追加した項目です。正直当初はそこまで考慮できていませんでしたが、有り難い指摘でした。 ただし・・・これらは全て、私たち作り手側の思いです。 以降では、実際にマネジメントポリシーを狙いどおり使っていくことに向き合っている話をさせていただきます。 マネジメントポリシーを作ってからこれまで 説明会を開催し、フィードバックを得た 直前に挙げたとおり、マネジメントポリシーは、マネージャー以外のメンバーにもしっかり知っておいてほしいものです。そこでいくつかの認知機会を設けました。 月次の「プロダクト全体会」で作成したことと内容とを報告 加えて、任意参加の「マネジメントポリシー説明会」を開催 説明会では、この記事に書いてきたような、背景、作成プロセス、使いかたの期待などを話して、質疑応答の時間を設けました。 結果的に、説明会の時間に突っ込んだ質問はもらえなかったのですが・・・数日後、説明会に参加してくれていたメンバーと 1on1 があったので「あれどう思いました?」と尋ねたところ・・・ こういうのって『作って終わり』になることが多いと思うんで・・・使えているかをどうやってふりかえるかが大事ですよね? と、なかなか鋭いツッコミを受けることができました。 私も、マネジメントポリシーを意識して使えているかとか、更新する必要がないかの定期的なチェックをすることは大切だろうと思ってはいたのですが・・・ 作成して説明会まで走り抜けたところで、正直やや『一段落した』感じで気が緩みそうになっていたので、この言葉で一気に身が引き締まりました。 このこともあって、緊張感を保ったままモニタリングの議論を続けることができたように思います。 モニタリングの議論 マネジメントポリシーに沿って行動した成果を測る観点では、エンゲージメントサーベイのスコアをモニタリングすることがすぐに想起できました。 どの Gap を埋めるために → どのスコアの改善を目論んで → どうアクションするか?という紐付けができれば、アクション前後の推移をみてモニタリングできそうですし、そもそもエンゲージメントサーベイのスコアに課題感がある場合には、そのスコアをターゲットとして改善アクションを検討・実行するというかたちでよさそうです。 逆に、1つのスコアは複合的な要因から影響を受けるはずなので、「こうアクションすれば→このスコアが上がる」という方向で紐付けるのは難しそうです。 また、サーベイスコアに効果が出るには時間がかかるため、なんらかの先行指標をモニタできないか?という議論も始まっています。 ある行動がみられているとか行動が変容したとかを測ることも考えられますが、一意に行動に現れることを追求しすぎると本末転倒するリスクもありそうです。 しばらく検討しているなかで、まず、我々マネージャー自身がマネジメントポリシーの各項目を意識して実践できているか?というセルフチェックする試みを始めています(超先行指標と言えるかもしれないものの超主観的かつ定性的ですが)。 マネジメントポリシーの活用 作ってから約6か月、マネジメントポリシーを元に次のようなことを行ってこれました。 組織運営グループの目標設定 マネジメント・アクションのバックログを共有して定例ミーティングで更新 新任マネージャーのオンボーディングセッションを開始 先に「マネジメントポリシーをどう使いたいか?」で挙げていたことを、少しは体現できているとよいと思います。 これまでのふりかえりと今後に向けて ふりかえり マネージャー全員でマネジメントポリシーを作ったことで、共通言語を得ることができ、その後は次第にマネージャー間で一緒にアクションできてきた気がします。 そして、当初は「育ってきた環境が違う」ことをデメリットに感じていましたが、今や、多様な経験から多様な視点を持ち込んでもらえること(Diversity)にメリットを感じることができ始めています。 定例ミーティングでの各グループの活動や問題点の共有も、だんぜん頻度が増えてきたと思います。 今後の課題 モニタリングについては、始めたばかりのマネージャーの実践セルフチェックを続けつつ、今後は、より客観的なモニタリングも検討していきたいと思います。先に書いた「行動変容との紐付け」モニタリングも特定の領域にはハマるかもしれないので、引き続き議論して仮説検証していければと思っています。 また、組織を俯瞰的にみて(システム思考で)アプローチすべきポイントを探す必要もあるのではと考えています。そういった議論の際も、マネジメントポリシーに照らし合わせながら進められるとよいと考えています。 「武器を手に入れた」と言うと旧来の軍事的世界観の組織論に聞こえるかもしれませんが、今回「マネジメントポリシー」を作れたことは、組織マネジメントに対する目線を合わせるための、なかなか強力なツールを手に入れた感覚があります。 というわけで、長文になりましたが、最後までお読みいただきありがとうございました。 このような組織の話も、またご報告できると嬉しいです。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
スタンバイアドベントカレンダー 2024 の 12/24 の記事になります。 プロダクト部の辻です。 スタンバイでは事業の成長・拡大および中長期的な事業継続のため、機能開発に加えて技術的な改善活動もいくつか実施しております。 この記事では2024年の振り返りも兼ねて、最近のスタンバイの技術的な取り組みをいくつか紹介します。 取り組み全体の概要 昨年度以前から継続しているものも含め、スタンバイ全体では ・コスト最適化 ・システムのリアーキテクチャ ・開発効率を上げるためのツールなどの導入 を進めてきました。 コスト最適化 スタンバイはサービスのインフラに AWS を利用しています。 もともとインフラコストは課題ではあり23年度も対応をしたのですが、昨今の強い円安の影響もありインフラコストの課題が大きくなってきていたため、24年度の前半は特に全体で優先度を上げて改めて対応を進めました。 ただ、コスト削減!という形で闇雲に進めてしまうと、システムの障害に繋がったり、開発活動の効率や品質が下がったり、Savings Plans の不適切な購入により長期間削減できない無駄なコストが発生したりといった問題も起きるリスクがあるため、慎重に調査や検討をして進めました。 特には開発活動の効率や品質を下げる(エンジニアの苦労が過剰に増えたり、制約事項が増える)ような削減を進めてしまうと本末転倒になるので、取り組みの呼び名も "削減" ではなく "最適化" とするなど、良くない方向に行かないように意識しながらみんなで進めました。 スタンバイでは各領域ごとの開発チームがスクラムを組んで開発活動をしています。 そのため、全体の目標と方針を共有した上で、各チーム自身が自立的にコスト目標と最適化施策を立案し実施していきました。 加えて全開発チームの代表が揃う定例会議の場なども適宜活かし、各種相談やチーム間調整などもスムーズに進められました。 具体的な各チームの目標設定と施策実施時期についてはトップダウンではなくボトムアップをベースとした形で進めたため、最終的に目標達成できるのかという懸念は最初に出ましたが、各チームが全体目標も意識しつつチーム間で連携しながら取り組み、全体で目標を超える金額のコスト最適化を達成できました! 主な実施施策の概要は以下になります。 ・求人データとその更新を管理する巨大 DB 周りのコスト削減  ・DB のクラスター再構築による容量削減やリザーブドインスタンスの活用  ・ Aurora MySQL のコストを 54% 削減 の記事の件。最も効果が大きかった施策 ・ECS, EKS で動くアプリのオートスケールの設定やリソースサイズの見直し ・S3 の保存データを精査し、安全な範囲で不要データの削除やライフサイクルの設定(一部は Intelligent-Tiering を利用) ・Compute Savings Plans の追加購入  ・今後の EC2, ECS, Lambda などのリソースの増減予測をモニタリング+各開発チームへのヒアリングで精度高く見積もり、追加購入  ・カバー率が 50% 程度だったものを 80% 程度までアップ ・検索エンジン Vespa で利用する EC2 のインスタンスタイプを AWS Arm ベースの Graviton に変更  ・サービス改善施策の実施に伴い大きなコスト増が見込まれていたが、この対応により増加幅を小さく抑えられた ・EKS の基盤を Fargate から EC2 に変更することにより、 Datadog 含めた EKS に関連するトータルコストの削減  ・昨年に実施していた施策ですが、効果があった施策なので紹介 各施策ごとに丁寧に調査やモニタリングをしながら段階的に進めたため、これらの施策を原因としたインシデントを起こさずに削減できたことも大きな成果でした。 各種システムのリアーキテクチャ スタンバイは2015年にビズリーチの新規事業として始まったサービスです。サービスとシステムの年齢はもう10年近くになることもあり、課題もたくさん出てきています。 検索エンジンについては 検索エンジンをVespaへ移行しています の記事にあるように刷新をしていますが、 検索エンジン以外のシステム群においても、扱うデータ量やトラフィックの増加への対応、各種施策のトライアンドエラーの歴史、 優先度の兼ね合いで改善活動を長期間実施できていないシステムがいくつか存在するなど、いわゆる技術負債と呼ばれるものがいくつかあります。 また、全体的なシステムアーキテクチャと技術選定に関しても、今後の開発や運用保守の効率の観点で見直したほうが良い部分も出てきていました。 これらの課題はすぐに深刻な問題に直結するものではありませんが、対応をしなければ中長期的には開発効率の低下やインシデント発生率の上昇が続き、中長期的にサービス改善の停滞や継続すら危ぶまれる状況を生み出しかねない類のものになります。 そのため、課題の影響度、緊急性、改善効果の見込みなどを考慮して優先順位をつけてロードマップを引き、いくつかのシステムのリアーキテクチャを各開発チームで進めています。 規模が大きく道半ばの案件もありますが確実に進捗しており、一部は本番リリースを迎えられ期待していた開発効率やパフォーマンスの改善を実現できました。 主には以下のような取り込みを実施しています。 開発に用いる言語の変更 スタンバイのシステムの大半は Scala で実装されてきたのですが、これについては昨今いくつかの課題が顕在化してきました。 世界的にも国内においても Scala の人気が他の言語と比較して高くない状況にあり、エンジニア採用が困難になってきていました。 また Akka のライセンス変更をはじめとする依存ライブラリやフレームワークに大きな変化があり、Scala を継続利用していくにも不確実性と諸々の対応工数が大きくなる見込みがありました。 昨年からスタンバイ内でこの課題に関する議論を重ね、 ・言語利用者数の状況 ・OSS の開発体制 ・今後のエンジニア採用 ・静的型付け言語であること(型安全性) ・並行処理の扱いやすさ ・マイクロサービス間の通信の安定性(壊れにくさ)と高パフォーマンスの実現のしやすさ ・学習コスト含めた現状からの移行コスト 等々の観点で複数の言語を比較検討し、バックエンド開発で用いるメインの言語を Go に変更していくことを決定しました。 そして、徐々にバックエンドのアプリケーションを Go での実装に切り替えることを進めています。 社内のサービス間通信についても REST から gRPC に徐々に移行していくことを進めています。 昨年の決定時点ではスタンバイ内の Go の経験者はかなり少なかったため不安要素もある大きな決定でしたが、外部の講師の方を招いた勉強会や社内でのノウハウ共有なども実施しながら、いくつかのサービスの Go への移行が完了しています。 求人取り込み・管理システムの刷新 前述のコスト最適化のパートでも触れた、巨大な DB を用いている求人データの取り込み管理システムについても、アーキテクチャの刷新を進めています。 このシステムは、求人データ本体だけでなく各種取り込み処理の状態管理についても主に1つの DB クラスターで集中管理されています。 取り扱う求人数自体の増加に加え、求人データ取り込みの処理数や内容の複雑さが増してきており、DB がパフォーマンスとコストにおいてネックになっています。 そして今後もスタンバイのサービス改善のため求人データの取り込み処理は追加や変更をし続けていく必要があります。 求人データの取り込み管理はスタンバイのサービスの改善と継続の肝となる重要な部分のため、 「パフォーマンス」、「コスト」、「開発・運用のしやすさ」を改善を目指して、DB を中心としたアーキテクチャからストリームを中心としたアーキテクチャへの刷新を進めています。 データ基盤の刷新 スタンバイのサービス改善のための各種意思決定や検索エンジン改良の材料となる各種ログや求人に関するデータを管理している「データ基盤」に関しても、DWH のリアーキテクチャを進めています。 現状のスタンバイのデータ基盤はデータ統合やモデリング、メタデータの整備などの観点で課題が多く、各種データ分析業務がとても煩雑で品質も高くできていない状況にあります。 DWH のリアーキテクチャによってこれらの課題が一気に解消されるという類の話ではなくデータの管理方法をはじめとした運用の課題も大きいですが、これらの課題解決を進めやすくするための基盤整備のためにリアーキテクチャを実施しています。 ここも時間がかかる取り組みではありますが、正確なデータと分析結果を元にした意思決定をしていくことはサービス改善において非常に重要になることのあので、取り組みを進めています。 開発効率や品質向上のためツール導入や取り組み 開発活動の効率や品質を高めるために、下記をはじめとするツールの導入や取り組みを実施しました。 ・GitHub Copilot の導入  ・AI のサポートも適宜利用し、開発の品質と効率の向上を図るため ・Renovate の導入推進による依存ライブラリの更新の効率化  ・低コストでこまめにアップデートする仕組みを整備することにより、安全性と安定性の向上を図るため ・Codecov の導入推進によるテストカバレッジの可視化  ・日々の開発運用に欠かせない自動テストの改善活動を進めやすくするため GitHub Copilot については AI からの提案内容について吟味する必要があるなど注意は必要ですが、利用しているエンジニアからは開発効率が良くなったという声が多かったです。 まとめ 上記で紹介した取り組みは一部で、他にも多数リファクタリングや各種改善活動を各開発チームで進めています。 どんな事業でも同様ですが、提供するサービスや組織を運営し成長させ続けていくためには、目には見えにくい部分の改善活動が必要になります。 こういった活動とサービス改善に直結する施策開発のバランスをどう取るのかは常に難しいことですが、スタンバイでは四半期ごとに全体での各種案件の優先度調整を実施できていることもありバランス良く取り組めていると感じています。 こういった取り組みで開発活動の地盤を固めつつ、求人検索サービスの磨き込みをしていきたいです。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
こんにちは、スタンバイで求人の取り込みシステムを開発・運用をしている鈴木です。 今回は Scala と Go に標準で組み込まれている正規表現エンジンの違いについてです。 概要 スタイバイでは Scala で書かれたシステムを Go にリプレイスする開発が進んでいます。 その中で Scala で実装されている正規表現が Go だと動かない事象に遭遇しました。その違いはどんなものがあるのか?まとめます。 機能差分の一例 今回発見した機能差分の例を見てみましょう。 スタンバイで扱っている求人情報を管理するために正規表現を使って色々な情報を抽出しています。 例としてこんな感じの求人情報から給与の情報を抽出したいとします。 (簡単化のため金額のカンマを削除しています。) ◎看護師(正・准)/時給1400円~1600円+交通費 月収例 246400円~281600円+交通費※20日勤務、1日8h 日勤帯のみの場合◎ヘルパー(2級以上)・介護福祉士/時給1000円~1200円+交通費 月収例 176000円~211200円+交通費※20日勤務、1日8h 日勤帯のみの場合※深夜勤(22:00~翌5:00)は時給25%アップ※日勤帯のみでも相談に応じます 数字を基準に抽出すれば良いのですが、単純に数字をマッチしてしまうと 1日8h や 22日勤務 なども抽出してしまうので、それらを除外しましょう。 そうするとこんな感じの正規表現で実現できます。 [1-9]+[0-9\.十百千万億\s ]*(?!\d*[分|時|日|月|年|h|級]) ざっくりどんなマッチになるかと言いますと、前半の [1-9]+[0-9\.十百千万億\s ]* の部分は数値と金額の単位をマッチしています。 後半の (?!\d*[分|時|日|月|年|h|級]) は前半のマッチのなかでも 分|時|日|月|年|h|級 の字が続く場合は除外しています。 この ?! で表されている マッチしないこと の動きが Go の正規表現では対応しておらずエラーになってしまいます。 error parsing regexp: invalid or unsupported Perl syntax: `(?!` これは Go の正規表現エンジンが否定先読みの機能をサポートしていないためです。 このように正規表現には正規表現を解釈して実行するエンジンがいくつかあり、サポートしている機能に差があります。 バックトラッキングについて Go で標準ライブラリを使用した正規表現は RE2 エンジンで動きます。 RE2 は他の正規表現エンジンと比較してバックトラッキングを行わない特徴があります。 否定先読みができなかったのもこれが関連しているわけですね。 このバックトラッキングを行わないことで多彩な機能は使えないものの、処理時間が線形時間で動作しメモリの使用量も抑えることができます。 一方、Scala で scala.util.matching.Regex を使った場合は java 標準の正規表現が使われます。 こちらはバックトラッキングをサポートしてるので RE2 と比べて否定先読みのように複雑なマッチングができます。 バックトラッキングの様子は こちら などのサイトで正規表現のデバッグをすると確認しやすいです。 デバッグモードは PCRE 系の正規表現エンジンのみ対応しているようなので PCRE2 で見てみましょう。 画面上部の REGULAR EXPRESSION の入力に正規表現を、その下の TEST STRING にマッチさせたい文字列を入力します。 そして、左メニューの FLAVOR で PCRE2 を選び Regex Debugger からデバッグができます。 Match1 を進めていくと ◎看護師(正・准)/時給1400円 のうち 1400 の部分にマッチして1つ目が完了しています。 Match2 から Match4 までも同様に 1600 , 246400 , 281600 にマッチしています。 Match5 を進めると 20日 の 20 にマッチしていますが、その後に 20 の後に 日 が マッチしないこと を確認している様子がわかります。 さらに進めると今度は先頭の2を除外して 0 まで戻った後、再び 日 がマッチしないことを確認しています。 このようにバックトラッキングを利用すると同じ箇所に何度もマッチするか試行してしまうため、場合によっては指数関数的に処理量が増加し、それに応じて必要なメモリ量も増えてしまいます。 そのため RE2 では処理量が線型増加していくという性質はパフォーマンスに関わってくるのが分かりますね。 その他の機能比較 バックリファレンス バックリファレンスは一度マッチしたグループを再利用できます。 こんな正規表現 (\b\w+at\b).*\1 でこんな文字列 The cat sat on the mat with another cat. をマッチしてみると... \1 の部分が初回マッチした cat となることで末尾の cat にマッチしていることがわかります。 ゼロ幅マッチ 正規表現上でも特別な意味を持つメタ文字があります。 ^ は文字列の先頭 $ は末尾にマッチするなどですね。基本的には Go でもゼロ幅マッチが利用できますが利用できないパターンがいくつかあります。 例えばこんな正規表現 cat(?=\s) でこんな文字列 The cat sat on the mat with another cat. をマッチしてみると... マッチの条件に空白は含んでいますがマッチ結果には含まれていないことがわかります。 条件付きの正規表現 条件をつけて満たす場合のパターンと満たさない場合のパターンの分岐をする機能です。 (?(test) true| false) のように ?(条件) と true/false のパターンといった記述になります。 こちらは java の正規表現でもサポートされていません。 フォワードリファレンス バックリファレンスの逆で後のキャプチャグループを参照できるらしいです。 が、マッチ試行していない部分を参照するという特殊な挙動なので利用できる正規表現エンジンはかなり限られるようです。 こちらも java, go ともにこの機能は使えません。 他の正規表現エンジンを Go で使う方法 このように Go の標準パッケージを利用すると、Scala では利用できていた一部の正規表現の機能が利用できなくなりました。 Go から標準の RE2 以外にも他の正規表現エンジンを利用する方法があるようですが、日本語対応に怪しいところがあるようです。 package main import ( "fmt" "github.com/GRbit/go-pcre" ) func main() { pattern := pcre.MustCompile(`[1-9]+[0-9\.十百千万億\s ]*(?!\d*[時|日|月|年|%|h|級])`, 0) subject := "◎看護師(正・准)/時給1400円~1600円+交通費 月収例 246400円~281600円+交通費※20日勤務、1日8h 日勤帯のみの場合◎ヘルパー(2級以上)・介護福祉士/時給1000円~1200円+交通費 月収例 176000円~211200円+交通費※20日勤務、1日8h 日勤帯のみの場合※深夜勤(22:00~翌5:00)は時給25%アップ※日勤帯のみでも相談に応じます" matcher := pattern.NewMatcher([]byte(subject), 0) for matcher.Matches { fmt.Printf("GroupString: %s\n", matcher.GroupString(0)) indices := matcher.Index() end := indices[1] subject = subject[end:] matcher = pattern.NewMatcher([]byte(subject[end:]), 0) } } 正規表現のパターンに 分 が含まれている場合に、入力文字列に含まれていなくてもマッチ結果が変わりました。また、マッチした文字列が文字数単位ではなくバイト単位で切り取られて文字化けしてしまう事象もありました。 現在取り組んでいるリプレイスの開発は求人情報から特定の情報を抽出するロジック全体も見直しつつ開発する方針で進めているため、正規表現エンジンに関しては外部ライブラリは採用しませんでした。 終わりに このように正規表現のエンジンは複数あり様々な特性を持っています。 今回は Scala(Java) と Go の正規表現の機能についてのみの調査になりましたが、動作速度のベンチマークを基準に比較するのも面白そうです。 必要な要件に合わせて適切な正規表現エンジンが選択できるようになれるといいですね! スタンバイでは、常に新しいアイデアや技術を調査し、試しています。新しいことに挑戦したい方や、素晴らしいプラットフォームで素晴らしい仲間と仕事をしたい方は、ぜひ 採用ページ をご覧ください! スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
はじめに こんにちは、スタンバイのアプリチームでiOS開発を担当している小村祐輝と申します。 私たちスタンバイのiOSチームでは、SwiftUIやCombine、Concurrencyなどのモダンな技術を用いて日々開発を進めています。 その中で、直近で浮上した課題の1つが「UIテスト」です。 この記事では、私たちスタンバイのiOSチームがどのようにしてUIテストを構築し、運用しているのかをご紹介します。 UIテスト導入の背景や、その必要性、具体的な手法やツールについても解説していきます。 UIテストの必要性とログ送信テスト導入の背景 スタンバイは、求職者と企業をつなぐプラットフォームとして展開しており、数多くの求人サイトを一括して検索・比較できる「アグリゲーションサービス」です。 アプリのデザインは求人の検索から応募までを簡単に行えるようになっており、月間アクティブユーザー数も急増しています。 ですが、アプリの規模が大きくなるにつれて、手動テストだけでは限界を感じる場面が増えてきたのもまた事実でした。 特にユーザー行動の分析が重要なスタンバイにおいては、送信されるログが正確かつ適切なタイミングで送られているかの確認が必須です。 そこで、まずはログ送信テストを重点的に行うUIテストの導入を開始しました。 ログ送信テストにUIテストを導入する理由 とはいえ、ログ送信のテストと聞くと中には、 「ユニットテストで担保できるのでは?」 と思われる方もいらっしゃるのではないでしょうか。 確かに、ログの内容だけであればユニットテストでも十分です。 しかし、ユーザー操作を伴うログ送信は、実際のユースケースに基づいたUIテストの方が堅牢性は高まります。 私たちは、この点を重要視し、UIテストを選択しました。 UIテスト構築の課題とViewInspectorの採用 しかし、iOSチームにはUIテスト構築の経験者がいませんでした。 XCUITestは学習コストが高く、UIテストを構築するには大きな壁が立ちはだかったわけです。 そんな中で出会ったのが、ViewInspectorというライブラリです。 ViewInspectorとは? ViewInspectorとは、SwiftUIで構築されたViewに対して、プログラムから直接アクセスし、その状態や動作を確認できるライブラリです。 ViewInspector SwiftUIのViewはその構造上、内部の状態を直接参照したり、検証したりすることが難しくなっています。 XCUITestの学習コストもそうですが、これもXCUITestを導入する際の障壁の1つでした。 ViewInspectorは、この問題を解決するために作られたツールであり、開発者がSwiftUIのViewを簡単にテストできる環境を提供してくれています。 具体的には、Viewの階層構造にアクセスし、 特定のViewやそのプロパティ 表示されるテキスト ボタンのアクション などをプログラム的に検証できます。 これにより、手動でのUIテストに頼ることなく、ログ送信のような重要な機能に対しても、実際のユーザー操作を模倣したテストを効率的に行うことが可能になるわけです。 また、ViewInspectorは直感的なAPIを提供しているため、SwiftUIを使っている開発者であれば、比較的容易に導入できる点も大きなメリットに他なりません。 私たちiOSチームも、UIテストの複雑さや学習コストの高さに悩まされていた中で、このViewInspectorを採用することで、テスト環境の構築をスムーズに進めることができました。 基本的な使い方と例 この次の項目からスタンバイのiOSプロジェクトでどのようにViewInspectorを利用しているのかを解説しますが、それに先立ってまずはViewInspectorの基本的な使い方を説明します。 ここでは3つのテストパターンを用意したので、それぞれ具体的に掘り下げていきます。 1. テキスト表示の検証 まず、 Text が正しく表示されているかを確認する基本的なテストを例に説明していきます。 import SwiftUI import ViewInspector import XCTest // テスト対象のView struct SimpleTextView : View { var body : some View { Text( "Hello, ViewInspector!" ) } } class SimpleTextViewTests : XCTestCase { func testTextIsDisplayedCorrectly () throws { let view = SimpleTextView() // ViewInspectorでTextの内容を取得 let text = try view.inspect().find(text : "Hello, ViewInspector!" ).text().string() // 検証 XCTAssertEqual(text, "Hello, ViewInspector!" ) } } ViewInspectorで特定のViewにアクセスする際、まずは view.inspect() と宣言した上で、Viewを階層的に参照していく必要があります。 view.inspect() これにより、内部的に参照対象の RootView が取得され、そこからさらに指定した要素へアクセスできるようになります。 次に、特定のテキストを持つViewにアクセスするためには、以下のように find(text:) メソッドを使います。 view.inspect().find(text : "Hello, ViewInspector!" ) これで、 "Hello, ViewInspector!" というテキストを持つViewが取得できます。 さらに今回は、このテキストの正確性をテストするため、文字列そのものを取得する必要があります。 そのために、 text() メソッドを使用して次のように記述します。 view.inspect().find(text : "Hello, ViewInspector!" ).text() これで、テキスト要素の文字列データにアクセスできました。 次に、この文字列を string() メソッドで取得し、テストの期待値と比較します。 let text = try view.inspect().find(text : "Hello, ViewInspector!" ).text().string() XCTAssertEqual(text, "Hello, ViewInspector!" ) ここで、 XCTAssertEqual を使って、取得したテキストが期待される内容 "Hello, ViewInspector!" であるかどうかを検証します。 2. ボタンのタップと状態変更の検証 次に、ボタンをタップして、内部状態が更新される動作テストを解説していきます。 import SwiftUI import ViewInspector import XCTest // テスト対象のView struct CounterView : View { @State private var count = 0 var body : some View { VStack { Text( " \( count ) " ) Button( "Increment" ) { count += 1 } } } } class CounterViewTests : XCTestCase { func testButtonTapIncrementsCounter () throws { let view = CounterView() let sut = try view.inspect() // 初期状態を確認 XCTAssertEqual( try sut.find(text : "0" ).text().string(), "0" ) // ボタンをタップ try sut.find(button : "Increment" ).tap() // タップ後、カウンターが1に増えていることを検証 XCTAssertEqual( try sut.find(text : "1" ).text().string(), "1" ) } } まず、このテストでは CounterView というカウンター機能を持ったシンプルなSwiftUIビューの動作を検証しています。 特定のボタンをタップした際に、カウントが正しくインクリメントされているかを確認するテストです。 XCTAssertEqual( try sut.find(text : "0" ).text().string(), "0" ) ここでは、 CounterView が持つ Text の初期状態が 0 という事を確認しています。 view.inspect() でRootViewにアクセスした後、 find(text:) を使って Text("0") を検索しています。 この Text はカウントを表示する部分です。 次に、 text() メソッドを使って Text 要素の中身(文字列データ)を取得し、 string() メソッドでその内容を文字列として取得します。 最終的に、 XCTAssertEqual を使用して、取得した文字列が初期状態で文字列 "0" であるかどうかを確認します。 その上で、ボタンをタップしてカウンターの値をインクリメントする処理を以下で実行しています。 try sut.find(button : "Increment" ).tap() ここでは、 find(button:) メソッドを使用して、 "Increment" というテキストを持つ Button を検索しています。 tap() メソッドを使うことで、ViewInspectorはそのボタンを実際にタップし、@Stateで管理されているカウンターの状態を更新できるわけです。 XCTAssertEqual( try sut.find(text : "1" ).text().string(), "1" ) タップ操作の後、カウントが 1 に増えていることを確認する箇所が上記です。 再度、 find(text:) を使って更新された Text("1") を検索すると共に、その内容を text().string() で取得し、期待通り取得した値が "1" であることを検証しています。 3. ネストされたビューの検証 最後に、ネストされたViewの中で特定のViewにアクセスし、その状態を検証する方法を解説します。 import SwiftUI import ViewInspector import XCTest // テスト対象のView struct ParentView : View { var body : some View { VStack { ChildView() } } } struct ChildView : View { var body : some View { Text( "child view" ) } } class ParentViewTests : XCTestCase { func testNestedViewText () throws { let view = ParentView() let sut = try view.inspect() // ネストされたChildViewのTextを確認 let text = try sut.find(ChildView. self ).find(text : "child view" ).text().string() // 検証 XCTAssertEqual(text, "child view" ) } } このテストケースでは、 ParentView という親ビューの中に ChildView という子ビューがあり、その中で表示されるテキストが正しいかどうかを確認しています。 ParentView は VStack の中に ChildView を含んでおり、ChildView では "child view" という固定のテキストが表示されています。 struct ParentView : View { var body : some View { VStack { ChildView() } } } その上で以下テストコードにもあるように、 testNestedViewText というメソッドでは、最初に ParentView のインスタンスを作成し、それを view.inspect() を使って検証の対象(sut)として設定します。 class ParentViewTests : XCTestCase { func testNestedViewText () throws { let view = ParentView() let sut = try view.inspect() // ネストされたChildViewのTextを確認 let text = try sut.find(ChildView. self ).find(text : "child view" ).text().string() // 検証 XCTAssertEqual(text, "child view" ) } } 次に、 sut を通じて、 ParentView の内部にある ChildView へアクセスします。 ここでは find(ChildView.self) を使用して、親ビュー内にある ChildView を見つけ出しています。 そして、 ChildView にアクセスした後、次に行うのは、その中に表示されているテキストの確認です。 ここは既に説明した通り、 find(text: "child view") を用いて、 ChildView 内のテキストを見つけ出します。 その後、 text() メソッドで Text 要素自体を取得し、さらに string() メソッドを使ってその文字列内容を取得します。 その上で、比較対象の検証するだけです。 XCTAssertEqual(text, "child view" ) 補足:ViewInspectorによる標準Viewへの階層アクセス方法 ここでは find() を利用して ChildView にアクセスしましたが、シーンによってはSwiftUI標準のViewにアクセスしたい場合もあるのではないでしょうか。 そのような独自の型を定義していない場合は、基本的にあらかじめViewInspector側で用意してある以下のようなメソッドを用いてViewの階層を掘っていくことが可能です。 try sut.vStack().hStack().geometryReader().zStack().group() ... これらのメソッドを利用することで、Viewの階層構造を1つずつ掘り下げながら目的のViewに到達できます。 一方で、以下のように find() を用いれば、直接目的のChildViewにアクセス可能です。 let text = try sut.vStack().find(ChildView. self ) find() を利用すると、階層をたどる手間を省けるため、特定のViewにアクセスするケースでは非常に便利です。 スタンバイにおけるViewInspectorの具体的な活用例 ViewInspectorの基本的な使い方は前述の通りで、複雑なケースでない限り、これだけで多くの動作をシミュレートできます。 その上で、冒頭で触れた通り、スタンバイのiOSアプリでは、このViewInspectorを用いてログ送信のテストを実装しているわけです。 ここでは実際のプロダクトにおける利用方法を元に、具体的なViewInspectorの使い方を解説していきます。 ログアナリティクスのモック化 まず、外部サービスに依存することなく、ログ送信をテストするために、ログ送信の代わりにモッククラスを作成します。 これにより、実際のネットワーク通信やサーバーの状態に左右されず、ログ送信が正しく行われるかどうかを検証できます。 class MockAnalyticsService : AnalyticsServiceProtocol { var events : [ MockAnalyticsEvent ] = [] var didFinish : (() -> Void ) ? func logEvent (_ name : String , parameters : [ String : Any ] ?) { let event = MockAnalyticsEvent(name : name , parameters : parameters ) events.append(event) didFinish?() } } ログ送信のテストコード 次に、具体的なテストケースとして、ボタンタップにより送信されるログが正しいかどうかを検証するコードの紹介です。 func test_ サンプル画面でのログ送信テスト() { // モックのViewModelに必要な情報を設定 viewModel.isLoading = false viewModel.items = [.stub(id : "item1" , name : "itemName1" , code : "itemCode1" )] let sut = SampleView(viewModel : self.viewModel ) let exp = analyticsExp(mockAnalyticsService : mockAnalyticsService ) // ボタンをタップしてイベントをトリガー do { try sut.inspect() .find(SampleCell. self ) // SampleViewの中のSampleCellを見つける .find(CellButton. self ) // SampleCellの中のCellButtonを見つける .find(button : "" ) // CellButtonの中のButtonを見つける(テキストなし) .tap() // 見つけたボタンをタップする } catch { XCTFail( "failed with: \( error ) " ) } wait( for : exp , timeout : 2.0 ) // 送信されるべきイベントを定義 let tapEvent = MockAnalyticsEvent( name : "tap_item" , parameters : [ "info1": "sample_screen", ..., ... ] ) // 実際に送信されたイベントと期待されるイベントを比較 verify(actual : mockAnalyticsService.events , expected : [ tapEvent ] ) } このコードでは、 SampleView で特定のアイテムをタップしたときに送信されるログが正しいかどうかを検証しています。 まず、viewModelに必要なアイテム情報を設定し、モックのログアナリティクスサービスを使ってログ送信イベントを捕捉します。 その上で、まずは画面描画に必要な設定値をプロパティにアサインしているのが以下の箇所です。 viewModel.isLoading = false viewModel.items = [.stub(id : "item1" , name : "itemName1" , code : "itemCode1" )] そして、以下の SampleView をご覧になって頂くと、 isLoading の状態に応じてViewを切り替えているので、今回Itemを表示するためにあらかじめ false をセットしています。 import SwiftUI struct SampleView : View { @StateObject var viewModel : SampleViewModel var body : some View { Group { if viewModel.isLoading { LoadingView() } else if viewModel.jobs.isEmpty { EmptyCell() } else { List { ForEach( 0 ..< viewModel.items.count, id : \. self ) { index in let item = viewModel.items[index] JobCell(item : item , didTap : { viewModel.showDetail(item : item ) }) } } } } } } 以下のコードでは設定したViewModelを用いてViewを初期化し、内部的にレンダリングを実行している箇所です。 let sut = SampleView(viewModel : self.viewModel ) 上記のようにViewを初期化することで初めて、ViewInspectorによるViewの参照や検証が可能になります。 初期化により返されるViewを sut とし、それを用いてこれまでに解説してきた方法で目当てのViewまで掘り下げていくのが以下の実装です。 do { try sut.inspect() .find(SampleCell. self ) // SampleViewの中のSampleCellを見つける .find(CellButton. self ) // SampleCellの中のCellButtonを見つける .find(button : "" ) // CellButtonの中のButtonを見つける(テキストなし) .tap() // 見つけたボタンをタップする } catch { XCTFail( "failed with: \( error ) " ) } コメントにもある通り、独自で作成した型を明示的に指定して対象となる Button まで掘り下げていきます。 その上で .tap() を実行することで、内部的にユーザーが特定のItemをタップしたのと同じ動きを実現しているということです。 ここまで来ればあとは簡単で、 .tap() により実行されるログ送信イベントを補足し、あらかじめexpectとして用意したデータと比較することで簡単にテストできます。 let tapEvent = MockAnalyticsEvent( name : "tap_item" , parameters : [ "info1": "sample_screen", ... ... ] ) // 実際に送信されたイベントと期待されるイベントを比較 verify(actual : mockAnalyticsService.events , expected : [ tapEvent ] ) ちなみに、以下のようなタイムアウト処理を入れているのは、非同期に実行されるログ送信処理が完了するのを待つためです。 wait( for : exp , timeout : 2.0 ) そのため遅延時間を指定しているのですが、ここに関してはあまり参考にしてほしくはなく、テスト実行環境次第では普通に落ちてしまうこともあります... ここは解消したい課題ではありますが、ほとんどの環境ではあまり気にすることもないので、現在のプロダクトコードでは上記のような形でも特段問題はありません。 ここまでで解説した手順で当初目的としていた、 イベント内容の正確性チェック 適切なタイミングでログが送信されているかのチェック を満たすことができました。 あとは、テストが必要な箇所で上記のように実装するだけであり、慣れたら非常に簡単です。 実際、UIテストやViewInspectorを利用したことがないメンバーに新規イベントのテストを着手してもらいましたが、非常に短時間で実装できたので、手軽にUIのテストを構築したい場合は非常に価値あるもになるはずです。 ViewInspectorのデメリット ここまでViewInspectorのメリットに焦点を当ててきましたが、実際に利用してみると、いくつかのデメリットもあります。 一連のUI動作や画面遷移のテストが難しい 複雑なアニメーションやジェスチャー操作には対応していない テスト実行時にWarningが出ることもあり、原因が不明確 特に、一連のUI動作をテストする場合、ViewInspectorでは難しいと感じることがあります。 なぜなら、ViewInspectorは主にビューの内部状態やプロパティを直接検証するために設計されており、ユーザーの操作を再現したアプリ全体の流れや画面遷移、アニメーションといった動的な動作を包括的にテストするには向いていないからです。 実際、ViewInspectorはUI全体の振る舞いをテストするというより、個別のコンポーネントが正しく動作するかを確認するためのユニットテスト向けのライブラリです。 そのため、アプリ全体のユーザーインターフェースや複雑なジェスチャー、アニメーションの動作を検証したい場合は、標準のXCUITestなど他のツールを併用する必要があります。 とはいえ、特定の画面やコンポーネントに焦点を当てたテストを素早く構築したい場合、ViewInspectorは非常に有用です。 導入コストも低く、コストパフォーマンスという面でも優れているため、使い方によっては十分な価値があると感じています。 まとめ UIテストでは、iOSにおいてXCUITestが理想的ですが、ナレッジや経験が十分でないチームも多いのが現状です。 そんなチームにとって、ViewInspectorは手軽に導入できる有力な選択肢になるのではないでしょうか。 また、私たちは他にもViewInspectorを使ったテストを書いています。 それについてはまた別の機会にご紹介するつもりなので、引き続きお読みいただければ幸いです。 ここまでご精読いただき、ありがとうございました。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
スタンバイアドベントカレンダー 2024 の 3 日目です! (スタンバイでは、毎年アドベントカレンダーを実施しており、そちらにもこの記事をリンクさせています。スタンバイアドベントカレンダーに興味を持っていただいた方は、そちらもご覧いただけると嬉しいです。) 株式会社スタンバイでフロントエンドエンジニアをしている川野です。 フロントエンドエンジニアという役割を担っていますが、最近では開発者体験や開発生産性というところに興味があり、そのあたりの改善にもよく取り組んでいます。 はじめに フロントエンドの機能を追加するとき、該当機能の影響を分析した上で 100% 公開に進むため、私たちはよく A/B テストを実施します。 いくつかのパターンを用意し、それぞれのパターンに対して異なる実装をします。 そして A/B テストの結果、その企画が棄却されることになると、そのコードは削除されます。 不要となったパターンのコードを削除する際に、TypeScript の AST と JSDoc を使うことで、コードの中から削除する箇所を機械的に検出し、安全に削除するプログラムを作ることができたので紹介します。 ただし、今回紹介する方法では、条件分岐を含むような複雑なケースに対応できていないため、すべてのケースには対応できません。 可能であれば、改善していきたい今後の課題です。 困っていたこと A/B テストのコードは頻繁に追加され、削除されます。 そのため、削除対象の箇所がすぐにわかるように、コメントアウトを使って目印を残していました。 たとえば、このような感じです。 const messages = { // ↓↓↓ AB テスト (KEY-042) no001: (value: string) => { return `No 001: ${value}`; }, no003: (value: string) => { return `No 003: ${value}`; }, // ↑↑↑ AB テスト (KEY-042) no002: (value: string) => { return `No 002: ${value}`; }, }; この方法にはいくつかの問題がありました。 ある A/B テストの範囲に、誤って別の A/B テストのコードが含まれてしまうことがある。 必要なコードまで消してしまうリスクがある。 順序通りにコードを書きたくても、範囲の記述により、順序通りに書けないことがある。 たとえば、オブジェクトのキーが上記のサンプルのように連番ときに、それを順序通りに記述的ないケースがある。 対応するコメントアウトを記述し忘れることがある。 たとえば、開いているが閉じていないコメントアウトがある。 人の目で確認して削除するため、削除し忘れることがある。 最初は、これらの問題を解決するための目印の残し方を考えていました。 そこで思いついたのが、JSDoc を使うことでした。 そして、JSDoc を使うのであれば、構文解析することで削除対象のコードを検出し、そのまま削除できるのではないかと考えました。 TypeScript の AST と JSDoc を使って課題を解決する AST (Abstract Syntax Tree: 抽象構文木) は、ソースコードをツリー構造で表現したものです。 AST を使うことで、削除対象のコードブロックを検出できます。 先ほど例に挙げたコードを、次のように修正します。 ここでは、JSDoc のタグを abtest にしています。 const messages = { /** @abtest KEY-042 */ no001: (value: string) => { return `No 001: ${value}`; }, no002: (value: string) => { return `No 002: ${value}`; }, /** @abtest KEY-042 */ no003: (value: string) => { return `No 003: ${value}`; }, }; この中から削除対象となるコードブロックを検出し、削除するプログラムは次のようになります。 import path from "path"; import { Node, Project } from "ts-morph"; import ts from "typescript"; const project = new Project({ tsConfigFilePath: path.join(import.meta.dirname, "/path/to/tsconfig.json"), }); const sourceFiles = project.getSourceFiles(); sourceFiles.forEach((sourceFile) => { sourceFile.forEachDescendant((node) => { // `remove` メソッドを持っていない `node` もあるため、型を絞り込む必要があります。 // 必要に応じて、絞り込む条件を追加します。 if (!Node.isPropertyAssignment(node)) return; // ts-morph で任意の `node` に対して JSDoc を取得する方法を見つけられなかったため、TypeScript の機能も併用しています。 // ts-morph で取得した `node` は `node.compilerNode` とすることで、 ts の関数に渡すことができます。 const jsDocs = ts.getJSDocTags(node.compilerNode); jsDocs.forEach((jsDoc) => { if (jsDoc.tagName.text === "abtest" && jsDoc.comment === "KEY-042") { node.remove(); } }); }); sourceFile.saveSync(); }); ts.getJSDocTags を使うことで、 node が持つ JSDoc のタグの一覧を取得できます。 このタグの中に、削除対象の条件と合致するものがあれば、その node を削除します。 これで不要になったコードを安全に削除できます。 まとめ TypeScript の AST と JSDoc を使うことで、コードの中から削除する箇所を検出し、安全に削除するプログラムを作ることができました。 これまで人間が目視で注意深く読みながらコードを削除していましたが、プログラムによって安全に行えるようになりました。 JSDoc にタグを書いておくだけで、コードを読まなくても不要になった部分を削除できるため、作業効率も良くなりました。 最初はどのように目印を残そうかと考えていただけだったのが、生産性の向上にまでつなげることができたのは嬉しい誤算でした。 取り組み自体はそこまで派手なものではないですが、このような地味に嬉しい改善をこれからも続けていきたいと考えています。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
株式会社スタンバイでフロントエンドエンジニアをしている川野です。 フロントエンドエンジニアという役割を担っていますが、最近では開発者体験や開発生産性というところに興味があり、そのあたりの改善にもよく取り組んでいます。 はじめに 私たちのチームでは、Google Apps Script (GAS) を利用して、非エンジニアの人たちがシステムにスプレッドシートのデータをアップロードできる仕組みを構築しています。 GAS は手軽に開発でき、プロジェクト開始当初の小さな要求を満たすには十分なものでした。 しかし、プロジェクトが進むにつれ次第に要件が増えていき、GAS で対応するのが大変になるほど複雑になってきました。 また GAS では、型の恩恵を受けることが難しかったり、エディタのサポートが満足いくものでなかったりし、増大していく複雑さに立ち向かうのが難しくなってきました。 このような課題を解決し、開発者体験や開発生産性を向上させるために、GAS から TypeScript への移行を決めました。 また、あわせてソフトウェアアーキテクチャも刷新しました。 その中で得られた学びや気づきなどをいくつか紹介します。 GAS から TypeScript への移行 TypeScript に移行するメリット TypeScript に移行することで次のようなメリットが得られます。 型の恩恵を受けることができる。 npm パッケージを利用できる。 ESLint で静的解析できる。 Prettier でコードフォーマットできる。 その他便利なライブラリを利用できる。 テストを書くことができる。 手慣れたエディタで開発できる。 いくつか挙げましたが、いつもの TypeScript での開発と同様の開発者体験を得られるようになります。 GAS アプリケーションの開発において、これは大きなメリットだと考えられます。 TypeScript で開発するには TypeScript で開発したものは、GAS のプラットフォーム上にデプロイする必要があります。 clasp という Google が開発している CLI ツールがあるので、それを導入する必要があります。 そして後述の理由により、TypeScript のコードを一度 JavaScript にビルドする必要があります。 私たちのプロジェクトでは、次のような npm scripts を用意し、ビルドとデプロイを実行できるようにしました。 { " scripts ": { " build:<feature> ": " vite build -c src/features/<feature>/vite.config.ts ", " deploy:<feature> ": " clasp -P <output>/<feature> push " } } 後述していますが、私たちのプロジェクトでは Package by Feature を採用しています。 そのため、それぞれの機能ごとに vite.config.ts が用意されており、そこに <output> のパスが記述されています。 clasp を使うときの注意点 clasp は、TypeScript のコードを GAS のコードに変換する機能を持っていますが、注意すべき制約があります。 それは、import/export 構文を扱うことができないことです。 そのため、デプロイする前に TypeScript のコードをビルドする必要があります。 ビルドには Vite を利用しました。 (将来的に凝った UI を作りたくなったときのことも視野に入れて。) ビルドするときの注意点 GAS にはトリガーと呼ばれる、組み込みの予約済み関数があります。 たとえば、 onOpen や doGet といった関数です。 このような関数は、TypeScript のコード上からは参照されません。 そのため、ビルド時のツリーシェイキングが有効になっていると、これらの関数が削除されてしまい、正常に動作しなくなります。 この問題に対して、専用の Vite のプラグインを作ることで解決しました。 import fs from "fs/promises"; import type { Plugin } from "vite"; interface Options { inputDir: string; outputDir: string; } export const keepGasTrigger = ({ inputDir, outputDir }: Options): Plugin => { const GAS_TRIGGER = [ "onOpen", "onInstall", "onEdit", "onSelectionChange", "doGet", "doPost", ]; // 1. トリガー関数を利用するダミーコードを生成する。 const dummyCodes = GAS_TRIGGER.map( (trigger) => `void ${trigger}.name.toString();` ).join("\n"); return { name: "vite-plugin-keep-gas-trigger", config: (config) => { return { ...config, build: { rollupOptions: { input: `${inputDir}/main.ts`, output: { dir: outputDir, entryFileNames: "[name].js", format: "commonjs", }, }, }, }; }, transform: (code, id) => { // 2. ダミーコードをコードの末尾に追加する。 if (id.includes("main.ts")) return [code, dummyCodes].join("\n"); return code; }, closeBundle: async () => { const filePath = `${outputDir}/main.js`; const code = await fs.readFile(filePath, "utf-8"); // 3. ビルド後のコードからダミーコードを削除する。 const transformed = code.replace(`${dummyCodes}\n`, ""); await fs.writeFile(filePath, transformed, "utf-8"); }, }; }; このように、ビルド前にトリガー関数を利用するダミーコードを追加し、ビルド後にそのダミーコードを削除するという、けっこう力技なことをしています。 本当にこれでいいのか?という疑問が拭えないので、良い解決策が見つかれば、乗り換えたいと思っています… ソフトウェアアーキテクチャの刷新 どのように刷新したか TypeScript への移行に伴い、ソフトウェアアーキテクチャも大幅に刷新しました。 というのも、そのまま移植したのでは複雑さや変化していく要件に立ち向かうことができないと思ったからです。 ソフトウェアアーキテクチャを見直すことで、より保守性の高いコードを書くことができるようになり、開発者体験や開発生産性を向上させることができると考えました。 アーキテクチャは、クリーンアーキテクチャ系ベースにし、以下のようなディレクトリ構成にしました。 src └── features └── awesome_feature # スプレッドシート単位で機能を作成する ├── controller # 外界とのやり取りを扱うコントローラを格納する ├── core # サービスやモデルなどビジネスロジックを扱うものを格納する ├── spreadsheet # スプレッドシートに関するものを格納する ├── main.ts # エントリーポイント(トリガー関数の記述等を行なっている) └── vite.config.ts # Vite の設定ファイル(本機能のビルド用) spreadsheet は controller や core など混ざって密結合しないように、外からコンストラクタを経由して依存注入するようにしました。 また、Package by Feature を採用し、機能ごとにディレクトリを分ける構成にしました。 こうすることで責務が整理され、コードの保守性が高まり、コードの読み書きがしやすくなると考えたからです。 スプレッドシートの扱い 今回の要件では、データをアップロードする前にバリデーションを行う必要があります。 スプレッドシートはデータベースのようなもので、今まで Repository パターンを使ってデータを取得するものだと思っていました。 しかし、その考え方だと、Repository から取得したデータに対してバリデーションを行う必要が出てきます。 個人的に、Repository から取得したデータに対してバリデーションを行うのは、違和感のある実装でした。 データベースにはバリデーション済みの安全なデータが格納されているイメージがあったからです。 そこで考え方を変え、スプレッドシートのデータをフォーム入力のようなもの、とみなすことにしました。 スプレッドシートのデータを、入力値としてコントローラで取得し、バリデーションを行うようにすることで、より自然な実装にできました。 同じスプレッドシートでも、データの扱い方次第では責務が異なり、それを見極めて適切なレイヤーに処理を書くことが重要だと感じました。 まとめ Google Apps Script の開発環境を刷新し、TypeScript へ移行しました。 その際に、ソフトウェアアーキテクチャも刷新し、責務を整理することによって保守性が高まり、コードの読み書きがしやすくなりました。 今後も、開発者体験や開発生産性を向上させるために、このような取り組みに積極的に取り組んでいきたいと考えています。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
概要 こんにちは、スタンバイで求人の取り込みシステムの開発・運用を担当している池田です。 スタンバイでは、求人データのマスターデータ管理に Aurora MySQL を使用しています。 運用開始から3年以上が経過し、その間にシステムは成長を続けてきましたが、それに伴いコストも徐々に増加していました。特に2024年は円高の影響も相まって、Aurora MySQL のインフラコストは、ますます大きな課題となりました。 実は2023年にも、Aurora MySQLのコスト削減を目指し、多くのRead操作をDynamoDBに移行することで、大幅な削減に成功しました(詳しくは こちらの記事 をご覧ください)。その時に55%のコスト削減を達成しましたが、2024年もさらなる最適化に挑戦しました! 今回のブログでは、2024年に行ったAurora MySQLのコスト最適化の取り組みと、どのようにしてさらに54%の削減を達成したのか、その詳細をご紹介します。 実施した施策と結果 まず先に結果からですが、2024年3月と2024年8月を比較した結果Aurora MySQLのインフラコストを約54%削減できました。 今回実施した施策と、その削減割合の内訳は以下の通りです。 番号 施策案 削減割合 ① Auroraクラスターのレプリカの台数を減らし、インスタンスタイプを下げる 9.6 %削減 ② Auroraクラスターの再構築により、肥大化した MySQL の ibdata1 を削除 30.5 %削減 ③ リザーブド DB インスタンスの購入 14.1 %削減 以下では、それぞれの施策について詳しく説明します。 ① Auroraクラスターのレプリカ台数を減らし、インスタンスタイプを下げる コスト最適化の前は、Auroraクラスターは6台のレプリカを使用していました。 レプリカのメトリクスを分析したところ、CPU使用率に十分な余裕があることが判明しました。これは、以前実施した対応や、過去1年にわたる仕様変更などによって、レプリカへの負荷が軽減されたためだと考えられます。そこで、レプリカ台数の削減とインスタンスタイプの変更について検討しました。 ただし、コスト削減を実施する際、パフォーマンスに悪影響が出ないようにすることが最も重要です。そのため、処理負荷のピーク時でもCPU使用率が60%を超えない範囲で、段階的にインスタンスタイプの変更とレプリカ台数の削減を進めました。 具体的には、負荷試験を事前に実施し、インスタンスの変更を段階的に行い、その都度状況を観察しました。詳細なインスタンスタイプは非公開ですが、レプリカ数を6台から5台に減らし、インスタンスタイプを一段階下げることで、約 9.6% のコスト削減を達成しました。 Auroraクラスター全体では9.6%の削減ですが、レプリカインスタンスに関しては、 変更前のインスタンス費用の約半分まで削減できました 。 なお、今回対象としているAurora MySQLは、write-heavyなアプリケーションであり、メトリクスを確認したところ、プライマリインスタンスにはコスト削減の余地がほとんどないと判断しました。 そのため、プライマリのインスタンスタイプは据え置くことにしました。 ② Auroraクラスターの再構築により、肥大化した MySQL の ibdata1 を削除 今回の施策の中でも、最も時間がかかった施策がこの「Auroraクラスターの再構築」です。 AuroraのCostExploreを確認すると、Auroraのストレージ料金であるAurora:StorageUsageという指標があります。 このAurora:StorageUsage は Auroraが使用しているストレージ容量を示す指標で、実データやインデックスだけでなく、InnoDBテーブルのメタデータやバッファ、UNDOログなどのシステムデータも含まれています。 これらの管理データは、MySQLのInnoDBストレージエンジンによってibdata1というファイルに格納されています。このibdata1ファイルは、以下のクエリで確認できます。 mysql> SELECT file_name,tablespace_name,engine,ROUND(SUM(total_extents * extent_size) / (1024 * 1024 * 1024 * 1024), 2) AS "TableSizeinTB" FROM information_schema.FILES WHERE file_name LIKE '%ibdata%'; +-----------+-----------------+--------+---------------+ | file_name | tablespace_name | engine | TableSizeinTB | +-----------+-----------------+--------+---------------+ | ./ibdata1 | innodb_system | InnoDB | 59.87 | +-----------+-----------------+--------+---------------+ 1 row in set (0.03 sec) ストレージに占める割合を調査した結果、ibdata1ファイルはAuroraが使用しているストレージ容量の 99.97% を占めていることがわかりました。 長期間にわたり、スタンバイの求人データを管理してきた結果、ibdata1は非常に肥大化していたのです。 ストレージ容量削減のために不要なテーブルやレコードを削除しましたが、ibdata1 はデータを削除してもサイズが減少しない ため、根本的な解決には一度Auroraクラスターを再構築する必要がありました。 ただし、物理バックアップ(DB クラスタースナップショット等)ではibdata1も含めて全データが復元されてしまうため、ストレージ削減の効果はありません。そこで、 論理バックアップ を使用して移行する必要がありました。しかし、テーブル数やレコード数が非常に多いため、物理バックアップに比べて時間がかかることが懸念されました。 いくつかの検証をした結果、 AWS Database Migration Service(DMS) を使用することで、ダウンタイムを最小限に抑えながらデータ移行を進められることがわかりました。 1 AWS DMS を利用する上で、工夫したこと AWS DMSは、ダウンタイムを最小限に抑えながらデータを移行できるマネージドサービスであり、MySQLを含む多くのデータベースに対応しています。 まず、 DBをサービスインしたまま移行できるか が最初の課題でした。これが可能であれば、ほぼダウンタイムなしでデータ移行が完了します。しかし、DMSを使用して検証をしたところ、フルロード完了後、レプリケーション遅延が広がり続ける問題が発生しました。 DMSは、Aurora MySQLのバイナリログを読み取ることで変更をキャプチャしますが、今回のように 更新頻度が非常に高い システムでは、 CDCソースレイテンシー (変更データキャプチャの遅延)が発生し、レプリケーションが追いつかなくなるという問題に直面しました。 ▲上記のキャプションで何度かCDCソースレイテンシーが下がっていますが、これは再度フルロードをやり直したためになります。 そこで、レプリケーションを行わず、Aurora MySQLのマスター更新を一時停止してDMSのフルロードを実行する方針に変更しました。検証の結果、マスター更新を停止した状態でのフルロードは約4時間で完了することが確認され、更新停止時間は許容範囲内に抑えられる見込みが立ちました。 最終的には社内で調整し、マスター更新を約4時間停止することで、無事DMSによるフルロード1回で再構を完了させました。 Aurora MySQL クラスター再構築の結果と効果 ibdata1のサイズを大幅に削減した結果、Aurora:StorageUsageのコストは 97.7%  削減されました。 さらに、次のような副次的効果も得られ、スタンバイのシステム全体に対して大きな改善が見られました。 Aurora MySQLのクエリレイテンシーが改善 Aurora MySQLに接続するAPIの応答速度も向上しました。 Aurora MySQLのバックアップ費用が97%低下 毎日バックアップを取得しているため、このコスト削減効果は非常に大きいです。 さらに、ibdata1 と直接的な因果関係がないのですが、Aurora:StorageIOUsage のコストも3月と比較して33%下がっておりました。(Aurora MySQLの削減割合で13.35% 削減) 明確な因果関係はわかっていないのですが、StorageUsageが下がったことに加え、インスタンス台数を減らしたことや、Aurora MySQLのメジャーバージョンをあげたことなどが影響しているかもしれません。 Aurora MySQL クラスター再構築の施策により、StorageUsage, StorageIOUsage, BackupUsageのインフラ費用が削減され、合計で3月のAuroraの費用から 30.5% のコスト削減に成功しました。 またこの施策以降、アプリケーションコードから不要なトランザクション処理を削除し、ibdata1が肥大化する速度を大幅に落としました。 ③リザーブド DB インスタンスの購入 最後に行った施策は、 リザーブド DB インスタンスの購入 です。 Amazon Aurora のリザーブド DB インスタンスは、1 年間または 3 年間の契約で特定のインスタンスタイプとリージョンを指定してインスタンスの予約購入することで、オンデマンドの DB インスタンスに比べて大幅な割引が適用されるプランです。 詳しくは、 Amazon Aurora 向けリザーブド DB インスタンス に記載されています。 リザーブドインスタンスは購入後にインスタンスタイプ、リージョン、期間の変更は不可となるため慎重さが求められます。複数メンバーで今後のAurora MySQLのキャパシティ予測を確認しながら購入するプランの検討を進めました。 また、支払い方法(「全額前払い」、「一部前払い」、「前払いなし」)によっても少し割引率が変わってくるため、支払い方法も含めて社内各所に確認と相談をし、購入を完了させました。 この施策によって全体の 14.1% のコスト削減を実現しました。 施策のまとめ 今回の記事で取り上げたこと以外にもいくつかコスト削減施策を実施しましたが、AWSのCost Explorerを活用して仮説を立て、それに基づいた施策の立案、検証、実施を進めることで大幅なコスト削減を達成できました。特に、長期間運用されていたAurora MySQLを再構築し、不要なトランザクション処理を見直すことで、大きなコスト削減を実現できたことが印象的でした。 また、Auroraクラスターの再構築によって、単にコスト削減だけでなく、DBクエリのレイテンシー改善やAPI応答速度の向上といった副次的な効果も得られました。 最後に 2023年にAurora MySQLのインフラコストを55%削減できましたが、今回の取り組みでさらにコストを54%削減し、最終的に Aurora MySQLのインフラコストが約1/5にまで下がりました。 (逆に言えば、昨年までのコストは現在の5倍もかかっていたということです。) コスト最適化は一度実施して終わりではなく、継続的に取り組むべきもの であると、改めて実感しました。そのため定期的にCost Explorerを確認し、分析することが重要かと思います。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com 論理バックアップの案として、Aurora MySQL への書き込みを停止し、mysqldump でデータをエクスポートし、新規作成した Aurora MySQL クラスターにインポートして再構築する方法も検討しました。しかし、この方法では停止時間が2日以上かかる見込みであったため、不採用としました。 ↩
アバター