🐙

📝 Go初心者がTodoアプリにPostgreSQL接続を実践

に公開

はじめに

エンジニア4ヶ月目のSomeです!

前回、Go vs C# Todo App のDocker化比較を書いて、Goの圧倒的性能を実感しました。しかし、その時のアプリはIn-Memoryでデータを管理していたため、アプリを再起動するとデータが消えてしまう問題がありました。

今回は、その続編としてGoアプリをPostgreSQL化してみました。実際の業務では避けて通れないデータベース接続を学びながら、設定管理やORM、Docker Composeなど、Web開発の基本的な要素を一通り体験できました。

この記事では、Go初心者の私がPostgreSQL接続を実装する過程で学んだことを、躓いたポイントも含めて正直にシェアしたいと思います。

環境情報

  • MacBook Air (M3)
  • Go: 1.21(学習中)
  • PostgreSQL: 15
  • Docker & Docker Compose

今回実装したもの

前回のシンプルなIn-Memory Todoアプリを、本格的なPostgreSQL版にレベルアップしました。

Before: In-Memory版の課題

// 全てメモリ上で管理
var (
    todos  []Todo
    nextID = 1
    mu     sync.RWMutex
)

この実装の問題点:

  • アプリを再起動するとデータが消える
  • 複数のインスタンスでデータを共有できない
  • 本番環境では使い物にならない

After: PostgreSQL版の構成

go-todo/
├── config/           # 環境設定管理
├── models/           # GORMモデル定義
├── database/         # DB接続・マイグレーション
├── docker-compose.yml # PostgreSQL環境
├── .env             # 環境変数
└── main.go          # メインアプリケーション

この構成により、データの永続化はもちろん、環境ごとの設定管理や、開発環境の簡単な構築が可能になりました。


学んだこと1: 設定管理の重要性を実感

なぜ環境変数による設定管理が必要なのか?

初心者の頃は、設定値をコードに直接書くことが多いと思います。私も最初はこんな感じでした:

// ❌ 悪い例:設定をハードコード
dsn := "host=localhost port=5432 user=todouser password=password dbname=todoapp"

しかし、この方法には大きな問題があります:

  1. 環境依存: 開発環境と本番環境で設定を変えられない
  2. セキュリティリスク: パスワードなどの機密情報がコードに露出
  3. メンテナンス性: 設定変更のたびにビルドし直しが必要
  4. チーム開発: 開発者ごとに異なる環境に対応できない

12-Factor Appとは?なぜ重要なのか?

調べてみると、この問題を解決するための指針として12-Factor Appという考え方があることを知りました。

12-Factor Appとは、Heroku社が提唱したWebアプリケーション開発のベストプラクティス集です。現代のクラウド環境で動作するアプリケーションを作るための12の原則が定められています。

その中でも今回特に重要だったのが、**「III. Config(設定)」**の原則です:

設定は環境変数に格納する

この原則が推奨される理由:

  1. 環境の分離: 開発・ステージング・本番環境で異なる設定を使用
  2. セキュリティ: 機密情報をコードから分離
  3. デプロイの柔軟性: 設定変更のためだけにビルドし直す必要がない
  4. スケーラビリティ: 複数のインスタンスで同じコードを使い回せる

実際の業務では、以下のような環境ごとの設定違いが発生します:

# 開発環境
DB_HOST=localhost
DB_PASSWORD=dev_password

# ステージング環境  
DB_HOST=staging-db.company.com
DB_PASSWORD=staging_secure_password

# 本番環境
DB_HOST=prod-db.company.com
DB_PASSWORD=super_secure_prod_password

12-Factor Appに基づく解決策の実装

この原則に従って、以下のような設定管理を実装しました:

// config/config.go
package config

import (
    "log"
    "os"
    "github.com/joho/godotenv"
)

type Config struct {
    Port       string
    DBHost     string
    DBPort     string
    DBUser     string
    DBPassword string
    DBName     string
    DBSSLMode  string
}

func LoadConfig() *Config {
    // .envファイルを読み込み(開発環境用)
    // 本番環境では.envファイルは使わず、システム環境変数を使用
    if err := godotenv.Load(); err != nil {
        log.Println("No .env file found, using environment variables")
    }
    
    return &Config{
        Port:       getEnv("PORT", "8080"),
        DBHost:     getEnv("DB_HOST", "localhost"),
        DBPort:     getEnv("DB_PORT", "5432"),
        DBUser:     getEnv("DB_USER", "todouser"),
        DBPassword: getEnv("DB_PASSWORD", "password"),
        DBName:     getEnv("DB_NAME", "todoapp"),
        DBSSLMode:  getEnv("DB_SSL_MODE", "disable"),
    }
}

