본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.
자바스크립트는 요청을 보낼 때 크게 동기(sync) 방식과 비동기(async) 방식으로 구분할 수 있다. 특히 웹 브라우저에서 통신 간 발생하는 요청은 대부분 비동기로 처리되는 경우가 많다. 비동기는 요청을 발송하고 이에 대한 응답이 반환될 때 까지 CPU 및 기타 자원을 놀게 두지 않고 다른 일을 할 수 있도록 효율적인 동작이 가능하다.
자바스크립트 호스트 환경이 제공하는 여러 함수를 사용하면 비동기 동작 스케줄링이 가능하다. 이전 챕터에서 살펴보았던 setTimeout
함수가 대표적인 스케줄링 함수이다.
비동기 요청은 강력한 만큼 그 설계와 구조가 동기 방식에 비해 직관적이지 않고 복잡하다. 특히 지금 다룰 콜백(callback
)을 이용한 비동기 요청은 그 구조가 매우 깊거나 복잡할 수 있다. 그러나 본래 자바스크립트는 ES6(ES2015) Promise
가 도입되기 전까지는 콜백방식을 이용해 비동기를 처리했다. 따라서 콜백 방식을 어떻게 사용했고 그에 따른 단점이 어땠는지 살펴보도록 하자.
보통 스크립트 또는 모듈을 로딩하는 것 역시 비동기로 처리하는 경우가 많다. 관련 파일의 용량이 큰 경우, 모두 완료되기 까지 기다리고 다음 작업을 처리한다면 정체현상이 일어날 수 있기 때문이다. 다음과 같이 외부의 파일을 읽어 스크립트로 추가하는 함수가 있다고 하자.
function loadScript (src) {
// <script> 태그를 만들고
let script = document.createElement('script');
// 스크립트 파일 속성을 지정 (외부 링크)
script.src = src;
// 해당 스크립트 태그를 도큐멘트 헤더에 추가
document.head.append(script);
}
해당 함수를 이용하여 스크립트 로딩을 수행할 때, 이는 비동기적으로 실행된다. 로딩은 지금 당장 시작되더라도 실행 자체는 함수가 끝나야 되기 때문이다. 따라서 loadScript
아래에 위치하는 코드들은 스크립트 로딩이 종료되는 것을 기다리지 않는다.
// 해당 파일에 newFunctionInScript() 함수가 있다고 할때
loadScript('/my/script.js');
newFunctionInScript(); // 함수가 존재하지 않는 에러 발생
앞서 이야기 했듯 스크립트를 읽어오기 까지 일정 시간이 소요될 수 있다. 즉 충분히 스크립트를 읽을 시간을 확보하지 못했기 때문에 위와 같은 에러가 발생한다. 따라서 우리는 스크립트를 다 읽어왔는지 체크하기 위한 방법이 필요한데, 현재로서는 이 지점을 체크할 수가 없다. 이를 위해 우리가 활용할 수 있는 것이 loadScript
의 인수로 함수를 던져주는 것이다. 이때 전달하는 함수를 콜백(callback)
이라고 한다. 그 의미는 영단어에서 말하듯이 나중에 호출할 함수를 의미한다. 즉 콜백은 자바스크립트에서 어떤 특별한 동작을 수행하는 함수가 아닌 일반 함수이다.
function loadScript (src, callback) {
let script = document.createElement('script');
script.src = src;
// 스크립트의 로드가 완료되었을 때 호출되는 함수
script.onload = () => callback(script);
document.head.append(script);
}
이처럼 콜백함수를 통해 스크립트가 완료되었을 때 이후의 동작을 지정해준다면, 완료 시점 이후에 우리가 원하는 동작을 진행할 수 있다. 앞서 에러가 있던 코드를 다음과 같이 콜백을 통해 전달하도록 수정한다면 원했던 대로 동작하는 것을 보장할 수 있다.
loadScript('/my/script.js', function() {
// 함수 자체가 callback 으로 전달된다.
// 전달된 callback은 모든 로딩이 완료되고서 실행된다
// 따라서 실행시점에서 외부 파일에 있던 함수를 사용 가능하다
newFunctionInScript();
});
이 같은 방식을 콜백 기반 비동기 프로그래밍이라고 한다. 앞서 이야기한 것과 같이 ES6(ES2015) 이전에는 이와 같은 방식으로 모든 비동기 요청을 처리했다.
만약 스크립트가 두 개 있는 경우라면, 어떻게 순차적으로 두 스크립트를 불러올 수 있을까? 이때 두 번째 스크립트 로딩은 당연히 첫 번째 스크립트의 로딩이 끝난 이후가 되어야 한다.
앞서 구현한 바와 같이 콜백을 사용하여 해결할 수 있다. 가장 간단하게 떠올릴 수 있는 방법으로는, 콜백 함수 안에서 두 번째 loadScript
를 호출하는 방법이다.
loadScript('/my/script.js', function(script) {
console.log(`${script.src}를 로딩했습니다.`);
loadScript('/my/script2.js', function(script) {
console.log(`${script.src}를 로딩했습니다.`);
});
});
이를 콜백 속의 콜백, 또는 중첩콜백이라고 한다. 중첩 콜백에서는 바깥에 위치한 콜백이 먼저 실행되고 나서 안쪽에 있는 콜백이 실행된다. 이러한 콜백에 개수에는 제한이 없다. 몇개의 콜백이 있던간에 외부에서 안쪽으로의 실행 흐름은 보장된다.
loadScript('...', function(script) {
loadScript('...', function(script) {
loadScript('...', function(script) {
loadScript('...', function(script) {
...
});
});
});
});
이와 같이 작성하더라도 새로운 동작이 콜백 안에 위치하고 있다면 순서 흐름은 외부에서 안쪽으로 일정하다. 하지만 이러한 방식으로 작성할 때 콜백의 개수가 두세개라면 괜찮지만 동작이 많아지는 경우에는 가독성이 굉장히 떨어진다. 이와 같이 콜백에 콜백이 꼬리를 물고 늘어지는 것을 콜백 지옥 또는 멸망의 피라미드라고 부른다.
호출이 중첩되면서 코드의 depth
가 깊어지는 것은 좋은 일이 아니다. 그만큼 가독성이 떨어지고 코드 관리가 힘들어진다. 만약 중간중간 다른 로직을 처리하는 반복문과 조건문 등이 들어가게 되면 더욱 더 알아보기 힘들어 질 것이다.
위 그림과 같이 비동기 동작이 하나씩 추가될 때 마다 중첩 호출이 만들어내는 피라미드는 오른쪽으로 계속 쌓여갈 것이다. 때문에 이러한 방식은 결코 좋지 않다.
각 동작을 독립적인 함수로 만들어 관리하면 depth
가 계속 깊어지는 것을 방지할 수 있다.
loadScript('1.js', step1);
function step1(error, script) {
if (error) {
handleError(error);
} else {
loadScript('2.js', step2);
}
}
function step2(error, script) {
if (error) {
handleError(error);
} else {
loadScript('3.js', step3);
}
}
function step3(error, script) {
if (error) {
handleError(error);
} else {
// ...
}
}
이처럼 작성한다면 무한정 depth
가 깊어지는 것을 어느정도 방지할 수 있다. 그리고 여전히 콜백 기반으로 동작하는 것 역시 보장한다. 그렇다면 문제가 완벽하게 해결된 것일까?
동작상의 문제는 없지만 이 경우 각 함수가 찢겨진 종이조각처럼 산발적으로 존재한다는 문제가 있다. 때문에 여전히 가독성 측면에서 좋지 않다. 코드의 흐름에 따라 계속해서 컨텍스트의 위치가 바뀌기 때문이다.
또한 이처럼 함수를 독립적으로 분할하며 명명한 step...
함수들은 오직 콜백지옥을 회피하기 위한 목적으로 만들었기 때문에 재사용이 불가하다는 단점 역시 존재한다. 연쇄 동작이 일어나는 코드 외부에서는 이러한 함수들을 재활용 할 수 없기에 네임 스페이스가 다소 복잡해지게 된다.
이러한 방식은 좋은 코드를 작성하는데에 문제가 된다. 다행히 이러한 콜백지옥을 회피할 수 있는 방법이 존재한다. 가장 좋은 방법 중에 하나는 다음 챕터에서 이야기할 프라미스(Promise
)를 활용하는 것이다.
위에서 잠깐 살펴보았지만 콜백 함수는 항상 에러를 핸들링할 수 있어야 한다. 위의 예시로 보자면 스크립트 로딩이 어떤 사정에 의해 실패할 가능성이 존재하기 때문이다. 따라서 loadScript
함수를 로딩 에러가 추적 가능하도록 개선해보자.
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(null, script);
script.onerror = () => callback(new Error('에러발생'));
document.head.append(script);
}
loadScript('/my/script.js', function(error, scirpt) {
if (error) {
// 에러핸들링
} else {
// 스크립트 로딩이 성공적으로 완료
}
});
콜백을 이용한 에러처리에서 위와 같은 방식은 흔히 사용되는 패턴이다. 이러한 패턴을 오류 우선 콜백(error-first-callback
)이라고 부른다. 이는 다음의 순서로 처리되며 에러를 핸들링한다.
callback
의 첫 번째 인수는 에러를 위해 남겨둔다. 에러가 발생하면 이 인수를 이용해 callback(err)
이 호출된다.callback(null, result1, result2, ...)
이 호출된다.이처럼 오류 우선 콜백 패턴에서는 단일 콜백 함수에서 에러 케이스와 성공 케이스를 모두 처리할 수 있다.
프라미스(Promise
)는 ES6에 추가되었다. 요즈음 자바스크립트에서 비동기 요청을 처리할 때는 기본적으로 프라미스를 기반으로 처리한다. 약속이란 뜻의 프라미스는 비동기 요청에 맞게 그 결과를 바로 반환하지 않고 일종의 '약속값'을 반환한다. 개발자는 프라미스가 반환한 약속값을 가진 객체를 가지고 추후 반환되는 결과 또는 에러 등에 접근할 수 있다.
본격적으로 프라미스를 다루기 전에 다음의 개념을 익히고 들어가자.
제작코드(producing code)
: 원격에서 스크립트를 불러오는 등의 시간이 걸리는 일을 수행한다.소비코드(consuming code)
: 제작코드
의 결과를 기다리고, 결과가 반환되면 이를 소비(사용)한다.프라미스(Promise)
: 제작코드
와 소비코드
를 연결해 주는 특별한 자바스크립트 객체이다. 프라미스
는 시간이 얼마나 걸리든 상관없이 약속한 결과를 만들어 내는 제작코드
가 준비되었을 때, 모든 소비코드
가 결과를 사용할 수 있도록 한다.promise
객체는 다음과 같은 문법으로 만들 수 있다.
let promise = new Promise(function(resolve, reject) {
// executor (제작코드)
});
new Promise
에 전달되는 함수는 executor(실행자)
라고 부른다. executor
는 new Promise
가 만들어질 때 자동으로 실행되는데, 결과를 최종적으로 만들어내는 제작코드를 포함한다.
executor
의 인수 resolve
와 reject
는 자바스크립트에서 자체 제공하는 일종의 콜백이다. 개발자는 resolve
와 reject
를 신경 쓰지 않고 executor
내부 코드만 작성할 수 있다. 대신 executor
에선 결과를 즉시 얻든 늦게 얻든 상관없이 상황에 따라 인수로 넘겨준 콜백 중 하나를 반드시 호출해야 한다.
resolve(value)
: 일이 성공적으로 끝난 경우 그 결과를 나타내는 value
와 함께 호출
reject(error)
: 에러 발생 시 에러 객체를 나타내는 error
와 함께 호출
new Promise
생성자는 내부적으로 executor
를 거쳐promise
객체를 반환하는데 해당 객체는 다음과 같은 내부 프로퍼티를 가지고 있다.
state
: 처음엔 pending
(대기) 상태를 유지하다가 resolve
호출 시 fullfilled
, reject
호출 시 rejected
로 갱신result
: 처음엔 undefined
였다가 resolve(value)
가 호출되면 value
로, reject(error)
가 호출되면 error
로 갱신promise
생성자와 간단한 executor
함수로 만든 예시를 살펴보자. setTimeout
함수를 이용해 executor
함수는 1초의 시간이 소요된 후 실행된다.
let promise = new Promise(function (resolve, reject) {
// 프라미스가 만들어짐과 동시에 자동으로 executor 함수 실행
// 1초 뒤에 resolve에 'done'이 전달되며 result는 'done'으로 갱신
setTimeout(() => resolve('done'), 1000);
});
위에 예시로 든 코드는 다음과 같은 흐름을 가진다.
executor
함수는 new Promise
에 의해 자동으로 그리고 즉각적으로 호출executor
는 인자로 resolve
와 reject
함수를 전달받아 둘 중 하나는 반드시 호출 (위에서는 resolve
호출)반환되는 promise
의 객체는 초기 좌측의 상태에서 1초후에 우측의 상태로 전환된다. 이처럼 성공적으로 처리된 경우의 프라미스는 fullfilled promise
라고 부른다.
이번에는 executor
가 에러와 함께 약속한 작업을 거부하는 경우에 대해 살펴보자. 1초 후에 reject(...)
가 호출되면서 promise
의 상태가 rejected
로 갱신될 것이다.
let promise = new Promise(function (resolve, reject) {
// 1초 뒤 에러와 함께 실행 종료 신호를 전달
setTimeout(() => reject(new Error('에러발생')), 1000);
});
fullfilled
또는 rejected
의 상태를 가지고 있는 promise
를 처리된(settled
) 프라미스라고 부른다. 반대의 경우는 앞서 살펴본 pending
상태를 가진 프라미스이다.
프라미스는 항상 성공(fullfilled
) 또는 실패(rejected
)만 하는 것을 보장한다. 이때 변경된 상태는 더 이상 변하지 않는다. 처리가 끝난 프라미스에 추가적으로 resolve
나 reject
를 호출하더라도 이는 무시된다.
let promise = new Promise(function (resolve, reject) {
resolve('done'); // 결과가 정해짐
reject(new Error('error')); // 무시됨
setTimeout(() => resolve('...')); // 무시됨
});
또한 resolve
와 reject
는 자바스크립트 엔진이 미리 정의한 함수로 개발자가 따로 만들 필요가 없는 네이티브 함수이다. 이들은 인수를 최대 하나만 받을 수 있다. 이를 초과한 경우 나머지 인수는 무시한다. 또는 아무런 인수를 전달받지 않는 것 역시 가능하다.
프라미스는 주로 비동기 요청에 많이 응용되어, 내부의 executor
함수가 특정 시간이 걸리는 작업을 수행하고 resolve
또는 reject
를 호출하는 패턴을 가지는데, 이는 문법적으로 강제되는 요소는 아니다. 즉 아래와 같이 resolve
또는 reject
를 즉시 호출해도 상관없다.
let promise = new Promise(function(resolve, reject) {
// 즉시 호출
resolve(123);
});
이와 같이 선언한다면 프라미스는 즉시 이행 상태가 된다.
프라미스 객체가 가지고 있는 내부 프로퍼티인 state
와 result
는 개발자가 직접 접근할 수 없다. 단 .then
/.catch
/.finally
메서드를 사용하면 접근이 가능하다.
앞서 에러 핸들링 챕터에서 try...catch
를 다루면서 이미 catch
와 finally
에 대해서 다룬 바 있다. 이와 유사한 쓰임새를 가지면서 추가적으로 then
이 추가되었다고 볼 수 있다.
프라미스 객체는 executor
와 결과 또는 에러를 받을 소비 함수를 이어주는 역할을 한다고 했는데, 이때 소비 함수는 then
/catch
/finally
메서드를 사용해 등록(구독)할 수 있다.
1. then
then
은 프라미스에서 가장 중요하고 기본이 되는 메서드이다. 문법은 다음과 같다.
promise.then(
function(result) { /* 결과 핸들링 */ }
function(error) { /* 에러 핸들링 */ }
);
then
메서드의 첫 번째 인수는 프라미스가 이행(fullfilled
)되었을 때 실행되는 함수이고, 여기서 그 실행결과를 받을 수 있다.
then
메서드의 두 번째 인수는 프라미스가 거부(rejected
)되었을 때 실행되는 함수이고, 여기서 그 에러를 받을 수 있다.
let promise = new Promise(function (resolve, reject) {
/*(1)*/ setTimeout(() => resolve('done'), 1000);
/*(2)*/ setTimeout(() => reject(new Error('에러')), 1000);
});
promise.then(
result => console.log(result)
error => console.log(error)
);
위 코드는 예시를 위해 하나의 executor
에서 resolve
와 reject
를 동시에 호출하고 있다. 그러나 문법적으로 하나의 상태가 처리되면 나머지는 무시된다. 여기서는 분기별 상황을 보기 위해 그냥 편의상 한 곳에 모아두었다는 점을 감안하고 살펴보자.
만약 (1)에서 선언된 resolve
가 호출된다면 then
메서드는 1초후 done
을 호출한다. 즉 then
메서드의 첫 번째 인수인 result => console.log(reulst)
가 실행된다.
만약 (2)에서 선언된 reject
가 호출된다면 then
메서드는 1초후 에러를 발생한다. 즉 then
메서드의 두 번째 인수인 error => console.log(error)
가 실행 된다.
따라서 만일 작업이 성공적으로 처리가 된 경우만 다루고 싶다면 then
메서드에 인수를 하나만 전달하면 된다.
promise.then(console.log);
// 또는 promise.them(result => console.log(result));
2. catch
에러가 발생한 경우만 다루고 싶다면 .then(null, errorHandler)
와 같은 형태로 null
을 then
메서드에 첫 번째 인수로 전달할 수 있다. 위에서 살펴보았듯이 then
메서드의 두 번째 인수가 에러를 처리하기 때문이다. 그러나 간단하게 catch
메서드를 사용하여 catch(errorHandler)
형태로 선언할 수 있다. 이때 catch
는 앞서 언급한 then
에 null
을 전달하는 것과 완벽히 동일하게 작동한다.
let promise = new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error('에러')), 1000);
});
promise.catch(console.log);
// 또는 promise.catch(error => console.log(error));
3. finally
try...catch
절에도 finally
가 있는 것 처럼 프라미스에도 이와 유사하게 finally
가 존재한다. 프라미스가 처리되면 항상 이행이나 거부 상태를 만드는 함수 f
가 실행된다는 점에서 .finally(f)
호출은 .then(f, f)
와 유사하다.
예를 들어 비동기 요청 시 더 이상 쓸모가 없어진 로딩 인디케이터(Loading Indicator)를 멈추는 경우와 같이, 결과가 어떻게 되든간 상관없이 마무리가 필요하다면 finally
가 유용하다.
new Promise(function (resolve, reject) {
// 작업 후 resolve 또는 reject 호출
}).finally(() => 로딩 인디케이터 중지)
.then(result => console.log(result)
그러나 .then(f, f)
는 .finally(f)
와 완벽하게 동일하지는 않다. 주요 차이점은 다음과 같다.
finally
핸들러에는 인수가 없다. 따라서 finally
에서는 프라미스가 이행되었는지, 아니면 거부되었는지 알 수가 없다. 보통 finally
에서는 절차를 마무리하는 보편적인 동작을 수행하기 때문에 이처럼 성공 또는 실패 여부를 몰라도 되는 경우가 많다.finally
핸들러는 자동으로 다음 핸들러에 결과와 에러를 전달한다. finally
는 프라미스 결과를 처리하기 위해 만들어진 것이 아니기 때문에 프라미스 결과는 finally
를 통과해 계속 전달된다.new Promise((resovle, reject) => {
setTimeout(() => resolve('done'), 1000);
}).finally(() => console.log('ready'))
.then(result => console.log(result))
// finally이후 실행되지만 then에서 result를 다룰 수 있다.
new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('에러')), 1000);
}).finally(() => console.log('ready'))
.catch(err => console.log(err));
// finally이후 실행되지만 catch에서 err를 다룰 수 있다.
finally(f)
는 함수 f
를 중복해서 쓸 필요가 없기 때문에 .then(f, f)
보다 문법적인 측면에서 더 편리하다.프라미스가 대기 상태일때는 소비 함수 then
/catch
/finally
는 모두 프라미스가 처리될 때 까지 기다린다. 그러나 프라미스가 이미 처리된 상태라면 핸들러가 즉각 실행된다. 앞에서 프라미스는 항상 일정 소요시간을 가지는 것이 아닌 즉시 이행 상태 역시 가능하다고 했다.
let promise = new Promise(resolve => resolve('done'));
promise.then(console.log); // 대기없이 바로 출력
앞서 콜백으로 구현한 loadScript
함수를 프라미스 방식으로 바꾸어보자. 콜백 함수 대신 스크립트 로딩이 완전히 끝났을 때 이행되는 프라미스 객체를 만들고, 이를 반환하도록 하자. 외부에서는 then
핸들러를 통해 결과값을 처리할 수 있다.
function loadScript(src) {
return new Promise((resolve, reject) => {
let script = document.createElement('script');
script.src = src;
script.onload = () => resolve(script);
script.onerror = () => reject(new Error('에러'));
document.head.append(script);
});
}
let promise = loadScript(url);
promise.then(
scirpt => console.log(script + '로드완료')
error => console.log(error)
);
// 또는
promise.them(script => console.log(script));
프라미스를 사용한 코드가 콜백 기반보다 좋은 점을 정리하면 다음과 같다.
프라미스 | 콜백 |
---|---|
프라미스는 흐름이 자연스럽다. 어떤 동작을 수행하고 그 결과에 따라 다음에 무엇을 할지 자연스러운 순서로 코드 작성이 가능하다. | 콜백은 함께 호출할 callback 함수가 준비되어 있어야 한다. 또한 호출 이전에 호출 결과로 무엇을 할지 callback 함수에 미리 정의가 되어야 한다. |
프라미스는 원하는 만큼 then 을 추가할 수 있다. 이를 프라미스 체이닝 이라고 한다. | 콜백은 하나만 가능하다. |
그 외의 다른 장점들은 다음 챕터에서 자세히 다루어보자.