비동기 타임아웃 관리 (Managing Async Timeouts)

비동기 타임아웃 관리 (Managing Async Timeouts)

자바스크립트에서 비동기 작업을 다룰 때 타임아웃 관리는 꽤 중요한 주제다. 비동기 작업이 너무 오래 걸리거나, 제한 시간 안에 끝나지 않으면 흐름을 제어할 필요가 생긴다. 이번에는 비동기 타임아웃을 관리하는 다양한 방법을 코드와 함께 하나씩 풀어보려고 한다.


타임아웃을 잘 다루면 비동기 작업의 안정성과 효율성을 크게 높일 수 있다. 단계별로 차근차근 알아보자.


타임아웃 기본 다루기

setTimeout과 비동기 작업을 결합하는 가장 단순한 방법부터 보자:

function delay(ms) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

async function fetchWithTimeout() {
    await delay(2000);
    console.log("작업 완료");
}

fetchWithTimeout();
// 2초 후 "작업 완료"

여기서는 단순히 지연 시간을 만들어서 비동기 작업을 흉내냈다. 하지만 실제로는 타임아웃을 강제해야 할 때가 많다.


1. Promise.race로 타임아웃 설정

Promise.race를 사용하면 작업과 타임아웃 중 먼저 끝나는 쪽을 선택할 수 있다:

function timeout(ms, promise) {
    const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => reject(new Error("시간 초과")), ms);
    });
    return Promise.race([promise, timeoutPromise]);
}

async function slowTask() {
    await delay(3000);
    return "작업 완료";
}

async function run() {
    try {
        const result = await timeout(2000, slowTask());
        console.log(result);
    } catch (error) {
        console.log(error.message);
    }
}

run();
// 2초 후 "시간 초과"

작업이 3초 걸리는데 타임아웃은 2초로 설정했으니, 타임아웃이 먼저 발생했다.


2. 실제 API 호출에 적용

API 호출에 타임아웃을 추가해보자:

async function fetchWithTimeout(url, ms) {
    const fetchPromise = fetch(url);
    const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => reject(new Error("API 호출 시간 초과")), ms);
    });
    const response = await Promise.race([fetchPromise, timeoutPromise]);
    return response.json();
}

async function run() {
    try {
        const data = await fetchWithTimeout(
            "https://jsonplaceholder.typicode.com/posts/1",
            1000
        );
        console.log(data.title);
    } catch (error) {
        console.log(error.message);
    }
}

run();
// 네트워크 상태에 따라 성공하거나 "API 호출 시간 초과" 출력

API 응답이 1초 안에 오지 않으면 타임아웃이 발생한다.


3. 타임아웃 취소 가능하게 만들기

clearTimeout을 활용해서 타임아웃을 취소할 수 있게 해보자:

function timeoutWithCancel(ms, promise) {
    let timeoutId;
    const timeoutPromise = new Promise((_, reject) => {
        timeoutId = setTimeout(() => reject(new Error("시간 초과")), ms);
    });
    return Promise.race([
        promise.then((value) => {
            clearTimeout(timeoutId);
            return value;
        }),
        timeoutPromise
    ]);
}

async function fastTask() {
    await delay(1000);
    return "빠른 작업 완료";
}

async function run() {
    try {
        const result = await timeoutWithCancel(2000, fastTask());
        console.log(result);
    } catch (error) {
        console.log(error.message);
    }
}

run();
// 1초 후 "빠른 작업 완료" (타임아웃 취소됨)

작업이 타임아웃 전에 끝나면 clearTimeout으로 타이머를 정리했다.


4. AbortController로 제어

AbortController를 사용하면 타임아웃과 함께 작업 자체를 중단할 수 있다:

async function fetchWithAbort(url, ms) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), ms);
    
    try {
        const response = await fetch(url, { signal: controller.signal });
        clearTimeout(timeoutId);
        return response.json();
    } catch (error) {
        if (error.name === "AbortError") {
            throw new Error("요청이 타임아웃으로 중단됨");
        }
        throw error;
    }
}

async function run() {
    try {
        const data = await fetchWithAbort(
            "https://jsonplaceholder.typicode.com/posts/1",
            500
        );
        console.log(data.title);
    } catch (error) {
        console.log(error.message);
    }
}

run();
// 0.5초 안에 응답 없으면 "요청이 타임아웃으로 중단됨"

AbortController로 요청 자체를 중단해서 자원을 아꼈다.


5. 다중 타임아웃 관리

여러 비동기 작업에 각기 다른 타임아웃을 적용해보자:

async function taskWithTimeout(task, ms) {
    const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => reject(new Error("시간 초과")), ms);
    });
    return Promise.race([task, timeoutPromise]);
}

async function runMultiple() {
    const tasks = [
        taskWithTimeout(delay(1000), 2000),
        taskWithTimeout(delay(3000), 2000),
        taskWithTimeout(delay(500), 1000),
    ];
    
    try {
        const results = await Promise.all(tasks);
        console.log("모두 성공");
    } catch (error) {
        console.log(error.message);
    }
}

runMultiple();
// "시간 초과" (2번째 작업이 3초 걸리는데 타임아웃은 2초)

각 작업에 맞는 타임아웃을 설정해서 병렬로 관리했다.


6. 타임아웃 재시도 로직

타임아웃이 발생하면 재시도하는 방법을 추가해보자:

async function fetchWithRetry(url, ms, retries) {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await timeout(ms, fetch(url));
            return response.json();
        } catch (error) {
            console.log(`시도 ${i + 1} 실패: ${error.message}`);
            if (i === retries - 1) throw error;
            await delay(1000);
        }
    }
}

async function run() {
    try {
        const data = await fetchWithRetry(
            "https://jsonplaceholder.typicode.com/posts/1",
            500,
            3
        );
        console.log(data.title);
    } catch (error) {
        console.log("최종 실패: " + error.message);
    }
}

run();
// 네트워크 상태에 따라 성공하거나 "최종 실패: 시간 초과"

3번 재시도하며 실패 시마다 1초 대기했다.


7. 타임아웃과 스트림 결합

비동기 스트림에 타임아웃을 적용해보자:

async function* streamWithTimeout() {
    let count = 0;
    while (count < 5) {
        count++;
        yield timeout(1000, delay(1500));
    }
}

async function processStream() {
    try {
        for await (const value of streamWithTimeout()) {
            console.log("스트림 진행");
        }
    } catch (error) {
        console.log(error.message);
    }
}

processStream();
// "시간 초과" (각 작업이 1.5초 걸리는데 타임아웃은 1초)

스트림 각 단계에 타임아웃을 적용해서 흐름을 제어했다.


8. 성능과 안정성 고려

타임아웃 관리가 코드에 어떤 영향을 주는지 살펴보자:

- 성능: 타임아웃으로 느린 작업을 끊어내니 전체 흐름이 빨라질 수 있다.

- 안정성: 작업이 무한정 대기하지 않도록 보장한다.

Promise.raceAbortController를 상황에 맞게 활용하는 점이 핵심이다.


마무리

비동기 타임아웃 관리는 단순한 지연부터 API 호출, 스트림까지 다양한 상황에서 유용하다. Promise.race, AbortController, 재시도 로직 등을 조합하면 안정적이고 효율적인 비동기 흐름을 만들 수 있다.


async/await 최적화 (Optimizing Async/Await)

async/await 최적화 (Optimizing Async/Await)

