TECH PLAY

Android

イベント

マガジン

技術ブログ

はじめに QEグループでモバイルチームに所属しているmです。 前職までは約7年ほどモバイルアプリの開発をメインに従事していました。 今はQAとしてプロダクト開発に関わっています。 この記事では、QAが設計や実装といった開発側との共通言語を持っておくと、テスト設計でも不具合対応でも有効である、という話を書きます。 共通言語があると問題の切り分けがしやすくなり、開発とのコミュニケーションコストも下がるため、本来集中すべき業務に工数を割きやすくなると考えています。 なお現在も対応中の取り組みなので、効果については「こう感じている」という話も含みます。 課題:内部の変更は、影響範囲が外から見えづらい まず、この記事の出発点となる課題から整理します。 内部実装だけが変わる対応、特にユーザーから見える動作は変えないリファクタリングなどは、影響範囲が外から見えづらいという特徴があります。 例えば、Swift6対応やKMP対応、UIライブラリの移行などが該当するかと思います。 挙動レベルの影響は開発から共有されるものの、外から触っているだけではどこがどう変わったのかを把握しにくいです。 そのため、テスト設計では「影響範囲が分からないので、念のため全項目をテストしよう」となりやすく、 不具合対応では「ゼロから原因を探そう」という方向に傾きやすくなります。 ただ、実際には期日などの制約もあるため、すべてを見るわけにもいかない場合があります。 この「影響範囲が外から見えづらい」という点が、このあと述べる2つの工程(テスト設計と不具合対応)に共通する課題になります。 前提:アプリ側の設計・実装を把握して見る 課題への向き合い方として、まず普段行っている見方を記載します。 アプリは一枚岩ではなく、機能や役割ごとに分かれて作られています。 たとえば、画面の見た目を作る部分、データを加工する部分、サーバーと通信する部分、データを保存する部分、といった具合です。 何かが起きたとき、「画面上どう見えるか」だけで捉えるのではなく、 「それはどの責務の話なのか」「どことつながっているのか」を、アプリの作りの上で考えるようにしています。 これが、本記事で言う「構造から見る」という見方です。 たとえば「ログイン後の表示がたまにおかしい」という事象であれば、見た目を作る部分ではなく、 データを取得する通信や処理の部分が怪しいのではないか、と当たりをつける、といった具合です。 もちろん開発経験があるため、コードや設計資料を見れば、どの部分がどのように動き、どこにつながっているかは比較的把握しやすいほうだと思います。 ただ、この見方だけに頼るのではなく、開発側にも影響範囲の資料を展開していただいています。 そして、この見方がまず効くのが、テスト設計です。 テスト設計:見るべき範囲を、根拠を持って絞る 先ほどの「つい全項目をテストしたくなる」場面を、前項の考え方で設計します。 ここで行うのは、実装を隅々まで読むことではありません。「この変更は何をしようとしているのか」「だとすれば、何が壊れそうか」を、設計レベルで把握することです。 ここが分かると、何を見るか・どこまで見るかの手がかりになります。 具体的には、次の3つを問いとして使っています。 ①動作は変わるのか、変えないはずなのか 変えないはずの変更であれば、見るべき軸は「変更前と同じか(同等性)」になります。見た目や操作感が、変更前と変わっていないかを確認する、ということです。 ②技術的にどこへ影響のある変更なのか 並行処理なのか、UIの作りなのか、通信まわりなのか。 影響する領域が分かると、その領域で起きやすい問題が、見るべき観点になります。 たとえば並行処理が変わるのであれば、断続的に固まる・クラッシュする・反映が遅延する、といった点です。 ③どの単位で入る変更なのか 画面単位なのか、モジュール単位なのか。これが分かると、見る範囲の単位が定まります。 たとえば、今回改修した画面のみを対象にする、といった具合です。 この3つの問いで根拠を持って絞れると、見る範囲が現実的なところに収まります。 「全部やる」から「変更によって壊れそうな箇所を確かめる」へ変わる、という感覚です。 あわせて、開発との「どこまで実施するか」のすり合わせも速くなると考えます。 後工程でも効く:不具合の起票と切り分け ここまではテスト設計の話でしたが、メリットはその先の工程にも続くと考えています。 テスト設計の段階で把握した「この変更はどの実装箇所に影響があるか」という理解は、不具合の起票や切り分けの場面でも、そのまま活用できます。 一度読み込んだ作りが、ここでも改めて効いてくる、という形です。 そのため、案件にもよりますが、起票を見た時点で「このあたりが怪しいのではないか」と当たりをつけ、仮説つきで起票できます。 たとえば、表示崩れ・断続的に固まる、iOS・Android共通の不具合、といった切り口で、どの部分の話なのかを見立てられます。 重要なのは、これは「QAが原因を完璧に特定できる」という話ではない、ということです。 あくまで当たりをつけ、仮説を立てるところまでです。ただ、その仮説があるかないかで、その後の動きはかなり変わると考えています。 「画面が固まりました」とだけ書かれた起票と、 「ここは非同期処理の変更が入った画面なので、そのあたりが怪しいかもしれません」という仮説つきの起票とでは、 開発側が状況を把握する速さも、誰に依頼すべきかの的確さも変わってきます。 切り分けが、「ゼロから探す」から「仮説を検証する」へ変化し、開発とQA双方にとってメリットが生まれるかと思います。 実装に寄りすぎない ひとつ、意識していることがあります。 現在の実装に寄りすぎると、「すでに実装されているもの」をなぞるだけになり、本来あるべきなのに、実装として抜けている観点を見落とすおそれがあります。 そのため、開発から共有された「本来こう動くべき」という挙動レベルの情報と、 コードや資料から読み取った「現状こうなっている」という状態を、突き合わせるようにしています。 構造理解で当たりをつけつつ、「ユーザーから見てどうあるべきか」という挙動ベースの視点も手放さない。 このバランスが大事だと考えます。 まとめ 今回の内容を要約すると、こうなります。 構造を理解しておくと、テスト設計でも後工程でも、QAの動きが「探す」から「検証する」へ変わる。一度の歩み寄りが、複数の工程で効いてくる。 もちろん、自身の背景などから知見があったという側面はありますが、それがないと無理な話ではないと考えています。 まずはプロジェクトのアーキテクチャ図や設計資料を読んでみる、詳細設計の境界を意識してみる、変更がどの設計箇所に影響するのかを開発に聞いてみる。 そうした小さなところからでも、見立ては変わってくるかと思います。 内部実装の変更で「全部見るしかない」となりがちな場面でも、構造から攻めることで、影響範囲の見立ての精度を上げられます。 同じような場面に立っている方の参考になれば嬉しいです。
こんにちは、 スタメンでEMをしている あさしん です 最近、Claudeのプランを上げて色々作ったり、自作キーボードの沼に徐々に沈み始めています。 はじめに スタメンが提供する「TUNAG 受付アプリ」は、2022年7月にリリースした来客受付アプリです。来訪者がiPad(当時はAndroidタブレット)を操作し、担当グループ・担当者を選んで呼び出すと、担当者へ通知が届く仕組みになっています。TUNAGをご利用いただいているお客様にご提供しているサービスです。 当初の通知先はTUNAGアプリ内のチャット機能でしたが、今回の改修によって「TUNAG チャット」にも通知できるようになりました。TUNAG チャットは、TUNAGアプリの中の1機能として提供されていたチャットを独立させたアプリで、2025年3月にリリースされました。音声通話やスレッド機能など、コミュニケーション機能をより強化した形で提供しています。サービスの成長に合わせてアプリが分化していく中で、受付アプリ側も新しい通知先に対応した形になります。 リリース当時の技術的な経緯は 2022年のテックブログ記事 にまとめています。かんたんに振り返ると、 Android開発を主担当するエンジニアが、将来のiOS拡張を見越してKotlin Multiplatform Mobile(KMM、現KMP)を採用した のが出発点でした。 当時の判断を引用すると: 「今後iOSアプリへの拡張性を考慮」してデータ・ドメイン層にKMMを導入しました。 この「将来の伏線」をついに回収しました。KMPで積み上げたロジック資産をそのまま活用し、 SwiftUIのUI層を追加するだけでiPad対応を実現した 話をします。 なぜ最小コストで済んだのか 最初にポイントを整理します。 Android版リリース時点で、ビジネスロジックはすでにKMPの shared モジュールにまとまっていました。具体的には: 認証フロー(パスワード認証・SSO判定) グループ・ユーザー一覧のフェッチ ViewModel の UI State 管理 APIクライアント(Ktor) クッキー永続化 これらはすべて commonMain に書かれており、Kotlinのまま動きます。iOS対応でやるべきことは、 SwiftUIの画面を足すだけ でした。ビジネスロジックを書き直す必要はありません。 ここで重要なのは、現在のチームにはiOSメンバーが増えており、ネイティブで0から作り直す選択肢も技術的には十分あった点です。それでもKMPの上に載せる判断をしたのは、 動いている資産を捨てるコストに見合うリターンがないから です。既存のロジックを再実装しても機能は増えません。エンジニアのリソースを「新しいビジネス価値を生む開発」に使える方が、今回の私たちの状況においては、ビジネス判断として合理的でした。 プロジェクト構成 tunag-reception/ ├── androidApp/ # Jetpack Compose による Android UI層 ├── iosApp/ # SwiftUI による iOS(iPad)UI層 └── shared/ # KMP 共有モジュール └── src/ ├── commonMain/ # 共通ロジック(Android/iOS 両方で動く) ├── androidMain/ # Android 固有実装 └── iosMain/ # iOS 固有実装 shared モジュールのレイヤリングはこうなっています。 shared/commonMain/ ├── domain/ │ ├── entity/ # データクラスのみ。ビジネスロジックなし │ └── repository/ # インターフェース定義のみ ├── application/ │ ├── service/ # ユースケース │ ├── dto/ # データ転送オブジェクト │ └── AppContainer.kt # DIコンテナ(唯一の組み立て口) ├── infrastructure/ │ ├── api/ # Ktor クライアント、Cookie管理 │ └── repository_impl/ ├── presentation/ # ViewModel └── stub/ # テスト・Preview 用スタブ 最重要ルールは「ViewModel・Repository・Entity は必ず shared に収める」 こと。iOS/Android のモジュールには View レイヤのみを配置します。この制約を守ることで、ロジックのダブルメンテが構造的に発生しません。 expect/actual パターン:プラットフォーム差を局所化する KMPでプラットフォーム固有の実装が必要な箇所は expect/actual で分岐します。代表的な例として ApiClient を紹介します。 ApiClient HTTPクライアントはプラットフォームごとにエンジンが異なります。 commonMain では expect だけを宣言します。 // commonMain — 共通のシグネチャ定義(各プラットフォームで実装を切り替える枠組み) expect class ApiClient(apiHost: ApiHost) { val apiHost: ApiHost val client: HttpClient suspend fun clearCookies() suspend fun addCookieToStorage(name: String , value: String ) fun close() } iOS側の actual はDarwinエンジン(NSURLSessionベース)を使います。 // iosMain — Darwin エンジンで実装 actual class ApiClient actual constructor ( actual val apiHost: ApiHost) { actual val client: HttpClient = HttpClient(Darwin) { engine { // Ktor の HttpCookies プラグインでクッキーを管理するため // NSURLSession のクッキーストレージを無効化して二重付与を防ぐ configureSession { setHTTPCookieStorage( null ) } } install(HttpCookies) { storage = PersistentCookieStorage(apiHost) } install(UserAgent) { agent = "Tunag-iOS/5.8.0.0 Reception" } } } Android側はOkHttpエンジンが入ります。呼び出し側は ApiClient を使うだけで、プラットフォームを意識しません。 なお、現在のKMPではクラス全体を expect class にするより interface + DI で注入するアプローチが推奨されることが多いです。今回は2022年当時の設計資産をそのまま活かす方針のため、当時の expect class をそのまま使い続けています。 クッキー永続化:iOS/Android の保存先の違いを吸収する アプリ再起動後もログイン状態を維持するため、セッションクッキーを永続化しています。保存先がプラットフォームで異なるため、 Multiplatform Settings ライブラリの Settings クラスで抽象化し、 commonMain に PersistentCookieStorage を実装しました。プラットフォーム固有の保存先は Settings の裏側に隠れるため、共通コードはストレージを意識しません。 // commonMain — プラットフォームを意識しない CookiesStorage 実装 internal class PersistentCookieStorage(apiHost: ApiHost) : CookiesStorage { private val settings = createCookieSettings() // expect/actual でプラットフォームごとの保存先に切り替わる private val storageKey = "ktor_cookies_ ${ apiHost.raw } " override suspend fun get (requestUrl: Url): List <Cookie> = mutex.withLock { ensureLoaded() val host = requestUrl.host val now = currentEpochMillis() cookieMap.entries .filter { (domain, _) -> host == domain || host.endsWith( ". $domain " ) } .flatMap { it.value } .filter { it.expiresAt == null || it.expiresAt > now } .map { it.toCookie() } } } 実装中にひとつ罠がありました。Ktor 3へのアップグレード後、ログアウト直後に再ログインするとクッキーが消えるバグが発生しました。原因はKtor 3での挙動変更で、 max-age=0 が「未設定」扱いから「即時削除」に変わっていました。RFC 6265に立ち返り maxAge <= 0 を正しく削除処理することで解決しましたが、地味な落とし穴でした。 StateFlow を SwiftUI から消費する:Wrapper パターンと SKIE KMPのViewModelはKotlinの StateFlow でUI状態を公開します。 // shared/commonMain — Kotlin ViewModel class SystemViewModel(...) : ViewModel() { val uiState: StateFlow<SystemUiState> = viewModelState .map { it.toUiState() } .stateIn(viewModelScope, SharingStarted.Eagerly, ...) } Swiftから StateFlow を直接 @Published として扱うことはできません。当初はKotlin側にコールバックを受け取るラッパーを用意していましたが( *ViewModelIos.kt )、 SKIE の導入でそれが不要になりました。 SKIEは、 Kotlin Multiplatformで作られたコードをSwiftから利用しやすくするためのコンパイラプラグインです。 Kotlinの Flow をSwiftの AsyncSequence に変換し、 sealed interface をSwiftのパターンマッチで扱えるようにしてくれます。 現在はSwift側に薄いWrapperクラスを置くだけです。 // iosApp — Swift Wrapper @MainActor final class SystemViewModelWrapper : ObservableObject { @Published private ( set ) var uiState : SystemUiState private var observationTask : Task < Void , Never > ? init (container : AppContainer ) { let vm = SystemViewModel( ... ) self .uiState = vm.uiState.value // StateFlow を for-await-in で購読し、@Published を更新する observationTask = Task { [ weak self ] in for await state in vm.uiState { guard let self else { return } self .uiState = state } } } deinit { observationTask?.cancel() } } sealed interface のパターンマッチも自然に書けます。 // SKIE の onEnum(of:) で Kotlin sealed interface を Swift で分岐する var currentBaseUrl : String { switch onEnum(of : uiState ) { case .signIn( let s ) : return "https:// \( s.currentHost ) " case .enterPassword( let e ) : return "https:// \( e.currentHost ) " case .admin( let a ) : return "https:// \( a.currentHost ) " } } SKIEなしではKotlin側にiOS専用のラッパー関数を用意する必要がありました。その手間がなくなり、Android向けに書いたKotlinコードをSwift側から直接使える体験が格段に改善されています。 ※コードは弊社環境のSKIEバージョン(0.10.11)に基づいています。SKIEの比較的新しいバージョンでは onEnum(of:) を使わずネイティブの switch で直接分岐できる場合もあります。 CI/CDを2本立てで構築する AndroidとiOSでCI/CDのパイプラインを分けています。 Android:CircleCI → Google Play 元のリリース時から変わらず、CircleCIを使っています。 # .circleci/config.yml(抜粋) deploy_production_to_playstore : docker : - image : cimg/android:2024.09 steps : - checkout - run : name : Place keystore file command : echo $STORE_FILE_BASE64 | base64 --decode > ./release.keystore - run : name : Build bundle & upload to Play Store command : ./gradlew publishProductionReleaseBundle リリースタグを打つと自動でPlay Storeの内部テスト版にデプロイされます。 iOS:Xcode Cloud → App Store iOS対応を機に Xcode Cloud を採用しました。GitHub連携でpushを検知し、アーカイブ・App Storeへの提出を自動化しています。 CircleCIに統一する案も検討しましたが、採用しませんでした。CircleCIでiOS/iPadOS向けのビルド環境を構築するには、App Store Connectとの連携に必要な認証トークンの発行・管理が必要です。さらにAndroidと同様に「ストアへのアップロード」と「社内テスト版の自動配信」を実現しようとすると、Xcode Cloudで構築するより圧倒的にコストがかかります。Xcode CloudはApple公式サービスだけあって、App Storeとの連携がそのまま動きます。 また、このアプリのテストはUnitTestレベルまでで、ほとんどのテストコードは shared モジュールに閉じています。CircleCIの既存のテストジョブで共通ロジックのテストはカバーできているため、iOS側のCIをCircleCIに統合する必然性がありませんでした。 Xcode CloudはApple公式のCI/CDサービスで、macOS環境が整っている点がメリットです。ただし、KMPの shared フレームワークをビルドするには JDK 17が必要 で、Xcode Cloudの環境にはデフォルトでJDKが含まれていません。 この問題を iosApp/ci_scripts/ci_post_clone.sh で解決しました。Xcode Cloudでは .xcodeproj と同階層の ci_scripts/ にスクリプトを置くことでビルドフックとして自動実行されます。クローン後に実行されるこのスクリプトで、AdoptiumのAPIからJDK 17を取得してセットアップします。 #!/bin/sh # Xcode Cloud: KMP shared framework のビルドに必要な JDK 17 をセットアップする JDK_INSTALL_DIR = " /Volumes/workspace/DerivedData/JDK " JDK_HOME = " $JDK_INSTALL_DIR /Home " # Apple Silicon / Intel を自動判定して対応アーキテクチャのJDKを取得する RAW_ARCH = $( uname -m ) ARCH = $( [ " $RAW_ARCH " = "arm64" ] && echo " aarch64 " || echo " x64 " ) mkdir -p " $JDK_INSTALL_DIR " curl -fsSL --retry 3 \ " https://api.adoptium.net/v3/binary/latest/17/ga/mac/ ${ARCH} /jdk/hotspot/normal/eclipse " \ -o /tmp/jdk.tar.gz # 展開前にトップディレクトリ名を取得してパスを決定的にする TOP_DIR = $( tar -tzf /tmp/jdk.tar.gz | head -1 | cut -d ' / ' -f1 ) tar -xzf /tmp/jdk.tar.gz -C " $JDK_INSTALL_DIR " rm /tmp/jdk.tar.gz # シンボリックリンクでパスを固定する ln -sfn " $JDK_INSTALL_DIR / $TOP_DIR /Contents/Home " " $JDK_HOME " Xcode CloudのワークフローにはJAVA_HOME環境変数として /Volumes/workspace/DerivedData/JDK/Home を設定し、Gradleが参照できるようにしています。Apple Silicon / Intelを uname -m で自動判定することで、Xcodeのインフラが変わっても動き続けます。 まとめ 3年前の設計判断が、今回の意思決定を楽にしました。 KMPで共通化したロジックはそのまま動いた 。認証・グループフェッチ・ViewModel・クッキー管理、すべて再実装ゼロ。 SwiftUIのUI層を追加するだけ でiPad対応が完了した。 SKIEでSwift連携が劇的に改善 した。Kotlin側にiOS専用のコードを書かずに済む。 Xcode CloudでiOSのCI/CDも整備 した。JDKをスクリプトで用意するひと工夫が必要だったが、以降は自動化できている。 「将来iOS対応できるようにKMMで作ります」というAndroidエンジニアの判断が、数年後に「動いている資産を捨てずに最大のリターンを得る」という意思決定の根拠になりました。技術選定は当時の最適解を選ぶだけでなく、将来の選択肢を広げるという意味でも重要です。 「体制が整ったから0から作り直す」という選択肢も魅力的に映ることがありますが、動いている資産を捨てるコストと得られる価値を冷静に天秤にかけた結果、今回はKMPの継続利用がベストだと判断しました。同じような場面で迷っている方の参考になれば嬉しいです。 TUNAG 受付アプリは Google Play 、 App Store で公開中です。 関連記事: 2022年リリース時のテックブログ
本記事は「 Merpay & Mercoin Tech Openness Month 2026 」の17日目の記事です。 この記事は新しいロールであるPdE (Product Engineer)というClient / Backend の境界をまたぐ「越境開発」ロールの取り組みについて、@anzai, @victoria Li, @ninnin, @panoramaの4名でお送りします。記事本文は、そのうちの1人である anzai が、自分の体験を一人称で振り返る形で書いています。 はじめに 「Client エンジニアが Backend を書き、Backend エンジニアが Client を書く」——そんな体制は実際に成立するのか。Q4 に試した小さな実験を紹介します。 ひとことでまとめると、Android エンジニアが Postgres のデータベース(DB)設計とサーバー実装を書き、Backend エンジニアが iOS/Android の画面を作りました。やってみて成立はしたものの、楽な道ではありません。何が効いて、どこで詰まったのか。本記事では「越境開発」というテーマに絞って、その実際を共有します。 越境開発の背景 私は普段メルペイでClient(Android / iOS)のチームを見ている Engineering Manager です。今回はManagerとしてではなく、自分の手でコードを書く一員として KYC(本人確認)領域のAI pod (小規模な開発チーム)に参加しました。 このチームでは、Product Manager(PdM)を置かず、エンジニアが1人で1プロジェクトを仕様策定からデリバリーまで一気通貫に持つ(1 Person 1 Release)、という体制を試しています。 そしてこの体制を回そうとすると、避けて通れない課題が1つ出てきます。それは「1人で End-to-End(E2E)に持つなら、Clientも Backend も自分で書くことになる」ということです。 KYC のプロジェクトは、ほとんどの場合 iOS / Android のClientと、サーバー側の API・DB の両方に手を入れます。従来はここをクライアント担当とサーバー担当で分けていました。1人1プロジェクトにするなら、その境界をまたぐ必要があります。E2Eで開発する中で「Client エンジニアが Backend も、Backend エンジニアが Client も開発できるか」を、実プロジェクトで検証することにしました。 いくつかのプロジェクトで Client-Backend を一気通貫で開発した結果、今期はどちらも成立しました。Client エンジニアがサーバーの DB と API を書いてリリースし、Backend エンジニアがモバイルの画面を出す。これ自体が、まず確かめたかったことです。 以下では、私がどう開発を進めたかを時系列で振り返り、そのあとで、どこで詰まったかを共有します。 AI を活用した越境開発の進め方 私はClientエンジニアです。今回担当したプロジェクトで最終的に必要としていたアウトプットは、具体的には2つでした。1つはお客さまに見せる UI/UX、もう1つは、その後の業務報告に使うレポートです。この2つの具体的なアウトプットから逆算して、「では、その裏側でどんな仕組みを実装しなければならないか」を決めていく必要がありました。そしてその仕組みの大部分が、私にとって初めてのサーバーサイド(Go / Postgres)です。 そこでまず、既存の実装を AI Agent に読み込ませ、「この2つのアウトプットを満たすには、どんな設計・実装が必要か」を AI に検討してもらうところから始めました。要件は手元にあるので、あとはそれを初めての言語と環境にどう落とすか。 この記事のここから先は、その道のりを時系列でたどります。 AI の説明もコードも、最初はわからない 最初にぶつかったのは、コードを書く以前の問題でした。AI Agent が返してくる説明そのものが、何を言っているのかわからないのです。 たとえば「Postgres を起動して、goose で既存の migration を流して、psql で確認して」と AI に言われても、最初は何を指しているのか、まったく理解できませんでした。goose とは何か、psql で何を確かめればいいのか ── そのひとつひとつが、Client 開発しかしてこなかった私には初めての言葉です。AI は正しいことを言っているのかもしれないけれど、こちらにそれを受け取る前提知識がない、という状態からのスタートでした。 ここでやったのは、ひたすら AI に問い返すことです。「それはどういう意味か」「図にして説明してくれ」と、いろいろなパターンで噛み砕いてもらう。そして自分が納得できたら、その理解を「こういうことですよね」と、今度は別のBackendエンジニアに確認しに行く。これを繰り返すことで、環境がどう動いていて、何をすればテストが回るのか、開発の前提条件をまず頭に入れていきました。 前提が見えてきたところで、いよいよ実装です。さきほどの要件をもとに「設計して実装してみて」と AI Agent にお願いし、コードを書いてもらいます。すると今度は、出てきたコードそのものが読めません。 なぜここで defer を呼んでいるのか。なぜ cancel() しなければならないのか。ここで context を受け取っているのは、つまりどういうことなのか。Go のイディオムが、ことごとくわからないのです。 そこで最初は、作ってもらったコードを一行一行、すべて AI に解説してもらいました。 context のキャンセルでリソースを解放しないと goroutine が漏れる、だからこの位置で defer cancel() する。そうした説明を1つずつ受けながら、文法書ではなく目の前の実コードを教材にして読み解いていきます。 なお、DB についてはまったくのゼロからではありませんでした。モバイルでも端末上の SQLite でテーブルを設計し、クエリを書く機会はあります。正規化やインデックスといった基礎概念そのものは持っていたので、サーバーの Postgres は「知っている概念を別のコンテキストに翻訳する」作業に近く、そこは助けになりました。 チャンクごとの並走型にたどり着く ただ、「全部書いてもらってから一行ずつ解説」では、どうしても効率が良くありません。これを変えたのが、チームのふりかえりでした。 私たちは週に1回レトロスペクティブを開き、チームの進め方を少しずつ改善していました。その中で、メンバーの一人が「チャンク(意味のある塊)ごとの並走型」というやり方を持ち込みます。AI に一気に全部を生成させるのではなく、意味のあるコードブロック単位で実装させる。そのブロックが「なぜ必要なのか」を自分が理解できればそのまま進め、理解できなければきちんと問い返す。「ここは A と B のやり方がありそうだけれど、なぜ A を選んだのか」と都度たずねていくことで、コードの一行ずつの意図を理解しながら前に進みます。 このやり方だと、巨大な生成物を上から順に読み下す必要がなくなり、開発の流れを「意味のある順序」で理解できます。機械的な行順ではなく、設計の意図に沿った順序で頭に入っていく感覚です。質問と回答はログとして残し、読み返せば「今日は何を学んだか」の復習にもなりました。 選んだ理由を残し、AI のコードを自分で理解する この進め方には、思わぬ副産物もありました。「いくつかの選択肢のなかで、なぜこの A を選んだのか」を開発の過程ですでに言語化しているので、それをそのまま Pull Request(PR)の説明に書けるのです。結果として、レビューはそれほど詰まることなく得られました。初めての領域でも、選択の根拠を添えられればレビュアーは判断しやすいですし、「ここまで考えた上でのPRなんだな」という信頼も得られます。ビギナーのPRを読むことはコードオーナーにとって大変な負担ですので、このような越境開発において信頼を作るための調査は必要な工程だったと思います。 ここで1つ、立ち止まって考えるべき論点があります。AI が書いたコードを、人間がわざわざ読んで理解する必要はあるのか。生成して、テストが通って、動けばよいのではないか。これはこれから重要になる議論だと思います。 私の現時点の答えは「理解すべき」です。 これからの生産性は、書いたコード量ではなく、意思決定の回数と質でほぼ決まっていくと考えています。そして意思決定の質と回数を上げるには、ドメインの理解が必要です。「このバックエンドの設計は、これで本当に正しいのか」を判断する場面で、いまのところ最終的な判断は人間がしています。その判断を下せるだけの理解を持っていないと、意思決定の回数も質も上がっていきません。だからこそ、AI が書いたコードでも中身を理解しておく価値がある、というのが今の立場です。 またAIの仕組み的に、永遠にそのPRに対して改善ポイントを考え出すことができます。たとえば、あるPRについて1回目のセッションでAIに聞くと A・B・C の3点を直すよう言われ、その修正結果を別のセッションで聞くと、今度は E・F・G の3点を直すよう言われる、ということが起こります。それを全部反映すると、最終的にはオーバーエンジニアリングした膨大なPRになりがちなので、現状は「どこまでが適切か」を人が判断する必要があるでしょう。 もしこの「理解する」のに2〜3年かかる長期投資なら、学ぶ理由はもっと厳しく問われるでしょう。ですが実際はそうではありませんでした。いまの AI Agent によるオンボーディングは本当にしやすく、いわば優秀な指導役が隣について、いつでもペアプロに付き合ってくれるような状況です。この環境であれば、3ヶ月もあれば、いま開発している領域の一通りの知識は得られるという手応えがありました。もちろん、もともとシニアエンジニアとしての経験がある、という条件付きではあります。投資回収が数年ではなく数ヶ月の単位に縮んでいるのなら、理解する側を選ぶのが合理的だと考えています。 設計の最終確認はシニアエンジニアと とはいえ、AI に聞けばそのまま採用、とはしませんでした。象徴的だったのがインデックスの設計です。 「このカラムにインデックスを張るべきか」を、AI の提案を鵜呑みにするのではなく、なぜ必要なのか/不要なのかを自分で根拠を持って調べました。どんなクエリパターンで引かれるのか、カーディナリティはどうか、読み取りが速くなるぶん書き込みコストとのトレードオフはどうなるのか。こうした観点を1つずつ確かめて、「この設計にはこういう理由がある」と説明できる状態にします。 それでも、最後はシニアエンジニアと一緒にレビューしました。正直に言えば、これは自分の自信のなさを埋めるためでもありました。AI が生成したものが本当に正しいのか、その最終的な判断が、私一人ではどうしてもつかなかったからです。 このレビューには、不安を消す以上の価値がありました。シニアエンジニアが、考え得るパターンをいくつも挙げてくれたうえで「では、この方法でいきましょう」と意思決定でき、いくつかの改善点も見つかりました。よりベターな やり方にたどり着くうえで、人と一緒に見てもらう工程はやはり必要だったように思います。こちらは Backend エンジニアが Client 開発をする場合も同様でした。 AI で下書きと理解を加速し、自分で根拠を固め、最終的には人とレビューする。この3段構えが、初めての領域で品質を担保し、かつ自分が安心して前に進むうえで、現実的なやり方でした。AI は出発点と伴走者としては非常に強力でしたが、設計の最終的な妥当性を見極め、選択肢を広げてくれるのは、まだ人間のレビューだった、というのが実感です。 なお、今回書いた DB 設計は2テーブルだけのシンプルな構成で、本格的な分散やシャーディングの考慮までは要りませんでした。テーブルが増え、複雑なインデックス設計が絡む規模で、AI とこの3段構えがどこまで通用するのかは、まだ検証できていません。「シンプルだったから回った」可能性は十分にあります。 最後に補足すると、こうして進められた背景には、組織図上で「越境させた」のではなく、同じチームの中で互いの領域をペアプロで教え合える環境があったことが大きいです。AI に聞いてもわからないこと、そもそも何を聞けばいいかわからないことは、隣にいる詳しいメンバーにその場で聞く。AI と人、両方に頼れる相手がいたことが、越境のコストを最も下げました。 つまずいたポイントと対策 ここまでは「どう乗り越えたか」を中心に書いてきましたが、実際には数多くの詰まりどころがありました。そしてその多くは設計や実装の難しさではなく、環境・ツール・運用、つまりコードを書く以外の全部にありました。Client しか書いてこなかった人間が Backend に入ると、ここでことごとく足を取られます。代表的なものを課題ごとに紹介します。 現状の開発スタイルがわからない 最初の壁は、開発スタイルそのものの違いでした。 Clientエンジニアの感覚として、まずローカルでビルドして動作を確認しようとしました。ところが、このコードをもともとメンテナンスしていたチームは、開発環境(dev)にまずデプロイしてから動かす、というスタイルを取っていました。つまり、ローカルの docker compose は、実のところほとんど使われていなかったのです。 私はそれを知らないまま、ローカルの docker compose を立ち上げようとして、「Postgres のバージョンが合わない」「環境変数が無効だ」と、動かない環境を前に四苦八苦しました。結果ローカルでテストできるようになったので良かったのですが、リポジトリ固有のこうした暗黙知は、AI ではどうにもならない部分です。 enum を一つ増やしたら、芋づる式にバージョンが上がる 次につまずいたのは、依存関係の更新でした。やりたかったのは、protobuf の定義に enum を一つ増やし、新しく追加された定数を参照することだけです。ところが、その定義を取り込もうと go mod tidy を実行したとたん、直接は触っていないはずのライブラリのバージョンが一斉に上がってしまいました。gRPC まわりから、最終的には Go 本体の要求バージョンまで動いてしまう。たった一つの enum のために、なぜここまで広がるのか、最初はまったく見当がつきませんでした。 やっかいだったのは、その「なぜ」に対して、もっともらしい説明がいくつも出てきて、しかもそれが間違っていたことです。AI に理由を聞くと、それらしい仮説、たとえば「余計な更新を巻き込んでいるだけだから、最小限に戻せる」といった答えを返してきます。ですが、その通りに戻そうとしても差分は収まりませんでした。 ひたすらAIと問答し、ようやく分かりました。原因は依存が構造的に噛み合っていたことでした。新しい生成ライブラリが新しい gRPC 系を要求し、それがさらに別の基盤ライブラリを引き、最終的に Go 本体の下限まで押し上げている構造だったのです。本当にそうなのかの依存関係のグラフを図示し、それでも自信が持てなかったのでPR Reviewでシニアエンジニアにレビューいただきました。 ローカルでのテストと Lint DB アクセス層(DAO: Data Access Object)のテストをローカルで回そうとすると、ただ実行するだけでは通りませんでした。テスト用の Postgres を専用ポートで立て、 PGPORT を明示的に指定する、といった、チームでは当たり前すぎて誰もドキュメントに書かない作法を、1つずつ踏みながら知っていきます。 たとえば DAO テストは、 5556 のような専用ポートでテスト用の Postgres を立て、 PGPORT でそこを向けて初めて通ります。これを知らないと、テストはローカルの既定のデータベースに接続しようとして、原因の見えないまま失敗し続けます。どこにも書かれていないこの一手にたどり着くまで、エラーメッセージとにらめっこする時間が続きました。 Lint も同様でした。ローカルで走らせると、設定のバージョン差(v1 系と v2 系の非互換)でうまく通らず、CI(Continuous Integration)に任せるという割り切りに落ち着きました。「ローカルで全部緑にしてから push する」という前提が、まず崩れます。 E2E 開発のマシン要件 1人で Client も Backend も1台で回すと、マシンへの負荷が一気に上がります。実際、Android ビルド+エミュレータ、iOS ビルド+シミュレータ、Docker コンテナ3つを同時に動かして、メモリを90GB使う場面がありました。スワップなしで E2E 開発をするなら、96GB は欲しいところです。 特にBackendのEngineerは元々高いスペックのPCを持っておらず、Client開発でまず最初のローカルビルドを試す段階で躓いてしまいました。ここからローカルビルドに耐えるPCを支給してもらうまでClient開発に着手できないということが起きてしまっていました。 越境して E2E を1人で持つということは、ツールチェーンも全部抱えるということでもあります。ハードウェアは目立ちませんが、無視できない条件でした。チーム内でも「越境する人ほどマシン要件が上がる」という前提を共有し、開発機の選定では多めのメモリを見込んでおく必要がある、という話になりました。 振り返って これらの課題に共通していたのは、詰まったポイントのほとんどが、設計や実装の難しさではなく、環境・ツール・運用の暗黙知だったことです。コードを書く力そのものよりも、その手前のところで足を取られていた、というのが実感でした。 ここで効いてくるのが、もともと人間のオンボーディングでも重要だった条件です。「DX(Developer Experience)が整っている」「オンボーディング資料や開発ドキュメントが整備されている」という、人にとっての整備状況が、そのまま AI にとっての整備状況でもありました。ドキュメントが薄く暗黙知に頼っている領域では、人も AI も同じように迷います。 今のところは、やってみて初めてわかる問題を踏んでは1つずつ潰していく、いわば「悲鳴駆動」で進めるしかなく、チームを立ち上げてから数ヶ月は生産性を上げるのが難しいのが正直なところです。逆に言えば、ここをドキュメントとセットアップスクリプトであらかじめ潰しておけば、越境のコストは大きく下げられる、という改善の的もはっきり見えました。次に越境に挑むなら、最初に着手するのはこの整備だろうと考えています。 まとめ KYC で試した範囲では、Client ⇄ Backend の越境開発は成立しました。Client エンジニアがサーバーを書き、Backend エンジニアが iOS/Android を出せています。 それを支えたのは、いくつかの条件でした。まず、小規模な開発チームとして一体で動き、ペアプロやオンボーディングセッションをしながら互いの領域を学べたこと。次に、AI Agent が初見の言語やコードを一文ずつ理解する伴走者になってくれたこと。ただし、設計の最終的な妥当性を見極め、そして開発者の不安を払拭するのは、いまも人間のシニアレビューでした。環境構築やツールのバージョン、ローカルテストといった暗黙知を埋めるには、オンボーディング環境とドキュメントが要ります。一方で、選択の根拠を開発の過程で言語化し、それを残しておけば、専門外の領域でもレビューは回りました。そして最後に、これらすべてを1台で回すには、メモリ96GB級の開発機が現実的に必要でした。 そして、これらが揃っていても、最初の数ヶ月は生産性が落ちます。越境は成長痛とセットでやってくる、というのが一番の学びでした。逆に言えば、その成長痛をチームとして引き受ける覚悟と、AI を含めたオンボーディングの仕組みがあれば、「クライアントエンジニア」「サーバーエンジニア」という肩書きの境界は、思っていたより動かせます。 次は、もっと複雑な DB 設計を含むプロジェクトで、AI がどこまで越境を支えられるのかを確かめていきます。エンジニア一人ひとりが領域をまたいで動けるようになることは、巡り巡って、より早く・より確かな価値をお客さまに届けることにつながると考えています。これからもこういった挑戦を続けていきたいと思います。 次の記事は yutaroさんです。引き続きお楽しみください。

動画

書籍