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?

結婚式60人・1週間だけ公開する地元紹介サイトを作った話

0
Last updated at Posted at 2026-11-08

はじめに

※ 何かしら結婚式向けに頑張ったこと書かなきゃと思いつつ1年経ってしまいましたが、当時の記憶や残っているドキュメントベースに掘り起こしてこの記事を書いています。
※ 一部記憶違いな記憶も混ざっていると思いますがご容赦くださいませ。

2025年に結婚式を挙げました。私はWeb エンジニアとして働いています。

私たち夫婦は隣町同士の出身です。遠方から来てくれるゲストの中には、こちらに来たことがない人も多くいました。そんなとき妻から「式場の周りや、二人の地元のおいしいお店やおすすめの場所を紹介するサイトを作ってみたら?」と提案されました。

そこで作ったのが、盛岡・北上・花巻のお店やスポットを、地図とカードで紹介するサイトです。席次表に QR コードを載せてもらい、ゲストがスマートフォンから開けるようにしました。

開発では、技術構成や実装の進め方を ChatGPT に相談し、実装の大部分は 2025 年当時の Codex で行いました。それから約 1 年経った 2026 年に、Claude Code を使って当時のコードを読み直し、レビューしました。

ソースコードは GitHub(my-hometown-guid) に公開しています。この記事では次の 3 点を、コードと開発の記録をもとに振り返ります。

  1. 結婚式という、日付の決まったイベントに向けた個人開発
  2. React・CakePHP 5・Leaflet・Cloudflare Tunnel を組み合わせたサイトの実装
  3. AI を使って完成させたコードを、1 年後にレビューして分かったこと

開発の背景と要件

項目 内容
利用者 結婚式のゲスト 60 人弱
公開期間 式の前後、約 1 週間
端末 席次表の QR コードから開くので、ほぼスマートフォン
費用 結婚式の費用を抑えたいので、サーバー代はできるだけかけない
内容 夫婦で選んだ約 35 のお店・スポットと、二人で考えたコメント

利用者が少なく、公開期間は短く、費用はかけたくない。この条件が、後で説明する「読み取り専用の API が 1 本だけ」「絞り込みはブラウザ側」「お気に入りはブラウザに保存」といった設計につながっています。

完成したサイト

この章の画面は、当時のコードをローカルで動かし、記事用の架空の店舗データを表示して撮影した再現画像です。実際に掲載していたお店の情報は載せていません。地図タイルは OpenStreetMap のものです。

top-page.png

画面は 1 ページだけです。上から順に、次の要素が並びます。

  • ゲストへの挨拶文
  • 地域(盛岡 / 北上 / 花巻)の切り替え、ジャンルの絞り込み、「お気に入りのみ表示」
  • Leaflet の地図
  • お店のカード

ピンをタップすると、店名とひとことコメント、「↓ 詳細を見る」ボタンが入ったポップアップが開きます。

map-view.png

「↓ 詳細を見る」を押すと、そのお店のカードまでスクロールし、カードの枠が 2 秒間ピンクで強調されます。

shop-card.png

カードには、ジャンル、「新郎チョイス」「新婦チョイス」などのタグ、紹介文、住所、営業時間、駅からの徒歩時間、公式サイトへのリンクを表示しています。カードをタップすると地図がそのお店にズームし、ピンのポップアップにある「↓ 詳細を見る」を押すとカードまでスクロールします。

カードの ♥ でお気に入りに登録し、「お気に入りのみ表示」にチェックを入れると、地図のピンとカードの両方がお気に入りだけに絞り込まれます。

favorites.png

開発の流れ(2025 年 5 月〜9 月)

Git の履歴と、当時の ChatGPT との会話を照らし合わせると、開発は次のように進みました。

時期 主な出来事
5 月 18 日 最初は React + Laravel + MySQL + Docker を検討していたが、バックエンドを使い慣れた CakePHP に変更。shops テーブルと /api/shops を作り始める
5 月下旬〜6 月上旬 React を導入し、axios・Leaflet・Tailwind CSS を追加。会話では Tailwind CSS v4 の導入を相談していたが、リポジトリには最初から v3.4 系がコミットされている
6 月 22 日 お気に入り機能と、GitHub Pages へのデプロイ設定を追加。バックエンドの公開先として Railway を試し、翌未明までに Docker と nginx の設定を 20 件以上の PR で直している
7 月 13 日 Render へのデプロイも試したが、PostgreSQL 中心のサービスだったため別ブランチに切り出して見送った(main には統合していない。コミットメッセージより)
7 月中旬 AWS 用に設定ファイルを環境ごとに分ける。EC2 上で CakePHP の API が動き、DuckDNS のサブドメインで公開していた
9 月 6 日ごろ 約 35 件のお店の情報を、Python と Google Places API で準備する
9 月 17〜18 日 一部地域のお店が表示されない不具合を調べて修正。ピンからカードへの導線、ジャンル絞り込み、お気に入り絞り込みを追加
9 月下旬 Cloudflare Tunnel 経由で API を公開する構成になっている

技術スタックとアーキテクチャ

領域 採用技術
Frontend React 19.1 / Vite 6.3 / Tailwind CSS 3.4
地図 Leaflet 1.9.4 + OpenStreetMap のタイル
Frontend の配信 GitHub Pages(gh-pages パッケージで dist を公開)
Backend CakePHP 5.2
Web / PHP nginx / php-fpm
インフラ AWS EC2(Amazon Linux 2023)
DB MariaDB 10.5(EC2 上。ローカル開発は Docker の MySQL 8.0)
公開経路 Cloudflare Tunnel(cloudflared を systemd で常駐)
データ準備 Python + Google Places API
AI ChatGPT(相談)、Codex(実装)、Claude Code(2026 年のレビュー)

