웹서비스 개발을 하면서 비동기 작업은 불가피하다. 파일 로딩 (Input/ Output), 네트워크 요청(유튜브 스트리밍 로딩), 시간의 흐름에 따른 애니메이션 등 상황은 다양하며 fs.readFile, requestAnimationFrame, setTimeout, fetch(browser), http(node.js) 등의 메소드들로 구현이 가능하다. 이 포스팅을 통해 비동기 스케줄링을 이해하는 시간이 되었으면 한다.
Asynchronous 비동기란?
sync[씽크]를 맞춰!
우리는 sync라는 단어가 익숙하다. "영화를 다운 받고, 자막과 영상의 씽크를 맞춘다", "나랑 부모님은 싱크로율 100%" 라는 표현을 자주 사용하기 때문이다. 그 어원에서 출발한 Synchronous는 동기식, Asynchronous는 비동기식을 의미한다. 아래 4가지 태스크를 동기적 or 비동기적으로 진행하는 방식을 그림으로 표현했다.
Synchrounous | Asynchronous |
요청을 보낸 후 응답을 받을 때까지 기다리고, 응답을 받으면 다음 동작을 실행. |
요청을 보낸 후 응답에 관계 없이 다음 동작을 실행함. |
여기서 드는 의문은, Synchronous는 '동시의' 라는 뜻을 가지고 있던데, 이는 오히려 1~4 까지의 태스크를 한꺼번에 진행하는 Asynchronous와 의미에 더 적합하지 않을까라는 혼돈을 야기했다. 이때 무엇과 무엇이 동시에 진행되는지를 생각해보면, 각 태스크가 진행을 시작하고 결과를 동시(같은 시간 선상) 에 받는다는 뜻에서, Synchronous의 의미를 다시 이해할 수 있었다.
다시, 그림으로 돌아가서 비동기식 진행은 모든 태스크들이 서로를 기다리지 않고 병렬적으로 진행되기 때문에 실행 시간을 단축시킨다는 장점이 있다. 실생활 예제를 통해 이해를 돕겠다.
선미는 점심에 된장찌개, 삼겹살, 밥을 준비해 먹기로 했다.
방법 1. 집에 가스레인지 1구만 존재해서 하나하나씩 요리하기로 했다. (총 50분 소요)
밥을 안친다. 밥이 다 될 때 까지 20분을 기다린다. 삼겹살을 10분동안 굽는다. 된장찌개를 20분동안 끓인다. 이 방법은 한 요리 시작 후 결과물이 나올 때까지 기다렸다가 다음 요리를 시작하는 "동기식" 방법이다.
방법2. 집에 가스레인지가 여러개 있어서 모든 요리를 한꺼번에 시작하기로 했다. (총 20분 소요)
3가지 요리를 한꺼번에 시작해서, 시작 후 5분 뒤 구운 삼겹살을 접시에 담고, 시작 후 20분 뒤 밥과 된장찌개가 완성된다. 이 방법은 각 요리 진행 속도와 상관 없이 그 요리의 결과물이 나오는 "비동기식" 방법이다.
선미는 방법2를 통해 점심을 빠르게 차리려고 한다. 하지만, 여기서 문제가 생겼다. 아직 삼겹살을 사오지 않았기 때문에 삼겹살 구매 태스크가 추가되었다. 그리고 해야할 작업 순서를 적어보았다.
- 밥을 안친다, 된장찌개를 끓인다. (20분 소요) - 진행 중
- 마트에 가서 삼겹살을 구매해 온다. (15분 소요) -- 완료 후 -- 삼겹살 굽기 (5분 소요)
여기서 주목할 점이 있다. 첫째, 가장 오래걸리는 밥과 된장찌개를 먼저 시작한다. 둘째, 삼겹살을 구매하고 나서 삼겹살 굽기를 진행해야한다는 것이다. 이 말은 비동기식 진행 과정에서도 순서를 컨트롤할 일이 있다는 것이다. 다음 챕터에서 비동기 동작 스케줄링 방법 3가지를 소개하겠다.
3가지 방법을 소개하기에 앞서 setTimeout 함수를 통해 비동기 상황을 연출해보겠다.
setTimeout(()=>{console.log("1. 밥 안치기")}, 20);
setTimeout(()=>{console.log("2. 된장 찌개 끓이기")}, 20);
setTimeout(()=>{console.log("3. 삼겹살 구매")}, 15);
setTimeout(()=>{console.log("4. 삼겹살 고기 굽기")}, 10);
아뿔싸, 순서가 엉망진창이다.
이대로는 선미가 점심을 차려 먹을 수 없다. 비동기 처리 순서를 컨트롤할 필요성이 절실하게 느껴진다.
방법1. callback 함수
정의 : 어떤 이벤트가 발생한 후, 수행될 함수
우리는 위 정의보다 DOM target.addEventListener(callback) 처럼 특정 함수 인자로 전달되는 callback 함수가 익숙할 것이다. 아래 코드처럼, 함수를 분리해서 인자로 넣어줄 수도 있고
function step1(callback){
setTimeout( ()=>{ console.log("1. 밥 안치기"); callback(); }, 20);
}
function step2(callback){
setTimeout( ()=>{ console.log("2. 된장 찌개 끓이기"); callback(); }, 20);
}
function step3(callback){
setTimeout( ()=>{ console.log("3. 삼겹살 구매"); callback(); }, 15);
}
function step4(callback){
setTimeout( ()=>{ console.log("4.삼겹살 고기 굽기"); }, 10);
}
step1.call(null, step2.bind(null, step3.bind(null, step4)))
함수를 한꺼번에 작성하여 1번 함수 내부에서 2번, 3번, 4번 함수가 실행되도록 넣어준다. 핵심은, 실행시키고 싶은 순서대로 함수 안에 넣어주어야한다는 것이다.
setTimeout(()=>{
console.log("1. 밥 안치기");
// 내부 함수로 실행
setTimeout(()=>{
console.log("2. 된장 찌개 끓이기");
// 내부 함수로 실행
setTimeout(()=>{
console.log("3. 삼겹살 구매");
// 내부 함수로 실행
setTimeout(()=>{
console.log("4. 삼겹살 고기 굽기")}, 10);
}, 15);
}, 20);
}, 20);
원하는 순서로 진행이 된다. 하지만, 여러 비동기 작업 진행 시 오른쪽으로 하염없이 들여쓰기 되어진다. (Callback Hell 현상)
방법2. Promise 객체
정의 : 비동기 작업의 결과(성공 시 반환값/실패)를 나타낸다.
Callback Hell 문제를 해결하기 위해 등장한 문법이다. Promise 객체는 다음과 같은 하나의 상태를 가진다.
Promise 3가지 상태
- Pending (대기) : 비동기 처리 대기 중. promise 내부에 resolve든, reject 든 있어야 다음 상태로 진행함.
- Fulfilled (이행) : 비동기 처리가 완료되어 결과 값을 반환한 상태
- Rejected (실패) : 비동기 처리가 실패하거나 오류가 발생한 상태
이 또한, Promise Prototype Object로 new 키워드를 통해 instance를 생성해서 사용한다.
Promise 사용방법 의사코드
const p = new Promise((resolve, reject) => {
// 새로운 프로미스 객체 인스턴스의 실행코드를 정의한다.
// 성공/실패에 따라 resolve, reject 메소드를 실행한다.
// resolve, reject 메소드는 Promise 객체를 반환한다.
if(success){
resolve("성공 시 반환하고 싶은 데이터");
} else{ // error
reject("error !!");
}
});
// 프로미스 이행 및 거부 chain
p.then((data)=>{
// 프로미스 p 작업 성공 -> resolve에서 건낸 데이터 받아 처리
return nextValue1;
}) // 작업 완료 후 새 프로미스 반환
.then((nextValue1)=>{
// 이행
return nextValue2;
}) // 작업 완료 후 새 프로미스 반환
.then((nextValue2)=>{
// 이행
return nextValue3;
})
.catch((err)=>{
// (중간에 하나라도 실패하면) 프로미스 p 작업 실패 프로미스 반환
})
Promise.all(iterable) 메소드
iterable 내의 모든 프로미스가 이행되고, 성공 시 모든 결과를 모은 배열이 반환된다.
/*promise1 ~ 3 모두 프로미스 객체 인스턴스이다.*/
const promise1 = Promise.resolve(3);
const promise2 = promise1.then(data=>data);
const promise3 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'foo');
// setTimeout 3번째 인자부터는 callback 함수 인자로 전달된다.
});
Promise.all([promise1, promise2, promise3]).then((values) => {
console.log(values);
});
// console 결과
// [3, 3, "foo"]
Promise.allSettled(iterable) 메소드
실패가 존재하더라도 멈추지 않고 전체 이행/실패 결과를 보고 싶다면? 이 메소드를 사용해보자.
const promise1 = Promise.reject(new Error("error 입니다"));
const promise2 = promise1.then(data=>data);
const promise3 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'foo');
});
// 에러 하나라도 존재하면 종료
Promise.all([promise1, promise2, promise3]).then((values) => {
console.log(values);
});
// 에러가 있어도 전체 이행 Promise 결과 출력
Promise.allSettled([promise1, promise2, promise3]).then((values) => {
console.log(values);
});
위 문법을 토대로 점심 준비 코드를 완료해보자.
이때 주의할 점은 then 문법 이전에 Promise 객체 타입이 return 되어해야하고, 성공 시 이전 객체의 resolve 결과가 then 내부 함수 인자로 전달 될 것이다.
function step1(){
return new Promise((resolve, reject)=>{
setTimeout( ()=>{ console.log("1. 밥 안치기"); resolve("첫번째 성공")}, 20);
})
}
function step2(){
return new Promise((resolve, reject)=>{
setTimeout( ()=>{ console.log("2. 된장 찌개 끓이기"); resolve("두번째 성공")}, 20);
})
}
function step3(){
return new Promise((resolve, reject)=>{
setTimeout( ()=>{ console.log("3. 삼겹살 구매"); resolve("세번째 성공")}, 15);
})
}
function step4(){
return new Promise((resolve, reject)=>{
setTimeout( ()=>{ console.log("4.삼겹살 고기 굽기"); resolve("끝")}, 10);
})
}
// 실행
step1().then((msg)=>{
console.log("msg :", msg);
return step2(); // promise 객체를 반환해야한다.
})
.then((msg)=>{ // then 메소드로 이행 후 resolve 결과 msg에 받기
console.log("msg :", msg);
return step3();
})
.then((msg)=>{
console.log("msg :", msg);
return step4();
})
.catch((err)=>{
console.log("실패");
})
서비스 코너
Promise 내부 함수와 그 안에 있는 resolve 함수 실행 순서 확인
아래 코드로 알 수 있는 사실은, Promise 내부 모든 함수를 callstack에 넣어 호출시키 및 실행 call stack에 쌓여있던 then 내부함수를 실행한다는 것이다.
function getData(callback){
console.log("1");
return new Promise((resolve, reject)=>{
console.log(2);
resolve(3);
console.log(4);
})
}
getData().then(function(data){
console.log(data);
})
/*
콘솔 결과
1
2
4
3
*/
하지만, setTimeout 함수의 경우 작동 방식이 다른 듯 함. resolve보다 빠르게 실행되었지만, 1ms delay 되어 then 함수 결과가 먼저 출력됨. 작동방식 궁금하면 더 파봐도 될 듯.
function getData(callback){
console.log("1");
return new Promise((resolve, reject)=>{
console.log(2);
resolve(3);
setTimeout(()=>{console.log(4);}, 1); // 1ms delay
})
}
getData().then(function(data){
console.log(data);
})
/*
콘솔 결과
1
2
3
4
*/
방법3. Async & Await
ES8 문법으로, Promise 비동기 스케줄링 작동 과정을 베이스로 하면서 코드 가독성이 높아졌다.
async 와 await 키워드가 짝꿍처럼 따라다니며 [fulfilledReturnValue] = awiat expression 문법에서
expression은 Promise 객체 타입 이어야한다.
function step1(){
return new Promise((resolve, reject)=>{
setTimeout( ()=>{ console.log("1. 밥 안치기"); resolve("첫번째 성공")}, 20);
})
}
function step2(){
return new Promise((resolve, reject)=>{
setTimeout( ()=>{ console.log("2. 된장 찌개 끓이기"); resolve("두번째 성공")}, 20);
})
}
function step3(){
return new Promise((resolve, reject)=>{
setTimeout( ()=>{ console.log("3. 삼겹살 구매"); resolve("세번째 성공")}, 15);
})
}
function step4(){
return new Promise((resolve, reject)=>{
setTimeout( ()=>{ console.log("4.삼겹살 고기 굽기"); resolve("끝")}, 10);
})
}
// 비동기 함수 스케줄링
async function callAsyncAwait(){
let result1 = await step1();
let result2 = await step2();
let result3 = await step3();
let result4 = await step4();
}
callAsyncAwait()
끝으로, 점심 준비하기라는 상황을 가정하고 A-Z까지 스스로 3가지 방법으로 구현해았다. 역시 코딩은 눈으로 보는 것보다 실제 구현할 때 비로소 내 것이 되는 것 같다. 이후 추가로 정리할 것들은 다음과 같다.
- 유사 개념인 blocking & non-blocking - 참고자료
- Client - Server 실제 통신에서 비동기 경험하기 - fetch, http
- 끝 -
'1️⃣ 개발 지식 A+ > FE' 카테고리의 다른 글
how to show cookie headers at client (0) | 2021.01.20 |
---|---|
[반응형 웹] #1. 미디어 쿼리 (0) | 2021.01.09 |
webpack 환경 설정 (0) | 2021.01.09 |
React 공식문서 쉽게 공부하기 (0) | 2021.01.02 |
[JS] 객체 지향 프로그램 (0) | 2020.12.10 |