JavaScript 비동기 프로그래밍

JavaScript 비동기 프로그래밍

개요

JavaScript는 단일 스레드로 동작하지만, 비동기 프로그래밍을 통해 네트워크 요청, 파일 읽기 등의 작업을 효율적으로 처리합니다. 이 문서에서는 콜백, Promise, async/await, 그리고 fetch API를 다룹니다.

선수 지식: 07_JS_Events_DOM.md


목차

  1. 동기 vs 비동기
  2. 콜백
  3. Promise
  4. async/await
  5. Fetch API
  6. 에러 처리
  7. 실전 패턴

동기 vs 비동기

동기 (Synchronous)

코드가 순서대로 실행됩니다. 한 작업이 끝나야 다음 작업이 시작됩니다.

console.log('1');
console.log('2');
console.log('3');
// 출력: 1, 2, 3

비동기 (Asynchronous)

작업을 기다리지 않고 다음 코드를 실행합니다.

console.log('1');
setTimeout(() => {
    console.log('2');
}, 1000);
console.log('3');
// 출력: 1, 3, 2 (1초 후 2)

비동기가 필요한 상황

  • 서버 요청 (API 호출)
  • 파일 읽기/쓰기
  • 타이머 (setTimeout, setInterval)
  • 이벤트 리스너
  • 데이터베이스 쿼리

이벤트 루프

┌─────────────────────────────────────────┐
│                                         │
│          Call Stack (호출 스택)          │
│                                         │
└───────────────────┬─────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────┐
│                                         │
│          Web APIs                       │
│   (setTimeout, fetch, DOM events)       │
│                                         │
└───────────────────┬─────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────┐
│                                         │
│     Callback Queue / Task Queue         │
│                                         │
└───────────────────┬─────────────────────┘
                    │
                    ▼
            Event Loop가 감시
                    │
          Call Stack이 비면
                    │
          Callback을 Stack으로

콜백

콜백 함수란?

다른 함수에 인자로 전달되어 나중에 실행되는 함수입니다.

function greet(name, callback) {
    console.log(`안녕, ${name}!`);
    callback();
}

greet('홍길동', function() {
    console.log('인사 완료');
});

setTimeout / setInterval

// 일정 시간 후 실행
setTimeout(() => {
    console.log('3초 후 실행');
}, 3000);

// 반복 실행
const intervalId = setInterval(() => {
    console.log('1초마다 실행');
}, 1000);

// 타이머 취소
clearTimeout(timeoutId);
clearInterval(intervalId);

콜백 지옥 (Callback Hell)

중첩된 콜백으로 코드가 복잡해지는 문제

// 나쁜 예: 콜백 지옥
getUser(userId, function(user) {
    getOrders(user.id, function(orders) {
        getOrderDetails(orders[0].id, function(details) {
            getProductInfo(details.productId, function(product) {
                console.log(product);
                // 계속 중첩...
            });
        });
    });
});

Promise

Promise란?

비동기 작업의 최종 완료(또는 실패)와 그 결과값을 나타내는 객체입니다.

Promise 상태:
┌──────────┐     ┌───────────┐
│ pending  │────▶│ fulfilled │  (성공)
│ (대기)   │     └───────────┘
│          │     ┌───────────┐
│          │────▶│ rejected  │  (실패)
└──────────┘     └───────────┘

Promise 생성

const promise = new Promise((resolve, reject) => {
    // 비동기 작업
    const success = true;

    if (success) {
        resolve('성공!');  // 성공 시
    } else {
        reject('실패!');   // 실패 시
    }
});

then / catch / finally

promise
    .then(result => {
        console.log(result);  // 성공 시 실행
        return '다음 값';     // 체이닝 가능
    })
    .then(nextResult => {
        console.log(nextResult);
    })
    .catch(error => {
        console.error(error);  // 실패 시 실행
    })
    .finally(() => {
        console.log('항상 실행');  // 성공/실패 무관
    });

Promise 체이닝

