Julog 로고 이미지JULOG

Javascript 비동기의 개념, 그리고 어떻게 작동할까?

2025년 9월 23일

image.png

서론

Javascript는 싱글스레드 언어입니다.

즉, 한 번에 하나의 호출 스택만 실행 되고, 다른 언어나 런타임 환경처럼 여러 스레드를 동시에 활용하여 처리하는 것도 불가능합니다.

하지만 우리가 대부분 Javascript 언어를 통해 마주하는 작업들은 시간이 걸리는 일들입니다.

  • 네트워크 요청
  • 파일 읽기 / 쓰기
  • 타이머 사용 / 애니메이션

만약 이런 작업들을 모두 동기로 처리했다면, 네트워크 응답이 돌아올 때까지 화면은 멈추고, 인터렉션도 불가능한 상황이 되었을 것입니다.

그래서 Javascript는 싱글스레드 환경에서 논블로킹 방식으로 동작하여, 마치 여러 작업을 동시에 처리하는 것처럼 작동되게 설계되었습니다.

그 핵심이 바로 비동기 모델입니다.

Javascript 비동기의 개념과 작동원리에 대해 알아보겠습니다.

동기 vs. 비동기

개발을 하다보면, 동기(Synchronous)와 비동기(Asynchronous)라는 단어는 자주 사용됩니다.

자바스크립트 관점에서 두 방식을 비교해 봅시다.

동기 실행

동기 실행은 한 줄이 끝나야 그 다음 줄이 실행되는 방식입니다.

아래 코드를 보겠습니다.

console.log("A"); console.log("B"); console.log("C");

실행 결과는 아래와 같습니다.

A B C

위에서 아래로, 코드가 적힌 순서대로 그대로 실행됩니다.

자바스크립트는 기본적으로 이렇게 순차적으로 실행되는 동기 모델을 따릅니다.

비동기 실행

그런데 모든 코드가 이렇게 단순히 순차적으로만 실행된다면 문제가 생깁니다.

예를 들어 서버에 요청을 보내고 응답을 기다리는 동안, 화면이 멈춘다면 사용자는 아무런 동작도 할 수 없을 겁니다.

자바스크립트는 이런 상황을 피하기 위해 비동기 모델을 제공합니다.

대표적인 예시가 setTimeout입니다.

console.log("A"); setTimeout(() => { console.log("B"); }, 1000); console.log("C");

실행 결과는 아래와 같습니다.

A C B

여기서 중요한 점은 setTimeout을 호출했다고 해서 호출 스택이 1초 동안 멈추는 게 아니라는 것입니다.

타이머는 브라우저(Web API)에 맡겨두고, 자바스크립트 엔진은 다음 코드 console.log("C")를 바로 실행합니다.

그리고 1초 뒤에 타이머가 끝나면 콜백 console.log("B")이 실행되는 거죠.

정리하면, 비동기 실행은 “작업을 나중에 실행하도록 예약하고, 그동안 다른 코드를 먼저 처리하는 것”입니다.

이벤트 루프와 큐

위의 내용까지는 당연히 1초를 기다렸다가 실행되는 코드이니 당연하게 느껴질 수있습니다. 하지만 이렇게 하면 어떨까요?

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

기존 1초였던 console.log(”B”)setTimeout(fn, 0)으로 두고 확인해보겠습니다.

A C B

실행 결과는 변하지 않았습니다.

왜 B는 0초로 설정했음에도 불구하고, 즉시 실행되지 않고 뒤로 밀렸을까요?

이는 **이벤트 루프(Event Loop)**와 큐(Queue) 동작 방식 때문입니다.

호출 스택/태스크 큐/출력호출 스택/태스크 큐/출력

그림에서 볼 수 있듯이, 자바스크립트는 호출 스택이 완전히 비었을 때만 큐에 쌓인 비동기 작업을 실행합니다.

그래서 아무리 0초를 줘도, 현재 실행 중인 코드가 끝나야 다음 차례가 오는 것이죠.

마이크로태스크 vs 태스크

자바스크립트의 이벤트 루프는 단순히 “큐에 있는 걸 순서대로 실행한다”가 전부가 아닙니다.

정확히는 두가지 큐가 존재합니다.

  • 태스크 큐(Macrotask Queue)

    setTimeout, setInterval, I/O 콜백

  • 마이크로태스크 큐(Microtask Queue)

    Promise.then/catch/finally, queueMicrotask, process.nextTick(Node)

이벤트 루프는 호출 스택이 비었을 때 항상 먼저 마이크로태스크 큐를 전부 처리합니다.

그 다음에야 태스크 큐에서 하나를 꺼내 실행합니다.

예시 1: setTimeout vs Promise

setTimeout(() => console.log("timeout"), 0); Promise.resolve().then(() => console.log("promise")); console.log("sync");

실행 결과는 아래와 같습니다.

sync promise timeout

순서를 분석해보면

  1. console.log("sync") → 동기 실행 → 바로 출력
  2. Promise.then → 마이크로태스크 큐에 들어감
  3. setTimeoutWeb API가 타이머 등록 → 태스크 큐에 들어감
  4. 스택이 비자마자 이벤트 루프는 마이크로태스크 큐를 먼저 처리 → "promise" 출력
  5. 마이크로태스크가 비워진 뒤 → 태스크 큐에서 "timeout" 실행

