メインコンテンツまでスキップ

「Rails」タグの記事が4件件あります

全てのタグを見る

· 約13分
藤崎 拓

はじめに

初めまして、株式会社リーディングマークで1on1のプロダクト開発を担当しているエンジニアの藤崎です。

今夏、新しいプロダクトの開発に携わりました。

そのプロダクトでは、長い文章を入力するフォームがあります。

その課題として、長時間のフォーム入力中にデータが消失するリスクが懸念されていたので、自動保存機能をつけるという要件が上がってきました。 Webアプリケーションでフォーム入力中にデータが消えてしまう体験は、ユーザーにとって大きなストレスです。

本記事では、自動保存機能を実装するためのアプローチや工夫した点について解説してみました!

環境情報

  • Ruby 3.3.5
  • Rails 7.0.8.6
  • Node.js 20
  • React 18.2.0
  • TypeScript 5.0.4

課題:自動保存とバリデーションの両立のジレンマ

自動保存機能を実装するにあたって、「保存」と「バリデーション」のジレンマが存在します。

例えば、googleフォームのようなアンケート回答システムを考えてみましょう。

回答テーブルに対して、自動保存機能をつけたいです。

求められるバリデーションとしては、必須回答(空文字を許容しない)、文字数制限10000文字などが想定できます。

この時、自動保存機能を実装するには、以下の2つが両立する必要があります。

  1. 自動で保存が実行されること ― ユーザーの操作なしに、バックグラウンドで保存が走る
  2. データが正しく保存されること ― 不正なデータがDBに入り込まない

一見当たり前に思えるこの2つですが、実際には両立が困難です。

なぜ難しいのか

バリデーションを設定した場合、入力途中のデータは未入力などにより、バリデーションエラーとなる可能性があります。そうなると、自動保存の最大の恩恵である「勝手に自動で保存してくれる」という体験が損なわれてしまいます。

かといって、バリデーションを外してしまうと、無制限の文字数や不正なデータがそのまま保存され、DBを破壊するリスクを抱えることになります。

結論、解決策は以下の通りです。

  1. テーブル分割

    自動保存用の下書きテーブルと、確定保存用の清書テーブルの2つのテーブルを用意する。

  2. 下書きテーブルへの自動保存

    バリデーションをなるべく最低限にする。今回の例で言うと、入力必須にはしないが、DB破壊リスクを防ぐための文字数制限だけ設定する。

    また、文字数制限超過のバリデーションエラーの場合、フロントでエラーを表示させ、保存できる入力箇所だけを保存する

  3. 清書テーブルへの保存

    入力完了後、保存ボタンからユーザに手動保存してもらう。ここではバリデーションを完全なものにする。バリデーションエラーが発生した場合は、ページ上にエラーを表示する

このあと詳細を記載します。

解決策:下書きテーブルの分離

下書き用のテーブルと清書用のテーブルを分離します。

下書きテーブル清書テーブル
保存タイミング入力変更後 1秒 debounce で自動保存送信ボタン押下時
バリデーション文字数上限のみ(10,000文字) contentは NULL許容文字数上限(10,000文字) contentは NOT NULL
目的データ損失防止 途中離脱しても安心 ブラウザクラッシュ対策正式データとして確定
CREATE TABLE answers (
id BIGSERIAL PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES questions(id),
user_id BIGINT NOT NULL,
content TEXT NOT NULL CHECK (char_length(content) >= 10), -- 必須 & 最低10文字
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(question_id, user_id)
);

ER図

image.png

下書きデータのcontentにはバリデーションを設定していませんが、 清書データのcontentにはバリデーションを設定しています。 これによって、入力中はユーザーの入力を寛容に受け入れることができます。

ただ、テーブルが分かれることによって、様々なことに考慮しなければなりませんでした。

フロントエンド工夫点1:Debounce処理による自動保存

  const triggerAutoUpdate = useCallback(
(onboarding: Onboarding) => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}

autoSaveTimeoutRef.current = setTimeout(async () => {
await saveOnboarding(onboarding);
}, debounceMs);
},
[saveOnboarding, debounceMs]
);

ユーザーが入力操作を行いフォームの中身が変化するたび、 triggerAutoUpdate が呼ばれるので、連続入力中の過度なAPIリクエストを防止するようにしました。

  • 既存のタイマーがあればクリアし、新しいタイマーをセット
  • 最後の入力から debounceMs(1000ms)経過後にAPIリクエストを送信

フロントエンド工夫点2:入力変更時のハンドラ

  const handleBasicInfoResponseChange = useCallback(
(key: keyof NewBasicInfoResponse, value: string) => {
// ...バリデーションエラーのクリア...

setBasicInfoResponse((prev) => {
const updated = { ...prev, [key]: value };
triggerAutoUpdate(buildOnboardingData({ basicInfoResponse: updated }));
return updated;
});
},
[triggerAutoUpdate, buildOnboardingData, validationErrorsState.basicInfoErrors]
);

状態更新と同時に triggerAutoUpdate を呼び出し、全ての入力項目をまとめて buildOnboardingData でオブジェクト化

これによって、setState のコールバック内で updated を作成し、その場でAPIに渡すので、100%最新の値が送信されることが保証されます。

フロントエンド工夫点3:送信前の強制保存

  const forceSave = useCallback(
async (onboarding: Onboarding) => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
await saveOnboarding(onboarding);
},
[saveOnboarding]
);

