자바스크립트 이벤트 루프와 비동기 고급 활용

자바스크립트 이벤트 루프와 비동기 고급 활용

지난 8번째 글에서 비동기 기초를 다루며 콜백, 프로미스, async/await를 학습하였다면, 이번에는 비동기의 핵심 메커니즘인 이벤트 루프(Event Loop)를 탐구한다. 또한 이벤트 루프를 기반으로 비동기를 심화하여 활용하는 고급 기술을 소개한다. 이벤트 루프는 자바스크립트의 단일 스레드 실행 방식을 이해하는 데 필수적인 개념이며, 비동기 코드를 효율적으로 관리하는 데 중요한 역할을 한다.


이벤트 루프의 동작 원리와 태스크 큐, 마이크로태스크 큐의 차이를 설명하며, 비동기 패턴의 고급 응용과 실습 예제를 다룬다. 콘솔이나 HTML 파일에서 예제를 실행하며 비동기의 동작 방식을 체감할 수 있다.


이전 비동기 기초 내용을 확장하여 자바스크립트 엔진이 비동기 작업을 처리하는 방식을 심도 있게 분석한다. 이벤트 루프를 이해하면 코드 실행 순서를 예측하고 복잡한 비동기 상황을 효과적으로 다룰 수 있다.

Event Loop Overview

이벤트 루프의 정의

이벤트 루프(Event Loop)는 자바스크립트가 비동기 작업을 처리하는 핵심 메커니즘이다. 자바스크립트는 단일 스레드(Single-Threaded) 언어로, 한 번에 하나의 작업만 수행한다. 그러나 setTimeout이나 fetch와 같은 비동기 작업은 동시에 처리되는 것처럼 보인다. 이는 이벤트 루프가 비동기 작업을 관리하기 때문이다.


자바스크립트 엔진(예: V8)에는 호출 스택(Call Stack)이 존재하며, 코드는 순차적으로 이 스택에 쌓여 실행된다. 비동기 작업은 호출 스택 외부에서 처리되며, 이벤트 루프가 이를 호출 스택으로 다시 가져오는 역할을 수행한다. 다음 예제를 통해 확인한다:

console.log('시작');
setTimeout(() => {
    console.log('타이머 끝');
}, 1000);
console.log('끝');
// 출력: "시작" -> "끝" -> 1초 후 "타이머 끝"
        

위 코드에서 setTimeout은 즉시 실행되지 않고 1초 후에 출력된다. 이는 이벤트 루프가 비동기 작업을 별도로 처리하여 호출 스택으로 적절히 전달하기 때문이다.


이벤트 루프는 호출 스택이 비었을 때 태스크 큐(Task Queue)에서 대기 중인 작업을 가져와 실행한다. 이 과정은 브라우저나 Node.js와 같은 런타임 환경이 제공하는 Web API와 협력하여 이루어진다. 예를 들어, setTimeout은 Web API에서 타이머를 처리한 후, 완료 시 태스크 큐에 콜백을 추가한다. 이벤트 루프는 이를 확인하고 호출 스택으로 이동시켜 실행한다.


이벤트 루프의 동작 과정

이벤트 루프의 동작 과정을 단계별로 분석한다. 이를 이해하면 비동기 코드의 실행 순서를 명확히 파악할 수 있다.


1. 호출 스택(Call Stack)

호출 스택은 자바스크립트가 코드를 실행하는 공간이다. 함수가 호출되면 스택에 추가되고, 실행이 완료되면 제거된다:

function a() {
    console.log('A');
}
function b() {
    a();
    console.log('B');
}
b();
// 출력: "A" -> "B"
        

2. Web API

비동기 작업(예: setTimeout, fetch)은 Web API로 전달된다. Web API는 브라우저가 제공하며, 자바스크립트 엔진 외부에서 작업을 처리한다:

setTimeout(() => {
    console.log('타이머');
}, 0);
console.log('즉시');
// 출력: "즉시" -> "타이머"
        

위 예제에서 setTimeout의 지연 시간이 0초임에도 즉시 실행되지 않는다. 이는 Web API에서 처리된 후 태스크 큐로 이동하기 때문이다.


3. 태스크 큐와 마이크로태스크 큐

비동기 작업이 완료되면 콜백이 큐에 저장된다. 큐에는 두 가지 종류가 존재한다:


- 태스크 큐(Task Queue): setTimeout, setInterval과 같은 일반 비동기 작업이 저장된다.

- 마이크로태스크 큐(Microtask Queue): Promise, queueMicrotask와 같은 우선순위가 높은 작업이 저장된다.

