Asynch JS - $.Deferred의 힘

제레미 초네
Jeremy Chone

원활하고 응답성이 뛰어난 HTML5 애플리케이션을 빌드하는 데 있어 가장 중요한 측면 중 하나는 데이터 가져오기, 처리, 애니메이션, 사용자 인터페이스 요소 등 애플리케이션의 모든 다양한 부분 간의 동기화입니다.

데스크톱이나 네이티브 환경과의 주요 차이점은 브라우저가 스레딩 모델에 대한 액세스 권한을 부여하지 않고 사용자 인터페이스 (예: DOM)에 액세스하는 모든 항목에 단일 스레드를 제공한다는 것입니다. 즉, 사용자 인터페이스 요소에 액세스하고 수정하는 모든 애플리케이션 로직이 항상 동일한 스레드에 있으므로 모든 애플리케이션 작업 단위를 최대한 작고 효율적으로 유지하고 브라우저가 제공하는 비동기 기능을 최대한 활용하는 것이 중요합니다.

브라우저 비동기 API

다행히 브라우저는 일반적으로 사용되는 XHR (XMLHttpRequest 또는 'AJAX') API와 IndexedDB, SQLite, HTML5 웹 작업자, HTML5 GeoLocation API 등 다양한 비동기 API를 제공합니다. 일부 DOM 관련 작업도 전환End 이벤트를 통해 CSS3 애니메이션과 같이 비동기식으로 노출됩니다.

브라우저가 애플리케이션 로직에 비동기 프로그래밍을 노출하는 방식은 이벤트 또는 콜백을 통해 이루어집니다.
이벤트 기반 비동기 API에서 개발자는 특정 객체(예: HTML 요소 또는 기타 DOM 객체)의 이벤트 핸들러를 등록한 후 작업을 호출합니다. 브라우저는 일반적으로 다른 스레드에서 작업을 실행하고 필요한 경우 기본 스레드에서 이벤트를 트리거합니다.

예를 들어, 이벤트 기반 비동기 API인 XHR API를 사용하는 코드는 다음과 같습니다.

// Create the XHR object to do GET to /data resource  
var xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
alert("We got data: " + xhr.response);
}
},false)

// perform the work
xhr.send();

CSS3 conversionEnd 이벤트는 이벤트 기반 비동기 API의 또 다른 예입니다.

// get the html element with id 'flyingCar'  
var flyingCarElem = document.getElementById("flyingCar");

// register an event handler 
// ('transitionEnd' for FireFox, 'webkitTransitionEnd' for webkit) 
flyingCarElem.addEventListener("transitionEnd",function(){
// will be called when the transition has finished.
alert("The car arrived");
});

// add the CSS3 class that will trigger the animation
// Note: some browers delegate some transitions to the GPU , but 
//       developer does not and should not have to care about it.
flyingCarElemen.classList.add('makeItFly') 

SQLite 및 HTML5 Geolocation과 같은 다른 브라우저 API는 콜백 기반입니다. 즉, 개발자가 함수를 인수로 전달하여 해당 해상도의 기본 구현에 의해 콜백됩니다.

예를 들어 HTML5 위치정보의 경우 코드는 다음과 같습니다.

// call and pass the function to callback when done.
navigator.geolocation.getCurrentPosition(function(position){  
        alert('Lat: ' + position.coords.latitude + ' ' +  
                'Lon: ' + position.coords.longitude);  
});  

이 경우 메서드를 호출하고 요청된 결과와 함께 다시 호출되는 함수를 전달합니다. 이렇게 하면 브라우저가 이 기능을 동기식 또는 비동기식으로 구현하고 구현 세부정보와 관계없이 개발자에게 단일 API를 제공할 수 있습니다.

애플리케이션을 비동기식에서 사용할 수 있도록 만들기

브라우저의 기본 제공 비동기 API 외에도 잘 설계된 애플리케이션은 특히 I/O 또는 많은 양의 연산 처리를 실행할 때 하위 수준 API를 비동기 방식으로 노출해야 합니다. 예를 들어 데이터를 가져오는 API는 비동기식이어야 하며 다음과 같이 표시되면 안 됩니다.

// WRONG: this will make the UI freeze when getting the data  
var data = getData();
alert("We got data: " + data);

이 API 설계에서는 getData()가 차단되어야 하므로 데이터를 가져올 때까지 사용자 인터페이스가 정지됩니다. 데이터가 JavaScript 컨텍스트에서 로컬이면 문제가 되지 않을 수 있지만 네트워크에서 데이터를 가져오거나 SQLite 또는 색인 저장소에서 로컬로 데이터를 가져와야 하는 경우 사용자 환경에 상당한 영향을 미칠 수 있습니다.

올바른 설계는 처리하는 데 시간이 걸릴 수 있는 모든 애플리케이션 API를 처음부터 비동기식으로 만드는 것입니다. 동기 애플리케이션 코드를 비동기로 재구성하는 작업은 어려운 작업이 될 수 있기 때문입니다.

