JavaScript 비동기 프로그래밍 완벽 가이드
JavaScript의 비동기 프로그래밍을 콜백부터 Promise, async/await, 이벤트 루프, 에러 처리, 동시성 패턴까지 단계별로 완벽하게 정리합니다.
JavaScript 비동기 프로그래밍 완벽 가이드
JavaScript는 싱글 스레드 언어입니다. 하나의 스레드에서 한 번에 하나의 작업만 처리할 수 있다는 의미입니다. 그러나 웹 애플리케이션에서는 네트워크 요청, 파일 읽기, 타이머 등 시간이 오래 걸리는 작업을 빈번하게 처리해야 합니다. 만약 이러한 작업을 동기적으로 처리한다면, 작업이 완료될 때까지 UI가 멈추고 사용자는 아무것도 할 수 없게 됩니다.
이 문제를 해결하기 위해 JavaScript는 비동기 프로그래밍 모델을 채택하고 있습니다. 이 글에서는 비동기 프로그래밍의 기초부터 고급 패턴까지 단계적으로 살펴보겠습니다.
동기 vs 비동기 이해하기
동기(Synchronous) 처리
동기 방식에서는 코드가 위에서 아래로 순차적으로 실행됩니다. 이전 작업이 완료되어야 다음 작업이 시작됩니다.
console.log("1. 첫 번째 작업");
// 만약 여기서 3초 걸리는 작업이 있다면...
console.log("2. 두 번째 작업"); // 3초 후에 실행됨
console.log("3. 세 번째 작업"); // 그 이후에 실행됨
비동기(Asynchronous) 처리
비동기 방식에서는 시간이 걸리는 작업을 시작한 후, 완료를 기다리지 않고 다음 작업을 즉시 수행합니다. 시간이 걸리는 작업이 완료되면 그때 결과를 처리합니다.
console.log("1. 첫 번째 작업");
setTimeout(() => {
console.log("2. 비동기 작업 완료 (2초 후)");
}, 2000);
console.log("3. 세 번째 작업");
// 출력 순서:
// 1. 첫 번째 작업
// 3. 세 번째 작업
// 2. 비동기 작업 완료 (2초 후)
이벤트 루프 (Event Loop)
비동기 프로그래밍을 제대로 이해하려면 JavaScript 엔진의 이벤트 루프 메커니즘을 알아야 합니다. 이벤트 루프는 JavaScript가 싱글 스레드임에도 불구하고 비동기 작업을 효율적으로 처리할 수 있게 해주는 핵심 메커니즘입니다.
구성 요소
JavaScript의 비동기 처리 시스템은 다음 구성 요소로 이루어져 있습니다.
1. 콜 스택 (Call Stack)
현재 실행 중인 함수들이 쌓이는 곳입니다. 함수가 호출되면 스택에 추가(push)되고, 실행이 완료되면 제거(pop)됩니다.
2. 웹 API (Web APIs)
브라우저가 제공하는 API로, setTimeout, fetch, addEventListener 등의 비동기 작업을 처리합니다. 이 작업들은 콜 스택이 아닌 브라우저의 별도 스레드에서 실행됩니다.
3. 태스크 큐 (Task Queue / Callback Queue)
웹 API에서 완료된 콜백 함수들이 대기하는 큐입니다. setTimeout, setInterval, I/O 작업 등의 콜백이 이곳에 들어갑니다.
4. 마이크로태스크 큐 (Microtask Queue)
Promise의 .then(), .catch(), .finally() 콜백과 MutationObserver 콜백이 대기하는 큐입니다. 태스크 큐보다 우선순위가 높습니다.
이벤트 루프의 동작 방식
console.log("1. 시작");
setTimeout(() => {
console.log("2. setTimeout 콜백");
}, 0);
Promise.resolve().then(() => {
console.log("3. Promise 콜백");
});
console.log("4. 끝");
// 출력 순서:
// 1. 시작
// 4. 끝
// 3. Promise 콜백
// 2. setTimeout 콜백
이 코드의 실행 과정을 단계별로 분석해보겠습니다.
console.log("1. 시작")이 콜 스택에 들어가고 즉시 실행됩니다.setTimeout이 콜 스택에 들어가고, 콜백을 웹 API에 등록한 후 제거됩니다. 0ms 후 콜백이 태스크 큐로 이동합니다.Promise.resolve().then()이 실행되고, 콜백이 마이크로태스크 큐에 등록됩니다.console.log("4. 끝")이 콜 스택에 들어가고 즉시 실행됩니다.- 콜 스택이 비었으므로 이벤트 루프가 먼저 마이크로태스크 큐를 확인합니다. Promise 콜백이 실행됩니다.
- 마이크로태스크 큐가 비었으므로 태스크 큐를 확인합니다. setTimeout 콜백이 실행됩니다.
핵심은 마이크로태스크 큐가 태스크 큐보다 항상 먼저 처리된다는 것입니다.
콜백 (Callback)
콜백은 JavaScript에서 비동기를 처리하는 가장 전통적인 방식입니다. 함수를 다른 함수의 인수로 전달하여, 비동기 작업이 완료되었을 때 호출되도록 합니다.
function fetchData(url, callback) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = function () {
if (xhr.status === 200) {
callback(null, JSON.parse(xhr.responseText));
} else {
callback(new Error(`HTTP Error: ${xhr.status}`), null);
}
};
xhr.onerror = function () {
callback(new Error('네트워크 오류'), null);
};
xhr.send();
}
// 사용
fetchData('/api/users', function (error, data) {
if (error) {
console.error('에러 발생:', error.message);
return;
}
console.log('데이터:', data);
});
콜백 지옥 (Callback Hell)
콜백의 가장 큰 문제는 여러 비동기 작업을 순차적으로 수행해야 할 때 발생합니다. 콜백이 중첩되면서 코드의 가독성이 급격히 떨어지는 현상을 "콜백 지옥"이라고 합니다.
// 콜백 지옥의 예
getUser(userId, function (error, user) {
if (error) {
console.error(error);
return;
}
getOrders(user.id, function (error, orders) {
if (error) {
console.error(error);
return;
}
getOrderDetails(orders[0].id, function (error, details) {
if (error) {
console.error(error);
return;
}
getShippingInfo(details.shippingId, function (error, shipping) {
if (error) {
console.error(error);
return;
}
console.log('배송 정보:', shipping);
// 더 깊어질 수 있음...
});
});
});
});
이러한 문제를 해결하기 위해 Promise가 등장했습니다.
Promise
Promise는 ES6(2015)에서 도입된 비동기 작업의 결과를 나타내는 객체입니다. 비동기 작업이 완료되었을 때의 결과값 또는 실패 이유를 담고 있으며, 세 가지 상태를 가집니다.
- Pending (대기): 비동기 작업이 아직 완료되지 않은 상태
- Fulfilled (이행): 비동기 작업이 성공적으로 완료된 상태
- Rejected (거부): 비동기 작업이 실패한 상태
Promise 생성과 사용
// Promise 생성
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId > 0) {
resolve({
id: userId,
name: '홍길동',
email: 'hong@example.com'
});
} else {
reject(new Error('유효하지 않은 사용자 ID'));
}
}, 1000);
});
}
// Promise 사용
fetchUserData(1)
.then(user => {
console.log('사용자:', user.name);
return user.id;
})
.then(userId => {
console.log('사용자 ID:', userId);
})
.catch(error => {
console.error('에러:', error.message);
})
.finally(() => {
console.log('작업 완료');
});
Promise 체이닝 (Chaining)
Promise의 .then() 메서드는 새로운 Promise를 반환하므로, 여러 비동기 작업을 체이닝하여 순차적으로 실행할 수 있습니다. 앞서 본 콜백 지옥을 Promise로 개선하면 다음과 같습니다.
// Promise 체이닝으로 콜백 지옥 해결
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => getOrderDetails(orders[0].id))
.then(details => getShippingInfo(details.shippingId))
.then(shipping => {
console.log('배송 정보:', shipping);
})
.catch(error => {
console.error('에러 발생:', error.message);
});
코드가 훨씬 읽기 쉽고 에러 처리도 한 곳에서 할 수 있습니다.
Promise 정적 메서드
Promise는 여러 비동기 작업을 동시에 처리하기 위한 정적 메서드를 제공합니다.
Promise.all
모든 Promise가 이행될 때까지 기다립니다. 하나라도 거부되면 즉시 거부됩니다.
const promise1 = fetch('/api/users');
const promise2 = fetch('/api/posts');
const promise3 = fetch('/api/comments');
Promise.all([promise1, promise2, promise3])
.then(([usersRes, postsRes, commentsRes]) => {
return Promise.all([
usersRes.json(),
postsRes.json(),
commentsRes.json()
]);
})
.then(([users, posts, comments]) => {
console.log('사용자:', users.length);
console.log('게시글:', posts.length);
console.log('댓글:', comments.length);
})
.catch(error => {
console.error('하나 이상의 요청 실패:', error);
});
Promise.allSettled
모든 Promise가 완료(이행 또는 거부)될 때까지 기다립니다. 각 결과의 상태를 확인할 수 있습니다.
const promises = [
fetch('/api/users'),
fetch('/api/nonexistent'), // 실패할 수 있는 요청
fetch('/api/posts'),
];
Promise.allSettled(promises).then(results => {
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`요청 ${index + 1}: 성공`);
} else {
console.log(`요청 ${index + 1}: 실패 - ${result.reason}`);
}
});
});
Promise.race
가장 먼저 완료되는 Promise의 결과를 반환합니다. 타임아웃 패턴 구현에 유용합니다.
// 타임아웃 패턴
function fetchWithTimeout(url, timeoutMs) {
const fetchPromise = fetch(url);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('요청 시간 초과')), timeoutMs);
});
return Promise.race([fetchPromise, timeoutPromise]);
}
fetchWithTimeout('/api/data', 5000)
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error.message));
Promise.any
가장 먼저 이행(fulfilled)되는 Promise의 결과를 반환합니다. 모두 거부되면 AggregateError를 던집니다.
// 여러 미러 서버 중 가장 빠른 응답 사용
const mirrors = [
fetch('https://mirror1.example.com/data'),
fetch('https://mirror2.example.com/data'),
fetch('https://mirror3.example.com/data'),
];
Promise.any(mirrors)
.then(response => response.json())
.then(data => console.log('가장 빠른 응답:', data))
.catch(error => console.error('모든 미러 서버 실패'));
async/await
async/await는 ES2017에서 도입된 문법으로, Promise를 더욱 직관적이고 동기 코드처럼 작성할 수 있게 해줍니다. 내부적으로는 Promise를 사용하지만, 코드의 가독성이 크게 향상됩니다.
기본 사용법
// async 함수 선언
async function getUserData(userId) {
// await는 Promise가 이행될 때까지 대기
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
return user;
}
// 화살표 함수로도 사용 가능
const getUserData = async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return await response.json();
};
// 사용
getUserData(1).then(user => console.log(user));
콜백 지옥을 async/await로 해결
async function getShippingInfo(userId) {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
const shipping = await getShippingInfo(details.shippingId);
console.log('배송 정보:', shipping);
return shipping;
}
콜백 지옥과 비교하면 코드가 동기 코드처럼 읽히면서도 완전한 비동기 처리가 이루어집니다.
에러 처리
async/await에서는 try/catch 문을 사용하여 에러를 처리합니다.
async function fetchData() {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`HTTP 에러: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
if (error.name === 'TypeError') {
console.error('네트워크 오류:', error.message);
} else {
console.error('데이터 가져오기 실패:', error.message);
}
throw error; // 에러를 상위로 전파
} finally {
console.log('요청 처리 완료');
}
}
에러 처리 유틸리티 패턴
반복되는 try/catch를 줄이기 위한 유틸리티 함수를 만들 수 있습니다.
// Go 스타일 에러 처리 유틸리티
async function to(promise) {
try {
const result = await promise;
return [null, result];
} catch (error) {
return [error, null];
}
}
// 사용 예시
async function processOrder(orderId) {
const [userError, user] = await to(getUser(orderId));
if (userError) {
console.error('사용자 조회 실패:', userError.message);
return;
}
const [orderError, order] = await to(getOrder(user.id));
if (orderError) {
console.error('주문 조회 실패:', orderError.message);
return;
}
console.log('주문 처리 완료:', order);
}
동시성 패턴 (Concurrency Patterns)
비동기 작업을 효율적으로 처리하기 위한 다양한 동시성 패턴을 알아보겠습니다.
병렬 실행 (Parallel Execution)
서로 독립적인 비동기 작업들은 동시에 실행하여 전체 소요 시간을 줄일 수 있습니다.
// 나쁜 예: 순차 실행 (총 3초 소요)
async function sequential() {
const users = await fetchUsers(); // 1초
const posts = await fetchPosts(); // 1초
const comments = await fetchComments(); // 1초
return { users, posts, comments };
}
// 좋은 예: 병렬 실행 (총 1초 소요)
async function parallel() {
const [users, posts, comments] = await Promise.all([
fetchUsers(), // 1초
fetchPosts(), // 1초 (동시 실행)
fetchComments(), // 1초 (동시 실행)
]);
return { users, posts, comments };
}
동시성 제한 (Concurrency Limit)
수백 개의 비동기 작업을 동시에 실행하면 서버에 과부하를 줄 수 있습니다. 동시 실행 수를 제한하는 패턴이 필요합니다.
async function asyncPool(concurrency, items, iteratorFn) {
const results = [];
const executing = new Set();
for (const [index, item] of items.entries()) {
const promise = iteratorFn(item, index).then(result => {
executing.delete(promise);
return result;
});
results.push(promise);
executing.add(promise);
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
// 사용 예: 동시에 최대 3개씩 URL 요청
const urls = [
'/api/data/1', '/api/data/2', '/api/data/3',
'/api/data/4', '/api/data/5', '/api/data/6',
'/api/data/7', '/api/data/8', '/api/data/9',
];
const results = await asyncPool(3, urls, async (url) => {
const response = await fetch(url);
return response.json();
});
재시도 패턴 (Retry Pattern)
네트워크 요청 등은 일시적인 오류로 실패할 수 있습니다. 자동 재시도 로직을 구현하면 안정성을 높일 수 있습니다.
async function fetchWithRetry(url, options = {}, maxRetries = 3, delay = 1000) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.warn(`시도 ${attempt}/${maxRetries} 실패:`, error.message);
if (attempt === maxRetries) {
throw new Error(`${maxRetries}회 재시도 후 최종 실패: ${error.message}`);
}
// 지수 백오프 (exponential backoff)
const waitTime = delay * Math.pow(2, attempt - 1);
console.log(`${waitTime}ms 후 재시도...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
}
// 사용
try {
const data = await fetchWithRetry('/api/unstable-endpoint');
console.log('성공:', data);
} catch (error) {
console.error('최종 실패:', error.message);
}
디바운싱과 쓰로틀링
사용자 입력 같은 빈번한 이벤트에서 비동기 작업의 호출 빈도를 제어하는 패턴입니다.
// 디바운싱: 마지막 호출 후 일정 시간이 지나야 실행
function debounce(fn, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}
// 사용 예: 검색어 자동완성
const searchInput = document.getElementById('search');
const debouncedSearch = debounce(async (query) => {
const response = await fetch(`/api/search?q=${query}`);
const results = await response.json();
displayResults(results);
}, 300);
searchInput.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
// 쓰로틀링: 일정 시간 간격으로만 실행
function throttle(fn, limit) {
let inThrottle;
return function (...args) {
if (!inThrottle) {
fn.apply(this, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
실전 예제: API 데이터 로딩 관리
실제 웹 애플리케이션에서 자주 사용되는 데이터 로딩 패턴을 구현해 보겠습니다.
class DataFetcher {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.cache = new Map();
this.pendingRequests = new Map();
}
async fetch(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const cacheKey = `${url}_${JSON.stringify(options)}`;
// 캐시 확인
if (this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey);
if (Date.now() - cached.timestamp < 60000) { // 1분 캐시
return cached.data;
}
this.cache.delete(cacheKey);
}
// 동일 요청 중복 방지 (요청 중인 Promise 재사용)
if (this.pendingRequests.has(cacheKey)) {
return this.pendingRequests.get(cacheKey);
}
const requestPromise = this._doFetch(url, options, cacheKey);
this.pendingRequests.set(cacheKey, requestPromise);
try {
return await requestPromise;
} finally {
this.pendingRequests.delete(cacheKey);
}
}
async _doFetch(url, options, cacheKey) {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!response.ok) {
throw new Error(`API 에러: ${response.status} ${response.statusText}`);
}
const data = await response.json();
// 캐시 저장
this.cache.set(cacheKey, {
data,
timestamp: Date.now(),
});
return data;
}
clearCache() {
this.cache.clear();
}
}
// 사용
const api = new DataFetcher('https://api.example.com');
async function loadDashboard() {
try {
const [users, stats, notifications] = await Promise.all([
api.fetch('/users'),
api.fetch('/stats'),
api.fetch('/notifications'),
]);
renderDashboard({ users, stats, notifications });
} catch (error) {
showErrorMessage('대시보드 로딩 실패: ' + error.message);
}
}
비동기 이터레이터와 for await...of
ES2018에서 도입된 비동기 이터레이터를 사용하면 스트리밍 데이터를 우아하게 처리할 수 있습니다.
// 비동기 제너레이터
async function* fetchPages(baseUrl) {
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(`${baseUrl}?page=${page}`);
const data = await response.json();
yield data.items;
hasMore = data.hasNextPage;
page++;
}
}
// for await...of로 소비
async function getAllItems() {
const allItems = [];
for await (const items of fetchPages('/api/products')) {
allItems.push(...items);
console.log(`${allItems.length}개 항목 로드됨...`);
}
return allItems;
}
흔한 실수와 주의사항
1. forEach에서 await 사용
forEach는 비동기 콜백을 기다리지 않습니다.
// 잘못된 예
async function processItems(items) {
items.forEach(async (item) => {
await processItem(item); // forEach는 이 await를 기다리지 않음!
});
console.log('완료'); // 모든 처리가 끝나기 전에 실행됨
}
// 올바른 예 (순차 실행)
async function processItems(items) {
for (const item of items) {
await processItem(item);
}
console.log('완료'); // 모든 처리가 끝난 후 실행됨
}
// 올바른 예 (병렬 실행)
async function processItems(items) {
await Promise.all(items.map(item => processItem(item)));
console.log('완료');
}
2. await 빠뜨리기
// 잘못된 예
async function getData() {
const data = fetch('/api/data'); // await를 빠뜨림
console.log(data); // Promise 객체가 출력됨, 데이터가 아님!
}
// 올바른 예
async function getData() {
const response = await fetch('/api/data');
const data = await response.json();
console.log(data);
}
3. 에러 처리를 빠뜨리는 경우
// 잘못된 예: 에러가 무시됨
async function riskyOperation() {
const data = await fetchData(); // 에러 발생 시 처리 안 됨
return data;
}
// 올바른 예
async function safeOperation() {
try {
const data = await fetchData();
return data;
} catch (error) {
console.error('에러 발생:', error);
return null; // 또는 적절한 기본값
}
}
마무리
JavaScript 비동기 프로그래밍은 현대 웹 개발의 핵심입니다. 콜백에서 Promise로, 그리고 async/await로 발전해온 비동기 처리 방식은 코드의 가독성과 유지보수성을 크게 개선했습니다.
핵심 내용을 정리하면 다음과 같습니다.
- 이벤트 루프를 이해하면 비동기 코드의 실행 순서를 정확히 예측할 수 있습니다.
- Promise는 비동기 작업의 결과를 체계적으로 관리할 수 있게 해줍니다.
- async/await는 비동기 코드를 동기 코드처럼 직관적으로 작성할 수 있게 해줍니다.
- 에러 처리는 선택이 아닌 필수입니다. try/catch를 적극적으로 활용하세요.
- 동시성 패턴을 활용하면 성능과 안정성을 동시에 확보할 수 있습니다.
비동기 프로그래밍은 처음에는 직관적이지 않을 수 있지만, 이벤트 루프의 동작 원리를 이해하고 다양한 패턴을 실습하다 보면 자연스럽게 능숙해질 것입니다. 특히 실제 프로젝트에서 API 호출, 데이터베이스 쿼리, 파일 처리 등을 구현하면서 경험을 쌓아가시기 바랍니다.
관련 게시글
웹 성능 최적화 완벽 가이드: Core Web Vitals
Core Web Vitals를 중심으로 웹 성능을 측정하고 개선하는 방법을 알아봅니다. LCP, FID, CLS 지표와 실전 최적화 기법을 다룹니다.
Tailwind CSS 실전 활용법: 모던 웹 스타일링
Tailwind CSS를 활용한 효율적인 웹 스타일링 방법을 알아봅니다. 유틸리티 퍼스트 철학부터 반응형 디자인, 다크 모드, 컴포넌트 패턴까지 다룹니다.
CSS Grid와 Flexbox 완벽 가이드: 모던 레이아웃의 모든 것
CSS Grid와 Flexbox의 핵심 개념, 차이점, 실전 활용법을 상세히 비교하고 반응형 레이아웃을 만드는 방법을 예제와 함께 알아봅니다.