JavaScript의 비동기 처리 이해하기

Yujin Baek
Techeer Tech Blog
Published in
17 min readOct 15, 2023

개발을 하다가 문득 API를 호출하는 함수에 항상 붙는 async/await 키워드는 어떻게 비동기 처리를 하는 것인지에 대한 궁금증이 생겼다. 그래서 이번 글에서는 그동안 모호하게만 알고 있던 개념인 비동기 처리에 대한 내용을 정리해보고자 한다.

그렇다면, 비동기 처리를 왜 사용해야하는지 이제부터 그 이유에 대해 알아보도록 하자. 먼저 자바스크립트에서 비동기 처리를 사용해야하는 이유를 이해하기 위해서는 자바스크립트의 동작 원리에 대해 알아야 한다.

JavaScript의 동작 방식

자바스크립트는 Java, Python 등의 언어와는 다르게 멀티 스레드를 지원하지 않는 싱글 스레드 언어이다.

싱글 스레드

하나의 프로세스 내에서 단 하나의 작업 흐름(스레드)만을 갖는 것

멀티 스레드

하나의 프로세스 내에서 두 개 이상의 작업 흐름(스레드)을 동시에 관리하는 것

스레드

프로세스 내부의 실행 단위

프로세스

실행중인 프로그램

프로세스는 하나 이상의 스레드로 구성된다. 이 때 하나의 스레드만 존재하면 싱글 스레드 프로세스라고 하고, 두 개 이상의 스레드로 구성되면 멀티 스레드 프로세스라고 한다.

스레드는 쉽게 말하면 작업의 실행 흐름으로, 스레드가 하나이면 한 번에 하나의 작업만 실행하는 것이고, 스레드가 여러 개면 동시에 여러 작업을 실행할 수 있는 것이다.

즉, 싱글 스레드로 동작한다는 것은 동시에 2개 이상의 작업을 실행하지 못하고, 한 번에 하나의 작업만 실행할 수 있다는 것을 의미한다.

좀 더 쉬운 이해를 위해 자바스크립트가 동작하는 방식을 예시를 통해 살펴보자.

const printRabbit = () => { console.log("🐰"); };

const printDog = () => { console.log("🐶"); };

printRabbit();
printDog();
자바스크립트 콜 스택(=실행 컨택스트 스택)자바스크립트 콜 스택(=실행 컨택스트 스택)
자바스크립트 콜 스택(=실행 컨택스트 스택)
  1. 프로그램의 실행 전 실행 컨택스트 스택은 비어있다.
  2. 전역 실행 컨택스트가 콜 스택에 푸시된다.
  3. 전역 코드가 실행되어 printRabbit() 함수의 실행 컨택스트가 콜 스택에 푸시된다.
  4. printRabbit() 함수의 실행이 종료되어 printRabbit() 함수의 실행 컨택스트가 콜 스택에서 팝되어 사라진다.
  5. 다시 전역 코드가 실행되어 printDog() 함수의 실행 컨택스트가 콜 스택에 푸시된다.
  6. printDog() 함수의 실행이 종료되어 printDog() 함수의 실행 컨택스트가 콜 스택에서 팝되어 사라진다.
  7. 전역 코드의 실행이 종료되어 전역 코드의 실행 컨택스트가 콜 스택에서 팝되어 사라진다.

함수가 콜 스택에 푸시되어 실행되고, 실행이 끝나면 해당 컨택스트는 콜 스택에서 사라지게 된다.

자바스크립트 엔진은 콜 스택의 가장 위에 있는 실행 컨택스트만을 실행하기 때문에 함수는 위에서 살펴본 것처럼 콜 스택에 푸시된 순서대로 실행된다.

작성한 코드가 순서대로 동작하는 것은 콜 스택이 이렇게 푸시된 순서대로 함수를 실행하기 때문이다.

위 예시 코드를 실행하면 아래와 같이 실행한 순서대로 토끼와 강아지가 차례대로 출력될 것이다.

