📕 시작

자바스크립트의 동작 원리에 대해서 알고 싶은 분들과 저의 학습을 위해 정리해 보려 합니다😀

자바스크립트는 기본적으로 싱글 스레드로 돌아가는 언어입니다.

  • 하지만 비동기적인 동작도 가능하기 때문에 헷갈리기 쉽다.

이를 위해, 먼저 Call stack, Heap, Web APIs, Queue들이 어떤 역할을 하는지 알아보겠습니다.

💡 Call stack(호출 스택)

기능 호출을 기록하는 데이터 구조로 프로그램에서 어떤 순서로 작업이 수행되는지 기록하는 작업 스케줄링과 관련된 자료 구조이다.
Call Stack은 기본적으로 자바스크립트를 한 줄씩 읽어가며 우리의 코드가 순서대로 돌 수 있도록 보장해주는 데이터 구조이다. 스택을 사용하기 때문에 후입 선출(LIFO, Last-In-First-Out)의 구조를 갖는다.

  • 실행하고자 하는 코드가 stack에 쌓이게 되고 이를 LIFO(후입선출) 방식으로 실행된다.

코드는 console.log(a) 같은 한 줄 코드 그리고 function fn() 같은 함수도 될 수 있습니다.

  • 함수의 경우 함수 내의 또 다른 함수의 호출이 있으면 계속 추적하여 stack에 쌓이게 된다.

call stack은 한 동작에 하나의 일만 수행하게 되는데 이러한 동작 방식을 싱글 스레드 또는 단일 스레드라고 합니다. 이것은 동기식 언어라고도 할 수 있습니다. (Single Call Stack)

만약 스택을 초과하게 된다면 어떻게 될까요? 무한 루프를 도는 코드를 돌리면 아래 처럼 Maximum call stack size 에러가 발생하게 됩니다.

무한 루프가 될 때를 그림으로 표현하면 밑의 그림처럼 표현할 수 있습니다.
Call Stack은 정해진 스택 사이즈가 존재하고, 하나씩 쌓이기 때문에 정해진 용량을 초과하게 되면 에러가 발생하게 됩니다. 흔히 이것을 Stack Overflow라고 말합니다.

자바스크립트는 이러한 특성 때문에 무한 루프는 발생할 수 있지만 동기화 문제인 교착상태(DeadLock)는 발생할 수 없습니다!

💡 Heap

메모리를 할당받는 영역

  • 변수와 같은 메모리를 할당받는 것들이 저장되는 공간입니다.

💡 비동기 로직

자바스크립트는 기본적으로 동기적 언어입니다.

  • 하지만 Timer, event listener, AJAX 등 비동기적 로직들을 사용하게 되면 call stack에 쌓였을 때 일반 코드와는 다르게 동작합니다.
  • 이러한 API들은 Web API라고 해서 웹 브라우저 혹은 nodejs 같은 자바스크립트 런타임에서 지원해주는 API입니다

위 그림을 보면 비동기적 코드는 call stack에 들어올 시 Web APIs로 보내지고 여기서 비동기적 로직이 수행된 다음 결과값이 Callback Queue에 쌓아뒀다가 call stack이 비어있을 때 값들이 call stack에 들어가게 됩니다.

예시를 보며 정리해 보겠습니다.

// 예시 코드
function goLunch(){
	console.log(“식사시간입니다.);
}

function work(){
	// 5초 이상 걸리는 작업 
	for(var i=0; i < 5000000000; i++);
	console.log(“work완료”);
}

function example(){
	var SECOND = 1000;
	setTimeout(goLunch,3*SECOND);
	work ();
		...
}

위 코드의 출력 순서는?

    1. 식사시간입니다 -> work완료
    1. work완료 -> 식사시간입니다.
      .
      .
      .
      결과는 2번입니다.
      계속해서 설명하겠습니다.

💡 JavaScript 실행 환경

