Loading
2022. 6. 18. 16:21 - lazykuna

Node.js + typescript 에서 gRPC 사용하기

최근 개인적으로 진행하고 있는 웹서버 프로젝트가 있는데, 서버 구성을 어쩌다 보니 Go + Node.js 로 하게 되어, 서버간 통신을 gRPC로 하기로 결정하여 개발한 기록을 정리해둡니다. 생각보다 삽질을 한 게 있네요.

왜 gRPC 인가

이미 많이들 다룬 내용이지만, 간단하게 다시 짚고 넘어가면, 바이트 단위 직렬화 + 압축기술이라서 무지막지하게 효율과 성능이 좋습니다. 참고로 데이터 전송시 간단한 압축 메커니즘을 사용하는 것은 병목에 거의 영향을 끼치지 않을 뿐더러 효율적이라는 사실이 이미 증명되어 있습니다!

게다가 HTTP/2 프로토콜을 지원하여, 연결을 재사용한다거나 등을 통해 훨씬 효율적입니다. 그리고 기본적으로 데이터 전송을 위한 규약 언어(protobuf) 형태로 기술하도록 되어 있고 이를 다른 언어로 재변환/프레임워크 위에서 사용하기 때문에, 개발 측면에서도 훨씬 편리합니다. 데이터 전송 로직 짤 거 없이 그냥 라이브러리를 쓰면 되거든요

이러한 특성들 때문에 REST대비해서 오버헤드도 적고 - 특히 MSA와 같이 내부 통신할 경우는 더더욱, (Web)Socket과 대조해서는 개발이 훨씬 용이한 이점이 있습니다.

구성 환경

  • gRPC 서버는 Golang이고,
  • Node.js 는 gRPC client 역할로, request만 쏘아주고 받아오는 게 주된 역할.

Naive usage for gRPC Client

가장 간단한 예제는 grpc.io/Node.js 의 Basic Tutorial에 나와 있습니다. 심지어 클라이언트 코드까지 제공해 주고 있습니다.

typescript 의 strict type에 전혀 맞지 않는 구조

다만 문제는 이 grpc 패키지의 클라이언트 구조체를 만드는 방식이 typescript의 언어와는 전혀 맞지 않는다는 이야기입니다. 제가 만들었던 클라이언트 코드를 잠깐 가지고 오겠습니다.

import { credentials, loadPackageDefinition } from '@grpc/grpc-js';
import { loadSync } from '@grpc/proto-loader'

let protoFileName = '../usermgr/message/user.proto'
let options =
{
    keepCase: true,
    longs: String,
    enums: String,
    defaults: true,
    oneofs: true
}
let protoDef = loadSync(protoFileName, options);
let userMgr: any = loadPackageDefinition(protoDef).usermgr
let grpcUserMgrClient = userMgr.UserMgr(cfg.USERMGR_ENDPOINT, credentials.createInsecure());

이렇게 해야 typescript에서 컴파일이 됩니다. 얼핏 보면 평범한 코드 같지만, 눈치채셨나요? 해당 패키지의 정의는 동적으로 생성이 됩니다. 런타임 때 prototype을 읽어오도록 만들어져 있어서, loadPackageDefinition 으로 읽어온 정의는 모두 any type입니다. type에 대해서 좀 더 자세히 써보겠습니다.

// type PackageDefinition
let protoDef = loadSync(protoFileName, options);
// type GrpcObject
let packageDef = loadPackageDefinition(protoDef);
// type any
// any type 이어야만이 하위의 UserMgr method 에 접근하여 new 객체를 생성할 수 있습니다.
let userMgr: any = packageDef.usermgr;
// type any
let grpcUserMgrClient = userMgr.UserMgr(cfg.USERMGR_ENDPOINT, credentials.createInsecure());

헉, 죄다 타입이 없어서 any로 만들어줘야 합니다. 심지어 메서드 콜도,

// type any
let client = grpcUserMgrClient;
// type any
let req = {
    hi: 'HelloWorld!',
};
// Test is any Function!
client.Test(req, (err: any, res: any) => {
    if (err) {
        console.log(err);
    } else {
        console.log(res);
    }
});

이런 식으로 any의 향연입니다. 타입스크립트의 강력한 타입 체크의 장점을 전혀 취할수가 없습니다.

proto-loader-gen-types autogen 사용하기

다행히도 type을 autogen 해주는 node-js 도구가 있습니다! grpc/grpc-js 에 포함된 도구라고 하니 쉽게 쓸 수도 있습니다.

