[JavaScript] 비동기 함수와 Promise, Async, Await

wha1e·2025년 1월 8일
0

TIL

목록 보기
6/8

📍 동기 처리와 비동기 처리

JavaScript에서 함수를 호출하면, 함수 코드가 평가되어 함수 실행 컨텍스트가 생성된다. 이때 생성된 실행 컨텍스트는 콜 스택이라고 불리는 실행 컨텍스트 스택에 푸시되고 함수가 실행된다. 콜 스택에서 차례가 되어 함수가 실행되고, 실행이 종료되면 해당 함수 실행 컨텍스트는 콜 스택에서 팝되어 제거되는 원리를 가지고 있다.

(1) JavaScript는 싱글 스레드 언어다?

인터프리터 방식으로 동작하는 JavaScript가 싱글 스레드라고 불리는 이유는 무엇일까? 정확한 이유와 원리는 자바스크립트 엔진의 동작 방식에 있다.

자바스크립트 엔진은 단 하나의 콜 스택을 가진다. 즉, 함수를 처리할 수 있는 창구가 단 하나밖에 없다는 의미이자, 2개의 함수를 동시에 실행할 수 없는 환경을 가지고 있다는 것이다.

콜 스택의 최상위 요소인 실행 중인 실행 컨텍스트를 제외하면, 콜 스택에 존재하는 다른 실행 컨텍스트들은 모두 실행 대기 중인 작업 요소라는 것이다. 따라서, 현재 실행 중인 실행 컨텍스트가 제외되어야 그 다음 최상위 요소인 실행 컨텍스트가 실행이 가능하다.

(2) 동기 호출방식의 문제점?

function sleep(func, delay) {
	const delayUntil = Date.now() + delay; // 현재 시간에 delay를 추가하여 언제까지 기다릴지 작성
	
	while (Date.now() < delayUntil);
	
	func();
}

function foo() {
	console.log('실행됐어요!');
}

sleep(foo, 3 * 1000);

위 함수를 실행하게 되면, 스택에 쌓여 순차적으로 실행을 한다. 즉, delayUntil 조건이 반드시 충족되어야만 foo가 실행된다. 이러한 동작 원리를 의도한다면 괜찮지만, 통신과 같이 언제 올지, 반드시 올지 알 수 없는 불확실한 작업에 대해서는 기다리며 다음 동작까지 멈춰버린다는 문제가 발생한다.

특정 작업이 완료되기 전까지 코드가 멈춰버리니, 기다릴 필요가 없는 기능이 대기에 지나치게 많은 시간을 쏟게 되는 것이다.

(3) JavaScript에서 비동기처리를 할 수 있는 이유는?

자바스크립트 엔진은 크게 2개의 영역으로 나눌 수 있다.

  • Call Stack (콜 스택) : 전역 코드, 함수 코드와 같이 평가 과정에서 생성된 실행 컨텍스트들이 쌓이는 곳으로, 최상위 컨텍스트만이 실행 중이며, 하위 컨텍스트 들은 상위 컨텍스트의 실행이 종료되어 스택에서 제거되어야만 실행이 가능하다.
  • heap (힙 메모리) : 객체가 저장되는 메모리 공간으로, 실행 컨텍스트들은 힙 메모리에 저장된 객체를 참조한다.

자바스크립트는 동기적인 처리 방식을 기본으로 한다. 비동기적인 처리로 보이도록 함수를 만들어 사용할 수 있지만, 이것이 자바스크립트 엔진 자체적으로 할 수 있는 일은 아니다.

코드가 실행되는 자바스크립트 엔진은 싱글 스레드이기 때문에 자체적으로 비동기처리를 하는 것에는 제한이 있다. 자바스크립트의 동시성을 가능하게 만드는 것은 Event Loop(이벤트 루프)에 있다.

Event Loop
JavaScript has a runtime model based on an event loop, which is responsible for executing the code, collecting and processing events, and executing queued sub-tasks. This model is quite different from models in other languages like C and Java.