console.log('시작');
setTimeout(() => console.log('타이머'), 0);
Promise.resolve().then(() => console.log('프로미스'));
console.log('끝');
// 출력: "시작" -> "끝" -> "프로미스" -> "타이머"
        

마이크로태스크 큐의 작업은 태스크 큐보다 먼저 실행된다. 이는 우선순위의 차이 때문이다.


4. 이벤트 루프의 실행

이벤트 루프는 호출 스택이 비었을 때 마이크로태스크 큐의 모든 작업을 먼저 처리한 후, 태스크 큐의 작업을 처리한다. 이 과정은 지속적으로 반복된다:

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
setTimeout(() => console.log('4'), 0);
console.log('5');
// 출력: "1" -> "5" -> "3" -> "2" -> "4"
        
Event Loop Process

비동기 패턴의 고급 활용

이벤트 루프를 이해한 후에는 비동기를 더욱 효과적으로 활용하는 방법을 탐구한다. 몇 가지 고급 패턴을 소개한다.


1. 프로미스 체이닝 개선

프로미스를 연속적으로 연결하여 코드를 깔끔하게 관리한다:

function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}
delay(1000)
    .then(() => {
        console.log('1초 후');
        return delay(500);
    })
    .then(() => console.log('1.5초 후'));
        

이 방법은 콜백 중첩 문제를 해결하며 가독성을 높인다.


2. 병렬 프로미스 처리

Promise.all을 활용하여 여러 비동기 작업을 병렬로 실행한다:

const p1 = delay(1000).then(() => '작업 1');
const p2 = delay(2000).then(() => '작업 2');
Promise.all([p1, p2]).then(results => {
    console.log(results); // ["작업 1", "작업 2"]
});
        

이 패턴은 긴 작업 시간을 단축한다.


3. 비동기 에러 처리 강화

async/await를 사용하여 에러를 체계적으로 처리한다:

async function fetchData() {
    try {
        const result = await Promise.reject('에러!');
    } catch (error) {
        console.log('잡힌 에러:', error);
    }
}
fetchData(); // "잡힌 에러: 에러!"
        

4. 순차 실행 제어

비동기 작업을 순차적으로 실행한다:

async function sequentialTasks() {
    await delay(1000);
    console.log('1초 후');
    await delay(500);
    console.log('1.5초 후');
}
sequentialTasks();
        

실습 예제

이벤트 루프와 비동기를 활용한 실습 예제를 제공한다. 버튼을 통해 순차적 및 병렬 작업을 시뮬레이션한다:

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>비동기 실습</title>
    <style>
        #result {
            margin-top: 20px;
        }
        button {
            margin: 5px;
        }
    </style>
</head>
<body>
    <button id="sequentialBtn">순차 실행</button>
    <button id="parallelBtn">병렬 실행</button>
    <div id="result"></div>
    <script>
        function delay(ms) {
            return new Promise(resolve => setTimeout(() => resolve(ms), ms));
        }

        const sequentialBtn = document.getElementById('sequentialBtn');
        const parallelBtn = document.getElementById('parallelBtn');
        const result = document.getElementById('result');

        sequentialBtn.addEventListener('click', async () => {
            result.textContent = '순차 실행 시작...';
            await delay(1000);
            result.textContent += '\n1초 후';
            await delay(500);
            result.textContent += '\n1.5초 후';
        });

        parallelBtn.addEventListener('click', async () => {
            result.textContent = '병렬 실행 시작...';
            const tasks = [delay(1000), delay(500)];
            const times = await Promise.all(tasks);
            result.textContent += `\n${times.join(', ')}ms 후 완료`;
        });
    </script>
</body>
</html>
비동기 실습

이 예제는 순차 실행과 병렬 실행의 차이를 시각적으로 확인할 수 있다.

Async Example Screen

비동기와 이벤트 루프의 실무 활용

실무에서는 API 호출, 데이터 로딩, 애니메이션 등 다양한 상황에서 이벤트 루프와 비동기를 활용한다:

async function loadData() {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
    const data = await response.json();
    console.log(data);
}
loadData();
        

이 예시는 API 데이터를 비동기로 가져오는 일반적인 사례이다.


주의할 점

마이크로태스크와 태스크의 우선순위를 정확히 이해한다. 비동기 작업이 많아질 경우 실행 순서를 예측한다. 에러 처리는 필수적으로 포함한다.


결론

이벤트 루프의 동작 과정, 큐의 차이, 고급 패턴을 학습함으로써 비동기 코드를 효과적으로 다룰 수 있다. 다음 포스팅에서는 뭐하지..

+ Recent posts