예시 코드의 실행 결과

자바스크립트 엔진은 위 그림에서처럼 단 하나의 콜 스택을 갖는다. 그렇기 때문에 한 번에 하나의 실행 흐름밖에 갖지 못하고, 싱글 스레드 방식으로 동작하는 것이다.

이렇게 한 번에 하나의 작업만을 처리하는 방식을 동기적으로 동작한다고 한다.

동기 실행/비동기 실행

동기(Synchronous) 실행

특정 작업이 완료될 때까지 대기하며, 해당 작업이 완료되기 전에는 다음 작업으로 넘어가지 않는 실행 방식

장점

작업의 순서와 실행 시간을 예측할 수 있기 때문에 결과의 일관성을 유지하기가 쉬움

단점

프로그램의 실행 중 블로킹(작업 중단)이 발생한다.

비동기(Asynchrnous) 실행

특정 작업이 완료되지 않아도 다른 작업을 병렬적으로 실행할 수 있는 실행 방식

장점

프로그램의 실행 중 블로킹(작업 중단)이 발생하지 않아, 다른 작업을 계속 실행할 수 있음

단점

작업의 결과가 즉시 반환되지 않을 수 있어 작업의 순서와 실행 시간을 예측하기 어려움

동기 실행과 비동기 실행은 각각의 장단점이 있기 때문에 모든 작업을 동기적으로, 또는 비동기적으로 실행하는 것은 좋은 방법이 아니다.

각 작업의 특성에 맞게 동기와 비동기를 적절하게 사용하는 것이 중요하다.

싱글 스레드(동기 실행)의 문제점

싱글 스레드 방식으로 실행하게되면 앞의 예시처럼 가벼운 작업들의 경우에는 상관없지만, 시간이 오래 걸리는 작업을 처리하게되면 이후의 작업들은 블로킹(작업 중단)이 발생하게 된다.

예를 들어 아래 코드를 한 번 살펴보자.

const printRabbit = () => {
let end = Date.now() + 3000;
while (end > Date.now());
console.log("🐰");
};

const printDog = () => { console.log("🐶"); };

printRabbit();
printDog();

현재 시간을 가져오는 Date.now() 함수를 이용해 현재 시간보다 3초 뒤의 시간을 end 변수에 할당하고, while 문을 통해 현재 시간이 end보다 작은지 반복적으로 확인한다.

결국 위 코드를 실행하면 3초 동안은 while 문의 조건을 체크하며 계속해서 while문을 다시 실행하게 된다.

예시 코드의 실행 결과

즉, 위 결과에서 볼 수 있듯이 토끼가 출력되기까지 3초 정도의 딜레이가 발생하게 된다.

이렇게 싱글 스레드 방식으로 실행을 하게되면 시간이 오래걸리는 작업을 실행했을 때, 뒤에서 대기중인 작업들은 해당 작업이 완료될 때까지 중단되는 문제가 발생하게 된다.

이러한 싱글 스레드의 문제점 때문에 비동기 실행이 필요한 것이다.

JavaScript를 멀티 스레드(비동기 실행)처럼 동작시키는 방법

싱글 스레드 방식으로 동작하는 자바스크립트는 비동기 실행을 통해 멀티 스레드와 비슷하게 동작하도록 할 수 있다.

자바스크립트는 기본적으로 동기적으로 실행된다. 하지만, 시간이 오래걸리는 작업들까지 동기 실행을 하게 된다면 프로그램의 실행 중 블로킹이 자주 발생하게 되기때문에 일부 무거운 작업들은 비동기로 실행하는 것이다.

비동기로 실행해야하는 작업들로는 대표적으로 DB 쿼리를 수행하는 작업, HTTP 요청 등의 작업이 있다.

