Backend

gRPC-Gateway로 단일 API 듀얼 프로토콜 지원

2025-12-307 min read

gRPC-Gateway로 단일 API 듀얼 프로토콜 지원

개요

gRPC-Gateway는 gRPC 서비스에 RESTful HTTP/JSON 인터페이스를 자동으로 추가해주는 리버스 프록시입니다. 하나의 Proto 정의로 두 프로토콜을 모두 지원할 수 있습니다.

왜 듀얼 프로토콜인가?

장점

프로토콜사용 시나리오
gRPC마이크로서비스 간 통신, 고성능 필요, 스트리밍
HTTP/JSON웹 브라우저, 외부 API 클라이언트, 디버깅

단일 코드베이스로 두 니즈를 모두 충족할 수 있습니다.

단점

특성설명
추가 레이어HTTP → gRPC 변환 오버헤드
기능 제한HTTP에서 gRPC 스트리밍 일부 지원 제한
복잡성두 프로토콜의 에러 처리 매핑 필요

아키텍처

Proto 정의

HTTP 어노테이션 추가

syntax = "proto3";

package v1beta;

import "google/api/annotations.proto";
import "google/protobuf/struct.proto";

service DocumentService {
  // POST /v1beta/collections/{collection}/documents
  rpc CreateDocument(CreateDocumentRequest) returns (CreateDocumentResponse) {
    option (google.api.http) = {
      post: "/v1beta/collections/{collection}/documents"
      body: "*"
    };
  }
  
  // GET /v1beta/collections/{collection}/documents/{uri}
  rpc GetDocument(GetDocumentRequest) returns (GetDocumentResponse) {
    option (google.api.http) = {
      get: "/v1beta/collections/{collection}/documents/{uri}"
    };
  }
  
  // PATCH /v1beta/collections/{collection}/documents/{uri}
  rpc UpdateDocument(UpdateDocumentRequest) returns (UpdateDocumentResponse) {
    option (google.api.http) = {
      patch: "/v1beta/collections/{collection}/documents/{uri}"
      body: "*"
    };
  }
  
  // DELETE /v1beta/collections/{collection}/documents/{uri}
  rpc DeleteDocument(DeleteDocumentRequest) returns (DeleteDocumentResponse) {
    option (google.api.http) = {
      delete: "/v1beta/collections/{collection}/documents/{uri}"
    };
  }
  
  // POST로 쿼리 (URL 길이 제한 회피)
  // POST /v1beta/collections/{collection}/documents:query
  rpc QueryDocuments(QueryDocumentsRequest) returns (QueryDocumentsResponse) {
    option (google.api.http) = {
      post: "/v1beta/collections/{collection}/documents:query"
      body: "*"
    };
  }
  
  // 배치 작업
  // POST /v1beta/collections/{collection}/documents:batchCreate
  rpc BatchCreateDocuments(BatchCreateRequest) returns (BatchCreateResponse) {
    option (google.api.http) = {
      post: "/v1beta/collections/{collection}/documents:batchCreate"
      body: "*"
    };
  }
}

message CreateDocumentRequest {
  // URL 경로 파라미터로 추출됨
  string collection = 1;
  DocumentInput document = 2;
}

message GetDocumentRequest {
  string collection = 1;
  string uri = 2;
  // 쿼리 파라미터: ?version=3
  optional int32 version = 3;
  // 쿼리 파라미터: ?skip_validation=true
  optional bool skip_validation = 4;
}

// ... 나머지 메시지 정의

URL 매핑 규칙

gRPC 필드 위치HTTP 위치
{field} in pathURL 경로 파라미터
body: "*"나머지 필드는 JSON body
body: "field"특정 필드만 body
그 외 필드쿼리 파라미터

코드 생성 설정

buf.gen.yaml

version: v2
plugins:
  # gRPC 서버/클라이언트
  - remote: buf.build/grpc/go:v1.5.1
    out: generated/go/proto
    opt: paths=source_relative
  
  # gRPC-Gateway 핸들러
  - remote: buf.build/grpc-ecosystem/gateway:v2.25.1
    out: generated/go/proto
    opt:
      - paths=source_relative
      - standalone=true       # 독립 파일로 생성
      - generate_unbound_methods=true

생성 실행

buf generate

생성된 파일:

  • api_grpc.pb.go: gRPC 서버/클라이언트
  • api.pb.gw.go: HTTP Gateway 핸들러

서버 구현

통합 서버

package main

import (
    "context"
    "net"
    "net/http"
    "sync"
    
    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    
    pb "github.com/myorg/myservice/generated/go/proto/v1beta"
)

type Server struct {
    grpcServer *grpc.Server
    httpServer *http.Server
    
    grpcPort string
    httpPort string
}