자바스크립트에서 비동기 코드를 다룰 때 async/await는 직관적이고 깔끔한 흐름을 만들어준다. 하지만 무작정 사용하면 성능이 떨어지거나 코드가 복잡해질 수도 있다. 이번에는 async/await를 더 효율적으로, 깔끔하게 활용하는 방법을 코드와 함께 단계별로 풀어보려고 한다.


비동기 작업을 최적화하면 속도도 빨라지고 유지보수도 쉬워진다. 하나씩 차근차근 살펴보자.


async/await 기본 다지기

async 함수는 Promise를 반환하고, await는 그 Promise가 해결될 때까지 기다린다:

async function fetchData() {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
    const data = await response.json();
    console.log(data.title);
}

fetchData();
// "sunt aut facere repellat provident occaecati excepturi optio reprehenderit"

순차적으로 비동기 작업을 기다리며 값을 받아왔다. 하지만 이런 기본 흐름에도 개선할 여지가 많다.


1. 불필요한 await 줄이기

await를 남발하면 비동기 작업이 순차적으로 실행돼 속도가 느려질 수 있다:

async function sequentialFetch() {
    const post1 = await fetch("https://jsonplaceholder.typicode.com/posts/1");
    const post2 = await fetch("https://jsonplaceholder.typicode.com/posts/2");
    const data1 = await post1.json();
    const data2 = await post2.json();
    console.log(data1.title, data2.title);
}

sequentialFetch();

위 코드는 각 요청이 끝날 때까지 기다리며 시간이 오래 걸린다. 병렬로 처리하면 훨씬 빠르다:

async function parallelFetch() {
    const promise1 = fetch("https://jsonplaceholder.typicode.com/posts/1");
    const promise2 = fetch("https://jsonplaceholder.typicode.com/posts/2");
    const [post1, post2] = await Promise.all([promise1, promise2]);
    const data1 = await post1.json();
    const data2 = await post2.json();
    console.log(data1.title, data2.title);
}

parallelFetch();

Promise.all로 병렬 처리를 적용해서 대기 시간을 줄였다.


2. 병렬성과 순차성 조화

모든 작업을 병렬로 처리할 순 없다. 일부는 순차적이어야 할 때도 있다:

async function mixedFetch() {
    const userPromise = fetch("https://jsonplaceholder.typicode.com/users/1");
    const postsPromise = fetch("https://jsonplaceholder.typicode.com/posts?userId=1");
    const [userRes, postsRes] = await Promise.all([userPromise, postsPromise]);
    
    const user = await userRes.json();
    const posts = await postsRes.json();
    
    const details = await fetch(
        `https://jsonplaceholder.typicode.com/users/${user.id}`
    );
    const detailData = await details.json();
    
    console.log(user.name, posts.length, detailData.email);
}

mixedFetch();

초기 요청은 병렬로, 이후 요청은 순차적으로 처리해서 효율과 흐름을 모두 챙겼다.


3. 에러 처리 개선

async/await에서 에러 처리를 깔끔하게 만들어보자:

async function fetchWithRetry(url, retries = 3) {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await fetch(url);
            if (!response.ok) throw new Error("네트워크 오류");
            return await response.json();
        } catch (error) {
            if (i === retries - 1) throw error;
            await delay(1000 * (i + 1));
        }
    }
}

function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

async function process() {
    try {
        const data = await fetchWithRetry("https://jsonplaceholder.typicode.com/posts/1");
        console.log(data.title);
    } catch (error) {
        console.log("최종 실패: " + error.message);
    }
}

process();

재시도 로직을 추가해서 실패 시 복구 가능성을 높였다.


4. 메모리 사용 줄이기

불필요한 Promise 객체 생성을 줄이면 메모리 부담이 덜하다:

async function memoryHeavy() {
    const promises = [];
    for (let i = 1; i <= 100; i++) {
        promises.push(fetch(`https://jsonplaceholder.typicode.com/posts/${i}`));
    }
    const responses = await Promise.all(promises);
    return await Promise.all(responses.map(res => res.json()));
}

async function memoryLight() {
    const results = [];
    for (let i = 1; i <= 100; i += 10) {
        const batch = [];
        for (let j = i; j < i + 10 && j <= 100; j++) {
            batch.push(fetch(`https://jsonplaceholder.typicode.com/posts/${j}`));
        }
        const responses = await Promise.all(batch);
        results.push(...await Promise.all(responses.map(res => res.json())));
    }
    return results;
}

async function process() {
    const data = await memoryLight();
    console.log(data.length);
}

process();

배치 처리를 통해 한 번에 처리하는 요청 수를 줄여 메모리 사용을 최적화했다.


5. 타임아웃 관리

비동기 작업에 타임아웃을 설정해서 오래 걸리는 작업을 제어할 수 있다:

async function fetchWithTimeout(url, timeout = 5000) {
    const controller = new AbortController();
    const signal = controller.signal;
    
    const timeoutId = setTimeout(() => controller.abort(), timeout);
    
    try {
        const response = await fetch(url, { signal });
        clearTimeout(timeoutId);
        return await response.json();
    } catch (error) {
        if (error.name === "AbortError") {
            throw new Error("요청 시간 초과");
        }
        throw error;
    }
}

async function process() {
    try {
        const data = await fetchWithTimeout("https://jsonplaceholder.typicode.com/posts/1", 2000);
        console.log(data.title);
    } catch (error) {
        console.log(error.message);
    }
}

process();

AbortController로 타임아웃을 설정해서 응답이 느리면 중단시켰다.


6. 캐싱으로 중복 요청 줄이기

반복 호출되는 비동기 작업을 캐싱하면 성능이 올라간다:

const cache = new Map();

async function fetchWithCache(url) {
    if (cache.has(url)) {
        return cache.get(url);
    }
    const response = await fetch(url);
    const data = await response.json();
    cache.set(url, data);
    return data;
}

async function process() {
    const data1 = await fetchWithCache("https://jsonplaceholder.typicode.com/posts/1");
    const data2 = await fetchWithCache("https://jsonplaceholder.typicode.com/posts/1");
    console.log(data1 === data2);
}

process();
// true (두 번째 호출은 캐시에서 가져옴)

Map으로 캐시를 만들어 동일한 요청을 재사용했다.


7. 비동기 작업 분리

복잡한 로직을 작은 단위로 나누면 가독성과 재사용성이 좋아진다:

async function fetchUser(id) {
    const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
    return await response.json();
}

