Allra Fintech

(기본기) 자바스크립트 비동기

콜백부터 async/await까지, 프론트엔드 비동기의 발전 과정을 이해합니다.

자바스크립트는 왜 비동기여야 할까

만약 자바스크립트가 전부 동기적으로 돌아간다면 어떻게 될까요?

  • 사용자가 "저장" 버튼을 누릅니다.
  • 서버에 요청을 보내고, 응답이 올 때까지 2초가 걸립니다.
  • 그 2초 동안 화면 전체가 얼어붙습니다.
  • 스크롤도 안 되고, 다른 버튼도 안 눌리고, 글자 입력도 안 됩니다.

자바스크립트는 스레드가 하나뿐이기 때문에, 동기 작업이 끝날 때까지 다른 모든 것이 멈춥니다.
자바라면 별도 스레드에서 처리하면 되지만, 자바스크립트에는 그 선택지가 없습니다.

그래서 자바스크립트는 "기다리는 작업은 엔진 바깥에 맡기고, 끝나면 알려달라"는 방식을 택했습니다.
이게 비동기이고, 프론트엔드 코드의 거의 모든 데이터 처리가 이 방식으로 돌아갑니다.

이 비동기를 다루는 문법이 어떻게 발전해왔는지 순서대로 보겠습니다.

1단계: XMLHttpRequest — 콜백의 시대

자바스크립트에서 서버와 통신하는 가장 오래된 방법은 XMLHttpRequest입니다.

const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users');
xhr.onload = function () {
  if (xhr.status === 200) {
    const users = JSON.parse(xhr.responseText);
    console.log(users);
  }
};
xhr.onerror = function () {
  console.error('요청 실패');
};
xhr.send();

요청 하나에 이 정도 코드가 필요합니다.

문제는 요청이 연쇄될 때 생깁니다.
"유저 목록을 가져온 뒤, 첫 번째 유저의 주문 내역을 조회하고, 그 주문의 상세 정보를 가져온다"를 콜백으로 쓰면 이렇게 됩니다.

getUsers(function (users) {
  getOrders(users[0].id, function (orders) {
    getOrderDetail(orders[0].id, function (detail) {
      console.log(detail);
    });
  });
});

중첩이 깊어질수록 읽기 힘들고, 에러 처리도 각 단계마다 따로 해야 합니다.
이걸 콜백 지옥(callback hell) 이라고 부릅니다.

2단계: Promise — 체이닝으로 개선

콜백 지옥을 해결하기 위해 Promise가 등장했습니다.
비동기 작업의 "미래 결과"를 객체로 감싸서, 결과가 오면 .then()으로 이어받는 방식입니다.

function getUsers() {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', '/api/users');
    xhr.onload = () => resolve(JSON.parse(xhr.responseText));
    xhr.onerror = () => reject(new Error('요청 실패'));
    xhr.send();
  });
}

resolve는 성공, reject는 실패입니다.
이렇게 Promise로 감싸두면, 호출하는 쪽에서 .then().catch()로 결과를 받을 수 있습니다.

getUsers()
  .then((users) => console.log(users))
  .catch((error) => console.error(error));

연쇄 요청도 중첩 없이 체이닝으로 풀립니다.
위의 콜백 지옥 예시와 비교해 보세요.

getUsers()
  .then((users) => getOrders(users[0].id))
  .then((orders) => getOrderDetail(orders[0].id))
  .then((detail) => console.log(detail))
  .catch((error) => console.error(error));

중첩이 사라지고, 에러 처리도 .catch() 하나로 모을 수 있습니다.

하지만 .then()이 길게 이어지면 여전히 흐름을 따라가기 쉽지 않습니다.

3단계: async/await — 동기처럼 읽히는 비동기

Promise 체이닝을 동기 코드처럼 쓸 수 있게 해주는 문법이 async/await입니다.
실무에서 비동기 코드의 대부분은 이 형태로 씁니다.

async function loadOrderDetail() {
  const usersRes = await fetch('/api/users');
  const users = await usersRes.json();

  const ordersRes = await fetch(`/api/orders?userId=${users[0].id}`);
  const orders = await ordersRes.json();

  const detailRes = await fetch(`/api/orders/${orders[0].id}`);
  const detail = await detailRes.json();

  console.log(detail);
}

