CSRFの生の挙動を知りたい~FastAPIとZAPを用いて検証してみた

初めまして、テックドクターでエンジニアをしている金子です。

CSRF(クロスサイト・リクエスト・フォージェリ)は非常に古典的な攻撃手法ですが、現代のWeb開発においてもその発生原理を正確に理解しておくことは重要だと思っています。

今回の記事では、CSRFについて、実際に手を動かして検証してみます。

この記事でやること

CSRFの基本的な仕組みを理解するために、わざと脆弱なサーバーを作成します。加えて「罠サイト」も用意して攻撃を再現し、何が起きているのかを脆弱性診断ツールのOWASP ZAPで観察します。

また、防御策としてCSRFトークンの有効性についても検証していきます。

※CSRF攻撃において、被害者に踏ませるためのページを本記事では「罠サイト」と呼びます。罠サイトは攻撃者が用意するもので、被害者がページを開いただけで標的サイトへリクエストが飛ぶ仕掛けが仕込まれています。

こんな人におすすめ

  • CSRFの名前は知っているが、実際どうやって攻撃が成立するのかピンときていない
  • フレームワークが守ってくれているため、生の脆弱な挙動を見たことがない
  • 攻撃の原理を自分の言葉で説明できるようになりたい

検証環境

  • ターゲットサーバー:FastAPI
  • 罠サイト(攻撃者):HTML + JavaScript(JSフレームワークは使いません)
  • ツール:OWASP ZAP、FoxyProxy

※今回は簡易的な検証のため、ターゲットサーバー、罠サイト共にローカル環境に設置します。

第1章:CSRFの仕組み

CSRFとは

CSRF(Cross-Site Request Forgery) は、日本語では「リクエスト強要」とも呼ばれます。被害者のブラウザを踏み台にして、本人の意図しないリクエストをWebアプリケーションに送信させる攻撃手法です。

攻撃の本質は「認証済みセッションの悪用」にあります。Webアプリケーションはリクエストに含まれるCookieを見て「この人は誰か」を判断しますが、そのリクエストが「本人の意思で送られたものか」までは検証していないことが多いです。CSRFではこの点が悪用されます。

攻撃者は罠サイトを用意し、被害者がそのページを開いた瞬間、被害者のブラウザから標的サイトへ不正なリクエストを自動送信させます。このとき被害者がたまたま標的サイトにログイン中であれば、ブラウザにより自動的にCookieが不正リクエストにも付与されます。そのため、サーバ側から見れば正規ユーザーからの正当なリクエストと区別がつかず、不正リクエストが処理されてしまうというわけです。

CSRFのしくみ(引用:IPA(独立行政法人 情報処理推進機構)より)

攻撃が成立する条件

CSRF攻撃が成立するには、以下の3つの条件が同時に満たされる必要があります。

  • 被害者が標的サイトにログイン済みである……ブラウザに標的サイトのセッションCookieが保存されており、有効な状態であること。ログアウト済み、またはセッション切れの場合は攻撃が成立しません。
  • 標的サイトがリクエストの正当性を検証していない……サーバ側で、CSRFトークンの検証、Refererヘッダのチェックなどを行っていない状態。つまり、「誰から送られたリクエストか」は見ているものの、「本人が意図して送ったリクエストか」までは確認しない実装になっている必要があります。
  • 被害者が攻撃者の用意した罠ページにアクセスする……メール内のリンク、SNSの投稿、不正広告など経路は様々です。被害者がそのページを開いた瞬間、隠されたフォームやimgタグによって標的サイトへリクエストが飛びます。多くの場合、被害者は攻撃が行われたことに気づきません。

※CSRFの仕組みについて、より詳細に知りたい方は次のサイトを参考にしてみてください。
安全なウェブサイトの作り方 - 1.6 CSRF(クロスサイト・リクエスト・フォージェリ) | 情報セキュリティ | IPA 独立行政法人 情報処理推進機構

具体的なインシデント例

PC遠隔操作事件(2012年)……Web掲示板のCSRF脆弱性を悪用し、無関係な一般市民のPCから犯罪予告を投稿させた事件です。被害者は誤認逮捕され、そのうち一人の大学生は自主退学に追い込まれました。サービス側のアクセスログには被害者のIPアドレスしか残らないため、当初は被害者が犯人だと疑われるなど、CSRFの「踏み台」としての恐ろしさを社会問題として浮き彫りにした事例です。

第2章:脆弱な環境を作る

では実際に検証を進めていきましょう。
まずは攻撃ターゲットとして、脆弱性を持つサーバーを構築します。

FastAPIによる簡単なサーバーの実装

このサーバーは下記のような機能を持っています。

  • ログイン機能(ログイン時、ブラウザにCookieをセット)
  • ユーザー情報としてID、パスワードのほか残高情報を持つ
  • パスワード変更APIが存在(CSRFトークンなし)

実装コードはこのようにしました。

from fastapi import FastAPI, Response, Cookie, HTTPException
from pydantic import BaseModel

app = FastAPI()

# 簡易ユーザーDB
users = {"alice": {"password": "pass123", "balance": 10000}}
sessions = {}

@app.post("/login")
def login(username: str, password: str, response: Response):
    if users.get(username, {}).get("password") == password:
        session_id = f"session_{username}"
        sessions[session_id] = username
        response.set_cookie("session_id", session_id, samesite="none", secure=False)
        return {"message": "ログイン成功"}
    raise HTTPException(401, "認証失敗")

class TransferRequest(BaseModel):
    to_user: str
    amount: int

@app.post("/transfer")  # ← CSRFトークンなし!
def transfer(req: TransferRequest, session_id: str = Cookie(None)):
    username = sessions.get(session_id)
    if not username:
        raise HTTPException(401, "未ログイン")
    users[username]["balance"] -= req.amount
    return {"message": f"{req.amount}円を送金しました"}

標的サイトの作成

次に標的となるサイトのページを作成しました。以下のような機能を持っています。

  • credentials: 'include' でCookieがやり取りする

実装コードの一部がこちらです。(重要な箇所のみ。HTML、CSSの全部、JavaScriptの一部は割愛します)

// ログイン時(ポイント:credentials: 'include')
  const res = await fetch(`${API_BASE}/token`, {
      method: 'POST',
      body: formData,
      credentials: 'include'  // ← Cookieを受け取る
  });

// 送金時(ポイント:Cookieが自動送信される)
  const res = await fetch(`${API_BASE}/transfers`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
      credentials: 'include'  // ← Cookieが自動で付く
  });

実装した画面

上記により、このような画面ができました。

  • ログイン画面

画面キャプチャ

  • 新規登録画面

画面キャプチャ

  • ダッシュボード

画面キャプチャ

第3章:罠サイトを作る

次に罠サイトを作成します。これは実際のCSRF攻撃においては攻撃者が作成するものです。下記のような機能を持っています。

  • 隠しフォームを持つ
  • フォーム自動送信のJavaScriptが存在(今回はボタンクリック時)

実装コード(CSSは割愛)

<form id="csrfForm" action="https://localhost:8443/api/transfers" method="POST">
      <input type="hidden" name="receiver_user_id" value="attacker">
      <input type="hidden" name="amount" value="5000">
  </form>
  
  <script>
  // 被害者がクリックすると...
  document.getElementById('claimBtn').addEventListener('click', async () => {
      await fetch('https://localhost:8443/api/transfers', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
              receiver_user_id: 'attacker',
              amount: 5000
          }),
          credentials: 'include'  // ← 被害者のCookieが送られる!
      });
  });
  </script>

実装した画面

画面キャプチャ

ここまでで、検証に必要な環境の作成が完了しました。

第4章:OWASP ZAPで検証

いよいよ、ツールを使ってCSRFの挙動を検証していきます。使用するツールは2つです。

OWASP ZAP

OWASP(Open Web Application Security Project)が開発するオープンソースのセキュリティテストツールです。ローカルプロキシとして動作し、ブラウザとWebサーバー間を流れるHTTP通信をすべて傍受・記録できます。

主な機能は以下の通りです。

  • リクエスト/レスポンスの可視化……ブラウザが裏で送っているリクエストの中身(ヘッダ、Cookie、POSTパラメータ等)を確認できる
  • リクエストの改ざん・再送信……傍受したリクエストを編集して再送信することで、パラメータ操作の影響を検証できる
  • 自動スキャン……既知の脆弱性パターンを自動検出する機能もあります。(本章では使用しません)
FoxyProxy

プロキシ設定の切り替えを簡単にするブラウザ拡張機能です。ChromeFirefoxに対応しています。
通常、ZAPのようなローカルプロキシを使うにはブラウザのネットワーク設定を手動で変更する必要がありますが、FoxyProxyを使えばワンクリックで「ZAP経由」と「直接接続」を切り替えられます。検証作業中に頻繁にプロキシのON/OFFを行う場面で便利です。

攻撃の再現

下準備として、被害者のアカウントと攻撃者のアカウントをそれぞれ作成しました。単純に作成するだけなので、画像の貼付は行いません。作成時の条件とユーザIDのみ記載します。

  • 被害者のユーザーID:test_target1
  • 攻撃者のユーザーID:attacker1
  • 初期値として設定した残高:いずれのユーザーも10,000円

以下、攻撃成立までのフローです。

1. 被害者ユーザーとして正規サイトにログインします。
画面キャプチャ
画面キャプチャ
ここでZAPを確認すると、バックエンドからバックエンドからアクセストークンが発行され、Cookieに保存されたことが分かります。
画面キャプチャ


2. 別タブで罠サイトを開きます。
画面キャプチャ
画面キャプチャ


