JavaScript에서 복잡한 객체 직렬화

게시 됨: 2022-03-11

웹사이트 성능 및 데이터 캐싱

최신 웹 사이트는 일반적으로 데이터베이스 및 타사 API를 포함하여 다양한 위치에서 데이터를 검색합니다. 예를 들어, 사용자를 인증할 때 웹사이트는 데이터베이스에서 사용자 레코드를 조회한 다음 API 호출을 통해 일부 외부 서비스의 데이터로 장식할 수 있습니다. 데이터베이스 쿼리를 위한 디스크 액세스 및 API 호출을 위한 인터넷 왕복과 같이 이러한 데이터 소스에 대한 값비싼 호출을 최소화하는 것은 빠르고 반응이 빠른 사이트를 유지하는 데 필수적입니다. 데이터 캐싱은 이를 달성하는 데 사용되는 일반적인 최적화 기술입니다.

프로세스는 작업 데이터를 메모리에 저장합니다. 웹 서버가 단일 프로세스(예: Node.js/Express)에서 실행되는 경우 동일한 프로세스에서 실행되는 메모리 캐시를 사용하여 이 데이터를 쉽게 캐시할 수 있습니다. 그러나 로드 밸런싱된 웹 서버는 여러 프로세스에 걸쳐 있으며 단일 프로세스로 작업하는 경우에도 서버가 다시 시작될 때 캐시가 유지되기를 원할 수 있습니다. 이를 위해서는 Redis와 같은 out-of-process 캐싱 솔루션이 필요합니다. 즉, 데이터를 어떻게든 직렬화하고 캐시에서 읽을 때 역직렬화해야 합니다.

직렬화 및 역직렬화는 C#과 같은 정적으로 형식화된 언어에서 달성하기가 비교적 간단합니다. 그러나 JavaScript의 동적 특성으로 인해 문제가 조금 더 까다로워집니다. ECMAScript 6(ES6)이 클래스를 도입했지만 이러한 클래스(및 해당 유형)의 필드는 초기화될 때까지 정의되지 않습니다(클래스가 인스턴스화될 때 아닐 수 있음). 그리고 필드 및 함수의 반환 유형이 정의되지 않습니다. 스키마에서 전혀. 게다가, 클래스의 구조는 런타임에 쉽게 변경할 수 있습니다. 필드를 추가하거나 제거할 수 있고, 유형을 변경할 수 있습니다. 이것은 C#에서 리플렉션을 사용하여 가능하지만 리플렉션은 해당 언어의 "어두운 예술"을 나타내며, 개발자는 기능이 중단될 것으로 예상합니다.

몇 년 전 Toptal 핵심 팀에서 일할 때 직장에서 이 문제를 접했습니다. 우리는 팀을 위해 민첩한 대시보드를 구축하고 있었는데, 이는 빨라야 했습니다. 그렇지 않으면 개발자와 제품 소유자가 사용하지 않습니다. 우리는 작업 추적 시스템, 프로젝트 관리 도구, 데이터베이스 등 다양한 소스에서 데이터를 가져왔습니다. 사이트는 Node.js/Express로 구축되었으며 이러한 데이터 소스에 대한 호출을 최소화하기 위해 메모리 캐시가 있었습니다. 그러나 신속하고 반복적인 개발 프로세스로 인해 하루에 여러 번 배포(따라서 다시 시작)하여 캐시가 무효화되어 많은 이점을 잃게 되었습니다.

확실한 해결책은 Redis와 같은 out-of-process 캐시였습니다. 그러나 몇 가지 연구 후에 JavaScript에 대한 좋은 직렬화 라이브러리가 없다는 것을 발견했습니다. 기본 제공 JSON.stringify/JSON.parse 메서드는 개체 유형의 데이터를 반환하므로 원래 클래스의 프로토타입에 대한 모든 기능이 손실됩니다. 이것은 역직렬화된 개체를 우리 응용 프로그램 내에서 단순히 "그 자리에서" 사용할 수 없다는 것을 의미하므로 대체 디자인으로 작업하려면 상당한 리팩토링이 필요합니다.

라이브러리 요구 사항

JavaScript에서 임의 데이터의 직렬화 및 역직렬화를 지원하기 위해 역직렬화된 표현과 원본을 서로 바꿔 사용할 수 있도록 다음 속성을 가진 직렬화 라이브러리가 필요했습니다.

  • 역직렬화된 표현은 원본 객체와 동일한 프로토타입(함수, getter, setter)을 가져야 합니다.
  • 라이브러리는 중첩된 객체의 프로토타입이 올바르게 설정된 중첩된 복잡성 유형(배열 및 맵 포함)을 지원해야 합니다.
  • 동일한 객체를 여러 번 직렬화 및 역직렬화하는 것이 가능해야 합니다. 프로세스는 멱등적이어야 합니다.
  • 직렬화 형식은 TCP를 통해 쉽게 전송할 수 있어야 하며 Redis 또는 유사한 서비스를 사용하여 저장할 수 있어야 합니다.
  • 클래스를 직렬화 가능으로 표시하려면 최소한의 코드 변경이 필요합니다.
  • 라이브러리 루틴은 빨라야 합니다.
  • 이상적으로는 일종의 매핑/버전 관리를 통해 클래스의 이전 버전의 역직렬화를 지원하는 방법이 있어야 합니다.

