블로그 내 검색

2012. 3. 5.

흔한 코딩에서 찾는 DOM 자바스크립트 성능 향상법 몇개 (수정)

개요
자바스크립트는 인터프리터 언어입니다.
코드 인터프리터는 자바스크립트 코드를 실제 컴퓨팅 환경에 맞는 방식으로 바꿔 그때그때 실행해야 합니다. 인터프리터 언어는 일반적으로 컴파일 언어에서 해주는 코드 재배치나 최적화를 거의 하지 못합니다.

크롬에 탑재된 V8엔진JIT(Just-In-Time)은 자바스크립트계의 혁신일 정도로 성능을 끌어올렸는데요. 인터프리트 말고 직접 기계어 컴파일을 시도합니다. 그것에 자극받은 타 엔진들, 사파리는 니트로 엔진, 파이어폭스는 트레이스몽키라는 엔진을 도입했지요.

하지만 아직 이런 엔진을 믿고 컴파일 언어처럼 방만(?)한 코딩을 하기엔 시기상조가 아닌가 싶습니다.

몇가지 자주 쓰이는 코드 패턴에서 자바스크립트를 최적화할 방법을 찾아봅시다.


로직 스크립트의 위치는 <body> 태그 맨 뒷부분에
대부분의 웹 프로그래머는 외부 스크립트 선언을 <head> 태그에 모아 둡니다. 하지만 이건 대부분 좋지 않다는걸 알고 있는 프로그래머는 드문 것 같습니다.

브라우저는 HTML을 파싱할 때, 스크립트 태그를 만나면 화면 파싱을 중지하고, 먼저 스크립트를 해석하고 실행합니다. 스크립트 해석이 완전히 끝난 뒤에야 다음의 DOM을 다시 해석하고 그려나갑니다.

만일 <head> 에 아주 길고 복잡한 스크립트 태그가 있다면, 그 자바스크립트 코드를 실행하고 파싱하느라 사용자는 허연 화면을 보고 있게 됩니다. 굳어버린 화면을 보며 뒤로 가기를 누르게 될 수도 있죠.

특별히 페이지에 뭔가를 보여주기 전에 실행해야 할 스크립트가 아니라면 (이런경우는 거의 없을 겁니다) <body> 태그를 닫기 전, 그러니까 </body> 바로 앞에 두는 편이 좋습니다.

이렇게 하면 DOM 파서는 모든 화면에 표시할 스크립트를 해석하고, 화면에 전부 표시한 뒤, 마지막으로 화면에 적용될 스크립트를 해석합니다.
이렇게 해 두면 적어도 사용자가 아무것도 표시되지 않은 허연 화면만을 보고 있는 상황은 피할 수 있습니다.

그리고 대부분의 경우, DOM 객체 조작을 하려고 굳이 window.onload 이벤트 핸들러에 동작을 할당할 필요도 없어지는 효과도 있습니다. 이미 모든 엘리먼트는 파싱되어 로드된 상태이므로, 그냥 바로 접근하면 됩니다.

(추가 - 익명님 댓글)
대부분의 프레임워크는 이 문제를 해결하기 위해 defer 기능을 제공합니다. 이것을 지원하는 이유는
  • <body></body> 사이에 <script>를 작성하지 않기 위해서입니다. 
  • 유지보수를 할 때 <body>에서 <script>를 찾는 것이 번거러우며 다수를 사용할 때는 놓칠 수 있습니다.
  • DOM으로 엘리먼트를 생성하여 <body>에 첨부할 때 제어하기가 번거롭습니다. 전체 image가 로드된 것을 체크해야 완전하게 <script> 코드 실행을 보장할 수 있기 때문입니다.


전역 객체를 지역 객체로 복사하기
다음 코드를 보세요.
이미지들을 모두 링크 태그로 바꾸는 함수입니다.
function imageSrcToAncher () {
    var downloadList = document.getElementById("downloadList");    
    var images = document.getElementsByTagName("img");
    for(var i = 0; i < images.length; i++) {
        var a = document.createElement("a")
        a.href = images[i].src;
        downloadList.appendChild(a);
        document.removeChild(images[i]);
    }   
}
이런 코드는 일견 문제가 없어 보이지만, 자바스크립트 스코프 탐색상 전역 객체는 스코프 체인의 제일 마지막에 위치합니다. 

