목차
- 비동기처리의 필요성
1-1. 실행컨텍스트 스택
1-2. 싱글스레드 - 비동기처리의 동작 원리
2-1. 브라우저의 이벤트 루프
2-2. 왜 싱글스레드일까? - 콜백 비동기 처리의 문제점
3-1. 콜백 헬 (Callback hell) - Promise
4-1. 상태
4-2. fetch와 axios - async/await
5-1. async/await
5-2. Promise 메소드
1. 비동기처리의 필요성
1-1 . 실행컨텍스트 스택 (=콜스택, Execution context)
- 자바스크립트의 동작 원리를 담고 있는 핵심 개념으로서 이를 통해 식별자&값 메모리 관리 방식, 호이스팅 발생 이유, 이벤트 핸들러(테스트 큐), 비동기 처리의 동작 방식을 이해할 수 있습니다.
- V8의 구조: Memory Heap (메모리 할당)+ Call Stack
모든 소스코드는 코드를 실행하기 앞서 평가 과정을 거치고, 이 평과 과정을 통해 실행에 필요한 정보(식별자, 스코프, 코드 실행 순서 등)를 담은 실행 컨텍스트라는 것을 생성하게 됩니다.
이 실행컨텍스트들을 담은 스택이 바로 콜스택!
*평가란? 코드를 계산(evaluation)하여 값을 만드는 것. (1+3 은 4로 평가)
만약 아래와 같은 소스코드를 실행한다고 하면
let x = 3
function f1() => {
console.log("This is func1");
}
//중첩되어있음
function f2() => {
f1();
console.log("This is func2");
}
f2();
스택 자료구조에 따라 아래와 같이 생성이 됩니다. (LIFO) 여기서 전역 컨텍스트는 전역 변수에 값이 할당되고 전역 함수가 호출되는 것을 말합니다.
콜스택에 push = 코드가 실행됨
콜스택에 pop = 역할을 다해 코드 실행이 종료됨
즉, 가장 위에 있는 컨텍스트가 실행중일때는 아래 컨텍스트들은 ‘실행 대기중인 테스크’가 됩니다. 위의 함수가 pop이 되야 아래 함수가 실행되기 때문입니다.
자바스크립트는 이 콜스택이라 불리는 실행컨텍스트 스택을 단 하나만 가지고 있습니다. 한번에 최상단에 있는 테스크만 실행 가능하고 동시에 여러개의 함수를 실행할 수 없다는 말이죠.
이러한 방식을 싱글스레드 방식이라고 합니다.
1-2. 싱글스레드 (Single-threaded Processes) 방식
실행 대기중인 태스크가 현재 실행중인 테스크의 종료까지 대기하는 과정을 블로킹이라고 하고, 딜레이된다고 표현합니다.
만약 현재 실행중인 함수가 크고 시간이 많이 걸리는 실행컨텍스트라면 블로킹이 발생하고 비효율적인 코드가 되겠죠.
이렇듯 실행 순서를 보장하는 방식을 동기처리 방식이라고 합니다.
setTimeout()이라는 함수를 보신 적이 있나요?
const f1 = () => {
console.log("f1");
}
const f2 = () => {
console.log("f2");
}
setTimeout(f1, 3000);
f2();
지금까지의 내용을 보면 위의 코드는 f1이 먼저 출력되고 3초 후 f2가 실행될 것 처럼 보입니다. 하지만 실제 출력은 그렇지 않습니다.
"f2"
"f1"
위와 같이 출력됩니다.
이렇듯 실행중인 테스크가 종료되지 않더라도 다음 태스크를 실행하는 것을 비동기처리 방식이라고 합니다. 실행 순서를 보장하지는 않지만 딜레이와 블로킹을 최소화하죠.
setTimeout은 브라우저에 내장(web API)되어있는 비동기함수로서 스택 내에서 다른 함수를 대기시키지 않습니다.
아래에서 이벤트 루프에 대해 설명한 뒤, 싱글 스레드에 대해 추가로 설명하겠습니다.
2. 비동기 처리의 동작 원리
2-1. 브라우저의 이벤트 루프 (Event loop)
우리가 브라우저를 사용하다보면, 싱글스레드 방식이라는것이 안 믿겨질 정도로 많은 태스크가 동시에 처리되는 것처럼 느껴집니다. 이는 브라우저에 이벤트루프 라는 기능이 내장되어있기 때문인데요. 아래에서 더 자세히 설명하도록 하겠습니다 😆
자바스크립트 엔진은 앞서 언급했던 콜스택을 통해 요청된 작업을 순차적으로 진행하게 되는데요, 이를 제외한 나머지 비동기 처리는 브라우저 또는 Node.js 가 담당하게 됩니다.
이러한 동시성을 위해 브라우저 환경은 테스크 큐와 이벤트 루프를 제공하고 있습니다.
그럼 아까 봤던 코드를 다시 살펴보면,
const f1 = () => {
console.log("f1");
}
const f2 = () => {
console.log("f2");
}
setTimeout(f1, 3000);
f2();
소스코드가 실행되면 자바스크립트 엔진을 구성하는 콜 스택에 실행컨텍스트가 쌓이다가 f1을 실행하는 setTimeout 함수를 만나게 될겁니다.
이 때 setTimeout은 3000 밀리초의 타이머를 세팅하고, 바로 콜 스택에서 빠집니다. 그러면 바로 다음의 f2()함수가 블로킹(대기)없이 바로 실행될 수 있겠죠.
이후의 전역 실행 컨텍스트까지 실행을 마치고 콜스택은 모두 pop 됩니다. setTimeoout의 3초가 진행되는 동안에요.
약속한 3초의 타이머가 완료되면 콜백함수 f1은 Task Queue 또는 Callback Queue라는 곳으로 이동하게 되는데요.
테스크 큐란, 비동기 함수의 콜백함수 (여기서는 f1) 또는 이벤트 핸들러가 일시적으로 보관되는 영역을 말합니다.
* callback queue라는 용어를 들어보셨을텐데, 혼용해서 쓰기도 합니다. 그러나 둘은 비슷하지만 task queue는 주로 비동기 함수를 저장하기위해, callback queue는 web API를 저장하는 큐를 가리킵니다.
이것을 관리하는 역할을 하는 것을 이벤트 루프 (event loop)라고 합니다. 이벤트 루프는 현재 실행중인 실행 컨텍스트가 있는지 또는 테스크큐에 대기중인 함수가 있는지 반복해서 확인합니다. 이러한 반복적인 행동을 tick 이라고 부릅니다.
지금과 같은 경우에는 타이머가 완료된 f1 함수가 테스크큐에 남아있을테니, 이를 콜스택에 이동시켜야겠죠? 콜스택은 모든 실행 컨텍스트가 완료되어 pop 되어있을겁니다. 이렇듯 모두 종료되면(콜스택이 비어있으면) 비로소 콜스택에 push 되어 f1이 실행됩니다.
(콜스택이 비어있어야 콜 스택으로 이동되어 실행되기 때문에 반드시 3초 뒤는 아니고 상황에 따라 다를 수 있습니다. 앞에 비동기가 아닌 10초짜리 함수가 있다면......?)
.
.
.
2-2. 왜 싱글 스레드일까?
브라우저를 편리하게 사용할 수 있었던 이유는 바로 싱글 스레드 방식으로 동작하는 것이 브라우저가 아닌 브라우저에 내장된 자바스크립트 엔진이었기 때문입니다. 브라우저는 멀티 스레드로 동작합니다.
프로그램이 실행되고 있을 때 존재하는 곳 (구동되는 환경)을 런타임환경이라고 합니다. 자바스크립트는 브라우저와 Node.js 를 런타임이라고 하며 이 환경은 멀티 스레드 환경이기 때문에 자바스크립트의 동시성을 보장해줄 수 있는 겁니다.
자바스크립트는 웹페이지의 보조적인 기능을 수행하기 위해 만들어진 언어입니다.
브라우저에서 동작하려면 경량의 프로그래밍 언어여야했고, 웹 개발자들에게 그 당시 자바라는 언어는 다소 무겁고 어려운 언어였다고 합니다.
멀티 스레드 모델은 프로그래밍 난이도가 높고 하나의 스레드에 문제가 발생하면 전체 프로세스가 영향을 받았기에 설계하기 까다로웠습니다. 스레드간의 동기화작업도 필요했고요.
그러나 자바스크립트는 웹에서 동작하는 보조적인 언어로 탄생했기 때문에 쉽고 간결해야했습니다.
이후 브라우저 환경이 더욱 개발되면서 이벤트 루프 등의 브라우저가 지원하는 기능이 많아지게 됩니다. 자바스크립트 엔진은 단순히 태스크가 요청되어 콜스택에 push 되면, 이를 순차적으로 실행만 하게 됩니다.
그럼 Node.js는?
node.js = v8 + libuv 라이브러리
v8은 비동기 처리를 할 수 없습니다. 콜스택에 주어진 것만 싱글 스레드로 실행하기 때문이죠. 그래서 node.js 에서 비동기 처리를 담당하는 부분은 libuv 라이브러리에서 이벤트 루프를 제공합니다. 이는 c언어로 생성되었고 멀티 스레드를 이용한다고 합니다.
3. 콜백 비동기 처리의 문제점
3-1. 콜백 헬 (Callback Hell)
위의 코드를 참고하면 이벤트 루프는 콜스택이 비어있는 상태에 콜백큐에 남아있는 비동기 함수를 콜스택에 push 하여 처리하기 때문에 원하는 값을 얻지 못했습니다.
try {
setTimeout(()=> {throw new Error('error!')},3000);
} catch {
//에러를 캐치하지 못함
console.error('에러:', e)
}
만약 try~catch문으로 에러를 잡으려고 했는데, 콜스택에서 catch까지 모두 끝나버리면 아직 완료되지 않은 setTimeout과 같은 비동기 코드에서 던진 에러는 프로그램에 반영되지 않을 것입니다.
이런 상황에서 비동기로 동작하는 코드의 처리 결과를 외부로 늦게 반환하거나, 상위 스코프의 변수에 할당한다면 기대한대로 동작하지 않습니다.
만약 콜백 안에 콜백 안에 콜백... 으로 이루어져 있다면 더욱 원하는 결과를 얻지 못할 것입니다.
이렇듯 비동기 처리 중 여러 콜백 함수들이 중첩되어, 에러의 처리가 어렵고 가독성이 떨어지는 상태를 콜백지옥(Callback hell)이라고 부릅니다.
👇
이러한 문제를 해결하기 위해 ES6에서는 Promise를 통해 비동기 처리 시점을 명확하게 표현할 수 있도록 했으며, ES8에서는 async/await 를 도입해서 비동기 처리를 마치 동기 처리처럼 구현할 수 있게 했습니다.
4. Promise
4-1. 상태
콜백 함수를 쓰지 않고 어떻게 비동기처리를 할 수 있을까요?
Promise란 자바스크립트 안에 내장되어 있는 객체입니다.
const promise = new Promise((resolve, reject)=> {
if (/*비동기처리 성공*/) {
resolve('result');
} else { //비동기처리 실패
reject('failure reason');
}
});
비동기 처리 후 성공적으로 응답을 받으면 resolve 함수를 호출하고, 실패하면 reject 함수를 호출해 에러를 인자로 전달합니다.
프로미스는에서 중요한 개념중 하나는 상태(state)입니다. 프로미스의 상태 정보에는 세가지가 있는데요, 의미와 상태 변경 조건은 아래와 같습니다.
1) pending : 비동기처리가 아직 수행되지 않은 상태로, 프로미스가 생성된 직후의 기본 상태
2) fulfilled: 비동기 처리에 성공한 상태로, resolve 함수 호출 직후 변경됨
3) rejected: 비동기 처리에 실패한 상태, reject 함수 호출 직후 변경됨
+ fulfilled 와 rejected 상태를 함께 settled 상태라고 부르기도 하며 이 상태에서는 더는 다른 상태로 변화할 수 없습니다.
프로미스는 비동기 처리 상태와 처리 결과를 관리하는 객체입니다.
fullfilled를 예로 들어보면, 위와같이 1이라는 PromiseResult 값도 같이 갖는 것을 볼 수 있습니다. rejected 상태라면 error 객체를 값으로 갖게 되겠죠.
반환된 프로미스 객체의 상태에 따라 성공했을때의 .then()메소드와 실패했을때의 .catch() 메소드가 각각 실행됩니다. 물론 then 메소드에서 error 처리도 가능합니다. then 메소드는 두개의 콜백함수를 인자로 받기 때문이죠. (첫번째: fulfilled , 두번째: rejected)
catch() = then(undefined, onRejected)
그러나 가독성을 위해 아래처럼 나누어서 작성하는 것이 일반적입니다.
const promise = new Promise((resolve, reject) => {
setTimeout(()=> {
resolve('ok');
}, 3000);
});
promise.then(
function (result) {/* doing something... */}
).catch(
function (error) {/* doing something... */}
).finally(
console.log("done!")
)
추가로 프로미스의 상태(성공or실패)와 상관없이 무조건 호출되는 .finally 메소드도 있습니다.
이러한 메소드들로 후속처리를 하는 것을 프로미스 체이닝(Promise chaining)이라고 합니다.
4-2. fetch 와 axios
fetch 함수라고 들어보셨을텐데요,
fetch함수는 HTTP 요청 전송 기능을 제공하는 클라이언트 사이드 Web API로, 프로미스를 지원하고 있습니다. (인터넷 익스플로러는 제공하지 않음!)
fetch('url')
.then(res => res.json())
.then(json => console.log(json))
.catch(() => console.log('error'))
fetch 함수에 .then 메소드를 사용할 수 있었던 이유는 바로 fetch 함수가 반환하는 것이 HTTP 응답에 대한 Response 객체를 래핑한 Promise 객체였기 때문입니다.
그러나 fetch 함수를 사용할때는 주의할 점이 있습니다.
//잘못된 url이라고 가정했을 때
const wrongURL = "/wrongURL/..."
fetch(wrongURL)
.then(() => console.log("ok"))
.catch(() => console.log("error")) //error가 아닌 ok가 출력된다.
잘못된 url을 전송한다고 가정했을때, catch() 메소드에 적용되어 error 가 출력될것 같지만 실제로는 그렇지 않고 "ok"가 출력됩니다. 왜 그럴까요?
바로, fetch 함수는 HTTP 에러(404,500)에는 reject를 보내는 것이 아닌, Response 객체의 ok상태를 false로 설정한 후에 resolve() 에 적용시키기 때문입니다. resolve() 이지만 HTTP 오류를 포함하고있기 때문에 response.ok 의 상태를 찍어 꼭 확인해주어야 합니다.
아래 코드처럼요.
fetch('wrongURL')
.then(response => {
if(response.ok) {
return response.json()
}
return Promise.reject(response) //false 이면 HTTP 오류이므로 reject 해주기!
})
.catch(() => console.log('error'))
fetch함수의 에러는 네트워크 장애나 CORS 에러에 의한 것만 reject해줍니다.
그래서 이에 대한 대응으로 fetch 대신 axios를 많이 사용하고 있습니다. axios는 모든 HTTP 에러를 reject 하는 프로미스를 반환하기 때문에 우리가 원하는 .catch() 메소드로 이동하게 됩니다.
axios또한 Promise를 기반으로 한 HTTP 비동기 통신 라이브러리입니다. 그러나 fetch는 js built-in 라이브러리이지만 axios는 써드파티 라이브러리이기 때문에 따로 설치가 필요하다는 단점이 있습니다. 그러나 에러를 핸들하기 수월하고 문법이 간결하기 때문에 많이 사용되고 있습니다. (특히 리액트 앱 👻 )
.
.
.
이처럼 프로미스는 연속적으로 메소드들을 호출하는 프로미스체이닝(Promise chaining)이라고 하는 then, catch, finally 의 후속처리 메소드를 통해 콜백 지옥을 해결해줍니다. 그러나 각각의 후속처리 메소드 또한 콜백 함수를 인자로 받는 콜백 패턴을 사용하고 있어 아예 사용하지 않는 것은 아닙니다.
그래서 가독성을 더욱 좋게 하기 위해 ES8에서는 비동기처리를 마치 동기처리처럼 결과를 반환하는 async/await을 도입했습니다.
5. async/await과 Promise 메소드
5-1. async/await
async/await를 통해 then/catch/finally 와 같은 후속 처리 메소드로 체이닝 할 필요 없이 마치 동기 처리처럼 프로미스를 사용할 수 있게 되었습니다.
예시코드를 보면,
async function fetchTodo() {
const url = "url/..."
const response = await fetch(url); //return Promise
const todo = await response.json();
console.log(todo);
}
fetchTodo();
이렇게 동기적인 코드처럼 작성할 수 있습니다.
비동기 코드를 담은 async 키워드를 활용한 async 함수(여기서는 fetchTodo())는 언제나 반환값을 resolve 하는 프로미스를 반환합니다. => then을 쓸 수 있습니다.
async function add(){
1 + 1
}
add().then(function(){
console.log('성공');
})
await는 async 함수 내에서만 사용 가능하며 이는 프로미스가 해결될 때 까지 기다리고 있는 키워드로 기억하시면 됩니다. 해당 프로미스가 settled상태 (resolved 또는 rejected)가 될 때까지 대기하다가 settled 상태가 되면 프로미스가 resolve()한 결과를 반환하여 변수에 할당합니다. 위의 예시 코드 같은 경우에는 response 와 todo라는 변수에 resolve() 결과가 할당되겠죠?
이렇듯 await이 '대기하고 있는' 상태이기 때문에 다음 코드를 일시중지하고, 프로미스가 settled 상태가 되면 다시 재개합니다. 이러한 특성은 동기적인, 즉 순서를 보장하는 코드를 작성할 수 있는 것이죠.
5-2. Promise 메소드
그런데 이런 상황이 있다면 어떨까요?
저 display()라는 비동기 함수를 3번 실행하고 싶은데, 사실 3번 실행하는 저 함수는 서로 연관된 것이 아닐수도 있습니다.
예를들어 유저조회하기 -> 유저아이디조회 -> 장바구니 상품 조회
이렇듯 연관되어 서로가 서로를 필요로하는 코드가 아닌 경우도 있을 것입니다.
아래의 비동기 함수 3개를 순차적으로 처리한다면 총 6초가 걸리겠죠?
const request1 = () => {
new Promise(resolve => setTimeout(()=> resolve(1),3000))
}
const request2 = () => {
new Promise(resolve => setTimeout(()=> resolve(2),2000))
}
const request3 = () => {
new Promise(resolve => setTimeout(()=> resolve(3),1000))
}
그런데 사실
상품목록, 상품목록 총 개수 ... 등을 구하는 작업은 동기적으로 실행할 필요가 없습니다. 이들을 한번에 실행하려면 Promise.all을 사용하면 훨씬 단축된 시간을 확인할 수 있습니다. 아래 코드 처럼요.
Promise.all
const request1 = () => {
new Promise(resolve => setTimeout(()=> resolve(1),3000))
}
const request2 = () => {
new Promise(resolve => setTimeout(()=> resolve(2),2000))
}
const request3 = () => {
new Promise(resolve => setTimeout(()=> resolve(3),1000))
}
Promise.all([request1(), request2(), request3()])
.then(console.log) //[1,2,3] 3초 소요!
.catch(console.error)
약 3초만에 모든 코드가 resolve 된 것을 확인할 수 있습니다.
그런데 Promise.all()에는 장점이자 단점이라고 할 수 있는 특징이 있습니다. 바로 인자로 전달된 비동기함수들 중 단 하나라도 먼저 에러가 나면 바로 reject하고 모두 이행취소가 된다는 것입니다. 그래서 이 메소드는 하나라도 reject 당했을 때 시행하던 코드를 멈추고 모두 reject 하고 싶을때 사용하면 되겠죠? 만약 프로그램이 중간에 이행 취소가 된다면 그냥 빈 배열을 반환해버린다던지 등의 에러 핸들링이 중요하게 될 것입니다.
👇
만약 이를 보완하여 Promise 들 중 하나가 실패하더라도 일단은 이행처리를 한 다음, 이후에 어떤게 reject 되었고 어떤게 resolve 되었는지에 대한 그 결과를 저장한 객체를 볼 수 있다면 어떨까요?
Promise.allSettled
바로 ES2020에 추가된 새로운 기술인 Promise.allSettled를 이용하면 됩니다.
settled는 처음 Promise의 상태를 설명할때 나온 단어인데요, 프로미스가 반환하는 resolved 또는 rejected 의 상태가 되면 settled 상태입니다.
이 메소드를 이용하면 배열을 반환하는데, 여기서 모든 비동기 함수들의 처리 결과를 확인할 수 있습니다.
// { status: 'rejected', reason: error reason },
// { status: 'fulfilled', value: 2 },
// { status: 'fulfilled', value: 3 }
.
.
.
.then이라는 구문보다 await이 직관적으로 이해하기 더 쉬워서 자주 사용되는 방식입니다. 아래의 두 코드는 같지만 await 이 동기적으로 보이기 때문에 조금 더 이해하기 쉽죠? 하지만 두 코드 모두 사용해도 됩니다 👍
let result = await promise;
console.log(result);
//--------------------------
promise.then(() => {
console.log("success")
})
await의 에러 핸들링은 try-catch 구문을 사용하면 됩니다. catch 구문을 쓰지 않을 경우 에러가 발생하고 코드 실행을 바로 멈추며 await 하단의 코드는 실행하지 않습니다.
try { let result = await promise }
catch { console.log("error")}
자바스크립트의 비동기 처리 동작 원리에 대해 살펴보았습니다 👻
Reference:
[모던 자바스크립트 Deep Dive, 2022] - 이웅모
https://developer.mozilla.org/ko/docs/Web/API/setTimeout
https://velog.io/@eggplantiny/Promise.allSettled-%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%9C-%EC%88%9C%EA%B0%84
https://code-masterjung.tistory.com/91
https://chanyeong.com/blog/post/44
https://www.youtube.com/watch?v=s3gDWV3x038&list=PLgXGHBqgT2TvpJ_p9L_yZKPifgdBOzdVH&index=15https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise
'Computer Programming > Javascript' 카테고리의 다른 글
WIL - JS ECMAScript(ES)란? ES5와 ES6의 주요 발전 (0) | 2023.06.18 |
---|---|
Browser - 브라우저의 구조와 렌더링 과정 (0) | 2023.06.15 |
JavaScript 숫자야구 - slice(), splice(), filter()의 차이와 문제점 (0) | 2023.06.13 |
자바스크립트 자료구조 Map과 Set Object의 사용 (0) | 2023.06.12 |
자바스크립트의 일급 객체(First-class Function)로서의 함수 (0) | 2023.06.12 |