[10분 테코톡] 📖 카일의 프론트엔드의 비동기(16분) 를 보고 정리하면서 추가적인 내용이 있는 글입니다 :)
자바스크립트 개발자들은 꽤 오랜 시간 동안 비동기 처리를 위해 콜백 함수를 사용해왔습니다. 하지만 요새는 단순히 이 방법만으로 비동기를 처리하는 코드는 거의 보지 못할 것입니다.
콜백 기반의 비동기 처리 방식이 가지고 있었던 한계와 그 대안점으로 출현한 Promise
, Async Function
이 해결해고자 한 것이 무엇인지 살펴보겠습니다.
Why & how ?
1. 프론트 엔드에서 비동기를 왜, 어떻게 처리해야 할까?
2. Promise와 Async function은 왜, 어떻게 사용할까?
비동기의 정의 📝
한 단어로 표현하자면 '간극' (시간 사이의 틈, 두 가지 현상 사이의 틈)
=> 현재의 시점과 나중에 시점에 그사이에 어떤 틈이 존재할 수 있다는 말
사용자와 가장 밀접하게 닿아있는 프론트 엔드 영역에서는 주기적으로 발생하는 인터렉션(게시물의 좋아요, 수정, 삭제, 등등)을 처리하면서 연속적으로 변경된 정보를 실시간으로 사용자에게 보여줄 수 있어야 했습니다.
이와 같은 목표에 도달하기 위해서는 모든 인터렉션을 그저 들어온 순서대로만 처리할 수 없었습니다. 타이머를 사용한 이벤트, 서버와의 네트워크 통신, 애니메이션 등 '간극'을 예측할 수 없는 여러 요인들이 차차 생겨나면서 자연스레 대기 시간이 발생했기 때문입니다.
유저들에게 있어서 대기 시간이 늘어나면 답답함을 느끼고 이탈률은 빠르게 증가할 것입니다. 이는 전반적인 웹 이용률의 하락이라는 결과를 불러옵니다. 결국 프론트엔드 영역에서 적절한 비동기 처리는 피할 수 없는 과제인 셈입니다.
제어의 역전 (Inversion Of Control)
=> 제어권의 주체가 뒤바뀌는 현상을 말함.
function BrowserTasks() {
// 제어의 대상
console.log('sync task');
asyncRequest(asyncTask);
}
function asyncRequest(callBackFn) {
ajax('url', function(data) { // ⭐️
callBackFn(data);
});
}
funciton asyncTask(data) {
console.log(data);
}
⭐️ 비동기 요청과 함께 전달된 콜백은 외부 라이브러리에 대한 의존성을 가져서 제어권의 주체가 뒤바뀝니다.
⭐️ 콜백 내부에서 데이터에 대한 예외처리는 가능해도 비동기 요청-콜백 호출로 이어지는 흐름은 외부에서 관찰하거나 제어할 수 없습니다.
❗️이렇게 제어권이 뒤바뀌는 현상을 '제어의 역전'이라 합니다.❗️
- 필요한 데이터를 콜백에 전달 했는지에 대한 여부
- 호출 시점 (빠르거나, 느리거나)
- 호출 횟수 (호출을 아예 안하거나, 많이 호출하거나)
유저가 어떤 상품의 구매 버튼을 눌렀을때, 서버에 요청을 보내는 콜백이 이와 같은 예시 중 하나라도 해당된다면 어떻게 될까요?
이러한 버그는 곧 매출에 직결되는 문제를 만듭니다.
더 심각한 것은 여기에 나열한 케이스는 최소한의 경우이고 이런 상황 또한 많은 예시 중 하나일 뿐이라는 점입니다.
- 미래에 값을 반환할 수도 있는 함수를 캡슐화한 객체
- 제어의 재역전 (제어를 할 수 없었다가 다시 제어를 할 수 있게 됨)
- 비동기 요청 수행에 대한 세 가지(성공, 실패, 대기)의 상태를 가지고 있다.
- 내부에서 비동기 요청이 끝나고 나면 결과값을 연결된 콜백으로 보내준다.
함수가 비동기 적인 작업을 한다 => 그 함수는 Promise를 반환한다
- Promise는 성공했을 때 부를 함수, 실패했을때 부를 함수를 가진다.
프로미스는 위에서 제시한 콜백의 문제점을 피하고자 생겨난 ES6의 새로운 문법입니다. 한마디로 정의하자면 ‘미래에 값을 반환할 수도 있는 함수를 캡슐화한 객체’라고 표현할 수 있습니다.
이것을 통해 이루고자 하는 목표는 간단했습니다. 바로 '코드에 대한 제어권'을 되찾는 것이었는데요, 이러한 요건을 충족하기 위해서 두가지 특징(위의 3,4번)을 대표적으로 가지고 있습니다.
위에서 callback
함수로 구현했던 코드를 Promise
를 사용해 변환한 코드입니다.
function request() {
return new Promise(function (resolve, reject) { // 1️⃣
ajax('url', function (data) { // 2️⃣
if (data) {
resolve(data);
} else {
reject('Error!');
}
});
});
}
function asyncTask() {
const promise = request();
promise // 3️⃣
.then(function (data) {
// 👍🏻 받아온 data를 이용한 일련의 작업 수행.
})
.catch(function (error) {
// 👎🏻 받아온 error를 이용한 예외 처리.
});
}
Promise 객체를 만들 때 인자로 들어가는 함수를 executor 함수라고 하는데 이 함수는 비동기 요청의 수행 결과에 따라 데이터를 넘겨줄 콜백을 인자(resolve, reject)로 받습니다.
여기서 유의해야 할 점은 executor 함수의 로직은 Promise 객체가 만들어지는 즉시 실행된다는 것입니다. 때문에 버튼의 클릭같은 반복적인 이벤트 내에서 비동기 요청을 수행해야 하는 경우라면, 다음과 같은 방법은 적절하지 않을 수도 있습니다.
그리고 이 부분에서 promise 객체의 then, catch 메서드를 통해 데이터를 넘겨 받을 콜백 함수를 연결시켜 줍니다. 이러한 매커니즘으로 우리는 더이상 외부 라이브러리에 콜백 함수를 넘겨주지 않고, 비동기 요청에 대한 결과를 받아와서 직접 처리할 수 있게 되었습니다.
제어권을 확보했기 때문에 콜백 방식에서 신뢰할 수 없었던 여러 상황을 대처할 수 있다.
체이닝을 통해 구조화된 콜백을 작성할 수 있다.
Promise 객체 외부에서 Promise 내의 연쇄적인 흐름에 대한 예외처리는 어렵다.
단일 값 전달의 한계 → 여러 개의 값이 연관성이 부족하더라도 다음 메소드로 넘겨주려면 객체, 배열로 감싸야 한다는 번거로움이 있다.
단순 콜백 처리와 비교했을 때 성능 저하에 대한 우려도 있을 수 있음.
이렇게 몇가지 단점도 있지만 기존의 콜백 처리와 비교했을때 Promise가 제공하는 신뢰성과 예측성은 이 모든 점을 아우르고도 충분히 사용할 가치가 있습니다.
두번째 콜백의 문제점은 ‘낮은 가독성’입니다. 콜백 지옥을 경험하거나 보신적이 있으실 것 같습니다. 이런 콜백 지옥을 지양해야 하는 이유는 단순히 코드가 길어서 읽기 힘든 것일까요?
일반적으로 인간의 사고방식은 일이 순차적으로 이루어지는 것에 더 친숙함을 느낍니다. 코드는 수많은 인과관계로 얽혀 있으며, 우리가 자주 하는 디버깅은 그런 인과관계의 흐름이 끊긴 부분을 찾아서 마치 점과 점 사이를 선으로 잇듯이 생각을 확장 시켜 나가는 과정입니다. 그렇기에 직선적인 논리의 흐름은 코드를 읽는 과정에서 중요한 전제라고 할 수 있습니다.
하지만 비동기 코드는 동작 방식의 특성 상 개발자들에게 직선적인 추론을 제시할 수 가 없었습니다. 다음과 같은 코드를 위에서 아래로 순차적으로 읽어 보겠습니다.
function A(callback) {
console.log("A"); // 1.
setTimeout(() => callback(), 0);
}
function B() {
console.log("B"); // 3.
}
function C() {
console.log("C"); // 2.
}
A(B);
C();
출력 값의 순서는 A → C → B 라는 사실을 추론하기 위해서 우리는 머리 속에서 최소 두개의 맥락을 고려해야 합니다. 즉 비동기 코드의 양이 많아질수록 추론의 순서는 이런 식으로 전개되기 때문에 개발자들의 디버깅 작업을 더욱 피로하게 만듭니다. 결국 비동기 코드의 가독성 문제는 인간의 사고방식과 동떨어진 비동기 자체의 특성으로 간주할 수 있습니다.
ES6 시기에는 Promise의 구조화된 콜백으로 이전 방식과 비교해 약간의 가독성은 챙길 수 있었지만, 비동기 코드 자체의 가독성 문제를 해결하진 못했기 때문에 개발자들은 이것을 동기 코드처럼 볼 수 있는 방법에 대해 탐구하기 시작했습니다.
- Promise에 Generator를 더한 일종의 Syntatic Sugar로 소개되었음
- Async는 두 문법의 매커니즘을 차용하면서, 코드에 대한 가독성을 높였음
- 함수 내에서 await 문을 만나면 함수의 실행을 일시 중지
- await 뒤에 있는 프로미스의 수행 결과 값을 받아 함수 재진행
Async function의 특징을 잘 보면, generator 함수의 매커니즘과도 다소 닮아 있는 것을 확인할 수 있습니다. (외부의 값을 기다렸다가 받은 시점에서 함수를 실행한다)
async function f() {
return Promise.resolve(1);
}
f().then(alert); // 1
promise.then
처럼 await
에도 thenable 객체(then 메서드가 있는 호출 가능한 객체)를 사용할 수 있습니다. async function f() {
let promise = new Promise((resolve, reject) => {
setTimeout(() => resolve("완료!"), 1000)
});
let result = await promise; // 프라미스가 이행될 때까지 기다림 (*)
// 프라미스가 처리되길 기다리는 동안엔 엔진이 다른 일을 할 수 있기 때문에, CPU 리소스가 낭비되지 않습니다.
alert(result); // "완료!"
}
f();
function request() {
return new Promise((resolve) => {
ajax("url", function (data) {
resolve(data);
});
});
}
async function asyncTask() {
try {
const data = await request(); // ⭐️
// 받아온 data를 이용한 일련의 작업 수행.
} catch (error) {
// handle error
}
}
한 눈에 봐도 가독성이 많이 좋아진 것을 확인할 수 있습니다.
⭐️ await문은 뒤에 있는 주체가 Promise 일때만 그 간극을 기다립니다. 만약 Promise 내부 수행 과정에서 에러가 발생했다면 해당 에러를 throw 한 것과 동일한 동작을 수행하고 에러가 발생하지 않으면 Promise의 결과 값을 반환하게 됩니다.
결과적으로 async 에서 프로미스 흐름을 관찰할 수 있기 때문에 가독성 향상에 더하여 예외 처리 까지 가능하게 된 것입니다.
await을 통해 반환 받은 것이 Promise의 수행된 ‘값’이기 때문에 외부에서 예외처리가 용이하다.
다른 방법에 비해 높은 가독성을 얻을 수 있다.
Promise 에 대한 syntatic sugar이기 때문에 Promise에 대한 이해가 선행되어야 한다.
하나의 함수 안에서 다수의 Promise를 병렬적으로 처리할 수 없다. → Promise.all, Promise.race
경우에 따라 async 키워드를 관련 함수마다 일일이 선언해야 할 수도 있다.
function wait(sec) {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject("wait Error"); // 3. 에러 발생
}, sec * 1000);
});
}
async function myAsyncFun() {
console.log(new Date()); // 1. 현재시간 출력
try {
await wait(3); // 2.
} catch (e) {
console.error(e); // 4. 에러 출력
}
console.log(new Date()); // 5. 현재시간 출력
}
myAsyncFun();
[10분 테코톡] 📖 카일의 프론트엔드의 비동기 - https://www.youtube.com/watch?v=fsmekO1fQcw
async와 await - https://ko.javascript.info/async-await