React Native에서 어떻게든 gRPC-Web 사용했던 삽질기

2020년 4월 15일

6개월 전 React Native를 사용하면서 gRPC-Web을 사용하고자 이런저런 삽질을 했었습니다. 삽질을 하는 동안 다른 서비스 로직들과 별개로 확인할 수 있는 환경이 필요했고, 혹시 나중에 찾아볼 일이 있지 않을까 싶어 별도의 저장소를 생성해두었습니다. 이번 글을 통해 단순한 코드만 있는 저장소에서 삽질한 부분만 추려내서 정리해보고자 합니다. 코드만 확인하고자 하신다면 react-native-grpc-web에서 확인하실 수 있습니다.

왜 React Native에서 gRPC-Web을 사용하게 되었나요?

gRPC가 무엇인지, React Native가 무엇인지를 설명하는 것은 주제에서 너무 벗어나는 것 같고 검색해보면 한글로 된 많은 자료들을 찾을 수 있어 생략하고자 합니다.

우선 앞서 말씀드린 두 기술을 사용해야 했던 많은 이유들이 있겠지만 제가 생각하는 가장 큰 이유를 말씀드리자면, gRPC의 경우는 내부 서비스들 간의 공통된 프로토콜을 제공하기 위해 사용하였고, React Native는 앱 개발자 채용이 되지 않은 상태에서 제품의 프로토타입을 빨리 만들어야 되는 상황에서 선택을 하게 되었습니다.

gRPC와 React Native의 사용이 결정되고 나서 React Native에서 gRPC의 사용에 대해 검색해보기 시작했습니다. React Native뿐만 아니라 이후 추가적으로 웹에서도 사용해야 했기 때문에, 가능하다면 NativeModule 형태로 제공하는 것이 아닌 JS Module인 gRPC-Web을 사용할 방법에 대해 중점적으로 찾아봤습니다.

grpc-web에서 @improbable-eng/grpc-web으로

gRPC Web supported

gRPC Web supported

gRPC-Web 구현체는 Google에서 구현한 grpc-web과 Improbable에서 구현한 @improbable-eng/grpc-web가 있습니다. Google 과 Improbable이 각 저장소에서 독립적으로 구현하여, 상단의 이미지에서 볼 수 있듯이 구현된 스펙도 동일하지 않습니다. Google은 Google의 클로저 라이브러리 베이스로 JavaScript 기반의 grpc-web을 구현하였으며, Improbable은 TypeScript 기반으로 @improbable-eng/grpc-web을 구현하였습니다.

var proto = require("./compiled.js");

