「動いてるからOK」で設計していた2年目エンジニアが、DBアクセス設計のレビューで学んだこと
はじめに
業務で、MVC構成のWebサービスで利用する共通処理の詳細設計を担当した際、パフォーマンス設計に関するレビュー指摘を受けました。
設計時に重視していたのは、以下のような点です。
- まず機能を成立させる
- 正しく動作する
- Python側で柔軟に加工できる
SQLAlchemy経由でDBからデータを取得し、Service層で加工する処理は、通常利用では問題なく動作していました。体感上の遅さもなく、「これで問題ないだろう」と考えていました。
しかしレビューを通して、
現時点で動いていることと、将来的な高負荷環境でも適切に運用できることは別
という視点を学びました。
特に今回対象となっていたのは、チーム内の複数APIから利用される共通処理です。
そのため、1回あたりは小さな負荷でも、利用箇所やアクセス数が増えることでシステム全体へ影響する可能性があります。
本記事では、実際に受けたレビュー内容をもとに、DBアクセスやデータ加工処理を設計する際に意識するようになったことを整理します。
この記事は、特定の正解パターンを紹介するものではなく、レビューを通して設計時に意識するようになった観点を整理したものです。
設計していた処理
対象となっていた処理は、ざっくり以下のような流れでした。
- API Input Schemaを定義する
- Input情報をもとにDBから必要データを取得する
- Service層で加工処理を行い、Outputを生成する
設計方針としては、
必要そうなデータを一度取得して、Python側で柔軟に加工する
という考え方でした。
イメージとしては以下のような処理です。
records = repository.get_records()
filtered_records = []
for record in records:
if record.created_at >= target_date:
filtered_records.append(record)
機能としては問題なく動作しており、実装もしやすかったため、大きな問題意識はありませんでした。
しかしレビューでは、パフォーマンスや将来的な運用を踏まえた観点から、いくつか指摘をいただきました。
指摘①: 絞り込み可能な処理はSQL側で行う
最初に指摘いただいたのは、
不要なデータをアプリ側まで持ってこない
という点です。
設計では全件取得後、Python側で条件判定を行っていました。
filtered_records = []
for record in records:
if record.created_at >= target_date:
filtered_records.append(record)
しかし、取得条件としてSQLで表現できるものは、DB側で絞り込んだ方が効率的です。
SELECT *
FROM sample_table
WHERE created_at >= NOW() - INTERVAL '7 day';
SQL側で絞り込むことで、以下を抑えられます。
- 不要なデータ取得
- DBからアプリへのデータ転送量
- アプリ側のメモリ使用量
- Python側のループ処理
「Python側で処理した方が柔軟」と考えていましたが、柔軟さを優先して不要なデータまで取得してしまうと、データ量が増えたときに処理が重くなる可能性があります。
このレビューを通して、
「処理を書ける場所」ではなく「処理すべき場所」を考える必要がある
と学びました。
指摘②: SQL発行回数を意識する
次に指摘いただいたのが、DBアクセス回数についてです。
必要なタイミングで都度Repositoryを呼び出す設計を考えていました。
しかしレビューでは、
SQLを呼ぶこと自体にもコストがある
という指摘を受けました。
DBアクセスでは、単純なPythonの関数呼び出しとは異なり、アプリケーションとDB間の通信やSQL実行のコストが発生します。
特に今回のような共通処理では、1回のSQL発行は小さな負荷に見えても、複数APIから何度も呼ばれることで無視できない負荷になる可能性があります。
そのため、設計時には以下を確認する必要があると学びました。
- 1リクエストで何回SQLが発行されるか
- ループ内でSQLを発行していないか
- 同じデータを何度も取得していないか
- 一度の取得でまとめられる処理はないか
この観点は、実装して動かすだけでは気づきにくい部分でした。
指摘③: 同じデータを何度も取得しない
Service層内で同じデータを複数回使う場合、毎回DBへ取得しに行くのではなく、取得結果を再利用する設計も検討します。
例えば以下のようなイメージです。
user_cache = {}
for user_id in user_ids:
if user_id not in user_cache:
user_cache[user_id] = repository.get_user(user_id)
user = user_cache[user_id]
このように関数内で取得結果を保持すれば、同じユーザー情報を何度も取得する必要がなくなります。
キャッシュすれば常に良いというわけではありません。
データの鮮度が重要な場合や、処理の途中で更新が入る場合は注意が必要です。
ただ、少なくとも設計段階で、
このデータは何回取得されるのか
一度取得した結果を再利用できないか
を考えることが重要だと学びました。
指摘④: JOINを増やせば良いわけではない
一方で、レビューでは「すべてSQL側で処理すれば良い」という話でもありませんでした。
印象に残っているのが、
JOINが増えすぎる場合は、処理分割も検討する
という指摘です。
SQL発行回数を減らすために、できるだけJOINして一度に取得する方向で考えていました。
しかし、JOINが増えすぎると以下のような問題が出る可能性があります。
- SQLの可読性が下がる
- 実行計画が複雑になる
- インデックス設計が難しくなる
- 修正時の影響範囲が分かりにくくなる
つまり、SQL発行回数を減らすこと自体が目的ではありません。
大事なのは、
DB側で処理するもの
アプリ側で処理するもの
分割して取得した方がよいもの
を、データ量や保守性も含めて判断することだと学びました。
レビューを受けて変わったこと
このレビューを受けてから、詳細設計や実装時に以下を確認するようになりました。
- 不要なデータを取得していないか
- SQLで絞り込める条件をPython側で処理していないか
- 1リクエストでSQLが何回発行されるか
- ループ内でRepositoryを呼び出していないか
- 同じデータを何度も取得していないか
- JOINが複雑になりすぎていないか
- なぜその設計にしたのか説明できるか
以前は「正しく動くか」を中心に考えていました。
しかし今は、それに加えて「データ量が増えたときにどうなるか」「共通処理として何度も呼ばれても問題ないか」を意識するようになりました。
おわりに
今回のレビューで学んだのは、単なるパフォーマンス改善のテクニックではありません。
一番大きかったのは、
動く実装と、運用を意識した実装は別
という考え方です。
実務では、とりあえず動くコードを書くことだけでなく、
- どこで処理するべきか
- どれくらいDBに負荷がかかるか
- データ量が増えても問題ないか
- 将来修正しやすいか
まで考える必要があります。
今回のレビューを通して、DBとアプリケーションそれぞれの責務を意識して設計する重要性を学びました。
今後もレビューで得た知見を整理しながら、保守性・運用性・性能を意識した設計ができるようになっていきたいです。