티스토리 뷰

자바스크립트는 싱글 스레드 언어다. 즉 콜 스택이 한 개고, 한 번에 한 가지 일밖에 처리하지 못한다는 뜻이다.

one thread == one call stack == one thing at a time

콜스택

function square(n) {
  return n * n;
}

function printSquare(n) {
  var squared = square(n);
  console.log(squared); 
}

printSquare(4); 

콜 스택은 프로그램의 어디가 실행되고 있는지 기록하는 자료 구조다. 만약 함수를 실행하게 되면 그 함수의 실행 컨텍스트를 기록하고(콜 스택에 푸시하고), 그 함수가 반환되면 콜 스택에서 팝한다.

이건 어디까지 했더라? 랑 비슷한 느낌이다. 위 예시처럼 함수 안에 함수가 있고, 또 함수 안에 함수가 있다면, 우리는 중첩 함수의 실행이 끝나면 어디로 돌아가야 하는지 알고 있어야 한다. 위 예시에서는 printSquare에서 square를 호출하고 있으므로, square의 실행이 끝나면 printSquare로 돌아가서 실행을 마저 끝내야 하는 것이다. 그래서 실행 순서를 콜 스택에 기록해둠으로써, 맨 위에 있는 실행 컨텍스트가 실행이 완료되어 pop되고 나면 그 아래에 있던 실행 컨텍스트로 돌아가는 것이다.

(참고 - 실행 컨텍스트) 콜 스택은 실행 컨텍스트 스택과 동의어고, 실행 컨텍스트가 무엇인지는 나중에 포스팅으로 정리할 것이다. 간단하게 말하자면 자바스크립트 엔진은 코드를 실행하기에 앞서 평가를 하는데, 코드를 평가할 때에 변수 선언과 함수 선언을 실행 컨텍스트라는 객체에 기록해둔다. 그렇게 평가 과정이 끝나고 나면 비로소 실행을 하는데, 예를 들어 코드에서 console.log(x) 가 있다면 이를 실행하기 위해 먼저 실행 컨텍스트에서 x라는 변수가 있는지, 있다면 어떤 값이 할당돼 있는지 찾아서 실행을 한다.

앞서 말했듯이 엔진은 평가 -> 실행의 과정을 반복하는데, 이 때 _평가를 마치고 나서 생성된 실행 컨텍스트_가, 실행 컨텍스트 스택에 푸시된다. 즉 실행 컨텍스트가 스택에 푸시되는 것은 해당 코드의 실행을 알리는 것이다.

자바스크립트는 이렇게 콜 스택이 하나이기 때문에, 한 작업이 엄청 오래 걸리면 문제가 된다. 왜냐하면 자바스크립트는 브라우저에서 돌아가기 때문이다. 만약 브라우저에서 어떤 버튼을 하나 클릭했는데, 동기적으로 오래 걸리는 작업이 돌아간다면(e.g. for loop 백만번 돌기) 유저는 그 동안 아무런 작업도 할 수 없게 된다. 뭔가가 렌더링 되는 것도 아니고, 이벤트 핸들링도 안된다. 그냥 멈춰 있는 화면을 지켜봐야 한다.

비동기

만약 아래 코드가 동기적이라고 가정한다면 대략 이런 과정을 거친다.

  • 첫 번째 setTimeout이 콜스택에 올라간다. 3초 동안 기다린다. 3초가 지난 후 Hi를 콘솔에 출력한다. 그리고 setTimeout은 실행이 완료되고 콜스택에서 사라진다.
  • 두 번째 setTimeout이 콜스택에 올라간다. 3초 동안 기다린다. 3초가 지난 후 Hi를 콘솔에 출력한다. 그리고 setTimeout은 실행이 완료되고 콜스택에서 사라진다.
  • 세 번째, 네 번째도 마찬가지로 동작하여 총 4개의 Hi가 출력되는데 12초가 걸릴 것으로 예상된다.

하지만 비동기는 실행 중인 태스크가 종료되지 않은 상태라 해도 다음 태스크를 곧바로 실행하는 방식을 말한다.

setTimeout(function () { console.log('Hi'); }, 3000);
setTimeout(function () { console.log('Hi'); }, 3000);
setTimeout(function () { console.log('Hi'); }, 3000);
setTimeout(function () { console.log('Hi'); }, 3000);

