비동기 이해하기 (연습)

이번시간에는 비동기 처리 결과를 이용하는 예제를 따라쳐보면서 익숙해지도록 해보자.

목적

API를 활용하여 서버에서 데이터를 가져와 화면에 렌더링하기

코드 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>

<body>
<ul></ul>
<script>
const url = "https://api.artic.edu/api/v1/artworks";
const render = (items) => {
document.querySelector("ul").innerHTML = items
.map((item) => `<li>${item.title}</li>`)
.join("");
};
const get = () => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();

xhr.addEventListener("load", () => {
if (xhr.status === 200) {
render(JSON.parse(xhr.response).data);
} else {
console.error("Status Error occured");
}
});
};
</script>
</body>
</html>

서버에 XMLHttpRequest 요청을 보내 데이터 응답을 받았다.

이 과정을 다시 살펴보면 비동기 코드의 처리 결과를 render() 함수의 인수로 전달해주면서 비동기 처리 결과를 다루는 모습을 볼 수 있다.

만약에 비동기 처리 결과를 단순히 렌더해주는 것만이 아니라, 데이터의 조작이나 어떤 로직으로 처리가 많아지면 그 때마다 함수의 인수로 전달해줘야 하기 때문에 콜백 헬 현상이 발생하여 가독성과 복잡성이 떨어지게된다.

콜백함수를 이용한 비동기 처리 결과 다루기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const url = "https://api.artic.edu/api/v1/artworks";
const render = (items) =>
items.map((item) => `<li>${item.title}</li>`).join("");
const get = (url, callback) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();

xhr.addEventListener("load", () => {
if (xhr.status === 200) {
callback(JSON.parse(xhr.response).data);
} else {
console.error(`${xhr.status} ${xhr.statusText}`);
}
});
};
get(url, (param) => {
document.querySelector("ul").innerHTML = render(param);
});

위와 같이 비동기 처리 결과를 렌더해주는 로직 하나만 해주고 있어서 콜백 헬이 발생하지 않는다.

만약 데이터를 받아와서 수정을 거치고 삭제도 하고 여러가지 로직을 다루게 되면 그 때마다 함수의 인수로 전달해줘야 하기 때문에 코드가 가로로 길어지게 된다.

또한, 콜백함수들에서 에러가 발생했을 경우 에러처리가 까다로워진다.

에러는 caller 즉, 함수 호출자(콜 스택하위)방향으로 전파가 된다. 비동기 함수는 호출자가 브라우저이므로 브라우저가 콜백함수를 호출할 때에는 이벤트 루프가 콜 스택에 비었을 때, 태스크 큐에서 콜 스택으로 콜백함수를 이동시켜(호출)주기 때문에 콜 스택 하위로 에러 전파가 발생하지 않아서 에러가 발생한 위치에서만 에러를 캐치해야한다는 단점이 있다.

프로미스로 비동기 처리 결과 다루기

프로미스는 객체의 완료 상태와 값을 저장한 객체이다. 이 때, 값에는 비동기 처리 성공시 resolve 함수의 인수로 전달되어 값이 저장되고 실패이 reject 함수의 인수로 전달된 에러가 저장된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const url = "https://api.artic.edu/api/v1/artworks";
const render = (items) =>
items.map((item) => `<li>${item.title}</li>`).join("");
const get = (url) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();

xhr.addEventListener("load", () => {
if (xhr.status === 200) {
resolve(render(JSON.parse(xhr.response).data));
} else {
reject(xhr.status);
}
});
});
const promise = get(url);
promise
.then((result) => {
document.querySelector("ul").innerHTML = result;
})
.catch((err) => {
console.error(err);
});

get 함수가 프로미스 객체를 반환하고 있다. 반환한 프로미스는 콜백함수를 인수로 받는데 그 콜백함수 안에서 로직을 구현하고 콜백함수의 인수로 2개의 함수를 전달한다. 성공했을 경우 호출함 함수 resolve 함수와 실패했을 때 호출할 함수 reject 함수를 구현한다.

get 함수를 호출하여 반환된 프로미스 객체를 후속 처리 메서드 then, catch가 이어서 받아서 처리를 해준다.

  • then 메서드 : 프로미스 객체의 상태가 pending 상태에서 fulfilled로 변경되면 resolve 함수의 인수를 인수로 전달 받아 콜백함수를 호출한다.

  • catch 메서드 : 프로미스 객체의 상태가 pending 상태에서 rejected로 변경되면 reject 함수의 인수를 인수로 전달받아 콜백함수를 호출한다.

catch 메서드를 사용하면 catch 이전의 구간에서 발생한 에러를 캐치할 수 있다.

async, await로 비동기 처리 결과 다루기

ES6에서 새로 생겨난 async, await를 사용하면 마치 동기 처리 방식처럼 비동기 처리결과를 다룰 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const url = "https://api.artic.edu/api/v1/artworks";
const render = (items) =>
items.map((item) => `<li>${item.title}</li>`).join("");
const get = (url) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();

xhr.addEventListener("load", () => {
if (xhr.status === 200) {
resolve(render(JSON.parse(xhr.response).data));
} else reject(xhr.status);
});
});
(async () => {
const titleData = await get(url);
document.querySelector("ul").innerHTML = titleData;
})();
  • await는 반드시 async 키워드로 선언된 함수 안에서 사용해야한다.

    단, Node.js 애플리케이션 환경에서 함수 레벨이 아닌 최상위 레벨에서도 await를 사용할 수 있다.

1
2
3
4
// package.json
{
"type": "module"
}

package.json 파일의 type을 module로 바꿔주면 독자적인 스코프인 모듈 스코프를 갖게되어 최상위 레벨에서도 await를 사용할 수 있다.

  • async를 사용하면 에러전파가 되어 에러처리가 용이하다.

제너레이터 함수와 연관지어 생각해보면 await 키워드를 만나면 일시 정지하고 제어권을 함수 외부로 이동시켜 동기 처리르 하다가 전역 코드가 다 실행되고 다시 일시 정지 했던 await 부분으로 돌아와서 프로미스 객체의 완료 상태가 settled로 바뀌면 코드를 실행하고 다음 코드 라인으로 넘어간다.

즉, 프로미스 객체의 완료 상태가 pending이면 다음 코드를 실행하지 않고 계속 대기한다.

결론

비동기를 동기처럼 다룰 수 있는 async,await를 사용하자.