[Javascript] 동작원리 - Execution Context, Call Stack, Memory Heap, Task Queue 그리고 Event Loop

yongtae·2024년 5월 17일
0

Javascript

목록 보기
4/4
post-thumbnail

먼저 실행 컨텍스트, 콜 스택/메모리 힙을 통해 자바스크립트가 코드를 실행할 경우 어떻게 관리하는지를 알아보자.

스택과 힙의 개념은 운영체제 시간에 배운 것과 동일한 개념이다.
프로세스(스레드)에서 Stack은 정적인 영역으로 static한 변수들을 저장하고, Heap은 동적인 영역으로 dynmaic한 변수들을 저장하는 역할을 수행한다.

이 개념은 자바스크립트 엔진에도 동일하게 적용된다.
자바스크립트는 싱글스레드 언어이므로 하나의 콜 스택을 가진다.

콜 스택에 대해 알기 전에 먼저 실행 컨텍스트에 대해서 알아보자.

1. Execution Context

실행 컨텍스트
실행 가능한 코드에 제공할 '환경 정보'를 모아놓은 객체.
변수 객체, 스코프 체인, this 정보 등을 포함하고 있다.

특정 코드가 실행되면 그 코드에 해당하는 실행 컨텍스트가 콜 스택에 쌓여 관리된다.
이를 통해 코드의 실행 환경(Context)과 순서(Stack의 LIFO)를 보장한다.

1-1. 컨텍스트 환경 정보

아래는 실행 컨텍스트의 주요 요소들이다.

1) 변수 객체 (Variable Object)

세가지 정보를 담고 있다.

a. 변수

처음 컨텍스트 생성 시에는 변수는 선언만 되어있고, 초기화는 되어있지 않아 undefined 상태이다.

b. 매개변수(parameter)와 인수(argument)

함수에 전달에 사용되는 매개변수와 이에 전달된 값인 인수를 할당한다.

let func = (a) =>{};
func(1);

위 코드에서 a는 매개변수, 1은 인수이다.

c. 함수 선언식 (함수 표현식은 제외)

함수 선언식 형태는 포함하고, 표현식 형태로 되어있는 것은 undefined 상태로 생성된다.

function a() {
  //do something
};
let b = function() {
  //do something
};

여기서 a와 같이 선언하는 것이 함수 선언식이고, b와 같이 선언하는 것이 함수 표현식이다.

변수객체 생성과정

function example(a) {
    let b = 2;
    function c() {}
    let d = function() {};
    b = 3;
}

example(1);

다음과 같은 코드를 실행시켰다고 해보자.

{
    a: 1, //parameter : argument
    b: undefined, // 변수선언 undfined
    c: function c() {}, // 함수 선언식이므로 선언 포함
    d: undefined // 함수 표현식이므로 undefined
}

위와 같은 느낌의 변수 객체가 컨텍스트 내부에 생성된다.
그리고 이 함수 객체는 코드가 실행될 때 다시 할당 받아 undefined를 실제 값으로 초기화 한다.

2) 스코프 체인 (Scope chain) (렉시컬 콘텍스트)

스코프 체인 말 그대로, 해당 컨텍스트가 속한 스코프와 상위 스코프에 대한 정보를 담고 있다.
변수 참조시 스코프 체인을 따라 현재 스코프에서 상위 스코프로 이동하며 변수를 찾는다.

(즉, 함수가 선언될 당시의 렉시컬 컨텍스트를 기억하고 있는다는 말과 같다.)

3) this 정보(this binding)

this 키워드가 참조하는 객체에 대한 정보를 담고있다.
즉, 해당 컨텍스트에 바인딩된 객체에 대한 정보를 말한다.

1-2. 실행 컨텍스트의 종류

실행 컨텍스트는 크게 두 가지로 나뉜다.
두 컨텍스트 모두 실행 시 각 상황에 맞게 환경정보를 가지고 콜 스택에 추가된다.

