API 호출을 연속으로 해야 하는 상황이다.
유저 정보 가져오고, 그 유저의 주문 목록 가져오고, 각 주문의 상세 정보 가져오기.
콜백으로 작성하면 이렇게 된다.
getUserInfo(userId, function(user) {
getOrders(user.id, function(orders) {
getOrderDetails(orders[0].id, function(details) {
console.log(details);
});
});
});
코드가 오른쪽으로 계속 들어간다.
콜백 지옥이다.
JavaScript는 싱글 스레드다. 한 번에 하나만 실행된다.
만약 동기적으로만 동작한다면?
const data = fetchDataFromServer(); // 3초 걸림
console.log(data);
console.log('다음 작업');
서버에서 데이터 받는 3초 동안 브라우저가 멈춘다. 아무것도 못 한다. 클릭도 안 된다.
그래서 비동기가 필요하다.
fetchDataFromServer(function(data) {
console.log(data);
});
console.log('다음 작업'); // 이게 먼저 실행됨
데이터 받는 동안 다른 작업을 한다. 브라우저는 안 멈춘다. 사용자는 스크롤도 하고 클릭도 한다.
비동기를 이해하려면 이벤트 루프를 알아야 한다.
JavaScript 엔진은 세 가지로 구성된다.
현재 실행 중인 함수가 쌓이는 곳이다.
function first() {
second();
console.log('첫 번째');
}
function second() {
console.log('두 번째');
}
first();
실행 순서
first() 스택에 들어감second() 스택에 들어감console.log('두 번째') 실행 → second() 스택에서 빠짐console.log('첫 번째') 실행 → first() 스택에서 빠짐출력
두 번째
첫 번째
간단하다. LIFO (Last In First Out)다.
브라우저가 제공하는 API다. setTimeout, fetch, addEventListener 같은 것들.
console.log('1');
setTimeout(function() {
console.log('2');
}, 0);
console.log('3');
실행하면?
1
3
2
왜 2가 마지막에 나올까? setTimeout이 0초인데?
setTimeout은 Web API다. Call Stack에서 바로 실행 안 된다.
Web API가 끝나면 콜백을 여기에 넣는다.
이벤트 루프가 Call Stack이 비면 Callback Queue에서 꺼내서 실행한다.
console.log('1'); // Call Stack에서 실행
setTimeout(function() {
console.log('2'); // Web API → Callback Queue → Call Stack
}, 0);
console.log('3'); // Call Stack에서 실행
흐름
console.log('1') 실행 → 출력 “1”setTimeout → Web API로 이동 (0초 대기)console.log('3') 실행 → 출력 “3”console.log('2') 실행 → 출력 “2”setTimeout(fn, 0)은 “0초 후 실행”이 아니라 “Call Stack 비면 실행”이다.
초반에 본 코드다.
getUserInfo(userId, function(user) {
getOrders(user.id, function(orders) {
getOrderDetails(orders[0].id, function(details) {
console.log(details);
});
});
});
문제가 뭘까?
코드가 오른쪽으로 계속 들어간다. 흐름을 따라가기 힘들다.
getUserInfo(userId, function(err, user) {
if (err) {
console.error(err);
return;
}
getOrders(user.id, function(err, orders) {
if (err) {
console.error(err);
return;
}
getOrderDetails(orders[0].id, function(err, details) {
if (err) {
console.error(err);
return;
}
console.log(details);
});
});
});
에러 처리 코드가 반복된다. 지옥이 더 깊어진다.
“첫 번째 API 실패하면 두 번째는 실행 안 하고, 세 번째만 실행하고 싶다”
콜백으로는 거의 불가능하다. 코드가 스파게티가 된다.
그래서 Promise가 나왔다.
Promise는 “미래의 값”을 나타낸다.
지금은 없지만, 나중에 생길 값이다.
const promise = getUserInfo(userId);
promise.then(function(user) {
console.log(user);
});
훨씬 깔끔하다. 콜백 안에 콜백 없이 체이닝할 수 있다.
const promise = new Promise(function(resolve, reject) {
const success = true;
if (success) {
resolve('성공!');
} else {
reject('실패!');
}
});
promise
.then(function(result) {
console.log(result); // "성공!"
})
.catch(function(error) {
console.error(error); // "실패!"
});
resolve를 호출하면 Fulfilled, reject를 호출하면 Rejected.
아까 콜백 지옥을 Promise로 바꾸면?
getUserInfo(userId)
.then(function(user) {
return getOrders(user.id);
})
.then(function(orders) {
return getOrderDetails(orders[0].id);
})
.then(function(details) {
console.log(details);
})
.catch(function(error) {
console.error(error); // 어디서 에러나도 여기서 잡힘
});
오른쪽으로 들어가지 않는다. 위에서 아래로 읽힌다.
에러 처리도 한 곳에서 한다.
훨씬 낫다.
여러 개의 Promise를 다뤄야 할 때가 있다.
“모든 Promise가 완료될 때까지 기다린다”
const promise1 = fetch('/api/users');
const promise2 = fetch('/api/products');
const promise3 = fetch('/api/orders');
Promise.all([promise1, promise2, promise3])
.then(function([users, products, orders]) {
console.log(users, products, orders);
// 세 개 다 완료되면 실행됨
})
.catch(function(error) {
// 하나라도 실패하면 여기로 옴
console.error(error);
});
하나라도 실패하면 전체가 실패한다. 주의해야 한다.
“제일 빨리 완료되는 Promise 하나만 기다린다”
const timeout = new Promise((resolve, reject) => {
setTimeout(() => reject('Timeout!'), 5000);
});
const fetchData = fetch('/api/data');
Promise.race([timeout, fetchData])
.then(function(result) {
console.log(result); // fetchData가 5초 안에 완료되면 이게 실행됨
})
.catch(function(error) {
console.error(error); // 5초 지나면 "Timeout!" 에러
});
타임아웃 구현할 때 유용하다.
“모든 Promise가 완료될 때까지 기다리되, 실패해도 상관없다”
const promises = [
fetch('/api/users'),
fetch('/api/invalid'), // 이게 실패해도
fetch('/api/products')
];
Promise.allSettled(promises)
.then(function(results) {
results.forEach(function(result) {
if (result.status === 'fulfilled') {
console.log('성공:', result.value);
} else {
console.log('실패:', result.reason);
}
});
});
Promise.all과 달리 하나 실패해도 멈추지 않는다.
Promise도 좋은데, 체이닝이 길어지면 또 복잡하다.
getUserInfo(userId)
.then(function(user) {
return getOrders(user.id);
})
.then(function(orders) {
return getOrderDetails(orders[0].id);
})
.then(function(details) {
return processDetails(details);
})
.then(function(processed) {
return saveToDatabase(processed);
})
.then(function(saved) {
console.log(saved);
})
.catch(function(error) {
console.error(error);
});
여전히 길다.
async/await가 해결책이다.
async function processUser(userId) {
try {
const user = await getUserInfo(userId);
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
const processed = await processDetails(details);
const saved = await saveToDatabase(processed);
console.log(saved);
} catch (error) {
console.error(error);
}
}
동기 코드처럼 보인다. 읽기 쉽다.
await는 Promise가 완료될 때까지 기다린다. 그 동안 다른 코드가 실행된다.
async/await는 Promise의 문법적 설탕이다. 내부적으로는 Promise다.
순차 실행은 느리다.
async function getDataSlow() {
const user = await fetch('/api/user'); // 1초
const products = await fetch('/api/products'); // 1초
const orders = await fetch('/api/orders'); // 1초
// 총 3초 걸림
return { user, products, orders };
}
각 요청이 끝날 때까지 기다린다. 다음 요청이 시작된다.
병렬 실행은 빠르다.
async function getDataFast() {
const userPromise = fetch('/api/user');
const productsPromise = fetch('/api/products');
const ordersPromise = fetch('/api/orders');
const [user, products, orders] = await Promise.all([
userPromise,
productsPromise,
ordersPromise
]);
// 총 1초 걸림 (가장 느린 요청 기준)
return { user, products, orders };
}
세 요청을 동시에 시작한다. 모두 끝날 때까지 기다린다.
3배 빠르다.
async/await에서 에러는 try-catch로 잡는다.
async function fetchUser(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const user = await response.json();
return user;
} catch (error) {
console.error('사용자 정보 가져오기 실패:', error);
throw error; // 상위로 전달
}
}
개별적으로 처리할 수도 있다.
async function processData() {
let user;
try {
user = await fetchUser(userId);
} catch (error) {
console.error('유저 정보 실패:', error);
return; // 여기서 중단
}
try {
const orders = await fetchOrders(user.id);
console.log(orders);
} catch (error) {
console.error('주문 정보 실패:', error);
// 계속 진행 가능
}
}
async function processUsers(userIds) {
userIds.forEach(async function(id) {
const user = await fetchUser(id);
console.log(user);
});
console.log('완료!'); // 이게 먼저 출력됨
}
for...of를 쓴다. 순차적으로 실행된다.
async function processUsers(userIds) {
for (const id of userIds) {
const user = await fetchUser(id);
console.log(user);
}
console.log('완료!'); // 모든 유저 처리 후 출력
}
병렬로 실행하려면 Promise.all을 쓴다. 훨씬 빠르다.
async function processUsers(userIds) {
const promises = userIds.map(function(id) {
return fetchUser(id);
});
const users = await Promise.all(promises);
users.forEach(function(user) {
console.log(user);
});
console.log('완료!');
}
function timeout(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('Timeout!')), ms);
});
}
async function fetchWithTimeout(url, ms) {
try {
const result = await Promise.race([
fetch(url),
timeout(ms)
]);
return result;
} catch (error) {
if (error.message === 'Timeout!') {
console.error('요청 시간 초과');
}
throw error;
}
}
// 사용
fetchWithTimeout('/api/data', 5000); // 5초 안에 완료 안 되면 에러
async function getUser() {
const user = fetchUser(userId); // await 없음!
console.log(user); // Promise 객체가 출력됨
}
await 안 붙이면 Promise 객체가 반환된다. 실제 값이 아니다.
async function processData() {
const data = await fetchData();
// return 없음!
}
const result = await processData(); // undefined
async 함수는 항상 Promise를 반환한다. 명시적으로 return 해야 한다.
async function fetchData() {
try {
const data = await fetch('/api/data');
return data;
} catch (error) {
console.error(error); // 로그만 찍고 끝
// throw 안 함
}
}
const data = await fetchData(); // 에러 발생해도 undefined 반환됨
에러를 잡았으면 처리하거나 다시 throw 해야 한다.
async function getData() {
return fetch('/api/data'); // await 없이 반환
}
Promise<Promise<Response>>가 된다.
async function getData() {
return await fetch('/api/data');
}
아니면 async를 뺀다.
function getData() {
return fetch('/api/data');
}
순차 실행하면 느리다.
async function getAll() {
const users = await fetch('/api/users'); // 1초 대기
const products = await fetch('/api/products'); // 1초 대기
// 총 2초
}
병렬로 실행한다. 2배 빠르다.
async function getAll() {
const [users, products] = await Promise.all([
fetch('/api/users'),
fetch('/api/products')
]);
// 총 1초
}
Promise는 일반 콜백과 다르게 동작한다.
console.log('1');
setTimeout(function() {
console.log('2');
}, 0);
Promise.resolve().then(function() {
console.log('3');
});
console.log('4');
출력은?
1
4
3
2
왜 3이 2보다 먼저 나올까?
마이크로태스크 큐 때문이다.
setTimeout → 태스크 큐 (Callback Queue)Promise.then → 마이크로태스크 큐이벤트 루프는 마이크로태스크 큐를 먼저 확인한다.
Call Stack 비면
그래서 Promise가 setTimeout보다 먼저 실행된다.
async function fetchWithRetry(url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed');
return await response.json();
} catch (error) {
if (i === retries - 1) throw error;
console.log(`재시도 ${i + 1}/${retries}`);
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
실패하면 1초 기다렸다가 다시 시도한다. 3번까지.
const cache = new Map();
async function fetchWithCache(url) {
if (cache.has(url)) {
console.log('캐시에서 가져옴');
return cache.get(url);
}
const data = await fetch(url).then(r => r.json());
cache.set(url, data);
return data;
}
같은 요청은 캐시에서 가져온다. 서버 부담 줄인다.
function debounceAsync(fn, delay) {
let timeoutId;
return function(...args) {
return new Promise((resolve) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
const result = await fn(...args);
resolve(result);
}, delay);
});
};
}
// 사용
const searchAPI = debounceAsync(async (query) => {
const response = await fetch(`/api/search?q=${query}`);
return response.json();
}, 300);
// 타이핑할 때마다 호출해도 300ms 후에 한 번만 실행됨
await searchAPI('javascript');
API를 100개 동시에 호출하면 서버가 터진다. 제한해야 한다.
async function promiseLimit(tasks, limit) {
const results = [];
const executing = [];
for (const task of tasks) {
const promise = task().then(result => {
executing.splice(executing.indexOf(promise), 1);
return result;
});
results.push(promise);
executing.push(promise);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
// 사용
const tasks = userIds.map(id => () => fetchUser(id));
const users = await promiseLimit(tasks, 5); // 동시에 5개까지만
async function executeSequentially(tasks) {
const results = [];
for (const task of tasks) {
const result = await task();
results.push(result);
}
return results;
}
// 혹은 reduce 사용
function executeSequentially(tasks) {
return tasks.reduce(async (promise, task) => {
const results = await promise;
const result = await task();
return [...results, result];
}, Promise.resolve([]));
}
useEffect에 async 함수를 직접 넣으면 안 된다.
function MyComponent() {
useEffect(async () => {
const data = await fetchData();
console.log(data);
}, []);
}
cleanup 함수를 반환해야 하는데 Promise가 반환된다.
내부에서 async 함수를 만들어서 호출한다.
function MyComponent() {
useEffect(() => {
async function loadData() {
const data = await fetchData();
console.log(data);
}
loadData();
}, []);
}
아니면 Promise를 그냥 쓴다.
function MyComponent() {
useEffect(() => {
fetchData().then(data => {
console.log(data);
});
}, []);
}
컴포넌트 언마운트됐는데 setState 하면 경고 뜬다.
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
let isCancelled = false;
async function loadData() {
const result = await fetchData();
if (!isCancelled) {
setData(result);
}
}
loadData();
return () => {
isCancelled = true;
};
}, []);
}
AbortController 사용
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(response => response.json())
.then(data => setData(data))
.catch(error => {
if (error.name === 'AbortError') {
console.log('요청 취소됨');
}
});
return () => {
controller.abort();
};
}, []);
}
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setData(data);
} catch (error) {
if (error.name !== 'AbortError') {
setError(error);
}
} finally {
setLoading(false);
}
}
fetchData();
return () => {
controller.abort();
};
}, [url]);
return { data, loading, error };
}
// 사용
function MyComponent() {
const { data, loading, error } = useFetch('/api/users');
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error.message}</div>;
return <div>{JSON.stringify(data)}</div>;
}
이중 await는 불필요하다.
async function getData() {
const data = await fetch('/api/data');
return data; // 여기서 또 await
}
const result = await getData(); // 이중 await
async 없이 Promise를 그냥 반환한다.
function getData() {
return fetch('/api/data');
}
const result = await getData();
느림 (4초)
async function loadPage() {
const user = await fetchUser(); // 1초
const posts = await fetchPosts(); // 1초
const comments = await fetchComments(); // 1초
const likes = await fetchLikes(); // 1초
return { user, posts, comments, likes };
}
빠름 (1초)
async function loadPage() {
const [user, posts, comments, likes] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments(),
fetchLikes()
]);
return { user, posts, comments, likes };
}
async function processData() {
const data = await fetchData();
doSomethingSync(); // 동기 작업인데 await 뒤에 있음
}
이렇게 하면 동기 작업이 비동기 대기 시간에 실행된다.
async function processData() {
const dataPromise = fetchData();
doSomethingSync(); // 데이터 받는 동안 실행됨
const data = await dataPromise;
}
async function getUser(id) {
const cache = userCache.get(id);
if (cache) {
return cache;
} else {
const user = await fetchUser(id);
userCache.set(id, user);
return user;
}
}
이렇게 쓴다.
async function getUser(id) {
const cache = userCache.get(id);
if (cache) return cache; // 조기 반환
const user = await fetchUser(id);
userCache.set(id, user);
return user;
}
Promise 체이닝
function a() {
return b().then(c).then(d);
}
// 에러 발생하면 스택 트레이스가 끊긴다
async/await
async function a() {
const resultB = await b();
const resultC = await c(resultB);
const resultD = await d(resultC);
return resultD;
}
// 스택 트레이스가 명확하다
async function fetchData(url) {
console.log(`요청 시작: ${url}`);
try {
const response = await fetch(url);
console.log(`응답 받음: ${response.status}`);
const data = await response.json();
console.log(`데이터 파싱 완료`);
return data;
} catch (error) {
console.error(`에러 발생: ${url}`, error);
throw error;
}
}
JavaScript 비동기 처리는 진화했다.
콜백 → 콜백 지옥
Promise → 체이닝은 낫지만 여전히 복잡
async/await → 동기 코드처럼 읽힌다
핵심은
비동기는 JavaScript의 핵심이다. 제대로 이해하면 코드 품질이 달라진다.
콜백 지옥과 async/await를 비교하면 차이가 명확하다. async/await가 압도적으로 읽기 쉽고 유지보수하기 좋다.