구현

이 격차를 메우기 위해 JavaScript용 범용 직렬화 라이브러리인 Tanagra.js 를 작성하기로 결정했습니다. 도서관 이름은 내가 좋아하는 Star Trek: Next Generation 에피소드 중 하나에서 따온 것입니다. 이 에피소드에서 Enterprise 의 승무원은 언어를 알아들을 수 없는 신비한 외계 종족과 소통하는 법을 배워야 합니다. 이 직렬화 라이브러리는 이러한 문제를 피하기 위해 공통 데이터 형식을 지원합니다.

Tanagra.js 는 간단하고 가볍게 설계되었으며 현재 Node.js(브라우저 내에서 테스트되지 않았지만 이론상 작동해야 함) 및 ES6 클래스(지도 포함)를 지원합니다. 기본 구현은 JSON을 지원하고 실험 버전은 Google 프로토콜 버퍼를 지원합니다. 라이브러리는 표준 JavaScript(현재 ES6 및 Node.js로 테스트됨)만 필요하며 실험 기능, Babel 변환 또는 TypeScript 에 종속되지 않습니다.

직렬화 가능한 클래스는 클래스를 내보낼 때 메서드 호출로 표시됩니다.

module.exports = serializable(Foo, myUniqueSerialisationKey)

메서드는 생성자를 가로채고 고유 식별자를 삽입하는 클래스에 프록시를 반환합니다. (지정하지 않으면 기본적으로 클래스 이름이 사용됩니다.) 이 키는 나머지 데이터와 함께 직렬화되며 클래스도 이를 정적 필드로 노출합니다. 클래스에 중첩 유형(즉, 직렬화가 필요한 유형이 있는 멤버)이 포함되어 있으면 메소드 호출에도 지정됩니다.

module.exports = serializable(Foo, [Bar, Baz], myUniqueSerialisationKey)

(예를 들어 Foo1을 직렬화하면 Foo2로 역직렬화할 수 있도록 이전 버전의 클래스에 대한 중첩 유형도 비슷한 방식으로 지정할 수 있습니다.)

직렬화하는 동안 라이브러리는 클래스에 대한 키의 전역 맵을 재귀적으로 구축하고 역직렬화 중에 이를 사용합니다. (키는 데이터의 나머지 부분과 함께 직렬화됨을 기억하십시오.) "최상위" 클래스의 유형을 알기 위해 라이브러리는 역직렬화 호출에서 이를 지정해야 합니다.

const foo = decodeEntity(serializedFoo, Foo)

실험적인 자동 매핑 라이브러리는 모듈 트리를 탐색하고 클래스 이름에서 매핑을 생성하지만 고유하게 명명된 클래스에서만 작동합니다.

프로젝트 레이아웃

프로젝트는 여러 모듈로 나뉩니다.

  • tanagra-core - 클래스를 직렬화 가능 으로 표시하는 기능을 포함하여 다양한 직렬화 형식에 필요한 공통 기능
  • tanagra-json - 데이터를 JSON 형식으로 직렬화
  • tanagra-protobuf - 데이터를 Google protobuffers 형식으로 직렬화합니다(실험용).
  • tanagra-protobuf-redis-cache - 직렬화된 protobuf를 Redis 에 저장하기 위한 도우미 라이브러리
  • tanagra-auto-mapper - Node.js 의 모듈 트리를 탐색하여 클래스 맵을 구축합니다. 즉, 사용자가 역직렬화할 유형을 지정할 필요가 없습니다(실험적).

라이브러리는 미국 철자를 사용합니다.

사용 예