全体の構成は次のとおりです。EC2 の中の構成(cloudflared → nginx → php-fpm → MariaDB)は当時の構成メモに基づくもので、設定ファイルはリポジトリに残っていません。

図を読むときのポイントは 2 つです。

  • API を呼んでいるのは GitHub Pages ではなく、ゲストのブラウザで動く React のコードです。GitHub Pages は HTML や JS を返すだけです。
  • Cloudflare と EC2 の間の接続は、EC2 上の cloudflared から Cloudflare に向けて外向きに張られています。リクエストは、その確立済みの接続を通って EC2 に届きます。

画面が利用する API は店舗一覧を一括で返す GET エンドポイント 1 本で、絞り込みはすべてブラウザで行っています。

React と Leaflet による地図表示

コンポーネント構成

フロントエンドの主なファイルは 3 つです。

App.jsx                 状態を持ち、API 取得・絞り込み・カード表示を担当
├─ useFavoriteShops.js  お気に入りを localStorage と同期
└─ MapView.jsx          Leaflet の地図・マーカー・ポップアップ

状態は App.jsx の useState 5 つだけで、ルーターも状態管理ライブラリも使っていません。

frontend/src/App.jsx
const [shops, setShops] = useState([]);
const [selectedShop, setSelectedShop] = useState(null);
const [selectedArea, setSelectedArea] = useState('morioka');
const [selectedGenre, setSelectedGenre] = useState('all');
const [showOnlyFavorites, setShowOnlyFavorites] = useState(false);

ジャンルの候補と、表示するお店の一覧は state にせず、描画のたびに shops から計算しています。

frontend/src/App.jsx
const genres = Array.from(
  new Set(
    shops
      .filter(shop => shop.area === selectedArea)
      .map(shop => shop.genre)
      .filter(Boolean)
  )
);

const filteredShops = shops.filter(shop => {
  return (
    shop.area === selectedArea &&
    (selectedGenre === 'all' || shop.genre === selectedGenre) &&
    (!showOnlyFavorites || isFavorite(shop.id))
  );
});

ジャンルの候補は、選んでいる地域に存在するものだけです。さらに、地域を切り替えるとジャンルを「すべて」に戻します。盛岡で「カフェ」を選んだまま花巻に切り替えたとき、花巻にカフェがないと 0 件になってしまうからです。

frontend/src/App.jsx
onChange={e => {
  setSelectedArea(e.target.value);
  setSelectedGenre('all');
}}

Leaflet 本体と地図タイルは別物

最初に整理しておくと、Leaflet は地図を描画するライブラリです。地図の画像(タイル)は別のサーバーから取得します。このサイトでは、OpenStreetMap Foundation(OSMF)が運営する標準のタイルサーバーを使っています。

要素 提供元 条件
描画ライブラリ Leaflet(OSS、BSD-2-Clause) 無料。ライセンス表記を守れば自由に使える
地図タイル OSMF の tile.openstreetmap.org(MapView.jsx の L.tileLayer で指定) 無料だが、OSMF のタイル利用ポリシーに従う必要がある
地図データ OpenStreetMap の貢献者 ODbL。帰属表示が必要
店舗データ 自前の DB 自分で管理

Leaflet の利用条件は「ライブラリのライセンス」の話で、タイルの利用条件は「OSMF が運営するサーバーを使わせてもらう条件」の話です。両者は別物です。OSMF のタイル利用ポリシーで、このサイトに関係するのは主に次の点です。

  • 帰属表示: 地図上に OpenStreetMap の帰属表示を見える形で出す。著作権ページへのリンクを付けることが求められる
  • Referer: Web サイトから使う場合は、タイルのリクエストに有効な Referer が付いている必要がある。Referrer-Policy を no-referrer などの厳しい値にして Referer を送らない設定にしてはいけない
  • User-Agent: 通常の Web ブラウザで表示するサイトなら、ブラウザ標準の User-Agent がそのまま送られる(JavaScript から変更することはできない)。アプリを識別できる独自の User-Agent を設定する義務があるのは、ネイティブアプリや独自の HTTP クライアントからタイルを取得する場合
  • キャッシュ: HTTP のキャッシュヘッダーに従う(ブラウザは自動的に従う)。オフライン用の一括ダウンロードは禁止
  • 利用量: 大量に利用する場合は事前の許可が必要で、問題があれば予告なくブロックされることがある

このサイトはブラウザから利用する Web サイトです。リポジトリの index.html には Referrer-Policy の指定がありません。ただし、実際に送信される Referer はブラウザや配信側のヘッダー設定にも左右されます。アクセス数が少ない想定でも、タイル配信が無条件で保証されるわけではありません。「Leaflet を使えば地図配信まで無条件に無料になる」わけではなく、タイルサーバーの利用条件を確認する必要があります。

素の Leaflet を ref で扱う

package.json には react-leaflet も入っていますが、使っていません。Leaflet の API を直接呼んでいます。

frontend/src/MapView.jsx
const mapRef = useRef(null);
const mapContainerRef = useRef(null);
const markersRef = useRef({});

