프로그래밍/넥스터즈

[Nexters]넥스터즈 (URL 단축팀) 활동 - 서버간 통신 gRPC 적용기

Jay Tech 2019. 3. 10. 23:24
반응형

gRPC 적용 내용

의존성 추가

먼저 의존성 추가를 하는데 이렇게 3가지가 들어간다.

       <dependency>
           <groupId>io.grpc</groupId>
           <artifactId>grpc-netty-shaded</artifactId>
           <version>1.18.0</version>
       </dependency>
       <dependency>
           <groupId>io.grpc</groupId>
           <artifactId>grpc-protobuf</artifactId>
           <version>1.18.0</version>
       </dependency>
       <dependency>
           <groupId>io.grpc</groupId>
           <artifactId>grpc-stub</artifactId>
           <version>1.18.0</version>
       </dependency>

그리고 컴파일러또한 의존성에 추가해준다. 출처 : grpc 공식 repo

그러면 proto 파일들의 기본 경로가 java폴더와 같은 level에 잡히게 된다.

proto 파일의 정의

그리고 이 폴더안에 .proto라는 파일을 만들어주게 되는데 자세한 내용은 이 쪽을 참고하였다. 출처 : protobuf from google

이 프로토버프 파일은 언어의 제약이 없다. (거의) 즉 이것의 최고의 장점은 범용성이다. 자바로 짜여진 grpc 서버와 파이썬인 클라이언트와 통신을 할 수 있다.

우리 프로젝트 에서 사용할 프로토파일을 정의해 보았다.

(.proto 플러그인을 설치를 여러번했는데 적용이 안된다.... syntax 체크를 할 수 없다...)

syntax는 proto3버젼으로 정의하였다. 그리고 message가 모델을 정의하는 keyword이다. 그리고 뒤에 숫자는 일종의 tag로써 순서를 부여하는 것이다. 저 값으로 초기화한다는 것이 아님!! 자료형은 위의 링크를 가서 확인하면 알 수 있다.

Url은 통계서버로 던질 메세지이고 Success는 통계서버측에서 메세지를 받기 위한 것이다.

proto파일을 컴파일 할 때 주의점!

일단 mvn clean으로 청소하고 mvn compile로 컴파일을 해주자.

generate 된 코드는 여기에 들어간다. proto 파일에서 만든 코드들이 자동으로 생성된다. 이것을 열어서 편집할 필요는 전혀 없다. 위치만 기억하면 된다. 그리고 중요한 점! 기본적으로 source로 등록된 폴더는 java 폴더 하나 일 것이다. File -> Project Structure -> Modules로 가서 밑에 그림처럼 protobuf로 들어가서 2개의 폴더를 클릭 후 Sources버튼을 클릭하여 우측에 파란색으로 등록되게 한다. 그렇지 않으면 코드상에서 파일경로를 찾을 수없다.

gRPC의 서비스

그리고 gRPC의 핵심인 서비스를 정의한다. 이 서비스에 정의할 수 있는 함수는 크게 4가지로 분류해 볼 수 있다.

  • 서버와 클라이언트간 단항 통신

  • 서버쪽에서 스트림을 여는 통신 (rpc 메서드 첫 번째 괄호에 stream을 넣어주면 된다)

  • 클라이언트에서 스트림을 여는 통신 (rpc 메서드 returns 다음에 나오는 괄호에 stream을 넣어주면 된다)

  • 서버와 클라이언트가 모두 스트림을 여는 통신 (Bidirectional 한 통신, http2의 장점 중 하나, 양쪽에 stream을 넣어주면 된다)

그림을 그려보고 튜토리얼의 모든 예제를 분석해보았다. 그리고 우리 프로젝트에 맞는것이 어떤것인지 고민하였다.

그럼 2번 방식부터 보자. 서버쪽에서 스트림을 여는경우이다. 예를들어, 클라이언트가 HumanRequest 모델에 age를 20을 담고 보냈다. 서버 측은 (가정) age가 20이상인 Human들을 보내준다. 일반적인 rest통신에서는 배열에 담거나 리스트에 담거나 해서 한번에 보내줄 것이다. 하지만 스트림을 열면 하나씩 계속 쏘게 된다. 그리고 서버는 다 보냈어 하고 onComplete()라는 것을 호출한다. 그러면 클라이언트는 서버가 다 보낸것을 인지하고 연결을 닫게된다. (StreamObserver의 역할)

3번 방식은 클라이언트에서 스트림을 여는 경우이다. 예를들어, 클라이언트가 10개의 모델을 보내고 싶다고 가정하자. 스트림이 열려있으므로 되는대로 일단 던진다. 서버 측은 10개를 다 받으면 그 값을 가공하여 하나의 응답을 보낸다.(가정) 클라이언트는 다 보냈으면 onComplete()를 해주면 서버는 다 보냈구나라고 인식하여 응답을 보내고 연결을 닫게된다.

