비동기란 무엇이죠? 왜 비동기가 필요한가요? 🤔
유튜브를 보려고 하는데 실수로 다른 영상을 클릭해버렸다... 그런데 이 유튜브세상은 모든 것이 순차적으로 동작하기 때문에, 다른 버튼을 클릭하기 위해서는 그 실수로 클릭한 영상이 모두 로딩이 될 때 까지 기다려야만 한다.
그냥 손이 미끄러졌을 뿐인데.. 정말 화나지 않을까?
100% 정확한 예시는 아닐 수도 있지만 어쨌든, 이처럼 하나의 동작이 모두 완료가 되어야만 그 다음 동작을 할 수 있는 처리 방식을 동기적 처리 방식이라고 부른다. 이렇게 순차적으로 실행되는 방식도 물론 필요하겠지만, 웹페이지처럼 모든 동작을 순서에 상관 없이 실행시킬 수 있어야 한다면, 앞선 예시처럼 불편한 상황이 일어날 것이다.
그런 문제점을 해결하기 위해 비동기적 처리 방식을 사용할 수 있다. 비동기란 '동기가 아니다'라는 의미로, 이 뜻은 처리 과정이 동기적이지 않다, 즉 여러 동작들이 순서에 상관 없이 실행될 수 있다는 의미이다.
유튜브 메인페이지에서 알고리즘이 영상을 추천해줄 때를 떠올려보라. 만약 동기적으로 로딩이 된다면 1번 영상 썸네일이 로딩이 되면 2번 영상 썸네일이 로딩되고 ... 이렇게 동작하겠지만, 많은 영상들이 동시에 로딩이 되고, 영상을 클릭했을 때 그 영상이 채 로딩되기도 전에 다른 썸네일을 클릭해 다른 동작을 할 수도 있다!
바꿔말하면 이런식으로 유연하고 또 효율적인 처리를 할 수 있기 때문에 비동기적 프로그래밍을 하는 것이다!
참고로 자바스크립트는 코드를 실행할 수 있는 공간이 하나밖에 없기 때문에(싱글스레드) 코드를 순차적으로 실행하는데, 비동기 함수는 백그라운드에서 요청을 처리하기 때문에 메인 콜스택에서 함수를 실행하면서도 비동기 요청을 처리할 수 있다...!
콜백함수로 비동기 처리 핸들링
비동기 요청이 말그대로 순서에 상관 없이 일어나기 때문에, 각각 다른 데이터를 가지고 비동기 요청을 하면, 내가 실행한 순서와 상관 없이 먼저 처리가 끝나는 순서대로 동작이 일어날 것이다.
따라서 비동기 요청에서도, 하나의 요청이 끝나면 그 다음 요청이 실행되도록 순서를 제어해줄 수 있다.
"이 비동기 요청이 끝나면, 이 함수를 실행시켜줘..!"...라고 말하며 콜백함수를 전달했다.
이벤트 리스너를 이용해 뭔가 로딩이 완료된 상태일 때 실행시키도록 할 수도 있고, 성공적으로 처리가 되었을 때 / 처리가 실패했을때를 가정하여 두 가지 콜백함수를 전달해 결과를 핸들링할 수 있다.
(아래 내용은 코드를 포함한 예제와 설명이다. 더보기를 클릭해 볼 수 있다.)
먼저 첫번째 함수인 taskA는 3000밀리초 즉 3초의 대기시간을 가진 뒤 콜백함수를 실행하고, 두번째 함수인 taskB는 1초의 대기시간을 가진 뒤 콜백함수를 실행한다.
만약 taskA를 먼저 호출하고 taskB를 그 다음에 호출하면 어떻게 될까?
결과는 당연하게도 1초의 대기시간을 가진 뒤 B가 실행되고, 그 뒤 2초를 더 기다려 A가 실행될 것이다. 현재 예제에서는 setTimeOut으로 처리가 실행될 시점을 지정해주었지만, fetch 등으로 API를 호출해 데이터를 받아온다면 어느 데이터가 먼저 올지 불명확하다.
따라서, 먼저 실행해줄 작업이 완료된 뒤에 다음 작업이 실행될 수 있도록 함수 전체를 콜백함수로써 전달해버리면 순서를 확실하게 정해줄 수 있을 것이다.
위 예제코드에서는, taskA 함수에 특정 값을 넣고 전달하고, 일정 시간이 지난 뒤 3번째로 전달된 콜백함수를 실행시킨다. 이 콜백함수는 taskA에서 처리된 결과값을 a_res라는 매개변수로 받아서 출력한 뒤, 곧바로 taskB에 해당 결과값을 넣어 다음 비동기 함수를 요청하고있다.
이런식으로 실행시키면, 결과값은 우리가 예상하듯 A먼저 그다음 B 가 실행될 것이다.
그런데 문제는, 현재 예제에서는 처리할 비동기 함수가 단 2개밖에 없기 때문에 상관없어보이지만 만약 10개, 20개가 존재한다면?? 콜백함수로 전달된 함수 안에 또 콜백함수를 전달하고 그 안에 또 콜백함수를 전달하는 무한 굴레에 빠질 수도 있다.
이런 코드는 일단 가독성이 너무 떨어지고, 유지보수가 어렵기 때문에 이를 해결하기 위해 우리는 promise 객체를 사용할 수 있다!
Promise 객체로 콜백 지옥 탈출하기
Promise 프로미스 객체란 무엇일까? 단어의 의미를 통해서 무언가 약속하다 혹은 약속되어있는 객체- 라고 유추해볼 수 있을 것 같다. 그러면 무엇을 약속하는 객체일까? promise로 비동기 함수의 실행 순서를 제어할 수 있다고 했으니까 무언가 비동기와 관련된 그런 객체가 아닐까?
프로미스 객체란 미래의 비동기 작업의 처리 결과를 저장해두기 위한 객체, 즉 미래의 어떤 시점에는 결과를 제공하겠다는 약속을 받은 객체라고 할 수 있다. 프로미스 객체가 방금 따끈따끈~하게 생성된 상태에서는 아직 어떤 값도 가지고 있지는 않지만, 미래의 어느 시점에는 여기에 비동기의 처리 결과가 담기게 된다는 것이다.
비동기 처리의 결과는 크게 두 가지로 나눌 수 있다. 성공했느냐? or 실패했느냐? 성공했다면 그에 맞는 데이터를 가지고 있는 프로미스가 반환될 것이고, 실패했다면 또 그에 맞는 프로미스가 반환될 것이다. 일단 아래 코드를 보자.
- 새로운 프로미스 객체를 new 연산자를 붙여 생성하였다. 프로미스 객체는 단 하나의 함수만을 인자로 받을 수 있는데, 이를 실행자 함수 executor 함수라고 부른다. 이 실행자 함수는 프로미스 객체가 생성되는 즉시 실행된다.
- 이 함수가 하는 일은 뭔가 비동기적인 처리를 해서 그 결과 값을 새로 생성된 프로미스 객체에 담아주는 역할이다. (그래서 실행자..! 비동기처리의 실행자..?🤪
const promise = new Promise((resolve, reject) => {});
console.log(promise);
//Promise { <pending> }
- 프로미스 객체에 실행자 함수가 전달되었고, 아직 내용은 아무것도 없다. 눈에 띄는 점은, 이 실행자 함수의 매개변수에 resolve와 reject라는 이름의 두 녀석이 있다.
mdn에 따르면, 실행자 함수는 두 개의 함수를 전달인자로 받아서 실행시킬 수 있는데 이름을 보면 알겠지만 resolve(해결하다) 이 녀석은 처리결과가 성공적일 때 실행되는 함수이고, reject(거부하다) 이 함수는 처리 결과가 거부되었을 때, 실패되었을 때 실행되는 함수이다.
참고로 프로미스는 3가지의 상태를 가지고 있는데, 하나는 프로미스가 실행되기 이전의 대기상태 pending, 실행이 되었고 결과가 성공적일 때의 상태 fulfilled, 처리가 실패했을 때의 상태 rejected 가 있다.
즉, 프로미스의 처리 결과에 따라서 resolve가 실행될지 reject가 실행될지 여부가 결정된다는 의미이다! 성공적으로 처리가 되었을 때 즉 프로미스가 fulfilled 상태일때 resolve 함수가 실행되어서 그 결과값을 리턴하고, rejected 상태일때는 reject 함수가 실행된다!
const promise = value => {
return new Promise((resolve, reject) => {
if (value === true) resolve('성공');
else reject('실패...');
});
};
console.log(promise(true)); //Promise { '성공' }
- 이제 이 코드를 보면, promise 라는 함수에 boolean 값을 인자로 전달한다. 함수 안에서는 전달 인자를 활용해 성공 혹은 실패의 결과값을 가지는 프로미스 객체를 리턴하고 있다. true 값을 넣어 호출했기 때문에 resolve 함수가 실행되고, '성공'이라는 결과값을 담은 Promise 객체가 반환되었다.
then, catch 메서드 사용하기
지금까지의 내용을 간략하게 정리해보자. 프로미스는 비동기처리의 결과가 담긴 객체이고, 이 객체의 상태는 크게 두가지로 나눌 수 있다. 성공이냐 혹은 실패냐! 두 경우 모두 결과 값이 담긴 프로미스를 리턴하는데, 거기에 담긴 값은 성공 혹은 실패 여부에 따라서 실행된 두가지 함수 resolve, reject와 함께 전달된 데이터이다.
다시 비동기의 흐름 제어로 돌아가보자. 우리가 하고 싶은 것은 앞선 비동기 처리가 완료된 후 실행될 그 다음 비동기 요청을 순차적으로 실행하는 것이다. 아까 프로미스 객체를 사용하지 않았을 때는 콜백함수 안에 콜백함수를 전달해 순서를 제어할 수 있었고, 콜백 지옥을 만들게 되었다. 프로미스에서는 어떤 방법을 쓰길래 콜백함수의 대안이 될 수 있는걸까?
바로 .then()과 .catch 메서드를 활용하는 것이다. 프로미스 객체에 대하여 실행할 수 있는 이 두 메서드는, 비동기 처리가 끝난 프로미스에 대해서 그 후속 동작을 수행하도록 할 수 있다. 아래 그림을 다시 보자.
비동기 처리가 성공적으로 완료된, 즉 프로미스가 fulfilled (이행된) 상태를 가정하자. 화살표를 따라가면 .then(on Fulfillment) 라는 박스가 있다(파랑느낌표!!). 이 것이 fulfilled 상태의 프로미스에 대해서 .then 메서드를 실행할 수 있다는 의미이다. 거기서 또 다른 비동기 요청을 할 수도 있고, fulfillment에 의해 전달된 결과값을 가공할 수도 있다. 그 다음 return 화살표를 따라가면 다시 promise로 연결이 되고, 그 프로미스에 대해서 또 then, catch 메서드를 쓸 수 있다는 것이다.
즉 then과 catch 메서드는 프로미스를 리턴한다. 그렇기 때문에 계속해서 이 메서드들을 연결시켜서 사용하기 때문에 콜백지옥을 예방할 수 있는 것이다. 아래 코드 예시를 살펴보자! (예시 출처는 코드스테이츠 과제)
const getDataFromFilePromise = filePath => {
return new Promise((resolve, reject) => {
fs.readFile(filePath, 'utf-8', (err, data) => {
if (err) {
reject(err);
}
resolve(data);
});
});
};
- 먼저 이 코드는 프로미스를 이행하는 함수이다. node.js의 fs 모듈로 로컬 내의 파일을 불러와 내용을 담은 프로미스 객체를 리턴하고 있다. (node.js의 fs 모듈 메서드는 비동기적으로 실행된다.) 이 fs.readFile 메서드의 세번째 인자로 콜백함수를 전달해야 하는데, 이 때 err 과 data 를 인자로 받을 수 있다.
- 실행 과정에서 에러가 발생하면 err을, 성공적이면 data를 받게 될 것이고, 그에 따라서 맞는 프로미스의 함수 reject와 resolve를 실행하며 각 데이터를 넣어 전달하고 있다.
- 이 getDataFromFilePromise라는 함수가 성공적인 결과 혹은 실패한 결과(err)을 가진 프로미스를 리턴하기 때문에, 여기에 then 혹은 catch를 사용해서 후속 처리를 해줄 수 있을 것이다.
const readAllUsersChaining = () => {
let data1;
return getDataFromFilePromise(user1Path)
.then(data => {
data1 = JSON.parse(data);
return getDataFromFilePromise(user2Path);
})
.then(data2 => {
data2 = JSON.parse(data2);
return [data1, data2];
})
.catch(err => {
console.log(err);
});
};
- 이렇게 리턴받은 프로미스에 .then을 사용해 후속 동작을 실행시키면서 체이닝을 이어가고 있다. 이 때 then 안에서 프로미스가 아닌 일반 값을 return 한다면, 그 결과값을 담은 프로미스가 리턴되는 것이다. 위 코드에서는 두개의 객체를 각각 data1과 data2에 담아 배열로 리턴하고 있는데, 최종적인 리턴 값은 두 내용이 배열안에 담겨있는 프로미스 객체이다.
Promise.all 사용하기
Promise.all은 여러개의 프로미스를 동시에 실행시키고, 모든 프로미스가 실행되어 준비된 상태에서 다음 동작을 처리할 때 사용할 수 있다. 위의 예제를 예시로 들면, 첫번째 프로미스와 두번째 프로미스가 모두 완료되면 배열에 담아 새로운 프로미스를 리턴하고 있는데, 이를 Promise.all을 이용해, 각각의 프로미스가 모두 준비되면 => 어떤 동작을 실행하라 이렇게 코드를 짤 수 있다.
const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'foo');
});
Promise.all([promise1, promise2, promise3]).then((values) => {
console.log(values);
});
// expected output: Array [3, 42, "foo"]
- Promise.all은 각각의 프로미스들을 배열형태로 받기 때문에, 배열에 담아 전달한다. Promise의 리턴값 또한 새로운 프로미스이기 때문에 then이나 catch 메서드로 핸들링할 수 있다. 또한 프로미스 중 하나라도 reject되면, Promise.all도 reject 한다.
var p1 = new Promise((resolve, reject) => {
setTimeout(() => resolve('하나'), 1000);
});
var p2 = new Promise((resolve, reject) => {
setTimeout(() => resolve('둘'), 2000);
});
var p3 = new Promise((resolve, reject) => {
setTimeout(() => resolve('셋'), 3000);
});
var p4 = new Promise((resolve, reject) => {
setTimeout(() => resolve('넷'), 4000);
});
var p5 = new Promise((resolve, reject) => {
reject(new Error('거부'));
});
// .catch 사용:
Promise.all([p1, p2, p3, p4, p5])
.then(values => {
console.log(values);
})
.catch(error => {
console.log(error.message)
});
// 콘솔 출력값:
// "거부"
참고 자료
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise/all
https://ko.javascript.info/promise-api
https://ko.javascript.info/promise-basics
'JavaScript > JavaScript' 카테고리의 다른 글
[JavaScript] 이벤트 위임, 이벤트 전파, e.target & e.currentTarget 차이 (2) | 2023.01.18 |
---|---|
[JavaScript] 비동기 흐름 제어 -2 : async / await 활용하기 (0) | 2022.07.28 |
[JavaScript] 클래스간의 상속 (프로토타입 상속) (0) | 2022.07.25 |
[JavaScript] 프로토타입 체인 (0) | 2022.07.25 |
[JavaScript] 프로토타입 (0) | 2022.07.22 |