入力して、すぐに清書ボタンがクリックされてしまうと、入力中のものは更新インターバルが1秒あるので、その間に入力された値が反映されずに、清書されてしまうことがあります。

これを懸念して、フォーム送信時に forceSave を使用しました。

バックエンド工夫点1:清書保存のためのサービスクラス

DraftOnboardingFinalizer

  def finalize
result = Onboarding::DraftOnboardingFinalizer.new(employee: current_user, program: program).call
...
end


class Onboarding::DraftOnboardingFinalizer
Result = Data.define(:success?, :errors)

def initialize(employee:, program:)
@program = program
@employee = employee
@errors = []
end

def call
check_validations_for_draft_answers

if errors.empty?
finalize_onboarding
Result.new(success?: true, errors: {})
else
formatted_errors = format_errors
Result.new(success?: false, errors: formatted_errors)
end
end

def check_validations_for_draft_answers
return if draft_answers_response.valid?

draft_answers.errors.each do |error|
errors << {
type: :draft_answers,
field: error.attribute.to_s,
message: error.full_message
}
end
end

...
end

保存処理はサービスクラスに切り分けています。

draftデータが存在しているか、清書可能なデータ状態か、部分的に保存が成功したのかどうかなど、構造体resultとして情報を返しています。

バックエンド工夫点2:アクセス制御


def answer_finalized?(employee_id)
# 清書レコードが存在しているか
end

def onboarding_auto_saved?(employee_id)
# draftレコードが存在しているか
end

また、自動保存の状態は3パターンに分かれていて、それらによる画面アクセス制御も行いました。パターンは以下のように分かれます。

  • パターン1:自動保存が一度もされていないパターン
  • パターン2:自動保存が少なくとも1度はされているが、清書が未完了のパターン
  • パターン3:自動保存が少なくとも1度以上されていて、清書が完了しているパターン

自動保存を実現するためにはテーブルをわけ、それのユーザの入力状態によって、各機能のアクセス制御も分岐するという点はよく考えておく必要があるなと実感しました。

application_controller.rb

  # パターン1(未回答)でアクセスできる画面
ACCESSABLE_UNSAVED_PATHS = [
'/',
].freeze

# パターン2(回答中)でアクセスできる画面
ACCESSABLE_ANSWER_IN_PROGRESS_PATHS = [
'/',
// ...
].freeze

# パターン3(回答済)でアクセスできる画面
ACCESSABLE_ANSWER_COMPLETED_PATHS = [
'/',
// ...
].freeze

良くできたこと

  • アクセス制御の保守性

    設計段階では、各画面のアクセス制御を各controllerのbefore_actionで実装する予定でした。しかしその方法だと、アクセス制御のエラーハンドリングを、各controllerで処理することになるため、アクセス制御の全体像を見通すことができなくなってしまいます。

    そうなると、機能の追加時などに考慮漏れなどが発生する可能性が高まったり、キャッチアップの負荷が増加したりと、保守性が悪化すると考えました。

    そこで、「バックエンド工夫点2:アクセス制御」で記述したapplication_controllerに処理をまとめる方法で、解決しました。

    controllerのbefore_actionで、そのcontroller特有のエラーハンドリングをすることは良いですが、機能全体に共通するような処理は、application_controllerに切り出すことが重要であることを学びました。

    今後の開発でもここは留意していきたいなと考えております!

もっと良くできたこと

  • オフライン対応

    現状ではオンライン前提となっているので、ネットワーク切断時にlocalStorageに一時保存しておき、オンライン復帰後に同期する仕組みがあると更に便利になるかなと思っています。

  • 自動保存時の通知

    自動保存が実行されたタイミングで、「保存されました」と言うような旨の通知がポップされると、ユーザー視点自動保存がされたことが認知できて、安心して入力作業を進められるようになると思います。

おわりに

本記事では、プロダクトに自動保存機能を持たせたい場合の弊社で実装した試みをまとめてみました。

今までの自分の開発経験としては、自動保存は初めてで、最初は全く実装イメージが沸いていませんでした。チーム内でどう実現するのが良いか、じっくり皆で詰めていった結果、今の自信を持てる形が実現できたと思っています。

以上になります!

今後とも、チームで協力してより良いプロダクトを作っていけたらと考えています!

· 約18分
中川 優

こんにちは。サービス開発部/プロダクト開発部門/従業員サーベイチームのエンジニアリングマネージャー、中川優です。

弊社では、ミキワメ適性検査・ミキワメウェルビーイングサーベイ・ミキワメマネジメントなど複数のプロダクトを展開しており、これらプロダクトごとに様々なタイミングでユーザーへの通知が届きます。

2025年初頭、Slack連携機能を追加するための開発が決定し、同年春のリリースを目指して、メールに加えて複数の通知チャネルを安全かつ簡潔に扱う仕組みづくりが課題となりました。

このプロジェクトは無事に2025年春にリリースを迎え、現在では多くのお客様にSlack通知機能をご活用いただいています。 リリースから半年以上が経過した現在のタイミングで、当時の設計思想を振り返ってみたいと思います。

プレスリリース:ミキワメ適性検査、Slack連携機能をリリース

※用語メモ:

  • 「受検」は適性検査の受検依頼
  • 「サーベイ」はウェルビーイングサーベイにて配信されるアンケート

本記事では、複数プロダクトで統一的なメール・Slack通知を実現するために設計した通知基盤について解説します。

複数プロダクトでの通知チャネル追加の複雑さ

着手時の状況

