ListView 항목 렌더링 최적화

컬렉션을 지원하도록 JavaScript로 작성된 Windows 스토어 앱의 성능에 가장 큰 영향을 미치는 것은 WinJS ListView 컨트롤과의 원활한 연동입니다. 사실 별로 놀라운 일은 아닙니다. 수많은 항목을 관리하고 표시할 때 항목 최적화 하나하나가 모두 중요합니다. 그 중에서도 가장 중요한 것은 각 항목을 렌더링하는 방법입니다. 즉, ListView 컨트롤의 각 항목을 언제, 어떻게 DOM에 구축하고 앱에서 표시할 것인지가 가장 중요합니다. 사실, 사용자가 신속하게 목록을 둘러보고 목록의 페이스를 유지하려고 할 때에는 언제와 어디서 중 '언제'가 더 중요합니다.

ListView의 항목 렌더링은 HTML에 정의된 선언적 템플릿 또는 목록의 모든 항목에 대해 호출되는 사용자 지정 JavaScript 렌더링 함수를 통해 이루어집니다. 선언적 템플릿을 사용하는 것이 가장 간단한 방법이지만 전체 프로세스에서 특정 컨트롤을 융통성 있게 사용할 수 없다는 단점이 있습니다. 한편 렌더링 함수는 항목별로 렌더링을 사용자 지정할 수 있어서 HTML ListView 성능 최적화 샘플에서 보여 준 것처럼 다양한 최적화가 가능합니다. 최적화의 종류는 다음과 같습니다.

  • 기본 렌더링 함수에서 지원하는 기능을 사용하여 항목 데이터 및 렌더링된 요소를 비동기식으로 제공할 수 있습니다.
  • ListView의 전체적인 레이아웃에 필요한 항목 모양 생성을 내부 요소와 분리할 수 있습니다. 이 기능은 자리 표시자 렌더러를 통해 지원됩니다.
  • 데이터를 대체하여 이전에 생성한 항목 요소 및 그 하위 요소를 재사용함으로써 요소 생성 단계 대부분을 생략하고 자리 표시자 렌더러를 재활용할 수 있습니다.
  • 항목이 표시되고 ListView가 빠른 속도로 이동되지 않을 때까지 이미지 로딩이나 애니메이션처럼 리소스 소모가 많은 시각화 작업을 지연합니다. 이 기능은 다단계 렌더러를 통해 수행됩니다.
  • 다단계 일괄 처리 렌더러를 통해 동일한 시각화 작업을 일괄 처리함으로써 DOM 재렌더링을 최소화합니다.

이 글에서는 이러한 단계를 살펴보고 ListView의 항목 렌더링 프로세스와 어떻게 연동하는지 알아보겠습니다. 어느 정도는 예상하시겠지만 항목 렌더링의 '시기'를 최적화하는 문제는 비동기 작업과 관련이 깊기 때문에 다양한 promise가 나오게 됩니다. 그러므로 이 블로그의 이전 글 promise의 모든 것을 바탕으로 promise 자체에 대해서도 자세히 알아볼 것입니다.

모든 렌더러에 적용되는 공통 사항으로, 언제나 핵심 항목 렌더링 시간(지연된 작업은 제외)을 최소로 유지하는 것이 중요합니다. ListView의 최종 성능은 업데이트가 화면 새로 고침 간격과 얼마나 일치하느냐에 따라 크게 좌우됩니다. 항목 렌더러에 소비하는 시간(밀리초)이 조금만 길어져도 ListView의 전체 렌더링 시간이 다음 새로 고침 간격까지 늘어나서 프레임이 손실되고 화면 끊김 현상이 발생합니다. 다시 말해서, 항목 렌더러는 JavaScript 코드 최적화에서 매우 중요한 역할을 합니다.

기본 렌더러