아래 그림은 자바스크립트의 실행 환경을 나타낸 그림입니다.

다시 간단하게 설명해 보자면, 메서드가 호출될 때마다 스택에 쌓이고 힙 영역은 객체를 생성할 때 메모리 할당이 일어나는 곳입니다. 자바스크립트는 싱글스레드로 동작하기 때문에 단 하나의 스택이 존재합니다.

그럼 그림에서 이벤트 큐의 역할은 무엇일까요?

이벤트 큐란 이벤트가 발생했을 때 수행해야 할 콜백 함수를 대기시키는 곳입니다.
이벤트 큐에 대기 중인 콜백은 스택이 비어있을 때 처리됩니다.

💡 Event Queue, Event Loop, Browser API

앞서 보여준 예시 코드를 수행하는 환경을 나타낸 그림을 보며 더 구체적으로 살펴보겠습니다.

  • 실행 중인 코드는 스택 위에 올려져 실행되고 위 그림은 setTimeout이 실행되고 있는 순간입니다.

setTimeout은 파라미터로 지정된 시간이 지나면 콜백 메서드를 수행시키는 기능을 제공합니다.
이때 누군가 시간을 계산해야 지정된 시간 후에 메서드를 수행할 텐데 자바스크립트 엔진이 한다면 setTimeout 뒤에 있는 코드가 지정된 시간이 지나야 수행될 수 있어 비동기 처리를 할 수 없습니다.

다행히 이 작업은 브라우저가 대신 맡아 수행하게 됩니다.
코드는 자바스크립트 모습이지만, 자바스크립트가 아니라 브라우저에 기능을 위임하고 다음 코드를 수행하는 것입니다. 지정된 시간이 지나면 Timeout 이벤트가 발생해서 이벤트 큐에 콜백이 추가됩니다.

💡 Event Loop

스택과 큐의 상태를 확인하면서 스택이 비어있을 때마다 큐에 있는 콜백을 하나씩 실행시킨다.

큐에 추가된 콜백은 반복되는 과정을 통해 최종적으로 자바스크립트 엔진에 의해 실행됩니다.

    1. 이벤트 큐에 작업이 있는지 확인한다.
    2. 스택이 비었을 때 큐에 있는 작업을 꺼내 수행한다.

이러한 과정을 이벤트 루프라고 합니다.

만약, 스택에 작업이 계속 실행 중이라면 이벤트 큐에 계속해서 작업이 대기할 수밖에 없습니다.
즉, 스택과 이벤트 큐의 상황에 따라 의도한 대로 콜백이 실행되지 않을 수도 있습니다.

위 그림은 예시 코드의 실행 중 스택, 이벤트 큐, 브라우저 상태를 시간 흐름도로 표현한 것입니다.

이제는 앞서 봤던 예시 코드의 결과가 왜 2번인지 어떻게 수행되는지 알게 되었습니다.

goLunch가 3초 후에 이벤트 큐에 추가되고 실행을 기다리고 있지만 이벤트 루프가 work가 종료된 후에야 goLunch를 스택에 올릴 수 있기 때문에 3초가 지나도 “식사시간입니다.”가 출력되지 않는 것이었습니다.

성능 측면으로 고려할 수 있는것

  1. call stack을 바쁘게 하지 않는다.
  • 스택에 어려운 연산을 넣는다면 퍼포먼스가 떨어질 수 있다.
  1. 큐를 적게 사용한다.

💡 전역변수와 지역변수

전역변수: 제일 바깥 범위 최상단 범위에 만든 변수를 뜻한다.
지역변수: 함수 안에 들어있는 변수를 뜻한다.

// 예시 코드
var x = 'a'; // 전역변수
function ex() {
var x = 'b'; // 지역변수
x = 'c';
}
ex(); // x를 바꿔본다.
alert(x); // 바뀌지 않고 그대로 'a'

위에서 전역변수는 x이고 지역변수는 ex함수 내의 x변수가 해당됩니다.

