[DevOps] Golang gRPC 서버 구축하기 2편
안녕하세요? 정리하는 개발자 워니즈 입니다. 이번시간에는 gRPC 샘플 서버 구축하기 2번째를 정리해보려고 합니다. 사실 이번 포스팅은 죄송스럽지만, devjin님의 github repository를 하나씩 정리하는 개념으로 가고있습니다. 다시한번 이 글을 통해서 감사하다는 말씀 전해드리고 싶습니다. 정말 알기 쉽게 작성이 잘 되어있어서, 나중에 책으로 집필해도 좋을거라는 생각이 들었습니다.
지난 글들은 아래를 참고 해주시면 됩니다.
1. 샘플 gRPC 서버의 간단한 아키텍처
이전에는 clinet(local PC)가 샘플로 띄운 gRPC를 호출하는 예제였습니다. 이번에는 2개의 go로 작성된 gRPC 서버를 띄워서 gRPC간 호출이 이뤄지는것을 확인해보도록 하겠습니다. 샘플 아키텍처는 아래와 같습니다.
- Post 서비스는 포스팅에 관한 목록을 조회하는 서비스 입니다.
- User 서비스는 유저에 관한 목록을 조회하는 서비스 입니다.
위의 내용을 보시면 붉은 박스로 표기해둔 것처럼 gRPC 통신을 하고 있습니다.
2. Post Protobuf service 정의하기
proto 파일을 보게 되면, 어떠한 api들이 정의가 되었는지 확인 할 수 있습니다. 마치 swagger-ui에서 api 명세를 확인 할 수 있는 것과 동일한 것 같습니다. 하지만, proto 파일은 실제 서비스의 골격이 되는 파일이고, 자연스럽게 명세파일을 작성하게 되니까 확인이 편리한 것 같습니다.
syntax = "proto3";
package v1.post;
option go_package = "github.com/dojinkimm/go-grpc-example/protos/v1/post";
service Post {
rpc ListPostsByUserId(ListPostsByUserIdRequest) returns (ListPostsByUserIdResponse);
rpc ListPosts(ListPostsRequest) returns (ListPostsResponse);
}
message PostMessage {
string post_id = 1;
string author = 2;
string title = 3;
string body = 4;
repeated string tags = 5;
}
message ListPostsByUserIdRequest {
string user_id = 1;
}
message ListPostsByUserIdResponse {
repeated PostMessage post_messages = 1;
}
message ListPostsRequest{}
message ListPostsResponse {
repeated PostMessage post_messages = 1;
}
필자는 이부분을 보고 어떠한 내용인지 이해할 수 있었습니다.
service Post {
rpc ListPostsByUserId(ListPostsByUserIdRequest) returns (ListPostsByUserIdResponse);
rpc ListPosts(ListPostsRequest) returns (ListPostsResponse);
}
위의 내용을 보면, userID를 파라미터로 받고, posts를 리스트 형식으로 리턴하는 rpc를 작성했고, 또다른 것은 포스트 전체 리스트를 전달해주는 rpc입니다.
3. Post Server 구현하기
그렇다면 위의 proto 파일을 기반으로 server 구현을 해보도록 하겠습니다.
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
"github.com/dojinkimm/go-grpc-example/data"
postpb "github.com/dojinkimm/go-grpc-example/protos/v1/post"
userpb "github.com/dojinkimm/go-grpc-example/protos/v1/user"
client "github.com/dojinkimm/go-grpc-example/simple-client-server"
)
const portNumber = "9001"
type postServer struct {
postpb.PostServer
userCli userpb.UserClient
}
// ListPostsByUserId returns post messages by user_id
func (s *postServer) ListPostsByUserId(ctx context.Context, req *postpb.ListPostsByUserIdRequest) (*postpb.ListPostsByUserIdResponse, error) {
userID := req.UserId
resp, err := s.userCli.GetUser(ctx, &userpb.GetUserRequest{UserId: userID})
if err != nil {
return nil, err
}
var postMessages []*postpb.PostMessage
for _, up := range data.UserPosts {
if up.UserID != userID {
continue
}
for _, p := range up.Posts {
p.Author = resp.UserMessage.Name
}
postMessages = up.Posts
break
}
return &postpb.ListPostsByUserIdResponse{
PostMessages: postMessages,
}, nil
}
// ListPosts returns all post messages
func (s *postServer) ListPosts(ctx context.Context, req *postpb.ListPostsRequest) (*postpb.ListPostsResponse, error) {
var postMessages []*postpb.PostMessage
for _, up := range data.UserPosts {
resp, err := s.userCli.GetUser(ctx, &userpb.GetUserRequest{UserId: up.UserID})
if err != nil {
return nil, err
}
for _, p := range up.Posts {
p.Author = resp.UserMessage.Name
}
postMessages = append(postMessages, up.Posts...)
}
return &postpb.ListPostsResponse{
PostMessages: postMessages,
}, nil
}
func main() {
lis, err := net.Listen("tcp", ":"+portNumber)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
userCli := client.GetUserClient("localhost:9000")
grpcServer := grpc.NewServer()
postpb.RegisterPostServer(grpcServer, &postServer{
userCli: userCli,
})
log.Printf("start gRPC server on %s port", portNumber)
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %s", err)
}
}
위에서 주목할 부분이 있습니다. Post 서버는 내부에서 gRPC 통신을 통해서 User의 정볼르 가져와야 합니다.
type postServer struct {
postpb.PostServer
userCli userpb.UserClient
}
...
userCli := client.GetUserClient("localhost:9000")
grpcServer := grpc.NewServer()
postpb.RegisterPostServer(grpcServer, &postServer{
userCli: userCli,
})
...
userpb를 import한 뒤, userCli라는 변수에 할당 client.GetUserClient(“localhost:9000”)를 통해서 9000번 user client에 dial로 연결을 수행하게 됩니다. 그렇게 되면, Post 서버에서는 마치 내부의 method를 사용하는 것처럼 내부에서 user method를 사용할 수 있습니다. 즉, Post
gRPC server내에서 User
gRPC server에 접근할 수 있는 client를 struct로 들고 있어서 코드 내에서 언제든지 접근할 수 있게 된 것이다.
[호출 결과]
4. gRPC의 서비스 형태
gRPC는 다음과 같이 43가지의 서비스 메소드를 정의하고 있습니다.
- Unary RPC
- 클라이언트는 서버에 싱글 리퀘스트를 보내고 다시 싱글 리스폰스를 받는다. 일반적인 함수의 호출과 같다.
service GreetService { // Unary rpc Greet(GreetRequest) returns (GreetResponse) {}; }
- Server streaming RPC
- 클라이언트가 서버에 리퀘스트를 보내고 스트림을 가져와 일련의 메세지를 읽는다. 리턴되는 스트림이 더 이상 메세지가 없을 떄까지 읽습니다. gRPC는 개별적인 RPC call 에 대해 메세지 순서를 보증합니다.
- Client streaming RPC
- 클라이언트는 일련의 메세지를 작성하고 제공되는 스트림을 통해 서버로 전송합니다. 클라이언트에서 메세지 작성을 끝내고 서버에 전송하면, 서버에서 읽고 응답을 반환할 떄까지 기다립니다. Server streaming RPC와 마찬가지로 개별적인 RPC call에 대해 메세지의 순서를 보증합니다.
[스트리밍 샘플]
service Room {
// Guest메시지를 스트림으로 전달하겠다고 정의함
rpc Entry (stream Guest) returns (Message);
}
[client 측 server 소스]
func main() {
conn, e := grpc.Dial("localhost:8080", grpc.WithInsecure())
if e != nil {
logrus.Error(e)
return
}
defer conn.Close()
// gRPC 클라이언트 접속
c := pb.NewRoomClient(conn)
stream, e := c.Entry(context.Background())
if e != nil {
logrus.Error(e)
return
}
for _, name := range []string{
"karl-1", "sienna-1",
"karl-2", "sienna-2",
"karl-3", "sienna-3",
} {
// [] 가상의 사용자 블럭
member := pb.Guest{Name: name, Age: 10}
// load 는 특정위치의 파일을 읽어 []byte 로 리턴한다.
sp, e := load("./cmd/stream-client/client/avatar.png")
if e != nil {
logrus.Error(e)
return
}
member.Avatar = sp
// [] 스트리밍 발송
stream.Send(&member)
logrus.Info("send")
time.Sleep(500 * time.Millisecond)
}
//
//
res, e := stream.CloseAndRecv()
if e != nil {
logrus.Error(e)
return
}
logrus.Info("Final response", res)
}
이미지에 대해서 로드를 하고, 해당 내용을 서버측으로 스트리밍을 통해서 보내는 작업을 수행합니다. 서버쪽에서는 스트리밍 데이터를 수신하고, 정상적으로 수신이 완료되면, return으로 메시지를 전송하고 있습니다.
[server Entry method]
func (t *room) Entry(stream pb.Room_EntryServer) error {
// [] 스트리밍 종료시까지 아바타 이미지를 저장한다.
for {
// [] 임시 파일 생성
file, e := os.Create("temp")
if e != nil {
logrus.Error(e)
return e
}
// [] 스트림 데이터 수신
res, e := stream.Recv()
if e == io.EOF {
// 스트리밍 종료 처리
logrus.Info("receive done")
logrus.Info(" ")
file.Close()
os.Remove("temp")
break
}
if e != nil {
logrus.Error(e)
file.Close()
os.Remove("temp")
return nil
}
// 아바타 이미지 저장
file.Write(res.Avatar)
file.Close()
// 파일명 변경
os.Rename("temp", fmt.Sprintf("%s.png", res.Name))
logrus.Info("receive ", res.Name)
}
// 스트리밍 종료시에 단항 메시지 전송
return stream.SendAndClose(&pb.Message{
Message: "success",
})
}
- bidirectional streaming RPC
- Read-write 스트림을 이용해 일련의 메세지를 서버, 클라이언트 양쪽에서 보냅니다. 두 개의 스트림은 독립적으로 작동하기에 클라이언트와 서버는 원하는 순서대로 읽고 쓸 수 있도록 해줍니다.
5. 마치며..
이번시간에는 gRPC 서버들간(client – server)의 호출에 대해서 정리를 해보았습니다. 확실히 rest에 익숙해져서인지 protobuf에 대한 개념과 rpc에 대한 개념이 조금은 생소하긴 합니다. 하지만 소스를 보면서, 어떤방식으로 호출이 이뤄지는지 볼 수 있었던 좋은 정리인것 같습니다. 다음 시간에는 gRPC 부하테스트를 위한 ghz라는 툴을 정리해보려고 합니다.
6. 참조..
https://devjin-blog.com/golang-grpc-server-2/#grpc-%ED%98%95%EC%8B%9D-ex-unary-stream
https://blog.breezymind.com/2019/11/20/grpc-%EA%B5%AC%ED%98%84-client-streaming-rpc-2/