다음 예제에서는 직렬화 가능한 클래스를 선언하고 tanagra-json 모듈을 사용하여 직렬화/역직렬화합니다.

 const serializable = require('tanagra-core').serializable class Foo { constructor(bar, baz1, baz2, fooBar1, fooBar2) { this.someNumber = 123 this.someString = 'hello, world!' this.bar = bar // a complex object with a prototype this.bazArray = [baz1, baz2] this.fooBarMap = new Map([ ['a', fooBar1], ['b', fooBar2] ]) } } // Mark class `Foo` as serializable and containing sub-types `Bar`, `Baz` and `FooBar` module.exports = serializable(Foo, [Bar, Baz, FooBar]) ... const json = require('tanagra-json') json.init() // or: // require('tanagra-protobuf') // await json.init() const foo = new Foo(bar, baz) const encoded = json.encodeEntity(foo) ... const decoded = json.decodeEntity(encoded, Foo)

성능

두 직렬 변환기( JSON 직렬 변환기 및 실험용 protobufs 직렬 변환기)의 성능을 컨트롤(네이티브 JSON.parse 및 JSON.stringify)과 비교했습니다. 각각 10번씩 총 10번의 실험을 했습니다.

Ubuntu 17.10을 실행하는 32Gb 메모리가 있는 2017 Dell XPS15 노트북에서 이것을 테스트했습니다.

다음 중첩 객체를 직렬화했습니다.

 foo: { "string": "Hello foo", "number": 123123, "bars": [ { "string": "Complex Bar 1", "date": "2019-01-09T18:22:25.663Z", "baz": { "string": "Simple Baz", "number": 456456, "map": Map { 'a' => 1, 'b' => 2, 'c' => 2 } } }, { "string": "Complex Bar 2", "date": "2019-01-09T18:22:25.663Z", "baz": { "string": "Simple Baz", "number": 456456, "map": Map { 'a' => 1, 'b' => 2, 'c' => 2 } } } ], "bazs": Map { 'baz1' => Baz { string: 'baz1', number: 111, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } }, 'baz2' => Baz { string: 'baz2', number: 222, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } }, 'baz3' => Baz { string: 'baz3', number: 333, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } } }, }

쓰기 성능

직렬화 방법 에비뉴 첫 번째 시도(ms) 표준 개발 주식회사 첫 번째 시도(ms) Ave. ex. 첫 번째 시도(ms) 표준 개발 전. 첫 번째 시도(ms)
JSON 0.115 0.0903 0.0879 0.0256
구글 프로토버프 2.00 2.748 1.13 0.278
통제 그룹 0.0155 0.00726 0.0139 0.00570

읽다

직렬화 방법 에비뉴 첫 번째 시도(ms) 표준 개발 주식회사 첫 번째 시도(ms) Ave. ex. 첫 번째 시도(ms) 표준 개발 전. 첫 번째 시도(ms)
JSON 0.133 0.102 0.104 0.0429
구글 프로토버프 2.62 1.12 2.28 0.364
통제 그룹 0.0135 0.00729 0.0115 0.00390

요약

JSON 직렬 변환기는 기본 직렬화보다 약 6-7배 느립니다. 실험적인 protobufs 직렬 변환기는 JSON 직렬 변환기보다 약 13배 또는 기본 직렬화보다 100배 더 느립니다.

또한 각 직렬 변환기 내에서 스키마/구조 정보의 내부 캐싱은 성능에 분명히 영향을 미칩니다. JSON 직렬 변환기의 경우 첫 번째 쓰기는 평균보다 약 4배 느립니다. protobuf 직렬 변환기의 경우 9배 더 느립니다. 따라서 메타데이터가 이미 캐시된 개체를 작성하는 것은 어느 라이브러리에서나 훨씬 더 빠릅니다.

읽기에서도 동일한 효과가 관찰되었습니다. JSON 라이브러리의 경우 첫 번째 읽기는 평균보다 약 4배 느리고 protobuf 라이브러리의 경우 약 2.5배 느립니다.

protobuf serializer의 성능 문제는 아직 실험 단계에 있다는 것을 의미하며 어떤 이유로 형식이 필요한 경우에만 권장합니다. 그러나 형식이 JSON보다 훨씬 간결하고 유선을 통해 전송하는 것이 더 좋기 때문에 시간을 투자할 가치가 있습니다. Stack Exchange는 내부 캐싱에 형식을 사용합니다.

JSON 직렬 변환기는 분명히 훨씬 더 성능이 우수하지만 여전히 기본 구현보다 훨씬 느립니다. 작은 개체 트리의 경우 이 차이는 중요하지 않지만(50ms 요청 위에 몇 밀리초가 추가되어도 사이트 성능이 저하되지는 않음), 이는 매우 큰 개체 트리의 경우 문제가 될 수 있으며 내 개발 우선 순위 중 하나입니다.

로드맵

라이브러리는 아직 베타 단계입니다. JSON 직렬 변환기는 상당히 잘 테스트되었으며 안정적입니다. 다음 몇 달 동안의 로드맵은 다음과 같습니다.

  • 두 직렬 변환기의 성능 향상
  • ES6 이전 JavaScript에 대한 더 나은 지원
  • ES-Next 데코레이터 지원

복잡한 중첩 개체 데이터 직렬화 및 원래 유형으로 역직렬화를 지원하는 다른 JavaScript 라이브러리가 없습니다. 라이브러리의 이점을 얻을 수 있는 기능을 구현하는 경우 시도해 보고 피드백을 받고 기여를 고려하십시오.

프로젝트 홈페이지
GitHub 저장소