エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

アンケートのバリデーション設計を考える 〜AIと探る実装の最適解〜

【Unit7 ブログリレー4日目】 こんにちは、エムスリーエンジニアリンググループのUnit7(リサーチプロダクトチーム)でチームリーダーをやっている遠藤(@en_ken)です。

私たちのチームは、アンケートシステムをはじめとするリサーチプロダクトを開発していますが、近年、大規模なシステムリアーキテクチャを推進しています。その背景や全体像については、ぜひこちらの記事をご覧ください。

www.m3tech.blog

記事内でも触れている通り、リアーキテクチャの大きな方針として「アンケート回答機能を1つのシステムに集約する」という目標を立てました。この目標を実現するためにゼロから開発されたのが、アンケート回答システム「Lion」です。

アンケートシステムを改めて構築してみると、既存のシステムのユースケースもカバーしつつ、汎用性を考慮して設計するというのは難しい点が多数あります。中でも、回答の整合性を担保するバリデーション周りの設計は一筋縄ではいかず、チーム全員で議論しながら設計を行いました。

設計していくうち「自分たちの設計は本当に正しいのか?もっと良い設計があるのでは?」という疑問が湧いてきたので、Geminiを活用した議論も行いました。本記事では、その様子をご紹介します。

アンケート回答システム「Lion」

Lionは、先に述べた通り、エムスリーに存在する多種多様なアンケートのユースケースをカバーするために作られた汎用的なアンケート回答システムです。

アンケートは基本的に、設問を構成する「画面」と、回答内容に応じた「分岐」の組み合わせで表現できます。Lionでは、この複雑なフローチャートのような構造を、JSONで宣言的に表現できるように設計されています。

アンケート定義の構造のイメージ(実際のものとはだいぶ異なる点はご留意ください。)

機能面では、アンケートシステムの基本機能はカバーしつつ、既存のアンケートユースケースを包含するために、次のような機能を有しています。

  • スマートフォン重視のデザイン
  • 1アンケートへの複数回回答オプションの提供
  • 回答の再編集オプションの提供
  • 設問の回答条件に応じた後続設問のループ回答機能

技術面では、アンケート画面はReact+TypeScriptで構成しています。 技術的な特徴を挙げると、

  • チーム初のサーバーサイドTypeScript (Hono)
  • pnpm workspaceを利用したMonorepo構成
  • インフラ構成: Cloud Load Balancing + Cloud Run + Cloud Spanner

など紹介したい点は多々ありますが、長くなってしまうので別の機会に譲るとして、今回はアンケートのバリデーションにフォーカスを当てていきます。

アンケート回答のバリデーション

アンケートにおけるバリデーションと一口に言っても様々ですが、本記事では特に 「クライアントサイドで、ユーザーの入力にリアルタイムで応答するバリデーション」 にスコープを絞って解説します。

その中でも特に厄介なのが、複数の設問にまたがるバリデーションです。

例えば、次のような設問があったとします。

Q1. この1ヶ月に購入した商品の総数は?

Q2. そのうち、書籍の数は?

このとき、「書籍の数は、購入した商品の総数以下の数値でなければならない」というルールを設け、データの矛盾を防ぎたいです。ユーザーが書籍の数に総数より大きい値を入力したら、即座にエラーメッセージを表示する必要があります。

Lionでは、このようなバリデーションルールもJSON定義で宣言的に実現します。具体的には、設問オブジェクトの中にvalidationsというキーを設け、バリデーションのルールと、それがどの設問の回答に依存しているかを宣言的に記述します。実際の定義はもっと複雑ですが、簡略化するとイメージとしては次のようになります。

{ // 例: Q1(商品の総数)の設問定義
  "id": "Q1",
  "type": "number",
  "validations": [
    {
      "rule": "more_than_or_equal_to", // 以上
      "refId": "Q2", // Q2(書籍の数)の回答と比較する
      "errorMessage": "書籍の数以上の値を入力してください。"
    }
  ]
},
{ // 例: Q2(書籍の数)の設問定義
  "id": "Q2",
  "type": "number",
  "validations": [
    {
      "rule": "less_than_or_equal_to", // 以下
      "refId": "Q1", // Q1(商品の総数)の回答と比較する
      "errorMessage": "商品の総数以下の値を入力してください。"
    }
  ]
}

