본문 바로가기
개발 지식/Backend

[RPC] gRPC 프레임 워크 적용기

by 에르주 2022. 5. 9.
반응형

MSA 아키텍처 그리고 API 개발에서 주로 Rest API를 활용했지만 서버와 클라이언트간 더 작은 payload값을 가지고 더 빠른 성능을 보여줄 수 있는 gRPC를 적용해보고자 한다.

 

gRPC와 Rest API의 자세한 사항은 다음 페이지 참고

https://docs.microsoft.com/ko-kr/aspnet/core/grpc/comparison?view=aspnetcore-6.0 

 

gRPC 서비스와 HTTP API 비교

gRPC와 HTTP API를 비교한 방법과 권장 시나리오를 알아봅니다.

docs.microsoft.com

 

 

 

두가지를 모두 고려하였으나 내가 구현하고 싶은 범위는 단순히 gRPC를 활용하여 client에서 proto Service를 조회하는 기능 뿐이었고 Armeria 같은 경우에는 너무 많은 기능을 제공하여

 

2번 grpc-spring-boot-starter를 활용하였다.

 

의존성을 다음과 같이 설정을 진행하였고

implementation("net.devh:grpc-spring-boot-starter:2.13.1.RELEASE")

 

Application Build시 proto 파일에 대한 stub class 생성을 위한 gradle 설정까지 진행하였다.

(Maven Setting으로 하려고 하니 너무 복잡해서 gradle project로 변경하였다.)

 

 

build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import com.google.protobuf.gradle.generateProtoTasks
import com.google.protobuf.gradle.id
import com.google.protobuf.gradle.plugins
import com.google.protobuf.gradle.protobuf
import com.google.protobuf.gradle.protoc

/*
 * This file was generated by the Gradle 'init' task.
 */

val grpcVersion = "3.19.3"
val grpcKotlinVersion = "1.2.1"
val grpcProtoVersion = "1.45.1"

group = "com.er"
version = "0.0.1-SNAPSHOT"
description = "kotlintoy"

plugins {
    id("org.springframework.boot") version "2.6.7"
    id("io.spring.dependency-management") version "1.0.11.RELEASE"
    kotlin("jvm") version "1.6.21"
    kotlin("plugin.spring") version "1.6.21"
    id("com.google.protobuf") version "0.8.18"
}

apply(plugin = "java")
apply(plugin = "idea")
apply(plugin = "eclipse")

java.sourceCompatibility = JavaVersion.VERSION_11

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "11"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

repositories {
    mavenCentral()
}

dependencies {

    // SPRING BOOT 설정
    implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive")
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
    implementation("org.springframework.boot:spring-boot-starter-webflux")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")

    implementation("org.projectlombok:lombok:1.18.24")
    implementation("org.mapstruct:mapstruct:1.4.2.Final")

    // GRPC 설정
    implementation("io.grpc:grpc-stub:$grpcProtoVersion")
    implementation("io.grpc:grpc-protobuf:$grpcProtoVersion")
    implementation("io.grpc:grpc-kotlin-stub:1.2.0")
    implementation("io.grpc:grpc-netty-shaded:$grpcProtoVersion")
    implementation("com.google.protobuf:protobuf-kotlin:$grpcVersion")

    runtimeOnly("org.springframework.boot:spring-boot-devtools:2.6.7")
    runtimeOnly("org.slf4j:log4j-over-slf4j:1.7.36")

    testImplementation("org.springframework.boot:spring-boot-starter-test:2.6.7")
    testImplementation("de.flapdoodle.embed:de.flapdoodle.embed.mongo:3.0.0")
    testImplementation("io.projectreactor:reactor-test:3.4.17")
    testImplementation("org.springframework.restdocs:spring-restdocs-webtestclient:2.0.6.RELEASE")

    implementation("net.devh:grpc-spring-boot-starter:2.13.1.RELEASE")
}


// gradle build시 srcDirs에 파일 생성
// gradle build의 target으로 잡히기 위해서 scrDirs 안에 path를 설정한다.
// (Client가 호출하는) sutb 클래스가 생성될 때의 경로를 설정한다.
sourceSets {
    getByName("main") {
        java {
            srcDirs(
                "build/generated/source/proto/main/java",
                "build/generated/source/proto/main/kotlin"
            )
        }
    }
}


// protobuf 생성 및 stub 클래스(java, kotlin) 생성
protobuf {
    // proto 및 stub 생성을 위한 artifact 설정 begin
    protoc {
        // protobuf compiler
        artifact = "com.google.protobuf:protoc:$grpcVersion"
    }
    plugins {
        // id값이 grpc일 경우 io.grpc:protoc-gen-grpc-java artifact를 활용하여 protobuf 생성 : JAVA
        id("grpc") {
            artifact = "io.grpc:protoc-gen-grpc-java:$grpcProtoVersion"
        }
        // id값이 grpckt일 경우 io.grpc:protoc-gen-grpc-kotlin artifact를 활용하여 protobuf 생성 : KOTLIN
        id("grpckt") {
            artifact = "io.grpc:protoc-gen-grpc-kotlin:$grpcKotlinVersion:jdk7@jar"
        }
    }
// proto 및 stub 생성을 위한 artifact 설정 end


    // protobuf 생성
    generateProtoTasks {
        all().forEach {
            it.plugins {
                id("grpc")
                id("grpckt")
            }
            it.builtins {
                id("kotlin")
            }
        }
    }
}

 

