1. 자바스크립트가 비동기를 구현한 방법
- 자바스크립트는 비동기적인 동작을 관리하기 위해서 "이벤트루프"라는 개념을 사용
- 자바스크립트의 실행과정은 크게 2가지 요소들이 관여
자바스크립트 엔진 (V8)
- 자바스크립트의 해석과 실행을 담당
이벤트루프 (Event loop)
- 비동기적인 동작들을 처리하고, 완료여부를 파악해서 필요한 동작을 수행
- "큐"라는 시스템을 이용한다. 큐는 FIFO(First In, First Out) 방식을 따르는 자료구조로, 먼저 줄을 선 사람이(First In), 가장 먼저 입장 개념이다 (First Out).
- 이벤트 루프는 내부에 큐를 가지고 있으며 비동기적인 동작을 수행한 후, 완료되면 내부의 큐에 콜백함수를 담아둔다. 그리고, 자바스크립트 엔진에서 처리할 준비가 되었다면 큐안에 있는 콜백함수를 하나씩 엔진에 넘겨줘서 실행
코드를 통한 비동기와 동기의 차이 알아보기
function delay(ms){
const start = Date.now();
let now = start;
while(now - start < ms){
now = Date.now()
}
}
console.log("Hello, ");
delay(1000);
console.log("World")
위 코드에서 delay 함수는 동기적으로 실행되는 코드다. 그리고 인자로 주어진 ms만큼 자바스크립트이 동작을 딜레이시키는 동작을 수행한다. 이 코드를 실행하면, Hello, 라는 글자가 출력되고 나서 1초가 지난 후 World라는 글자가 출력된다.
이러한 방식은 자바스크립트의 동작방식이 Run To Completion 방식이기 때문인데, 자바스크립트는 기본적으로 하나의 동작이 완료되어야지만 다음 동작으로 이어지게 되어있다.
- console.log("Hello, ")
- delay(1000)
- console.log("World")
이 순차적으로 앞의 동작이 끝날때까지 대기 후, 다음 동작을 수행하게 되는 것이다. 그런데 이런방식으로 동작할 경우 delay와 같은 처리가 오래 걸리는 동작이 전체 프로그램의 동작을 막는 문제가 발생한다.
이러한 문제를 해결하기 위해서 오랜 시간이 소요되는 동작들을 모두 이벤트루프에 위임해버리는 방식을 취할 수 있다. 다만, 이벤트루프에게 동작이 완료되고 나면 다시 연락을 받고, 어떤 처리를 해야하는지 알려줘야한다. 이를 위해 이벤트 루프에 동작을 위임할때는 콜백함수를 통해서 완료된 후 어떤 처리를 해야하는지 함께 전달해줘야한다.
- 이벤트루프를 이용한 코드
function delay(ms){
const start = Date.now();
let now = start;
while(now - start < ms){
now = Date.now()
}
}
console.log("Hello, ");
setTimeout(() => delay(1000), 0);
console.log("World")
이 코드는
- Hello,
- World
순으로 콘솔이 출력되고 1초 후에 프로그램이 종료된다.
이 코드는 중간에 delay가 있는 건 동일하지만 setTimeout을 통해서 delay 함수를 실행시켰다.
내부를 뜯어보자면, delay는 바로 실행된 것이 아닌 setTimeout을 통해서 실행되었다. setTimeout은 우리가 만든 함수가 아니라, 노드차원에서 제공해주는 함수이며 내부적으로 비동기적으로 동작하도록 설계되어있다. 즉, 비동적인 처리를 한 후, 완료되면 첫번째 인자로 전달된 콜백이 실행되도록 만들어 진 것이다. 따라서 이 코드는 아래와 같이 동작한다.
- console.log("Hello, ") 실행
- setTimout 실행
- console.log("World") 실행
- setTimeout 의 callback 함수인 () => delay(1000) 실행
- 더 이상 실행 할 코드가 없으니 프로그램 종료
여기서 좀 더 많은 내용을 생각해 볼 수 있다. 먼저 노드는 I/O 등의 시간이 오래걸리는 동작들을 비동기로 처리해준다고 했고, setTimeout이나 입출력등의 함수들은 모두 이미 비동기로 만들어져있다. 그렇다면 이 timeout이나 입출력등의 함수는 어떻게 비동기적으로 실행이 될 수 있을까?
노드는 자바스크립트 실행 환경이다. 좀 더 자세히 알아보자면 자바스크립트 코드를 읽고 실행할 수 있는 엔진과 더불어 동작상에 필요한 여러가지 API들과 비동기 처리를 할 수 있는 추가적인 구성요소들을 포함한다는 의미다. 이 중 노드는 비동기 처리를 하기 위해 libuv라는 라이브러리를 사용한다. 이 라이브러리는 C++로 작성되어있다.
libuv는 기본적인 전략으로 OS단에서 제공하는 API를 사용한다. 따라서 OS에서 이미 비동기적으로 동작할 수 있는 API가 구현되어있다면 해당 API를 그대로 사용한다. 하지만 만약 특정 동작을 OS에서 지원을 안해준다면 libuv가 내부적으로 가지고 있는 쓰레드들을 활용해서 해당 동작을 수행한다. 즉, 자바스크립트가 싱글스레드라는 것은 개발자가 작성한 코드를 실행할 수 있는 스레드가 하나라는 것이지 노드 내부적으로는 멀티 스레드를 통해서 여러가지 동작을 수행하고 있습니다.
이벤트 루프 내부의 6개 페이즈
지금까지는 이벤트루프의 이해를 쉽게하기 위해 단순화해서 이벤트루프가 단 하나의 큐만가지고 있는것처럼 묘사했지만, 사실 노드의 이벤트루프는 내부에 6개의 "페이즈"를 가지고 있으며, 페이즈들을 계속해서 돌아가면서 Loop를 돈다. 그리고 각 페이즈들은 각자 자기만의 큐를 가지고 있다.
이벤트루프 내부에는
- timers
- pending Callbacks
- idle, prepare
- poll
- check
- close callbacks
6개의 페이즈가 존재하며, 이벤트루프는 수행할 작업이 남아있는 한 이 페이즈들을 계속해서 순회한다. 그리고 페이즈들은 각기 자신만의 콜백 큐를 가지고 있다. 이 콜백 큐에는 실행되어야 할 콜백함수들이 담겨있으며, 이벤트루프는 각 페이즈들을 확인하면서 큐에 있는 콜백을 실행한다. 이때 큐에 있는 콜백이 모두 실행되거나, 최대 실행 한도에 다다르면 다음 페이즈로 이동합니다. 이렇게 다음 페이즈로 이동하는 것을 "틱"이라고 부른다.
각 페이즈들이 담당하고 있는 동작은 다음과 같다.
- timers: setTimeout, setInterval
- pending Callbacks: 일부 시스템 오퍼레이션에서 발생한 에러 콜백, 실행한도를 넘어간 콜백
- idle, prepare: 내부적인 동작 수행
- poll: I/O callback
- check: setImmediate 콜백
- close callbacks: close event에 관련된 콜백
각 페이즈들에는 위에서 기술한 내용에 관련된 콜백들을 큐에 담고 있으며, 이벤트 루프는 한페이지씩 들리면서 각 페이즈의 큐에 있는 콜백을 수행한다. 즉, 만약 같은 루프안에 타이머 페이즈와 클로즈 페이즈에 각각 콜백이 있다면 무조건 타이머 콜백이 먼저 수행된다는 의미이다.
function delay(ms){
const start = Date.now();
let now = start;
while(now - start < ms){
now = Date.now()
}
}
console.log("Hello, ");
setTimeout(() => delay(1000), 0);
console.log("World")
코드의 동작을 좀 더 자세히 분석하자면
- Hello console 출력
- setTimeout 호출, 비동기 처리를 libuv가 수행하도록 위임
- World console 출력
- 이벤트 루프 확인
- timer phase에 callback queue 확인
- ()=>delay(1000) 함수 발견 후 실행
- 더 이상 이벤트루프 안에 수행할 동작이 없기에 프로세스 종료
위의 순서대로 동작이 수행된다.
그리고 노드는 여기에 덧붙여 nextTickQueue, microTaskQueue 를 추가로 더 관리한다. 이 큐들은 각각 process.nextTick 의 콜백과, Promise 의 콜백을 관리한다.
- nextTickQueue: process.nextTick 의 콜백
- microTaskQueue: Promise 의 콜백
이 두개의 큐가 이벤트 루프의 페이즈들이 관리하는 큐와 다른점은 이들이 우선순위가 더 높다는 것이다. 이 두가지 큐들은 페이즈와 관계없이 지금 현재 수행하고 있는 작업이 완료되고 나면 무조건 이 큐들을 확인한다. 그리고 이 둘중에서는 nextTickQueue가 microTaskQueue보다 우선순위가 높기에 nextTickQueue를 먼저 수행한다.
console.log("start"); // 1
setImmediate(() => console.log("immediate")); // 2
process.nextTick(() => console.log("tick")); // 3
Promise.resolve().then(() => console.log("promise")); // 4
console.log("end"); // 5
이 코드의 출력 결과는 어떻게 수행되는지 확인해보자
- 정답은
- start
- end
- tick
- promise
- immediate
- 먼저 start 콘솔이 출력
- 그 과정에서 setImmediate 로 인해 timer phase에 immediate를 출력하는 콜백이 담김
- 그리고 process.nextTick 으로 인해 nextTickQueue에 tick을 출력하는 콜백이 담김
- promise.resolve.then 으로 인해 microTaskQueue에 tick을 출력하는 콜백이 담김
- end 콘솔이 출력
- 현재 실행중인 작업이 종료되었고, 따라서 nextTickQueue를 확인 후, 담겨져있는 콜백을 실행
- 그 다음 microTaskQueue를 확인 후 담겨져 있는 콜백을 실행
- timer phase를 확인 후 담겨져 있는 콜백을 실행
- 나머지 페이즈들을 돌면서 남아있는 콜백 및 실행중인 비동기 작업이 없는 것을 확인 후 최종적으로 이벤트루프가 종료
그렇다면 아래의 코드는 어떻게 동작할지 생각해보자.
function run() {
console.log("run");
process.nextTick(run);
}
console.log("start"); // 1
setImmediate(() => console.log("immediate")); // 2
process.nextTick(run); // 3
Promise.resolve().then(() => console.log("promise")); // 4
console.log("end"); // 5
- 정답은
이 코드는 start와 end 콘솔이 출력된 후 계속해서 run 콘솔만 출력하는 무한루프에 빠지게 된다.
※ 자바스크립트는 각 실행환경에 따라서 각자 다르게 동작하지만, 가장 많이 사용되는 실행환경인 Node를 기준으로 정리
2. Callback
초창기에 자바스크립트 개발자들은 이벤트루프를 이용해서 비동기 프로그래밍을 해야했다. 그리고 처음에 코드를 작성하는 방식은 이벤트루프의 방식이 그대로 코드에 드러나도록 작성했다. 비동기 함수를 호출할 때 동작이 완료될 시 실행될 콜백함수를 인자로 넘기는 방식이었다.
setImmediate(() => console.log("callback"));
하지만 이런 방식은 비동기 처리가 계속해서 이어지는 패턴에서는 callback의 callback이 계속해서 중첩되면서 가독성이 안좋아지고 결국 프로그램의 동작을 이해하기 어렵게 만드는 단점이 있었다.
이렇게 콜백함수가 반복되어 가독성이 떨어질 정도로 깊어지는 현상을 콜백 지옥(Callback Hell) 이라고 한다.
listen("click", () => {
dataFetch("~~~", () => {
handleResponse(() => {
if (condition) {
doSomeThing();
} else {
doSomeThingElse();
}
});
});
});
'Language > JavaScript' 카테고리의 다른 글
Promise & Async Await (0) | 2022.11.10 |
---|---|
비동기에 대해서 (0) | 2022.11.08 |
Iterator & Generator (0) | 2022.10.29 |
Javascript ES6 전체적으로 살펴보기 (0) | 2022.06.27 |
[JavaScript] 유사 배열 객체(Array-like objects) (0) | 2022.03.23 |