func NewServer(service DocumentService) *Server {
    // gRPC 서버 설정
    grpcServer := grpc.NewServer(
        grpc.UnaryInterceptor(loggingInterceptor),
    )
    pb.RegisterDocumentServiceServer(grpcServer, &documentHandler{service})
    
    return &Server{
        grpcServer: grpcServer,
        grpcPort:   ":9090",
        httpPort:   ":8080",
    }
}

func (s *Server) Start(ctx context.Context) error {
    var wg sync.WaitGroup
    errCh := make(chan error, 2)
    
    // 1. gRPC 서버 시작
    wg.Add(1)
    go func() {
        defer wg.Done()
        lis, err := net.Listen("tcp", s.grpcPort)
        if err != nil {
            errCh <- err
            return
        }
        if err := s.grpcServer.Serve(lis); err != nil {
            errCh <- err
        }
    }()
    
    // 2. HTTP Gateway 시작
    wg.Add(1)
    go func() {
        defer wg.Done()
        if err := s.startHTTPGateway(ctx); err != nil {
            errCh <- err
        }
    }()
    
    select {
    case err := <-errCh:
        return err
    case <-ctx.Done():
        s.Shutdown()
        return ctx.Err()
    }
}

func (s *Server) startHTTPGateway(ctx context.Context) error {
    mux := runtime.NewServeMux(
        // JSON 필드 이름 설정
        runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{
            MarshalOptions: protojson.MarshalOptions{
                UseProtoNames:   true,
                EmitUnpopulated: true,
            },
            UnmarshalOptions: protojson.UnmarshalOptions{
                DiscardUnknown: true,
            },
        }),
        // 에러 핸들링 커스터마이즈
        runtime.WithErrorHandler(customErrorHandler),
    )
    
    opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
    
    // gRPC 서버에 연결
    err := pb.RegisterDocumentServiceHandlerFromEndpoint(ctx, mux, "localhost"+s.grpcPort, opts)
    if err != nil {
        return err
    }
    
    // 추가 HTTP 엔드포인트
    handler := http.NewServeMux()
    handler.Handle("/", mux)
    handler.HandleFunc("/openapi.yaml", serveOpenAPI)
    handler.HandleFunc("/ready", healthCheck)
    
    s.httpServer = &http.Server{
        Addr:    s.httpPort,
        Handler: handler,
    }
    
    return s.httpServer.ListenAndServe()
}

func (s *Server) Shutdown() {
    s.grpcServer.GracefulStop()
    if s.httpServer != nil {
        s.httpServer.Shutdown(context.Background())
    }
}

에러 핸들링

gRPC 상태 코드를 HTTP 상태 코드로 매핑:

func customErrorHandler(
    ctx context.Context,
    mux *runtime.ServeMux,
    marshaler runtime.Marshaler,
    w http.ResponseWriter,
    r *http.Request,
    err error,
) {
    // gRPC 에러에서 상태 추출
    st, _ := status.FromError(err)
    
    // HTTP 상태 코드 매핑
    httpStatus := runtime.HTTPStatusFromCode(st.Code())
    
    // 커스텀 에러 응답
    response := map[string]interface{}{
        "code":    st.Code().String(),
        "message": st.Message(),
    }
    
    if details := st.Details(); len(details) > 0 {
        response["details"] = details
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(httpStatus)
    json.NewEncoder(w).Encode(response)
}

인-프로세스 vs 네트워크 연결

네트워크 연결 (기본)

// gRPC 서버에 네트워크로 연결
pb.RegisterDocumentServiceHandlerFromEndpoint(ctx, mux, "localhost:9090", opts)

인-프로세스 연결 (권장)

네트워크 오버헤드 없이 직접 연결:

func (s *Server) startHTTPGatewayInProcess(ctx context.Context) error {
    mux := runtime.NewServeMux()
    
    // 서버 구현체를 직접 등록 (네트워크 경유 없음)
    err := pb.RegisterDocumentServiceHandlerServer(ctx, mux, s.documentHandler)
    if err != nil {
        return err
    }
    
    s.httpServer = &http.Server{
        Addr:    s.httpPort,
        Handler: mux,
    }
    
    return s.httpServer.ListenAndServe()
}

미들웨어

HTTP 미들웨어 체인

func (s *Server) buildHTTPHandler(mux *runtime.ServeMux) http.Handler {
    // 미들웨어 체인
    handler := corsMiddleware(mux)
    handler = loggingMiddleware(handler)
    handler = metricsMiddleware(handler)
    
    return handler
}

func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        
        if r.Method == "OPTIONS" {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        
        next.ServeHTTP(w, r)
    })
}

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        wrapped := &responseWriter{ResponseWriter: w, status: 200}
        next.ServeHTTP(wrapped, r)
        
        log.Printf("%s %s %d %v", r.Method, r.URL.Path, wrapped.status, time.Since(start))
    })
}

