이 글은 You Don't Know JS 서적을 참고하여 작성하였습니다.
프로그램을 개발하다 보면, 어느 부분은 지금
실행되고 다른 부분은 나중
에 실행되면서 발생한다. 이러한 지금
과 나중
에 해당하는 부분 사이의 관계가 바로 비동기
프로그램의 핵심이다.
좀 더 구체적으로 살펴보면, 자바스크립트 프로그램은 함수라는 단위로 지금
실행중인 프로 그램 덩이와 나중
에 실행할 프로그램 덩이로 구성된다. 나중
이라는 의미는 지금
당장 끝낼 수 없는 작업을 비동기적으로 처리 한 후 프로그램 중단없이 실행하는 것을 말한다.
지금
부터 나중
까지 기다리는 가장 간단한 방법은 콜백 함수
를 이용하는 것이다.
그럼, 다음 코드에서 지금
과 나중
에 발생할 코드를 구분해보자.
function now() {
return 21;
}
function later() {
answer = answer * 2;
console.log("answer = " + answer);
}
var answer = now();
setTimeout(later, 1000);
// answer = 42
지금
실행되는 코드는 아래와 같다.
function now() {
return 21;
}
function later() { ... }
var answer = now();
setTimeout(later, 1000);
나중
에 실행되는 코드는 다음과 같다.
answer = answer * 2;
console.log("answer = " + answer);
프로그램을 시작하면 지금
에 해당하는 부분은 바로 실행되지만, 나중
에 해당하는 부분은 setTimeout() 함수로 타이머 처리기
에 등록되어 1초 이후에 실행된다.
later
함수는 비동기적으로 실행되는 것을 확인할 수 있다.
자바스크립트는 단일 스레드로 동작하는데 어떻게 비동기를 처리할 수 있는 것일까?
자바스크립트 엔진은 스레드 내부 스택에서 함수를 하나씩 처리해 나가는데, 비동기 처리가 필요한 영역에서는 Block 되지 않고 호스트(ex. 브라우저)에 요청을 한 후 다음 코드를 순차적으로 실행해 나간다.
호스트(ex. 브라우저)는 비동기 처리를 끝마치고 나서 콜백함수를 EventQueue 에 삽입한다. EventLoop는 지속적으로 Task Queue를 확인해서 콜백함수가 존재한다면, 스레드 내부 스택에 콜백함수를 삽입해 실행한다.
Job Queue는 ES6부터 이벤트 루프 큐에 새롭게 도입된 개념이며, 주로 프라미스의 비동기 작업에서 이용된다.
Job Queue는 일반적인 Task Queue보다 우선 순위가 높기 때문에, Event Loop는 프라미스와 같은 콜백이 있는 Job Queue를 먼저 처리하고 Task Queue를 처리한다.
따라서, 이론적으로는 Job Queue의 원소가 많을 경우 Task Queue를 처리하지 못하는 경우가 발생할 수도 있다.
정리해보면 아래와 같은 과정으로 이루어지는 것이다.
자바스크립트 엔진은 타이머
, DOM Event
와 같은 비동기 처리를 호스트(브라우저) 전달한다. 브라우저는 전달받은 '비동기성 기능'을 각 처리기에서 완료하고, 큐에 콜백
을 등록한다. 이후, 자바스크립트 엔진은 EventLoop로 큐에 등록된 콜백
을 Pop하여 Call Stack에 추가해 처리한다.
콜백
은 자바스크립트에서 비동기성을 표현하는 기본 단위이다.
뿐 만 아니라 콜백함수는 프로그램의 연속성을 캡슐화한 장치이다.
아래의 출력을 예상해보자.
console.log('A');
setTimeout(function() {
console.log('C');
}, 0);
console.log('B');
출력 순서는 A -> B -> C
이다. 호출 순서와 함수는 0초 대기하는 것으로 생각하여 A -> C -> B
로 예상했을 것이다. 하지만, setTimeout() 함수의 콜백함수는 호스트(브라우저)에서 처리가 된 후 Task Queue에서 대기를 하기 때문에 콜백함수는 console.log('B')
보다 늦게 호출된다.
콜백함수는 비동기성을 표현/관리하는 가장 일반적인 기법이다.
하지만, 콜백함수는 몇 가지 문제점을 가지고 있는데 하나씩 살펴보자.
비동기적인 상황을 순차적으로 사용하는 방법으로 콜백을 사용하게 된다면 이른바 콜백 지옥
을 마주하게 된다.
setTimeout(function() {
console.log('foo');
setTimeout(function() {
console.log('bar');
setTimeout(function() {
console.log('baz');
setTimeout(function() {
console.log('baf');
}, 4000);
}, 3000);
}, 2000);
}, 1000);
위 코드는 타이머를 중첩으로 순차적으로 실행하는 콜백함수 구조이다.
보시다시피 가독성이 좋지 않다는 것을 느낄 뿐만 아니라, 이보다 더한 경우에는 개발자가 동작을 추론하기가 힘들어진다.
콜백 중심적 설계는 비동기 상황에서 제어권 교환
이 일어난다. 즉, 콜백 함수
는 다른 프로그램에 의해서 나중
에 실행된다.
서드 파티가 제공한 유틸리티를 이용해 비동기 처리를 한다고 가정해보자. 내가 개발한 프로그램임에도 불구하고 실행 흐름은 서드 파티에 의존하게 되는 제어의 역전
이 발생한다.
제어권이 서드 파티에 넘어가 제어의 역전
이 발생한 경우, 해당 유틸리티가 전달한 콜백 함수
를 제대로 호출한다는 것을 보장할 수 없기 때문에 예기치 못한 상황을 방지하고자 방어적인 코드(함수 인자 체크)를 추가하게 된다. 결국 매번 비동기성 코드를 처리할 때마다 콜백 함수에 방어적인 코드가 추가된다.
결과적으로, 제어의 역적
으로 인해 예기치 못한 상황이 벌어질 수 있고 추후에 난감한 버그가 발생할 가능성이 있다.
앞서 살펴본 콜백
에 대한 믿음성의 문제로 부터 여러 해결 방법이 강구되어 왔다.
첫 번째로 서드 파티 유틸리티에서 에러를 처리하기 위해, 분할 콜백
기능을 제공하기도 한다.
function success(arg) {
//
}
function failure(arg) {
//
}
request('http://google.com', success, failure);
request
서드 파티 유틸리티가 성공적으로 처리된 경우에는 success
함수가 호출되고, 에러/실패한 경우에는 failure
함수가 호출된다.
다음으로, 에러 우선 스타일
이라는 콜백 패턴을 사용하기도 한다.
function response(error, arg) {
if(error) {
// 에러 처리
} else {
//
}
}
request('http://google.com', response);
콜백 함수가 에러/실패한 경우에는 첫 번째 인자로 에러 객체
를 전달하고, 성공적으로 처리한 경우에는 falsy
값으로 채워진다.
위와 같은 두 방법은 콜백
에러 처리에 대한 보다 나은 규칙을 제공하지만, 실질적으로 믿음성에 대한 문제는 해결해주진 못한다.
구체적으로, 콜백 함수의 반복적인 호출을 방지하거나 한 번도 호출되지 않는 것을 처리하지도 않는다.
따라서, 다음과 같이 콜백 함수를 구현할 때마다 방어적인 코드를 추가해야 한다.
function timeoutify(fn, delay) {
let id = setTimeout(function() {
id = null;
fn(new Error('타임 아웃'));
}, delay);
return function() {
if(id) {
clearTimeout(id);
fn.apply(this, arguments);
}
}
}
function foo(err, arg) {
if(err) {
console.error(err);
} else {
console.log(arg);
}
}
// 1초 안에 콜백함수가 호출되는 지를 확인한다.
request('http://google.com', timeoutify(foo, 1000));
위와 같이, 특정 시간안에 콜백함수의 실행 여부를 확인해 처리하는 추가적인 함수가 필요로 하게 된다.
추가적으로, 서드 파티 유틸리티가 콜백 함수를 비동기적으로 호출한다고 보장할 수 없는 경우도 있을 것이다.
function foo() {
console.log(num);
}
let num = 1;
bar(foo, 0);
num++;
bar
함수가 콜백 함수를 동기적으로 처리한다면 결과는 1일 것이고, 비동기적으로 호출한다면 2를 출력할 것이다.
이렇게 서드 파티 유틸리티에 따라 정확한 결과를 예상할 수 가 없다.
따라서, 콜백 함수가 무조건 비동기적으로 호출되도록 변경하는 방법이 있다.
function asyncify(fn) {
let originFn = fn,
// 무조건 EventLoop를 거쳐 오도록 설정(비동기로 처리되도록 설정)
id = setTimeout(function () {
// 'setTimeout 콜백'이 '반환된 함수'보다 먼저 호출된 경우
// 바로 기존 콜백(originFn)을 호출하도록 설정
id = null;
// 'setTimeout 콜백'이 '반환된 함수'보다 나중에 호출된 경우
if(fn) fn();
}, 0);
fn = null;
return function() {
if(id) {
// '반환된 함수' => 'setTimeout 콜백' 순으로 호출된 경우
fn = originFn.bind(this, ...[].slice.call(arguments));
} else {
// 'setTimeout 콜백' => '반환된 함수' 순으로 호출된 경우
originFn.apply(this, arguments);
}
}
}
function foo(a, b, c) {
console.log(a, b, c)
console.log(num);
}
// 동기적으로 콜백 호출
function bar(fn) {
fn(1,2,3);
}
let num = 1;
bar(asyncify(foo), 0);
num++;
위와 같이 만약 bar() 함수가 동기적으로 수행된다면, asyncify()
함수를 이용해 비동기적으로 수행되도록(EventLoop를 무조건 한 번 거치도록) 변경할 수 있다.
즉, setTimeout()
함수를 이용해 무조건 EventLoop를 거쳐 콜백 함수
가 실행되도록 설정한다.
콜백
은 항상 코드 추론에 시간 투자을 투자해야만 한다..;