[JavaScript] 비동기 처리 이해하기 - Callback부터 Promise, async/await까지

2026. 8. 24. 10:57·개발/JavaScript
반응형

JavaScript를 공부하다 보면 반드시 만나게 되는 개념 중 하나가 비동기 처리(Asynchronous Processing)다.

setTimeout, Promise, async/await, Event Loop처럼 여러 개념이 한꺼번에 등장해 어렵게 느껴질 수 있다.

하지만 비동기 처리의 핵심은 생각보다 단순하다.

시간이 오래 걸리는 작업을 기다리는 동안 프로그램 전체를 멈추지 않고 다른 작업을 수행하는 것

이 개념을 중심으로 JavaScript의 비동기 처리 방식이 뭔지 살펴보자.


JavaScript의 동기와 비동기

동기 vs. 비동기

먼저 일반적인 JavaScript 코드는 위에서 아래로 순서대로 실행된다.

console.log("1");
console.log("2");
console.log("3");

결과는 다음과 같다.

1
2
3

앞의 코드가 실행된 후 다음 코드가 실행되는 가장 기본적인 방식이다.

이처럼 이전 작업이 끝나야 다음 작업을 수행하는 방식을 동기(Synchronous) 처리라고 한다.

간단한 계산이나 변수 선언처럼 즉시 끝나는 작업이라면 동기 방식으로 처리해도 문제가 없다.

const a = 10;
const b = 20;

const result = a + b;

console.log(result);

문제는 서버 요청이나 파일 처리처럼 시간이 오래 걸리는 작업이다.

웹 애플리케이션에서는 다음과 같은 작업들이 자주 발생한다.

  • 서버에 API 요청
  • 데이터베이스 조회
  • 파일 읽기
  • 이미지 다운로드
  • 일정 시간 대기
  • 사용자 이벤트 처리

예를 들어 서버에서 데이터를 받아오는 데 5초가 걸린다고 가정해보자.

만약 이를 완전히 동기적으로 처리한다면 다음과 같은 흐름이 된다.

서버 요청
   ↓
5초 동안 대기
   ↓
응답 도착
   ↓
다음 작업

서버의 응답을 기다리는 동안 아무런 작업도 하지 못한다면 웹 페이지가 멈춘 것처럼 느껴질 수 있다.

그래서 JavaScript에서는 시간이 오래 걸리는 작업을 비동기적으로 처리한다.

서버 요청
   ↓
작업을 맡겨둠
   ↓
다른 코드 계속 실행
   ↓
서버 응답 도착
   ↓
응답 결과 처리

즉, 결과가 필요한 작업을 요청해두고 그 작업이 끝날 때까지 다른 일을 처리하는 것이 비동기의 기본적인 목적이다.


setTimeout으로 이해하는 비동기 처리

JavaScript의 비동기 동작을 가장 간단하게 확인할 수 있는 것이 setTimeout()이다.

console.log("시작");

setTimeout(() => {
    console.log("3초 후 실행");
}, 3000);

console.log("끝");

코드를 위에서 아래로 읽으면 다음과 같은 결과를 예상할 수도 있다.

시작
3초 후 실행
끝

하지만 실제 실행 결과는 다음과 같다.

시작
끝
3초 후 실행

setTimeout()을 만나도 JavaScript가 그 자리에서 3초 동안 기다리지 않기 때문이다.

흐름을 단순하게 표현하면 다음과 같다.

"시작" 출력
    ↓
3초 타이머 등록
    ↓
다음 코드 실행
    ↓
"끝" 출력
    ↓
3초 경과
    ↓
등록한 함수 실행

여기서 setTimeout() 내부에 전달한 함수가 바로 콜백 함수(Callback Function)다.

() => {
    console.log("3초 후 실행");
}

콜백 함수란 쉽게 말하면 다음과 같다.

특정 작업이 끝난 뒤 실행할 함수

JavaScript에서는 이렇게 콜백 함수를 이용해 비동기 작업이 완료된 이후의 동작을 지정할 수 있다.


Callback에서 Promise로

예전 JavaScript에서는 비동기 작업을 처리하기 위해 콜백 함수를 많이 사용했다.

function getData(callback) {
    setTimeout(() => {
        const data = 100;
        callback(data);
    }, 2000);
}

getData((data) => {
    console.log(data);
});

getData()가 실행되면 2초 후 데이터를 만들고, 작업이 완료되면 전달받은 콜백 함수를 실행한다.

getData 실행
    ↓
2초 대기
    ↓
데이터 생성
    ↓
callback 실행

