개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

비동기 프로그래밍 — JavaScript가 기다리는 방법

2025년 9월 4일
JavaScript비동기콜백Promiseasync/await

서버에 데이터를 요청하고 응답이 올 때까지 화면이 멈춰버린 경험이 있으신가요? 또는 반대로, 요청을 보내고도 페이지가 자연스럽게 동작하는 것을 당연하게 쓰고 있지는 않으신가요? 이 두 경험의 차이가 바로 동기와 비동기입니다.

싱글 스레드인데 어떻게 기다리면서 다른 일을 하는가

JavaScript는 한 번에 하나의 작업만 처리하는 싱글 스레드 언어입니다. 그런데도 네트워크 요청을 보내는 동안 사용자 클릭을 받고, 타이머가 만료되면 콜백을 실행합니다. 어떻게 가능한 걸까요?

답은 JavaScript 엔진이 혼자 모든 일을 처리하지 않기 때문입니다. 시간이 걸리는 작업은 브라우저나 Node.js의 외부 API에 위임하고, 엔진은 나머지 코드를 계속 실행합니다. 위임한 작업이 끝나면 콜백이 큐에 올라오고, 엔진이 한가해지면 그것을 처리합니다.

동기 vs 비동기

동기(Synchronous) 방식은 코드가 순서대로 실행되며, 앞 작업이 끝나야 다음 줄로 넘어갑니다.

console.log("1번");
const result = heavyComputation(); // 끝날 때까지 대기
console.log("2번"); // 1번이 완전히 끝난 후 실행

비동기(Asynchronous) 방식은 시간이 걸리는 작업을 위임하고, 결과를 기다리지 않고 다음 코드를 실행합니다.

console.log("1번");
setTimeout(() => console.log("타이머 완료"), 1000);
console.log("2번"); // 타이머를 기다리지 않고 바로 실행
// 출력 순서: 1번 → 2번 → 타이머 완료

JavaScript가 비동기를 처리하는 구조

┌─────────────────────────────────────────────┐
│              JavaScript Engine              │
│  ┌─────────────┐      ┌──────────────────┐  │
│  │  Call Stack │      │  Heap (memory)   │  │
│  │             │      │                  │  │
│  │ [fn calls]  │      │  [objects]       │  │
│  └─────────────┘      └──────────────────┘  │
└───────────────┬─────────────────────────────┘
                │ delegate
┌───────────────▼─────────────────────────────┐
│          Browser / Node.js APIs             │
│  (fetch, fs, setTimeout, DOM Events ...)    │
└───────────────┬─────────────────────────────┘
                │ on complete → enqueue callback
┌───────────────▼─────────────────────────────┐
│              Callback Queue                 │
└───────────────┬─────────────────────────────┘
                │ when Call Stack is empty
┌───────────────▼─────────────────────────────┐
│              Event Loop                     │
│  (moves callbacks from Queue to Stack)      │
└─────────────────────────────────────────────┘

Call Stack이 비어있을 때만 Event Loop가 큐의 콜백을 실행합니다. JavaScript가 "한 번에 하나"를 지키면서도 비동기를 처리할 수 있는 핵심 구조입니다.

비동기가 필요한 상황

상황예시
네트워크 요청REST API 호출, fetch
파일 I/ONode.js에서 파일 읽기/쓰기
타이머setTimeout, setInterval
사용자 이벤트클릭, 키보드 입력

이 중 네트워크 요청과 파일 I/O처럼 외부 자원에 의존하는 작업이 비동기 처리의 주요 대상입니다.

I/O: Input/Output의 약자. 파일 읽기, 네트워크 통신 등 외부와 데이터를 주고받는 작업.

콜백(Callback)

콜백은 가장 오래된 비동기 패턴입니다. "작업이 끝나면 이 함수를 실행해줘"라고 함수를 인자로 전달합니다.

function fetchUser(id, callback) {
  setTimeout(() => {
    callback(null, { id, name: "김철수" });
  }, 1000);
}

fetchUser(1, (err, user) => {
  if (err) return console.error(err);
  console.log(user.name); // 김철수
});

