0
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?

Google Places APIで月$1,440の請求が来る前に — 個人開発で従量課金を「呼ばない」8層の防壁

0
Posted at

はじめに

😇「Google Maps Platform には月 $200 の無料枠があるし、個人開発なら収まるでしょ」
🤔「店名と住所くらいしか取らないから、安い階層で済むはず…」
😱「……ユーザーが 5,000 人になったら、月いくら?」

従量課金の API を組み込むとき、最初の見積もりはだいたい楽観的です。

自分が個人開発している食の口コミサービス「あじぴた」でも、まさにこれが起きました。
店舗検索に Google Places API を入れたところ、試算は MAU 5,000 で月 $1,440。
サーバーとデータベースにかかっている月額(約 $45)の 32 倍です💸

この記事では、そこから8 層の防壁を組み立てて、
課金の上振れを構造的に起こらないようにした設計をまとめます🛡️

Places API の話ですが、LLM API や決済 API など、従量課金の API 全般にそのまま持ち帰れる内容にしています。

結論:単価は下げられない。呼ぶ回数を減らすしかない💡

先に結論です👇

従量課金 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)

参考になれば幸いです🙏

0
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
0
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?