이벤트 루프
JavaScript의 런타임 모델은 코드의 실행, 이벤트의 수집과 처리, 큐에 대기 중인 하위 작업을 처리하는 이벤트 루프에 기반하고 있으며, C 또는 Java 등 다른 언어가 가진 모델과는 상당히 다릅니다.

이벤트 루프는 웹 브라우저 환경 혹은 Node.js에서 제공하는 기능으로 싱글 스레드로 동작하는 자바스크립트 엔진에서도 비동기 처리가 가능하도록 한다.


📌 웹 브라우저의 구조?

위 그림과 같이 웹 브라우저에서는 비동기 함수에 대응하기 위해 Event LoopCallback Queue(콜백 큐/테스크 큐/이벤트 큐 등으로 불린다)를 제공한다.

  • Web API or Browser API : 웹 브라우저에 구현된 API이다. DOM event, AJAX(Asynchronous Javascript And Xml : 자바스크립트를 이용해 서버와 브라우저가 비동기 방식으로 데이터를 교환할 수 있는 통신 기능), Timer 등이 있다.
  • 콜백 큐 : setTimeout이나 setInterval과 같은 비동기 함수의 콜백 함수 또는 이벤트 핸들러가 저장되는 영역이다.
  • 이벤트 루프 : 콜 스택에 실행 컨텍스트가 비었는지, 콜백 큐에 대기 중인 함수(비동기 함수의 콜백 함수 또는 이벤트 핸들러)가 있는지 반복해서 확인한다.

이벤트 루프는 하나 이상의 콜백 큐를 갖는다. 이때 태스크를 가져오기 위해서는 반드시 콜 스택이 비어있어야만 한다. 콜 스택이 비어있고, 콜백 큐에 하나 이상의 대기 중인 함수가 있다면, FIFO(First In First Out) 형태로 콜백 큐에 있는 함수를 가져온다.

웹 브라우저에서 동작하는 것이 아니라 Node.js에서 동작하는 상황도 똑같이 적용되는데, Node.js의 상세 구조는 아래와 같다.


📌 Node.js의 구조

  • Node.js 는 비동기 IO를 지원하기 위해 libuv라이브러리를 사용한다. 이 libuv가 이벤트 루프를 제공한다.

각 환경과 상황에 따라 다를 뿐 기본적으로 같은 원리를 통해 비동기 처리를 지원한다.

이 때, 싱글 스레드로 동작하는 것은 웹 브라우저 혹은 Node.js에 내장된 자바스크립트 엔진이라는 것을 알고 있어야 한다. 웹 브라우저가 싱글 스레드로 동작한다면 비동기 처리를 지원할 수 없으며, 웹 브라우저 자체는 멀티 스레드로 동작한다.

이것이 JavaScript 코드 환경에서 비동기 함수를 처리할 수 있는 원리다.


📍 비동기에 대처하는 promise, async await

(1) 전통적인 비동기 콜백 함수 패턴의 한계

JavaScript는 비동기 처리를 위한 패턴으로 콜백 함수를 사용한다. 하지만, 전통적인 콜백 패턴의 경우, 가독성이 나쁘고 비동기 처리 중 발생한 에러의 처리가 어려울 뿐만 아니라 여러 개의 비동기 처리를 한 번에 처리하는 데도 한계가 존재한다.

비동기 함수를 호출하면 함수 내부의 비동기로 동작하는 코드가 완료되지 않았다 해도 기다리지 않고 즉시 종료되는 경우가 있다.

let g = 0;

setTimeout(() => {g = 100;}, 0);
console.log(g);

실행 결과

동기 함수를 쓰면 해결되는 문제이지만, 서버와 통신할 때 에러 핸들링과 콜백 함수를 통한 후속 처리는 더욱 복잡해진다.

const get = (url, successCallback, failureCallback) => {
	const xhr = new XMLHttpRequest();
	xhr.open('GET', url);
	xhr.send();
	
	xhr.onload = () => {
		if (xhr.status === 200) {
			successCallback(JSON.parse(xhr.response));
		} else {
			failureCallback(xhr.status);
		}
	};
};

