메인 콘텐츠로 바로가기

JavaScript 비동기 처리 완벽 가이드

개요

이 문서에서는 JavaScript의 핵심 비동기 처리 메커니즘인 Promise와 async/await를 깊이 있게 다룹니다. 비동기 처리의 기본 개념부터 실전 활용법까지, 실행 가능한 코드 예제와 함께 학습할 수 있습니다.

이 가이드를 완료하면 다음을 할 수 있습니다:

  • Promise의 동작 원리를 이해하고 직접 생성할 수 있습니다
  • async/await를 사용하여 가독성 높은 비동기 코드를 작성할 수 있습니다
  • 복잡한 비동기 흐름을 효과적으로 제어할 수 있습니다
  • 실무에서 자주 발생하는 비동기 관련 문제를 해결할 수 있습니다

배경: 왜 비동기 처리가 필요한가?

JavaScript의 특성

JavaScript는 싱글 스레드 언어입니다. 하나의 호출 스택(Call Stack)에서 코드를 순차적으로 실행합니다. 만약 모든 작업을 동기적으로 처리한다면 어떻게 될까요?

// ❌ 동기 방식의 문제점 console.log('작업 시작'); const start = Date.now(); while (Date.now() - start < 5000) { // 아무것도 하지 않고 대기 } console.log('작업 완료');

위 코드는 5초 동안 전체 애플리케이션이 멈춥니다. 이 시간 동안 사용자 클릭도, 화면 업데이트도 불가능합니다.

해결책: 비동기 처리

비동기 처리를 사용하면 시간이 오래 걸리는 작업을 백그라운드에서 실행하고, 메인 스레드는 다른 작업을 계속할 수 있습니다.

// ✅ 비동기 방식 console.log('작업 시작'); setTimeout(() => { console.log('5초 후 실행'); }, 5000); console.log('작업 완료'); // 출력 순서: // 작업 시작 // 작업 완료 // (5초 후) // 5초 후 실행

진화의 역사

1세대: 콜백 (Callback)

초기 JavaScript는 콜백 함수로 비동기를 처리했습니다.

// 콜백 방식 function fetchUser(userId, callback) { setTimeout(() => { callback({ id: userId, name: 'John' }); }, 1000); } fetchUser(1, (user) => { console.log(user.name); // 'John' });

문제점: 콜백 지옥 (Callback Hell)

// ❌ 콜백 지옥의 예 fetchUser(1, (user) => { fetchPosts(user.id, (posts) => { fetchComments(posts[0].id, (comments) => { fetchReplies(comments[0].id, (replies) => { console.log(replies); // 4단계 중첩! }); }); }); });

2세대: Promise (ES6/2015)

Promise는 콜백 지옥 문제를 해결하기 위해 등장했습니다.

// ✅ Promise 체이닝 fetchUser(1) .then(user => fetchPosts(user.id)) .then(posts => fetchComments(posts[0].id)) .then(comments => fetchReplies(comments[0].id)) .then(replies => console.log(replies));

3세대: async/await (ES8/2017)

async/await는 Promise를 더 직관적으로 사용할 수 있게 해줍니다.

// ✅ async/await - 동기 코드처럼 읽힘 async function loadData() { const user = await fetchUser(1); const posts = await fetchPosts(user.id); const comments = await fetchComments(posts[0].id); const replies = await fetchReplies(comments[0].id); console.log(replies); }

Promise 완전 정복

Promise란?

Promise는 미래에 완료될 작업을 나타내는 객체입니다. 3가지 상태를 가집니다:

┌─────────────┐ │ Pending │ 초기 상태 │ (대기중) │ └──────┬──────┘ ┌───┴────┐ ┌──────┐ ┌──────┐ │Fulfilled│ │Rejected│ │ (이행) │ │ (거부) │ └──────┘ └──────┘

Promise 생성하기

// 기본 구조 const promise = new Promise((resolve, reject) => { // 비동기 작업 수행 const success = true; if (success) { resolve('성공 데이터'); // 성공 시 } else { reject('실패 이유'); // 실패 시 } });

실전 예제 1: API 요청