fetchUser(userId)
    .then(user => fetchOrders(user.id))
    .then(orders => fetchOrderDetails(orders[0].id))
    .then(details => fetchProductInfo(details.productId))
    .then(product => console.log(product))
    .catch(error => console.error(error));

Promise 정적 메서드

// 즉시 이행된 Promise
Promise.resolve('값');

// 즉시 거부된 Promise
Promise.reject('에러');

// 모두 성공해야 성공 (하나라도 실패하면 실패)
Promise.all([promise1, promise2, promise3])
    .then(results => {
        // results = [result1, result2, result3]
    })
    .catch(error => {
        // 첫 번째 실패
    });

// 모든 결과 반환 (성공/실패 구분)
Promise.allSettled([promise1, promise2, promise3])
    .then(results => {
        results.forEach(result => {
            if (result.status === 'fulfilled') {
                console.log('성공:', result.value);
            } else {
                console.log('실패:', result.reason);
            }
        });
    });

// 가장 먼저 완료되는 것 (성공이든 실패든)
Promise.race([promise1, promise2, promise3])
    .then(result => {
        // 가장 빠른 결과
    });

// 가장 먼저 성공하는 것
Promise.any([promise1, promise2, promise3])
    .then(result => {
        // 가장 먼저 성공한 결과
    })
    .catch(error => {
        // 모두 실패한 경우
    });

Promise 실전 예제

// 타이머 Promise화
function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

delay(2000).then(() => console.log('2초 경과'));

// 콜백 API를 Promise로 변환
function loadImage(src) {
    return new Promise((resolve, reject) => {
        const img = new Image();
        img.onload = () => resolve(img);
        img.onerror = () => reject(new Error(`이미지 로드 실패: ${src}`));
        img.src = src;
    });
}

loadImage('image.jpg')
    .then(img => document.body.appendChild(img))
    .catch(error => console.error(error));

async/await

기본 사용법

async 함수는 항상 Promise를 반환합니다. await는 Promise가 처리될 때까지 기다립니다.

async function fetchData() {
    const response = await fetch('/api/data');
    const data = await response.json();
    return data;
}

// 호출
fetchData().then(data => console.log(data));

에러 처리

async function fetchData() {
    try {
        const response = await fetch('/api/data');
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        return data;
    } catch (error) {
        console.error('에러 발생:', error);
        throw error;  // 에러 재전파
    }
}

순차 실행 vs 병렬 실행

// 순차 실행 (느림)
async function sequential() {
    const user = await fetchUser();      // 1초
    const orders = await fetchOrders();  // 1초
    const products = await fetchProducts();  // 1초
    // 총 3초
}

// 병렬 실행 (빠름)
async function parallel() {
    const [user, orders, products] = await Promise.all([
        fetchUser(),
        fetchOrders(),
        fetchProducts()
    ]);
    // 총 1초 (가장 느린 것 기준)
}

반복문에서 async/await

// 순차 처리
async function processSequential(items) {
    for (const item of items) {
        await processItem(item);  // 하나씩 순서대로
    }
}

// 병렬 처리
async function processParallel(items) {
    await Promise.all(items.map(item => processItem(item)));
}

// forEach는 await를 기다리지 않음 (주의!)
items.forEach(async item => {
    await processItem(item);  // 동시에 모두 시작됨
});

최상위 await (ES2022)

모듈에서 최상위 레벨에서 await 사용 가능

// module.js
const response = await fetch('/api/config');
export const config = await response.json();

Fetch API

기본 사용법

// GET 요청
fetch('https://api.example.com/data')
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error(error));

// async/await
async function getData() {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    return data;
}

HTTP 메서드

// GET (기본)
fetch('/api/users');

// POST
fetch('/api/users', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
    },
    body: JSON.stringify({
        name: '홍길동',
        email: 'hong@example.com'
    })
});

// PUT
fetch('/api/users/1', {
    method: 'PUT',
    headers: {
        'Content-Type': 'application/json',
    },
    body: JSON.stringify({
        name: '김철수'
    })
});

