안녕하세요, 2025년 상반기 셀메이트 개발자 컨퍼런스 D²에서 모바일 직무자 대표로 발표를 하게 된 POS팀 장지은입니다.👋🏻
Flutter 어플리케이션으로 gRPC 통신을 하는 방법에 대한 주제로 발표를 진행하였는데요, 블로그를 통해 다시 인사드리면서~ 많은 관심 부탁드립니다!🙇🏻‍♀️





🙋🏻‍♀️ 새로운 기술을 도입사고자 하는 당신의 선택은?!

1️⃣ 이론부터 빡세게 준비한다!

2️⃣ 공식문서를 훑어보고 바로 적용해보면서 알아가본다!

3️⃣ 바로 프로젝트 만들어본다!

컨퍼런스 당일 많은 분들이 2번 항목에 손을 들어주셨는데요. 저 또한 2번의 방식으로 새로운 기술을 배워가기 때문에 이번 주제에 대해서도 해당 기술 이론에 살짝 접근해보고 접목해보려고 합니다.



Sellmate POS앱과 Front앱

Sellmate POS앱과 Sellmate POS - Front앱은 실시간 통신을 통해 고객에게 판매 정보를 제공합니다.
👉🏻 Android
👉🏻 IOS

pos_imge pos_front_img

2024년 대략 3분기에 셀메이트 프론트앱을 출시하였습니다. 제일 기본 기능으로 어플리케이션끼리 네트워크를 통신을 통해 고객이 구매하고자하는 물건의 내역과 결제 정보를 확인할 수 있도록 했습니다. 통신 방법으로는 실시간 통신에서 가장 많이 쓰이는 Web Socket을 사용하였는데요. Global팀과의 협업으로 필요한 서비스가 많아지면서 경험으로 느껴지는 문제점들이 있었습니다.

문제점

  1. 통신 전문 규약의 중복 구현
  2. 점점 복잡해지는 요구사항, 엄격한 서비스 정의의 필요성 증가
  3. 수동적인 오류 처리

크게 3가지로 정리해봤습니다. 서비스 단위로 주고받는 데이터의 규약이 많아지면서 판매앱+프론트앱에 점점 중복 구현이 많아졌습니다. 또한 제공하는 서비스가 많아지면서 텍스트기반의 서비스 정의로 인한 도메인 관리가 힘들어지고 이에 따른 오류 처리 또한 점점 복잡해져만 갔습니다. 바쁘면 바빠질수록 더 효율적인 작업 방법에 대한 고민이 커져갔습니다. todo

여러 고민 끝에 gRPC로 통신 방법을 바꾸기로 하였습니다.
[참고] compare


목표

서론이 길었는데요, 따라서 이번 발표의 목표는 다음과 같습니다.

  1. gRPC를 이해할 수 있다.
  2. Protocol Buffer를 사용할 수 있다.
  3. Flutter 앱간에 gRPC를 활용한 실시간 통신을 구현할 수 있다.

그럼 시작해보겠습니다! lets_go


RPC(Remote Procedure Call)

  • 네트워크 통신을 통해 원격 서버에 있는 함수/메서드를 호출하여 사용하는 방법론
  • 클라이언트가 서버의 함수를 로컬에서 호출하는 것처럼 사용 가능
  • 정해진 프로토콜이 없으며 제약이나 규약이 없다
  • IDL(Interface Description Language)
  • RpyC, JSON-RPC, gRPC 등

gRPC(Google Rmote Procedure Call)

장점 및 특징

On the server side, the server implements this interface and runs a gRPC server to handle client calls.

On the client side, the client has a stub (referred to as just a client in some languages) that provides the same methods as the server.

grpc

단점

  • 브라우저에 비친화적(모바일간, 서버 간 통신에 주로 사용)
  • 사람이 읽을 수 없는 바이너리 데이터
  • REST보다 다소 복잡

Protocol Buffer

Protocol Buffers are language-neutral, platform-neutral extensible mechanisms for serializing structured data.

