GORMのN+1問題とマイグレーション設計—Goで踏みやすい罠と回避策

GORMのN+1問題とマイグレーション設計—Goで踏みやすい罠と回避策 | mohablog
// ユーザー一覧と投稿を取得する、よくあるコード
var users []User
db.Find(&users)
for _, u := range users {
    var posts []Post
    db.Where("user_id = ?", u.ID).Find(&posts)
    fmt.Printf("%s: %d件\n", u.Name, len(posts))
}

一見すると普通に動くこのコード、ユーザーが100人いれば101回のSQLクエリが走る。開発環境のSQLite では一瞬で返ってくるから気づかないけれど、本番のMySQL でレコードが増えたときにレスポンスが10秒を超えて初めて問題に気づく――GORMを使った開発で、たぶん一番多い事故パターンがこれです。

GORMはGoで最も使われているORMライブラリで、CRUD操作をシンプルに書ける反面、暗黙の挙動に頼りすぎると本番で痛い目に遭います。この手の問題は公式ドキュメントにも書いてあるんですが、入門記事だけ読んで使い始めると見落としがちです。

ここでは GORM v2(2.0)+ Go 1.22 + PostgreSQL 16 の環境で、実際に踏みやすい3つの罠とその回避策を整理します。

目次

N+1問題—Preloadを忘れるとクエリ数が爆発する

なぜ起きるのか

GORMのリレーション(HasMany, BelongsToなど)は、デフォルトでは遅延ロードです。関連データは明示的に読み込まない限り取得されません。冒頭のコードのように、ループ内で関連データを個別に取得すると、親レコードN件に対してN+1回のクエリが発行されます。

// モデル定義
type User struct {
    gorm.Model
    Name  string
    Posts []Post  // HasMany
}

type Post struct {
    gorm.Model
    Title  string
    UserID uint
}

NGパターン: ループ内でクエリ

var users []User
db.Find(&users)
// ユーザーごとにSELECTが走る = N+1
for _, u := range users {
    var posts []Post
    db.Where("user_id = ?", u.ID).Find(&posts)
    // ...
}
-- 実行されるSQL(ユーザーが3人の場合)
SELECT * FROM users;
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
SELECT * FROM posts WHERE user_id = 3;
-- → 4クエリ。100人なら101クエリ。

回避策: Preloadで一括取得

var users []User
db.Preload("Posts").Find(&users)
for _, u := range users {
    fmt.Printf("%s: %d件\n", u.Name, len(u.Posts))
}
-- 実行されるSQL(何人いても2クエリ固定)
SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);

たった.Preload("Posts")を追加するだけで、クエリ数がN+1から2回固定になります。ネストしたリレーション(投稿に紐づくコメントなど)もPreload("Posts.Comments")で一括取得できます。

Preloadに条件をつける

「公開済みの投稿だけ取得したい」ような場合は、Preloadにクロージャで条件を渡せます。

db.Preload("Posts", func(db *gorm.DB) *gorm.DB {
    return db.Where("status = ?", "published").Order("created_at DESC")
}).Find(&users)
SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (...) AND status = 'published' ORDER BY created_at DESC;

条件付きPreloadはパフォーマンスにも直結するので、不要なデータまで読み込まないよう意識しておくといいです。データベース接続まわりの最適化についてはGo のデータベース接続プーリングで何が変わる?database/sqlの最適化と実装のコツも参考になります。

AutoMigrateの限界—本番運用で裏切られるポイント

AutoMigrateがやること・やらないこと

GORMのAutoMigrateは便利ですが、公式ドキュメントにも明記されている通り万能ではないです。

操作AutoMigrateの対応
テーブル作成対応
カラム追加対応
インデックス作成対応
カラム型変更対応(一部)
カラム削除非対応
カラムリネーム非対応
テーブル削除非対応

開発中にstructからフィールドを消しても、DBのカラムは残り続けます。これが本番で「使われていないカラムにNOT NULL制約がかかっていてINSERTが失敗する」という事故につながることがあります。

NGパターン: AutoMigrateだけで本番運用

// main.goの初期化で毎回AutoMigrate
func main() {
    db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    db.AutoMigrate(&User{}, &Post{}, &Comment{})
    // → フィールドを消してもカラムは残る
    // → 本番で予期しないスキーマ差分が蓄積していく
}

回避策: マイグレーションツールを併用する

本番環境ではgolang-migrategooseといった専用のマイグレーションツールを使い、SQLファイルでスキーマ変更を管理するのが安全です。

-- migrations/000002_drop_legacy_column.up.sql
ALTER TABLE users DROP COLUMN IF EXISTS legacy_field;