두 작업 모두 서버의 요청을 기다려야하는 작업들인 것을 알 수 있다. 이처럼 클라이언트에서 서버의 요청을 기다리는 동안 작업이 블로킹 되지 않고, 다음 작업을 실행하고 싶을 때 비동기 실행을 사용한다.

아래는 비동기 실행의 예시이다.

const printRabbit = () => {
setTimeout(() => console.log("🐰"), 3000);
};

const printDog = () => { console.log("🐶"); };

printRabbit();
printDog();
예시 코드의 실행 결과

결과를 보면 강아지가 먼저 출력된다음 3초 뒤에 토끼가 출력되는 것을 확인할 수 있다. 원래 자바스크립트의 작동 방식대로 동작한다면 3초 뒤에 토끼가 출력되고, 그 다음에 강아지가 출력되는 것이 맞다.

그런데 왜 이런 결과가 나온 것일까?

바로 setTimeout() 함수가 비동기적으로 실행되기 때문이다.

setTimeout() 함수

- 타이머 함수로, Web API이다.

- 첫 번째 인자 : 타이머가 종료된 후 실행할 콜백 함수

- 두 번째 인자 : 타이머에 지정할 시간(밀리초 단위)

콜백 함수(Callback Function)

다른 함수의 인자로 전달되는 함수

이벤트 루프의 작동 방식
이벤트 루프의 작동 방식
const printRabbit = () => {
console.log("🐰");
};

const printDog = () => {
console.log("🐶");
};

setTimeout(() => printRabbit(), 0);
printDog();

위 코드를 예시로 비동기 함수가 어떻게 처리되는지 알아보자.

  1. 프로그램의 실행 전 콜 스택은 비어있다.

2. 전역 실행 컨택스트가 콜 스택에 푸시된다.

3. 전역 코드가 실행되어 setTimeout() 함수의 실행 컨택스트가 콜 스택에 푸시된다.

  1. setTimeout() 함수가 실행되어 콜백 함수인 printRabbit() 함수를 호출 스케줄링한다.
  2. setTimeout() 함수의 실행이 종료되어 setTimeout() 함수의 실행 컨택스트가 콜 스택에서 팝되어 사라진다.

브라우저

6–1. 브라우저는 타이머를 만료를 기다리다가 타이머가 종료되면 콜백 함수인 printRabbit() 함수의 실행 컨택스트를 태스크 큐에 푸시한다.

자바스크립트 엔진

6–1. 다시 전역 코드가 실행되어 printDog() 함수의 실행 컨택스트가 콜 스택에 푸시된다.

6–2. printDog() 함수의 실행이 종료되어 printDog() 함수의 실행 컨택스트가 콜 스택에서 팝되어 사라진다.

7. 전역 코드의 실행이 종료되어 전역 코드의 실행 컨택스트가 콜 스택에서 팝되어 사라진다.

8. 이벤트 루프가 콜 스택이 빈 것을 확인하고, 태스크 큐에서 대기중이던 콜백 함수 printRabbit()를 콜 스택에 푸시한다.

9. printRabbit() 함수의 실행이 종료되어 printRabbit() 함수의 실행 컨택스트가 콜 스택에서 팝되어 사라진다.

위 실행 과정에서 6번은 브라우저와 자바스크립트 엔진이 병렬적으로 실행하게 된다. 즉, 브라우저는 타이머를 기다렸다가 콜백 함수를 태스크 큐에 푸시하는 작업을 하고, 동시에 자바스크립트 엔진은 전역 코드를 이어서 실행하는 작업을 하는 것이다.

둘 중 어느 작업이 먼저 끝날지는 예측할 수 없으며, 이런 실행 방식을 비동기 실행이라고 한다.

브라우저는 Web API, 자바스크립트 엔진 등을 제공하며, 멀티 스레드 방식으로 동작한다. 즉, Web API와 자바스크립트 엔진은 병렬적으로 실행될 수 있다.