위 코드에서 지역변수는 전역변수에 영향을 끼칠 수 없는데 그 이유는 함수 스코프 때문이다.

💡 Scope(스코프)

스코프는 범위란 뜻으로 변수들의 범위를 결정하는 것을 뜻한다. 함수 안에서 선언된 변수는 함수 안에서만 쓸 수 있다.

그렇기에 위 예시에서 ex함수 내의 var x= 'b'는 함수 안에서 선언된 변수이므로 함수 내에서만 적용됩니다. 따라서 아래 x = 'c'가 ex안에서의 x만 바꿔 밖에서는 그대로 a인 것입니다.

하지만 아래 예시 코드는 위 예와 달리 ex함수 안에서 변수를 선언하지 않았기 때문에 전역변수인 x = 'a' 가 c로 바뀌게 됩니다.

var x = 'a';
function ex() {
  x = 'c';
}
ex();
alert(x); // 'c'로 바뀜

위 결과가 나오는 이유는 자바스크립트가 변수의 범위를 호출한 함수의 지역 스코프부터 전역 스코프까지 점차 넓혀가며 해당 변수를 찾기 때문이다. 이 개념이 scope chain(스코프 체인)이다.

💡 Scope chain(스코프 체인)

자바스크립트에서 내부 함수는 외부 함수의 변수에 접근 가능하지만 외부 함수에서는 내부 함수의 변수에 접근할 수 없습니다.

var name = '제로';
function outer() {
  console.log('외부', name); // 외부 제로
  function inner() {
    var enemy = '네로';
    console.log('내부', name); // 내부 제로
  }
  inner();
}
outer();
console.log(enemy); // 접근 못함 undefined
  • 위의 예는 outher() -> inner() ...으로 진행되는데 inner함수는 name 변수를 찾기 위해 자기 자신의 스코프에서 찾고 없다면 상위 스코프에서 그래도 없으면 그 상위인 전역 스코프까지 접근하는 과정을 반복한다. 결국 전역 스코프에서 name 변수를 찾아 '제로' 값을 얻게 된다.

  • 하지만 console.log(enemy)는 외부에서 내부로 접근하지 못하기 때문에 inner 안에 enemy 값을 찾지 못하고 undefined가 발생한다.

이렇게 꼬리를 물고 계속 범위를 넓혀가면서 찾는 것을 Scope chain(스코프 체인)이라 한다.

💡 Lexical Scope(렉시컬 스코프)

함수를 어디서 호출하는지가 아니라 어디에 선언하였는지에 따라 결정되는 것을 말한다.

즉, 함수를 어디서 선언하였는지에 따라 상위 스코프를 결정한다는 뜻이며, 가장 중요한 점은 함수의 호출이 아니라 함수의 선언에 따라 결정된다는 점입니다.

함수가 선언됐을 때 형성되는 스코프를 다른 말로 정적 스코프(Static scope) 라 부르기도 합니다.

var name = '제로';
function log() {
  console.log(name); // 네로
}

function wrapper() {
  name = '네로';
  log();
}
wrapper();
  • 위 예시 코드는 상단의 스코프 개념에서 배웠기 때문에 이제 답을 알 수 있습니다.
    log 함수는 호출될 때가 아니라 선언될 때 이미 스코프가 형성이 되었지만
    log 함수가 호출되기 전에 전역변수 name에 재할당이 일어났습니다.
    따라서 console.log(name)은 '네로'를 출력하게 됩니다.
var name = '제로';
function log() {
  console.log(name);
}

function wrapper() {
  var name = '네로';
  log();
}
wrapper();
  • 하지만 이번 예시 코드에서는 다릅니다. log함수는 위에서 말했던 거와 같이 호출될 때가 아닌 선언될 때 이미 스코프가 형성이 되었고 해당 스코프에서 스코프체인을 통해 전역 변수 name인 '제로'를 찾아 반환하게 됩니다.
  • wrapper()에서 name은 지역 변수이기 때문에 전역 변수 name에는 영향이 없습니다.
    결국 wrapper 함수 안에서 log를 호출해도 var name = '네로'를 참조하는 것이 아닌 그대로 전역변수 name의 값 zero가 된다.