document는 전역 객체이며 이 함수에서는 전역 객체에 1 + n*2(이미지태그의 수*2) 만큼 접근됩니다. 만일 이 함수가 글로벌 컨텍스트에서 실행하는 함수라면 별문제가 없을 수도 있습니다. 

하지만, 이 함수가 중첩 함수이거나 객체의 메소드, 반환 클로저 등의 상황에서 동작하는 경우라면 이야기가 다릅니다. 

자바스크립트의 스코프 탐색 방법에 따라, 실행시의 변수 식별에서는 스코프 체인의 제일 첫번째부터 순차적으로 변수를 뒤져갑니다. 스코프 체인의 구성 규칙에 의해 전역 객체변수는 스코프 체인의 제일 마지막에 있게 되며 따라서 스코프 체인 탐색의 제일 마지막에 전역 객체변수에 접근하게 됩니다.

위 코드에서는 그것이 여러번 반복됩니다. 자연히 성능은 떨어지고 속도는 늦어집니다. 그리고 사용자는 역시 굳은 화면을 보고 뒤로가기나 새로고침을 연타할지 모르는 일입니다.

이런 경우는 다음과 같이 고쳐서 성능을 올릴 수 있습니다.
function imageSrcToAncher () {

    // 내부 스코프에 전역 객체 참조를 복사해둡니다.
    var doc = window.document;

    var downloadList = doc.getElementById("downloadList");    
    var images = doc.getElementsByTagName("img");    
    var len = images.length;
    for(var i = 0; i < len; i++) {
        var a = doc.createElement("a")
        a.href = images[i].src;
        downloadList.appendChild(a);
        doc.removeChild(images[i]);
    }   
}
간단한 코드 한줄로 성능은 이루 말할 수 없이 올라가고, 코드도 한결 깔끔해집니다.

(추가 Insanehong 님 댓글)
문서에 엘리먼트를 다수 추가할 때에는 DocumentFragment 를 사용하는 것이 좋다고 합니다. 일단 필요한 엘리먼트를 미리 DocumentFragment를 사용하여 만들어 두고, 문서에 한번에 추가할 경우, 문서의 요소 재배치와 표현 연산을 많이 줄일 수 있기 때문입니다.

(추가 - 익명님 댓글) 
var emptyEl = doc.createDocumentFragment();
브라우저에 따라 속성이 설정되지 않는 경우가 있으므로 생성하려는 엘리먼트와 속성을 살펴본 후 사용하는 것이 좋다고 합니다.

innerHTML 사용 및 문자열 결합 주의
innerHTML은 참 편한 Element의 프로퍼티입니다. 하지만 잘못된 사용은 성능 저하를 가져옵니다.
var console = document.getElementById("console");
var lis = document.getElementsByTagName("il");
var len = lis.length;
for(var i = 0; i < len; i++) {
    console.innerHTML += li[i].innerHTML + "<br />";
}
위 코드는 innerHTML 속성을 무분별하게 참조합니다.
DOM 객체에 접근하는건 생각하는 것 이상으로 많은 자원을 소모하는 일입니다. 자바스크립트는 자바스크립트 코어에서 이웃집인 DOM API에 계속 방문하여 innerHTML을 찾는 거라고 보면 됩니다.

위 코드는 루프가 돌 때마다 3번씩 innerHTML을 참조하고 있습니다.
1번은 console의 innerHTML을 읽고, 그다음은 li의 innerHTML을 읽은 뒤, 마지막으로 console의 innerHTML에 합친 속성값을 할당하기 위해 읽습니다.

굉장히 비효율적입니다.

아래처럼 고치면 더욱 효율적입니다.
var console = document.getElementById("console");
var lis = document.getElementsByTagName("il");
var len = lis.length;
var cache = [];
for(var i = 0; i < len; i++) 
    // 일반적으로 이러한 문자열 결합은 +로 더해가는 것보다,
    // 배열을 사용하여 전부 요소로 등록하고 join을 쓰는 것이 빠릅니다.
    cache.push(li[i].innerHTML);
}
console.innerHTML = cache.join("<br />");
이렇게 고치면 루프 당 한번씩만 innerHTML을 참조하게 되어 효율이 올라갑니다.

(추가 아웃사이더 님 댓글)
최근의 모던 브라우저들은 + 문자열 결합을 최적화해주기 때문에 Array.join보다 좋은 성능을 보여주고 있다고 합니다.

(추가 - 익명님 댓글)
innerHTML이 표준이 되었기에, 문자열을 조합하여 한 번에 innerHTML을 실행하는 것도 하나의 방법이 된다고 합니다.