항목 렌더링 함수, 줄여서 '렌더러'가 어떤 모양인지 신속하게 살펴보겠습니다. 렌더러는 ListView의 itemTemplate 속성에 템플릿 이름 대신 할당하는 함수로, 필요에 따라 ListView가 DOM에 포함하려는 항목에 대해 호출됩니다. (렌더러에 대한 기본 설명서는 itemTemplate 페이지에서 찾을 수 있지만 이 페이지에서는 최적화만을 간단하게 보여 줍니다.)

여러분은 항목 렌더링 함수에 ListView 데이터 원본의 항목이 제공되고, 그 후 해당 항목에 필요한 HTML 요소를 생성한 후 ListView가 DOM에 추가할 수 있는 루트 요소를 반환할 것으로 예상하실 것입니다. 기본적으로는 이 예상이 맞지만 두 가지 사항을 더 고려해야 합니다. 첫째, 항목 데이터 자체는 비동기적으로 로드되기 때문에 요소 생성을 해당 데이터의 가용성과 연결할 수 있습니다. 뿐만 아니라 항목 자체를 렌더링하는 프로세스에 원격 URI에서 이미지를 로드한다거나 항목 데이터에서 확인된 다른 파일을 읽는 등의 다른 비동기 작업이 관련될 수 있습니다. 앞으로 보게 될 다양한 최적화 수준에서 항목 요소를 요청하고 해당 요소를 실제로 제공하는 사이에 수많은 비동기 작업이 가능합니다.

그렇기 때문에 이번에도 promise가 관련될 것으로 예상할 수 있습니다! 첫 번째 근거로, ListView는 항목 데이터에 렌더러를 직접 제공하지 않고 해당 데이터에 대한 promise를 제공합니다. 그리고 함수는 항목의 루트 요소를 직접 반환하지 않고 해당 요소에 대한 promise를 반환합니다. 따라서 항목으로 구성된 전체 페이지가 렌더링될 때까지 ListView가 여러 항목 렌더링 promise를 함께 연결하여 기다릴(비동기) 수 있습니다. 이러한 방법을 통해 여러 페이지를 지능적으로 구축합니다. 시각적 항목으로 구성되는 페이지를 먼저 구축한 후 그 페이지 앞뒤로 사용자가 그 다음으로 이동할 확률이 높은 오프스크린 두 페이지를 구축합니다. 뿐만 아니라 이러한 promise를 모두 제 위치에 배치하면 사용자가 페이지를 이동할 때 ListView가 완료되지 않은 항목에 대한 렌더링을 쉽게 취소할 수 있으므로 불필요한 요소를 생성하지 않을 수 있습니다.

다음 샘플을 통해 이러한 promise가 simpleRenderer 함수에서 어떻게 사용되는지 살펴보겠습니다.

 

 function simpleRenderer(itemPromise) {
    return itemPromise.then(function (item) {
        var element = document.createElement("div");
        element.className = "itemTempl";
        element.innerHTML = "<img src='" + item.data.thumbnail +
            "' alt='Databound image' /><div class='content'>" + item.data.title + "</div>";
        return element;
    });
}

이 코드는 가장 먼저 completed 처리기를 itemPromise에 연결합니다. 항목 데이터가 제공되면 처리기가 호출되어 호출에 대한 응답으로 요소를 효과적으로 생성합니다. 다시 한 번 강조하지만 요소를 직접 반환하는 것이 아니라 해당 요소로 처리되는 promise를 반환하는 것입니다. 즉, itemPromise.then() 의 반환 값은 ListView가 '요소'를 필요로 할 때 해당 요소에 의해 처리되는 promise입니다.

promise를 반환하기 때문에 필요하다면 다른 비동기 작업을 수행할 수 있습니다. 이 사례에서 렌더러는 중간 promise를 연결하여 체인의 마지막 then에서 해당 promise를 반환할 수 있습니다. 예를 들면 다음과 같습니다.

 function someRenderer(itemPromise) {
    return itemPromise.then(function (item) {
        return doSomeWorkAsync(item.data);
    }).then(function (results) {
        return doMoreWorkAsync(results1);
    }).then(function (results2) {
        var element = document.createElement("div");
        // use results2 to configure the element
        return element;
    });
}