function fetchUserData(userId) { return new Promise((resolve, reject) => { // 1초 후 데이터 반환 (실제로는 fetch API 사용) setTimeout(() => { if (userId > 0) { resolve({ id: userId, name: 'John Doe', email: 'john@example.com' }); } else { reject(new Error('유효하지 않은 사용자 ID')); } }, 1000); }); } // 사용 fetchUserData(1) .then(user => { console.log('사용자 정보:', user); }) .catch(error => { console.error('오류 발생:', error.message); });

Promise 체이닝

여러 비동기 작업을 순차적으로 실행할 수 있습니다.

// 순차적 비동기 처리 fetchUserData(1) .then(user => { console.log('1단계: 사용자 조회 완료'); return fetchUserPosts(user.id); // 다음 Promise 반환 }) .then(posts => { console.log('2단계: 게시글 조회 완료'); return fetchPostComments(posts[0].id); }) .then(comments => { console.log('3단계: 댓글 조회 완료'); console.log('전체 댓글:', comments); }) .catch(error => { // 체인 중 어디서든 발생한 오류를 여기서 처리 console.error('오류:', error); }) .finally(() => { // 성공/실패 여부와 관계없이 항상 실행 console.log('작업 종료'); });

Promise 병렬 처리

Promise.all() - 모두 성공해야 함

// 여러 요청을 동시에 실행 const promise1 = fetchUserData(1); const promise2 = fetchUserData(2); const promise3 = fetchUserData(3); Promise.all([promise1, promise2, promise3]) .then(results => { console.log('모든 사용자 조회 완료'); console.log('사용자 1:', results[0]); console.log('사용자 2:', results[1]); console.log('사용자 3:', results[2]); }) .catch(error => { // 하나라도 실패하면 전체 실패 console.error('오류:', error); });

사용 사례: 여러 API를 동시에 호출하고 모든 결과가 필요할 때

// 실제 사용 예시 async function loadDashboard() { try { const [userData, postsData, statsData] = await Promise.all([ fetch('/api/user').then(r => r.json()), fetch('/api/posts').then(r => r.json()), fetch('/api/stats').then(r => r.json()) ]); return { user: userData, posts: postsData, stats: statsData }; } catch (error) { console.error('대시보드 로딩 실패:', error); } }

Promise.allSettled() - 결과와 관계없이 모두 대기

// 일부가 실패해도 전체 결과를 확인 const promises = [ fetchUserData(1), // 성공 fetchUserData(-1), // 실패 fetchUserData(2) // 성공 ]; Promise.allSettled(promises) .then(results => { results.forEach((result, index) => { if (result.status === 'fulfilled') { console.log(`${index}번째 성공:`, result.value); } else { console.log(`${index}번째 실패:`, result.reason.message); } }); }); // 출력: // 0번째 성공: { id: 1, name: 'John Doe', ... } // 1번째 실패: 유효하지 않은 사용자 ID // 2번째 성공: { id: 2, name: 'Jane Doe', ... }

사용 사례: 여러 작업의 성공/실패를 모두 로깅하고 싶을 때

Promise.race() - 가장 빨리 완료된 것만

// 가장 빠른 응답만 사용 const promise1 = new Promise(resolve => setTimeout(() => resolve('느린 서버'), 3000)); const promise2 = new Promise(resolve => setTimeout(() => resolve('빠른 서버'), 1000)); Promise.race([promise1, promise2]) .then(result => { console.log('결과:', result); // '빠른 서버' });

사용 사례: 타임아웃 구현

function fetchWithTimeout(url, timeout = 5000) { const fetchPromise = fetch(url); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('요청 시간 초과')), timeout); }); return Promise.race([fetchPromise, timeoutPromise]); } // 사용 fetchWithTimeout('/api/data', 3000) .then(response => response.json()) .catch(error => { if (error.message === '요청 시간 초과') { console.error('서버 응답이 너무 느립니다'); } });

Promise.any() - 하나만 성공하면 됨

// 여러 소스 중 하나만 성공하면 됨 const promises = [ fetch('https://api1.example.com/data'), fetch('https://api2.example.com/data'), fetch('https://api3.example.com/data') ]; Promise.any(promises) .then(response => response.json()) .then(data => { console.log('데이터 가져오기 성공:', data); }) .catch(error => { // 모두 실패한 경우에만 실행 console.error('모든 소스에서 실패:', error); });

