1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【GAS】Google Apps Script受験校調査システム - パフォーマンスチューニングとUX向上の軌跡

Posted at

はじめに

Google Apps Script (GAS) とスプレッドシートで構築した「受験校調査システム」の運用を通じて得られた知見と、パフォーマンス・堅牢性・UXを向上させるために実施した一連の改善について紹介します。

本記事では、v1.0.0から最新版v2.3.0に至るまでの技術的な進化を、実装の詳細とともに解説します。前記事へのリンクはこの記事の最後にあります。

GitHubリポジトリ: https://github.com/suzukishigeru4b/juken-survey-public


目次

  1. 背景: v1.0.0の課題
  2. v2.1.1: パフォーマンス改善
  3. v2.1.1: 堅牢性の向上
  4. v2.3.0: UXとキャッシュ戦略の改善
  5. まとめ

背景: v1.0.0の課題

初期リリース (v1.0.0) では、GASの標準的なメソッド (SpreadsheetApp) を中心に実装していました。
しかし、利用者(生徒・教員)が増えるにつれて、以下の問題が顕在化してきました。

主な課題

  1. 読み込みが遅い: 初期データ表示に時間がかかる
  2. 保存が重い: データ件数が増えるほど、保存処理(全件上書き)に時間がかかる
  3. 排他制御の限界: 同時アクセス時にロック待ちが発生しやすい
  4. 保守性の問題: クライアント側で開始行を指定する設計のため、シート構造変更時の影響範囲が大きい

これらを解決するため、段階的な改善を実施しました。


v2.1.1: パフォーマンス改善

1. Sheets API (batchGet) によるデータ取得

v1.0.0では、データ取得に getValues() を多用していました。しかし、スプレッドシートへのアクセスはAPIコールのオーバーヘッドが大きいため、回数が増えると致命的に遅くなります。

Before (v1.0.0):

// シートごとに個別にアクセス(遅い)
const examTypes = activeSpreadsheet.getSheetByName('試験形態').getDataRange().getValues();
const results   = activeSpreadsheet.getSheetByName('合否選択肢').getDataRange().getValues();
const students  = activeSpreadsheet.getSheetByName('学籍データ').getDataRange().getValues();
// ...

After (v2.1.1):
Google Sheets API (Advanced Services) を利用し、batchGet で必要なデータを1回のリクエストで一括取得するように変更しました。

// 必要な範囲を一括指定
const ranges = [
  `'試験形態'!A2:A`,
  `'合否選択肢'!A2:A`,
  `'学籍データ'!A1:E`,
  `'職員データ'!A1:B`,
  `'設定'!A2:B`
];

// 1回のAPIコールで全て取得(速い!)
const response = Sheets.Spreadsheets.Values.batchGet(spreadsheetId, { ranges: ranges });
const valueRanges = response.valueRanges;
// valueRanges[0] <- '試験形態'!A2:A
// valueRanges[1] <- '合否選択肢'!A2:A
// ...

rangesの配列と同様の並びでvalueRangesが一度に取得できます。
これにより、初期ロード時のサーバーレスポンスタイムが劇的に改善しました。


2. 初期ロードの2段階化 (UX向上)

「画面が真っ白な時間が長い」は、ユーザーにとって最大のストレスです。
v1.0.0では全てのデータを一度に取得していましたが、v2.1.1では初期ロードを2段階に分け、体感速度を向上させました。

  1. First Paint (即座に表示):
    getInitialData() で、ユーザー情報・権限・設定値などの「アプリの起動に最低限必要な軽量データ」のみを取得します。これにより、数秒以内にヘッダーやメニューなどのUIが表示されます。

  2. Lazy Load (裏で取得):
    UI表示後、役割に応じて重いデータを非同期で取得します。

    • 教員の場合: 全生徒の名簿データ (getStudentsList)
    • 生徒の場合: 自分の受験登録データ (getExamDataList)
    • 共通: 大学マスタデータ (getUniversityDataList)
