一日の記録を準備しています

少しだけお待ちください。

一覧へ戻る

1枚の写真がコスト方針に、1つのチャットルームがセキュリティ方針になった理由

チャット写真の反復転送を減らすためにポーリングとメディアの保存方式を変え、複数のグループと非公開ルームを運用するなかで、画面を隠すだけではないサーバー側の権限境界を築いた2週目の試行錯誤を記録しました。

本のように読む
標準
大小の写真プリントが置かれた木の作業台で、光の線が4区画の木箱と鍵へつながる場面

テーマ

ハルスペース・ラボ

1枚の写真がコスト方針に、1つのチャットルームがセキュリティ方針になった理由

チャット写真の反復転送を減らすためにポーリングとメディアの保存方式を変え、複数のグループと非公開ルームを運用するなかで、画面を隠すだけではないサーバー側の権限境界を築いた2週目の試行錯誤を記録しました。

要約

要約

  1. 一度アップロードした写真がポーリングのたびに再転送される構造を見つけ、差分取得、サムネイル優先表示、ブラウザからBlobへの直接アップロードによって転送範囲を絞りました。
  2. 公開型オブジェクトストレージ(Public Blob)はコストの経路を変える保存先にすぎず、非公開アクセスを保証しないため、資料の機密性に応じて別の保存・アクセス方式を選ぶ必要があるという限界を残しました。
  3. グループとチャットルームの権限をサーバーで検査し、招待リンクと実際のメンバーシップ作成を分離しましたが、後に見つかった招待コードとパスワードの組み合わせに関する不備から、複合条件を一つの判断で検証する方法を学びました。
12ページ
大小の写真プリントが置かれた木の作業台で、光の線が4区画の木箱と鍵へつながる場面

Summary

ひと目でわかる要約

  • 一度アップロードした写真がポーリングのたびに再転送される構造を見つけ、差分取得、サムネイル優先表示、ブラウザからBlobへの直接アップロードによって転送範囲を絞りました。
  • 公開型オブジェクトストレージ(Public Blob)はコストの経路を変える保存先にすぎず、非公開アクセスを保証しないため、資料の機密性に応じて別の保存・アクセス方式を選ぶ必要があるという限界を残しました。
  • グループとチャットルームの権限をサーバーで検査し、招待リンクと実際のメンバーシップ作成を分離しましたが、後に見つかった招待コードとパスワードの組み合わせに関する不備から、複合条件を一つの判断で検証する方法を学びました。

この記事は、2026年7月6–12日のGit履歴と、当時のメディア・グループ権限の設計文書、そして後になって見つけて修正した非公開ルームの問題をあわせて振り返った、2週目の回顧録だ。

チャットルームに写真を1枚アップロードすると、人の目には一度だけ送ったように見える。

しかし、当時のHaru Spaceのネットワークではそうではなかった。チャット画面は新しいメッセージがあるかを一定間隔でサーバーに問い合わせ、サーバーはそのたびにメッセージ一覧を返していた。一覧のレスポンスに元画像が文字列として混ざっていれば、すでに見た写真も取得のたびに再びついてくる。ルームにいる人と写真が増えるほど、1回のアップロードが何度ものダウンロードへ膨らむ構造だった。

2週目、私は写真を単なる添付機能として見られなくなった。

1枚の写真をどう読み込むかは画面の実装であると同時にデータ転送の方針であり、突き詰めればコスト方針だった。

同じ週、チャットルームも似た問いを投げかけてきた。一覧からルームを隠せば非公開になるのか。招待リンクを受け取った人は、すぐにルームのメンバーなのか。プラットフォーム管理者なら、すべてのグループの会話を当然読めるべきなのか。

問いをたどっていくと、1つのチャットルームはメニュー項目ではなく権限の境界になった。

1枚送ったはずの写真が、なぜ何度も転送されたのか

初期のチャットは短い間隔のポーリングで新しいメッセージを確認していた。実装は単純で、小さなテスト環境では十分に速く見えた。問題は、取得頻度とレスポンスサイズを別々に考えていたことにあった。

当時の構造では、次の3つが重なっていた。

  • 画面が開いている間、ルーム一覧とメッセージ一覧を繰り返し取得していた。
  • メッセージ一覧は新しい項目だけでなく、直近の履歴のまとまりを再び受け取ることがあった。
  • サムネイルがなければ、一覧でも元画像のデータを代わりに使うことができた。

それぞれの選択は、単独なら些細に見えた。組み合わさると話が変わった。元画像のサイズに取得回数と接続人数を掛けた値が、反復転送リスクのおおよその大きさになる。写真が増えるほどデータベースのレスポンスも重くなり、サーバーレス関数が送り出す転送量とモバイルデータ使用量も一緒に増えていった。