Protocol Buffer는 구조화된 데이터를 직렬화하는 방식으로 gRPC에서 IDL을 작성할 때 많이 쓰입니다. Protocol Buffer를 이용하여 다양한 스트림과 언어를 구조화된 데이터로 생성할 수 있습니다.

Protocol Buffer에 대해서는 제가 API First Approach에 대한 블로그 글에서 소개드린 적 있습니다.

직관적으로 알 수 있게 예시코드로 작성 방법을 알아보겠습니다.

  • JSON
1
2
3
4
5
{
  "name": "jieun",
  "id": 1,
  "email": ["jieun.jang@sellmate.co.kr"]
}        
  • Protocol Buffer
1
2
3
4
5
  message Person {
    optional string name = 1;
    int32 id = 2;
    repeated string email = 3;
  }

바이너리 데이터는 순서가 중요하므로 매세지에 순서를 부여해줍니다.

  • ENUM

Use PascalCase (with an initial capital) for enum type names and CAPITALS_WITH_UNDERSCORES for value names

1
2
3
4
5
  enum FooBar {
   FOO_BAR_UNSPECIFIED = 0;
   FOO_BAR_FIRST_VALUE = 1;
   FOO_BAR_SECOND_VALUE = 2;
  }
  • map
1
2
3
4
5
6
7
8
9
message TestMap {
 map<string, int32> g_map = 7;
}
message Test{
 message Address{
   string postCode = 1;
   optional string address = 2;
 }
}
  • 서비스 정의

package로 해당 서비스의 package를 정의할 수 있으며 각 서비스 집합의 목적에 맞게 package를 분리하고 있습니다.

1
2
3
4
5
6
7
8
9
syntax = "proto3";
package setting.v1;
import "setting/v1/model/app_version_compatibility_exception.proto";

service  SettingService {
 rpc Connect(ConnectRequest) returns (stream ConnectStream);
 rpc GetFrontAppBuildNumber(GetFrontAppBuildNumberRequest) returns (GetFrontAppBuildNumberResponse);
 rpc CheckAppVersionCompatibility(CheckAppVersionCompatibilityRequest) returns (CheckAppVersionCompatibilityResponse);
}

Protocol Buffer Encoding

그럼 작성한 IDL은 어떻게 바이너리 데이터로 변환될까요?

먼저 간단한 메세지를 JSON과 크기를 비교하며 Encoding해보겠습니다.
[WriteType 참고]

  • JSON
1
2
3
{
 "id": 727
}

특수문자: {, “, “, :, } => 5byte
영문자: id => 2byte
숫자: 727 => 3byte
총: 10byte

  • protocol Buffer
1
2
3
message Person {
 int32 id = 1;
}
  1. 먼저 필드번호(5자리) + write type(3자리)를 조합하여 1바이트의 데이터를 만들어줍니다.
    필드번호:1, write type: 0
    -> 00001 + 000 => 0000 1000
  2. 해당 필드의 값(727)을 이진수로 변환해줍니다.
    -> 10 1101 0111 -> 0101 0111 0000 0101
  3. 8비트씩 뒤에 값이 있으면 1, 없으면 0으로 MSB처리를 해줍니다.
    -> 1101 0111 0000 0101
    Encoding한 데이터를 합치면
    0000 1000 1101 0111 0000 0101로 총 3byte가 됩니다.

이번엔 문자를 변환해볼까요?

1
2
3
{
 "name": "pos"
}

특수문자: {, “, “, : “, “ } 7byte
영문자: 7byte
총: 14byte

  • protocol Buffer
1
2
3
message Team {
 string name = 1;
}
  1. 필드번호(5자리) + write type(3자리) -> 00001 010 => 0000 1010
  2. 문자의 길이 pos => 3(10) => 03(16)
  3. 각 문자열 변환 => 70(16) / 6F(16) / 73(16) Encoding한 데이터를 합치면 0000 1010 0000 0011 0111 0000 0110 1111 0111 0011 총 5byte가 됩니다.