// PATCH
fetch('/api/users/1', {
    method: 'PATCH',
    headers: {
        'Content-Type': 'application/json',
    },
    body: JSON.stringify({
        name: '이영희'
    })
});

// DELETE
fetch('/api/users/1', {
    method: 'DELETE'
});

Response 객체

const response = await fetch('/api/data');

// 응답 정보
response.ok;         // 성공 여부 (200-299)
response.status;     // HTTP 상태 코드
response.statusText; // 상태 메시지
response.headers;    // 응답 헤더
response.url;        // 요청 URL

// 본문 읽기 (한 번만 가능)
await response.json();   // JSON 파싱
await response.text();   // 텍스트
await response.blob();   // Blob (바이너리)
await response.arrayBuffer();  // ArrayBuffer
await response.formData();     // FormData

헤더 설정

fetch('/api/data', {
    headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + token,
        'X-Custom-Header': 'value'
    }
});

// Headers 객체
const headers = new Headers();
headers.append('Content-Type', 'application/json');
headers.append('Authorization', 'Bearer ' + token);

fetch('/api/data', { headers });

쿼리 파라미터

// URL에 직접
fetch('/api/users?page=1&limit=10');

// URLSearchParams 사용
const params = new URLSearchParams({
    page: 1,
    limit: 10,
    search: '홍길동'
});

fetch(`/api/users?${params}`);

// URL 객체
const url = new URL('/api/users', 'https://api.example.com');
url.searchParams.set('page', 1);
url.searchParams.set('limit', 10);

fetch(url);

FormData 전송

// HTML 폼에서
const form = document.querySelector('form');
const formData = new FormData(form);

fetch('/api/upload', {
    method: 'POST',
    body: formData  // Content-Type 자동 설정
});

// 직접 생성
const formData = new FormData();
formData.append('username', '홍길동');
formData.append('file', fileInput.files[0]);

fetch('/api/upload', {
    method: 'POST',
    body: formData
});

파일 업로드

const fileInput = document.querySelector('input[type="file"]');

fileInput.addEventListener('change', async (e) => {
    const file = e.target.files[0];

    const formData = new FormData();
    formData.append('file', file);

    try {
        const response = await fetch('/api/upload', {
            method: 'POST',
            body: formData
        });

        if (!response.ok) throw new Error('업로드 실패');

        const result = await response.json();
        console.log('업로드 완료:', result);
    } catch (error) {
        console.error(error);
    }
});

요청 취소 (AbortController)

const controller = new AbortController();
const signal = controller.signal;

// 5초 후 자동 취소
const timeoutId = setTimeout(() => controller.abort(), 5000);

try {
    const response = await fetch('/api/data', { signal });
    clearTimeout(timeoutId);
    const data = await response.json();
} catch (error) {
    if (error.name === 'AbortError') {
        console.log('요청이 취소되었습니다');
    } else {
        throw error;
    }
}

// 수동 취소
controller.abort();

타임아웃 유틸리티

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

    try {
        const response = await fetch(url, {
            ...options,
            signal: controller.signal
        });
        return response;
    } finally {
        clearTimeout(timeoutId);
    }
}

// 사용
const response = await fetchWithTimeout('/api/data', {}, 3000);

에러 처리

fetch는 네트워크 에러만 reject

// 주의: 404, 500 등은 에러가 아님!
fetch('/api/not-found')
    .then(response => {
        // 404여도 여기로 옴
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.json();
    })
    .catch(error => {
        // 네트워크 에러 또는 위에서 던진 에러
        console.error(error);
    });

커스텀 에러 클래스

class HttpError extends Error {
    constructor(response) {
        super(`HTTP error! status: ${response.status}`);
        this.name = 'HttpError';
        this.response = response;
    }
}

async function fetchJSON(url, options) {
    const response = await fetch(url, options);

    if (!response.ok) {
        throw new HttpError(response);
    }

    return response.json();
}