// script.html (Client)
google.script.run.withSuccessHandler(data => {
  // 1. まずUIを描画
  renderHeader(data);
  
  // 2. その後、重いデータを非同期で取りに行く
  if (data.role === 'teacher') {
    google.script.run.withSuccessHandler(showStudentList).getStudentsList();
  }
}).getInitialData();

これにより、ユーザーは「待たされている」感覚が薄れ、アプリがサクサク動いているように感じられます。


3. データ書き込みアルゴリズムの見直し (Upsert)

最も効果が大きかったのが、保存ロジックの刷新です。
v1.0.0では「全データを読み込み → メモリ上で修正 → シート全体をクリア → 全データを書き込み」という全件上書き方式をとっていました。これはデータ量に比例して処理時間が延びるだけでなく、データ全件喪失などのリスクが気になっていました。

After (v2.1.1): 差分更新 (Upsert) の導入

v2.1.1では、変更が必要な行だけを特定し、batchUpdate で更新、新規データは append で追加する方式に変更しました。ここでも利用しているのはSheets APIです。

  1. インデックススキャン: 対象ユーザーのデータが含まれる行番号だけを高速に特定
  2. 差分検知: 実際に値が変わったレコードのみを更新対象リストに追加
  3. 一括実行: Sheets API で更新と追加を実行
// 更新用リクエストの作成イメージ
if (isChanged) {
  dataToUpdate.push({
    range: `'受験校DB'!A${rowIndex}:H${rowIndex}`,
    values: [updateRow]
  });
}

// ... (中略) ...

// APIで一括更新
if (dataToUpdate.length > 0) {
  Sheets.Spreadsheets.Values.batchUpdate({
    valueInputOption: 'USER_ENTERED',
    data: dataToUpdate
  }, spreadsheetId);
}

この変更により、データが数千件に増えても保存時間はほぼ一定(数秒以内)に収まるようになりました。また、ロック占有時間も短縮され、並行実行性能が向上しました。


4. クライアントサイド・キャッシュ (LocalStorage)

大学データのような「頻繁に変わらないがサイズが大きいデータ」は、サーバーから毎回取得するのは無駄です。
v2.1.1では、ブラウザの localStorage を活用し、24時間の有効期限付きでクライアント側にキャッシュするようにしました。

// script.html (Client Side)
function getUniversityDataList() {
  const CACHE_KEY = 'universityDataCache';
  // LocalStorageを確認
  const cachedData = localStorage.getItem(CACHE_KEY);
  if (cachedData && !isExpired(cachedData)) {
    // キャッシュから復元(通信なし!)
    onUniversityDataReceived(cachedData); 
    return;
  }
  // ない場合はサーバーへ問い合わせ
  google.script.run...getUniversityDataList();
}

5. サーバーサイド・キャッシュ (CacheService)

「設定」「試験形態」「合否選択肢」といったマスタデータは、すべてのユーザーが共通で利用する上に変更頻度が極めて低いため、GASの CacheService を活用しています。
スプレッドシートへのアクセスはコストが高いため、一度読み込んだデータはサーバーメモリ(Cache)に保存し、次回以降のリクエストではシートを開かずにキャッシュから返却します。

// main.js (Server)
function getSheetData(sheetName) {
  const cache = CacheService.getScriptCache();
  const cachedData = cache.get(sheetName);
  if (cachedData) return cachedData; // キャッシュヒット!

  // キャッシュがない場合のみシートにアクセス
  const data = spreadsheet.getSheetByName(sheetName)...getValues();
  cache.put(sheetName, JSON.stringify(data), 21600); // 6時間キャッシュ
  return JSON.stringify(data);
}

これにより、同時アクセスが増えた際のスプレッドシート負荷を最小限に抑えています。


v2.1.1: 堅牢性の向上

学校システムでは「データの正確性」が命です。バグによるデータ破損や不整合を防ぐため、以下の対策を徹底しました。

1. データ型の厳格化 (Strict Mode)

Sheets API は、データの型を自動判別して返しますが、Boolean値が "TRUE" (文字列) として返ってくるなどの罠があります。
v2.1.1では、アプリケーション内で扱う型を厳密に定義し、ヘルパー関数を通して利用するように統一しました。

