Go言語でのエラーハンドリングにおいて、「ユーザーに詳細を見せない」かつ「開発者には詳細を記録する」という切り分けは、セキュリティと保守性の両面で非常に重要です。
攻撃者にスタックトレースやDB構造を教えないための、クリーンな実装パターンを見ていきましょう。
1. 基本的な考え方:情報の分離
Webサーバーにおいて、エラー情報は以下の2つのルートに分ける必要があります。
- HTTPレスポンス (External): ユーザー向け。不透明で抽象的なメッセージ(例: "Internal Server Error")と、追跡用のリクエストIDのみを返します。
- アプリケーションログ (Internal): 開発者向け。スタックトレース、エラーの根本原因(ラップされたエラー)、実行時のコンテキスト(User IDなど)を詳細に記録します。
2. Goでの実装パターン
カスタムエラー構造体の定義
まず、ユーザーに返すための「安全なメッセージ」と、ログ用の「詳細なエラー」を保持する仕組みを作ります。
type AppError struct {
Err error // 内部デバッグ用(ログに出力)
Message string // ユーザー用メッセージ(レスポンスとして返却)
Code int // HTTPステータスコード
}
func (e *AppError) Error() string {
return e.Err.Error()
}
ミドルウェアでの一括処理
各ハンドラーでログ出力を書くのではなく、ミドルウェアで一括管理するのがクリーンです。
func ErrorHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// コンテキストにリクエストIDを付与(追跡用)
requestID := uuid.New().String()
// 実際の処理
// (ここでは簡略化のため、ハンドラーからのエラーをどう受け取るかの概念のみ)
// エラーが発生した場合の処理例:
if err != nil {
// 1. ログには詳細を記録(リクエストIDを紐付ける)
log.Printf("[ID:%s] ERROR: %v", requestID, err)
// 2. ユーザーには安全なメッセージとIDのみを返す
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error": "予期せぬエラーが発生しました", "request_id": "%s"}`, requestID)
}
})
}
3. 実務で使えるTips
エラーのラッピング (fmt.Errorf)
Go 1.13以降、%w を使ってエラーをラップすることで、ログにはコールスタックのような文脈を持たせつつ、プログラム内部でエラーの判定が可能になります。
// 悪い例:情報を捨てている
if err != nil {
return errors.New("database error")
}
// 良い例:文脈を追加してラップする
if err != nil {
return fmt.Errorf("failed to update user (ID: %d): %w", userID, err)
}
構造化ログの活用 (slog)
標準ライブラリの log/slog を使うと、JSON形式でログを出力でき、後からCloudWatch LogsやDatadogなどで検索しやすくなります。
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Error("database connection failed",
"err", err,
"request_id", reqID,
"user_id", userID,
)
4. チェックリスト
-
本番環境で
debug=trueになっていないか?(フレームワークのデフォルト設定に注意) - SQL文やスタックトレースがレスポンスに含まれていないか?
- ログにパスワードやクレカ番号などの個人情報(PII)が含まれていないか?
-
ログとレスポンスを結びつける
Request-IDを発行しているか?
「このエラーが発生した際、ユーザーにはどう見え、ログにはどう残るべきか」を常に意識することが、プロフェッショナルなバックエンド開発への第一歩ですね。
Goの標準ライブラリ log/slog を活用し、「内部エラーの詳細はログへ、ユーザーにはリクエストIDのみを返す」 という実践的なミドルウェアの実装例を紹介します。
この構成にすることで、ユーザーから問い合わせがあった際に「リクエストID」を教えてもらうだけで、開発者はログから即座に原因(スタックトレースやSQLエラーなど)を特定できるようになります。
実践的なエラーハンドリングの実装
1. エラー情報の分離(構造体とインターフェース)
まず、ハンドラーから「ユーザー向けメッセージ」を吸い上げるための仕組みを作ります。
package main
import (
"errors"
"log/slog"
"net/http"
"os"
"github.com/google/uuid"
)
// APIError はユーザーに返す情報を持つカスタムエラー
type APIError struct {
StatusCode int
Message string // ユーザー向け
Internal error // 開発者向け(ログ用)
}
func (e *APIError) Error() string {
return e.Internal.Error()
}
2. slog を使ったミドルウェアの実装
ここで、レスポンスの書き込みとログの出力を一括管理します。
func ErrorMiddleware(next func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
// 構造化ロガーの設定(JSON形式)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
return func(w http.ResponseWriter, r *http.Request) {
// 追跡用IDの発行
traceID := uuid.New().String()
// ハンドラーを実行し、エラーを受け取る
err := next(w, r)
if err == nil {
return
}
var apiErr *APIError
if errors.As(err, &apiErr) {
// 明示的なAPIエラーの場合
logger.Error("Application Error",
"trace_id", traceID,
"status", apiErr.StatusCode,
"error", apiErr.Internal.Error(), // 詳細をログへ
"path", r.URL.Path,
)
w.WriteHeader(apiErr.StatusCode)
// ユーザーには安全なメッセージとIDを返す
fmt.Fprintf(w, `{"error": "%s", "trace_id": "%s"}`, apiErr.Message, traceID)
} else {
// 予期せぬエラー(パニックや未知のエラー)
logger.Error("Unexpected System Error",
"trace_id", traceID,
"error", err.Error(),
)
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error": "予期せぬエラーが発生しました", "trace_id": "%s"}`, traceID)
}
}
}
3. ハンドラーでの利用例
ハンドラー内では、具体的なエラー(DBエラーなど)を Internal に隠蔽します。
func GetUserHandler(w http.ResponseWriter, r *http.Request) error {
// 例えばDBでエラーが発生したとする
dbErr := errors.New("sql: relation 'users' does not exist")
// ユーザーには「取得失敗」だけ伝え、ログには dbErr を残す
return &APIError{
StatusCode: http.StatusNotFound,
Message: "ユーザーが見つかりませんでした",
Internal: dbErr,
}
}
この実装のメリット
-
セキュリティ:
relation 'users' does not existのようなテーブル構造が漏洩する情報を、ユーザーは一切見ることができません。 -
追跡可能性 (Observability): ユーザーに表示された
trace_idをログマネージャー(CloudWatch, Datadog等)で検索するだけで、一発で該当のエラーログに辿り着けます。 - 一貫性: JSON形式でログを吐くため、後から解析ツールを通しやすくなります。
また、パニック(Panic)が発生した際にも同様に安全なメッセージを返す「リカバリ・ミドルウェア」 の実装についても臨機応変に対応が必要な場合もあるでしょう。