// 사용
try {
    const data = await fetchJSON('/api/data');
} catch (error) {
    if (error instanceof HttpError) {
        if (error.response.status === 404) {
            console.log('데이터를 찾을 수 없습니다');
        } else if (error.response.status === 401) {
            console.log('인증이 필요합니다');
        }
    } else {
        console.error('네트워크 에러:', error);
    }
}

재시도 로직

async function fetchWithRetry(url, options = {}, retries = 3, delay = 1000) {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await fetch(url, options);
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            return response;
        } catch (error) {
            if (i === retries - 1) throw error;

            console.log(`재시도 ${i + 1}/${retries}...`);
            await new Promise(r => setTimeout(r, delay * (i + 1)));
        }
    }
}

실전 패턴

API 클라이언트

class ApiClient {
    constructor(baseURL) {
        this.baseURL = baseURL;
    }

    async request(endpoint, options = {}) {
        const url = `${this.baseURL}${endpoint}`;
        const config = {
            headers: {
                'Content-Type': 'application/json',
                ...options.headers
            },
            ...options
        };

        if (config.body && typeof config.body === 'object') {
            config.body = JSON.stringify(config.body);
        }

        const response = await fetch(url, config);

        if (!response.ok) {
            const error = await response.json().catch(() => ({}));
            throw new Error(error.message || `HTTP ${response.status}`);
        }

        return response.json();
    }

    get(endpoint) {
        return this.request(endpoint);
    }

    post(endpoint, data) {
        return this.request(endpoint, {
            method: 'POST',
            body: data
        });
    }

    put(endpoint, data) {
        return this.request(endpoint, {
            method: 'PUT',
            body: data
        });
    }

    delete(endpoint) {
        return this.request(endpoint, {
            method: 'DELETE'
        });
    }
}

// 사용
const api = new ApiClient('https://api.example.com');

const users = await api.get('/users');
const newUser = await api.post('/users', { name: '홍길동' });
await api.delete('/users/1');

로딩 상태 관리

class LoadingState {
    constructor() {
        this.isLoading = false;
        this.error = null;
        this.data = null;
    }

    async execute(asyncFn) {
        this.isLoading = true;
        this.error = null;

        try {
            this.data = await asyncFn();
            return this.data;
        } catch (error) {
            this.error = error;
            throw error;
        } finally {
            this.isLoading = false;
        }
    }
}

// 사용
const state = new LoadingState();

// UI 업데이트
function updateUI() {
    if (state.isLoading) {
        showSpinner();
    } else if (state.error) {
        showError(state.error.message);
    } else {
        renderData(state.data);
    }
}

await state.execute(() => fetch('/api/data').then(r => r.json()));
updateUI();

디바운스와 쓰로틀

// 디바운스: 마지막 호출 후 일정 시간 후 실행
function debounce(fn, delay) {
    let timeoutId;
    return function(...args) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => fn.apply(this, args), delay);
    };
}

// 검색 자동완성
const searchInput = document.querySelector('#search');
searchInput.addEventListener('input', debounce(async (e) => {
    const results = await fetch(`/api/search?q=${e.target.value}`);
    renderResults(await results.json());
}, 300));

// 쓰로틀: 일정 시간에 한 번만 실행
function throttle(fn, limit) {
    let inThrottle;
    return function(...args) {
        if (!inThrottle) {
            fn.apply(this, args);
            inThrottle = true;
            setTimeout(() => inThrottle = false, limit);
        }
    };
}

// 스크롤 이벤트
window.addEventListener('scroll', throttle(() => {
    console.log('스크롤 위치:', window.scrollY);
}, 100));

무한 스크롤

class InfiniteScroll {
    constructor(container, loadMore) {
        this.container = container;
        this.loadMore = loadMore;
        this.page = 1;
        this.loading = false;
        this.hasMore = true;

        this.setupObserver();
    }

