비동기 프로그래밍
Async Non Blocking Programming
자바스크립트는 Asynchronous Non Blocking 연산을 지원하는 프로그래밍 언어입니다.
Asynchronous Non Blocking 란 특정 코드 블럭을 순차적으로 실행하지 않고 나중에 실행해야 할 코드들은 별도로 처리를 맡기고 먼저 실행해야 하는 코드들을 먼저 실행하는 일의 절차를 의미합니다.
가령 위와 같이 코드가 구성되어 있다고 생각해보겠습니다. 초록색 함수는 실행을 눈치채지 못할 정도로 빠르게 실행됩니다. 하지만 전체적으로 프로그램은 느리게 느껴집니다. 왜냐하면 모든 코드는 순차적으로 실행되어야 하고, 프로그램이 끝까지 실행되기 위해서는 반드시 빨간색 함수의 실행을 마쳐야만 하기 때문입니다. 이와 같이 느리게 처리되는 빨간색 코드 블럭들은 다른 실행을 막는다는 의미로써 Blocking 이라고 부릅니다.
더 정확하게는 Asynchronous 와 Non Blocking 는 분리된 개념입니다.
Non Blocking 은 서브 루틴에게 제어권을 넘기지 않는 방식을 의미하며 Asynchronous 는 서브루틴의 리턴 및 리턴포인트를 메인 스레드와 동기화 시키지 않는 것을 의미합니다.
해당 글에서는 이 차이를 굳이 세세하게 구분하지 않습니다. 만약 세부적인 차이를 알고 싶으시면 blocking non blocking sync async 키워드로 자료를 검색해보는 것을 추천드립니다.
이를 해결하기 위해서는 빨간색 함수들을 해당 실행 순서(flow)에서 분리 시켜야만 합니다.
이제 더 이상 Main flow 의 초록색 함수는 Blocking 의 영향을 받지 않습니다. 왜냐하면 Blocking 을 발생 시키던 빨간색 함수들은 Sub flow 로 분리 되었기 때문입니다. 이와 같은 실행 순서를 허용하는 프로그래밍을 Non Blocking 프로그래밍이라고 부릅니다.
Non Blocking 은 문제를 좋게 해결된 것처럼 보이지만 몇몇 함수를 Sub flow 로 분리하면서 새로운 문제가 발견됩니다. 첫 번째는 ”프로그램의 종료 시점을 알 수 없다” 는 것입니다.
Main flow 만이 존재 했을 때는 당연히 마지막 함수의 실행이 프로그램의 종료 시점입니다.
하지만 Sub flow 가 존재하는 프로그램일 경우에는 빠르게 실행되는 함수로 이루어진 Main flow 가 가장 먼저 처리 될까요? 혹은 Sub flow 가 Main flow 보다 충분히 빨라서 Sub flow 가 먼저 실행을 마칠까요? 두 개의 Sub flow 중 어느 것이 먼저 실행을 마칠까요?
분명한 것은 프로그램의 종료 시점을 아무렇게나 정해버린다면 실행해야 할 함수들이 모두 실행되기 전에 종료 되버릴지도 모른다는 것입니다.
두 번째 문제는 flow 의 실행 순서를 어떻게 결정 하느냐의 문제 입니다. Main flow 만이 존재할 때는 단지 Main flow 를 실행하면 됐지만 여러 flow 들로 나뉘게 되면서 어떤 flow 를 먼저 실행해야 하는지에 대한 결정이 필요하게 됐습니다. 그냥 닥치는대로 flow를 실행 하다가는 기껏 분리한 빨간색 함수를 우선적으로 처리하여 Blocking 되는 것과 다름 없는 동작을 다시 만날지도 모르는 일이니까요.
정리하자면 Non Blocking 은 프로그램이 종료되는 시점을 결정하고 flow 의 실행 순서를 결정하는 기능이 필요하게 됩니다.
첫 번째 문제를 해결하기 위해서 가장 중요한 것은 Sub flow 가 실행을 모두 마치기 전까지 Main flow 가 실행해야할 코드들을 모두 마친 후에 대기 상태로 기다리고 있어야 한다는 점입니다. Main flow 가 종료된다는 것은 즉 프로그램이 종료된다는 것을 의미하기 때문입니다.
때문에 위 그림에서 보듯이 Main flow 에는 Sub flow 실행 중에 프로그램이 종료되지 않도록 대기하는 코드가 존재합니다. 보통 우리가 프로그램을 무한정 대기 시키기 위해서는 무한히 루프하는 방식으로 대기 합니다.
while(true) {
// 계속해서 반복하며 무한대로 대기 중
// ...waiting
// 반복 중에 시스템이 종료 요청이 발생하면 프로그램을 종료
if(SYSTEM.EXIT) {
process.exit();
}
}
JavaScript
복사
두 번째 문제는 기능 대신 규칙을 추가함으로써 문제를 해결할 수 있습니다. 일반적으로 Main flow 혹은 Main Thread 는 핵심적인 기능을 포함할 뿐만 아니라, 운영체제가 직접 관여하는 영역이기 때문에 많은 권한을 부여 받습니다. 때문에 Main flow 를 가장 먼저 실행 하고 나머지 Sub flow 는 시스템에 필요한 규칙 순서대로 실행되게 됩니다. 다만 Sub flow 는 본인이 실행해야 할 코드를 마치면 Main flow 가 이후 작업을 처리하기 위해 종료 되었음을 알려야 합니다. 이렇게 실행 완료를 알리는 것을 이벤트를 발생 시킨다고 표현합니다.
while(true) {
// 계속해서 반복하며 무한대로 대기 중
// ...waiting
Subflow.이벤트처리("SUB FLOW END", (result)=>{
// Sub flow 이후 처리
})
}
Subflow {
// ... 코드 실행 중
result = "RESULT";
Subflow.이벤트발생("SUB FLOW END", result)
}
C++
복사
지금까지 Non-Blocking 을 구현하기 위한 두 가지 문제 해결 방법을 정리해보겠습니다.
1.
Main flow 는 모든 flow 가 실행을 마치기 전까지 프로그램이 종료되지 않게 무한히 반복(Loop)하며 대기합니다.
2.
각각의 sub flow 들은 Main flow 가 실행을 마친 후에 실행 됩니다. 그리고 각각의 Sub flow 는 이벤트를 발생시켜 Sub flow 의 실행 결과를 Main flow 에 반환합니다. 이후에 Main flow 는 flow 종료에 대한 처리를 합니다.
Javascript 내에서는 이러한 해결방법을 통틀어 이벤트 루프(Event Loop) 라고 부릅니다.
이벤트 루프(Event Loop) 활용하기
모든 프로그램들은 유저가 멈춤 현상 즉 “랙 걸린다.” 를 느끼지 않도록 최선을 다해야 합니다. 시스템이 멈추는 이유는 다양하지만 가장 본질적인 이유는 한정된 자원을 가지고 많은 연산을 하기 때문이죠. 이 문제를 해결하기 위해서는 연산을 위한 자원을 늘리거나 “랙을 느끼지 않도록” 어플리케이션을 설계 할 수 있습니다.
우리는 오래 걸리는 연산들을 Sub flow 로 분리하였습니다. 그리고 NodeJS 와 같은 프레임워크는 Thread Pool 이라고 불리는 무거운 연산을 별도로 처리하기 위한 자원을 확보해두었기 때문에 Sub flow 를 아예 이러한 추가적인 자원에 대신 처리하게 함으로써 더 빠른 연산이 가능합니다. 하지만 Browser 를 비롯한 다른 환경에서는 모두 이러한 아키텍처로 구성되어 있지는 않습니다. 때문에 이번 글에서는 NodeJS의 이벤트 루프에 대해서는 설명하지 않습니다. 즉, 무거운 연산을 위한 자원을 확보 보다 “랙을 느끼지 않도록” 어플리케이션을 설계하는 해결 방법에 초점을 둡니다.
< 브라우저 이벤트 루프 아키텍처 >
< NodeJS 이벤트 루프 아키텍처 >
NodeJS와 달리 Web Browser 는 연산을 대신 처리해 줄 THREAD POOL 과 같은 자원이 따로 존재 하지 않습니다. 대신 연산을 Queue에 담기 위해 제공하는 WEP API 만 존재합니다.
“랙을 느끼지 않도록 어플리케이션을 설계” 한다는 말이 썩 좋은 문제해결방식은 아닌 것처럼 보이기도 합니다. 하지만 조금 생각해보면 브라우저의 탄생에 꼭 필요한 일이었을 것입니다.
순서대로 실행되지 않는 코드
MDN의 비동기 예제에서 이를 잘 설명합니다. 만약 브라우저 내에 이미지를 가져와야 한다고 상상 해봅시다. 서버에서 이미지를 가져오면 네트워크 환경, 다운로드 속도 등의 영향을 받아 이미지를 즉시 확인할 수 없습니다. 이는 브라우저를 대기 상태로 만들게 됩니다.
이미지와 관련된 요청은 아주 작은 양인데 비해 그 외 남은 작업 처리는 많은 양이 있을 수 있습니다. 하지만 이미지를 서버에서 가져와야 다음 코드를 실행할 수 있기 때문에 남은 작업 처리는 후순위로 밀려날 수 밖에 없는 것이죠. 그리고 이러한 것들은 유저가 볼 수 있는 화면의 양을 제한합니다. 이상적인 상황이라면 아래와 같을 겁니다.
코드로 표현하자면 다음과 같을 것 겁니다.
doucment.querySelector("#navigation").innerHTML = `<div>Nav</div>`
doucment.querySelector("#body").innerHTML = `<div><Body/div>`
doucment.querySelector("#footer").innerHTML = `<div>Footer</div>`
// 이상적인 상황 처리를 위해 해당 코드는 항상 아래에 존재해야 한다.
const image = requestImage("https://example.com/image/1");
imageProcess(image);
JavaScript
복사
하지만 실제로는 웹 개발을 하는 동안 코드 순서에 대해 신경 쓸 필요가 없습니다. 이것이 비동기 프로그래밍의 역할 중 하나 입니다.
코드 블럭이 순차적으로 실행되지 않는다는 말은 무엇을 뜻하는지 조금 생각해보면 “실행 순서대로 코드 블럭을 작성할 필요가 없다.” 라고도 생각해 볼 수 있습니다.
비동기는 순서 대신 콜백(callback)이라는 언어의 스타일을 통해 이 문제를 해결합니다.
// 1. 비동기적으로 실행하고픈 Sub flow 코드를 만나면 잠시 다른 곳에 보관해둔다.
requestImage("https://example.com/image/1")
.then(function(image) {
// 3. 동기적인 동작을 마치면 보관한 곳에서 비동기 함수를 실행하고 그 결과를 보관해둔다.
// 그 이후에 callback함수 호출을 요청하면 보관된 결과를 callback함수의 인자에 담아 실행한다.
imageProcess(image);
})
// 2. 비동기적으로 실행하지 않을 코드들을 먼저 실행한다.
doucment.querySelector("#navigation").innerHTML = `<div>Nav</div>`
doucment.querySelector("#body").innerHTML = `<div><Body/div>`
doucment.querySelector("#footer").innerHTML = `<div>Footer</div>`
JavaScript
복사
callback 이라는 디자인은 위 코드 처럼 내부 함수의 인자로 callback 이라고 불리는 함수를 넘깁니다. 이 함수는 내부 함수의 컨텍스트를 기반으로 실행되게 됩니다.
아래에서 Promise를 만들며 callback을 직접 사용해볼 예정이니, 지금 당장 이해하진 않아도 좋습니다.
그런데 왜 callback 과 같이 함수를 넘겨주는 방식이 된 것 일까요? 순서대로 실행되지 않는다는 것은 코드가 아래로 실행 중에 특정 위치로 올라가야 합니다. 즉 Main flow 의 실행 순서와는 별개로 실행되어야 할 Sub flow 가 결과를 Main flow 에 반환하기 위해서 독립된 실행 순서를 가지기 위해 함수를 넘겨주는 디자인이 되는 것은 자연스러운 결과 라고도 생각해 볼 수 있습니다.
잘게 쪼개기
코드를 우선시 되어야할 작업 순서대로 실행 시킬 수 있다는 점은 물론 유용하지만 브라우저는 아주 많은 상호작용을 매 순간 발생 시킵니다. 그리고 브라우저는 하나의 자원 만을 가지고 있기 때문에 하나의 작업이 느리다면 여전히 Blocking 과 같은 상황이 발생하게 됩니다.
위 다이어그램처럼 실행 순서를 가질 때, 2 가 종료되기 전까지는 마우스 호버, 마우스 클릭 등의 이벤트에 전혀 반응할 수 없게 됩니다.
다음 코드를 브라우저 콘솔에 실행하면 이를 확인 할 수 있습니다.
const slowFunction = () => {
let result = 0;
for(let i=0; i<5_000_000_000; i++) {
result++;
}
return result;
}
document.querySelector("div").innerHTML = slowFunction();
JavaScript
복사
이는 단순히 비동기 API를 사용하여 작업을 뒤로 미룬다고 해결될 문제는 아닙니다. 순서가 어찌됐든 slowFunction 이 자원을 독점하는 순간은 오기 마련이기 때문입니다.
WEP API 인 setTimeout 을 이용하여 slowFunction 을 비동기로 변경하고 브라우저에서 실행해도 여전히 화면은 얼어버림을 확인 할 수 있습니다.
const slowFunction = () => {
let result = 0;
for(let i=0; i<5_000_000_000; i++) {
result++ i;
}
return result;
}
setTimeout(()=> {
document.querySelector("div").innerHTML = slowFunction();
}, 0)
JavaScript
복사
이는 꽤 고전적인 문제입니다. 많은 프로그램에서 이와 같은 이슈가 있었고 해결하기 위한 몇 가지 방법이 존재합니다. 가장 대표적으로는 큰 작업을 잘게 나눔으로써 이 문제를 해결 할 수 있습니다.
위의 2번 코드 내의 50억번의 for 문을 한 번에 계산 하는 대신에 충분히 작은 정도 ( 약 10만개 )로 나누어서 실행하면 위의 다이어그램과 같은 순서로 실행됩니다.
작게 코드를 나누고 이를 비동기 흐름으로 처리하면 실행되는 비동기 사이사이마다 중요한 이벤트(마우스 호버, 마우스 클릭 등) 혹은 Main Flow 코드가 실행될 수 있는 틈이 생기게 됩니다. 이러한 틈마다 동기 코드, 비동기 코드등이 정해진 순서대로 실행 되게 됩니다.
아래 코드를 브라우저 콘솔에서 실행하게 되면 브라우저가 멈추지 않은 채로 결과를 연산한 후 반환 합니다.
const UNIT = 10_000_000;
let result = 0;
const slowFunction = () => {
do {
result++;
}while(result % UNIT != 0);
if(result < 1_000_000_000) {
setTimeout(slowFunction,0);
} else {
document.querySelector("div").innerHTML = result;
}
}
slowFunction();
JavaScript
복사
방금 말씀드렸다시피 이는 고전적인 문제이므로 많은 시스템에서 이 방법을 활용하고 있습니다.
대표적으로 네트워크의 패킷 처리가 이러합니다. 네트워크로 너무 큰 요청을 보내게 되면 해당 경로가 Blocking 되게 됩니다. 이는 다른 요청이 해당 경로로 네트워크 전송이 불가능하다는 의미이고 이러한 경로가 많아지게 되면 네트워크 통신이 불가능한 지경까지 이를 수 있습니다.
대신 패킷이라는 아주 작은 단위로 나뉘어 전송하게 되면 Blocking 을 예방 할 수 있게 되고 결국 다양한 요청들이 각각 가장 빠른 경로를 Blocking 이라는 제약 없이 사용할 수 있게 됩니다.
또한 리액트에서 가상돔이라고 불리는 트리의 변화를 비교할 때 역시 많은 연산이 필요하게 되므로 이 같은 작업을 잘게 나눈 후 requestAnimationFrame 와 같은 API를 이용하여 처리합니다. https://github.com/facebook/react/issues/11171
Promise 작성하기
지금까지 비동기 동작에 대해서 살펴보았습니다. 하지만 비동기 코드는 몇 가지 아쉬운 점을 가지고 있습니다. 첫 번째로는 비동기 처리를 마친 이후에 결과에 대한 추가적인 처리를 항상 비동기 코드와 함께 작성 되어야 하는 점입니다.
fetch("https://localhost:3000/list").then((list)=> {
//...결과 리스트
fetch("https://localhost:3000/" + id).then((obj)=> {
//... 결과
fetch("https://localhost:3000/"+ id + "image").then((image)=> {
//결과 이미지
setImage(image)
}).catch((e)=> {
throw new SomethingError(e)
}).finally(()=> {
if(image) {
...
}
})
}).catch((e)=> {
throw new FetchError({status: 500, ...})
})
}).finally(()=>{
console.log("fetch done");
})
JavaScript
복사
이와 같이 중첩된 비동기 처리등은 코드의 가독성을 나쁘게 만들기 쉽습니다. 또 하나 아쉬운 점은 콜백 스타일은 우리가 사용할 코드의 제어권을 마음대로 할 수 없다는 점입니다. 두 가지 라이브러리가 있다고 가정해보겠습니다.
const library1 = (callback) => {
const value = "value"
callback(value) // 콜백 함수가 언제, 어떻게 실행되는지 알려면 library1를 뜯어봐야 함.
}
const library2 = () => {
return "value" // 해당 library는 사용자와 사용할 코드와 독립적임
}
library1((v)=> console.log(v)); // 사용자가 실행할 callback의 제어권을 library가 가짐
const v = library2()
console.log(v) // 사용자가 실행할 코드의 제어권을 사용자 본인이 가짐
JavaScript
복사
둘 다 console.log 로 결과를 출력하는 결과를 기대합니다. 하지만 library1 과 같은 경우에는 우리가 실행할 코드를 library 에 전달하여 library 의 정책에 따라 내부에서 실행됩니다. 반대로 library2 는 결과를 받아 우리의 코드에 사용하는 방식입니다. 이와 같이 library2 와 같은 방법은 제어권을 현재 실행중인 컨텍스트가 직접 가지고 있으므로 callback 을 사용하지 않는 방식이 더욱 신뢰성 있는 코드를 작성할 수 있습니다.
Promise 는 이러한 코드 스타일을 탈피하기 위해 만들어졌습니다. 실행 및 결과를 특정 장소에서 실행 해두고 그 결과를 보관해두어 비동기 코드와 떨어진 영역에서도 자유롭게 사용할 수 있게 말입니다.
Promise 의 스펙을 모두 설명드리기에는 내용이 내용이 많기 때문에 만약 Promise를 처음 접한다면 https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise 및 다른 아티클에서 간단한 사용법을 익히는 것을 추천드립니다.
Promise는 프로미스가 생성된 시점에는 알려지지 않았을 수도 있는 값을 위한 대리자로, 비동기 연산이 종료된 이후에 결과 값과 실패 사유를 처리하기 위한 처리기를 연결할 수 있습니다. 프로미스를 사용하면 비동기 메서드에서 마치 동기 메서드처럼 값을 반환할 수 있습니다. 다만 최종 결과를 반환하는 것이 아니고, 미래의 어떤 시점에 결과를 제공하겠다는 '약속'(프로미스)을 반환합니다.
위의 정의를 바탕으로 우리가 만들어야 하는 스펙은 다음과 같이 정리 해볼 수 있을 겁니다.
1.
결과 값과 실패 사유를 처리하기 처리기에 연결된 값을 반환한다.
2.
이 값은 최종 결과가 아닌 미래에 어떤 시점에서 결과를 제공하겠다는 ‘약속’을 반환 한다.
이 중 결과 값을 반환하는 데에 먼저 집중해 봅시다.
Then() 과 Resolve() 구현
위 조건을 바탕으로 Promise 구현체와 동일한 간단한 테스트 코드를 작성 할 수 있습니다.
it(`Promise는 callback을 실행 시키고 결과를 resolve에 담는다.
그리고 then호출 시에 callback 인자로 value를 전달한다.`, () => {
let isRunning = false;
const result = new MyPromise((resolve) => {
resolve(DEFAULT_VALUE)
})
result.then(v => {
isRunning = true;
expect(v).toEqual(DEFAULT_VALUE)
})
expect(isRunning).toBeTruthy();
})
JavaScript
복사
결과 값을 제공하기 위한 Promise 객체를 반환합니다. 이 객체는 최종 결과가 결정되면 결과 값을 처리하기 위한 처리기를 반환합니다.
위 인터페이스에 맞춰 클래스를 구현해봅시다.
class MyPromise {
#value
constructor(callback) {
callback(this.#resolve)
}
#resolve(value) {
this.#value = value
}
then(callback) {
callback(this.#value)
}
}
JavaScript
복사
생성자 함수(constructor) 는 callback 를 전달 받습니다. 그리고 함수가 실행된 이후에 결과로 반환될 값을 전달할 resolve 메서드를 전달합니다.
resolve 메서드는 Promise 내부의 결과값을 저장해 둡니다.
그리고 저장된 값은 추후에 then 의 callback 의 결과 값으로 전달되게 됩니다.
하지만 위 코드는 TypeError: Cannot set properties of undefined (setting '#value') 과 같은 에러를 발생시킵니다. #resolve 함수 내용인 this.#value 에서의 this 가 undefined 라는 것이죠.
이러한 이유는 this 가 메소드의 호출 시에 결정되기 때문입니다.
class Person {
constructor(name){
this.name = name
}
greet(){
console.log(`Hello I'm ${this.name}`);
}
}
const pedal = new Person("Pedal");
pedal.greet(); // Hello I'm Pedal
const pedalGreet = pedal.greet;
pedalGreet(); // [Error] this는 undefined
JavaScript
복사
위 코드에서 pedal.greet() 처럼 인스턴스에 직접 참조되어진 상태로 메소드를 실행하게 되면 참조되어진 객체가 this 에 연결됩니다. 이를 this 에 바인딩(binding) 된다고도 표현 합니다.
하지만 greet 함수를 다른 변수에 저장해두고 실행하게 되면 참조할 수 있는 객체가 사라지게 됩니다. 이 때문에 this 는 undefined 가 되고 에러가 발생하게 됩니다.
이러한 경우에는 직접 this 를 바인딩 함으로써 문제를 해결 할 수 있습니다.
const pedal = new Person("Pedal");
pedal.greet(); // Hello I'm Pedal
const pedalGreet = pedal.greet;
pedalGreet.bind(pedal)(); // pedal을 this 참조 값으로 바인딩 함
JavaScript
복사
Promise 역시 이와 마찬가지로 callback 에 넘겨주는 모든 내부의 메소드들을 바인딩 시켜줌으로써 문제를 해결해보겠습니다.
class MyPromise {
#value
#resolveBind = this.#resolve.bind(this)
constructor(callback) {
callback(this.#resolveBind)
}
#resolve(value) {
this.#value = value
}
then(callback) {
callback(this.#value)
}
}
new MyPromise((resolve)=> {
resolve(1)
}).then((v)=> console.log(v)); // 1
JavaScript
복사
resolve 의 지연 및 then의 중복 처리
위 테스트에서는 resolve 를 곧바로 호출하여서 문제 없지만 다음과 같은 코드를 만났다고 가정해보겠습니다.
new MyPromise((resolve)=> {
setTimeout(()=> {
resolve(1)
}, 1000)
}).then((v)=> console.log(v)); //undefined
JavaScript
복사
resolve는 1초 뒤에 실행되기 때문에 Promise 내부의 값도 1초 뒤에 할당 될 것입니다.
때문에 then이 호출 된 시점은 아직 값이 할당되기 이전이므로 값은 undefined 일 것입니다.
이와 같은 이유로 호출 순서를 바꿔야만 합니다. then 을 호출 했을 때 callback이 곧바로 실행되는 것이 아니라 resolve 가 호출 될 때 then 의 callback 이 실행되도록 순서를 바꿔보겠습니다.
이를 위해선 resolve 가 호출되기 전까지 then 으로 호출 된 callback 의 생애주기를 유지해야 하는데, 함수만 가지고는 함수가 끝나버리는 순간 모든 값의 생애주기가 끝나므로 적절한 컨테이너를 활용하도록 합니다.
코드로 살펴봅시다.
class MyPromise {
#state = STATE.PENDING
#callbacks = new Queue()
// ... 코드 생략
#runCallbacks() {
if(this.#state === STATE.FULFILLED) {
while(!this.#callbacks.isEmpty()) {
this.#callbacks.top()(this.#value);
this.#callbacks.pop();
}
}
}
#resolve(value) {
this.#state = STATE.FULFILLED
this.#value = value
this.#runCallbacks();
}
then(callback) {
this.#callbacks.push(callback);
if(this.#state === STATE.FULFILLED) this.#runCallbacks();
}
}
JavaScript
복사
큰 그림을 살펴 보면
then 이 호출 되면 callback 을 곧바로 처리하지 않고 Queue 에 저장합니다. 그리고 저장된 callback 함수들은 두 가지 조건에서 요청된 순서대로 실행됩니다.
1.
then 이 호출 될 때 이미 resolve 가 처리 되었다면 callback 함수를 실행합니다.
2.
resolve 함수가 실행 될 때, 실행 해야 할 callback 함수가 있다면 실행합니다.
이제 조금 더 세부적으로 살펴보겠습니다.
Promise의 상태
Promise 는 세 가지 상태를 가지고 있습니다.
각각
•
대기(pending): 이행하지도, 거부하지도 않은 초기 상태.
•
이행(fulfilled): 연산이 성공적으로 완료됨.
•
거부(rejected): 연산이 실패함.
과 같습니다.
그리고 then에서 resolve 콜백을 실행하기 위해서는 fulfilled 상태 즉 연산이 성공적으로 완료된 상태 에서만 콜백을 반환해야 합니다.
#runCallbacks() {
if(this.#state === STATE.FULFILLED) {
// ...callback들을 처리
}
}
JavaScript
복사
또한 이미 해결된 resolve 인 경우에는 then 에서 바로 callback 함수를 해소해주어야 하기 때문에 다음과 같은 코드를 추가합니다.
then(callback) {
// ... 생략
if(this.#state === STATE.FULFILLED) this.#runCallbacks();
}
JavaScript
복사
Queue를 사용하는 하는 이유
callback 들을 담을 컨테이너로 Queue 를 구현해서 사용하고 있습니다. 하지만 Javascript의 배열은 이미 충분히 풍부한 기능을 제공합니다.
그럼에도 불구하고 Queue 를 구현하는 이유는 오히려 배열을 사용하는 것은 불필요하게 많은 제어를 개발자에게 허용하기 때문입니다. 반대로 컨테이너를 직접 제공함으로써 접근자를 통제하고 이를 통해 의도를 명확하게 할 수 있습니다.
예를 들어 #callbacks 을 배열로 선언했다고 가정해보겠습니다.
#callbacks = [];
method() {
// 아래와 같이 "순서대로" 라는 요청 외에 다양한 위치와 방향으로 접근 가능하다.
this.#callbacks[3]
this.#callbacks.unshift(1)
this.#callbacks.splice(0)
}
JavaScript
복사
#callbacks 을 배열로 선언해서는 안되는 이유는 method 내부와 같이 우리가 정의한 “순서대로 처리한다.” 외에 다른 접근을 허용하기 때문입니다.
물론 처음 개발에는 map 혹은 forEach 와 같은 메소드를 통해 순서대로 처리 하겠지만 여러 개발자들이 유지보수하면서 이 규칙은 언제라도 깨질 수 있는 가능성을 제공하게 됩니다.
대신Queue 를 활용함으로써 설계된 정의를 풍부한 의도를 담아 코드로 표현할 수 있게 됩니다.
Promise와 MicroTask
const func = () => {
new Promise((resolve, reject)=>{
resolve()
}).then(()=>alert("Promise 실행"));
alert("End");
};
func();
JavaScript
복사
위의 Promise 내의 callback 은 resolve() 호출에 있어 즉각적으로 실행합니다. 하지만 브라우저에서 해당 코드를 실행하면 “End” → “Promise 실행” 순으로 alert가 호출되는 것을 확인 할 수 있습니다.
Promise 가 이러한 동작을 하는 것은 Promise 의 핸들러가 마이크로태스크(Microtask) 내에서 실행되기 때문입니다. queueMicrotask 와 같은 API를 활용하면 실행해야할 함수를 microtask queue 내에 담아두고 이벤트 루프의 라이프사이클에 따라 실행되게 됩니다.
#resolve(value) { // Promise의 핸들러들의 실행을 Microtask queue에 담아 실행한다.
queueMicrotask(()=> {
this.#state = STATE.FULFILLED
this.#value = value
this.#runCallbacks();
})
}
JavaScript
복사
마이크로태스크(Microtask)
이벤트 루프는 비동기 처리를 위한 QUEUE 를 두 가지 종류로 보관하는데 MICROTASK QUEUE 는 process.nextTick(), Promise callback, async functions 등에 보관됩니다.
나머지 setTimeout, setInterval , I/O, UI rendering와 같은 API 들은 MACROTASK QUEUE에 보관되어 소비됩니다.
그리고 보관된 MICROTASK QUEUE 는 MACROTASK QUEUE 실행 이전에 소비됩니다.
1. *매크로태스크* 큐에서 가장 오래된 태스크를 꺼내 실행합니다(예: 스크립트를 실행).
2. 모든 *마이크로태스크*를 실행합니다.
- 이 작업은 마이크로태스크 큐가 빌 때까지 이어지고
- 태스크는 오래된 순서대로 처리됩니다.
3. 렌더링할 것이 있으면 처리합니다.
4. 매크로태스크 큐가 비어있으면 새로운 매크로태스크가 나타날 때까지 기다립니다.
5. 1번으로 돌아갑니다.
ABAP
복사
MACROTASK QUEUE 와 MICROTASK QUEUE 를 구분하는 이유는 마우스 좌표 변경이나 네트워크 통신에 의한 데이터 변경 같이 애플리케이션 환경에 변화를 주는 작업에 영향을 받지 않고 모든 마이크로태스크를 동일한 환경에서 처리할 수 있기 때문입니다.
마이크로태스크 전체가 처리되는 동안에는 UI 변화나 네트워크 이벤트 핸들링이 일어나지 않습니다. 렌더링이나 네트워크 요청 등의 작업들은 마이크로태스크 전부가 처리되고 난 직후 처리됩니다.
이런 처리 순서 덕분에 queueMicrotask를 사용해 함수를 비동기적으로 처리할 때 애플리케이션 상태의 일관성이 보장됩니다.
Promise 가 마이크로태스크에 포함되는 이유 역시 UI의 변경등으로 본래 기능이 일관성을 잃지 않으며 실행을 마쳐야 하기 때문입니다.
Chaining
Promise 인스턴스의 then 함수는 계속해서 이어가며 호출 할 수 있습니다. 가령
new Promise((resolve)=>{
resolve(1)
}).then((v)=>v).then((v)=>v+1).then((v)=>v+1) 각각 // 1 , 2 , 3
JavaScript
복사
와 같은 호출이 가능합니다. 이를 Chaining 이라고 부릅니다.
Chaining 은 메서드의 리턴 값으로 메서드를 포함한 인터페이스를 사용합니다.
대표적인 Chaining 으로는 iterator 메서드가 있습니다.
[1,2,3].map((v)=>v * 2).filter((v)=> v < 5)
JavaScript
복사
MyPromise 의 then 에 Chaining을 적용하기 위해서는 마찬가지로 Promise 인터페이스를 리턴 할 필요가 있습니다.
then(callback) {
return new MyPromise((resolve, reject)=> {
this.#callbacks.push(callback);
if(this.#state === STATE.FULFILLED) this.#runCallbacks();
})
}
JavaScript
복사
하지만 아직 인터페이스만 맞췄을 뿐이지 새로 체이닝할 Promise 를 새롭게 정의해 줄 필요가 있습니다. 이를 위해서는 then 에서 리턴할 Promise 가 미래에 가질 결과 값이 무엇인지 고민해볼 필요가 있습니다.
then 에서 체이닝Promise 은 fulfill 상태에서 resolve 의 값으로 이전 callback 에서 리턴한 결과 값을 가져야 합니다. 즉 thenCallback(value) 를 resolve 의 인자로 넘겨주어서 #value 를 세팅하게 됩니다. 그리고 이러한 실행 역시 callback queue 에 담아 실행합니다.
then(callback) {
return new MyPromise((resolve, reject)=> {
this.#callbacks.push((value)=> {
resolve(callback(value));
});
})
}
JavaScript
복사
즉, then의 callback을 다음 Promise 에게 위임하는 코드가 됩니다.
재귀적인 성질을 가진 코드를 작성할 때는 전체 흐름을 이해하려고 하기 보다는 작은 부분으로 나누어서 각각의 부분이 제대로 동작하는 지 계획을 세우는 것이 이해를 도울 수 있습니다.
알고리즘 전략 중에서도 분할 정복법등이 이러한 접근 방법입니다.
지금까지의 코드를 정리하면 다음과 같습니다.
Queue
MyPromise
MyPromise Test
비동기 실패 Reject와 Catch 구현
Promise 에는 대기(Pending) 와 이행(Fulfill) 이외에도 비동기 처리 시 실패를 의미하는 reject 상태와 reject 값을 소비하기 위한 catch 메서드가 존재합니다.
const promise = new Promise((resolve, reject) => {
// ... 코드
if(true) reject("ERROR")
})
// catch 소비
promise.catch((v)=>expect(v).toBe("ERROR")));
JavaScript
복사
이와 같이 MyPromise 의 인터페이스를 수정 해봅시다.
class MyPromise {
// ... 코드 생략
#rejectBind = this.#reject.bind(this)
#catchCallbacks = new Queue();
constructor(callback) {
callback(this.#resolveBind, this.#rejectBind)
}
#runCallbacks() {
// ... 생략
}
#reject(value) {
}
then(callback) {
}
catch(catchCallback) {
}
}
JavaScript
복사
먼저 catch 메서드를 살펴보겠습니다.
명세서 와 같이 obj.catch(onRejected) 와 같은 인터페이스를 가지는 데, 내부는 obj.then(undefined, onRejected) 를 호출하는 것과 같습니다.
때문에 then 과 catch 가 다음과 같이 변경됩니다.
then(thenCallback, catchCallback) {
return new MyPromise((resolve, reject) => {
// thenCallbacks CODE
this.#catchCallbacks.push((result)=> {
try{
resolve(catchCallback(result));
}catch(error) {
reject(error)
}
})
this.#runCallbacks();
})
}
catch(catchCallback) {
return this.then(undefined, catchCallback);
}
JavaScript
복사
catchCallback 역시 마찬가지로 Queue 적재합니다. 코드 전반적으로 바뀐 부분은 resolve 내에서 에러가 발생할 때 역시 reject 가 되어야 하므로 try-catch 로 모든 callback 호출 부분을 감쌉니다.
이와 같은 이유로 생성자의 callback 마찬가지로 try-catch 를 감쌉니다.
constructor(callback) {
try{
callback(this.#resolveBind, this.#rejectBind)
}catch(error) {
this.#reject(error);
}
}
JavaScript
복사
다음으로 보관된 catchCallback 를 소비하는 코드를 작성해보겠습니다.
#runCallbacks() {
// ... FULFILL CODE
if(this.#state === STATE.REJECTED) {
}
else if(this.#state === STATE.REJECTED) {
while(!this.#catchCallbacks.isEmpty()) {
this.#catchCallbacks.top()(this.#value);
this.#catchCallbacks.pop();
}
}
}
JavaScript
복사
#runCallbacks 에서 성공과 실패 모두 소비되기 때문에 상태에 따른 분기 코드가 필요하다는 것을 확인 할 수 있습니다.
#reject 메서드는 #resolve 와 마찬가지로 상태를 변경하고 값을 변경합니다.
#reject(value) {
queueMicrotask(()=> {
this.#state = STATE.REJECTED
this.#value = value
this.#runCallbacks();
})
}
JavaScript
복사
Finally 구현
명세에서 확인 할 수 있듯이 finally 메서드는 callback 인자를 받아 실행합니다. 이 때 실행되는 시점은 Promise 의 값이 결정된(settled)이후에 실행 됩니다. finally 의 반환 값은 then ,catch 와 마찬가지로 Promise 입니다.
function checkMail() {
return new Promise((resolve, reject) => {
if (Math.random() > 0.5) {
resolve('Mail has arrived');
} else {
reject(new Error('Failed to arrive'));
}
});
}
checkMail()
.then((mail) => {
console.log(mail);
})
.catch((err) => {
console.error(err);
})
// Promise가 settled(fulfill 혹은 rejected)되면 then, catch중 어떤 것이든
// 실행된 이후에 이후에 실행된다.
.finally(() => {
console.log('Experiment completed');
})
// finally 의 리턴 값도 Promise 라서 Chainning 메서드 사용 가능.
.then(()=>console.log("next chain"));
JavaScript
복사
finally 역시 catch 와 마찬가지로 then 에게 처리를 위임해서 해결할 수 있습니다.
이 때, finally로 넘어온 callback 함수는 settled 된 시점이기 때문에 then 혹은 catch callback이 소비되는 시점으로 합의하여 처리할 수 있습니다.
finally(cb) {
return this.then((result)=>{
cb();
return result;
}, (result)=>{
cb();
throw result;
})
}
JavaScript
복사
catch 와 finally , reject 등의 기능이 추가된 MyPromise 코드는 다음과 같습니다.
MyPromise
MyPromiseTest
Static 메서드 구현
Promise 는 인스턴스 메서드 외에도 Static 메서드가 존재합니다.
Promise.resolve & Promise.reject
이 중 resolve 와 reject는 다른 메서드와 비교해서 굉장히 단순합니다. 이들은 단지 주어진 값으로 이행하는 Promise.then 객체를 반환합니다. 즉 value 를 결과로 가지는 Promise 를 상태에 맞게 각각 반환해주면 됩니다.
Promise.resolve(1).then(v => expect(v).toEqual(1))
Promise.reject(1).catch(v => expect(v).toEqual(1))
JavaScript
복사
스펙에 맞춰 코드를 구현하면 다음과 같습니다.
static resolve(value) {
return new MyPromise((resolve)=> {
resolve(value)
})
}
static reject(value) {
return new MyPromise((_, reject)=> {
reject(value)
})
}
JavaScript
복사
Promise.all
다음으로 살펴볼 함수는 Promise.all 입니다. Promise.all() 메서드는 순회 가능한 객체에 주어진 모든 프로미스가 이행한 후, 혹은 프로미스가 주어지지 않았을 때 이행하는 Promise를 반환합니다. 주어진 프로미스 중 하나가 거부하는 경우, 첫 번째로 거절한 프로미스의 이유를 사용해 자신도 거부합니다.
예시를 살펴보면 쉽게 이해할 수 있습니다.
const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'foo');
});
Promise.all([promise1, promise2, promise3]).then((values) => {
expect(values).toBe([3,42,'foo']);
});
JavaScript
복사
보시다시피 인자로 Promise 가 담긴 배열을 받고 then 에 결과들을 호출하게 됩니다. 이 때 하나라도 reject 가 있다면 첫 번째 reject 이유를 Promise 의 결과로 반환합니다.
스펙에 맞춰 코드를 구현하면 다음과 같습니다.
static all(promises) {
const results = [];
let settledPromiseCount = 0;
return new MyPromise((resolve, reject) => {
for(let i =0; i<promises.length; i++) {
const promise = promises[i];
promise.then((v)=> {
results[i] = v;
settledPromiseCount++;
if(settledPromiseCount == promises.length) {
resolve(results);
}
}).catch(reject)
}
})
}
JavaScript
복사
all 을 구현하기 위해서는 모든 함수가 이행된 상태여야 하기 때문에 이행된 프라미스를 세기 위한 settledPromiseCount 와 각각의 결과를 담을 results 컨테이너가 필요합니다.
그리고 반환하는 MyPromise 의 콜백 내에서 인자로 전달받은 promises 를 모두 실행하며 settledPromiseCount 갯수를 셉니다. 만약 모든 Promise 가 이행상태라면 resolve 를 반환합니다.
만약 Promise 중에 catch 가 발생할 경우에 해당 Promise의 reject이유를 그대로 반환해야 할 Promise 의 reject 로 전달합니다.
Promise.allSettled
Promise.allSettled()메서드는 주어진 모든 프로미스를 이행하거나 거부한 후, 각 프로미스에 대한 결과를 나타내는 객체 배열을 반환합니다. 해당 객체 배열은 status: 상태, value: 결과 와 같은 인터페이스를 가집니다.
아래 예시에서 다음과 같은 결과를 기대합니다.
const promise1 = new Promise((resolve)=> resolve(1));
const promise2 = new Promise((_, reject)=> reject(1));
Promise.allSettled([promise1, promise2]).then(v =>
expect(v).toEqual([
{ status: "fulfilled", value: 1 },
{ status: "rejected", value: 1 },
])
)
JavaScript
복사
Promise.all 과 동작 방법은 유사하나, 각각의 Promise의 reject 상황에서 실패를 발생하더라도 Promise 의 상태와 값을 객체에 결과와 값을 담습니다. 즉 allSettled 에서는 메인 Promise 가 reject 인 경우는 없습니다.
스펙에 맞춰 코드를 구현하면 다음과 같습니다.
static allSettled(promises) {
const results = [];
let settledPromiseCount = 0;
return new MyPromise((resolve) => {
for(let i =0; i<promises.length; i++) {
const promise = promises[i];
promise
.then((v)=> results[i] = {status: STATE.FULFILLED, value: v})
.catch((v)=> results[i] = {status: STATE.REJECTED, value: v})
.finally(()=> {
settledPromiseCount++;
if(settledPromiseCount == promises.length) {
resolve(results);
}
})
}
})
}
JavaScript
복사
Promise.race
Promise.race() 메소드는 Promise 객체를 반환합니다. 이 프로미스 객체는 iterable 안에 있는 프로미스 중에 가장 먼저 완료된 것의 결과값으로 그대로 이행하거나 거부합니다.
기대하는 결과는 아래와 같습니다.
const promise1 = new Promise((resolve, reject) => {
setTimeout(resolve, 500, 'one');
});
const promise2 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'two');
});
Promise.race([promise1, promise2]).then((value) => {
expect(value).toBe('two') // 실행이 더 빠른 two가 결과로 반환 됩니다.
});
JavaScript
복사
Promise 가 pending 이 아닌 상태에서는 이미 결정된 결과를 반환하기 때문에 race 함수 내에서는 전달받은 Promise 를 그냥 실행하고 첫 번째 실행된 결과가 then 혹은 catch 로 반환하는 결과를 리턴할 Promise 의 결과로 전달하기만 하면 됩니다.
static race(promises) {
return new SimplePromise((resolve, reject) => {
promises.map((promise)=> {
promise.then(resolve).catch(reject);
})
})
}
JavaScript
복사
Promise.any
Promise.any() 는 Promise 객체를 담은 이터러블을 인자로 받습니다. 이행(fulfill) 되어진 이터러블 값 중 가능한 빨리 실행된 결과를 반환 값으로 얻습니다. 만약 이행된 Promise가 존재하지 않는다면
기대하는 결과는 아래와 같습니다.
const promise1 = Promise.reject(0);
const promise2 = new Promise((resolve) => setTimeout(resolve, 100, 1));
const promise3 = new Promise((resolve) => setTimeout(resolve, 500, 2));
const promises = [promise1, promise2, promise3];
// fulfill 중 가장 빠른 Promise 결과 값
Promise.any(promises).then((value) => expect(value).toBe(1));
const promise1 = Promise.reject(0);
const promise2 = new Promise((_, reject) => setTimeout(reject, 100, 1));
//
const promises = [promise1, promise2];
Promise.any(promises).catch((error) => expect(error.errors).toBe([0,1]));
JavaScript
복사
전달받은 Promise 요소들 중 하나라도 fulfill 상태라면 errors 에 상관 없이 곧바로 resolve 를 반환 합니다.
그게 아닐 경우에는 rejected 시 마다 errors 에 결과 값을 보관하고, rejectedPromiseCount 개수를 증가 시킵니다. 모든 Promise 가 rejected 임이 확정되면 AggregateError 를 errors 와 함께 반환합니다.
static any(promises) {
const errors = [];
let rejectedPromiseCount = 0;
return new SimplePromise((resolve, reject)=> {
for(let i=0; i<promises.length; i++) {
const promise = promises[i];
promise.then(resolve)
.catch((e)=> {
rejectedPromiseCount++;
errors[i] = e;
if(rejectedPromiseCount == promises.length) {
reject(new AggregateError(errors, "All promises were rejected"));
}
})
}
})
}
JavaScript
복사
전체 코드는 아래 레파지토리에서 확인 가능합니다.
마치며
코드가 많지 않아서 글도 금방 쓸 줄 알았는데 생각보다 너무 힘드네요.
역시 비동기라는 주제는 항상 다루기 어려운 것 같습니다.