특히 이번 toy Project에서는 kotlin을 활용하고 있기 때문에 stub class로 kotlin 생성까지 설정하였고

경로는 "build/generated/source/proto/main/kotlin"으로 설정하였다.

srcDirs(
    "build/generated/source/proto/main/java",
    "build/generated/source/proto/main/kotlin"
)

it.builtins {
    id("kotlin")
}

 

프로젝트 언어로 kotlin을 활용한다고 하더라도 반드시 kotlin 경로 및 generateProtoTasks kotlin을 생성할 필요는 없다.

Java Class를 그대로활용할 수 있으며 kotlin 은 Java Class를 kotlin 형태로 래핑한 형태 이기 때문이다. 


즉 Java Class를 활용하거나 kotlin 함수를 활용하거나 결과는 똑같다.

 

// JAVA CLASS 적용
class BaseGrpcServerService(var baseRepository: BaseRepository) : BaseProtoServiceGrpc.BaseProtoServiceImplBase() {


    override suspend fun retrieveBaseOnDB(request: BaseRequest): BaseResponse {

        val baseDTO = baseRepository.findByBaseId(request.baseId)

        return BaseResponse.newBuilder()
            .setBaseId(withContext(Dispatchers.IO) {
                baseDTO.blockFirst()
            }?.baseId)
            .setBaseName(withContext(Dispatchers.IO) {
                baseDTO.blockFirst()
            }?.baseName)
            .setBaseNumber(withContext(Dispatchers.IO) {
                baseDTO.blockFirst()
            }?.baseNumber)
            .build()
    }
}


// KOTLIN Coroutine 활용
@GrpcService
class BaseGrpcServerService(var baseRepository: BaseRepository) : BaseProtoServiceGrpcKt.BaseProtoServiceCoroutineImplBase() {


    override suspend fun retrieveBaseOnDB(request: BaseRequest): BaseResponse {

        val baseDTO = baseRepository.findByBaseId(request.baseId)

        return BaseResponse.newBuilder()
            .setBaseId(withContext(Dispatchers.IO) {
                baseDTO.blockFirst()
            }?.baseId)
            .setBaseName(withContext(Dispatchers.IO) {
                baseDTO.blockFirst()
            }?.baseName)
            .setBaseNumber(withContext(Dispatchers.IO) {
                baseDTO.blockFirst()
            }?.baseNumber)
            .build()
    }
}

// 결과는 동일하다.

 

 

 

Baseproto.proto

syntax = "proto3";

// com.er.kotlintoy.grpc.v1.BaseProtoService/retrieveBaseOnDB
package com.er.kotlintoy.grpc.v1;

option java_multiple_files = true; // message와 enum, service 각 java 파일로 생성
option java_outer_classname = "BaseProto"; // 생성될 자바코드의 클래스명 (camel case 변환)

// The BaseProtoService service definition.
// Client에서 stub 객체로 호출할 서비스
service BaseProtoService {
  rpc retrieveBaseOnDB (BaseRequest) returns (BaseResponse);
}

// Request messagex
message BaseRequest {
  string baseId = 1;
}

// Response message
message BaseResponse {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  string baseId = 4;
  string baseName = 5;
  string baseNumber = 6;

}

 

proto 파일에서 enum, repeated(List)를 활용할 수 있었지만 이번 프로젝트에서는 message만을 활용해도 되기 때문에 다음과 같이 설정하였다.

 

1. syntax = "proto3" 

  •  proto3 문법으로 해당 파일이 작성 되었다.

2. package com.er.kotlintoy.grpc.v1 

  •  stub 객체 생성 package 경로를 설정하였다. (밑의 사진 확인)

3. java_multiple_files = true (defalut 값은 false)

  • false인 경우 BaseProto.class 파일만 생성
  • true인 경우 message, enum, repeated 등 각각의 class파일이 독립적으로 생성

좌측 false, 우측 true

 

코드로 간단히 예시를 들면 BaseProto 객체 활용시 다음과 같은 차이가 있다.

// false 인 경우
// BaseProto.BaseRequest, BaseProto.BaseReponse와 같이 BaseProto 객체 안의 메소드 호출
@GrpcService
class BaseGrpcServerService() : BaseProtoServiceGrpcKt.BaseProtoServiceCoroutineImplBase() {

