function test1() {
console.log(1);
}
function test2() {
console.log(2);
}
function test3() {
console.log(3);
}
function test4() {
console.log(4);
}
test1()
Promise.resolve().then(test2);
setTimeout(test3, 0);
test4()
위 코드의 실행 결과는 어떻게 될까?
setTimeout
에서 타이머가 0이니까 1, 2, 3, 4로 출력될까?
1️⃣ 싱글 스레드
JS는 한 번에 하나의 작업만 실행할 수 있다. 다시 말해 단 하나의 실행 컨텍스트 스택을 갖는다.
이러한 JS의 특징은 JS가 "싱글 스레드" 방식이라는 것이다.
한 함수의 처리에 시간이 오래 걸리게 된다면 블로킹(작업 중단)이 발생할 것이다.
2️⃣ 비동기 처리
싱글 스레드 방식으로 동작한다는 특성 때문에 많은 블로킹이 발생할 수 있다.
아래 예제를 보자.
function test1() {
console.log(1);
}
function test2() {
console.log(2);
}
setTimeout(test1, 2000); // setTimeout은 ms단위를 사용한다. 2000ms = 2s
test2();
// 2 -> (2초 뒤) -> 1
만약, 싱글 스레드를 사용한다면 setTimeout
에 의해 2초간 실행이 멈추고 1이 출력되어야 한다.
하지만, 결과를 직접 확인해보면 2가 바로 출력되고 2초 뒤 1이 출력되는 것을 확인할 수 있다.
이러한 현상이 발생한 이유는 setTimeout
이 종료되기 전에 test2가 실행되었기 때문이다.
위와 같이 현재 실행 중인 작업이 종료되지 않은 상태라 해도 다음 작업을 곧바로 실행하는 방식을 "비동기 처리"라 한다.
비동기 처리의 장점은 블로킹이 발생하지 않는다는 것이다. 하지만, 작업의 실행 순서가 보장되지 않는다는 단점이 있다.
비동기 함수는 일반적으로 콜백 패턴을 사용한다.
하지만, 콜백 패턴은 가독성을 나쁘게 하고 비동기 처리 중 발생한 에러의 예외 처리가 곤란한 등 여러 한계가 있다.
JS에서 비동기적으로 동작하는 기능의 예시는 setTimeout
, setInterval
, HTTP, 이벤트 핸들러가 있다.
3️⃣ 이벤트 루프
JS는 싱글 스레드로 동작하지만 동시성을 지원하기 위해 이벤트 루프라는 것을 사용한다.
이벤트 루프는 JS엔진의 구동 환경 (Node.js, 브라우저)에 포함된 기능 중 하나다. (이하, 구동환경)
JS 엔진 (대표적으로 구글의 V8)은 크게 두 영역으로 나눌 수 있다.
- 콜 스택
- 함수를 호출하면 함수 실행 콘텍스트가 순차적으로 콜 스택에 푸시되어 스택 자료구조 형식으로 작동한다.
- 가장 위에 있는 실행 컨텍스트가 종료되어 스택에서 제거될 때 까지 다른 어떤 작업도 실행되지 않는다.
- 힙
- 객체가 저장되는 메모리 공간
- 실행 컨텍스트는 힙에 저장된 객체를 참조한다
결과적으로 JS 엔진은 작업이 요청되면 콜 스택을 통해 작업을 순차적으로 실행한다.
비동기 처리를 위한 다양한 처리 과정은 구동환경에서 담당한다.
예를 들어, setTimeout
의 콜백 함수의 평가와 실행은 JS 엔진이 담당하지만 타이머 설정은 구동환경에서 담당한다.
이를 위해 구동 환경은 두 가지를 제공한다.
- 태스크 큐
- 비동기 함수의 콜백 함수 또는 이벤트 핸들러가 보관된다.
- 이벤트 루프
- 콜 스택을 확인하여 현재 실행중인 실행 컨텍스트가 있는지, 태스크 큐에 대기 중인 함수가 있는지 반복적으로 확인
- 만약 콜 스택이 비어있고 태스크 큐에 대기 중인 함수가 있다면 이벤트 루프는 FIFO 방식으로 태스크 큐에 있는 함수를 콜 스택으로 이동시킨다. 이때 콜 스택으로 이동한 함수는 바로 실행된다.
만약, 타이머 설정과 같은 작업도 JS 엔진이 담당한다면 싱글 스레드로 작동하는 JS 엔진의 특성에 따라 타이머의 만료까지 어떤 작업도 수행되지 않을 것이다.
(모든 코드가 동기적으로 작동한다)
4️⃣ 마이크로 태스크 큐
setTimeout
의 콜백 함수가 태스크 큐에 보관되는 것과 달리 프로미스의 콜백 함수는 태스크 큐가 아니라 마이크로 태스크 큐에 저장된다.
마이크로 태스크 큐는 태스크 큐와는 다르다.
요약하자면, 프로미스의 콜백 함수는 마이크로 태스크 큐에 보관되고 그 외의 비동기 함수의 콜백 함수나 이벤트 핸들러는 태스크 큐에 보관된다.
또한, 마이크로 태스크 큐는 태스크 큐보다 우선순위가 높다.
이벤트 루프는 마이크로 태스크 큐 또는 태스크 큐에 있는 함수를 콜 스택에 넣고 실행한다.
하지만, 마이크로 태스크 큐를 먼저 확인하고 마이크로 태스크 큐가 비어있다면 태스크 큐에서 함수를 가져와 실행한다.
💡 결론
function test1() {
console.log(1);
}
function test2() {
console.log(2);
}
function test3() {
console.log(3);
}
function test4() {
console.log(4);
}
test1()
Promise.resolve().then(test2);
setTimeout(test3, 0);
test4()
// 1, 4, 2, 3순으로 출력
위의 예제 출력 결과는 1, 4, 2, 3
이다.
프로그램이 시작되며 전역 실행 컨텍스트가 생성되고 콜 스택에 푸시된다.
test1
에 대한 함수 실행 컨텍스트가 생성되고 콜 스택에 푸시되어 실행된다.
다음으로 프로미스에 대한 실행 컨텍스트가 생성된 후 콜 스택에 푸시되어 실행된다.
이때, 프로미스의 콜백 함수인 test2
가 마이크로 태스크 큐에 푸시된다.
이후, setTimeout
함수가 호출되어 setTimeout
함수의 함수 실행 컨텍스트가 생성되고 콜 스택에 푸시된다.
구동환경은 setTimeout
에 대한 타이머를 설정하고 시간이 만료되면 콜백 함수를 태스크 큐에 푸시한다.
test4
에 대한 함수 실행 컨텍스트가 생성되고 콜 스택에 푸시되어 test4가 실행된다.
test4
의 종료와 함께 전역 실행 컨텍스트가 콜 스택에서 제거된다.
이때, 콜 스택은 비게 되고 이벤트 루프는 마이크로 태스크 큐에 있는 test2
를 콜 스택에 푸시한다.
test2
가 실행되고 태스크 큐에 있는 test3
이 콜 스택에 푸시되어 실행된다.
따라서, 위의 예제 출력 결과는 1, 4, 2, 3
이 된다.
[참고자료]
위키북스, 모던 자바스크립트 Deep Dive, 2020
'개발 > JavaScript' 카테고리의 다른 글
[JavaScript] addEventListener과 onclick의 차이 (0) | 2023.09.03 |
---|---|
[NodeJS] 싱글 스레드와 이벤트 루프 (0) | 2023.07.29 |
[JavaScript] 함수 (0) | 2023.02.02 |
[JavaScript] 원시 값과 객체 (0) | 2023.01.21 |
[JavaScript] 객체 (0) | 2023.01.07 |