console.log(a);
test();

var a = 1;

function test() {
  console.log(b);
  var b = 2;
}

이 코드의 실행 결과는 어떻게 될까?

실행 결과는 다음과 같다.

undefined
undefined

console.log(a)는 에러가 발생하는 것이 아니라 undefined를 출력한다.
또한 test() 함수도 정상적으로 호출되며, 함수 내부의 console.log(b) 역시 undefined를 출력한다.

이를 통해 자바스크립트는 코드를 단순히 위에서부터 한 줄씩만 실행하는 것이 아니라, 실행 전에 먼저 필요한 정보를 준비한 뒤 코드를 실행한다는 것을 알 수 있다. 그럼 어떻게 이런 일들이 가능한지 알아보도록 하자

JS에는 실행 컨텍스트라는 개념이 존재한다.
한 줄로 정리해보자면 코드를 실행하기 위해 필요한 정보를 저장하는 실행 단위이다.

실행 컨텍스트는 크게 2가지로 나뉜다

1. 전역 실행 컨텍스트
2. 함수 실행 컨텍스트

이 2가지의 차이점을 알아보기 전에 이 실행 컨텍스트 내부도 어떻게 이루어져 있는지 알아보자.

실행 컨텍스트는 갑자기 생겨나지 않는다. 실행 컨텍스트가 생성되는 과정도 매우 중요하다. 어떤 정보들이 실행 컨텍스트에 저장되는지 알아보자

실행 컨텍스트를 이루는 요소들

기본적으로 아래와 같이 이루어져 있다.

  1. Lexical Environment
  2. Variable Environment
  3. This Binding

입문 단계에서는 Lexical Environment와 Variable Environment를 매우 비슷한 역할을 하는 구조로 이해해도 괜찮다.

이제 다시 돌아가 전역 컨텍스트부터 확인해보자.

전역 실행 컨텍스트

말 그대로 전역 범위로 선언된 변수와 함수를 관리하는 컨텍스트이다.
자바스크립트 코드가 실행되면 가장 먼저 전역 실행 컨텍스트가 생성된다.

console.log(a);
test();

var a = 1;

function test() {
  console.log(b);
  var b = 2;
}

이 코드를 예시로 들어보겠다.
여기에서는 var로 선언된 변수 a와 test 함수의 선언 부분만 보면 된다.

전역 컨텍스트가 생성되면서 a는 undefined로 초기화 되고, test는 test 함수의 객체와 연결된다.

실행 컨텍스트 내부의 환경 정보에는 식별자들을 저장하는 Environment Record가 포함되며, a와 test 같은 선언 정보가 여기에 등록된다.

또한, 중요한 것은 test가 연결되는 것은 test 함수의 구현부가 아니라 test 함수의 객체라는 것이다.

이처럼 JS 엔진은 코드를 실행할 때 가장 먼저 전역 실행 컨텍스트를 생성한다.

함수 실행 컨텍스트

함수 실행 컨텍스트는 말 그대로 함수를 실행하기 위한 정보를 가지고 있다.

함수 실행 컨텍스트는 함수가 선언될 때가 아니라, 실제로 호출될 때 생성된다.

전역 컨텍스트와의 차이점이라고도 볼 수 있다.
전역 컨텍스트는 JS가 코드를 실행하기 전에 생성된다.
하지만 함수 실행 컨텍스트는 전역 실행 컨텍스트가 생성되고, 전역 코드가 실행되면서 자신이 호출될 때 만들어진다는 것이다.

JS 엔진이 일하는 순서를 대충 보면 이렇다

1.	전역 코드 평가(준비)
2.	전역 코드 실행
3.	실행 중 함수를 만나서 함수 호출
4.	그때 함수 실행 컨텍스트 생성
5.	함수 코드 평가(준비)
6.	함수 코드 실행

함수 실행 컨텍스트도 전역 실행 컨텍스트와 비슷하게 동작한다.
함수 내부에 선언된 변수, 함수, 매개변수 등의 정보를 저장하고 실행에 필요한 나머지 정보도 함께 관리한다.

이제 대충 실행 컨텍스트가 코드를 실행하기 위해 정보들을 저장해준다는 것은 알 것이다. 그런데 이 실행 컨텍스트는 과연 어떻게 관리되는가?
이것을 알아보면 실행 컨텍스트에 대해서 조금 더 구조화된 이해가 가능하다.

콜 스택

콜 스택은 모든 실행 컨텍스트를 저장하는 JS 엔진의 자료구조이다.

이렇게 Last In First Out으로 컨텍스트들이 관리된다.

Low Level 언어의 스택 프레임과 비슷한 방식으로 이해해도 된다.

자신이 호출될 때 스택에 쌓이게 되고, 함수가 종료되면 스택에서 pop된다.

콜 스택은 예제를 이해하는게 더 직관적이다.

function a() {
  console.log("a start");
  b();
  console.log("a end");
}

function b() {
  console.log("b start");
  c();
  console.log("b end");
}

function c() {
  console.log("c");
}

a();

해당 예제의 출력 순서는 아래와 같다