export default class App extends React.Component {
constructor(props){
super(props);

    var service = new proto.proto.EchoServiceClient("http://localhost:8080");
    var unary_request = new proto.proto.EchoRequest();
    unary_request.setMessage("Test");
    service.echo(unary_request, {}, function(err, response){
        console.log("Unary echo response: " + response.getMessage());
    });
}
// render...

처음에는 grpc-web을 사용하고자 시도하였지만, 이 이슈와 같은 이유로 해결책을 찾지 못하여 @improbable-eng/grpc-web을 사용하는 것으로 결정하였습니다.

ReactNativeTransport for binary transfer

Error about binary transfer

Error about binary transfer

@improbable-eng/grpc-web을 사용해서 코드를 작성하면 빌드가 실패하지는 않았지만, 막상 호출을 하면 상단의 이미지와 같이 XHR이 바이너리 전송을 지원하도록 구현되어 있지 않아 오류가 발생합니다. 이와 관련된 이슈 여기에서 찾을 수 있었고, 당시에는 아직 리뷰 중이었지만 이를 해결할 수 있는 Pull Request도 올라와 있었습니다.

import { grpc } from '@improbable-eng/grpc-web';
import { ReactNativeTransport } from '@improbable-eng/grpc-web-react-native-transport';
grpc.setDefaultTransport(ReactNativeTransport());

당시에는 PR이 머지되는 것을 기다릴 여유가 없어서 일단 ReactNativeTransport 코드를 가져와 기본 Transport를 변경하여 사용하였지만, 지금은 PR이 머지가 되어서 상단의 코드와 같이 @improbable-eng/grpc-web-react-native-transport를 의존성 추가하고 사용할 수 있습니다.

import { grpc } from '@improbable-eng/grpc-web';
import { ReactNativeTransport } from '@improbable-eng/grpc-web-react-native-transport';
import { NodeHttpTransport } from '@improbable-eng/grpc-web-node-http-transport';

if (typeof document != 'undefined') {
  // web
} else if (typeof navigator != 'undefined' && navigator.product == 'ReactNative') {
  // react-native
  grpc.setDefaultTransport(ReactNativeTransport());
} else {
  // node
  grpc.setDefaultTransport(NodeHttpTransport());}

React Native 이외의 환경에서 같이 사용하도록 구성해야 될 경우에는 상단의 코드와 같이 환경에 맞는 Transport를 설정해 주시면 됩니다.

위와 같이 별도의 Package를 만들어 사용할 경우에 ReactNativeTransport를 사용하도록 설정해도 XHR 오류가 지속적으로 발생하는 경우가 있었습니다. 제 경우에는 별도의 Package가 보고 있는 node_modulesgrpc-webReactNativeTransport를 사용하도록 설정을 하는 곳에서 보고 있는 grpc-web이 달라 발생했습니다. 이를 두 경우 모두 같은 것을 보도록 변경함으로써 해결하였습니다.

Network Error

앞서 언급한 것들을 하고 서버를 호출하였을 때 앞서 봤던 무서운(?) 빨간 오류 화면은 사라졌지만, 여러 이유로 Network Error가 발생하면서 통신이 실패하였습니다. 오류가 발생한 원인이 서로 인과관계가 있어 보이지 않아 기억이 나는 대로 나열해보고자 합니다.

Proxy

gRPC Web Proxy

gRPC Web Proxy

gRPC Web은 HTTP/1.1 과 HTTP/2를 모두 지원하는 반면에 브라우저 API가 HTTP/2 액세스를 지원하지 않아, gRPC Web과 서버 간에 직접 통신을 할 수는 없었습니다. 이를 해결하기 위해서는 Proxy 서버를 통해 요청 및 응답을 브라우저가 처리할 수 있도록 변환해야 하는데, 저희는 Envoy Proxy 대신 Improbable의 Proxy를 사용하였습니다. 이때 설정 등의 미스로 CORS 등의 이슈가 발생하여 통신에 실패하는 경우가 발생했었습니다.

Loopback

export default () => {
  ...

  async function onGreet() {
    const service = new GreeterClient('http://localhost:9000');    try {
      const request = new HelloRequest();
      request.setName('World');
      const metadata = { 'custom-header-1': 'value1' };
      service.sayHello(request, metadata, (err: any, res: any) => {
        setResponse(
          (res && res.toObject() && JSON.stringify(res.toObject())) ||
            (err && err.message),
        );
      });
    } catch (e) {
      console.error(e);
    }
  }

  ...

글을 시작하면서 언급했던 저장소와 같이 로컬에서 통신을 검증하기 위해 설정을 하고 시도하는 경우, 상단에서처럼 Android 코드 내에서 localhost127.0.0.1 같은 loopback 주소를 주어 검증을 시도하였습니다. 이때 예상과 달리 통신이 제대로 동작하지 않고 Network Error가 발생했습니다. 동작만 확인하면 됐기 때문에 원인이 무엇인지 제대로 확인해보지 않았지만, loopback이 대신 IP 주소를 넣으면 로컬 통신이 동작하는 것을 확인할 수 있었습니다.

SSL Certificate

<manifest
    xmlns:android="https://schemas.android.com/apk/res/android"
    package="com.client">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
      ...
      android:usesCleartextTraffic="true">      ...
    </application>

</manifest>

Android에서 https로 통신 시 네트워크 보안 구성을 설정하지 않았을 때 Network Error가 발생하였습니다. 앞서 언급했던 저장소와 같이 간단하게 통신을 검증하기 위한 경우에는 상단의 코드와 같이 android:usesCleartextTraffic="true"를 설정하면 동작을 확인할 수는 있습니다. 만약 인증서를 설정해야 하는 경우에는 아래와 같은 방식으로 인증서를 넣어주시면 됩니다.

단, http로 통신할 때도 Network Error가 발생한다면, 주소 및 포트가 정확한지 확인하고 두 경우가 아니라면 http가 https로 Redirect 되고 있는지 확인해야 합니다.

SSL Certificate

src/main/res/raw 폴더 아래 인증서를 저장합니다.

network_security_config.xml

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">api.xxx.xxx</domain>
        <trust-anchors>
            <certificates src="@raw/certificate"/>
        </trust-anchors>
    </domain-config>
</network-security-config>

src/main/res/xml 폴더 아래 network_security_config.xml을 생성하고 인증서에 대해 설정합니다.

AndroidManifest.xml

<manifest
    xmlns:android="https://schemas.android.com/apk/res/android"
    package="com.client">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        ...
        android:networkSecurityConfig="@xml/network_security_config">
        ...
    </application>

</manifest>

AndroidManifest.xml에 앞서 생성한 XML을 networkSecurityConfig에 설정합니다.

기타

Steaming

프로토타입을 작성하는 동안 대부분의 경우에 대해 사용하는데 큰 문제가 없었습니다. 단지 Steaming의 경우, RequestStream, ResponseStream은 어떻게든 사용이 가능했었지만 BidirectionalStream의 경우는 추가적인 설정이 필요한 걸로 보였습니다. @improbable-eng/grpc-webTransport 문서를 살펴보면 Socket-based Transports를 통해 실험적으로 나마 지원해 주는 기능을 이용해 볼 수 있을 것 같습니다만, 저희의 경우 그쯤 Native로 개발이 진행돼서 시도해볼 이유가 사라져 확인해보지는 못했습니다.

CamelCase with proto

message Test {
    string testMessage = 1;}
export class Test extends jspb.Message {
  getTestmessage(): string;  setTestmessage(value: string): void;  ...
}

proto 파일을 작성할 때, 상단의 testMessage 같이 카멜 표기법으로 작성을 하면 컴파일된 TS 파일에서 getTestmessage와 같이 예상과 다른 형태로 컴파일되는데, 이와 관련된 이슈는 여기에서 확인할 수 있습니다.

message Test {
    string test_message = 1;}
export class Test extends jspb.Message {
  getTestMessage(): string;  setTestMessage(value: string): void;  ...
}

이를 해결하기 위해서는 Protocol Buffer의 Style Guide에 맞추어 underscore(_)로 작성을 해주시면 정상적으로 컴파일 되는 것을 확인하실 수 있습니다.

마치며…

gRPC Web with App

gRPC Web with App

gRPC Web with Web

gRPC Web with Web

“React Native에서 어떻게든 gRPC-Web 사용했던 삽질기”라는 제목에서 보이다시피 주어진 상황에서 어떻게든 프로토타입을 빠르게 작성하기 위해 최고의 선택이 아닌 최선의 선택을 하고자 gRPC-Web을 선택했습니다. 만약 다시 돌아갈 수 있고 여력이 충분하다면 React Native를 사용하는 경우에 gRPC-Web 대신 Native Module로 gRPC를 사용하는 것이 더 안정적일 것이라고 생각이 듭니다. 다만 React Native와 gRPC-Web을 사용해야만 하는 상황이라면, 이 글이 조그만 도움이라도 될 수 있기를 바라봅니다.

Recently posts
© 2016-2023 smilecat.dev