get('https://api.testurlwhale.com/post/1', console.log, console.error);

만약, 콜백 함수를 통해 얻은 결과로 다시 한 번 비동기함수를 호출하게 된다면, 코드의 결과 예측이 더욱 힘들어질 뿐만 아니라, 가독성이 떨어지며 콜백 함수 호출이 중첩되어 복잡도가 증가한다.

이를 Callback Hell(콜백 헬)이라고 한다.

(2) Promise

ES6에서는 위와 같은 전통적인 비동기 함수의 콜백 패턴의 단점을 극복하고자 비동기 처리를 위한 새로운 패턴으로 Promise를 도입하게 된다.

The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.

Promise 객체는 비동기 작업이 맞이할 미래의 완료 또는 실패와 그 결과 값을 나타냅니다.

Promise 생성자 함수는 비동기 처리를 수행할 콜백 함수를 인수로 전달받는데, 이 콜백 함수는 resolvereject 함수를 인수로 전달받는다.

Promise에는 3가지 상태가 존재한다.

  1. 대기 상태 (pending) : new Promise() 를 이용하여 프로미스 객체를 생성한 시점
new Promise();
  1. 이행 상태 (resolve) : 비동기 처리가 성공하여 resolve()가 실행된 상태
function printNumber(n) {
  return new Promise((resolve, reject) => {
    const number = n + 1;
    resolve(number);   // resolve == 비동기처리 성공
  })
}
  • resolve()에 전달한 파라미터는 then()에서 사용할 수 있다.
printNumber(1).then((n) => console.log(n));// 2
  1. 거절 상태 (reject) : 비동기 처리가 실패하여 reject()가 실행된 상태, catch() 에서 에러 원인을 확인할 수 있다.

이처럼 Promise는 기본적으로 pending 상태를 거쳐 비동기 처리가 수행되면 처리 결과에 따라 상태가 변경된다. Promise의 상태는 resolve 또는 reject 함수를 호출하는 것으로 결정된다.