3. 「景品を受け取る」ボタンをクリックします。

その結果、Javascriptにより攻撃者に送金をするリクエストがバックエンドに送信されます。
このとき、ZAPでCookieヘッダーを見てみると、被害者がログインしたときと同じAccessTokenが使われてしまっていることがわかります。

画面キャプチャ

リクエストヘッダーとJSONペイロードも確認してみましょう。

receiver_user_id(送金を受けるアカウント)には攻撃者のアカウントが、amount(送金額)には5000円が設定されています。(このとき、sender_id(送金者)のような送金者を特定するパラメータがありませんが、バックエンドではCookieのアクセストークンによって送金者を特定できるので、仕組みとしてはsender_idを指定しなくても送金処理を実行できます。)

送金完了後に被害者のダッシュボードで画面を更新すると、残高が5000に減っていることが分かります。

画面キャプチャ

また、攻撃者のアカウントにログインすると残高が15,000に増えていることが分かります。

画面キャプチャ

以上で、CSRF攻撃が成立するまでの挙動が確認できました。

第5章:CSRFへの防御策を検証する

攻撃について理解したところで、次は防御策についても検証してみたいと思います。
CSRFへの防御策のひとつに、CSRFトークンの実装が挙げられます。

CSRFトークンの仕組み

  1. 発行: ユーザーがフォームのある画面(送金画面など)を開いたとき、サーバーは乱数で作った、「予測不可能なトークン」を生成し、HTMLの隠しフィールド(input type="hidden")に埋め込んでユーザーに渡します
  2. 送信:ユーザーが送信ボタンを押すと、入力データと一緒にこの「トークン」もサーバーへ送られます
  3. 検証: サーバーは、送られてきたトークンが、ステップ1で自分が発行したものと一致するかを確認します。
    • 一致すれば……「正規の画面からの操作だ」と判断して処理を実行
    • 不一致/トークンなしなら……「不正なリクエストだ」と判断して拒否

CSRFトークンの実装

実際に実装してみましょう。全てを掲載すると長くなるので、実装したコードのうち重要な部分のみを例として掲載します。

1. CSRFトークンの生成とCookie設定(ログイン時)

# auth.py - ログイン時にCSRFトークンをCookieにセット
import secrets

def generate_csrf_token() -> str:
    return secrets.token_hex(32)

# ログインエンドポイント内
csrf_token = generate_csrf_token()
response.set_cookie(
    key="csrf_token",
    value=csrf_token,
    httponly=False,  # JSから読み取り可能にする(重要)
    samesite="none",
    secure=True,
)


2. CSRFトークンの検証(サーバー側)

# auth.py - CSRFトークン検証関数
def verify_csrf_token(
    csrf_token_cookie: str | None = Cookie(alias="csrf_token"),
    csrf_token_header: str | None = Header(alias="X-CSRF-Token"),
) -> None:
    """CookieとヘッダーのCSRFトークンを比較"""
    if csrf_token_cookie is None or csrf_token_header is None:
        raise HTTPException(status_code=403, detail="CSRF token missing")

    if csrf_token_cookie != csrf_token_header:
        raise HTTPException(status_code=403, detail="CSRF token mismatch")

# transfer.py - 送金エンドポイントで検証を適用
@router.post("/transfers")
def transfer_money(
    request: TransferRequest,
    current_user: Annotated[UserAuth, Depends(get_current_user)],
    _csrf: Annotated[None, Depends(verify_csrf_token)],  # ← これを追加
):
    ...


3. CSRFトークンをヘッダーに含める(フロントエンド)

// dashboard.html - CookieからCSRFトークンを取得してヘッダーに含める
function getCsrfToken() {
    const cookies = document.cookie.split(';');
    for (const cookie of cookies) {
        const [name, value] = cookie.trim().split('=');
        if (name === 'csrf_token') return value;
    }
    return null;
}

// 送金リクエスト
const res = await fetch('/api/transfers', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': getCsrfToken()  // ← これを追加
    },
    body: JSON.stringify(data),
    credentials: 'include'
});

再検証

ここまででCSRFトークンの実装が完了しました。

では再びOWASP ZAPを用いて、防御策がちゃんと機能しているか検証してみましょう。

1. 被害者のアカウントにログインします。
画面キャプチャ
ZAPを確認すると、アクセストークンとCSRFトークンが発行されていることがわかります。
画面キャプチャ


2. 別タブで罠サイトを開き、「景品を受け取る」ボタンをクリックします。

ZAPを確認してみると……
画面キャプチャ
X-CSRF-Tokenがリクエストのヘッダーに存在しないことと、403エラーが出て不正なリクエストが処理されていないことが確認できます。

無事、CSRF攻撃を防御することができました!


3. 念のため、正常時のリクエストも確認します。

現在の残高は5,000円です。

画面キャプチャ

ダッシュボードより2,500円の送金操作を行いました。

画面キャプチャ

送金が実施され、残高が2,500円になりました。

ZAPでリクエストの内容を確認してみましょう。
X-CSRF-Tokenがリクエストのヘッダーに含まれていることが分かります。
画面キャプチャ

送金先である、攻撃者のアカウントにログインをして残高を確認します。
画面キャプチャ
残高が増えていました。

正常なリクエストは、正常に処理されることが確認できました。

他の防御方法

なお、CSRF対策としてCSRFトークンが唯一の手段ではありません。
他にも下記のような対策が考えられます。

  • SameSite属性の設定
  • Referer/Originヘッダの検証

本記事の趣旨から逸れるのでこれらについて詳しくは説明しませんが、複数の防御策を組み合わせ、耐攻撃性の高い実装をすることが重要です。

まとめ

今回の検証を通じて、現代のフレームワークは開発者が意識せずともCSRFから守ってくれているという事実を、肌で感じることができました。

最近はAIを使って実装をする機会が増えましたが、生成されたコードでは「何となく動くけれど、仕組みはよく分からない」という状態に陥ることも少なくありません。

こうした「何となく」の知識を確かなものにするためには、時間をかけて手を動かし、実践するプロセスもやはり重要だと改めて感じました。


似顔絵
書いた人:金子

自己申告に頼らない飲酒測定――ウェアラブルでとらえる飲酒とその影響

1.飲酒習慣を測ることの難しさ

こんにちは。この記事は、テックドクターでデータ解析を担当する坂本と藤本が共同で執筆しています。

みなさんが健康診断を受けると、飲酒の習慣を尋ねられると思います。私(坂本)はよく、「飲み過ぎ注意」と言われていました。飲酒は、健康のいろいろな面に影響を与えるとされています(※1)

飲酒の量や習慣は、主に質問紙(アンケート)などで測定されます。しかし、例えば、過去7日間を振り返って飲酒の量を思い出して回答した場合、実際より少ない量で回答してしまうなどのリスクがあると言われています(※2)
もちろん、質問紙を用いた飲酒の測定も十分に有効活用できますが、毎日の記録/長期的な記録が必要な場面では、回答の負担が大きく、継続も難しいです。

そんなとき、スマートウォッチで自動的に飲酒の記録ができたら便利ですよね。
こういった背景から、私たちは「スマートウォッチを代表とするウェアラブル端末を装着しているだけで、飲酒を自動検出し、飲酒の影響を評価する」という技術開発に挑戦することにしました。

そして先日、その成果について、第32回日本行動医学会学術総会にてポスター発表を行いました(※3)。この記事では、その発表をもとに、「ウェアラブル端末で飲酒の検出と影響評価を行う技術」、そして「ウェアラブル端末のデータからわかった、飲酒が睡眠や翌日の活動に与える影響」について紹介したいと思います。

※1
例えば、厚生労働省「健康に配慮した飲酒に関するガイドラインについて」, URL: https://www.mhlw.go.jp/stf/newpage_38541.html, Last Accsess: 2025-12-10)。

※2
例えば、
Gmel G, Daeppen JB. Recall bias for seven-day recall measurement of alcohol consumption among emergency department patients: implications for case-crossover designs. J Stud Alcohol Drugs. 2007 Mar;68(2):303-10. doi: 10.15288/jsad.2007.68.303. PMID: 17286350.

※3
◎坂本・◯藤本・深見(2025). ウェアラブル端末を用いた20歳以上の一般成人を対象とする飲酒の多変量予測モデルの開発とアルコール飲料摂取が日常生活に与える影響の評価. 第32回日本行動医学会学術総会, 神奈川県相模原市 相模原市立産業会館, 2025年12月6日. (◎は責任著者・発表者、◯は発表者)

イメージ画像

2.飲酒日を検出する指標の開発

ウェアラブルデバイスデータを用いた「飲酒日」の検出

ウェアラブル端末では、主に心拍数、歩数、睡眠などの情報が取得できます。
しかし、例えば「心拍数が高い」というだけでは、運動や緊張、発熱などと区別がつけられず、飲酒を特定することができません。飲酒を見つけ出すためには相応の工夫が必要です。

そこで、「お酒を飲むと心拍が早くなる」という経験に着想を得て、「歩いていない時間に持続的に心拍数が上昇する」という特徴を定量化する計算手法を開発しました。
この指標を、本記事では「飲酒指標(Alcohol Index)」と呼ぶことにしましょう。この指標が、飲酒と高い関連性を示しました(※4)

※4
2026年1月現時点: 特許出願中

グラフ画像
図. 飲酒があった日の飲酒指標の例

飲酒指標を代表的な情報として、睡眠中の心拍数などを加えた特徴量を用いて機械学習モデルによる解析(XGBoost)を行ったところ、飲酒日の検出精度は90%程度となりました。