정리하자면, setTimeout() 함수는 Web API에서 처리하고, printDog() 함수는 자바스크립트 엔진에서 실행한다. 그래서 타이머의 실행과 printDog() 함수의 실행이 동시에 처리될 수 있는 것이다.

자바스크립트 엔진은 싱글 스레드 방식으로 동작하지만, 브라우저가 멀티 스레드 방식으로 동작하기 때문에 자바스크립트에서도 비동기 실행이 가능하다

JavaScript에서의 비동기 실행

비동기 실행이 시스템의 효율성을 높여주는 것은 맞지만, 비동기 실행에 장점만 있는 것은 아니다.

위에서 살펴봤듯이 비동기 실행은 두 개 이상의 작업을 병렬적으로 처리할 수 있는 대신 작업의 결과가 언제 반환될지는 예측할 수 없다는 단점이 있다.

따라서 순서가 중요한 작업을 처리할 때는 비동기 실행을 하면 원하는 순서가 보장되지 않을 수도 있다.

그렇다면 비동기 실행을 사용하면서 함수의 실행 순서를 보장할 수 있는 방법은 없을까?

자바스크립트에는 이를 해결하기 위한 3가지 방법이 존재하는데 하나씩 살펴보도록 하자.

1. 콜백 함수

첫 번째 방법은 바로 콜백 함수이다. 나중에 실행돼야할 함수를 콜백 함수로 넘겨주는 방식이다.

const printRabbit = (callback) => {
setTimeout(() => {
console.log("🐰");
callback();
}, 3000);
};

const printDog = () => { console.log("🐶"); };

printRabbit(printDog);
예시 코드의 실행 결과

위 예시처럼 구현하면 setTimeout() 함수는 비동기로 실행되면서 3초 뒤에 토끼와 강아지가 차례대로 출력되도록 할 수 있다.

이렇게 콜백 함수를 사용하면 순서를 보장해주긴 하지만, 아래 코드처럼 실행해야할 함수의 개수가 많아지면 코드가 끝도없이 복잡해진다는 문제점이 있다.

const printRabbit = (callback) => {
setTimeout(() => {
console.log("🐰");
callback(printCat);
}, 3000);
};

const printDog = (callback) => {
setTimeout(() => {
console.log("🐶");
callback();
}, 3000);
};

const printCat = () => {
console.log("🐱");
};

printRabbit(printDog);

콜백 함수 안에서 계속해서 콜백 함수를 호출하는 것이 반복되는 것을 Callback Hell이라고 한다. Callback Hell이 발생하면 코드의 가독성이 매우 나빠지고, 유지보수가 힘들어지기 때문에 이 방법은 비동기를 처리하는데 있어 좋은 방법이 아니다.

2. Promise

Promise는 비동기 처리를 위해 만들어진 객체이다.

Promise 객체의 상태

Pending(대기) : 비동기 처리가 실행하지 않은 상태

Fulfilled(이행) : 비동기 처리가 완료된 상태

Rejected(실패) : 비동기 처리가 실패한 상태

아래 예시 코드와 함께 좀 더 자세히 살펴보자.

1) Promise 생성하기

const promise = new Promise((resolve, reject) => {
resolve("🐰");
});

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

Promise 객체는 new 키워드로 만들 수 있으며, 매개변수로는 resolve(), reject() 두 가지 함수를 갖는다.

resolve() 함수

- Promise의 작업이 성공적으로 완료되었을 때 호출되는 함수

- 이 함수를 호출하면 Promise는 “이행(Fulfilled)” 상태가 됨

reject() 함수

- Promise의 작업이 실패하거나 오류가 발생했을 때 호출되는 함수

- 이 함수를 호출하면 Promise는 “거부(Rejected)” 상태가 됨

즉, resolve(), reject() 함수는 프로미스의 상태를 변화시킨다.

2) Promise 처리하기

Promise의 처리는 then() 함수로 할 수 있는데, 이 함수는 매개변수로는 onFulfilled(), onRejected() 두 가지 함수를 갖는다.

