はじめに
😇「Google Maps Platform には月 $200 の無料枠があるし、個人開発なら収まるでしょ」
🤔「店名と住所くらいしか取らないから、安い階層で済むはず…」
😱「……ユーザーが 5,000 人になったら、月いくら?」
従量課金の API を組み込むとき、最初の見積もりはだいたい楽観的です。
自分が個人開発している食の口コミサービス「あじぴた」でも、まさにこれが起きました。
店舗検索に Google Places API を入れたところ、試算は MAU 5,000 で月 $1,440。
サーバーとデータベースにかかっている月額(約 $45)の 32 倍です💸
この記事では、そこから8 層の防壁を組み立てて、
課金の上振れを構造的に起こらないようにした設計をまとめます🛡️
Places API の話ですが、LLM API や決済 API など、従量課金の API 全般にそのまま持ち帰れる内容にしています。
あじぴた全体の構成は、こちらの記事にまとめています。
「低評価を公開しない」口コミサービスを個人開発した話 — 4,300コミット・6リポジトリの全体像
結論:単価は下げられない。呼ぶ回数を減らすしかない💡
先に結論です👇
従量課金 API のコストは「安く使う」では抑えられない。「呼ばない」設計にするしかない。
単価は提供元が決めるもので、こちらからは動かせません。🙅
動かせるのは呼ぶ回数だけです。💡
そこで、外部 API を呼ぶまでに 8 つの関門を置きました。🚧
| # | 防壁 | 効き方 |
|---|---|---|
| ① | 外部 API を使わない経路を用意する | そもそも呼ばない |
| ② | Places を呼ぶ条件を絞る | 必要なときしか呼ばない |
| ③ | キャッシュする | 同じ検索で 2 回呼ばない |
| ④ | 回数の上限を設ける | 呼べる回数に天井を作る |
| ⑤ | 回数の記録を消させない | 天井を外させない |
| ⑥ | 取得する項目を最小にする | 1 回あたりの単価を上げない |
| ⑦ | サーバー側で実測する | 実際に何回呼んだかを知る |
| ⑧ | 検索結果で返した ID だけ受け付ける | でたらめな ID で呼ばせない |
一つずつ説明する前に、なぜ Places API を入れることになったのかから書きます。
ここが一番の「つまずいたポイント」でした😅
つまずいたポイント:HotPepper だけで行けると思っていた
① リリースしたら、口コミを書きたい店が出てこない
あじぴたは、もともとPlaces API を使わない方針でした。🙅
店舗検索は HotPepper グルメサーチ API(無料)だけで組み、
Google Maps は地図の表示だけに限定していました。従量課金を避けるためです。🗺️
ところがリリースして運用してみると、
口コミを投稿しようとして検索しても、店が出てこないケースが多発しました😢
原因は HotPepper 側にありました。🔍
HotPepper グルメサーチ API に載っているのは、ホットペッパーに掲載しているお店です。
個人店やチェーンの一部は、構造的に抜けています。🏮
検索条件や設定をいくら変えても、載っていない店は出てきません。
口コミ投稿というサービスの中核の、入口が塞がっている状態でした。🚪
② 「店舗申請」はユーザーの負担が大きすぎた
この時点での救済策は、店舗申請でした。📝
検索で出てこない店は、ユーザーが Google マップのリンクを貼って申請し、登録してから口コミを書く。
ただ、これは口コミを書きたいだけのユーザーに、店舗の登録作業を求めることになります。
ユーザーがやりたいこと: この一皿の口コミを書きたい
実際に求めていたこと: ① 店を検索 → ② 出てこない → ③ Google マップでリンクを取る
→ ④ 申請 → ⑤ ようやく口コミを書く
**手間が多すぎて、中核の体験として成立していませんでした。**😩
③ 慌てて Places を入れたら、コストが想像以上に重かった
そこで、検索そのものに Places API を入れることにしました。
Google マップにある店なら、検索で出てくるようにする。🔎
ところが、改めてコストを見積もると想像以上に重かったんです💦
ここから先は、「なるべくお金をかけずに、出てこない店を出す」ための設計の話になります。💪
前提が 2 つ崩れていた😇
コストを見積もりし直したとき、それまでの前提が 2 つとも成り立っていないことが分かりました。😲
月 $200 の無料クレジットは、もう無い
以前の設計判断の記録(ADR)には、こう書いてありました。
無料クレジット(月 $200)の範囲内で運用する
ところが Google は 2025 年 3 月 1 日に、月 $200 のクレジットを廃止しています。
代わりに、SKU(課金単位)ごとの無料コール数に変わりました。📋
| 階層 | 無料コール数(月) |
|---|---|
| Essentials | 10,000 件 |
| Pro | 5,000 件 |
| Enterprise | 1,000 件 |
「クレジットの範囲に収める」という考え方自体が、成立しなくなっていました😇
店名を取った時点で Pro 階層になる
もう 1 つは、課金階層の認識です。
「安い階層のフィールドだけに絞れば、コストを抑えられる」と考えていました。🤔
しかし Places API (New) の課金は、リクエストで要求したフィールドのうち、最も高い階層で決まります。
そして店名(displayName)は Pro 階層です。
店名を取らない店舗検索はあり得ないので、Pro 階層の課金は避けられません。💸
| 用途 | 無料枠を超えた後の単価 |
|---|---|
| 店舗検索(Text Search Pro) | $32 / 1,000 件 |
| 店舗詳細(Place Details Pro) | $17 / 1,000 件 |
| エリア名 → 座標(Geocoding) | $5 / 1,000 件 |
試算したら MAU 5,000 で月 $1,440
前提を直したうえで、「1 ユーザーあたり月 10 回検索する」と仮定して試算しました📊
| MAU | 月間リクエスト | 月額 |
|---|---|---|
| 500 | 5,000 | $0(無料枠内) |
| 1,000 | 10,000 | $160 |
| 2,000 | 20,000 | $480 |
| 5,000 | 50,000 | $1,440 |
あじぴたの固定費は、Vercel と Supabase を合わせて月 $45 程度です。
MAU 5,000 で、外部 API だけで固定費の 32 倍になります。🤯
しかもこれは、サービスが成功するほど死ぬ構造です。
ユーザーが増えて嬉しいはずの状況が、そのまま請求に直結する😱
個人開発なので、払えない額になった時点でサービスは続けられません。
だから呼ぶ回数そのものを減らす設計に振り切りました。✂️
8 層の防壁🛡️
全体の流れはこうなっています👇
外部 API に到達して課金が発生するのは、右下の赤い 1 箇所だけです。
それ以外のすべての分岐は、無料で結果を返します。🆓
① 外部 API を使わない経路を用意する
「あじぴた登録店のみ」という検索モードを用意しています。
これは自前のデータベースだけを引くので、外部 API を一切呼びません。🏠
この経路の利用比率が上がるほど、コストは下がります📉
② Places を呼ぶ条件を絞る
Places は常に呼ぶわけではありません。☝️
- 主経路は HotPepper(無料)
- HotPepper が 0 件のときだけ、Places でも検索する(フォールバック)
- それも1 ページ目だけ
この条件は、純粋関数として切り出してテストで固定しています👇
// 課金額に直結する契約なので、関数として独立させてテストで固定する
export const shouldFallbackToPlaces = (params: {
registeredOnly: boolean;
hotpepperCount: number;
isFirstPage: boolean;
}): boolean =>
!params.registeredOnly && params.isFirstPage && params.hotpepperCount === 0;
ここで大事なのは、「件数が少ないときも Places で検索する」をあえて入れなかったことです。⚠️
「3 件以下なら Places も呼ぶ」のようなしきい値は、課金額を直接左右します。
実測データがない状態で決める数字ではないので、
まずは 0 件のときだけに限定しました。これなら、上振れが構造的に起こりません👍
課金に直結する条件は、関数として独立させてテストで固定するのがおすすめです。
条件式がルートハンドラの中に埋もれていると、何気ないリファクタリングで課金額が変わります。
③ キャッシュする
同じ検索で 2 回課金しないように、検索結果をキャッシュしています。🗃️
有効期間は 7 日です(最初は 3 日でしたが、課金の抑止を優先して延ばしました)。
ここで工夫が要ったのは、キャッシュキーの設計です。🔑
キー = 検索クエリ + 座標グリッド + 半径 + 件数
座標をそのままキーにすると、1 メートルずれただけで別のキーになり、キャッシュが効きません。
かといって、座標を決まった幅のマス目(グリッド)に丸めると、今度は別の問題が出ます。😵
| 方式 | 問題 |
|---|---|
| 座標そのまま | 少しずれるだけで別キーになり、ヒットしない |
| 固定幅のグリッド | 半径 100m の狭い検索が、離れた場所の結果を拾う |
| 半径からグリッド幅を決める | 狭い検索は細かく、広い検索は粗く丸められる |
そこで、グリッド幅を検索半径から決めるようにしました👇
// グリッド幅 = 検索半径の半分(ただし上限あり)
const MAX_GRID_DEGREES = 0.01; // 約 1.1km
const KM_PER_DEGREE = 111;
export const gridDegreesForRadius = (radiusKm: number): number =>
Math.min(radiusKm / 2 / KM_PER_DEGREE, MAX_GRID_DEGREES);
// floor だと常に南西へ寄るので、round でグリッドの中心へ寄せる
const snapToGrid = (value: number, grid: number): number =>
Math.round(value / grid) * grid;
もう 1 つ、キャッシュに含めないものも決めています。
「この店は非表示」という判定は、キャッシュに入れない。
キャッシュには外部 API から返ってきた結果をそのまま保存し、
非表示の除外は毎回データベースを見て行います。
逆にすると、キャッシュした後で非表示にした店が、
有効期間の 7 日間ずっと表示され続けます👀
④ 回数の上限を設ける
キャッシュだけでは防げないケースがあります。
**毎回違うクエリを投げられると、キャッシュは 1 回もヒットしません。**😇
そこで、外部 API を呼ぶ直前に、データベース側で上限を消費するようにしました。🚦
| 経路 | 1 分 / 1 時間(IP ごと) | 1 日(全体) |
|---|---|---|
| 店舗検索 | 5 回 / 30 回 | 500 回 |
| エリア名 → 座標 | 10 回 / 60 回 | 1,000 回 |
設計で気をつけたのは 4 点です。✍️
1. 「1 分・1 時間あたり」と「1 日あたり」の 2 種類の上限を持つ。
1 分・1 時間の上限だけでは、1 日の合計が際限なく増えます。
1 日の上限だけでは、1 人が短時間で使い切ると、その日は全員が使えなくなります。⚖️
2. 生の IP アドレスは保持しない。
ハッシュ化した値だけを記録し、24 時間で消します。🧹
3. 回数に数えるのは、キャッシュに無かったときだけ。
守りたいのは課金であって、リクエスト数ではありません。
キャッシュから返せた(課金されない)検索まで数えると、普通に検索を繰り返しているだけのユーザーが先に止まってしまいます。🚶
4. 上限を確認できないときは「呼ばない」側に倒す(fail closed)。 ここが一番重要です🔒
// Before(fail open): 上限を確認できなければ、とりあえず呼ぶ
const allowed = await consumeQuota(ipHash).catch(() => true);
// After(fail closed): 上限を確認できなければ、呼ばない
const allowed = await consumeQuota(ipHash).catch(() => false);
if (!allowed) return hotpepperResults; // Places は呼ばず、HotPepper の結果だけ返す
逆の「確認できなければ呼ぶ」(fail open)にすると、守るための仕組みが壊れているときだけ無防備になるという、
一番まずい壊れ方をします。DB のエラーや想定外の戻り値のときこそ、止めるべきです。🛑
上限に達したときの振る舞いは、経路ごとに変えています。
| 経路 | 上限に達したとき | 理由 |
|---|---|---|
| エリア名 → 座標 | エラー(429)を返す | 代わりの手段がない |
| 店舗検索 | Places を呼ばず、HotPepper の結果を返す | Places は補完なので、無くても検索は成立する |
店舗検索でエラーを返すと、「HotPepper が 0 件のときだけ、検索全体が失敗する」という
ユーザーから見て意味の分からない壊れ方になります。
こちらはエラーにせず、HotPepper の結果だけを返すほうが適切でした。
⑤ 回数の記録を消させない
上限の判定は、「呼び出しを記録したテーブル(以下、台帳)に何行あるか」で行っています。
ということは、行を消せる権限 = 上限をリセットできる権限です😇
そのため、この台帳テーブルの権限は誰にも配っていません。管理者用のロールも含めてです。🔐
上限を判定する関数そのものが特別な権限で動くので、呼び出し側にテーブルの権限は要りません。
「数を数えるテーブル」は、消せる権限を持つ人が上限を外せる、という視点は見落としがちです。
レート制限や利用回数の台帳を作るときは、書き込み・削除の権限を誰が持つかまで確認しておくと安心です。
⑥ 取得する項目を最小にする
店名を取る以上 Pro 階層は避けられませんが、
さらに上の Enterprise 階層(評価・レビュー・営業時間など)は使わないように、
取得する項目の指定(フィールドマスク)を固定しています。🎯
// 店舗検索で要求するフィールド(Enterprise 階層のものは含めない)
const STORE_SEARCH_FIELD_MASK = [
"places.id",
"places.displayName", // ← これで Pro 階層になる(店名は必須なので避けられない)
"places.location",
"places.formattedAddress",
"places.addressComponents",
"places.primaryType",
"places.primaryTypeDisplayName",
"places.types",
].join(",");
あじぴたは自前の口コミで評価するサービスなので、Google の評価やレビューはそもそも要らない、というのもあります🙅
⑦ サーバー側で実測する
コストを管理するには、実際に何回呼んだかを知る必要があります。🔢
GA4 は導入済みですが、課金の試算には使えません。🙅
| 理由 | 内容 |
|---|---|
| 同意で欠損する | 分析 Cookie に同意したユーザーしか計測できない |
| ブロックで欠損する | ブラウザのトラッキング防止や拡張機能に遮られる |
| PV から逆算できない | 検索画面は条件を変えるたびにリクエストが飛ぶ。1 PV あたり 3〜10 リクエストと幅が大きい |
サーバー側で数えれば、同意にもブロッカーにも左右されず、課金対象と 1 対 1 で対応します📊
数え方にもルールを決めました。📏
- 数えるのは、HTTP リクエストを出す直前。API キー未設定などで手前で止まった場合は、課金されないので数えない
- リクエストを出した後は、成否で分けない。タイムアウトやエラーでも提供元の枠は消費されうるので、成功だけ数えると試算が小さく出る
- キャッシュヒットは別枠で数える。ヒット率を実測するため
- 経路ごとに分けて数える(HotPepper / 登録店のみ / Places)。①の比率を見るため
記録はレスポンスを返した後に非同期で行うので、検索の応答時間には乗りません。
記録に失敗しても、検索は成功します。✅
また、この計測テーブルは個人を特定できる情報を一切持たない設計にしていて、
「その列が存在しないこと」をデータベースのテストで固定しています🧪
⑧ 検索結果で返した ID だけ受け付ける
ここまでは店舗「検索」の話でした。
店舗の詳細ページは別の経路で、別の料金区分で課金されます。
店舗詳細ページは SEO のためにログインなしで公開しています。
つまり、URL に任意の IDを入れてアクセスできます。⚠️
素朴に実装すると、でたらめな ID を投げ続けるだけで、課金を無制限に発生させられます😱
キャッシュも上限も、検索の経路にしか掛かっていないからです。
しかも店舗詳細は、ページ本体・メタデータ・OGP 画像で1 ページあたり複数回呼ばれます。
ID が毎回違えば CDN のキャッシュも効かないので、呼び出し回数はさらに膨らみます。📈
そこで、検索結果として一度でも返した ID だけを受け付けるようにしました。🎫
いわゆる許可リスト(allowlist)です。
// 店舗詳細: 台帳にない ID では外部 API を叩かない
const issued = await isIssuedPlaceId(placeId); // 検索結果として返したことがある ID か
if (!issued) notFound(); // 外部 API を呼ばずに 404
const place = await fetchPlaceDetails(placeId); // ここで初めて課金
検索結果として返した ID を台帳に記録しておき、
台帳にない ID は外部 API を呼ばずに 404 を返します。
8 つの中で、悪意のあるアクセスで課金を膨らませる経路を塞ぐ、いちばん守りの要になる防壁です🛡️
無料の API にも許可リストが要る!
⑧の許可リストは、無料の HotPepper API にも入れています。
「無料なら関係ないのでは?」と思うかもしれませんが、守っているのはお金ではなく、サービスが止まらないことです。🤔
でたらめな ID で店舗詳細を叩き続ける
→ HotPepper への実リクエストが発生する
→ 1 日あたりの利用上限を使い切られる
→ 検索も店舗詳細も、全ユーザーで止まる
無料の API でも、利用回数の上限がある時点で、それは有限の資源です。
ログインなしで任意の ID を受け付けるページがあるなら、同じように塞いでおく必要があります💡
これは、Places の移行をリリースする前の監査で検出したものです。
Places 側は台帳で塞いでいた一方、HotPepper 側には同じガードが無いことが分かり、
リリース前に同じ設計で塞ぎました🔍
守るべきは「課金」だけではなく「枠」。
無料 API の日次上限も、使い切られればサービスが止まります。
機能を止める・戻れる形にしておく
役目を終えた機能を閉じる
防壁を積むだけでなく、課金が発生する機能そのものを閉じる判断もしました。🚫
つまずいたポイント②の店舗申請です。
申請では、ユーザーが貼った Google マップのリンクから店舗情報を取得するため、Places API を呼びます。
ところが Places で検索できるようになったことで、申請で登録できる店は、検索で拾えるようになりました。🔁
申請に必要なのは Google マップのリンク
→ 申請できる店 = Google マップにある店
→ Google マップにある店は、Places の検索で拾える
機能として存在する理由がなくなったので、申請フローは停止しました。この機能による課金はゼロです。🎉
ただしコードは消さず、設定 1 つで戻せる状態で残しています。♻️
万が一、コストの都合で検索を HotPepper だけに戻すことになっても、すぐに申請フローを復活できるようにするためです。
使われていない機能は、コストだけを生みます。
消すのではなく止める、という選択肢を持っておくと判断が軽くなります。
いつでも HotPepper に戻せるようにしておく
もう 1 つ効いたのが、以前から決めていたデータの持ち方です。
店舗の外部 ID を、店舗テーブルに直接持たせず、別テーブルで複数ソース分を保持しています。🗂️
stores(内部 ID)
└── store_external_refs
├── HotPepper の ID
└── Google の place_id
ユーザーの口コミは内部 IDに紐づいているので、外部のソースを切り替えても無傷です。🛟
つまり、コストが見合わなければ、すぐ HotPepper に戻せます🔙
この形にしたのは、Places を入れるかどうかも決めていなかった頃です。
それでも戻れる形にしたのは、単純にコストが怖かったからでした。
乗り換えた先が値上げしたときに戻れなければ、「払えるかどうか」の一択になります。😨
現在は HotPepper を主経路、Places を 0 件のときだけ使う構成で運用しています。
全面移行は、ユーザーが増えてコストをカバーできる収益が立ってから検討します。📈
他の従量課金 API に持ち帰れるチェックリスト✅
ここまでの話を、Places 以外の API にも使える形にまとめます📝
LLM API でも、決済 API でも、地図 API でも、構造は同じです。🤖
| 観点 | 確認すること |
|---|---|
| 試算 | MAU を増やしたときの月額を、実装より先に出しているか |
| 前提 | 料金体系の前提(無料枠・課金階層)を公式の料金ページで確認しているか |
| 呼ばない経路 | 外部 API を使わずに済む経路を用意しているか |
| 呼ぶ条件 | 呼ぶ条件を純粋関数にしてテストで固定しているか |
| キャッシュ | キーの細かさは適切か。後から変わる情報をキャッシュに含めていないか |
| 上限 | 短時間と 1 日の2 種類があるか。数えるのは課金されるときだけか |
| 確認できない時 | 上限を確認できないとき、呼ばない側に倒れるか |
| 上限到達時 | エラーを返すか、代わりの結果を返すかを経路ごとに決めたか |
| 記録の権限 | 回数を記録するテーブルを消せる人がいないか |
| 単価 | 要求するパラメータ・フィールドで階層が上がっていないか |
| 計測 | リクエストを出す直前にサーバー側で数えているか |
| 任意の ID | ログインなしのページで、返していない IDを受け付けていないか |
| 無料 API | 日次上限を使い切られる経路が残っていないか |
| 可逆性 | コストが見合わないとき、元に戻せるデータの持ち方か |
まとめ
- 従量課金 API は単価を下げられない。呼ぶ回数を減らすしかない
- 実装より先に試算する。あじぴたは MAU 5,000 で月 $1,440 という数字から防壁を組み立てた📊
- 料金体系の前提は変わる。無料クレジットの廃止と課金階層は公式の料金ページで確認する
- 防壁は多層で。呼ばない経路・呼ぶ条件・キャッシュ・上限・権限・取得項目・計測・許可リスト🛡️
- 上限を確認できないときは呼ばない(fail closed)。守る仕組みが壊れているときこそ止める🔒
- 無料 API にも許可リストが要る。守るべきは課金だけでなく「枠」
- 使わない経路は止める。データは戻せる形で持っておく🔙
「HotPepper だけで行ける」と思っていたところから始まって、
結果的には外部 API を呼ぶ前提をすべて疑う設計になりました。🧐
従量課金の API を組み込む予定がある方の、設計の叩き台になれば幸いです💡
シリーズの他の記事も、よろしければ📚
このシリーズでは、個人開発サービス「あじぴた」の設計をテーマごとに書いています。
サービスの全体像や、ほかの記事の一覧はハブ記事にまとめています👇
🔗 「低評価を公開しない」口コミサービスを個人開発した話 — 4,300コミット・6リポジトリの全体像
次回は、Next.js 16 で Web Vitals を測って直した話を書く予定です。
loading.tsx を足したら LCP が倍になった、という罠の話です🪤
設計の中身を通して読みたい方には、解剖ドキュメントも公開しています🔬
🔗 あじぴたの内側(ajipita-inside)
参考になれば幸いです🙏