グラフ画像
図. 飲酒日検知モデルの性能

予測モデルの妥当性評価と研究報告

飲酒の検出精度が90%と聞くと、「すぐにでも実用化できる」ように感じられるかもしれません。しかし、安全な技術を確立するためには、多面的な検証を積み重ねる必要があります。
例えば、「どのような人でも同じ性能が得られるのか」や「誤判定が生じやすい条件は何か」といったような、公平性やリスク、安全性等に関する検証や対策が必要となります。

研究報告において大切なのは、何が未検証/未対策であるかを誠実に報告し、次の検証に繋げることです。「TRIPOD+AI声明」という研究報告で求められる事項が整理された国際的な指針(※5)があるので、それに沿って多面的な評価と今後の課題をまとめました。

ウェアラブル端末を装着しているだけで飲酒の自動記録が可能になり、健康管理など様々な場面で活用できるようになるという未来に向けて、検証を積み重ねたいと考えています。

※5
Collins G S, Moons K G M, Dhiman P, Riley R D, Beam A L, Van Calster B et al. TRIPOD+AI statement: updated guidance for reporting clinical prediction models that use regression or machine learning methods BMJ 2024; 385 :e078378 doi:10.1136/bmj-2023-078378


3.ウェアラブルデバイスを用いた飲酒の翌日の影響評価

次に、実際に飲酒が睡眠や翌日の活動にどのような影響を及ぼすかについても解析しました。

社内での取り組みを通して可視化できたデータをご紹介していきます。

心拍変動指標について

今回解析に利用した、心拍変動に関する指標を紹介しておきます。

  • RMSSD……副交感神経活動を反映する指標。これが低下した場合、十分にリラックスできていないと考えられる。
  • SDNN……交感・副交感神経の両方の変動を反映する指標であり、値が高いほど活発な自律神経活動が発生していたと考えられる。

データと参加者

下記の対象者/対象データを解析しました。

  • 21名、4932日分の飲酒報告と対応する日のウェアラブル端末装着情報を利用(男性57%)
  • 前日の飲酒報告(非飲酒・少量・中量・大量)
  • アンケート回答日と翌日のウェアラブル装着がある者
  • 装着時間が70%以上の日のみ採用
  • 平均値±3SD以内の睡眠時間の日

※今回は飲酒の有無はアンケートを利用して判定しました。

解析方法

解析では、一元配置分散分析、線形混合モデルと呼ばれる手法を使用しました。

  • 一元配置分散分析(ANOVA)……飲酒量4段階の群間差を比較
  • 線形混合モデル(LMM)……個人差を調整し、飲酒有無の影響を精緻に検討

解析結果

まず、一元配置分散分析により見えてきた結果をご紹介していきます。

睡眠 大量の飲酒をした場合、少量・中量飲酒に比べてさらに睡眠中の最低心拍が高くなっていました。(図1)
翌日の心拍 大量の飲酒をすると、翌日の安静時心拍数が高くなっていました。(図2)
翌日の活動 少量・中量の飲酒をすると、飲酒しなかった場合に比べて翌日の歩数が多くなっていました。(図3)
大量の飲酒をすると、少量・中量の飲酒に比べて翌日の歩行ペースが遅くなっていました。(図4)
少量の飲酒をすると、飲酒しなかった場合に比べて翌日の高強度運動時間割合が上がっていました。(図5)

次に、線形混合モデルにより見えてきた結果をご紹介します。

睡眠 飲酒をすると睡眠中の最低心拍が高くなっていました。(図6)
飲酒をすると睡眠中のSDNNが高くなっていました。(図7)
飲酒をすると睡眠中のRMSSDが低くなっていました。(図8)
翌日の心拍 飲酒をすると翌日の安静時心拍数が高くなっていました。(図9)
グラフ画像
図1: 飲酒レベルごとの睡眠中の最低心拍数比較
グラフ画像
図2: 飲酒レベルごとの安静時心拍数比較
グラフ画像
図3: 飲酒レベルごとの翌日の合計歩数比較
グラフ画像
図4: 飲酒レベルごとの翌日の歩行ペース比較
グラフ画像
図5: 飲酒レベルごとの翌日の高強度運動時間比較
グラフ画像
図6: 飲酒日と非飲酒日の睡眠中の最低心拍数比較
グラフ画像
図7: 飲酒日と非飲酒日の睡眠中のSDNN比較
グラフ画像
図8: 飲酒日と非飲酒日の睡眠中のRMSSD比較
グラフ画像
図9: 飲酒日と非飲酒日の安静時心拍数比較

考察

飲酒をすると、しなかった日に比べて睡眠中の最低心拍およびSDNNが高くなり、RMSSDが低くなっています。このことから、飲酒した日は自律神経が交感神経優位な状態になりやすく、睡眠の質が低下している可能性があります。

また、大量の飲酒をすると中量以下の飲酒時に比べて睡眠中の最低心拍が高くなり、翌日の歩行ペースが遅くなります。ここからは、大量の飲酒は特に睡眠および翌日の活動に大きな影響を与える可能性が読み取れます。
一方で、少量の飲酒では飲酒していない場合との差が少ないことや、むしろ活動量が増える人もおり、個人差が大きい可能性が考えられます。

4.まとめ

ウェアラブルデバイスのデータを使用することで、飲酒日を検出する指標の開発と、飲酒の影響の評価を行うことができました。

さらなる研究によって、より安全で確かな技術を確立してゆくことが必要とされています。技術開発が進めば、アンケートの併用がなくともより手軽に飲酒の影響についての解析ができるようになるかもしれません。
ウェアラブルデバイスを活用することで日々の行動を振り返る負担を減らしつつ、健康状態への理解を深めることができます。
こうした技術が、一人ひとりの生活を少し便利に、そして健やかにする助けになることを期待しています。

5.今後の展望

飲酒を検知する技術については、まだ様々な方々を対象とした多面的な検証と安全性の評価を必要としています。

本テーマに関する共同開発にご関心のある方がいらっしゃいましたら、お気軽に、お問い合わせフォームよりテックドクターまでご連絡ください。

似顔絵似顔絵
書いた人:坂本、藤本

FlutterのFlavor設定完全ガイド:iOS/Android対応のマルチ環境を構築する

モバイルアプリ開発では、開発・ステージング・本番など複数の環境を切り替えて運用することがあります。
Flutterでのアプリ開発において、その運用に役立つのがFlavorです。

Flavorは、アプリを複数の環境ごとに設定を切り替えるための仕組みです。
例えば「開発環境ではテスト用API、本番環境では本番APIを使いたい」といった場合に、Flavorを使うことでビルド時に環境を選択し、自動的に異なる設定を適用することができます。

この記事では、そんな便利なFlavorの設定方法をご紹介します。iOS/Androidの両方に対応しているので、マルチプラットフォーム向けアプリの開発に役立ててください。

この記事はこんな人向けです
  • Flutterで複数環境のアプリビルドを構築したい方
  • iOS/Android両方のFlavor設定を網羅的に知りたい方
  • Firebase等の外部サービスを環境ごとに切り替えたい方

 

1. 概要:Flavorとは

Flutterでは「Flavor」という仕組みを使って、同一コードベースから異なる設定のアプリをビルドできます。

Flavorを使うことで、例えばこういった設定を環境ごとに切り替えられます:

  • アプリ名・アイコン:開発版と本番版を異なる見た目にすることで視覚的に区別できます
  • Bundle ID / Application ID:同一端末に複数環境のアプリを共存させることができます
  • APIエンドポイント:環境ごとに接続先の自動切り替えが可能です
  • Firebase設定:環境ごとに異なるFirebaseプロジェクトを使用できます
プラットフォームごとの実現方法

AndroidとiOSそれぞれにおいて、Flavorを定義する方法は下記のとおりです。

  • Android:Gradleの productFlavors 機能を使用
  • iOS:XcodeのSchemeとBuild Configurationを使用

それぞれ、詳しいやり方は記事中で説明していきます。

本記事で構築する環境(Flavor)

本記事では、Flutter公式ドキュメントを参考に、4環境(local、development、sandbox、production)のFlavor設定手順を解説します。

Flavor 用途 アプリ名 Bundle ID /
Application ID
local ローカル開発 [LOC] アプリ名 *.local
development 開発サーバー接続 [DEV] アプリ名 *.development
sandbox ステージング環境 [SAN] アプリ名 *.sandbox
production 本番リリース アプリ名 (サフィックスなし)

では、具体的な手順を紹介していきましょう。

2. Android側のFlavor設定

まずはAndroidでの構成方法です。
Gradleの productFlavors 機能を使用し、シンプルに定義できます。

build.gradleの設定

android/app/build.gradleflavorDimensionsproductFlavors を追加します。

android {
   // ... 既存の設定

   flavorDimensions += "default"

   productFlavors {
       create("local") {
           dimension = "default"
           manifestPlaceholders = [
               appName: "[LOC] アプリ名",
           ]
           applicationIdSuffix = ".local"
       }
       create("development") {
           dimension = "default"
           manifestPlaceholders = [
               appName: "[DEV] アプリ名",
           ]
           applicationIdSuffix = ".development"
       }
       create("sandbox") {
           dimension = "default"
           manifestPlaceholders = [
               appName: "[SAN] アプリ名",
           ]
           applicationIdSuffix = ".sandbox"
       }
       create("production") {
           dimension = "default"
           manifestPlaceholders = [
               appName: "アプリ名",
           ]
           applicationIdSuffix = ""
       }
   }
}