자세한 방법은 공식문서 설명을 참고해주세요.

dart로 변환하기

설치

1
brew install protobuf
1
flutter pub global activate protoc_plugin

generate

1
protoc --proto_path=src --dart_out=grpc=build/gen src/foo.proto src/bar/baz.proto

위와 같이 명령어로 generate할 수 있으며 포스 프로젝트에서는 모든 package의 하위 proto파일을 찾아서 전부 변환해주는 실행파일을 작성해서 사용하고 있습니다. generate_pb


실습해보기

IDL 작성하기

stream키워드로 스트리밍 요청/응답을 정의할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
syntax = "proto3";

package setting.v1;

import "setting/v1/model/app_version_compatibility_exception.proto";

service SettingService {
 rpc Connect(ConnectRequest) returns (stream ConnectStream);
 rpc GetFrontAppBuildNumber(GetFrontAppBuildNumberRequest) returns (GetFrontAppBuildNumberResponse);
 rpc CheckAppVersionCompatibility(CheckAppVersionCompatibilityRequest) returns (CheckAppVersionCompatibilityResponse);
}

포스/프론트앱 연결을 세팅한다는 의미로 해당 package의 서비스를 정의했습니다.

  1. 연결을 맺고(이때 연결 후 stream으로 지속적인 상태를 전송합니다.)
  2. 판매앱/프론트앱이 서로 호환가능한 버전인지 체크 후
  3. 마지막으로 연결 가능 여부를 알려줍니다.

Server(Sellmate POS)

grpc 서버를 생성합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
static final Server grpcServer = Server.create(
 services: [
   SettingService(),
 ],
 codecRegistry: CodecRegistry(
   codecs: const [
     IdentityCodec(), GzipCodec(),
   ],
 ),
);

server_pbgrpc_ex

package별로 생성된 서비스를 정의합니다. server_service_ex

Client(Sellmate POS Front)

client channel을 생성해줍니다.

gRPC는 50051포트를 기본 포트로 사용합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
static ClientChannel? clientChannel;
static void createChannel(String address) {
 clientChannel = ClientChannel(address,
   port: 50051,
   options: ChannelOptions(
     credentials: const ChannelCredentials.insecure(),
     codecRegistry: CodecRegistry(codecs: const [IdentityCodec(),GzipCodec(),],
     )),
 );
}

server에 연결합니다.

1
2
3
4
5
6
7
8
9
Stream<ConnectStream> connect(
   {required String address}) {
 try {
   ClientChannel channel = posFrontGRPCDataSource.createClientChannel(address);
   return posFrontGRPCDataSource.settingServiceClient(channel).connect(ConnectRequest());
 } catch (error) {
   throw error;
 }
}

👀Stream을 받는 곳에서는 구독하여 상태를 관찰할 수 있습니다.

1
2
3
4
5
6
7
Stream<ConnectStream> connectStream =
   _posFrontSettingRepository.connect(address: address);
 connectStream.listen(
   (_) {
     print(DateTime.now());
   },
 );

마지막으로 우리의 목표인 실시간 판매데이터 연동을 위한 IDl을 작성해볼까요?

상품 리스트

scan_product_1

결제 정보

scan_product_2



회고

gRPC로 통신 방법을 바꾸면서 이러한 생각이 들었습니다.

  1. 강력한 서비스 정의로 개발 비용 절갑
  2. 한눈에 파악 가능한 서비스 정의
  3. 아직은 아쉬운 Dart 지원(Reflection 지원 없음🥹)

아직 마이그레이션을 100% 진행하지 못했지만 매우 긍정적인 결과가 나올 것으로 예상됩니다!

앞으로 POS Front앱은 많은 서비스를 제공할 예정인데요, 많은 관심과 가져주시길 부탁드립니다🙏🏻
또한 여러분들의 gRPC 기술 경험과 다른 기술에 대한 경험을 공유해주세요! waiting

긴글 읽어주신 분들께 감사의 말씀 전합니다!

wink





출처

protocol Buffer

gRPC

버즈빌