싱글 스레드 언어 Javascript
자바스크립트는 싱글 스레드 위에서 동작하는 언어이다. 이 말은 한 번에 하나의 작업만을 처리할 수 있다는 것을 뜻한다.
그런데 실제로 자바스크립트에서도 네트워크 요청, 파일 입출력 등의 무거운 작업들을 메인 스레드의 중단 없이 수행할 수 있다. 즉 자바스크립트에서도 동시성 프로그래밍이 가능하다는 뜻이다! 그렇다면 싱글 스레드인 자바스크립트에서 어떻게 이것이 가능한걸까?
동시성과 병렬성
먼저 동시성의 정의에 대해 살펴보고 병렬성과 구분해보고자 한다.
동시성(Concurrency)
- 여러 작업의 실행 구간이 겹치는 것
- ex) A 작업이 끝나기 전에 B 작업이 시작될 수 있음
병렬성(Parallelism)
- 여러 작업을 "실제로" 동시에 처리하는 것
동시성과 병렬성은 서로 배타적인 개념으로 보일 수 있지만, 사실 동시성은 구조적인 개념이고 병렬성은 그 구현 방식 중 하나이다. 동시성은 논리적으로 여러 작업이 서로 겹칠 수 있음을 의미하고, 병렬성은 그중에서도 실제로 동시에 실행되는 것을 말한다. 따라서 동시성은 병렬적으로 구현될 수도, context switching을 통해 동시에 실행되지 않지만 그렇게 보이도록 구현될 수도 있다.(ex. 코루틴).
자바스크립트의 동시성 구현 방식
본론으로 들어와서, 자바스크립트는 비동기 논블로킹 모델을 이용하여 싱글 스레드에서도 동시성을 구현한다. 어떤 작업을 비동기로 실행하여 메인 스레드를 멈추지 않으면서, 동시에 비동기 작업도 처리하는 방식이다. 이것을 가능하게 하는 핵심은 바로 런타임(Runtime) 에 있다.
자바스크립트 언어 자체는 비동기 처리 능력이 없으며, 런타임의 도움을 받아야만 한다.
실제적인 비동기 처리는 런타임에 의해 수행되고, 자바스크립트는 Promise, async/await 와 같은 비동기를 표현하는 인터페이스를 제공할 뿐이다.
비동기 작업을 처리하는 런타임 구성 요소
자바스크립트의 런타임으로 브라우저, Node.JS, Deno, Bun 등 여러가지가 존재한다. 이 런타임들은 모두 비동기 작업을 가능케 하는 요소들을 포함하고 있다. 그 중심에는 크게 이벤트 루프와 태스크 큐가 있다.
콜 스택(Call Stack): 프로그램의 실행 컨텍스트가 담기는 자료구조(JS 엔진에 포함)
태스크 큐(Task Queue): 비동기 작업 이후에 실행될 작업(콜백)이 담기는 자료구조
이벤트 루프(Event Loop): 콜 스택과 태스크 큐의 상호작용을 관리하는 주체
런타임이 비동기 처리를 수행하는 원리를 알아보기 위해, 함수 A에서 네트워크 요청(ex. fetch)을 보내는 상황을 생각해보자.
- 콜 스택의 함수 A 컨텍스트에서 네트워크 요청이 실행되면, 해당 작업은 런타임의 네트워크 스레드에서 처리된다.
- 비동기 작업이 완료되면 해당 작업의 콜백은 태스크 큐에 들어간다.
- 이벤트 루프는 콜스택이 비는 것을 확인하면 콜 스택으로 태스크를 이동시킨다.
이렇게 네트워크 스레드, 태스크 큐, 이벤트 루프와 같은 런타임 구성 요소들이 콜 스택을 블락하지 않으면서 백그라운드에서 작업이 처리될 수 있게 하는 것이다.
태스크 큐
태스크 큐에 대해 조금 더 설명하면, 태스크 큐는 세부적으로 매크로 태스크 큐와 마이크로 태스크 큐로 나뉜다. 비동기 작업의 종류에 따라 태스크(콜백)가 매크로 태스크 큐에 들어가냐, 마이크로 태스크 큐에 들어가냐가 결정된다.
- 매크로 태스크:
setTimeout,setInterval, I/O Callback, .. - 마이크로 태스크:
Promise,Observercallback, ..
태스크 우선순위
공통적으로 콜 스택이 비어야 각 태스크 큐의 작업들이 콜 스택으로 이동할 수 있다. 차이점은 태스크 처리 우선순위가 다르다는 것인데, 마이크로 태스크가 더 우선적으로 처리된다.
콜 스택이 비면 이벤트 루프는 먼저 마이크로 태스크 큐를 전부 비울 때까지 처리한다. 그 후 매크로 태스크를 하나 처리하고, 다시 마이크로 태스크 큐를 전부 비우는 과정을 반복한다.
이러한 차이는 마이크로 태스크들이 현재 작업의 연속적인 흐름으로 취급되도록 설계되었기 때문에 나타난다. 즉 하나의 마이크로 태스크가 처리되면, 나머지 마이크로 태스크까지 처리되어야 하나의 비동기 흐름이 끝났다고 보는 것이다.
Promise도 체인 전체를 하나의 연속된 작업으로 취급하기 위해 마이크로 태스크를 기반으로 설계되었다.
그러므로 Promise 체인이 실행되는 동안 다른 매크로 태스크의 실행이나 UI 렌더링이 발생하지 않음을 확신할 수 있다.
비동기 처리 패턴의 발전
비동기 작업이 완료되면 콜백 함수가 태스크 큐에서 콜 스택으로 들어와서 실행된다. ES6 이전에는 콜백 함수를 인자로 직접 전달하는 방식을 사용했다. 그러다보니 콜백 함수들의 중첩이 깊어지는 콜백 지옥(Callback hell) 이 발생했다.
js// callback hell getUser(id, (user) => { getPosts(user.id, (posts) => { getComments(post[0].id, (comments) => { // .. }) }); });
이 문제를 해결하기 위해 ES6에서 Promise가 등장하였고, 콜백 지옥 없이 비동기 작업을 체이닝하여 순차적인 흐름을 표현할 수 있게 되었다.
jsgetUser(id) .then(user => getPosts(user.id)) .then(posts => getComments(posts[0].id)) .then(comments => //.. );
async/await
ES8에서는 async/await이 등장하는데, 비동기 흐름을 동기 흐름처럼 보이게 하는 문법적 설탕(syntactic sugar) 이다.
await을 만난 순간 마치 동기적인 코드인 것 처럼 작업이 끝날 때 까지 await 이후의 코드이 실행이 지연되는 것이다.
그러나 동기 흐름인 것처럼 보이게 할 뿐, 실제로는 비동기로 수행되는 것이므로 메인 스레드는 블락되지 않는다.
이것을 가능하게 하기 위해 자바스크립트는 내부적으로 Promise와 Generator를 활용한다.
jsasync function test() { console.log(1); await Promise.resolve(); console.log(2); } // 위 코드는 내부적으로 이 코드와 유사하게 동작 function test() { return new Promise(resolve => { const gen = (function* () { console.log(1); // 1. Generator가 Promise를 외부로 던짐 yield Promise.resolve(); console.log(2); })(); function step(result) { if (result.done) return resolve(result.value); // 2. step 함수가 Promise를 받아 then에 등록 // 3. resolve되면 next를 호출하여 작업 재개 result.value.then(val => step(gen.next(val))); } step(gen.next()); }); }
async 함수에서 await을 만나면, 해당 함수의 컨텍스트는 즉시 콜스택에서 제거된다.
이 동작은 실제 제너레이터의 동작과 같아서, 제어권을 즉시 메인 스레드로 넘기고 남은 작업들은 제너레이터 객체 내부에 저장한다.
그리고 yield한 Promise가 resolve되면 나머지 작업들이 마이크로 태스크 큐로 들어갔다 콜 스택으로 돌아와 다시 실행되는 것이다.
즉 await은 "Promise를 yield하고, resolve되면 자동으로 next()를 호출해" 의 추상화라고 볼 수 있다.
마무리
자바스크립트가 싱글 스레드임에도 어떻게 동시에 여러 작업을 처리할 수 있었는지 살펴보았다.
그리고 그 비결은 자바스크립트 자체가 아닌 브라우저, Node와 같은 런타임이라는 것을 확인하였다.
Promise와 async/await 같은 문법은, 비동기를 쉽게 다룰 수 있는 인터페이스일 뿐이다.