함수를 호출하면 자바스크립트 엔진에서 어떤 동작이 이루어질까? 가장 간단하게 보자면,
1. 자바스크립트 엔진이 함수 코드를 평가하고 함수 실행 컨텍스트를 생성한다.
2. 생성된 실행 컨텍스트는 실행 컨텍스트 스택(콜 스택)에 푸시되고 함수 코드가 실행된다.
3. 실행이 종료되면 함수 실행 컨텍스트가 콜 스택에서 팝으로 제거된다.
자바스크립트 엔진은 단 하나의 실행 컨텍스트 스택을 갖는다. 즉, 한 번에 하나의 함수만 실행 가능하다.
실행 컨텍스트 스택의 최상위 요소인 실행 중인 실행 컨텍스트
를 제외한 모든 실행 컨텍스트는 모두 대기 중인 태스크
들이다. 이들은 현재 실행 중인 함수가 팝되어 나가면 그 다음에 하나씩 실행된다.
이처럼 자바스크립트 엔진은 한 번에 하나의 태스크만 실행할 수 있는 싱글 스레드(single thread) 방식으로 동작한다. 그러므로 처리에 시간이 걸리는 태스크를 실행하는 경우 블로킹(blocking, 작업 중단)이 발생한다.
function sleep(func, delay) {
const delayUntil = Date.now() + delay;
while(Date.now() < delayUntil); // 3초
func();
}
function foo() {
console.log('foo');
}
function bar() {
console.log('bar');
}
sleep(foo, 3 * 1000);
bar();
// foo
// bar 블로킹 되었다가 (foo함수의 실행 시간 + 3초) 후에 호출
위 예제처럼 현재 태스크가 종료될 때까지 다음 태스크가 대기하는 방식을 동기 처리라고 한다. 장점은 실행 순서가 보장된다는 것, 단점은 블로킹이다.
이를 setTimeout
을 사용하여 수정해본다.
function foo() {
console.log('foo');
}
function bar() {
console.log('bar');
}
setTimeout(foo, 3* 1000);
bar();
// bar
// foo
setTimeout 함수는 sleep
함수와 유사하게 일정 시간 경과 후에 콜백 함수를 호출하지만 이후 태스크(bar()
)를 블로킹 하지 않고 바로 실행한다. 이처럼 현재 실행 중인 태스크 종료 이전에 다음 태스크를 바로 실행하는 방식을 비동기 처리라고 한다. 블로킹이 없다는 장점과 실행 순서가 보장되지 않는다는 단점이 있다.
비동기 처리에는 전통적으로 콜백 패턴을 사용한다. 이는 콜백 헬을 발생시켜 가독성을 해치고, 에러의 예외 처리를 곤란하게 한다.
자바스크립트는 싱글 스레드지만 브라우저 동작을 보면 여러 태스크가 동시에 처리되는 것처럼 느껴진다. 이는 이벤트 루프가 자바스크립트의 동시성을 지원하기 때문이다.
이벤트 루프는 브라우저에 내장되어 있는 기능 중 하나다.
구글의 V8을 비롯한 대부분의 자바스크립트 엔진은 크게 2가지 영역으로 구분된다.
콜 스택(call stack): 소스코드(전역 코드나 함수 코드 등) 평가 과정에서 생성된 실행 컨텍스트가 추가되고 제거되는 실행 컨텍스트 스택
힙(heap): 객체가 저장되는 메모리 공간으로, 콜 스택의 요소인 실행 컨텍스트는 힙에 저장된 객체를 참조한다. 객체는 원시 값과 달리 크기가 정해져있지 않으므로 할당해야 할 메모리 공간의 크기를 런타임에 결정(동적 할당)해야 한다. 그러므로 힙은 구조화 되어있지 않다는 특징이 있다.
콜 스택과 힙으로 구성된 자바스크립트 엔진은 태스크가 요청되면 콜 스택을 통해 순차적으로 작업을 실행할 뿐이다.
비동기 처리에서 소스코드의 평가와 실행을 제외한 모든 처리는(호출 스케줄링을 위한 타이머 설정, 콜백 함수의 등록 등)은 브라우저 또는 Node.js가 담당하고, 이를 위해 브라우저는 태스크 큐와 이벤트 루프를 제공한다.
태스크 큐(task queue): 비동기 함수의 콜백 함수 또는 이벤트 핸들러가 일시적으로 보관되는 영역.
이벤트 루프(event loop): 콜스택에 현재 실행 중인 실행 컨텍스트가 있는지, 태스크 큐에 대기 중인 함수(콜백 함수, 이벤트 핸들러 등)가 있는지 반복해서 확인한다. 콜 스택이 비어있고 태스크 큐에 대기 중인 함수가 있다면 순차적으로(선입선출) 함수를 콜 스택으로 이동시킨다.
function foo() {
console.log('foo');
}
function bar() {
console.log('bar');
}
setTimeout(foo, 0);
bar();
자바스크립트 엔진이 전역 코드를 평가한다.
전역 실행 컨텍스트가 생성 된다.
생성된 전역 실행 컨텍스트가 콜스택에 푸시된다.
전역 코드 실행이 시작되고, setTimeout
함수가 호출된다.
setTimeout
함수 실행 컨텍스트가 생성된다.
setTimeout 실행 컨텍스트가 콜스택에 푸시되어 현재 실행 중인 컨텍스트가 된다.
setTimeout
함수가 실행되어 콜백 함수를 호출 스케줄링하고,
종료되어 콜 스택에서 제거(팝)된다.
4-1과 4-2는 병행 처리된다.
4-1. 브라우저가 setTimeout
에 지정된 시간에 따라 타이머를 설정하고 만료를 기다린다.
만료되면 콜백 함수 foo
를 태스크 큐에 푸시한다.
이 때, 지정된 시간은 0이다. 지연 시간이 4ms 이하인 경우 4ms가 디폴트로 지정되므로 4ms후에 푸시된다.
4-2. bar
함수가 호출되어 bar
함수의 실행 컨텍스트가 생성된다.
콜스택에 푸시되어 현재 실행 중인 실행 컨텍스트가 된다.
bar
함수의 실행이 끝나고 종료되어 콜 스택에서 제거된다.
이 때 브라우저가 타이머를 설정한 후 4ms가 경과했다면 foo 함수는 태스크 큐에 푸시되어 대기 중일 것이다.
전역 코드 실행이 종료되고 전역 실행 컨텍스트가 콜 스택에서 제거된다.
이제 콜 스택은 비어있다.
이벤트 루프가 콜 스택이 비어있음을 감지하고 태스크 큐에서 대기 중인 콜백 함수 foo
를 콜 스택에 푸시한다.
foo
함수의 실행 컨텍스트가 생성되고 현재 실행 중인 실행 컨텍스트가 된다.
foo
함수가 종료되고 콜 스택에서 팝 된다.
즉 비동기 함수인 setTimeout
의 콜백 함수는 태스크 큐에 푸시되어 대기하다 전역 코드 및 명시적으로 호출된 함수가 모두 종료되고나면 콜 스택에 푸시되어 실행된다.
자바스크립트가 싱글 스레드 방식으로 동작한다는 말에서 동작의 주체는 자바스크립트 실행 환경(브라우저, Node.js)이 아닌 자바스크립트 엔진이다. 브라우저와 Node.js는 멀티 스레드로 동작하므로, 모든 자바스크립트 코드가 싱글 스레드로 동작하지 않는 것이다. 모든 자바스크립트 코드가 싱글 스레드로 동작하면 비동기 처리가 아예 불가능할 것이다.
🔗 모던 자바스크립트 딥 다이브 - 비동기 프로그래밍