async function fetchPosts(userId) {
    const response = await fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`);
    return await response.json();
}

async function processUserData(id) {
    const user = await fetchUser(id);
    const posts = await fetchPosts(user.id);
    console.log(user.name, posts.length);
}

processUserData(1);

작은 함수로 나눠서 흐름을 명확히 하고 재사용 가능성을 높였다.


8. 성능과 가독성의 균형

async/await를 최적화하면 어떤 변화가 생길까:

- 성능: 병렬 처리와 캐싱으로 속도가 빨라지고, 메모리 사용이 줄어든다.

- 가독성: 분리된 로직과 깔끔한 에러 처리로 코드가 단순해진다.

Promise.all캐싱을 적절히 활용하면 성능과 가독성을 모두 잡을 수 있다.


마무리

async/await는 비동기 코드를 간결하게 만들어주지만, 최적화를 통해 더 강력해진다. 병렬 처리, 에러 관리, 메모리 절약, 타임아웃 설정까지 다양한 방법으로 성능과 유연성을 높일 수 있다.


프로미스 병렬 처리 심화 (Advanced Promise Parallelism)

프로미스 병렬 처리 심화 (Advanced Promise Parallelism)

자바스크립트에서 여러 비동기 작업을 동시에 처리하려면 프로미스 병렬 처리가 필수다. 순차적으로 기다리는 대신 병렬로 실행하면 시간도 줄고 효율도 올라간다. 이번에는 프로미스 병렬 처리의 기본부터 심화 기법까지 코드와 함께 자세히 파헤쳐보자.


단계별로 하나씩 풀어보면서 다양한 상황에서 어떻게 활용할 수 있는지 알아보자.


프로미스 병렬 처리 기본

Promise.all은 여러 프로미스를 병렬로 실행하고 모두 완료될 때까지 기다린다:

function delay(ms, value) {
    return new Promise((resolve) => {
        setTimeout(() => resolve(value), ms);
    });
}

const promises = [
    delay(1000, "첫 번째"),
    delay(2000, "두 번째"),
    delay(1500, "세 번째")
];

Promise.all(promises)
    .then(results => {
        console.log(results);
    });
// 2초 후 ["첫 번째", "두 번째", "세 번째"]

가장 긴 지연 시간(2초) 후에 결과가 한꺼번에 나온다. 순차 처리였다면 4.5초가 걸렸을 텐데 병렬로 하니 훨씬 빠르다.


1. 병렬 API 호출

여러 API 요청을 병렬로 처리해보자:

async function fetchPost(id) {
    const response = await fetch(
        `https://jsonplaceholder.typicode.com/posts/${id}`
    );
    return response.json();
}

const postIds = [1, 2, 3];
const promises = postIds.map(id => fetchPost(id));

Promise.all(promises)
    .then(posts => {
        posts.forEach(post => console.log(post.title));
    });
// 병렬로 호출 후 결과 출력
// "sunt aut facere repellat provident occaecati excepturi optio reprehenderit"
// "qui est esse"
// "ea molestias quasi exercitationem repellat qui ipsa sit aut"

각 요청이 독립적으로 실행되고, 가장 느린 요청이 끝날 때까지 기다린 후 결과를 한꺼번에 받았다.


2. 에러 처리와 Promise.all

Promise.all은 한 프로미스가 실패하면 전체가 실패한다:

const promises = [
    delay(1000, "성공"),
    Promise.reject(new Error("실패")),
    delay(2000, "늦은 성공")
];

Promise.all(promises)
    .then(results => console.log(results))
    .catch(error => console.log("에러: " + error.message));
// "에러: 실패"

하나라도 실패하면 즉시 에러가 발생하고 나머지 프로미스는 무시된다.


3. Promise.allSettled로 실패 무시

Promise.allSettled는 모든 프로미스가 완료될 때까지 기다리며 성공/실패 상태를 반환한다:

const promises = [
    delay(1000, "성공"),
    Promise.reject(new Error("실패")),
    delay(2000, "늦은 성공")
];

Promise.allSettled(promises)
    .then(results => {
        results.forEach(result => {
            if (result.status === "fulfilled") {
                console.log("성공: " + result.value);
            } else {
                console.log("실패: " + result.reason.message);
            }
        });
    });
// "성공: 성공"
// "실패: 실패"
// "성공: 늦은 성공"

실패해도 다른 프로미스 결과를 잃지 않고 모두 확인할 수 있다.


4. 병렬 실행 제어 (Concurrency Control)

너무 많은 프로미스를 한꺼번에 실행하면 리소스가 과부하될 수 있다. 제한된 병렬 실행을 구현해보자:

async function limitParallel(tasks, limit) {
    const results = new Array(tasks.length);
    const executing = [];

    async function runTask(task, index) {
        try {
            const result = await task();
            results[index] = { status: "fulfilled", value: result };
        } catch (error) {
            results[index] = { status: "rejected", reason: error };
        } finally {
            const indexToRemove = executing.indexOf(promise);
            executing.splice(indexToRemove, 1);
            if (queue.length) {
                executing.push(queue.shift()());
            }
        }
    }

    const queue = [...tasks.entries()].map(([i, task]) => () => runTask(task, i));

    for (let i = 0; i < limit && queue.length; i++) {
        const promise = queue.shift()();
        executing.push(promise);
    }

    while (executing.length || queue.length) {
        await Promise.race(executing);
    }

    return results;
}

const tasks = [
    () => delay(1000, "작업 1"),
    () => delay(2000, "작업 2"),
    () => delay(1500, "작업 3"),
    () => delay(3000, "작업 4"),
    () => delay(500, "작업 5")
];

limitParallel(tasks, 2)
    .then(results => {
        results.forEach(r => console.log(r.value));
    });
// 최대 2개씩 병렬 실행
// "작업 1", "작업 5", "작업 3", "작업 2", "작업 4" 순으로 출력

동시에 실행되는 작업 수를 2개로 제한하면서도 모든 작업을 효율적으로 완료했다.


5. Promise.race와 경쟁

Promise.race는 가장 먼저 완료된 프로미스의 결과를 반환한다:

const promises = [
    delay(1000, "빠른 작업"),
    delay(2000, "느린 작업"),
    delay(1500, "중간 작업")
];

Promise.race(promises)
    .then(result => console.log(result));
// 1초 후 "빠른 작업"

가장 빠른 프로미스만 결과를 주고 나머지는 무시된다.


6. 복잡한 병렬 작업 조합

API 호출과 계산 작업을 병렬로 조합해보자:

async function fetchUser(id) {
    const response = await fetch(
        `https://jsonplaceholder.typicode.com/users/${id}`
    );
    return response.json();
}

async function computeHeavyTask() {
    return new Promise(resolve => {
        let sum = 0;
        for (let i = 0; i < 1e7; i++) sum += i;
        resolve(sum);
    });
}

async function processCombined() {
    const results = await Promise.all([
        fetchUser(1),
        fetchUser(2),
        computeHeavyTask()
    ]);
    console.log("유저 1: " + results[0].name);
    console.log("유저 2: " + results[1].name);
    console.log("계산 결과: " + results[2]);
}

processCombined();
// "유저 1: Leanne Graham"
// "유저 2: Ervin Howell"
// "계산 결과: 49999995000000"

네트워크 요청과 CPU 작업을 병렬로 처리하며 결과를 한 번에 받았다.


7. 타임아웃과 병렬 처리

특정 시간이 지나면 타임아웃을 적용해보자:

function withTimeout(promise, ms) {
    const timeout = new Promise((_, reject) =>
        setTimeout(() => reject(new Error("타임아웃")), ms)
    );
    return Promise.race([promise, timeout]);
}

const promises = [
    withTimeout(delay(1000, "빠름"), 1500),
    withTimeout(delay(2000, "느림"), 1500)
];

Promise.all(promises)
    .then(results => console.log(results))
    .catch(error => console.log(error.message));
// "타임아웃" (2초 작업은 1.5초 제한을 초과)

시간 제한을 두어 느린 작업을 실패 처리했다.


8. 병렬 스트림 처리

데이터 스트림을 병렬로 처리해보자:

async function processChunk(chunk) {
    return delay(1000, `처리된 ${chunk}`);
}

async function streamParallel(data, batchSize) {
    const results = [];
    for (let i = 0; i < data.length; i += batchSize) {
        const batch = data.slice(i, i + batchSize);
        const batchResults = await Promise.all(
            batch.map(item => processChunk(item))
        );
        results.push(...batchResults);
    }
    return results;
}