문제는 비동기 작업이 계속 연결될 경우다.

login(user, () => {

    getUserInfo(() => {

        getPosts(() => {

            getComments(() => {

                console.log("완료");

            });

        });

    });

});

작업의 순서를 맞추기 위해 콜백 안에 또 다른 콜백을 넣다 보면 코드가 계속 오른쪽으로 깊어진다.

이를 흔히 Callback Hell(콜백 지옥)이라고 한다.

login
  └─ getUserInfo
       └─ getPosts
            └─ getComments

코드가 길어질수록 실행 흐름을 파악하기 어려워지고 에러 처리도 복잡해진다.

이런 문제를 개선하기 위해 JavaScript에 Promise가 등장했다.

Promise는 말 그대로 아직 완료되지 않은 비동기 작업의 미래 결과를 표현하는 객체라고 볼 수 있다.

Promise는 크게 세 가지 상태를 가진다.

Pending
  │
  ├─ Fulfilled
  │
  └─ Rejected
  • Pending : 작업 진행 중
  • Fulfilled : 작업 성공
  • Rejected : 작업 실패

예를 들어 다음과 같이 Promise를 만들 수 있다.

const promise = new Promise((resolve, reject) => {

    setTimeout(() => {
        resolve("데이터 가져오기 성공");
    }, 2000);

});

비동기 작업이 성공하면 resolve()를 호출한다.

그리고 성공한 결과는 .then()으로 받을 수 있다.

promise.then((result) => {
    console.log(result);
});

실패한 경우에는 reject()와 .catch()를 사용한다.

const promise = new Promise((resolve, reject) => {

    const success = false;

    if (success) {
        resolve("성공");
    } else {
        reject("실패");
    }

});

promise
    .then((result) => {
        console.log(result);
    })
    .catch((error) => {
        console.log(error);
    });

흐름으로 보면 다음과 같다.

Promise
   │
   ├─ 성공 → resolve → then()
   │
   └─ 실패 → reject  → catch()

Callback에 비해 비동기 작업의 성공과 실패를 구분하기 쉽고, 여러 비동기 작업을 순서대로 연결하기도 편해졌다.


async / await으로 더 쉽게 작성하기

실제 개발에서는 Promise를 직접 사용하는 것보다 async / await 형태의 코드를 더 자주 만나게 된다.

예를 들어 서버에 데이터를 요청하는 fetch()는 Promise를 반환한다.

Promise 방식으로 작성하면 다음과 같다.

fetch("https://example.com/users")
    .then((response) => {
        return response.json();
    })
    .then((data) => {
        console.log(data);
    })
    .catch((error) => {
        console.log(error);
    });

Promise를 사용하면 Callback Hell은 상당 부분 해결할 수 있지만, .then()이 계속 이어지면 코드가 길어질 수 있다.

그래서 등장한 것이 async / await이다.

같은 코드를 다음과 같이 작성할 수 있다.

async function getUsers() {

    const response = await fetch("https://example.com/users");

    const data = await response.json();

    console.log(data);
}

훨씬 일반적인 코드의 실행 순서와 비슷하게 읽힌다.

여기서 async는 해당 함수가 비동기 함수임을 나타낸다.

async function test() {
    return 10;
}

async 함수는 항상 Promise를 반환한다.

따라서 위의 함수는 사실상 다음과 비슷하게 동작한다.

function test() {
    return Promise.resolve(10);
}

그리고 await은 Promise의 결과가 나올 때까지 기다리는 역할을 한다.

const response = await fetch("/api/users");

다만 여기서 중요한 점이 있다.

await이 있다고 해서 JavaScript 프로그램 전체가 멈추는 것은 아니다.

해당 async 함수의 실행만 잠시 멈추고, 다른 JavaScript 작업은 계속 수행할 수 있다.

다음 코드를 보면 이를 확인할 수 있다.

async function test() {

    console.log("1");

    await new Promise((resolve) => {
        setTimeout(resolve, 2000);
    });

    console.log("2");
}

test();

console.log("3");

실행 결과는 다음과 같다.

1
3
2

test() 함수는 await에서 잠시 멈추지만, 그동안 JavaScript 전체가 멈추는 것은 아니다.

그래서 바깥에 있는 다음 코드인

console.log("3");

이 먼저 실행된다.

비동기 작업이 끝난 이후 다시 함수의 실행이 이어지면서 2가 출력된다.


비동기 작업의 에러 처리

Promise에서는 일반적으로 .catch()로 에러를 처리한다.

