자바스크립트는 싱글스레드 방식으로 동작하는 언어이지만 비동기 처리 방식을 지원한다.
자바스크립트의 동작 방식을 간단히 요약하자면 위와 같다. 이 문장에서 '싱글스레드'와 '비동기' 라는게 무엇일까?
이에 대해 우선 간단히 알아보고 자바스크립트가 어떤 식으로 동작하는지, 어떻게 비동기 처리가 가능한지에 대해 정리해보기 📋
그리고 자바스크립트에서의 "비동기 처리"의 의미가 무엇인가? 에 대해 알아보는게 목표!
이지만 개념 정리하다보니 글이 길어져서.. 결론은 마지막 파트에 ㅎ
프로그래밍 언어의 동작 방식은 싱글스레드와 멀티스레드로 구분될 수 있다. 여기서 스레드(thread)를 쉽게 비유하자면 일하는 사람이라고 보면 되는데 싱글스레드는 일하는 사람이 한명, 멀티스레드는 일하는 사람이 여러명이라는 뜻이다.
따라서 싱글스레드는 주어진 작업을 하나씩 처리해야 하지만, 멀티스레드는 주어진 작업을 한번에 여러 단위씩 병렬적으로 처리가 가능하다.
이렇게 일상으로 비유해보면 당연히 멀티스레드가 무조건 더 좋은 거 같지만 프로그래밍의 관점에선 마냥 그렇지는 않다.
싱글스레드는 여러명이 작업하는 병렬 처리인 멀티스레드보다는 작업 효율이 평균적으로 떨어지지만 경쟁 상태와 교착 상태가 없고(비동기 작업을 동반하는 js의 경우는 위험이 있음) 코드 복잡도가 비교적 낮다.
반면 멀티스레드는 평균적으로 싱글스레드보다 작업 효율이 좋지만 경쟁 상태, 교착 상태를 고려해야 하며 코드 복잡도가 높다.
프로그래밍 언어의 동작 방식은 또한 동기와 비동기로 구분될 수 있다.
동기(Synchronous)는 작업을 순차적으로 실행하는 방식이다. 하나의 작업을 마치고 나서 다음 작업을 실행하는 순차적 실행 방식으로, 작업이 길어지면 다음 작업이 지연되는 단점이 있다.
즉 하나의 작업 실행이 다른 작업의 실행을 막게(block) 되는 흐름으로 인해, 자바스크립트에서는 블로킹(blocking) 이라는 용어와 혼용하기도 한다.
비동기(Asynchronous)는 여러 작업을 동시에 실행하는 방식이다. 특정 작업의 완료를 기다리지 않고 다른 작업을 같이 실행하는 병렬적 처리가 가능하다. 하나의 작업이 길어져도 다음 작업의 수행을 막지 않기 때문에 자바스크립트에서는 논블로킹(non-blocking) 이라는 용어와 혼용하기도 한다.
동기/비동기와 블로킹/논블로킹은 엄밀히 말하면 다른 개념이지만 자바스크립트에서는 두 용어를 혼용하기도 하는듯 합니다.
동기/비동기, 블로킹/논블로킹의 차이점 참고 : https://dokit.tistory.com/35
기술적으로 비동기 방식은 싱글스레드 만으로는 불가능하다. 그래서 비동기 처리 방식을 지원하는 자바스크립트는 멀티스레드일 것 같지만 싱글스레드 방식의 언어이다.
자바스크립트는 싱글스레드 방식으로 동작하는 언어이지만 싱글스레드 상황에서 성능을 높이기 위해 비동기(non-blocking, asynchronous) 처리 방식을 지원하는데, 이로 인해 자바스크립트는 특유의 이상한 실행 사이클이 있다.
자바스크립트의 코드가 실행되면 다음과 같은 사이클을 거친다.
이러한 실행 환경을 구성하는 4개의 요소를 살펴보며 전체적인 흐름을 정리해보자.
자바스크립트 코드를 읽어서 해석하고 작업을 수행하는 역할을 한다. chrome의 V8, ie의 Chakra, firefox의 SpiderMonkey, safari의 JavaScriptCore 등이 있다.
자바스크립트 엔진은 크게 Memory Heap과 Call Stack 으로 이루어져있다. Memory Heap은 자바스크립트 코드에서 필요한 메모리 할당이 일어나는 장소이고 중요한건 Call Stack 인데, Call Stack은 코드가 실행될 때 함수가 실행됨에 따라 실행 컨텍스트들이 push 되는 스택이라고 보면 된다.
실행되는 자바스크립트 함수들이 Call Stack에 쌓이고 Call Stack에 쌓인 작업들을 하나하나 처리(pop)하며 자바스크립트 코드가 실행되게 된다. 즉 자바스크립트 엔진 자체는 작업을 동기로 수행한다.
하지만 콜스택에 쌓이는 작업들과는 별개로, JS 엔진이 아닌 다른 곳에서 별도로 실행되는 함수들이 있다. 대표적인 예로 웹 브라우저에서 제공하는 Web API가 있다.
Web API는 타이머, 네트워크 요청(통신), 이벤트 처리 등 브라우저에서 제공하는 API들을 총칭하는 것으로, Web API의 예시로는 다음과 같은 함수들이 있다.
💡 Web API
- DOM API (document.querySelector 와 같은 DOM 조작 api들)
- AJAX (XMLHttpRequest 등)
- Timer API (setTimeout, setInterval 등)
이런 함수들은 자바스크립트에서 평소에도 자주 사용하는 함수들이지만 사실 JS에 내장되어 있는 기능이 아닌 웹 브라우저에서 제공하는 API이다. 그래서 JS 엔진이 실행시킬 수가 없기 때문에 웹 브라우저가 대신 실행해주어야 한다.
따라서 JS는 이런 Web API 작업을 만나면 콜스택에 push 하지 않고 브라우저로 실행을 위임한다.
그래서 이런 애들은 JS 엔진이 아닌 별도의 공간에서 따로 실행되기 때문에 JS 엔진의 콜스택에 쌓인 작업들이 처리되고 있는 동안 동시에 같이 처리가 될 수 있는 함수들이고, 이런 애들이 바로 자바스크립트에서 비동기적으로 실행되는 함수들이다.
이런 Web API는 브라우저에서 멀티 스레드로 구현되어 있다. 예를 들어 setTimeout 함수가 실행되면 setTimeout의 타이머 동작은 브라우저의 Web API의 한 종류인 Timer API에서 타이머 스레드를 사용하여 수행된다.
그래서 브라우저는 비동기 작업에 대해 메인 스레드를 차단하지 않고 다른 스레드를 사용하여 동시에 처리할수 있는 것이다.
흐름도의 Web APIs 공간에 있는 DOM, AJAX, setTimeout 이미지가 이런 각각의 API에 해당하는 스레드를 표현한 것이다.
Web API의 인자로 지정된 콜백함수가 쌓이는 곳이 큐(Queue)라고 생각하면 되는데, Web API중 하나인 setTimeout 함수의 예시로 살펴보자.
console.log('stack 1');
console.log('stack 2');
setTimeout(() => {
console.log('setTimeout callback');
}, 3000);
console.log('stack 3');
이 코드를 실행했을 때 동기적으로 생각하자면 console.log('stack 1') -> console.log('stack 2') -> setTimeout(...) -> console.log('stack 3') 순으로 콜스택에 쌓일 거 같지만 setTimeout 함수는 웹 브라우저에서 제공하는 Web API이기 때문에 js 엔진 콜스택에 쌓이지 않는다.
js 엔진은 console.log('stack 1') -> console.log('stack 2') 순으로 콜스택에 push 한 뒤 setTimeout 함수는 웹 브라우저(흐름도에서 Web APIs)에게 보내고 console.log('stack 3') 을 push 한다.
따라서 콜스택에 쌓인 작업들과는 별개로 웹 브라우저에서 setTimeout의 이벤트가 수행된다.
이 때, setTimeout의 이벤트란 두번째 인자로 지정한 시간만큼 대기하는 것이다. 브라우저에서는 그 시간을 count 하는 동작이 실행되고, 그 시간이 지나면 첫번째 인자로 지정된 콜백함수를 큐(흐름도 상에서의 Callback Queue)로 보내게 된다.
이렇게 Web API의 이벤트가 실행된 후에 수행되도록 지정된 콜백함수가 쌓이는 곳이 큐(Queue)이다.
💡 Queue 의 종류
Queue는 브라우저에서는 Task Queue(Macrotask Queue)와 Microtask Queue 로 나뉘는데(node 에서는 더 여러 종류가 있다고 함) 여기서는 그냥 흐름도와 같이 Callback Queue 로 통칭.
Task Queue(Macrotask Queue)와 Microtask Queue에 대해 자세히 알고싶다면
https://velog.io/@titu/JavaScript-Task-Queue%EB%A7%90%EA%B3%A0-%EB%8B%A4%EB%A5%B8-%ED%81%90%EA%B0%80-%EB%8D%94-%EC%9E%88%EB%8B%A4%EA%B3%A0-MicroTask-Queue-Animation-Frames-Render-Queue 참고!
자바스크립트 코드가 비동기적으로 실행될 수 있도록 해주는 핵심 요소이다.
이벤트 루프는 JS 엔진의 콜스택과 콜백 큐를 감시하다가 콜스택이 비었을 때 콜백 큐에 존재하는 함수를 꺼내 콜스택으로 넣어주는 역할을 한다.
위의 setTimeout 예시로 보면, Web API가 setTimeout의 이벤트인 waiting 동작을 수행하고 나면 setTimeout의 콜백함수가 콜백 큐로 이동된다. 그 후 콜스택의 모든 작업이 수행되어 콜스택이 비게 되면 이벤트 루프가 콜백 큐 내에 있는 setTimeout의 콜백함수를 콜스택으로 이동시킨다. 그래서 JS 엔진에 의해 setTimeout의 콜백함수가 실행되게 된다.
이러한 과정으로 인해
console.log('stack 1');
console.log('stack 2');
setTimeout(() => {
console.log('setTimeout callback');
}, 3000);
console.log('stack 3');
이 코드는 stack 1 -> stack 2 -> stack 3 -> setTimeout callback 의 순서로 콘솔창에 찍히는 결과가 나오는 것이다.
💡 (참고) setTimeout 의 두번째 인자로 지정하는 지연 시간은 reliable 하지 않다.
setTimeout의 콜백 함수는 딱 지정한 시간 이후에 콜백 큐에 들어가지만, 콜백 큐에 있는 애들은 JS 엔진의 콜스택이 완전히 비어야만 이벤트 루프에 의해 콜스택으로 올려지는데 콜스택의 상태가 그때그때 어떨지 모른다. 그리고 콜스택은 자바스크립트 코드가 실행되는 유일한 메인 스레드 이므로, 보통 한가한 경우보다는 바쁜 경우가 훨씬 많을 것이다.
따라서 setTimeout의 waiting time은 최소 지연시간으로 보는게 맞을 것이다.
이렇게 이벤트 루프로 인해 자바스크립트는 싱글스레드 임에도 불구하고 특정 작업들은 비동기적으로 실행될 수 있다는 특징을 가진다.
이런 이벤트 루프를 기반으로 한 비동기 방식으로, 자바스크립트는 프로그램의 실행이 이벤트에 의해 결정된다.
위에서 살펴본 setTimeout(callback, time)의 경우를 보면 time 만큼의 시간이 지나는 이벤트는 브라우저에서 처리가 되고 완료되면 callback 으로 지정된 콜백함수가 콜백 큐로 보내져 콜스택이 비었을 때 올려져 동작이 실행된다.
또 다른 예시로 addEventListener(event, callback)의 경우는 event에 해당하는 이벤트가 발생하는지 감지하는 동작이 브라우저에서 실행되다가 이벤트가 발생하면 callback 으로 지정된 콜백함수가 콜백 큐로 보내지고 콜스택이 비었을 때 올려져 동작이 실행된다.
(만약 addEventListener가 비동기로 동작하는 함수가 아니었다면 지정된 이벤트가 발생할 때 까지 콜스택을 차지하여 다음 동작이 수행될 수 없었을 것이다)
이렇게 어떠한 지정된 이벤트가 발생하면 그에 맞는 콜백함수가 실행된다. 이렇게 프로그램의 흐름이 이벤트에 의해 결정되는 방식을 이벤트 기반(Event Driven) 프로그래밍 이라고 한다.
이런 흐름을 보고 곰곰히 생각해보면, 자바스크립트의 비동기는 우리가 비동기 방식을 원해서 비동기적으로 실행되는게 아니다. Web API 와 같이 그냥 비동기적으로 처리가 되도록 정해진 작업이 따로 있는 거고, 이런 작업들은 개발자가 원하든 말든 무조건 그냥 비동기 방식으로 처리가 되는 것일 뿐이다.
또 setTimeout을 잠깐 들먹이자면, 위에서 말했다시피 setTimeout의 기본 동작은 지정된 시간만큼 기다리는 것이다. 지정된 시간 후에 어떠한 동작을 실행하는게 아니라 그냥 지정된 시간만큼 기다리는 것이다. 그래서 콜스택이 아닌 브라우저에서 실행하는 setTimeout의 이벤트도 그냥 지정된 시간이 지날때까지 count만 하지 않는가.
그렇다면 만약 setTimeout 함수가 인자로 콜백함수를 받지 않고 그냥 지연 시간만 받는다면 어떻게 될까?
console.log("1");
console.log("2");
setTimeout(1000);
console.log("3");
(setTimeout 함수는 첫번째 인자로 콜백 함수를 넣어야 하지만 그냥 지연 시간만 넣을 수 있다고 가정)
1, 2가 출력되고 1초 후에 3이 출력되게 하려는 의도로 코드를 이렇게 짰다. 하지만 1초를 기다리는 timer 동작인 setTimeout은 비동기로 처리되는 함수이기 때문에 1, 2 가 출력된 다음 1초 waiting 동작의 수행은 브라우저로 가서 따로 실행되고 3이 곧바로 출력되어 버린다.
사용한 timer가 비동기로 처리되는 바람에 의도한 동작인 "1초 기다린 후 다음 코드가 실행" 이 제대로 실행되지 않은 것이다.
그렇기 때문에 setTimeout은 인자로 시간 뿐만 아니라 그 시간이 지난 후에 실행할 동작을 받도록 설계되어 있다. 첫번째 인자로 지정한 시간 후에 실행할 동작에 해당하는 함수 자체를 넘겨주면 사용자의 의도대로 수행할 수 있게 된다.
console.log("1");
console.log("2");
setTimeout(() => {
console.log("3");
}, 1000);
이렇게 하면 setTimeout의 이벤트인 1초 타이머 동작이 브라우저에서 실행되고 그게 완료되면 console.log("3") 동작을 하는 함수가 콜백 큐로 보내지고 콜스택으로 올려져 실행될 것이다.
따라서 원래 의도했던 대로 1, 2가 출력된 후 1초 후에 3이 출력될 것이다.
여기서 비동기 함수의 인자로 함수를 지정해준 행위처럼 우리는 비동기 함수 다음 코드가 비동기 함수가 실행된 후에, 원하는 타이밍에 실행될 수 있도록 직접 무언가 처리를 해줘야 한다. 이러한 처리를 자바스크립트에서의 비동기 처리 라고 한다.
비동기 처리에서 "처리" 라는 단어에 초점을 두지 않고 앞의 "비동기" 글자만 인식하다보니, 자바스크립트의 비동기 처리 부분을 처음 공부했을 때 뭔가 이해가 가지 않았던 점이 있었다.
자바스크립트의 비동기 처리 방법으로 방금 소개한 콜백 함수와 프로미스, async/await 라는 것들이 나오는데, 이 문법들을 이해하고나니 "함수 실행이 동기적으로 수행되도록 조작하는 개념인 거 같은데 왜 비동기 처리인거지?" 라는 의문이 생겼다.
심지어 비동기로 실행되는 작업들은 말 그대로 "비동기로 실행". 그냥 알아서 무조건 비동기적으로 실행되는 애들인데 뭘 비동기 처리를 해준다는거지? 라는 생각이 들었다.
그래서 비동기 처리 파트의 내용인 콜백 함수, 프로미스, async/await 문법 자체는 이해했는데 뭔가.. 뭔가 맥락적으로(?) 납득이 되지 않았다.
하지만 자바스크립트의 실행 사이클을 자세히 알아보고 예시로 들었던 setTimeout이나 addEventListener 같은 비동기 함수들이 왜 다 콜백함수를 인자로 받는지를 생각해보니, 앞서 말한 것 처럼 비동기 작업이 수행된 후에 원하는 타이밍에 다음 동작이 수행될 수 있도록 하기 위함이고 이러한 조치를 "비동기 처리"라고 하는 것 같다.
즉 자바스크립트의 비동기 처리란 어떤 동작을 비동기적으로 실행되도록 처리하는게 아니라 지 멋대로 비동기적으로 실행되어버리는 함수를 다른 함수들과 함께 질서있게 사용할 수 있도록, 비동기 함수를 동기 함수처럼 사용하기 위한 처리를 의미하는 게 아닐까..! 귀찮은 놈을 처리한다는
라고 혼자 결론내렸습니다.
참고자료
https://blog.toktokhan.dev/t-767eb0fa38f3
https://baeharam.netlify.app/posts/javascript/event-loop
https://inpa.tistory.com/entry/%F0%9F%94%84-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84-%EA%B5%AC%EC%A1%B0-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC