javaScrip의 이벤트 루프(Event Loop)
2022년 09월 27일
이 포스터는 원티드 프론트엔드 강의중 알게된점을 기술했습니다.
또한 상당수의 내용이 강의중 정리해주신 글을 참고해서 작성했습니다.
javaScrip가 비동기 방식을 채택한 이유
현대의 소프트웨어는 기본적으로 여러 동작을 동시에 수행할 수 있게 설계됩니다. 현대 소프트웨어는 유저와 많은 상호작용을 수행하면서 동작하기에 한가지 동작을 수행하는 동안 다른 유저가 발생시키는 이벤트(클릭, 타이핑 등)에 제대로 응답해주지 못한다면 유저는 프로그램을 매끄럽게 사용하지 못한다는 느낌을 받게되고 결국 다른 대체재를 찾게 될 것입니다.
이처럼 현대 프로그램에서 여러 동작을 동시에 수행하는 "동시성"은 굉장히 중요하면서 필수적입니다. 이러한 동시성을 구현하기 위해서 각 프로그래밍 언어들은 자기만의 전략을 가지고 있는데 가장 대표적인 전략이 "멀티 쓰레딩"입니다.
멀티 쓰레딩이란 쉽게 말해 프로그램이 연산을 처리할 수 있도록 일꾼을 더 고용하는 것입니다. 쓰레드란 특정한 연산을 수행하는 일꾼으로 비유할 수 있습니다. 일꾼이 한명밖에 없다면 이 일꾼이 한가지 계산을 하는동안은 나머지 계산은 수행할 수 없게됩니다.
멀티 쓰레딩은 여기서 일꾼을 추가로 2명, 3명, 4명씩 고용해서 필요한 계산들을 각 일꾼들에 분배해서 수행하도록 하는 전략입니다.
이는 동시에 여러가지 작업을 효율적으로 수행할 수 있기에 기본적으로 프로그래밍 언어에서 가장 많이 사용하는 전략입니다.
대표적으로 Java가 이러한 멀티 쓰레딩 전략을 취하고 있으며, Go는 쓰레드를 만들고 관리하기가 편하다는 장점으로 유명세를 얻었습니다.
이러한 멀티스레딩 전략은 복잡한 연산들을 동시에 처리할 수 있다는 이점을 가지고 있지만, 그에 따른 단점도 존재합니다. 일단, 여러명의 일꾼을 고용한다는 것은 그만큼 그들을 잘 관리하기 위한 리소스가 많이 든다는 것입니다.
여러명의 일꾼이 같이 일을 할 때 발생할 수 있는 문제점들은 어떤것들이 있을까요? 일단 기본적으로 같은 리소스에 두명의 일꾼이 접근해서 발생하는 문제가 생길 수 있습니다. 가령 "계약서"라는 서류가 있는데 A라는 일꾼과 B라는 일꾼이 둘다 이 계약서가 필요하고 이를 수정한다고 했을 때 이 계약서를 필요한 순간에 맞춰서 적절하게 서로 잘 넘겨주도록 만들어야 할 것이며 특정한 상황에서는 A가 수정한 계약서의 내용때문에 B가 할 작업이 잘못되거나 영향을 받을수도 있습니다. 그리고, 만약 A와 B가 협업을 해야 한다고 했을 때 A가 특정한 작업을하고 B에게 넘겨주고, B가 다시 작업 후 A에 넘겨주고, A가 작업 후 B에 넘겨주고 이런 과정들이 반복적으로 많이 필요한 협업이라고 가정할때는 서로 작업물을 기다리고, 넘겨가면서 작업하는것보다 오히려 한명의 작업자가 전체 과정을 한번에 해버리는게 더 효율적일수도 있습니다.
이런 환경에서 효율적으로 작업을 조율하기 위해서는 매니저(개발자)는 각 일꾼들을 관리하고 조율하는데 많은 노력을 기울여야 할 것입니다. 이처럼 멀티스레딩 방식으로 동작하는 프로그램을 구현하기 위해서는 개발자가 멀티스레드 환경하에서 문제가 발생하지 않고 효율적으로 돌아갈 수 있도록 코드 작성단계에서부터 많은 주의를 기울어야하며, 이로 인해 프로그램의 복잡성이 올라간다는 단점을 지니고 있습니다.
자바스크립트 브라우저에서 실행되는 간단한 스크립트 언어로서 탄생했기에 멀티스레드와 같은 복잡성을 가지는 것은 적절하지 않았습니다. 따라서 자바스크립트는 싱글스레드로서 동작하도록 설계되었습니다. 하지만, 그렇다면 멀티스레드가 없을 때 발생할 수 있는 문제인 A라는 동작을 하는 동안 B라는 동작을 하지 못하게 된다. 라는 문제가 남아있습니다. 결국 자바스크립트는 싱글 스레드의 단순함을 유지하면서도 동시성을 지원하기 위해서 비동기 프로그래밍 방식을 채택하게 되었습니다.
비동기방식을 간단하게 설명하자면 일꾼(스레드)은 한명이지만, 이 일꾼이 외주업체를 사용할 수 있게 만든 방식입니다. 자바스크립트에서 일꾼은 한명입니다. 하지만 다소 시간이 오래걸릴 것 같은 작업이 일꾼에게 주어지면, 일꾼은 외주업체에 이 작업을 맡깁니다.
그리고 이 작업이 완료되었을 때 해야 할 일을 기록해둡니다. 그 다음, 일꾼은 다시 필요한 일들을 수행하다가 외주업체에서 작업을 완료했다는 연락을 받으면, 미리 기록해 둔 해야 할 일을 처리합니다. 이런 방식으로 애플리케이션을 설계하는 방법을 비동기 프로그래밍이라고 부릅니다. 외주업체를 통해서 동시에 일을 처리하긴 하지만, 이 외주업체가 어떤 방식으로 일을 하는지는 직접 관리하지 않습니다.
이미 이 외주업체는 신뢰도가 높다고 평가되었기때문에 그저 믿고 일을 맡긴 후 결과만 받아오는 방식으로 일을 합니다. 이런 방식을 통해서 매니저(개발자)는 신경써야 할 부분이 현저히 줄어듭니다. 매니저가 해야 할 일은 오로지 단 한명의 일꾼만 관리해주면 됩니다. 자바스크립트는 이러한 방식을 통해서 단 한명의 일꾼만을 가지고 있지만, 시간이 오래걸리는 작업들(HTTP, 유저의 카메라 접근, 유저의 파일에 접근)등의 동작을 수행하면서도 유저가 발생시키는 클릭, 타이핑등의 이벤트에 정상적으로 반응할 수 있게 만들어졌습니다.
javaScrip가 비동기를 구현한 방법 (event loop)
위 챕터에서는 비동기가 무엇이고, 어떤 개념인지에 대해서 알아보았습니다. 그렇다면 이제 실제로 자바스크립트 내부에서는 어떤 방식으로 비동기를 구현했는지에 대해 깊게 알아보겠습니다.
자바스크립트는 각 실행환경에 따라서 각자 다르게 동작하지만, 여기서는 가장 많이 사용되는 실행환경인 Node를 기준으로 설명하겠습니다.
자바스크립트는 비동기적인 동작을 관리하기 위해서 이벤트루프라는 개념을 사용합니다.
자바스크립트의 실행과정은 크게 2가지 요소들이 관여합니다.
- 자바스크립트 엔진(V8): 자바스크립트의 해석과 실행을 담당
- 이벤트루프: 비동기적인 동작들을 처리하고, 완료여부를 파악해서 필요한 동작을 수행
그리고 이벤트루프는 "큐"라는 시스템을 이용합니다. 큐는 FIFO(First In, First Out) 방식을 따르는 자료구조로, 식당의 웨이팅을 생각하면 됩니다. 먼저 줄을 선 사람이(First In), 가장 먼저 식당으로 들어가는 개념입니다(First Out) 이벤트 루프는 내부에 큐를 가지고 있으며(callback queue) 비동기적인 동작을 수행한 후, 완료되면 내부의 큐에 콜백함수를 담아둡니다. 그리고, 자바스크립트 엔진에서 처리할 준비가 되었다면 큐안에 있는 콜백함수를 하나씩 엔진에 넘겨줘서 실행합니다.
실제 코드를 보면서 비동기와 동기의 차이에 대해 알아보겠습니다.
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)실행- 더 이상 실행 할 코드가 없으니 프로그램 종료
위 사진을 토대로 설명해보자면 js는 v8을 통해서 읽고 해석이됩니다. 그리고 비동기 코드들은 node api 인 NODE.JS BINDINGS에 의해 옮겨지고 callback queue에 입력이 됩니다. setTiemout이 1초일 경우 1초뒤에 이벤트 루프에의해서 event queue에 쌓이게됩니다. 그리고 call stack이 비었을경우(동기 코드가 모두 완수되었을때) 해당 비동기 코드가 실제로 비동기 코드를 수행합니다.
여기까지가 일반적으로 이벤트루프를 공부할 때 학습하게 되는 내용입니다.
하지만 여기서 좀 더 많은 내용을 생각해 볼 수 있습니다. 먼저 노드는 I/O 등의 시간이 오래걸리는 동작들을 비동기로 처리해준다고 했고, setTimeout이나 입출력등의 함수들은 모두 이미 비동기로 만들어져있습니다. 그런데, 그렇다면 이 timeout이나 입출력등의 함수는 어떻게 비동기적으로 실행이 될 수 있을까요?
노드는 자바스크립트 실행 환경입니다. 이를 좀 더 자세히 알아보자면 자바스크립트 코드를 읽고 실행할 수 있는 엔진과 더불어 동작상에 필요한 여러가지 API들과 비동기 처리를 할 수 있는 추가적인 구성요소들을 포함한다는 의미입니다. 이 중 노드는 비동기 처리를 하기 위해 libuv라는 라이브러리를 사용합니다. 이 라이브러리는 C++로 작성되어있습니다.
libuv는 기본적인 전략으로 OS단에서 제공하는 API를 사용합니다. 따라서 OS에서 이미 비동기적으로 동작할 수 있는 API가 구현되어있다면 해당 API를 그대로 사용합니다. 하지만 만약 특정 동작을 OS에서 지원을 안해준다면 libuv가 내부적으로 가지고 있는 쓰레드들을 활용해서 해당 동작을 수행합니다. 즉, 자바스크립트가 싱글스레드라는 것은 개발자가 작성한 코드를 실행할 수 있는 스레드가 하나라는 것이지 노드 내부적으로는 멀티 스레드를 통해서 여러가지 동작을 수행하고 있습니다.
그리고 지금까지는 이벤트루프의 이해를 쉽게하기 위해 단순화해서 이벤트루프가 단 하나의 큐만가지고 있는것처럼 묘사했지만, 사실 노드의 이벤트루프는 내부에 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:
closeevent에 관련된 콜백
각 페이즈들에는 위에서 기술한 내용에 관련된 콜백들을 큐에 담고 있으며, 이벤트 루프는 한페이지씩 들리면서 각 페이즈의 큐에 있는 콜백을 수행합니다. 즉, 만약 같은 루프안에 타이머 페이즈와 클로즈 페이즈에 각각 콜백이 있다면 무조건 타이머 콜백이 먼저 수행된다는 의미입니다.
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를 먼저 수행합니다.