// 初回のみマップ作成
useEffect(() => {
  if (!mapRef.current) {
    const defaultCenter = areaCenters[selectedArea] || [39.7036, 141.1527];
    mapRef.current = L.map(mapContainerRef.current).setView(defaultCenter, 13);

    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
      attribution: '© OpenStreetMap contributors',
    }).addTo(mapRef.current);
  }

  return () => {
    if (mapRef.current) {
      mapRef.current.remove();
      mapRef.current = null;
    }
  };
}, []);

Leaflet の map やマーカーは、内部に状態を持つミュータブルなオブジェクトです。useState に格納すること自体は可能ですが、地図オブジェクトの変更を React の再描画と結び付ける必要はありません。そこで useRef に置き、React は「地図を描画する div を 1 つ用意するだけ」にしています。アンマウント時には map.remove() で後始末をし、mapRef を null に戻しています。開発時の StrictMode では effect が「実行 → クリーンアップ → 再実行」の順に動きますが、この形なら再実行のときに地図を作り直すだけで済みます。

現状の帰属表示 © OpenStreetMap contributors には、著作権ページへのリンクがありません。また、{s}(a/b/c のサブドメイン)は、現在は単一ホストの tile.openstreetmap.org を使うことが推奨されています。今なら次のように書きます。

L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
  maxZoom: 19,
  attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);

マーカーとカードの連動

マーカーは、お店の ID をキーにして ref に保存しています。絞り込みの結果(shops)が変わるたびに、すべて消してから作り直します。ピンは多くても 35 個なので、差分だけを更新するより単純な実装を選んでいます。

frontend/src/MapView.jsx
useEffect(() => {
  if (!mapRef.current) return;

  Object.values(markersRef.current).forEach(marker => marker.remove());
  markersRef.current = {};

  shops.forEach(shop => {
    if (shop.lat && shop.lng) {
      const marker = L.marker([shop.lat, shop.lng]).addTo(mapRef.current);
      // …ポップアップの生成(次の節)
      marker.bindPopup(popupDiv);
      markersRef.current[shop.id] = marker;
    }
  });
}, [shops]);

ID をキーにしておくと、カードがタップされたときに対応するマーカーをすぐに取り出せます。

frontend/src/MapView.jsx
useEffect(() => {
  if (selectedShop && mapRef.current) {
    const { lat, lng, id } = selectedShop;
    mapRef.current.setView([lat, lng], 16);
    const marker = markersRef.current[id];
    if (marker) marker.openPopup();
  }
}, [selectedShop]);

ピンからカードへは「詳細を見る」ボタンを経由する

逆方向(地図 → カード)の動作は、2025 年 9 月 17 日に ChatGPT と相談していました。会話の記録によると、ピンをタップしてすぐにカードへ移動するのではなく、「ポップアップを開き、その中の『詳細を見る』を押したらカードへスクロールする」動作を検討しています。翌 18 日のコミット「詳細導線の追加」で、その動作が次のように実装されています。

frontend/src/MapView.jsx
const popupDiv = L.DomUtil.create('div');
popupDiv.innerHTML = `<strong>${shop.name}</strong><br>${shop.comment || ''}`;

const btn = L.DomUtil.create('button', 'popup-btn', popupDiv);
btn.innerText = '↓ 詳細を見る';

btn.onclick = () => {
  const target = document.getElementById(`shop-card-${shop.id}`);
  if (target) {
    target.scrollIntoView({ behavior: 'smooth', block: 'start' });
    target.classList.add('ring', 'ring-pink-300', 'ring-offset-2');
    setTimeout(() => {
      target.classList.remove('ring', 'ring-pink-300', 'ring-offset-2');
    }, 2000);
  }
};

カード側には id={`shop-card-${shop.id}`} を付けておき、スクロールした後に 2 秒間だけ Tailwind の ring で枠を強調します。スマートフォンでは、地図の高さが画面の半分(h-[50vh] sm:h-[500px])で、カードは地図の下に 1 列で並びます。ピンを見ているときはカードが画面の外にあることが多いので、地図からカードへ戻る手段が必要です。ボタンを経由する形なら、地図を拡大・移動している間に画面が勝手にスクロールすることもありません。

(このコードの innerHTML には改善点があります。後のレビューの章で取り上げます)

お気に入りは localStorage に保存

ログインなしで使えるように、お気に入りはブラウザの localStorage に、お店の ID の配列として保存しています。

frontend/src/hooks/useFavoriteShops.js
const [favorites, setFavorites] = useState(() => {
  try {
    const stored = localStorage.getItem('favoriteShops');
    return stored ? JSON.parse(stored) : [];
  } catch {
    return [];
  }
});

useEffect(() => {
  localStorage.setItem('favoriteShops', JSON.stringify(favorites));
}, [favorites]);

useState に関数を渡して、localStorage の読み込みを初回だけにしています。保存した値が壊れていても、try/catch で空の配列に戻ります。サーバー側でお気に入りを保持するためのログイン・セッション・個人別データの管理は不要になりました。1 週間だけ使うサイトなら、端末ごとに保存されれば十分です。

Python と Google API による店舗情報の準備

約 35 件のお店について、地図にピンを立てるための緯度経度や、カードに載せる住所・営業時間・公式サイトといった情報が必要でした。これらを揃えるために、Python から Google Places API を呼んで、お店の座標などを取得する方式を取りました。

