...
자바스크립트 비동기 처리 3가지 방식
자바스크립트는 싱글 스레드 프로그래밍 언어기 때문에 멀티 작업을 하기 위해선 비동기 처리 방식이 자주 쓰인다. 비동기 처리는 백그라운드로 동작되기 때문에 그 결과가 언제 반환될지 알수 없어, 완료되면 결과를 받아 처리하기 위해 사용되는 대표적인 방법으로 콜백 함수(Callback) 와 이를 개선한 프로미스 객체(Promise)가 있다. 하지만 서비스 규모가 커질 수록 코드가 복잡해짐에 따라 코드를 중첩해서 사용하다가 가독성이 떨어지고 유지보수가 어려워지는 상황이 발생되게 되는데, 이를 Callback Hell, Promise Hell 이라고 불리운다.
/* Callback Hell */
getData (function (x) {
getMoreData (x, function (y) {
getMoreData (y, function (z) {
...
});
});
});
/* Promise Hell */
fetch('https://example.com/api')
.then(response => response.json())
.then(data => fetch(`https://example.com/api/${data.id}`))
.then(response => response.json())
.then(data => fetch(`https://example.com/api/${data.id}/details`))
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
위의 코드를 보면 알 수 있듯이 콜백 헬은 코드가 활처럼 굽어 보기가 엉성해지고, 프로미스 헬도 지나친 then 핸들러 남용으로 인해 구현하고자 하는 의도를 한번에 파악할수가 없다.
자바스크립트 async 와 await 는 이런 문제들을 해결하기 위해 탄생하였으며, 문법에 있어서도 훨씬 단순해져 가독성과 유지보수성을 향상 시켜준다.
async function getData() {
const response = await fetch('https://example.com/api');
const data = await response.json();
const response2 = await fetch(`https://example.com/api/${data.id}`);
const data2 = await response2.json();
const response3 = await fetch(`https://example.com/api/${data.id}/details`);
const data3 = await response3.json();
console.log(data3);
}
getData();
위 코드를 보면 콜백 헬과 같은 허리 굽은 코드는 없으며, 프로미스 헬과 같은 지나친 then 핸들러 남용으로 인한 가독성 하향도 없다. 마치 함수의 리턴값을 변수가 받는 정의문 형식대로 되어 있어 코드가 의도하고자 하는 바를 동일 코드 레벨 라인에서 알수가 있어 훨씬 편하다.
자바스크립트 async / await
async/await는 ES2017에 도입된 문법으로서, Promise 로직을 더 쉽고 간결하게 사용할 수 있게 해준다. 유의해야 할 점이 async/await가 Promise를 대체하기 위한 기능이 아니라는 것이다. 내부적으로는 여전히 Promise를 사용해서 비동기를 처리하고, 단지 코드 작성 부분을 프로그래머가 유지보수하게 편하게 보이는 문법만 다르게 해줄 뿐이라는 것이다.
마치 자바스크립트에서 prototype과 class 문법의 차이라고 봐도 무방하다. 자바스크립트를 자바와 같이 클래스 형식의 문법을 지원하더라고 내부적으로는 여전히 프로토타입 형식으로 객체 지향을 처리하듯이, async/await도 내부적으로는 프로미스 방식으로 비동기를 처리하지만, 문법 형태만 좀 더 간편한 스타일로 추가한 것으로 보면 된다.
async / await 기본 사용법
async 와 await 는 절차적 언어에서 작성하는 코드와 같이 사용법도 간단하고 이해하기도 쉽다. function 키워드 앞에 async 만 붙여주면 되고, 비동기로 처리되는 부분 앞에 await 만 붙여주면 된다.
다음은 setTimeout 비동기 함수를 이용해 delay 기능을 구현한 프로미스 객체 비동기 함수를 기존 Promise.then() 방식과 async/await 방식으로 똑같이 처리하지만 다르게 코드를 구현한 예제이다.
// 프로미스 객체 반환 함수
function delay(ms) {
return new Promise(resolve => {
setTimeout(() => {
console.log(`${ms} 밀리초가 지났습니다.`);
resolve()
}, ms);
});
}
// 기존 Promise.then() 형식
function main() {
delay(1000)
.then(() => {
return delay(2000);
})
.then(() => {
return Promise.resolve('끝');
})
.then(result => {
console.log(result);
});
}
// 메인 함수 호출
main();
// async/await 방식
async function main() {
await delay(1000);
await delay(2000);
const result = await Promise.resolve('끝');
console.log(result);
}
// 메인 함수 호출
main();
예시 코드를 보면 promise는 then 메서드를 연속적으로 사용하여 비동기 처리를 하지만, async/await는 await 키워드로 비동기 처리를 기다리고 있다는 것을 직관적으로 표현하고 있음을 볼 수 있다. async/await의 장점은 비동기적 접근방식을 동기적으로 작성할 수 있게 해주어 코드가 간결해지며 가독성을 높여져 유지보수를 용이하게 해준다.
async 키워드
async 키워드는 어렵게 생각할 필요없이 await를 사용하기 위한 선언문 정도로 이해하면 된다. 즉, function 앞에 async을 붙여줌으로써, 함수내에서 await 키워드를 사용할 수 있게 된다. 이는 반대로 말하면 await 키워드를 사용하기 위해선 반드시 async function 정의가 되어 있어야 한다는 말과 같다.
// 함수 선언식
async function func1() {
const res = await fetch(url); // 요청을 기다림
const data = await res.json(); // 응답을 JSON으로 파싱
}
func1();
// 함수 표현식
const func2 = async () => {
const res = await fetch(url); // 요청을 기다림
const data = await res.json(); // 응답을 JSON으로 파싱
}
func2();
async 리턴값은 Promise 객체
이렇게 async 키워드를 붙인 function에서 값을 리턴하면 어떻게 될까?
async function func1() {
return 1;
}
const data = func1();
console.log(data); // 프로미스 객체가 반환된다
단순 정수 1을 리턴했음에도 위 결과에서 보듯이, 이행(fulfilled) 상태의 프로미스 객체 형태로 반환됨을 볼 수 있다. 이를 통해 async function에서 어떤 값을 리턴하든 무조건 프로미스 객체로 감싸져 반환 된다는 특징을 알 수 가 있다.
다른 Promise 상태를 반환하기
물론 직접 프로미스 정적 메서드를 통해 다음과 같이 프로미스 상태(state)를 다르게 지정하여 반환이 가능하다.
async function resolveP() {
return Promise.resolve(2);
}
async function rejectP() {
return Promise.reject(2);
}
reject 같은 경우 위와 같이 Promise.reject() 정적 메서드를 통해 반환되는 프로미스 상태를 실패(rejected) 상태로 지정해줄 수 있지만, async 함수 내부에서 예외 throw 를 해도 실패 상태의 프로미스 객체가 반환되게 된다.
async function errorFunc() {
throw new Error("프로미스 reject 발생시킴");
}
만일 async function에서 일부러 return을 하지 않아도 자동으로 return undefiend 으로 처리 되기 때문에 어찌됫든 무조건 프로미스 객체를 반환하게 된다
async 함수와 then 핸들러
많은 새내기들이 오로히 프로미스 객체에만 then 핸들러를 붙일 수 있다고 생각한다. 위에서 배웠듯이 async 함수의 리턴값은 프로미스 객체이기 때문에 async 함수 자체에 then 핸들러를 붙일수도 있다.
async function func1() {
return 1;
}
func1()
.then(data => console.log(data));
다만 then 핸들러 코드 방식은 남용할 경우 Promise Hell에 걸릴 수 있기 때문에 이러한 방식을 이용하지 않고 대신에 await 키워드를 통해 처리한다.
await 키워드
await 키워드는 promise.then() 보다 좀 더 세련되게 비동기 처리의 결과값을 얻을 수 있도록 해주는 문법이라고 보면 된다. 예를들어 서버에 리소스를 요청하는 fetch() 비동기 함수를 다음과 같이 then 핸들러 방식으로 결과를 얻어 사용해왔을 것이다.
// then 핸들러 방식
fetch(url)
.then(res => res.json()) // 응답을 JSON으로 파싱
.then(data => {
// data 처리
console.log(data);
})
await 키워드를 사용하면 then 핸들러를 복잡하게 처리할 필요 없이, 심플하게 비동기 함수 왼쪽에 await 만 명시해주고 결과값을 변수에 받도록 코드를 정의하면 끝이다. then과 콜백 함수를 남발하여 코드가 들여쓰기로 깊어지는 것을 방지하고, 한 줄 레벨에서 코드를 나열하여 가독성을 높일 수 있다.
// await 방식
async function func() {
const res = await fetch(url); // 요청을 기다림
const data = await res.json(); // 응답을 JSON으로 파싱
// data 처리
console.log(data);
}
func();
await는 Promise 처리가 끝날때까지 기다림
await는 키워드 이름에서 보듯이 Promise 비동기 처리가 완료될때 까지 코드 실행을 일시 중지하고 wait 한다라는 뜻으로 보면 된다. 예를 들어 fetch() 함수를 사용하여 서버에서 데이터를 가져오는 경우를 생각해보자. 이 함수는 Promise를 반환한다. 따라서 await 키워드를 사용하여 이 Promise가 처리될 때까지 코드 실행을 일시 중지하고, Promise가 처리되면 결과 값을 반환하여 변수에 할당하는 식이다.
async function getData() {
const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
const data = await response.json();
console.log(data):
}
위 예제를 보면 getData() async 함수 내에서 fetch() 비동기 함수를 호출하고, 반환된 Promise를 await 키워드로 처리한다. 이로 인해 함수 내 코드 실행이 일시 중지되고, fetch() 함수가 완료될 때까지 기다리게 된다. 서버로부터 리소스를 성공적으로 가져와 fetch() 함수가 완료되면, 바로 다음 response.json() 함수를 호출하여 반환된 Promise를 다시 await로 처리한다. 다시 데이터를 가져오는 동안 코드 실행이 일시 중지되고, 데이터가 성공적으로 가져와지면 최종 결과 값을 반환한다. 따라서 await는 Promise를 처리하고 결과를 반환하는 데, 비동기적인 작업을 동기적으로 처리할 수 있게 되는 것이다.
async/await 에러 처리
기존의 Promise.then() 방식의 에러 처리는 catch() 핸들러를 중간 중간에 명시함으로써 에러를 받아야만 했다.
// then 핸들러 방식
function fetchResource(url) {
fetch(url)
.then(res => res.json()) // 응답을 JSON으로 파싱
.then(data => {
// data 처리
console.log(data);
})
.catch(err => {
// 에러 처리
console.error(err);
});
}
우리가 일반적으로 에러를 처리하기 위해선 try/catch 문을 사용하여 에러를 처리해왔다. async/await도 이와 같이 비동기 처리에 대한 에러를 처리할 필요가 생기면 고대로 try/catch문을 씌우면 되게 된다.
// async/await 방식
async function func() {
try {
const res = await fetch(url); // 요청을 기다림
const data = await res.json(); // 응답을 JSON으로 파싱
// data 처리
console.log(data);
} catch (err) {
// 에러 처리
console.error(err);
}
}
func();
이처럼 async/await의 장점은 비동기 코드를 마치 동기 코드처럼 읽히게 해준다는 것이다. 우리가 일반적으로 코드를 쓰고 읽어 내리듯이 말이다.
async / await 함정과 병렬처리
비동기 프로그래밍은 웹에서 메인 스레드를 차단하지 않고 시간 소모적인 작업을 병렬적으로 수행할 수 있도록 하는 웹 개발의 필수적인 부분이다. 자바스크립트에서 비동기 코드를 처리하는 문법으로 Promise와 await 키워드를 사용하는 것이다. 그러나 await를 깊은 이해 없이 막무가내로 사용하게 된다면 성능 문제 및 기타 문제가 발생할 수도 있다. await 자체가 Promise가 해결될 때까지 함수 실행을 일시 중지하는 것인데, 병렬적으로 멀티로 처리할 수 있는 작업을 억지로 동기적으로 처리하게 함으로써 오히려 2초만에 해결할 로직을 6초씩이나 걸리게 할 수 있기 때문이다.
따라서 await를 올바르게 사용하지 않으면 오히려 성능 문제가 발생하기 때문에, 자바스크립트 await 남용에 대한 몇 가지 주의 사항과 Promise.all을 통해 병렬 처리를 최적화하는 방법을 살펴볼 예정이다.
적절하지 않은 async/await 사용
아래 코드는 Promise를 사용하여 비동기식으로 과일 정보를 가져오는 함수들을 정의하고, 이를 조합하여 과일 정보를 출력하는 함수를 만드는 간단한 예제다.
우선, getApple() 함수는 "apple"이라는 문자열을 반환하는 Promise 객체를 생성한다. 이때 서버로부터 결과를 얻기위해 1초가 걸린다고 가정해보자. 마찬가지로, getBanana() 함수는 1초 후에 "banana"라는 문자열을 반환하는 Promise 객체를 생성한다.
function getApple(){
return new Promise( (resolve, reject) => {
setTimeout(() => resolve("apple"), 1000);
})
}
function getBanana(){
return new Promise( (resolve, reject) => {
setTimeout(() => resolve("banana"), 1000);
})
}
이를 우리가 지금까지 배운 async/await 형식의 비동기 처리 코드를 작성하자면 아래와 같을 것이다.
async function getFruites(){
let a = await getApple();
let b = await getBanana();
console.log(`${a} and ${b}`);
}
getFruites();
하지만 이러한 구성은 잠재적인 문제점이 존재한다. 우선 사과와 바나나를 가져오는 getApple() 와 getBanana() 비동기 함수는 서로 연관이 없다. 즉, 사과를 먼저 가져와도 되고 바나나를 먼저 가져와도 된다. 이를 비동기로 백그라운드에서 한꺼번에 처리해 가져오게 된다라면 1초를 아주아주 약간 넘기는 수준으로 약 1초 안으로 가져오는 것이 맞을 것이다.
하지만 생각없이 await 키워드를 두번 붙임으로써 두 과일을 가져오는데 동기적으로 실행됨으로써 총 2초가 걸리게 되었다. 시간 낭비인 셈이다. 이를 console.time() 을 통해 총 비동기 코드의 실행히간을 구해보면 아래와 같이 2초가 조금 넘는 시간이 걸림을 알 수 있다.
async function getFruites(){
console.time();
let a = await getApple(); // 1초 소요
let b = await getBanana(); // 1초 소요
console.log(`${a} and ${b}`);
console.timeEnd();
}
getFruites();
적절한 async/await 사용
await 키워드를 쓰면 비동기가 강제적으로 동기 처리가 되어 코드가 순차적으로 수행된다. 그러면 이를 어떻게 '병렬 처리 '한다는 것일까? 핵심은 프로미스 객체 함수를 await과 같이 써서 실행시키는게 아니라, 미리 함수를 동기/논블록킹으로 실행하고 그 결과 프로미스값을 await를 통해 받는 식이다.
기존에는 비동기 처리 요청을 하고 동시에 요청이 완료될때 까지 await 하였기 때문에 1초안에 처리될 것이 2초가 걸렸었다.
async function getFruites() {
let a = await getApple(); // getApple() 비동기 처리를 요청하고, 요청이 처리될때 까지 기다림 (1초 소요)
let b = await getBanana(); // getBanana() 비동기 처리를 요청하고, 요청이 처리될때 까지 기다림 (1초 소요)
console.log(`${a} and ${b}`); // 총 2초 소요
}
getApple() 와 getBanana() 비동기 로직이 만일 순서를 지켜야하는 로직이라면 위와 같이 구성하여야 하는 것이 옳지만, 현재로서는 서로 연관 없이 때문에 반드시 순차적으로 실행 시킬 필요가 없다. 따라서 비동기 처리 요청과 값을 await 하는 로직을 분리시키면 된다.
async function getFruites(){
let getApplePromise = getApple(); // async함수를 미리 논블록킹으로 실행한다.
let getBananaPromise = getBanana(); // async함수를 미리 논블록킹으로 실행한다.
// 이렇게 하면 각각 백단에서 독립적으로 거의 동시에 실행되게 된다.
console.log(getApplePromise)
console.log(getBananaPromise)
let a = await getApplePromise; // 위에서 받은 프로미스객체 결과 변수를 await을 통해 꺼낸다.
let b = await getBananaPromise; // 위에서 받은 프로미스객체 결과 변수를 await을 통해 꺼낸다.
console.log(`${a} and ${b}`); // 본래라면 1초+1초 를 기다려야 하는데, 위에서 1초기다리는 함수를 바로 연속으로 비동기로 불려왔기 때문에, 대충 1.01초만 기다리면 처리된다.
})
Promise.all 메소드
또다른 방법으론 Promise.all() 정적 메서드를 사용하는 방법이 있다. 위와 같이 구성할 경우 비동기 처리 완료 시점을 가늠하기 힘들기 때문에 대부분의 실무에선 Promise.all()로 처리한다.
Promise.all() 은 배열 인자의 각 프로미스 비동기 함수들이 resolve가 모두 되야 결과를 리턴 받는다. 배열인자의 각 프로미스 함수들은 제각각 비동기 논블록킹으로 실행되어 시간을 단축 할 수 있다. 리턴값은 각 프로미스 함수의 반환값들이 배열로 담겨져 있다.
async function getFruites(){
console.time();
// 구조 분해로 각 프로미스 리턴값들을 변수에 담는다.
let [ a, b ] = await Promise.all([getApple(), getBanana()]);
console.log(`${a} and ${b}`);
console.timeEnd();
}
getFruites();
top-level await
기존에는 await 키워드를 사용하기 위해선 무조건 async function를 정의하고 async 함수를 호출하는 식으로 사용해와야 했다. 그래서 굳이 함수로 감싸지 않아도 될 코드를 어거지로 함수로 감싸 IIFE로 호출하는 식으로 사용해왔다.
(async function func1() {
const res = await fetch(url); // 요청을 기다림
const data = await res.json(); // 응답을 JSON으로 파싱
console.log(data);
})();
이러한 한계점 때문에 추가된 Top-level await 란 async 함수나 모듈 외부에서도 await 키워드를 사용할 수 있게 해주는 편의 기능이다. 이 기능은 ES2022에 추가되었으며, 현재 대부분의 최신 브라우저와 Node.js에서 지원된다.
// Top Level에선 async funciton 정의없이 곧바로 await 키워드 사용이 가능하다
const res = await fetch(url); // 요청을 기다림
const data = await res.json(); // 응답을 JSON으로 파싱
console.log(data);
# 참고자료
https://www.youtube.com/watch?v=aoQSOZfz3vQ
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.