안녕하세요, 이번 글에서는 javascript의 Proxy API를 사용해 웹 기반 게임 Admin
에서 gRPC 게임 서버와 효과적으로 통신하는 방법을 소개합니다.
Introduction
요즘 웹 서비스들은 통신 방식으로 대부분 REST API를 사용합니다. 하지만 실시간으로 많은 데이터를 주고받아야 하는 게임의 특성상, REST API를 사용하기에는 어려운 부분이 있습니다. 쿠키런: 킹덤을 포함한 데브시스터즈의 여러 게임은 효율적인 통신을 위해 API 프로토콜로 gRPC를 채택하고 있습니다. 아마 gRPC를 처음 들어보시는 분들도 있을 것 같은데요, gRPC의 특징 몇 가지 짚고 가겠습니다.
- gRPC는 HTTP/2에 커스텀 헤더를 추가한 프로토콜로 통신합니다.
- 메시지 데이터는 protocol buffers(이하 protobuf)를 사용해 바이너리 형태로 직렬화하여 전송합니다.
- protobuf를 사용하여 데이터 구조를 정의할 수 있고, code-gen 라이브러리로
.proto
스키마에 대응하는.d.ts
파일과.js
파일을 생성할 수 있습니다.
(protobuf와 gRPC에 대한 자세한 내용은 이 링크를 참고해 주세요)
위의 세 가지 특징으로 게임 Admin
의 아키텍처를 아래와 같이 구성했습니다.
gRPC에 필요한 커스텀 헤더를 브라우저가 지원하지 않아 게임 서버
로 직접 gRPC 호출을 할 수 없기 때문에 Admin BE
를 프록시 서버로 두어야 합니다.[1] code-gen으로 생성된 .js
파일은 직렬화/역직렬화 로직으로 Admin BE
와 게임 서버
간의 gRPC 통신에 사용되고, Admin FE
와 Admin BE
간의 통신은 직접 HTTP endpoint를 작성해야 합니다.
그런데 이 구조에서는 한가지 문제점이 있습니다. 대부분의 protobuf code-gen 라이브러리가 Node.js 런타임을 대상으로 하기에 FE ↔ BE 통신의 구현체는 code-gen을 활용하지 못하고, gRPC method가 추가될 때마다 REST API의 endpoint를 직접 추가해야 하는 번거로움이 있습니다. 즉, protobuf를 쓰는 이유가 퇴색되고 있던 것입니다.
interface ApiClient {
/* auto-generated */
serviceA: {
method1(): Promise<void>;
method2(): Promise<void>;
},
serviceB: {
method1(): Promise<void>;
}
};
// Admin BE
const beClient: ApiClient = { /* auto-generated (생략) */ };
app.post('/grpc/serviceA/method1', async (req, res) => {
const response = await beClient.serviceA.method1(req.body.payload);
res.send(response.data);
});
app.post('/grpc/serviceA/method2', async (req, res) => {
const response = await beClient.serviceA.method2(req.body.payload);
res.send(response.data);
});
app.post('/grpc/serviceB/method1', async (req, res) => {
const response = await beClient.serviceB.method1(req.body.payload);
res.send(response.data);
});
------------------------------------------------------
// Admin FE
const beClient: ApiClient = {
serviceA: {
method1: (payload) => fetch('/gprc/serviceA/method1', { body: payload }),
method2: (payload) => fetch('/gprc/serviceA/method2', { body: payload }),
},
serviceB: {
method1: (payload) => fetch('/gprc/serviceB/method1', { body: payload }),
},
};
Idea
오늘도 어김없이 개발자 김용쿠는 gRPC와 똑같은 REST API를 추가하다 문득 이런 생각이 들었습니다.
BE용 api client가 타입만 보면 브라우저에서도 사용할 수 있을 것 같은데,
저 타입처럼 동작하는 브라우저용 구현체를 자동으로 만들 수 없을까?[2]
protobuf parser로 직접 FE용 code-gen을 만들어 이 문제를 해결할 수도 있습니다만, 이미 타입 정보가 존재하는데 각 endpoint와 1대1 대응하는 js 코드를 또 만들어야 할까요? 아래의 pseudo code를 상상하며 다음의 목표를 세웠습니다.
interface ApiClient { /* auto-generated */ };
// Admin BE
const beClient: ApiClient = { /* auto-generated */ };
// single & unique BE endpoint
app.post('/grpc', async (req, res) => {
const paths = req.body.paths;
const payload = req.body.payload;
const rpc = paths.reduce((prev, path) => prev[path], beClient);
const response = await rpc(payload);
res.send(response.body);
})
------------------------------------------------------
// Admin FE
const feClient: ApiClient = ?;
feClient.serviceA.method1(payload); // fetch('/grpc', { body: JSON.stringify({ path: ['serviceA', 'method1'], payload }) })
feClient.serviceA.method2(payload); // fetch('/grpc', { body: JSON.stringify({ path: ['serviceA', 'method2'], payload }) })
feClient.serviceB.method1(payload); // fetch('/grpc', { body: JSON.stringify({ path: ['serviceB', 'method1'], payload }) })
- JavaScript 구현체와 code-gen으로 생성된 타입을 합쳐, 기존에 BE에서 사용하던 모습 그대로 마치 FE에도 api client 존재하듯 개발자를 속이자
feClient
구현체는beClient
타입 시그니처대로 동작해야 한다feClient
는 code-gen을 사용하지 않고 런타임에 동적으로beClient
의 함수를 호출하도록 설계하자
Implementation
그런데 어떻게 code-gen 없이 feClient
구현체를 만들 수 있을까요? 답은 JavaScript Proxy에서 찾을 수 있습니다.
JavaScript의 Proxy는 Reflection과 함께 JavaScript의 meta-programming 기법에 사용하는 API로, 객체의 여러가지 기본 동작을 재정의 할 수 있습니다. 아래 코드는 get() handler와 두 번째 인자인 property
를 사용하여 객체의 프로퍼티 접 근을 재정의해, 실제 프로퍼티 값 대신 프로퍼티 접근 경로를 반환하도록 기본 동작을 재정의 한 예시입니다.
const obj = {};
const proxy = new Proxy(obj, {
get(_target, property) {
return "access: " + property;
},
});
console.log(proxy.service); // "access: service"
proxy.service
같은 1단계 자식에 대한 접근은 쉽게 구현할 수 있었는데요, proxy.service.method
와 같이 2단계 이상의 자식에 대한 접근은 어떻게 구현할 수 있을까요? 바로 재귀함수를 통해서 이를 쉽게 구현할 수 있습니다.
function createProxy(target, path = []) {
return new Proxy(target, {
get(_, property) {
const newPath = [...path, property];
console.log(newPath)
return createProxy(target, newPath); // 재귀!!
},
})
}
const obj = {};
const proxy = createProxy(obj);
proxy.service.method
// ['service']
// ['service', 'method']
이제 거의 다 왔습니다.
지금까지는 프로퍼티 접근에 대해서 다루었는데요, 어떻게 하면 proxy.service.method
호출시 실제 rpc
가 호출되게 할 수 있을까요? 이를 위해서 Proxy API 중 apply() handler를 사용해 함수의 호출을 재정의 해보겠습니다.
const rpc = (path, payload) => console.log(path.join('.'), payload);
function createProxy(target, path = []) {
return new Proxy(target, {
get (_target, property) {
return createProxy(target, [...path, property]);
},
apply (_target, _thisArg, argumentsList) {
return target(path, argumentsList[0]); // 실제 rpc 호출
}
});
}
const proxy = createProxy(rpc);
proxy.service.method("payload"); // "service.method", "payload"
마치 실제로 proxy.service
라는 객체 안에 method
라는 함수가 있는것처럼 보이지 않나요?
이렇게 저희는 실제로는 rpc
라는 하나의 함수로, 개발자에게는 proxy.service.method
라는 api 인스턴스가 있는 것 같은 개발 경험을 선사할 수 있었습니다.
const callGrpc = (path: string[], payload: any) => fetch('/grpc', { body: JSON.stringify({ path, payload }) });
function createClient(target: (path: string[], payload: any) => Promise<any>, path: string[] = []): any {
return new Proxy(target, {
get (_target, property) {
return createClient(target, [...path, property.toString()]);
},
apply (_target, _thisArg, argumentsList) {
return target(path, argumentsList[0]); // 실제 rpc 호출
},
});
}
const feClient: ApiClient = createClient(callGrpc);
feClient.serviceA.method1(payload); // OK
feClient.serviceA.method2(payload); // OK
feClient.serviceA.method3(payload); // type error
feClient.serviceB.method1(payload); // OK
feClient.serviceB.method2(payload); // type error
Furthermore
지금까지 동적으로 동작하는 웹 gRPC 클라이언트에 대해 알아보았습니다. 이번 단락에서는 위의 구현체를 활용하여 프록시 서버를 최적화하는 방법을 간단히 소개합니다.
grpc-node에서 제공하는 code-gen 예제를 보면 간단한 스키마에 대해서도 수백 줄의 코드가 생성되는 것을 확인 할 수 있는데요. 게임이 출시되고 시간이 흐름에 따라 게임이 갖는 스키마는 굉장히 거대하고 복잡해집니다. 그리고 code-gen으로 생성한 코드의 양도 비례해서 많아지죠. 무려 코드가 너무 무거워서 Admin BE
의 부팅시간에 영향이 갈 정도가 됩니다.
Faster apps with JSON.parse (Chrome Dev Summit 2019) 발표에서 최적화 방안을 찾았습니다. code-gen의 결과물이 꼭 JavaScript일 필요는 없습니다. 직렬화/역직렬화 로직을 표현하는 JSON을 생성하고 이 JSON을 기반으로 동작하는 함수 하나만 있다면 같은 방법으로 성능을 올릴 수 있겠다고 판단했습니다.[3] 그리고 앞에서 작성한 Proxy 객체를 재사용하면 기존의 code-gen으로 생성된 코드와 동일한 타입과 동작을 갖는 함수가 완성됩니다.
Conclusion
이 방법으로 쿠키런: 킹덤 운영툴은 FE와 BE를 합쳐 약 100만 줄의 JavaScript 코드를 삭제했고, 개발자는 endpoint를 작성하는 번거로운 작업에서 벗어났습니다. 혹자는 너무 안전하지 못 한 코드가 아니냐고 할 수 있겠지만 TypeScript가 컴파일타임을, meta-data가 런타임을 지켜주는 상황에서 JavaScript스럽게 문제를 해결했다는 말로 마무리합니다.
Off the Record
[1] grpc-gateway를 사용할 수도 있지만 Node.js를 지원하지 않습니다. 이미 Next.js를 사용하고 있어서 고려하지 않았습니다.
[3] protobufjs는 런타임에 동적으로 proto → js 변환을 가능하게 해주는 reflection 기능을 제공합니다. 이를 활용하여 쉽게 구현할 수 있었습니다.