이제 go를 사용하여 간단한 gRPC 서비스를 만들어보도록 하자.
아래는 각 메서드 호출에 대한 ProductInfo 서비스의 클라이언트-서버 통신 과정을 보여준다. 서버는 addProduct(product)와 getProduct(productId)의 두 가지 원격 메서드를 제공하는 gRPC 서비스를 호스팅한다. 클라이언트는 이러한 원격 메서드 중 하나를 호출할 수 있다.

먼저 ProductInfo gRPC 서비스 정의를 생성해보자
gRPC application을 개발할 때 가장 먼저 하는 일은 소비자가 원격으로 호출할 수 있는 메서드, 매개변수, 메시지 형식 등이 포함된 서비스 interface를 정의하는 것이다. 이러한 모든 서비스 정의는 protocol buffer의 정의로 기록되며 gRPC에서 사용되는 인터페이스 정의 언어(IDL)이다.
message는 클라이언트와 서비스 간에 교환되는 데이터 구조이다. ProductInfo 사용 사례에는 두 가지 메시지 유형이 있다. 하나는 시스템에 새 제품을 추가할 때 필요하며 특정 제품을 검색할 때 반환되는 제품 정보(Product)이다. 다른 하나는 제품의 고유 식별(ProductID)으로 시스템에서 특정 제품을 검색할 때 필요하며 새 제품을 추가할 때 반환된다.
ProductID: 문자열 값일 수 있는 제품의 고유 식별자이다. 문자열 필드를 포함하는 자체 메시지 유형을 정의하거나 protocol buffer 라이브러리에서 제공하는 잘 알려진 메시지 유형 google.protobuf.StringValue를 사용할 수도 있다. meesage ProductID {
string value = 1;
}
Product: 제품에 존재해야하는 데이터를 나타내는 사용자 지정 메시지 유형이다. 각 제품과 관련된 데이터를 나타내는 필드 집합을 가질 수 있다. ID, Name, Description, Price` 등이 있다.message Product {
string id = 1;
string name = 2;
string description = 3;
float price = 4;
}
여기서 각 메시지 필드에 할당된 번호는 메시지에서 해당 필드를 고유하게 식별하는 데 사용된다. 따라서, 동일한 메시지 정의의 서로 다른 두 필드에 동일한 번호를 사용할 수 없다. protocol buffer의 메시지 정의 기술에 대해 자세히 살펴보고, 각 필드에 고유 번호를 제공해야 하는 이유에 대해서는 추후에 설명하도록 하자. 지금은 protocol buffer 메시지를 정의할 때의 규칙이라고 생각하자.
protocol buffer에 잘 사용되는 타입은 아래 링크에서 찾아볼 수 있다. https://protobuf.dev/reference/protobuf/google.protobuf/
service는 클라이언트에 노출되는 remote 메서드의 모음이다. ProductInfo 서비스에는 addProduct(product)와 getProduct(productId)라는 두 개의 remote 메서드가 있다. protocol buffer 규칙에 따라 remote 메서드에는 하나의 입력 매개변수만 가질 수 있으며, 하나의 값만 반환할 수 있다. addProduct 메서드에서와 같이 여러 값을 메서드에 전달해야 하는 경우 Prouct 메시지 유형에서와 같이 메시지 유형을 정의하고 모든 값을 그룹화해야한다.
addProduct: 시스템에 Product을 새로 생성한다. 이 메서드는 제품의 세부 정보를 입력으로 요구하며 작업이 성공적으로 완료되면 새로 추가된 제품의 제품 식별 번호를 반환한다.rpc addProduct(Product) returns (google.protobuf.StringValue);
getProduct: 제품 정보를 검색한다. ProductID를 입력으로 받으며 특정 제품이 시스템에 존재하는 경우 Product 세부 정보를 반환한다. rpc getProduct(google.protobuf.StringValue) returns (Product);
이제 메시지와 서비스를 함께 쓰면 다음의 완전한 protocol buffer 정의가 완성된다.
syntax = "proto3";
package ecommerce;
option go_package = "productinfo/service/ecommerce";
service ProductInfo {
rpc addProduct(Product) returns (ProductID);
rpc getProduct(ProductID) returns (Product);
}
message Product {
string id = 1;
string name = 2;
string description = 3;
}
message ProductID {
string value = 1;
}
syntax = "proto3": 서비스 정의에 사용하는 protocol buffer 버전은 proto3으로 지정하겠다는 것이다.package ecommerce: 패키지 이름을 지정하는 것으로 protocol buffer 메시지 간의 이름 충돌을 방비하는 것이다.option go_package: protocol buffer로 생성될 go 코드를 어디에 적재할 지 지정하는 것이다. service ProductInfo: remote method에 대한 서비스 인터페이스에 정의이다.protocol buffer 정의에서 패키지 이름으로 ecommerce을 지정할 수 있으므로 서로 다른 project 간의 이름 충돌을 방지하는 데 도움이 된다. 패키지와 함께 이 서비스 정의를 사용하여 서비스 또는 클라이언트 용 코드를 생성하면 코드 생성을 위해 명시적으로 다른 패키지를 지정하지 않는 한 코드가 생성되는 각 프로그래밍 언어에 동일한 패키지가 생성된다. 또한, ecommerce.v1, ecommerce.v2와 같이 버전 번호로 패키지 이름을 정의할 수도 있다.
import를 사용하면 다른 protocol buffer에 있는 메시지 유형을 가져올 수 있다. 가령 wrappers.proto 파일에 정의된 StringValue 타입을 사용하려면 다음과 같이 google/protobuf/wrappers.proto 파일을 정에서 가져올 수 있다.
syntax = "proto3";
import "google/protobuf/wrappers.proto";
package ecommerce;
...
서비스를 만들었다면 이제 gRPC 서비스 및 클라이언트 구현을 진행할 수 있다.
서비스 정의에서 지정한 remote 메서드를 사용하는 gRPC 서비스를 구현해보도록 하자.
먼저 ProductInfo 서비스 정의를 컴파일하고 선택한 언어에 대한 소스 코드를 생성해야 한다. 서비스 정의를 컴파일하여 소스 코드를 생성하는 것은 protocol buffer 컴파일러를 사용하여 자동으로 할 수 있다.
각 언어마다의 code를 만들기 전에 protocol buffer의 compiler를 설치하도록 하자.
PROTOC_VERSION="31.1"
INSTALL_DIR="/usr/local"
wget https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip
sudo unzip protoc-${PROTOC_VERSION}-linux-x86_64.zip -d ${INSTALL_DIR}
rm protoc-${PROTOC_VERSION}-linux-x86_64.zip
protoc --version
libprotoc 31.1 이렇게 나오면 성공이다.
Go 서비스 구현은 다음의 3단계를 따른다고 생각하면 된다.
먼저 go project를 만들어보도록 하자.
mkdir -p ./productinfo/service && cd ./productinfo/service
go mod init productinfo/service
이런 디렉터리 구조를 갖게 된다.
└─ productinfo
└─ service
├── go.mod
├ . . .
└── ecommerce
└── . . .
다음의 의존성을 설치해주도록 하자.
go get github.com/gofrs/uuid/v5
go get google.golang.org/protobuf
go get github.com/google/uuid
go get google.golang.org/grpc
go에서 gRPC 서비스를 만들기 위한 기본 library들은 모두 설치한 것이다. 다음으로 go용 protocol buffer 컴파일러 플러그인을 설치하도록 하자.
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
이제 gRPC 코드를 생성해보도록 하자.
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative ./ecommerce/product_info.proto
실제로 생성되었는 지 확인해보도록 하자.
tree
.
├── ecommerce
│ ├── product_info_grpc.pb.go
│ ├── product_info.pb.go
│ └── product_info.proto
├── go.mod
└── go.sum
1 directory, 5 files
prodcut_info 관련 코드들이 자동으로 생성된 것을 볼 수 있다.
product_info.pb.go: 메세지에 대한 gRPC 직렬화/역직렬화 go 코드를 만들어준다.product_info_grpc.pb.go: 서비스에 대한 gRPC 호출 stub을 만들어준다. 즉, client측 stub과 서버측 skeleton 코드를 만들어준 것이다.이제 skeleton code에 비지니스 로직을 구현해보도록 하자.
먼저 product_info_grpc.pb.go을 살펴보면 다음의 인터페이스가 있는 것을 볼 수 있다.
type ProductInfoServer interface {
AddProduct(context.Context, *Product) (*ProductID, error)
GetProduct(context.Context, *ProductID) (*Product, error)
mustEmbedUnimplementedProductInfoServer()
}
서버 측에서 구현해야하는 spec인 것이다. AddProduct와 GetProduct를 보면 protocol buffer에 명시된 값으로 잘 만들어진 것을 볼 수 있다.
재밌는 것은 mustEmbedUnimplementedProductInfoServer라는 메서드인데, 해당 메서드가 구현된 타입을 보면 아래와 같다.
type UnimplementedProductInfoServer struct{}
func (UnimplementedProductInfoServer) AddProduct(context.Context, *Product) (*ProductID, error) {
return nil, status.Errorf(codes.Unimplemented, "method AddProduct not implemented")
}
func (UnimplementedProductInfoServer) GetProduct(context.Context, *ProductID) (*Product, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetProduct not implemented")
}
func (UnimplementedProductInfoServer) mustEmbedUnimplementedProductInfoServer() {}
func (UnimplementedProductInfoServer) testEmbeddedByValue() {}
ProductInfoServer에 대한 기본적인 메서드 구현을 담고 있는 것을 볼 수 있다. 또한, mustEmbedUnimplementedProductInfoServer도 구현하고 있다. 따라서, 우리가 만들 grpc ProductInfo 서버는 UnimplementedProductInfoServer을 기본적으로 타입 임베딩해야한다. 이는 사실 임베딩하도록 의도하여 구현이 안되어 있을 때를 방지한 것으로 볼 수 있다. 그래서 mustEmbedUnimplementedProductInfoServer 메서드 명이 이렇게 써져 있는 것이다.
이제 우리가 비지니스 로직을 위해서 할 일은 다음과 같다.
ProductInfoServer 인터페이스의 구현체를 만들자.UnimplementedProductInfoServer을 타입 임베딩해야한다.AddProduct와 GetProduct에 구현 사항을 넣도록 하자.package main
import (
"context"
"log"
"net"
pb "productinfo/service/ecommerce"
"github.com/gofrs/uuid/v5"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// 1. `ProductInfoServer` 인터페이스의 구현체를 만들자.
type server struct {
// 2. 구현체 안에는 `UnimplementedProductInfoServer`을 타입 임베딩해야한다.
pb.UnimplementedProductInfoServer
productMap map[string]*pb.Product
}
// 3. `AddProduct`와 `GetProduct`에 구현 사항을 넣도록 하자.
func (s *server) AddProduct(ctx context.Context, product *pb.Product) (*pb.ProductID, error) {
out, err := uuid.NewV6()
if err != nil {
return nil, status.Errorf(codes.Internal, "Error while generating Product ID", err)
}
product.Id = out.String()
if s.productMap == nil {
s.productMap = make(map[string]*pb.Product)
}
s.productMap[product.Id] = product
log.Printf("Product %v : %v - Added", product.Id, product.Name)
return &pb.ProductID{Value: product.Id}, status.New(codes.OK, "").Err()
}
// 3. `AddProduct`와 `GetProduct`에 구현 사항을 넣도록 하자.
func (s *server) GetProduct(ctx context.Context, productId *pb.ProductID) (*pb.Product, error) {
product, exists := s.productMap[productId.Value]
if exists && product != nil {
log.Printf("Product %v : %v - Retrieved.", product.Id, product.Name)
return product, status.New(codes.OK, "").Err()
}
return nil, status.Errorf(codes.NotFound, "Product does not exist.", productId.Value)
}
const (
port = ":50051"
)
func main() {
s, err := net.Listen("tcp", port)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
gs := grpc.NewServer()
// 4. 우리의 비즈니스 로직을 grpc 서버에 등록하자.
pb.RegisterProductInfoServer(gs, &server{})
// 5. grpc 서버를 실행하자.
log.Println("server starting... " + port)
if err := gs.Serve(s); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
위와 같이 구현하면 완료이다.
이제 grpc 서버를 실행시켜보도록 하자.
go run ./...
2025/08/01 14:35:09 server starting... :50051
서버가 시작되었다면 성공이다. 다음으로 client를 만들어 실행시켜보도록 하자.
gRPC client 역시도 서버와 마찬가지로 protocol buffer를 기반으로 code를 만들어야 한다.
먼저 go project를 만들어보도록 하자.
mkdir -p ./productinfo/client && cd ./productinfo/client
go mod init productinfo/client
cd ..
productinfo 디렉터리는 이제 이런 모습을 띈다.
productinfo$ tree
.
├── client
│ └── go.mod
└── service
├── ecommerce
│ ├── product_info_grpc.pb.go
│ ├── product_info.pb.go
│ └── product_info.proto
├── go.mod
├── go.sum
└── main.go
다시 client로 들어가서 다음의 의존성을 설치해주도록 하자.
go get github.com/gofrs/uuid/v5
go get google.golang.org/protobuf
go get github.com/google/uuid
go get google.golang.org/grpc
이제 gRPC 코드는 이미 server측에 있으니 해당 code를 import하여 사용하도록 하자.
먼저 local에 있는 repo를 가져오기 위해서는 client 측 go.mod에 다음을 추가해야한다.
replace productinfo/service => ../service
productinfo/service 모듈로 요청하는 경로에 대해서는 현재 경로에서 한 단계 상위에서 찾는 것이다.
productinfo$ tree
.
├── client
│ └── go.mod
└── service
├── ecommerce
│ ├── product_info_grpc.pb.go
│ ├── product_info.pb.go
│ └── product_info.proto
├── go.mod
├── go.sum
└── main.go
tree를 보면 알 수 있듯이 client의 상위 디렉터리에서 찾게 되므로 productinfo에서 service를 찾을 수 있게 된다.
client에서 import 할 때는 이제 다음과 같이 사용하면 된다.
import (
pb "productinfo/service/ecommerce"
)
실제로는
import (
pb "../service/ecommerce"
)
경로로 찾게 되는 것이다.
ecommerce에 접근 가능하게 되었음으로 protocol buffer의 client code에 접근이 가능해진다. 이제 다음과 같이 코드를 만들도록 하자.
package main
import (
"context"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "productinfo/service/ecommerce"
)
func main() {
conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewProductInfoClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
product, err := c.AddProduct(ctx, &pb.Product{
Name: "apple",
Description: "iphone",
})
if err != nil {
log.Fatalf("Could not add product: %v", err)
}
log.Printf("ProductID: %s added successfully", product.Value)
getProduct, err := c.GetProduct(ctx, &pb.ProductID{Value: product.Value})
if err != nil {
log.Fatalf("Could not get product: %v", err)
}
log.Printf("Product: ", getProduct.String())
}
c := pb.NewProductInfoClient(conn)로 gRPC connection을 만들고 c.AddProduct, c.GetProduct을 호출하는 것을 볼 수 있다.
실제 실행해보도록 하자.
go run main.go
2025/08/01 15:05:06 ProductID: 1f06e9d7-8b6a-6230-ad51-c4ceadc115fd added successfully
2025/08/01 15:05:06 Product: %!(EXTRA string=id:"1f06e9d7-8b6a-6230-ad51-c4ceadc115fd" name:"apple" description:"iphone")
gRPC 서버가 제대로 동작 중이라면 요청에 성공하는 것을 볼 수 있을 것이다.
현재의 디렉터리 구조를 보면 다음과 같다.
productinfo$ tree
.
├── client
│ ├── go.mod
│ ├── go.sum
│ └── main.go
└── service
├── ecommerce
│ ├── product_info_grpc.pb.go
│ ├── product_info.pb.go
│ └── product_info.proto
├── go.mod
├── go.sum
└── main.go
3 directories, 9 files
client와 service(server)가 공유하여 사용하고 있는 protocol buffer가 service에 위치하고 있는 것을 볼 수 있다. 실제로는 이렇게 사용하지 않고 공통의 디렉터리를 만들어서 사용하는 데 다음과 같이 사용한다.
productinfo$ tree
.
├── client
│ ├── go.mod
│ ├── go.sum
│ └── main.go
├── product_info
│ ├── product_info.proto
│ ├── go
│ │ ├── go.mod
│ │ ├── go.sum
│ │ ├── product_info_grpc.pb.go
│ │ └── product_info.pb.go
│ ├── java
│ ├── ruby
│ └── python
└── service
├── go.mod
├── go.sum
└── main.go
공통 부분에 대해서 서로 간의 공유를 할 수 있도록 사용하는 것이다. 그리고 각 언어마다의 공통 코드를 따로 생성하는 것이다.
또는 자동 생성되는 코드는 각 프로젝트마다 달리 관리하도록 할 수 있다.
productinfo$ tree
.
├── client
├── productinfo
│ ├── product_info_grpc.pb.go
│ └── product_info.pb.go
│ ├── go.mod
│ ├── go.sum
│ └── main.go
├── product_info
│ ├── product_info.proto
└── service
├── productinfo
│ ├── product_info_grpc.pb.go
│ └── product_info.pb.go
├── go.mod
├── go.sum
└── main.go
이렇게 만들면 매번 각자의 project에서 빌드할 때마다 공통 함수를 생성한다는 장점이 있다. 개인적으로 이 방법이 실제 개발에 있어서는 가장 효율적이다.