플러그인 성능을 200% 향상시킨 방법

작성자

카테고리:

,

지난 2017년 2월 2일, Ann Taylor는 CodeInWP에 12 Things We Need to See From WordPress Page Builders in 2017 + Who Already Gets It Right라는 기사를 작성했습니다. 저희는 1년 넘게 Page Builder Sandwich를 지속적으로 개선해 오고 있었습니다. 시장 조사도 하고, 고객의 의견에 귀를 기울여왔기에 Page Builder Sandwich가 CodeInWP의 “제대로 해낸 이들(Who Got it Right)” 기사에 선정될 것이라 생각했습니다. 하지만 성능 문제 때문에 선정되지 못했습니다. ?

그래서 저희는 Ann에게 이에 관해 물었고, 그녀는 다음과 같이 답했습니다:

…무료 버전을 잠시 테스트해 보았습니다. 많은 모듈, 꽤 직관적인 UI, 훌륭한 WYSIWYG 환경, 모든 요소를 쉽게 드래그할 수 있다는 점에서 가능성이 돋보입니다. 하지만 페이지에 약 10개 정도의 모듈이 배치되자 편집 속도가 느려져 작업을 계속하기가 힘들었습니다…

그녀에 따르면 Page Builder Sandwich에 성능 문제가 있다는 것이었습니다. 저희는 항상 피드백에 귀를 기울이므로 즉시 확인 작업에 착수했습니다.

성능 테스트

PBS의 성능을 확인할 방법이 필요했습니다. PBS는 주로 자바스크립트를 통해 브라우저에서 실행되므로, 크롬의 Timeline 프로파일러가 이 작업에 가장 적합한 도구였습니다. Timeline 프로파일러는 런타임 중에 웹페이지에서 어떤 프로세스가 실행되고 있는지 식별하고 측정할 수 있어 속도 병목 현상을 찾는 데 안성맞춤입니다.

저희는 가장 간단한 단계부터 테스트해 보기로 했습니다. 여러 요소와 행 위로 마우스를 움직여보는 것이었죠. 간단하죠? 이 정도로 단순한 작업에서는 성능 문제가 발생하지 않을 것이라 자신했습니다. 하지만 안타깝게도 저희 생각이 틀렸습니다.

기록 버튼을 누르고 화면에서 약 2초간 마우스를 움직인 후 정지 버튼을 눌렀습니다.  프로파일러에서 나온 결과 타임라인은 다음과 같습니다:

이럴 수가. 성능 병목 현상으로 가득 차 있었습니다!

그래프에서 많은 일들이 일어나고 있지만, 가장 명백한 문제는 보라색 영역이 너무 많다는 점이었습니다(원래는 이러면 안 됩니다). 게다가 이 보라색 영역들은 눈에 띄게 넓었으며, 모두 그래프의 최고점에 도달하는 것처럼 보였습니다. 또한 그래프가 항상 꽉 차 있고 보라색 영역이 0으로 떨어지는 경우가 거의 없다는 점도 눈에 띌 것입니다. 브라우저가 쉴 틈 없이 계속 무언가를 처리하느라 바쁜 것처럼 보입니다.

그렇다면 이 보라색 영역들은 실제로 무엇을 의미할까요?

프로파일러는 친절하게도 이것들이 리플로우(reflow)라고 알려주었습니다. 다음은 Google 개발자 사이트:

리플로우(Reflow)는 문서 내 요소들의 위치와 지오메트리(기하학적 형태)를 다시 계산하기 위한 웹 브라우저 프로세스의 이름입니다.. 리플로우는 브라우저에서 사용자 작업을 차단하는(user-blocking) 작업입니다.

즉, PBS에서 마우스를 이리저리 움직이는 것만으로도 수많은 리플로우가 유발된다는 뜻입니다. 더 안 좋은 점은 각 리플로우가 계산을 끝내기 위해 브라우저를 순간적으로 멈추게 만든다는 것입니다. 리플로우가 많을수록 브라우저가 더 많이 멈추게 되고 화면은 더욱 버벅거리게(janky) 됩니다. 간단히 말해 보라색 영역이 많다는 것은 나쁜 징조입니다.