当時の会話の記録では、取得の対象にしていたのは店舗名、住所、緯度経度、営業時間、公式サイトなどです。店舗情報は Python と Google API で取得し、データベースには SQL を直接実行して登録したと記憶しています。スクリプトも SQL もこのリポジトリには残っていません。最終的にどのエンドポイントを使い、どのフィールドを指定し、どの値を Google から取得したままの形で登録したかまでは確認できていません。そのため、ここでは流れと設計上の考え方を中心に紹介します。

紹介文やコメント、「誰が選んだか」のタグは、夫婦で考えて登録しました。なお、ここでいう「SQL を直接実行する」は、管理者である私が DB に INSERT 文を流したという意味です。アプリが外部からの入力で SQL を組み立てる話(SQL インジェクション)とは別の話です。

閲覧時には Google API を呼ばない

この構成の特徴は、公開後のサイト閲覧時に Google API を呼ばないという点です。ゲストがサイトを開いたときは、自前の DB から返すだけで、Google の API は一度も呼びません。そのため、少なくともゲストのアクセス数に比例して Places API の呼び出し料金が増える構成ではありません。取得した情報に夫婦で書いた紹介文やコメントを足し、内容を確かめてから登録できるのも、事前に準備する方式の利点です。

DB の営業時間は open_time と end_time の 1 組だけで、Places API が返す曜日ごとの営業時間の構造とは違います。営業時間がどのように整形・確認されたかは記録が残っていません。DB の形式と Google API のレスポンス形式が異なる点だけが確認できます。

Places API (New) の Place Details などでは、取得するフィールドを FieldMask(X-Goog-FieldMask ヘッダーなど)で必ず指定します。課金は、指定したフィールドのうち最も高い SKU で決まります。必要なフィールドだけを指定することが、そのまま費用を抑えることにつながります。

# 参考:Place Details (New) の呼び出し例(当時のスクリプトではありません)
curl -s "https://places.googleapis.com/v1/places/${PLACE_ID}" \
  -H "X-Goog-Api-Key: ${GOOGLE_MAPS_API_KEY}" \
  -H "X-Goog-FieldMask: id,displayName,formattedAddress,location"

取得用スクリプトはリポジトリに残っていないため、当時の API キーの保管方法までは確認できません。公開リポジトリ内に取得用の API キーは確認できていません。Google は、API キーには「使える API の制限」と「アプリケーションの制限(サーバーから使うなら IP アドレスなど)」を両方かけることを推奨しています。

後から気づいた、Google API のデータ利用条件

ここは、今回の振り返りで最も見落としていた点です。API の呼び出し回数を減らすことと、取得したデータを保存・表示してよいことは別問題でした。

Google Maps Platform の規約は、全サービス共通の Google Maps Platform Terms of Service と、サービスごとの Service Specific Terms に分かれています。Places API に関係するのは、主に次の制限です。

  • 保存:共通規約の 3.2.3(a)(b) は、Google Maps のコンテンツをサービスの外に保存すること、個別規約で認められた場合を除いてキャッシュすることを禁止しています。例として「店名や住所をコピーして保存すること」も挙げられています。place_id は、個別規約で保存してよいとされています。
  • 緯度経度:Service Specific Terms は、Places API の緯度経度について、連続 30 日間までの一時キャッシュを認め、その後は削除するよう定めています。「30 日なら自由に永続保存できる」という意味ではありません。
  • 地図との組み合わせ:Service Specific Terms は、Places API のコンテンツを Google 以外の地図と組み合わせて使うことを禁止しています。共通規約の 3.2.3(e) にも、「Places のコンテンツを Google 以外の地図に表示・利用しない」と例として書かれています。Google の地図を使わずに情報だけを表示する場合(帰属表示が必要)とは、区別して考える必要があります。

Places API の条番号は、現行版では 14.1〜14.3 です。開発時点で有効だった 2025 年 9 月 4 日版 では 10.1〜10.3 でしたが、内容は同じです。共通規約の 3.2.3 も、開発時点で有効だった 2025 年 7 月 14 日版 と現行版で、内容は変わっていません。

リポジトリには当時の取得用 Python スクリプトや投入 SQL がなく、最終的にどの値を Google から取得して DB に保存したか、どのように確認・編集したかまでは追えません。アプリケーションコード上、保存期限を管理する仕組みも見つかりませんでした。ただ、規約の書き方ははっきりしています。Google から取得した座標や店名・住所を DB にそのまま保存し、それを Leaflet と OpenStreetMap の地図に表示していたのであれば、規約が例として禁止している使い方に当たります。手で確認・修正した値をどこまで使ったかは、今となっては区別できません。それでも、規約に沿った使い方だったとは言えないと考えています。

費用面では事前取得の構成に利点があったものの、データのライセンスや使用条件を、API 設計の段階で確認すべきだったと考えています。今なら、Leaflet を使う構成では、Google の Places API から転載した値を流用せず、使用権限を確認できる情報源や、自分たちで独立に作成したデータでスポットを管理します。Google 由来の Places コンテンツを利用するなら、対応する地図表示・保存制限に沿って構成を決めます。

これは記事上の反省点であって、実環境に残っているデータの確認・整理が不要になるわけではありません。

CakePHP 5 による API

エンドポイントは 1 本

backend/config/routes.php
$routes->prefix('Api', ['path' => '/api'], function (RouteBuilder $builder): void {
    $builder->setExtensions(['json']);
    $builder->resources('Shops');
    $builder->fallbacks(DashedRoute::class);
});

resources('Shops') は REST の各ルートを生成しますが、実装しているアクションは index だけです。書き込みの API、認証、管理画面はありません。