gRPC 인터셉터

func loggingInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    start := time.Now()
    
    resp, err := handler(ctx, req)
    
    log.Printf("gRPC %s %v err=%v", info.FullMethod, time.Since(start), err)
    
    return resp, err
}

OpenAPI 스펙 제공

자동 생성된 스펙 제공

//go:embed generated/docs/openapi.yaml
var openAPISpec []byte

func serveOpenAPI(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/x-yaml")
    w.Write(openAPISpec)
}

Stoplight Elements 통합

정적 HTML과 Stoplight Elements 웹 컴포넌트로 인터랙티브 API 문서를 제공합니다:

<!-- docs/openapi.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>API Documentation</title>
    <script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css" />
  </head>
  <body>
    <elements-api
      apiDescriptionUrl="openapi.swagger.json"
      router="hash"
      layout="sidebar"
      hideInternal="true"
    />
  </body>
</html>

Proto 프로젝트에서 embed.FS로 정적 파일 제공:

// generated/go/docs/embed.go
package docs

import "embed"

//go:embed openapi.html openapi.swagger.json
var StaticFiles embed.FS

gRPC-Gateway Mux에 직접 경로 등록:

import (
    "io/fs"
    "net/http"
    
    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "myproject/generated/go/docs"
)

func setupAPIRoutes(gatewayMux *runtime.ServeMux) {
    // 파일 서빙 헬퍼
    serveEmbeddedFile := func(w http.ResponseWriter, r *http.Request, fsys fs.FS, name string) {
        fileServer := http.FileServer(http.FS(fsys))
        r.URL.Path = name
        fileServer.ServeHTTP(w, r)
    }
    
    // OpenAPI JSON 스펙
    gatewayMux.HandlePath("GET", "/openapi.swagger.json", 
        func(w http.ResponseWriter, r *http.Request, _ map[string]string) {
            serveEmbeddedFile(w, r, docs.StaticFiles, "openapi.swagger.json")
        })
    
    // Stoplight Elements UI (HTML)
    gatewayMux.HandlePath("GET", "/openapi.html", 
        func(w http.ResponseWriter, r *http.Request, _ map[string]string) {
            serveEmbeddedFile(w, r, docs.StaticFiles, "openapi.html")
        })
    
    // 루트 접속 시 문서로 리다이렉트
    gatewayMux.HandlePath("GET", "/", 
        func(w http.ResponseWriter, r *http.Request, _ map[string]string) {
            http.Redirect(w, r, "/openapi.html", http.StatusFound)
        })
}

요청/응답 예시

HTTP 요청

# 문서 생성
curl -X POST http://localhost:8080/v1beta/collections/users/documents \
  -H "Content-Type: application/json" \
  -d '{
    "document": {
      "uri": "user-001",
      "fields": {
        "name": "John Doe",
        "email": "john@example.com"
      }
    }
  }'

# 문서 조회
curl http://localhost:8080/v1beta/collections/users/documents/user-001?version=1

# 문서 업데이트
curl -X PATCH http://localhost:8080/v1beta/collections/users/documents/user-001 \
  -H "Content-Type: application/json" \
  -d '{"fields": {"name": "Jane Doe"}}'

gRPC 요청 (grpcurl)

# 문서 생성
grpcurl -plaintext -d '{
  "collection": "users",
  "document": {
    "uri": "user-001",
    "fields": {"name": "John Doe"}
  }
}' localhost:9090 v1beta.DocumentService/CreateDocument

# 문서 조회
grpcurl -plaintext -d '{
  "collection": "users",
  "uri": "user-001"
}' localhost:9090 v1beta.DocumentService/GetDocument

모범 사례

  1. 인-프로세스 연결: 가능하면 RegisterHandlerServer 사용
  2. 일관된 에러 처리: gRPC/HTTP 에러 매핑 통일
  3. OpenAPI 통합: 자동 생성 스펙으로 문서화
  4. 미들웨어 분리: HTTP/gRPC 각각 적절한 미들웨어 적용
  5. Health Check: /ready 엔드포인트로 헬스체크 분리

참고 자료

Share

Related Articles

Comments

이 블로그는 제가 알고 있는 것들을 잊지 않기 위해 기록하는 공간입니다.
직접 작성한 글도 있고, AI의 도움을 받아 정리한 글도 있습니다.
정확하지 않은 내용이 있을 수 있으니 참고용으로 봐주세요.

© 2026 Seogyu Kim