결국 Ann의 말이 맞았습니다. 저희에게는 정말로 거대한 성능 병목 현상이 존재했고, 개발팀은 이를 바로잡기 위해 즉각 작업에 착수했습니다.

리플로우 / 버벅임(Jank) 문제 해결하기

다른 무엇보다 먼저, 애초에 무엇이 이러한 리플로우를 유발하는지 파악하기 위해 조사를 진행해야 했습니다. 그 결과 놀랍게도 자바스크립트의 매우 흔한 요소들이 브라우저의 리플로우를 유발할 수 있다는 사실을 알아냈습니다.

DOM 요소의 너비, 높이 또는 위치를 가져오는 데 사용하는 거의 모든 함수가 리플로우를 일으킬 수 있습니다. Paul Irish는 이러한 동작을 유발하는 자바스크립트의 모든 속성과 함수에 대한 광범위한 목록을 작성한 바 있습니다. 안 좋은 소식은 Page Builder Sandwich가 이러한 속성과 함수를 아주 많이 사용하고 있었으며, 이 모든 것들이 리플로우를 일으키고 있었다는 점입니다:

  • elem.getBoundingClientRect()
  • window.getComputedStyle()
  • window.scrollY
  • 그 외 다수…

해결책 1: 비용이 큰 리플로우 프로세스 캐싱하기

가장 명백한 해결책은 해당 속성과 함수를 사용하지 않는 것입니다. 하지만 PBS는 이에 크게 의존하고 있어서 그것들 없이 작동할 수는 없었습니다.

남은 해결책은 이러한 함수에 대한 호출을 최대한 줄이는 것이었습니다. 까다로운 부분은 PBS의 다양한 위치에서 이러한 속성과 함수를 수없이 사용하고 있었고, 대부분 동일한 DOM 요소에 대해 여러 번 중복 사용하고 있었다는 점입니다. 이를 해결하기 위해 문제가 되는 각 속성/함수별로 자체 캐싱 메커니즘을 만들었습니다.

예를 들어 getBoundingClientRect  함수는 DOM 요소의 현재 top, left, width, height, right, bottom 픽셀 측정값을 반환합니다. 저희는 PBS에서 요소 외곽선을 그릴 위치를 계산하기 위해 이 함수를 항상 사용합니다. 다음은 이를 위해 저희가 만든 캐싱 함수입니다:

// Cache: 클라이언트 사각형(client rect)이 여기에 보관되며, 나중에 이를 지울 때 사용할 수 있습니다.
var elemsWithBoundingRects = [];

window.pbsGetBoundingClientRect = function( element ) {

	// 이전에 이미 client rect를 가져왔는지 확인합니다.
	if ( ! element._boundingClientRect ) {

		// 없다면 가져와서 나중에 사용할 수 있도록 저장합니다.
		element._boundingClientRect = element.getBoundingClientRect();
		elemsWithBoundingRects.push( element );
	}
	return element._boundingClientRect;
};
​

이제 getBoundingClientRect 를 직접 호출하는 대신 pbsGetBoundingClientRect 를 사용했습니다. 이제 client rect가 조회될 때마다 나중에 참조할 수 있도록 해당 값을 저장하므로 리플로우 트리거 횟수를 제한할 수 있게 되었습니다.

또한 저장된 client rect가 무효화되었을 때 캐시를 지울 방법도 필요했습니다. 저장된 client rect 값은 사용자가 페이지를 스크롤하거나 화면의 요소를 추가/변경/삭제할 때 변경됩니다. 그래서 최신 client rect 값이 필요할 때마다 호출할 수 있는 캐시 삭제 함수를 만들었습니다:

var clearClientRects = function() {
	var i;
	for ( i = 0; i < elemsWithBoundingRects.length; i++ ) {
		if ( elemsWithBoundingRects[ i ] ) {
			elemsWithBoundingRects[ i ]._boundingClientRect = null;
		}
	}
	elemsWithBoundingRects = [];
};​

자주 사용하는 다른 비용 높은 함수 호출에 대해서도 동일한 캐싱 메커니즘을 적용했습니다.