4번 방식은 양방향 통신이다. 예를들어, 현재 채널에 연결되어 있는 모든 클라이언트들은 각자 메세지를 서버에 보낸다. 서버는 그 메세지를 하나하나 받고 (StreamObserver의 배열이 하나씩 관리하게 된다) 전부 뿌려줄 수 있다. 즉 내가 보낸 메세지는 옆 사람도 모두 받게될 수 있다. (채널에 연결되어 있는 한)

마지막 1번 방식이다. 우리 프로젝트에 가장 적합하다고 판단했다. 단항 통신이다.

Q : 비동기이면 스트림을 써야할 텐데 단항이면 그게 안되는거 아닌가? 단항 통신도 동기/비동기 방식을 구현할 수 있다.

그러기전에 먼저 stub을 알아야 한다.


Stub

stub은 서버와 클라이언트의 약속 구현체이다. 즉 이둘은 stub을 통해서 통신을 할 수 있다. 열려있는 채널에 stub을 등록하여 사용할 수 있다. Stub은 크게 두가지 이다. 동기와 비동기.

private UrlClickServiceGrpc.UrlClickServiceStub urlClickStub;
private UrlStatsServiceGrpc.UrlStatsServiceBlockingStub urlStatsServiceBlockingStub;

우리 프로젝트 안의 코드이다. Url을 클릭했을 때 Redirect가 된다. 그러면 클라이언트는 리다이렉트된 주소로 이동 될 것이고 뒷 단에서는 통계서버쪽으로 url의 정보와 사용자의 정보가 넘어가게 된다. 넘어가는 과정이 stub을 통해서 이루어진다. 이것은 비동기로 이루어져야 한다. 통계서버로 넘긴 메세지에 대한 응답은 사용자는 알 필요가 없고 그것 때문에 로딩이 되면 안된다. 그래서 UrlClickService의 스텁은 AsyncStub이다. (gRPC 디폴트 스텁이 async이다. 블로킹으로 구현하려면 BlockingStub을 써야한다.)

@GetMapping("/{shortenUrl}")
   @ApiOperation(value = "Url 리다이렉트", notes = "단축 Url을 리다이렉트 해준다", response = UrlResponseDto.class)
   public ResponseEntity<UrlResponseDto> redirect(@PathVariable("shortenUrl") String shortenUrl,
                                                  @RequestHeader(value = "Referer",required = false, defaultValue = "none") String referer,
                                                  @RequestHeader(value = "User-Agent", defaultValue = "myBrowser") String userAgent){

       Url url = redirectService.redirect(shortenUrl);

       String originUrl = url.getOriginUrl();

       UrlResponseDto responseDto = UrlResponseDto.builder()
              .originUrl(originUrl)
              .shortUrl(shortenUrl)
              .build();

       // gRPC 비동기 호출
       grpcClient.insertStatsToStatsServer(shortenUrl, referer, userAgent);

       HttpHeaders headers = new HttpHeaders();
       headers.setLocation(URI.create(originUrl));

       return new ResponseEntity<>(responseDto, headers, HttpStatus.MOVED_PERMANENTLY);
  }

리 다이렉트 부분에서 비동기 호출 부분 코드는 실행 되는 순간 그냥 뒤로 건너뛴다. ResponseEntity는 예정대로 바로 반환되게 된다. 극단적으로 gRPC 통계서버를 내리고 실행도 해보았다. 리다이렉트되는대는 전혀 문제가 없다.

public void insertStatsToStatsServer(String shortenUrl, String referer, String userAgent) {
       Url url = Url.newBuilder().setShortUrl(shortenUrl)
              .setClickTime(System.currentTimeMillis())
              .setPlatform(userAgent)
              .setReferer(referer).build();

       logger.info("클라이언트 측에서 클릭 정보 전송");

       urlClickStub.unaryRecordCount(url, new StreamObserver<Success>() {
           @Override
           public void onNext(Success success) {
               logger.info(success.getMessage());
          }

           @Override
           public void onError(Throwable throwable) {
               logger.error(throwable.getMessage());
          }

           @Override
           public void onCompleted() {
               logger.info("서버 응답 종료");
          }
      });
  }

urlClickStub의 unaryRecordCount는 protofile에서 정의한 서비스이다.

  rpc unaryRecordCount (Url) returns (Success);

이 메서드를 서버측에서 구현을 해 주어야 한다. 즉 공용으로 정의되어 있는 Stub을 이용하여 서버와 클라이언트가 통신하는 것이다. 클라이언트측은 서버측에 정의되어있는 unaryRecordCount라는 함수를 마치 로컬에 있는 함수를 호출하는 것처럼 이용하게 된다. Remote Procedure Call 즉, rpc의 기본개념이다.

public class UrlClickService extends UrlClickServiceGrpc.UrlClickServiceImplBase {
   public static Logger logger = LoggerFactory.getLogger(UrlClickService.class);