이 예에서는 마지막 then 호출에서 promise를 반환하기 때문에 체인의 마지막에 done을 사용하지 '않는다'는 점에 주의하시기 바랍니다. 발생하는 모든 오류는 ListView에서 처리합니다.

자리 표시자 렌더러

ListView 최적화의 다음 단계에서는 요소 구축을 두 단계로 나누는 '자리 표시자 렌더러'를 사용합니다. 이렇게 하면 ListView가 각 항목 내에 모든 요소를 구축하지 않고도 목록의 전체적인 레이아웃을 정의하는 데 필요한 요소 부분만 요청할 수 있습니다. 결과적으로 ListView가 레이아웃 단계를 신속하게 완료하고 추가 입력에 대해 계속해서 민첩하게 반응할 수 있습니다. 그리고 나머지 요소는 나중에 요청할 수 있습니다.

자리 표시자 렌더러는 하나의 promise만을 반환하는 것이 아니라 다음과 같은 두 가지 속성을 가진 개체를 반환합니다.

  • element - 크기 및 모양을 정의하기에 충분하고 항목 데이터에 종속되지 않는 항목 구조의 최상위 요소입니다.
  • renderComplete - 남은 요소의 콘텐츠가 구성될 때 처리되는 promise입니다. 다시 말해서 앞에서 본 것처럼 itemPromise.then으로 시작하는 체인에서 promise를 반환합니다.

ListView는 렌더러가 promise를 반환하는지(이전과 같은 기본 사례) 아니면 'element' 및 'renderComplete' 속성을 반환하는지(좀 더 발전된 사례) 확인할 수 있을 정도로 똑똑합니다. 따라서 이전 simpleRenderer와 동등한 자리 표시자 렌더러는 다음과 같습니다.

 function placeholderRenderer(itemPromise) {
    // create a basic template for the item that doesn't depend on the data
    var element = document.createElement("div");
    element.className = "itemTempl";
    element.innerHTML = "<div class='content'>...</div>";

    // return the element as the placeholder, and a callback to update it when data is available
    return {
        element: element,

        // specifies a promise that will be completed when rendering is complete
        // itemPromise will complete when the data is available
        renderComplete: itemPromise.then(function (item) {
            // mutate the element to include the data
            element.querySelector(".content").innerText = item.data.title;
            element.insertAdjacentHTML("afterBegin", "<img src='" +
                item.data.thumbnail + "' alt='Databound image' />");
        })
    };
}

샘플의 css/scenario1.css 파일의 'itemTempl' 클래스가 항목의 너비와 높이를 직접 지정하기 때문에 'element.innerHTML'의 배치를 renderComplete 내부로 이동할 수 있습니다. 'element' 속성에 포함된 것은 자리 표시자에 기본 “…” 텍스트를 제공하기 때문입니다. 개발자는 모든 항목 간에 공유하는(그래서 렌더링 속도가 빠른) 작은 인패키지(in-package) 리소스를 참조하는 'img' 요소를 간편하게 사용할 수 있습니다.

자리 표시자 렌더러 재활용

다음 최적화인 '자리 표시자 재활용' 렌더러는 promise와 관련이 있는 위치에 새 항목을 전혀 추가하지 않습니다. 그보다는 앞에서 렌더링되었지만 더 이상 표시되지 않는 항목의 루트 요소인 'recycled'라는 렌더러에 두 번째 매개 변수의 존재를 인식시킵니다. 즉, recycled 요소의 하위 요소가 이미 제 위치에 있기 때문에 개발자가 데이터를 대체하거나 일부 요소를 수정할 수 있습니다. 완전히 새로운 항목을 추가할 경우 리소스가 많이 드는 요소 생성 호출 과정이 필요한데, 이 과정을 대부분 생략할 수 있으므로 렌더링 프로세스 시간을 크게 절약할 수 있습니다.

