-
계기
본인은 server / client간의 양방향 통신을 위해서 NATS 라는 Protocol을 사용했다. NATS는 오픈 소스 메세징 시스템으로 publish / subscribe 방식, request / reply 방식 그리고 queue 을 사용한다.
장비를 관리하는 업무를 하다보니 각각의 client에 따로 명령을 전달해야 할 필요가 있었고 이에 사용하게 되었다. 사용방법이 매우 편리하고 빠르기 때문에 문제 없이 사용하고 있었는데 사용 간 type 에러 문제와 실시간성을 보장해야하는 기능이 필요할 때 양방향 스트링이 되지 않는 불편함이 생겼다. 이를 해결하기 위해 grpc를 찾아보게 되었다.
gRPC란?
gRPC는 Google에서 개발되었고 고성능 RPC 프레임워크이다. HTTP/2 에서 동작하며 양방향 스트리밍 및 흐름 제어를 제공한다.
gRPC는 원격으로 호출할 수 있는 메서드를 지정하여 서비스를 정의하는 개념이 기반이며 protobufs(IDL로 사용)를 기본 메세지 형식으로 사용한다. 이 덕분에 효율적인 직렬화가 가능하고 Type 을 명시적으로 체크할 수 있다.
※IDL: Interface Definition Language
gRPC의 특징
- 언어 독립성: gRPC는 다양한 언어를 지원한다. 클라이언트와 서버가 서로 다른 언어로 작성되어 있어도 상호 작용이 가능하다.
- 양방향 스트리밍: HTTP/2를 기반으로 하는 gRPC는 클라이언트와 서버 간에 양방향 스트리밍을 지원한다.
- 강력한 타입 체크: 메시지 형식을 프로토콜 버퍼로 정의하면, gRPC는 Type 체크를 제공한다.
- 높은 성능: gRPC는 HTTP/2와 프로토콜 버퍼를 활용하여 높은 성능을 제공한다.
- Google API 연동: Google API에는 인터페이스의 gRPC 버전이 제공되므로 애플리케이션에 Google 기능을 쉽게 빌드할 수 있다.
gRPC에서는 클라이언트 애플리케이션이 로컬 객체처럼 다른 컴퓨터의 서버 애플리케이션에 있는 메서드를 직접 호출할 수 있기 때문에 분산 app과 서비스를 더 만들기 쉽다. 많은 RPC 시스템에서와 마찬가지로 gRPC는 서비스를 정의하고 매개변수와 반환 유형으로 원격으로 호출할 수 있는 메서드를 지정한다. 서버 측에서는 서버가 이 인터페이스를 구현하고 클라이언트 호출을 처리하기 위해 gRPC 서버를 실행합니다. 클라이언트 측에서는 클라이언트가 서버와 동일한 메서드를 제공하는 stub을 가지고 있다.
예를 들어, 만약 Server에서 SayHello라는 함수를 제공하면 client는 server와 연결한 뒤 conn.GetUser 호출을 통해 서버가 제공하는 SayHello라는 함수를 실행할 수 있다. 이에 대한 명세는 .proto라는 파일을 생성하여 정의한 뒤 protobuf로 빌드하면 생성할 수 있다.
Protobuf
protobuf는 구글에서 개발한 언어와 플랫폼에 상관 없이 직렬화를 가능하게 하는 데이터 구조이다. 서로 다른 시스템끼리 데이터를 공유 / 저장하기 위해 사용된다. protobuf는 XML, JSON과 같이 잘 알려져 있는 다른 데이터 형식보다 더 효율적인 방식으로 데이터를 관리하기 때문에 전송시 / 저장 시 데이터를 더 적게 사용할 수 있다.
프로토콜 버퍼로 작업하기 위해서는 우선 데이터의 구조를 정의해야 한다. 확장자가 .proto인 일반 텍스트 파일을 통해서 정의할 수 있으며 프로토콜 버퍼 데이터는 메시지로 구조화되고, 각 메시지는 필드라고 하는 일련의 이름-값 쌍을 포함한다.
아래는 예제코드에서 가져온 .proto의 예시이다.
git clone -b v1.55.0 --depth 1 https://github.com/grpc/grpc-go
완전 동일하지는 않고 본인의 환경에서 하기 위해서 go_package만 변경하였다.
syntax = "proto3"; option go_package = "grpc/helloworld"; package helloworld; // The greeting service definition. service Greeter { // Sends a greeting rpc SayHello (HelloRequest) returns (HelloReply) {} } // The request message containing the user's name. message HelloRequest { string name = 1; } // The response message containing the greetings message HelloReply { string message = 1; }
위와 같이 .proto 파일을 생성한 뒤 버퍼 컴파일러인 protoc을 사용해서 사용하는 언어에 맞는 코드를 생성할 수 있다. 사용법에 대해서는 기본적인 설명 후 다루도록 하겠다.
gRPC의 4가지 서비스 정의 방법
- 클라이언트가 서버에 단일 요청을 보내고 일반 함수 호출과 마찬가지로 단일 응답을 받는 단항 RPC
rpc SayHello(HelloRequest) returns (HelloResponse);
- 클라이언트가 서버에 요청을 보내고 일련의 메시지를 읽을 수 있는 스트림을 다시 받는 서버 스트리밍 RPC
rpc LotsOfReplies(HelloRequest) returns (stream HelloResponse);
- 클라이언트가 일련의 메시지를 작성하고 제공된 스트림을 사용하여 다시 서버로 보내는 클라이언트 스트리밍 RPC
rpc LotsOfGreetings(stream HelloRequest) returns (HelloResponse);
- 양쪽이 읽기-쓰기 스트림을 사용하여 일련의 메시지를 보내는 양방향 스트리밍 RPC
rpc LotsOfGreetings(stream HelloRequest) returns (HelloResponse);
서비스 예제 코드 작성하기
본인은 간단하게 https://github.com/zzihyeon/grpc-server에 작성해 놓았으니 참고하면 될 듯 하다.
위의 1에 대해서 설명하고 마무리 하려고 한다. 이 경우 결국 평범한 당방향 통신이다. 이에 대한 예제 코드를 작성해 보자.
- 우선 proto file을 생성해야 한다. 위에 선언한 것과 동일한 proto file을 사용하겠다.
syntax = "proto3"; option go\_package = "grpc/helloworld"; //내 package 경로 package helloworld; // Greeting 서비스 정의 service Greeter { rpc SayHello (HelloRequest) returns (HelloReply) {} } // 요청은 name이라는 string이 있어야 한다. message HelloRequest { string name = 1; } // 응답은 message라는 string이 있어야 한다. message HelloReply { string message = 1; }
- proto file 생성이 완료되었으면 이를 build하여야 한다.
protoc --go\_out=. --go\_opt=paths=source\_relative --go-grpc\_out=. --go-grpc\_opt=paths=source\_relative helloworld/helloworld.proto
- 이제 server 코드를 작성해 보자.
package main import ( "context" "flag" "fmt" pb "grpc/helloworld" "log" "net" "google.golang.org/grpc" ) var ( port = flag.Int("port", 50051, "The server port") ) // server is used to implement helloworld.GreeterServer. type server struct { pb.UnimplementedGreeterServer //이부분은 안하면 에러가 발생한다. protobuf generate시 생성됨 } // SayHello implements helloworld.GreeterServer func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) { log.Printf("Received: %v", in.GetName()) return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil } func main() { flag.Parse() lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port)) if err != nil { log.Fatalf("failed to listen: %v", err) } s := grpc.NewServer() pb.RegisterGreeterServer(s, &server{}) //client가 사용할 수 있도록 등록 log.Printf("server listening at %v", lis.Addr()) if err := s.Serve(lis); err != nil { log.Fatalf("failed to serve: %v", err) } }
protobuf 빌드 시 mustEmbedUnimplementedGreeterServer method가 생성된다. - 이제 client 코드를 작성해보자
package main import ( "context" "flag" "log" "time" pb "grpc/helloworld" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) const ( defaultName = "world" ) var ( addr = flag.String("addr", "localhost:50051", "the address to connect to") name = flag.String("name", defaultName, "Name to greet") ) func main() { flag.Parse() // Set up a connection to the server. conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { log.Fatalf("did not connect: %v", err) } defer conn.Close() c := pb.NewGreeterClient(conn) //서버의 method를 사용할 수 있게 해줌 // Contact the server and print out its response. ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() r, err := c.SayHello(ctx, &pb.HelloRequest{Name: *name}) if err != nil { log.Fatalf("could not greet: %v", err) } log.Printf("Greeting: %s", r.GetMessage()) }