// Boolean変換ヘルパー
const isTrue = (val) => String(val).toUpperCase() === 'TRUE';

// 使用例:比較演算子も '===' を使用
if (isTrue(row[EXAM_DATA.SHINGAKU]) === true) { ... }

また、大学コードなどの識別子は数値を入力しても必ず String 型として扱うよう徹底し、00123123 が別のものとして扱われる(あるいは同一視されるべき場面でされない)事故を防ぎました。


2. 重複データへのResilience (回復力)

教員が直接スプレッドシートを編集して、同一大学コードのデータを誤って登録してしまった場合など、稀にデータが重複して登録されるケースがありました。
保存ロジック (saveExamDataList) 内で 重複を検知し、自動的に修復するロジックを組み込みました。

  • 同じ大学コードのデータが複数見つかった場合、先頭のデータを正として更新
  • 重複している残りのデータは「論理削除フラグ」を立てて無効化

これにより、万が一データ不整合が発生しても、ユーザーの目には正常な状態が維持されるようになりました。


3. なりすまし防止 (Server-side Validation)

クライアントサイド(JavaScript)の変数は、ブラウザのデベロッパーツールを使えば簡単に書き換えられてしまいます。
例えば、「自分のメールアドレス」が入っている変数を他人のものに書き換えて送信すれば、他人になりすましてデータを登録できてしまうリスクがあります。

v2.1.1では、サーバーサイド(GAS)の保存処理において、**「実行権限の再確認」**を実装しました。

  • 生徒が実行する場合: Session.getActiveUser().getEmail() で取得した「実行者のメールアドレス」と、保存しようとしているデータのメールアドレスが一致するかを必ずチェックします。
  • 教員が実行する場合: 実行者が「職員データ」に含まれているかを確認し、代理入力権限がある場合のみ他人のデータ保存を許可します。

これにより、クラッキングや悪意ある操作によるデータ改ざんを未然に防いでいます。


4. 論理削除データの定期パージ (Time-driven Trigger)

パフォーマンス向上のために導入した「論理削除」(Upsert時に削除フラグを立てる処理)ですが、これを放置するとスプレッドシート内に不要な「ゴミデータ」が蓄積し続け、長期的には読み込み速度の低下などを招きます。

そこで、GASの**「時間主導型トリガー」**を利用し、アクセスが少ない深夜帯に、論理削除フラグが付いたレコードを一括で物理削除(完全消去)するバッチ処理をスケジュールしました。

  • 日中: 「論理削除」で高速にレスポンスを返す
  • 夜間: 「物理削除」でデータをクリーンアップし、サイズを最小化する

このハイブリッドな運用により、パフォーマンスとデータ健全性の両立を実現しました。


v2.3.0: UXとキャッシュ戦略の改善

v2.3.0では、プロトタイプから「実務で耐えうるアプリケーション」へと進化させるため、UX(ユーザー体験)の向上キャッシュ戦略の完全自動化に注力しました。

改善ポイント概要

  1. データ取得の堅牢化: フロントエンドとバックエンドの密結合を解消
  2. キャッシュ戦略の自動化: onEditトリガーを活用した「腐らないキャッシュ」
  3. 通信パフォーマンスの最適化: 巨大なマスタデータのGzip圧縮転送
  4. UXの洗練: ダークモード対応と初期ロードの高速化

1. データ取得の堅牢化

抱えていた課題

v1.0.0では、データ取得時にクライアント側から「何行目から取得するか」を指定する startRow パラメータを渡していました。
しかし、これには致命的な欠点がありました。「ヘッダー行が増えた」「仕様が変わった」といった変更があるたびに、クライアント側の定数を書き換える必要があり、保守性が低かったのです。

改善策

v2.3.0では、**「データの取得範囲はサーバー(GAS)が全責任を持つ」**という設計に変更しました。

  1. GASは常にシートの先頭(A1)からデータを取得する
  2. GAS側でヘッダー行(1行目)を削除する
  3. クライアントには純粋なデータのみを渡す
// 変更前(イメージ): クライアントが開始行を指定
// function getSheetData(sheetName, startRow) { ... }

