자바스크립트 공부하다 비동기를 처리하는 방법에 XMLHttpRequest라는 것을 만나게 됐다. 나는 async/awiat을 써왔기 때문에 이게 뭔가 싶었다. 갑자기 fetch는 무엇이고 Promise는 무엇인지 햇갈렸다.
이건 한번 정리해놓지 않으면 앞으로도 계속 모호함 속에서 헤엄칠 수 있기 때문에 각 객체가 의미하는 것이 무엇인지 알아보고 실사용 예제를 찾아서 공부해보았다.
자바스크립트는 싱글 스레드 언어다. 한번에 하나의 일만 할 수 있다. 자바스크립트를 사용하면서 작동되는 원리는 동기 방식이다. 요청한 값이 호출 스텍에 쌓이게 되고 쌓인 순서대로 하나씩 처리하게 된다. 어떤 조건에서도 막힘 없이 몇 밀리세컨즈 안에 요청한 값에 응답을 한다면 동기 방식으로만 작동하면 될 것이다. 하지만 어플리케이션이 동작하는 환경에서 요청값을 빠르게 처리하지 못하는 경우가 발생하는데 그럴 경우에 호출 스택에 요청 값이 쌓이게 되고 내가 원하는 요청값을 한참동안 기다려야할 수도 있다. 그럴 경우에 사용자 경험에 안좋은 영향을 주게 된다.
참조
어쨌든 이벤트 루프는 무엇입니까? | Philip Roberts | JSConf EU
[10분 테코톡] 🍗 피터의 이벤트루프
자바스크립에서 동기와 비동기
비동기 실행이 필요한 이유는 동기로 코드를 실행하는 동안 특정 요청으로 인해 블로킹이 된다면 사용자는 동기가 진행되는 동안 블로킹이 끝날 때까지 웹 브라우저에서 아무것도 할 수 없다. 그래서 비동기 콜백을 받아서 요청이 처리되는 동안 다른 일을 할 수 있도록 비동기 실행을 필요로 하게 된다.
비동기 콜백을 이해할 때, setTimeout 함수를 이용하여 스택의 작동 원리를 설명한다. 아래 코드를 복사해서 크롬 개발자 도구 콘솔에 붙여 넣기를 하면 hi, bye가 먼저 출력되고 1초 뒤에 where이 출력되는 것을 알 수 있다.
console.log("hi");
setTimeout(() => console.log("where"), 1000);
console.log("bye");
어쨌든 이벤트 루프는 무엇입니까? 12분 49초부터에서 발표자 Philip Roberts는 위의 코드를 실행했을 때, 어떤 일이 일어나는지를 설명한다.
스택에서 동작을 요청받은 setTimeout을 웹 브라우저가 만나면 타이머를 동작시키고 setTimeout 함수는 스텍에서 제거한다. 다른 코드가 동작하는 동안 Web APIs에서 타이머는 실행이 되고 있다. WEB APIs의 동작이 완료되면 콜백 함수를 테스크 큐에 밀어 넣고 이때 이벤트 루프가 동작한다.
"이벤트 루프는 콜 스택과 테스크 큐를 주시하는 역할을 한다. 스택이 비어있다면, 테스크 큐의 첫번째 콜백을 스택에 쌓아 효과적으로 실행할 수 있게 해준다."(자막 그대로)
여기서 주의 깊게 봐야할 점은 setTimeout의 값을 0초라고 하여도 가장 마지막에 실행되는데 Web APIs의 동작이 완료 되었다고 해서 스택 중간에 동작이 완료된 setTimeout 함수를 끼어넣을 수 없기 때문이다.(다른 말로 콜백 스택이 전부 비어있어야 이벤트 루프가 테스크 큐에서 콜백을 스택으로 넣는다.)
결국 자바스크립트가 싱글 스레드 언어임에도 불구하고 비동기 실행이 가능한 이유는 콜 스택과 테스크 큐 그리고 이벤트 루프 덕분이라고 할 수 있다.
Web APIs
Web APIs는 전부 비동기 메서드 들이다.
DOM(document), AJAX(XMLHttpRequest), Timeout(setTimeout)
콜백 지옥 또는 멸망의 피라미드라는 용어를 봤을 때, 혼자서 한참 웃었었다. 얼마나 짜증나는 예제이면 이름을 콜백 지옥, 멸망의 피라미드라고 붙였을까. 다행이 내가 콜백 지옥에 가지 않도록 선구자들은 프러미스를 보내주셨으니 프러미스를 믿는자마다 프로그래밍을 포기하지 않고 좋은 코드를 얻으리라.
비동기로 처리해야하는 함수가 여러개일 경우에 콜백 지옥이 발생한다. 각각의 비동기로 처리해야하는 함수는 언제 요청에 대한 응답을 할지 예측할 수 없기 때문에 순차적으로 비동기 값을 받기 위해서 콜백 안에 콜백 안에 콜백 안에 콜백 안에 콜백 안에 콜백 안에 콜백...을 반복하다 보면 콜백 지옥에 빠진다. 콜백 지옥을 해결하기 위한 가장 좋은 방법은 Promise를 사용하는 것이다.
참고
Promise : javascript.info
예시와 함께 코드를 실행해보면서 프러미스를 공부하는게 좋다.
자바스크립트 Promise 쉽게 이해하기 : 캡틴판교
Promise는 비동기를 처리하기 위한 내장 객체다. Promise는 excutor에서 반드시 resolve와 reject를 호출해야한다. 이때를 약속을 이행했다고 표현한다. 그래서 promise라고 이름을 붙였나보다.
let promise = new Promise(function (resolve, reject) {
// executor
});
resolve(value)는 일이 성공적으로 끝난 경우 value와 함께 호출한다.
reject(error)는 에러가 발생했을 경우 에러 객체를 나타내는 error와 함께 호출한다. resolve와 reject는 자바스크립트 내장 함수이기 때문에 따로 만들필요가 없다. executor가 resolve와 reject를 호출하면 변경된 상태는 더이상 변하지 않는다.
// 호출 상태가 더이상 변하지 않는다.
const promise = new Promise((resolve, reject) => {
resolve(1);
setTimeout(() => resolve(2), 3000);
});
promise.then(console.log);
promise 객체는 내부 프로퍼티를 갖는데 stste와 result가 있다. state는 처음에는 pending(보류)였다가 resolve가 호출되면 fulfilled, reject가 호출되면 rejected로 변한다. result는 undefined였다가 resolve(value)가 호출되면 value, reject(error)가 호출되면 error로 변한다.
// 함수 실행을 하고 promise는 pending이지만 호출 이후에 값을 콘솔에 조회하면 fulfilled가 된다.
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const promise = delay(3000).then(() => console.log("3초후 실행"));
console.log(promise);
/*
Promise {<pending>}
[[Prototype]]: Promise
[[PromiseState]]: "pending"
[[PromiseResult]]: undefined
undefined
*/
console.log(promise);
/*
Promise {<fulfilled>: undefined}
[[Prototype]]: Promise
[[PromiseState]]: "fulfilled"
[[PromiseResult]]: undefined
*/
then은 Promise에서 가장 중요한 메서드다. then의 첫 번째 인수는 프러미스가 이행됐을 때 받는 함수이며 여기서 결과를 받는다. 두 번째 인수는 프러미스가 거부 되었을 때 받는 함수이며 여기서 에러를 받는다.
let promise = new Promise(function (resolve, reject) {
setTimeout(() => resolve("done!"), 1000);
// 만약 위 함수 대신 아래 함수를 쓰면 error가 반환된다.
// setTimeout(()=>resolve(new Error("에러...")), 1000)
});
// resolve 함수는 .then의 첫 번째 함수(인수)를 실행합니다.
promise.then(
(result) => console.log(result), // 1초 후 "done!"을 출력
(error) => console.log(error), // 실행되지 않음
);
보통 then은 프러미스 체이닝을 할 때 많이 봤었다. 프러미스 체이닝은 순차적으로 해야하는 비동기 작업이 여러개 있을 경우에 사용한다. 프러미스 체이닝이 가능한 이유는 data.then을 호출했을 때, 프러미스가 반환되기 때문이다. then이 값을 반환 할 경우 result는 이 값이 되기 때문에 다음 .then에서 이 값을 이용해 호출된다.
const data = fetch("/get-data", {
method: "GET",
headers: { "Content-Type": "application/json" },
})
.then((response) => response.json())
.then((user) => {
const button = document.createElement("button");
button.innerText = "사용자 이름 보기";
button.addEventListner("click", () => {
const name = documenet.createElement("p");
name.innerText = user.name;
container.append(p);
});
});
then으로 이전 프러미스 이행에 대한 결과 값을 받아 실행할 수 있기 때문에 콜백 지옥으로 가지 않아도 된다.
더 좋은 사용 방법
fetch와 체이닝 함께 응용하기
프러미스 체인에서 catch를 이용해서 예외 처리를 할 수 있다. catch는 정상적인 경우에 절대로 동작하지 않지만 체이닝에서 어느 하나라도 reject가 되면 catch에서 에러를 잡는다.
const data = fetch("/get-data", {
method: "GET",
headers: { "Content-Type": "application/json" },
})
.then((response) => response.json())
.then((user) => {
const button = document.createElement("button");
button.innerText = "사용자 이름 보기";
button.addEventListner("click", () => {
const name = documenet.createElement("p");
name.innerText = user.name;
container.append(p);
});
})
.catch((error) => console.log(error.message));
만약 catch 다음에 then이 있는데 거기에서 reject가 되면 어떻게 될까? 결론적으로 에러를 잡을 수 없다. catch는 에러를 잡으려는 위치에서 정확하게 동작해야한다. 가장 가까운 catch가 잡아낼 줄 알았는데 그렇지 않다.
new Promise((resolve, reject) => {
resolve("done");
})
.then((result) => console.log(result))
.catch(console.log)
.then((result) => {
throw new Error("에러");
})
.catch(console.log);
에러가 처리되지 않은 상태에서 에러가 발생하면 어플리케이션 전체가 알 수 없는 이유로 죽어버릴 수도 있다. 해결할 수 있는 가장 쉬운 방법은 체인 끝에 catch를 붙여 에러를 잡는 것이다.
그밖에 finally, 프러미스 API 등이 있다.
async/await은 비동기 처리를 위한 키워드다. async를 함수 앞에 쓰면 promise가 아닌 값에도 값을 감싸서 promise를 반환하게 된다.
async function getData() {
const data = await fetch("/get-data");
return data.json();
}
// 화살표 함수에서 예제
const findUser = async () => {
const {
data: { id },
} = await getData();
return await User.findById(id);
};
async는 함수 앞에 써야한다. await은 반드시 async가 붙은 함수 안에서만 사용할 수 있다. await은 말 그대로 프러미스가 이행 될 때까지 기다린다는 암시다. 약속이 이행되면 값이 반환된다. await이 붙은 함수는 약속이 이행되는 동안 다른 함수를 실행시킬 수 있다. 프러미스를 쓸 때보다 가독성 좋은 코드를 작성할 수 있다는 장점이 있다.
XMLHttpRequest는 자바스크립트에서 HTTP 요청을 위한 내장 객체다. XML이란 이름이 붙었지만, XML뿐 아니라 다른 데이터도 처리할 수 있다.
오늘날에는 fetch를 사용하기 때문에 XMLHttpRequest는 더이상 사용되지 않는다. 하지만 요즘에도 XMLHttpRequest를 사용할때가 있는데 오래된 브라우저를 폴리필 없이 지원하거나 fetch가 하지 못하는 일을 하기 위해서다.(upload 상황을 추적하는 일)
"fetch는 서버에 네트워크 요청을 보내고 새로운 정보를 받아오는 일을 할 수 있다." fetch를 사용하면 서버에 요청을 보내거나 사용자 정보를 하는 일을 새로고침 없이 비동기로 실행이 가능하다.
fetch를 사용해서 댓글 쓰기, 좋아요 누르기 등에 대한 작업도 비동기로 처리가 가능해진다.
// fetch를 사용한 post 요청 간단한 예제
// async/await, try/catch 와 함께 사용하기
async function postData() {
try {
const response = await fetch("/new-message/post", {
method: "POST",
headers: { "Content-Type": "application/json;charset=utf-8" },
body: JSON.stringify(newMessage),
});
return response;
} catch (e) {
console.log(e);
}
}
async function redirect() {
const data = await postData();
if (data.status === 200) {
return (window.location.pathname = "/");
} else {
return (window.location.pathname = "/404");
}
}
redirect();
promise를 반환하기 때문에 then이나 catch로 결과 값을 처리할 수 있다. async/await을 사용하면 promise 값을 훨씬 쉽게 처리할 수 있고 가독성 좋은 코드를 작성할 수 있다.
XMLHttpRequest, fetch, Promise, async/awiat에 대해서 간략하게 정리해보자.
XMLHttpRequest는 네트워크 요청을 위한 객체이며 요즘에는 거의 사용하지 않는다. 이름과 다르게 여러가지 데이터를 처리할 수 있다.
fetch도 XMLHttpRequest와 같이 네트워크 요청을 위한 메서드이며 오늘날에는 fetch를 사용해서 네트워크 요청을 하게 된다. fetch는 Promise 기반이며 결과로 Promise를 리턴한다.
Promise는 비동기 처리를 위한 객체이며 콜백 지옥(?)과 같은 불상사를(콜백 패턴을) 해결하기 위해 등장한 객체이다. Promise 객체가 나오기 이전보다 더 쉽게 데이터 요청을 처리할 수 있다.
async/await은 try/catch와 함께 비동기 처리를 위한 키워드이며 결국 Promise를 반환하지만 Promise를 사용하는 것보다 더 간략하게 네트워크 요청을 처리할 수 있다.