onFulfilled() 함수

Promise가 “이행(Fulfilled)” 상태 (resolve() 함수가 호출된 상태) 가 될 때 호출되는 함수

onRejected() 함수

Promise가 “거부(Rejected)” 상태 (reject() 함수가 호출된 상태)가 될 때 호출되는 함수

따라서 위의 예시 코드에서, resolve("🐰")가 호출되면 Promise는 이행 상태가 되고, then() 메소드 내의 onFulfilled() 함수가 호출된다. 이때 onFulfilled() 함수는 Promise의 비동기 처리 결과인 "🐰"를 인자로 받는다. 결과적으로 콘솔창에 "🐰"가 출력된다.

const printRabbit = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("🐰");
resolve();
}, 3000);
});
};

const printDog = () => {
console.log("🐶");
};

printRabbit().then(printDog);

위 코드는 앞선 콜백 함수 예제 코드를 Promise를 사용해 개선한 것이다. printDog() 함수를 콜백 함수로 전달하는 대신, printRabbit() 함수의 Promise가 이행되면 printDog() 함수가 실행되도록 코드를 변경했다.

콜백 함수를 사용할 때와 동작은 같지만, 콜백 함수 안에서 콜백 함수를 계속해서 호출하는 코드를 사용하지 않아도 돼서 코드의 가독성이 훨씬 개선된 것을 확인할 수 있다.

3) Promise의 체이닝

const printRabbit = () => {
return new Promise((resolve) => {
setTimeout(() => {
console.log("🐰");
resolve();
}, 3000);
});
};

const printDog = () => {
return new Promise((resolve) => {
setTimeout(() => {
console.log("🐶");
resolve();
}, 3000);
});
};

const printCat = () => {
console.log("🐱");
};

printRabbit()
.then(printDog)
.then(printCat)
.catch(console.log("error!"))
.finally(() => {
console.log("끝!");
});

위 코드에서 볼 수 있듯이 프로미스 처리 메서드인 then() 함수는 체이닝이 가능하다.

then 함수 이외에도 catch, finally 등의 Promise 후속처리 함수의 체이닝을 통해 Promise의 결과를 처리할 수 있다.

then, catch, finally 함수들은 모두 Promise를 결과로 반환하며, 반환된 Promise에 대해 연속해서 후속 처리를 수행한다.

catch() 함수

Promise의 비동기 처리에서 발생한 에러를 처리하는 함수

finally() 함수

Promise의 상태와 상관없이 공통적으로 실행해야하는 작업을 처리하는 함수

3. async/await

Promise로 Callback Hell은 방지할 수 있지만, Promise도 체이닝이 지나치게 길어지면 Promise Hell이 발생할 수 있다.

이러한 문제를 해결하기위해 나온 것이 바로 async/await 키워드이다. async/await 키워드는 비동기 처리를 좀 더 직관적으로 작성할 수 있도록 도와준다.

async 키워드를 함수의 앞에 붙여 비동기로 동작하는 함수라는 것을 나타내고, 비동기 함수 안에서 동기적으로 작동해야하는 부분에 await 키워드를 붙여준다.

await 키워드를 붙이게 되면 해당 Promise가 이행되기 전까지는 다음 코드가 실행되지 않기 때문에 비동기 함수 안에서도 순서를 보장받을 수 있다.

아래 코드는 앞에서 살펴봤던 예제를 async/await 키워드를 이용해 다시 작성한 것이다.

const printRabbit = () => {
return new Promise((resolve) => {
setTimeout(() => {
console.log("🐰");
resolve();
}, 3000);
});
};

const printDog = () => {
return new Promise((resolve) => {
setTimeout(() => {
console.log("🐶");
resolve();
}, 3000);
});
};

const printCat = () => {
console.log("🐱");
};

const printAnimals = async () => {
await printRabbit();
await printDog();
printCat();
};

printAnimals();

--

--