Promise.all/allSettled/race 같은 정적 메서드, AbortController로 요청을 취소하는 방법, 그리고 콜백을 Promise로 바꾸는 Promisification을 정리합니다.
앞서 Promise 체이닝과 에러 핸들링을 살펴봤습니다. 이번 편에서는 여러 Promise를 한 번에 다루는 Promise API의 정적 메서드들과, 요청을 중간에 취소하는 AbortController, 그리고 콜백 함수를 Promise로 바꾸는 Promisification을 정리합니다.
여러 개의 Promise를 동시에 실행시키고 모든 Promise가 준비될 때까지 기다립니다. Promise인 배열을 받고 새로운 Promise를 반환합니다.
Promise.all([
new Promise((resolve) => setTimeout(() => resolve(1), 3000)),
new Promise((resolve) => setTimeout(() => resolve(2), 2000)),
new Promise((resolve) => setTimeout(() => resolve(3), 1000)),
]).then(alert); // Promise 전체가 처리되면 1, 2, 3이 반환됩니다.
각 Promise 요청이 다 끝나면 배열의 순서에 따라 결과값이 또 다른 배열 형태로 소비 함수에 전달됩니다.
fetch는 인수에 있는 url에 요청을 보내서 response를 Promise 형태로 반환하는 함수입니다.
let urls = [
"https://api.github.com/users/iliakan",
"https://api.github.com/users/Violet-Bora-Lee",
"https://api.github.com/users/jeresig",
];
// fetch를 사용해 url을 Promise로 매핑하고, 모든 작업이 이행될 때까지 기다립니다.
let requests = urls.map((url) => fetch(url));
Promise.all(requests).then((responses) =>
responses.forEach((response) => alert(`${response.url}: ${response.status}`)),
);
받은 응답 배열을 다시 .then((responses) => Promise.all(responses.map((r) => r.json())))처럼 Promise.all에 넣어 json() 파싱하는 식으로 체인을 늘려갈 수도 있습니다.
내부 배열에서 하나라도 rejected가 일어나면 전체를 에러로 판단합니다.
Promise.all([
new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
new Promise((resolve, reject) =>
setTimeout(() => reject(new Error("에러 발생!")), 2000),
),
new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)),
]).catch(alert); // Error: 에러 발생!
Promise reject가 일어나도 호출은 계속 일어납니다. 별도의 처리(AbortController)가 없다면 rejected가 일어나도 다른 Promise 처리가 취소되지는 않고, 결과만 무시됩니다.
실제로 네트워크 요청을 보면 fetch 자체는 일어난 것을 확인할 수 있습니다.
Promise.all의 인수에 있는 배열은 꼭 Promise 객체가 아니어도 됩니다.
Promise.all([promise, 2, 3])처럼 숫자를 넣으면 그 값을 그대로 소비 함수로 전달합니다.
Promise.all은 하나라도 에러가 나면 전부 결과를 받지 못합니다. html, css, json이 전부 필요한 render 함수처럼 하나라도 없으면 의미가 없는 경우엔 이 방식이 유용하지만, allSettled는 하나가 에러 나더라도 다른 요청의 결과는 받을 수 있게 하려고 나왔습니다.
{status:"fulfilled", value:result}{status:"rejected", reason:error}Promise.allSettled(urls.map((url) => fetch(url))).then((results) => {
results.forEach((result, num) => {
// 응답이 성공하면 value에, 에러가 발생하면 reason에 결과가 들어갑니다.
if (result.status == "fulfilled") {
alert(`${urls[num]}: ${result.value.status}`);
}
if (result.status == "rejected") {
alert(`${urls[num]}: ${result.reason}`);
}
});
});
Promise.all과 유사하게 Promise 배열 전체를 처리하지만, 가장 먼저 처리되는 Promise의 결과를 반환합니다.
Promise.race([
new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
new Promise((resolve, reject) =>
setTimeout(() => reject(new Error("에러 발생!")), 2000),
),
new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)),
]).then(alert); // 1
resolve와 reject 메서드는 async/await의 등장으로 잘 사용되지 않습니다. 하지만 이어질 폴리필을 이해하기 위해 한번 짚어보겠습니다. (async function은 리턴값을 무조건 Promise로 꺼내고, 내부에서 throw Error하면 rejected 상태의 Promise를 반환합니다.)
Promise.resolve(value)는 결과값이 value인 fulfilled한 Promise를 생성합니다.
// let promise = new Promise(resolve => resolve(value));
let cache = new Map();
function loadCached(url) {
if (cache.has(url)) {
return Promise.resolve(cache.get(url));
}
return fetch(url)
.then((response) => response.text())
.then((text) => {
cache.set(url, text);
return text;
});
}
loadCached는 fetch할 때 cache에 데이터를 저장해두고, 다음에 같은 요청을 보냈을 때 cache에서 꺼내서 전달합니다. 이때 Promise.resolve를 이용해 loadCached가 항상 Promise를 반환한다는 걸 정해두면, 사용하는 쪽에서는 .then/catch 같은 메서드로 소비 함수를 넣을 수 있게 됩니다. 같은 방식으로 Promise.resolve(p).then(onFulfilled, onRejected)를 활용하면 구식 브라우저를 위한 allSettled 폴리필도 만들 수 있습니다.
Promise.reject(error)는 결과값이 error인 rejected Promise를 반환합니다.
let promise = Promise.reject(error);
글 흐름을 위해 Promise API 파트에 이어서 다루지만, 엄밀히 말하면 AbortController는 Promise API는 아닙니다.
앞서 Promise.all에서 에러가 발생해도 다른 Promise의 결과는 무시될 뿐 호출 자체는 막을 수 없다고 했습니다. AbortController라는 빌트인 객체를 활용하면 Promise 호출 자체를 막을 수 있습니다.
let controller = new AbortController();
AbortController는 프로퍼티 하나, 메서드 하나를 갖고 있습니다.
signal은 인스턴스에서 바로 찾을 수 있으며, event listener를 세팅할 수 있는 프로퍼티입니다.abort가 실행되면 instance.signal이 "abort" 이벤트를 발생시키고, instance.signal.aborted가 true가 됩니다.즉 signal에 취소 가능한 작업을 리스너로 달아 놓고, abort() 함수로 취소하는 방식입니다.
let controller = new AbortController();
let signal = controller.signal;
// 취소 가능한 작업에 signal을 주고, controller.abort()가 실행되면 실행될 리스너를 세팅합니다.
signal.addEventListener("abort", () => alert("abort!"));
controller.abort(); // abort!
alert(signal.aborted); // true
다른 이벤트를 구현해서 동일한 기능을 만들 수도 있지만, 내부적으로 fetch는 AbortController를 인식하기 때문에 AbortController를 사용하는 것이 좋습니다.
fetch 옵션에는 signal이라는 프로퍼티가 존재합니다.
let controller = new AbortController();
fetch(url, {
signal: controller.signal,
});
이제 이전과 같이 controller.abort() 함수로 fetch 요청을 제어해주면 됩니다.
단, fetch가 abort하는 시기보다 더 빨리 끝나면 이미 나온 결과를 없애지는 않습니다.
하나의 controller를 여러 fetch에 등록해두면, 등록된 모든 fetch가 하나의 controller 신호를 따르게 됩니다. Promise.all을 통해 fetch와 다른 일반 Promise를 결합할 수도 있습니다.
let urls = [...];
let controller = new AbortController();
let ourJob = new Promise((resolve, reject) => { // our task
...
controller.signal.addEventListener('abort', reject);
});
let fetchJobs = urls.map(url => fetch(url, { // fetches
signal: controller.signal
}));
// Wait for fetches and our task in parallel
let results = await Promise.all([...fetchJobs, ourJob]);
// if controller.abort() is called from elsewhere,
// it aborts all fetches and ourJob
콜백을 받는 함수를 Promise를 반환하는 함수로 바꾸는 것을 Promisification이라고 합니다. 4편에서 다룬 오류 우선 콜백 방식의 loadScript(src, callback)를 다시 예로 들면, 콜백을 내부로 숨기고 resolve/reject로 바꿔 감싸주기만 하면 됩니다.
function loadScriptPromise(src) {
return new Promise((resolve, reject) => {
// (err, script) => {...}는 기존 콜백 방식 loadScript에서 쓰이던 콜백 함수가 내부로 숨겨진 것입니다.
loadScript(src, (err, script) => {
if (err) reject(err);
else resolve(script);
});
});
}
// 사용법:
// loadScriptPromise('path/script.js').then(...)
이 Promisification은 하나의 함수에만 쓸 수 있습니다. 확장성 있게 만들기 위해 함수 f를 받아서 f 호출부에 커스텀 콜백을 붙인 래퍼 함수를 반환하는 헬퍼 함수를 만들어 보겠습니다. 콜백의 성공 결과가 여러 개일 수도 있으므로 manyArgs 옵션까지 포함한 최종 형태는 다음과 같습니다.
// manyArgs가 false면(기본값) 첫 번째 결과만, true면 결과 전체를 배열로 반환합니다.
function promisify(f, manyArgs = false) {
return function (...args) {
return new Promise((resolve, reject) => {
function callback(err, ...results) {
if (err) {
reject(err);
} else {
resolve(manyArgs ? results : results[0]);
}
}
args.push(callback);
f.call(this, ...args);
});
};
}
// 사용법: promisify에 들어가는 f는 여러 인수가 필요하고 콜백이 마지막 인수로 들어가는 함수입니다.
// f(arg1, arg2, ..., (err, ...results)=>{});
f = promisify(f, true);
f(...).then(arrayOfResults => ..., err => ...);
단, Promisification은 콜백을 완전히 대체하지는 못합니다. 콜백은 여러 번 호출할 수 있지만 Promise는 하나의 결과만 가질 수 있기 때문입니다(Promisification한 함수의 콜백을 여러 번 호출해도 두 번째부터는 무시됩니다).
이를 미리 처리해주는 promisify 라이브러리나 Node.js 내장 함수도 존재합니다.
다음 편에서는 Promise 핸들러가 왜 항상 마이크로태스크 큐를 거쳐 비동기로 실행되는지, 그리고 이를 훨씬 편하게 다룰 수 있게 해주는 async/await 문법을 다루면서 시리즈를 마무리합니다.
Promise 핸들러가 항상 비동기로 실행되는 이유인 마이크로태스크 큐를 짚고, 이를 편하게 다루는 async/await 문법과 실행 순서 종합 문제로 시리즈를 마무리합니다.
then을 이어 붙여 비동기 작업을 순서대로 처리하는 Promise 체이닝과, fetch 예제, 그리고 체인에서 에러를 다루는 방법을 정리합니다.
콜백 기반 비동기 처리의 한계인 콜백 지옥을 짚어보고, 이를 해결하기 위해 등장한 Promise의 기본 구조와 소비 함수를 정리합니다.
메타 태그를 다 넣었는데도 검색 결과는 파란 링크 한 줄뿐이었습니다. 검색엔진이 페이지를 '읽는' 것과 '이해하는' 것은 다른 문제였고, 그 사이를 메우는 게 구조화된 데이터였습니다.
전역 Suspense로 감싸면 경고는 사라지지만, 페이지의 정적 마크업까지 placeholder로 대체됩니다. Next.js 소스코드를 따라가 BailoutToCSRError가 CLIENT_RENDERED 경계로 어떻게 흐르는지 살펴봅니다.
hooks 기반 이벤트 드리븐 위젯을 만들고, 그 과정에서 왜 Electron을 선택했는지, hook과 기록 파일이라는 두 신호를 어떻게 교차 검증했는지 정리했습니다.
문서에는 "queued"라고만 적혀 있습니다. 호출 단위인지 컴포넌트 단위인지 탭 전체인지 알 수 없어서, 30개 요청을 세 가지 방식으로 직접 재봤습니다.
참조형 데이터는 내용이 같아도 주소가 다르면 다른 값으로 취급됩니다. 이 특성이 useEffect 의존성 배열과 React.memo에서 왜 문제가 되는지, useCallback과 useMemo가 무엇을 해결하는지, 그리고 왜 모든 곳에 쓰면 안 되는지 정리합니다.
인앱 브라우저에서 기능이 깨지는 문제와 대용량 목록 렌더링 성능 문제, 두 가지를 실제로 부딪히고 풀어낸 기록입니다.