🔥 gRPC?
server to server 통신을 할 때 원격 서버의 함수를 로컬 함수처럼 호출하게 해주는 통신 방식(RPC 프레임워크)이다. 서비스와 메시지를
.proto 파일(Protocol Buffers) 로 정의한 후 코드 생성기로 각 언어에 맞게 클라이언트/서버 스텁 생성한다. 이후 호출하면 된다. HTTP/2 기반 스트리밍, 멀티플렉싱과 바이너리 직렬화(Protobuf) 덕분에 REST/JSON 방식보다 특정 상황에서 더 효율적이며 MSA 등 서버 간 통신에서 여러 언어들의 일관된 인터페이스를 제공한다.🚀 .proto
직렬화할 데이터의 스키마(구조)를
.proto 파일에 정의하여 사용할 수 있다. 프로토콜 버퍼 데이터는 메시지로 구성되며, 각 메시지는 여러 개의 필드(name-value) 로 이루어져있다. 각 필드는 고유 태그 번호도 가진다.- example
message Person { string name = 1; int32 id = 2; bool has_ponycopter = 3; }
정의한
.proto 파일을 프로토콜 버퍼 컴파일러인 protoc 를 사용해 데이터 접근 클래스를 생성한다.생성된 클래스는
name()/setName() 같은 필드 접근자와 전체 구조를 바이트 배열로 직렬화/파싱하는 메서드를 제공한다. (Java,Kotlin 이라면 class 가 생기는 것과 같다.)// Greeter 서비스 정의 service Greeter { // 메서드 정의 rpc SayHello (HelloRequest) returns (HelloReply) {} } // 요청 메시지 message HelloRequest { string name = 1; } // 응답 메시지 message HelloReply { string message = 1; }
Protocol Buffers 는 오픈 소스로 공개된지 오래됐고 문법 단순화, 언어 지원 확대, 상호 운용성 때문에 대부분 버전 3(proto3) 를 사용한다.
다시 요약하자면,
gRPC에서는 직렬화할 데이터의 스키마와 서비스 인터페이스를
.proto 파일(Protocol Buffers)로 정의한다.메시지는 타입이 있는 필드들의 집합이며 각 필드에는 고유 태그 번호가 있다.
protoc 로 메시지 클래스를 생성하고, gRPC 플러그인을 함께 사용하면 클라이언트/서버 스텁도 생성되어 원격 메서드를 로컬 메서드처럼 호출할 수 있게 된다. Java 기준에선 getter,setter 와 같은 접근자를 제공하고 메시지는 바이너리로 직렬화/역직렬화된다. 기본적으로 HTTP/2 위에서 동작하고 특정 상황에서 REST/JSON 보다 더 높은 성능을 보여준다.🔥 간단하게 구현해보자
.proto 명세서를 기반으로 통신하게 될텐데 해당 파일을 어디에서 관리할지도 고려해봐야하는데, 본 글에서는 멀티 모듈로 두어서 해당 모듈을 의존하게끔 구현한다.fruit contracts 모듈은 명세서만 모아둔 모듈이며 나머지 2개의 모듈은 테스트해볼 모듈(서버)이다.
Kiwi server 는 10051 로, Tomato server 는 20051 로 띄운다. 톰캣 포트는 각각 18080, 28080이다.
🚀 .proto 작성하기
- fruit contracts 모듈에 명세서를 작성한다.
syntax = "proto3"; package lkdcode.kiwi.v1; import "google/protobuf/empty.proto"; option java_multiple_files = true; option java_package = "lkdcode.grpc.kiwi.v1"; option java_outer_classname = "KiwiServiceV1Proto"; service KiwiService { rpc action(KiwiRequest) returns (stream google.protobuf.Empty){} } message KiwiRequest { string name = 1; }
option java_multiple_files = true;: 각각의 메시지/서비스를 별도의 .java 클래스로 생성한다. 메시지/enum 이 개별 최상위 클래스로 생성되고 false 로 설정할 경우 static 중첩 클래스로 생성된다. 조직에 알맞게 설정하자.
option java_package = "lkdcode.grpc.kiwi.v1";: 클래스의 패키지를 지정한다.
option java_outer_classname = "KiwiServiceV1Proto";: 파일 전체를 대표하는 아우터 클래스를 정의한다..proto파일 1개당 생성되는 컨테이너 자바 클래스로 파일 메타데이터, 확장 등록 함수 등이 들어간다.
🚀 server ↔ server 통신을 gRPC로!
작성된
.proto 명세서는 Kiwi 모듈이 server(구현체)고 Tomato 모듈이 client 이다.application.yml 설정을 통해 gRPC-port 와 client 입장에서 요청보낼 server-port 를 설정할 수 있다.- Kiwi
application.yml
server: port: 18080 grpc: server: port: 15001 address: 0.0.0.0
- Tomato
application.yml
server: port: 28080 grpc: server: port: 25001 address: 0.0.0.0 client: kiwi: address: static://localhost:15001 # DNS 기반 혹은 정적 주소 enableKeepAlive: true # 연결 유지를 위해 주기적으로 ping 전송. keepAliveWithoutCalls: true # 활성 RPC가 없어도 ping 보냄 인프라 정책 확인 필요. 서버/LB 너무 잦으면 연결을 끊을 수 있음 keepAliveTime: 120s # ping 주기 (유휴 시간) 마지막 데이터 전송 이후 120초 지나면 ping 전송 keepAliveTimeout: 30s # ping 보낸 뒤 응답(ACK)을 기다리는 최대 시간, 초과 시 연결이 비정상이라고 간주 maxInboundMessageSize: 10485760 # 수신 메시지 최대 크기 (bytes) negotiationType: plaintext # 전송 보안 방식, PLAINTEXT TLS 없이 평문,TLS(인증서 설정 필요)
톰캣도 띄우므로
server.port=28080 를 설정해 주고 grpc.server.port=25001 를 통해 해당 gRPC 서버가 수신할 포트도 설정해준다. grpc.server.address 를 통해 바인딩 할 IP 주소를 설정할 수 있는데 여기서는 모든 네트워크 인터페이스에 접근 가능하도록 설정한다.🚀 의존성
plugins { // Gradle 용 Protobuf 플러그인을 추가 id("com.google.protobuf") version "0.9.4" } dependencies { // gRPC 클라이언트/서버 스텁 동작 implementation("io.grpc:grpc-stub:1.75.0") // protobuf 메시지 인코딩/디코딩 implementation("io.grpc:grpc-protobuf:1.75.0") // 전송 구현 implementation("io.grpc:grpc-netty-shaded:1.75.0") // 메시지 런타임 직렬화 로직 implementation("com.google.protobuf:protobuf-java:4.32.0") // gRPC 자동 구성 제공 implementation("net.devh:grpc-spring-boot-starter:3.1.0.RELEASE") } protobuf { protoc { // 빌드 시 사용하는 프로토콜 버퍼 컴파일러 artifact = "com.google.protobuf:protoc:4.32.0" } plugins { grpc { // protoc 가 gRPC 용 자바 스텁 코드 새성 artifact = "io.grpc:protoc-gen-grpc-java:1.75.0" } } generateProtoTasks { all().forEach { it.plugins { grpc { // 생성되는 gRPC 코드에 @Generated 어노테이션 생략 option "@generated=omit" } } } } }
🚀 Tomato Api → Kiwi gRPC
gRPC 를 사용하기 위해 시나리오는 다음과 같다.
Tomato 서버는 Api 요청을 받고 내부적으로 Kiwi gRPC 를 호출한다.
Kiwi gRPC 는 service 와 repository 로 나뉘는데 크게 로직은 없고 콘솔에 출력만 한다.
🚀 Kiwi server (server)
@GrpcService 어노테이션을 붙이고 해당 프로토의 구현을 작성해주면 된다.responseObserver.onNext() 메서드를 통해 응답 메시지를 전송하고,responseObserver.onCompleted() 메서드는 응답 스트림을 완료처리한다.@GrpcService class KiwiService : KiwiServiceGrpc.KiwiServiceImplBase() { override fun validate( request: KiwiRequest, responseObserver: StreamObserver<Empty> ) { println("V1. KiwiGrpcService 호출") println("... 검증") responseObserver.onNext(Empty.getDefaultInstance()) responseObserver.onCompleted() println("V1. KiwiGrpcService 종료") } }
응답 스트림 완료 처리 후
println("V1. KiwiGrpcService 종료") 코드는 gRPC 네트워크 응답과는 무관하게 정상적으로 출력된다. onNext 만 호출하는 경우 응답 스트림이 정상적으로 종료되지 않음을 의미해 클라이언트는 계속 기다리게 되고 결국 타임아웃에 이를 수 있다.🚀 Tomato server (client)
아래와 같이 스텁 객체를 호출하여 사용할 수 있다.
// Tomato module @Service class KiwiService( @GrpcClient("kiwi") private val kiwiServiceGrpc: KiwiServiceGrpc.KiwiServiceBlockingStub, ) { fun validate(request: KiwiRequest) { println("Tomato.KiwiService 호출") kiwiServiceGrpc.validate(request) println("Tomato.KiwiService 종료") } }
// Tomato module @Service class KiwiRepository( @GrpcClient("kiwi") private val kiwiRepositoryGrpc: KiwiRepositoryGrpc.KiwiRepositoryBlockingStub, ) { fun save(request: KiwiRequest) { println("Tomato.KiwiRepository 호출") kiwiRepositoryGrpc.save(request) println("Tomato.KiwiRepository 종료") } }
@GrpcClient(”kiwi”) 는 application.yml 의 gRPC 클라이언트 설정 이름과 매칭된다.자세히보면
..BlockingStub 클래스를 호출하게 되는데 gRPC Java Client는 총 3개의 스텁을 제공한다.🎯 Blocking stub
@GrpcClient("kiwi") private val kiwiRepositoryGrpc: KiwiRepositoryGrpc.KiwiRepositoryBlockingStub,
지금 호출하고 있는 클래스이다. 동기식으로 메서드가 리턴될 때까지 현재 스레드가 블록된다.
🎯 Async stub
@GrpcClient("kiwi") private val kiwiRepositoryGrpcStub: KiwiRepositoryGrpc.KiwiRepositoryStub,
StreamObserver 콜백 기반 비동기 호출이다. 기본적으로 gRPC 내부 Executor에서 콜백을 실행한다.🎯 Future stub
@GrpcClient("kiwi") private val kiwiRepositoryGrpcFutureStub: KiwiRepositoryGrpc.KiwiRepositoryFutureStub,
역시 비동기로
ListenableFuture<T> 를 리턴하는데 then.. 체이닝 등 비동기 합성을 지원한다.Future.get() 메서드를 호출해 동기적으로 결과를 기다릴 수도 있다.🚀 요청 보내서 확인하기
- 전체 흐름
client 가
/api/tomato 앤드포인트로 요청을 보내게 되면 Tomato server 는 kiwi server 와 gRPC 통신을 하며 해당 메서드를 호출하게 된다.아래의 콘솔 출력 화면처럼 실제 요청을 보내면 확인해볼 수 있다.