主要プロダクトのそれぞれで、複数の通知機能が存在しています。

  • 適性検査: 受検依頼、受検リマインド、受検結果完了通知、など
  • サーベイ: 回答依頼、回答リマインド、集計完了通知、など

これらは全てメール通知として実装されており、各プロダクトのコードベースに散在していました。

新たな要件

Slack連携機能の追加により、以下の要件が発生しました。

  • メール通知は従来通り、通知先アカウントに対して必須である
  • Slack通知は、企業がSlack連携済みの場合のみに限る

設計上の課題

単純に、既存の通知実装にSlack送信の処理を追加する案も考えましたが、以下の問題が発生することを容易に想像できてしまい、正直に言うとかなり気が重かったです。

  • 通知の実装箇所ごとにSlack連携有無判定チェックが重複(DRYじゃない)
  • Slack API周辺のロジックが各プロダクトに散らばり、「どこで何が送られているのか」が分からなくなるカオス
  • 送信順序や失敗時の処理が分散され、デバッグ時の確認箇所を増やす羽目になる

解決策:単一の呼び出し口としてのクラスを基盤とする設計

設計上の課題を解決するため、以下の通知基盤を実装しました。

  • 単一の呼び出し口:呼び出し側は1つメソッドを呼ぶだけで、メール・Slack両方の通知に対応できる
  • チャネルの透過性:呼び出し側はメールとSlackの技術的詳細の違いを意識する必要がない
  • Slack未連携企業でも、メール通知は確実に届くことを担保する

処理フロー

通知基盤の各メソッドは、以下の流れで処理を行います:

  1. メール送信(必須)
  2. Slack連携チェック
  3. 送信対象のフィルタリング
  4. Slack通知本文のメッセージ生成
  5. Slack API実行用非同期Job投入

この順序により、Slack送信の成否に関わらずメール通知は確実に送信させることができます。

通知基盤の実装

※本記事のコード例は説明のため一部簡略化・抽象化しています。

コード構造

適性検査の受検依頼用のメソッドを例に説明します。

class MultiChannelNotifier
def send_examination_notification(company_id, recruitment, template, user_ids)
# 1. メール送信(必須)
MailService.call(company_id:, recruitment:, template:, user_ids:)

# 2. 企業のSlack連携設定状況のチェック(オプショナル)
slack_connection = SlackConnection.find_by(company_id:)
return unless can_slack_send?(
slack_connection,
required: -> { recruitment.target_type.employee? }
)

# 3. 送信対象取得
targets = recruitment.employees.where(id: user_ids)

# 4-5. メッセージ生成 & 非同期送信
slack_bulk_send_process(slack_connection, targets) do |employee|
content = SlackMessageBuilder.call(company_id:, recruitment:, employee:)

{ user_email: employee.email, content: }
end
end

private

def can_slack_send?(slack_connection, required: nil)
slack_connection.present? && (required.nil? || required.call)
end

def slack_bulk_send_process(slack_connection, targets)
direct_messages = targets.map do |target|
message_data = yield(target)
SlackDirectMessage.new(
recipient_email: message_data[:user_email],
content: message_data[:content]
)
end

SlackBulkSendJob.perform_async(
slack_connection.id,
direct_messages.map(&:to_h)
)
end
end

条件分岐の設計

can_slack_send? メソッドがチェックする条件は下記の通り。

条件説明
Slack連携の有無Slack連携情報(その企業がSlackと連携中にのみ存在する)の存在確認
通知機能固有条件requiredパラメータで指定(オプショナル)。未指定の場合は考慮されない。

通知機能固有条件の例:

前述したコード例の recruitment.target_type.employee? の部分が固有条件に相当します。これは、適性検査の受検依頼をする対象者のアカウント種別が自社社員かどうか?を確認していることを表しています。

※ミキワメでは、自社社員の他、新卒候補者や中途候補者などのアカウント種別が存在します。

この設計により、通知機能ごとの細かい条件分岐を柔軟に実装できるようになりました。

Slack通知基盤の詳細

Slack通知は以下のレイヤー構成で実装しています(名称は簡略化しています):

MultiChannelNotifier(呼び出し口)

SlackSendJob / SlackBulkSendJob(非同期処理)

SlackSingleSender / SlackBulkSender(サービス層)

SlackApiChat / SlackUsersApi(APIラッパー)

Slack::Web::Client(slack-ruby-client gem)

「後から見たときに、どこを直せばいいか分かる構造にしたい」という思いで、責務が明確になるようレイヤーを分離しました。これにより、テストもしやすく保守性の高い設計になったと感じています。

slack-ruby-client gemの選定理由

https://github.com/slack-ruby/slack-ruby-client

  • Slack API仕様に準拠したインターフェース
    • chat.postMessageusers.lookupByEmailなどの要件上必要なメソッドが提供済み
    • TooManyRequestsErrorretry_after属性が含まれるなど、Slack特有のエラー処理が組み込まれている
  • 定期的なメンテナンスが入っており、Star数も1.3k超(2025年12月時点)
  • READMEが充実しておりトラブルシューティングがしやすい

slack-ruby-clientを介さず、Faradayで直接実装することも検討しましたが、エンドポイントのパス管理、パラメータの組み立て、エラーレスポンスの解釈などを全て自前で実装・保守する必要があり、保守コストが相対的に高いと判断しました。

※slack-ruby-clientは内部的にFaradayを使用しているため、HTTPクライアントとしてのFaradayの利点は享受しつつ、Slack API特有の処理を抽象化できています。

主要なクラス(名称は簡略化しています)