fetch("/api/users")
    .then((response) => response.json())
    .then((data) => console.log(data))
    .catch((error) => console.error(error));

async / await에서는 일반적인 JavaScript 예외 처리 방식인 try / catch를 사용할 수 있다.

async function getUsers() {

    try {

        const response = await fetch("/api/users");

        const data = await response.json();

        console.log(data);

    } catch (error) {

        console.error("에러 발생:", error);

    }
}

실제 프로젝트에서는 다음과 같은 형태를 굉장히 자주 볼 수 있다.

async function loadData() {
    try {
        const result = await fetchData();

        console.log(result);

    } catch (error) {
        console.error(error);
    }
}

따라서 비동기 코드를 처음 작성한다면

async
  +
await
  +
try / catch

구조를 하나의 기본 패턴으로 기억해두는 것도 좋다.


JavaScript는 싱글 스레드인데 어떻게 비동기가 가능한가?

여기까지 이해하면 한 가지 의문이 생긴다.

JavaScript는 싱글 스레드라는데 어떻게 기다리는 작업과 다른 작업을 동시에 처리할 수 있을까?

JavaScript 엔진의 기본 실행 구조는 싱글 스레드다.

즉, JavaScript 코드를 실행하는 Call Stack에서는 기본적으로 한 번에 하나의 작업만 수행한다.

하지만 브라우저에는 JavaScript 엔진만 존재하는 것이 아니다.

브라우저는 타이머, 네트워크 요청, 사용자 이벤트 등을 처리하기 위한 여러 기능을 제공한다.

이를 보통 Web API라고 부른다.

대표적으로 다음과 같은 기능이 있다.

setTimeout()
fetch()
DOM Event
AJAX

JavaScript에서 이런 비동기 작업을 요청하면 모든 작업을 Call Stack에서 직접 기다리는 것이 아니라 브라우저 환경에 처리를 맡길 수 있다.

개념적인 구조는 다음과 같다.

JavaScript 코드
      │
      ▼
 ┌─────────────┐
 │ Call Stack  │
 └──────┬──────┘
        │
        │ 비동기 작업 요청
        ▼
 ┌─────────────┐
 │   Web API   │
 │             │
 │ setTimeout  │
 │ fetch       │
 │ DOM Event   │
 └──────┬──────┘
        │
        │ 작업 완료
        ▼
 ┌─────────────┐
 │    Queue    │
 └──────┬──────┘
        │
        ▼
 ┌─────────────┐
 │ Event Loop  │
 └──────┬──────┘
        │
        ▼
 ┌─────────────┐
 │ Call Stack  │
 └─────────────┘

여기서 중요한 역할을 하는 것이 Event Loop다.

Event Loop는 Call Stack이 비어 있는지 확인하고, 실행할 수 있는 비동기 작업이 Queue에 있다면 해당 작업을 다시 Call Stack으로 보내준다.

다음 코드를 살펴보자.

console.log("A");

setTimeout(() => {
    console.log("B");
}, 0);

console.log("C");

setTimeout()의 시간이 0ms이므로 바로 실행될 것처럼 보인다.

하지만 실제 결과는 다음과 같다.

A
C
B

setTimeout()의 콜백은 즉시 Call Stack에서 실행되는 것이 아니기 때문이다.

실행 흐름은 대략 다음과 같다.

console.log("A")
       ↓
A 출력
       ↓
setTimeout 등록
       ↓
console.log("C")
       ↓
C 출력
       ↓
현재 실행할 코드 종료
       ↓
Event Loop가 대기 중인 callback 확인
       ↓
callback 실행
       ↓
B 출력

따라서 setTimeout(callback, 0)에서 0은

지금 당장 실행한다

라는 뜻이 아니다.

정확하게는

최소한 지정된 시간이 지난 후 실행 가능한 상태로 만든다

정도로 이해하는 것이 좋다.


결국 비동기 처리는 왜 필요한가?

JavaScript 비동기 처리의 목적은 단순히 코드를 복잡하게 만드는 것이 아니다.

웹 애플리케이션은 수많은 외부 작업을 기다려야 한다.

예를 들어 사용자가 쇼핑몰에 접속했다고 생각해보자.

상품 목록 API 요청
사용자 정보 API 요청
이미지 다운로드
장바구니 조회
사용자 클릭 이벤트

이 중 하나의 작업이 끝날 때까지 다른 모든 작업을 멈춘다면 사용자 경험은 크게 떨어질 것이다.

그래서 시간이 오래 걸리는 작업은 요청해두고 JavaScript는 다른 작업을 계속 수행한다.

