ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [JS/Event Loop] 자바스크립트, 이벤트 루프(Event Loop)와 동시성(concurrency)에 대하여
    Frontend 2019. 6. 22. 00:23

     

     

    자바스크립트의 이벤트 루프(Event Loop)에 대해서 공부를 해보려다가 정말 좋은 영상을 발견했다.

    JSconf EU에서 Philip Roberts라는 사람이 이벤트 루프에 대해서 발표한 스피치 영상인데,

    오늘은 이 동영상 내용을 정리해보려고 한다.

     

     

     


     

     

     

    자바스크립트는 기본적으로 싱글 스레드 프로그래밍 언어(Single threaded programming language)이다. 여기서 싱글 스레드라는 것은 한 번에 하나의 작업만 할 수 있다는 뜻이다.

     

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

     

    여기서 호출 스택(call stack)이라는 것은 지난번에 재귀함수에 대해서 정리하면서 언급했었다. call stack은 프로그램 상에서 우리가 어떤 순서로 작업을 수행하는지 기록하는 작업 스케쥴링과 관련된 자료 구조이다. 우리가 어떤 함수를 실행하게 되면 우리는 그 함수를 스택의 맨 위에 놓는 데, 이것을 push라고 한다. 만약에 함수가 어떤 값을 리턴하거나 실행을 종료하면 우리는 다시 그 함수를 스택 맨 위에서부터 꺼내는데 이것을 pop이라고 한다. 

     

     

    자바스크립트의 Call Stack

     

    호출 스택(call stack)이 어떤 식으로 작동하는지 위 그림을 보면 이해하기 쉽다.

     

    [PUSH]

    위 코드를 실행하면 일단 저 함수들을 감싸고 있는 main() 함수를 스택에 넣는다.

    그리고 printSquare(4) 함수가 실행되므로 printSquare(4)를 스택에 넣는다.

    printSquare() 함수 내부에서 square(n) 함수를 실행하므로 스택에 square(n)을 넣는다.

    square(n) 함수 내부에서 실행되는 multiply(a, b)를 스택에 넣는다.

     

    [POP]

    드디어 처음으로 리턴이 되었다. multiply(a, b)가 값을 리턴하며 스택에서 나온다.

    square(n)도 값을 리턴하고 나온다.

    printSquare(n)에서 console.log(squared)를 스택에 push하고 실행한 후 다시 빠져나온다.

    printSquare(n) 함수가 종료되었으므로 stack에서 나온다.

    main() 함수가 종료되었으므로 스택에서 나온다.

     

     

    이러한 구조로 함수가 호출되기 때문에 만약에 우리가 잘못해서 무한 호출되는 재귀 함수를 실행시키면

    스택에 함수 호출이 계속해서 쌓이다가 Maximum call stack size exceeded라는 에러 메시지를 만나게 된다.

     

     


     

    자바스크립트는 이렇게 싱글 스레드 언어이기 때문에

    함수를 실행하면 함수 호출이 스택에 순차적으로 쌓이고

    스택의 맨 위에서부터 차례대로 한 번에 하나의 함수만 처리할 수 있다.

     

    간단한 프로그램이라면 상관없지만 만약에 우리가 아주 복잡한 프로그램을 구동한다고 생각해보자.

    시간이 매우 오래 걸리는 작업이 스택에 쌓이고 실행되면 그 다음 작업은 무한정 대기할 수밖에 없다.

     

    우리가 화장실에서 차례를 기다리는 순간을 생각해보자.

    우리는 앞 사람이 볼일을 보고 나올 때까지 아무 것도 할 수 없고 가만히 서서 대기해야 한다.

    이렇게 다른 작업을 실행하기 위해서 이 전 작업이 완료될 때까지 기다려야만 하는 상황을 블로킹(blocking)이라고 한다.

     

    왜 이것이 문제가 되냐면 어떤 작업이 실행되어 동작하고 있는 동안에는

    브라우저가 다른 일을 전혀 할 수 없으므로 잠시 먹통이 되기 때문이다.

     

    우리가 어떤 사이트에서 문서를 작성하고 제출 버튼을 눌렀을 때,

    제출이 완료되어 페이지가 새로고침되기 전에 모래시계가 돌아가는 순간을 생각해보자.

    우리는 얼른 페이지가 새로고침되서 다시 작업을 할 수 있을 때까지 가만히 기다려야하고,

    브라우저는 그 시간 동안 잠시 렌더링을 멈춘다.

    만약에 이렇게 브라우저가 블락(block)되는 순간이 잦다면 사용자의 불만은 점점 커질 것이다.

     

    이러한 점을 극복하기 위한 해결 방안이 바로 Asynchronous Callbacks(비동기 콜백)이다!

    자바스크립트가 싱글 스레드 언어임에도 불구하고 우리가 웹 사이트에서 끊김없이 

    여러 작업을 동시에 할 수 있는 것은 바로 브라우저가 Web APIs 같은 것들을 제공하여

    비동기 작업을 가능하게 해주기 때문이다.

     

     


     

     

     

    기본적인 동작 원리를 그림으로 표현하면 이렇게 표현할 수 있다.

    함수를 동기 호출하게 되면 call stack에 차곡차곡 쌓여 순차적으로 실행된다.

    이 때 만약에 우리가 AJAX나 setTimeout 혹은 DOM event 함수를 실행하면

    자바스크립트 엔진은 call stack에서 Web APIs로 보내고 정해진 시간 혹은 

    이벤트가 발생한 순간에 순차적으로 callback queue에 적재한다.

     

    callback queue에 줄을 선 함수들은 call stack에 쌓여있던 것들이 모두 제거되어 깨끗해지면

    차례대로 스택에 쌓여서 실행되게 된다. 

     

     


    여기서 토막상식으로 큐(Queue)에 대해서 짚고 넘어가보자.

    큐는 스택(Stack)과 같이 자료 구조의 일종이다. 한 쪽에서만 삽입과 삭제가 이루어졌던 스택과는 달리

    한쪽에서는 삽입이 되고 다른 한쪽에서는 삭제 작업이 이루어지는 자료 구조이다.

     

    가장 먼저 삽입된 자료가 가장 먼저 삭제되는 구조이므로 선입선출(FIFO: First In First Out)이라고도 부른다.

    이 구조는 계산대나 매표소에서 줄을 서서 기다리는 장면을 생각해보면 된다.

     

    뒤에서부터 차례대로 줄을 서고 가장 먼저 줄을 선 사람이 가장 먼저 작업을 하고 빠지는 구조이기 때문이다.

     

     


     

    그렇다면 이제 예제 코드를 통해 어떤 식으로 흘러가는지 살펴보자.

     

     

     

    위 예제는 setTimeout()을 사용했을때, 어떤 식으로 동작하는지 그림으로 나타낸 것이다.

     

    먼저 main() 함수가 실행되고, console.log(1)이 스택에 쌓인다.

    console.log(1)이 실행되어 콘솔 창에 1이 출력되고 setTimeout의 콜백 함수인 cb가 스택에 쌓이는데,

    setTimeout은 브라우저에 의해 제공된 API로 자바스크립트 엔진에서 처리하지 않고 바로 web APIs로 넘긴다.

     

    그러면 브라우저는 마치 setTimeout 함수가 완료된 것처럼

    스택에서 pop하고 다음 작업을 진행하므로 console.log(3)이 실행되어 콘솔 창에 3이 출력된다.

     

    모든 코드가 실행되었으므로 main() 함수가 스택에서 제거되고,

    5초 동안 대기하고 있던 cb 함수가 5초가 지난 시점에 task queue에 들어온다.

    stack이 비어있으므로 cb 함수를 stack에 적재하고 console.log(2)를 실행하게 된다.

     

     

     

     

    지금 이 코드는 setTimeout이 5초 후에 실행하는 코드이기 때문에 명백하게 1 - 3 - 2의 순서로 출력된다.

    그렇다면 만약에 setTimeout 함수를 0초 후에 실행하도록 코드를 변경하면 어떤 결과가 일어날까?

     

     

    이 경우에도 결과는 크게 다르지 않다.

    그 이유는 task queue에 줄 서 있는 callback 함수들은 stack이 비어있을 때만 stack으로 이동할 수 있기 때문이다.

    위 경우 setTimeout으로 설정한 cb 함수는 web APIs로 이동하는 즉시 task queue로 이동하게 되는데,

    stack이 비어있지 않기 때문에 대기 상태로 있게 되고 console.log(3)이 출력되고 스택이 클리어되면 이동하여 console.log(2)가 실행된다.

     

     

     

     

    모든 web APIs는 위와 같은 방식으로 작동된다. AJAX나 DOM 이벤트도 동일하다.

     

    document.querySelector('button').addEventListener('click', function () {
      console.log('clicked');
    });

     

    만약 이런 코드를 실행한다고 해보자.

     

    그러면 브라우저는 일단 click event 함수를 call stack에 저장하고 이는 즉시 web APIs로 옮겨진다.

    그 상태로 무한 대기하고 있다가 사용자가 버튼을 클릭하는 순간

    click 이벤트의 콜백 함수는 callback queue로 이동한다. 

    그리고 stack이 비는 순간에 stack으로 이동하여 함수를 실행한다.

     

    만약에 사용자가 버튼을 10번 누른다면 callback queue에 10개의 콜백 함수가 쌓일 것이고

    먼저 들어온 콜백부터 순차적으로 스택으로 이동하여 실행되고 없어지고를 반복할 것이다.

     

     

     

     

    setTimeout(function timeout () {
      console.log(1);
    }, 1000);
    
    setTimeout(function timeout () {
      console.log(1);
    }, 1000);
    
    setTimeout(function timeout () {
      console.log(1);
    }, 1000);
    
    setTimeout(function timeout () {
      console.log(1);
    }, 1000);

     

    만약에 우리가 1초 후에 콘솔 창에 1을 출력하는 setTimeout()을 4번 썼다고 생각해보자.

    이 경우 각각의 함수들은 stack -> web APIs로 이동하고 차례대로 callback queue에 쌓이게 된다.

    이 때 가장 먼저 적재된 timeout 함수부터 순차적으로 stack에 "이동 -> 실행 -> 제거"를 반복하게 된다.

     

    즉, 4개의 함수를 1초 후에 실행하라고 설정했다고 해서 모든 이벤트가 동시에 실행되는 것이 아니다.

    이 점을 항상 유의해야한다. AJAX나 setTimeout() 등을 이용하면 마치 자바스크립트가 여러 가지 일을 동시에 수행하는 것처럼 보이지만 그것은 일종의 눈속임일 뿐이고 자바스크립트는 오직 한 번에 하나의 작업만을 수행한다.

     

    그렇기 때문에 위의 timeout 이벤트는 queue에 줄 서서 하나씩 차례대로 스택으로 이동하여 실행된다.

    그러므로 setTimeout()과 같은 메소드로 어떤 시간을 설정한다고 해서 

    아주 정밀하고 정확한 시간이 보장되는 것은 아니다. 

    모두 미세한 오차가 존재하며 브라우저는 단지 그 오차를 최소한으로 줄여줄 뿐이다.

     

     

     

     

    window.addEventListener('scroll', function () {
      console.log('hello');
    });

     

    만약에 우리가 스크롤 이벤트를 발생시키는 함수를 만들었다고 해보자.

    스크롤 이벤트는 실행해보면 느끼겠지만 아주 약간의 움직임에도 엄청나게 많은 이벤트가 실행된다.

     

     

    몇 번의 스크롤로 199번의 이벤트가 발생했다.

     

    이 경우 브라우저는 아주 많은 콜백을 callback queue에 적재하게 된다.

    만약 위와 같이 아주 간단한 코드 한 줄이라면 상관없겠지만 

    매우 복잡한 이벤트가 일어나야한다면 프로그램 성능에 좋지 않은 영향을 줄 것이다.

    그래서 우리는 디바운싱(debouncing)을 통해 이벤트가 큐에 적재되는 속도를

    느리게 만든다거나 하는 방법을 사용할 수 있다.

     

     

     

     


    참고 사이트:

    http://latentflip.com/loupe/

     

    http://latentflip.com/loupe/

     

    latentflip.com

     

    반응형

    COMMENT