JavaScript에서 함수를 호출하면, 함수 코드가 평가되어 함수 실행 컨텍스트가 생성된다. 이때 생성된 실행 컨텍스트는 콜 스택이라고 불리는 실행 컨텍스트 스택에 푸시되고 함수가 실행된다. 콜 스택에서 차례가 되어 함수가 실행되고, 실행이 종료되면 해당 함수 실행 컨텍스트는 콜 스택에서 팝되어 제거되는 원리를 가지고 있다.
인터프리터 방식으로 동작하는 JavaScript가 싱글 스레드라고 불리는 이유는 무엇일까? 정확한 이유와 원리는 자바스크립트 엔진의 동작 방식
에 있다.
자바스크립트 엔진은 단 하나의 콜 스택을 가진다. 즉, 함수를 처리할 수 있는 창구가 단 하나밖에 없다는 의미이자, 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가 실행된다. 이러한 동작 원리를 의도한다면 괜찮지만, 통신과 같이 언제 올지
, 반드시 올지
알 수 없는 불확실한 작업에 대해서는 기다리며 다음 동작까지 멈춰버린다는 문제가 발생한다.
특정 작업이 완료되기 전까지 코드가 멈춰버리니, 기다릴 필요가 없는 기능이 대기에 지나치게 많은 시간을 쏟게 되는 것이다.
자바스크립트 엔진은 크게 2개의 영역으로 나눌 수 있다.
자바스크립트는 동기적인 처리 방식을 기본으로 한다. 비동기적인 처리로 보이도록 함수를 만들어 사용할 수 있지만, 이것이 자바스크립트 엔진 자체적으로 할 수 있는 일은 아니다.
코드가 실행되는 자바스크립트 엔진은 싱글 스레드이기 때문에 자체적으로 비동기처리를 하는 것에는 제한이 있다. 자바스크립트의 동시성을 가능하게 만드는 것은 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 Loop
와 Callback Queue(콜백 큐/테스크 큐/이벤트 큐 등으로 불린다)
를 제공한다.
(Asynchronous Javascript And Xml : 자바스크립트를 이용해 서버와 브라우저가 비동기 방식으로 데이터를 교환할 수 있는 통신 기능)
, Timer 등이 있다.이벤트 루프는 하나 이상의 콜백 큐를 갖는다. 이때 태스크를 가져오기 위해서는 반드시 콜 스택이 비어있어야만 한다. 콜 스택이 비어있고, 콜백 큐에 하나 이상의 대기 중인 함수가 있다면, FIFO(First In First Out) 형태
로 콜백 큐에 있는 함수를 가져온다.
웹 브라우저에서 동작하는 것이 아니라 Node.js에서 동작하는 상황도 똑같이 적용되는데, Node.js의 상세 구조는 아래와 같다.
📌 Node.js의 구조
libuv
라이브러리를 사용한다. 이 libuv가 이벤트 루프를 제공한다.각 환경과 상황에 따라 다를 뿐 기본적으로 같은 원리를 통해 비동기 처리를 지원한다.
이 때, 싱글 스레드로 동작하는 것은 웹 브라우저
혹은 Node.js
에 내장된 자바스크립트 엔진
이라는 것을 알고 있어야 한다. 웹 브라우저가 싱글 스레드로 동작한다면 비동기 처리를 지원할 수 없으며, 웹 브라우저 자체는 멀티 스레드로 동작한다.
이것이 JavaScript 코드 환경에서 비동기 함수를 처리할 수 있는 원리다.
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(콜백 헬)
이라고 한다.
ES6에서는 위와 같은 전통적인 비동기 함수의 콜백 패턴의 단점을 극복하고자 비동기 처리를 위한 새로운 패턴으로 Promise
를 도입하게 된다.
The
Promise
object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.
Promise
객체는 비동기 작업이 맞이할 미래의 완료 또는 실패와 그 결과 값을 나타냅니다.
Promise 생성자 함수
는 비동기 처리를 수행할 콜백 함수
를 인수로 전달받는데, 이 콜백 함수는 resolve
와 reject
함수를 인수로 전달받는다.
Promise에는 3가지 상태가 존재한다.
프로미스 객체를 생성한 시점
new Promise();
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
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의 후속처리는 어떻게 하는 것일까? 바로 then
, catch
를 사용하는 것이다.
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도 콜백 패턴을 사용하고 있기 때문에, 완전히 독립되었다고 볼 수는 없다.
즉, 후속 처리을 가능케 함으로서 개선되었지만, 가독성이 좋지 않다는 문제가 존재한다.
Promise 이후 제너레이터
를 통해 비동기 처리를 동기 처리처럼 동작하도록 구현한 기능이 나오기도 했지만, 가독성이 나쁘다는 단점은 여전히 존재했다. 이러한 문제를 해결하기 위해서 ES8(ECMAScript 2017)에서는 제너레이터보다 간단하고 가독성 좋게 비동기 처리를 동기 처리처럼 동작하도록 구현할 수 있는 async/await
이 도입된다.
The
async function
declaration creates a binding of a new async function to a given name. Theawait
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