for(var i=0;i<1000;i+=1) {
document.body.innerHTML += '<div>~Pen Pineapple Apple Pen~</div>'
}
innerHTML에 텍스트 어펜드로 HTML 텍스트를 적용하면 기존에 해당 엘리먼트의 자식으로 속해 있던 이미 만들어진 엘리먼트들도 새로 추가된 텍스트와 함께 다시 파싱되고 만들어지기 때문에 루프를 돌고 난 후 보여지는 div 태그의 갯수는 1,000개이지만 실제로 브라우저가 파싱해서 만들거나 지우게 된 div태그는 500,500개이다. 그래서 루프를 돌수록 파싱하는 양도 많아져 점점 수행하는 시간이 길어진다. 매번 파싱하는 비용도 그렇지만 점점 파싱하는데 걸리는 시간도 길어지니 최악의 퍼포먼스를 보여준다. 해결 방법은 여러가지가 있지만 여기서는 innerHTML을 이용해서 해결하는 방법으로 비교해보겠다. 해결방법은 HTML 텍스트를 미리 만들어둔 다음 한번에 innerHTML에 적용하면 된다.
var str = '';
for(var i=0;i<1000;i+=1) {
str += '<div>~Pen Pineapple Apple Pen~</div>';
}
document.body.innerHTML = str;
코드를 실행해보면 육안으로도 두 번째 구현이 훨씬 빠른것을 확인할 수 있다. 이제 위 두 가지의 구현이 타임라인에서는 어떤 차이를 보여주는지 확인해보자. 우선 타임라인의 기본 사용법을 알아보자.
타임라인 기본 사용법
타임라인의 기본적인 작동 방법은 레코딩 버튼을 눌러 레코딩을 시작하고 측정하고자 하는 동작을 수행하고 레코딩을 종료한 뒤 측정된 결과를 확인하는 것이다. 레코딩시 추가로 기록할 정보들을 선택할 수도 있는데 상단의 컨트롤 바의 체크박스를 통해서 할 수 있다.
레코딩할 이벤트 종류로 자바스크립트 스택트레이스를 포함하려면 JS Profile 체크 박스를 활성화 해야 한다. 이외 각 체크 박스로 추가되는 정보의 내용은 체크박스 레이블로 바로 알 수 있는데 이중 Screenshots를 선택하면 측정 시간대별 웹페이지의 스크린샷을 기록한다. 화면에서 그려지는 내용의 변화를 확인할 때 매우 유용하게 사용된다. 이제 레코딩을 해보자. 컨트롤 바의 맨 왼편의 동그란 버튼이 레코딩 버튼이고 그 바로 옆의 버튼은 레코딩된 내용을 지워 버리는 버튼이다. 레코딩을 버튼을 누르자.
레코딩을 누르면 레코딩 상태창이 나온다 측정할 동작이 끝나면 Stop 버튼을 눌러 측정된 결과를 확인할 수 있다.
오버뷰와 플레임차트
아래의 이미지는 성능에 문제가 있었던 innerHTML의 첫번째 구현 코드를 측정한 것이다.
컨트롤 바 밑으로는 측정된 결과를 조망해볼 수 있는 오버뷰가 있다. 시간의 변화에 따른 CPU의 사용량과 FPS, 네트워크의 사용 내용의 변동 추이를 확인할 수 있다. 오버뷰에서는 측정 시간 동안의 전반적인 상황을 살펴볼 수 있고 오버뷰 밑으로는 바 형태로 세부 이벤트들이 표현되는 플레임차트(Flame Chart)와 디테일뷰를 통해 각 이벤트에 대한 자세한 정보를 확인할 수 있다. 오버뷰에서 플레임 차트로 이어지는 중간에 약간 붉은바가 보이고 그 안에 1171.3ms라고 적혀있는데 이 바는 한 프레임(frame)을 뜻하고 붉은색을 띄는건 한 프레임이 너무 길다는 뜻이다. 여기서의 프레임은 한 화면을 갱신하는데 걸린 시간이라고 생각하면 된다. 프레임에 소요된 시간이 길었기 때문에 오버뷰의 녹색 FPS(Frame per second) 그래프가 거의 바닥을 찍고 있다.
플레임 차트를 확대해서 세부 내용을 살펴보거나 위치를 이동하려면 오버뷰의 레인지를 컨트롤 하는 두개의 바를 조절하거나 플레임 차트에 마우스 휠과 드래그를 이용한다. 플레임 차트를 확대 해서 살펴보니 구현 코드가 들어있는 hitListener함수가 실행되는 동안 Parse HTML이라는 파란색 이벤트가 여러번 발생하고 있다. Parse HTML 이벤트는 루프안에서 매번 innerHTML을 변경 적용할때 마다 브라우저가 innerHTML의 HTML텍스트를 다시 파싱해 적용하기 때문에 발생했다. 브라우저의 필요 동작에 따라 더 발생하기도 하는데 루프가 천 번을 돌았으니 적어도 천 번이상의 Parse HTML이벤트가 발생한것이다. 그리고 이미지에는 나타나지 않았지만 심지어 중간에 몇번이나 가비지 컬렉터가 돌아갔다.
그리고 첫 Parse HTML 이벤트와 마지막 이벤트의 지속 시간을 비교해보면
첫번째 이벤트는 0.04ms 마지막 이벤트는 1.24ms로 큰 차이가 난다. 루프가 돌면 돌수록 기존에 적용된 이미 만들어진 엘리먼트들도 모두 지우고 다시 파싱해 생성하기 때문에 시간이 늘어난 것이다. Parse HTML 이벤트의 발생 횟수와 시간의 증가 이렇게 두가지 문제점이 측정결과로 나타났다.
성능이 개선된 두번째 구현의 측정 결과를 확인해보자.
전체적으로 성능이 개선되어 브라우저의 내부동작이 줄었기 때문에 오버뷰에서 측정된 데이터의 범위도 좁다. 프레임의 시간이 줄었기 때문에 FPS도 많이 올라갔다. 아무래도 1000개의 엘리먼트를 한번에 그리는 작업이다 보니 FPS가 약간 떨어지는 것은 어쩔수 없다. 프레임바를 보면 총 소요시간도 21.9ms으로 이전의 1171.3ms에 비하면 어마어마하게 빨라졌다. innerHTML에 1000개의 태그를 담은 HTML텍스트를 단 한 번만 적용 했기 때문에 플레임차트에서 보여주듯 함수가 실행되는 동안 발생한 Parse HTML 이벤트는 단 한번이고 소요된 시간도 0.98ms밖에 안된다. 이렇게 타임라인은 자바스크립트의 구현에 따라 어떻게 성능이 떨어졌고 혹은 개선되었는지에 대한 판단 근거들을 제공한다.
레이아웃 이벤트
레이아웃 이벤트는 리플로우라고도 한다. 주로 엘리먼트의 사이즈나 위치등 말 그대로 엘리먼트의 레이아웃이 변경될때 발생하는 작업이다. 엘리먼트들은 서로 위치나 사이즈에 영향을 주거나 받는다.(블럭요소나 인라인요소등) 특정 엘리먼트의 레이아웃이 변경되면 렌더트리가 다시 배치되면서 레이아웃에 관련된 속성들을 재계산 하는 작업이 필요하게 되는데 이때 발생하는게 레이아웃 이벤트다. 이런 레이아웃 이벤트는 수행시간이나 수행횟수에 의해 성능에 영향을 주게 된다. 원인과 해결방법은 여러가지가 있는데 간단한 예를 통해 레이아웃 이벤트와 문제있는 코드를 추적하고 해결해 보자.
디테일뷰와 레이아웃 이벤트 추적
사실 UI를 다루는 이상 시각적인 효과를 위해 발생하는 꼭 필요한 레이아웃은 이벤트는 사실 피할 수가 없다. 다만 불필요하게 발생하는 케이스를 줄이는 작업은 필요하다. 아래와 같은 플레임 차트는 가장 이상적인 모습이라고 볼 수 있다.
버튼이 클릭되면 특정 엘리먼트의 height를 변경하는 단순한 구현의 플레임 차트이다. 자바스크립트 함수(hitListener)의 실행이 종료되고 뒤이어 레이아웃 이벤트가 한번 발생했다. 리스너 함수에서 DOM 엘리먼트의 height를 변경하는 속성을 조작했기 때문에 레이아웃이 발생한 것이다. 여기서 디테일 뷰를 이용해 레이아웃 이벤트에 관한 자세한 정보를 얻을 수 있다. 플레임 차트에서 레이아웃 이벤트를 클릭하면 디테일뷰에서 해당 이벤트에 관한 정보가 나온다.
디테일뷰의 Summary탭에는 선택된 이벤트의 수행 시간 및 이벤트에 특정된 정보들이 노출되게 된다. 그뒤로 이어지는 Bottom-up, Call Tree, Event Log들은 선택된 이벤트의 하위 이벤트들을 여러가지 형태로 정리해서 보여준다. 하나씩 선택해서 보면 각 탭의 특징을 파악할 수 있다. 우린 디테일뷰에서 레이아웃 이벤트를 발생하게 만든 코드를 찾아 보려고 한다. Summary탭 하단의 First Layout invalidation 정보에서 레이아웃의 발생원인을 찾을 수 있다. First가 붙어 첫번째 원인만 알려주는 이유는 레이아웃 이벤트는 실행되는 자바스크립트 코드중 원인이 되는 코드 라인이 1개가 아니라 1000개라도 첫번째 코드에 의해 한번만 발생 하기때문에 처음 코드라인에 대한 정보만을 표기한다. script2.js라는 파일에 6번째 라인이라고 표시 되어있다. 클릭하면 해당 코드로 이동된다.
엘리먼트의 기존의 height값을 읽어와 10증가시키고 다시 적용하는 코드이다. 정확하게 커서가 height를 엘리먼트에 적용하는 부분에 멈춰있다. 여기서 발생한 레이아웃 이벤트는 height를 변경한다는 시각적인 UI스펙을 구현하기 위해서는 어쩔 수 없는 부분이기 때문에 이 코드는 개선할 여지가 없다. 그러면 문제가 되는 구현의 플레임 차트를 살펴보자.
이번 구현은 아까 height를 바꾼 코드에 이어서 비슷한 내용으로 width도 변경하는 내용이 추가된 구현이다. 자바스크립트 실행이 끝나고 레이아웃이 발생하는 것은 그대로 인데 hitListener함수가 실행되는 중간에 추가로 레이아웃 이벤트가 발생했다. 심지어 이 레이아웃 이벤트의 마우스 툴팁에는 리플로는 성능에 병목을 가져온다고 빨갛게 경고하고 있다. 해당 레이아웃 이벤트를 클릭해서 디테일뷰를 살펴보자.
디테일 뷰에서도 상단에 크게 경고문구가 떠있다. 그리고 First Layout invalidation항목 위에 한가지 항목이 더 추가되었다. Layout Forced라는 항목인데 레이아웃이 스크립트 실행이 완료된 후 발생하지 않고 스크립트 실행중에 당겨져서 발생했을 때 표시된다. 해당 코드때문에 중간에 레이아웃이 발생했다는 것이다. 해당 코드를 따라가 보자.
Layout Forced에서 지목한 라인은 18번째 라인이다. 엘리먼트의 width값을 읽어오는 부분인데 이 부분이 왜 문제가 될까? 아래 코드에 주석으로 브라우저가 해당 코드를 실행하면서 판단하는 내용들을 임의로 적어봤다.
var newHeight = el.offsetHeight + 10;
el.style.height = newHeight + 'px';
var newWidth = el.offsetWidth + 10;
el.style.width = newWidth + 'px';
레이아웃 작업이 수행되기 전에 레이아웃 관련 속성을 읽는 자바스크립트 코드가 실행이 되어 해당 속성의 값을 넘겨주기 전에 미리 레이아웃을 당겨서 수행하는 것이다. 관련있는 엘리먼트들의 속성값들을 재계산해서 정확한 값으로 갱신해야 하기 때문이다. 이 문제를 해결하는 방법은 매우 간단한 편인데 레이아웃 관련 속성을 변경하는 코드가 1번이 되었던 1000번이 되었던 레이아웃 이벤트는 한 번만 발생한다는 사실이 단서가 된다. 간단하게 코드의 위치를 변경하는 것으로 해결할 수 있다.
var newHeight = el.offsetHeight + 10;
var newWidth = el.offsetWidth + 10;
el.style.height = newHeight + 'px';
el.style.width = newWidth + 'px';
엘리먼트의 레이아웃 속성을 변경하기 전에 미리 필요한 속성의 값을 읽어온 이후에 레이아웃 관련 속성을 수정 적용했다. 이후 플레임 차트의 모습은 다시 레이아웃이 한번만 발생하는 이상적인 모습이 된다. 사실 이 예는 문제가 전혀 되지 않을 정도의 작은 부분이지만 큰 UI의 성능을 개선 할때도 비슷한 방식으로 레이아웃 이벤트가 자주 발생하거나 수행 시간이 긴 부분의 구현을 구간 구간 추적해서 구현을 달리해 해결하는 방법으로 성능을 향상할 수 있다. 레이아웃 이벤트의 수행시간 역시 성능에 큰 영향을 주고 또 다루지 않았지만 리페인트(repaint)로 알려져있는 Recalculate Style 이벤트도 성능을 개선할 포인트가 될 수 있다.
정리
당연한 말이겠지만 프론트 엔드의 성능이 저하되는 원인은 일일이 다 열거 할 수 없을 정도로 많이 있다. 타임라인은 브라우저 내부의 동작들을 살펴보며 성능에 영향을 주는 문제를 발견하는 데에는 현재로서는 유일무이한 도구라고 생각한다. 플레임 차트에서 발생하는 이벤트의 종류는 구글 개발자 사이트에서 잘 정리되어 있다. 직접 구현한 코드에서 시간을 많이 잡아 먹는 이벤트가 발생한다면 해당 이벤트에 대한 정보를 수집해 개선할 방법을 찾아 보는 것으로 성능 향상을 꾀할 수 있다.