はじめに
こんにちは。ソーイ株式会社の工藤です。
本記事では、予約機能で実際に発生した「Googleカレンダー連携同期ラグによる誤予約」を題材に、再発防止として実装した内容を共有します。
結論
同期バッチだけでは防げていなかった「カレンダーの予定登録直後のラグによる誤予約」を防ぐため、予約確定前にGoogle Calendar APIでリアルタイムチェックを行う仕組みを追加しました。
重複予定がある場合は予約不可、外部確認に失敗した場合も予約を成立させない(fail-closed)設計にしています。
背景と業務との関連
本対応は、受託開発している予約機能を備えた業務システムにおいて発生した、ユーザーの誤予約を防ぐための対応です。
誤予約はユーザー体験の低下だけでなく、手動でのスケジュール調整コスト増加にも直結するため、実装側での防御を優先しました。
想定読者
- Google Calendar APIを使った予約機能を実装しているエンジニア
- 外部カレンダー連携の競合問題に悩んでいる方
- 「同期しているから大丈夫」と思っていたが、実際に不整合を経験した方
この記事でわかること
- なぜ「バッチ同期だけ」では競合を防ぎ切れないのか
- 予約確定前チェックをどこに組み込むと効果的か
- fail-closed設計で予約整合性を守る実装パターン
- 検証時に見落としやすい境界ケースと運用上の注意点
何が問題だったか
実際の運用では、Googleカレンダーに予定が登録されているにもかかわらず、その時間帯に予約が成立する事象が確認され、ダブルブッキングのリスクが発生していました。
このとき、アプリ側では同期済みデータをもとに空き枠を表示していたため、Googleカレンダーに予定が追加された直後は、表示上は空いていても実際には予約できない時間帯が一時的に生じていました。
仕様検証で確認された問題は次の通りです。
- Googleカレンダーに予定を追加した直後、同期バッチ反映前は空き枠に見える
- その間にユーザーが予約確定すると、実際には予定がある時間帯でも予約成立し得る
なぜこの方法を選んだか
今回採用した方針は「同期短縮」ではなく「確定直前の最終確認」です。
代替案として同期バッチの実行間隔をさらに短縮する方法も検討しましたが、API呼び出し回数の増加に対して同期ラグを完全には解消できないため、確定直前に最終確認を行う方式を選びました。
処理フロー(今回の実装)
処理としては、まずアプリ側で予約リクエストの妥当性と予約可能状態を確認し、その後、連携中の Googleカレンダーに対して同時間帯の予定有無を照会します。アプリ側で予約不可ならその時点でブロックし、アプリ側で予約可能でも Googleカレンダー上で競合が見つかった場合は予約を成立させません。さらに、外部確認自体に失敗した場合も予約をブロックします。