ポイント解説

  • flavorDimensions:Flavorのグループを定義します。複数の軸でFlavorを管理する場合に使用します(例:有料版/無料版 × 環境)
  • applicationIdSuffix:ベースのApplicationIDに追加されるサフィックスです。これにより同一端末に複数環境のアプリをインストール可能にできます。
  • manifestPlaceholders:次に説明するAndroidManifest.xmlで参照するための変数を定義します。
AndroidManifest.xmlでの変数参照

くわえて、android/app/src/main/AndroidManifest.xml でplaceholderを参照します。

<application
   android:label="${appName}"
   android:name="${applicationName}"
   android:icon="@mipmap/ic_launcher">
   <!-- ... -->
</application>

Android側の設定手順は以上です!

3. iOS側のFlavor設定

iOSのFlavor設定はAndroidより少し複雑です。
XcodeのSchemeとBuild Configurationを使用します。まずBuild ConfigurationとSchemeを作成し、それぞれを紐づけたあと、具体的な設定を定義していく流れです。

Build Configurationの作成

Xcodeで`Runner.xcodeproj`を開き、以下の手順でBuild Configurationを作成します。

  1. プロジェクトナビゲータで Runner プロジェクトを選択
  2. Info タブ → Configurations セクションを開く
  3. 「+」ボタンで以下のConfigurationを追加:
Configuration名 複製元
Debug-local Debug
Debug-development Debug
Debug-sandbox Debug
Debug-production Debug
Release-local Release
Release-development Release
Release-sandbox Release
Release-production Release
Profile-local Release
Profile-development Release
Profile-sandbox Release
Profile-production Release

重要:Configuration名は必ず {BuildType}-{flavor名} の形式にしてください。Flutterがこの命名規則でFlavorを識別します。

Schemeの作成

