배경 상황

Untitled

Monument Gallery의 초기 렌더링 성능을 계산하고 있던 도중, FCP나 LCP는 준수한 성능을 보여주지만, Total Blocking Time이 매우 오래 걸리는 것을 발견했습니다. Total Blocking Time은 렌더링 과정 중 50ms 이상 오래 걸리는 작업의 시간들로, 주로 자바스크립트 실행 중 오래 걸리는 로직으로 인해 발생하게 됩니다. 미디어 아트 프로젝트의 특성상 사용자는 초기 렌더링에는 관대한 편이나, 서비스 도중 데이터를 변경하여 전체를 리렌더링하는 부분(히스토리 변경 등)이 존재하고, 이 과정에서 블로킹 타임이 오래 걸리면 사용자의 경험을 해칠 수 있다는 판단 하에 데이터 렌더링 속도를 개선하기로 했습니다.

무엇이 문제인가?

지난 성능 향상 과정에서, 메인 페이지와 갤러리 페이지의 라이트하우스 퍼포먼스 점수는 개인 페이지 쪽이 Total Blocking Time이 0이어서 갤러리 페이지의 퍼포먼스 점수가 더 높았습니다.(메인 페이지의 블로킹 타임은 약 600ms) 하지만, 위에서 볼 수 있다시피 다른 갤러리 페이지의 블로킹 시간이 2초이기 때문에, 퍼포먼스 점수가 좋지 않습니다. 앞서 테스트했던 갤러리 페이지는 /gallery로 들어가서 three.js 오브젝트를 생성조차 하지 않기 때문에, Total Blocking Time이 0이라는 것을 알았고, three.js 오브젝트 중 문제가 있다는 것을 파악했습니다.

갤러리 페이지에서 가장 많은 비중을 차지하는 오브젝트는 각 페이지를 시각화하는 페이지 섬(GalleryPageIsland)입니다. 페이지 섬은 타이틀, 섬, 워드클라우드, 부제목, 사진 파편, 링크 페달로 구성되며, 각각의 오브젝트 하나만 생성했을 때 로딩 속도를 비교했습니다.

이 중 가장 긴 로딩 속도를 자랑하는 사진 파편이 문제라고 판단하여, 사진 파편의 초기 렌더링을 최적화하기로 하였습니다.

사진 파편 초기 로딩 최적화

최적화 이전의 로딩 과정 메모리 그래프. 130MB까지 치솟는 메모리를 볼 수 있으며, 메인 가비지 컬렉션이 10번이나 일어나는 모습을 볼 수 있다.

최적화 이전의 로딩 과정 메모리 그래프. 130MB까지 치솟는 메모리를 볼 수 있으며, 메인 가비지 컬렉션이 10번이나 일어나는 모습을 볼 수 있다.

사진 파편의 초기 로딩에 가장 큰 영향을 주는 것은 바로 사진 파편의 지오메트리 생성입니다. 사진 파편의 지오메트리는 크게 두 부분으로 나뉘는데, 각 버텍스에 속성을 계산하여 부여하는 부분과, 속성을 기반으로 실제 위치를 계산하는 부분으로 나뉩니다. 버텍스에 속성을 계산하는 부분이 50~70ms, 실제 위치를 계산하는 부분이 20ms로 코스트가 큰 편입니다.

for (let y = 0; y < this.rows; y++) {
	for (let x = 0; x < this.columns; x++) {
	  const { uv, pivot, vertexPosition, localRot, globalRot, globalDist, pickedTriangle } = this.#setAttributes(
	    x,
	    y,
	  );
	
	  const normal = [];
	  const color = [];
	  const { r, g, b } = new Color(pixels[y][x]);
	  for (let i = 0; i < 6; i++) {
	    normal.push(0, 0, 1);
	    color.push(r, g, b);
	  }
	
	  normals.push(...normal);
	  uvs.push(...uv);
	  vertexPositions.push(...vertexPosition);
	  trianglePivots.push(...pivot);
	  localRotationQuaternions.push(...localRot);
	  globalRotationQuaternions.push(...globalRot);
	  globalScatteredDistances.push(...globalDist);
	  colors.push(...color);
	  pickedTriangles.push(...pickedTriangle);
	}
}

사진 파편은 각 픽셀에 해당하는 속성마다 uv, pivot 등 배열을 새로 생성하며, 이 배열들을 스프레드 연산자를 이용해 원 배열에 추가하는 방식으로 구현되어 있었습니다. 문제는 이 과정에서 한 번만 쓰이고 버려지는 메모리가 매우 많이 생긴다는 것이었습니다. 배열을 스프레드 연산자로 펼치는 것 자체도 연산이 많이 들어갈 뿐더러, 안 쓰이는 메모리가 많이 생기면 브라우저에 할당된 메모리의 상한을 빨리 찍어서 잦은 가비지 컬렉션을 유발하기 때문에, 성능 저하가 일어난 것입니다.

const attributes = {
  uvs: [],
  vertexPositions: [],
  trianglePivots: [],
  localRotationQuaternions: [],
  globalRotationQuaternions: [],
  globalScatteredDistances: [],
  pickedTriangles: [],
};

const _color = new Color();
for (let y = 0; y < this.rows; y++) {
  for (let x = 0; x < this.columns; x++) {
    this.#setAttributes(x, y, attributes);

    const { r, g, b } = _color.set(pixels[y][x]);
    for (let i = 0; i < 6; i++) {
      normals.push(0, 0, 1);
      colors.push(r, g, b);
    }
  }
}

#setUpperTriangleAttributes(x: number, y: number, attributes: PixelFragmentAttributes) {
  // set uv
  this.#setVertexUVAttributes(x, y, 0, 0, attributes.uvs);
  this.#setVertexUVAttributes(x, y, 0, 1, attributes.uvs);
  this.#setVertexUVAttributes(x, y, 1, 1, attributes.uvs);

  // set fragment vertex position
  this.#upperVertexPosition.forEach((e) => attributes.vertexPositions.push(e));

  // set fragment triangle pivots
  for (let i = 0; i < 3; i++) {
    this.#setVertexPosAttributes(x, y, 0.25, 0.75, attributes.trianglePivots);
  }

  // set common triangle attributes
  this.#setTriangleAttributes(attributes);
}

버려지는 메모리를 최소화하고 가비지 컬렉션을 최소화하기 위해, 루프 밖에 배열을 선언하고, #setAttributes 함수 내부에서는 매개변수로 받은 배열 묶음에 생성한 데이터를 추가하는 방식으로 변경했습니다. 이 과정에서 깔끔한 코드를 희생하였으나, 주석을 추가하는 것으로 코드를 읽는 가이드를 주어 코드의 품질을 보완하였습니다.