const data = ["a", "b", "c", "d", "e"];
streamParallel(data, 2)
    .then(results => console.log(results));
// ["처리된 a", "처리된 b", "처리된 c", "처리된 d", "처리된 e"]

데이터를 배치로 나눠 병렬 처리하며 순차적 흐름과 병렬의 장점을 결합했다.


9. 병렬 처리 성능 분석

병렬 처리가 성능에 어떤 영향을 주는지 살펴보자:

- 속도: 작업이 독립적일수록 병렬 실행으로 시간이 단축된다.

- 리소스: 너무 많은 병렬 작업은 메모리와 네트워크를 과부하할 수 있다.

Promise.all은 단순 병렬에 강력하지만, allSettled나 concurrency 제어로 유연성을 더할 수 있다.


마무리

프로미스 병렬 처리는 비동기 작업을 효율적으로 관리하는 강력한 도구다. 기본적인 Promise.all부터 allSettled, concurrency 제어, 타임아웃, 스트림 처리까지 다양한 상황에 맞춰 활용할 수 있다.


이벤트 루프와 태스크 스케줄링 (Event Loop and Task Scheduling)

이벤트 루프와 태스크 스케줄링 (Event Loop and Task Scheduling)

자바스크립트에서 비동기 작업이 어떻게 실행되는지 이해하려면 이벤트 루프태스크 스케줄링을 알아야 한다. 이 두 개념이 자바스크립트 엔진의 핵심 동작을 좌우한다. 이번에는 이벤트 루프의 기본부터 태스크와 마이크로태스크의 스케줄링까지 코드와 함께 깊이 파고들어보려고 한다.


단계별로 하나씩 뜯어보면서 자바스크립트의 비동기 흐름을 명확히 파악해보자.


이벤트 루프 기본

이벤트 루프는 자바스크립트가 단일 스레드 환경에서 비동기 작업을 처리할 수 있게 해준다. 간단한 동작부터 보자:

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

setTimeout은 비동기 작업으로 태스크 큐에 들어가고, 이벤트 루프가 이를 나중에 실행한다. 호출 스택이 비면 태스크 큐를 확인해서 실행한다.


1. 호출 스택과 태스크 큐

호출 스택과 태스크 큐의 관계를 이해해야 한다:

console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");
// "1"
// "3"
// "2"

여기서 setTimeout의 콜백은 태스크 큐로 이동하고, 호출 스택이 비워진 후에 이벤트 루프가 태스크 큐에서 꺼내 실행한다. 0초라도 바로 실행되지 않는다.


2. 마이크로태스크와의 차이

태스크 외에 마이크로태스크라는 게 있다. Promise는 마이크로태스크 큐로 간다:

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

마이크로태스크는 호출 스택이 비면 바로 실행되고, 태스크는 그 다음에 처리된다. 이벤트 루프는 먼저 마이크로태스크 큐를 비우고 나서 태스크 큐로 넘어간다.


3. 복잡한 태스크 스케줄링

여러 비동기 작업이 얽히면 어떻게 될까:

console.log("1");
setTimeout(() => {
    console.log("2");
    Promise.resolve("3").then(value => console.log(value));
}, 0);
setTimeout(() => console.log("4"), 0);
Promise.resolve("5").then(value => console.log(value));
console.log("6");
// "1"
// "6"
// "5"
// "2"
// "3"
// "4"

호출 스택이 비면 마이크로태스크("5")가 먼저 실행되고, 그 다음 태스크 큐에서 순서대로 "2", "4"가 처리된다. "2" 안의 프로미스는 다시 마이크로태스크로 들어가 "3"을 출력한다.


4. 태스크 지연 이해

태스크가 지연되는 상황을 보자:

function block() {
    let start = Date.now();
    while (Date.now() - start < 2000);
}

console.log("시작");
setTimeout(() => console.log("타이머"), 0);
block();
console.log("끝");
// "시작"
// 약 2초 후 "끝"
// 바로 "타이머"

호출 스택이 block으로 막히면 태스크 큐의 "타이머"는 스택이 비워질 때까지 기다린다. 이벤트 루프는 스택이 비어야 동작한다.


5. 마이크로태스크 과부하

마이크로태스크가 쌓이면 어떻게 될까:

Promise.resolve()
    .then(() => console.log("1"))
    .then(() => console.log("2"))
    .then(() => console.log("3"));
console.log("4");
// "4"
// "1"
// "2"
// "3"

마이크로태스크는 한 번에 모두 처리된다. 호출 스택이 비면 마이크로태스크 큐를 끝까지 비운다.


6. 브라우저 렌더링과의 관계

이벤트 루프는 렌더링과도 연결된다:

function heavyTask() {
    let i = 0;
    while (i < 10000000) i++;
    console.log("무거운 작업 끝");
}

setTimeout(() => console.log("타이머"), 0);
Promise.resolve().then(() => console.log("프로미스"));
heavyTask();
// 약간의 지연 후 "무거운 작업 끝"
// "프로미스"
// "타이머"

무거운 작업이 스택을 막으면 렌더링도 지연된다. 브라우저는 이벤트 루프 한 사이클이 끝나야 화면을 갱신한다.


7. 태스크 분할로 부하 줄이기

긴 작업을 쪼개서 태스크로 나눌 수 있다:

function splitTask(total, chunkSize) {
    let count = 0;
    function step() {
        let start = count;
        while (count - start < chunkSize && count < total) {
            count++;
        }
        console.log(`진행: ${count}/${total}`);
        if (count < total) {
            setTimeout(step, 0);
        }
    }
    step();
}

splitTask(100, 20);
// "진행: 20/100"
// "진행: 40/100"
// "진행: 60/100"
// "진행: 80/100"
// "진행: 100/100"

작업을 태스크로 나눠 이벤트 루프가 다른 일을 처리할 틈을 준다.


8. 실제 API 호출 스케줄링

API 호출을 스케줄링해보자:

async function fetchData(id) {
    const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
    const data = await response.json();
    return data.title;
}

console.log("시작");
setTimeout(() => console.log("타이머"), 0);
fetchData(1).then(title => console.log("API: " + title));
console.log("끝");
// "시작"
// "끝"
// "API: sunt aut facere repellat provident occaecati excepturi optio reprehenderit"
// "타이머"

API 호출은 마이크로태스크로 처리되고, setTimeout은 태스크로 나중에 실행된다.


9. 이벤트 루프의 한계

이벤트 루프에도 한계가 있다:

function infiniteMicrotasks() {
    Promise.resolve()
        .then(() => {
            console.log("마이크로태스크");
            infiniteMicrotasks();
        });
}

infiniteMicrotasks();
setTimeout(() => console.log("타이머"), 0);
// "마이크로태스크" 무한 출력
// "타이머"는 절대 실행되지 않음

마이크로태스크가 끝없이 쌓이면 태스크 큐로 넘어갈 기회가 없다. 이벤트 루프가 멈춘다.


10. 성능과 흐름 제어

이벤트 루프가 코드에 미치는 영향을 보자:

- 성능: 태스크와 마이크로태스크를 잘 분배하면 부하를 줄일 수 있다.

- 흐름: 실행 순서를 예측하고 제어할 수 있다.

태스크마이크로태스크의 우선순위를 이해하는 게 핵심이다.


마무리