ここで、記録の限界をはっきりさせておきたい。当時のコミットにはポーリング間隔とレスポンス形式を変えた事実が残っているが、変更前後の本番転送量を同じ条件で測り、比較した計測資料は十分に残っていない。したがって「コストを何パーセント削減した」とは言えない。

私たちが確認したのは計測済みの削減率ではなく、コード上で反復転送が避けられない経路だった。コストのリスクは元画像のサイズ、反復間隔、利用者数から推定し、実際の削減効果はその後の観測指標で確かめる課題として残した。設計上の推定と運用上の計測を混同しないことも、このときに身につけた運用の習慣だ。

まず問い合わせを減らし、変わった分だけ受け取った

最初の対策はポーリングをなくすことではなく、無駄を減らすことだった。ルーム一覧の確認間隔を延ばし、メッセージの確認も細かすぎないように調整した。ブラウザのタブが見えていないときは反復取得を止めた。

次に、レスポンスの範囲を変えた。

チャットルームに初めて入るときは直近のメッセージを一部受け取り、文脈をつくる。それ以降は、最後に受け取ったメッセージの作成時刻と識別子を一緒に送り、その後に作成されたものだけを要求する。同じ瞬間に複数のメッセージが保存されても取りこぼしたり重複して積み上げたりしないよう、クライアントは識別子を基準に統合する。

この小さな変更には、重要な視点の転換があった。

以前の問いは「新しいメッセージがあるか」だった。新しい問いは「自分がすでに持っているものと比べて、何が変わったか」だった。前者はサーバーに現在の状態全体を何度も求めやすく、後者は変化量だけをやり取りする構造をつくる。

もちろん、差分取得も万能ではない。クライアントが最後の位置を誤って記憶すれば欠落が起き、同じ時刻のデータ順を識別子なしに判断すれば境界でメッセージが抜ける可能性がある。そこで時刻だけに頼らず、時刻と識別子を一緒に使い、初回取得とそれ以降の取得の役割も分けた。

元画像と一覧用画像は同じリソースではなかった

ポーリングを減らしても、一覧のレスポンスに大きな元画像が入っていれば無駄は残る。そこで、メディアが移動する経路そのものを変えた。

ブラウザはファイルが選ばれると、一覧と吹き出しに使う小さなサムネイルをつくる。サーバーはログイン状態とそのルームへアクセスする権限を確認し、アップロードに必要な限定的な権限だけを発行する。元画像とサムネイルはブラウザからオブジェクトストレージへ直接アップロードされ、メッセージデータにはファイル名、形式、サイズ、保存先といったメタデータだけを残す。

利用者がチャットを読むときは、小さなサムネイルを先に見る。サムネイルがなければ元画像をひそかに代替として読み込まず、プレースホルダーを表示する。元画像は利用者が写真を押して大きく見る瞬間にだけ取得する。以前の方式で保存した添付が突然消えないよう、別の互換経路も維持した。

私はこの構造を3つの文にまとめた。

  1. アップロード権限はサーバーが判断する。
  2. 一覧には、一覧に必要なサイズだけを送る。
  3. 元画像は、元画像を必要とする操作でのみ読み込む。

こうすることで、サーバーレス関数が元ファイルを毎回中継する負荷と、データベースのレスポンスサイズを減らせる。ただし、コストが消えるわけではない。元画像をオブジェクトストレージからダウンロードすると、そのストレージの転送量が発生する。コストの経路を変えることと、コストをなくすことは違う。

Public Blobは非公開の金庫ではなかった

この節は2026年7月当時の保存方式を振り返る内容であり、現在の添付セキュリティ方針や、機密資料をアップロードできるかどうかを案内するものではない。

当時選んだ保存方式はPublic Blobだった。ブラウザがストレージから直接ファイルを受け取りやすく、メディア転送の経路を単純にする助けになった。しかし、その名のとおり、リンクを知っている人がファイルへアクセスできる性質を持つ。

アプリ内でルームへのアクセスを遮断しても、すでに知られている公開型オブジェクトのURLまでアプリの権限検査が代わりに遮断してくれるわけではない。メッセージ一覧をグループとルームの権限で守ることと、保存された元画像そのものを非公開で届けることは、別の問題だ。

だから、Public Blobを選んだ事実を「添付が完全に非公開になった」という意味では書かないことにした。2週目の実装は元画像の自動転送と、サーバーを経由するコストを減らすことに焦点を置いており、リンクそのものの機密性を保証する構造ではなかった。

資料の機密性が高いなら、選択も変えなければならない。非公開ストレージで要求のたびに権限を確認するか、短時間だけ有効な署名付きURLを発行する方式が必要になる。保存期間、削除、リンクの再利用、キャッシュ方針も一緒に決めなければならない。

この区別は後に、Haru Spaceの公開・非公開データを分ける原則へつながった。

