비동기 타임아웃 관리 (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, 재시도 로직 등을 조합하면 안정적이고 효율적인 비동기 흐름을 만들 수 있다.


+ Recent posts