티스토리 뷰

서문

회사의 쌓여있는 기술 부채를 해결하던 중,

모든 API 응답을 Intercept 해서 transform하는 함수의 성능을 개선하는 작업을 맡게 되었다.

 

응답 속도가 느린 특정 API가 평균 473ms, transform은 251ms 시간을 잡아먹고 있어, 고객 경험에 불편함을 주고있었다.

또한, 응답하는 데이터가 커질수록 더욱 응답속도가 느려질 것으로 보여 시급히 해결해야하는 기술 부채였다.

 

개선한 결과부터 공개하면 P99 API 기준, 평균 응답속도 933.5ms 에서 495.3ms로 188% 정도 개선되었다.

 

이제 개선 과정에 대해 알아보자.


분석

서문에 적은 모든 API 응답을 Intercept 해서 transform하는 함수의 이름을 global interceptor라고 정의하겠다.

 

global interceptor의 역할을 알아보면,

백엔드에서 bigint 타입을 사용하고 있는데 JSON은 bigint형을 지원하지 않아서 모든 JSON 응답의 bigint형을 number형으로 변환하여 반환하는 역할을 주로 하고있었다.

 

문제점은 모든 응답에 대응하기 위하여 JSON을 재귀적으로 탐색하여 bigint형을 발견할 때 마다, number형으로 변환하고 있었고, JSON이 커질 수록 응답속도 또한 저하되고 있었다.

 

개선해야 하는 global interceptor 코드 예시

function transForm(obj) {
  for (const key in obj) {
    // convert main tree
    if (typeof obj[key] == "bigint") {
      obj[key] = Number(obj[key].toString());
    }

    for (const prop in obj[key]) {
      // recursively search for inner branches
      if (typeof obj[key][prop] == "object") {
        transForm(obj[key]);
      } else {
        if (typeof obj[key][prop] == "bigint") {
          obj[key][prop] = Number(obj[key][prop].toString());
        }
      }
    }
  }

  return obj;
}

 

이 코드를 개선하기 위하여 여러가지 방안을 고민해보자.

 


개선 방법 모색

개선을 위하여 3가지 방법으로 접근해보았다.

  • Bigint.toJSON
  • Json-bigint 모듈
  • JSON.stringify의 replacer

 

Bigint.toJSON

Json.stringify시 bigint형을 포함하고 있으면 에러가 발생한다.

"그러면 stringify가 실행될 때, bigint형도 처리할 수 있게 하면 되는거 아니야? 분명히 가능할 것 같은데." 라는 발상으로 처음 접근을 시작했다.

그래서 Json.stringify의 동작방식을 찾아보니, 변환하는 객체에 toJson이 선언되어있으면, 해당 custom을 이용하여 변환한다는 사실을 알아내었다.

참고: Json.stringify toJSON()

 

그래서 Bigint prototype에 toJson을 선언하면 Json.stringify가 이용할 것이다! 라는 결론을 내고 아래 코드와 같이 monkey patch를 작성해보았다.

declare global {
  interface BigInt {
    toJSON: () => string;
  }
}

BigInt.prototype.toJSON = function () {
  return this.toString();
};

결과적으로 Json.stringify시, bigint가 적절하게 string으로 변환되어 global interceptor가 필요 없어졌으나,

몇가지 문제점이 존재했다.

  • 원하는 객체 뿐 아니라 프로그램 전체의 BigInt 객체에 영향을 주어 사이드 이펙트 제어가 불가능
  • Bigint와 동시에 Date 타입도 변경하고 싶은 경우엔 Date 타입에 이미 toJSON이 선언되어있어 연구가 필요
    • global로 Date 타입의 toJSON 수정은 좋지 않음
  • Monkey patch 자체가 선호되는 방법은 아님

다른 방법이 필요했다.

 

Json-bigint 모듈

