JavaScript 비동기 완전 정복: 콜백부터 async/await까지
JavaScript는 싱글 스레드 언어입니다. 한 번에 하나의 작업만 처리할 수 있다는 뜻이죠. 그런데 어떻게 네트워크 요청을 보내면서 동시에 UI를 업데이트할 수 있을까요? 바로 비동기 처리 덕분입니다.
동기 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 (2는 1초 후 출력)
콜백 함수#
가장 오래된 비동기 처리 방식입니다. 작업이 완료됐을 때 호출할 함수를 인자로 전달합니다.
function fetchUser(id, callback) {
setTimeout(() => {
const user = { id, name: '홍길동' }
callback(null, user)
}, 1000)
}
fetchUser(1, (error, user) => {
if (error) {
console.error('오류:', error)
return
}
console.log('유저:', user)
})
콜백 지옥#
중첩된 비동기 작업이 많아지면 코드가 피라미드 형태가 됩니다.
fetchUser(1, (err, user) => {
fetchPosts(user.id, (err, posts) => {
fetchComments(posts[0].id, (err, comments) => {
fetchLikes(comments[0].id, (err, likes) => {
// 읽기도 힘들고, 에러 처리도 복잡합니다
console.log(likes)
})
})
})
})
코드 깊이가 깊어질수록 읽기 어렵고, 에러 처리도 각 단계마다 해야 해서 실수가 생기기 쉽습니다.
Promise#
ES6에서 도입된 Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다.
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ id, name: '홍길동' })
} else {
reject(new Error('잘못된 ID입니다'))
}
}, 1000)
})
}
fetchUser(1)
.then(user => console.log('유저:', user))
.catch(error => console.error('오류:', error))
.finally(() => console.log('요청 완료'))
Promise 상태#
| 상태 | 설명 |
|---|---|
pending | 초기 상태, 아직 완료되지 않음 |
fulfilled | 작업 성공, resolve 호출됨 |
rejected | 작업 실패, reject 호출됨 |
Promise 체이닝#
.then()을 연결해서 콜백 지옥을 해결합니다.
fetchUser(1)
.then(user => fetchPosts(user.id))
.then(posts => fetchComments(posts[0].id))
.then(comments => fetchLikes(comments[0].id))
.then(likes => console.log(likes))
.catch(error => console.error('어딘가에서 오류 발생:', error))
Promise.all / Promise.allSettled#
여러 비동기 작업을 동시에 실행할 때 사용합니다.
// 모두 성공해야 결과 반환 (하나라도 실패하면 catch)
const [user, posts, tags] = await Promise.all([
fetchUser(1),
fetchPosts(1),
fetchTags(),
])
// 실패해도 모든 결과 반환
const results = await Promise.allSettled([
fetchUser(1),
fetchPosts(99), // 실패해도 괜찮음
])
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('성공:', result.value)
} else {
console.log('실패:', result.reason)
}
})
async / await#
ES2017에서 도입된 문법으로, Promise를 더 읽기 쉽게 작성할 수 있습니다.
async function loadUserData(userId) {
try {
const user = await fetchUser(userId)
const posts = await fetchPosts(user.id)
const comments = await fetchComments(posts[0].id)
return { user, posts, comments }
} catch (error) {
console.error('오류 발생:', error)
throw error
}
}
await는 async 함수 내에서만 사용할 수 있고, Promise가 resolve될 때까지 실행을 일시 중지합니다. 동기 코드처럼 보이지만 실제로는 비동기로 동작합니다.
병렬 실행 주의#
await를 순차적으로 사용하면 각 요청이 완료될 때까지 기다립니다.
// ❌ 순차 실행: 총 3초 소요
const user = await fetchUser(1) // 1초 대기
const posts = await fetchPosts(1) // 1초 대기
const tags = await fetchTags() // 1초 대기
// ✅ 병렬 실행: 총 1초 소요
const [user, posts, tags] = await Promise.all([
fetchUser(1),
fetchPosts(1),
fetchTags(),
])
이벤트 루프#
JavaScript의 비동기 처리는 이벤트 루프를 통해 구현됩니다.
Call Stack Web APIs Callback Queue
┌─────────┐ ┌─────────┐ ┌─────────────┐
│ main() │ │setTimeout│ │ │
│ │ → │fetch() │ → │ callback() │
│ │ │ │ │ │
└─────────┘ └─────────┘ └─────────────┘
↑ │
└──────── Event Loop ──────────┘
- Call Stack에서 코드 실행
- 비동기 API 호출은 Web APIs로 위임
- 완료된 콜백은 Callback Queue에 대기
- Call Stack이 비면 Event Loop가 Queue에서 꺼내 실행
마무리#
| 방식 | 장점 | 단점 |
|---|---|---|
| 콜백 | 간단함, 구형 브라우저 지원 | 콜백 지옥, 에러 처리 복잡 |
| Promise | 체이닝, 에러 처리 통합 | 코드가 여전히 장황할 수 있음 |
| async/await | 동기 코드처럼 읽기 쉬움 | 병렬 처리 실수하기 쉬움 |
실무에서는 async/await를 기본으로 사용하되, 병렬 처리가 필요한 경우 Promise.all을 함께 활용하는 것이 가장 효율적입니다.
관련 포스트
웹 성능 최적화 실전 가이드: Core Web Vitals와 최적화 기법
LCP, FID, CLS 등 Core Web Vitals의 의미와 실제 프론트엔드 성능을 개선하는 실전 기법을 정리했습니다.
Docker 입문 가이드: 개발자가 꼭 알아야 할 컨테이너 기초
Docker가 왜 필요한지부터 이미지, 컨테이너, Docker Compose까지 개발자 관점에서 실용적으로 정리했습니다.
REST API vs GraphQL: 실무에서 뭘 선택해야 할까?
REST와 GraphQL의 핵심 차이를 이해하고, 프로젝트 상황에 따른 올바른 선택 기준을 정리했습니다.