자연스럽고 반응이 빠른 HTML5 애플리케이션을 만들 때 가장 중요한 것들 중 하나는 애플리케이션의 데이터 가져오기, 연산, 애니메이션, UI 요소같은 서로 다른 모든 부분을 동기화하는 것이다.
데스크탑이나 네이티브 환경과의 주요 차이점은 브라우저는 쓰레딩 모델에 접근할 수 없으며 UI(예: DOM)를 건드리는 모든 것들에 대해서 싱글쓰레드를 제공한다. 이것은 UI 요소들을 건드리고 변경하는 모든 애플리케이션 로직이 항상 같은 쓰레드에서 동작한다는 것을 의미한다. 따라서 애플리케이션은 브라우저가 제공하는 비동기 기능들을 최대한 사용하여 가능한 작고 효율적인 단위로 잘 동작하도록 유지하는 것이 중요하다.
다행히 브라우저는 일반적으로 사용되는 XHR(XMLHttpRequest 또는 'AJAX') API와 같은 몇 가지 비동기 API들을 제공한다. 몇 가지 예를 들면 IndexedDB, SQLite, HTML5 Web workers, 그리고 HTML5 GeoLocation API가 있다. transitionEnd 이벤트를 사용하는 CSS3 애니메이션같은 몇몇 DOM에 관련된 동작들도 비동기적으로 되어있다.
브라우저가 비동기 프로그래밍을 애플리케이션 로직에 노출시키는 방법은 이벤트 또는 콜백을 사용하는 것이다. 이벤트 기반 비동기 API의 경우, 개발자가 특정 객체(예: HTML 엘리먼트 또는 다른 DOM 객체들)에 이벤트 핸들러를 등록하고 나서 해당 동작을 호출한다. 브라우저는 보통 다른 쓰레드로 그 동작을 수행할 것이고 적절한 때에 메인 쓰레드에서 이벤트를 발생시킬 것이다.
예를 들어 이벤트 기반 비동기 API인 XHR을 사용하는 코드는 아래와 같다.
// /data 리소스를 GET으로 요청하기 위해 XHR 객체를 생성한다. var xhr = new XMLHttpRequest(); xhr.open("GET","data",true); // 이벤트 핸들러를 등록한다. xhr.addEventListener('load',function(){ if(xhr.status === 200){ alert("We got data: " + xhr.response); } },false) // 작업을 수행한다. xhr.send();
CSS3 transitionEnd 이벤트는 이벤트 기반 비동기 API의 또 다른 예이다.
// id가 'flyingCar'인 HTML 엘리먼트를 가져온다. var flyingCarElem = document.getElementById("flyingCar"); // 이벤트 핸들러를 등록한다. // (FireFox는 'transitionEnd'를, Webkit은 'webkitTransitionEnd'를 사용한다.) flyingCarElem.addEventListener("transitionEnd",function(){ // 트랜지션이 끝나면 호출될 것이다. alert("The car arrived"); }); // 애니매이션을 시작시킬 CSS3 클래스를 추가한다. // 비고: 어떤 브라우저들은 GPU에 몇몇 트랜지션들을 위임한다. 그러나 개발자들은 이를 제어하지 못하고 신경 쓸 필요가 없다. flyingCarElemen.classList.add('makeItFly')
SQLite와 HTML5 Geolocation 같은 다른 브라우저 API들은 콜백 기반이다. 이것은 개발자가 해당 해상도를 가지고 내부구현에 의해 콜백될 함수를 인수로 전달한다는 것을 의미한다.
예를 들어 HTML5 Geolocation에 대한 코드는 아래와 같다.
// 완료되면 콜백으로 함수를 호출하고 전달한다. navigator.geolocation.getCurrentPosition(function(position){ alert('Lat: ' + position.coords.latitude + ' ' + 'Lon: ' + position.coords.longitude); });
이 경우 메소드를 호출하고 요청된 결과를 가지고 콜백될 함수를 전달하기만 하면 된다. 이것은 브라우저가 동기나 비동기에 상관없이 이 기능을 실행하게 하고 개발자에게는 상세구현에 상관없이 하나의 API를 제공한다.
브라우저에 내장된 비동기 API들 이상으로, 잘 설계된 애플리케이션들은 저수준 API들도 비동기 방식으로 드러내야 한다. 어떤 종류의 I/O나 부하가 큰 연산을 하는 경우에 특히 그러하다. 예를 들어 데이터를 가져오는 API들은 비동기여야 하고 아래처럼 해서는 안된다.
// 잘못됐음: 데이터를 가져올때 UI를 멈추게 만들것이다. var data = getData(); alert("We got data: " + data);
이 API 디자인은 getData()가 데이터를 가져올때까지 UI를 멈추게하는 블록현상(blocking)을 요구한다. 만약 데이터가 JavaScript의 컨텍스트에 로컬로 저장되있다면 문제가 안될 수도 있지만 데이터를 네트워크, SQLite(로컬이더라도), 인덱스저장소 같은 곳에서 가져올 필요가 있다면 사용자경험에 막대한 영향을 미칠 수 있다.
올바른 디자인은 처리하는데 시간이 좀 걸릴 수 있는 모든 애플리케이션 API를 처음부터 비동기로 앞서서 만드는 것이다. 비동기여야 할 동기 애플리케이션 코드를 보강하는 것은 부담스러운 작업이 될 수 있기 때문이다.
예를 들어 단순화된 getData() API는 아래처럼 될 것이다.
getData(function(data){ alert("We got data: " + data); });
이러한 처리방법이 좋은 점은 애플리케이션 UI 코드가 처음부터 비동기 중심이 되도록 강제하고 기본 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); });
위 콜백 처리방법의 한가지 한계점은 약간의 고급 동기화 로직을 작성하는 것만으로도 매우 번잡하게 될 수 있다는 것이다.
예를 들어 만약 세 번째 API를 사용하기 전에 2개의 비동기 API가 실행완료 되기를 기다려야 한다면 코드의 복잡도는 비약적으로 증가할 것이다.
// 첫 번째로 데이터를 가져온다. getData(function(data){ // 두 번째로 위치정보를 가져온다. 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 코어에 비동기 프로그래밍을 하는데 간단하고 강력한 솔루션을 제공하는$.Deferred라는 원기왕성하고 현대적인 구현체가 있다.
간단하게 하기 위해 Promises 패턴은 일종의 "결과가 해당 데이터를 가지고 해결될 것이라는 약속"인 Promise 객체를 반환하는 비동기 API를 정의한다. 문제해결을 위해 호출함수는 Promise 객체를 얻고 "data"가 해결됐을 때 successFunc를 호출하기 위해 Promise 객체에게 말할 done(successFunc(data))를 호출한다.
그래서 위의 getData 호출 예제는 아래와 같다.
// 이 API를 위한 Promise 객체를 얻는다. var dataPromise = getData(); // 데이터가 해결됐을 때 호출될 함수를 등록한다. dataPromise.done(function(data){ alert("We got data: " + data); }); // 오류 함수를 등록한다. dataPromise.fail(function(ex){ alert("oops, some problem occured: " + ex); }); // 참고: 원하는 만큼 dataPromise.done(...)을 많이 가질 수 있다. dataPromise.done(function(data){ alert("We asked it twice, we get it twice: " + data); });
여기에서 먼저 dataPromise 객체를 얻고나서 데이터가 해결됐을 때 콜백으로 실행될 함수를 등록하기 위해 .done 메소드를 호출한다. 또한 오류를 처리하기 위해 .fail메소드를 호출할 수 있다. 참고로 필요한대로 많은 .done과 .fail 호출들을 가질 수 있다. Promise의 내부구현(jQuery 코드)이 등록과 콜백들을 다루기 때문이다.
이 패턴으로 향상된 동기화 코드를 구현하는 것은 비교적 쉽다. 그리고 jQuery는 이미 가장 일반적인 구현체인 $.when을 제공한다.
예를 들어 위의 중첩된 getData/getLocation 콜백은 아래처럼 될 것이다.
// 가령 getData와 getLocation 둘 다 그들 각자의 Promise를 반환한다면 var combinedPromise = $.when(getData(), getLocation()) // 함수는 getData와 getLocation이 둘 다 해결됐을 때 호출될 것이다. combinePromise.done(function(data,location){ alert("We got data: " + dataResult + " and location: " + location); });
그래서 장점은 jQuery.Deferred가 개발자들을 위해 비동기 함수를 구현하기 매우 쉽게 만든다는 것이다. 예를 들어 getData는 아래처럼 할 수 있다.
function getData(){ // 1) 사용될 jQuery Deferred 객체를 생성한다. var deferred = $.Deferred(); // ---- AJAX 호출 ---- // XMLHttpRequest xhr = new XMLHttpRequest(); xhr.open("GET","data",true); // 이벤트 핸들러를 등록한다. xhr.addEventListener('load',function(){ if(xhr.status === 200){ // 3.1) DEFERRED를 해결한다. (모든 done()...을 동작시킬 것이다.) deferred.resolve(xhr.response); }else{ // 3.2) DEFERRED를 거부한다. (모든 fail()...을 동작시킬 것이다.) deferred.reject("HTTP error: " + xhr.status); } },false) // 작업을 수행한다. xhr.send(); // 참고: jQuery.ajax를 사용할 수 있었고 해야할 수 있었다. // 참고: jQuery.ajax는 Promise를 반환하지만 다른 Deferred/Promise를 사용하여 애플리케이션에 의미있는 구문으로 감싸는 것은 언제나 좋은 생각이다. // ---- /AJAX 호출 ---- // // 2) 이 deferred의 promise를 반환한다. return deferred.promise(); }
이와 같이 getData()가 호출됐을 때, 먼저 새로운 jQuery.Deferred 객체를 생성한다. (1) 그리고나서 그것의 Promise를 반환한다. (2) 그러면 호출함수는 done과 fail 함수들을 등록할 수 있다. XHR 호출이 반환됐을 때는 deferred를 (3.1) 해결하거나 (3.2) 거부한다. deferred.resolve를 호출하는 것은 모든 done(...) 함수들과 다른 promise 함수들(예: then과 pipe)을 동작시킬 것이고 deferred.reject를 호출하는 것은 모든 fail() 함수들을 동작시킬 것이다.
여기 Deferred를 유용하게 사용할 수 있는 좋은 사용례들이 있다.
데이터 접근: 데이터 접근 API들을 $.Deferred로 구현하는 것은 보통 좋은 디자인이다. 이것은 원격데이터에 대해서 명백하다. 동기적인 원격 호출은 사용자 경험을 완전히 망가뜨리기 때문이다. 하지만 로컬데이터에 대해서도 마찬가지다. 종종 저수준 API들은(예: SQLite와 IndexedDB) 비동기이기 때문이다. Deferred API인 $.when과 .pipe는 비동기 서브쿼리들을 동기화하고 체인으로 연결하는데 매우 강력하다.
UI 애니매이션: transitionEnd 이벤트를 사용하여 하나 이상의 애니매이션을 구성하는 것은 매우 지루하다. 특히 애니매이션들이 CSS3 애니매이션과 JavaScript의 조합일때(흔히 있는 일이지만) 그렇다. Deferred로 애니매이션 함수들을 감싸는 것은 코드의 복잡도를 상당히 감소시키고 유연성을 증가시킬 수 있다. transitionEnd일 때 해결되는 Promise 객체를 반환할 cssAnimation(className)같은 간단하고 일반적인 래퍼(wrapper) 함수도 아주 크게 도움이 될 수 있다.
UI 컴포넌트 표시: 이것은 다소 진보적이지만 고급 HTML 컴포넌트 프레임워크들도 Deferred를 사용해야 한다. 자세한 내용은 여기에 쓰지 않지만(다른 글의 주제가 될 것이다.) 애플리케이션이 별도의 UI를 보여줄 필요가 있을 때, Deferred로 캡슐화된 컴포넌트들의 라이프사이클을 가지는 것은 타이밍을 더 잘 조절할 수 있게 한다.
브라우저 비동기 API: 일반화 목적으로 브라우저 API 호출들을 Deferred로 감싸는 것은 보통 좋은 생각이다. 이것은 정말로 각 코드마다 네다섯줄이 필요하지만 어떤 애플리케이션 코드도 아주 간단해질 것이다. 위의 getData/getLocation 의사(pseudo) 코드에서 보듯이 이것은 애플리케이션 코드가 모든 형태의 API(브라우저, 애플리케이션 세부사항과 이들의 조합)를 통틀어서 한가지 비동기 모델로 사용하게 한다.
캐싱: 이것은 일종의 부수효과지만 어떤 경우에는 매우 유용하게 될 수 있다. Promise API들(예: .done(..)과 .fail(..))은 비동기 호출이 수행되기 전이나 후에 호출될 수 있기 때문이다. Deferred 객체는 비동기 호출에 대해서 캐싱 핸들러로 사용될 수 있다. 예를 들어 CacheManager는 특정 요청들에 대해 Deferred를 추적할 수 있고, 무효화되지 않는다면 매치되는 Deferred의 Promise를 반환한다. 이것의 장점은 호출함수가 이미 해결된 것인지 해결도중인 것인지 알지 못해도 해당 콜백함수는 정확히 같은 방법으로 호출될 것이라는 점이다.
$.Deferred의 개념은 간단하지만 이를 잘 다루기 위해서는 시간이 걸릴 수 있다. 하지만 브라우저 환경의 특성상, JavaScript로 비동기 프로그래밍을 마스터하는 것은 진지한 HTML5 애플리케이션 개발자의 의무이다. 그리고 Promise 패턴(과 jQuery 구현)은 비동기 프로그래밍을 신뢰성있고 강력하게 만들기 위한 대단한 도구이다.