위에서 아래로, 한 줄씩 읽으면 됩니다.
await가 "이 작업이 끝날 때까지 기다려"라는 뜻이고, 그동안 화면은 멈추지 않습니다.

에러 처리는 try/catch로 합니다.

async function loadOrderDetail() {
  try {
    const res = await fetch('/api/users');
    const users = await res.json();
    console.log(users);
  } catch (error) {
    console.error('요청 실패:', error);
  }
}

React에서의 사용

React에서는 useEffect 안에서 비동기 함수를 호출하는 패턴이 가장 흔합니다.

import { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    async function fetchUsers() {
      const res = await fetch('/api/users');
      const data = await res.json();
      setUsers(data);
    }
    fetchUsers();
  }, []);

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

useEffect 자체는 async 함수를 직접 받지 않기 때문에, 안에서 async 함수를 선언하고 바로 호출합니다.

이 패턴은 동작하지만, 실무에서는 로딩 처리, 에러 처리, 캐싱, 재요청 같은 것들을 전부 직접 관리해야 합니다.
이런 서버 데이터 관리를 통째로 맡기기 위해 TanStack Query 같은 도구를 쓰기도 합니다.

세 가지 방식 비교

방식코드 형태에러 처리가독성
콜백중첩 함수각 단계마다 개별 처리깊어지면 읽기 어려움
Promise.then() 체이닝.catch() 하나로 통합체이닝이 길면 복잡
async/await위→아래 순서try/catch동기 코드처럼 읽힘

실무에서는 async/await를 기본으로 쓰고, 라이브러리 내부에서 Promise를 만나는 정도입니다.

(심화) 이벤트 루프의 동작 원리

자바는 여러 스레드를 활용해서 작업을 동시에 처리할 수 있습니다.
반면 자바스크립트는 스레드가 하나뿐입니다.
그런데 await으로 서버 응답을 기다리는 동안에도 화면이 멈추지 않습니다.
어떻게 가능할까요?

비밀은 이벤트 루프에 있습니다.

자바스크립트 엔진(V8 등)은 싱글 스레드로 코드를 실행합니다.
엔진이 fetchsetTimeout을 만나면 직접 처리하지 않고, 브라우저의 Web API(또는 Node.js의 libuv)에 넘깁니다.
Web API는 C++로 구현되어 있고 OS 수준의 비동기 I/O를 사용하기 때문에, JS 엔진의 싱글 스레드를 막지 않고 작업을 처리할 수 있습니다.

이 과정을 단계별로 보면 이렇습니다.

  1. 콜 스택: 자바스크립트 엔진이 코드를 실행하는 곳입니다. 엔진은 한 번에 하나의 작업만 처리합니다.
  2. Web API: 엔진이 fetchsetTimeout을 만나면 Web API에 작업을 넘기고, 콜 스택에서는 다음 코드를 바로 실행합니다.
  3. 콜백 큐: Web API가 작업을 끝내면, 결과를 처리할 함수를 큐에 넣어둡니다.
  4. 이벤트 루프: 이벤트 루프가 콜 스택이 비었는지 계속 확인하고, 비면 큐에서 함수를 하나 꺼내서 콜 스택에 올립니다.

스레드가 하나뿐이니, 현재 실행 중인 코드가 끝나야 이벤트 루프가 다음 비동기 결과를 처리할 수 있습니다.

그래서 아래 같은 코드는 주의해야 합니다.

const start = Date.now();
// 화면이 5초 동안 완전히 멈춤
while (Date.now() < start + 5000) {}

이런 반복문이 콜 스택을 오래 차지하면 이벤트 루프가 돌지 못하고, 화면도 버튼도 전부 멈춥니다.
자바에서 메인 스레드를 Thread.sleep()으로 막는 것과 같은 효과입니다.

핵심은 간단합니다.
자바스크립트 엔진은 오래 걸리는 작업을 엔진 바깥(Web API)에 맡기고, 끝나면 알림을 받는 방식으로 비동기를 처리합니다.
그래서 await로 기다려도 화면이 멈추지 않는 겁니다.

직접 해보기

  • fetchJSONPlaceholder API를 호출해서 콘솔에 찍어보기
  • 같은 코드를 .then() 방식과 async/await 방식으로 각각 작성해보기

다음 문서