자바스크립트는 어떻게 동시성을 지원할까?

date
Jul 31, 2022
thumbnail
slug
js-event-loop
author
status
Published
tags
Javascript
summary
Javascript 동시성의 핵심에 있는 Event-Loop
type
Post
updatedAt
Jan 23, 2023 03:13 PM
다들 자바스크립트는 싱글 스레드 기반 언어라고 한다. 하지만 실제로 자바스크립트가 사용되는 곳들을 보면, 브라우저에서 웹 서버에 요청을 보내고 받는 도중에도 페이지의 랜더링 동작은 멈추지 않고, node.js 서버에선 여러 개의 HTTP 요청을 처리하기도 한다.
싱글 스레드 기반에서 이러한 작업들이 어떻게 가능한지 알아보자.

자바스크립트는 싱글스레드 언어

javascript는 싱글스레드로 동작하는 언어이다. 즉 하나의 단일 메인 스레드로 구성되어 있다.
싱글스레드는 말 그대로 한번에 하나의 작업만 수행할 수 있음을 의미한다. 다른 작업이 중간에 끼어들 수도 없고, 기존에 수행하던 작업이 끝나야만 그 다음 작업을 수행할 수 있다.javascript를 실행하는 엔진은 하나의 Memory HeapCall Stack을 갖는다.
notion image
  • Memory Heap : 메모리 할당을 담당하는 곳
  • Call Stack : 코드가 호출되면서, 스택으로 쌓이는 곳
하나의 메인스레드에서 호출되는 함수들이 콜스택에 쌓일것이고, 이 함수들은 LIFO(Last In First Out)방식으로 실행된다.
콜스택과 싱글스레드를 연관지어 생각해보면, javascript가 하나의 메인스레드와 하나의 콜스택을 갖는다는 말이다.
하지만 javascript의 특징들을 알아보면 비동기동시성논블로킹(Non Blocking) I/O 등의 상반되는 개념들이 등장한다.

싱글스레드라며? 어떻게 동시성을 가질 수 있는거지?

동시성을 보장하는 비동기, 논블로킹 작업들은 javascript 엔진을 구동하는 런타임(Runtime) 환경에서 담당한다. 여기서의 런타임 환경이란, 브라우저 혹은 Node.js를 의미한다.
Node.js 공식사이트에서는 Node.js를 다음과 같이 소개한다.
Node.js는 비동기 이벤트 주도 javaScript 런타임으로써 Node.js 는 확장성 있는 네트워크 애플리케이션을 만들 수 있도록 설계되었습니다.

node.js 구조

notion image
Node.js를 크게 나눠봤을 때, 내장 라이브러리와 v8엔진 그리고 libuv로 구성되어 있다. Node.js의 특성인 이벤트 기반, 논블로킹 I/O 모델들은 모두 libuv 라이브러리에서 구현된다.
libuv의 공식 홈페이지 소개는 아래와 같다.
libuv is a multi-platform support library with a focus on asynchronous I/O. It was primarily developed for use by Node.js, but it’s also used by Luvit, Julia, uvloop, and others.
  1. Node.js는 Javascript와 C++언어로 구성되어 있다. V8엔진도 70% 이상의 C++로 구성되어 있으며, libuv는 100%의 C++언어로 구성된 라이브러리이다.
  1. Node.js의 코어 라이브러리는 binding API을 통해 javascript 환경에서 사용될 수 있다. (예를 들면 Node.js의 내장 모듈인 crypto는 원래 C++ 언어로 작성되어 있다고 한다)
  1. Node.js에 동작하는 이벤트 루프는 libuv 내에서 구현된다. 이벤트 루프가 libuv 내에서 실행된다고 해서 Javascript의 스레드와 이벤트 루프의 스레드가 별도로 존재한다고 생각할 수도 있지만, Node.js는 싱글스레드이기 때문에 하나의 이벤트 루프를 가지며, 하나의 스레드가 모든 것을 처리한다.

이벤트루프