    setupObserver() {
        const sentinel = document.createElement('div');
        sentinel.className = 'sentinel';
        this.container.appendChild(sentinel);

        const observer = new IntersectionObserver(entries => {
            if (entries[0].isIntersecting && !this.loading && this.hasMore) {
                this.load();
            }
        });

        observer.observe(sentinel);
    }

    async load() {
        this.loading = true;

        try {
            const items = await this.loadMore(this.page);

            if (items.length === 0) {
                this.hasMore = false;
                return;
            }

            items.forEach(item => {
                const el = this.createItem(item);
                this.container.insertBefore(el, this.container.lastChild);
            });

            this.page++;
        } finally {
            this.loading = false;
        }
    }

    createItem(item) {
        const div = document.createElement('div');
        div.className = 'item';
        div.textContent = item.title;
        return div;
    }
}

// 사용
new InfiniteScroll(
    document.querySelector('#list'),
    async (page) => {
        const response = await fetch(`/api/items?page=${page}`);
        return response.json();
    }
);

캐싱

class ApiCache {
    constructor(ttl = 60000) {  // 기본 1분
        this.cache = new Map();
        this.ttl = ttl;
    }

    async fetch(url, options) {
        const key = JSON.stringify({ url, options });
        const cached = this.cache.get(key);

        if (cached && Date.now() < cached.expiry) {
            return cached.data;
        }

        const response = await fetch(url, options);
        const data = await response.json();

        this.cache.set(key, {
            data,
            expiry: Date.now() + this.ttl
        });

        return data;
    }

    clear() {
        this.cache.clear();
    }
}

const cachedApi = new ApiCache(30000);  // 30초 캐시
const data = await cachedApi.fetch('/api/data');

연습 문제

문제 1: Promise 체이닝

3개의 API를 순차적으로 호출하여 결과를 합치세요.

// /api/user/1 → { name: '홍길동' }
// /api/user/1/posts → [{ id: 1, title: '글1' }]
// /api/posts/1/comments → [{ id: 1, text: '댓글1' }]
정답 보기
async function getUserData(userId) {
    const user = await fetch(`/api/user/${userId}`).then(r => r.json());
    const posts = await fetch(`/api/user/${userId}/posts`).then(r => r.json());
    const comments = await fetch(`/api/posts/${posts[0].id}/comments`).then(r => r.json());

    return {
        user,
        posts,
        firstPostComments: comments
    };
}

문제 2: 병렬 요청

여러 사용자의 정보를 동시에 가져오세요.

const userIds = [1, 2, 3, 4, 5];
정답 보기
async function fetchAllUsers(userIds) {
    const promises = userIds.map(id =>
        fetch(`/api/users/${id}`).then(r => r.json())
    );

    const users = await Promise.all(promises);
    return users;
}

// 또는 실패 허용
async function fetchAllUsersSafe(userIds) {
    const results = await Promise.allSettled(
        userIds.map(id =>
            fetch(`/api/users/${id}`).then(r => r.json())
        )
    );

    return results
        .filter(r => r.status === 'fulfilled')
        .map(r => r.value);
}

문제 3: 폼 제출

폼 데이터를 서버에 전송하고 결과를 처리하세요.

정답 보기
const form = document.querySelector('#userForm');

form.addEventListener('submit', async (e) => {
    e.preventDefault();

    const submitBtn = form.querySelector('button[type="submit"]');
    submitBtn.disabled = true;
    submitBtn.textContent = '전송 중...';

    try {
        const formData = new FormData(form);
        const data = Object.fromEntries(formData);

        const response = await fetch('/api/users', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(data)
        });

        if (!response.ok) {
            throw new Error('서버 에러');
        }

        const result = await response.json();
        alert('등록 완료!');
        form.reset();
    } catch (error) {
        alert('에러: ' + error.message);
    } finally {
        submitBtn.disabled = false;
        submitBtn.textContent = '전송';
    }
});

다음 단계


참고 자료

to navigate between lessons