ここに加えて、

  • 設問への回答が必須か
  • 設問が表示されているか(表示条件によって出たり出なかったりする設問の場合)
  • 特定ユースケースをカバーするための専用設問の場合、設問自体がビジネスロジックとして持つバリデーション結果はどうなっているか

などの要素が影響してくるため、さらにややこしくなっていきます。

バリデーションの実現方法を考える

このようなJSON定義を元に、Reactコンポーネントでバリデーションをどう実現するかが本題です。 次のような設計になっていることを前提とします。

  • 回答データおよびエラーは上位コンポーネントのStateで一元管理している。
  • 各設問コンポーネントはContext APIを通じて回答データにアクセスできる。

そして、知識の凝集性を高めコンポーネント間の結合度を下げる観点から「バリデーションをどう処理するのかは各設問コンポーネントで管理する」ことを設計制約として話を進めます。

また、React Hook FormやFormikなどのライブラリの採用も一旦見送ることとしました。Lionはリサーチプロダクトチームの基盤として長期維持していくことを想定しており、 フロントエンドライブラリは栄枯盛衰が速いことに加え、ライブラリでカバーできない(あるいは、実現できるが実装しづらい)ユースケースが出てくる可能性もあり得るため、現段階では利用は避け自作する判断をしています。(React Hook Formはそうそう廃れなそうですが。。)

イベントハンドラでの実現を考える

回答が入力された(onChange)ことを受けてバリデーションが走るわけなので、イベントハンドラ内で実現するのが最も素直な考え方です。

しかし、この方法で設問間の依存関係を扱おうとすると、すぐにつまずきます。 Q2のコンポーネントのイベントハンドラでバリデーションを実行するには、Q1の回答データが必要です。Contextを使えばQ1のデータは取得できますが、逆にQ1の値が変更されたときに、Q2のバリデーションをどうやって再実行するのか? という問題が出てきます。

そうなると、Q1のコンポーネントに「自分の値が変わったらQ2のバリデーションを動かしてね」というロジックを持たせる必要が出てきます。 それではコンポーネント間の結合度がどんどん高くなります。 全部のロジックを上位コンポーネント側で中央集権的に管理すればいけそうな気はしましたが、 設問固有のバリデーション知識を各設問コンポーネント内に閉じることができないため、設計制約を達成できなそうでした。 そこで、私達は別のアプローチを考えることにしました。

我々の考えたアプローチ

useEffectは、コンポーネントのレンダリング結果が画面に反映された後に、副作用(Side Effect)を実行するためのフックです。一般的には、外部システムとの同期(API通信など)や、イベントリスナーの登録・解除といった限られたユースケースで使われます。

したがって、実装コードの中にuseEffectが多用されている場合、必ずしも必要でないところで使ってしまっているケースの可能性が高そうです。

しかし、私たちの設計は、あえてアンチパターン的にこのuseEffectを利用することで、複雑なバリデーションを実現する方法をとっています。

我々の設計では、バリデーションルールは宣言的に定義されているので、別の設問の回答を使ってバリデーションする場合、validationsのどこかに該当設問IDが含まれているはずです。 したがって、関連IDを抽出して回答データを依存関係に入れてしまえば、自動的に依存する回答の更新に従ってバリデーションロジックを発火させることができる、という考えに基づいて実装しています。

// 大枠の流れは以下
// 実際のコードではありませんので、イメージとしてご理解ください。
const QuestionNumberInput = ({ question }) => {
  const {
    answers,
    errors,
    updateAnswer,
  } = useAnswers();

  const { id, label } = question;

  useValidationEffect(question, () => {
     //ここでバリデーションロジックを記述
  }, [ .... ]);

  return (
    <div>
      <label>{label}</label>
      <input
        type="number"
        value={answers[id] || ''}
        onChange={(e) => updateAnswer(id, e.target.value)}
      />
      {errors[id] && <p style={{ color: 'red' }}>{errors[id]}</p>}
    </div>
  );
};