アプリ画面が非公開であること、APIがアクセスを拒否すること、保存されたオブジェクトが非公開であることは、3つの別々の境界だ。

グループができると、すべての取得に所属がついてきた

メディアのコスト経路を直している間に、Haru Spaceは一つの集まりだけが使うサービスから、複数のグループがともに使うサービスへ変わろうとしていた。この変化は、グループ選択画面を一つ追加することではなかった。利用者、ルーム、メッセージ、お知らせ、メニュー設定がどのグループに属するかを、すべての主要な取得と変更で確かめる必要があった。

当時の最初の権限モデルは、プラットフォーム全体を管理する役割、1つのグループを管理する役割、一般メンバーを区別していた。利用者が承認されると、自分のグループのデフォルト公開ルームだけに入るようにし、以前のように新しい利用者をすべてのルームへ自動追加する流れは禁止した。公開ルームは同じグループの一覧に表示できるが、非公開ルームは管理者か、すでに参加しているメンバーにだけ見せた。パスワードロックは一覧の公開範囲とは別の条件として扱った。

最も重要な一文は、文書の下のほうにあった。

クライアントでボタンやルームを隠すことは、セキュリティではない。

画面は利用者が間違えないよう助けるだけだ。メッセージの読み書き、添付元画像の閲覧、ルーム設定の変更では、サーバーがセッション、グループ、ルームのメンバーシップを改めて確認しなければならない。別のグループの識別子を直接指定して要求しても、データを受け取れない必要がある。存在そのものが不必要に明らかにならないよう、レスポンスの返し方にも注意が必要だった。

この原則によって、画面とサーバーの責任が分かれた。画面は「この利用者に何ができるか」をわかりやすく見せる。サーバーは「この要求が実際に許可されるか」を最終的に判断する。二つの結果が食い違ったとき、画面を直して終わりにせず、まずサーバーの拒否が保たれているかを確認するようになった。

招待リンクはメンバーシップではなかった

非公開ルームを一緒に使うには招待が必要だった。リンクを押すのは便利だが、リンクがそのまま権限になってはならなかった。

私たちは招待リンクを、ルームへ向かうための移動情報として扱った。ログインしていない人はいったん認証へ戻り、認証が終わると元の招待フローを再開する。ログイン済みのクライアントも、リンクを読み取っただけではメンバーにならない。招待情報をサーバーへ提出し、サーバーが同じグループの承認済み利用者であることと、有効なルームであることを確認したあとにだけ、ルームのメンバーシップを作成する。

この区別は利用体験を少し複雑にするかもしれない。その代わり、リンクが閲覧履歴やメッセンジャーでの転送過程に残ったというだけで、セッションとグループの確認が省略されることを防ぐ。

当時は、次のように分けて考えた。

  • リンク:利用者がどのルームに入りたいかを示す情報
  • 認証:要求した人が誰なのかを確かめる手続き
  • グループメンバーシップ:その人が当該コミュニティで承認されたメンバーかを示す状態
  • ルームメンバーシップ:そのメンバーがこの非公開ルームへ入れることを示すサーバー上の記録

4つを一つの「招待成功」にまとめると、どの段階で許可されたのかがわかりにくい。分ければ失敗の理由も説明でき、権限が生まれる正確な瞬間も記録できる。

最初の境界は、完成した境界ではなかった

ここまで読むと、2週目にセキュリティの問題が整理されたように見えるかもしれない。実際はそうではなかった。

グループ境界を導入した最初のバージョンでは、アカウント登録時に一つのグループを選び、プラットフォーム管理者とグループ所属を現在よりも密接に結びつけていた。やがて1人が複数のグループに参加するようになり、アカウント作成とグループ参加申請を分離した。プラットフォームを点検する権限と、実際にグループの会話を読むメンバーシップも分けた。グループ退出後の非アクティブ状態、再参加、過去の記録を保存するライフサイクルの規則も後から加わった。

つまり、2週目の成果は完成した権限体系ではなく、すべてのデータにグループという境界を適用し始めたことだった。今の方針を当時からすべて備えていたと語れば、変化の過程で学んだことを消してしまう。

もっと直接的な失敗もあった。数週間後、パスワード付きの招待専用ルームで、招待コードの公開条件とパスワード検証が別々の経路に置かれている不備を見つけた。画面ではロックされているように見えたが、特定の組み合わせでは、参加前の利用者に招待情報が見える可能性があった。サーバーも、招待コードとパスワードという二つの条件を一つの判断で強制できていなかった。

私たちは参加者にだけ招待情報を返すよう範囲を絞り、参加前には関連メニューを開けないようにした。より重要な修正は、サーバーが二つの条件をともに通過したあとでのみメンバーシップを作成するよう、処理順を変えたことだった。画面を閉じる修正だけなら、同じ要求を別の方法で送る問題は残ったはずだ。