※予約確定時に「内部判定 → Google確認」の順で二重チェックしています。
システム連携の前提(Google Calendar API と OAuth)
予約確定直前に Google Calendar API(例:events.list など)を呼び出し、連携中カレンダー上の予定と時間帯が重ならないかを確認しています。Google Calendar API は、利用者のGoogleアカウントに代わってカレンダーを参照する用途で使うため、単なる API キーだけでは足りず、OAuth 2.0 による認可とアクセストークンが前提になります。
初回連携
利用者がブラウザでGoogleの同意画面に進み、アプリに権限(カレンダー参照など)を付与する
トークン取得
認可コードをアクセストークン(および多くの場合リフレッシュトークン)に交換し、アプリ側で安全に保管する
API 呼び出し
予約確定前の競合チェックなど、必要なタイミングでアクセストークンを付与して Calendar API を呼び出す
トークン更新
アクセストークンの有効期限が切れた場合は、リフレッシュトークンから再取得する(実装・保管ポリシーはアプリ側の設計による)
実装したポイント(コードベース)
1.予約確定前フローへの組み込み
// target_id をもとに、予約可否判定の対象となる予約対象を取得する
$target = $this->findBookableTarget($request->input('target_id'));
$startAt = new DateTimeImmutable($request->input('start_at'));
$endAt = new DateTimeImmutable($request->input('end_at'));
// 予約確定前に、連携カレンダー上の重複予定を確認する
if ($this->hasCalendarConflict($target, $startAt, $endAt)) {
return response()->json([
'message' => ['連携カレンダーに重複予定があるため、この時間帯は予約できません。']
], 422);
}
// 問題がなければ予約処理を続行する
2.連携カレンダーがある場合のみGoogle APIを照会
// Google カレンダー競合確認で参照する関連データを、未読み込みの場合のみ追加で読み込む
$target->loadMissing(['linkedCalendar', 'linkedAccount']);
// カレンダー連携がない場合は、外部カレンダーとの競合確認は行わない
if (! $target->linkedCalendar) {
return false;
}
補足
外部API呼び出しの中で関連情報を追加参照する場合があるため、必要なリレーションは先にまとめて読み込んでおくと、不要なクエリを減らせます。
3.競合判定ロジック
外部カレンダーとの競合確認用メソッドを追加し、対象時間帯の予定を取得して重複を判定します。
予定取得時には、判定に必要な条件を指定して展開済みのイベント一覧を取得します。
また、キャンセル済みの予定や、予定ありとして扱わないイベントは除外し、終日予定と時間指定予定の両方を判定対象に含めます。
判定式は以下:
// 既存予定の終了時刻が新規予約の開始時刻より後
// かつ、既存予定の開始時刻が新規予約の終了時刻より前なら、時間帯が重なっている
$isOverlapping = $eventEnd > $startAt && $eventStart < $endAt;
4.外部確認失敗時は予約を成立させない(fail-closed)
try {
// 連携カレンダーとの競合確認処理
} catch (\Throwable $e) {
Log::error('Failed to check linked calendar conflict.', [
'target_id' => $target->id,
'start_at' => $startAt->format(DATE_RFC3339),
'end_at' => $endAt->format(DATE_RFC3339),
'error_message' => $e->getMessage(),
]);
return response()->json([
'message' => ['連携カレンダーの確認に失敗しました。時間をおいて再度お試しください。']
], 500);
}
5.特定の予約パターンでも競合チェックを省略しない
このシステムでは、利用者操作・管理画面操作など複数経路で予約が作成されるため、作成経路にかかわらず同じ競合チェックを行います。
// 予約の作成元にかかわらず、連携カレンダーとの予定重複は確認する
if ($this->hasCalendarConflict($target, $startAt, $endAt)) {
return response()->json([
'message' => ['連携カレンダーに重複予定があるため、この時間帯は予約できません。']
], 422);
}
検証結果
- 重複時(単発・繰り返し・終日予定)は予約不可
- 境界条件(予約終了 = 予定開始)は予約可能
- 連携対象外カレンダーは影響しない
- バッチ未反映状態でも競合検知可能
実際にハマったポイント
1.設定不一致によるOAuth 403(access_denied)
OAuthの403エラーは単純な権限エラーではなく、以下の不一致で発生しやすいです。最初、テスターの設定を行っていませんでした。
- OAuthクライアントID
- テスター設定
- リダイレクトURI
これらが一致していない場合、認証時に access_denied となります。
2.ローカル環境でのOAuth設定の落とし穴
staging環境で利用していたGoogle CloudのOAuthクライアントをそのままローカルで使用したところ、 リダイレクトURIやテスター設定の不一致により認証エラーが発生しました。環境ごとに前提が異なるため、以下の対応が必要です。
- 環境ごとにOAuthクライアントを分ける
- またはリダイレクトURIを適切に設定する
3.競合判定の対象は「連携中カレンダー」のみ
Googleカレンダー上に予定が存在していても、 アプリと連携していないカレンダーの予定は競合対象になりません。そのため、どのカレンダーを連携対象にしているかを正しく把握することが重要です。実際に複数のカレンダーを利用しているケースで、連携カレンダーが意図したものと異なり、予定が反映されないケースがありました。
4.連携アカウントのメールアドレスではエイリアスは使用できない
Googleカレンダー連携時、メールアドレスのエイリアス(例:user+test@gmail.com)を使用すると、正しく認証・連携できません。テストのためエイリアスを使おうとして引っかかりました。実際にGoogleアカウントに紐づいているメールアドレスを使用する必要があります。
参考リンク(公式)
- Google Calendar API Overview
- Use OAuth 2.0 to Access Google APIs
- Calendar API events.list
- Handle errors
お知らせ
技術ブログを週1〜2本更新中、ソーイをフォローして最新記事をチェック!
https://qiita.com/organizations/sewii