loadingBehavior를 "randomaccess"로 설정할 경우 ListView가 recycled 요소를 제공할 수 있습니다. 'recycled'가 제공되면 요소 및 하위 요소에서 데이터를 삭제하고 자리 표시자로 반환한 다음 데이터를 채우고 필요하다면 'renderComplete' 내에 하위 요소를 추가할 수 있습니다. ListView가 맨 처음으로 생성되거나 loadingBehavior가 "incremental"로 설정되어 있어서 recycled 요소가 제공되지 않을 경우 요소를 새로 생성해야 합니다. 다음은 이러한 변형에 대한 샘플 코드입니다.

 function recyclingPlaceholderRenderer(itemPromise, recycled) {
    var element, img, label;
    if (!recycled) {
        // create a basic template for the item that doesn't depend on the data
        element = document.createElement("div");
        element.className = "itemTempl";
        element.innerHTML = "<img alt='Databound image' style='visibility:hidden;'/>" +
            "<div class='content'>...</div>";
    }
    else {
        // clean up the recycled element so that we can reuse it 
        element = recycled;
        label = element.querySelector(".content");
        label.innerHTML = "...";
        img = element.querySelector("img");
        img.style.visibility = "hidden";
    }
    return {
        element: element,
        renderComplete: itemPromise.then(function (item) {
            // mutate the element to include the data
            if (!label) {
                label = element.querySelector(".content");
                img = element.querySelector("img");
            }
            label.innerText = item.data.title;
            img.src = item.data.thumbnail;
            img.style.visibility = "visible";
        })
    };
}

'renderComplete'에서 새 자리 표시자에 대해 생성하지 않은 요소(예: 'label')가 있는지 확인하고 필요하다면 요소를 생성합니다.

좀 더 일반적인 방법으로 recycled 항목을 제거하려면 ListView의 resetItem 속성에 함수를 제공하는 방법을 고려해 보십시오. 이 함수는 위에서 보여 준 것과 비슷한 코드를 갖고 있습니다. resetGroupHeader 속성도 동일합니다. 그룹 헤더뿐만 아니라 항목에도 템플릿 함수를 사용할 수 있기 때문입니다. 그룹 헤더는 그 수가 훨씬 적고 일반적으로 성능에 미치는 영향이 같지 않기 때문에 자세히 다루지는 않았습니다. 하지만 그 성능은 보시는 바와 같습니다.

다단계 렌더러

이번에 살펴 볼 최적화는 '다단계 렌더러'입니다. 이 최적화에서는 나머지 항목이 DOM에 모두 표시될 때까지 재활용 자리 표시자 렌더러를 로드 지연 이미지 및 기타 미디어로 확장합니다. 또한 항목이 실제로 화면에 나타날 때까지 애니메이션 같은 효과를 지연합니다. 이를 통해 사용자가 ListView를 신속하게 이동하는 것을 인식하므로 ListView가 안정적인 상태가 될 때까지 리소스가 많이 필요한 작업을 비동기적으로 지연할 수 있습니다.

item의 멤버는 itemPromise 즉, ready라는 속성(promise)과 두 개의 메서드 loadImageisOnScreen에서 오기 때문에 ListView가 필요한 후크를 제공하며 둘 모두 (더 많은) promise를 반환합니다. 예를 들어 다음과 같습니다.

 renderComplete: itemPromise.then(function (item) {
    // item.ready, item.loadImage, and item.isOnScreen available
})

사용 방법은 다음과 같습니다.

  • ready 체인의 첫 번째 completed 처리기에서 이 promise를 반환합니다. 이 promise는 요소의 전체 구조가 렌더링되어 표시될 때 처리됩니다. 즉, 또 다른 then을 이미지 로딩 등의 시각화 후속 작업을 하는 completed 처리기와 체인으로 연결할 수 있습니다.
  • loadImage URI에서 이미지를 다운로드하여 제공된 'img' 요소에 표시하고, 같은 요소에 의해 처리되는 promise를 반환합니다. 이 promise에 completed 처리기를 연결하면 스스로 isOnScreen에서 promise를 반환합니다. img 요소를 제공하지 않을 경우 loadImage가 'img' 요소를 생성하여 completed 처리기에 제공합니다.
  • isOnScreen 처리 값이 항목의 표시 여부를 나타내는 부울인 promise를 반환합니다. 현재 구축에서는 알려진 값이기 때문에 promise가 동기적으로 처리됩니다. 하지만 promise에 래핑하면 보다 긴 체인에 사용할 수 있습니다.