예를 들어 간단한 getData() API는 다음과 같습니다.

getData(function(data){
alert("We got data: " + data);
});

이 접근 방식의 장점은 애플리케이션 UI 코드가 처음부터 비동기 중심이 되도록 강제하고 기본 API가 이후 단계에서 비동기식이어야 하는지 여부를 결정할 수 있다는 것입니다.

모든 애플리케이션 API가 비동기식 API를 필요로 하거나 비동기적이어서는 안 됩니다. 일반적으로 모든 유형의 I/O 또는 과도한 처리 (15ms보다 오래 걸릴 수 있는 모든 작업)를 실행하는 모든 API는 첫 번째 구현이 동기적이더라도 처음부터 비동기식으로 노출되어야 합니다.

실패 처리

비동기 프로그래밍에서 발견되는 한 가지 문제는 일반적으로 오류가 다른 스레드에서 발생하기 때문에 실패를 처리하는 기존의 try/catch 방법이 더 이상 실제로 작동하지 않는다는 것입니다. 따라서 피호출자는 처리 중에 문제가 발생하면 호출자에게 알리는 구조화된 방법이 있어야 합니다.

이벤트 기반 비동기 API에서는 주로 이벤트를 수신할 때 이벤트 또는 객체를 쿼리하는 애플리케이션 코드로 이 작업을 수행합니다. 콜백 기반 비동기 API의 경우, 두 번째 인수를 두 번째 인수로 보유하는 것이 좋습니다. 이 인수는 오류 발생 시 호출될 함수를 적절한 오류 정보와 함께 인수로 갖는 것입니다.

getData 호출은 다음과 같습니다.

// getData(successFunc,failFunc);  
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});

$.Deferred와 합치기

위의 콜백 접근 방식의 한 가지 제한사항은 중간 수준까지 고급 동기화 로직을 작성하는 것이 정말 번거로워질 수 있다는 점입니다.

예를 들어 두 비동기 API가 완료될 때까지 기다린 후에 세 번째 API를 실행해야 하는 경우 코드 복잡성이 빠르게 증가할 수 있습니다.

// first do the get data.   
getData(function(data){
// then get the location
getLocation(function(location){
alert("we got data: " + data + " and location: " + location);
},function(ex){
alert("getLocation failed: "  + ex);
});
},function(ex){
alert("getData failed: " + ex);
});

애플리케이션이 애플리케이션의 여러 부분에서 동일한 호출을 해야 하는 경우에는 모든 호출이 이러한 다단계 호출을 실행해야 하거나 애플리케이션이 자체 캐싱 메커니즘을 구현해야 하기 때문에 상황이 더 복잡해질 수도 있습니다.

다행히도 비교적 오래된 패턴인 Promises (Java의 Future와 유사)와 jQuery Core의 강력한 최신 구현인 $.Deferred는 비동기 프로그래밍의 단순하면서도 강력한 솔루션을 제공합니다.

간단하게 설명하기 위해 프로미스 패턴은 비동기 API가 일종의 '해당 데이터로 결과가 해결될 것이라는 프로미스'인 프로미스 객체를 반환하도록 정의합니다. 해상도를 얻기 위해 호출자는 프로미스 객체를 가져오고 done(successFunc(data)를 호출합니다. 이 객체는 '데이터'가 해결될 때 프로미스 객체에 이 successFunc를 호출하도록 지시합니다.

따라서 위의 getData 호출 예는 다음과 같습니다.

// get the promise object for this API  
var dataPromise = getData();

// register a function to get called when the data is resolved
dataPromise.done(function(data){
alert("We got data: " + data);
});

// register the failure function
dataPromise.fail(function(ex){
alert("oops, some problem occured: " + ex);
});

// Note: we can have as many dataPromise.done(...) as we want. 
dataPromise.done(function(data){
alert("We asked it twice, we get it twice: " + data);
});

여기에서는 먼저 dataPromise 객체를 가져온 다음 .done 메서드를 호출하여 데이터가 리졸브될 때 다시 호출하려는 함수를 등록합니다. 또한 .fail 메서드를 호출하여 최종 실패를 처리할 수도 있습니다. 기본 프로미스 구현 (jQuery 코드)이 등록 및 콜백을 처리하므로 .done 또는 .fail 호출은 필요한 만큼 호출할 수 있습니다.

이 패턴에서는 고급 동기화 코드를 구현하기가 상대적으로 쉬우며 jQuery가 이미 $.when과 같은 가장 일반적인 코드를 제공합니다.

예를 들어 위의 중첩된 getData/getLocation 콜백은 다음과 같습니다.

// assuming both getData and getLocation return their respective Promise
var combinedPromise = $.when(getData(), getLocation())

// function will be called when both getData and getLocation resolve
combinePromise.done(function(data,location){
alert("We got data: " + dataResult + " and location: " + location);
});  

무엇보다도 jQuery.Deferred를 사용하면 개발자가 비동기 함수를 매우 쉽게 구현할 수 있습니다. 예를 들어 getData는 다음과 같을 수 있습니다.

function getData(){
// 1) create the jQuery Deferred object that will be used
var deferred = $.Deferred();

// ---- AJAX Call ---- //
XMLHttpRequest xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
    // 3.1) RESOLVE the DEFERRED (this will trigger all the done()...)
    deferred.resolve(xhr.response);
}else{
    // 3.2) REJECT the DEFERRED (this will trigger all the fail()...)
    deferred.reject("HTTP error: " + xhr.status);
}
},false) 

