devlog.

JavaScript 비동기 완전 정복: 콜백부터 async/await까지

·9분 읽기

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
  }
}

awaitasync 함수 내에서만 사용할 수 있고, 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 ──────────┘
  1. Call Stack에서 코드 실행
  2. 비동기 API 호출은 Web APIs로 위임
  3. 완료된 콜백은 Callback Queue에 대기
  4. Call Stack이 비면 Event Loop가 Queue에서 꺼내 실행

마무리#

방식장점단점
콜백간단함, 구형 브라우저 지원콜백 지옥, 에러 처리 복잡
Promise체이닝, 에러 처리 통합코드가 여전히 장황할 수 있음
async/await동기 코드처럼 읽기 쉬움병렬 처리 실수하기 쉬움

실무에서는 async/await를 기본으로 사용하되, 병렬 처리가 필요한 경우 Promise.all을 함께 활용하는 것이 가장 효율적입니다.

관련 포스트