Skip to Content
BlogJavaScript 정리기초 정리비동기 처리(콜백, async/await, AJAX)

비동기 처리(콜백, async/await, AJAX)

동기 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초 뒤에 실행됨)

JS는 싱글 스레드 언어라서 한 번에 하나의 작업만 처리할 수 있다. 그런데 네트워크 요청처럼 시간이 오래 걸리는 작업을 동기적으로 처리하면, 그 작업이 끝날 때까지 화면이 완전히 멈춰버린다. 이런 문제를 막기 위해 시간이 걸리는 작업은 브라우저의 Web API에 맡기고, JS는 그 작업이 끝나길 기다리지 않고 다음 코드를 계속 실행하는 방식을 쓴다.



타이머 함수 (setTimeout, setInterval, clearTimeout)

setTimeout — 일정 시간 뒤에 한 번 실행

setTimeout(() => { console.log("1초 뒤에 실행됨"); }, 1000);

setInterval — 일정 간격마다 반복 실행

let count = 0; const intervalId = setInterval(() => { count++; console.log(`${count}초 경과`); }, 1000);

clearTimeout / clearInterval — 타이머 취소

const timeoutId = setTimeout(() => { console.log("실행 안 됨"); }, 2000); clearTimeout(timeoutId); // 실행되기 전에 취소
const intervalId = setInterval(() => { console.log("반복 중"); }, 1000); setTimeout(() => { clearInterval(intervalId); // 5초 후 반복 종료 }, 5000);

주의할 점은 setTimeout(fn, 0)이어도 즉시 실행되지 않는다는 것이다. 콜스택이 비워진 다음, 이벤트 루프를 거쳐 실행되기 때문에 항상 동기 코드보다 나중에 실행된다.



콜백 함수와 콜백 지옥

콜백 함수는 다른 함수의 인자로 전달되어, 특정 작업이 끝난 뒤에 호출되는 함수를 말한다. 비동기 작업이 끝난 시점을 알기 위한 전통적인 방식이다.

function getUser(id, callback) { setTimeout(() => { const user = { id, name: "gori" }; callback(user); }, 1000); } getUser(1, (user) => { console.log(user); // { id: 1, name: "gori" } });

콜백 지옥 (Callback Hell)

비동기 작업이 여러 단계로 이어져야 할 때, 콜백 안에 콜백을 계속 중첩하게 되면서 코드가 옆으로 계속 늘어나는 현상이다.

getUser(1, (user) => { getPosts(user.id, (posts) => { getComments(posts[0].id, (comments) => { console.log(comments); // 콜백이 중첩될수록 들여쓰기가 계속 깊어지고 // 에러 처리도 각 단계마다 따로 해줘야 해서 가독성이 크게 떨어진다 }); }); });

이 문제를 해결하기 위해 등장한 것이 Promise다.



Promise (then/catch/finally, Promise.all)

Promise는 비동기 작업의 **최종 결과(성공 또는 실패)**를 나타내는 객체다. 콜백 지옥 문제를 해결하기 위해 ES6에서 도입됐다.

Promise의 3가지 상태

  • pending: 대기 중 (아직 결과가 나오지 않음)
  • fulfilled: 성공적으로 완료됨
  • rejected: 실패함 Promise 만들기
function getUser(id) { return new Promise((resolve, reject) => { setTimeout(() => { if (id) { resolve({ id, name: "gori" }); // 성공 시 } else { reject(new Error("id가 없습니다")); // 실패 시 } }, 1000); }); }

then / catch / finally로 결과 처리

getUser(1) .then((user) => { console.log(user); // { id: 1, name: "gori" } return user.name; }) .then((name) => { console.log(name); // "gori" }) .catch((error) => { console.log(error.message); // 실패 시에만 실행 }) .finally(() => { console.log("요청 완료"); // 성공/실패 상관없이 항상 실행 });

then을 연결하는 것을 **체이닝(chaining)**이라고 하는데, 콜백 지옥과 달리 중첩되지 않고 옆으로 늘어지지 않아 가독성이 훨씬 좋다.

콜백 지옥을 Promise로 개선

getUser(1) .then((user) => getPosts(user.id)) .then((posts) => getComments(posts[0].id)) .then((comments) => console.log(comments)) .catch((error) => console.log(error)); // 에러 처리도 한 곳에서 가능

Promise.all — 여러 비동기 작업을 동시에 처리

여러 Promise를 동시에 실행하고, 모두 성공해야 결과를 받을 수 있다. 하나라도 실패하면 전체가 reject된다.

Promise.all([getUser(1), getUser(2), getUser(3)]) .then((users) => { console.log(users); // [user1, user2, user3] 배열로 한번에 받음 }) .catch((error) => { console.log("하나라도 실패하면 여기로", error); });

서로 의존성이 없는 여러 요청을 동시에 보내고 싶을 때 유용하다. 순서대로 하나씩 await 하는 것보다 훨씬 빠르다.

참고 — Promise.allSettled

Promise.all과 달리 일부가 실패해도 나머지 결과를 확인할 수 있다.

Promise.allSettled([getUser(1), getUser(0)]).then((results) => { console.log(results); // [{ status: "fulfilled", value: {...} }, { status: "rejected", reason: Error }] });


async / await

async/await는 Promise를 동기 코드처럼 보이게 작성할 수 있게 해주는 문법이다. 내부적으로는 여전히 Promise를 기반으로 동작한다.

기본 사용법

async function fetchUser() { try { const user = await getUser(1); console.log(user); // { id: 1, name: "gori" } const posts = await getPosts(user.id); console.log(posts); } catch (error) { console.log("에러 발생:", error.message); } } fetchUser();
  • async 키워드가 붙은 함수는 항상 Promise를 반환한다.
  • await는 Promise가 처리될 때까지 기다렸다가, 그 결과값을 반환한다. await는 반드시 async 함수 안에서만 사용할 수 있다.
  • 에러 처리는 .catch() 대신 try...catch로 한다. Promise 체이닝과 비교
// Promise 체이닝 getUser(1) .then((user) => getPosts(user.id)) .then((posts) => console.log(posts)) .catch((error) => console.log(error)); // async/await (같은 동작, 더 읽기 쉬움) async function fetchData() { try { const user = await getUser(1); const posts = await getPosts(user.id); console.log(posts); } catch (error) { console.log(error); } }

async/await와 Promise.all 함께 쓰기

여러 작업을 병렬로 처리하고 싶다면 await를 하나씩 순서대로 쓰기보다 Promise.all과 함께 쓰는 게 좋다.

async function fetchAllUsers() { const [user1, user2, user3] = await Promise.all([ getUser(1), getUser(2), getUser(3) ]); console.log(user1, user2, user3); }

만약 await getUser(1); await getUser(2); await getUser(3); 처럼 순서대로 쓰면, 서로 의존관계가 없는데도 앞 요청이 끝나야 다음 요청이 시작돼서 불필요하게 느려진다.



태스크 큐 vs 마이크로태스크 큐

비동기 작업이 완료된 뒤 콜백이 정확히 언제 실행되는지 이해하려면, JS 엔진이 작업을 대기시키는 두 개의 큐를 알아야 한다.

  • 태스크 큐 (매크로태스크 큐): setTimeout, setInterval, 이벤트 핸들러 같은 콜백이 대기하는 곳
  • 마이크로태스크 큐: Promise의 then/catch/finally 콜백, queueMicrotask가 대기하는 곳 핵심 규칙: 마이크로태스크 큐가 항상 먼저 처리된다

콜스택이 비워지면, 이벤트 루프는 마이크로태스크 큐를 모두 비운 다음에야 태스크 큐에서 작업을 하나 꺼내온다.

console.log("1"); setTimeout(() => { console.log("2"); // 태스크 큐 }, 0); Promise.resolve().then(() => { console.log("3"); // 마이크로태스크 큐 }); console.log("4"); // 실행 순서: 1 4 3 2

동기 코드(1, 4)가 먼저 실행되고, 콜스택이 비워진 뒤 마이크로태스크(3)가 태스크(2)보다 먼저 처리된다. 이 순서는 실무에서 렌더링 타이밍이나 상태 업데이트 순서와 관련된 버그를 이해할 때 중요한 기반 지식이 된다.



AJAX (XMLHttpRequest, fetch API)

AJAX(Asynchronous JavaScript and XML)는 페이지 전체를 새로고침하지 않고, 서버와 비동기적으로 데이터를 주고받는 기술을 말한다. 지금은 이름에 XML이 들어가지만 실제로는 JSON을 주로 사용한다.

XMLHttpRequest — 전통적인 방식

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

콜백 기반이라 문법이 장황하고, 콜백 지옥 문제에서도 자유롭지 못해서 요즘은 거의 쓰이지 않는다.

fetch API — 요즘 기본으로 쓰는 방식

Promise를 기반으로 동작해서 then 체이닝이나 async/await와 자연스럽게 어울린다.

// Promise 방식 fetch("https://api.example.com/users/1") .then((response) => response.json()) // 응답 body를 JSON으로 파싱 .then((data) => console.log(data)) .catch((error) => console.log("요청 실패:", error)); // async/await 방식 async function getUser() { try { const response = await fetch("https://api.example.com/users/1"); const data = await response.json(); console.log(data); } catch (error) { console.log("요청 실패:", error); } }

주의할 점: fetch는 서버가 404, 500 같은 에러 상태 코드를 응답해도 자동으로 reject되지 않는다. 네트워크 자체가 끊기는 등 요청이 아예 실패한 경우에만 reject된다. 그래서 상태 코드를 직접 확인해야 한다.

async function getUser() { const response = await fetch("https://api.example.com/users/1"); if (!response.ok) { // response.ok는 status가 200~299일 때만 true throw new Error(`요청 실패: ${response.status}`); } const data = await response.json(); console.log(data); }

POST 요청 예시

async function createUser(userData) { const response = await fetch("https://api.example.com/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(userData) }); return await response.json(); } createUser({ name: "gori", age: 20 });


마지막 수정