performance2025-05-05·7·89/348

Node.js AbortController fetch 타임아웃 처리

Node.js에서 AbortController를 사용하여 fetch 요청의 타임아웃을 처리하는 방법을 다룹니다.

Introduction

Node.js에서 외부 API 호출 시 타임아웃을 설정하는 것은 중요합니다. AbortController와 AbortSignal을 사용하면 fetch 요청의 타임아웃을 효과적으로 관리할 수 있습니다.

Environment

node --version
# v20.11.0

# AbortController는 Node.js 15+에서 네이티브 지원
node -e "console.log(typeof AbortController)"
# function

Problem

API 호출 시 타임아웃이 설정되어 있지 않아 무한 대기 상태가 발생했습니다:

// 타임아웃 미설정
async function fetchData(url) {
    const response = await fetch(url);
    return response.json();
}

// 응답이 없으면 무한 대기
const data = await fetchData('https://api.slow-service.com/data');

Solution

1. 기본 AbortController 사용

async function fetchWithTimeout(url, timeoutMs = 5000) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

    try {
        const response = await fetch(url, {
            signal: controller.signal,
        });

        clearTimeout(timeoutId);
        return response;
    } catch (error) {
        clearTimeout(timeoutId);

        if (error.name === 'AbortError') {
            throw new Error(`Request timeout after ${timeoutMs}ms`);
        }
        throw error;
    }
}

// 사용 예시
try {
    const response = await fetchWithTimeout('https://api.example.com/data', 3000);
    const data = await response.json();
    console.log(data);
} catch (error) {
    console.error('Fetch failed:', error.message);
}

2. 재시도 로직과 타임아웃

class FetchClient {
    constructor(defaultTimeout = 5000, maxRetries = 3) {
        this.defaultTimeout = defaultTimeout;
        this.maxRetries = maxRetries;
    }

    async fetch(url, options = {}) {
        const {
            timeout = this.defaultTimeout,
            retries = this.maxRetries,
            retryDelay = 1000,
            ...fetchOptions
        } = options;

        let lastError;

        for (let attempt = 0; attempt <= retries; attempt++) {
            try {
                const controller = new AbortController();
                const timeoutId = setTimeout(() => controller.abort(), timeout);

                const response = await fetch(url, {
                    ...fetchOptions,
                    signal: controller.signal,
                });

                clearTimeout(timeoutId);

                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }

                return response;
            } catch (error) {
                lastError = error;

                if (error.name === 'AbortError') {
                    console.log(`Attempt ${attempt + 1} timed out`);
                } else {
                    console.log(`Attempt ${attempt + 1} failed:`, error.message);
                }

                if (attempt < retries) {
                    await this.delay(retryDelay * Math.pow(2, attempt));
                }
            }
        }

        throw lastError;
    }

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

// 사용 예시
const client = new FetchClient(5000, 3);

try {
    const response = await client.fetch('https://api.example.com/data', {
        timeout: 3000,
        retries: 2,
    });
    const data = await response.json();
} catch (error) {
    console.error('All attempts failed:', error.message);
}

3. 병렬 요청 타임아웃

async function fetchAllWithTimeout(urls, timeoutMs = 10000) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

    try {
        const promises = urls.map(url =>
            fetch(url, { signal: controller.signal })
                .then(res => res.json())
        );

        const results = await Promise.allSettled(promises);
        clearTimeout(timeoutId);

        return results.map((result, index) => ({
            url: urls[index],
            status: result.status,
            data: result.status === 'fulfilled' ? result.value : null,
            error: result.status === 'rejected' ? result.reason.message : null,
        }));
    } catch (error) {
        clearTimeout(timeoutId);
        throw error;
    }
}

// 사용 예시
const urls = [
    'https://api1.example.com/data',
    'https://api2.example.com/data',
    'https://api3.example.com/data',
];

const results = await fetchAllWithTimeout(urls, 5000);
console.log(results);

4. 커스텀 훅 스타일

function useFetchWithTimeout(url, options = {}) {
    const { timeout = 5000, deps = [] } = options;

    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);

    const cleanup = () => {
        clearTimeout(timeoutId);
    };

    return {
        fetch: async () => {
            try {
                const response = await fetch(url, {
                    signal: controller.signal,
                });
                cleanup();
                return response.json();
            } catch (error) {
                cleanup();
                throw error;
            }
        },
        abort: () => controller.abort(),
        cleanup,
    };
}

// 사용 예시
const { fetch: fetchData, abort } = useFetchWithTimeout(
    'https://api.example.com/data',
    { timeout: 3000 }
);

// 3초 후 자동으로 요청 취소
const data = await fetchData();

5. AbortController 재사용

class AbortManager {
    constructor() {
        this.controllers = new Map();
    }

    create(id, timeoutMs = 5000) {
        // 기존 컨트롤러가 있으면 취소
        if (this.controllers.has(id)) {
            this.abort(id);
        }

        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

        this.controllers.set(id, { controller, timeoutId });
        return controller.signal;
    }

    abort(id) {
        const entry = this.controllers.get(id);
        if (entry) {
            clearTimeout(entry.timeoutId);
            entry.controller.abort();
            this.controllers.delete(id);
        }
    }

    abortAll() {
        for (const [id] of this.controllers) {
            this.abort(id);
        }
    }
}

// 사용 예시
const abortManager = new AbortManager();

// API 요청
const signal = abortManager.create('user-fetch', 3000);
const response = await fetch('/api/user', { signal });

// 요청 취소
abortManager.abort('user-fetch');

Lessons Learned

  1. 타임아웃 설정 필수: 모든 외부 API 호출에 타임아웃을 설정하세요
  2. 에러 분류: AbortError와 다른 에러를 구분하여 처리하세요
  3. 재시도 전략: 타임아웃 발생 시 지수 백오프로 재시도하세요
  4. 리소스 정리: AbortController와 setTimeout을 적절히 정리하세요
  5. 병렬 처리: 여러 요청을 병렬로 처리할 때 전체 타임아웃을 설정하세요

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.