クラス役割
SlackConnection企業ごとのOAuth認証情報を保持するActiveRecordモデル
SlackDirectMessageメッセージのValue Object(4000文字制限に対応)
SlackApiBaseリトライ処理・レートリミット対応の基底クラス
SlackBulkSenderusers.listで全ユーザー取得後、メールでフィルタして並列送信
SlackSingleSenderusers.lookupByEmail でユーザーを一件取得し、見つかった場合に送信

単発送信 vs 一括送信

Slack APIの呼び出し回数を最適化し、送信対象の人数に応じて2つの方式を使い分けています:

方式APIコール用途
単発送信users.lookupByEmail → chat.postMessage1人への通知
一括送信users.list → chat.postMessage × N複数人への通知

一括送信では、事前にusers.listで全ユーザーを取得し、メールアドレスでマッチングすることで、users.lookupByEmailの呼び出し数を削減しています。

リトライ処理とレートリミット対策

Slack APIを使用したユーザーへのメッセージ送信には、chat.postMessage というエンドポイントを使用する必要があります。

このエンドポイントのリクエスト制限に至る上限値は公開されていないため、いつ制限に至るか読むことが困難です。そのため、あらかじめレートリミット時のエラーハンドリングを適切に行う必要がありました。リトライ処理を共通化した基底クラスを用意し、ここでリトライ処理を実装しています。

対応するエラー

エラー対策
レートリミットレスポンスに含まれるretry_afterの秒数だけ待機して再試行
予期しないタイムアウト指数バックオフで最大数回リトライ

実装コード例(簡略化しています)

※実運用では、リトライ上限の設定やデッドレターキュー(DLQ)への退避など、ジョブが長時間滞留しないための仕組みも併せて検討することをおすすめします。

class SlackApiBase
def initialize(credentials:)
@credentials = credentials
end

private

attr_reader :credentials

def client
@client ||= Slack::Web::Client.new(token: credentials)
end

def with_retry(method_name:, max_retries:)
begin
yield
rescue Slack::Web::Api::Errors::TooManyRequestsError => e
# レートリミット: Slackが指定した秒数だけ待機して再試行
...
rescue Slack::Web::Api::Errors::TimeoutError => e
# タイムアウト: 指数バックオフで再試行
...
end
end
end

# chat.postMessageへのリクエストを実行する継承クラスでwith_retryを使用するようにしている
class SlackChatApi < SlackApiBase
def post_message(channel:, text:)
with_retry(method_name: 'post_message') do
client.chat_postMessage(channel:, text:)
end
end
end

設計のポイント

  • レートリミット対応:Slack APIはretry_afterヘッダーで待機時間を指定するため、それに従うことで確実にリトライできます
  • タイムアウト対応:一時的なネットワーク障害に対応するため、指数バックオフで再試行します

一括送信時の処理

一括送信では、レートリミットを考慮しつつ効率的に送信するため、以下の方針で実装しています:

  • 一定件数ごとにバッチ処理
  • 複数スレッドで並列にAPIコール
  • 各APIコールはwith_retryでラップされており、エラー時は自動リトライ

この設計により、大量のユーザーへの通知でもレートリミットに引っかかることなく、送信処理を維持することができます。

メッセージ生成の設計

各プロダクトで、通知種別ごとにメッセージ本文の文字列を戻り値とするSlackMessageクラス(PORO)を配置しています。

各クラスは単一責務の原則を守り、1つの通知種別のみを担当するようにしています。

# モジュラーモノリス構成のイメージ(実際の構成とは異なります)
packs/
├── product_a/app/services/
│ ├── user/slack_message/
│ │ ├── notification_type_1.rb # ユーザー向け通知
│ │ └── ...
│ └── admin/slack_message/
│ ├── notification_type_2.rb # 管理者向け通知
│ └── ...
├── product_b/app/services/
│ ├── user/slack_message/
│ │ ├── notification_type_3.rb
│ │ └── ...
│ └── ...
└── ...

この設計で得られたメリット

1. 呼び出し側のシンプルさ

新しい通知を追加する際、MultiChannelNotifierにメソッドを1つ追加するだけで、メール・Slack両方に対応できます。

2. 技術的詳細とビジネスロジックの分離

  • 通知基盤クラス:「いつ、誰に通知するか」というビジネスロジックを担当
  • Slack送信サービス:「どうやってSlackに送信するか」という技術的詳細を担当

この分離により、Slack APIの仕様変更があっても、通知基盤側の変更は不要です。

今後の課題と改善点

大規模企業での送信速度と、将来的な新チャネル追加を見据えた拡張性が主な検討ポイントです。

1. エンプラ企業向けの高速化

現在の実装では、レートリミットを考慮して並列数を抑えて送信していますが、実装時の計測時は100通弱程度から制限がかかることが多かったです。したがってエンプラ規模の企業では通知完了までにかかる時間が増大してしまう可能性が残っています。

今後の改善案として、並列処理のスレッドごとにSlack Appを割り当てるなど、インフラ側でのスケーリング前提の対応なども検討する必要があります。

2. 通知基盤クラス自体のリファクタ

現在はメールとSlackのみですが、今後Microsoft TeamsやLINE WORKSなど、他のチャネルへの対応も考えられます。

その際は、以下のような設計変更を検討する必要があると考えています。

  • チャネルごとのStrategyパターンの導入
  • 通知設定の柔軟な管理(企業ごとに有効なチャネルを選択可能に)

まとめ

