안녕하세요. 이번 시간에는 호출 스택과 이벤트루프에 대해서 알아보겠습니다! 정말 오랜만에 자바스크립트 파트 강좌를 올리네요. 사실 웬만한 것들은 다 다뤘다고 생각해서 50강으로 끝내려고 했는데, 정작 제일 중요한 이벤트루프를 다루지 않았습니다. 사실 제가 이벤트 루프를 최근에 와서야 제대로 이해하게 되었기 때문이기도 합니다.
호출 스택과 이벤트 루프는 자바스크립트 코드 동작 원리를 이해하는데 필수이기 때문에 꼭 알아두고 가세요. 이걸 모른다면 자바스크립트를 모르는 겁니다. 단, 입문자 분들도 쉽게 이해하실 수 있도록 복잡한 엔진 내용은 제외하고 핵심적인 개념만 간추려서 설명하겠습니다. 따라서 실제보다 몇 과정이 간소화되어 있을 수도 있습니다.
호출 스택
먼저 호출 스택에 대해 알아봅시다. 단, 실행 컨텍스트가 뭔지 미리 알고 계셔야 합니다. 실행 컨텍스트를 찬찬히 보다 보면 이게 스택 과 비슷하다는 것을 알 수 있습니다. 먼저 다음 코드의 실행 순서를 예측해봅시다.
function first() {
second();
console.log('첫 번째');
}
function second() {
third();
console.log('두 번째');
}
function third() {
console.log('세 번째');
}
first();
third();
쉽죠? 세 번째, 두 번째, 첫 번째, 세 번째 순으로 콘솔이 나옵니다. 실행 컨텍스트의 원리를 따라 분석하면 됩니다. 그림으로 그려보면 아래와 같습니다.
무려 제가 그림판으로 그림을 그려보았습니다. ㅎㅎ. first()
가 호출되었을 때의 상황입니다. 처음의 main()은 전역 컨텍스트라고 생각해주세요. 크롬에서는 main 대신 (anonymous)함수로 나옵니다. first가 호출되고, first 안의 second가 호출되고, 마지막에 second 안의 third가 호출됩니다. 호출된 순서와 반대로 실행이 되는데요. 실행되면 저기 쌓여진 더미에서 제거됩니다. LIFO(마지막에 들어온 것이 먼저 나감) 구조죠. 호출 "스택"이라고 부르는 이유입니다.
third가 실행되고 지워지고, second가 실행되고 지워지고, first가 실행되고 지워지면 main만 남았죠?그 후 third()
가 호출됩니다. 그러면 third가 스택에 들어가고, 실행되고 다시 지워집니다. 마지막으로 전역 컨텍스트 main이 지워집니다. 그러면 호출 스택에는 아무것도 남지 않게 되겠죠. 이러면 실행 완료입니다.
다음과 같은 에러를 보신 적이 있을 겁니다.
Uncaught RangeError: Maximum call stack size exceeded
바로 호출 스택이 가득 찼을 때 발생하는 에러인데요. 재귀함수같이 함수 안에서 계속 다른 함수를 호출하다보면 저기 스택이 가득 차다못해 터져버립니다. 그래서 나오는 에러입니다. 브라우저마다 호출 스택 최대치가 다른데요. 요즘은 10만 개가 넘는 브라우저도 있습니다. 하지만 보통 만 개 정도라고 생각하시면 편합니다. 함수를 만 번 이상 중첩해서 호출하지 마세요!
이벤트 루프
이벤트 루프는 실행 컨텍스트와 함께 필수적으로 알고 있어야 하는 개념입니다. 자바스크립트와 노드에서 사용되는데요. 자바스크립트는 보통 싱글 쓰레드라고 불리는데, 바로 메인 쓰레드인 이벤트 루프가 싱글 쓰레드이기 때문입니다.
다음 코드의 순서를 예측해보세요.
function run() {
console.log('동작');
}
console.log('시작');
setTimeout(run, 3000);
console.log('끝');
정말 쉽죠? 시작, 끝이 콘솔에 찍힌 후 3초 후에 동작이 콘솔에 찍힙니다. 그렇다면 이것은요?
function run() {
console.log('동작');
}
console.log('시작');
setTimeout(run, 0);
console.log('끝');
이번에는 3초가 아니라 0초입니다. 하지만 콘솔에는 그대로 시작, 끝, 동작이 찍힙니다. 0초면 바로 실행하라는 것 같은데 왜 끝이 먼저 콘솔에 찍힐까요? 호출 스택으로 설명할 수 있을까요?
아마 아무도 할 수 없을 겁니다. 눈을 감고 머리속으로 따라와보세요. 전역 컨텍스트 main()이 호출 스택에 들어가고, console.log('시작')
이 들어갑니다. 콘솔이 실행되어 호출스택에서 빠지고, 다시 setTimeout()
이 들어갑니다. setTimeout이 실행되어 빠지고, console.log('끝')
이 들어갑니다. 이제 3초 뒤에 run이 실행되어야 하는데요. 호출 스택에는 run이 없는데 어떻게 실행된 걸까요? 바로 여기서 이벤트 루프와 백그라운드, 태스크 큐가 나옵니다.
setTimeout 3초의 경우를 자세히 살펴봅시다. setTimeout이 호출되고 지워지면서 백그라운드로 run 함수와 함께 3초 타이머를 보냅니다. 백그라운드는 3초를 센 후 태스크 큐에 run 함수를 보냅니다.
이벤트 루프는 항상 대기하고 있다가 호출 스택이 비워지면(전역 컨텍스트 main 실행이 종료되면) 태스크 큐에서 함수를 하나씩 호출 스택으로 밀어 올립니다.
이제 run 함수가 실행되고 호출 스택에서 지워지게 됩니다. 이벤트루프는 태스크 큐에 새로운 함수가 들어올 때까지 대기합니다.
setTimeout 0초도 마찬가지입니다. 일단 setTimeout을 하는 순간 백그라운드를 거쳐 태스크 큐로 run 함수가 이동하기 때문에 끝이 먼저 콘솔에 찍히고 동작이 찍힙니다. (사실 setTimeout 0도 기본적으로 4ms의 지연 시간을 갖고 있어서 setTimeout 4ms와 마찬가지입니다. 노드는 1ms의 지연 시간을 갖고 있습니다)
참고로 백그라운드에서 3초를 정확하게 세어 주었다고 하더라도, 호출 스택에 함수들이 가득차 있다면 3초 후에 실행되지 않을 수도 있습니다. 이벤트 루프가 태스크 큐에서 run 함수를 호출 스택으로 끌어올리지 못하거든요. setTimeout의 초가 정확하지 않을 수도 있는 이유입니다. 호출 스택에서 너무 많은 일을 하게 되면 태스크 큐에 쌓인 콜백 함수들이 제 때에 실행되지 않기 때문에 너무 버거운 일들은 하지 않는 게 좋습니다.
위에서 나왔던 Maximum call stack size exceeded 에러도 setTimeout 0을 사용해서 극복할 수 있습니다. setTimeout을 하는 순간 호출 스택에 함수가 쌓이는 게 아니라 백그라운드를 거쳐 태스크 큐로 넘어가기 때문에 호출 스택이 터지는 일이 발생하지 않습니다.
이제 모든 현상이 설명이 되죠? 호출 스택 외에도 백그라운드와 태스크 큐, 이벤트 루프가 존재했던 겁니다. 백그라운드를 사용하는 작업은 타이머 외에도 ajax 요청, 이벤트 리스너나 FileReader 등이 있습니다. 자바스크립트 기본 제공 메소드 중 콜백 함수를 사용하는 것들은 백그라운드를 사용하는 경우가 많습니다.
이벤트 루프의 동작을 시각적으로 보시려면 http://latentflip.com/loupe/ 여기를 이용하세요.
여기서는 백그라운드와 태스크 큐, 이벤트 루프가 정확히 어떻게 구현되어 있는지에 대해서는 다루지 않습니다. 심지어 태스크 큐 외에도 마이크로태스크 큐, 잡 큐 등이 더 있어 이벤트 루프가 실행하는 순서가 다릅니다. 하지만 위의 그림만 명확하게 이해하신다면 앞으로 자바스크립트 코딩을 하는 데 있어 큰 문제는 없을 겁니다. 이상으로 자바스크립트 강좌를 마칩니다. (그래놓고 몇 개 더 추가할 수도..?)
당연히 직접 비동기 코드 작성해서 잘 실행되게끔 하셔야 합니다. 이걸 잘 하느냐가 싱글스레드 자바스크립트 실력입니다.