gRPC ③ gRPC + 스프링부트 프로젝트 구성해보기
이번엔 gRPC의 이해를 높이기 위해서 스프링부트 프로젝트를 이용하여 client 모듈과 gRPC 서버 모듈로 분리하여 서로 통신하는 것을 확인해보자.
참고한 글
Spring Boot + gRPC (and, my experience of gRPC)
이번 포스트는 google에서 개발한 HTTP-based RPC Framework, gRPC를 소개하려 한다. Spring Boot를 사용해 gRPC Server를 만드는 방법을 알아보자.
medium.com
gRPC 사용법, gRPC 예제 코드 실행해보기, 원리는 몰라도 gRPC 입문은 가능하다 (grpc java example)
이 포스트는 springcamp2017에서 grpc발표를 하신 오명운님의 발표 자료 및 github소스를 참고해서 작성한 것입니다. gRPC의 장점 service 정의가 단순하다 여러 프로그래밍 언어나 플랫폼에서 사용이 가
jeong-pro.tistory.com
GitHub - LogNet/grpc-spring-boot-starter: Spring Boot starter module for gRPC framework.
Spring Boot starter module for gRPC framework. . Contribute to LogNet/grpc-spring-boot-starter development by creating an account on GitHub.
github.com
참고한 영상
- 👍🏼 ✨ 코드 → https://github.com/DevProblems/grpc-with-springboot
- 모든 코드는 윗 영상에서 참고하여 연습하였습니다. 🙌
GitHub - DevProblems/grpc-with-springboot: This repo is part of my youtube video. It covers gRPC with spring boot demo
This repo is part of my youtube video. It covers gRPC with spring boot demo - GitHub - DevProblems/grpc-with-springboot: This repo is part of my youtube video. It covers gRPC with spring boot demo
github.com
1. 기본 프로젝트 실행하기
- 먼저 https://start.spring.io/에서 다음과 같이 프로젝트를 생성해준다.
- 앞으로 구성할 전체적인 프로젝트 구조는 다음과 같다.
2. 프로젝트에 proto 모듈 생성
- proto 모듈에는 기본적으로 grpc의 proto base files를 생성하기위한 정보를 담는다.
- 먼저 pom.xml에 다음과 같이 grpc 사용을 위한 의존관계를 추가해준다.
- 여기서 ‘:osx-x86_64’는 자신의 컴퓨터 버전에 알맞게 설정해준다.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>grpc-with-springboot</artifactId>
<groupId>com.example</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>proto</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.30.0</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.30.0</version>
</dependency>
</dependencies>
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.6.1</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>
com.google.protobuf:protoc:3.3.0:exe:osx-x86_64
</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>
io.grpc:protoc-gen-grpc-java:1.4.0:exe:osx-x86_64
</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
- proto / src / main / proto 폴더에 schema.proto 파일을 생성해준다.
- unary - synchronous, server streaming - asynchronous, client streaming - asynchronous, bidirectional streaming - asynchronous 종류의 서비스를 생성
// 프로토 최신 버전사용
syntax = "proto3";
// 사용할 패키지
package com.example;
// 파일이 자바 멀티플 파일 바탕으로 생성되도록
option java_multiple_files = true;
// 책과 작가의 클래스 정의
message Book {
int32 book_id = 1;
string title = 2;
float price = 3;
int32 pages = 4;
int32 author_id = 5;
}
message Author {
int32 author_id = 1;
string first_name = 2;
string last_name = 3;
string gender = 4;
int32 book_id = 5;
}
service BookAuthorService {
// unary - synchronous
// client will send one request and server will respond with one response
rpc getAuthor(Author) returns(Author){}
// server streaming - asynchronous
// client will send one request and server will respond with stream of messages to the client
rpc getBookByAuthor(Author) returns(stream Book){}
// client streaming - asynchronous
// client will send stream of messages and server will respond with one response
rpc getExpensiveBook(stream Book) returns(Book){}
// bidirectional steaming - asynchronous
// client will send stream of messages and server will respond with stream of messages to the client
rpc getBookByAuthorGender(stream Book) returns(stream Book){}
}
- 생성된 proto 폴더를 sources root로 지정해준다.
- proto / src / main / java / com / example에 TempDb(테스트용 데이터베이스)를 추가로 작성해준다.
package com.example;
import java.util.ArrayList;
import java.util.List;
public class TempDb {
public static List<Author> getAuthorsFromTempDb() {
return new ArrayList<Author>() {
{
add(Author.newBuilder().setAuthorId(1).setBookId(1).setFirstName("Charles").setLastName("Dickens").setGender("male").build());
add(Author.newBuilder().setAuthorId(2).setFirstName("William").setLastName("Shakespeare").setGender("male").build());
add(Author.newBuilder().setAuthorId(3).setFirstName("JK").setLastName("Rowling").setGender("female").build());
add(Author.newBuilder().setAuthorId(4).setFirstName("Virginia").setLastName("Woolf").setGender("female").build());
}
};
}
public static List<Book> getBooksFromTempDb() {
return new ArrayList<Book>() {
{
add(Book.newBuilder().setBookId(1).setAuthorId(1).setTitle("Oliver Twist").setPrice(123.3f).setPages(100).build());
add(Book.newBuilder().setBookId(2).setAuthorId(1).setTitle("A Christmas Carol").setPrice(223.3f).setPages(150).build());
add(Book.newBuilder().setBookId(3).setAuthorId(2).setTitle("Hamlet").setPrice(723.3f).setPages(250).build());
add(Book.newBuilder().setBookId(4).setAuthorId(3).setTitle("Harry Potter").setPrice(423.3f).setPages(350).build());
add(Book.newBuilder().setBookId(5).setAuthorId(3).setTitle("The Casual Vacancy").setPrice(523.3f).setPages(450).build());
add(Book.newBuilder().setBookId(6).setAuthorId(4).setTitle("Mrs. Dalloway").setPrice(623.3f).setPages(550).build());
}
};
}
}
- proto 모듈의 maven compile을 실행하여 proto base files가 다음과 같이 자동 생성되는 것을 확인한다.
- grpc-java 폴더와 java 폴더를 generated sources root로 지정해준다.
- proto 모듈의 전체적인 구조는 다음과 같다.
3. 프로젝트에 grpc-service 모듈 생성
- grpc 서버 파트를 담당할 모듈을 생성하고 다음과 같이 dependency를 추가해준다.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>grpc-with-springboot</artifactId>
<groupId>com.example</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>grpc-service</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>proto</artifactId>
<version>0.0.1-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
</exclusion>
<exclusion>
<groupId>io.grpc</groupId>
<artifactId>google-protobuf</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-spring-boot-starter</artifactId>
<version>2.9.0.RELEASE</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- grpc-service / src / main / java / com / example / BookAuthorServerService 클래스를 생성한다.
- BookAuthorServerService 클래스는 BookAuthorServiceGrpc.BookAuthorServiceImplBase를 상속받고 메서드를 오버라이딩 해준다.
- 각각의 메서드는 StreamObserver 는 client streaming 유무에 따라 다르게 작성된다.
package com.example;
import io.grpc.stub.StreamObserver;
import net.devh.boot.grpc.server.service.GrpcService;
import java.util.ArrayList;
import java.util.List;
// proto에 정의한 메서드를 만듦
@GrpcService
public class BookAuthorServerService extends BookAuthorServiceGrpc.BookAuthorServiceImplBase {
// onNext : back to client, onCompleted : call the successful end of the method
@Override
public void getAuthor(Author request, StreamObserver<Author> responseObserver) {
TempDb.getAuthorsFromTempDb().stream()
.filter(author -> author.getAuthorId() == request.getAuthorId())
.findFirst()
.ifPresent(responseObserver::onNext);
responseObserver.onCompleted();
}
@Override
public void getBookByAuthor(Author request, StreamObserver<Book> responseObserver) {
TempDb.getBooksFromTempDb().stream()
.filter(book -> book.getAuthorId() == request.getAuthorId())
.forEach(responseObserver::onNext);
responseObserver.onCompleted();
}
@Override
public StreamObserver<Book> getExpensiveBook(StreamObserver<Book> responseObserver) {
return new StreamObserver<Book>() {
Book expensiveBook = null;
float priceTrack = 0;
// 각각의 책들은 book 파라미터로 넘어올 것이다
@Override
public void onNext(Book book) {
if (book.getPrice() > priceTrack) {
priceTrack = book.getPrice();
expensiveBook = book;
}
}
@Override
public void onError(Throwable throwable) {
responseObserver.onError(throwable);
}
@Override
public void onCompleted() {
// 다시한번 onNext 를 call (book 1개를 리턴하기 때문)
responseObserver.onNext(expensiveBook);
responseObserver.onCompleted();
}
};
}
@Override
public StreamObserver<Book> getBookByAuthorGender(StreamObserver<Book> responseObserver) {
return new StreamObserver<Book>() {
List<Book> books = new ArrayList<>();
@Override
public void onNext(Book book) {
TempDb.getBooksFromTempDb().stream()
.filter(booksFromDb -> book.getAuthorId() == booksFromDb.getAuthorId())
.forEach(books::add);
}
@Override
public void onError(Throwable throwable) {
responseObserver.onError(throwable);
}
@Override
public void onCompleted() {
// 클라이언트의 옵저버를 부름
books.forEach(responseObserver::onNext);
responseObserver.onCompleted();
}
};
}
}
- 가장 메인 클래스인 ServerApplication은 다음과 같이 작성해준다.
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ServerApplication {
public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}
}
- resources / application.ym에 client 서버와 포트를 다르게 설정하기 위해 다음과 같이 작성한다.
- 톰캣 포트는 8081에, grpc 포트(기본 9090)는 9000에 열린다.
server:
port: 8081
grpc:
server:
port: 9000
- grpc-service 모듈의 구조는 다음과 같다.
4. 프로젝트에 client-service 모듈 생성
- grpc 서버를 이용할 클라이언트 모듈을 생성하고 다음과 같이 dependency를 추가해준다.
- 여기서 grpc-client-spring-boot-starter가 서버와 다름을 확인할 수 있다.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>grpc-with-springboot</artifactId>
<groupId>com.example</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>client-service</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>proto</artifactId>
<version>0.0.1-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
</exclusion>
<exclusion>
<groupId>io.grpc</groupId>
<artifactId>google-protobuf</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-client-spring-boot-starter</artifactId>
<version>2.9.0.RELEASE</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.12.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
- client-service / src / main / java / com / example / controller 폴더에 BookAuthorController 에 다음과 같이 작성한다.
- 이를 통해 client 호스트를 통해서 들어온 Rest API 요청이 전달된다.
- 전체적인 흐름
- 클라이언트의 BookAuthorController 를 통해 요청이 들어옴
- BookAuthorClientService의 메서드를 호출 (asynchronous 의 경우에 CountDownLatch를 사용)
- streamObserver를 이용해서 BookAuthorClientService의 옵저버를 작성한다.
- 해당 서비스 메서드에서 gRPC 서버의 옵저버를 호출하여 onNext동작
- gRPC의 서비스 메서드에서는 클라이언트 서비스의 옵저버를 통해 onNext 및 onComplete동작 수행
- 클라이언트 서비스 메서드에서 onComplete 수행 및 반환값 반환
- 결과가 controller로 전해짐
import com.google.protobuf.Descriptors;
import java.util.List;
import java.util.Map;
@RestController
public class BookAuthorController {
private final BookAuthorClientService bookAuthorClientService;
public BookAuthorController(BookAuthorClientService bookAuthorClientService) {
this.bookAuthorClientService = bookAuthorClientService;
}
@GetMapping("/authors/{authorId}")
public Map<Descriptors.FieldDescriptor, Object> getAuthor(@PathVariable String authorId){
return bookAuthorClientService.getAuthor(Integer.parseInt(authorId));
}
@GetMapping("/books/authors/{authorId}")
public List<Map<Descriptors.FieldDescriptor, Object>> getBooksByAuthor(@PathVariable String authorId) throws InterruptedException {
return bookAuthorClientService.getBooksByAuthor(Integer.parseInt(authorId));
}
@GetMapping("/expensive-book")
public Map<String, Map<Descriptors.FieldDescriptor, Object>> getExpensiveBook() throws InterruptedException {
return bookAuthorClientService.getExpensiveBook();
}
@GetMapping("/books/by-authors-gender/{gender}")
public List<Map<Descriptors.FieldDescriptor, Object>> getBooksByAuthorGender(@PathVariable String gender) throws InterruptedException {
return bookAuthorClientService.getBooksByAuthorGender(gender);
}
}
- client-service / src / main / java / com / example / service 폴더에 BookAuthorClientService 에 다음과 같이 작성한다.
- @GrpcClient 어노테이션을 통해서 grpc 통신에 사용할 채널을 정해준다.
- synchronous, asynchronous 등의 여부에 따라서 BookAuthorServiceBlockingStub, BookAuthorServiceStub 등을 정한다.
package com.example.service;
import com.example.Author;
import com.example.Book;
import com.example.BookAuthorServiceGrpc;
import com.example.TempDb;
import com.google.protobuf.Descriptors;
import io.grpc.stub.StreamObserver;
import net.devh.boot.grpc.client.inject.GrpcClient;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@Service
public class BookAuthorClientService {
// (채널)을 열어준다
@GrpcClient("grpc-example-service")
BookAuthorServiceGrpc.BookAuthorServiceBlockingStub synchronousClient;
@GrpcClient("grpc-example-service")
BookAuthorServiceGrpc.BookAuthorServiceStub asynchronousClient;
public Map<Descriptors.FieldDescriptor, Object> getAuthor(int authorId) {
Author authorRequest = Author.newBuilder().setAuthorId(authorId).build();
Author authorResponse = synchronousClient.getAuthor(authorRequest);
return authorResponse.getAllFields();
}
// 비동기 설정
public List<Map<Descriptors.FieldDescriptor, Object>> getBooksByAuthor(int authorId) throws InterruptedException {
// streamObserver는 다른 쓰레드에서 동작하게 된다
// main Thread는 다른 쓰레드가 완료하기 까지 기다려야만 한다
// rest api는 반응을 빠르게 갖는다
final CountDownLatch countDownLatch = new CountDownLatch(1);
Author authorRequest = Author.newBuilder().setAuthorId(authorId).build();
final List<Map<Descriptors.FieldDescriptor, Object>> response = new ArrayList<>();
asynchronousClient.getBookByAuthor(authorRequest, new StreamObserver<Book>() {
@Override
public void onNext(Book book) {
response.add(book.getAllFields());
}
// 만약 오류가 발생하면 countDown을 하여 메인 쓰레드가 기다리지 않도록 한다
@Override
public void onError(Throwable throwable) {
countDownLatch.countDown();
}
@Override
public void onCompleted() {
countDownLatch.countDown();
}
});
// 하나의 쓰레드가 완료하기 까지 1분 기다림
boolean await = countDownLatch.await(1, TimeUnit.MINUTES);
// 완료 되었으면 반응을 return, 아니면 빈 리스트 반환
return await ? response : Collections.emptyList();
}
public Map<String, Map<Descriptors.FieldDescriptor, Object>> getExpensiveBook() throws InterruptedException {
final CountDownLatch countDownLatch = new CountDownLatch(1);
final Map<String, Map<Descriptors.FieldDescriptor, Object>> response = new HashMap<>();
// callback registration
StreamObserver<Book> responseObserver = asynchronousClient.getExpensiveBook(new StreamObserver<Book>() {
@Override
public void onNext(Book book) {
response.put("ExpensiveBook", book.getAllFields());
}
@Override
public void onError(Throwable throwable) {
countDownLatch.countDown();
}
@Override
public void onCompleted() {
countDownLatch.countDown();
}
});
TempDb.getBooksFromTempDb().forEach(responseObserver::onNext);
responseObserver.onCompleted();
boolean await = countDownLatch.await(1, TimeUnit.MINUTES);
return await ? response : Collections.emptyMap();
}
public List<Map<Descriptors.FieldDescriptor, Object>> getBooksByAuthorGender(String gender) throws InterruptedException {
final CountDownLatch countDownLatch = new CountDownLatch(1);
final List<Map<Descriptors.FieldDescriptor, Object>> response = new ArrayList<>();
// callback registration
StreamObserver<Book> responseObserver = asynchronousClient.getBookByAuthorGender(new StreamObserver<Book>() {
// 서버에서 부른 클라이언트의 옵저버 onNext
@Override
public void onNext(Book book) {
response.add(book.getAllFields());
}
@Override
public void onError(Throwable throwable) {
countDownLatch.countDown();
}
@Override
public void onCompleted() {
countDownLatch.countDown();
}
});
TempDb.getAuthorsFromTempDb()
.stream()
.filter(author -> author.getGender().equalsIgnoreCase(gender))
// server의 옵저버를 부름
.forEach(author -> responseObserver.onNext(Book.newBuilder().setAuthorId(author.getAuthorId()).build()));
responseObserver.onCompleted();
boolean await = countDownLatch.await(1, TimeUnit.MINUTES);
return await ? response : Collections.emptyList();
}
}
- 가장 메인 클래스인 ClientApplication은 다음과 같이 작성해준다.
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ClientApplication {
public static void main(String[] args) {
SpringApplication.run(ClientApplication.class, args);
}
}
- grpc 서버의 설정을 위해 다음과 같이 resources / application.yml 파일을 작성해준다.
grpc:
client:
grpc-example-service:
address: static://localhost:9000
negotiationType: plaintext
- client-service 모듈의 구조는 다음과 같다.
5. 프로젝트 실행 및 API 콜
- 이제 차례대로 올바르게 결과값을 반환하는지 확인해보자.
- client-service와 grpc-service를 run한 상태에서 API를 조회한다.
- 먼저 작가의 아이디를 통해 작가정보를 반환하는 unary - synchronous의 getAuthor 메소드
- 두번째로, 작가의 아이디를 통해 작가의 책 정보들을 반환하는 server streaming - asynchronous의 getBooksByAuthor 메소드
- 세번째로, 주어진 TempDb의 책중에서(client streaming) 가장 비싼 책 정보를 반환하는 client streaming - asynchronous의 getExpensiveBook 메소드
- 마지막으로, 주어진 TempDb의 책중에서(client streaming) 주어진 gender 정보와 일치하는 책 리스틑 정보를(server streaming) 반환하는 bidirectional streaming - asynchronous의 getBooksByAuthorGender 메소드
- 알파벳 케이스 ignore를 이용했기 때문에 다음과 같이 조회해도 올바른 결과 값을 조회하는 것을 확인이 가능하다.