RESTからgRPC(connect-go)に移行した話

より良い開発者体験を求めて、シンプルなRESTエンドポイント実装からconnectへ移行しました。開発初期からの変遷と、なぜ以降に至ったのか、移行で何が変わったのかについてまとめました。

はじめに

みなさんは、フロントエンドとバックエンドの型の共有に悩んだことはありませんか?
Cosmemoでは、規模が大きくなるにつれ手書きでの型共有に限界を感じるケースが増えてきていました。

そこで、より型安全・開発者体験の向上を目指して、protobufとconnectを導入しました。 開発初期はシンプルなRESTエンドポイントからスタートし、どのような課題があって、なぜprotobuf(connect)を導入したのかについて書こうと思います。

初期構成

最初はシンプルな構成でのスタートです。

規模が小さいうちはこれで十分でした。フロントが欲しいデータとDBスキーマをすり合わせながら、エンドポイントを逐次作成して対応、という感じです。

つらいポイントの発生

初期構成でも開発を進めることは可能でしたが、機能の増加に伴い、だんだんとつらいポイントが出てきました。

FEとBEの型が同期していない

バックエンドのレスポンス構造を変えても、フロントエンド側の型定義に反映されません。
後述のDTO手書き問題も重なり、レスポンス構造の変更でミスが発生しやすい状態になっていました。

DTOが手書き

バックエンドのJSONレスポンスに対応するインターフェースを毎回手で書いていました。

バックエンドはjsonアノテーションが必要で、

// DTOが手書き!json用のアノテーションが必要!
type DailyLogResponse struct {
	Date      time.Time `json:"date"`
	Memo      *string   `json:"memo"`
	SkinScore *float64  `json:"skin_score"`
  // ..
}

フロントエンドはレスポンスに応じて手書きでDTOを作成していました。

export type DailyLogDTO = {
    date: string;
    memo: string | null;
    skin_score: number | null;
    // レスポンスに変更があったら修正・・・
}

型アサーションで as DailyLogDTO と無理やりキャストしているだけで、実行時の型は保証されません。
手書き = タイポ祭りなので、ミスに気付かずデータが取れないと右往左往するケースも発生しがちでした。 個人的には、これが一番しんどいポイントでした・・・。

エンドポイント設計のセンスがない

RESTのエンドポイント設計は、設計者のセンスか、チーム内での規約が必要な気がします。
個人開発なので、先輩エンジニアに相談することもできず、何とか頭をひねって作り出す必要がありました。

GET "/daily-logs/month" ← 1か月分固定?クエリパラメータとか使ったほうが柔軟?
GET "/daily-logs/stats/month" ← ネストが深くなっていく・・・
GET "daily-logs/status/year" ← 単一の責務を表現しようと思ったらエンドポイントが長く、多くなる

「エンドポイントは単一リソースの操作を表す」という原則は分かっていても、実際に設計するとブレが出ますし、なんとなく違和感があっても経験不足で言語化が難しいケースが多々ありました。

fetchコードの量が増える

フロントエンドでは、エンドポイントが増えるたびにfetch関数を手書きする必要があります。

const fetchDailyLog = async (id: number): Promise<DailyLogResponse> => {
  const res = await fetch(`/api/daily-logs/${id}`);
  if (!res.ok) throw new Error("failed");
  return res.json() as DailyLogResponse; // 型安全性なし
};

このボイラープレートをエンドポイントごとに書き続けるのはしんどいですし、前述のとおり型アサーションのみで実質型安全性がない状況というのもあまりうれしくありません。

ツールの導入を検討する

ここで気になってくるのが、スキーマから型定義やクライアントを自動生成できるようなツールたちです。
つらいポイントの解消のため、大きく2軸で検討しました。

  1. OpenAPI
    既存のインフラをそのまま活用できるのが、OpenAPIです。 RESTエンドポイントをOpenAPIに沿って事前にyamlファイルで定義、そこからコードを生成することができます。
    しかし、エンドポイント設計からは逃げられません。明確なガイドラインやレビューのない環境だと、結局センスのないエンドポイントを量産する可能性がありそうです。

  2. gRPC(protobuf)
    もう一つの候補が、gRPC/protobufです。
    名前の通りRemote Procedure Callで、.protoファイルで定義した処理を、まるで手元の関数のように呼べる・・・そういうイメージのやつです。