(추가 - 익명님 댓글)
문자열 결합 부분을 단정적으로  +, Array.join() 중 어느 방법이 낫다고 판단할수 없습니다.
아웃사이더 님의 댓글에서 제시된 사이트의 테스트 환경은 제대로 된 테스트 조건이 아니었기 때문이지요.
Array.join()은 모든 연결시킬 문자열을 배열에 다 넣은 다음 join()문을 단 한번만 실행하는게 핵심입니다. 하지만 벤치 테스트 사이트에서는 문제의 join() 문장을 매번 호출하면서 테스트하는 모습을 볼 수 있습니다. 이는 결코 이상적인 상황이 아니며 속도가 빨리 나오는거 자체가 이상합니다.

거기에 실제 코드 작성 상황에선

반복문{
    s += "문장" + "문장" + "문장";
}
의 형태가 자주 나온다는 사실도 고려해야 합니다.

벤치 테스트에서 + 가 성능이 좋게 나온 이유는 아마도 브라우저 인터프리터가 한 문장에 +문이 연속되어 있는경우 처음에 모든 문자열을 담을 수 있는 공간을 만든 다음 한번에 입력해서 성능이 향상되는걸로 보이지만, 실 상황에선 조건문과 함수로 인한 추가 문장 등, 위와 같은 상황이 생기기 힘든 상황이 자주 발생하게 됩니다.

(개인적 의견...)
테스트 사이트에서 루프 밖 조인을 한다고 되어 있는 듯 합니다.
(testing for concat vs join outside of a loop)

그리고

반복문{
    s += "문장" + "문장" + "문장";
}

의 결합은 대부분 브라우저에서 다른 결합들 (순차 += 결합, join 등...) 보다 빠른 속도를 내는 것 같습니다.

타이머를 사용한 루프 최적화
루프문은 프로그래밍 사용시 거의 반드시라고 해도 좋을 정도로 등장하는 코드입니다. 제일 성능 이슈가 많이 일어나는 곳이기도 하죠.

자바스크립트도 예외가 아닌데요.

만일 이러한 코드가 있다고 봅시다.
imageSoruce 변수에 배열 형식으로 이미지 태그의 src 속성이 담겨 있다고 가정해 봅시다. 이것을 활용하여 특정 영역에 받아온 src 정보를 사용하여 이미지 태그를 그리는 로직입니다.
var doc = document;
var imageSlide = doc.getElementById("image_slide");
var len = imageSoruce.length;

for(var i = 0; i < len; i++) {
    var img = doc.createElement("img");
    img.src = imageSoruce[i];
    imageSlide.appendChild(img);
}
적은 양의 이미지라면 상관 없을 듯 싶습니다.
그러나 문제는 언제나 루프가 비대해질 경우입니다. 표시해야할 이미지가 많다면 이 코드는 전부 실행되기 전까지 화면은 일체의 사용자 인터랙션을 거부합니다.

즉, 화면이 굳습니다. 
아무것도 동작하지 않고 화면에 받아온 데이터의 끝까지 루프를 돌면서 이미지 태그를 전부 그릴 때까지 화면은 이무것도 하지 않습니다. 스크롤도 안되고 클릭도 안됩니다.

이런 상황은 별로 좋지 않습니다...

타이머를 사용하여 루프를 최적화해 봅시다.
자바스크립트의 대표적인 타이머 함수는 setTimeoutsetInterval이 있습니다.
이중 setTimeout을 사용해 루핑 중 화면이 굳어버리는 불상사를 막아봅시다.
var doc = document;
var imageSlide = doc.getElementById("image_slide");

// 배열을 복사합니다.
var copy = imageSoruce.slice();

// 타이머를 사용하여 재귀호출로 자료를 처리합니다
setTimeout(function() {    
    var img = doc.createElement("img");

    // 복사한 배열에서 요소 하나를 가져옵니다.
    img.src = copy.shift();
    imageSlide.appendChild(img);

    // 배열에 남은 요소가 있다면 다시 호출합니다.
    if(copy.length > 0) {
        setTimeout(arguments.callee, 25);
    }
    else {
        // 작업 완료 작업...
    }
}, 25);
이런 방식으로 타이머를 사용하여 루프를 만들면 각 작업이 UI 큐에 쌓이게 되면서 순차적으로 처리됩니다. 물론 중간에 사용자의 인터랙션이 오게 되면 그 작업도 큐에 쌓이면서 처리됩니다.