아직 베타버전이지만 그럭저럭 쓸만해서 사용법을 적어놓고 갑니다.

./node_modules/.bin/proto-loader-gen-types --longs=String --enums=String --defaults --oneofs --grpcLib=@grpc/grpc-js --outDir=apigateway/src/messages usermgr/message/user.proto

이렇게 만들어진 파일에는 아래와 같은 인터페이스가 포함되게 됩니다.

그렇죠! Request, Response, Client call 등 필요한 모든 정보가 명시적 타입을 가지고 있습니다. 그래서 예시코드는 아래처럼 바꿀 수 있게 됩니다.

// type PackageDefinition
let protoDef = loadSync(protoFileName, options);
// type GrpcObject => ProtoGrpcType
let packageDef: ProtoGrpcType = loadPackageDefinition(protoDef) as any;
// type UserMgrClient
let grpcUserMgrClient: UserMgrClient = new packageDef.usermgr.UserMgr(cfg.USERMGR_ENDPOINT, credentials.createInsecure());
let req: TestReq = {
    hi: 'HelloWorld!',
};
...

타입이 있으니 개발 실수도 미리 잡아주고, 다시금 타입스크립트 답게 코드를 쓸 수 있게 되었습니다.

비동기 형태의 call

grpc 패키지의 기본 콜들은 모조리 비동기 형태입니다.

그런데 저 같은 경우에는 api 결과값을 grpc 로부터 돌려받은 값을 넘겨주어야 하는 상황입니다. 기본 인터페이스는 Promise 객체가 아니니 await를 쓸 수도 없고, 곤란합니다.

let resOut;

client.Test(req, (err: any, res: any) => {
    if (err) {
        resOut = err;
    } else {
        resOut = res;
    }
});

// 이러면 .Test 보다 return 구문이 더 빨리 돌아서 undefined 가 나갑니다.
return resOut;

해결 방법은 의외로 간단합니다. Promise 객체로 만들어 버리면 되죠!

let grpcPromise = new Promise((resolve, reject) =>
    client.Test(req, (err: any, res: any) => {
        if (err) {
            let errRes = { 'error': String(err) }
            reject(errRes);
            return;
        }
        resolve(res);
    })
);
let res = await grpcPromise.catch((err) => { return err; });
return res;

아주 깔끔하게 정석적인 async / await 코드가 되었습니다.

grpc-web과는 다르다!

  • @improbable-eng/grpc-web 은 말 그대로 브라우저에서 gRPC를 호출하기 위한 방식으로, HTTP2.0 프로토콜을 사용합니다.
  • 이 때문에 기존 grpc 서버가 해당 형태(grpc-web)의 프로토콜을 지원해야만 합니다. 그렇지 않으면 리퀘스트를 날려도 서버에서 응답을 해주지 않습니다. (아예 No data response 하더라고요)
  • 그리고 우리는 웹 브라우저가 아닌 Node.js 에서 리퀘스트를 날리는 것이 목적이기 때문에, 굳이 이걸 쓸 필요가 없습니다.

트러블슈팅

처음에 잘 모르고 grpc-web으로 구현했다가 Response closed without headers 오류가 떠서 굉장한 삽질을 했었습니다. 원인 자체는 호환되지 않는 프로토콜이었던 것이 핵심이었지만, 그 과정까지 문제를 좁히기 위해서 아래 작업들을 해 봤습니다.

1. grpcui 로 직접 콜 날려보기

이 방식을 쓰기 전에, grpc/reflection 이 서버에서 켜져 있어야 합니다. 그래야지 grpcui가 specification을 endpoint에서 가져올 수 있거든요. 여기 참조.

만약 이 방식으로 정상작동하는 게 확인된다면, 일단 서버 무죄일 가능성이 높습니다.

2. tcpdump로 데이터가 잘 전달되는지 확인해 보기

저 같은 경우는 endpoint가 [localhost:8081](http://localhost:8081) 이기 때문에, lo 로 시작하는 로컬 eth와 8081 포트를 조건으로 주고 패킷을 검사해 봤습니다.

tcpdump -i lo0 dst port 8081

이 방식에서 패킷이 가는 것이 확인이 된다면, 적어도 네트워크 문제는 아닌 겁니다. 서버까지 데이터가 갔다는 이야기이므로, 서버에서 데이터를 거절했다고 볼 수 있습니다. 즉 data corruption을 의심해 봐야 합니다.

아니나 다를까, 포멧이 다르게 갔던 것을 확인할 수 있었습니다.