따라서 출력 순서는 sync → promise → timeout이 됩니다.

예시2: 연속된 then 체인

Promise.resolve() .then(() => console.log("microtask 1")) .then(() => console.log("microtask 2")); console.log("sync");

실행 결과는 아래와 같습니다.

sync microtask 1 microtask 2

여기서 중요한 점은 Promise then 체인도 모두 마이크로태스크라는 겁니다.

즉, 이벤트 루프는 매 사이클마다 마이크로태스크 큐를 전부 비운 뒤 태스크 큐로 넘어갑니다.

그 결과 microtask 1microtask 2가 연속 실행되는 것입니다.

왜 이 개념이 중요한가?

  • Promise를 사용하면 setTimeout보다 항상 먼저 실행된다는 보장이 생깁니다.
  • UI 업데이트 시점, 애니메이션, 네트워크 응답 처리 등에서 순서 제어가 필요할 때 반드시 이해하고 있어야 합니다.
  • 특히 await도 사실상 마이크로태스크이므로, 이 개념을 알아야 async/await의 동작을 정확히 예측할 수 있습니다.

async/await은 어떻게 동작하는가

앞에서 살펴본 것처럼 Promise와 마이크로태스크는 자바스크립트 비동기의 핵심입니다.

async/awaitPromise를 읽기 쉽게 만든 문법일 뿐이죠.

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

먼저 async 키워드가 붙은 함수는 반드시 Promise를 반환합니다. 동기적인 값을 반환하더라도 자동으로 Promise.resolve()로 감싸집니다.

async function foo() { return 42; } foo().then(console.log); // 42

위 코드에서 foo()는 42라는 값을 반환하는 것이 아닌, Promise<number>값을 반환합니다.

await의 정체

await 키워드는 함수 실행을 멈추는 것처럼 보이지만, 호출 스택을 블로킹하지 않습니다.

동작과정은 아래와 같습니다.

  1. await expr을 만나면, exprPromise라면 그 결과를 기다리고, Promise가 아니라면 **Promise.resolve(expr)**로 변환합니다.
  2. 이후 코드 실행을 마이크로태스크 큐에 등록합니다.
  3. 호출 스택에서는 현재 함수 실행을 잠시 빠져나가고, 다른 코드가 계속 실행됩니다.

예시1: 기본적인 동작

async function main() { console.log("A"); await null; console.log("B"); } main(); console.log("C");

실행 결과는 아래와 같습니다.

A C B

왜 이런 결과가 나왔을까요?

  • A는 동기적으로 실행됩니다.
  • await null을 만나면 함수의 나머지부분 console.log("B")는 마이크로태스크로 변환됩니다.
  • 따라서 호출 스택이 비게되어 동기 코드인 C를 먼저 실행한 뒤, 마지막으로 마이크로태스크 큐에서 B가 실행됩니다.

예시2: 직렬 실행과 병렬 실행

await을 잘못 쓰면 비효율적인 코드를 작성하기 쉽습니다.

// 직렬 실행: 하나 끝나야 다음 fetch 실행 for (const url of urls) { const res = await fetch(url); console.log(await res.text()); } // 병렬 실행: 동시에 시작하고 결과 모으기 const results = await Promise.all( urls.map(url => fetch(url).then(res => res.text())) ); console.log(results);
  • 첫 번째 방식은 각 요청이 순차적으로 실행되기 때문에 전체 시간이 오래 걸립니다.
  • 두 번째 방식은 요청을 동시에 시작해 병렬적으로 처리하므로 훨씬 빠릅니다.

에러 처리

async/await의 장점 중 하나는 에러 처리를 동기 코드처럼 할 수 있다는 점입니다.

async function fetchData() { try { const res = await fetch("https://api.example.com/data"); return await res.json(); } catch (err) { console.error("에러 발생:", err); } }

await에서 발생한 에러는 try/catch로 잡을 수 있습니다.

이는 내부적으로 Promise.reject가 된 것을 동기적인 throw처럼 처리하기 때문입니다.

따라서 async/await은 다음과 같이 정리될 수 있습니다.

  • async 함수는 항상 Promise를 반환한다.
  • await은 호출 스택을 멈추는 것이 아니라, 나머지 코드를 마이크로태스크로 미루는 것이다.
  • 이 때문에 await 뒤 코드는 Promise.then과 동일한 실행 순서를 가진다.
  • await 사용 시 직렬/병렬 실행 방식을 잘 구분해야 성능 이슈를 피할 수 있다.
  • try/catch로 에러 처리를 쉽게 할 수 있다.

정리

자바스크립트의 비동기 모델은 싱글 스레드 환경에서도 사용자 경험을 끊기지 않게 설계하기 위한 설계입니다.

이벤트 루프와 마이크로태스크의 흐름을 이해하면, 비동기는 더 이상 언제 실행될지 모르는 코드가 아니라 예측 가능한 동작이 됩니다.