해결책 2: FastDom을 사용하여 읽기(Read)와 쓰기(Write) 한데 묶기

해결책 1을 적용하여 PBS의 성능이 향상되었지만 그것만으로는 충분하지 않았습니다.

구글링을 좀 더 해보다가, Wilson Page가 작성한 ‘레이아웃 스래싱(layout thrashing)’ 방지에 관한 기사를 발견했습니다. Wilson은 다음과 같이 말합니다:

레이아웃 스래싱(리플로우)은 자바스크립트가 DOM에 대해 격렬하게 쓰기(Write) 작업을 한 후 읽기(Read) 작업을 여러 번 반복하여 문서 리플로우를 일으킬 때 발생합니다.

알고 보니 특정 리플로우 유발 속성과 함수의 호출을 제한하는 것만으로는 충분하지 않았습니다. DOM에 대해 여러 번 연속으로 “읽기”와 “쓰기”를 수행할 때도 브라우저 리플로우가 발생합니다. Wilson의 사이트에 나온 일반적인 시나리오는 다음과 같습니다:

// 읽기 (Read)
var h1 = element1.clientHeight;

// 쓰기 (Write, 레이아웃 무효화)
element1.style.height = (h1 * 2) + 'px';

// 읽기 (Read, 리플로우 유발)
var h2 = element2.clientHeight;

// 쓰기 (Write, 레이아웃 무효화)
element2.style.height = (h2 * 2) + 'px';

// 읽기 (Read, 리플로우 유발)
var h3 = element3.clientHeight;

// 쓰기 (Write, 레이아웃 무효화)
element3.style.height = (h3 * 2) + 'px';

// 프레임 끝에서 문서 리플로우 발생​

위의 예시는 쓰기 작업 후에 전체 페이지의 치수가 무효화되기 때문에 3번의 리플로우를 일으킵니다. 이를 위해 권장되는 해결책은 코드를 재정렬하여 모든 읽기와 쓰기 작업을 한데 묶어 전체 과정에서 브라우저 리플로우가 단 1회만 발생하도록 하는 것입니다:

// 읽기 (Read)
var h1 = element1.clientHeight;
var h2 = element2.clientHeight;
var h3 = element3.clientHeight;

// 쓰기 (Write, 레이아웃 무효화)
element1.style.height = (h1 * 2) + 'px';
element2.style.height = (h2 * 2) + 'px';
element3.style.height = (h3 * 2) + 'px';

// 프레임 끝에서 문서 리플로우 발생​

위 스니펫을 보면 문제 해결이 쉬워 보입니다. 하지만 실제 PBS에서는 리플로우를 유발하는 읽기 및 쓰기 작업이 라이프사이클 동안 수많은 서로 다른 시점과 위치에서 일어납니다. 수천 줄에 달하는 자바스크립트 코드를 일일이 재구성하는 것은 불가능한 일이었습니다.

다행히도 Wilson은 기사에서 해결책까지 제시해 주었습니다. 그는 이러한 읽기와 쓰기를 하나로 묶는 데 도움이 되는 FastDom이라는 작은 유틸리티 스크립트를 만들었습니다.

다음은 FastDom을 사용하고 위의 해결책 1도 포함된 Page Builder Sandwich의 코드 일부입니다:

// 다른 스레드의 읽기 작업을 하나로 묶습니다.
fastdom.measure( function() {

	// 캐시된 client rect를 사용합니다.
	var rect = window.pbsGetBoundingClientRect( elem );

	// 다른 스레드의 쓰기 작업을 하나로 묶습니다.
	fastdom.mutate( function() {
		elem.style.top = ( rect.top + window.pbsScrollY() ) + 'px';
		elem.style.height = rect.height + 'px';
		elem.style.left = rect.left + 'px';
		elem.style.width = rect.width + 'px';
	}.bind( this ) );
}.bind( this ) );​

FastDom이 하는 일은 모든 fastdom.measure  호출과 모든 fastdom.mutate  호출을 모아 비동기적으로 실행함으로써, 모든 읽기와 쓰기를 효율적으로 그룹화하여 브라우저의 리플로우를 줄여주는 것입니다.