比較としては、以下のようになると思います(主観です)。

OpenAPIgRPC (protobuf)
スキーマ定義YAML/JSON.proto ファイル
型生成openapi-generator などprotoc / buf
HTTP互換◎ そのままREST△ HTTP/2、ブラウザ直接は難しい
型安全性
学習コスト
個人開発での採用難易度

個人開発においてprotobufはオーバーエンジニアリングか?答えはYES、だと思います。
RESTで回っているところに、gRPCというプロトコルが変わる選択肢をねじ込むのは、よっぽど理由がないと厳しいかな、と個人的には感じていました。

ただ、心の中のエンジニアリングマネージャーがgoサインを出しました。それなら仕方ない。
新しい技術に触れること自体が勉強になりますし、なにより好奇心が満たされるのでモチベーションも上がります。

そんな経緯で、gRPC(protobuf)を導入することにしました。

gRPCはwebアプリでは使えない?connectを使えばいいじゃない

gRPCはHTTP/2ベースのバイナリプロトコルで、ブラウザから直接呼び出すことができません。grpc-webというブラウザ対応プロトコルもありますが、サーバー側でEnvoyのようなプロキシを挟む必要があり、設定が複雑そうでした。
ですが、自分が悩むということは先人たちも悩んできたということで、そこを解消してくれるツールももちろん存在します。
それが、connect-go です。

厳密にはconnectはgRPCとは別のプロトコル(Connect Protocol)です。ただし、gRPCサーバーとの互換性もあり、protobufのエコシステムをそのまま活かせます。
「protobufの型安全性とコード生成」を、「RESTの手軽さ」で使える、そんないいとこ取りで素敵なツールです。

移行の手順

Cosmemoで、RESTからconnectに移行した手順をざっくりまとめます。

1. protoファイルを設計する

まず .proto ファイルでAPIのスキーマを定義します。

syntax = "proto3";

package cosmemo.v1;

service DailyLogService {
  rpc GetDailyLog(GetDailyLogRequest) returns (GetDailyLogResponse);
  rpc CreateDailyLog(CreateDailyLogRequest) returns (CreateDailyLogResponse);
}

message GetDailyLogRequest {
  int32 id = 1;
}

message GetDailyLogResponse {
  DailyLog daily_log = 1;
}

エンドポイントの設計をURLではなく「サービスとメソッド」として考えるので、迷いが減りました。
関数を定義するイメージで(RPCなので)作成することができます。慣れもあるからか、関数だと責務を意識した分離がしやすいですよね。

2. bufでコードを生成する

buf を使って、GoとTypeScriptのコードを自動生成します。

# buf.gen.yaml
version: v2
plugins:
  # Go struct 生成
  - remote: buf.build/protocolbuffers/go
    out: ../be/gen
    opt:
      - paths=source_relative

  # Connect-Go サービス/クライアント生成
  - remote: buf.build/connectrpc/go
    out: ../be/gen
    opt:
      - paths=source_relative

  # TypeScript protobuf メッセージ型生成
  - remote: buf.build/bufbuild/es
    out: ../fe/src/gen
    opt:
      - target=ts

buf generate 一発で、GoのハンドラインターフェースとTypeScriptの型が生成されます。
GoはconnectrpcのGoプラグイン、TypeScriptはbufbuild/esでメッセージ型を生成し、フロントエンドのクライアント呼び出しには @connectrpc/connect-web を使う構成です。
自動生成、楽すぎます。一度体験すると戻れないと思います。

3. Goのハンドラを実装する

生成されたインターフェースを実装するだけです。既存のusecase/repositoryはそのまま再利用できます。
protobufを導入するような開発では、ある程度レイヤーを意識した構成になっていることが多いと思います。
その場合は、シンプルにハンドラーを移し替えるだけ、くらいのイメージで進めることができました。

func (h *DailyLogHandler) GetDailyLog(
  ctx context.Context,
  req *connect.Request[v1.GetDailyLogRequest],
) (*connect.Response[v1.GetDailyLogResponse], error) {
  // usecase層は既存のものをそのまま使う
  log, err := h.usecase.GetByID(ctx, int(req.Msg.Id))
  // ...
}