그리고 작업이 완료되었을 때 그 결과를 다시 처리한다.

이것이 JavaScript 비동기 처리의 핵심이다.


정리

JavaScript의 비동기 처리를 공부하다 보면 여러 문법이 등장하지만 각각 완전히 별개의 개념은 아니다.

발전 과정을 보면 이해하기 쉽다.

비동기 작업
    ↓
Callback
    ↓
Callback Hell 문제
    ↓
Promise
    ↓
then / catch
    ↓
async / await

Callback은 비동기 작업이 끝난 이후 실행할 코드를 지정하는 방법이고,

Promise는 비동기 작업의 성공과 실패, 미래의 결과를 하나의 객체로 관리하는 방법이다.

 

그리고 async / await은 Promise를 기반으로 하면서 비동기 코드를 동기 코드처럼 쉽게 읽고 작성할 수 있도록 만든 문법이다.

실제 개발에서는 다음과 같은 패턴을 가장 많이 접하게 된다.

async function loadData() {

    try {

        const response = await fetch("/api/data");

        const data = await response.json();

        console.log(data);

    } catch (error) {

        console.error(error);

    }

}

결국 JavaScript 비동기 처리에서 가장 중요한 것은 문법을 외우는 것보다 다음 흐름을 이해하는 것이다.

시간이 오래 걸리는 작업 발생
        ↓
JavaScript가 직접 기다리지 않음
        ↓
외부 환경에 작업을 맡김
        ↓
다른 JavaScript 코드 실행
        ↓
비동기 작업 완료
        ↓
Queue에서 대기
        ↓
Event Loop
        ↓
Call Stack에서 후속 코드 실행

 

다음 단계에서는 여기서 한 단계 더 들어가 Call Stack, Task Queue, Microtask Queue, Event Loop의 실행 순서를 살펴보면 된다.

특히 다음과 같은 코드에서 왜 Promise가 setTimeout()보다 먼저 실행되는지를 이해한다면 JavaScript의 비동기 처리 구조를 상당 부분 이해했다고 볼 수 있다.

console.log("1");

setTimeout(() => {
    console.log("2");
}, 0);

Promise.resolve().then(() => {
    console.log("3");
});

console.log("4");

이 코드의 결과가 왜 아래처럼 

1
4
3
2

가 되는지는 다음 글에서 Event Loop와 Microtask Queue를 통해 자세히 알아볼 수 있다.

반응형

'개발 > JavaScript' 카테고리의 다른 글

[JavaScript][Vue.js] Props와 Emits 정리 - 부모·자식 컴포넌트 통신  (0) 2026.08.26
[JavaScript][Vue.js] Component Lifecycle과 Lifecycle Hook 정리  (0) 2026.08.26
[JavaScript][Vue.js] Composition API란? - Options API와의 차이부터 ref, computed, watch까지  (0) 2026.08.26
[JavaScript][Vue.js] Vue Directive 문법 정리 - v-bind부터 v-if, v-for까지  (1) 2026.08.24
[JavaScript][Vue.js] Vue 기본 개념 이해하기  (0) 2026.08.24
'개발/JavaScript' 카테고리의 다른 글
  • [JavaScript][Vue.js] Component Lifecycle과 Lifecycle Hook 정리
  • [JavaScript][Vue.js] Composition API란? - Options API와의 차이부터 ref, computed, watch까지
  • [JavaScript][Vue.js] Vue Directive 문법 정리 - v-bind부터 v-if, v-for까지
  • [JavaScript][Vue.js] Vue 기본 개념 이해하기
danieLee
danieLee
개발일지
  • danieLee
    Code log
    danieLee
  • 전체
    오늘
    어제
    • 분류 전체보기 (77)
      • 개발 (76)
        • C++ (3)
        • java (6)
        • JavaScript (9)
        • python (0)
        • AWS (2)
        • Docker (6)
        • git (0)
        • 백엔드 (4)
        • Spring (7)
        • Django (2)
        • AI (3)
        • 코테 준비 (13)
        • 알고리즘 (6)
        • SKALA 4기 (13)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    개발자
    개발
    skala
    서버
    js
    java
    대학생
    프로그래머스
    vue.js
    코테
    백엔드
    Ai
    JavaScript
    spring
    파이썬
    개념
    API
    프론트
    알고리즘
    4기
  • 최근 댓글

  • 최근 글

  • 반응형
  • hELLO· Designed By정상우.v4.10.1
danieLee
[JavaScript] 비동기 처리 이해하기 - Callback부터 Promise, async/await까지
상단으로

티스토리툴바