해결책 3: 스타일 객체 접근 비용 줄이기

저희는 jsPerf를 통해 일부 스타일 조회 함수가 다른 함수들보다 훨씬 빠르다는 사실도 발견했습니다.

PBS에서는 요소의 특정 스타일에 대한 현재 값을 가져와야 하는 경우가 많습니다. 예를 들어 일반적으로 top margin을 가져오기 위해 다음과 같이 사용합니다:

var styles = window.getComputedStyle( elem );
var marginTop = styles.marginTop;​

하지만 다음과 같이 처리하는 것이 훨씬 빠르다는 것을 확인했습니다:

var marginTop = elem.style.marginTop;​

그러나 elem.style.marginTop 과 getComputedStyle 은 매우 다릅니다. 전자는 요소의 인라인 스타일만 반환하며 스타일시트는 고려하지 않습니다. 후자는 인라인 스타일과 스타일시트를 모두 고려합니다.

게다가 getComputedStyle  결과를 캐싱하더라도 여전히 속도가 매우 느렸습니다. 알고 보니 스타일 객체 자체에 접근하는 것 자체가 느린 프로세스였던 것입니다.

다행히도 PBS는 요소에 인라인 스타일을 추가하는 방식이며, (대부분의 경우) 인라인 스타일이 스타일시트보다 우선순위를 갖기 때문에 인라인 스타일 값을 가져오는 것만으로도 충분한 경우가 있습니다. 따라서 위의 두 가지 방식을 결합하면 훨씬 빠르게 실행되는 코드를 만들 수 있습니다:

var marginTop = elem.style.marginTop;
if ( ! marginTop ) {
	var styles = window.getComputedStyle( elem );
	marginTop = styles.marginTop;
}​

코드 개선의 결과

위의 3가지 해결책은 수많은 연구와 테스트, 실제 구현을 필요로 했고(이로 인해 Fonts 기능의 출시가 지연되기도 했습니다), 결국 그 모든 과정은 충분히 가치가 있었습니다.

화면에서 마우스를 2초간 움직였던 초기 테스트로 돌아가 보겠습니다. 다음은 보기 흉하고 버벅거리던 이전 타임라인입니다:

그리고 성능 개선 후의 새로운 타임라인은 다음과 같습니다:

보시다시피 그래프에서 보라색 부분이 눈에 띄게 줄어들었습니다. 브라우저에서 수행되는 프로세스의 양에도 확연한 차이가 있습니다. 하단의 직사각형 영역들도 훨씬 얇아졌는데, 이는 프로세스가 이전보다 빠르게 실행되고 있음을 의미합니다.

이전 그래프와 새 그래프에서 높고 넓은 보라색 영역의 양을 비교해 보면, 무려 200%의 성능 향상을 확인할 수 있습니다! 전반적인 영역에서도 전반적인 성능 향상을 보이고 있습니다. 아주 만족스러운 결과입니다!

교훈 및 알게 된 점

솔직히 Ann이 언급하기 전까지는 PBS에 성능 문제가 있다고 생각하지 못했습니다. 주된 이유는 개발용 고사양 PC를 사용하고 있었기 때문에 성능 병목 현상이 크게 두드러지지 않았기 때문입니다. 돌이켜보면 성능 점검이 최우선 과제 중 하나였어야 했습니다. 하지만 적어도 이제 4.3 버전부터는 이 문제가 완벽히 해결되었습니다. ?

자체 워드프레스 플러그인의 성능을 개선하고자 하는 분들, 특히 무거운 자바스크립트를 사용하고 계신 분들을 위해 이번 경험을 통해 얻은 교훈을 정리해 드립니다:

  1. 리플로우를 유발하는 속성/함수의 호출을 제한하거나 캐싱하세요. 가이드로 다음 문서를 참고하세요: What forces layout/reflow. The comprehensive list.
  2. DOM 스타일에 대한 “읽기”와 “쓰기”를 하나로 묶으세요. 이를 자동으로 처리해 주는 FastDom을 활용해 보세요.
  3. 크롬의 Timeline 프로파일러를 사용하고 리플로우(보라색 영역)가 최대한 적게 발생하도록 유지하세요.