// perform the work
xhr.send();
// Note: could and should have used jQuery.ajax. 
// Note: jQuery.ajax return Promise, but it is always a good idea to wrap it
//       with application semantic in another Deferred/Promise  
// ---- /AJAX Call ---- //

// 2) return the promise of this deferred
return deferred.promise();
}

따라서 getData()가 호출되면 먼저 새 jQuery.Deferred 객체 (1)를 만든 다음 Promise (2)를 반환하여 호출자가 수행된 함수와 실패한 함수를 등록할 수 있도록 합니다. 그런 다음 XHR 호출이 반환되면 지연된 호출을 해결 (3.1)하거나 거부합니다 (3.2). deferred.resolve를 실행하면 모든 완료(...) 함수와 기타 프로미스 함수 (예: then and pipe)가 트리거되고, deferred.reject를 호출하면 모든 failed() 함수가 호출됩니다.

사용 사례

다음은 Deferred가 매우 유용할 수 있는 몇 가지 좋은 사용 사례입니다.

데이터 액세스: 데이터 액세스 API를 $.Deferred로 노출하는 것이 올바른 설계인 경우가 많습니다. 이는 원격 데이터의 경우 명백합니다.동기식 원격 호출은 사용자 환경을 완전히 망가뜨리기 때문입니다. 하지만 로컬 데이터의 경우에도 하위 수준 API (예: SQLite 및 IndexedDB)는 그 자체가 비동기식입니다. Deferred API의 $.when 및 .pipe는 비동기 하위 쿼리를 동기화하고 연결하는 데 매우 강력합니다.

UI 애니메이션: conversionEnd 이벤트로 하나 이상의 애니메이션을 조정하는 작업은 꽤 지루할 수 있습니다. 특히 애니메이션이 CSS3 애니메이션과 JavaScript가 혼합되어 있는 경우 그렇습니다. 애니메이션 함수를 Deferred로 래핑하면 코드 복잡성을 크게 줄이고 유연성을 개선할 수 있습니다. conversionEnd에서 확인되는 프로미스 객체를 반환하는 cssAnimation(className)과 같은 간단한 일반 래퍼 함수도 큰 도움이 될 수 있습니다.

UI 구성요소 디스플레이: 조금 더 고급 기능이지만 고급 HTML 구성요소 프레임워크에서도 Deferred를 사용해야 합니다. 애플리케이션에서 사용자 인터페이스의 여러 부분을 표시해야 하는 경우 이러한 구성요소의 수명 주기를 Deferred에 캡슐화하면 (다른 게시물의 주제임) 너무 자세히 설명하지 않고도 타이밍을 더 잘 제어할 수 있습니다.

모든 브라우저 비동기 API: 정규화를 위해 브라우저 API 호출을 지연됨으로 래핑하는 것이 좋은 아이디어인 경우가 많습니다. 코드마다 문자 그대로 4~5줄의 코드가 필요하지만 모든 애플리케이션 코드가 크게 간소화됩니다. 위의 getData/getLocation 의사 코드에서 볼 수 있듯이 이렇게 하면 애플리케이션 코드가 모든 유형의 API (브라우저, 애플리케이션 사양, 복합)에서 하나의 비동기 모델을 가질 수 있습니다.

캐싱: 일종의 부수적인 이점이지만 경우에 따라 매우 유용할 수 있습니다. Promise API (예: .done(...) 및 .fail(...))은 비동기 호출이 실행되기 전이나 후에 호출될 수 있으며, 지연된 객체를 비동기 호출의 캐싱 핸들로 사용할 수 있습니다. 예를 들어 CacheManager는 주어진 요청에 관해 Deferred를 추적하고, 무효화되지 않은 경우 일치하는 Deferred의 Promise를 반환할 수 있습니다. 가장 좋은 점은 호출자가 호출이 이미 해결되었는지 또는 확인 중인지를 알 필요가 없다는 것입니다. 이 경우 콜백 함수가 정확히 동일한 방식으로 호출됩니다.

결론

$.Deferred의 개념은 간단하지만 제대로 처리하는 데는 시간이 걸릴 수 있습니다. 하지만 브라우저 환경의 특성상 자바스크립트에서 비동기 프로그래밍을 마스터하는 것은 전문 HTML5 애플리케이션 개발자에게 필수이며 프로미스 패턴 (및 jQuery 구현)은 비동기 프로그래밍을 안정적이고 강력하게 만드는 엄청난 도구입니다.