나와 같은 문제에 도달한 누군가 있겟지라고 생각하여 npm을 탐색해서 발견한 모듈 (링크)

bigint를 parse, stringify 할 수 있게 만들어 주는 모듈로 매우 훌륭한 선택지였으나 아래와 같은 문제가 있었다.

  • 의존성 추가
  • front단에서도 사용해야 함
  • bigint를 제외한 Date 타입 변환과 같은 이슈 해결 불가

역시 또다른 방법이 필요했다.

 

JSON.stringify의 replacer

우측 링크에서 동일한 문제에 직면한 사람이 해결한 방법을 참고했다. (링크)

역시 언제나 선구자는 존재한다.

 

Json.stringify의 parameter중 하나인 replacer 함수를 이용하여 원하는 로직을 실행하는 방법이다.

예시 코드

JSON.stringify(data, (k, v) => (typeof v === "bigint" ? Number(v) : v))

이렇게 replacer를 활용하면 bigint를 원하는데로 다룰 수 있고, 동시에 다른 타입도 다룰 수 있다.

또한 해당 stringify를 제외한 다른 코드에는 사이드 이팩트를 주지 않는다!

 

너무 좋은 방법이라고 생각했고, 이를 이용하면 재귀함수로 짠 코드보다 뛰어난 퍼포먼스를 낼 것이다라는 점을 테스트하는 단계로 넘어갔다.


테스트

가설

JSON 객체의 BigInt를 Number로 형변환 시, 재귀 반복문보다 stringify의 replacer 퍼포먼스가 좋다.

 

테스트 데이터

BigInt 10만개를 포함한 depth 3의 JSON

{
  "0": {
    "0": {
      "0": 59312533718484063337n,
      "1": 2095074097253798812312n,
      ...
    }
  ...
  }
 ...
}

 

테스트 코드

- 재귀 반복

function transForm(obj) {
  for (const key in obj) {
    // convert main tree
    if (typeof obj[key] == "bigint") {
      obj[key] = Number(obj[key].toString());
    }

    for (const prop in obj[key]) {
      // recursively search for inner branches
      if (typeof obj[key][prop] == "object") {
        transForm(obj[key]);
      } else {
        if (typeof obj[key][prop] == "bigint") {
          obj[key][prop] = Number(obj[key][prop].toString());
        }
      }
    }
  }

  return obj;
}

- replacer

JSON.stringify(data, (k, v) => (typeof v === "bigint" ? Number(v) : v));

 

결과

  • 재귀 반복문
    • 10회 테스트 평균 2138.9ms 소요
  • replacer
    • 10회 테스트 평균 230.6ms 소요
  • replacer를 활용한 방식이 기존의 재귀 반복문보다 약 927% 퍼포먼스 향상

 

결론

JSON.stringify의 replacer를 활용한 방식이 기존 재귀 반복문보다 뛰어나다는 결론을 얻었다.

이제 실제 프로덕트에 반영하여 결과를 살펴 볼 시간이다.


결과

프로덕트의 gloabal interceptor를 기존 코드에서 replacer를 반영하고 22시간 모니터링을 해 보았다.

ms 평균 P50 P95 P99
기존 53.4 11 226.6 933.5
개선 29.8 10 72.8 495.3
차이 179%(23.6) 110%(1) 311%(153.8) 188%(438.2)

API 응답속도를 평균 179%가량 개선해서 매우 만족스러운 결과를 얻었다.

 


고찰

인간의 평균 반응속도는 200ms라고 한다.

이 말은, 200ms 안에 API가 고객에게 서빙된다면 최고의 경험을 제공할 수 있다는 말로 해석된다.

 

이번 성능 개선 작업에서 P95의 평균 응답속도를 200ms 밑으로 줄일 수 있어 매우 의미가 있다고 생각한다.

앞으로도 응답속도에 더욱 신경쓰는 개발자가 되어야한다고 조금 더 마음먹게 된 작업이었던 것 같다.