이 모든 것을 적용한 것이 아래 샘플의 multistageRenderer 함수이며, 이미지 로드가 완료되는 위치에서 페이드 인 애니메이션이 시작됩니다. 'renderComplete' promise에서 무엇이 반환되는지 잘 보시기 바랍니다.

 renderComplete: itemPromise.then(function (item) {
    // mutate the element to update only the title
    if (!label) { label = element.querySelector(".content"); }
    label.innerText = item.data.title;

    // use the item.ready promise to delay the more expensive work
    return item.ready;
    // use the ability to chain promises, to enable work to be cancelled
}).then(function (item) {
    // use the image loader to queue the loading of the image
    if (!img) { img = element.querySelector("img"); }
    return item.loadImage(item.data.thumbnail, img).then(function () {
        // once loaded check if the item is visible
        return item.isOnScreen();
    });
}).then(function (onscreen) {
    if (!onscreen) {
        // if the item is not visible, don't animate its opacity
        img.style.opacity = 1;
    } else {
        // if the item is visible, animate the opacity of the image
        WinJS.UI.Animation.fadeIn(img);
    }
})

다양한 작업이 진행되지만 여전히 기본 promise 체인만을 사용하고 있습니다. 렌더러의 첫 번째 비동기 작업은 항목 요소 구조에서 텍스트처럼 간단한 부분을 업데이트하는 것입니다. 그러면 item.ready에서 promise가 반환됩니다. 해당 promise가 처리될 때, 좀 더 정확하게 말해서 해당 promise가 처리될 경우 항목의 비동기 loadImage 메서드를 사용하여 이미지 다운로드를 시작하면 해당 completed 처리기에서 item.isOnScreen promise가 반환됩니다. 즉, '화면' 가시성 플래그가 체인의 마지막 completed 처리기에 전달됩니다. isOnScreen promise가 처리될 때, 조금 더 정확히 말해서 처리될 경우, 즉 항목이 실제로 표시될 때 애니메이션과 같은 관련 작업을 수행할 수 있습니다.

처리될 경우를 강조해서 말씀 드렸는데, 이 동작이 수행될 때 사용자가 ListView 내부를 돌아다니고 있을 확률이 매우 높기 때문입니다. 모든 promise를 체인으로 연결해 놓으면 항목이 화면에 표시되지 않거나 버퍼링된 페이지가 사라질 때 ListView가 비동기 작업을 취소할 수 있습니다. 이 정도면 ListView 컨트롤이 '수많은' 성능 테스트를 거쳤다는 충분한 증거가 되지 않을까요?

또한 여전히 'renderComplete' 속성 내의 렌더링 함수에서 promise를 반환하고 있기 때문에 모든 체인에서 then을 사용한다는 점을 다시 한 번 강조하겠습니다. 이러한 렌더러에서 체인 마지막까지 갈 일이 없기 때문에 마지막 부분에 done을 사용할 일도 없습니다.

미리 보기 일괄 처리

마지막 최적화는 ListView 컨트롤을 향해 날리는 '마지막 일격'입니다. batchRenderer라는 함수를 보면 다음과 같은 renderComplete 구조를 확인할 수 있습니다(대부분의 코드 생략).

 renderComplete: itemPromise.then(function (item) {
    // mutate the element to update only the title
    if (!label) { label = element.querySelector(".content"); }
    label.innerText = item.data.title;

    // use the item.ready promise to delay the more expensive work
    return item.ready;
    // use the ability to chain promises, to enable work to be cancelled
}).then(function (item) {
    // use the image loader to queue the loading of the image
    if (!img) { img = element.querySelector("img"); }
    return item.loadImage(item.data.thumbnail, img).then(function () {
        // once loaded check if the item is visible
        return item.isOnScreen();
    });
}).then(function (onscreen) {
    if (!onscreen) {
        // if the item is not visible, don't animate its opacity
        img.style.opacity = 1;
    } else {
        // if the item is visible, animate the opacity of the image
        WinJS.UI.Animation.fadeIn(img);
    }
})