// 変更後: サーバー側で制御
function getInitialData() {
  // ... (省略)
  // バッチ取得したデータから、サーバー側でslice(1)してヘッダーを除去
  const examTypeOptions = (dataList[0] || []).slice(1).map(row => row[0] || '');
  const resultOptions = (dataList[1] || []).slice(1).map(row => row[0] || '');
  // ... (省略)
}

これにより、スプレッドシートの構造が変わってもサーバー側の修正だけで済み、クライアントコードへの影響を最小限に抑えることができました。


2. キャッシュ戦略の完全自動化

抱えていた課題

GASの CacheService は高速化に必須ですが、 「スプレッドシートを直接編集した内容が反映されない」 という問題がありました。
例えば、教員がスプレッドシートで「学籍データ」を更新しても、キャッシュ有効期限(6時間)が切れるまで古いデータがアプリに表示され続けてしまうのです。

改善策:イベント駆動型キャッシュ更新

スプレッドシートの編集イベント(onEdit, onChange)をトリガーにして、**「データが変更された瞬間に対象のキャッシュだけを更新する」**仕組みを構築しました。

// キャッシュ更新対象のシート
const CACHE_TARGET_SHEETS = [
  SHEET_NAMES.SETTINGS,
  SHEET_NAMES.TEACHERS,
  SHEET_NAMES.STUDENTS
  // ...
];

// 編集時トリガー
function onEdit(e) {
  if (!e) return;
  checkAndUpdateCache(e.range.getSheet().getName());
}

管理者メニューからこれらのトリガーを一発で設定できる機能も追加し、「高速表示」と「データの鮮度」を両立させました。


3. 大容量データの圧縮転送とローカルストレージ活用

今回のアップデートで最も効果が大きかったのが、大学マスタデータの取り扱いです。

抱えていた課題

約60000件以上のデータを含む「大学マスタ」は、JSON化すると約2MBにもなります。
これを毎回GASからそのまま転送すると、通信時間がかかり、モバイル回線の生徒には負担となります。また、GASの実行時間制限(TimeOut)のリスクもありました。

改善策:Gzip圧縮とLocal Storageの活用

① サーバー側での圧縮(GAS)

Utilities.gzip() を使用してデータを圧縮し、Base64エンコードして送信します。
これにより、データサイズを**約2MB → 約600KB(約70%削減)**まで軽量化しました。

// サーバー側 (GAS)
function getCompressedUniversityData() {
  const data = sheet.getDataRange().getValues();
  const jsonString = JSON.stringify(data);
  const blob = Utilities.newBlob(jsonString, 'application/json');
  
  // Gzip圧縮してBase64で返す
  const compressed = Utilities.gzip(blob);
  return Utilities.base64Encode(compressed.getBytes());
}

② クライアント側での解凍と保存(Client)

クライアント側では、ブラウザの標準ライブラリを使用して解凍します。さらに、一度取得したデータは localStorage に保存し、24時間は再取得しないようにしました。

// クライアント側
async function loadUniversityData() {
  // 1. ローカルストレージをチェック
  const cachedData = localStorage.getItem('univData');
  if (cachedData && !isExpired(cachedData)) {
    return JSON.parse(cachedData);
  }

  // 2. なければサーバーから圧縮データを取得
  const base64Data = await google.script.run.withSuccessHandler(d => d).getCompressedUniversityData();

  // 3. 解凍処理 (Base64 -> Uint8Array -> ungzip -> String)
  const response = JSON.parse(responseStr); // サーバーからのレスポンスをパース
  const compressedData = base64ToUint8Array(response.compressed); // Base64デコードしてUint8Arrayに変換
  const decompressed = await decompressGzip(compressedData); // gzip解凍(ブラウザ標準API使用)

  // 4. ローカルストレージに保存
  localStorage.setItem('univData', decompressed);
  return JSON.parse(decompressed);
}

この実装により、 初回のロード時間短縮」「2回目以降の爆速表示(サーバー通信ゼロ)」 を実現しました。