backend/src/Controller/Api/ShopsController.php
public function index()
{
    $shops = $this->Shops->find()->all();

    $this->response = $this->response
        ->withType('application/json')
        ->withStringBody(json_encode([
            'success' => true,
            'data' => $shops,
        ], JSON_UNESCAPED_UNICODE));

    return $this->response;
}

DB は shops テーブル 1 つです。マイグレーションは 5 本あり、機能を足すたびにカラムを増やした履歴が残っています。誰が選んだお店かを表す chosen_by は ENUM で、カードのタグに対応しています。

backend/config/Migrations/20250601100619_AddMoreFieldsToShops.php
->addColumn('chosen_by', 'enum', [
    'values' => ['groom', 'bride', 'both'],
    'default' => 'both',
    'null' => true,
    'after' => 'image_url'
])
frontend/src/App.jsx
const chosenByLabel = {
  groom: '新郎チョイス',
  bride: '新婦チョイス',
  both: '新郎・新婦チョイス',
};

一部地域のお店が表示されない不具合

9 月 17 日に、「一部の地域のお店が表示されない」という問題を ChatGPT と相談しながら調べています。

原因

当時の index() は、ページネーションを使っていました。

a1df092「paginatorをとりやめ」(2025-09-18)
-        $shops = $this->paginate($this->Shops->find());
+        $shops = $this->Shops->find()->all();

CakePHP 5 の paginate() は、既定の設定が limit: 20 です(CakePHP 5.2.4 の NumericPaginator で確認)。クエリパラメータを付けずに呼ぶと、先頭の 20 件だけを返します。しかも、戻り値の PaginatedResultSet を JSON にすると、20 件分の配列になるだけで、総件数や次のページがあるかどうかといった情報は付きません。

// cakephp/cakephp 5.2.4: src/Datasource/Paging/PaginatedResultSet.php
public function jsonSerialize(): array
{
    return iterator_to_array($this->items());
}

フロントエンドは、受け取った data を「全件」として扱い、地域で絞り込んでいます。お店は約 35 件あったので、先頭の 20 件に入らなかったお店は、どの地域を選んでも表示されません。並び順も指定していなかったので、結果として「ある地域のお店が出ない」という現象になりました。

修正と修正後の動作

翌 18 日に、paginate() をやめて find()->all() で全件を返すように直しました。これで、地図にすべてのピンが表示されるようになっています。約 35 件しかなく、地図にすべてのピンを出したいサイトなので、全件を返すのが要件に合っていました。

学び

  • 「API は全件を返し、フロントで絞り込む」という前提のコードに、件数制限のある処理が混ざると、エラーにならず、データだけが静かに欠けます。
  • レスポンスに件数(count)を含めておく、テストのフィクスチャを上限より多くしておく、といった対策をしておけば、もっと早く気づけたはずです。

CORS

GitHub Pages(*.github.io)と API はオリジンが違うので、自作のミドルウェアで Access-Control-Allow-Origin: * を返しています。データの保持と配信は CakePHP、絞り込みと表示は React、お気に入りはブラウザ、という分担です。

CORS は「別のオリジンのページで動く JavaScript が、レスポンスを読めるかどうか」をブラウザが判断するための仕組みです。API へのアクセスそのものを止めるものではなく、curl などからは CORS の設定に関係なく呼べます。このサイトの API は認証なしで公開データを返すだけで、Cookie などの認証情報も使っていません。そのため、Access-Control-Allow-Origin: * であること自体は問題ではありません。認証情報付きのリクエストを受けたり、非公開のデータを返したりする API なら、許可するオリジンを絞る必要があります。

公開構成の変遷と Cloudflare Tunnel

フロントエンドは最終的に GitHub Pages に配置しました。一方、バックエンドの公開先は何度か検討・変更しています。

時期 構成
6 月 22 日 Railway を試す(Docker、nginx、supervisord の設定を何度も修正)
7 月 13 日 Render を試すが、別ブランチに切り出して見送る
7 月中旬 EC2 上で CakePHP が動き、DuckDNS のサブドメインで HTTP の API を公開。nginx での HTTPS 化を検討
9 月下旬の記録 Cloudflare Tunnel 経由で公開

公開環境の検討では Railway・Render・DuckDNS を経由し、最終的には Cloudflare Tunnel を使う構成になりました。結果として、EC2 の 80/443 を直接公開せずに API を提供する構成になっています。切り替えた正確な時期や、Tunnel を選んだ細かい理由は覚えていません。そのため、ここでは今から見た構成の特徴として説明します。

なお、DuckDNS の時期の API は HTTP でした。GitHub Pages は HTTPS で配信されるので、そのページから HTTP の API を呼ぶと、ブラウザは混在コンテンツ(mixed content)としてブロックします。どの方法を取るにしても、API の HTTPS 化は避けて通れない状況でした。

Cloudflare Tunnel の仕組み

ここから先の構成は、当時の構成メモとアーキテクチャ図に基づいています。cloudflared の設定ファイルや Security Group の設定は、リポジトリには含まれていません。

EC2 上の cloudflared は、起動すると Cloudflare に向けて外向きの接続を張ります(Cloudflare の資料では、7844 番ポートの UDP(QUIC)または TCP(HTTP/2))。ゲストのブラウザが API のホスト名に HTTPS でアクセスすると、Cloudflare はそのリクエストを、確立済みの接続を通して cloudflared に渡します。cloudflared がそれを EC2 内の nginx に転送し、nginx が php-fpm 経由で CakePHP を実行します。