1) 전역 실행 컨텍스트

  • JS가 처음 실행될 때 제일 처음 생성되어 스택에 추가된다.
  • 모든 코드가 종료될 때 까지 스택 최하단에 남아 유지되다가 모두 끝나면 사라진다.
  • 전역 객체, 전역 스코프에 대한 정보를 담고있다.
  • 전역 실행 컨텍스트의 변수객체는 Global Object(GO)라고 명명한다.

2) 함수 실행 컨텍스트

  • 함수가 실행될 때 생성되어 스택에 추가된다.
  • 함수가 종료되면 해당 컨텍스트가 사라진다.
  • 함수의 정보인 매개변수, 지역 변수, 함수 선언문 등에 대한 정보를 담고있다.
  • 함수 실행 컨텍스트의 변수객체는 Activation Object(AO)라고 명명한다.

2. Call Stack

Call Stack
1. 정적인 변수 (원시 타입 - string, number, boolean...)들을 관리하는 공간이다.
2. 실행 중인 코드, 함수의 실행 컨텍스트에 대한 정보도 여기서 관리한다.
스택이기에 LIFO 방식으로 관리한다.

2-1. 콜 스택, 메모리 힙의 변수 저장 상태

위와 같이 모든 정적 변수에 대한 정보를 담고 있다.
객체 또한 객체 내용 값 자체는 힙에 저장되지만, 참조하는 대상은(객체 이름) 콜 스택에 저장되어 있다.

2-2 .함수 실행시 콜 스택 작동 과정

  1. 컴파일 시 콜 스택에 전역 컨텍스트를 제일 먼저 push한다.
  2. 함수 호출 시 콜 스택에 해당 함수 콘텍스트를 push한다.
  3. 해당 함수를 수행한 뒤에는 스택에서 pop한다.
  4. 콜 스택의 크기는 제한되어 있어, 스택의 할당 범위를 넘어서면 stack overflow가 발생한다.
  5. 모든 코드에 대한 수행이 끝나면 전역 컨텍스트도 pop하고 종료한다.

3. Memory Heap

Memory Heap
동적인 변수 (참조 타입 변수 - Array, Object, 함수)들을 관리하는 공간이다.

객체에 정보가 담겨져 있는 공간으로 객체의 정보에 따라 동적으로 늘어나거나 줄어든다.
객체 이름은 포인터(즉, 참조 값)로, 콜 스택에 저장이 되어있고 value에는 참조하는 메모리힙의 주소정보를 담고 있다.

번외) const 객체가 수정 가능한 이유

const obj = { name: "Alice" };
obj = { name: "Bob" }; //error : 객체 재할당 불가능
obj.name = "Bob"; //no error : 프로퍼티는 변경 가능

위 코드와 같이 const로 선언한 객체의 프로퍼티(요소) 변경이 가능하다.
const로 객체를 선언한다는 것은 완전히 새로운 객체를 참조하는 재할당만 막겠다는 것이고,
객체 내의 프로퍼티의 변경은 가능하다.

메모리 힙을 보면, 객체의 참조 정보는 콜 스택에, 객체의 내용은 메모리 힙에 저장된다.
따라서 우리는 콜 스택에 저장된 객체의 참조정보만 const로 설정하기 때문에 위 상황이 발생하는 것이다.

번번외) Object.freeze()

만약 객체 프로퍼티도 변경이 불가능하도록 하려면 Object.freeze()를 활용하면 된다.

const obj = { name: "Alice" };
Object.freeze(obj);
obj.name = "Bob"; //error

단, Object.freeze()는 얕은 프리즈만 수행하기 때문에 객체 내부에 또 객체 프로퍼티가 존재한다면 이 객체는 프리즈가 되지 않는다.
내부 객체 또한 프리즈하고 싶다면 재귀적으로 수동으로 프리즈를 시켜야 깊은 프리즈를 할 수 있다.

const obj = { 
  name: "Alice",
  innerObj: {
  	husband: "Bob",
  },
};

Object.freeze(obj);
obj.innerObj.husband = "Tom" //no error : 변경가능

Object.freeze(obj.innerObj);
obj.innerObj.husband = "Tom" //error

4. Task Queue

