[DevOps] Golang gRPC 서버 구축하기 1편

안녕하세요? 정리하는 개발자 워니즈입니다. 그동안 이직후에 포스팅을 많이 못해서 개인적으로 다소 아쉽게 생각하고 있습니다. 사실 이직후에 너무나 많은 정보들이 들어오고 있어서, 정리할 시간이 없었기에 따로 포스팅을 못하고 있었습니다.

최근에 많이 학습을 하게 된것이 gRPC라는 개념입니다. 예전부터 여러가지 세미나를 들으면서 단어는 많이 들었었는데 그때마다 우리쪽에 해당하는 사항이 없으니 한귀로 듣고 한귀로 흘리는 경우가 많았습니다. 그러나 이번에 gRPC를 경험해보게 될 일이 있어서, 개인적으로 DevOps Engineer지만 개발이 어떤식으로 이뤄지는 지 호출이 어떤식으로 이뤄지는 지가 너무 궁금하게 됐습니다.

지난 글들은 아래를 참고 해주시면 됩니다.

1. gRPC 란 무엇인가요?

gRPC를 알기전에 RPC의 개념에 대해서 먼저 알아야 합니다. RPC(원격 프로시저 호출)은 한 프로그램이 네트워크의 세부 정보를 이해하지 않고도 네트워크 안의 다른 컴퓨터에 있는 프로그램에서 서비스를 요청하는 프로토콜 입니다. RPC는 client-server 모델을 사용합니다. 클라이언트에서 서비스를 요청(function call) 하면, 서버에서 서비스를 제공합니다.

그렇다면 gRPC는 무엇일까요? 바로 구글에서 개발한 RPC 시스템입니다. 기본적인 개념은 RPC와 동일하지만 특징으로는 HTTP/2를 기반으로 양방향 스트리밍을 지원하고 메세지의 압축률과 성능이 좋습니다.

  • 일반적인 http 요청/응답 방식이 아니고, 서버와 클라이언트가 서로 동시에 데이터를 스트리밍하고 주고 받을 수 있음.
  • http기반 전송보다 높은 헤더 압축률을 보장합니다. 압축되기떄문에 네트워크 트래픽이 줄어들고 시스템 리소스를 절약하여 성능을 높일 수 있습니다.

grpc

2. gRPC는 어디에 적합할까요?

gRPC는 대부분의 아키텍에서 사용할 수 있지만, MSA(Microservice Architecture)에 가장 적합한 기술입니다. gRPC를 활용하면 비지니스 로직에 집중하여 빠른 서비스 개발이 가능하고, 간단한 설치와 빠른 배포가 가능합니다. 프로토콜 버퍼의 IDL을 활용한 서비스 및 메시지 정의는 MSA의 다양한 기술 스택으로 인해 발생하는 단점을 보완하고 많은 서비스간의 API호출로 인한 성능 저하를 개선할 수 있습니다.

3. Protocol Buffers(proto)

XML의 문제점을 개선하기 위해 제안된 IDL(Interface Definition language) 이며, XML보다 월등한 성능을 지닙니다. Protocol Buffers는 구조화(structured)된 데이터를 직렬화(sefialization) 하기 위한 프로토콜로 XML보다 작고 빠르고 간단합니다. XMl 스키마처럼 .proto 파일에 메시지 타입을 정의합니다.

직렬화란, 데이터 표현을 바이트 단위로 변환하는 작업을 말합니다. 가장 대표적인 예제가 아래의 예제입니다. json인 경우 82 byte가 소요되는데 반해, 직렬화 된 protocol buffers는 필드 번호, 필드 유형 등을 1byte로 받아서 식별하고, 주어진 length 만큼만 읽도록 하여 단지 33 byte만 필요하게 됩니다.

protocaol buffers

4. gRPC server 샘플 구현하기

그럼 본격적으로 gRPC에 대한 샘플 서버를 구현해보려고 합니다. Git repository는 아래와 같고, 이해하기 쉬운 샘플 예제를 작성해주신 dojinkimm님께 감사함을 표합니다.

  • git repository : https://github.com/dojinkimm/go-grpc-example

