: 여태까지 배웠던 모나드들은(Maybe, Either, IO) 모두 한가지 목표를 모나드 안에서 커버하는 방식으로 쓰여졌다. 예를 들어, null, undefined 등을 필터링하기 위해 썼던 Maybe 모나드, 에러 메시지까지 출력하기 위해 썼던 Either 모나드, 입출력(DOM 입력, 출력 등) 처리를 감싸기 위해 썼던 IO 모나드 등등이 그러하다. Promise 모나도 앞선 예시들과 유사하다. 그럼 Promise 모나드는 어떤 역할을 처리할까? Promise는 오래 걸리는 계산을 모나드로 감싸서 처리하는 역할을 한다. 기존 콜백 기반(async, await가 나오기 전이라 생각해보자)의 접근 방법에 비하면 훨씬 더 간편하게 비동기 작업을 실행, 합성, 관리할 수 있는 대안으로서 쓸 수 있다.
: 사실 요즘은 보통 비동기 처리를 할 때 async, await를 쓰기 때문에 Promise를 굳이 쓸까? 라는 생각을 할 수 있는데, 일단은 await, async 대신 쓴다는 접근으로 사용법을 체크해보자.
한가지 예시를 구현해본다는 관점으로 접근해보면, 비트코인 가격을 api로 받아서 렌더링하는 로직을 짜보자.
async function getBitcoinPrice() {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.open(
"GET",
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd"
);
xhr.onload = function () {
if (xhr.status === 200) {
resolve(xhr.response.bitcoin.usd);
} else {
reject(new Error(xhr.statusText));
}
};
xhr.onerror = function () {
if (reject) {
reject(new Error("IO Error"));
}
};
xhr.send();
});
}
getBitcoinPrice()
.then((res) => {
document.querySelector("#bitcoin-price").innerHTML = res;
})
.catch((err) => console.error(err));
굳이 옛날 방식인 XMLHttpRequest
을 써봤다. 위와 같이 Promise 객체로 '비동기 작업'을 감싸면 우리는 마음 편하게 비동기 로직을 짤 수 있다. Promise 객체가 해당 비동기 작업의 settled 상태에 따른 결과물(resolve, reject)를 기다렸다가 체이닝 함수에(then 안에) 넘겨줄 것을 믿기 때문이다. 그럼 Promise 객체가 없었다면 어떻게 해야하나?. 원래는 비동기 로직이 포함된 함수에 콜백 함수를 넣어서 onload 가 됐을 때 콜백함수를 실행하도록 해줘야한다. 하지만, 예를 들어, API를 한번 호출한 다음에 그 데이터로 다시 한번 데이터를 호출해야한다면 ? 이러다보면 콜백헬이 생기게 된다. 하지만, Promise 객체로 체이닝을 해두면 가독성도 좋고, then 안의 함수는 계속해서 return 값을 Promise로 자동으로 래핑해서 리턴하기 때문에 다시한번 비동기 로직을 포함시켜도 상관이 없다. 그리고 마지막엔 catch 체이닝을 통해 에러 상황도 간편하게 대비할 수 있다.
: 원래 모나드를 만들 때 중요한 것중에 하나가 flatMap이다. 모나드를 하나의 자료구조 상자라고 했을 때, 보통의 모나드 map 메서드는 특정 값을 상자에 담아서 리턴하게 된다. 하지만, 함수 내부에 이미 상자에 넣어서 리턴하는 로직이 들어있을 때는 ? 상자[상자 [ 데이터 ] ]
이런식으로 될 수 있다. 그러면 원하는 결과를 얻을 수 없다. 즉, 에러 상황으로 연결될 수 있다. 그래서 Either 같은 경우에도
export class Right extends Either {
map(f: any) {
return Either.of(f(this._value));
}
get value() {
return this._value;
}
getOrElse(other: any) {
return this._value;
}
orElse() {
return this; // 쓰지 않음
}
chain(f: any) {
return f(this._value);
}
getOrElseThrow(_: any) {
return this._value;
}
filter(f: any) {
return Either.fromNullable(f(this._value) === null ? this._value : null);
}
toString() {
return this._value;
}
}
위와 같이 함수의 결과값을 Either.of로 감싸서 리턴하는 map메서드가 있고, 그냥 함수의 값만 리턴하는 chain이 있다. 여기서는 chain이 flatMap에 해당되겠다. 하지만, Promise는 이런 걱정을 할 필요가 없다. 예를 들어,
const oneSecPromise = x => new Promise((resolve) => {
setTimeout(() => {
resolve(x);
}, 1000);
});
oneSecPromise(2) // ⏳ 1 Sec
.then(x => x + 3)
.then(x => oneSecPromise(x * 4)) // ⏳ 1 Sec
.then(x => oneSecPromise(x / 5)) // ⏳ 1 Sec
.then(console.log); // Log the result
이렇게 코드를 작성했을 때, .then(x => x + 3)
여기 부분에서 결과값을 Promise 객체에 알아서 넣어서 다음 then으로 체이닝하고, .then(x => oneSecPromise(x * 4))
여기에서의 결과값도 위의 상자[상자 [ 데이터 ] ]
이 예시처럼 두번 감싸는게 아니라 알아서 하나의 데이터를 감싸는 식으로 처리를 한다(자동으로 map, flatMap을 컨트롤하는 것).
: 앞서 말했듯이, 요즘은 async, await로 비동기 로직을(명령형에 가까운 스타일) 짜는게 대부분이므로 사실 Promise 는 크게 쓸일이 없을 것처럼 보이기도 한다. 하지만, 여전히 Promise가 제공하는 장점이 많다.
async function fetchAllData() {
try {
const results = await Promise.all([
fetch('https://api.example.com/data1'),
fetch('https://api.example.com/data2'),
fetch('https://api.example.com/data3')
]);
// 결과 처리
results.forEach((result, index) => {
console.log(`API ${index + 1} 결과:`, result);
});
} catch (error) {
console.error('에러 발생:', error);
}
}
원래는 세개의 await 문을 써서 각각의 API를 순차적으로 호출하고(동기적으로), 그 다음에 세개의 response 값을 출력하는 로직으로 짜야한다면, Promise,all을 사용하면 배열에 fetch 로직을 넣어주기만 하면 세개의 fetch 로직을 병렬적으로 호출하여 모든 response가 도착했다면 다음 콘솔 출력문으로 넘어간다.
const timeout = (promise, ms) => {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
)
]);
}
위와 같이 Promise.race를 쓰게되면 매개변수로 준 ms 시간이 지나면 자동으로 배열의 두번째 요소가 race의 승자가 돼 실행되고, 배열의 첫번째 요소인 promise 로직은 resolve를 리턴하지 못하게 된다. 즉, 위의 실행에 대한 결과는 Timeout
이라는 시간 초과 에러가 되고, 정상적인 플로우의 실패를 나타낸다. 이처럼 Promise.race를 쓰면 위와 같은 로직을 더 쉽게 표현할 수 있다.
정확히는 Promise 객체가 제공하는 기능이 아니지만, 연관된 기능을 살펴보자. 시간이 굉장히 중요한 서버 요청이기 때문에 3초 이내에 성공을 못하면 요청을 파기해야하는 로직이 있다고 가정해보자. 그런 경우 AbortController 객체를 fetch의 signal에 넣어줌으로써 특정 시간이 지났는데도 fulfilled가 되지 않으면 연결을 취소할 수 있다.
function fetchWithTimeout(url, timeout = 3000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
return fetch(url, { signal: controller.signal })
.finally(() => clearTimeout(timeoutId));
}
// 사용 예
fetchWithTimeout('https://api.example.com/data', 3000)
.then(response => response.json())
.catch(error => {
if (error.name === 'AbortError') {
console.log('요청이 취소되었습니다');
}
});
혹은 MDN에 있는 예시처럼 이런식으로 버튼을 눌렀을 때 AbortController 객체를 직접 가져와서 abort() 하는 방법도 있다.
const controller = new AbortController();
const fetchButton = document.querySelector("#fetch");
fetchButton.addEventListener("click", async () => {
try {
console.log("Starting fetch");
const response = await fetch("https://example.org/get", {
signal: controller.signal,
});
console.log(`Response: ${response.status}`);
} catch (e) {
console.error(`Error: ${e}`);
}
});
const cancelButton = document.querySelector("#cancel");
cancelButton.addEventListener("click", () => {
controller.abort();
console.log("Canceled fetch");
});
출처 : MDN
: 사실 이부분은 내가 전부터 괜히 겁내기만 했던 부분이기도 하다. 요즘은 async, await를 주로써서 Promise를 써야하는 상황이 오거나, 관련 지식이 필요한 상황이 되면 이유없이 겁이 났던 것 같다. 하지만, Promise는 사실 await, async의 기저에 깔린 로직이고, 실제로 async, await는 이를 래핑한 문법적 설탕과 같다. 따라서 겁먹을 것 없이 저수준 제어를 할 때는 Promise 객체를 적극 활용해주면 된다.