화면이 굳는 것을 막을 수 있을 뿐 아니라, 좀더 코드를 추가하여 이미지를 순차적으로 로딩하는 멋진 UX를 제공할 수도 있습니다.


참고자료
이 포스트에는 자바스크립트 성능 최적화 를 많이 참고하였습니다.
자바스크립트 프로그래머라면 반드시 읽어야 할 좋은 책이니, 만일 읽어보지 못한 자바스크립트 공부를 하시는 분이 계시다면 한번 읽어보는걸 추천드립니다.

댓글 17개:

  1. 문자열 concatenation을 Array.join을 사용하면 더 빠르다는 팁은 IE6이 인기있던 예전 얘기라고 생각하고 있습니다.
    모던 브라우저들은 + concatenation을 최적화해주기 때문에 Array.join보다 훨씬 좋은 성능을 보여주고 있습니다.
    IE6,7의 크로스브라우징을 생각하면 어느쪽이 좋은지 고민되기는 하지만 차후를 생각하면 + 가 낫다고 생각합니다.
    http://jsperf.com/join-concat/24 참조하세요.

    답글삭제
    답글
    1. 답글 감사합니다.
      알려주신 사이트도 굉장히 좋네요.

      :: 책 잘 보고 있습니다 ^^

      삭제
    2. 앗!! 감사합니다. ^^
      jsperf 사이트 참 좋죠... 오랜만에 저 링크찾아 들어가 봤는데 요즘 관리안하는지 중간에 스크립트 테스트 돌리는 부분에서 오류가 발생하는듯 하네요. ㅠㅠ

      삭제
  2. 동적으로 엘리먼트가 추가 되는 것이라면
    document.createDocumentFragment(); 를 이용하여 empty element를 생성하고

    추가되는 element를 append 시킨후에 dom tree에 한번에 append 하는 것이 reflow와 repaint 를 가장 최소화 하는 방법일거 같네요.

    var emptyEl = doc.createDocumentFragment();
    for(var i = 0; i < len; i++) {
    var img = doc.createElement("img");
    img.src = imageSoruce[i];
    emptyEl.appendChild(img);
    }
    imageSlide.appendChild(emptyEl);

    답글삭제
    답글
    1. reference URL : https://developer.mozilla.org/en/DOM/document.createDocumentFragment

      삭제
    2. 그렇군요.
      본문의 예제 소스대로 하면 리플로우와 리페인트가 루프때마다 일어날듯 합니다;

      의견 감사합니다~
      여러 분들의 댓글로 제가 더 배우네요 :-)

      삭제
    3. var emptyEl = doc.createDocumentFragment();
      브라우저에 따라 속성이 설정되지 않는 경우가 있으므로
      생성하려는 엘리먼트와 속성을 살펴본 후 사용하는 것이 좋을 것 같네요.
      한편 innerHTML이 표준이 되었으므로
      문자열을 조합하여 한 번에 innerHTML을 실행하는 것도 하나의 방법이 될 것 같네요.

      삭제
  3. 잘 읽었습니다. 좋은 글 감사합니다.

    답글삭제
  4. 로직 스크립트의 위치는 <body> 태그 맨 뒷부분에---
    대부분의 프레임워크는 이 문제를 해결하기 위해 defer 기능을 제공합니다. 이것을 지원하는 이유는
    - <body></body> 사이에 <script>를 작성하지 않기 위해서입니다.
    - 유지보수를 할 때 <body>에서 <script>를 찾는 것이 번거러우며 다수를 사용할 때는 놓칠 수 있습니다.
    - DOM으로 엘리먼트를 생성하여 <body>에 첨부할 때 제어하기가 번거롭습니다.
    - 전체 image가 로드된 것을 체크해야 완전하게 <script> 코드 실행을 보장할 수 있기 때문입니다.

    답글삭제
    답글
    1. 위 댓글에도 썼지만 제가 더 많이 배우네요. 좋은 설명 감사합니다.

      삭제
  5. 문자열 결합 부분은 단정적으로 +, Array.join() 둘 중 어느게 낫다 이야기 할 수 없습니다. 왜냐하면, 위의 댓글에서 제시한 테스트 사이트는 제대로된 테스트 조건이 아니었기 때문이죠.

    Array.join()은 모든 연결시킬 문자열을 배열에 다 넣은 다음 join()문을 단 한번만 실행하는게 핵심입니다. 하지만 벤치 테스트 사이트에서는 문제의 join() 문장을 매번 호출하면서 테스트하는 모습을 볼 수 있습니다. 이는 결코 이상적인 상황이 아니며 속도가 빨리 나오는거 자체가 이상합니다.

    거기에 실제 코드 작성 상황에선
    반복문{
    s += "문장" + "문장" + "문장";
    }
    의 형태가 자주 나온다는 사실을 생각해 주세요.

    답글삭제
    답글
    1. 추가로 벤치 테스트에서 + 가 성능이 좋게 나온 이유는 아마도 브라우저 인터프리터가 한 문장에 +문이 연속되어 있는경우 처음에 모든 문자열을 담을 수 있는 공간을 만든 다음 한번에 입력해서 성능이 향상되는걸로 보이지만, 실 상황에선 조건문과 함수로 인한 추가 문장등등, 위와 같은 상황이 생기기 힘든 상황이 자주 발생하게 됩니다.

      삭제
    2. 여러 상황의 문자열 결합을 주제로 한 포스팅도 유익할 것 같네요.

      다른말이지만 글보다 댓글이 더 흥하네요. 웬지 기쁩니다(?)

      삭제
    3. 음...
      테스트 사이트에서 루프 밖 조인을 한다고 되어 있는 듯 합니다.

      (testing for concat vs join outside of a loop)

      테스트 결과를 믿을 수 있을 듯 합니다.

      그리고
      s += "문장" + "문장" + "문장";
      의 결합은 대부분 브라우저에서 다른 결합들 (순차 += 결합, join 등...) 보다 빠른 속도를 내는 것 같아요.

      확실한 테스트가 필요할 것 같은데...내공 부족이네요.

      삭제
    4. ;; 한번 테스트해봤습니다. 테스트 사이트의 내용이 맞네요. ^^;;;;

      //arr.join()
      var arr = [];
      var i = 0;
      var t = 0;
      var sTime = new Date().getTime();
      var eTime;
      for(t; t<200000; t++){
      arr[i++] = "";
      arr[i++] = (Math.random() % 10);
      arr[i++] = (Math.random() % 10);
      arr[i++] = (Math.random() % 10);
      }
      arr.join('');
      eTime = new Date().getTime();
      alert(eTime - sTime);

      //한번 해봤는데 약 800ms 네요.

      //문자열 결합
      var str = '';
      var i = 0;
      var sTime = new Date().getTime();
      var eTime;
      for(i; i<200000; i++){
      str += "" + (Math.random() % 10) + (Math.random() % 10) + (Math.random() % 10);
      }
      eTime = new Date().getTime();
      alert(eTime - sTime);

      //한번 돌렸는데 약 600ms...

      문자열 결합이 압도적이네요...

      arr.join()은 문자열 결합용으로 이제 사용할 필요가 전혀 없을듯 합니다.
      그 동안 혹시? 라는 생각은 있었는데... 역시나였네요
      앞의 제가 잘못된 내용을 작성한 댓글은 삭제부탁드립니다^^

      삭제
    5. 저도 익명님의 기존의 댓글을 보고 다양한 방법으로(?) 테스트를 해봤는데...
      Array.join() 보다 단순 + 가 항상 빠르더군요.

      테스트 방법이 이상한가 싶기도 하고 그랬는데 이 댓글을 보니 제가 한것도 얼추 맞겠구나 싶습니다.

      그렇다고 Array.join() 을 포기하기엔 아쉬운게 많아요.

      + 결합 최적화라는것이 모던 브라우저에서나 그렇지 아직 산소호흡기 달고 계신 구형 브라우저분들(IE6이라던가...)에서는 Array.join() 이 효율이 더욱 좋다고 합니다.
      + 결합시마다 결합 문자열과 피결합 문자열 두개를 메모리에 복사한뒤 결합해가는 만행을 저지른다고 하는군요.
      긴 루프에 긴 문자열이면 로프때마다 메모리 복사가 일어나는 불상사...끔찍하겠죠 =_=;

      반면에 Array.join() 은 IE6에서도 한번만 메모리 복사가 일어나서 빠른데...
      물론 모던 브라우저를 쓴다면 이야기가 달라지겠지만, 상황이 하위 효율성까지 생각해야 하는 것이라면 Array.join() 도 좋은 선택인 듯 싶습니다.

      무엇보다 Array.join()도 모던 브라우저에서도 일정 속도는 보장해 주니까요.

      삭제