notion image
이벤트에 따라 호출되는 콜백함수를 관리하는 역할을 담당한다.
  • 이벤트 루프가 태스크 큐에서 콜스택으로 콜백 함수를 넘겨주는 작업은 콜스택에 쌓여있는 함수가 없을때만 수행된다. 따라서 콜스택에 많은 함수들을 쌓아 놓는 것들은 다른 태스크들을 블로킹할 여지를 높이게 된다. (실행이 오래 걸리는 코드들은 적절하게 작업을 세분화하여 비동기 호출을 통해 성능을 개선하자!)
  • 매 이벤트를 처리하며 순환되는 Event Loop는 싱글 쓰레드이며 한 번에 하나의 이벤트만 처리할 수 있다. 이벤트 루프는 여러 개의 페이즈(Phase)들을 갖고 있으며, 해당 페이즈들은 각자만의 큐(Queue)를 갖는다.
  • 이벤트 루프는 라운드 로빈(round-robin) 방식으로 노드 프로세스가 종료될때까지 일정 규칙에 따라 여러 개의 페이즈들을 계속 순회한다.
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
위 코드에서 어느 것이 먼저 출력이 될까? 정답은 알 수 없다 이다.
setTimeout은 이벤트 루프의 timer단계에서 처리되고, setImmediate는 check단계에서 처리된다. 이벤트 루프는 싱글 스레드로 계속해서 페이즈가 변화하므로, 현재 어느 페이즈인지에 따라 둘의 실행 순서는 달라지게 된다.

논블로킹(Non-Blocking)

notion image
Node.js에서의 논블로킹 모델은 Input과 Output이 관련된 작업(http, Database CRUD, third party api, filesystem), 오랫동안 CPU를 사용해야하는 작업 등의 블로킹 작업들을 백그라운드에서 수행하고, 이를 비동기 콜백함수로 이벤트 루프에 전달하는 것을 말한다.
여기서 백그라운드란 OS 커널 혹은 libuv의 스레드 풀을 의미한다. libuv는 OS 커널에서 어떤 비동기 작업들을 지원해주는지 알고 있기때문에, 작업 종류에 따라 커널 혹은 스레드 풀로 분기한다. 작업이 완료되면 이벤트 루프에게 이를 알려주고, 이벤트 루프에 콜백함수로 등록된다.

동시성의 의미

사실 동시성의 의미가 정말로 javascript 코드가 동시에 실행되는걸 의미하는 건 아니다.
어차피 비동기 작업의 끝은 Event Loop를 통해 결국엔 javascript 엔진의 단일 Call Stack으로 오게 되어있다.
앞에서 말했듯이 javascript는 결국엔 싱글 스레드 언어이고, 이 핵심(single thread → single call stack → single thing at a time)은 변하지 않는다.

음부터 멀티스레드를 지원하게 만들면 되지 않았을까?

사실 이럴거면 언어 차원에서 그냥 멀티 쓰레드를 지원하는게 낫지 않았을까? 하는 생각이 든다.
하지만 javascript가 이렇게 설계된 이유는 싱글 쓰레드가 제일 동시성 문제를 해결하기 간편한 방법이기 때문이었던 것 같다.
사실 javascript가 개발됐던 시점에는 오늘 날처럼 복잡한 작업을 요구하지 않았을 것이다.무엇보다도 DOM을 조작할 때 멀티스레드라면 타이밍이 어긋나는 문제를 해결하기 힘들 거라 생각된다.
ios나 android에서도 UI에 대한 변경은 UI thread(main thread)에서만 가능한 것으로 알고 있다.
또한 이미 실행을 지연하거나 분기할 수 있는 이벤트 개념이 있기 때문에 멀티스레드는 복잡성만 늘릴 뿐이라고 생각된다.
내가 애용하는 python(CPython 기준)도 멀티 쓰레드로 코드를 짜도, 실상은 python 인터프리터에 하나의 유저 레벨 스레드만 코드에 접근이 가능한 GIL(Global Iterpreter Lock)이라는 개념이 있다.이 역시 python에서 thread-safe 관련한 문제들을 가장 간단하게 해결하기 위한 방법이었을 것이라는 생각이 든다.
요즘은 스프링도 기존 서블렛 방식(1요청→1스레드)에서, 이벤트 루프 기반으로 대량의 클라이언트 요청 처리에 더 적합한 Netty기반도 많이 사용하는 추세인 것 같다. 각 구조마다의 장단점이 확실히 존재하고 결국엔 선택의 문제인 것 같다.

참고