次に、各FlavorごとにSchemeを作成します。

  1. ProductSchemeNew Scheme… を選択
  2. Scheme名を入力(例:localdevelopmentsandboxproduction

重要:Scheme名は小文字で、Flavor名と完全に一致させるようにしてください。

SchemeとConfigurationの紐付け

作成したSchemeを、Configurationに割り当てていきます。

  1. ProductSchemeManage Schemes… を開く
  2. 各Schemeを選択し「Edit...」をクリック
  3. 以下のように各アクションにConfigurationを割り当て:


localスキームの例:

アクション Build Configuration
Run Debug-local
Test Debug-local
Profile Profile-local
Analyze Debug-local
Archive Release-local


重要:全てのSchemeで「Shared」にチェックを入れてください。これにより.xcscheme ファイルがリポジトリにコミットされ、チームで共有できます。


作成されたSchemeファイル( ios/Runner.xcodeproj/xcshareddata/xcschemes/ 配下)の例:

<?xml version="1.0" encoding="UTF-8"?>
<Scheme version = "1.7">
  <BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES">
     <!-- 省略 -->
  </BuildAction>
  <TestAction buildConfiguration = "Debug-local">
     <!-- 省略 -->
  </TestAction>
  <LaunchAction buildConfiguration = "Debug-local">
     <!-- 省略 -->
  </LaunchAction>
  <ProfileAction buildConfiguration = "Profile-local">
     <!-- 省略 -->
  </ProfileAction>
  <AnalyzeAction buildConfiguration = "Debug-local">
  </AnalyzeAction>
  <ArchiveAction buildConfiguration = "Release-local" revealArchiveInOrganizer = "YES">
  </ArchiveAction>
</Scheme>

 

Build Settingsの設定

各ConfigurationでのBundle IDやアプリ表示名を設定します。

  1. Runner ターゲット → Build Settingsタブを開く
  2. 「+」→「Add User-Defined Setting」で以下のカスタム設定を追加:

DISPLAY_PRODUCT_NAME_PREFIX(アプリ名のプレフィックス):

Configuration
Debug-local [LOC]
Debug-development [DEV]
Debug-sandbox [SAN]
Debug-production (空)
Release-* 同上
Profile-* 同上


PRODUCT_BUNDLE_IDENTIFIER:

Configuration
*-local com.example.app.local
*-development com.example.app.development
*-sandbox com.example.app.sandbox
*-production com.example.app

 

Info.plistの設定

ios/Runner/Info.plist でBuild Settings変数を参照します。
これにより、アプリのビルド時にはConfigurationごとに設定した値が使用されるようになります。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
   <!-- アプリ表示名:プレフィックス + アプリ名 -->
   <key>CFBundleDisplayName</key>
   <string>$(DISPLAY_PRODUCT_NAME_PREFIX)アプリ名</string>

   <!-- Bundle IDはBuild Settingsから自動取得 -->
   <key>CFBundleIdentifier</key>
   <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>

   <!-- その他の設定... -->
</dict>
</plist>

iOS側の設定は以上で完了です!

4. Firebase設定(FlutterFire CLI)

複数の設定ファイルを生成することで、Flavorごとに異なるFirebaseプロジェクトを使用することができます。ここではその設定方法を紹介します。

事前に準備しておくこと

  • 各Flavorに対応したFirebaseプロジェクトが作成済み
  • Firebase CLIとFlutterFire CLIがインストール済み
# Firebase CLIのインストール
npm install -g firebase-tools
firebase login

# FlutterFire CLIのインストール
dart pub global activate flutterfire_cli

 

Flavor別の設定ファイルを生成

各Flavorに対して flutterfire config コマンドを実行し、設定ファイルを生成します。
こうすることで、FlutterFire CLIはDart設定ファイルの生成に加えて、iOS/Android両方の設定ファイルを適切な場所に配置し、必要なBuild Phase設定も自動で行います。

# development環境の設定
flutterfire config \
 --project=your-project-development \
 --out=lib/config/firebase/firebase_options_development.dart \
 --ios-bundle-id=com.example.app.development \
 --ios-out=ios/flavors/development/GoogleService-Info.plist \
 --android-package-name=com.example.app.development \
 --android-out=android/app/src/development/google-services.json

# sandbox環境の設定
flutterfire config \
 --project=your-project-sandbox \
 --out=lib/config/firebase/firebase_options_sandbox.dart \
 --ios-bundle-id=com.example.app.sandbox \
 --ios-out=ios/flavors/sandbox/GoogleService-Info.plist \
 --android-package-name=com.example.app.sandbox \
 --android-out=android/app/src/sandbox/google-services.json

# production環境の設定
flutterfire config \
 --project=your-project-production \
 --out=lib/config/firebase/firebase_options_production.dart \
 --ios-bundle-id=com.example.app \
 --ios-out=ios/flavors/production/GoogleService-Info.plist \
 --android-package-name=com.example.app \
 --android-out=android/app/src/production/google-services.json

 
ここで使用する主要なオプション

オプション 説明
--project FirebaseプロジェクトID
--out Dart設定ファイルの出力先
--ios-bundle-id iOSのBundle ID
--ios-out GoogleService-Info.plistの出力先
--android-package-name AndroidのパッケージID
--android-out google-services.jsonの出力先

 

生成されるファイル構成

コマンド実行後、以下のファイルが生成されます:

lib/config/firebase/
├── firebase_options_development.dart
├── firebase_options_sandbox.dart
└── firebase_options_production.dart

ios/flavors/
├── development/
│   └── GoogleService-Info.plist
├── sandbox/
│   └── GoogleService-Info.plist
└── production/
   └── GoogleService-Info.plist

android/app/src/
├── development/
│   └── google-services.json
├── sandbox/
│   └── google-services.json
└── production/
   └── google-services.json

つづいて、各FirebaseプロジェクトをDartのコード内から扱うための設定をします。

Flavor enumの定義

lib/config/env/flavor.dart でFlavorを定義します。Dart 3以降ではenumに直接メソッドやgetterを定義できます。

import 'package:firebase_core/firebase_core.dart';
import 'package:your_app/config/firebase/firebase_options_development.dart'
   as development;
import 'package:your_app/config/firebase/firebase_options_production.dart'
   as production;
import 'package:your_app/config/firebase/firebase_options_sandbox.dart'
   as sandbox;

enum Flavor {
 local,
 development,
 sandbox,
 production;

 /// Dart define から Flavor を取得
 static const _flavorStr = String.fromEnvironment('FLAVOR');
 static Flavor get fromDartDefine => Flavor.values.byName(_flavorStr);

 /// Firebase設定
 FirebaseOptions get firebaseOptions {
   switch (this) {
     case Flavor.local:
     case Flavor.development:
       return development.DefaultFirebaseOptions.currentPlatform;
     case Flavor.sandbox:
       return sandbox.DefaultFirebaseOptions.currentPlatform;
     case Flavor.production:
       return production.DefaultFirebaseOptions.currentPlatform;
   }
 }
}

設定自体はこれで完了です。

main.dartでの使用例

実際にDartのコード内でFirebaseを扱うにはこのようにします。

void main() async {
 WidgetsFlutterBinding.ensureInitialized();

 final flavor = Flavor.fromDartDefine;

 // Flavorに応じたFirebase初期化
 await Firebase.initializeApp(
   options: flavor.firebaseOptions,
 );

 runApp(MyApp(flavor: flavor));
}

以上で、Firebaseに関する設定は完了です。

5. アプリアイコンの切り替え

flutter_launcher_icons パッケージを使用して、Flavor別のアイコンを生成することもできます。
各環境用のアプリを視覚的に区別できるようになり、便利です。

アイコン画像の配置

各Flavor用のアイコン画像を作成し、assets/app_icons/ ディレクトリに配置します。

assets/
└── app_icons/
   ├── app_icon_local.png
   ├── app_icon_development.png
   ├── app_icon_sandbox.png
   └── app_icon_production.png

アイコン画像は1024x1024px以上の正方形PNGファイルを推奨します。

設定ファイルの作成

各Flavor用の設定ファイルを、プロジェクトルートに作成します。

flutter_launcher_icons-local.yaml

flutter_launcher_icons:
 image_path: "assets/app_icons/app_icon_local.png"
 android: true
 ios: true
 remove_alpha_ios: true

 
flutter_launcher_icons-development.yaml

flutter_launcher_icons:
 image_path: "assets/app_icons/app_icon_development.png"
 android: true
 ios: true
 remove_alpha_ios: true

 
flutter_launcher_icons-sandbox.yaml

flutter_launcher_icons:
 image_path: "assets/app_icons/app_icon_sandbox.png"
 android: true
 ios: true
 remove_alpha_ios: true

 
flutter_launcher_icons-production.yaml

flutter_launcher_icons:
 image_path: "assets/app_icons/app_icon_production.png"
 android: true
 ios: true
 remove_alpha_ios: true

 

アイコン生成

最後に、下記のコマンドを実行するとアイコンの生成が行われます。

# 全Flavorのアイコンを生成
dart run flutter_launcher_icons

# 特定Flavorのみ生成
dart run flutter_launcher_icons --flavor development

以上で、全ての設定が完了しました。

7. 実行方法

最後に、ここまでで設定したFlavorを使い、実際にアプリを実行/ビルドする方法を紹介します。

コマンドラインから実行
# local環境で実行
flutter run --flavor local --dart-define=FLAVOR=local

# development環境で実行
flutter run --flavor development --dart-define=FLAVOR=development

# sandbox環境でリリースビルド
flutter run --flavor sandbox --dart-define=FLAVOR=sandbox --release

# production環境でアーカイブ
flutter build ios --flavor production --dart-define=FLAVOR=production
flutter build appbundle --flavor production --dart-define=FLAVOR=production

 

Makefileでの管理

毎回コマンドを打つのは面倒なので、Makefileにまとめると便利です。

run: ## Run app. Pass F={local,development,sandbox,production}, M={profile,release} (optional)
ifeq (${M},)
 flutter run --flavor ${F} --dart-define=FLAVOR=${F}
else
 flutter run --flavor ${F} --dart-define=FLAVOR=${F} --${M}
endif

 
使用例:

make run F=development        # デバッグモード
make run F=production M=release  # リリースモード

 

IDE(VS Code / Android Studio)での実行

各IDEで実行する場合は、それぞれ設定が必要です。

VS Code(設定)
.vscode/launch.json に設定を追加:

{
 "version": "0.2.0",
 "configurations": [
   {
     "name": "Local",
     "request": "launch",
     "type": "dart",
     "args": ["--flavor", "local", "--dart-define=FLAVOR=local"]
   },
   {
     "name": "Development",
     "request": "launch",
     "type": "dart",
     "args": ["--flavor", "development", "--dart-define=FLAVOR=development"]
   },
   {
     "name": "Sandbox",
     "request": "launch",
     "type": "dart",
     "args": ["--flavor", "sandbox", "--dart-define=FLAVOR=sandbox"]
   },
   {
     "name": "Production",
     "request": "launch",
     "type": "dart",
     "args": ["--flavor", "production", "--dart-define=FLAVOR=production"]
   }
 ]
}


上記の設定により、Run and Debugから構成名を選択して実行できるようになります。

Android Studio(設定)

  1. 上部メニューから RunEdit Configurations... を選択
  2. 左上の「+」ボタンをクリックし、「Flutter」を選択
  3. 各Flavor用の設定を作成:
項目 設定値(developmentの例)
Name Development
Dart entrypoint lib/main.dart
Additional run args --dart-define=FLAVOR=development
Build flavor development

4. 同様に local 、sandbox、production 用の設定も作成

これにより、ツールバーのドロップダウンから実行したいFlavorを選択して実行できるようになります。

8. まとめ


本記事では、FlutterのFlavor設定をiOS/Android両対応で解説しました。

ポイントのおさらい
  1. Android:build.gradleproductFlavors で設定する
  2. iOS:Xcode Scheme + Build Configurationで設定する。命名規則が重要
  3. Firebase:FlutterFire CLIで各Flavorの設定ファイルを生成する
  4. Dart:--dart-define でFlavorを渡し、enumで管理する

Flavorを適切に設定することで、開発効率とリリース品質の両方を向上させることができます。

行動を分解すると、課題や解決策が見えてくる~「プロダクトデザインの第一歩」体験ワークショップ

こんにちは、プロダクトデザイナーの庄司です。
今回は、社内で「プロダクト開発を皆に身近に思ってもらう」を目的として開催したワークショップについて紹介します。

弊社では毎月末にその月の成果等を発表する締め会(*1)を行っています。

その中に毎月テーマを変えた全社ワークショップの時間があり(*2)、先日は私がファシリテーターとなりプロダクトデザインに関するワークショップを開催しました。

*1 参考:文化は育てるもの。また“締め会”で会いましょう
*2 参考:問いを囲んで、チームを耕す月イチワークショップ

デザインとは設計である

もともとは、毎月のワークショップを主催している組織開発のメンバーから打診を受けたのがきっかけです。

「新規プロダクトのリリース予定もあることだし、プロダクト開発を皆に身近に思ってもらえるように、デザインのワークショップを行いませんか?」というオファーでした。

説明スライドのキャプチャ

  1. 新規アプリの開発が進行中だが、社内のデザインへの理解がまだ浅い。
  2. プロダクト開発全体をデザインの観点で巻き込むことが必要。

このような課題意識を受け、情報設計の体験をしてもらうワークショップを考えました。
プロダクトデザインは、デザインという言葉のイメージから、どうしても「見た目を整える仕事でしょ?」と思われがち。確かにそういう側面もあるのですが、実際にはプロダクトの体験やそのプロダクトがもたらす課題解決まで含めた情報設計を行う仕事です。デザインとは設計であると理解してもらうことが第一歩。

そんなわけで、学生時代に「インフォメーション・アーキテクト」の授業で行ったワークを大いに思い出しつつ、多少の応用を加えたワークショップを考えました。

このワークショップは、2つのパートで構成しました。
①行動を洗い出す
②課題を見つけ、ソリューションを考える
順に紹介していきます。

①行動を洗い出してみる

例として「カップやきそばを作って食べる」場合。
その中にあるたくさんの行動を、ひとつひとつ書き出してみます。
「ビニール包装を取る」「蓋を開ける」「あと入れの袋を取り出す」……。

実際にやってみると、全てを書き出すのは果てしない作業に思えてくるかもしれません。
一つの行動として扱っているものでも、考えてみると実は無数の行動の連なりであることに気づくからです。 また、同じ「ビニール包装を取る」にしても爪を立てるのかハサミを使うのかなど多くの派生が生まれうるでしょう。

しかし今回はざっくりこれだけの行動の連なりで体験ができているということが分かればいいです。例えばこのようなサンプルを作成しました。

カップ焼きそばを作って食べる工程の図。11工程ある
良い洗い出しの例
カップ焼きそばを作って食べる工程の図。2工程
大雑把すぎるのは×

個人ワーク「歓迎会ランチの予約をするときの行動」

ワークショップでは、このパートは個人ワークとして行いました。時間は10分間。

テーマは「歓迎会ランチの予約をする」に統一しました。テーマを自由設定にせず統一することによって、あとで比較しやすく、人によって行動を分解する粒度の違いも見えやすいからです。
またより具体的にイメージするため、「ランチ参加人数10名」「12:00〜」「オフィス周辺」「予算2,000円以下」といった条件も付け加えています。

ワークはmiroにスペースを作って行いました(FigJamなどの他ツールや、紙の付箋でも実施できます)。
自分の名前のあるエリアに、一つの行動につき一つの付箋を貼っていきます。

ワークの結果

ワークの結果

まず最初の行動を見てみても、「何を食べるか考える」「アレルギーについて聞く」、はたまた「社内のグルメの●●さんにまかせる」のようなユニークなものまで個性が出ます。

終わらせ方についても、「予約」で終わる人もいれば、「予約完了のslackを送る」「カレンダーにお店のURLを貼る」など、どこまでを一連の行動とみなしているかが可視化されて非常に興味深かったです。

②課題を見つけ、ソリューションを考える

さて、行動を書き出していくにつれ、いろいろなことが見えてきます。
「この行動の不便を解消するためにこの機能が生まれたのでは?」という気づきを得たり、「こうすることで解決できるのでは?」と課題の解決法を思いついたり。

たとえばカップ焼きそばの例であれば、「お湯を捨てる」時にうっかり麺も一緒に流してしまう……という不便が思いつきますし、そのためのソリューションとして湯切りの穴が開発されたのだろう、ということも想像できます。

説明用スライドのキャプチャ

別の例として、情報デザインの大切さとして私がよく引き合いに出す事例があります。1996年に発売された日立の冷蔵庫、「野菜中心蔵」です。

それ以前の冷蔵庫は野菜室が一番下なのが普通でした。しかしこの製品は野菜室が真ん中にあります。使用シーンの行動を洗い出していくにつれ、「野菜を取り出すためにしゃがまなければいけない」という課題(ペイン)に行きつき、その解決案として野菜室を中心におくという手段を取ったであろうことが想像できます。

説明用スライドのキャプチャ

こういった気付きは、日常の中にも隠れています。

「帰宅」の行動を洗い出してみる
→「手を洗う」際に毎回「泡立てる」という行動が発生する
→「泡立てる」手間を解消するための、プッシュすると泡で出てくるディスペンサー

「webで新規会員登録」の行動を洗い出してみる
→住所を入力するフォームで、郵便番号にくわえ全ての住所を手入力する行動が発生する
→郵便番号を入力すれば、地名まで入力された状態になる自動入力フォーム

などなど。

ちなみに、ここに行動に付随する感情変化を付け加えていけば、いわゆるカスタマージャーニー、ペイシェントジャーニー等を作成することができます。(が、長くなるのでそれに関してはまた別の機会に...)

グループワーク「歓迎会ランチ予約のソリューションを考える」

ワークショップでは、このパートは40分間のグループワークとして行いました。
ランダムに指定した4~5人ずつのグループに分かれ、「行動に対するソリューションを考えてみる」をテーマにディスカッションしてもらいます。

まずは個人ワークで自分が書き出したものをグループ内で共有しあいます。
その後、みんなに共通していた行動や、書き出してみてストレスがあると感じた行動、この行動に対してこんな機能や仕組みがあったら問題が解決されるのではないか……などを話しあってもらいました。
実現可能かどうかは置いておいて、あくまで自由に話し合ってもらうのがポイントです。

最後にそれぞれのグループの代表に、どんな案がでたのかを軽く紹介してもらいました。
「多数決が決まったら勝手に予約されるシステム」「AIエージェントで条件の店をリスト化」のような開発者らしい意見が出たり、「歓迎会はいつも同じお店にする」といったそっちで解決するんかい!と笑ってしまいたくなるようなアイディアも出てきて、大変興味深かったです。

ワークショップを終えて

今回のワークショップは、情報設計というデザインの基本の部分を身近な行動に落とし込んで体験してもらおうというものでした。

どんな行動でも、洗い出し細分化していくと、無意識に行っていた無数の行動が存在することに気づきます。そしてそこには必ずユーザーの体験を改善するヒントが隠れています。

デザインとは何かを体験する目的のみならず、サービスやプロダクトを新しく考える時、はたまたそれらのUXを改善したい時など、行き詰まったらまずは「行動を洗い出してみる」ワークをぜひやってみてください。


似顔絵
書いた人:庄司

ホルモン治療薬が身体のリズムに与える影響:女性たちの半年間のデータから見えたこと

こんにちは、データサイエンスチームの瀬川です。

テックドクターでは、女性社員のみで構成された「Ladynamic」プロジェクトを通して、女性の視点に立った課題提起とデータ解析を目指しています。同プロジェクトでは女性の健康に関する様々なデータ分析を行っており、これまでのブログ記事でもいくつかの事例をご紹介しました。

techblog.technology-doctor.com

techblog.technology-doctor.com

そんなLadynamicプロジェクトから、今回は基礎体温についてのお話です。

基礎体温は排卵の前には低く、排卵の後には高くなるというように、2つの段階に分かれます(このような性質を「二相性」と呼びます)。また、この変化には月経周期と連動した周期性があります。この連動を利用して月経周期の把握ができるため、基礎体温を毎日記録されている方もいらっしゃるかと思います。

基礎体温のニ相性。引用元:病気が見えるvol.9 婦人科・乳腺外科(第4版)(メディックメディア)

スマートウォッチ等の一般的なウェアラブルデバイスで基礎体温を記録することは、現時点ではできません(※)。代わりにウェアラブルデバイスから得られる別のデータを使用して、この月経周期と連動した周期性と同様の傾向は確認できないでしょうか?

※皮膚温であればFitbit等で記録が可能ですが、その違いについては後ほど触れます。また一般的なスマートウォッチ等ではない、基礎体温測定用の専用のウェアラブル機器は存在します。

本稿では、この疑問を解明するため、月経周期とウェアラブルデバイスデータで得られた心拍数・脈拍数との関連を探っていきます。また、ホルモン製剤を服用している人と服用していない人とのデータから、服薬がその関連にどういった影響を与えるかも調べていきたいと思います。

イメージイラスト

ウェアラブルデータが月経周期と関連するかどうか

まずはウェアラブルデバイスのデータが月経周期と連動した周期性(二相性)を示すかどうかを検証しました。

【対象データ】

女性ホルモン製剤を服用していない女性社員(Aさん)の約半年間のデータを可視化しました。

使用ウェアラブルデバイス:Fitbit
指標:安静時心拍数、起床直前30分(基礎体温の計測タイミングに合わせて)の脈拍数、皮膚温

【結果】

グラフはそれぞれの指標の1日ごとのデータを点で表し、7日移動平均線を表示しています。月経開始日は縦の赤線で示しました。

グラフ

分析の結果、安静時心拍数と起床直前30分の脈拍数については、月経開始日に向けて上昇し、その後低下するという傾向が見られました。この二つの値については、月経周期と連動した周期性があると言えそうです。

一方、皮膚温では心拍数・脈拍数データほどの明確な周期性は確認できませんでした。皮膚温も基礎体温と同様に周期性を示すかと思われましたが、Fitbitを装着した手の位置(布団の中か外か)や室温といった外部環境の影響を受けやすく、月経周期の影響が反映されにくい結果になったと考えられます。

ホルモン剤を服用することによって周期性が変化するのかどうか

次に、ウェアラブルデバイスの心拍数データと月経周期との関係が、女性ホルモン製剤を服用している方々でどのように異なるかを検証しました。

【対象データ】

2名の女性社員から得られた約半年間のデータが対象です。
先ほどの検証で月経周期と特に明確な関連性が見られた、安静時心拍数に注目して分析を進めました。

  • Aさん: 何も服薬していない方
  • Bさん: ジエノゲストを服用している方

【ジエノゲストの作用】

可視化したデータを見る前に、ジエノゲストが体にどのような作用をもたらすのかを簡単に説明します。

  • ジエノゲスト:

子宮内膜症や子宮腺筋症に伴う痛みの治療に使用され、毎日服用します。
女性ホルモンの一種であるプロゲステロン受容体に対して似た働きをし、卵巣機能抑制および子宮内膜細胞の増殖抑制によりプロスタグランジン産生を抑制することから、月経困難症に対する有効性を示すと考えられます。また、LHサージを抑制し、排卵抑制作用を示すと考えられます(1)。排卵が抑えられ月経が来なくなります。

ジエノゲストの服用は、女性の身体の周期性にどのように影響を与えるのでしょうか。

【結果】

2人の安静時心拍数の経過を可視化しました。

グラフ

グラフからは、以下の特徴が見られました。

服用なし(Aさん):

先ほど見たとおり、安静時心拍数が月経開始日に向けて上昇し、その後低下するという周期性が見られます。女性ホルモンの変動が心拍数にも影響を与えている可能性があります。

ジエノゲスト服用者(Bさん):

Aさんよりグラフの変動幅が少なく、明確な周期性は見られませんでした。
ジエノゲストは毎日服用することから服用による女性ホルモン量の変動が少なくなります。これにより、安静時心拍数の周期性が、服用なしのAさんよりもみられないのではないかと考えています。

まとめ

今回の調査から、以下のようなことがわかりました。

  • 安静時心拍数と起床直前30分の脈拍数については、月経開始日に向けて上昇し、その後低下するという傾向が見られ、月経周期と連動した周期性があると言えそうです。
  • ジエノゲストを服用している方の心拍数に周期性が見られなかったことは、薬が女性ホルモンを通じて身体の周期性にも影響を与えているという可能性を示唆しているかもしれません。

ジエノゲストなど女性ホルモン剤の服用者に関する基礎体温や体調のデータはまだ多くありません。そんな中、ウェアラブルデバイスのデータは、毎朝手動で計測しなければいけない基礎体温と違って継続的に女性の体調を記録できるという点から非常に意義があると考えられます。今回の可視化から得られた「女性ホルモンが生体へ影響を与える可能性がある」という結果も、今後の女性の体調管理に役立つかもしれません。

今回の調査は少数のデータに基づいたものであり、今後医学的観点からの考察をより深める必要があります。今後も、より多くのデータを集め、女性の健康に関する課題を解き明かし、よりパーソナライズされた健康管理や治療法の開発に貢献していきたいと考えています。

DDDにCQRSをどう組み込むか~バックエンドアーキテクチャ設計時の考え方

はじめに

こんにちは。テックドクターでバックエンドエンジニアをしている筧と申します。

新規プロダクトのバックエンドで、DDD (Domain-Driven Design) と CQRS (Command Query Responsibility Segregation) を組み合わせたアーキテクチャを採用しました。

DDDの本や記事は、Eric Evans著『Domain-Driven Design』や『実践ドメイン駆動設計』など様々あります。CQRSについてもMartin Fowler氏のCQRSの解説記事などがあります。しかし、DDDにCQRSをどう組み込んでいったかという話はあまり見かけません。

この点について以前より情報収集や試行錯誤を重ねていましたが、今回のプロダクトでようやく納得のいく形で実装ができました。この記事ではそのポイントをご紹介します。特にCQRSを具体的に実装していくApplication層を中心に、他の層とのデータのやりとりや責務分担について詳しく説明したいと思います。

この記事の想定読者とゴール

この記事は、以下のような方を想定しています:

  • DDDの基本(Entity、Value Object、Repository等の概念)は理解している
  • 実際のコードでCQRSをどう導入すればよいかわからない

記事を読み終わったときに、Application層のCommand/QueryHandlerの実装や、他の層とのデータのやりとりを具体的にイメージできるようになることがゴールです。

CQRSとは何か

まず、この記事でのCQRSの定義を明確にしておきます。

CQRSは、データを変更する操作(Command)と、データを読み取る操作(Query)を分離するパターンです。通常のCRUDではデータモデルが読み書き共通ですが、CQRSでは以下のように分けます:

  • Command側(書き込み): ビジネスルールの検証を重視。Entityを経由してデータを更新
  • Query側(読み込み): パフォーマンスと利便性を重視。最適化されたクエリで直接DTOにマッピング

この分離により、それぞれの操作に最適な実装を選択できるようになります。

全体像:レイヤー構成の概略と責務の割り当て

今回のプロダクトは一種のダッシュボードシステムで、データの集計・可視化を行うほか、組織やユーザー情報等の登録も行います。
レイヤー構成は以下の5層としました。

層名 説明
Presentation層 ← API エンドポイント(薄い層)
UseCase層 ← ビジネスフロー + 認可制御
Application層 ← Command/Query Handler(純粋なCRUD)
Domain層 ← Entity、Value Object、Repository Interface
Infrastructure層 ← Repository実装、Query Service

各層の責務を簡単に整理します。

Presentation層はHTTPリクエスト/レスポンス変換のみを担当します。ビジネスロジックは持たせません。

UseCase層は認可チェックや、複数のCommand/Queryを組み合わせたビジネスフローを扱います。

Application層は純粋なCRUD操作のみで、認可処理は持ちません。

Domain層はビジネスルールの中核を担い、他の層に依存しません。

Infrastructure層はデータベースや外部サービスとの実際のやりとりを担当します。

Core層もありますが、DIコンテナや共通設定を担う補助的な層なので本稿では詳しく扱いません。

特に重要なのはUseCase層とApplication層の境界です。Application層のCommand/QueryHandlerは認可処理を持たず、純粋にドメインロジックに集中します。一方、認可や監査ログといった横断的な関心事はUseCase層で扱います。

この設計にした理由は、別プロダクトでUseCase層とその下のService層(今回のApplication層に相当)を分けて成功した経験があったからです。UseCase層にCQRSのCommand/Queryを直接配置すると、認可処理とデータ操作のロジックが混在してしまうため、Application層として分離しました。

Application層におけるCQRS実装

Application層では、Command(書き込み)とQuery(読み込み)を明確に分離しています。

Commandの実装例:組織を作成する

例として、組織を作成するCommandHandlerを見てみましょう。以下は実際のプロダクトのコードを簡略化したサンプルです。

class CreateOrganizationCommand(BaseModel):
    """組織作成コマンド"""
    organization_id: OrganizationId
    name: OrganizationName
    # ... その他のフィールド

class CreateOrganizationCommandResult(BaseModel):
    """組織作成結果"""
    organization: Organization
    created: bool

class CreateOrganizationCommandHandler(
    ICommandHandler[CreateOrganizationCommand, CreateOrganizationCommandResult]
):
    """組織作成コマンドハンドラー"""

    def __init__(self, organization_repository: IOrganizationRepository) -> None:
        self._organization_repository = organization_repository

    async def handle(
        self, command: CreateOrganizationCommand
    ) -> CreateOrganizationCommandResult:
        # 1. 既存チェック
        if await self._organization_repository.exists(command.organization_id):
            raise EntityAlreadyExistsException(...)

        # 2. ドメインモデルでビジネスルール検証
        organization = Organization.create(
            organization_id=command.organization_id,
            name=command.name,
            # ...
        )

        # 3. 永続化
        await self._organization_repository.save(organization)

        return CreateOrganizationCommandResult(
            organization=organization,
            created=True,
        )

このCommandHandlerで最も重要なのは、認可処理を一切持たない点です。「この組織を作成できる権限があるか?」といったチェックはUseCase層の仕事で、Application層は純粋に「組織の作成」というビジネスロジックに集中しています。

具体的な処理の流れは、入力としてCommand(必要なデータのみ)を受け取り、出力としてCommandResult(処理結果)を返します。内部では、ドメインモデルのOrganization.create()を使ってビジネスルール検証を行い(例えば、OrganizationNameというValue Objectで組織名の長さや形式をチェックしています)、最後にRepositoryで永続化します。

こうすることでHandlerがシンプルになり、テストも書きやすくなります。認可を気にする必要がなく、Infrastructure層のDBセッションにも依存しないため、モックのRepositoryを渡すだけで単体テストができました。

Queryの実装例:組織を取得する

次に、組織を取得するQueryHandlerです。

class GetOrganizationQuery(BaseModel):
    """組織取得クエリ"""
    organization_id: OrganizationId

class GetOrganizationQueryResult(BaseModel):
    """組織取得結果"""
    organization: OrganizationDTO | None

class GetOrganizationQueryHandler(
    IQueryHandler[GetOrganizationQuery, GetOrganizationQueryResult]
):
    """組織取得クエリハンドラー"""

    def __init__(self, organization_repository: IOrganizationRepository) -> None:
        self._organization_repository = organization_repository

    async def handle(self, query: GetOrganizationQuery) -> GetOrganizationQueryResult:
        organization = await self._organization_repository.find_by_id(
            query.organization_id
        )

        if organization is None:
            return GetOrganizationQueryResult(organization=None)

        # Entity → DTOへの変換
        organization_dto = OrganizationDTO(
            organization_id=str(organization.organization_id.value),
            name=str(organization.name.value),
            created_at=organization.created_at,
            # ...
        )

        return GetOrganizationQueryResult(organization=organization_dto)

QueryHandlerで重要なのは、Entity→DTOの変換です。入力としてQuery(検索条件)を受け取り、出力としてQueryResult(DTO形式のデータ)を返します。責務はデータ取得とDTO変換のみで、Command側のようなビジネスルール検証は行いません。

DTOに変換する理由は、Presentation層で使いやすい形にするためです。Entityはビジネスロジックを持つ重いオブジェクトですが、DTOは単なるデータ転送用の軽いオブジェクトです。この変換をApplication層で行うことで、Presentation層はシンプルに保てます。

データモデルの使い分け

実装していて最も悩んだのが、「どのデータモデルをどこで使うか」でした。

当初、各データモデルの役割は ”なんとなく” 決まっていたものの、具体的なルールがありませんでした。例えば、DTOの使い回しや、概念の分離ができていなかったりしました。

最終的に、各層の境界を明確にするため、以下のようにデータモデルを整理しました:

データモデル 役割 配置場所 命名の由来
Command/Query リクエストデータ(入力) Application層 CQRSの概念からそのまま
CommandResult
/QueryResult
レスポンスデータ(出力) Application層 Commandの結果、Queryの結果という明確な名前
Projection Infrastructure
→Application層のデータ
Infrastructure層 CQRSの文献で使われている用語(後述)
DTO Application
→Presentation層のデータ
Application層 特に他の名前が思いつかなかった

Projectionという名前について補足します。当初はXXResultという名前も検討しましたが、CommandResult/QueryResultと名前が被ってしまうこと、そしてRepositoryの結果とQuery Serviceの結果の両方があり「どちらの名前を取るか」という論争が起きそうだったため不採用にしました。

CQRSの文献を調べたところ、読み取り側のデータモデルとして「Projection」という用語が使われていること(参考1参考2)がわかり、この名前を採用しました。

これらを明確に分けることで、各層の関心事が混ざらないようにできています。特に名前付けには苦労しましたが、役割が明確になってからはコードの見通しが格段に良くなりました。

UseCase+Infrastructure:認可とデータアクセスをどう接続したか

Application層のCommand/QueryHandlerは純粋なCRUD操作だけを扱うので、認可処理はUseCase層で行います。

UseCaseでの認可統合:Command実行前に権限チェック

UseCase層では、Application層のHandlerをラップして認可処理を追加します。実装例を見てみましょう。

class CreateOrganizationUseCase:
    """組織作成UseCase(認可付き)"""

    def __init__(
        self,
        permission_checker: PermissionChecker,
        create_organization_handler: CreateOrganizationCommandHandler,
    ):
        self._permission_checker = permission_checker
        self._create_organization_handler = create_organization_handler

    async def execute(
        self,
        request: CreateOrganizationRequest,
        user_claims: JWTClaims
    ) -> Organization:
        # 1. 認可チェック(UseCase層の責務)
        await self._permission_checker.verify_role(user_claims, required_roles=["org-admin"])

        # 2. RequestからCommandへの変換
        command = CreateOrganizationCommand(
            organization_id=OrganizationId.generate(),
            name=OrganizationName(request.name),
            # ...
        )

        # 3. Application層のHandlerを実行
        result = await self._create_organization_handler.handle(command)

        return result.organization

このように、認可チェックとCommand実行を分離することで、いくつかのメリットがあります。

まず、Application層はビジネスロジックに集中できます。「組織を作成する」というドメインロジックに認可処理が混ざらないので、コードが読みやすくなります。

次に、認可ロジックを一箇所に集約できます。権限チェックの方法を変更したいとき、UseCase層だけ修正すれば済みます。

そして何より、テストが書きやすくなります。Application層はビジネスロジックの単体テストに集中でき、UseCase層は認可のテストとして分離できました。

認可処理の設計

認可処理の実装について、今回のプロダクトでは細かいロール設定をバックエンド側で担当することにしました。これにより、より柔軟な権限管理を実現しています。

Query Serviceでのデータ取得最適化

読み取り側において、複雑なクエリはInfrastructure層のQuery Serviceで最適化します。

Query Serviceとは、最適化されたクエリを実行するための読み取り専用のサービスです。Repositoryパターンとは異なり、複数のテーブルをJOINして1回のクエリで必要なデータを取得することに特化しています。

今回のプロダクトは、ダッシュボードシステムであり、複雑なデータの集計・可視化を行います。Repositoryパターンだけだと大量のクエリが発生し、パフォーマンスが低下する可能性があるため、CQRSを採用し、読み取り側ではQuery Serviceを使って1回のクエリで効率的にデータを取得しています。

具体例を見てみましょう(実際のプロダクトのコードを簡略化したものです):

class OrganizationQueryService:
    """組織クエリサービス(Infrastructure層)"""

    async def get_organization_with_stats(
        self, organization_id: str
    ) -> OrganizationProjection | None:
        # 最適化されたJOINクエリで一度に取得
        query = select(
            OrganizationModel.id,
            OrganizationModel.name,
            func.count(MemberModel.id).label('member_count'),
            func.count(ItemModel.id).label('item_count'),
        ).select_from(
            OrganizationModel
        ).outerjoin(
            MemberModel
        ).outerjoin(
            ItemModel
        ).where(
            OrganizationModel.id == organization_id
        ).group_by(OrganizationModel.id)

        result = await self._session.execute(query)
        row = result.first()

        if not row:
            return None

        # Projectionとして返す(DTO変換はQueryHandlerで)
        return OrganizationProjection(
            id=row.id,
            name=row.name,
            member_count=row.member_count or 0,
            item_count=row.item_count or 0,
        )

ここでのポイントは、Query ServiceがProjectionを返す点です。データフローは次のようになります:

QueryService(Projection) → QueryHandler(DTO) → UseCase → API(Response)

ProjectionはInfrastructure層からApplication層へのデータモデル、DTOはApplication層からPresentation層へのデータモデルと、役割が分かれています。

この設計にしたのは、Infrastructure層にドメイン知識を漏らさないためです。もしQuery ServiceがDTOを直接返すと、Infrastructure層がPresentation層の都合(どんな形式でデータを返すか)を知る必要があり、依存方向が逆転してしまいます。Projectionという中間データモデルを挟むことで、各層の責務を明確に保てています。

Query ServiceとRepositoryの使い分け

具体的な判断基準として、複数回JOINが必要かどうかを見ています。

単純な取得であればRepositoryを使いますが、組織に紐づく複数の関連データを同時に取得する場合など、複数のテーブルをJOINする必要がある場合はQuery Serviceを使います。これにより、クエリ実行回数を抑え、パフォーマンスを向上させています。

Presentation層とCore層の扱い

Presentation層:UseCaseに委譲するだけの薄い層

APIエンドポイントは本当に薄く、UseCaseに処理を委譲するだけです。

@router.post("", response_model=CreateOrganizationResponse, status_code=201)
@require_roles(["org-admin"])
async def create_organization(
    request: CreateOrganizationRequest,
    create_organization_use_case: Annotated[
        CreateOrganizationUseCase, Depends(get_create_organization_use_case)
    ],
    user_claims: Annotated[JWTClaims, Depends(get_current_user)],
) -> CreateOrganizationResponse:
    """組織作成エンドポイント"""
    try:
        # UseCaseに処理を委譲
        organization = await create_organization_use_case.execute(request, user_claims)

        # レスポンスに変換して返すだけ
        return CreateOrganizationResponse(
            organization_id=str(organization.organization_id.value),
            name=str(organization.name.value),
            # ...
        )
    except ValidationException:
        # エラーハンドリング(詳細は省略)
        raise

エンドポイントではビジネスロジックを持たず、HTTPの世界とアプリケーションの世界を繋ぐ責務だけに徹しています。実際のコードを見ても、UseCaseの実行結果をResponseに変換し、例外をHTTPステータスコードに変換しているだけです。

この設計にしてよかったのは、エンドポイントのテストがシンプルになったことです。HTTPリクエストの形式が正しいかだけをテストすればよく、ビジネスロジックのテストはUseCase層で完結します。

Core層

Core層はDI ContainerやJWT検証といった共通機能を提供していますが、アーキテクチャの中心ではないので本稿では詳しく触れません。重要なのは、各層の責務を明確に分けることです。

まとめ:実際に導入してみて感じたメリット

DDD+CQRSを実際のプロダクトに導入してみて、以下のメリットを感じました。

まず、認可ロジックの明確化です。UseCase層に認可処理を集約することで、「誰が何にアクセスできるか」が一目でわかるようになりました。

次に、テストの容易性です。Application層のHandlerがシンプルなので、単体テストが書きやすくなりました。認可処理と分離されているため、ビジネスロジックのテストに集中できます。また、Infrastructure層に依存しないため、DBセッションを用意する必要もありません。

そして、保守性の向上です。各層の責務が明確なので、変更の影響範囲が予測しやすく、コードの見通しが良くなりました。新しいメンバーがジョインしたときも、「この機能はどこを見ればいい?」という質問に明確に答えられるようになっています。

特にApplication層とUseCase層を分けたことで、ビジネスロジックと認可処理が混ざらず、それぞれに集中できるようになりました。当初は「層が多すぎて複雑では?」と心配したのですが、実装を進めるうちに明確なルールが整備され、責務が明確な分、むしろシンプルに感じています。

DDDもCQRSも学習コストは高いですが、実際に手を動かして設計してみると、その価値が実感できます。この記事が、これからDDD+CQRSを導入しようとしている方の参考になれば幸いです。

似顔絵
書いた人:筧

UI生成ツールv0でゼロイチ開発が変わった話~AI製プロトタイプの「捨てやすさ」が仮説検証を加速する

こんにちは。テックドクターでプロダクトマネージャーをしている田向です。

テックドクターでは各種AIツールを積極的に導入し、プロダクト開発のプロセス改善に取り組んでいます。

中でもUIデザイン生成ツールv0の導入は、単に個々のプロトタイプの実装を効率化するだけでなく、プロトタイプ開発全体を大きく加速させてくれました。

本記事では、v0の概要から具体的な導入効果までをご紹介します。

v0とは

v0は、Vercelが開発したAIツールです。自然言語のプロンプトや画像、Figmaファイルなどをもとに、WebやモバイルアプリのUIを簡単に作成できます。

v0.app

v0を選んだ理由

他にもUIを生成できるAIツールはありますが、今回は下記の理由からv0を選びました。

非エンジニアでも使える手軽さ

v0の大きな魅力は、自然言語のプロンプトでUIを生成できる点です。

大まかな指示でも一定の品質のものが作成できますが、プロンプトで要件を細かく指定したり参考画像を提示することで、さらに質が高く、意図に近いプロトタイプを仕上げることができます。

簡易的な指示で作成したサンプル。所要時間は1分程度!プロンプトは「プロジェクト管理のSaaSのダッシュボード画面を作成してください」

shadcn/ui との親和性

現在開発中のプロダクトでは、UIコンポーネントライブラリにshadcn/uiを採用しています。
v0はshadcn/uiを使ってUIを生成するため、プロトタイプとして作成したコードを本番の実装にも活かすことができ、開発のリードタイム短縮に繋がります。

※shadcn/uiとは

shadcn/uiは、Radix UIとTailwind CSSを組み合わせて構築された、カスタマイズ性に優れたコンポーネントライブラリです。
従来のライブラリと異なり、必要なコンポーネントのコードを直接自分のプロジェクトにインストールする方式のため、デザインの微調整や機能追加を自由に行えるのが特徴です。

v0の導入効果

v0を導入することで、様々なメリットがありました。

プロトタイプ開発の高速化とコスト削減

v0の導入前、プロトタイプを開発するには、①エンジニアやデザイナーに要件を伝え、②仕様書を作成し、③開発する……という工程が必要でした。関わる人数も多くなりますし、開発期間もアイデアから検証までに数週間かかることが当たり前でした。

v0導入後は、プロダクトマネージャーが自然言語の指示により自分でプロトタイプを作成できるようになりました。プロトタイプ作成にくわえフィードバックを受け改善するサイクルまでが1人で完結するようになったことで、プロトタイプ開発のコストが大幅に削減されました。

認識のズレと開発の手戻り防止

従来、仕様の共有はドキュメントや静的なワイヤーフレームで行っていましたが、静的なドキュメントだけでは関係者がそれぞれ頭の中で挙動を想像しながら議論することになり、認識のズレが生まれがちです。
その結果、実装後に「イメージと違う…」という手戻りが発生することも少なくありませんでした。

v0導入後はv0で作成したプロトタイプを中心に議論することで、認識のズレに起因する開発の手戻りを未然に防げるようになりました。

「捨てやすさ」が仮説検証を加速する

プロトタイプの目的は、あくまでアイデアの仮説検証です。

仮説検証の結果、もしユーザーにとってそのアイデアに価値がないと分かれば、すぐに捨てて次のアイデアを考えるのが正しいアプローチです。

しかし、エンジニアが時間をかけて作ったプロトタイプは、「せっかく作ってもらったのだから…」というサンクコストバイアスに陥りやすく、客観的な判断を鈍らせる原因になり得ます。

その点、v0で作成したプロトタイプはひとりで短時間で作成したものです。検証結果が思わしくないときは、何の心理的な負い目も感じることなく、躊躇なく捨てることができます。

このことが、プロトタイプ開発における仮説検証のサイクル全体を大きく加速させてくれました。

今後の展望

現在はshadcn/uiのレジストリからコンポーネントを配布し、v0のプロジェクトに適用する検証を進めています。
本番環境と同等のコンポーネントを使うことで、質の高いプロトタイプをより短時間で作成できるようになることを目指しています。

ui.shadcn.com

今回はAIツール導入の一例として、v0の導入効果についてご紹介しました。参考になれば幸いです。

似顔絵
書いた人:田向