本記事では、複数プロダクトで統一的にメール・Slack通知を実現する通知基盤について解説しました。

この設計により、以下を実現できました:

  • 呼び出し側のシンプルさを保ちながら、複数チャネルへの通知を実現
  • slack-ruby-clientを活用した安定的なSlack API連携
  • Slack APIの技術的詳細を隠蔽し、保守性を向上
  • Slack未連携企業への影響なし

正直なところ、レートリミットの挙動が読めない中での実装は特にきつさを感じました...!公式ドキュメントには上限値が書かれていないため、「100通目くらいで制限がかかるな」「並列数はどの程度なら安定するか」といった感覚を、実際に送信テストを繰り返しながら掴んでいく必要がありました。この試行錯誤の部分が、特に苦労したポイントです。

抽象化を目的としたクラスを設けたことで、各プロダクトのビジネスロジックとSlack APIの技術的詳細の直接的な依存を避けることができました。この設計判断により変更容易性が大きく向上し、リリース後も新しい通知種別の追加がスムーズに行えています。

今後も、ユーザー体験を向上させるため、通知基盤の改善を続けていきます!

· 約14分
中川 優

初めまして、中川優(すぐる)と申します!サービス開発部/プロダクト開発部門/従業員サーベイチームのプロダクトエンジニアです!

仕事で使用している主な言語はRuby・TypeScriptです。個人的にGolangあたりを最近触ったりしています。趣味はヘヴィメタルです。

今回は、当社プロダクトの一つであるミキワメウェルビーイングサーベイが過去に直面した、閲覧範囲制御機能のパフォーマンス上の課題、および改善に至るまでの事例をご紹介いたします。

ミキワメウェルビーイングサーベイとは?

心理学・行動科学の知見に基づき従業員のウェルビーイング(幸福度)を測定・分析する、実名性の”パルスサーベイ”ツールです。

  • 短時間の回答(3分間)によって、即座に回答結果・アドバイスを確認できる
  • 個人の性格に基づいた、ウェルビーイング向上のためのアドバイスを提供
  • 組織全体の回答結果を可視化し、組織単位でのアドバイスも受けることができる

詳しくは下記もご覧ください。

『ミキワメ ウェルビーイングサーベイ』とは?社員の幸福度を高め離職・休職を防ぐ|ミキワメラボ

閲覧範囲制御機能とは?

ミキワメでは大きく分けて2つの画面を提供しています。

  1. 従業員向け画面:従業員が自身の回答結果を閲覧するためのページ
  2. 企業向け管理画面:企業担当者が組織・各従業員の回答結果を閲覧するためのページ

閲覧範囲制御は、この内の企業向け管理画面における新機能として、2024年5月30日にリリースされたアップデートです。

アップデート前

企業向け管理画面のログインユーザーでは複数の権限が存在し、その中に”マネージャー”という権限があります。マネージャーは、特定の部署・および部署に所属する従業員のサーベイ回答結果のみを閲覧することができます。 しかし、そのマネージャーが現実でその役職に任命以前の結果まで閲覧できてしまう状態でした。

  • 2024年6月1日にサービス開発部のマネージャーに任命されたAさんは、任命されるより以前のサービス開発部のサーベイ回答結果も閲覧できてしまう

ミキワメウェルビーイングサーベイでは、従業員が自由記述できるコメント機能、企業担当者が従業員に対する打ち手を記録するサポート履歴機能などもあります。 その為、誰かに見られては困る内容を、その誰かが閲覧できてしまうリスクがありました。

アップデート後

マネージャーが特定の部署のみ回答結果を閲覧することができるという条件に加え、その部署を閲覧権限を付与した以前の結果は、閲覧不可とする制御ができるようになりました。

  • 2024年6月1日にサービス開発部のマネージャーに任命されたAさんは、任命されるより以前のサービス開発部のサーベイの回答結果は閲覧することができない

これによって、アップデート前のリスクを防止することができ、より安心して利用できる仕様にすることはできました。

ところが、このアップデートのリリース直後、とある問題が起きてしまいます・・・

画面の表示速度がめっちゃ遅くなっている!!!

それは、サービス開発部で毎月末に実施している、各チームの進捗報告などの定例の中での話でした(確かその初回だったと記憶)。

EM「サーベイで画面表示速度が遅くなっているらしいので、従業員サーベイチームは調査に入ってください」

わたし「!!?」

企業向け管理画面では、全社・部署単位で組織のサーベイ回答結果を閲覧できるページがあり、ミキワメウェルビーイングサーベイにおけるコア機能の一つなのですが、このページにおいて著しいパフォーマンス低下が発覚。

例:数千人分の回答結果を持つページの場合

  • 初期表示までに10~20秒、あるいはそれ以上かかってしまう
  • サーベイに回答した社員一覧データを取得するための非同期処理1~3分以上かかってしまう

当時はエンタープライズ企業への導入が活発化し始めていた状況だったのですが、その矢先にこのような事態に見舞われてしまったのです・・・!!

ボトルネックを探し、仮説を立てる

この事態を受け、従業員サーベイチームではリードエンジニアの力を借りながら、とにかくやれることをやっていくこととしました。

  • 不足しているeager loadingの追加
  • メモ化できていない所のメモ化
  • 画面表示の都度発生していた計算処理を、DBから参照する形に修正
  • さらに非同期取得してよさそうな所があれば非同期化
  • etc

このあたりは一定改善があったのですが、元の速度に戻るまでには至らず。そして気づけば1ヶ月経過・・・。別の観点でも調査を継続していくこととなりました。