func getEnv(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}

この実装により、12-Factor Appの原則に従った設定管理が実現できました:

  • 環境変数の優先順位: システム環境変数 → .envファイル → デフォルト値
  • 開発時の利便性: .envファイルで簡単に設定変更
  • 本番環境の安全性: .envファイルなしでもシステム環境変数で動作

実際の開発での使い分け

開発環境での使用

.envファイルを作成して、ローカル開発用の設定を記述:

# .env(開発環境用)
PORT=8080
DB_HOST=localhost
DB_USER=todouser
DB_PASSWORD=password
DB_NAME=todoapp
DB_PORT=5432
DB_SSL_MODE=disable

本番環境での使用

.envファイルは使わず、システム環境変数で設定:

# 本番環境(例:DockerやKubernetes)
export PORT=8080
export DB_HOST=prod-db.company.com
export DB_USER=prod_user
export DB_PASSWORD=super_secure_password
export DB_NAME=todo_production
export DB_SSL_MODE=require

学びのポイント

12-Factor Appの原則を学ぶことで、なぜ環境変数による設定管理が重要なのかを理論的に理解できました。最初は「なんで環境変数なんて面倒なことを...」と思っていましたが、実際に実装してみると、開発環境と本番環境の設定を簡単に切り替えられる便利さを実感できました。

これは、今後のWebアプリ開発において必須のスキルだと思います。特に、チーム開発やクラウド環境でのデプロイを考えると、避けて通れない概念だと理解しました。


学んだこと2: GORMでORMの威力を体験

ORMとは何か?初心者目線での理解

ORM(Object-Relational Mapping) はこれまでC#のRazorPageやPHPのLaravelにも搭載されていた機能なので馴染みがありました。

簡単に言うと、ORMはGoの構造体とデータベースのテーブルを自動で対応付けてくれる仕組みです。これにより、SQLを直接書くことなく、Goのコードでデータベース操作ができるようになります。

従来のSQL方式との比較

従来のSQL方式(database/sqlパッケージ):

// データ取得の例
rows, err := db.Query("SELECT id, title, completed FROM todos WHERE completed = ?", false)
if err != nil {
    return nil, err
}
defer rows.Close()

var todos []Todo
for rows.Next() {
    var todo Todo
    err := rows.Scan(&todo.ID, &todo.Title, &todo.Completed)
    if err != nil {
        return nil, err
    }
    todos = append(todos, todo)
}

この方法の問題点:

  • SQLを手書きする必要がある
  • 手動でフィールドをマッピングする必要がある
  • タイプミスやフィールドの順序間違いが起きやすい
  • コードが冗長になりがち

GORM方式:

// 同じ処理がこれだけ!
var todos []Todo
db.Where("completed = ?", false).Find(&todos)

この簡潔さに最初は驚きました。しかも、タイプセーフでコンパイル時にエラーを検出できるのも大きなメリットです。

GORMでのモデル定義

// models/todo.go
package models

import (
    "errors"
    "time"
)

type Todo struct {
    ID        uint      `json:"id" gorm:"primaryKey"`
    Title     string    `json:"title" gorm:"not null;size:200"`
    Completed bool      `json:"completed" gorm:"default:false"`
    CreatedAt time.Time `json:"createdAt"`
    UpdatedAt time.Time `json:"updatedAt"`
}

// テーブル名を明示的に指定
func (Todo) TableName() string {
    return "todos"
}

// バリデーション関数
func (t *Todo) Validate() error {
    if t.Title == "" {
        return errors.New("title is required")
    }
    if len(t.Title) > 200 {
        return errors.New("title is too long (max 200 characters)")
    }
    return nil
}

GORMタグの理解

GORMタグを使うことで、データベースの制約を構造体の定義と一緒に管理できます:

  • primaryKey: 主キーとして設定
  • not null: NULL値を禁止
  • size:200: VARCHAR(200)として定義
  • default:false: デフォルト値を設定

これらのタグにより、以下のようなSQLが自動生成されます:

CREATE TABLE todos (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    completed BOOLEAN DEFAULT false,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

学びのポイント

ORMを使うことで、データベース操作のコードが劇的にシンプルになりました。また、構造体とテーブルの定義が一元管理されるため、保守性も向上します。ただし、複雑なクエリの場合は生SQLの方が適している場合もあるので、適材適所で使い分けることが大切だと学びました。
逆に生SQLはあまり書いたことがないので、書いて学ばないとですね〜。


学んだこと3: Docker Composeによる開発環境構築

PostgreSQL環境構築の課題

以前なら、PostgreSQLを使いたい場合は以下のような手順が必要でした:

  1. PostgreSQLをローカルにインストール
  2. ユーザーとデータベースを作成
  3. 設定ファイルを編集
  4. サービスを起動

これらの作業は環境によって手順が異なり、チーム開発では「私の環境では動くのに...」という問題が頻発します。

Docker Composeによる解決

Docker Composeを使うことで、この問題を一気に解決できました:

# docker-compose.yml
version: '3.8'

services:
  postgres:
    image: postgres:15-alpine
    container_name: todo-postgres
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    ports:
      - "${DB_PORT}:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - todo-network

volumes:
  postgres_data:

networks:
  todo-network:
    driver: bridge

環境変数の統一管理

特に素晴らしいと感じたのは、.envファイルの設定をそのままDocker Composeで使える点です:

environment:
  POSTGRES_USER: ${DB_USER}          # .envから読み込み
  POSTGRES_PASSWORD: ${DB_PASSWORD}  # .envから読み込み
  POSTGRES_DB: ${DB_NAME}            # .envから読み込み

これにより、アプリケーションとデータベースで同じ設定を共有できます。

portsの仕組みを理解

最初、portsの設定で混乱しました:

ports:
  - "${DB_PORT}:5432"

この意味は:

  • 左辺(${DB_PORT}): ホスト側(自分のMac)のポート
  • 右辺(5432): コンテナ内のPostgreSQLポート

つまり、.envでDB_PORT=5432と設定すれば、localhost:5432でPostgreSQLにアクセスできるということです。

実際の使用感

# PostgreSQL環境を起動
docker-compose up postgres -d

# 停止
docker-compose down

# データも含めて完全削除
docker-compose down -v

これだけで、チーム全員が同じPostgreSQL環境を使えるようになりました。開発環境の構築が劇的に楽になったと実感しています。

学びのポイント

Docker Composeは、開発環境構築の煩わしさを解決してくれる強力なツールです。特に、データベースなどの外部サービスを簡単に起動できる点が非常に便利でした。今後は、よくお話に聞くRedisやElasticsearchなど、他のサービスも同様に管理できそうです。


学んだこと4: マイグレーションによるスキーマ管理

マイグレーションとは何か?

マイグレーションとは、データベースのスキーマ(テーブル構造)を管理する仕組みです。アプリケーションの開発が進むにつれて、テーブルの追加や変更が必要になりますが、これらの変更を安全に適用するためにマイグレーションが使われます。

GORMのAutoMigrate機能

GORMにはAutoMigrateという便利な機能があります:

// database/connection.go
func Migrate() error {
    log.Println("🔄 データベースマイグレーション開始")
    
    // Todo構造体からテーブルを自動生成
    err := DB.AutoMigrate(&models.Todo{})
    if err != nil {
        return fmt.Errorf("マイグレーションエラー: %w", err)
    }
    
    log.Println("✅ データベースマイグレーション完了")
    return nil
}

この機能の素晴らしい点:

  1. 構造体ベース: Go の構造体定義からテーブルを自動生成
  2. 安全性: 既存データを保持しながらスキーマを更新
  3. 簡単さ: 複雑なSQLを書く必要がない

実際に生成されるテーブル

Todo構造体から、以下のようなテーブルが自動生成されます:

CREATE TABLE todos (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    completed BOOLEAN DEFAULT false,
    created_at TIMESTAMP WITHOUT TIME ZONE,
    updated_at TIMESTAMP WITHOUT TIME ZONE
);

マイグレーションの動作確認

実際にマイグレーションが動作することを確認してみました:

# PostgreSQLコンテナに接続
docker exec -it todo-postgres psql -U todouser -d todoapp

# テーブル一覧を確認
\dt

# todosテーブルの詳細構造を確認
\d todos

結果:

                Table "public.todos"
   Column   |            Type             | Nullable |              Default
------------+-----------------------------+----------+------------------------------------
 id         | bigint                      | not null | nextval('todos_id_seq'::regclass)
 title      | character varying(200)      | not null |
 completed  | boolean                     |          | false
 created_at | timestamp without time zone |          |
 updated_at | timestamp without time zone |          |

構造体の定義通りにテーブルが作成されていることが確認できました。

学びのポイント

AutoMigrateにより、データベーススキーマの管理が非常に簡単になりました。特に、開発初期段階では頻繁にスキーマが変更されるため、手動でテーブルを作成・変更するよりもはるかに効率的です。ただし、本番環境では慎重な運用が必要だということも学びました。


実装で躓いたポイントと解決策

1. .envとdocker-composeの設定不一致

最初に遭遇したエラーです:

hostname resolving error: lookup my-postgres-server: no such host

原因

.envファイルとdocker-compose.ymlの設定が一致していませんでした:

# .env(間違った設定)
DB_HOST=my-postgres-server  # ← 存在しないホスト名

# docker-compose.yml
container_name: todo-postgres  # ← 実際のコンテナ名

解決策

設定を統一することで解決しました:

# .env(修正後)
DB_HOST=localhost  # ← ホスト側からアクセスする場合

学び

Docker環境では、ネットワークの概念が重要だということを学びました。コンテナ同士の通信とホスト-コンテナ間の通信では、設定が異なる場合があります。

2. package文の書き忘れエラー

これは初歩的なミスですが、よく遭遇しました:

// ❌ config/config.go
import (
    "os"
    "github.com/joho/godotenv"
)
// package文が抜けている

// ✅ 正しい書き方
package config

import (
    "os"
    "github.com/joho/godotenv"
)

エラーメッセージ

package main
        ↑ ここに赤線が表示される

学び

Goでは、ファイルの最初に必ずpackage文が必要です。これを忘れると、importエラーが発生します。IDEの設定で自動補完できるようにしておくと良いでしょう。

3. godotenvライブラリのimportエラー

"github.com/joho/godotenv" 
↑ ここに赤線が表示される

原因

パッケージがダウンロードされていない状態でimportしようとしたため。

解決手順

# Go modulesの初期化(まだの場合)
go mod init go-todo-app

# 必要なパッケージをダウンロード
go get github.com/joho/godotenv
go get gorm.io/gorm
go get gorm.io/driver/postgres

# 依存関係を整理
go mod tidy

学び

Go modulesの管理は、現代のGo開発では必須スキルです。go getでパッケージを追加し、go mod tidyで整理するという流れを覚えました。


実際に動かしてみた結果

データベース接続テストの実行

以下のテストコードで動作確認を行いました:

func main() {
    log.Println("=== データベース接続テスト ===")
    
    // 1. 設定読み込み
    cfg := config.LoadConfig()
    log.Printf("設定読み込み完了: DB=%s@%s:%s/%s",
        cfg.DBUser, cfg.DBHost, cfg.DBPort, cfg.DBName)
    
    // 2. データベース接続
    if err := database.Connect(cfg); err != nil {
        log.Fatalf("データベース接続失敗: %v", err)
    }
    
    // 3. マイグレーション実行
    if err := database.Migrate(); err != nil {
        log.Fatalf("マイグレーション失敗: %v", err)
    }
    
    log.Println("全ての処理が成功しました!")
}

実行結果

2025/07/22 08:24:34 === データベース接続テスト ===
2025/07/22 08:24:34 設定読み込み完了: DB=todouser@localhost:5432/todoapp
2025/07/22 08:24:34 🐘 データベース接続試行: host=localhost port=5432 user=todouser password=password dbname=todoapp sslmode=disable
2025/07/22 08:24:34 データベース接続成功
2025/07/22 08:24:34 データベースマイグレーション開始
2025/07/22 08:24:34 データベースマイグレーション完了
2025/07/22 08:24:34 全ての処理が成功しました!

全ての処理が正常に動作することを確認できました。

PostgreSQLでの確認

実際にデータベースに接続してテーブルが作成されていることも確認しました:

# PostgreSQLに直接接続
docker exec -it todo-postgres psql -U todouser -d todoapp

# テーブル一覧確認
\dt
              List of relations
 Schema | Name  | Type  |  Owner
--------+-------+-------+----------
 public | todos | table | todouser

# テーブル構造確認
\d todos
                Table "public.todos"
   Column   |            Type             | Nullable |              Default
------------+-----------------------------+----------+------------------------------------
 id         | bigint                      | not null | nextval('todos_id_seq'::regclass)
 title      | character varying(200)      | not null |
 completed  | boolean                     |          | false
 created_at | timestamp without time zone |          |
 updated_at | timestamp without time zone |          |

構造体の定義通りにテーブルが作成されていることを確認できました。


Go + PostgreSQLの組み合わせで感じたメリット

1. 開発体験の良さ

シンプルな設定管理

Goの構造体ベースの設定管理は非常に直感的でした。型安全性も確保されており、コンパイル時にエラーを検出できるのは大きなメリットです。

GORMの使いやすさ

ORMを使うことで、SQLを書く頻度が大幅に減りました。特に、構造体のタグでデータベース制約を定義できる点が気に入っています。

Docker Composeによる環境構築

PostgreSQL環境を一瞬で構築できるのは、開発効率の向上に大きく貢献しています。

2. 学習コストの低さ

理解しやすい構造

Goの構造体ベースのアプローチは、初心者にとって理解しやすいと感じました。オブジェクト指向に慣れている人なら、すぐに概念を理解できると思います。

エラーメッセージの分かりやすさ

Goのエラーメッセージは比較的分かりやすく、問題の特定が容易でした。

3. パフォーマンスの期待

前回の記事で確認したGoの高いパフォーマンスが、データベース接続時にも発揮されることを期待しています。特に、コンパイル済みバイナリの軽量性は、本番環境での運用において大きなアドバンテージになりそうです。


今後の課題と次のステップ

直近の実装予定

  1. WebUIからのデータベース操作: 現在のテストコードを実際のWebアプリケーションに統合
  2. CRUD操作の完全実装: Create、Read、Update、Deleteの全操作をGORMで実装
  3. エラーハンドリングの強化: より堅牢なエラー処理の実装

中期的な学習目標

  1. テストの追加: 単体テスト・統合テストの実装
  2. API化: JSON APIエンドポイントの提供
  3. 認証機能: JWT認証の実装
  4. ログ改善: 構造化ログの導入

長期的な展望

  1. 監視: Prometheus + Grafanaによるメトリクス収集
  2. CI/CD: GitHub Actionsによる自動化
  3. 本番運用: 実際のクラウド環境へのデプロイ

まとめ

今回のPostgreSQL化を通じて、Webアプリケーション開発に必要な基本的な要素を一通り学ぶことができました。

特に価値を感じた学び

  1. 設定管理の重要性: 環境変数による設定の外部化は、今後どんなアプリケーションを作る際も必須のスキル
  2. ORMの威力: GORMにより、データベース操作が劇的にシンプルになった
  3. Docker Composeの便利さ: 開発環境構築の煩わしさから解放された
  4. Go言語の学習しやすさ: シンプルな言語設計により、初心者でも比較的容易に理解できた

実装してみての率直な感想

シンプルなGoを体感できて素直に面白かったです。
GORMのAutoMigrate機能により、面倒なテーブル作成作業から解放されたのは大きな収穫です。

また、Docker Composeを使うことで、PostgreSQL環境の構築が一瞬でできるようになったのも印象的でした。これまで「環境構築が面倒」と感じていた作業が、設定ファイル一つで完了するのは革命的だと思います。(今更感)

次への意気込み

PostgreSQL化により、アプリケーションがより実用的になりました。データが永続化されることで、本格的なWebアプリケーションに一歩近づいたと実感しています。

次回は、実際にWebUIからデータベース操作ができるよう、CRUD操作を完全実装していきたいと思います。また、テストやログ機能など、プロダクション品質のアプリケーションに必要な要素も順次学んでいく予定です。

同じように学習中の方へ

Go言語でのWebアプリケーション開発は、思っているより敷居が低いと感じました。特に、GORMやDocker Composeなどのツールを活用することで、初心者でも本格的なアプリケーションを構築できます。

この記事が、同じようにGo学習中の方や、データベース接続に挑戦しようとしている方の参考になれば幸いです。


参考資料

読んでくださってありがとうございました!質問やフィードバックがあれば、コメントでお気軽にお声がけください 🙌

Discussion