当時の構成メモでは、Security Group の受信は SSH(22 番、管理用 IP に限定)のみで、80/443 は開けない運用としていました。ブラウザと Cloudflare の間の HTTPS 証明書は Cloudflare 側で扱います。cloudflared と nginx の間の暗号化要否は、その通信経路・設定によって判断します。ALB も Elastic IP も使っていません。

Tunnel を使っても残るリスク

Tunnel が変えるのは「ネットワーク上の入口をどこに作るか」です。それ以外の層は、別に考える必要があります。

層 Tunnel で変わること 残ること
ネットワーク EC2 の IP へ 80/443 で直接アクセスされない API のホスト名は公開されていて、誰でも呼べる。SSH は IP 制限付きで開いている
HTTPS ブラウザから Cloudflare までは HTTPS、その先はトンネルで暗号化 cloudflared から nginx までは EC2 内の通信
認証・アクセス制御 変わらない API に認証はない。公開情報だけを返す前提
CORS 変わらない ブラウザでの読み取りの制御なので、Tunnel とは無関係。公開データなので * で問題ない
HTTP メソッド 変わらない 実装しているのは GET の一覧だけ。REST 用のルート定義はあるが、書き込み用のアクションは実装していない。実際の応答はルーティング設定に依存する
アプリの脆弱性 変わらない SQL は ORM で組み立てている。XSS とエラー時の情報の扱いは次の章とレビューの章で扱う
認証情報 — cloudflared のトークンが漏れると、トンネルを乗っ取られる

このサイトでは、公開情報だけを返し、書き込み用アクションを設けないという構成にしていました。ただし、読み取り専用の GET でも、アプリ側で確認すべきことはなくなりません。

確認すること このサイトの API の状況
クエリパラメータの検証 index() はクエリパラメータを受け取らない(?area= などを付けても無視される)。将来、絞り込みのパラメータを足すなら検証が必要
SQL インジェクション $this->Shops->find() だけで、文字列を連結した SQL はない
外部データの扱い Google から取得したデータを含む店舗情報を返している。表示する側でのエスケープが前提になる
HTML への出力時のエスケープ カードは React の JSX なので自動でエスケープされる。ポップアップだけ innerHTML を使っている(レビューの章で扱う)
エラー時の情報漏えい エラーの表示は CakePHP の debug 設定に依存する。debug が有効だと、スタックトレースなどの内部情報が表示される。本番の設定値は記録が残っていない

コスト

実際の請求額は記録していないので、どこに費用が発生する構成だったかを整理します。

項目 費用の考え方
Google Places API 公開前のデータ準備時に呼び出し。約35件の店舗を扱ったが、実際のリクエスト回数・請求額は未確認。閲覧時には呼ばない
GitHub Pages 公開リポジトリでの静的ホスティングは無料枠で利用可能(利用条件は要確認)
Cloudflare Tunnel 無料の範囲で利用。Cloudflare で管理するドメインが必要
EC2 インスタンスと EBS。パブリック IPv4 アドレスを割り当てていた場合はその料金も対象
DB EC2 上の MariaDB なので、RDS の料金はかからない
ALB / EIP 使っていない

AWS の無料利用枠は 2025 年 7 月 15 日に変わり、それ以降に作ったアカウントと、以前からあるアカウントで条件が違います。「EC2 は無料枠で動く」とは一概に言えないので、自分のアカウントがどちらに当たるかを確認してください。

ChatGPT と Codex を使った開発

役割分担は次のようにしていました。

担当 役割
ChatGPT 技術構成と実装方針の相談(Laravel から CakePHP への変更、公開先の選択、Places API の使い方、ページネーションの調査、ピン連動の動作など)
Codex アプリケーションの実装の大部分
私 要件の整理、開発の進行、完成までの調整、掲載内容(夫婦で決定)

会話の記録を読み返すと、ChatGPT には「何を使うか」「なぜ動かないか」を相談していました。その結論を、Codex に実装させるという流れです。ピン連動のように、会話で決めた動作がそのままコードに反映されている部分もあります。

使った Codex のモデル名は、正確には記録が残っていません。GPT-5-Codex は 2025 年 9 月 15 日に公開されましたが、開発は 5 月から始まっているので、時期によって違うモデルを使っていたはずです。この記事では「2025 年当時の Codex」と書いています。

どの行を Codex が書き、どこを私が直したかは、今となっては区別できません。そのため、次の章のレビューでは、原因を AI に求めず「当時の実装」として評価しています。

約 1 年後にコードをレビューしてみた

2026 年に、Claude Code を使ってリポジトリの実装と Git 履歴を調査し、コードレビューを行いました。

今でも妥当だと思う実装

  • 派生値を state にしていない: genres と filteredShops を、描画のたびに計算している
  • Leaflet を useRef で扱っている: React と命令的なライブラリの役割分担がはっきりしている
  • 地域を変えたらジャンルをリセットする: 0 件表示を防いでいる
  • ピンからカードへの導線をボタンに分けた: 地図の操作を邪魔しない
  • 公開情報だけを返す読み取り用 API にした: 個人別の認証や書き込み操作に伴う CSRF 対策を必要としないシンプルな構成になった(GET API でも入力・出力の安全性の確認は必要)
  • 複雑な抽象化が少ない: 小規模な画面に対して構成が比較的追いやすい

改善したい点 1:API エラー時に何も表示されない