위 코드에서 4개의 Hi는 3초가 지난 다음에 거의 동시에 나타난다.

  • 첫 번째 setTimeout은 콜스택에 올라가고 나서 바로 pop 되어서 콜스택에서 사라진다.
  • 첫 번째 setTimeout이 바로 콜스택에서 사라졌으므로 두 번째 setTimeout의 실행이 블로킹되지 않고 바로 콜스택에 올라간다. 이 역시 바로 pop된다. 세 번째, 네 번째로 이런 식으로 바로 실행된다.
  • 대략 3초 후 4개의 Hi가 거의 동시에 출력된다.

그러면 콜스택에서 바로 pop된 타이머들은 어디로 갔을까? 이벤트 루프(Event loop)라는 아이가 관리하고 있다가, 콜스택이 모두 비고 나서(전역 코드의 실행까지 모두 종료되고 나서), 타이머들을 꺼내서 지정한 시간(예시에서는 3초)이 지났는지 체크한다. 만약 3초가 지나지 않았으면 다음 루프에서 또 3초가 지났는지 검사한다. 이렇게 3초가 지날 때까지 루프를 계속 돌다가 3초가 지난 시점에 이벤트 핸들러가 관리하는 태스크 큐(콜백 큐, 메세지 큐 등으로도 불린다)라는 큐(Queue)에 콜백 함수를 넣는다. 그리고 First In First Out의 순서대로, 먼저 들어온 큐에 콜백 함수부터 차례 차례 실행한다.

위 과정이 12초가 아닌 거의 3초만에 끝나는 것이 가능한 것은(즉 비동기적으로 동작하는 것이 가능한 것은) 위에서 언급했다시피 이벤트 루프 때문이다. 이벤트 루프는 자바스크립트의 동시성(concurrency) 을 지원한다. 예를 들어 우리는 ajax 요청을 보내놓고 응답이 올 때까지 블로킹돼서 기다리는 것이 아니라 요청을 보내놓고 바로 다음 코드를 실행할 수 있다. 자바스크립트 엔진의 콜스택이 한 개임에도 불구하고 이렇게 여러 작업을 동시에 하는 것처럼 보이게 할 수 있는 것은 브라우저에서 제공하는 이벤트 루프 때문이다.

또 다른 예시를 들어보자.

setTimeout(function() { console.log('5sec later'); }, 5000);
setTimeout(function() { console.log('1sec later'); }, 1000);

위 코드를 실행하면 5초를 기다리는 setTimeout이 더 먼저 실행됨에도 불구하고 1sec later가 먼저 출력되고 그 다음 5sec later가 출력된다. 그 이유는 위에서 말한 것처럼 이벤트 루프가 비동기로 처리하도록 도와줘서 두 번째 setTimeout의 실행이 블로킹이 되지 않기 때문이다.

  • 일단 콜스택에 첫 번째 setTimeout이 들어가고 바로 pop되어 사라진다.
  • 이벤트 루프는 pop된 5초짜리 타이머를 타이머 힙에 저장한다.
  • 두 번째 줄의 setTimeout도 콜스택에 올라가고 바로 pop되어 사라진다.
  • 이벤트 루프는 pop된 1초짜리 타이머를 타이머 힙에 저장한다.
  • 모든 코드의 실행이 끝나고 콜스택이 비게 된다.
  • 이벤트 루프는 힙에 저장된 타이머를 꺼내서 타이머가 등록된 시간 이후 지정된 시간이 지났는지 확인한다. 만약 1초 짜리 타이머가 등록된 뒤로 1초가 지났다면 이 타이머의 콜백 함수를 꺼내서 태스크 큐에 넣는다. 하지만 5초 짜리 타이머는 아직 5초가 안 지났으므로 이 때 태스크 큐에 넣지 않는다. 태스크 큐에 넣어진 1초 짜리 타이머의 콜백 함수가 콜스택으로 올라가고 1sec later를 출력하고 실행이 종료되어 사라진다.
  • 이벤트 루프는 계속 루프를 돌면서 실행되어야 할 타이머가 있는지(체크하는 시간 기준으로 delay time이 지난 타이머가 있는지) 확인한다.
  • 마침내 5초가 지났을 때 5초 짜리 타이머가 실행되어야 한다는 것을 확인하고 태스크 큐에 넣는다. 이 콜백 함수는 콜스택에 올라가 5sec later을 출력한 다음 사라진다.

(참고 - 힙) 힙이란 완전 이진 트리로 이루어진 자료 구조로, 느슨한 정렬 상태를 유지한다. 부모 노드가 자식 노드보다 항상 크거나(맥스힙) 항상 작다(민힙). 그러므로 민힙의 경우에는 루트 노드가 항상 가장 작은 값이 된다. 전체 정렬이 필요한 것이 아니라, 가장 큰 값 또는 가장 작은 값만 효율적으로 알고자 할 때 쓰인다. 이 경우 타이머를 오름차순으로 힙에 저장해둔다. 그래서 1초 짜리 타이머를 5초 짜리 타이머보다 먼저 검사할 수 있다.