Task Queue(Event Queue, Callback Queue)
Web API를 통한 비동기 처리를 끝낸 콜백함수들이 콜 스택에 할당되기 위해 기다리는 공간
비동기 처리의 가장 핵심적인 요소라 할 수 있다.
큐이기에 FIFO 방식으로 관리한다.

자 이제 스택과 힙에 대해서는 자세히 알아봤으니 비동기 처리의 핵심인 태스크 큐에 대해서 알아보자.
(태스크 큐는 이벤트 큐, 콜백 큐 등 다양한 이름으로 불린다.)

이 태스크 큐는 좀 더 세분화 되어 마이크로 태스크 큐, 매크로 태스크 큐, 애니메이션 프레임 큐 등 다양하게 나눠진다.
(외에도 다양한 큐가 있으나, 이 3가지가 가장 주요한 큐로서 역할을 한다고 한다.)

‼️ 태스크 큐에 있는 비동기 처리 작업들은 콜 스택이 비어있을 때만 콜 스택에 할당된다는 것이다.

특정 동기 작업이 오래 걸려서, 스택에서 해당 동기 작업이 오랜 시간을 차지할 경우, 비동기 처리가 완료되더라도 콜 스택에 할당 받지 못해 실행 완료가 되지 못하게된다.

실행시간이 오래 걸리거나, 언제 끝날 지 모르는 것은 꼭 비동기로 처리를 하자!

이것이 바로 비동기 작업이 있는 이유이기도 하다.

콜스택과 태스크 큐를 통한 전체적인 작동과정은 마지막에서 다뤄보도록 하겠다.

4-1. Microtask Queue

태스크 큐 중에서도 가장 높은 우선순위를 가진 큐이다.

  • Promise의 then, catch, finally 콜백
  • MutationObserver 콜백
  • queueMicrotask로 예약된 콜백

등이 존재한다고 한다.
여기서 가장 중요한 것은 Promise 처리가 이 마이크로태스크 큐에서 수행된다는 것이다.
Promise에서 async/await 처리 시에는 await를 만날 경우, 해당 동기 작업이 마이크로태스크 큐로 옮겨진다.

Promise는 Web API등의 외부 환경을 사용하지 않고 자바스크립트 엔진 내부에서 자체적으로 이벤트 루프를 통해 비동기 작업을 조율을 한다고 한다.

4-2. Macrotask Queue

마이크로태스크 큐 다음으로 우선순위가 높다.
마이크로태스크 큐가 우선순위가 더 높으므로 마이크로태스크 큐가 비었을 때 매크로태스크 큐 처리가 수행된다.

  • setTimeout 콜백
  • setInterval 콜백
  • setImmediate 콜백 (Node.js 환경)
  • I/O 작업 콜백 (예: 파일 읽기, 네트워크 요청 등)
  • UI 이벤트 (예: 클릭, 입력 등)
  • fetch

등이 여기서 처리가 된다.

여기서 Web API가 비동기적으로 처리하는 setTimeout, setInterval은 매크로태스크 큐에서 처리된다고 한다.

fetch함수 또한 매크로태스크 큐에서 처리되는데, 네트워크 요청과 응답 처리는 매크로태스크 큐에서, 그리고 이 fetch가 반환한 Promise처리(resolve, reject)는 마이크로태스크 큐에서 관리한다.

4-3. Animation Frame Queue

브라우저의 렌더링 주기에 맞춰 콜백을 실행하는 큐이다.

  • requestAnimationFrame 콜백
    애플리케이션에서 애니메이션을 부드럽게 구현하기 위해 제공하는 브라우저 API라고 한다.

5. Event Loop

이벤트 루프는 위에서 설명했던 콜 스택과 태스크 큐를 지속적으로 모니터링한다.

  1. 자바스크립트 엔진은 콜 스택에서 동기 코드를 순차적으로 실행한다.
  2. 비동기 작업이 발생하면, 해당 작업은 백그라운드에서 실행됩니다.
  3. 비동기 작업이 완료되면, 그 작업의 콜백 함수가 태스크 큐에 추가된다.
  4. 이벤트 루프는 콜 스택이 비어 있는지 확인하고, 비어 있다면 태스크 큐에서 콜백 함수를 가져와 실행한다.

