비동기 처리는 모던 웹 개발에서 피할 수 없는 핵심 개념이다. 다만 대부분의 개발자들은 Promise와 async/await의 정확한 차이를 모른 채 코드를 작성하거나, 둘을 같은 것으로 생각하는 실수를 범한다. 이번에는 Promise의 기초부터 시작해서 async/await이 정확히 뭐고 왜 필요한지, 언제 어떤 상황에서 써야 하는지 완벽하게 정리해서 소개하겠다.
Promise는 뭔가 - 기초부터 이해하기
Promise는 자바스크립트에서 비동기 작업의 결과를 나타내는 객체다. 세 가지 상태를 가진다: 대기(pending), 성공(fulfilled), 실패(rejected). 서버에 데이터를 요청했을 때 응답이 올 때까지 기다리지 말고, "나중에 응답 오면 이 콜백을 실행해" 라는 식으로 작동한다.
기본 Promise 문법
// ✗ 잘못된 코드 - 콜백 지옥 (Callback Hell)
function fetchData(url, callback) {
setTimeout(() => {
console.log('데이터 로드됨: ' + url);
callback(null, { data: 'result' });
}, 1000);
}
fetchData('/api/users', function(err, data) {
if (!err) {
fetchData('/api/posts', function(err, data2) {
if (!err) {
fetchData('/api/comments', function(err, data3) {
// 깊어질수록 읽기 어려움
console.log(data3);
});
}
});
}
});
✗ 왜 안 되는가: 콜백이 중첩되면서 코드 가독성이 급격히 떨어진다. 에러 처리도 각 단계마다 해야 한다.
// ✓ 올바른 코드 - Promise 체이닝
function fetchData(url) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('데이터 로드됨: ' + url);
resolve({ data: 'result' });
}, 1000);
});
}
fetchData('/api/users')
.then(data => {
console.log('1단계 완료:', data);
return fetchData('/api/posts');
})
.then(data => {
console.log('2단계 완료:', data);
return fetchData('/api/comments');
})
.then(data => {
console.log('3단계 완료:', data);
})
.catch(error => {
console.error('어디든 에러 발생:', error);
});
✓ 왜 이게 맞는가: .then() 체인으로 순차 처리가 명확해졌고, .catch()로 모든 에러를 한 곳에서 처리할 수 있다.
Promise의 세 가지 상태와 resolve/reject
// Promise 생성 - resolve와 reject 파라미터
const myPromise = new Promise((resolve, reject) => {
const success = true;
if (success) {
// 성공 상태로 전환 - then으로 받음
resolve('작업 성공!');
} else {
// 실패 상태로 전환 - catch로 받음
reject('작업 실패!');
}
});
myPromise
.then(result => console.log(result)) // '작업 성공!'
.catch(error => console.error(error)) // 에러 발생 시
.finally(() => console.log('완료!')); // 상태 관계없이 항상 실행
async/await - Promise를 더 간결하게
async/await는 Promise를 더 읽기 쉬운 "동기식" 코드처럼 보이게 만든 문법 설탕(syntactic sugar)일 뿐이다. 내부적으로는 여전히 Promise로 동작한다. async 함수는 항상 Promise를 반환한다.
async/await 기본 사용법
// ✗ 잘못된 코드 - await 없이 Promise 반환값 사용 시도
async function loadUserData() {
const userData = fetchData('/api/users'); // Promise 객체가 그대로 반환됨
console.log(userData.id); // undefined - 실제 데이터가 아님
}
loadUserData();
✗ 왜 안 되는가: await를 빼먹으면 Promise 객체 자체만 받아서 .then()을 체인할 수 없고, userData.id는 존재하지 않는다.
// ✓ 올바른 코드 - async/await로 비동기 작업을 마치 동기처럼 처리
async function loadUserData() {
try {
const userData = await fetchData('/api/users');
console.log('유저 데이터:', userData);
const postData = await fetchData('/api/posts');
console.log('포스트 데이터:', postData);
return { user: userData, posts: postData };
} catch (error) {
console.error('데이터 로드 실패:', error);
}
}
// async 함수는 항상 Promise를 반환하므로 then으로 받거나 다른 async에서 await
await loadUserData();
✓ 왜 이게 맞는가: 마치 동기 코드처럼 읽혀서 이해하기 쉽고, try/catch로 에러 처리도 깔끔하다. 여러 await를 순차 실행할 때도 직관적이다.
Promise vs async/await - 실전 비교
| 항목 | Promise (.then()) | async/await |
|---|---|---|
| 코드 가독성 | 체인이 길어지면 복잡해짐 | 동기식처럼 직관적 |
| 에러 처리 | .catch() 메서드 | try/catch 블록 |
| 여러 비동기 대기 | .all() / .race() 필요 | 간단한 반복문 or Promise.all() 조합 |
| 디버깅 | 스택 트레이스가 길어짐 | 명확한 라인 번호 추적 |
| 브라우저 지원 | IE11부터 가능 | 최신 브라우저 필요 (IE 미지원) |
Promise.all() vs async/await - 병렬 처리
// Promise.all() 방식 - 3개 요청을 동시에 보냄
Promise.all([
fetchData('/api/users'),
fetchData('/api/posts'),
fetchData('/api/comments')
])
.then(([users, posts, comments]) => {
console.log('모든 데이터:', users, posts, comments);
})
.catch(error => console.error(error));
// async/await 방식 - 동일하게 동시 실행
async function loadAllData() {
try {
// 이렇게 하면 순차 실행됨 (잘못된 사용)
const users = await fetchData('/api/users');
const posts = await fetchData('/api/posts'); // users 완료 후 시작
const comments = await fetchData('/api/comments'); // posts 완료 후 시작
// 총 3초 소요
} catch (error) {
console.error(error);
}
}
async function loadAllDataFast() {
try {
// 올바른 병렬 실행 - Promise.all() 조합
const [users, posts, comments] = await Promise.all([
fetchData('/api/users'),
fetchData('/api/posts'),
fetchData('/api/comments')
]);
// 총 1초 소요 (가장 긴 요청 기준)
console.log('병렬 완료:', users, posts, comments);
} catch (error) {
console.error(error);
}
}
await loadAllDataFast();
흔한 실수와 해결책
실수 1: async 함수 내에서 여러 await를 순차 실행해버리기
// ✗ 잘못된 코드 - 불필요한 순차 대기
async function getUserWithPosts(userId) {
const user = await fetchData(`/api/users/${userId}`);
const posts = await fetchData(`/api/posts?userId=${userId}`);
// user 받은 후에야 posts를 요청함 - 낭비
return { user, posts };
}
// ✓ 올바른 코드 - 병렬 처리
async function getUserWithPosts(userId) {
const [user, posts] = await Promise.all([
fetchData(`/api/users/${userId}`),
fetchData(`/api/posts?userId=${userId}`)
]);
return { user, posts };
}
실수 2: await를 빼먹고 Promise 객체만 받기
// ✗ 잘못된 코드
async function processData() {
const result = fetchData('/api/data'); // await 없음!
console.log(result.name); // Promise { <pending> } 출력, name은 undefined
}
// ✓ 올바른 코드
async function processData() {
const result = await fetchData('/api/data');
console.log(result.name); // 실제 데이터 출력
}
실수 3: async 함수에서 return하면 그게 Promise로 래핑됨
// async 함수는 자동으로 Promise를 반환함
async function getValue() {
return 42; // 실제로는 Promise.resolve(42)를 반환
}
// 따라서 이렇게 받아야 함
getValue().then(value => console.log(value)); // 42
// 또는 다른 async 함수에서
async function useValue() {
const val = await getValue();
console.log(val); // 42
}
실무에서 자주 쓰는 패턴
패턴 1: API 요청 래퍼 함수
// 실무에서 자주 쓰이는 fetch 래퍼
async function apiCall(url, options = {}) {
try {
const response = await fetch(url, {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...options.headers
},
body: options.body ? JSON.stringify(options.body) : null
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API 호출 실패:', error);
throw error; // 호출자가 처리하도록 넘김
}
}
// 사용
async function loadUsers() {
try {
const users = await apiCall('/api/users');
console.log('유저 목록:', users);
} catch (error) {
// 에러 처리
}
}
패턴 2: 재시도 로직 (Retry)
async function retryFetch(url, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fetch(url).then(r => r.json());
} catch (error) {
if (i === maxRetries - 1) throw error; // 마지막 시도 실패
console.log(`재시도 ${i + 1}/${maxRetries}...`);
await new Promise(r => setTimeout(r, 1000)); // 1초 대기
}
}
}
await retryFetch('/api/unreliable-endpoint');
패턴 3: 타임아웃 설정
// Promise.race()로 타임아웃 구현
async function fetchWithTimeout(url, timeoutMs = 5000) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('요청 타임아웃')), timeoutMs)
);
return await Promise.race([
fetch(url).then(r => r.json()),
timeout
]);
}
try {
const data = await fetchWithTimeout('/api/data', 3000);
console.log(data);
} catch (error) {
console.error(error.message); // '요청 타임아웃' 또는 네트워크 에러
}
정리 - 언제 뭘 쓸까
Promise와 async/await은 결국 같은 비동기 처리 도구다. 다만 async/await이 코드 가독성이 훨씬 낫고 try/catch도 직관적이어서 최신 프로젝트에서는 async/await을 권장한다. 다만 Promise.all()이나 Promise.race() 같은 유틸리티 함수는 여전히 필요하므로 둘을 적절히 조합해서 쓰면 된다. 이 글에서 소개한 병렬 처리 패턴과 재시도/타임아웃 로직을 참고해 팀의 API 호출 코드를 개선하면, 안정성 높고 유지보수하기 좋은 비동기 코드를 작성할 수 있을 것이다.