自動取得後の大量データ確認を一日半から2時間に短縮した、ワークキュー型UIの設計
作成日:2026.09.12
自動取得後の大量データの確認・補正作業を題材に、管理画面をワークキュー型UIへ改善した事例を紹介します。少数件のキュー、自動補充、解決済み項目の非入力化、例外の別レーン化、unknown_resultや楽観的ロックなど、人間が大量データを安全かつ効率的に処理するための設計をまとめます。
目次
将棋のプロ棋士の対局結果を集めたデータベースを、趣味で構築しています。
将棋連盟公式サイトから月間の対局結果を取り込み、棋士や棋戦、対局結果を確認して、必要なら補正してから正式なデータとして反映する処理を作っていました。ところが、データを取り込むところまでは自動化できても、その後の確認と補正がかなり大変でした。
一か月分で約300件のデータを、管理画面で1件ずつ確認していました。最初の画面では、この作業に一日半ほどかかり、肩こりと疲労の地獄を見ました。これはやってられん、ということで作業の流れを見直し、ワークキュー型のUIに変更したところ、同じ一か月分の処理が2時間程度で終わるようになりました。
※ AIに任せればいいやん、とも考えたんですが、データ登録に例外ケースが発生することも多々あり、対応ルールをAIに渡せる粒度で文書化するのが難しかったです。結局AIに作業を任せた後に、再度人間の目で確認が必要になり、二度手間になりそうだったので今回は断念しました。
この記事では、そのときに考えたことと実装の要点をまとめます。特定の画面をそのまま紹介するのではなく、データの確認・補正・承認のように、人間が大量の対象を順番に処理する管理画面へ応用できる形に一般化して説明します。
今回の前提
今回の実装は、次のような構成の既存アプリケーションに追加しました。
- PHP 8.3.15
- Laravel 13.24.0
- MariaDB 10.4.32
- Vue.js 3.5.41
- Vite 8.0.0
ここでいうワークキューは、利用者が順番に処理する対象を画面上で保持するためのキューです。LaravelのJobやワーカープロセスで使う、バックグラウンド処理用のQueueとは別のものです。
まず自分で一連の作業をやってみる
最初に行ったのは、画面をいきなり作り直すことではありません。データの取り込みから確認、補正、承認、正式反映までの作業を、自分で最初から最後まで一巡してみました。
実際に触ってみると、仕様書や画面設計だけでは見えにくい負担が分かります。たとえば、次のようなことです。
- 毎回同じ説明を読み直している
- すでに確定している値まで入力欄に入っている
- 対象を処理した後、次の対象を探すために一覧へ戻っている
- 1件の例外や判断待ちで、全体の作業が止まっている
- 通信が終わった後に、どの入力へ戻ればよいか分からない
自分で作業してみることによって、「どの作業を簡略化できるか」「どの値を編集項目として残すか」「どういう画面なら人的負荷が減るか」を具体的に考えられるようになります。大量処理のUIでは、実装前に一度利用者として触ってみることがかなり大事だと思います。
一覧画面のままでは人間がボトルネックになる
最初の画面は、取り込んだデータを一覧で表示し、各行から詳細画面へ移動して確認する形式でした。一般的な管理画面としては自然な構成です。
ただし、今回の作業では1件ごとに次のような操作を繰り返します。
- 一覧から対象を選ぶ
- 詳細画面を開く
- 内容を確認する
- 必要な項目を補正する
- 承認または保留する
- 一覧へ戻って次の対象を選ぶ
1回の操作だけなら気にならない画面遷移やクリックでも、300回繰り返すと大きな負担になります。データベースの検索やサーバーの処理が速くても、人間が次の対象を探している時間は短くなりません。
この作業では、システムの処理時間よりも、人間の操作と判断の時間がボトルネックになっていました。そこで、一覧を高機能にする方向ではなく、作業の流れそのものを見直すことにしました。
少数件だけを保持するワークキュー
変更後の画面では、取り込んだ全件を最初から一覧に表示しません。現在処理する対象と、その後に処理する数件だけをワークキューとして保持します。
5件にした理由は、次の対象が見えている安心感と、目の前の対象へ集中できる状態のバランスがよかったためです。
- 次に処理する対象を少し先まで確認できる
- 画面上に対象が並びすぎず、現在の対象が埋もれない
- キューの状態をフロントエンドで管理しやすい
- 一度に取得するデータ量を抑えられる
これは5件が常に最適という意味ではありません。1件の処理に時間がかかる業務なら、もっと少なくてもよいでしょう。逆に、次の対象の確認が重要なら増やす選択肢もあります。大切なのは、全件を見せることではなく、作業に必要な範囲だけを見せることです。
処理が終わったら次の対象を自動的に補充する
キューを用意するだけでは、対象を処理するたびに利用者が次のデータを探すことになり、負担はあまり減りません。そこで、先頭の対象の処理が終わったら、次の対象を自動的に表示し、キューの末尾へ次の対象を補充します。
処理の流れを単純化すると、次のようになります。
キューの先頭を表示
↓
先頭の対象を確認・補正・処理
↓
完了した対象をキューから削除
↓
次の対象を先頭に表示
↓
キューが5件未満なら次の対象を取得
フロントエンド側の補充処理は、概念的には次のような形です。実際には認証エラーやレスポンスの検証なども行います。
const MAX_QUEUE_SIZE = 5;
const queue = ref([]);
async function refillQueue() {
while (queue.value.length < MAX_QUEUE_SIZE) {
const excludeIds = queue.value.map((item) => item.id);
const item = await fetchNextItem({
limit: 1,
excludeIds,
});
if (!item) {
break;
}
if (queue.value.some((queued) => queued.id === item.id)) {
continue;
}
queue.value.push(item);
}
}
async function completeCurrentItem() {
queue.value.splice(0, 1);
await refillQueue();
}
取得APIには、キューに入っている対象のIDを除外条件として渡しています。フロントエンドだけで重複を防ごうとせず、サーバー側でも対象の並び順や除外条件を適用することが重要です。
また、補充に失敗した場合でも、現在表示している対象の操作まで失敗扱いにしないようにしました。現在の対象を処理できる状態と、次の対象を取得できる状態は別々に管理します。
確認と正式反映を2段階に分ける
今回の画面は、大きく「確認・補正」と「正式反映」の2段階に分けました。
最初の段階では、取り込んだ内容と既存の情報を比較し、必要な項目だけを補正します。その後、確認済みのデータを正式なデータへ反映します。
この2つを1つのボタンにまとめると、どこまで完了したのかが分かりにくくなります。反対に、段階を分ければ、確認済みだが正式反映されていない対象や、正式反映で失敗した対象を別々に扱えます。
通常の対象はこの2段階をキューで順番に処理します。一方、確認できない対象や、追加の判断が必要な対象はキューから外し、個別確認待ちの一覧へ移します。
操作対象と判断対象を減らす
画面の入力欄を減らすことも、作業時間の短縮に効果がありました。
取り込んだデータの中には、すでに正しく解決できている値もあります。その値まで常にセレクトボックスや入力欄として表示すると、利用者は「変更しなくてよいこと」を毎回確認しなければなりません。
そこで、解決済みの値は通常のテキストとして表示し、確認や変更が必要な値だけを編集UIとして表示するようにしました。
const shouldShowEditor = computed(() => {
return props.field.status !== 'resolved';
});
実際の画面では、値を単純に隠すのではなく、解決済みであることが分かる表示にします。変更が必要な場合だけ入力欄を開けるようにすれば、入力項目を減らしながら情報量を保てます。
比較結果のうち、既存値と取り込み値が同じ項目も、差分一覧から除外しました。利用者が判断すべき差分だけを残すことで、判断対象そのものを減らしています。
フォーカスとスクロールを画面側で制御する
入力欄を減らしても、利用者が毎回マウスで入力欄を探していたら操作は軽くなりません。対象を切り替えたときは、最初に操作すべき入力欄やボタンへ自動的にフォーカスを移します。
ただし、コンポーネントを表示した直後は、まだDOMが更新されていないことがあります。Vue.jsでは、状態を更新した直後ではなく、nextTick()の後にフォーカス処理を行います。
await nextTick();
const target = cardElement.value?.querySelector(
'[data-work-input]:not(:disabled)'
);
if (target instanceof HTMLElement) {
target.focus({ preventScroll: true });
target.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
フォーカスしただけでは、画面の下の方にある入力欄が見えないことがあります。そのため、フォーカスと同時にscrollIntoView()も呼び出します。
対象を切り替えるたびに必ず先頭へスクロールすると、利用者が読んでいる位置まで動いてしまうことがあります。利用者がすでにカード内で入力中なら現在のフォーカスを維持し、カードが切り替わった場合だけ必要な位置へ移動するようにしました。
候補を選ぶセレクトボックスには、表示ラベルの先頭へ連番も付けました。候補名だけでは似た値を見分けにくい場合でも、「1番目」「2番目」のように選択肢を認識しやすくなります。キーボード操作と組み合わせると、マウスでの細かな位置合わせを減らせます。
正常系と例外系を別レーンに分ける
大量処理では、すべての対象が同じように処理できるとは限りません。参照先を解決できないデータ、既存データとの競合、入力内容が不十分なデータなどが混ざります。
このような対象をその場で解決しようとすると、正常に処理できる対象まで止まります。そこで、通常の処理レーンと、個別確認が必要なレーンを分けました。
現在の対象を判断できない場合は、「後で確認」としてキューの末尾へ回せます。
function deferCurrentItem() {
if (queue.value.length <= 1) {
return;
}
const current = queue.value.shift();
queue.value.push(current);
}
これにより、利用者は作業の流れを止めずに、判断できる対象から先に進められます。末尾へ回した対象は、後で再び表示されます。
一方、クイック処理の対象外になったものや、処理結果を確認する必要があるものは、個別確認待ちの一覧へ退避します。この一覧には、対象、発生した理由、必要なら詳細画面へのリンクを残します。
ここで大切なのは、例外を無かったことにしないことです。通常のキューから外しても、後から確認できる情報と導線は残します。正常系の処理を止めず、例外系の追跡可能性も維持する設計です。
通信の結果不明を処理失敗と分ける
非同期処理で特に注意したのが、リクエストを送信した後にレスポンスを受け取れなかった場合です。
たとえば、正式反映のリクエストを送信した直後に、ネットワークが切れたとします。このとき、サーバー側で処理が実行されていないとは限りません。実行されたものの、レスポンスだけが届かなかった可能性があります。
この状態で「失敗した」と決めつけて同じリクエストを自動再送すると、二重反映につながるおそれがあります。反対に、成功と決めつけて次へ進むと、未反映のデータを見落とすかもしれません。
そこで、処理状態を少なくとも次のように分けます。
submitting 送信中
success 成功を確認できた
validation 入力値の修正が必要
conflict 他の更新との競合
not_applied 反映されていないことを確認できた
applying サーバー側で処理中の可能性がある
unknown_result リクエスト結果を確認できない
unknown_resultになった場合は、同じMutationをすぐに再送しません。まず対象の最新状態を取得し、すでに反映済みなのか、処理中なのか、未反映なのかを確認します。その結果に応じて、再実行できる場合だけ利用者に再試行ボタンを表示します。
通信エラーと業務処理の失敗を同じ「エラー」として表示すると、利用者は次に何をすべきか判断できません。状態を分けることで、再送してよい状態と、状態確認を優先すべき状態を区別できます。
古いレスポンスで現在の対象を上書きしない
対象を切り替える画面では、前の対象の取得が終わる前に、次の対象の取得が始まることがあります。後から始めたリクエストが先に返ると、さらに後から前のレスポンスが返り、現在の画面を古いデータで上書きする可能性があります。
今回の実装では、対象を読み込むたびにリクエスト連番を増やし、現在の連番と一致しないレスポンスを破棄するようにしました。前のリクエストにはAbortControllerも使います。
let requestSequence = 0;
let controller;
async function loadWorkspace(id) {
const currentSequence = ++requestSequence;
controller?.abort();
controller = new AbortController();
const response = await fetch(`/workspace/${id}`, {
signal: controller.signal,
});
const workspace = await response.json();
if (currentSequence !== requestSequence) {
return;
}
currentWorkspace.value = workspace;
}
AbortControllerだけで十分とは限りません。中断できなかったリクエストや、すでに返り始めているレスポンスもあるため、連番による判定を組み合わせます。
楽観的ロックで古いデータの反映を防ぐ
UIを速くしても、別の利用者が同じデータを更新できるなら、書き込みの安全性を落としてはいけません。
楽観的ロックは、通常は他の処理と競合しないだろうと見込み、最初からデータをロックして待たせるのではなく、処理を先に進める方式です。その代わり、データを読み込んだ時点のバージョンを記録し、書き込む直前にそのバージョンが変わっていないか確認します。このように競合しないことを「楽観的」に仮定しているため、この名前で呼ばれます。変更されていれば、別の処理が先に更新したと判断して、上書きを中止します。
編集画面を開いている間に別の処理が対象を更新していた場合、画面に表示された古い内容で上書きすると、後から行われた更新を消してしまいます。そこで、読み込んだ時点のロックバージョンを、更新リクエストに含めるようにしました。
await fetch('/candidate/apply', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
candidateId: item.id,
expected_lock_version: item.lockVersion,
}),
});
サーバー側のバージョンと一致しなければ競合として扱い、画面を成功状態へ進めません。利用者には最新状態を読み込んで確認するよう案内します。
ワークキューによる高速化は、こうしたドメイン側の安全条件を省略することとは別です。画面上の操作回数を減らしても、認可、入力検証、CSRF対策、ロック、重複反映防止などは正式な書き込み処理側で引き続き必要です。
親コンポーネントと子コンポーネントの責務を分ける
ワークキューを1つの大きなコンポーネントに詰め込むと、対象1件の入力処理と、全体の進行管理が絡み合います。今回の画面では、親コンポーネントと対象カードの責務を分けました。
- 親コンポーネント: キュー、現在の対象、処理件数、補充、後回し、個別確認待ちを管理する
- 子コンポーネント: 1件の表示、入力、検証、確認処理、処理結果の通知を担当する
子コンポーネントは、処理が成功したときに「完了した」と親へ通知します。次の対象を表示するか、キューへ何件補充するかは親が判断します。
対象カード
├─ 入力値を検証する
├─ 1件の処理を実行する
└─ 成功・保留・競合などを親へ通知
キュー画面
├─ 通知を受けて先頭を削除する
├─ 次の対象を表示する
├─ キューを補充する
└─ 例外を別レーンへ移す
この分割により、対象カードの入力仕様を変更してもキューの進行処理へ影響しにくくなります。また、処理結果の種類が増えた場合も、親側で「キューから削除する」「残す」「個別確認へ移す」を整理しやすくなります。
ブラウザの作業状態と業務データを分ける
画面を閉じたり、途中で別のページへ移動したりすると、現在の作業件数や個別確認待ちの一覧が失われることがあります。そこで、現在のタブで作業を再開するための情報をsessionStorageへ保存しました。
保存するのは、次のような画面上の進行状態です。
- そのセッションで処理した件数
- 一度キューから退避した対象のID
- 個別確認待ちの対象と理由
一方、正式に反映されたかどうか、承認内容、ロックバージョンなどの業務データは、ブラウザの保存領域を信頼しません。これらはサーバー側のデータと状態照会APIで確認します。
sessionStorageは、あくまで作業画面を再開しやすくするための補助情報です。保存できない場合や内容が壊れている場合も考慮し、読み込みに失敗したら警告を表示して、サーバー側の状態を優先するようにします。
改善結果と、ワークキューが向いている作業
今回、一か月分で約300件の処理時間は、一日半程度から約2時間になりました。厳密な条件を揃えた性能ベンチマークではなく、同じデータを同じように人間が処理したときの実作業時間です。
短縮できた理由は、単に一覧から詳細画面への遷移を減らしたことだけではありません。
- 次の対象を探す時間をなくした
- 解決済みの値を毎回操作する必要をなくした
- 入力すべき場所へ自動的にフォーカスした
- 判断できない対象で正常系の流れを止めなかった
- 処理結果が分からない状態で不用意に再送しなかった
このような構成は、次のような業務と相性がよいと思います。
- CSVや外部サービスから取り込んだデータの確認
- OCRや自動抽出結果の確認・補正
- 移行データの差分確認
- 定型的な申請や登録内容の確認
- 機械的な判定結果を人間が最終確認する作業
反対に、1件の判断に長い時間がかかる場合、複数件を横に並べて比較する必要がある場合、対象ごとの差が大きく操作手順を定型化できない場合には、キュー型UIが必ずしも適しているとは限りません。利用者が何を見ながら判断しているのかを確認してから、キュー化する範囲を決める必要があります。
まとめ
大量のデータを人間が1件ずつ処理する画面では、サーバーの処理速度だけでなく、人間の操作と判断の流れを短くすることが重要です。
今回の実装では、次に処理する少数件だけをワークキューとして保持し、先頭の対象に集中できる画面を用意しました。1件の処理が終わると次の対象を自動表示し、キューの末尾へ補充します。
さらに、解決済みの値を入力欄から外し、必要な入力へフォーカスを移し、判断待ちや例外を別レーンへ退避しました。非同期処理では、成功・失敗・競合・結果不明を区別し、結果不明の場合は状態を確認してから再実行します。
いきなり大きな仕組みを作る必要はありません。まずは自分で一連の作業を行い、繰り返している操作、編集が不要な値、作業を止めている例外を見つけます。そのうえで、画面遷移の削減、解決済み項目の非入力化、自動フォーカス、少数件のキュー化という順に改善していくと、効果を確認しながら進めやすいと思います。
参考資料
奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。
これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。
【制作会社・企業様向けサポート】
Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。
ぜひ、プロフィールやWeb制作会社様向け業務案内、一般企業様向け業務案内もご覧くださいね。
ViteのJavaScriptチャンクサイズ警告が出たときの判断基準:コード分割・閾値変更・現状維持
2026.09.08
ViteのビルドでJavaScriptチャンクサイズ警告が出たときに、ビルドエラーとの違い、非圧縮サイズとgzipサイズの見方、静的importによるチャンク肥大化の原因を整理する。動的importや依存ライブラリの見直し、警告閾値の変更、現状維持について、一般ユーザー向けサービス・社内管理画面・自分専用の管理画面ごとの判断基準と本番運用上の注意点を紹介する。
Vue.jsで一覧画面の無限スクロールを実装する:サンプルJSONから一覧データを取得
2026.09.03
Vue.js単体のViteプロジェクトで、サンプルJSONからデータを取得し、IntersectionObserverで画面下部の監視要素を検知して5件ずつカード表示する無限スクロールを実装します。fetchのエラー処理や再読み込み、全件表示後の監視解除も扱い、サンプルJSONを一括取得するモックと、実際のAPIによるページングの違いも説明します。
Laravel 13+Vue.jsで画像・CSVを非同期アップロードする:FormData・ファイル検証・非公開保存
2026.08.31
Laravel 13のBladeに組み込んだVue.jsから、FormDataを使って画像またはCSVを非同期で送信する方法を解説します。JPEG・PNGの画像検証、CSVの文字コード変換と簡易的な内容確認、20MBのサイズ制限、非公開ディスクへの保存、CSRF・バリデーションエラー処理を実装し、fetchを基本にaxiosとの違いも整理します。
Laravel 13+Vue.jsで登録・編集フォームを非同期化する:fetchとaxiosを比較
2026.08.26
Laravel 13のBlade画面にVue.jsを組み込み、membersテーブルのnameとemailを登録・編集するフォームを作ります。Vue.jsからLaravelのWebルートへJSONを送信し、CSRFトークン、バリデーションエラー、送信中・通信失敗の状態を扱います。リクエスト送信にはfetchとaxiosを使う方法を比較し、追加パッケージの有無やエラー処理の違いを整理します。