03. JavaScript 실행
JavaScript 코드는 어떻게 실행될까?
앞에서 브라우저가 HTML과 CSS를 받아서 화면을 만드는 과정을 살펴봤다.
HTML
↓
DOM
CSS
↓
CSSOM
DOM + CSSOM
↓
Render Tree
↓
Layout
↓
Paint
↓
Composite
↓
화면그렇다면 JavaScript는 어디에서 실행될까?
JavaScript 파일을 브라우저가 읽었다고 해서 코드가 바로 위에서부터 단순하게 실행되는 것은 아니다.
JavaScript가 실행되기 위해서는 먼저 실행 환경이 만들어지고, 그 안에서 코드를 실행한다.
이 과정을 이해하기 위해 알아야 하는 핵심 개념이
실행 컨텍스트
↓
생성 단계 / 실행 단계
↓
호이스팅
↓
Call Stack
↓
Heap이다.
이 개념들을 따로 외우기보다,
"JavaScript 코드가 실행될 때 브라우저와 JavaScript 엔진 내부에서 무슨 일이 일어나는가?"
라는 관점에서 살펴보자.
1. JavaScript 엔진
먼저 JavaScript가 실행되는 장소부터 알아보자.
브라우저에는 JavaScript를 실행할 수 있는 JavaScript 엔진이 들어 있다.
대표적으로 Chrome과 Edge는 V8 엔진을 사용한다.
JavaScript 코드를 작성하면 브라우저는 이 코드를 JavaScript 엔진을 통해 실행한다.
const name = "Yonghee";
function sayHello() {
console.log(`Hello ${name}`);
}
sayHello();JavaScript 엔진은 이 코드를 읽고 실행하면서 변수와 함수 등을 관리해야 한다.
그런데 단순히
위에서부터 한 줄씩 실행만으로는 충분하지 않다.
함수가 호출되면 함수 내부에서 사용할 변수도 관리해야 하고,
함수가 끝나면 다시 원래 실행하던 위치로 돌아와야 한다.
예를 들어
const name = "Yonghee";
function sayHello() {
const message = `Hello ${name}`;
console.log(message);
}
sayHello();실행 과정은 대략 다음과 같다.
전역 코드 실행
↓
sayHello 함수 호출
↓
sayHello 함수 실행 환경 생성
↓
함수 내부 코드 실행
↓
함수 종료
↓
전역 코드로 돌아감JavaScript는 이런 실행 환경을 관리하기 위해 Execution Context(실행 컨텍스트)라는 개념을 사용한다.
2. 실행 컨텍스트(Execution Context)
실행 컨텍스트는 쉽게 말하면
JavaScript 코드가 실행되기 위해 필요한 정보를 담아놓은 실행 환경
이라고 생각하면 된다.
예를 들어 함수가 실행되면 JavaScript는 그 함수 안에서
function sayHello() {
const message = "Hello";
console.log(message);
}어떤 변수를 사용할 수 있는지,
어떤 외부 변수에 접근할 수 있는지,
this는 무엇인지 등을 알아야 한다.
이런 실행에 필요한 정보들을 관리하는 단위가 실행 컨텍스트다.
대표적으로 다음과 같은 실행 컨텍스트가 있다.
Global Execution Context
Function Execution Context
Eval Execution Context실무에서 가장 중요한 것은
전역 실행 컨텍스트
함수 실행 컨텍스트이다.
3. JavaScript를 실행하면 가장 먼저 전역 실행 컨텍스트가 만들어진다
JavaScript 코드가 실행되면 가장 먼저 전역 실행 컨텍스트가 만들어진다.
예를 들어
const name = "Yonghee";
console.log(name);를 실행하면 개념적으로 다음과 같은 과정이 시작된다.
JavaScript 실행
↓
Global Execution Context 생성
↓
전역 코드 실행그런데 실행 컨텍스트는 만들어지자마자 모든 코드를 바로 실행하는 것이 아니다.
크게 두 단계로 나누어 생각할 수 있다.
실행 컨텍스트 생성
↓
생성 단계
↓
실행 단계이 두 단계를 이해하면 호이스팅도 자연스럽게 이해할 수 있다.
4. 생성 단계와 실행 단계
생성 단계(Creation Phase)
먼저 JavaScript 엔진은 코드를 실제로 실행하기 전에 실행 환경을 준비한다.
개념적으로 보면
실행 컨텍스트 생성
↓
┌───────────────────┐
│ 생성 단계 │
│ │
│ 변수/함수 바인딩 준비 │
│ Lexical Environment │
│ this 결정 │
│ 외부 환경 연결 │
└───────────────────┘
↓
실행 단계이때 중요한 것은
코드를 실행하기 전에 실행에 필요한 환경을 먼저 준비한다는 것
이다.
예를 들어
console.log(name);
const name = "Yonghee";를 생각해보자.
실행 순서만 보면 name을 선언하기 전에 사용하고 있다.
하지만 JavaScript 엔진은 코드를 실행하기 전에 실행 환경을 준비한다.
여기서 let, const는 선언이 준비되더라도 초기화되기 전까지 접근할 수 없기 때문에 ReferenceError가 발생한다.
즉,
선언을 코드 위로 실제 이동시킨다라고 이해하면 안 된다.
이것이 바로 호이스팅(Hoisting)을 이해할 때 중요한 부분이다.
5. 호이스팅(Hoisting)
호이스팅은 흔히
"변수와 함수 선언이 코드의 위로 끌어올려진다."
라고 설명한다.
하지만 실제로 코드가 물리적으로 위로 이동하는 것은 아니다.
호이스팅은
JavaScript 엔진이 코드를 실행하기 전에 선언에 대한 정보를 준비하는 현상
으로 이해하는 것이 더 정확하다.
var의 경우
console.log(name);
var name = "Yonghee";실행하면
undefined가 출력된다.
개념적으로 보면 var는 선언과 동시에 초기화가 준비되기 때문이다.
var name;
console.log(name);
name = "Yonghee";와 비슷한 결과가 나타난다.
중요한 것은 실제 코드가 이렇게 변경되는 것은 아니라는 점이다.
let과 const
이번에는
console.log(name);
let name = "Yonghee";를 실행해보자.
이번에는
ReferenceError가 발생한다.
let과 const도 선언 자체는 실행 전에 처리되지만 초기화되기 전에는 접근할 수 없다.
이 구간을 TDZ(Temporal Dead Zone)라고 한다.
개념적으로 보면
let name 선언
↓
TDZ
↓
name 초기화
↓
사용 가능이다.
그래서
console.log(name); // ReferenceError
let name = "Yonghee";가 된다.
6. 함수 선언식도 호이스팅된다
함수 선언식은 조금 다르다.
sayHello();
function sayHello() {
console.log("Hello");
}이 코드는 정상적으로 실행된다.
왜냐하면 함수 선언식은 실행 전에 함수에 대한 정보를 준비하기 때문이다.
개념적으로 보면
실행 컨텍스트 생성
↓
sayHello 함수 등록
↓
코드 실행
↓
sayHello() 호출과 같은 흐름으로 이해할 수 있다.
반면 함수 표현식은 다르다.
sayHello();
const sayHello = function () {
console.log("Hello");
};const 변수에 함수가 할당되는 형태이기 때문에 sayHello가 초기화되기 전에 접근하게 되어 에러가 발생한다.
즉,
function sayHello() {}와
const sayHello = function () {}은 호이스팅 관점에서도 다르게 동작한다.
7. 실행 단계(Execution Phase)
생성 단계에서 실행 환경이 준비되면 이제 실제 코드가 실행된다.
const name = "Yonghee";
console.log(name);개념적으로 보면
[생성 단계]
name이라는 식별자를 위한 환경 준비
↓
[실행 단계]
name = "Yonghee"
↓
console.log(name)처럼 생각할 수 있다.
여기서 중요한 것은
생성 단계에서 실행에 필요한 환경을 준비하고, 실행 단계에서 실제 값을 계산하고 코드를 실행한다.
는 것이다.
이제 함수가 호출되면 어떻게 되는지 살펴보자.
8. 함수를 호출하면 새로운 실행 컨텍스트가 만들어진다
다음 코드를 보자.
const name = "Yonghee";
function sayHello() {
const message = `Hello ${name}`;
console.log(message);
}
sayHello();처음에는 전역 실행 컨텍스트가 존재한다.
Global Execution Context그리고 마지막 줄에서
sayHello();가 실행된다.
그러면 sayHello를 실행하기 위한 새로운 실행 컨텍스트가 만들어진다.
Global Execution Context
↓
sayHello()
↓
Function Execution Context즉,
함수가 호출될 때마다 해당 함수의 실행을 위한 실행 컨텍스트가 만들어진다.
라고 이해하면 된다.
그리고 이 실행 컨텍스트들은 아무렇게나 관리되는 것이 아니라 Call Stack에서 관리된다.
9. Call Stack
Call Stack은 이름 그대로 호출된 함수들의 실행 순서를 관리하는 스택(Stack)이다.
스택은
LIFO
Last In, First Out구조다.
쉽게 말하면
가장 나중에 들어온 것이 가장 먼저 나온다.
예를 들어
function first() {
second();
}
function second() {
third();
}
function third() {
console.log("third");
}
first();를 실행하면 다음과 같은 흐름이 만들어진다.
first()
↓
second()
↓
third()Call Stack에서는
┌─────────┐
│ third │ ← 가장 먼저 종료
├─────────┤
│ second │
├─────────┤
│ first │
├─────────┤
│ global │
└─────────┘처럼 쌓인다.
third()가 종료되면 빠지고,
third 제거그다음 second()가 종료되면
second 제거마지막으로 first()가 종료된다.
third
↓
second
↓
first
↓
global이렇게 실행 흐름을 관리한다.
10. Call Stack이 중요한 이유
브라우저에서 JavaScript가 실행되는 과정을 이해할 때 Call Stack은 매우 중요하다.
왜냐하면 JavaScript의 일반적인 코드 실행은 동기적으로 하나의 실행 흐름에서 진행되기 때문이다.
예를 들어
console.log("A");
console.log("B");
console.log("C");라면
A
↓
B
↓
C순서대로 실행된다.
그런데 하나의 함수가 매우 오래 실행된다면 어떻게 될까?
function heavyWork() {
for (let i = 0; i < 10_000_000_000; i++) {
// 무거운 작업
}
}
heavyWork();Call Stack에서
heavyWork()가 끝나지 않는 동안 다음 JavaScript 코드를 실행할 수 없다.
그리고 브라우저의 Main Thread에서 실행되는 작업이라면 사용자 입력이나 화면 업데이트에도 영향을 줄 수 있다.
이 문제는 뒤에서 다룰 Event Loop와 Web Worker와 연결된다.
11. Call Stack은 어떻게 쌓이고 빠지는가?
조금 더 자세히 보면
function a() {
b();
}
function b() {
c();
}
function c() {
console.log("Hello");
}
a();실행 과정은 다음과 같다.
① 전역 코드 실행
Global② a() 호출
a
Global③ b() 호출
b
a
Global④ c() 호출
c
b
a
Global⑤ c() 종료
b
a
Global⑥ b() 종료
a
Global⑦ a() 종료
Global⑧ 전역 코드 종료
Empty이 구조를 이해하면 나중에 Stack Overflow가 왜 발생하는지도 이해할 수 있다.
12. Stack Overflow
대표적인 예가 재귀 함수다.
function recursion() {
recursion();
}
recursion();recursion()이 다시 recursion()을 호출한다.
그러면
recursion
recursion
recursion
recursion
recursion
...계속 Call Stack에 쌓이게 된다.
결국 Stack이 감당할 수 있는 범위를 넘어가면
Maximum call stack size exceeded와 같은 오류가 발생한다.
즉,
Call Stack은 무한하게 쌓을 수 있는 공간이 아니다.
13. 그렇다면 Heap은 무엇일까?
JavaScript를 설명할 때 Call Stack과 함께 자주 등장하는 것이 Heap이다.
둘의 역할은 다르다.
간단하게 생각하면
Call Stack
→ 실행 흐름 관리
Heap
→ 동적으로 생성되는 객체 등의 메모리 영역이라고 이해할 수 있다.
예를 들어
const user = {
name: "Yonghee",
age: 30
};라는 객체가 있다고 하자.
개념적으로 보면 객체와 같은 동적으로 생성되는 값은 Heap 영역에서 관리되고,
변수 user는 그 객체를 가리키는 참조를 가지고 있다고 생각할 수 있다.
Call Stack Heap
user ────────────────→ {
name: "Yonghee",
age: 30
}여기서 중요한 것은
user = 객체 자체라고 단순하게 생각하기보다는
user
↓
객체에 대한 참조
↓
Heap에 존재하는 객체라고 이해하는 것이다.
14. 원시값과 객체
JavaScript의 값을 크게 나누면
Primitive Value
Reference Value로 생각할 수 있다.
원시값에는
const age = 30;
const name = "Yonghee";
const isDeveloper = true;처럼 숫자, 문자열, 불리언 등이 있다.
반면 객체는
const user = {
name: "Yonghee"
};처럼 여러 값을 묶어 관리한다.
특히 JavaScript의 객체는 다른 변수나 객체가 참조할 수 있기 때문에 메모리와 관련된 문제를 이해할 때 중요하다.
이 부분은 뒤에서 Heap → Reference → Garbage Collection → Closure → Memory Leak으로 이어진다.
15. 실행 컨텍스트와 Call Stack의 관계
지금까지의 내용을 연결하면 훨씬 이해하기 쉽다.
JavaScript 코드가 실행된다.
JavaScript 실행
↓
Global Execution Context 생성
↓
Call Stack에 들어감그리고 함수가 호출된다.
함수 호출
↓
Function Execution Context 생성
↓
Call Stack에 추가함수가 종료된다.
Function Execution Context
↓
Call Stack에서 제거전체적으로 보면
JavaScript 실행
↓
Execution Context 생성
↓
Call Stack에 push
↓
코드 실행
↓
함수 호출
↓
새로운 Execution Context
↓
Call Stack에 push
↓
함수 실행
↓
함수 종료
↓
Call Stack에서 pop이렇게 연결된다.
16. 실행 컨텍스트는 무엇을 가지고 있을까?
실행 컨텍스트를 조금 더 깊게 보면 실행에 필요한 여러 정보를 가지고 있다.
대표적으로
Lexical Environment
Variable Environment
This Binding
Outer Environment Reference등의 개념이 연결된다.
특히 중요한 것은 Lexical Environment다.
Lexical Environment는 쉽게 말하면
현재 코드에서 사용할 수 있는 식별자와 그 값을 관리하고, 외부 환경과의 관계를 연결하는 환경
이라고 이해할 수 있다.
예를 들어
const name = "Yonghee";
function sayHello() {
const message = `Hello ${name}`;
console.log(message);
}
sayHello();name은 sayHello 함수 내부에서 선언되지 않았다.
그런데도 사용할 수 있다.
왜 가능할까?
함수의 실행 환경이 자신의 외부 환경과 연결되어 있기 때문이다.
sayHello 실행 환경
│
│ 외부 환경 참조
↓
Global 실행 환경
│
↓
name이 개념이 바로 이후에 배울 Scope와 Closure를 이해하는 기반이 된다.
17. "렉시컬(Lexical)"이라는 말은 무엇일까?
처음 보면 Lexical Environment라는 이름이 어렵게 느껴질 수 있다.
Lexical은 쉽게 말하면
코드가 작성된 구조와 위치를 기준으로 한다
는 의미로 이해하면 된다.
예를 들어
const name = "Yonghee";
function sayHello() {
console.log(name);
}name을 어디서 찾을 수 있는지는 함수가 실행되는 위치가 아니라 코드가 작성된 구조와 관련이 있다.
그래서 JavaScript를 Lexical Scope 기반의 언어라고 설명한다.
이것은 뒤에서 Closure를 이해할 때 매우 중요하다.
18. 지금까지의 내용을 하나로 연결하면
이제 JavaScript 실행을 처음부터 다시 보자.
const name = "Yonghee";
function sayHello() {
const message = `Hello ${name}`;
console.log(message);
}
sayHello();브라우저에서 JavaScript가 실행되면
① JavaScript 실행 시작
↓
② Global Execution Context 생성
↓
③ 생성 단계
↓
④ 전역 변수/함수 등에 대한 실행 환경 준비
↓
⑤ 실행 단계
↓
⑥ sayHello() 호출
↓
⑦ Function Execution Context 생성
↓
⑧ 생성 단계
↓
⑨ 함수 내부 실행 환경 준비
↓
⑩ 실행 단계
↓
⑪ message 생성
↓
⑫ name 조회
↓
⑬ console.log 실행
↓
⑭ 함수 종료
↓
⑮ Function Execution Context 제거
↓
⑯ 전역 코드 계속 실행그리고 이 실행 컨텍스트들의 실행 순서를 관리하는 것이
Call Stack이다.
객체 등의 동적으로 생성되는 값은 메모리에서 관리되며,
Heap이라는 개념과 연결된다.
19. 그런데 여기서 하나의 문제가 생긴다
지금까지의 설명만 보면 JavaScript는 단순하다.
코드 실행
↓
Call Stack
↓
함수 실행
↓
종료그런데 실제 브라우저에서는 이런 코드가 존재한다.
setTimeout(() => {
console.log("Hello");
}, 1000);
console.log("World");우리가 기대하는 결과는
World
Hello다.
그렇다면 setTimeout의 1초를 기다리는 동안 JavaScript는 무엇을 하고 있을까?
만약 Call Stack에서
setTimeout이 1초 동안 계속 기다린다면 다음 코드인
console.log("World");도 실행할 수 없을 것이다.
하지만 실제로는 그렇지 않다.
setTimeout
↓
브라우저에게 작업 요청
↓
JavaScript는 다음 코드 실행
↓
World 출력
↓
시간이 지나면 callback이 실행될 수 있도록 대기이 지점부터 JavaScript 엔진만 알아서는 부족하다.
브라우저가 제공하는 기능인
Web API와
Task Queue
Microtask Queue
Event Loop를 함께 이해해야 한다.
20. 다음 글에서 Event Loop로 넘어가는 이유
지금까지 우리는 JavaScript가
Execution Context
↓
Call Stack
↓
코드 실행이라는 구조로 동작한다는 것을 살펴봤다.
그런데 브라우저에서는
fetch(...)
setTimeout(...)
Promise
addEventListener(...)처럼 JavaScript 코드만으로 처리하기 어려운 비동기 작업이 존재한다.
이런 작업은 브라우저의 Web API와 연결되고,
작업이 완료되면 callback이나 Promise의 후속 작업이 특정 Queue에 들어간다.
그리고 Event Loop가 Call Stack의 상태를 확인하면서 실행할 수 있는 작업을 연결한다.
전체 흐름을 미리 보면 다음과 같다.
JavaScript
↓
Call Stack
↓
Web API
↓
Task Queue / Microtask Queue
↓
Event Loop
↓
Call Stack따라서 다음 글에서는
Web API
↓
Promise
↓
Microtask Queue / Task Queue
↓
Event Loop을 하나의 흐름으로 연결해서 살펴볼 것이다.
그리고 이 내용을 이해하면
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");왜
A
D
C
B순서로 출력되는지도 자연스럽게 이해할 수 있다.
마무리
이번 글에서 중요한 것은 각각의 정의를 외우는 것이 아니다.
JavaScript가 실행되는 큰 구조를 잡는 것이 먼저다.
JavaScript 코드
↓
Execution Context
↓
생성 단계
↓
실행 단계
↓
Call Stack
↓
함수 호출
↓
새로운 Execution Context그리고 메모리 관점에서는
Call Stack
→ 실행 흐름
Heap
→ 객체 등의 동적 데이터가 관리되는 메모리 영역으로 생각할 수 있다.
여기까지 이해하면 JavaScript가 단순히
"위에서부터 코드를 실행하는 언어"
가 아니라,
실행 환경을 만들고, 실행 컨텍스트를 구성하고, Call Stack을 통해 실행 흐름을 관리하는 언어
라는 것을 볼 수 있다.
그리고 여기서 자연스럽게 다음 질문이 생긴다.
그런데setTimeout,fetch,Promise같은 비동기 작업은 Call Stack 하나만으로 어떻게 처리할까?
이 질문의 답이 다음 글에서 다룰 Event Loop다.