a start
b start
c
b end
a end

콜 스택에 쌓이는 순서는 a -> b -> c
pop되는 순서는 c -> b -> a

a를 예로 들어보면, b,c가 작업을 모두 마칠 때까지 콜 스택에 계속 남아있게 된다.

콜 스택의 동작원리는 이렇다.

그런데 이 부분에서 궁금한 점이 생길 수도 있다.

setTimeout같은 비동기 처리는 어떻게 하는거지?

지금까지 알아본 바로는 콜 스택에 쌓이면서 순서대로 작업을 처리하는 것 같은데, JS에는 순서대로 작업을 하지 않는 것처럼 보이는 비동기 작업들이 여러개 존재한다.

JS 엔진은 이런 비동기 작업을 처리하기 위해서 별도의 자료구조를 이용한다.

비동기 작업을 위한 자료구조를 콜백 큐라고 부른다.

콜백 큐

콜백 큐는 크게 2가지로 나뉜다.

  1. 마이크로 태스크 큐
  2. 매크로 태스크 큐

마이크로 태스크 큐에는

  • Promise
  • async / await
    등이 들어갈 수 있다.

매크로 태스크 큐에는

  • setTimeout
  • setInterval
    등이 들어갈 수 있다.

이 둘 사이에도 우선순위가 있는데, 마이크로 태스크 큐가 매크로 태스크 큐보다 높은 우선순위를 가진다.

따라서 Promise와 setTimeout이 동시에 큐에 존재하면 Promise 작업부터 처리된다.

하지만, 코드의 실행은 콜 스택에서 관리한다고 하지 않았던가?

그럼 콜백 큐에 있는 작업들은 어떻게 실행이 되는것일까?
이를 이해하려면 이벤트 루프라는 친구를 알아야 한다.

이벤트 루프

기본적으로 콜 스택의 상태를 계속해서 감시하는 역할이다.
조금 더 직관적으로는 그냥 관리자라고 생각하면 편하다.

콜 스택이 비어있는지 확인하고, 비어있다면 큐에 있는 작업들을 콜 스택으로 넘겨주는 역할을 한다.

이때 콜 스택이 비면, 이벤트 루프는 먼저 마이크로태스크 큐를 모두 처리한 뒤 매크로태스크 큐의 작업을 콜 스택으로 보낸다.

반복해서 확인하기 때문에 루프(Loop)가 붙은 것이라고 보면 된다.

그림으로 보면 아래와 같다.

예제로 이해해보자
아래 코드의 실행 결과는 무엇일까?

console.log("A");

setTimeout(() => {
  console.log("B");
}, 0);

Promise.resolve().then(() => {
  console.log("C");
});

Promise.resolve().then(() => {
  console.log("D");
});

console.log("E");

실행 결과는 아래와 같다.

A
E
C
D
B

setTimeout(..., 0)이라서 바로 실행되어야 하지 않나? 라는 의문이 생길 수도 있는데
setTimeout(..., 0)은 바로 실행되어야 한다는 뜻이 아니라, 0ms 이후에 실행될 자격을 얻는다는 것이다.

현재 실행 중인 전역 작업이 끝나기 전까지는 큐에 있는 콜백들이 실행되지 않는다. 전역 실행 컨텍스트가 아직 콜 스택에 남아 있기 때문이다.

그래서 A,E가 먼저 나오고 그 이후에 콜백 큐가 실행되는 것이다.

클로저

다음은 클로저에 대해서 이야기 해보겠다
일단 클로저란 내부 함수가 자신이 선언되었을 때의 렉시컬 환경을 참조하고 유지하는 현상이다.

아래 예시로 알아보자

function outer() {
  let count = 0;

  return function inner() {
    count++;
    console.log(count);
  };
}

const fn = outer();
fn();
fn();

이 코드의 결과는 아래와 같다.

1
2

outer 함수는 콜 스택에서 정리되었지만, inner 함수는 자신이 선언될 당시의 바깥 렉시컬 환경인 outer의 환경을 참조하고 있기 때문에 count에 계속 접근할 수 있다.

조금 더 자세하게는
inner 내부에는 count가 없기 때문에, inner가 선언될 때 연결된 바깥 렉시컬 환경인 outer에서 count를 찾아 증가시킨다.

그렇다면, inner가 기억하고 있는 환경은 어디에 존재하는 것일까?

outer가 호출되면 해당 함수의 렉시컬 환경이 생성되고, inner는 그 환경을 참조한다.

조금 더 나아가 아래 코드를 보자

function createCounter() {
  let count = 0;
  
  return function() {
    count++;
    return count;
  };
}

const counterA = createCounter();
const counterB = createCounter();

console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1

여기서 렉시컬 환경의 특징이 나타나는데, 바로
같은 함수이더라도, 호출이 여러번 되면 그만큼 독립적인 렉시컬 환경이 생성된다는 것이다

위 코드에서는 counterA가 참조하는 렉시컬 환경과 counterB가 참조하는 렉시컬 환경이 다르기 때문에 count값이 다른 것이다.

0개의 댓글