Git repository를 먼저 다운로드 받아, go의 src 폴더에 위치 시킵니다. 이후에 심플 서버 코드를 한번 보도록 하겠습니다.

 package main

 import (
     "log"
     "net"
     "google.golang.org/grpc"
 )

 const portNumber = "9000"

 func main() {
     lis, err := net.Listen("tcp", ":"+portNumber)
     if err != nil {
         log.Fatalf("failed to listen: %v", err)
     }
     grpcServer := grpc.NewServer()
     log.Printf("start gRPC server on %s port", portNumber)
     if err := grpcServer.Serve(lis); err != nil {
         log.Fatalf("failed to serve: %s", err)
     }
 }
  • net 패키지를 이용해서 어떤 포트를 사용할지 정의합니다.
  • grpc 패키지를 이용해서 서버를 생성합니다.

위의 코드를 실행하게 되면, 9000번의 포트로 gRPC 서버가 생성된 것을 확인 할 수 있습니다.

Protobuf 서비스 정의하기

gRPC server는 protobuf 라는 방식을 사용해서 정해진 양식대로 데이터를 주고 받는다고 했습니다. 그래서, protobuf로 만들 gRPC server의 메세지들을 먼저 정의해야 합니다.

  • go-grpc-example-main/protos/v1/user/user.proto
syntax = "proto3";
package v1.user;

option go_package = "github.com/dojinkimm/go-grpc-example/protos/v1/user";
service User {
    rpc GetUser(GetUserRequest) returns (GetUserResponse);
    rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
}
message UserMessage {
    string user_id = 1;
    string name = 2;
    string phone_number = 3;
    int32 age = 4;
}
message GetUserRequest {
    string user_id = 1;
}
message GetUserResponse {
    UserMessage user_message = 1;
}
message ListUsersRequest{}
message ListUsersResponse {
    repeated UserMessage user_messages = 1;
}

위에서 user에 대한 메세지를 정의했고, GetUser, ListUsers라는 서비스를 생성해두었습니다. 이 파일을 통해서 protoc 컴파일러를 사용해서 컴파일 해야 합니다.

go install google.golang.org/protobuf/cmd/protoc-gen-go

protoc 컴파일러를 다운 받고 나서, 밑의 명령어를 활용해서 컴파일을 진행합니다.

protoc -I=. \
        --go_out . --go_opt paths=source_relative \
        --go-grpc_out . --go-grpc_opt paths=source_relative \
        protos/v1/user/user.proto

정의한 Protobuf로 gRPC 서버 구현하기

user에 대한 정보를 전달해주는 gRPC 샘플 서버를 구현해보려고 합니다. User의 정보는 스태틱하게 아래와 같이 go파일로 작성이 되어 있습니다.

package data

import (
    userpb "github.com/dojinkimm/go-grpc-example/protos/v1/user"
)

var UserData = []*userpb.UserMessage{
    {
        UserId: "1",
        Name: "Henry",
        PhoneNumber: "01012341234",
        Age: 22,
    },
    {
        UserId: "2",
        Name: "Michael",
        PhoneNumber: "01098128734",
        Age: 55,
    },
    {
        UserId: "3",
        Name: "Jessie",
        PhoneNumber: "01056785678",
        Age: 15,
    },
    {
        UserId: "4",
        Name: "Max",
        PhoneNumber: "01099999999",
        Age: 37,
    },
    {
        UserId: "5",
        Name: "Tony",
        PhoneNumber: "01012344321",
        Age: 25,
    },
}

이제는 위의 generated proto 파일을 통해서 실제 서버상에 user정보를 가져오는 내용을 구현합니다.

  • go-grpc-example-main/simple-user
import (
    "context"
    "log"
    "net"
    "google.golang.org/grpc"

    "github.com/dojinkimm/go-grpc-example/data"
    userpb "github.com/dojinkimm/go-grpc-example/v1/user"
)

const portNumber = "9000"
type userServer struct {
    userpb.UserServer
}

// GetUser returns user message by user_id
func (s *userServer) GetUser(ctx context.Context, req *userpb.GetUserRequest) (*userpb.GetUserResponse, error) {
    userID := req.UserId
    var userMessage *userpb.UserMessage
    for _, u := range data.Users {
        if u.UserId != userID {
            continue
        }
        userMessage = u
        break
    }
    return &userpb.GetUserResponse{
        UserMessage: userMessage,
    }, nil
}