遅くなっている画面でrack-mini-profilerを用いたクエリ発行数の調査をしていく中で、多くのクエリが発行している箇所を確認しました。この箇所では、サーベイの回答結果が閲覧できるかどうかを以下のように制御しており、これが速度低下に影響しているのではないか?という仮説を立てることとしました。

  • 回答者がサーベイに回答した日時の時点で所属していた部署が、マネージャーの閲覧可能な部署である

一人一人回答タイミングは異なることから、配信対象になった社員全員分を対象をループさせ、回答者ごとに都度閲覧可能かを判定する必要があります。

# コードはイメージ。実際のものは異なります。

# cq_recruitment_examinees: サーベイ配信対象者になった社員
# supervisor: 企業向け管理画面のログインユーザー。ここではマネージャーを想定
# division: 部署

cq_recruitment_examinees.select do |cq_recruitment_examinee|
# 社員がサーベイに回答した日を取得するメソッド
deadline = cq_recruitment_examinee.completed_time

# 部署IDと回答日を渡してマネージャーが閲覧可能かを判定
# 内部で閲覧可否判定用のデータを取得するクエリの発行をしている
supervisor.survey_permission?(deadline, division.id)
end
  • ループごとにクエリが発行されN+1が発生
  • 社員が多いほど処理時間が線型に増加する

上記のコード例は説明のために簡略化させていますが、もう少し色々処理が伴っていたり、類似した処理が複数箇所で実装されていたりします。これらが速度低下のボトルネックなのでは!?という話になったのです。

解決に向けたアプローチ

一定ボトルネックが明確になったので、下記の改善案を考案する形になりました。

  • 配信されたサーベイの配信終了日の時点で回答者が所属していた部署が、マネージャーの閲覧可能な部署である

これにより、全社員に対して同一の時点で閲覧可否を判断することができます。

# コードはイメージ。実際のものとは異なります。

# cq_recruitment: 配信されたサーベイ。cq_recruitment->cq_recruitment_examineesで1対多のリレーション。
# サーベイが終了した日付を取得するメソッド
deadline = cq_recruitment.deadline
CqRecruitmentExaminee.none if supervisor.survey_permission?(deadline, division.id)

cq_recruitment_examinees

ボトルネックとなっていたループ処理が不要になり、回答者人数に依存することなく制御できるようになりました。 ただし元々の仕様自体を変えることになるので、PdM・カスタマーサクセスのメンバーとの合意を得る必要がありましたが、一定議論を重ね、パフォーマンス以外のUX改善も盛り込む形で上記の対策を取る方針で合意することができました!

まとめ

閲覧範囲制御の実装範囲は広く、実装自体にも約2ヶ月弱かかりましたが、概ね狙い通りにパフォーマンスを改善!

beforeafter
初期表示期間10〜20秒(または数分以上)1秒未満
一覧データ取得時間(非同期)1〜3分以上数秒〜15秒程度

これで、エンタープライズ企業への導入の障壁を大きく下げることができました。

このアップデートのリリース時点で2024年9月半ば。3ヶ月半の長い道のりで、この間メンバーの入れ替わりなどもありましたが、無事にここまでやり遂げられたことは今でも貴重な経験だったなと思っております。特に下記は教訓となりました。

  • 大規模データを想定して、アップデート前後で速度の低下が発生していないかを検証した上で実装すること。非機能要件の重要性を再認識した。
  • 時にはビジネスロジックの再考も話し合える姿勢を持つこと。役割を超えてより良い開発をする機会となった。

ネクストアクション

非同期処理の中には、まだ社員がサーベイに回答した日起点で閲覧可否を制御せざるを得ないパターンがあり、10〜15秒程度かかる場合があります。 こちらも更に速度が出るように改善し、より快適なサービスにしていきたいです。

そして、実は今回の取り上げた事例以外にも表示速度が遅いページが残っています・・・!同じく企業向け管理画面にある、企業上に作成された部署の一覧表示するページです。これもエンタープライズ企業にて顕在化した課題であり、同様にパフォーマンスチューニングが必要な状況です。こちらにも積極的に関わっていきたいなと思っております。

以上です。最後までご覧いただきありがとうございました!

· 約19分
梶原 悠司

こんにちは。リーディングマークのミキワメ適性検査開発チーム、エンジニアリングマネージャーの梶原です。
本稿では、2023年末から2024年初頭にかけて実施した「受検者データ分離プロジェクト」について解説します。このプロジェクトは、リーディングマークが提供しているミキワメ適性検査とミキワメウェルビーイングサーベイ(以下、「適性検査」「サーベイ」)のデータ構造を改善し、開発スピードの向上とシステムのスケーラビリティを実現することを目的としていました。


「受検者データ分離プロジェクト」は、2023年末に起案され、2024年1月から3月の約3ヶ月かけて開発が行われました。このプロジェクトの背景には、ミキワメ適性検査における課題がありました。従来、新卒・中途・社員の受検データを単一のテーブルで管理していましたが、事業拡大と新サービスの追加に伴い、このデータ構造が開発スピードの制約要因となっていました。
そこで、80万件を超えるデータを分割し、よりスケーラブルなシステムを構築することを目指しました。具体的には、単一のテーブルで管理していた新卒・中途・社員の3区分のデータを、3つの独立したテーブルに分割することが主な目的でした。
開発チームは、テックリード1名、適性検査開発チームから1名、サーベイ開発チームから1名の計3名で構成されました。私は主に適性検査の範囲を担当しました。
プロジェクト実施時点で、バックエンドにはRuby on Rails(バージョン6.1)を使用していました。 この記事では、このプロジェクトの設計方針、技術選定、移行プロセス、直面した課題とその解決策について詳しく解説していきます。