이벤트 루프와 태스크 스케줄링은 자바스크립트의 비동기 동작을 이해하는 열쇠다. 호출 스택, 태스크 큐, 마이크로태스크 큐가 어떻게 조화를 이루는지 알면 코드 흐름을 더 잘 다룰 수 있다.


비동기 패턴 심화 (Advanced Async Patterns)

비동기 패턴 심화 (Advanced Async Patterns)

비동기 프로그래밍을 다루다 보면 단순한 Promiseasync/await로는 해결하기 까다로운 상황이 생긴다. 이때 비동기 패턴을 활용하면 복잡한 흐름을 깔끔하게 정리할 수 있다. 이번엔 비동기 작업을 다루는 다양한 패턴을 코드와 함께 단계적으로 풀어보려고 한다.


기본적인 사용법부터 시작해서 점점 복잡한 상황까지 다뤄보며, 비동기 코드를 더 유연하고 강력하게 만드는 법을 알아보자.


비동기 패턴의 기본 흐름

비동기 패턴은 작업의 순서나 의존성을 조정하는 데 유용하다. 먼저 간단한 순차 실행부터 보자:

function delay(ms, value) {
    return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

async function sequentialFlow() {
    const a = await delay(1000, "A 완료");
    console.log(a);
    const b = await delay(1000, "B 완료");
    console.log(b);
    const c = await delay(1000, "C 완료");
    console.log(c);
}

sequentialFlow();
// 1초 후 "A 완료"
// 2초 후 "B 완료"
// 3초 후 "C 완료"

각 작업이 순차적으로 끝날 때까지 기다리며 진행했다. 단순하지만 의존성이 있을 때 유용하다.


1. 병렬 실행 패턴

순차 실행이 필요 없는 경우엔 병렬로 처리해서 시간을 줄일 수 있다:

async function parallelFlow() {
    const promises = [
        delay(1000, "A 완료"),
        delay(1500, "B 완료"),
        delay(500, "C 완료")
    ];
    const results = await Promise.all(promises);
    results.forEach(result => console.log(result));
}

parallelFlow();
// 1.5초 후:
// "A 완료"
// "B 완료"
// "C 완료"

Promise.all로 모든 작업을 동시에 시작하고, 가장 오래 걸리는 작업이 끝날 때까지 기다렸다. 순서가 중요하지 않을 때 효율적이다.


2. 경쟁 조건 패턴 (Race)

여러 작업 중 가장 먼저 끝나는 걸 사용하고 싶다면 Promise.race를 활용할 수 있다:

async function raceFlow() {
    const promises = [
        delay(2000, "느린 서버"),
        delay(1000, "빠른 서버"),
        delay(1500, "중간 서버")
    ];
    const winner = await Promise.race(promises);
    console.log("가장 빠른 결과: " + winner);
}

raceFlow();
// 1초 후 "가장 빠른 결과: 빠른 서버"

가장 빠른 응답을 받아서 바로 처리했다. 서버 요청처럼 속도가 중요한 경우에 유용하다.


3. 순차 병렬 혼합 패턴

일부는 순차로, 일부는 병렬로 처리해야 할 때도 있다:

async function mixedFlow() {
    const step1 = await delay(1000, "첫 단계");
    console.log(step1);

    const parallelSteps = await Promise.all([
        delay(1000, "병렬 A"),
        delay(1000, "병렬 B")
    ]);
    parallelSteps.forEach(step => console.log(step));

    const step3 = await delay(1000, "마지막 단계");
    console.log(step3);
}

mixedFlow();
// 1초 후 "첫 단계"
// 2초 후 "병렬 A"
// "병렬 B"
// 3초 후 "마지막 단계"

첫 단계가 끝난 뒤 병렬로 두 작업을 처리하고, 그 다음 마지막 단계를 기다렸다. 흐름을 세밀하게 조정할 수 있다.


4. 재시도 패턴 (Retry)

작업이 실패하면 자동으로 재시도하는 패턴도 자주 쓰인다:

async function unstableTask() {
    if (Math.random() > 0.3) {
        throw new Error("실패");
    }
    return "성공";
}

async function retry(task, maxAttempts) {
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            return await task();
        } catch (error) {
            console.log(`시도 ${attempt} 실패: ` + error.message);
            if (attempt === maxAttempts) throw error;
            await delay(1000, "재시도 대기");
        }
    }
}

async function run() {
    const result = await retry(unstableTask, 3);
    console.log(result);
}

run();
// "시도 1 실패: 실패" (랜덤)
// 1초 후 "시도 2 실패: 실패" (랜덤)
// 1초 후 "성공" (랜덤)

최대 3번까지 재시도하며 실패하면 에러를 던지고, 성공하면 바로 결과를 반환했다. 불안정한 네트워크 상황에서 빛을 발한다.


5. 타임아웃 패턴

작업이 너무 오래 걸리면 강제로 끝낼 수도 있다:

async function timeout(promise, ms) {
    const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => reject(new Error("시간 초과")), ms);
    });
    return Promise.race([promise, timeoutPromise]);
}

async function run() {
    try {
        const result = await timeout(delay(2000, "느림"), 1000);
        console.log(result);
    } catch (error) {
        console.log(error.message);
    }
}

run();
// "시간 초과"

Promise.race로 타임아웃과 원래 작업을 경쟁하게 만들어 제한 시간을 넘기면 중단했다.


6. 파이프라인 패턴

비동기 작업을 연속적으로 연결해서 처리할 수도 있다:

async function step1(input) {
    return await delay(1000, input + " -> 1단계");
}

async function step2(input) {
    return await delay(1000, input + " -> 2단계");
}

async function step3(input) {
    return await delay(1000, input + " -> 3단계");
}

async function pipeline(input) {
    const result1 = await step1(input);
    const result2 = await step2(result1);
    const result3 = await step3(result2);
    return result3;
}

async function run() {
    const result = await pipeline("시작");
    console.log(result);
}

run();
// 3초 후 "시작 -> 1단계 -> 2단계 -> 3단계"

각 단계를 연결해서 데이터를 순차적으로 변환했다. 데이터 흐름을 체계적으로 관리할 때 좋다.


7. 큐 기반 패턴

작업을 큐에 쌓아서 순차적으로 처리하는 방식도 유용하다:

class AsyncQueue {
    constructor() {
        this.queue = [];
        this.running = false;
    }

    add(task) {
        this.queue.push(task);
        this.process();
    }

    async process() {
        if (this.running || !this.queue.length) return;
        this.running = true;
        while (this.queue.length) {
            const task = this.queue.shift();
            await task();
        }
        this.running = false;
    }
}

const queue = new AsyncQueue();

queue.add(async () => {
    const result = await delay(1000, "작업 1");
    console.log(result);
});
queue.add(async () => {
    const result = await delay(1000, "작업 2");
    console.log(result);
});
queue.add(async () => {
    const result = await delay(1000, "작업 3");
    console.log(result);
});
// 1초 후 "작업 1"
// 2초 후 "작업 2"
// 3초 후 "작업 3"

큐에 작업을 쌓아서 순서대로 처리했다. 동시성을 제어하면서 순서를 보장할 수 있다.


8. 동시성 제한 패턴

병렬 작업 개수를 제한하고 싶을 때 사용할 수 있다:

async function limitedParallel(tasks, limit) {
    const results = [];
    const executing = [];

    for (const task of tasks) {
        const promise = task().then(result => {
            results.push(result);
            executing.splice(executing.indexOf(promise), 1);
        });
        executing.push(promise);
        if (executing.length >= limit) {
            await Promise.race(executing);
        }
    }
    await Promise.all(executing);
    return results;
}