frontend/src/App.jsx(現状)
useEffect(() => {
  axios
    .get(`${import.meta.env.VITE_API_URL}/api/shops.json`)
    .then(res => setShops(res.data.data ?? []))
    .catch(err => console.error(err));
}, []);

API が失敗すると、エラーはブラウザのコンソールに出るだけです。画面は「ピンもカードもない地図」になります。読み込み中の画面も、0 件のときの画面も同じ見た目です。QR コードから初めて開いたゲストには、壊れているのか、待てばよいのか、条件を変えればよいのかが分かりません。

静的配信(GitHub Pages)と API(EC2)の 2 か所に依存する構成なので、EC2 側だけが止まることは十分に起こりえます。その場合の表示を用意しておくべきでした。

改善案
const [status, setStatus] = useState('loading'); // 'loading' | 'success' | 'error'

useEffect(() => {
  const controller = new AbortController();
  axios
    .get(`${API_BASE}/api/shops.json`, { signal: controller.signal, timeout: 10000 })
    .then(res => {
      setShops(res.data.data ?? []);
      setStatus('success');
    })
    .catch(err => {
      if (axios.isCancel(err)) return;
      console.error(err);
      setStatus('error');
    });
  return () => controller.abort();
}, []);

// JSX
// {status === 'loading' && <p role="status">お店の情報を読み込んでいます…</p>}
// {status === 'error' && <p role="alert">お店の情報を読み込めませんでした。時間をおいて再読み込みしてください。</p>}
// {status === 'success' && filteredShops.length === 0 && <p>条件に合うお店がありません。</p>}

改善したい点 2:ポップアップの innerHTML

先ほど紹介したポップアップのコードには、実は次のコメントが付いています。

frontend/src/MapView.jsx(現状)
// 安全にカスタム要素を生成
const popupDiv = L.DomUtil.create('div');
popupDiv.innerHTML = `<strong>${shop.name}</strong><br>${shop.comment || ''}`;

DB の name と comment が、HTML としてそのまま解釈されます。コメントには「安全に」とありますが、中身はエスケープしていません。

ただし、XSS が実際に成立するには、name や comment にスクリプトを実行させる HTML(例:<img src=x onerror=...>)が入っている必要があります。このサイトでは、DB にデータを登録するのは私だけなので、現実のリスクは低いです。問題になるのは、将来入力フォームを足して第三者が書き込めるようになった場合や、Google から取得した店名のような外部データを、中身を確かめずに登録した場合です。XSS にならなくても、店名に < が含まれていれば表示が崩れます。むしろ気になったのは、コメントと実装が食い違っていることでした。後から読む人がコメントを信じれば、入力元が増えたときに見落とします。カードの側は React が自動でエスケープするので、同じデータなのに場所によって安全性が違うことにもなります。

改善案
function createPopupContent(shop, onDetailClick) {
  const root = document.createElement('div');

  const title = document.createElement('strong');
  title.textContent = shop.name;
  root.append(title);

  if (shop.comment) {
    root.append(document.createElement('br'), shop.comment); // テキストノードとして追加
  }

  const btn = document.createElement('button');
  btn.type = 'button';
  btn.className = 'popup-btn'; // スタイルは CSS に移す
  btn.textContent = '↓ 詳細を見る';
  btn.addEventListener('click', () => onDetailClick(shop.id));
  root.append(btn);

  return root;
}

改善したい点 3:リクエストごとに書き込まれるデバッグログ

backend/src/Application.php(現状)
public function routes(RouteBuilder $routes): void
{
    file_put_contents(TMP . 'routes_called.log', 'routes.php loaded' . PHP_EOL, FILE_APPEND);

    $routesCallback = require CONFIG . 'routes.php';
    $routesCallback($routes);
}

開発初期(2025 年 5 月 25 日のコミット)に、ルーティングが読み込まれているかを確かめるために入れたコードが、そのまま残っていました。CakePHP 5 の RoutingMiddleware は、リクエストごとにルートを読み込みます。そのため、該当の routes() 処理が実行されるたびにログファイルへ追記され、ログのローテーションや削除を用意しなければ、ファイルは増え続けます。

書き込んでいるのは固定の文字列だけなので、このログから情報が漏れることはありません。ただ、同じ書き方でリクエストの内容を出力すれば漏えいにつながります。デバッグ出力は CakePHP の Log を使って、設定でレベルや出力先を管理するほうが安全です。この処理は CakePHP の既定の routes() と同じなので、メソッドごと削除すれば済みます。

そのほかに気づいた点

  • API に ORDER BY がなく、カードの順番が DB 任せになっている(ページネーションの不具合とも関係していた)
  • 地域の一覧が App.jsx と MapView.jsx の 2 か所にある
  • CORS の Access-Control-Allow-Methods に、実装していない POST/PUT/DELETE まで含めている(実害はないが、API の実態と合っていない)
  • index.html の <title> が「Vite + React」のまま
  • バックエンドのテストファイルが構文エラーで、テストスイートを実行できない

どれも「期日までに動くものを出す」過程で残りやすいものです。AI を使ったかどうかに関係なく起こりえます。

AI が書いたドキュメントもレビューが必要

