1. 자바스크립트 엔진과 런타임 환경
자바스크립트 런타임이란?
JavaScript가 구동되는 환경을 말한다. JavaScript의 런타임 환경으로는 웹 브라우저 또는 Node.js 환경이 있다.
우선 대표적인 웹 브라우저 Chome을 기준으로 브라우저의 구성과 자바스크립트 런타임 환경에 대해 알아보자.
Chrome 브라우저는 Chromium이라는 브라우저 엔진을 기반으로 동작한다. 이 브라우저 엔진에는 Blink라는 렌더링 엔진이 탑재되어 있다. 이 Blink라는 렌더링 엔진 내부에는 V8이라는 자바스크립트 엔진을 내장하고 있으며, 자바스크립트 코드는 이런 브라우저 내부에 탑재된 V8 엔진을 통해 브라우저 환경에서 해석되고 실행되게 된다.
각 브라우저 마다 탑재되어 있는 렌더링 엔진과 자바스크립트 엔진은 각각 다르다. Chromium 브라우저 엔진과 Blink 렌더링 엔진, V8이 궁금하다면 공식 문서를 참고해 보자.
자바스크립트 엔진과 싱글 스레드(Single thread)
Chrome 브라우저 엔진 내부에는 V8과 같은 자바스크립트 엔진이 내장되어 있다고 했다. 이 자바스크립트 엔진은 단일 스레드(Single thread)로 구성되어 있다.
즉, 자바스크립트는 Single Thread 언어이기 때문에 하나의 Thread만 존재하며, 하나의 Thread 안에 하나의 stack과 Memory Heap이 존재한다.
하나의 Call stack에 환경정보를 기반으로 실행 컨텍스트를 생성하여 쌓고, LIFO 원칙에 의해 순차적으로 코드를 실행시키기 때문에 순서가 보장된다는 것이며, 즉 자바스크립트 엔진 자체만으로는 멀티쓰레딩이 불가능 하다.
비동기 처리 방식이 필요한 이유
자바스크립트 엔진은 싱글 쓰레드 방식으로 실행되기 때문에 하나의 실행 컨텍스트 스택을 갖는다. 이는 함수를 실행 할 수 있는 창구가 단 하나이며, 동시에 2개이상의 함수를 동시에 실행시킬 수 없다는 의미이다. 또 이 말은 즉, 처리에 시간이 걸리는 태스크를 실행하는 경우 블로킹(blocking)이 발생한다.
자바스크립트는 Single Thread언어로, 한 번에 하나의 task만 실행할 수 있다. 동기적 방식은 현재 task가 완료될 때 까지 *blocking(작업중단)되어 다른 작업들이 일시정지 되고 다음 코드가 읽히지 않는다. 따라서 코드의 복잡도가 높거나 네트워크 통신과 같이 처리시간이 긴 로직을 동기적으로 수행할 경우 blocking되는 시간이 늘어나고, 다른 자바스크립트 코드를 중단시키기 때문에 사용자 불편성을 초래할 수 있다. 이와 같은 이유로 주로 네트워크 통신이나 요청, 대기, 보류 등 작업이 오래걸리는 코드를 수행할 때에는 비동기적으로 처리하여 해당 task가 진행되는 동안에도 다른 코드들이 실행될 수 있도록 해야 한다.
*blocking : 콜 스택이 멈춘 상태를 블로킹 상태라고 한다. 해당 task가 완료될 때 까지 작업이 중단되고 이후 코드들이 실행되지 않는다. 사용자에게 원활한 제공을 위해 콜 스택이 멈추게 해서는 안된다. 블로킹 상태를 해결하는 방법은 논-블로킹, 비동기 콜백을 사용하는 것이다.
하지만 브라우저는 여러가지 일처리를 비동기적으로 처리하는 것처럼 보인다.
자바스크립트는 Single Thread인데 어떻게 논 블로킹 언어이고 비동기 처리가 가능할까?
자바스크립트 런타임 환경에서의 비동기 처리 방법
자바스크립트가 싱글 쓰레드 기반임에도 불구하고 '동시성' 언어, 멀티 쓰레딩 처럼 동작할 수 있는 이유는 웹 브라우저가 제공하는 API를 통해 동시에 작업을 할 수 있기 때문이다. 이것은 비동기 콜백을 통해 이루어 진다.
일반적으로 비동기 콜백을 설명할 때 가장 많이 사용하는 함수가 바로 setTimeout 이다. 주어진 시간만큼 기다렸다가 콜백함수를 실행하는 이 함수는 JS엔진인 V8에 내장되어 있지 않고, 웹 브라우저에서 제공하는 Web API에 존재하여 웹 브라우저가 대신 실행시켜 주어야 한다.
이 부분에 대해서는 자바스크립트 런타임 환경에 대한 이해가 필요하다. 자바스크립트는 런타임 환경(브라우저)에서 동작하게 되는데, 이 때 자바스크립트는 자바스크립트 엔진과 웹 브라우저의 Web API와 함께 동작하게 된다.
웹 브라우저는 멀티쓰레딩을 지원하며, 다양한 비동기 처리 Web API들과 함께 자바스크립트 엔진에 탑재된 event loop와 Task Queue에 비동기 작업의 콜백을 전달하여 자바스크립트에서도 비동기적인 작업이 가능하게 한다.
자세한 내용이 궁금하다면 자바스크립트 엔진, event loop와 Task Queue 등을 공부해 보도록 하자.
자바스크립트 런타임 동작 원리
웹 APIs(Web APIs)
- Web API는 JavaScript가 실행되는 런타임 환경에 존재하는 별도의 API이다.(V8 소스코드에는 존재하지 않는다.)
- Web API에는 DOM API, AJAX(XMLHttpRequest) 또는 Fetch API, 타이머 함수(setTimeout, setInterval), 프로미스(Promise), 이벤트 핸들러, 웹 워커(Web Workers: 백그라운드에서 병렬로 실행되는 웹 워커 스레드를 생성하고 관리하는 API) 등으로 구성되어 있으며 비동기 동작을 수행한다.
콜 스택(Call Stack)
- 스택은 함수 호출과 관련된 데이터를 저장하는 공간으로, 함수가 호출될 때마다 스택에 실행 컨텍스트가 추가되고, 함수 실행이 완료되면 해당 컨텍스트가 제거된다.
- 후입선출(LIFO) 구조를 가지며, 함수 호출 및 반환 순서를 관리한다.
- 원시 타입의 변수 값과, 참조 타입 변수(객체 등)의 메모리 주소값이 저장된다.
- 스코프 범위 내에서의 원시 타입 값들은 함수 호출 시 스택에 할당 되어, 해당 변수가 스코프에서 벗어날 때 자동으로 제거된다.
메모리 힙(Memory Heap)
- 힙은 동적으로 할당된 데이터와 객체를 저장하는 영역으로, 주로 객체, 배열, 함수 등의 동적 데이터가 저장된다.
- 힙에 저장된 객체들은 가비지 컬렉터(Garbage Collector)에 의해 관리 된다. 더 이상 사용되지 않는 객체들이 탐지되고 메모리가 자동으로 해제된다.
- 가비지 컬렉터가 모든 경우에 항상 완벽하게 메모리를 관리해주지는 않을 수 있다. 때때로 메모리 누수가 발생할 수 있으며, 특히 클로저(Closure)와 같은 경우에는 주의가 필요할 수 있다. 따라서 코드를 작성할 때에는 객체에 대한 참조가 더 이상 필요하지 않은 경우에는 해당 참조를 해제하여 가비지 컬렉터가 정상적으로 동작하도록 해야 한다.
이벤트 루프(Event loop)
이벤트 루프(Event Loop)는 주로 자바스크립트 엔진 내의 단일 스레드에서 동작하는 개념이다. 이벤트 루프는 비동기 콜백 함수 및 이벤트 핸들러의 실행을 관리하여 프로그램의 흐름을 제어한다.
이벤트 루프는 for문과 같이 루프를 돌면서 계속해서 Task Queue와 Call Stack를 관찰한다. 그리고 콜스택이 완전히 비워질 때 까지 기다렸다가, 콜스택이 비워지면 이벤트 큐에 있는 작업을 콜 스택으로 이동시켜 실행하고, 다시 콜 스택이 비어질 때까지 이 과정을 반복한다. 이를 통해 비동기적인 작업이 순차적으로 처리된다.
Chromium의 V8 엔진 내부 소스 코드를 살펴 보면, worker_threads_task_runners_ 로 구현되어 있는 것을 확인할 수 있다. (소스 코드를 보면서 네이밍이 참 간결하면서도 가독성이 좋다고 느껴졌다...)
void DefaultPlatform::EnsureBackgroundTaskRunnerInitialized() {
DCHECK_NULL(worker_threads_task_runners_[0]);
for (int i = 0; i < num_worker_runners(); i++) {
worker_threads_task_runners_[i] =
std::make_shared<DefaultWorkerThreadsTaskRunner>(
thread_pool_size_,
time_function_for_testing_ ? time_function_for_testing_
: DefaultTimeFunction,
priority_from_index(i));
}
DCHECK_NOT_NULL(worker_threads_task_runners_[0]);
}
태스크 큐(Task Queue=Callback Queue)
태스크 큐는 이벤트 루프의 일부로써, 비동기 작업이 완료되었을 때 그 결과나 콜백 함수를 임시로 저장하는 대기열이다.
V8엔진의 Task Queue 소스 코드이다. C++ 언어이지만 코드가 궁금하다면 참고로 보면 좋을 것 같다.
DelayedTaskQueue::MaybeNextTask DelayedTaskQueue::TryGetNext() {
for (;;) {
// Move delayed tasks that have hit their deadline to the main queue.
double now = MonotonicallyIncreasingTime();
for (;;) {
std::unique_ptr<Task> task = PopTaskFromDelayedQueue(now);
if (!task) break;
task_queue_.push(std::move(task));
}
if (!task_queue_.empty()) {
std::unique_ptr<Task> task = std::move(task_queue_.front());
task_queue_.pop();
return {MaybeNextTask::kTask, std::move(task), {}};
}
결론
1. 콜스택에 처리할 연산이 너무 무겁거워서 오래걸리거나, 너무 많은 스택이 쌓여 오래걸릴 경우 블로킹 현상이 일어난다. 사용자는 이 시간을 기다려야 하므로 for문 같이 연산이 많거나, 오래걸리는 연산은 스택에서 처리하지 않도록 최대한 자제하는 것이 좋다. 따라서 복잡한 연산이나 시간이 오래 걸리는 작업은 적절히 분리하고 비동기적으로 처리하는 것이 중요하다. 이를 통해 사용자 경험을 향상시킬 수 있다.
2. 콜스택에 처리할 연산이 많이 남아있으면, 이벤트가 발생해도 처리되지 않는다. 이벤트 루프는 콜스택이 비어져야 태스크 큐에서 콜백을 가져오기 때문이다. 예를들어 for 루프의 실행이 끝나기 전까지는 콜스택이 계속해서 차있게 되어 다른 비동기 작업이나 이벤트 처리를 위해 기회를 주지 않을 수 있다. 결과적으로 for 루프가 완료되어 콜스택이 비어질 때까지 setTimeout 콜백이 실행되지 않을 수 있다. 이런 상황을 해결하려면, for 루프 내부에서 사용되는 setTimeout 콜백 함수의 실행 컨텍스트를 캡처하여 변수에 저장하거나, Promise나 async/await 패턴을 사용하여 비동기 처리를 해야한다. 이를 통해 비동기 작업의 타이밍을 더 정확하게 제어할 수 있다. 추가 적으로 Queue에 이벤트 리스너가 너무 많을 경우에도 사이트가 버벅거릴 수 있다. 항상 콜스택과 큐가 너무 많이 쌓이지 않도록 하여 성능에 주의하도록 하자.
3. event loop와 렌더링과의 관계, 즉 런타임 환경이 렌더링에 미치는 영향에 대해서도 공부해 보자.
< 참고 >
'웹(Web)' 카테고리의 다른 글
CSR(Client Side Rendering)과 SSR(Server Side Rendering)의 차이점 (0) | 2024.03.14 |
---|---|
모듈 시스템 require와 import의 차이 / commonJs와 ES6 / 웹팩 바벨 (0) | 2023.07.26 |
브라우저 저장소의 차이점(Local storage, Session storage, cookie) (0) | 2023.07.25 |
HTTP란 무엇인가? (0) | 2023.07.24 |
브라우저의 구성 요소와 렌더링 과정 (0) | 2023.07.16 |