export const useValidationEffect = (
  question,
  effect,
  deps,
) => {
  // 1. フックの内部でContextを呼び出し、必要なメソッドとデータを取得
  //    これにより、フックは`allAnswers`の変更を検知できるようになる
  const { getAnswerByIds, allAnswers } = useSurveyContext();

  // 2. 依存する設問IDのリストを取得
  const questionIds = useMemo(() => 
    question.validations?.flatMap((validation) =>
      validation.dependencyQuestionIds(), // validationの基底クラスに依存を取得するI/Fを定義
    ) ?? [],
    [question.validations]
  );
  
  // 3. 依存する回答の値からなるオブジェクトをuseMemoでメモ化
  const memoizedDepAnswers = useMemo(() => {
    // getAnswerByIdsはContextから取得した、最新のallAnswersを知っている関数
    return getAnswerByIds(questionIds);
  }, [questionIds, allAnswers, getAnswerByIds]);

  // 4. 依存回答データをuseEffectの依存配列に指定
  return useEffect(effect, deps ? [...deps, memoizedDepAnswers] : [memoizedDepAnswers]);
};

useValidationEffectというカスタムフックが私たちの設計の肝です。設問定義(question)から依存先の設問IDを読み取り、その回答の値が変更されたことを検知して、バリデーションを再実行します。これにより、各設問コンポーネントは、自身の設問定義に従ってuseValidationEffectを呼び出すだけで、複雑な依存関係を意識することなく、正しいタイミングでバリデーションを実行できます。 依存先の変更を検知して動作させたいという点の大半はuseMemoにより実現できていますが、エラー状態をグローバルで管理するために、useEffectの中で処理する必要があります。

useValidationEffectに渡すコールバック関数内にバリデーションロジックを記述することで、バリデーション知識は設問コンポーネントにカプセル化され、凝集性の高い設問コンポーネントという設計制約を満たすことができます。

Geminiに提示されたアプローチ

useEffectを用いたアプローチは、求めている設計制約は満たすものの、フックの挙動がやや直感的でなく、実装の理解が困難になる点が問題になります。

そこで、別の設計アプローチを探るため、Gemini 2.5 Proに私たちの設計の前提やコードを説明し、代替案を相談してみたところ、もう1つの設計パターンを提示してくれました。それがバリデーション関数の「登録」です。

以下に、Geminiが出力した実装例を示します。

1. 司令塔となるContext Providerの実装

import React, { createContext, useState, useContext, useCallback } from 'react';

const AnswerContext = createContext();

export const AnswerProvider = ({ children }) => {
  const [answers, setAnswers] = useState({ Q1: '', Q2: '' });
  const [errors, setErrors] = useState({});
  // バリデーション関数をオブジェクトで管理 { Q1: validatorFn1, Q2: validatorFn2 }
  const [validators, setValidators] = useState({});

  // 設問コンポーネントが自身のバリデーション関数を登録するための関数
  const registerValidator = useCallback((name, validatorFn) => {
    setValidators(prev => ({ ...prev, [name]: validatorFn }));
  }, []);

  // 設問コンポーネントが消える際に、登録を解除するための関数
  const unregisterValidator = useCallback((name) => {
    setValidators(prev => {
      const newValidators = { ...prev };
      delete newValidators[name];
      return newValidators;
    });
  }, []);

  const updateAnswer = useCallback((name, value) => {
    const newAnswers = { ...answers, [name]: value };
    setAnswers(newAnswers);

    // 【ここが核心】
    // 回答が更新されるたびに、現在登録されている"全て"のバリデーションを実行
    const newErrors = {};
    Object.keys(validators).forEach(fieldName => {
      const validator = validators[fieldName];
      // 全ての回答データを渡してバリデーションを実行
      const errorMessage = validator(newAnswers); 
      if (errorMessage) {
        newErrors[fieldName] = errorMessage;
      }
    });
    setErrors(newErrors);

  }, [answers, validators]);

  const value = { answers, errors, updateAnswer, registerValidator, unregisterValidator };
  
  return (
    <AnswerContext.Provider value={value}>
      {children}
    </AnswerContext.Provider>
  );
};

export const useAnswers = () => useContext(AnswerContext);

2. 設問コンポーネントの実装

import React, { useEffect } from 'react';
import { useAnswers } from './AnswerContext';

const QuestionNumberInput = ({ question }) => {
  const {
    answers,
    errors,
    updateAnswer,
    registerValidator,
    unregisterValidator,
  } = useAnswers();

  const { id, label } = question;

  const validate = () => {
     //ここでバリデーションロジックを記述
  }

  useEffect(() => {
    // バリデーション関数が設定として渡されている場合のみ、登録処理を行う
    if (validate) {
      registerValidator(id, validate);
    }

    // アンマウント時に登録解除する
    return () => {
      if (validate) {
        unregisterValidator(id);
      }
    };
  }, [id, registerValidator, unregisterValidator]);

  return (
    <div>
      <label>{label}</label>
      <input
        type="number"
        value={answers[id] || ''}
        onChange={(e) => updateAnswer(id, e.target.value)}
      />
      {errors[id] && <p style={{ color: 'red' }}>{errors[id]}</p>}
    </div>
  );
};

この実装のポイントは以下だそうです。

  1. 定義と実行の分離: 各設問コンポーネントは「バリデーションのやり方(How)」を関数として定義します。
  2. 関数の登録: コンポーネントがマウントされる際、そのバリデーション関数を上位のContext Providerに「登録」します。
  3. 司令塔による実行: いずれかの回答が変更されたら、Context Providerが「いつ(When)」の判断を下し、登録されている全てのバリデーション関数を実行して、エラー状態を一元管理します。

言われてみると、バリデーションの実行をイベントと考えれば、登録するという行為は非常に素直な気がします。 useEffectを使っていますが、バリデーションの登録というユースケースはイベントハンドラと考えれば本来の正しい使い方ですし、実際のバリデーション処理はイベントハンドラの処理の中で行われるので、useEffectで処理されるよりもコードが追いやすそうです。

設計の比較

このGeminiの提案した設計と私たちの考えた設計を比較してもらいました。 その結果が次の表になります。

評価軸 Geminiの設計 私たちの設計
実行トリガー いずれかの回答変更時 依存する回答の変更時 (※)
実行スコープ システム全体に登録された全バリデーション関数 表示中のコンポーネントに紐づくバリデーション計算
パフォーマンス △ (設問数に比例して負荷が増大) ◎ (影響範囲が限定的で高効率)
設計思想 手続き型 (ロジックを関数として登録・実行) データ駆動 (データ定義に基づいて挙動が決定)
依存関係の管理 暗黙的 (関数内部で allAnswers を参照) 宣言的 (question 定義で依存関係を明示)
実装の複雑性 〇 (ロジックがシンプルで直感的) △ (フックが高度で、やや「魔法」的)

(※) 正確には、allAnswers の変更をトリガーに計算が走り、その計算結果が変わった場合にのみ副作用が実行される。

やはり、実装の複雑性の面では私の実感と同様、useEffectを利用する分、評価は低いものとなっていました。

一方で、全体としては、Geminiは我々の設計を高く評価してくれました。「パフォーマンスが重視される複雑なフォームにおいては、あなたのチームの設計の方が優れています。」とのことでした。 確かに、現状提示された登録の実装では、入力のたびに全てのバリデーションが動くことになるので、依存関係で絞ってバリデーションしている分、パフォーマンス面は我々の設計に分がありそうです。

ただ、これは登録する方法をもう少しスマートにする、例えば、各バリデーション関数がどの設問IDに依存しているかの情報も一緒に登録し、updateAnswer側では変更された設問に関係するバリデーションのみを実行する、といった改善が考えられます。そこが解消すればGeminiのアプローチはより良い設計になる余地がありそうです。

まとめ

今回、自分たちの考えた設計を客観的に見つめ直すため、Geminiとの対話を通じて設計パターンの探求をしました。

今回やってみたAIを使って自分たちの設計を一緒に考えていく行為自体は、自分たちでは出せなかったアイデアを知ることができることはもちろん、自分たちの設計の理解度を高める上でも非常に有用だなと感じました。 チームの開発の中にもうまく取り込んで、コードの質を高めていきたいなと思いました。

We are hiring!!

リサーチプロダクトチームでは一緒にリサーチプラットフォームの将来を構築していってくれる仲間を募集しています! まずはカジュアル面談から、以下URLよりご応募をお待ちしています。

jobs.m3.com

また、リサーチプロダクトチームに関する詳細は以下をご覧ください。