プロダクトの課題とプロジェクト背景

前提として、弊社プロダクトのうち、適性検査・サーベイは同一のデータベースを参照しています。
ミキワメ適性検査の受検者データは、単一の examinees テーブルで管理され、新卒・中途・社員の3種類のユーザーを区別するために examinee_type カラムを使用していました。
このデータ設計では、企業に属する社員の情報を取得する際に、適性検査の受検データを経由する必要がありました。(図1)

[図1] 図1

この構造では、サーベイ機能を独立させる際に不要な依存関係が生じるため、受検者のテーブルをstudentsworkersemployees に分離することで直接企業と紐付けられるようにしました。受検者と紐づいている受検データにはリレーションを張っていたのですが、ポリモーフィック関連を用いて擬似的にリレーションを張ることにより、データの相関を実現する形となっています。(図2)

[図2] 図2

先の図1で示したプロジェクト開始前のデータ構造では、ユーザーは適性検査の受検を通じて初めて企業に属することがわかるかと思います。
この構造の問題点は、社員がサーベイに回答するには、事前に適性検査を受検して企業に属している必要があったことです。当初は運用上大きな問題にはなりませんでしたが、企業が社員に適性検査とサーベイを同時に依頼する機能を実装する際、データ上企業に属していない社員へのサーベイ発行が課題となりました。
一時的な対応も可能でしたが、将来的に社員向けサービスを展開する上で、この問題が繰り返し障害になることが予想されました。結果として、このデータ構造がプロダクト全体の拡張性を制限し、開発の難易度を高めていました。
既存の構造では、企業に属する社員の情報を取得するために、適性検査の受検依頼を介する必要があり、これは無駄に複雑な構造でした。


プロジェクトを進行するにあたっての課題

プロダクトの課題を改善できる理想状態を定義できたものの、プロジェクト進行上の課題も多く存在しました。

プロジェクトの主な課題:

  1. データ構造の大規模変更
    受検データはプロダクトのコアであり、影響範囲が広い
  2. ビッグバンリリースの回避
    他プロジェクトと並行して進めるため、段階的な移行が必要
  3. データ同期の複雑性
    旧テーブルと新テーブルの間で一貫性を保つ必要がある
  4. トランザクション管理
    エラー時のロールバックや同期処理の信頼性が求められる(図3)

[図3] 図3

これらの複雑かつ大規模な課題に対して、システムの安定性を維持しながら慎重に対応することが求められました。
それ考慮した上でどのような戦略を立て、どのような手順を踏んでプロジェクトを進めていったのかを説明していきます。


プロジェクト進行の戦略と手順

図4の手順に従ってプロジェクトを進行しました。

[図4] 図4

これら手順を具体的に解説していきます。

前準備

  • 受検者区分ごとに必要なカラムの精査
  • 受検者情報を置き換える必要があるアプリケーションのリソースの洗い出し
  • 新受検者テーブル、およびそれらの関連テーブル(サインイン情報を持つテーブルなど)の作成

新テーブル作成と既存周辺テーブルの関連付け

  • 各区分の受検者テーブルへ旧受検者テーブルの情報を関連付けするためのカラム設定
  • 旧受検者テーブルに対する外部キーを保持しているテーブル(受検データなど)へ、新受検者テーブルの情報をポリモーフィックで保持するためのカラムの追加、または社員のみのリレーションで十分なテーブルも存在していたため、その場合は社員テーブルに対する外部キーのカラム追加

旧受検者データを新受検者データへ複製

約80万人(2024年1月1日時点)存在していた受検者のデータをstudents, workers, employeesそれぞれのテーブルへ移管する必要がありました。
問題点として、プロダクトリリース当初のバリデーションが十分に記述されていなかった時代に保存された受検者のデータが存在しており、それらを検知するためにいつ終わるかわからない分量のデータを移行するバッチを走らせて確認していました。
具体的には以下のような不整合のパターンが存在しました。

  • employees テーブルでは現在、セイメイ(仮名姓名)の保存が必須だが、過去にはブランクが許容されていた
  • 新しい受検者のテーブルには、メールアドレスのフォーマットに制約があるが、以前のデータには全角英数が許容されていたりと、制約が不十分であった
    このような過去データを新しい制約のあるテーブルへ複製する際、バッチ処理を実行して初めて問題に気付き、その対応に苦労しました。
    最終的には、目的が「複製すること」だったので、セイメイ(姓名)のnot NULL制約などDBレベルで制約がかかっているものに関しては仮の値を、メールアドレスのフォーマットなどアプリケーションレベルで制約がかかっているものに関しては一時的にアプリケーションの制約を外して対応することになりました。

新旧テーブル間データ同期の設定

  • 旧受検者テーブルのデータに変更が加わった時に、新受検者テーブルの対応するデータも変更するための処理
  • またその逆の、新受検者テーブルのデータに変更が加わった時に、旧受検者テーブルの対応するデータも変更するための処理
    これらは更新・作成・削除のそれぞれで行う必要があり、モデルにコールバックを記述することで対応しました。それぞれafter_update, after_create , after_destroy を用い、同期対象となるテーブルのデータを操作する形としました。
    以下のコードは、ExamineeモデルとStudentモデルにおけるコールバックの処理の記述を表したものです。(Employeeモデル, Workerモデル, update, destroyに関しては省略します。)
