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번이나 일어나는 모습을 볼 수 있다.
사진 파편의 초기 로딩에 가장 큰 영향을 주는 것은 바로 사진 파편의 지오메트리 생성입니다. 사진 파편의 지오메트리는 크게 두 부분으로 나뉘는데, 각 버텍스에 속성을 계산하여 부여하는 부분과, 속성을 기반으로 실제 위치를 계산하는 부분으로 나뉩니다. 버텍스에 속성을 계산하는 부분이 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
함수 내부에서는 매개변수로 받은 배열 묶음에 생성한 데이터를 추가하는 방식으로 변경했습니다. 이 과정에서 깔끔한 코드를 희생하였으나, 주석을 추가하는 것으로 코드를 읽는 가이드를 주어 코드의 품질을 보완하였습니다.