-- migrations/000002_drop_legacy_column.down.sql
ALTER TABLE users ADD COLUMN legacy_field VARCHAR(255);
// 環境で使い分ける
func setupDB(db *gorm.DB, env string) {
    if env == "development" {
        // 開発環境ではAutoMigrateで手軽に
        db.AutoMigrate(&User{}, &Post{})
    }
    // 本番・ステージングはgolang-migrateで管理
    // AutoMigrateは使わない
}
$ migrate -path ./migrations -database "postgres://..." up
2/2 migrations applied

AutoMigrateは開発環境限定、本番はSQLマイグレーション。この使い分けを最初から決めておくと、後から移行する手間が省けます。

トランザクション管理—db.Transactionを使わないリスク

GORMのトランザクション2つの書き方

GORMにはトランザクションの書き方が2通りあります。

方式書き方ロールバック
手動管理db.Begin() / Commit() / Rollback()自分でやる
クロージャ方式db.Transaction(func(tx) error)自動

NGパターン: 手動管理でRollback漏れ

tx := db.Begin()
user := User{Name: "tanaka"}
tx.Create(&user)

post := Post{Title: "記事", UserID: user.ID}
if err := tx.Create(&post).Error; err != nil {
    tx.Rollback() // ここは書いた
    return err
}

// ← この後にpanicが起きたらRollbackされない
tx.Commit()
return nil

回避策: db.Transactionクロージャ方式

err := db.Transaction(func(tx *gorm.DB) error {
    user := User{Name: "tanaka"}
    if err := tx.Create(&user).Error; err != nil {
        return err // 自動Rollback
    }

    post := Post{Title: "記事", UserID: user.ID}
    if err := tx.Create(&post).Error; err != nil {
        return err // 自動Rollback
    }

    return nil // 自動Commit
})
-- errorを返す → 自動でROLLBACK
-- nilを返す → 自動でCOMMIT
-- panicが起きても → 自動でROLLBACK + recover

クロージャ方式なら、関数内でerrorを返すだけでロールバック、nilを返せばコミット。panic時もGORMが内部でrecoverしてロールバックしてくれるので、手動管理より安全です。エラーの伝搬パターンについてはGoのエラーハンドリングで失敗しない方法—wrappingパターンと構造化ログの組み合わせも参考になります。

GORMを安全に使うための設計パターン

リポジトリ層でGORMを閉じ込める

GORMの*gorm.DBをハンドラやサービス層に直接渡すと、どこからでもDBアクセスできてしまい、N+1やトランザクション漏れが散在します。リポジトリパターンでGORMへの依存を1箇所に閉じ込めるのが効果的です。

// repository/user_repo.go
type UserRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindWithPosts(id uint) (*User, error) {
    var user User
    err := r.db.Preload("Posts").First(&user, id).Error
    return &user, err
}

func (r *UserRepository) CreateWithPosts(user *User) error {
    return r.db.Transaction(func(tx *gorm.DB) error {
        return tx.Create(user).Error
    })
}
リポジトリ層のメリット:
- Preloadの付け忘れをリポジトリ内で防げる
- トランザクション境界が明確になる
- テスト時にインターフェースでモックに差し替えられる

リポジトリをインターフェース化してDIする方法はGoのインターフェースとDI設計パターン—テスタブルなコードの作り方で詳しく書いています。

DryRunとLoggerでクエリを可視化する

N+1問題の厄介なところは、コードを読んだだけでは気づきにくいことです。開発中にクエリログを有効にしておくと、問題を早期に発見できます。

import "gorm.io/gorm/logger"

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: logger.Default.LogMode(logger.Info),
})

// DryRunで実際には実行せずSQLだけ確認
stmt := db.Session(&gorm.Session{DryRun: true}).
    Preload("Posts").Find(&User{}).Statement
fmt.Println(stmt.SQL.String())
2026/04/16 20:30:00 [info] SELECT * FROM "users"
2026/04/16 20:30:00 [info] SELECT * FROM "posts" WHERE "user_id" IN ($1,$2,$3)

logger.Infoモードにすると全SQLがログに出力されるので、意図しないクエリが走っていないかすぐ確認できます。本番ではlogger.Warnに切り替えて、スロークエリだけ検知するのが現実的です。

まとめ

  • N+1問題: Preloadで一括取得。ループ内での個別クエリは原則禁止
  • AutoMigrate: 開発環境限定で使い、本番はgolang-migrategooseでSQLマイグレーション
  • トランザクション: db.Transactionクロージャ方式で自動Rollbackを保証する
  • 設計: リポジトリ層でGORMを閉じ込め、Preload漏れやトランザクション管理を集約
  • 可視化: 開発中はlogger.Infoでクエリログを出し、N+1を早期検知する
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次