2023년 5월 26일
자바스크립트는 싱글스레드라 stack을 하나만 가지고 있음에도 불구하고 어떻게 비동기처리를 하는지 알아보겠습니다.
자바스크립트는 싱글스레드이기 때문에 하나의 실행컨텍스트 스택을 가집니다. 이는 스택에서 하나의 일만 할수있다는 뜻이 됩니다. 즉, 처리에 시간이 걸리는 태스크를 실행하는 경우 블로킹(blocking) 이 발생하게 됩니다. 이를 동기처리라고 합니다.
function first() {
second()
console.log('첫번째')
}
function second() {
third()
console.log('두번째')
}
function third() {
console.log('세번째')
}
first()
그에 비해 비동기적 처리는 다음과 같은 특징을 가집니다.
console.log('시작')
setTimeout(function() {
console.log('3초후 실행')
}, 3000)
console.log('끝')
자바스크립트는 런타임 환경에서 웹API와 이벤트루프, 그리고 태스크 큐를 통해 비동기 처리를 수행합니다.
가령 다음과 같은 코드가 있다면
function second() {
setTimeout(function() {
console.log('2+2')
}, 2000)
}
function first() {
console.log('1+1')
second()
console.log('3+3')
}
first()
자바스크립트는 위와 같은 방식으로 비동기적 처리를 수행하고 있습니다.
앞서 자바스크립트에 비동기 처리를 말씀드렸지만, 종종 우리는 비동기적 처리를 동기적으로 해야할 때가 있습니다.
function findUser(id) {
let user
setTimeout(function() {
console.log('waited 0.1 sec.')
user = {
id: id,
name: 'User' + id,
email: id + '@test.com',
}
}, 100)
return user
}
const user = findUser(1)
console.log('user:', user)
가령 findUser 라는 비동기적 함수를 호출하고 그 결과값을 콘솔로 찍는다고 하면, 이당시 user는 선언만되고 값이 할당되지 않았기때문에 undefind가 출력됩니다.
이때 user를 정상적으로 출력하고 싶다면 다음과 같은 콜백을 사용할 수 있습니다.
function findUserAndCallBack(id, cb) {
setTimeout(function() {
console.log('waited 0.1 sec.')
const user = {
id: id,
name: 'User' + id,
email: id + '@test.com',
}
cb(user)
}, 100)
}
findUserAndCallBack(1, function(user) {
console.log('user:', user)
})
하지만 이런 콜백이 굉장히 많아진다면 함수의 뎁스가 깊어져 콜백지옥이 만들어 질것입니다.
findUserAndCallBack_1(1, function(user) {
findUserAndCallBack_2(1, function(user) {
findUserAndCallBack_3(1, function(user) {
findUserAndCallBack_4(1, function(user) {
...
})
})
})
})
위와 같은 방법은 유지보수가 힘들기 때문에 좋은 방법은 아닙니다.
하지만 이런 방식은 ES6부터 추가된 프로미스(Promise)를 통해 해결할 수 있습니다.
Promise는 현재에는 당장 얻을 수는 없지만 가까운 미래에는 얻을 수 있는 어떤 데이터에 접근하기 위한 방법을 제공합니다. 당장 원하는 데이터를 얻을 수 없다는 것은 데이터를 얻는데까지 지연 시간(delay, latency)이 발생하는 경우를 말합니다. I/O나 Network를 통해서 데이터를 얻는 경우가 대표적인데, CPU에 의해서 실행되는 코드 입장에서는 엄청나게 긴 지연 시간으로 여겨지기 때문에 Non-blocking 코드를 지향하는 자바스크립트에서는 비동기 처리가 필수적입니다.
이런 프로미스를 사용하려면 첫번째로 프로미스 객체를 리턴하는 함수를 생성하면 됩니다.
function returnPromise() {
return new Promise((resolve, reject) => { ... } );
}
이때 프로미스는 resolve, reject 2개의 함수형 파리미터를 가지게 됩니다. resolve는 실행함수 내에서 실행할수 있는 함수이며, resolve호출의 의미는 작업의 성공을 뜻하며 reject는 실패를 의마힙니다.
또한 then/catch 메서드를 통해 각각 성공과 실패시의 매개변수를 받아 콜백함수를 실행할수 있습니다.
function findUser(id) {
return new Promise(function(resolve) {
setTimeout(function() {
console.log('waited 0.1 sec.')
const user = {
id: id,
name: 'User' + id,
email: id + '@test.com',
}
resolve(user)
}, 100)
})
}
findUser(1)
.then(function(user) {
console.log('user:', user)
})
.catch(function(error) {
console.error('Error:', error)
})
위 함수를 프로미스를 사용하는 함수로 만들면 다음과 같습니다. 또한 매서드 체이닝을 통해 then 안에 findUser를 리턴하고 다시 then을 호출하는 방식으로 콜백보다는 더 보기쉬운 함수호출을 할 수 있습니다.
이는 then/catch 메서드는 또 다른 프로미스 객체를 리턴하며, 이 프로미스 객체는 인자로 넘긴 콜백 함수의 리턴값을 다시 then/catch 메서드를 통해 접근할 수 있도록 하기 때문입니다.
findUser(1)
.then(function(user) {
console.log('user:', user)
return findUser(1)
})
.then(function(user) {
console.log('user:', user)
return findUser(1)
})
.then(function(user) {
console.log('user:', user)
return findUser(1)
})
.catch(function(error) {
console.error('Error:', error)
})
그리고 가장 최근에서는 ECMAScript2017 공식 스펙으로 지정된 async/await을 사용하여 좀더 간결하게 사용 할 수 있습니다.
async function asyncFindUser() {
try {
const user = await findUser(1)
console.log('user:', user)
} catch (error) {
console.log(error)
}
}
이경우 try/catch 매서드를 사용하여 보다 간결하고 보기 쉽게 순차적 비동기처리를 실행할 수 있습니다.
참고로 async 함수의 리턴 값은 프로미스 입니다.
function a() {
console.log('a')
}
async function b() {
console.log('b1')
await a()
console.log('b2')
}
b()
console.log('c')
위와 같은 코드를 async를 지원하지 않는곳에서 바벨로 트랜스파일링할 경우 다음과 같은 코드가 나옵니다.
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
try {
var info = gen[key](arg)
var value = info.value
} catch (error) {
reject(error)
return
}
if (info.done) {
resolve(value)
} else {
Promise.resolve(value).then(_next, _throw)
}
}
function _asyncToGenerator(fn) {
return function() {
var self = this,
args = arguments
return new Promise(function(resolve, reject) {
var gen = fn.apply(self, args)
function _next(value) {
asyncGeneratorStep(gen, resolve, reject, _next, _throw, 'next', value)
}
function _throw(err) {
asyncGeneratorStep(gen, resolve, reject, _next, _throw, 'throw', err)
}
_next(undefined)
})
}
}
function a() {
console.log('a')
}
function b() {
return _b.apply(this, arguments)
}
function _b() {
_b = _asyncToGenerator(function*() {
console.log('b1')
yield a()
console.log('b2')
})
return _b.apply(this, arguments)
}
b()
console.log('c')
위 코드에서 볼수있듯이 b 함수가 b와 _b로 나누어졌고 _asyncToGenerator와 asyncGeneratorStep 함수가 생겼습니다.
function _asyncToGenerator(fn) {
return function() {
const self = this
const args = arguments
return new Promise(resolve => {
const gen = fn.apply(self, args)
const _next = value => {
asyncGeneratorStep(gen, resolve, _next, 'next', value)
}
_next(undefined)
})
}
}
함수이름이 asyncToGenerator인 만큼 제너레이트함수로 변경된 async함수를 generator 객체로 변경시키고 실행하는 함수입니다. 인자로 generator함수를 받게 되며, Promise객체를 반환하는 함수를 반환하는 함수입니다.
new Promise(resolve => {
// gen: 제네레이터 객체
const gen = fn.apply(self, args)
const _next = value => {
asyncGeneratorStep(gen, resolve, _next, 'next', value)
}
// 첫번째 yield(await)까지 실행한다.
_next(undefined)
})
인자로 받은 제너레이터 함수를 가지고 제네레이터 객체를 만듭니다. (gen)
_next함수를 실행 === asyncGeneratorStep함수를 실행합니다. (generator 객체를 내부에서 next해주는 함수)
결국 _asyncToGenerator함수는 인자로 받은 제너레이터함수를 한번 next해준다고 생각하면 됩니다. 즉, 첫번째 await까지 실행한다고 보면됩니다.
function asyncGeneratorStep(gen:제너레이터 객체, resolve:promise의 resolve함수, _next:_asyncToGenerator의 _next함수 (재귀적으로 사용), key, arg) {
/**
* 제네레이터 객체의 next 메소드를 호출한다.
*/
const genValue = gen[key](arg)
/**
* generator가 done이 됐으면 resolve한다.
* 아니면 뒤의 작업은 then으로 이어서 한다. => 여기서 마이크로 테스크 큐로 들어가기 때문에 순서의 차이가 발생한다.
*/
if (genValue.done) {
resolve(genValue.value)
} else {
Promise.resolve(genValue.value).then(_next)
}
}
맨 처음 _asyncToGenerator에서 실행된 asyncGeneratorStep에서는 genValue에서 gen(제너레이터 객체)의 next 메소드를 호출합니다. (이때 첫번째 await까지 실행)
이제 genValue가 끝났는지 genValue.done 으로 확인 후 끝났으면 resolve를 끝나지 않았으면 뒤의 작업을 then에 넘겨서 실행합니다. (즉, 첫번째 await 이후의 작업은 then에 들어가서 실행)
위 작업을 반복합니다.
즉 async 함수는 제너레이터 함수로 변경이되어서 실행되며, async함수에서 변경된 제너레이터 함수는 내부적으로 yield 될 때 그 뒤의 작업은 then에게 넘겨줍니다.
이때 뒤의 작업은 마이크로 태스크큐에 들어가게 되고, 콜스택이 비워진 다음에 실행되기 때문에 맨 위 예제와 같은 현상이 발생하는 것입니다.
[참고]
https://www.daleseo.com/js-async-promise/
https://inpa.tistory.com/entry/%F0%9F%91%A9%E2%80%8D%F0%9F%92%BB-%EB%8F%99%EA%B8%B0%EB%B9%84%EB%8F%99%EA%B8%B0-%EB%B8%94%EB%A1%9C%ED%82%B9%EB%85%BC%EB%B8%94%EB%A1%9C%ED%82%B9-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC
https://springfall.cc/post/7
https://velog.io/@proshy/async-await-%EB%8F%99%EC%9E%91%EC%9B%90%EB%A6%AC