중첩이 깊어지면 이른바 콜백 지옥이 발생합니다.

콜백 지옥(Callback Hell): 콜백 함수가 중첩되어 코드가 오른쪽으로 계속 들여쓰기 되는 패턴. "Pyramid of Doom"이라고도 합니다.

fetchUser(1, (err, user) => {
  if (err) return handleError(err);
  fetchOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    fetchProducts(orders[0].id, (err, product) => {
      if (err) return handleError(err);
      console.log(product.name); // 가독성 최악
    });
  });
});

Promise

ES6에서 도입된 Promise는 콜백 지옥 문제를 해결합니다. 비동기 작업의 결과를 나타내는 객체로, pending → fulfilled / rejected 세 가지 상태를 가집니다.

function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id, name: "김철수" });
    }, 1000);
  });
}

fetchUser(1)
  .then((user) => fetchOrders(user.id))
  .then((orders) => fetchProducts(orders[0].id))
  .then((product) => console.log(product.name))
  .catch((err) => console.error(err)); // 에러 한 곳에서 처리

중첩 대신 .then 체이닝으로 흐름이 수평적으로 펼쳐져 가독성이 크게 개선됩니다.

async/await

ES2017에서 도입된 async/await는 Promise를 기반으로 하되, 동기 코드처럼 읽히도록 문법을 제공합니다.

async function loadProduct(userId) {
  try {
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id);
    const product = await fetchProducts(orders[0].id);
    console.log(product.name);
  } catch (err) {
    console.error(err);
  }
}

loadProduct(1);

await는 Promise가 완료될 때까지 해당 함수의 실행을 일시 정지하지만, Call Stack 자체를 블로킹하지는 않습니다. 내부적으로는 .then과 동일하게 동작합니다.

세 방식 비교 — 같은 작업을 세 방식으로

사용자 정보를 가져와서 이름을 출력하는 동일한 작업입니다.

콜백 방식

getUser(1, (err, user) => {
  if (err) return console.error("오류:", err.message);
  console.log("이름:", user.name);
});

Promise 방식

getUser(1)
  .then((user) => console.log("이름:", user.name))
  .catch((err) => console.error("오류:", err.message));

async/await 방식

async function printUserName() {
  try {
    const user = await getUser(1);
    console.log("이름:", user.name);
  } catch (err) {
    console.error("오류:", err.message);
  }
}

printUserName();

비동기 에러 처리 패턴 비교

각 방식마다 에러를 처리하는 위치와 방법이 다릅니다.

콜백 — 첫 번째 인자로 에러를 전달하는 Node.js 관례(Error-first callback)를 사용합니다.

fs.readFile("data.txt", "utf8", (err, data) => {
  if (err) {
    console.error("파일 읽기 실패:", err.message);
    return;
  }
  console.log(data);
});

Promise — .catch로 체인 전체의 에러를 한 번에 처리합니다.

fetch("/api/user")
  .then((res) => res.json())
  .then((data) => console.log(data))
  .catch((err) => console.error("요청 실패:", err.message));

async/await — 일반적인 try/catch 구문을 그대로 사용합니다.

async function loadData() {
  try {
    const res = await fetch("/api/user");
    const data = await res.json();
    console.log(data);
  } catch (err) {
    console.error("요청 실패:", err.message);
  }
}

정리

방식장점단점
콜백추가 문법 없이 바로 사용 가능중첩 시 가독성 급감, 에러 처리 분산
Promise체이닝으로 흐름 명확, 에러 한 곳 처리복잡한 흐름에서 여전히 장황
async/await동기 코드처럼 읽힘, 디버깅 쉬움async 함수 안에서만 사용 가능

JavaScript의 비동기 패턴은 콜백에서 Promise로, Promise에서 async/await로 진화했지만 세 방식 모두 Event Loop라는 동일한 메커니즘 위에서 동작합니다. async/await가 가장 읽기 좋지만, Promise의 동작 원리를 이해해야 예상치 못한 버그를 피할 수 있습니다.

← 이전 글Promise — 미래의 값을 다루는 방법
다음 글 →에러 처리 — 예외가 발생했을 때 무너지지 않는 코드