大学データについてはクライアント側でキャッシュしています。
したがって、シート上で大学コードを更新してもクライアントの大学データは更新されません。だからといって毎回600Kbものデータを転送するのはコストがかかりすぎます。

そこで、設定シートに大学データのシリアル番号を持たせて、初期データーとして送信するようにしました。このシリアル番号とキャッシュされているシリアル番号を比較してシリアル番号に変更があった場合はサーバーからデータを読み込むようにしました。
これにより大学データは常に最新になります。
シリアルはBenesseのシートを取り込むと自動的にインクリメントするようプログラムしています。ただし、データを手動で訂正した場合はシリアル番号を手動で更新する必要があります。


4. UXとパフォーマンスの向上

初期ロードの高速化(Batch Get)

アプリ起動時に必要なデータ(設定、マスタ、ユーザー情報など)を、個別に取得するのではなく、Sheets APIbatchGet を使って1回のリクエストで全て取得するようにしました。
GASはサーバーとの通信回数がパフォーマンスに直結するため、これは劇的な効果がありました。

ダークモード対応

生徒たちは夜間にスマホで操作することも多いため、css.html でCSS変数を活用し、OSの設定に連動したダークモードを実装しました。

:root {
  /* Light Theme */
  --color-bg: #f5f7fb;
  --color-text: #222222;
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Dark Theme */
    --color-bg: #1a1a1a;
    --color-text: #f0f0f0;
  }
}

まとめ

GASは手軽で強力ですが、スプレッドシートをデータベースとして大規模に使う場合は、APIの特性を理解した実装が不可欠です。

パフォーマンス最適化のポイント

  • Read: batchGet でまとめて取る。初期ロードは2段階に分けてUX向上
  • Write: 全件上書きをやめて、batchUpdate で差分更新(Upsert)する
  • Cache:
    • 不変データはクライアント(localStorage)
    • 共有マスタはサーバー(CacheService)に持つ
    • イベント駆動型の自動更新で鮮度を保つ
    • ブラウザ側のキャッシュはシリアル番号で管理する
  • データ転送: 大容量データはGzip圧縮して転送

堅牢性向上のポイント

  • 型の厳格化: ヘルパー関数を通じた一貫した型管理
  • なりすまし防止: サーバーサイドでの実行権限チェック
  • データ整合性: 重複データの自動検知と修復
  • 定期メンテナンス: 論理削除データのパージ

アーキテクチャ設計のポイント

  • 責任の分離: データ取得範囲の制御はサーバー側に一元化
  • 保守性: クライアント側のハードコーディングを排除
  • UX: 段階的なデータロードと視覚的フィードバック

これらを意識することで、GASアプリの品質は飛躍的に向上します。
同様のシステムを構築されている方の参考になれば幸いです。


ソースコード

本プロジェクトの全ソースコードは GitHub で公開しています。

GitHubリポジトリ: https://github.com/suzukishigeru4b/juken-survey-public

ファイル構成

juken-survey-public/
├── main.js                      # サーバーサイドロジック
├── index.html                   # メイン画面HTML
├── css.html                     # スタイルシート
├── script.html                  # クライアントサイドJS
├── appsscript.json              # マニフェスト
├── README.md                    # プロジェクト概要
├── PROGRAM_SPECIFICATION.md     # 技術仕様書
├── TEACHER_MANUAL.md            # 教員向けマニュアル
├── STUDENT_MANUAL.md            # 生徒向けマニュアル
└── VERSION_CHANGES.md           # 変更履歴

スプレッドシートの構造(各シートの列定義など)は v1.0.0 からほぼ変更していないため、旧バージョンのシートをそのまま v2.3.0 のバックエンドとして流用することが可能です。
設定シートのみ変更 があります。
4行目に大学データシリアルを挿入してください

項目
1 ページタイトル 受験校調査アプリ
2 受験校入力最大数 30
3 入力許可(生徒) TRUE
4 大学データシリアル 1
5 メール件名 調査書発行願
6 メール本文 調査書発行願を添付します。
印刷して提出してください。

関連記事

初期バージョンの実装については、以前の記事もご覧ください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?