   @Override
   public void unaryRecordCount(Url request, StreamObserver<Success> responseObserver) {
       logger.info("shortUrl from client : " + request.getShortUrl());
       logger.info("referrer from client : " + request.getReferer());
       logger.info("platform from client : " + request.getPlatform());
       logger.info("click time from client : " + request.getClickTime());

       // 비동기 확인 할 때는 이쪽에 Thread sleep 주면 됨
       // TODO Queue insert

       Success success = Success.newBuilder().setMessage("save success : " + request.getShortUrl()).build();

       responseObserver.onNext(success);
       responseObserver.onCompleted();
  }
}

이렇게 오버라이드를 해주고 StreamObserver를 이용하여 클라이언트에 응답하는 부분을 구현한다. 로컬 테스트에서는 속도가 매우 빠르기 때문에 비동기가 제대로 동작하는지 확인하려면 주석부분에 Thread Sleep을 주고 onNext를 호출한 이후에 Thread Sleep을 주고 테스트를 해보면 클라이언트가 제대로 리다이렉트 된 후 한 참 후에 서버쪽에서 성공메세지가 오게되는 것을 볼 수 있다. (참고로 Observer는 콜백메서드로 stream을 사용하는 경우 구현할 때 매우 헷갈린다. 기본적으로 사용하게되는 [반환형] 함수이름 [매개변수] 이 양식을 따르지 않기 때문이다.)

이런식으로 리다이렉트를 구현하였고 통계 값 요청은 비동기 스텁으로 구현하였다.

Q: 통계 api를 쪼개 놨는데 이거 stream으로 보내면 안되나? A: proto 파일 내에 message 구현체의 양식이 달라서 안된다. 같은 포맷의 응답을 여러번 보낼 때 stream이 가능하지만 각기 다른 내용들이 오기 때문에 단항 동기 방식을 사용하였다. (밑에 참고)

통계 쪽 .proto 파일 내용이다.

syntax = "proto3";

option java_multiple_files = true;
option java_package = "me.nexters.chop.grpc";
option java_outer_classname = "UrlStatsProto";

package grpc;

message UrlStatsRequest {
 string short_url = 1;
}

message Platform {
 string short_url = 1;
 int32 mobile = 2;
 int32 browser = 3;
}

message Referer {
 string short_url = 1;
 repeated string referer = 2;
 repeated int32 count = 3;
}

message TotalCount {
 string short_url = 1;
 int32 total_count = 2;
}

service UrlStatsService {
 rpc getPlatformCount (UrlStatsRequest) returns (Platform);
 rpc getRefererCount (UrlStatsRequest) returns (Referer);
 rpc getTotalCount (UrlStatsRequest) returns (TotalCount);
}

레퍼러는 shortUrl당 여러개가 나오므로 repeated를 사용하였다. 문서를 찾아본 결과 repeated의 java쪽 컴파일은 List인터페이스로 반환된다고 한다. 그래서 우리 프로젝트에서는 ArraysList로 구현하였다. 실제 요청되어 반환되는 모습이다.

referer만 리스트로 넘어오고 나머지는 단일 값으로 넘어간다. 동기인 이유는 통계 요청구현은 최소한의 요청으로 쪼개논 상태이고 이 값이 넘어오지 않으면 의미있는 일이 일어나지 않는다. (위에 리다이렉트는 사용자가 바로 리다이렉트된 페이지로 넘어가는게 필요했지만 통계 조회는 통계값이 넘어오지않으면 필요가없다)

채널

채널을 먼저 적었어야하는데 마지막으로 적어본다... 일단 클라이언트 측에서 채널 생성을 빈으로 등록하였다.

@Configuration
public class GrpcConfig {
   @Bean
   public ManagedChannel setChannel() {
       return ManagedChannelBuilder.forAddress("localhost", 6565)
              .usePlaintext().build();
  }
}

(개선점 : 현재 localhost로 들어가있는데 운영시에는 통계 서버의 주소가 들어가게끔 바뀌는 profile설정 혹은 yml설정을 해주어야 한다)

gRPC의 기본포트는 6565이다. 사용시에는

@Component
public class ChopGrpcClient {
   public static Logger logger = LoggerFactory.getLogger(ChopGrpcClient.class);

   private UrlClickServiceGrpc.UrlClickServiceStub urlClickStub;
   private UrlStatsServiceGrpc.UrlStatsServiceBlockingStub urlStatsServiceBlockingStub;

   public ChopGrpcClient(Channel channel) {
       urlStatsServiceBlockingStub = UrlStatsServiceGrpc.newBlockingStub(channel);
       urlClickStub = UrlClickServiceGrpc.newStub(channel);
  }

이렇게 constructor 주입 방식으로 써주면 채널이 Stub에 등록되게 된다. 참고로 문서에서 앱 당 하나의 채널을 권장한다고 한다.

앞으로의 개선점은 코드 리팩토링과 네이밍의 부적절성, 그리고 필요하다면 grpc의 오픈소스 라이브러리를 활용할 수 있다.

이상 gRPC 적용기이다.


반응형