
들어가기 앞서...
굉장히 오랜만에 CS 관련 블로그를 작성하게 되었는데! 이를 계기로 조금씩 블로그를 다시 채워보려고 한다...😎
오늘 다뤄볼 내용은! 콜 스택과 이벤트 루프에 대한 내용을 다뤄보려고 하는데 뜬금없이 왜 이런 주제를 들고 왔나! 싶을 수 있지만...
최근 프로젝트를 여러개 진행하면서 '화면 버벅임'에 대한 고민이 많았는데, 해당 부분과 밀접하게 관련되어 있는 주제가 바로 오늘 다루는 주제라고 해서 들고 오게 되었다😎
아마... 관련된 내용의 기초를 저번 블로그에서 다뤘던 것 같은데... (한~~~참 전이니까 링크를 같이 남겨두었다!)
바로 이어서 저번 블로그와 관련된 내용이 나오니까! 동기와 비동기에 대해 조금 헷갈리면 참고해 보면 좋을 것 같다😉
자바스크립트는 싱글 스레드다
위에서도 언급했듯이, 이전 글에서 동기와 비동기를 다룬 적이 있는데, 그때 이런 의문이 남았을 수도 있다. (아닐 수도 있지만...)
"자바스크립트는 한 번에 하나만 처리하는데 어떻게 비동기 작업이 가능한 걸까?"
"싱글 스레드(Single Thread)란, 한 번에 하나의 작업만 처리할 수 있는 구조를 의미하는거 아닌가?"
자바스크립트 엔진은 코드를 실행하는 콜 스택이 딱 하나뿐이다. 그래서 어떤 코드가 실행 중이면 다른 코드는 그 작업이 끝날 때까지 기다려야 한다. (여기까지만 보면 동기 그 자체.)
그런데 setTimeout이나 fetch를 쓰면 기다리는 동안 화면이 멈추지 않는다는 걸 이미 알고 있을 것이다. 이 과정의 비밀이 바로 위의 의문에 대한 해답이 될 수 있는데, 자바스크립트는 엔진 혼자 일하는 게 아니라 브라우저(런타임)가 도와준다는 사실이 바로 숨겨져 있던 비밀이다. 이 역할 분담을 이해하는 게 오늘의 핵심이 될 것이다!
콜 스택(Call Stack)이란?
위에서 잠~깐 콜 스택이라는 것을 언급했는데... 정확히 이게 뭔지 알아야! 다음에 이어지는 내용을 이해할 수 있어서 먼저 짚고 넘어가 보려고 한다!
콜 스택(Call Stack)이란, 함수의 호출 순서를 기록하는 자료구조 (LIFO: 나중에 들어온 게 먼저 나감)
function first() {
second();
}
function second() {
third();
}
function third() {
console.log("done");
}
first();
위 코드의 실행 순서는 아래와 같이 진행된다.
1. first()가 호출되어 스택에 쌓임
2. first() 안에서 second()가 호출되어 그 위에 쌓임
3. second() 안에서 third()가 호출되어 또 그 위에 쌓임
4. third()가 끝나면 스택에서 빠지고, 이어서 second(), first()순서로 빠짐
함수가 끝나면 맨 위부터 하나씩 빠지는 구조라서, 가장 마지막에 호출된 함수가 가장 먼저 끝나는 구조이다.
참고로 함수 호출이 끝없이 쌓이면 스택 용량을 넘겨서 Maximum call stack size exceeded, 즉 스택 오버플로우 에러가 발생한다. (아마 재귀 함수 종료 조건을 넣지 않았던 적이 있다면 봤을 수도 있다...😶🌫️)
이벤트 루프(Event Loop)란?
비동기를 이해하기 위해서는 콜 스택 뿐만 아니라 브라우저가 제공하는 요소들에 대해서도 알아야 한다. (1+1 세트 상품 같은 느낌.)
구성 요소
- 콜 스택: 실행 중인 함수가 쌓이는 곳을 의미하며, 자바스크립트 엔진이 담당
- Web API: setTimeout, DOM 이벤트, fetch 같은 기능을 의미하며, 엔진이 아니라 브라우저가 제공하고 자바스크립트와 별개로 동작
- 태스크 큐(매크로태스크 큐): setTimeout 콜백이나 클릭 같은 이벤트 핸들러가 실행 차례를 기다리는 대기줄
- 마이크로태스크 큐: Promise.then, queueMicrotask 콜백이 기다리는 대기줄을 의미하며 태스크 큐보다 우선순위가 높음
- 이벤트 루프: 콜 스택이 비었는지 계속 확인하고, 비어 있으면 큐에서 작업을 꺼내 콜 스택으로 옮겨주는 역할을 수행
즉, 이벤트 루프(Event Loop)란, 콜 스택이 비어 있을 때 큐에 대기 중인 작업을 콜 스택으로 옮겨 실행시키는 동작 방식을 의미
동작 순서
setTimeout(callback, 1000)을 예로 들면 동작 순서는 아래와 같이 진행된다.
1. setTimeout이 콜 스택에서 실행되고, 타이머를 Web API에 전달
2. 콜 스택은 기다리지 않고 바로 다음 코드를 실행
3. 1초 뒤 타이머가 끝나면 Web API가 callback을 태스크 큐에 삽입
4. 이벤트 루프가 콜 스택이 비었는지 확인하고, 비어 있으면 callback을 콜 스택으로 옮겨 실행
그래서 setTimeout(fn, 0)이라고 써도 즉시 실행되지 않는다!! 현재 실행 중인 코드가 모두 끝나서 콜 스택이 비어야 비로소 수행 차례가 오기 때문에 이러한 점을 유의해야 한다!
태스크 vs 마이크로태스크
위에서 잠깐 언급했는데... 태스크...? 마이크로태스크...?에 대해서 뭔 차인가 싶은 분들이 있을 거라고 생각한다. (일단 내가 그렇다.)
그래서 표로 정리해보았다😎
| 구분 | 태스크 큐 | 마이크로태스크 큐 |
| 대표 예시 | setTimeout, setInterval, 클릭 등의 이벤트 핸들러 | Promise.then, queueMicrotask |
| 우선순위 | 낮음 | 높음 |
| 처리방식 | 한 번에 하나씩 꺼내서 실행 | 큐가 빌 때까지 전부 실행 |
이벤트 루프는 태스크 하나를 실행하고 나면, 마이크로태스크 큐를 먼저 싹 비운 뒤 다음 태스크로 넘어간다. Promise가 setTimeout보다 항상 먼저 실행되는 이유가 태스크와 마이크로태스크의 차이에 있다고 보면 된다!
렌더링도 같은 메인 스레드에서 한다
여기서부터가 오늘 글의 진짜 핵심이라고 할 수 있는 부분인데...~!
브라우저의 메인 스레드는 자바스크립트 실행만 하는 게 아니라, 화면을 그리는 렌더링(스타일 계산, 레이아웃, 페인트) 도 같이 담당한다. 즉 JS와 렌더링이 한 스레드를 번갈아 쓰는 구조를 의미한다.
말로만 하면 조금 애매하니까! 밑에 정리를 간단히 해보겠다.
1. 태스크 하나 실행
2. 마이크로태스크 전부 실행
3. 화면을 갱신할 타이밍이면 requestAnimationFrame 콜백 실행 → 스타일 계산 → 레이아웃 → 페인트
4. 다음 태스크로...
화면이 보통 1초에 60번(60Hz) 갱신되면, 한 프레임에 쓸 수 있는 시간은 약 16.7ms이다. 그런데 자바스크립트 작업 하나가 이 시간을 훌쩍 넘기면 어떻게 될까...?!
우선 콜 스택이 비지 않으니 이벤트 루프는 렌더링 단계로 넘어갈 수가 없다. 결과적으로는 화면이 멈추고, 클릭도 먹히지 않는 현상이 생긴다. 이렇게 메인 스레드를 오래 붙잡는 작업을 긴 태스크(Long Task)라고 칭한다. (Chrome은 50ms 이상 걸리는 태스크를 Long Task로 분류한다고 한다!!)
정리하면, 이벤트 루프 관점에서 "버벅임"이란 콜 스택이 오래 비지 않아서 렌더링 차례가 늦어지는 현상을 의미한다!
이전 글 "14. Node.js 환경 vs 브라우저 환경"에서 다뤘듯이, 브라우저에는 렌더링이라는 역할이 추가로 있다는 점이 Node.js와의 큰 차이이기도 하다.
그래서 버벅임은 어떻게 해결할까?
앞에서 배운 내용을 정리하면 버벅임의 원인은 콜 스택이 오래 비지 않아서 렌더링 차례가 늦어지는 것이었다. 그렇다면 해결 방향도 의외로 단순하지 않을까 해서 생각한 나의 답은 바로~!
콜 스택을 오래 붙잡지 않게 만들기!
방법은 크게 세 가지로 나눌 수 있다😎
1. 일 자체를 줄이기
가장 확실한 방법은 메인 스레드가 해야 할 일을 줄이는 것이다.
- 화면에 그리는 양 줄이기: 차트라면 데이터 다운샘플링, 긴 리스트라면 가상화(Virtualization)나 페이지네이션
- 이벤트 횟수 줄이기: resize, scroll, mousemove 같은 이벤트는 하나하나가 태스크라서 디바운스/쓰로틀로 호출 횟수 자체를 줄이기
- 불필요한 재계산·재렌더 막기: React라면 useMemo, memo 활용
2. 긴 작업을 잘게 쪼개기
일을 줄이는 데에도 한계가 있을 것이기 때문에... 어쩔 수 없이 오래 걸리는 작업이라면 덩어리로 나눠서 중간중간 렌더링할 틈을 줘야 한다. 한 덩어리가 끝날 때마다 콜 스택이 비면, 이벤트 루프가 그 사이에 렌더링을 끼워 넣을 수 있기 때문이다!
여기서 함정이 하나 있는데... 쪼갠 작업을 Promise(마이크로태스크)로 넘기면 소용이 없다. 마이크로태스크는 "큐가 빌 때까지 전부 실행"되기 때문에 렌더링 단계로 넘어가지 못한다. 위에서 표로 정리했던 차이를 다시 생각해 보면 좋을 것 같다.
- setTimeout 등으로 태스크를 나눠서 넘기기: 덩어리 사이에 렌더링 기회 확보
- React의 startTransition / useDeferredValue: 급하지 않은 렌더링을 뒤로 미뤄서 입력 같은 급한 반응을 우선 처리
3. 메인 스레드 밖으로 보내기
- Web Worker: 무거운 연산을 별도 스레드에서 처리 (단, DOM에는 접근할 수 없다...)
- requestAnimationFrame: 애니메이션은 setTimeout 대신 화면 갱신 타이밍에 맞춰 실행
한눈에 정리해보면?
| 방법 | 핵심 아이디어 |
| 일 줄이기 | 데이터·DOM·이벤트 횟수를 줄여서 콜 스택 점유 시간 단축 |
| 작업 쪼개기 | 태스크를 나눠서 중간에 렌더링 기회를 확보 |
| 밖으로 보내기 | 메인 스레드가 아닌 곳(Worker)에서 처리 |
그리고 가장 중요한 것! 먼저 측정하자
위 방법들은 모두 어디가 느린지 알아야 의미가 있다. Chrome 개발자 도구의 Performance 탭에서 Long Task(50ms 이상)가 어디서 발생하는지 먼저 확인하고, 그 부분에 맞는 방법을 적용하는 것이 순서이다. (앞으로의 프로젝트에서 한번 확인해보려고 한다...😶🌫️)
마무리...
길다면 길고 짧다면 짧은 글이 끝났다!
이번 블로그를 쓰면서 들었던 생각은 '되게 당연한 내용을 다루고 있는 것 같은데... 왜 이걸 모르고 살았지...?'였다.
요즘 가장 많이 듣는 이야기 중 하나가 'CS 지식은 밥 먹듯이 공부해도 끝이 없다.'인데... 이번 블로그를 쓰면서 조금 크게. 아니 많이 크게 느낀 것 같다... (그동안 나태했던 본인을 탓하며.)
하.지.만 '왜 이제 시작했을까.' 하는 후회보다는! '이제부터 하나씩 해나가자!'라는 다짐으로! 차근차근 CS 지식을 쌓아나가 보려고 한다!
오랜만이지만~! 오늘도 새로운 지식을 쌓기 위해 노력한 나에게 박수를 보내며 이번 글을 마무리해 보자 👏👏
'CS' 카테고리의 다른 글
| 18. TCP vs HTTP (0) | 2025.07.26 |
|---|---|
| 17. HTTP VS HTTPS (0) | 2025.07.21 |
| 16. 대칭키? 비대칭키? (0) | 2025.07.14 |
| 15. HTTP (0) | 2025.07.03 |
| 14. Node.js 환경? 브라우저 환경? (5) | 2025.06.28 |
댓글