const tasks = [
    () => delay(1000, "작업 1"),
    () => delay(1500, "작업 2"),
    () => delay(500, "작업 3"),
    () => delay(2000, "작업 4")
];

async function run() {
    const results = await limitedParallel(tasks, 2);
    results.forEach(r => console.log(r));
}

run();
// 1초 후 "작업 1"
// 1.5초 후 "작업 2"
// 1.5초 후 "작업 3"
// 2초 후 "작업 4"

최대 2개 작업만 동시에 실행되도록 제한했다. 리소스 사용을 조절하면서 병렬성을 유지할 수 있다.


9. 이벤트 기반 비동기 패턴

이벤트에 반응하며 비동기 작업을 처리할 수도 있다:

const { EventEmitter } = require("events");
const emitter = new EventEmitter();

async function handleEvent(eventName) {
    return new Promise(resolve => {
        emitter.once(eventName, data => resolve(delay(1000, data)));
    });
}

async function run() {
    const task = handleEvent("data");
    setTimeout(() => emitter.emit("data", "이벤트 발생"), 500);
    const result = await task;
    console.log(result);
}

run();
// 1.5초 후 "이벤트 발생"

이벤트 발생을 기다리며 비동기 작업을 연결했다. 외부 트리거에 반응할 때 유연하다.


10. 에러 복구 패턴

에러가 발생해도 흐름을 이어가게 만들 수 있다:

async function riskyTask(id) {
    if (id === 2) throw new Error("문제 발생");
    return await delay(1000, `작업 ${id} 완료`);
}

async function runWithRecovery() {
    const tasks = [1, 2, 3].map(id => async () => {
        try {
            return await riskyTask(id);
        } catch (error) {
            console.log(`에러: ${id} - ` + error.message);
            return `작업 ${id} 복구됨`;
        }
    });
    const results = await Promise.all(tasks.map(t => t()));
    results.forEach(r => console.log(r));
}

runWithRecovery();
// 1초 후:
// "작업 1 완료"
// "에러: 2 - 문제 발생"
// "작업 2 복구됨"
// "작업 3 완료"

에러가 발생한 작업은 복구 값을 반환하며 전체 흐름을 유지했다.


비동기 패턴의 장단점

이런 패턴들이 코드에 어떤 영향을 주는지 보자:

- 유연성: 병렬, 순차, 제한 등 상황에 맞게 조정할 수 있다.

- 복잡성: 패턴이 늘어날수록 코드가 복잡해질 수 있으니 적절히 선택해야 한다.

상황에 맞는 패턴을 골라서 쓰는 게 핵심이다. 필요 이상으로 복잡하게 만들지 않도록 주의하자.


마무리

비동기 패턴은 단순한 순차 실행부터 병렬, 재시도, 큐, 동시성 제한까지 다양한 흐름을 다룰 수 있게 해준다. 각 패턴을 상황에 맞춰 적용하면 비동기 코드를 더 효율적이고 깔끔하게 관리할 수 있다.


비동기 제너레이터 (Async Generators)

비동기 제너레이터 (Async Generators)

자바스크립트에서 비동기 작업을 순차적으로 생성하며 처리하려면 비동기 제너레이터가 유용하다. 이는 비동기 이터레이터를 더 간결하게 구현할 수 있게 해준다. 이번에는 비동기 제너레이터의 기본부터 심화까지 코드와 함께 자세히 풀어보려고 한다.


비동기 제너레이터를 잘 다루면 비동기 데이터 흐름을 깔끔하고 효율적으로 관리할 수 있다. 단계별로 하나씩 살펴보자.


비동기 제너레이터 기본

async function*로 정의하며, yield가 비동기 값을 생성한다:

async function* asyncGenerator() {
    yield Promise.resolve(1);
    yield Promise.resolve(2);
    yield Promise.resolve(3);
}

async function process() {
    const gen = asyncGenerator();
    for await (const num of gen) {
        console.log(num);
    }
}

process();
// 1
// 2
// 3

for await...of로 비동기 제너레이터를 순회하며 값을 받았다. Promise가 자동으로 해결된다.


1. 비동기 작업과 yield 결합

시간 지연을 포함한 비동기 작업을 생성해보자:

function delay(ms, value) {
    return new Promise((resolve) => {
        setTimeout(() => resolve(value), ms);
    });
}

async function* asyncGenerator() {
    yield delay(1000, "첫 번째");
    yield delay(1000, "두 번째");
    yield delay(1000, "세 번째");
}

async function process() {
    for await (const value of asyncGenerator()) {
        console.log(value);
    }
}

process();
// 1초 후 "첫 번째"
// 2초 후 "두 번째"
// 3초 후 "세 번째"

yield가 비동기 작업을 기다리며 순차적으로 값을 생성했다.


2. 외부 API 데이터 생성

API 호출을 비동기 제너레이터로 처리해보자:

async function* fetchPosts() {
    for (let id = 1; id <= 3; id++) {
        const response = await fetch(
            `https://jsonplaceholder.typicode.com/posts/${id}`
        );
        const data = await response.json();
        yield data.title;
    }
}

async function process() {
    for await (const title of fetchPosts()) {
        console.log(title);
    }
}

process();
// "sunt aut facere repellat provident occaecati excepturi optio reprehenderit"
// "qui est esse"
// "ea molestias quasi exercitationem repellat qui ipsa sit aut"

API에서 가져온 데이터를 하나씩 yield로 생성하며 순회했다.


3. 에러 처리

비동기 제너레이터에서도 에러를 관리할 수 있다:

async function* asyncGenerator() {
    yield delay(1000, 1);
    throw new Error("에러 발생");
    yield delay(1000, 3);
}

async function process() {
    try {
        for await (const num of asyncGenerator()) {
            console.log(num);
        }
    } catch (error) {
        console.log("에러: " + error.message);
    }
}

process();
// 1초 후 1
// "에러: 에러 발생"

throw로 에러를 발생시키고, try-catch로 잡아서 처리했다.


4. 입력값 기반 생성

외부 입력을 받아 동적으로 값을 생성할 수 있다:

async function* countGenerator(max) {
    for (let i = 1; i <= max; i++) {
        yield delay(1000, i);
    }
}

async function process() {
    for await (const num of countGenerator(3)) {
        console.log(num);
    }
}

process();
// 1초 후 1
// 2초 후 2
// 3초 후 3

매개변수로 받은 최대값까지 비동기적으로 생성했다.


5. 스트림처럼 활용

비동기 제너레이터를 스트림처럼 사용할 수 있다:

async function* streamGenerator() {
    let count = 0;
    while (count < 5) {
        count++;
        yield delay(500, `스트림 ${count}`);
    }
}

async function processStream() {
    for await (const chunk of streamGenerator()) {
        console.log(chunk);
    }
}

processStream();
// 0.5초 후 "스트림 1"
// 1초 후 "스트림 2"
// 1.5초 후 "스트림 3"
// 2초 후 "스트림 4"
// 2.5초 후 "스트림 5"

지속적인 데이터 흐름을 시뮬레이션하며 순차적으로 값을 생성했다.


6. 조기 종료와 return

제너레이터를 조기에 종료하고 정리할 수 있다:

async function* asyncGenerator() {
    try {
        yield delay(1000, 1);
        yield delay(1000, 2);
        yield delay(1000, 3);
    } finally {
        console.log("제너레이터 종료");
    }
}

async function process() {
    const gen = asyncGenerator();
    for await (const num of gen) {
        console.log(num);
        if (num === 2) {
            await gen.return();
            break;
        }
    }
}

process();
// 1초 후 1
// 2초 후 2
// "제너레이터 종료"

return 메서드로 종료하고, finally로 정리 작업을 실행했다.


7. next()로 수동 제어

for await...of 대신 next()로 수동으로 제어할 수 있다:

async function* asyncGenerator() {
    yield delay(1000, 1);
    yield delay(1000, 2);
    yield delay(1000, 3);
}

async function processManual() {
    const gen = asyncGenerator();
    let result = await gen.next();
    while (!result.done) {
        console.log(result.value);
        result = await gen.next();
    }
}

processManual();
// 1초 후 1
// 2초 후 2
// 3초 후 3

수동으로 next()를 호출해서 값을 하나씩 처리했다.


8. 성능과 가독성에 미치는 영향

비동기 제너레이터가 코드에 어떤 영향을 주는지 보자:

- 성능: 비동기 작업을 순차적으로 처리하니 대기 시간이 늘어날 수 있지만, 필요한 만큼만 생성해서 효율적이다.

- 가독성: yieldasync 조합으로 비동기 흐름이 명확해진다.

yield로 비동기 값을 간결하게 생성하고, for await로 쉽게 순회하는 점이 핵심이다.


마무리

비동기 제너레이터는 비동기 데이터 생성과 순회를 단순화한다. 기본 사용부터 API 처리, 스트림, 에러 관리까지 다양한 상황에서 유연하게 활용할 수 있다.


비동기 이터레이터 (Async Iterators)

비동기 이터레이터 (Async Iterators)

자바스크립트에서 비동기 작업과 반복을 결합하면 강력한 흐름 제어가 가능하다. 비동기 이터레이터는 비동기 데이터 소스를 순차적으로 처리할 수 있게 해준다. 이번에는 비동기 이터레이터의 기본부터 심화까지 코드와 함께 자세히 풀어보려고 한다.


이를 잘 활용하면 비동기 데이터를 더 유연하고 직관적으로 다룰 수 있다. 단계별로 하나씩 살펴보자.


비동기 이터레이터 기본

비동기 이터레이터는 [Symbol.asyncIterator]를 구현한 객체로, next()가 Promise를 반환한다:

const asyncIterable = {
    [Symbol.asyncIterator]() {
        let count = 0;
        return {
            async next() {
                count++;
                if (count <= 3) {
                    return Promise.resolve({ value: count, done: false });
                }
                return Promise.resolve({ done: true });
            }
        };
    }
};

async function process() {
    for await (const num of asyncIterable) {
        console.log(num);
    }
}

process();
// 1
// 2
// 3

for await...of로 비동기 이터레이터를 순회하며 값을 출력했다. 각 next()가 Promise를 반환한다.


1. 비동기 데이터 소스 처리

비동기적으로 데이터를 가져오는 상황을 다뤄보자:

function delay(ms, value) {
    return new Promise((resolve) => {
        setTimeout(() => resolve(value), ms);
    });
}

const asyncIterable = {
    [Symbol.asyncIterator]() {
        let id = 0;
        return {
            async next() {
                id++;
                if (id <= 3) {
                    return delay(1000, `데이터 ${id}`)
                        .then(value => ({ value, done: false }));
                }
                return Promise.resolve({ done: true });
            }
        };
    }
};

async function process() {
    for await (const data of asyncIterable) {
        console.log(data);
    }
}

process();
// 1초 후 "데이터 1"
// 2초 후 "데이터 2"
// 3초 후 "데이터 3"

시간 지연을 시뮬레이션해서 비동기 데이터를 순차적으로 처리했다.


2. 외부 API와 비동기 이터레이터

실제 API 호출을 이터레이터로 처리해보자:

const apiIterable = {
    [Symbol.asyncIterator]() {
        let page = 1;
        return {
            async next() {
                if (page <= 3) {
                    const response = await fetch(
                        `https://jsonplaceholder.typicode.com/posts/${page}`
                    );
                    const data = await response.json();
                    page++;
                    return { value: data.title, done: false };
                }
                return { done: true };
            }
        };
    }
};

async function process() {
    for await (const title of apiIterable) {
        console.log(title);
    }
}

process();
// "sunt aut facere repellat provident occaecati excepturi optio reprehenderit"
// "qui est esse"
// "ea molestias quasi exercitationem repellat qui ipsa sit aut"

API에서 데이터를 페이지 단위로 가져와 순차적으로 출력했다.


3. 에러 처리 추가

비동기 이터레이터에서도 에러를 관리할 수 있다:

const asyncIterable = {
    [Symbol.asyncIterator]() {
        let count = 0;
        return {
            async next() {
                count++;
                if (count === 2) {
                    return Promise.reject(new Error("2에서 실패"));
                }
                if (count <= 3) {
                    return delay(1000, count)
                        .then(value => ({ value, done: false }));
                }
                return Promise.resolve({ done: true });
            }
        };
    }
};

async function process() {
    try {
        for await (const num of asyncIterable) {
            console.log(num);
        }
    } catch (error) {
        console.log("에러: " + error.message);
    }
}

process();
// 1초 후 1
// "에러: 2에서 실패"

try-catch로 에러를 잡아서 순회 중단 시 처리했다.


4. 커스텀 비동기 이터레이터 클래스

클래스로 더 구조화된 이터레이터를 만들어보자:

class AsyncCounter {
    constructor(max) {
        this.max = max;
    }

    [Symbol.asyncIterator]() {
        let count = 0;
        return {
            async next() {
                count++;
                if (count <= this.max) {
                    return delay(1000, count)
                        .then(value => ({ value, done: false }));
                }
                return Promise.resolve({ done: true });
            }
        };
    }
}

async function process() {
    const counter = new AsyncCounter(3);
    for await (const num of counter) {
        console.log(num);
    }
}

process();
// 1초 후 1
// 2초 후 2
// 3초 후 3

클래스로 이터레이터를 만들어 재사용성을 높였다.


5. 스트림처럼 사용

비동기 이터레이터를 스트림처럼 활용할 수 있다:

const streamIterable = {
    [Symbol.asyncIterator]() {
        let count = 0;
        return {
            async next() {
                if (count < 5) {
                    const value = await delay(500, `스트림 ${count}`);
                    count++;
                    return { value, done: false };
                }
                return { done: true };
            }
        };
    }
};

async function processStream() {
    for await (const chunk of streamIterable) {
        console.log(chunk);
    }
}

processStream();
// 0.5초 후 "스트림 0"
// 1초 후 "스트림 1"
// 1.5초 후 "스트림 2"
// 2초 후 "스트림 3"
// 2.5초 후 "스트림 4"

데이터가 지속적으로 도착하는 상황을 시뮬레이션했다.


6. 조기 종료와 cleanup

이터레이터에 종료 로직을 추가할 수 있다:

const asyncIterable = {
    [Symbol.asyncIterator]() {
        let count = 0;
        let timer;
        return {
            async next() {
                count++;
                if (count <= 5) {
                    return delay(1000, count)
                        .then(value => ({ value, done: false }));
                }
                return Promise.resolve({ done: true });
            },
            async return() {
                clearTimeout(timer);
                console.log("이터레이터 종료");
                return { done: true };
            }
        };
    }
};

