js 비동기 프로그래밍
도입
Q. js에서 비동기 대해 설명해주세요
A. 네? 비동기요? 비동기가 어떻게 이루어지는 지를 말씀히시는건가요?
Q. 네 뭐든요.
A. 이벤트 루프 어쩌구 저쩌구 프로미스 어저꾸 저쩌구 async, await 주저리주저리..
Q. Ajax 아시죠?
A. 네. Ajax는 비동기 통신을 위한 저는 JQuery 어저꾸 저쩌구 (뇌는 하얗게 변함 🤯)
Q. 콜백 지옥이라고 들어보셨죠?
A. 네. 콜백은 호출하는 함수를 파라미터로 전달에서 어떠한 동작이 끝나고 호출
Q. 개발하시면서 콜백 지옥을 경험해본적은?
A. (이미 뇌는 멈췄다) 어... 그러니까...
Q. ...
A. ...
대략 이런 대화였다. 지금 와서 생각해보면 JS 특징과 JS가 어떻게 비동기 처리를 하는지 알고 있는지를 물어보려고 하신 거 같다. 아마 실제 답을 할 때는 나 스스로 정리도 안 되고 내 머릿속에서 떠다니는 단편적인 지식을 조합해서 대답했다.
지금 생각하면 어떤 기능을 개발하기에만 급급하고 그 기능을 구현할 때 사용하는 기본적인 지식 습득에는 관심이 없어서 대답을 저렇게 한 거 같다. 항 상 어떤 개발을 할 때, 왜 사용하는지를 알고 해결하고 끝이 아닌 정리를 하지 않으면 기술 면접에서는 영원히 불합격할 거 같다.
본론
Javascript 특징이 뭐에요?
에? JS에 특징이요? 어... 그러니까 JS는 인터프리터 언어이고 싱글 스레드 뭐 블라블라 라고 대답하겠다. JS 비동기 프로그래밍에서 중요한 건 싱글 스레드이다. JS가 싱글 스레드라는 의미는 작업이 시작되면 이 작업이 완료되기까지 다른 작업을 할 수 없다.
왜곡된 설명을 하자면 한 식당이 있다. 이 식당의 기본적인 프로세스는 주문 -> 요리 -> 서빙 순으로 진행된다. 이 식당에 근로자가 1명이라면? 첫 번째 손님이 오면 근로자는 위에 프로세스대로 일한다. 서빙을 마치고 두 번째 손님이 왔다면 다시 위 프로세스를 진행한다. 만약 요리하는 와중에 세 번째 손님이 오면 이 손님은 주문을 하려 해도 근로자가 서빙까지 마치고 나서야 주문을 할 수 있다는 것이다.
즉 JS는 한번에 하나의 일밖에 처리 못하는 single Thread
다
Thread: 스레드는 어떠한 프로그램이 실행되는 작업을 말함
function sleep (ms) {
const loopTime = Date.now() + ms;
while (Date.now() < loopTime) {
}
}
function order(o) {
cook(o)
}
function cook(o) {
const ms = Math.floor(Math.random() * 10000) ;
sleep(ms)
serve(o, ms)
}
function serve(o, ms) {
console.log("메뉴: " , o, "조리시간: ", ms/1000, "초")
}
order('피자')
order('치킨')
order('물')
피자
치킨
물
위에 예시를 실행하면 항상 조리시간(ms) 상관없이 피자, 치킨, 물로 출력이 된다.
비동기 프로그래밍
비동기 프로그래밍이란 어떤 작업이 완료 될 때까지 기다리지 않고 다른 작업을 수행 하는것이다.
엥? 근데 js는 싱글 스레드다. 어떻게할까?
javascript 엔진(V8)과 브라우저에 대해서 어느정도 이해가 필요하다.
[구조 사진 들어가는 곳]
-
Stack
- 스택은 우리가 잘 아는 자료구조의 하나인 스택과 비슷한 개념으로 함수를 호출하면
stack
에 push 된다.
- 스택은 우리가 잘 아는 자료구조의 하나인 스택과 비슷한 개념으로 함수를 호출하면
-
Heap
- js에 객체 등 메모리 할당이 발생하는 곳
-
Web API
- DOM, AJAX, 등 브라우저가 제공하는 API
-
callback queue
- DOM, AJAX, 등의 이벤트가 끝난 후
queue
에서 대기한다.
- DOM, AJAX, 등의 이벤트가 끝난 후
-
Event Loop
- Event Loop는 Stack이 비어 있으면 queue에 있는 함수를 Stack으로 전달한다.
callback queue는 microtask queue 와 macrotask queue가 존재하고 우선 순위도 다르지만 자세한 설명은 생략한다.
위 구조를 이해하기 위해 간단한 예제인 아래 코드를 보면서 이해를 해보자.
동기 (진동기 부회장 아님)
const SyncTest = () => {
const [foods, setFoods] = useState([]);
function sleep(ms) {
const loopTime = Date.now() + ms;
while (Date.now() < loopTime) {
}
}
function order() {
const foods = ['피자', '치킨', '물'];
foods.forEach(cook);
}
function cook(food) {
const ms = Math.floor(Math.random() * 10000);
console.log('조리 시간:', ms);
sleep(ms);
serve(food, ms);
}
function serve(food, time) {
time = time / 1000;
setFoods((old) => [...old, {food, time}]);
console.log("서빙 : ", food);
}
return (
<div style={{display: 'flex', flex: '1', flexDirection: 'column'}}>
<div onClick={order} style={{display: 'flex', flexDirection: 'column'}}>
<button> 주문</button>
</div>
<hr/>
<div>
{
foods.map(({food, time}, idx) => <div key={"food_" + idx}>음식:{food}, 시간: {time} 초</div>)
}
</div>
</div>
)
}
아까 본 코드를 실행해보면 조리 시간과 상관없이 피자, 치킨, 물이 출력되는데 위 코드의 실행순서를 그림으로 보면 아래와 같다.
[JS 구조와 실행순서 그림(동기)]
onClick
Event 실행 되고, 큐에 할당되고, 스택이 비어있으므로 바로 스택에서 실행된다.order
함수가 스택에 쌓이고,cook
함수를 호출한다.cook
함수가 스택에 쌓이고,sleep
함수를 호출한다.sleep
함수에서 인자로 받 은 값 만큼 대기하고,sleep
함수가 스택에서 제거된다.serve
함수를 호출하고,log
출력 후, 스택에 쌓인 함수들이 순차적으로 제거된다.- 위 과정을
foods
길이 만큼 반복한다.
비동기
const AsyncTest = () => {
const [foods, setFoods] = useState([]);
function order() {
const foods = ['피자', '치킨', '물'];
foods.forEach(cook);
}
function cook(food) {
const ms = Math.floor(Math.random() * 10000);
console.log('조리 시간:', ms);
setTimeout(()=> {
serve(food, ms)
},ms)
}
function serve(food, time) {
time = time / 1000;
setFoods((old) => [...old, {food, time}]);
console.log("서빙 : ", food);
}
return (
<div style={{display: 'flex', flex: '1', flexDirection: 'column'}}>
<div onClick={order} style={{display: 'flex', flexDirection: 'column'}}>
<button> 주문</button>
</div>
<hr/>
<div>
{
foods.map(({food, time}, idx) => <div key={"food_" + idx}>음식:{food}, 시간: {time} 초</div>)
}
</div>
</div>
)
}
[Timeout을 실행 방법]
onClick
Event 실행 되고, 큐에 할당되고, 스택이 비어있으므로 바로 스택에서 실행된다.order
함수가 스택에 쌓이고,cook
함수를 호출한다.cook
함수가 스택에 쌓이고,serve
함수는setTimeout
에 의해 브라우저의WebAPI
에서ms
후에 실행 된다.cook
함수가 스텍에서 제거된다.- 위 과정을
foods
길이 만큼 반복한후,order
함수도 스택에서 제거된다. setTimeout
이ms
값에 따라 실행된 순서대로, 큐에 쌓인다.- 스택이 비어 있으면 이벤트 루프에 의해 스택에 쌓이고
serve
함수를 호출한다. log
출력 후,serve
가 제거된다.- 6~8번의 과정이 큐에 값이 없을 때 이벤트 루프에 의해 까지 반복 된다.
위에 두 코드의 가장 큰 차이점은 sleep
과 setTimeout
을 통한 지연이다. sleep
의 경우 스택에서 실행되어 지연되고 setTimeout
은 브라우저의 WebAPI 통해 실행된다.
즉 JS에서 비동기 처리는 브라우저의 WebAPI 에서 실행되고, 실행이 끝난후 큐에 쌓이고, 이벤트 루프에 의해서 스택이 비어있으면 스택에 쌓인뒤 실행된다는 것을 알수있다.
스택에 쌓인뒤 실행되는 함수를 콜백 함수라고 한다.
콜백
콜백이란 특별한 의미가 아닌 당장 실행되지 않고 미래에 실행하는 단순한 의미이다. 비동기 프로그래밍은 언제 해당 함수의 처리가 완료 될지 모르므로 해당 함수가 종료 된 후, 그 다음 함수를 실행하기 위해 파라미터를 함수로 인자로 전달한뒤, 해당 로직이 끝난 후 실행하는것을 보장하기 위한 방법이다.
- callback을 사용하는 예시
// forEach에서 사용하는 callback
[1,2,3].forEach(n=>{
console.log(n)
})
// setTimeout에서 사용하는 callback
setTimeout(()=>{
callback()
},0)
function func1(num, callback) {
console.log('func1')
num += 1;
callback(num)
}
function call(){
func1(0, num=>
console.log(num)
)
}
call()
js에서 함수도 object이므로 파라미터로 전달하는게 가능하다
그리고 비동기 관련 질문에서 빼놓을수 없는게 콜백 지옥(👹)이다.
콜백 지옥
콜백 지옥은 무수히 많은 콜백으로 인해 코드가 직관적이지 않고 가독성이 떨어지는 상황이다.
- 이미지를 로드하고 그리는 상황에 예시
getImage("https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQ8B8c7i0OCqgXJiV7al7cTXiaAjDWEHharTg&usqp=CAU",(img,err)=>{
if(err){
throw new Error(err)
} else {
resizeImage(img, (img, err)=>{
console.log(img, img.width, img.height)
})
}
})
위 코드는 이미지를 불러오고 사이즈를 재조정하는 코드이다. 음 아직까지는 읽을만하다.🙄
아래 코드를 살펴 보자
getImage("https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQ8B8c7i0OCqgXJiV7al7cTXiaAjDWEHharTg&usqp=CAU",(img,err)=>{
if(err){
throw new Error(err)
} else {
resizeImage(img, (img, err)=>{
if(err) {
throw new Error(err)
} else {
applyFilter(img, (img, err)=> {
if(err) {
throw new Error(err)
} else {
saveImage(img, (img, err)=> {
if(err) {
throw new Error(err)
} else {
console.log('Save Image')
}
})
}
})
}
})
}
})
위 코드는 이미지를 불러오고, 사이즈를 조정하고, 필터를 씌우고, 이미지를 저장하고, 출력한다.
... 점점 복잡해 진다.🤯
만약 여기서 더 기능을 추가하면 수 많은 })
를 보게 되고, 코드의 가독성은 점점 줄어든다.
위 코드처럼 콜백에 콜백에 콜백을 더해서 어머 깜짝이야 하는 상황이 콜백 지옥이다.
엥? 콜백 지옥을 어떻게 벗어남?
콜백 지옥에서 벗어나는 방법: Callback hell
Promise (🤙)
위와 같이 Callback Hell 을 벗어나기 위해서 ES6에서 나온 문법이 Promise
다.
프로미스는 어느 시점에 비동기 작업의 최종 완료 혹은 실패에대한 결과값을 나타낸다.
프로미스 문법
프로미스 객체는 callback
을 받는 생성자를 통해 사용가능하며 파라미터로는 resolve
와 reject
를 사용한다.
new Promise((resolve, reject)=>{})
Promise
[[PromiseState]]: 'pending'
[[PromiseResult]]: undefiend
위를 콘솔창에 실행하면 PromiseState
와 PromiseResult
값을 반환한다.
PromiseState
는 pending
이고, PromiseResult
는 undefiend
값을 갖고 있다.
PromiseState
와 PromiseResult
를 통해 무언가를 하지 않는다.
하지만 Promise
가 어떤 상태를 값을 갖고 그 상태 값의 의미는 알아야 한다.
프로미스의 상태
프로미스의 상태는 pending
, fulfilled
, rejected
를 갖고 있다.
- ⏳
pending
: Promise는 해결(fulfilled) 혹은 실패(rejected)되지도 않고 대기중인 상태 - ✔
fullfilled
: Promise가 해결(resolve)된 상태 - ✖
rejected
: 어떤 이유로 Promise가 실패(rejected)한 상태
new Promise((resolve, reject)=>{
if(true) {
resolve("성공")
} else {
reject("실패")
}
})
위 코드를 실행해보면 아래와 같은 값을 볼 수 있다.
Promise
[[PromiseState]]: "fulfilled"
[[PromiseResult]]: "성공"
프로미스로 콜백지옥을 벗어나자.
- 프로미스의 상태는
pending
,fullfilled
,rejected
를 갖음 resolve
,reject
then
,cacth
,finally
- 사실 callback queue 에는 두가지 task queue가 존재함
- microtask queue
- promise, process.nextTick, queueMicrotask
- macrotask queue
- setTimeout, setInterval, setImmediate
- 우선순위는 마이크로태스크 큐가 더 높음
- microtask queue
- ES6에서 두두등장
프로미스의 우선순위
console.log('first log')
setTimeout(()=>{console.log("timeout log")},0)
Promise.resolve("Promist log").then((res)=>console.log(res))
console.log('final log')
위에 실행 결과는 어떻게 될까?
first log
final log
Promist log
timeout log
async / await
- ES7에 두두등장 ( Promise는 ES6에서 등장함)
- 사용법은
async
는 function 앞페await
은promise
를 리턴받아야함! async
는 프로미스를 리턴함