Go で JSON と構造体の変換を効率化する:パフォーマンスと設計のコツ
JSON と構造体の変換は Go の Web 開発で最も頻繁に行われる処理の一つです。小さな API では気にならなくても、高トラフィックなサービスでは設計次第で大きな差が出ます。パフォーマンスと保守性を両立するための実践的なパターンをまとめます。
入出力で構造体を分ける
リクエスト用とレスポンス用で構造体を分けるのは、もっとも効果の高い設計判断です。
// リクエスト用(受け取るフィールドだけ定義)
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
// レスポンス用(返すフィールドだけ定義)
type UserResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}1 つの構造体を使い回すと、リクエストでは不要な ID や CreatedAt が混在し、omitempty だらけの見通しの悪いコードになります。構造体を分ければフィールドの意図が明確になり、バリデーションも書きやすくなるでしょう。
コード量は少ないが、不要なフィールドが混在する。omitempty が増えて意図が不明確になりがち。
多少の重複はあるが、各構造体の責務が明確。バリデーション・ドキュメント・テストがすべて楽になる。
json:“-” で除外する
構造体のフィールドを JSON 変換の対象から完全に外すには json:"-" を使います。
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
PasswordHash string `json:"-"`
}PasswordHash は Marshal 時に出力されず、Unmarshal 時にも無視されます。パスワードハッシュのような機密情報がレスポンスに紛れ込むのを防ぐ安全策です。内部処理用の計算済みフィールドやキャッシュ用フィールドも - で除外しておくと、変換対象が減る分だけパフォーマンスも改善します。
構造体の埋め込みで共通フィールドを再利用する
タイムスタンプやメタデータのような共通フィールドは、埋め込み構造体で一箇所にまとめられます。
type Timestamps struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Timestamps
}
type Article struct {
ID int `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Timestamps
}埋め込みフィールドは JSON 出力時にフラットに展開されます。ネストにはなりません。
// User の JSON 出力
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"created_at": "2025-01-15T09:00:00Z",
"updated_at": "2025-01-20T14:30:00Z"
}同じフィールド定義を複数の構造体に書く必要がなくなり、タグの書き間違いも防げます。
Encoder / Decoder を使う
HTTP ハンドラでの JSON 処理では、Marshal + Write より Encoder を使うほうが効率的です。
// 中間バッファが生まれる
func handler1(w http.ResponseWriter, r *http.Request) {
data, _ := json.Marshal(user)
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
// Writer に直接書き出す
func handler2(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}Marshal はまず []byte を生成し、それを Write で書き込むため、メモリ確保が 2 回発生します。Encoder は io.Writer に直接書き出すので、中間の []byte が不要です。リクエストの読み取りも同様に、io.ReadAll + Unmarshal より Decoder + Decode のほうがメモリに優しいです。
ヘルパー関数を作る
JSON レスポンスの書き出しは多くのハンドラで繰り返されるため、ヘルパーにまとめておくとコードがすっきりします。
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, message string, status int) {
writeJSON(w, status, map[string]string{"error": message})
}func getUser(w http.ResponseWriter, r *http.Request) {
user, err := findUser(r.Context(), userID)
if err != nil {
writeError(w, "ユーザーが見つかりません", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, user)
}Content-Type の設定忘れやステータスコードの不一致を防げます。全ハンドラで一貫したレスポンス形式になるため、クライアント側の実装も楽になります。
ベンチマークで計測する
最適化の前にまず計測が重要です。Go の標準テスト機能で簡単にベンチマークを取れます。
func BenchmarkMarshal(b *testing.B) {
user := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
for i := 0; i < b.N; i++ {
json.Marshal(user)
}
}
func BenchmarkEncoder(b *testing.B) {
user := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
for i := 0; i < b.N; i++ {
json.NewEncoder(io.Discard).Encode(user)
}
}go test -bench=. -benchmem-benchmem を付けるとアロケーション回数とメモリ使用量も表示されます。JSON 変換の最適化では、処理速度よりもアロケーション回数を減らすことがもっとも効果的なアプローチです。
高速ライブラリの選択肢
標準の encoding/json はリフレクションベースのため、構造的に速度の限界があります。パフォーマンスが本当に必要な場面ではサードパーティライブラリも選択肢に入ります。
| encoding/json | 標準ライブラリ。互換性が最も高い |
| go-json | 標準互換で高速。ドロップイン置換可能 |
| sonic | ByteDance 製。JIT を使い最速クラス |
| json-iterator | 標準互換 API。設定で挙動を変えられる |
ただし、サードパーティライブラリには標準との微妙な挙動差がある場合もあります。ベンチマークで効果を確認し、テストが通ることを検証してから採用するのが賢明です。
多くのプロジェクトでは、構造体設計の改善と Encoder / Decoder の活用だけで十分な性能が得られます。ライブラリの差し替えは、プロファイリングで JSON 変換がボトルネックだと判明してからでも遅くありません。