item.loadImage 호출과 item.isOnScreen 확인 사이에 thumbnailBatch라는 이 신기한 함수를 호출하는 것만 빼면 multistageRenderer와 거의 차이가 없습니다. 체인에 thumbnailBatch를 배치했기 때문에 그 반환 값은 자체적으로 또 다른 promise를 반환하는 completed 처리기여야 합니다.

어려운가요? 그렇다면 자세히 알아보겠습니다. 하지만 그 전에 우리가 하려는 것에 대한 배경 지식을 좀 더 쌓아야 합니다.

ListView에 항목이 하나밖에 없다면 다양한 로드 최적화가 눈에 띄지 않을 수 있습니다. 하지만 일반적으로 ListView에는 여러 항목이 있고 각 항목마다 렌더링 함수가 호출됩니다. 이전 단원의 multistageRenderer에서 각 항목을 렌더링하면 비동기 item.loadImage 작업이 시작되어 임의의 URI에서 축소판이 다운로드되며, 각 작업마다 임의의 시간이 배정될 수 있습니다. 따라서 목록 전체를 놓고 보면 여러 loadImage 호출이 동시에 진행되고, 각 항목의 렌더링은 특정 미리 보기가 완료될 때까지 기다립니다. 여기까지는 별 문제가 없을 것입니다.

하지만 multistageRenderer에서 전혀 보이지 않는 중요한 특징 한 가지가 있습니다. 그것은 바로 미리 보기의 'img' 요소가 '이미' DOM에 있으며, 다운로드가 완료되는 즉시 loadImage 함수가 해당 이미지의 'src' 특성을 설정한다는 것입니다. 결국 promise 체인의 나머지 부분에서 돌아오는 즉시 렌더링 엔진에서 업데이트가 트리거되고, 해당 시점 이후부터는 근본적으로 동기 상태가 됩니다.

따라서 짧은 시간 내에 다수의 미리 보기가 UI 스레드로 돌아올 수 있고 그렇게 될 경우 렌더링 엔진에 과도한 변동이 일어나 표시 성능이 떨어지게 됩니다. 과도한 변동을 피하려면 이러한 'img' 요소가 DOM에 있기 '이전에' img 요소를 완전히 생성한 후 단일 렌더링 경로에서 모두 처리할 수 있도록 img 요소를 일괄적으로 추가해야 합니다.

샘플에서는 createBatch라는 마법과 같은 promise 코드 함수를 통해 이 작업을 처리했습니다. createBatch는 전체 앱에 대해 단 한 번만 호출되며, 그 결과(또 다른 함수)는 thumbnailBatch라는 변수에 저장됩니다.

 var thumbnailBatch;
thumbnailBatch = createBatch();

지금부터 설명할 이 thumbnailBatch 함수를 호출하면 다시 렌더러의 promise 체인으로 삽입됩니다. 이렇게 삽입하는 이유는 잠시 후 보게 될 일괄 처리 코드의 특성을 감안한 것으로, 로드된 'img' 요소를 그룹화해 두었다가 나중에 적절한 간격으로 릴리스하여 처리하기 위함입니다. 다시 한 번 강조하지만, 렌더러의 promise 체인을 살펴보면 thumbnailBatch() 호출의 결과로 promise를 반환하는 completed 처리기가 반환되어야 하며, 해당 promise의 처리 값(체인의 다음 단계를 살펴볼 것)은 이후에 DOM에 추가할 수 있는 'img' 요소여야 합니다. 일괄 처리 '후' DOM에 이미지를 추가하여 전체 그룹을 동일한 렌더링 경로로 결합할 수 있습니다.

