then을 이어 붙여 비동기 작업을 순서대로 처리하는 Promise 체이닝과, fetch 예제, 그리고 체인에서 에러를 다루는 방법을 정리합니다.
지난 편에서 Promise의 기본 구조와 .then/.catch/.finally 소비 함수를 살펴봤습니다. 이번 편에서는 여러 개의 .then을 이어 붙여 비동기 작업을 순서대로 처리하는 Promise 체이닝과, 체인 안에서 에러를 다루는 방법을 정리합니다.
Promise를 연결해서 비동기 작업을 차례대로 처리하는 방법입니다.
new Promise(function (resolve, reject) {
setTimeout(() => resolve(1), 1000); // (*)
})
.then(function (result) {
// (**)
alert(result); // 1
return result * 2;
})
.then(function (result) {
// (***)
alert(result); // 2
return result * 2;
})
.then(function (result) {
alert(result); // 4
return result * 2;
});
위 코드가 가능한 이유는 .then이 Promise를 반환하기 때문입니다. .then의 리턴값이 해당 Promise의 result가 됩니다.
.then은 리턴할 값을 Promise로 만들어서 반환하지만, .then 안에서 직접 Promise를 만들어 반환할 수도 있습니다. 이전 편의 loadScript를 예로 들면 다음과 같이 쓸 수 있습니다.
loadScript("/article/promise-chaining/one.js")
.then(function (script) {
return loadScript("/article/promise-chaining/two.js");
})
.then(function (script) {
return loadScript("/article/promise-chaining/three.js");
})
.then(function (script) {
// 불러온 스크립트 안에 정의된 함수를 호출해
// 실제로 스크립트들이 정상적으로 로드되었는지 확인합니다.
one();
two();
three();
});
Promise를 반환하는 loadScript에 바로 메서드를 연결해서 loadScript(...).then((script1) => { loadScript(...).then((script2) => {...}) })처럼 중첩해서 쓸 수도 있지만, 이 방식은 또 다른 콜백 지옥을 만들게 됩니다. 기본적으로는 위처럼 체이닝으로 이어 붙이는 편이 낫습니다.
핸들러(executor, 소비 함수 등)는 Promise가 아니라 thenable 객체를 반환하기도 합니다. thenable 객체는 서드파티 라이브러리가 Promise와 호환 가능한 자체 객체를 구현할 수 있도록 나온 개념입니다.
class Thenable {
constructor(num) {
this.num = num;
}
then(resolve, reject) {
// 1초 후 this.num*2와 함께 이행됨
setTimeout(() => resolve(this.num * 2), 1000);
}
}
new Promise((resolve) => resolve(1))
.then((result) => {
return new Thenable(result); // (*)
})
.then(alert); // 1,000밀리 초 후 2를 보여줌
프론트 단에서 네트워크 요청 시 Promise를 자주 사용합니다. fetch(url)을 실행하면 url에 네트워크 요청을 보내고 Promise를 반환하며, resolve 시 얻은 자원을 핸들러로 넘겨줍니다.
fetch("/article/promise-chaining/user.json")
// 원격 서버가 응답하면 .then 아래 코드가 실행됩니다.
.then((response) => response.json()) // 응답 텍스트가 다운로드되면 JSON으로 파싱해 반환합니다.
.then((user) => alert(user.name)); // Violet-Bora-Lee
response.text()나 response.json()을 호출해야 합니다. 이 메서드들은 다운로드가 끝나면 그 결과를 담은 fulfilled Promise를 반환합니다.이제 user.json에서 이름을 읽어와 GitHub API로 해당 사용자 정보를 다시 요청하고, 받아온 아바타 이미지를 3초간 보여주는 식으로 fetch를 순차적으로 이어 붙일 수 있습니다. 이런 체인은 다음과 같이 함수로 정리해서 가독성을 높이는 편이 좋습니다.
function loadJson(url) {
return fetch(url).then((response) => response.json());
}
function loadGithubUser(name) {
return fetch(`https://api.github.com/users/${name}`).then((response) =>
response.json(),
);
}
function showAvatar(githubUser) {
return new Promise(function (resolve, reject) {
let img = document.createElement("img");
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
setTimeout(() => {
img.remove();
resolve(githubUser);
}, 3000);
});
}
// 함수를 이용하여 다시 동일 작업 수행
loadJson("/article/promise-chaining/user.json")
.then((user) => loadGithubUser(user.name))
.then(showAvatar)
.then((githubUser) => alert(`Finished showing ${githubUser.name}`));
// ...
결론적으로 Promise Chaining은 다음의 구조입니다.
// 없는 url일 경우
fetch("https://no-such-server.blabla") // 거부
.then((response) => response.json())
.catch((err) => alert(err));
// TypeError: failed to fetch (출력되는 내용은 다를 수 있음)
Promise가 거부되면 제어 흐름이 가장 가까운 rejection 핸들러로 넘어갑니다. 앞서 만든 체인에도 마찬가지로 적용됩니다.
loadJson("/article/promise-chaining/user.json")
.then((user) => loadGithubUser(user.name))
.then(showAvatar)
.catch((error) => alert(error.message)); // JSON 형식이 잘못되는 등 어디서 에러가 나든 여기서 잡힘
체인 끝에 catch를 붙이면 위쪽 Promise 중 하나라도 거부되었을 때 그 catch가 에러를 잡습니다.
Promise executor와 Promise 핸들러 코드 주위에는 암시적 try...catch가 존재합니다. 예외가 발생하면 이 암시적 try...catch가 예외를 잡아서 reject처럼 다룹니다. throw로 에러를 던지는 것 자체가 거부된 Promise를 의미하며, 핸들러 안에서 존재하지 않는 함수를 호출하는 것 같은 비정상 에러도 동일하게 잡힙니다.
// 아래 두 코드는 같은 기능을 합니다.
new Promise((resolve, reject) => {
throw new Error("에러 발생!");
}).catch(alert); // Error: 에러 발생!
new Promise((resolve, reject) => {
reject(new Error("에러 발생!"));
}).catch(alert); // Error: 에러 발생!
// 핸들러 안의 비정상 에러도 동일하게 잡힙니다.
new Promise((resolve, reject) => {
resolve("OK");
})
.then((result) => {
blabla(); // 존재하지 않는 함수
})
.catch(alert); // ReferenceError: blabla is not defined
단, 동기적 에러만 암시적 try...catch에서 처리됩니다. 다음과 같이 executor 내부의 비동기 코드에서 에러가 발생하면 해당 에러를 감지할 수 없습니다.
new Promise(function (resolve, reject) {
setTimeout(() => {
throw new Error("에러 발생!");
}, 1000);
}).catch(alert);
.catch가 정상적으로 종료되면 fulfilled인 Promise를 반환합니다. 그래서 catch 이후에도 .then으로 정상적인 흐름을 이어갈 수 있습니다. 이 성질을 활용하면 에러 핸들링을 세분화할 수 있습니다.
// 실행 순서: catch -> catch
new Promise((resolve, reject) => {
throw new Error("에러 발생!");
})
.catch(function (error) {
// URIError만 처리하는 소비 함수
if (error instanceof URIError) {
// 에러 처리
} else {
alert("처리할 수 없는 에러");
throw error; // URIError가 아닐 경우 다음 에러 핸들러로 전달
}
})
.then(function () {
/* 여기는 실행되지 않습니다. */
})
.catch((error) => {
alert(`알 수 없는 에러가 발생함: ${error}`);
});
new Promise(function () {
noSuchFunction(); // 존재하지 않는 함수를 호출하기 때문에 에러가 발생함
}).then(() => {
// 성공 상태의 Promise를 처리하는 핸들러. 한 개 혹은 여러 개가 있을 수 있음
}); // 끝에 .catch가 없음!
예외를 처리해 줄 핸들러가 없으면 에러가 갇혀 버립니다. 일반적인 에러를 try...catch로 처리하지 못했을 때 스크립트가 죽고 콘솔에 메시지가 출력되듯, rejected Promise가 처리되지 않아도 동일한 일이 발생합니다.
JS 엔진은 Promise reject를 추적하다가 에러가 갇힌 걸 확인하면 전역 에러를 생성합니다. 브라우저에서는 HTML 명세서에 정의된 표준 이벤트인 unhandledrejection으로 이런 에러를 처리합니다.
// unhandledrejection 이벤트엔 event.promise(에러를 생성한 Promise), event.reason(처리하지 못한 에러 객체) 두 특수 프로퍼티가 있습니다.
window.addEventListener("unhandledrejection", function (event) {
alert(event.reason); // Error: 에러 발생!
});
new Promise(function () {
throw new Error("에러 발생!");
}); // 에러를 처리할 수 있는 .catch 핸들러가 없음
다음 편에서는 여러 Promise를 한 번에 다루는 Promise.all, allSettled, race 같은 정적 메서드와, 요청을 중간에 취소할 수 있게 해주는 AbortController를 살펴보겠습니다.
Promise 핸들러가 항상 비동기로 실행되는 이유인 마이크로태스크 큐를 짚고, 이를 편하게 다루는 async/await 문법과 실행 순서 종합 문제로 시리즈를 마무리합니다.
Promise.all/allSettled/race 같은 정적 메서드, AbortController로 요청을 취소하는 방법, 그리고 콜백을 Promise로 바꾸는 Promisification을 정리합니다.
콜백 기반 비동기 처리의 한계인 콜백 지옥을 짚어보고, 이를 해결하기 위해 등장한 Promise의 기본 구조와 소비 함수를 정리합니다.
메타 태그를 다 넣었는데도 검색 결과는 파란 링크 한 줄뿐이었습니다. 검색엔진이 페이지를 '읽는' 것과 '이해하는' 것은 다른 문제였고, 그 사이를 메우는 게 구조화된 데이터였습니다.
전역 Suspense로 감싸면 경고는 사라지지만, 페이지의 정적 마크업까지 placeholder로 대체됩니다. Next.js 소스코드를 따라가 BailoutToCSRError가 CLIENT_RENDERED 경계로 어떻게 흐르는지 살펴봅니다.
hooks 기반 이벤트 드리븐 위젯을 만들고, 그 과정에서 왜 Electron을 선택했는지, hook과 기록 파일이라는 두 신호를 어떻게 교차 검증했는지 정리했습니다.
문서에는 "queued"라고만 적혀 있습니다. 호출 단위인지 컴포넌트 단위인지 탭 전체인지 알 수 없어서, 30개 요청을 세 가지 방식으로 직접 재봤습니다.
참조형 데이터는 내용이 같아도 주소가 다르면 다른 값으로 취급됩니다. 이 특성이 useEffect 의존성 배열과 React.memo에서 왜 문제가 되는지, useCallback과 useMemo가 무엇을 해결하는지, 그리고 왜 모든 곳에 쓰면 안 되는지 정리합니다.
인앱 브라우저에서 기능이 깨지는 문제와 대용량 목록 렌더링 성능 문제, 두 가지를 실제로 부딪히고 풀어낸 기록입니다.