もう 1 つ印象に残った点があります。2026 年 5 月に、Codex にドキュメントの整備を頼みました(PR #29)。その README には「Google Map URL、価格帯、画像を表示する」と書かれていました。しかし今回の調査では、これらは DB にカラムがあっても、画面側で表示する実装は確認できませんでした。

この PR は、lint エラーの修正や、テストを実行できなかったことの報告など、丁寧な内容でした。それでも、DB のカラム名から機能を推測して書いたと思われる部分がありました。AI が作成した説明も、実装と突き合わせて確認する必要があるという例になりました。今回の Claude Code によるレビューについても、指摘とコードの対応を確認しながら記事に整理しました。

AI を使ってみて

  • 任せやすかったこと: 雛形の作成、ライブラリの定番の使い方(Leaflet と ref、マーカー画像のパスなど)、インフラ設定の調整、不具合の原因の当たりをつけること
  • 人が決める必要があったこと: 何を作らないか(管理画面・ログイン・サーバー側のお気に入り)、データをどこに置くか、公開期間に合わせた割り切り
  • 人が確認する必要があったこと: 外部 API のデータの扱い(規約)、コメントと実装が一致しているか、デバッグ用のコードが残っていないか、ドキュメントの内容が正しいか

特に、Google のデータの扱いは、単にコードの品質だけをレビューしていては見落としやすい論点でした。AI のおかげで期日までにサイトを完成させられた一方、規約や運用のようにコードの外にある判断は、要件を知っている人が責任を持つ部分だと感じています。

今ならどう作るか

60 人弱が約 1 週間使うサイトとして、当時の構成と「React + 静的 JSON + GitHub Pages」を比べてみます。

観点 当時の構成(+ CakePHP + MariaDB + EC2 + Tunnel) React + 静的 JSON + GitHub Pages
初期開発 API・DB・インフラの構築が必要。公開先も何度か変わった JSON を置くだけ
インフラ費用 EC2・EBS・パブリック IP・ドメイン 追加費用なし
運用負荷 OS、nginx、php-fpm、MariaDB、cloudflared を動かし続ける ほぼなし
セキュリティ 公開 API、SSH、OS の更新、Tunnel の認証情報 静的ファイルのみ
コンテンツ更新 DB を直せば即反映 JSON を直して push(Actions で自動デプロイ)
拡張性 管理画面や書き込み API を足しやすい 動的な機能には別の仕組みが必要
保守性 2 言語。サーバーの設定がリポジトリの外 フロントだけで完結

要件だけを見れば、静的 JSON で十分でした。それでも、当時の構成を失敗だったとは考えていません。普段使っている CakePHP で迷わず書けましたし、QR コードを配った後でも DB 側で情報を更新できる構成になっていました。EC2・nginx・php-fpm・Tunnel まで含めた公開構成を経験できた点も、個人開発としては収穫でした。

今作り直すなら、次のような静的配信を検討します。

  • 座標などは利用権限を確認できる情報源、または独立に整備したデータを利用し、Google の Places API 由来のコンテンツを OSM の地図に流用しない
  • 読み込み中・エラー・0 件の表示を最初から作る
  • ポップアップは DOM API で組み立てる
  • OSM の帰属表示を、著作権ページへのリンク付きにする
  • 管理画面が必要になったら、そのときに API とサーバーを足す

夫婦で協力したこと

技術的な部分は私が担当し、サイトに載せる内容は夫婦で決めました。

  • サイトを作ること自体が、妻の提案でした
  • 紹介するお店やスポットは、二人で相談して選びました
  • 紹介文やコメントも、二人で相談して決めました。カードの「新郎チョイス」「新婦チョイス」「新郎・新婦チョイス」のタグは、誰が選んだかを表しています
  • 席次表は外注し、そこにサイトの QR コードを載せてもらいました

DB の chosen_by カラムは、この「誰が選んだか」をそのまま持たせるために用意したものです。データ構造に夫婦での決め方が表れているのは、このサイトならではだと思います。

ゲストの反応

公開した後、ゲストからは次のような声をもらいました。

  • 「何かのサービスを使って作ったサイトだと思った」
  • 式の翌日に、このマップを使って、紹介した場所を実際に訪れてくれた人がいた
  • 「QR コードが席次表にあまりにも自然に載っていて、気づかなかった」

既存のサービスで作ったと思われる程度には、サイトとして形になっていたようです。マップを見て実際に足を運んでくれた人がいたのは、遠方から来るゲストに地元を案内するという、作った目的どおりの使われ方でした。

一方で、QR コードに気づかなかったという声もありました。
席次表に載せるだけでなく、存在を知らせることも必要だったと感じています。

まとめ

  • 「60 人弱・1 週間・費用を抑えたい」という条件に対して、公開情報を返す API と、ブラウザ側の絞り込み・localStorage によるお気に入りで実装した
  • Leaflet は描画ライブラリとしては無料だが、地図タイルには提供元の利用条件がある
  • 外部 API を公開前の準備段階だけで呼ぶ設計は、費用の面では合理的。ただし、取得したデータの保存や、Google 以外の地図との組み合わせには規約上の制限がある
  • paginate() の既定の件数制限で、エラーにならずにデータが欠けた。全件を前提にした API では件数を確認できるようにしておく
  • Cloudflare Tunnel を使うと、受信ポートを開けずに HTTPS で API を公開できる。ただし、API が公開されていること自体は変わらない
  • AI を使って期日までに完成させられた。1 年後に読み返すと、問題の多くは設計よりも作業途中の残骸で、規約のようにコードの外にある判断こそ人が担う部分だった

結婚式という期日のあるイベントに向けて、夫婦で内容を考え、私が技術を選んで作ったサイトでした。1 年経って読み返すことで、作っていたときには気づけなかったことが多く見つかりました。

参考資料

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?