💡 동적언어 정적언어

동적 언어 (Dynamically Typed Language)

  • 런타임에 타입이 결정되는 언어.
  • 즉, 소스가 빌드 될 때 자료형을 결정하는 것이 아니라 실행 시 결정된다.
  • JavaScript, Ruby, Python 등은 대표적인 동적 언어이다.

정적 언어 (Statically Typed Language)

  • 컴파일 시간에 변수의 타입이 결정되는 언어.
  • 타입 즉, 자료형을 컴파일 시에 결정하는 것.
  • 정적 언어는 변수에 들어갈 값의 형태에 따라 자료형을 지정해 주어야 한다.
  • C, C++, Java 등은 대표적인 정적 언어이다.

자바스크립트 언어는 동적언어입니다, 하지만 유효범위는 코드가 작성되는 순간 정해지는 정적인 특성을 가집니다.
다시 한번 정리하면, 함수 안에서 정의된 변수는 함수 밖으로 빠져나갈 수 없다는 것이 핵심입니다.

✅ 마무리 복습

  • 마지막으로 자바스크립트의 비동기 작업 수행을 간단하게 그림으로 예시를 들어가며 설명하도록 하겠습니다.
  1. 먼저 first() 함수가 Call Stack에 쌓이게 됩니다.

  2. 그 다음 first() 함수 안에 있는 console.log()가 Call Stack에 쌓이게 됩니다.

  3. 콘솔 창에 1을 출력합니다.

  4. first() 함수는 종료되었으니 Call Stack에서 빠지게 되고, setTimeout()이 Call Stack으로 들어옵니다.

  5. setTimeOut()은 비동기 함수입니다. 그렇다면 이걸 Web API에서 처리하도록 보냅니다.
    만약 nodejs 같은 경우에는 백그라운드에서 처리하도록 보냅니다.
    그리고 그다음 함수인 second() 함수를 불러옵니다.

  6. second() 함수 안에 있는 console.log()가 Call Stack에 쌓이게 됩니다.

  7. 콘솔 창에 2를 출력합니다.

  8. second() 함수는 종료되었으니 Call Stack에서 빠지게 됩니다.
    이제 들어올 함수가 없는 거 같지만 아직 비동기 함수를 처리하고 있습니다.
    setTimeOut()의 시간을 2초로 설정해 두었으니 2초간은 Wab API에서 처리하게 됩니다.
    2초가 지난 후에는 setTimeOut()의 콜백 함수를 Callback Queue로 보내게 됩니다.

❗ 여기서 헷갈리면 안되는 게 Call Stack에 있던 second() 함수가 종료되고 나서야 Web API를 처리하는 게 아니고 setTimeOut()이 Web API로 넘어간 시점부터 처리되고 있는 것입니다.
즉, Call Stack에서는 second() 함수를 처리하고 있었고, Web API에서는 setTimeOut()을 2초 동안 돌리고 있었다는 것입니다.

  1. 이제 Event Loop가 등장하게 됩니다. Event Loop는 Callback Queue에 있는 콜백 함수를 Call Stack으로 보내서 처리하기 위해 Call Stack이 비어있는지를 검사합니다.
    만약 Call Stack이 비어 있다면 Callback Queue에 있던 함수를 Call Stack으로 보내서 처리하게 됩니다.

  2. 마지막으로 Call Stack에 있던 console.log()를 콘솔에 출력하는 것으로 프로그램이 종료됩니다.

이 글을 읽고 도움이 되셨으면 좋겠습니다. 감사합니다 😊

profile
# HoHo.log :)

0개의 댓글