class Examinee < ApplicationRecord
after_update :update_student

private

def update_student
self.student.update!(**{some_attributes})
end
end

class Student < ApplicationRecord
after_update :update_examinee

private

def update_examinee
self.examinee.update!(**{some_attributes})
end
end

コールバック内でデータを操作する処理の中で仮にエラーが発生した場合、トランザクションはどうなるのかという問題がありましたが、最初に操作を受けたテーブルのデータの処理からコールバック内で発火したデータ操作の処理は、同一トランザクションで囲まれる形となっているため、特段気にすることなくコールバックの記述をすることができました。
しかしながらここには一点問題があり、例えばExamineeの更新がコールバックでStudentの更新を呼んだ時、Studentの更新もコールバックでExamineeの更新を呼んで、さらにまた、、、という無限ループが発生してしまいます。
この無限ループ問題に対して、他社事例も参考にしながらの対処を行いました。
まず、両方のモデルでattr_accessorを使用して、一時的なインスタンス変数skip_double_writeを定義します。このようにすることで、インスタンスのライフサイクルの中でしか存在しないskip_double_write がそれぞれのActiveRecordインスタンスのカラムのように振る舞います。
このキーを、Examineeクラスのコールバックで呼ばれるプライベートメソッド内でtrueとしてStudentインスタンスに受け渡し、Studentクラスのコールバックの発火条件として利用することで、「ExamineeクラスのコールバックでStudentインスタンスが更新された場合、StudentクラスでExamineeインスタンスの更新を行うコールバックを発火しない」という、更新が再帰的に発生しないような挙動の実現が可能となります。
一部簡略化していますが、具体的なコードとしては以下です。

class Examinee < ApplicationRecord
attr_accessor :skip_double_write

after_update :update_student, unless: :skip_double_write

private

def update_student
self.student.update!(**{some_attributes}, skip_double_write: true)
end
end

class Student < ApplicationRecord
attr_accessor :skip_double_write

after_update :update_examinee, unless: :skip_double_write

private

def update_examinee
self.examinee.update!(**{some_attributes}, skip_double_write: true)
end
end

これらの設定を行うことにより、無事同期的にデータの整合性を保つ仕組みを整えることができました。もちろんここにはパフォーマンス観点で問題がありましたが、ここに関しては一定許容していただきつつ運用することとなりました。

アプリケーションが参照しているテーブルを旧テーブルから新テーブルへ全て移行

ここでは前述したように、段階的に既存処理の移行を図りました。
当時、30以上の機能が旧受検者を参照している形であったため、その全てを新受検者のテーブルへ移管する必要がありました。
ここに関しては特に何かを工夫したわけではありませんでしたが、一つ一つドメインを把握している必要があり、入社2年弱であった私もキャッチアップから入る箇所もあったため、正直一番大変な作業でした。
ただ移行作業をするだけでなく、一部大きく処理を変えなければならない箇所も出てきました。
例えば受検データの検索においては、受検者のメールアドレスなどで検索することができるUIが存在していて、その場合関連テーブルとしてexaminees単体の検索で良かったものが、students, workers, employeesそれぞれを跨いだ検索ができるような機能に修正する必要がありました。
そのほかにも煩雑なコードやロジックが残されている箇所に関しては、そのまま移行するのではなく、全体的なリファクタリングを行った上で移行作業をしていったり、そもそも機能をほぼ作り直したような箇所もあったため、思ったようにタスクが消化できない時もありましたが、最終的には無事に移行作業を完了することができました。

対応漏れログの抽出用の設定

移行作業は完了したものの、間髪入れずにexamineeクラスを削除するのはリスクが高かったため、実際にexamineeが呼ばれている箇所がないかをログとして抽出できるように、examineeクラスに以下のような記述をしました。

  after_initialize do |_examinee|
backtrace = caller.grep(/#{Regexp.escape(Rails.root.to_s)}/)
Rails.logger.info "Examinee initialize. #{backtrace}"
end

after_find do |_examinee|
backtrace = caller.grep(/#{Regexp.escape(Rails.root.to_s)}/)
Rails.logger.info "Examinee find. #{backtrace}"
end

after_initializeでexamineeインスタンスが初期化された後、また、after_find でexamineeモデルを介してデータベースに検索をかけにいった際に、ログを残すような仕組みを残しました。
こうすることで、一定期間ユーザーがサービスを実際に利用する中で本当にexamineesテーブルに対する取得や操作は行われていないのかを確認することができます。
このおかげで、レガシーとなったexamineeモデルを安心して削除できるような状態を実現できています。

旧受検者モデルの削除

対応漏れの確認と検証を十分に行った上で、アプリケーションから旧受検者であるexamineeの概念を取り除きます。
アクセスがされないモデルが残ってしまうため、モデルとそれらに関連するファイルを削除しました。
移行先の新テーブルにデータは残っていますが、念の為旧テーブルのデータ自体の削除は一定期間行わない方針です。


プロジェクトを振り返って

このプロジェクトを通じて、データ構造のシンプル化と、プロダクトの拡張性向上を実現しました。
特に、段階的な移行戦略やデータ同期の仕組み、トランザクション管理の工夫は、同様の課題を抱えるエンジニアにとって参考になるポイントかと思います。
今後も、システムの進化に合わせて最適なデータ設計を模索しながら、継続的に改善を進めていきます。