const promiseGet = url => {
	return new Promise((resolve, reject) => {
		const xhr = new XMLHttpRequest();
		xhr.open('GET', url);
		xhr.send();
		
		xhr.onload = () => {
			if (xhr.status === 200) {
				resolve(JSON.parse(xhr.response));
			} else {
				reject(new Error(xhr.status));
			}
		};
};

getPromise('https://api.testurlwhale.com/post/1');

Promise는 비동기 처리를 성공(resolve)할 수도, 실패(reject)할 수도 있다. 그렇다면 Promise의 후속처리는 어떻게 하는 것일까? 바로 thencatch를 사용하는 것이다.

  • getApartmentInfo()를 사용해 아파트 정보를 받고, 결과를 출력할 때, 성공했을 때는 resolve()에 응답값을 넘기고, 실패했을 시 reject()에 에러 메시지를 넘겨준다.
function getApartmentInfo() {
  return new Promise(function(resolve, reject) {
    $.get('url주소', (response) => {
      if (response) resolve(response);               // 성공시 응답값을 넘김
      reject(new Error("InValid Request Error"));    // 실패시 에러메시지를 넘김
    })
  })
}

프로미스 처리가 성공하고 난 뒤, then()에서 그 응답 결과를 사용할 수 있다. 만약 프로미스 처리가 실패했다면, catch()를 사용 하여 에러 메시지를 확인할 수 있다.

getApartmentInfo()
  .then((data) => {// 성공시 응답값을 출력console.log(data);
  })
  .catch((error) => {// 실패시 에러 메시지를 출력console.error(error);
  })

위와 같이 단순히 끝내는 것이 아닌, 후속 처리 과정에서 또다시 새로운 Promise를 호출할 수도 있다.

이런 경우를 Promise Chaning(프로미스 체이닝)이라고 한다.

Promise에서는 후속 처리를 통해 비동기 함수 콜백 패턴에서 발생하던 콜백 헬이 발생하지는 않지만, Promise도 콜백 패턴을 사용하고 있기 때문에, 완전히 독립되었다고 볼 수는 없다.

즉, 후속 처리을 가능케 함으로서 개선되었지만, 가독성이 좋지 않다는 문제가 존재한다.

(3) async/await

Promise 이후 제너레이터를 통해 비동기 처리를 동기 처리처럼 동작하도록 구현한 기능이 나오기도 했지만, 가독성이 나쁘다는 단점은 여전히 존재했다. 이러한 문제를 해결하기 위해서 ES8(ECMAScript 2017)에서는 제너레이터보다 간단하고 가독성 좋게 비동기 처리를 동기 처리처럼 동작하도록 구현할 수 있는 async/await이 도입된다.

The async function declaration creates a binding of a new async function to a given name. The await keyword is permitted within the function body, enabling asynchronous, promise-based behavior to be written in a cleaner style and avoiding the need to explicitly configure promise chains.

async function 선언은 AsyncFunction객체를 반환하는 하나의 비동기 함수를 정의합니다. 비동기 함수는 이벤트 루프를 통해 비동기적으로 작동하는 함수로, 암시적으로 Promise를 사용하여 결과를 반환합니다. 그러나 비동기 함수를 사용하는 코드의 구문과 구조는, 표준 동기 함수를 사용하는것과 많이 비슷합니다.

동기처리를 할 함수 앞에 async를, 그리고 비동기 대상 앞에 await를 붙여주는 방식으로 사용할 수 있으며, 보다 직관적인 코드로 변한 것을 볼 수 있다.

const fetch = require('node-fetch');

async function fetchTodo() {
	const url = 'https://api.testurlwhale.com/post/1');
	
	const response = await fetch(url);
	const todo = await response.json();
	console.log(todo);
}

fetchTodo();

async/await은 Promise를 기반으로 동작한다. 기존 Promise의 then, catch, finally 후속 처리 메서드에 콜백 함수를 전달해서 비동기 처리 결과를 후속 처리할 필요 없이 동기 처리처럼 사용할 수 있게된 것이다.

기존 Promise에서는 발생하는 에러에 대해 후속 처리를 통해 에러를 핸들링했지만, await은 Promise가 resolve 혹은 reject된 처리 결과를 반환한다.

만약 비동기 처리 중 발생하는 에러에 대처하고 싶다면 try~catch문을 사용한다.

const fetch = require('node-fetch');

const foo = async () => {
	try {
		const wrongUrl = 'https://api.testUrlDolphin.com/post/1';
		
		const response = await fetch(wrongUrl);
		const data = await response.json();
		console.log(data);
	} catch (err) {
		console.error(err);
	}
};

foo();

catch 문은 HTTP 통신 과정에서 발생한 네트워크 에러 뿐만 아니라 try 코드 블록 내의 모든 곳에서 발생한 일반적인 에러까지 모두 캐치한다.

async 함수 내에서 catch 문을 사용해서 에러 처리를 하지 않으면 async 함수는 발생한 에러를 reject하는 Promise를 반환한다. 때문에, 구체적인 에러 핸들링을 위해 try~catch 후속 처리 메서드를 사용해서 에러를 처리하면 된다.


💡그렇다면 async await에 then을 사용해도 될까?

promise.then 처럼 await에도 thenable 객체 (호출 가능한 then 메서드가 있는 객체)를 사용할 수 있다.

관련 내용 : using async await and .then together

객체도 존재하고 사용도 가능하지만, 쓸 이유가 없다.

어디까지나, await를 사용하면 그 값을 더 직관적으로 사용할 수 있기 때문에, .then은 올바르지 않다기보다는 불필요한 방법이라고 볼 수 있다. (위처럼 사용할 이유가 없음)


참고자료

https://poiemaweb.com/es6-generator

https://velog.io/@tosspayments/예제로-이해하는-awaitasync-문법

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/async_function

profile
상상을 현실로 만드는 FE

0개의 댓글

관련 채용 정보