JavaScript의 평가, 실행, 그리고 비동기 처리까지 정리해보자
2025-07-12

최근 기본기를 다져보자! 라는 마음에 예전에 구매하고 완독하지못한 모던 자바스크립트 Deep Dive라는 책을 다시 펼쳐보았고 쭉 살펴봤다. 그때 Javascript의 실행컨텍스트 라는 목차를 마주쳤고 "이런역할이구나" 라고만 알고 넘어갔던 부분을 실제로 깊게 살펴보는 시간이어서 한번 정리해보려 한다.


실행 컨텍스트

혹시 ECMAScript가 소스코드를 전역, 함수, 모듈, Eval 코드로 구분하는걸 알고있었나? 소스코드는 기본적으로 평가 -> 실행과정을 거친다. 이 평가과정에서 소스코드 실행에 필요한 정보(변수 및 함수선언 등)이 평가되어 저장되는 어떠한 환경이 만들어지는데 이게 실행 컨텍스트다.

이 환경이 소스코드마다 다르기때문에 구분되어지는데, 예를들면 함수코드평가 -> 함수 실행컨텍스트가 생성됐을때, 해당 컨텍스트에는 매개변수 및 arguments객체라는 특별한 정보가 보관되어있는거다.

렉시컬 환경

실행컨텍스트의 구성요소이자 위의 환경이 저장되는 장소다. 정확히는 렉시컬환경 -> 환경레코드에 식별자를 key로 객체형태로 저장되어있다.

편의상 실행컨텍스트 == 렉시컬환경 이라고 알고있자.

var foo = "foo";

function test(){
  var bar = "bar";
  console.log(window.foo); // 'foo'
  console.log(window.bar); // undefined
}

test()

var 선언은 전역객체 즉 window에 프로퍼티로써 참조가 가능하다는건 알고있을것이다. 근데 bar는 왜 undefined 일까? 이는 둘의 소스코드가 달라 생성되는 실행컨텍스트가 다르기 때문이다.

foo는 전역코드 즉 전역실행컨텍스트에 등록되어있는데, 여기에는 전역객체가 같이 저장되어있다. 하지만 bar의 함수실행컨텍스트는 전역객체가 포함되어있지 않다. 그래서 참조가 안되는것이다. 추가로 환경레코드 이외에 선언적-환경레코드도 존재하는데 여기에는 let,const등 블록스코프의 성향이 강한 식별자들이 보관된다.

호이스팅

Javascript를 공부하다보면 var 변수선언과 함수선언에 적용되는 호이스팅에 대해 들어봤을거다. 사실 let,const 변수선언 모두 코드평가 과정에서 렉시컬환경이 생성되고 식별자가 등록되기 때문에 엄밀하게 호이스팅 자체는 모두 발생한다. 하지만 var는 선언과 동시에 평가과정에서 undefined로 초기화가 되기때문에 참조가능한 문제가 발생하는 것이고, let/const는 코드 실행과정에서 초기화가 일어나기때문에 참조를 하지못하는 TDZ 상태인것 뿐이다.

외부 렉시컬환경 참조

실행컨텍스트의 구성요소로 현재 평가중인 소스코드의 외부 렉시컬환경에 대한 참조다. 이를 이용한 스코프-체이닝을 통해 실행중인컨텍스트에서 변수를 찾을 수 없는경우 외부 렉시컬환경으로 넘어가 변수를 찾고 결국 전역실행컨텍스트까지 도달하는 것이다. 당연하겠지만 전역실행컨텍스트는 가장 상위기 때문에 해당 값이 null이다.

중요한건 렉시컬환경은 코드평가 과정에서 생성된다는 것이다. 즉 함수로 예를들면 그 함수의 외부 렉시컬환경에 대한 결정은 함수실행이 아닌 선언시점인 것이다.

클로저

클로저를 보통 함수를 return하는 함수로 알고있는 경우가 종종있다. 클로저의 정의는 함수와 그 함수가 선언된 렉시컬 환경의 조합이다. 그렇다면 아래함수는 클로저일까?

function outer(){
  const foo = "foo";

  function inner(){
      const bar = "bar";

      console.log(bar);
  }

  return inner;
}

outer()
}

만약 클로저를 함수를 return하는 함수로 알고있다면 yes라고 대답하겠지만. 위의 설명을 이해하고 클로저 정의를 생각한다면 위함수는 클로저가 아니다. 왜냐면 inner함수가 외부 outer 렉시컬환경에 대한 참조가 없기때문이다.


JS 엔진

image

위에서 코드 평가 -> 실행을 거친다고 했는데 이 코드실행 순서즉 실행컨텍스트를 관리하기위해 콜스택을 사용하고, Heap(힙)에는 처음부터 메모리공간을 할당할 수 없는 동적인데이터 즉 배열, 객체, 함수등이 저장되는 공간이다. int, boolean 등의 불변데이터는 렉시컬환경에 저장되고 위의 동적인 데이터는 힙에 저장되고 그 힙의 주소만 렉시컬환경에 저장되는 것이다.