4. フロントエンドから呼び出す

生成されたクライアントをそのまま使います。
これがとても楽です。自前でfetch関数を書いて、res.json()を読んで・・・という一連の処理をスキップしつつ、型安全性を享受することができます。

import { createClient } from "@connectrpc/connect";
import { DailyLogService } from "./gen/ts/cosmemo/v1/daily_log_connect";

const client = createClient(DailyLogService, transport);

// 型付きで呼び出せる!
const res = await client.getDailyLog({ id: 1 });
console.log(res.dailyLog); // 型安全!

これは、とても感動体験でした。

結果

フロントエンドのDX が上がった

手書きDTOが消えました。クライアントも自動生成されるので、「fetchして、キャストして・・・」というボイラープレートを書く必要がなくなりました。
さらに、バックエンドのスキーマ変更がフロントエンドに即座に反映されるため、型の不整合に気づかないまま実行時エラーになる、という状況がなくなりました。

バックエンドは段階的に移行できた

既存のEchoエンドポイントを残したまま、connectのエンドポイントを並走させることができます。

// echo
e := echo.New()

// echo ハンドラ
cosmeHandler := handler.NewCosmeHandler(cosmeRepo, s3)
// echo パス
e.GET("/cosmes", cosmeHandler.GetCosmes)
e.POST("/cosmes", cosmeHandler.CreateCosme)

// connect ハンドラ
dailyLogConnectHandler := connecthandler.NewDailyLogConnectHandler(dailyLogUsecase)
// connect パス
dailyLogPath, dailyLogH := cosmemov1connect.NewDailyLogServiceHandler(
  dailyLogConnectHandler,
  connect.WithInterceptors(authInterceptor),
)

// 共存!
mux := http.NewServeMux()
mux.Handle(dailyLogPath, dailyLogH)
mux.Handle("/", e)

エンドポイントを少しずつ移行、動作確認しながら進められました。

追記(2026-05-11)

ログ周りをmiddlewareで仕込みたいなあ、と思ったとき、
上記の構成だと、echo Middlewareとinterceptorで同様の処理をはさまないとだめでした。

なので、Connect handlerもechoに登録する方法に移行しました。

	dailyLogPath, dailyLogH := cosmemov1connect.NewDailyLogServiceHandler(dailyLogConnectHandler)
	e.Any(dailyLogPath+"*", echo.WrapHandler(dailyLogH), authMW)

e.Any(Path+"*")でConnectの生成したパスをワイルドカードで指定しています。
Connectはトレーリングスラッシュのエンドポイント生成しますが、echoだと完全一致で処理されてしまいます。なので、/cosmemo.v1.DailyLogService/GetMonthlyLogsみたいなパスは404でエラーになります。その回避のため、+"*"でサービス名をすべて処理できるようにしています。

また、handler自体はecho.WrapHandlerでラップするだけでOKです。とても面白いなと思ったんですが、http.Handlerを実装しているので、こんな芸当ができるみたいです。
最小限の振る舞いだけを抽象化すると、想定外の組み合わせにも耐えられるというのは、抽象化の本来あるべき姿を示している気がします。

これでログの追加への対応もしやすくなりました。

レイヤードアーキテクチャの恩恵を改めて感じた

移行してみて気づいたのですが、repository層とusecase層は変更不要でした。変更範囲がハンドラ周りだけに閉じていたのは、レイヤードアーキテクチャ設計の恩恵です。

「トランスポート層の変更がドメインロジックに影響しない」というのは教科書的な話ですが、実際に移行を経験すると、その意味を肌で感じた経験になりました。
図らずも、レイヤードアーキテクチャのメリットを実感する機会になりました。

まとめ

個人開発にprotobuf+connectは確かにオーバーエンジニアリングかもしれません。ただ、得られたものは大きかったです。

新しい技術を個人プロダクトで試してみるのは、モチベーション的にも良い学習方法だと思います。

皆さんもぜひ、protobufとconnectで型安全な開発を試してみてください。
それでは。

ブログ一覧へ戻る