이것이 바로 이전 단원에서 본 batchRenderermultistageRenderer의 결정적인 차이점입니다. 후자의 경우 미리 보기의 'img' 요소가 이미 DOM에 있으며 loadImage에 두 번째 매개 변수로 전달됩니다. 따라서 loadImage가 이미지의 'src' 특성을 설정할 때 렌더링 업데이트가 트리거됩니다. batchRenderer 내에서는 'img' 요소가 loadImage 내에 별도로 생성되지만('src' 또한 설정됨) 'img'가 아직 DOM에 없습니다. thumbnailBatch 단계가 완료된 후에만 DOM에 추가되어 단일 레이아웃 단계 내에 있는 그룹에 속하게 됩니다.

그러면 지금부터 일괄 처리 작업이 어떻게 수행되는지 살펴보겠습니다. 다음은 완전한 createBatch 함수입니다.

 function createBatch(waitPeriod) {
    var batchTimeout = WinJS.Promise.as();
    var batchedItems = [];

    function completeBatch() {
        var callbacks = batchedItems;
        batchedItems = [];
        for (var i = 0; i < callbacks.length; i++) {
            callbacks[i]();
        }
    }

    return function () {
        batchTimeout.cancel();
        batchTimeout = WinJS.Promise.timeout(waitPeriod || 64).then(completeBatch);

        var delayedPromise = new WinJS.Promise(function (c) {
            batchedItems.push(c);
        });

        return function (v) {
            return delayedPromise.then(function () {
                return v;
            });
        };
    };
}

다시 한 번 말씀 드리지만 createBatch는 '한 번만' 호출되며 목록의 '각' 렌더링된 항목마다 thumbnailBatch 결과가 호출됩니다. 그 후 loadImage 작업이 완료될 때 thumbnailBatch에서 생성하는 completed 처리기가 호출됩니다.

이러한 completed 처리기를 아주 간단하게 렌더링 함수에 직접 삽입할 수 있지만 여기서 우리가 하려는 것은 항목별 조정 작업이 아닌 '여러 항목에 걸친' 조정 작업입니다. 이 조정 작업은 createBatch 시작 부분에서 생성 및 초기화되는 두 개의 변수를 통해 수행됩니다. 하나는 빈 promise로 초기화되는 'batchedTimeout'이고, 다른 하나는 처음에는 비어 있는 여러 함수를 초기화하는 'batchedItems'입니다. createBatch 역시 completeBatch 함수를 선언하며, 이 함수는 'batchedItems'를 비우고 배열의 각 함수를 호출합니다.

 function createBatch(waitPeriod) {
    var batchTimeout = WinJS.Promise.as();
    var batchedItems = [];

    function completeBatch() {
        var callbacks = batchedItems;
        batchedItems = [];
        for (var i = 0; i < callbacks.length; i++) {
            callbacks[i]();
        }
    }

    return function () {
        batchTimeout.cancel();
        batchTimeout = WinJS.Promise.timeout(waitPeriod || 64).then(completeBatch);

        var delayedPromise = new WinJS.Promise(function (c) {
            batchedItems.push(c);
        });

        return function (v) {
            return delayedPromise.then(function () {
                return v;
            });
        };
    };
}

렌더링되는 각 항목에 대해 다시 호출되는 thumbnailBatch(createBatch에서 반환되는 함수) 내에서 어떤 일이 벌어지는지 살펴봅시다. 먼저 기존 'batchedTimeout'을 '취소'하고 바로 다시 만듭니다.

 batchTimeout.cancel();
        batchTimeout = WinJS.Promise.timeout(waitPeriod || 64).then(completeBatch);

두 번째 줄을 보면 Promises의 모든 것<TODO: link>에서 살펴본 미래의 제공/처리 패턴이 보입니다. 'waitPeriod' 밀리초(기본값은 64밀리초) 후 completeBatch를 호출하라고 합니다. 다시 말해서 이전 호출의 'waitPeriod' 내에 thumbnailBatch를 다시 호출할 경우 batchTimeout이 또 다른 waitPeriod에 대해 설정된다는 뜻입니다. 그리고 item.loadImage 호출이 완료된 '후' thumbnailBatch만 호출되기 때문에 이전 호출의 'waitPeriod' 내에 완료되는 모든 loadImage 작업이 같은 배치에 포함된다고 자신 있게 말할 수 있습니다. 'waitPeriod'보다 더 긴 간격이 있을 경우 배치가 처리되고(이미지가 DOM에 추가되고) 다음 배치가 시작됩니다.

