타입스크립트스럽게 성능과 생산성 두 마리 토끼 모두 잡기

문태근, 김경준

안녕하세요, 이번 글에서는 javascript의 Proxy API를 사용해 웹 기반 게임 Admin에서 gRPC 게임 서버와 효과적으로 통신하는 방법을 소개합니다.

Introduction

요즘 웹 서비스들은 통신 방식으로 대부분 REST API를 사용합니다. 하지만 실시간으로 많은 데이터를 주고받아야 하는 게임의 특성상, REST API를 사용하기에는 어려운 부분이 있습니다. 쿠키런: 킹덤을 포함한 데브시스터즈의 여러 게임은 효율적인 통신을 위해 API 프로토콜로 gRPC를 채택하고 있습니다. 아마 gRPC를 처음 들어보시는 분들도 있을 것 같은데요, gRPC의 특징 몇 가지 짚고 가겠습니다.

  1. gRPC는 HTTP/2에 커스텀 헤더를 추가한 프로토콜로 통신합니다.
  2. 메시지 데이터는 protocol buffers(이하 protobuf)를 사용해 바이너리 형태로 직렬화하여 전송합니다.
  3. protobuf를 사용하여 데이터 구조를 정의할 수 있고, code-gen 라이브러리로 .proto 스키마에 대응하는 .d.ts 파일과 .js파일을 생성할 수 있습니다.

(protobufgRPC에 대한 자세한 내용은 이 링크를 참고해 주세요)

위의 세 가지 특징으로 게임 Admin의 아키텍처를 아래와 같이 구성했습니다.

Admin Frontend(이하 Admin FE), Admin Backend(이하 Admin BE), 게임 서버로 구성된 게임 Admin 아키텍처
Admin Frontend(이하 Admin FE), Admin Backend(이하 Admin BE), 게임 서버로 구성된 게임 Admin 아키텍처

gRPC에 필요한 커스텀 헤더를 브라우저가 지원하지 않아 게임 서버로 직접 gRPC 호출을 할 수 없기 때문에 Admin BE를 프록시 서버로 두어야 합니다.[1] code-gen으로 생성된 .js 파일은 직렬화/역직렬화 로직으로 Admin BE게임 서버 간의 gRPC 통신에 사용되고, Admin FEAdmin 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 }) })
  1. JavaScript 구현체와 code-gen으로 생성된 타입을 합쳐, 기존에 BE에서 사용하던 모습 그대로 마치 FE에도 api client 존재하듯 개발자를 속이자
  2. feClient 구현체는 beClient 타입 시그니처대로 동작해야 한다
  3. 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를 사용하고 있어서 고려하지 않았습니다.

[2] tRPC에서 아이디어를 얻었습니다.

[3] protobufjs는 런타임에 동적으로 proto → js 변환을 가능하게 해주는 reflection 기능을 제공합니다. 이를 활용하여 쉽게 구현할 수 있었습니다.

© 2024 Devsisters Corp. All Rights Reserved.