// ListUsers returns all user messages
func (s *userServer) ListUsers(ctx context.Context, req *userpb.ListUsersRequest) (*userpb.ListUsersResponse, error) {
    userMessages := make([]*userpb.UserMessage, len(data.Users))
    for i, u := range data.Users {
        userMessages[i] = u
    }
    return &userpb.ListUsersResponse{
        UserMessages: userMessages,
    }, nil
}

func main() {
    lis, err := net.Listen("tcp", ":"+portNumber)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    grpcServer := grpc.NewServer()
    userpb.RegisterUserServer(grpcServer, &userServer{})

    log.Printf("start gRPC server on %s port", portNumber)
    if err := grpcServer.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %s", err)
    }
}

go code를 정확히 모르더라도, GetUser. ListUsers의 역할을 대강 이해할 수 있을 것입니다. import된 data파일에서 매칭되는 userid가 존재하면, 해당 데이터를 usermessage포맷으로 담아서 return을 해주는 구조(GetUser) 일 것입니다.

gRPC client tool을 통해서 호출해보기

필자는 rest에 굉장히 익숙해져있기때문에 뭔가 grpc도 브라우저에서 호출하고 return을 받는 것이라고 생각했다. 하지만, grpc의 가장큰 단점 중 하나는 사람이 읽기 어려운 구조이고, 브라우저 해석기가 아직까진 존재하지 않습니다. 그래서 조사해보니 아래의 툴을 통해서 호출이 가능한 것을 확인했습니다.

  • [Bloomrpc]: https://awesomeopensource.com/project/uw-labs/bloomrpc “bloomRPC”

bloomRPC의 사용은 간단하기에 추후에 포스팅을 통해서 올리도록 하겠습니다.

Localhost:9000으로 아래의 내용을 호출합니다.

[client]
{
  "user_id": "3"
}

[server return]
{
  "user_message": {
    "user_id": "3",
    "name": "Jessie",
    "phone_number": "01056785678",
    "age": 15
  }
}

실제로 호출은 http://localhost:9000/v1.user.User/GetUser 와 같이 호출이 되는 형태이고, post방식으로 파라미터를 던지게 됩니다.

5. 마치며…

RPC의 개념도 정확히 몰랐던 필자가, gRPC 샘플 서버를 구축해보고, 호출까지 하는 과정에서 정말 많은 것을 배우게 된 것 같습니다. 특히나 내부적으로 http/2의 동작 방식과 거기서 얻는 이점들은 어떤것들이 있는지를 생각해보게 되었습니다. 추가적으로 k8s에 grpc 서버를 올릴때 http/2로 호출을 하다보니 단일 pod로 sticky connection 이 생기는 문제가 있었습니다. 왜 그렇게 발생을 하는지 이해할 수 있는 좋은 학습이 됐고, 특히 MSA구조와 철학에 대해서 배울 수 있는 계기가 된 것같습니다.

다음 시간에는 실제로 grpc server끼리의 통신(client-server)에 대해서 샘플 구현을 통해 학습해보는 포스팅을 작성하도록 하겠습니다.

6. 참고

https://grpc.io/docs/what-is-grpc/introduction/

https://devjin-blog.com/golang-grpc-server-1/

https://chacha95.github.io/2020-06-15-gRPC1/

https://corgipan.tistory.com/6

https://medium.com/naver-cloud-platform/nbp-%EA%B8%B0%EC%88%A0-%EA%B2%BD%ED%97%98-%EC%8B%9C%EB%8C%80%EC%9D%98-%ED%9D%90%EB%A6%84-grpc-%EA%B9%8A%EA%B2%8C-%ED%8C%8C%EA%B3%A0%EB%93%A4%EA%B8%B0-1-39e97cb3460

One Reply to “[DevOps] Golang gRPC 서버 구축하기 1편”

  1. 이직하셨군요. ㅊㅋㅊㅋ
    워니즈님 덕분에 grpc 개념을 이해할 수 있게 되었네요~

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다