    override suspend fun retrieveBaseOnDB(request: BaseProto.BaseRequest): BaseProto.BaseResponse {

        return BaseProto.BaseResponse.newBuilder()
            .setBaseId("baseId")
            .setBaseName("baseName")
            .setBaseNumber("BaseNumber")
            .build()
    }
}

 

// true 인 경우
// BaseRequest, BaseResponse와 같이 독립적인 객체 활용
@GrpcService
class BaseGrpcServerService(var baseRepository: BaseRepository) : BaseProtoServiceGrpcKt.BaseProtoServiceCoroutineImplBase() {

    override suspend fun retrieveBaseOnDB(request: BaseRequest): BaseResponse {

        return BaseResponse.newBuilder()
            .setBaseId("baseId")
            .setBaseName("baseName")
            .setBaseNumber("BaseNumber")
            .build()
    }
}

 

4. java_outer_classname = "BaseProto"

  • Java class명을 설정할 수 있으며 camel Case로 변환하여 파일이 생성된다.

5. (baseProto)service BaseProtoService {
  rpc retrieveBaseOnDB (BaseRequest) returns (BaseResponse); }

  • Stub 객체  (BaseProtoService.class 또는 BaseProtoService.kt)에서 파라미터 값으로 BaseRequest를 가진 retriveBaseOnDB 메소드 또는 함수 실행하게 된다면 BaseResponse를 리턴해줄 것이다.

 

stub 객체 호출 (client -> Server)

 

추가적으로 gRPC 서버 port를 9090으로 지정해주자.

grpc:
  server:
    port: 9090

 


이제 설정은 모두 완료 되었다!

gRPC를 활용하여 API 호출을 해보자. 

 

netty, gRPC RUN

 

Client 또한 로직 개발 후 테스트 또는 터미널 grpcurl 명령어로 테스트를 진행 할 수 있지만 우리에게는 Rest API 개발 진행하면서 훌륭한 UI를 가진 test 프로그램 Postman이 있으므로 Postman으로 테스트를 진행 하였다.

 

gRPC Test Postman

 

Enter Server URL

  • gRPC 서버와 Port 입력 (localhost:9090)

Choose a way to load Service and methods

  • 우리가 호출하려는 Stub Class의 패키지 경로 (com.er.kotlintoy.grpc.v1)

패키지 경로 찾기

Postman에서 stub Class의 경로를 찾는 네가지가 있다.

 

1) Use server reflection : 서버 내 존재하고 있는 stub class 조회 하기

클래스 내 stub class 경로 전부 보여주기

 

2) Import protobuf definition from local file : .proto 파일을 직접 등록하기

 

직접 등록

3) Import protobuf definition from local URL : url 내 .proto 파일을 직접 등록하기

 

4) Create a new protobuf defintion : 직접 proto 파일 형식을 정의 및 생성하기

직접 protobuf 파일 정의

 

 

select a method

  • stub class내에서 호출하려고 하는 method 명 (retrieveBaseOnDB)

 

 

 

테스트 하려는 stub class와 그 안의 method까지 설정하고 빨간 테두리의 Generate Example Message를 클릭하면

 



 

다음과 같은 retrieveBaseOnDB의 파라미터값을 받는 baseId로 자동으로 설정해준다.

 

실행(Invoke)하면 우리가 base 값들을 설정한 것들이 리턴하는 것을 볼 수 있다.

 


그럼 더 나아가서 baseId값을 받아 MongoDB에서 해당 base값들을 조회하는 로직을 넣어보자.

@GrpcService
class BaseGrpcServerService(var baseRepository: BaseRepository) : BaseProtoServiceGrpcKt.BaseProtoServiceCoroutineImplBase() {


    override suspend fun retrieveBaseOnDB(request: BaseRequest): BaseResponse {

        val baseDTO = baseRepository.findByBaseId(request.baseId)
        
        return BaseResponse.newBuilder()
            .setBaseId(withContext(Dispatchers.IO) {
                baseDTO.blockFirst()
            }?.baseId)
            .setBaseName(withContext(Dispatchers.IO) {
                baseDTO.blockFirst()
            }?.baseName)
            .setBaseNumber(withContext(Dispatchers.IO) {
                baseDTO.blockFirst()
            }?.baseNumber)
            .build()
    }
}

 

 

baseId가 "erer"인 값을 조회 해보자.

 

erer 조회하기

 

"erer"에 해당하는 데이터가 조회되어 리턴되는 것을 확인 할 수 있다.

 

 

 

stub class의 패키지 경로 또는 service 객체명이 맞지 않는다면 다음과 같은 에러가 발생 할 수 있다.

Error

 

 

gRPC 서버 상태 값 또한 확인 할 수 있는데 

grpc.health.v1.Health/Check stub class를 호출 하면 정상적인 서버 상태에는 다음과 같은 응답값이 발생한다.

 

 

 

또한 gRPC 호출 관련한 grpc.health.v1.Health/Watch로 Trace 정보까지 확인 할 수 있다.

Watch

 

끝.

반응형

댓글