이 시간 제한 작업을 처리한 후 thumbnailBatch가 complete 디스패처 함수를 'batchedItems' 배열에 단순히 푸시하는 새로운 promise를 생성합니다.

 var delayedPromise = new WinJS.Promise(function (c) {
         batchedItems.push(c);
     });

Promise의 모든 것 <TODO: link>에서 promise는 단지 코드 구조일 뿐이라고 했던 것을 기억하시기 바랍니다. 오늘 우리가 하는 모든 것이 그 연장선에 있습니다. 새로 생성된 promise는 그 자체로는 비동기 동작이 전혀 없습니다. 우리는 지금 complete 디스패처 함수 'c'를 'batchedItems'에 추가하고 있습니다. 물론 'batchedTimeout'이 비동기적으로 완료되기 전에는 디스패처에 아무 것도 하지 않습니다. 따라서 여기서는 사실상 비동기 관계가 하나 있습니다. 시간 제한이 발생하고 completeBatch 내의 배치를 비울 경우 다른 곳에서 delayedPromise.then에 제공되는 completed 처리기를 호출하게 됩니다.

그러면 thumbnailBatch의 코드 마지막 줄로 이동합니다. 이 함수는 createBatch가 실제로 반환하는 함수입니다. 이 함수는 렌더러의 전체 promise 체인에 삽입되는 바로 그 completed 처리기입니다.

 return function (v) {
           return delayedPromise.then(function () {
               return v;
           });
       };

이 코드를 promise 체인에 바로 넣어서 그 결과로 나타나는 관계를 보겠습니다.

 return item.loadImage(item.data.thumbnail);
          }).then(function (v) {
              return delayedPromise.then(function () {
                  return v;
              });
          ).then(function (newimg) {

이제 인수 'v'가 item.loadImage의 결과이고, 그 결과는 우리가 원하는 'img' 요소라는 것을 알 수 있습니다. 일괄 처리를 원하지 않을 경우 return WinJS.Promise.as(v) 만 사용해서 체인 전체가 작동하도록 하면 됩니다. 그렇게 하면 'v'가 동기적으로 전달되어 다음 단계에서 'newimg'로 나타납니다.

그 대신 현재 'batchedTimeout'이 처리될 때까지 'v'로 처리되지 않는 delayedPromise.then에서 promise를 반환하게 됩니다. 이때 loadImage 완료 사이에 'waitPeriod' 간격이 또 있을 경우 'img' 요소가 체인의 다음 단계로 전달되어 DOM에 추가됩니다.

그것으로 끝입니다!

결론

HTML ListView 성능 최적화 샘플에서 보여 준 다섯 가지 렌더링 함수 사이에 한 가지 공통점이 있습니다. promise로 표현되는 ListView와 렌더러 간의 비동기 관계로 인해 렌더러가 매우 유연하게 언제 어떻게 목록에 있는 항목에 사용할 요소를 생성할지 알 수 있다는 점입니다. 여러분이 앱을 작성할 때 ListView 최적화에 사용하는 전략은 데이터 원본의 크기, 항목 자체의 복잡성, 해당 항목에 대해 비동기적으로 얻는 데이터의 양(예: 원격 이미지 다운로드)에 크게 좌우됩니다. 당연한 말이겠지만 여러분은 원하는 성능에 도달할 때까지 항목 렌더러를 최대한 단순하게 유지하고 싶을 것입니다. 이제는 어떤 경우에도 ListView와 여러분의 앱이 최고의 성능을 발휘하도록 도와 주는 도구가 준비되어 있습니다.

Kraig Brockschmidt

Windows 에코시스템 팀 프로그램 관리자

작성자, HTML, CSS 및 JavaScript로 Windows 8 앱 프로그래밍하기