이벤트 루프에 대해 오해하기 쉬운 부분들

  • 이벤트 루프는 자바스크립트 엔진의 일부가 아니라 브라우저나 Node.js에 속해 있다. 자바스크립트 엔진은 단지 콜스택과 객체를 저장하는 힙으로 이루어져 있으며 이 콜스택과 힙을 이용하여 코드를 평가 및 실행할 뿐이다. 이벤트 루프는 엔진을 이용하여 코드를 실행한다.
  • 이벤트 루프는 엔진을 이용하여 자바스크립트 코드를 실행하고, 이는 단일 스레드에서 이뤄지는 작업이다.
    자바스크립트가 싱글 스레드고, 이벤트 루프가 이 한계를 극복하게 도와준다는 점 때문에 마치 이벤트 루프의 실행이 별도의 스레드에서 이루어지는 것처럼 오해할 수 있다. 하지만 자바스크립트 엔진의 실행과 이벤트 루프의 실행은 단일 스레드 내에서 이루어진다고 한다. 즉 엔진이 자바스크립트 실행을 하는 동안 다른 스레드에서 이벤트 루프가 계속 뭔가 작업하면서 돌고 있는 것이 아니다. 이벤트 루프는 자바스크립트 메인 모듈 실행에 앞서 먼저 생성된다. 그리고 이벤트 루프의 바깥에서, 메인 모듈을 자바스크립트 엔진을 이용하여 코드를 실행한다. 매 이터레이션마다 콜스택에 실행할 함수가 있는지 확인하고 있다면 실행한다. 이 과정은 콜스택이 전부 clear될 때까지 반복된다. 그렇게 메인 모듈이 한 번 실행되고 나서, Node.js나 브라우저는 이벤트 루프에서 해야할 일이 있는지 확인한다. 예를 들어 타이머가 있는지, 응답을 기다리고 있는 네트워크 요청이 있는지, 관리하고 있는 큐들에 실행되기를 기다리는 콜백 함수들이 있는지 등이다. 이런 것들이 있다면 이벤트 루프는 루프를 돌면서 자기가 관리하고 있는 큐들을 사용해서 할 일을 하기 시작한다. 만약 이벤트 루프가 할일이 없다면 Node.js나 브라우저는 이벤트 루프를 종료할 것이다. 만약 자바스크립트의 실행이 완료되지 않는다면 이벤트 루프 또한 진행되지 않는다.
  • 스레드풀은 이벤트 루프에 속한 것이 아니다.
    스레드풀은 이벤트 루프 매커니즘의 일부가 아니며, Node.js가 아니라 비동기 작업을 처리하는 라이브러리인 libUV에 포함된 기능이다. 예를 들어 fs.readFile 메서드를 만나면 이벤트 루프는 libUV에게 이 작업을 던진다. 파일 시스템 관련 명령이나 네트워크 I/O 작업은 libUV가 처리한다. 만약 그 비동기 작업이 OS 커널에서 제공하는 API로 해결이 될 수 있는 것이라면 libUV는 다시 커널에게 던지고, 만약 OS 커널에서 제공하지 않는다면 별도 스레드에 작업을 던진다. 파일 읽기와 같은 경우 후자이므로 별도 스레드에 작업을 던질 것이다. 작업이 완료되면 이벤트 루프는 자기가 관리하는 I/O 콜백 큐에다가 콜백을 등록해놓고, 이터레이션에서 해당 phase를 지날 때 콜백을 실행한다.

이벤트 루프에 대해서 더 자세히 알고 싶다면 이 포스팅을 참조해보자.

Ref

https://www.youtube.com/watch?v=8aGhZQkoFbQ

https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop

https://nodejs.dev/learn/the-nodejs-event-loop

위키북스 - 모던 자바스크립트 Deep Dive

'공부일지(TIL) > JavaScript' 카테고리의 다른 글

[JavaScript] Property Accessor  (0) 2021.05.15
[JavaScript] Math.floor vs parseInt (feat. ~~)  (1) 2021.05.08
[JavaScript] 콜백 함수의 this  (0) 2021.04.01
[JavaScript] Getter  (0) 2021.04.01
[JavaScript] 숫자(Number) 타입  (0) 2021.03.23
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함