번외) 이벤트 루프의 종류

이벤트 루프는 또 여러가지 종류가 존재한다고 한다...
작동과정을 이해하는데 중요한 것은 아니니 넘어가도 좋을 듯 하다.

Window Event Loop

브라우저와 사용하는 이벤트 루프이다.
주로 DOM 이벤트 처리, 비동기 작업 처리, 렌더링 등을 담당한다.

Worker Event Loop

웹 워커(Web Worker)는 별도의 스레드에서 자바스크립트를 실행할 수 있다. 이 경우 웹 워커는 자체적인 이벤트 루프를 가지며, 워커 스레드에서 동작한다고 한다.

Worklet Event Loop

웹 워크렛(Worklet)은 웹 페이지에서 AudioWorklet, PaintWorklet 등으로 사용된다고 한다. 이러한 웹 워크렛은 워크렛 스레드에서 동작하며, 자체적인 이벤트 루프를 가진다.

Node.js Event Loop

Node.js는 브라우저와 달리 다른 형태의 이벤트 루프를 가진다.
렌더 태스크는 적고, I/O 태스크를 더 가진다고 한다.

6. 예시로 작동과정 살펴보기

해당 코드를 실행하면 어떻게 자바스크립트 엔진이 처리하는지를 살펴보자.

console.log("Start");

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

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

async function asyncFunc() {
    console.log("Async Start");
    await Promise.resolve();
    console.log("Async End");
}

asyncFunc();

console.log("End");

1) 동기 작업 처리

  1. console.log('start')가 콜 스택에 push되고 처리되어 Start를 출력한다. pop을 한다.
  2. setTimeout()이 Web API를 통해 비동기 처리된다. 0ms 이후 매크로태스크 큐에 push된다.
  3. Promise.resolve().then()마이크로태스크 큐에 push된다.
  4. asyncFunc()가 콜 스택에 push된다.
    4-1. console.log('Async Start')가 처리되어 Async Start를 출력한다.
    4-2. await Promise.resolve()마이크로태스크 큐에 push된다.
    4-3. asyncFunc()가 잠시 중단 되고, 마이크로태스크 큐로 옮겨진다.
  5. console.log('End')가 콜 스택에 push되고 처리되어 End를 출력한다. pop을 한다.

2) Microtask Queue 처리

  1. 동기 작업을 모두 처리하고, 마이크로태스크 큐 처리를 시작한다.
  2. Promise.resolve().then()이 실행된다. 내부의 console.log('Promise')를 콜 스택에 push하고, Promise를 출력한다. pop을 한다.
  3. await로 인해 대기하던 asyncFunc()를 다시 처리, console.log('Async End')를 처리하여 Async End를 출력한다.

3) Macrotask Queue 처리

  1. 마이크로태스크 큐가 비어있다면, 매크로태스크 큐 처리를 시작한다.
  2. setTimeout()내의 콜백이 처리되어 콜 스택에 console.log(Timeout)을 push한다. Timeout을 출력한다. pop을 한다.
  3. 최종적으로 모든 코드 수행을 완료했다.
Start
Async Start
End
Promise
Async End
Timeout

다음과 같은 결과가 나오게된다.

정리

loupe에서 콜 스택과 태스크 큐가 어떻게 작동하는지 이벤트 루프를 볼 수 있다.

자바스크립트는 하나의 이벤트 루프를 공유하면서 그 속에서 콜 스택, 태스크 큐를 통해 비동기 처리를 담당한다.

이번을 통해 자바스크립트가 어떻게 동작하는지 깊이 이해 할 수 있었다.
아무래도 비동기 처리가 많은 언어이다 보니 작동방식이 이해가 어려웠던 점이 많았는데,
동기처리 / 비동기 처리를 나눠서 해야하는 것의 중요성을 배웠다.

다음에는 Promise에 대해서 더 깊게 다뤄볼 예정이다.

참조

profile
성장하는 프런트엔드 개발자

0개의 댓글