자바스크립트 비동기 처리 3가지 방식
JS는 싱글 스레드 프로그래밍 언어기에 멀티 작업을 하기 위해서는 비동기 처리 방식이 자주 쓰인다.
비동기 처리는 백그라운드로 동작되기에 결과가 언제 반환될지 알 수 없어, 완료되면 결과를 받아 처리하기 위해 대표적으로 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));
위의 코드를 보면 Callback hell과, then 핸들러의 남용은 구현하고자하는 의도의 파악과 가독성이 크게 떨어진다.
JS의 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();
위 코드를 보면 마치 함수의 리턴값을 변수가 받은 정의문 형식대로 되어 있어 코드가 의도하고자 하는 바를 동일 코드 레벨 라인에서 아수가 있어 편하다.
JS async/await
async/await는 ES2017에 도입된 문법으로, Promise 로직을 더 쉽고 간결하게 사용할 수 있게 해준다. async/await가 Promise를 대체하기 위한 기능이 아니라는 것이다. 내부적으로는 여전히 Promise를 사용해서 비동기를 처리하고, 단지 코드 작성 부분을 프로그래머가 유지보수학 편하게 보이는 문법만 다르게 해줄 뿐이라는 것이다.
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 키워드
await를 사용하기 위한 선언문이다. 즉 function앞에 async를 붙여줌으로써, 함수 내에서 await 키워드를 사용할 수 있게 된다.
// 함수 선언식
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 fuction에서 어떤 값을 리턴하든 무조건 promise 객체로 감싸져 반환된다.
async function func1() {
return 1;
}
const data = func1();
console.log(data); // 프로미스 객체가 반환된다
다른 Promise 상태를 반환하기
직접 Promise 정적 메서드를 통해 다음과 같이 Promise 상태(state)를 다르게 지정하여 반환이 가능하다.
async function resolveP() {
return Promise.resolve(2);
}
async function rejectP() {
return Promise.reject(2);
}
reject 같은 경우 위와 같이 Promise.reject() 정적 메서드를 통해 반환되는 Promise 상태를 실패(rejected) 상태로 지정해줄 수있지만, async 함수 내부에서 예외 throw를 해도 실패 상태의 Promise 객체가 반환된다.
async function errorFunc() {
throw new Error("프로미스 reject 발생시킴");
}
만일 async fuction에서 일부러 return을 하지 않아도 자동으로 return undefined으로 처리되게 때문에 어찌됬든 무조건 Promise 객체를 반환하게 된다.
async 함수와 then 핸들러
Promise 객체를 반환하기에 then 핸들러를 붙일 수 있다. 허나 then 핸들러를 남용할 경우 Promise Hell에 걸릴 수 있기에 대신 await 사용을 권장한다.
async function func1() {
return 1;
}
func1()
.then(data => console.log(data));
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):
}
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);
});
}
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 함정과 병렬처리
비동기 프로그래밍은 웹에서 메인 스레드를 차단하지 않고 시간 소모적인 작업을 병렬적으로 수행할 수있도록 하는 웹 개발의 필수적인 부분이다.
JS에서 비동기 코드를 처리하는 문법으로 Promise, await 키워드를 사용하는 것 이다. 그러나 await를 남발하면 성능 문제 및 기타 문제가 발생할 수 있다. await는 Promise가 해결될 때까지 함수 실행을 일시 중지 하는 것인데, 병렬적으로 멀티로 처리할 수 있는 작업을 억지로 동기적으로 처리하게 함으로써 오히려 2초만에 해결할 로직을 6초씩이나 걸리게 할 수 있기 때문이다.
따라서 await를 올바르게 사용하지 않으면 오히러 성능 문제가 발생하기에, JS await 남용에 대한 몇 가지 주의 사항과 Promise.all을 통해 병렬 처리를 최적화하는 방법을 살펴볼 예정이다.
적절하지 않은 async/await 사용
function getApple(){
return new Promise( (resolve, reject) => {
setTimeout(() => resolve("apple"), 1000);
})
}
function getBanana(){
return new Promise( (resolve, reject) => {
setTimeout(() => resolve("banana"), 1000);
})
}
async function getFruites(){
let a = await getApple();
let b = await getBanana();
console.log(`${a} and ${b}`);
}
getFruites();
위의 코드에서 apple과 banana는 1초가 걸린다. 만일 이를 병렬적으로 처리하면 console에 찍히기 까지 1초가걸리지만, 동기적으로 처리하면 2초가 걸린다. 위의 비동기함수 getApple, getBanana는 서로 연관이 없다. 그럼에서 생각없이 await 키워드를 두 번 붙이면 동기적으로 실행되는데, 이는 명백한 시간 낭비이다.
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 사용
핵심은 Promise 객체 함수를 await와 같이 써서 실행시키는 것이 아니라, 미리 함수를 동기/논블록킹으로 실행하고 그 결과 Promise값을 await를 통해 받는 식이다.
위의 코드 처럼 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()은 배열 인자의 각 Promise 비동기 함수들이 모두 resolve되어야 결과를 리턴 받는다. 배열 인자의 각 promise 함수들은 제각각 비동기 논블로킹으로 실행되어 시간을 단축할 수 있다. return 값은 각 promise 함수의 반환값들이 배열로 담겨져 있다.
async function getFruites(){
console.time();
// 구조 분해로 각 프로미스 리턴값들을 변수에 담는다.
let [ a, b ] = await Promise.all([getApple(), getBanana()]);
console.log(`${a} and ${b}`);
console.timeEnd();
}
getFruites();
출처
'JS' 카테고리의 다른 글
동기, 비동기 (1) | 2024.06.12 |
---|---|
Call back 함수 (1) | 2024.06.11 |
Binding의 개념과 call, apply, bind (1) | 2024.06.08 |