この失敗は、「招待リンクとメンバーシップを分ける」という初期の判断が正しいことと、その実装が十分だったという主張が別であることを示した。良い原則にも、すべての組み合わせを試さなければ隙間が生まれる。

コストと権限は同じように漏れた

振り返ると、メディアのコストとルームの権限の問題は似ていた。

元画像は一覧で必要ないのに、利便性のためレスポンスについてきた。招待情報は参加前の利用者には必要ないのに、ルームのオブジェクトに入っているという理由でついていく可能性があった。どちらも「すでにデータにあるのだから、一緒に送ってもいいだろう」という緩い境界から始まった。

そこで2週目以降、APIレスポンスをつくるとき、フィールドごとに同じ問いをつけた。

  • この画面とこの操作に、今どうしても必要か
  • 要求者にはこの値を受け取る所属と権限があるか
  • 反復して呼ばれたとき、サイズとコストはどう増えるか
  • レスポンスがブラウザ、記録、共有リンクに残っても問題ないか

最小転送と最小権限は別の原則に見えるが、実務では同じ設計習慣だった。必要な人に、必要な瞬間、必要な量だけを送る。コスト最適化がセキュリティを改善することは多く、権限を細かく分ければ不要な取得も減らせる。

2週目が残した実践チェックリスト

似たようなチャットやコラボレーションサービスをつくるなら、機能単位ではなくデータ移動の単位で次を確認したい。

1. 繰り返し表示する画面では、要求回数とレスポンスサイズを一緒に見る

ポーリング間隔を延ばすだけ、またはファイルサイズを小さくするだけで終わらせない。1人の利用者が1時間に呼び出す回数、1レスポンスの平均サイズ、同時に見る人数を掛けてリスクを推定する。推定値は実際の転送量指標と分けて記録する。

2. 一覧用データと詳細用データを分ける

一覧にはタイトル、メタデータ、サムネイルだけを置く。元画像と長い本文は利用者が開いた瞬間に別途要求する。サムネイルがないからといって元画像を自動で代用しない。

3. ストレージの公開範囲とプロダクトの公開範囲を混同しない

公開型オブジェクトのURLを非公開チャットで使うなら、その限界を文書に残す。機密資料は非公開ストレージ、サーバーの権限確認、または有効期間の短いアクセス方式へ分ける。

4. すべての読み書きで所属を改めて確認する

画面でメニューが見えないことは補助的な確認にすぎない。サーバーは利用者の状態、アクティブなグループ、ルームのメンバーシップ、必要な役割を要求ごとに確認する。別グループの識別子を指定する不正経路のテストも含める。

5. リンク、コード、パスワード、メンバーシップを別々の状態として扱う

それぞれの値は異なる問いに答える。二つ以上の条件が必要なルームでは、それぞれを別々に通過させず、サーバーの一つの判断ですべてを確認したあとにメンバーシップを作成する。

6. 成功経路より先に組み合わせ表をつくる

同じグループと別グループ、参加者と非参加者、公開ルームと招待専用ルーム、パスワードの有無を組み合わせた表をつくる。各マスで、一覧への表示、招待情報、メッセージの閲覧、メンバーシップ作成の結果を確認する。

写真とルームが残した次の問い

2週目が終わったとき、Haru Spaceは写真をより軽く表示し、グループ外からの要求をサーバーで拒否する最初の基準を備えていた。だからといって、コストがすべて計測されたわけでも、添付が完全な非公開ストレージへ変わったわけでも、権限モデルが完成したわけでもない。

重要な変化は、機能の名前の後ろに運用単位を見るようになったことだった。

写真の後ろには、1回のアップロード、複数回の一覧取得、サムネイルと元画像それぞれの異なる寿命があった。チャットルームの後ろには、画面表示、グループ所属、ルームメンバーシップ、サーバーの最終的な拒否があった。これらの単位を分けると、コストとセキュリティはリリース後につけ加える点検項目ではなく、機能をつくる材料になった。

1枚を一度だけ送り、1つのルームを許可された人にだけ開くこと。2週目の仕事は、結局その単純な約束をコードのすべての経路で守ることだった。

次の記録では、このポーリング構造をリアルタイム接続へ変えるなかで、無料転送量の上限と旧型iPhoneの制約をどのように一緒に扱ったのか、そして接続は生きているのに一部のイベントだけが消えたように見えた問題をどう追跡したのかを続けていく。

続けて読む

前の記事 · 次の記事

前の記事模倣ではなく目的を選んだ — Haru Spaceがメッセンジャーになった最初の一週間次の記事 接続済みは同期済みではなかった — 無料枠・旧型iPhone・Realtimeの取りこぼしに向き合った二日間