async function process() {
    for await (const num of asyncIterable) {
        console.log(num);
        if (num === 2) break;
    }
}

process();
// 1초 후 1
// 2초 후 2
// "이터레이터 종료"

return 메서드로 조기 종료 시 정리 작업을 추가했다.


7. 성능과 가독성에 미치는 영향

비동기 이터레이터가 코드에 어떤 영향을 주는지 보자:

- 성능: 비동기 작업을 순차적으로 처리하니 대기 시간이 늘어날 수 있지만, 스트림 처리에 적합하다.

- 가독성: for await...of로 비동기 순회가 직관적이 된다.

for await...of로 간결함을 유지하고, 에러 처리로 안정성을 더하는 점이 핵심이다.


마무리

비동기 이터레이터는 비동기 데이터 소스를 순회하며 처리하는 데 유용하다. 기본 구현부터 API 호출, 스트림 처리까지 확장 가능하며, 에러 관리와 종료 로직으로 더 튼튼해진다.


에러 로깅과 모니터링 (Error Logging and Monitoring)

에러 로깅과 모니터링 (Error Logging and Monitoring)

자바스크립트 애플리케이션에서 에러는 언제든 발생할 수 있다. 에러 로깅과 모니터링을 잘하면 문제를 빠르게 파악하고, 사용자 경험을 개선할 수 있다. 이번에는 에러를 기록하고 추적하는 방법을 기본부터 심화까지 코드와 함께 자세히 풀어보려고 한다.


에러를 체계적으로 관리하면 코드 안정성이 높아지고, 문제 해결이 쉬워진다. 단계별로 하나씩 살펴보자.


기본적인 에러 로깅

가장 간단한 방법은 console.error로 에러를 기록하는 것이다:

try {
    let data = JSON.parse("잘못된 JSON");
    console.log(data);
} catch (error) {
    console.error("에러 발생: ", error);
}
// "에러 발생: " SyntaxError: Unexpected token � in JSON at position 0

console.error로 에러 메시지와 객체를 출력했다. 개발 중에는 유용하지만, 운영 환경에서는 더 체계적인 로깅이 필요하다.


1. 에러 객체 상세 기록

에러의 세부 정보를 기록하면 디버깅이 쉬워진다:

function logError(error) {
    const errorDetails = {
        message: error.message,
        stack: error.stack,
        time: new Date().toISOString()
    };
    console.error("에러 상세: ", errorDetails);
}

try {
    throw new Error("테스트 에러");
} catch (error) {
    logError(error);
}
// "에러 상세: " { message: "테스트 에러", stack: "...", time: "2025-03-19T..." }

메시지, 스택 트레이스, 발생 시간을 객체로 만들어 기록했다. 문제의 원인을 추적하기 좋아진다.


2. 비동기 에러 로깅

비동기 작업에서도 에러를 기록할 수 있다:

async function fetchData() {
    const response = await fetch("https://invalid.url");
    return response.json();
}

async function process() {
    try {
        await fetchData();
    } catch (error) {
        const log = {
            message: error.message,
            stack: error.stack,
            time: new Date().toISOString()
        };
        console.error("비동기 에러: ", log);
    }
}

process();
// "비동기 에러: " { message: "Failed to fetch", stack: "...", time: "2025-03-19T..." }

try-catch로 비동기 에러를 잡고 상세 로그를 남겼다.


3. 서버로 에러 전송

운영 환경에서는 에러를 서버로 보내 모니터링할 수 있다:

function sendErrorToServer(error) {
    const log = {
        message: error.message,
        stack: error.stack,
        time: new Date().toISOString(),
        userAgent: navigator.userAgent
    };
    fetch("https://your-api.com/error", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(log)
    }).catch(err => console.error("전송 실패: ", err));
}

try {
    throw new Error("운영 에러");
} catch (error) {
    sendErrorToServer(error);
}

fetch로 에러를 서버로 보내고, 사용자 정보도 추가했다. 서버에서 이를 저장하면 실시간 모니터링이 가능하다.


4. window.onerror로 전역 에러 잡기

처리되지 않은 에러를 전역적으로 잡을 수 있다:

window.onerror = function (message, source, lineno, colno, error) {
    const log = {
        message,
        source,
        line: lineno,
        column: colno,
        stack: error ? error.stack : "없음",
        time: new Date().toISOString()
    };
    console.error("전역 에러: ", log);
    return true; // 기본 에러 표시 방지
};

throw new Error("전역 테스트");
// "전역 에러: " { message: "전역 테스트", source: "...", line: ..., column: ..., stack: "...", time: "2025-03-19T..." }

window.onerror로 잡히지 않은 에러를 기록하고, 기본 에러 팝업을 막았다.


5. 모니터링 시스템 연동

Sentry 같은 외부 서비스와 연동하면 더 강력해진다:

// Sentry 초기화 (가정)
Sentry.init({ dsn: "https://your-dsn@sentry.io" });

function logWithSentry(error) {
    Sentry.captureException(error, {
        extra: {
            userAgent: navigator.userAgent,
            time: new Date().toISOString()
        }
    });
}

try {
    throw new Error("Sentry 테스트");
} catch (error) {
    logWithSentry(error);
}

Sentry로 에러를 전송하고 추가 데이터를 붙였다. 대시보드에서 실시간으로 확인할 수 있다.


6. 커스텀 에러 로깅

커스텀 에러 클래스로 구체적인 로깅을 할 수 있다:

class ValidationError extends Error {
    constructor(message, field) {
        super(message);
        this.name = "ValidationError";
        this.field = field;
    }
}

function logCustomError(error) {
    const log = {
        name: error.name,
        message: error.message,
        field: error.field || "없음",
        stack: error.stack,
        time: new Date().toISOString()
    };
    console.error("커스텀 에러: ", log);
}

try {
    throw new ValidationError("잘못된 입력", "email");
} catch (error) {
    logCustomError(error);
}
// "커스텀 에러: " { name: "ValidationError", message: "잘못된 입력", field: "email", stack: "...", time: "2025-03-19T..." }

커스텀 속성을 추가해서 에러의 맥락을 더 풍부하게 기록했다.


7. 모니터링 알림 설정

중요 에러 발생 시 알림을 받을 수 있다:

async function notifyError(error) {
    const log = {
        message: error.message,
        stack: error.stack,
        time: new Date().toISOString()
    };
    await fetch("https://your-api.com/notify", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ log, level: "critical" })
    });
}

try {
    throw new Error("심각한 에러");
} catch (error) {
    notifyError(error);
}

서버로 에러를 보내고, 이를 슬랙이나 이메일로 알림 설정할 수 있다.


8. 성능과 가독성에 미치는 영향

에러 로깅과 모니터링이 개발에 어떤 영향을 주는지 보자:

- 성능: 로깅과 전송은 약간의 오버헤드를 추가하지만, 문제 발견 속도를 높인다.

- 가독성: 체계적인 로그는 코드 상태를 이해하기 쉽게 한다.

서버 전송으로 실시간 추적을 하고, 커스텀 로그로 세부 정보를 강화하는 점이 핵심이다.


마무리

에러 로깅과 모니터링은 단순히 콘솔 출력에서 시작해서 서버 연동, 전역 관리, 외부 서비스 활용까지 확장된다. 이를 통해 문제를 사전에 감지하고, 안정적인 애플리케이션을 유지할 수 있다.


+ Recent posts