여기서 중요한건, JS엔진은 저 2개가 및 소스코드 평가 + 실행및 컨텍스트 관리의 역할이 전부다. 이벤트루프, 태스크큐를 통한 비동기처리는 모두 JS 런타임환경의 기능인것이다.


비동기

Javascript가 싱글스레드여서 한번에 한가지의 작업만 한다는것, 비동기 처리의 개념도 알고는있지만, 딱 여기까지만 알고있다면 Javascript는 멀티작업이 가능하다는 소리가 되버린다. 비동기 작업이 가능한 이유는 브라우저 및 Node가 해당 비동기작업을 처리하기 때문인걸 아는게 중요하다.

let foo = "foo";

setTimeout(() => { foo = "foo-foo" },0)

console.log(foo) // "foo"

setTimeout이 0인데도 불구하고 왜 "foo"일까? JS엔진은 코드를 평가하고 실행하기만 하며, 비동기작업은 호출준비가 완료되었을때 태스크큐라는 공간에 쌓이고 이벤트루프가 JS엔진의 콜스택이 비었을때 하나씩 작업을 올린다. 즉 JS엔진은 setTimeout 함수를 실행시킨다음 곧바로 console.log(foo)를 실행시키는데 이때 렉시컬환경에서 foo의 값은 "foo" 이기 때문이고. 그뒤에 foo = "foo-foo"가 실행되기때문에 그렇다.

여기까지는 모두 아는 내용이다. 그렇다면 다음 에러처리에선 어떤 결과가 발생할까?

try{
  const error = () => {
      throw new Error("Error!") 
  }

  setTimeout(error,0)
}catch(e){
  console.error("error!!",e)
}

마찬가지로 catch되지 않는다, setTimeout 함수 자체는 실행 즉시 콜스택에서 빠진다, 이후 error함수가 태스크큐에 있다가 콜스택으로 넘어와서 호출된다. 즉 호출은 setTimeout함수가 아니라 전역스코프에서 한다는건데 이시점에는 이미 try catch블록을 빠져나온 시점이기 때문이다.

설명이 길었지만 중요한건 비동기작업은 호출준비가 끝나면 태스크큐에 쌓여 콜스택이 비었을때 이벤트루프를 통해 콜스택으로 큐기때문에 FIFO로 쌓인다는 것이다. 이 비동기작업에는 이벤트핸들러 및 fetch등이 포함된다.

Promise

Javascript의 비동기작업시 빠질수 없는 Promise다. Promise는 비동기작업을 상태(pending, fulfilled, rejected)와 결과를 함께 컨트롤 하게 해주는 객체이다. 그럼 Promise는 어느시점에 태스크큐에 호출될 작업을 올릴까?

const promise = new Promise((resolve,reject) => {
  setTimeout(resolve,1000)
})
.then(i => console.log("now!!"))

바로 후속처리 메서드인 then, catch, finally가 실행되었을때 등록되는데 이때 태스크큐가 아닌 마이크로 태스크큐 라는 곳에 등록된다. 사실 태스크큐는 Promise의 후속메서드 및 await의 호출이 등록되는 마이크로 태스크큐와 이외의 호출이 등록되는 매크로 태스크큐로 나뉜다.

매크로태스크큐는 용어를 다르게 부르는 경우도 있는거같다. 중요한건 마이크로태스크큐는 매크로태스크큐보다 우선순위가 높다는것이다.

Promise 후속메서드 외에도 queueMicrotask, MutationObserver도 존재한다.

그럼 위의 내용을 종합한다면 다음 코드는 어떤순서로 log가 찍힐까?

console.log("A");

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

Promise.resolve().then(() => console.log("D")).then(() => console.log("E"))
console.log("F");
  • console.log("A") 실행
  • 첫번째 setTimeout 실행 -> 콜스택에서 빠짐 -> 100ms 뒤에 태스크큐에 쌓임
  • 두번째 setTimeout 실행 -> 콜스택에서 빠짐 -> 바로 태스크큐에 쌓임
  • Promise.resolve로 then 콜백함수가 마이크로 태스크큐에 쌓임 -> 한번더
  • console.log("F") 실행

위의 순서일텐데 콜스택에 쌓인 A,F가 차례로 빠져나오고 콜스택이 빈 이후 마이크로태스크큐의 작업(D -> E)부터 이벤트루프가 콜스택에 올리고, 그다음 나머지 태스크큐에 있는 작업(C -> B)이 올라갈것이다. 즉 A -> F -> D -> E -> C -> B 순서로 찍힌다.


마치며..

실행컨텍스트 부터 비동기처리까지 알아보았는데 알아본 계기가 사실 위의 문제를 틀렸기 때문이다..🥹 그냥 개념적으로만 알고 상세하게 생각을해보지 않은 결과였고 참 스스로가 부끄러워 바로 정리를 해봤고 이제라도 명확히 살펴봐서 너무너무 다행이라고 생각한다. 항상 새로운걸 채워넣기보다는 기존에 놓치고있던 개념이나 지식이 없는지를 점검해봐야겠다는 좋은 시간이었다.

명확히 이해하기 힘든 내용을 나름 쉽게 풀어썼다고 생각한다. 나말고도 다른사람에게도 유용한 정보였으면 좋겠다!