[ 글의 목적: js에서 비동기를 처리하는 방법에 대한 기록과 활용, 비동기 개념에 대해서는 다루지 않음 ]
javascript 에서 "비동기" 처리를 위해 대표적으로
callback
함수를 활용한다. 하지만 callback의 depth 때문에 callback hell 과 같은 형태가 만들어지기도 하며, 이를 개선하기 위해 "간결하고 깔끔하게, 그리고 동기 처리하는 것 처럼 비동기를 처리하게" 만들기 위한 promise와 async & await를 살펴보자.
callback이 뭘까? 프로그래밍에서 콜백(callback)
또는 콜백 함수(callback function)
는 "다른 코드의 인수로서 넘겨주는 실행 가능한 코드" 를 말한다. 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다. 쉽게 생각해보면 어떤 코드 덩어리를 어떠 순서 또는 순차적으로 실행하고 싶을 때 쓴다 고도 볼 수 있다.
javascript에서 함수는 "객체(object)"
로 취급한다. 따라서 함수는 함수를 인자로 받고 다른 함수를 통해 반환될 수 있다. 인자로 대입되는 함수를 콜백함수라고 부를 수 있다. 더 정확하게는 일급 객체 로 취급힌다. python - 클로저(Closure) 와 데코레이터(Decorator) 글에서 "일급 객체" 부분을 읽어보면 도움이 된다.
setTimeout(callback, delay)
, setInterval(callback, delay)
, addEventListener(event, callback)
, fetch(url, options)
, 등이 있다. setTimeout(callback, delay)
을 먼저 살펴보자. console.log('one');
setTimeout(function() {
console.log('two');
}, 1000); // 1초 이후 실행
console.log('three');
위 함수를 실행하면 결과가 어떻게 나올까? "동기식으로 실행 되었다면" 결과는 one > 1초 기다리고 > two > three
가 나올 것 이다.
왜 아래와 같이 실행되는지 이해가 안된다면, 시리즈의 앞선 js의 원리들을 살펴보는게 좋다. callback은 event loop로 갔다가 call stack에 올라가고, 그에 따라 나중에 실행된다. 특히 node의 경우, 스케쥴링의 깊은 원리는 event queue중 "Timer" queue phaser 에서 체크되기 때문에 바로 원리를 받아들이기는 난해할 수 있다.
setTimeout
은 첫 번째 "인자"로 callback 을 받고 있기 때문에 위에 언급한 대로, 함수를 넘길 수 있다. 그에 따라 "익명 함수" 를 만들어서 바로 넘길 수 있다. 아래와 같이 말이다.const myFun = (fun) => { fun(); }; // 인자를 callback 함수로 받는 함수
console.log('one');
setTimeout(function() {
myFun(function() {
console.log("사용자 정의 함수 인자를 callback을 받게 하고 바로 사용하기");
});
}, 1000); // 1초 이후 실행
console.log('three');
이제 점점 복잡해진다. setTimeout
의 callback에 바로 익명함수를 선언했고, 그 익명함수 안에 myFun
를 호출하고, 또 바로 익명함수를 만들어서 활용했다. 실제로 js & node 를 활용하다보면 위와 같은 "형태" 를 많이 마주하게 된다. 특히 DBMS를 활용한다면 말이다.
만약 myFun 에서 한 번 더 myFun 를 호출하면 어떻게 될까?
const myFun = (fun) => { fun(); }; // 인자를 callback 함수로 받는 함수
console.log('one');
setTimeout(function() {
myFun(function() {
console.log("사용자 정의 함수 인자를 callback을 받게 하고 바로 사용하기");
myFun(function() {
console.log("한번 더 바로 사용하기");
});
});
}, 1000); // 1초 뒤 실행
console.log('three');
const myFun = (fun) => { fun(); };
const myFun2 = () => {
console.log("사용자 정의 함수 인자를 callback을 받게 하고 바로 사용하기");
myFun(function () {
console.log("한번 더 바로 사용하기");
});
};
console.log('one');
setTimeout(function () {
myFun(myFun2);
}, 1000); // 1초 뒤 실행
console.log('three');
단어 뜻 그대로 "약속" 을 의미한다. 비동기적으로 실행되는 코드 흐름에서 특정 이벤트 (성공, 실패 등) 에 따라 그 결과를 전달하겠다는 약속을 의미한다.
위에서 살펴본 "callback 지옥, 멸망의 피라미드" 을 보완할 수 있으며, 비동기로 처리하는 시점을 명확히 할 수 있다. Promise
는 객체 이기 때문에 Promise
"생성자 함수"를 통해 "인스턴스"가 만들어지고, 생성자 함수 내에 전달 되는 함수를 실행자(executor) 라 한다.
promise는 new Promise
로 instance를 만들때 바로 executor가 자동으로 실행된다!
Promise
를 생성할 때, 비동기적 작업이 "성공" 혹은 "실패" 했으냐에 따라 다른 결과를 전달할 수 있게 하는 "매개변수"로 resolve()
와 reject()
라는 callback 함수를 가진다.
그렇기 때문에 "상태" 에 따라 "어떤 매개변수 함수" 를 전달하는지, 그리고 promise를 만드는 "producer", 그 promise를 활용하는 "consumer" 에 대한 이해가 필요하다. 나아가 async & await 역시 promise object를 활용하는 것이라, js 비동기 핸들링을 위해 promise의 이해가 필수다.
아래는 아주 간단한 프로미스 생성의 예제이다.
let a = 1;
console.log("프로그램 코드 시작");
const myPromise = new Promise((resolve, reject) => {
console.log("프로미스 생성!");
if (a === 1) resolve(a);
else reject(new Error("a가 1이 아닙니다!"));
});
myPromise.then(value => {
console.log(value);
});
console.log("프로그램 코드 끝");
new Promise
로 인스턴스를 만들자마자 "executor가 자동으로 실행" 된다고 했다. 그렇기 때문에 생성과 동시에 "pending" 상태라고 보면 된다. 아래 코드를 잠깐 살펴보자!console.log("코드 시작");
let a = 1;
const myPromise = new Promise((resolve, reject) => {
// 여기에 비동기 함수를 실행하면 된다.
setTimeout(() => {
if (a === 1) {
a += 1;
// resolve는 fulfilled 된 상태
resolve(a);
}
// reject는 rejected 된 상태
else reject(new Error("a는 1이 아닙니다."));
}, 2000);
});
myPromise
.then(value => {
console.log(value);
})
.catch(err => {
console.error(err);
})
// 성공 실패 모두 실행하는 finally
.finally(() => {
console.log("모든 작업 끝!");
})
console.log("코드 끝");
코드 시작
코드 끝
2
모든 작업 끝!
new Promise
에서 pending 되어myPromise.then...
부분의 비동기적으로 실행으로 때문에 console.log("코드 끝");
가 먼저 나오고, resolve(a);
에 의해서 "a" 값이 "return" 되고, then
에 의해 console.log(value); // 2
이 출력되며console.log("모든 작업 끝!");
가 출력이 된다. reject(new Error("a는 1이 아닙니다."));
가 되며, "Error object" 를 catch(err...
에서 받아서 이렇게 출력이 된다. 결과에서 알 수 있듯이, finally
는 성공 실패 모두 실행하는 라인이다!코드 시작
코드 끝
Error: a는 1이 아닙니다.
at Timeout._onTimeout (/Users/nuung/Desktop/Outsourcing/spartacodingclub/promise.js:24:21)
at listOnTimeout (node:internal/timers:569:17)
at process.processTimers (node:internal/timers:512:7)
모든 작업 끝!
new Proimse
로 만들어진 instance는 pending
단계에 있고, 이 단계에서 넘어가고 상태처리를 위해 then()
, catch()
, finally()
메서드를 제공 한다고 보면 된다. Producer
& Consumer
promise를 활용할때 위 두 개의 단어로 입장을 분리를 하곤 한다.
producer
는 new Proimse
로 instance를 만들고 어떤 작업을 수행할지, 어떤 액션을 "만들어" 낼지 resolve & reject로 구현하고
consumer
는 then()
, catch()
, finally()
메서드를 활용해 producer
가 만든 promise를 활용(소비)한다.
하나의 promise instance로 promise가 제공하는 메서드(then()
, catch()
, finally()
) 를 활용해 method chaning을 할 수 있다.
위 코드에서 producer
부분 외에 consumer
부분만 아래와 같이 바꿔서 실행해보자!
myPromise
.then(value => {
console.log(value);
return value;
})
.then(value => {
console.log(value * value);
return value * value;
})
.then(value => {
console.log(value * value);
return value * value;
})
.then(value => {
console.log(value * value);
})
.catch(err => {
console.error(err);
})
// 성공 실패 모두 실행하는 finally
.finally(() => {
console.log("모든 작업 끝!");
})
resolve(a);
가 a를 return하는 것을 의미하고, then(value)...
에서 그 return 값을 value
라는 변수로 받아서, then
chaning을 통해 계속 return을 반복할 수 있다. 코드 시작
코드 끝
2
4
16
256
모든 작업 끝!
then
에서 당연하게 한 번 더 new Promise
로 instance를 만들어서 다시 그 다음 then으로도 받을 수 있다.myPromise
.then(value => {
console.log(value);
return value;
})
.then(value => {
console.log(value * value);
return value * value;
})
.then(value => {
console.log(value * value);
return value * value;
})
.then(value => {
return new Promise((resolve, reject) => {
// 여기에 비동기 함수를 실행하면 된다.
setTimeout(() => {
resolve(value * value);
}, 2000);
});
})
.then(value => {
console.log(value);
})
.catch(err => {
console.error(err);
})
// 성공 실패 모두 실행하는 finally
.finally(() => {
console.log("모든 작업 끝!");
})
console.log("코드 끝");
return new Promise
이후의 then
에서 2초의 대기시간이 생긴다. 이제 위 callback hell의 예제를 promise를 활용한 형태로 바꿔보자!const myFun = () => {
return new Promise((resolve, reject) => {
resolve("myFun 실행!");
});
};
console.log('one');
setTimeout(() => {
myFun()
.then(value => console.log(value))
.then(value => myFun().then(value => console.log(`${value}, 한번 더 실행`)));
}, 1000);
console.log('three');
promise
만으로는 비동기/동기 제어가 여전히 불편했고, 여전히 callback
지옥에서 "쉽게" 벗어날 수 없었다. 그래서 ES2017, ES8 에서 async
, await
문법이 등장했다. 이 async & await를 제대로 이해하려면 무조건 callback과 promise를 알아야 한다.async & await를 뭔가가 새로운 object 라고 이해할 수 있지만, 여전히 promise object를 control할 뿐이고, 이런 promise 사용을 "편하게" 해주는 "syntactic sugar"
일 뿐이다.
new Promise
대신 함수 선언시 async
접두사를 가지며 이는 "promise object" 가 된다. 사용할때는 용도에 따라 await
를 붙일 수 도, 붙이지 않을 수 도 있다.
위에서 만든 Promise를 async
를 접두사를 통해 함수를 선언해서 간단하게 위와 같이 바꿀 수 있다. "producer" 부분에서 resolve
, reject
를 굳이 사용하지 않아도 되며, "consumer" 부분이 확연하게 사용하기 편해졌다는 것 을 알 수 있다.
하지만 위 실행결과가 같을까? 오른쪽의 실행결과는 아래와 같다.
코드 시작
Promise { undefined }
코드 끝
2 리턴 합니다!
Promise { undefined }
가 console.log(b);
에 의해 생겼다. 앞서 언급한 것과 같이 async
는 새로운 무엇인가가 아니라 promise 를 만드는 간단한 문법이며, 이에 따라 여전히 Promise를 return 한다
. 그러니 여전히 then을 사용할 수 있다.const test = async () => { return "test"; };
const a = test();
console.log(a);
a.then((value) => console.log(value));
// 결과는
>> Promise { 'test' }
>> test
setTimeout
함수로 "딜레이"를 주는 함수를 하나 먼저 만들어보자. const wait = async (count, value) => {
setTimeout(() => {
console.log(value);
}, count);
};
const a = async () => {
await wait(1000, "a");
return "return a";
};
const b = async () => {
await wait(1000, "b");
return "return b";
};
const c = async () => {
await wait(1000, "c");
return "return c";
};
const main = async () => {
const result1 = a();
const result2 = b();
const result3 = c();
console.log(result1, result2, result3);
}
main();
>> Promise { <pending> } Promise { <pending> } Promise { <pending> }
>> a
>> b
>> c
완전한 절차식 수행 이라면 a()
호출 했을 때 return을 기다리지 않는다. 그렇다고 setTimeout
을 수행할때 까지 기다리지도 않는다. a()
, b()
, c()
호출 모두 " 동시에 실행되는 것 '처럼' " 보인다.
그러면 await
를 모두 붙이면? return을 기다린다. 그래서 출력을 해보면 Promise
대신 해당 값의 return 값을 볼 수 있다.
// ... 생략 ...
const main = async () => {
const result1 = await a();
const result2 = await b();
const result3 = await c();
console.log(result1, result2, result3);
}
main();
// 결과는
>> return a return b return c
>> a
>> b
>> c
result2
변수에서만 a()
함수의 return 값이 필수적이라면? 아래와 같이 코딩할 수 있다.// ... 생략 ...
const main = async () => {
const result1 = await a();
if (result1 === "return a") {
const result2 = b();
console.log(result2);
}
const result3 = c();
}
main();
await getUser()
) 가져온 유저 정보를 바탕으로 다른 것들을 조회할 수 있다. Promise.all
예제를 활용하면 위 코드를 아래와 같이 변경할 수 있다.// ... 생략 ...
const main = () => {
return Promise.all([a(), b(), c()]).then((results => results.join(" ")));
};
main().then(console.log);
// 결과는
>> return a return b return c
>> a
>> b
>> c
coding 에는 정답은 없지만 "깔끔한 코드"는 있다고 평가된다. clean code 등을 넘어 기본적으로 "함수"가 지향하면 좋은 부분을 다시 짚어보자!
js에서 비동기는 쉽게 표현하자면 쉽게, 깊게 표현하자면 - v8 마이크로 테스크 큐와 기본 메모리(heap)와 call stack 등 과 같이 - 어렵게 표현할 수 있다.
개인적으로 Promise
는 DBMS를 활용하면서 많이 익혔다. 자연스럽게 외부 통신과 같은 N/W I/O 가 발생하는 작업을 마주해야 "비동기" 에 대한 필요를 이해하고, 활용할 수 있다고 생각한다.
해당 글에서 다루는 개념을 짧게 다시 요약하면 아래와 같다.
해당 내용은 바로 앞 글 "node - 기본 동작 원리와 이벤트 루프, 브라우저를 벗어난 js 실행!" 의 "왜 싱글 쓰레드를 고집하는가?" 부분을 한 번 다시 보시는 것을 추천합니다.
js는 "싱글 스레드" 를 고집하고 있다. 시리즈에서 계속 언급하는 부분인데 "일 하는 사람이 한 명이라고 생각하면, 한번에 한 가지 작업밖에 수행하지 못한다!" 라고 비유할 수 있다.
이런 상황에서 "철저하게 절차적 수행 + 동기식" 이라면, setTimeout
과 같은 함수를 만나면 모든게 멈춘다. 특정 버튼이나 DOM에 event가 있으면 그 클릭 이벤트를 처리 완료할 때 까지 js는 멈춰버리는 것이다.
그래서 Event Loop, Task queue 등을 통해 "영혼의 최적화" 를 했다. 그 산출물, 그 결과물이 "비동기" 의 면모가 되었다고 볼 수 있다.
잘 읽었습니다. 1번 콜백에서 예제 설명을 하나로 통합하시는건 어떨까 싶네요. 코드는 setTimeout인데 주석 설명은 1초 간격으로 실행됨 이고 아래 사진은 setIntervel이고 설명으로는 timeout이라고 적으셔서 헷갈리는것 같아요.