4 비동기(Asynchronous) 프로그래밍
source: categories/study/database-mongodb/database4.md
4.1 Non-blocking
4.1.1 Blocking
식당을 예시로 들겠다.
웨이터 한명이 손님 한명의 주문을 받는다.
그리고 그 주문을 주방장에게 전달해준다.
음식이 나올 때까지 웨이터는 기다린다.
음식이 나오면 손님에게 가져다준다.
위와 같은 프로세스는 손님이 1명일 경우는 아무 문제가 없다.
하지만 손님이 2명 이상이라면, 웨이터가 손님 1명의 주문을 처리하는 동안 나머지 손님들은 불만이 쌓일 것이다.
주문도 안 받고 앞 손님 음식이 다 처리가 될 때까지 계~속 기다려야되기 때문이다.
기존 프로그래밍들은 보통 위와 같은 식이었다.
코드 위에서 아래로 순차적으로 실행되는..
4.1.2 멀티 스레딩
그래서 이런 Blocking 문제를 해결하기위해 기존 프로그래밍에선 웨이터를 여러명을 추가하는 방식으로 해결했다.
만약 웨이터가 4명이 있다면, 손님이 4명이 있어도 상관이 없겠지?
손님이 4명이 동시에 들어왔다고 했을 때, 웨이터가 각 손님마다 1명씩 붙는 것이다.
이렇게되면 손님이 1명이나 4명이나 처리를 빨리 할 수 있겠지?
그런데 지금은 손님 4명만 예로 들었는데, 손님이 더 많을 수도 있잖아?
그런데 손님이 늘어날 때마다 웨이터를 늘리면 인건비가 엄청많이 들겠죠?
그리고 손님이 많은 경우도 있을거고, 없는 경우도 있을거고, 그럴때마다 웨이터를 잘랐다가 다시 고용했다가..
물론 그렇게 하는 방법도 있지만, 실제로 그렇게 하는 경우도 많이 있고..
하지만 딱 봐도 매우 비효율적이다.
왜냐하면 웨이터가 주방장이 요리를 다 할 때까지 기다리면서 하는 것이 없다.
이것이 프로그래밍적으로 따지면 동기적인 코딩이다.
4.1.3 비동기
비동기적인 코딩은 웨이터 1명이 손님 4명의 주문을 각각 다 받아서 주방장으로 전달해주는 것이다.
이것이 Non-Blocking이다.
원래는 웨이터가 주방장이 요리를 다 할 때까지 기다렸어야됐잖아?
그게 Blocking이었는데, 지금은 웨이터가 주방장이 요리 다 할때까지 기다리지 않아도 되기 때문에 Non-Blocking인 것이다.
그런데 어쨌든 언젠가 요리가 다 완성되면 받으러 가야겠지?
요리를 받고 그 요리를 해당되는 손님에게 가져다줘야되잖아?
그래서 Non-Blocking으로 하려면 2가지 장치가 필요하다.
프로그래밍 언어로는 Queue라고 할 수 있다.
주방장이 보는 Queue가 하나가 있고, 웨이터가 보는 Queue가 하나가 있다.
다시 처음부터 생각해보자.
웨이터가 손님1의 주문을 받는다. (주문1)
그 주문1을 가지고 주방장의 Queue에 올려놓는다.
그럼 주방장이 자신의 Queue에 들어온 주문1을 보고 요리를 한다.
그러는 사이 웨이터는 손님2한테 가서 주문2를 받는다.
그 주문2를 주방장의 Queue에 올려놓는다.
이런식으로 손님들의 주문들을 주방장의 Queue로 계속 전달한다.
그런데 주방장의 요리가 끝나면 그 요리를 웨이터가 다시 받아서 손님에게로 갖다줘야되는 역할도 있잖아?
원래는 웨이터가 아무일도 안하면서 주방장이 요리를 끝낼 때까지 기다리기만 했었다.
그런데 지금은 그렇게하는 것이 아니고
주방장이 주문1의 요리가 끝나면(요리1) 요리1을 웨이터의 Queue에 올려놓는다.
그러면 웨이터는 계속 주문받고 주방장에게 전달하고 왔다갔다를 하다가 중간중간 주기적으로, 예를 들어 5분에 한번씩 자신의 Queue를 확인한다.
보고 완성된 요리가 있으면 가서 요리를 가지고 해당 손님에게 전달을 해준다.
지금 중요한 요소가 추가된 것이다.
Non-Blocking을 하기 위해선 외부에서 일하는 사람들(주방장)의 리스트들이있고(주방장의 Queue),
이 리스트들이 끝났을 때 웨이터에게 끝났다는 것을 알려주는 것이다.
그런데 요리가 끝났다고해서 당장와서 요리 받아가라! 라는 것이 아니라 웨이터가 하던 일들을 마무리하고, 주기적으로 확인하는 웨이터의 Queue에 올려놓는 것이다.
이 Queue라는 것이 정말 중요한 개념이다.
- 웨이터: 노드JS 역할
- 주방장: DB 역할 (특정 처리들을 동시에 할 수 있다. concurrent. 즉, 주방장을 여러명으로 늘릴 수도 있다.)
즉, 이렇기 때문에 노드JS는 싱글 스레드(웨이터 1명)여도 엄청 많은 처리를 할 수가 있는 것이다.
4.1.4 노드JS 아키텍쳐
- Call Stack: 노드JS상에서 메인스레드가 코드를 실행하는 개념. 웨이터 같은 개념.
- APIs: I/O 등등.. 처리를 위한 외부 API.. 주방장 같은 개념.
- Callback Queue: 위에서 웨이터가 웨이터의 Queue를 1분마다 5분마다 주기적으로 확인한다고 했지?
그걸 대신 해주는 녀석이 Event Loop.
주방장이 완료한 일을 APIs에서 Callback Queue로 옮긴다.
또한 웨이터가 일이 없을 때 웨이터(Call Stack)에게 Callback Queue에 있는 것을 옮겨준다.
Event Driven이란 Event Loop가 하는 액티비티 같은 것들을 전체적으로 뜻한다고 보면된다.
4.2 Callback
console.log('start');
// setTimeout: 주방장에 맡기는 코드
setTimeout(() => {
console.log('your meal is ready');
}, 3000);
console.log('end');
// start
// end
// your meal is ready
const addSum = (a, b, callback) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') return callback('a, b must be numbers'); // callback 함수에서 첫번째 인자는 에러메시지이다. 이는 "약속"이다.
callback(undefined, a + b); // 에러가 아니라면 첫번째 인자는 undefined 또는 null, 그리고 두번째 인자엔 결과값을 넘겨주면된다.
}, 3000)
}
let callback = (error, sum) => {
if (error) return console.log({error});
console.log({sum});
}
addSum(10, 20, callback);
// { sum: 30 }
const addSum = (a, b, callback) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') return callback('a, b must be numbers'); // callback 함수에서 첫번째 인자는 에러메시지이다. 이는 "약속"이다.
callback(undefined, a + b); // 에러가 아니라면 첫번째 인자는 undefined 또는 null, 그리고 두번째 인자엔 결과값을 넘겨주면된다.
}, 3000)
}
let callback = (error, sum) => {
if (error) return console.log({error});
console.log({sum});
}
addSum(10, 'abcd', callback);
// { error: 'a, b must be numbers' }
4.3 Promise
const addSum = (a, b) => new Promise((resolve, reject) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') reject('a, b must numbers');
resolve(a + b);
}, 3000)
})
addSum(10, 20)
.then(sum => console.log({sum}))
.catch(error => console.log({error}))
// {sum: 30}
4.3.1 위 callback 함수와의 차이점
- callback 함수에 첫번째 인자가 true면 error이다. 이건 개발자들간의 약속이지 이걸 어겼다고해서 문법 에러가 생긴다거나 그러진 않는다.
즉, 모든 코드가 반드시 이 약속을 지켰다고 상정할 수가 없다. -
Promise 상태는 3가지가있다. Pending(대기중), resolve, reject
Pending 상태는 아까 위에서 외부 APIs에 있는 상태를 말한다.
거기서 Queue로 올 때 resolve인지 reject인지가 정해진다.즉, Promise에서 resolve나 reject가 한번 실행이되고나면(결정이되고나면) 나머지는 실행이되지 않는다.
그래서 콜백함수 사용할 때return
을 빼먹어서 실행되지 말아야될 코드가 실행된다던지 그런 실수를 줄일 수 있다. - 에러났을 때도 callback 함수에선 첫번째 인자로 error를 전달해라 라는 ‘약속'만 있었을 뿐인데,
Promise에선 아예catch
메소드로 처리한다.
그래서reject
가 되면 자동으로catch
에 있는 콜백함수가 실행이된다.
그래서 콜백함수에 비해 코드가 깔끔하게 정리가된다.
4.4 Promise Chain
위에서 예시로든 식당을 생각하면, 한명의 웨이터가 고객 요청이 있을 때 주문을 주방장에게 전달해야되는 역할. 즉, 비동기적인 일이 하나밖에 없었다.
그런데 저희가 실제로 개발을하면 그런 비동기적인 호출들이 하나만 있는 것이 아니라 여러개가 있을 수가 있다.
예를 들어서 유저정보를 먼저 불러오고, 불러온 다음에 유저정보를 확인하고나서 블로그를 작성한다, 그리고 블로그가 작성되고나서 다른 API 정보를 보내주고 그 다음에 최종적으로 return을 한다.
이런식으로 개발해야될 때도 있다.
순차적으로 하는거죠?
이를 콜백함수로 구현을하면, 사람들이 흔히 콜백헬이라고 많이 부르는데, 아래와 같이 콜백함수를 많이쓰면 많이쓸 수록 depth가 계속해서 깊어진다.
그래서 코드가 굉장히 복잡해진다.
const addSum = (a, b, callback) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') return callback('a, b must be numbers'); // callback 함수에서 첫번째 인자는 에러메시지이다. 이는 "약속"이다.
callback(undefined, a + b); // 에러가 아니라면 첫번째 인자는 undefined 또는 null, 그리고 두번째 인자엔 결과값을 넘겨주면된다.
}, 3000)
}
addSum(10, 10, (error1, sum1) => {
if (error1) return console.log({error1});
console.log({sum1});
addSum(sum1, 15, (error2, sum2) => {
if (error2) return console.log({error2});
console.log({sum2});
})
});
// { sum1: 20 }
// { sum2: 35 }
프로미스를 사용하면 어떻게되는지 보도록하겠다.
const addSum = (a, b) => new Promise((resolve, reject) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') reject('a, b must numbers');
resolve(a + b);
}, 3000)
})
addSum(10, 20)
.then(sum => addSum(sum, 3))
.then(sum => console.log({sum}))
.catch(error => console.log({error}))
// { sum: 33 }
훨씬 코드가 깔끔하다.
오류 처리도 위와 같이 한번만해주면된다.
const addSum = (a, b) => new Promise((resolve, reject) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') reject('a, b must numbers');
resolve(a + b);
}, 3000)
})
addSum(10, 20)
.then(sum => {
console.log({sum});
return addSum(sum, 3);
})
.then(sum => console.log({sum}))
.catch(error => console.log({error}))
// { sum: 30 }
// { sum: 33 }
위 문법을 조금 더 이쁘게 사용할 수 있는 기법을 다음 강의에서 설명드리겠습니다.
4.5 Async Await
const totalSum = async () => {
}
console.log(totalSum());
// Promise { undefined } // Promise 인스턴스가 return된다.
const addSum = (a, b) => new Promise((resolve, reject) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') reject('a, b must numbers');
resolve(a + b);
}, 3000)
})
const totalSum = async () => {
let sum = await addSum(10, 10);
console.log({sum});
}
console.log(totalSum());
// Promise { <pending> } // 대기상태
// { sum: 20 } // resolve 결과값
const addSum = (a, b) => new Promise((resolve, reject) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') reject('a, b must numbers');
resolve(a + b);
}, 3000)
})
const totalSum = async () => {
let sum = await addSum(10, 10); // async await를 썼을 뿐인데 동기코드와 같아보인다. 하지만 비동기로 작동된다.
console.log({sum});
}
totalSum();
// { sum: 20 } // resolve 결과값
const addSum = (a, b) => new Promise((resolve, reject) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') reject('a, b must numbers');
resolve(a + b);
}, 3000)
})
const totalSum = async () => {
let sum = await addSum(10, 10);
let sum2 = await addSum(sum, 10);
console.log({sum, sum2});
}
totalSum();
// { sum: 20, sum2: 30 }
const addSum = (a, b) => new Promise((resolve, reject) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') reject('a, b must numbers');
resolve(a + b);
}, 3000)
})
addSum(10, 20)
.then(sum1 => addSum(sum1, 3))
.then(sum2 => addSum(sum2, 3))
.then(sum3 => addSum(sum3, 3)) // 이거의 단점: 이 부분의 sum에 접근하고 싶은데 접근이 안된다. 여기에 접근하기 위해선..
.then(sum4 => addSum(sum4, 3))
.then(sum5 => addSum(sum5, 3))
.then(sum6 => console.log({sum6}))
.catch(error => console.log({error}))
const addSum = (a, b) => new Promise((resolve, reject) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') reject('a, b must numbers');
resolve(a + b);
}, 3000)
})
let sum1_, sum2_, sum3_; // 이런식으로 선언하고 아래와 같이 코드를 작성해야된다.
addSum(10, 20)
.then(sum1 => {
sum1_ = sum1;
return addSum(sum1, 3);
})
.then(sum2 => addSum(sum2, 3))
.then(sum3 => addSum(sum3, 3))
.then(sum4 => addSum(sum4, 3))
.then(sum5 => addSum(sum5, 3))
.then(sum6 => console.log({sum6}))
.catch(error => console.log({error}))
// 그런데 아래 코드에선 위와 같은 번거로움이 없다.
const addSum = (a, b) => new Promise((resolve, reject) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') reject('a, b must numbers');
resolve(a + b);
}, 3000)
})
const totalSum = async () => {
let sum = await addSum(10, 10);
let sum2 = await addSum(sum, 10);
console.log({sum, sum2});
}
totalSum();
// { sum: 20, sum2: 30 }
그럼 catch
는 어떻게하느냐.
const addSum = (a, b) => new Promise((resolve, reject) => {
setTimeout(() => {
if (typeof a !== 'number' || typeof b !== 'number') reject('a, b must numbers');
resolve(a + b);
}, 3000)
})
const totalSum = async () => {
try {
let sum = await addSum(10, 10);
let sum2 = await addSum(sum, 10);
console.log({sum, sum2});
} catch (err) {
if (err) console.log({err});
}
}
totalSum();
4.6 요약
function creatBlog() {
User.findOne({ ... }) // 먼저 User를 찾고
Blog.insertOne({ ... }) // 찾은 User의 Blog글을 생성하고
User.updateOne({ ... }) // Blog 생성됐으므로 다시 User를 업데이트하고
LogApi({ ... }) // LogAPI 호출할거있으면 호출하고
return "success"; // 위 과정이 모두 완료되면 success 리턴
}
4.6.1 동기적 실행
4.6.2 비동기적 실행
비동기적 실행을하면 첫번째 코드가 끝나지않았는데도 그 다음 코드들이 주르륵 호출된다.
그런데 우린 비동기적 실행을하면서 위와 같은 동기적인 실행을 하고 싶다.
그래서 Promise
객체를 활용해 then
메소드도 사용해봤고 그 전엔 콜백함수를 사용하는 방법도 봤다.
그리고 제일 마지막으론 async/await
도 봤다.
위와 같이 async
, await
키워드를 사용해 작성하면 비동기적으로 실행되지만 순차적으로, 동기적으로 실행되는 것을 확인했다.
위 이미지보면 막대에 파랑색과 빨강색 부분이 있다.
빨강색 부분은 외부 API에서 일어나는 일을 표시한 것이다. (Non-Blocking 구간 - 외부 API에 일을 맡기는 거기 때문. 웨이터가 주방장에게 주문지 넘겨주는 것과 같음)
예를 들어 DB 호출해서 데이터 가공하고 네트워크 요청 처리하고 등등 하는 것이 빨강색 부분.
파랑색 부분은 요청을 보낼 때, 뭔가 가공을해서 보낼거잖아?
응답을 받을 때도 파싱하고.
웨이터가 주문지에 손님 주문을 받아서 표기하고 주방장으로 전달하러가는 과정, 빨강색 부분은 주방장이 요리하는 과정.. 다시 파랑색 부분은 웨이터가 요리를 받아서 손님에게 가져다주는 과정.
이렇게 생각하면 된다.
그리고 빨강색 부분은 위에 말했듯 Non-Blocking 구간. 외부에서 일을 처리하고있기 때문에.
즉, 이때 NodeJS는 다른일을 쉼없이 하고 있다. 파랑색 부분의 일을 하고있는 것.
이것이 노드의 엄청난 강점이다.
빨강색 부분에선 원래 CPU가 놀고있으니가 쓰레드를 추가하거나 프로세스를 추가하는데, 그렇게되면 호출이 많지도 않은데 서버가 쉽게 터질 수가 있다.
괜히 서버 비용만 증가하게되고..
병렬적으로 실행이되도 상관없는 것은 위와 같이 Promise.all
을 활용하는 것도 좋다.
그것이 훨씬 더 효율적이다.
Blocking이 많은.. CPU intensive한 것들은 노드에서 좋지 않다.
위와 같이 길이가 긴 반복문, 아니면 복잡한 매트릭스 연산(그래픽 연산이 대부분 매트릭스 연산이다.).
이런 것들은 CPU를 엄청 많이 잡아먹는다.
이런 것을을 노드 메인스레드가 가져가게되면 Blocking이 된다.
위 이미지 보시면 파랑색이란걸 볼 수 있다.
파랑색은 Blocking되는 구간이다.
저 구간동안 다른일은 전혀 못하는 것이다.
아까 위에서 봤던 그래프들은 중간중간 빨강색 부분마다 노드가 다른일들을 처리할 수 있었다.
즉, 빨강색 부분동안 노드 메인 스레드는 다른걸 처리할 수 있기 때문에 Blocking하지 않다.
그래서 많은 API호출을 처리할 수 있는데,
위와 같이 CPU intensive한 것들은 노드에서 피해주는 것이 좋다.
그럼 어떻게하면 좋냐.
for (let i = 0; i < 1000000; i++) {
}
위와 같은 코드를 async
, await
같은거를 사용해서 따로 API를 만들어서..
예를 들어 위와 같은 코드를 C++이 처리해주는 코드를 만들어서 그거를 async/await
같은 거를 써서 호출을 하는 것이다.
노드에서 처리하는 것이 아니라 C++을 호출해서 처리한다던지..
아니면 노드에서도 Child thread
를 만들 수 있다.
노드가 싱글 스레드라고 하지만 Child thread
를 만들어 멀티 스레드 코딩을 할 수 있다.
즉 위와 같은 CPU intensive 코드만 처리하는 child thread
를 만들어 처리하게 한다던지, 그렇게 할 수 있다.
여튼 우리가 사용하는 대부분의 어플리케이션(배달의 민족, 카카오톡 등등)들은 CPU intensive 코드들을 처리하는 것이 아니라
위에서 보여드렸던 Promise, async, await
같은거를 사용해만든.. 다 API 처리하고 관리 권한 이런거 확인하는겁니다.
여튼 CPU intensive한 것들은 노드에서 피하자!
API 처리 코드, 비동기적으로 작성, 이런 것이 중요하다!
병렬 실행되어도 상관없는 것들, 그리고 순차적으로 실행되어야하는 것들, 그런것들을 잘 생각해 짜야된다!