Javascript로 코딩을 하다보면 싱글 스레드니 Stack이니 Heap이니 하는 말을 듣게 됩니다. 그런데 의외로 많은 분들이 대충 느낌은 아니까 정도로 넘어가는 경우를 많이 봤습니다. 의외로 제대로 아는 사람들이 드물더라구요. 한번 쯤은 정리하고 넘어가는 게 좋을 것 같다는 생각이 들어서 다시금 정리를 해보겠습니다. 메모리 누수나 Garbage Collection 등 Javascript의 심화 개념을 이해하기 위해 반드시 필요한 지식이라고 생각하거든요.
Call Stack
Javascript를 구동하는 V8 Engine에는 크게 두 가지 구성요소가 있습니다. Call Stack과 Memory Heap인데요, 간단히 설명하자면 Memory Heap에는 참조형 데이터가 저장됩니다. 객체나 배열 같은 것들이요. Call Stack에서는 원시 타입의 데이터가 저장되고, 호출된 함수를 쌓아두었다가 실행을 해줍니다. 실행을 할 때는 마지막에 들어온 것을 먼저 반환을 하게 되는데요, 이것을 LIFO(Last In First Out) 구조라고 부릅니다. 무슨 말인지 잘 이해가 안될 겁니다. 예시를 한번 볼까요?
function four() {
console.log('4')
}
function three() {
console.log('3')
four()
}
function two() {
console.log('2')
three()
}
function one() {
console.log('1')
two()
}
one()
우선 one()이 호출됩니다. 그러면 console에는 1이 찍히고 two()를 호출, 2가 찍힌 뒤 three()를 호출, 3이 찍힌 뒤 four()를 호출해서 실행 결과 4가 찍히게 됩니다. 이 시점에 Call Stack은 아래와 같습니다.
four()는 더 이상 실행할 코드가 없습니다. 따라서 Call Stack에서 제거됩니다.
three()도 더 이상 실행할 코드가 없네요. Call Stack에서 제거되겠죠?
이런 순서로 two()와 one()까지 제거되면 Call Stack은 텅 비게 됩니다. 간단하죠? 그러면 Stack을 쌓지 않고 그냥 호출하면 어떻게 되는지 궁금한 분도 있을 것 같습니다.
이 경우라면 당연히 one()이 종료되고 two()가 호출 및 종료된 뒤 three()가 호출되는 식으로 실행됩니다. Stack에 쌓일 이유가 없으니까요. 그렇다면 만약 setTimeout()과 같은 비동기 함수가 실행되게 되면 어떻게 될까요?
function three() {
console.log('3')
}
function two() {
console.log('2')
}
function one() {
console.log('1')
}
one()
setTimeout(() => {
two()
}, 1000)
three()
Javascript를 아는 사람이라면 위 코드의 결과가 1, 3, 2 순으로 찍히게 된다는 걸 아실 겁니다. 그런데 왜일까요?
동기(Synchronous)? 비동기(Asynchronous)?
setTimeout()이 비동기 함수라고 말씀드렸습니다. 굉장히 흔하게 들을 수 있는 단어입니다. 그런데 동기는 뭐고 비동기는 뭘까요? 일단 헷갈리지 말아야 할 것은 Javascript는 기본적으로 동기식 언어라는 점입니다. Javascript는 V8 Engine을 사용하는 언어로, 해당 엔진은 Single thread로 구성되어있습니다. 그래서 한 번에 하나의 작업만을 수행할 수 있는데 그 얘기인 즉슨, 어떤 작업을 처리하는 동안 다른 작업들은 멈춰서 대기해야 한다는 뜻입니다. 그렇다면 비동기는 뭘까요? 어떤 작업을 아직 처리하는 도중인데도 다른 작업을 먼저 수행할 수 있다는 뜻입니다. 분명히 Single thread로 구성되었다고 했는데 이상하게 들리시나요?
Task Queue와 Event Loop
setTimeout()과 같은 비동기 함수는 Call Stack에 쌓이지 않습니다. Task Queue라는 곳에 일시적으로 보관되게 되죠. 정확히 말하면, setTimeout()의 콜백 함수나 이벤트 핸들러 같은 친구들이 보관되는 곳이라고 보시면 됩니다. 이곳에 보관된 함수들은 자신을 불러줄 친구를 기다리는데요, 그 친구를 Event Loop라고 부릅니다. 이 Event Loop는 Call Stack에 현재 실행 중인 함수가 있는지, Task Queue에 대기 중인 함수가 있는지를 계속해서 확인합니다. 그러다가 Call Stack이 비는 순간 대기 중인 Task Queue에 있던 함수를 Call Stack으로 올려주고 실행이 되게 됩니다. 그러면 만약 아래와 같이 코드를 구성한다면 어떻게 될까요?
...
one()
setTimeout(() => {
two()
}, 0)
three()
0초를 기다리고 수행하라고 했으니까 바로 one() -> two() -> three()의 순서로 호출된다고 생각하기 쉽지만, two()는 setTimeout()의 callback함수에 들어있기 때문에 몇 초를 기다리는 것과는 상관없이 Task Queue로 들어가 대기하게 됩니다. 그래서 이 예시도 one() -> three() -> two() 순서로 실행되게 되죠. 이것을 잘 이용하면 의도적으로 함수의 호출 순서를 조절할 수 있게 됩니다.