async/await 마스터하기

기본 문법

// async 함수 선언 async function myFunction() { // await는 Promise가 완료될 때까지 기다림 const result = await someAsyncOperation(); return result; } // async 함수는 항상 Promise를 반환 myFunction().then(result => console.log(result));

실전 예제 2: 사용자 데이터 로딩

async function loadUserProfile(userId) { try { // 1. 사용자 기본 정보 조회 const user = await fetchUserData(userId); console.log('사용자 조회 완료:', user.name); // 2. 사용자의 게시글 조회 const posts = await fetchUserPosts(userId); console.log('게시글 수:', posts.length); // 3. 최근 게시글의 댓글 조회 const latestPost = posts[0]; const comments = await fetchPostComments(latestPost.id); console.log('댓글 수:', comments.length); // 4. 모든 데이터를 합쳐서 반환 return { user, posts, latestComments: comments }; } catch (error) { console.error('프로필 로딩 실패:', error); throw error; // 상위로 에러 전파 } } // 사용 loadUserProfile(1) .then(profile => { console.log('프로필 로딩 완료:', profile); }) .catch(error => { console.error('최종 에러 처리:', error); });

병렬 처리 최적화

❌ 비효율적: 순차 처리

// 나쁜 예: 6초 소요 (각 2초씩) async function loadData() { const user = await fetchUser(); const posts = await fetchPosts(); const comments = await fetchComments(); return { user, posts, comments }; }

✅ 효율적: 병렬 처리

// 좋은 예: 2초 소요 (동시 실행) async function loadData() { const [user, posts, comments] = await Promise.all([ fetchUser(), // 동시 실행 fetchPosts(), // 동시 실행 fetchComments() // 동시 실행 ]); return { user, posts, comments }; }

에러 처리 패턴

패턴 1: try-catch (권장)

async function fetchData() { try { const response = await fetch('/api/data'); // HTTP 에러 체크 if (!response.ok) { throw new Error(`HTTP 에러! status: ${response.status}`); } const data = await response.json(); return data; } catch (error) { // 네트워크 에러, 파싱 에러 등 모두 여기서 처리 console.error('데이터 가져오기 실패:', error); // 사용자에게 친화적인 메시지 if (error.message.includes('Failed to fetch')) { alert('네트워크 연결을 확인해주세요'); } else { alert('데이터를 불러오는데 실패했습니다'); } return null; } }

패턴 2: 여러 에러 타입 처리

async function processUserData(userId) { try { const user = await fetchUserData(userId); if (!user.verified) { throw new Error('인증되지 않은 사용자'); } const data = await processData(user); return data; } catch (error) { if (error.message === '유효하지 않은 사용자 ID') { console.error('사용자를 찾을 수 없습니다'); return null; } else if (error.message === '인증되지 않은 사용자') { console.error('인증이 필요합니다'); throw error; // 상위로 전파 } else { console.error('예상치 못한 오류:', error); throw error; } } }

패턴 3: finally를 활용한 정리 작업

async function uploadFile(file) { // 로딩 상태 표시 showLoadingSpinner(); try { // 파일 업로드 const formData = new FormData(); formData.append('file', file); const response = await fetch('/api/upload', { method: 'POST', body: formData }); if (!response.ok) { throw new Error('업로드 실패'); } const result = await response.json(); console.log('업로드 성공:', result); return result; } catch (error) { console.error('업로드 에러:', error); alert('파일 업로드에 실패했습니다'); return null; } finally { // 성공/실패 여부와 관계없이 항상 실행 hideLoadingSpinner(); } }

실전 활용 패턴

패턴 1: 재시도 로직

async function fetchWithRetry(url, maxRetries = 3) { let lastError; for (let i = 0; i < maxRetries; i++) { try { console.log(`시도 ${i + 1}/${maxRetries}`); const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return await response.json(); } catch (error) { lastError = error; console.error(`시도 ${i + 1} 실패:`, error.message); // 마지막 시도가 아니면 대기 후 재시도 if (i < maxRetries - 1) { const delay = Math.pow(2, i) * 1000; // 지수 백오프: 1s, 2s, 4s console.log(`${delay}ms 후 재시도...`); await new Promise(resolve => setTimeout(resolve, delay)); } } } // 모든 시도 실패 throw new Error(`${maxRetries}번 시도 후 실패: ${lastError.message}`); } // 사용 fetchWithRetry('/api/data', 3) .then(data => console.log('성공:', data)) .catch(error => console.error('최종 실패:', error));

패턴 2: 순차 처리 (배열)

// 여러 항목을 순차적으로 처리 async function processItemsSequentially(items) { const results = []; for (const item of items) { try { console.log(`처리 중: ${item.id}`); const result = await processItem(item); results.push(result); console.log(`완료: ${item.id}`); } catch (error) { console.error(`실패: ${item.id}`, error); results.push(null); } } return results; } // 사용 const items = [{ id: 1 }, { id: 2 }, { id: 3 }]; const results = await processItemsSequentially(items); console.log('전체 결과:', results);

패턴 3: 병렬 처리 (배열) with 동시성 제어

// 한 번에 너무 많은 요청을 보내지 않도록 제어 async function processItemsWithLimit(items, limit = 3) { const results = []; // 청크로 나누기 for (let i = 0; i < items.length; i += limit) { const chunk = items.slice(i, i + limit); console.log(`청크 처리: ${i + 1}~${i + chunk.length}`); // 청크 내에서는 병렬 처리 const chunkResults = await Promise.all( chunk.map(item => processItem(item).catch(err => null)) ); results.push(...chunkResults); } return results; } // 사용 const items = Array.from({ length: 10 }, (_, i) => ({ id: i + 1 })); const results = await processItemsWithLimit(items, 3); // 3개씩 병렬로 처리: [1,2,3] → [4,5,6] → [7,8,9] → [10]

패턴 4: 데이터 캐싱

// 간단한 캐시 구현 class DataCache { constructor(ttl = 60000) { // 기본 1분 this.cache = new Map(); this.ttl = ttl; } async get(key, fetchFunction) { // 캐시 확인 const cached = this.cache.get(key); if (cached && Date.now() - cached.timestamp < this.ttl) { console.log('캐시 히트:', key); return cached.data; } // 캐시 미스 - 데이터 가져오기 console.log('캐시 미스:', key); const data = await fetchFunction(); // 캐시에 저장 this.cache.set(key, { data, timestamp: Date.now() }); return data; } clear() { this.cache.clear(); } } // 사용 const cache = new DataCache(30000); // 30초 TTL async function getUserData(userId) { return cache.get(`user-${userId}`, async () => { console.log(`API 호출: user ${userId}`); const response = await fetch(`/api/users/${userId}`); return response.json(); }); } // 첫 호출: API 요청 const user1 = await getUserData(1); // "API 호출: user 1" // 30초 이내 재호출: 캐시 사용 const user1Again = await getUserData(1); // "캐시 히트: user-1"

패턴 5: 폴링 (Polling)

// 주기적으로 데이터 확인 async function pollUntilComplete(checkFunction, interval = 1000, timeout = 30000) { const startTime = Date.now(); while (true) { // 타임아웃 체크 if (Date.now() - startTime > timeout) { throw new Error('타임아웃: 작업이 완료되지 않았습니다'); } // 상태 확인 const result = await checkFunction(); if (result.completed) { console.log('작업 완료!'); return result.data; } console.log('대기 중... 진행률:', result.progress); // 대기 후 재시도 await new Promise(resolve => setTimeout(resolve, interval)); } } // 사용 예시: 파일 처리 상태 확인 async function waitForFileProcessing(fileId) { return pollUntilComplete( async () => { const response = await fetch(`/api/files/${fileId}/status`); const status = await response.json(); return { completed: status.state === 'completed', progress: status.progress, data: status.result }; }, 2000, // 2초마다 체크 60000 // 최대 60초 ); } // 실행 try { const result = await waitForFileProcessing('file-123'); console.log('처리 결과:', result); } catch (error) { console.error('처리 실패:', error); }

자주 하는 실수와 해결법

실수 1: await 누락

// ❌ 잘못된 코드 async function getData() { const data = fetchData(); // await 누락! console.log(data); // Promise 객체가 출력됨 return data; } // ✅ 올바른 코드 async function getData() { const data = await fetchData(); console.log(data); // 실제 데이터 출력 return data; }

실수 2: 불필요한 순차 처리

// ❌ 비효율적 (6초) async function loadData() { const user = await fetchUser(); // 2초 const posts = await fetchPosts(); // 2초 const stats = await fetchStats(); // 2초 return { user, posts, stats }; } // ✅ 효율적 (2초) async function loadData() { const [user, posts, stats] = await Promise.all([ fetchUser(), // 동시 실행 fetchPosts(), // 동시 실행 fetchStats() // 동시 실행 ]); return { user, posts, stats }; }

실수 3: 에러 처리 누락

// ❌ 에러 처리 없음 async function processData() { const data = await fetchData(); return data.process(); // fetchData나 process가 실패하면? } // ✅ 적절한 에러 처리 async function processData() { try { const data = await fetchData(); if (!data) { throw new Error('데이터가 없습니다'); } return data.process(); } catch (error) { console.error('처리 중 오류:', error); return null; // 또는 기본값 반환 } }

실수 4: forEach에서 await 사용

// ❌ 작동하지 않음 async function processItems(items) { items.forEach(async (item) => { await processItem(item); // 제대로 기다리지 않음! }); console.log('완료'); // 실제로는 아직 진행 중 } // ✅ 올바른 방법 1: for...of async function processItems(items) { for (const item of items) { await processItem(item); } console.log('완료'); } // ✅ 올바른 방법 2: Promise.all (병렬) async function processItems(items) { await Promise.all( items.map(item => processItem(item)) ); console.log('완료'); }

실수 5: Promise 체인에서 에러 전파 누락

// ❌ 에러가 숨겨짐 fetchData() .then(data => { processData(data); // 에러 발생 시 무시됨 }); // ✅ 에러 처리 fetchData() .then(data => { return processData(data); // return 필수! }) .catch(error => { console.error('오류:', error); });

성능 최적화 팁

1. 필요한 것만 await

// ❌ 불필요한 await async function example() { await console.log('Hello'); // console.log은 동기 함수! const result = await 42; // 일반 값에 await 불필요 } // ✅ 필요한 곳에만 async function example() { console.log('Hello'); const data = await fetchData(); // 비동기 함수만 await return data; }

2. Promise.all로 병렬화

// ❌ 순차 처리 (느림) const user = await fetchUser(1); const settings = await fetchSettings(1); const stats = await fetchStats(1); // ✅ 병렬 처리 (빠름) const [user, settings, stats] = await Promise.all([ fetchUser(1), fetchSettings(1), fetchStats(1) ]);

3. 조기 반환 (Early Return)

// ❌ 불필요한 중첩 async function getData(id) { try { const data = await fetchData(id); if (data) { return data; } else { return null; } } catch (error) { return null; } } // ✅ 조기 반환 async function getData(id) { try { const data = await fetchData(id); if (!data) return null; return data; } catch (error) { return null; } }

실전 종합 예제

전자상거래 장바구니 시스템

class ShoppingCart { constructor() { this.items = []; this.cache = new Map(); } // 상품 정보 조회 (캐싱 포함) async getProductInfo(productId) { // 캐시 확인 if (this.cache.has(productId)) { console.log(`캐시 사용: ${productId}`); return this.cache.get(productId); } // API 호출 try { const response = await fetch(`/api/products/${productId}`); if (!response.ok) { throw new Error(`상품 조회 실패: ${response.status}`); } const product = await response.json(); // 캐시에 저장 this.cache.set(productId, product); return product; } catch (error) { console.error('상품 조회 오류:', error); throw error; } } // 장바구니에 상품 추가 async addItem(productId, quantity = 1) { try { // 상품 정보 확인 const product = await this.getProductInfo(productId); // 재고 확인 if (product.stock < quantity) { throw new Error('재고가 부족합니다'); } // 기존 항목 찾기 const existingItem = this.items.find(item => item.productId === productId); if (existingItem) { existingItem.quantity += quantity; } else { this.items.push({ productId, name: product.name, price: product.price, quantity }); } console.log(`추가됨: ${product.name} x ${quantity}`); return true; } catch (error) { console.error('추가 실패:', error.message); return false; } } // 전체 가격 계산 (병렬 처리) async calculateTotal() { if (this.items.length === 0) { return 0; } // 모든 상품의 최신 가격을 동시에 조회 const prices = await Promise.all( this.items.map(async (item) => { const product = await this.getProductInfo(item.productId); return product.price * item.quantity; }) ); // 총합 계산 return prices.reduce((sum, price) => sum + price, 0); } // 주문 처리 async checkout(userId) { try { console.log('결제 시작...'); // 1. 총액 계산 const total = await this.calculateTotal(); console.log(`총액: ${total}원`); // 2. 재고 확인 (병렬) const stockChecks = await Promise.all( this.items.map(async (item) => { const product = await this.getProductInfo(item.productId); return { productId: item.productId, available: product.stock >= item.quantity }; }) ); // 재고 부족 확인 const unavailable = stockChecks.filter(check => !check.available); if (unavailable.length > 0) { throw new Error('일부 상품의 재고가 부족합니다'); } // 3. 결제 처리 const paymentResult = await this.processPayment(userId, total); if (!paymentResult.success) { throw new Error('결제 실패'); } // 4. 주문 생성 const order = await this.createOrder(userId, this.items, total); // 5. 장바구니 비우기 this.items = []; console.log('주문 완료:', order.id); return order; } catch (error) { console.error('결제 실패:', error.message); throw error; } } // 결제 처리 (재시도 로직 포함) async processPayment(userId, amount, maxRetries = 3) { let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log(`결제 시도 ${attempt}/${maxRetries}`); const response = await fetch('/api/payments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId, amount }) }); if (!response.ok) { throw new Error(`결제 API 오류: ${response.status}`); } const result = await response.json(); console.log('결제 성공'); return result; } catch (error) { lastError = error; console.error(`시도 ${attempt} 실패:`, error.message); if (attempt < maxRetries) { // 재시도 전 대기 (지수 백오프) const delay = Math.pow(2, attempt - 1) * 1000; console.log(`${delay}ms 후 재시도...`); await new Promise(resolve => setTimeout(resolve, delay)); } } } throw new Error(`결제 실패: ${lastError.message}`); } // 주문 생성 async createOrder(userId, items, total) { const response = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId, items, total, timestamp: new Date().toISOString() }) }); if (!response.ok) { throw new Error('주문 생성 실패'); } return response.json(); } } // 사용 예시 async function example() { const cart = new ShoppingCart(); // 상품 추가 await cart.addItem('product-1', 2); await cart.addItem('product-2', 1); await cart.addItem('product-3', 3); // 총액 계산 const total = await cart.calculateTotal(); console.log('장바구니 총액:', total); // 결제 try { const order = await cart.checkout('user-123'); console.log('주문 번호:', order.id); } catch (error) { console.error('결제 실패:', error.message); } }

디버깅 팁

1. async 함수는 항상 Promise를 반환

async function test() { return 42; } console.log(test()); // Promise { 42 } test().then(value => console.log(value)); // 42

2. console.log로 Promise 상태 확인

async function debug() { const promise = fetchData(); console.log('Promise 객체:', promise); // Promise { <pending> } const data = await promise; console.log('실제 데이터:', data); // 실제 값 }

3. 에러 스택 추적

async function trackError() { try { await problematicFunction(); } catch (error) { console.error('에러 메시지:', error.message); console.error('스택 트레이스:', error.stack); } }

브라우저 호환성

기능ChromeFirefoxSafariEdge
Promise32+29+8+12+
async/await55+52+11+15+
Promise.allSettled76+71+13+79+
Promise.any85+79+14+85+

참고: 구형 브라우저를 지원해야 한다면 Babel 을 사용하세요.


다음 단계

이제 Promise와 async/await의 기초를 마스터했습니다! 다음 주제로 넘어가세요:


참고 자료

공식 문서

추가 학습

도구


작성일: 2025-11-30
버전: 1.0.0
작성자: Claude with technical-writing skill

댓글

developjik
All content is licensed under CC BY-NC-SA 4.0 unless otherwise noted.