You don't know JS - 비동기

이동창·2021년 9월 24일
0

비동기와 성능

https://engineering.huiseoul.com/df65ffb4e7e

이벤트와 루프

사실...

JS 엔진은 비동기라는 개념이 없음
엔진은 묵묵히 주어지는 대로 진행할 뿐이고,
비동기 스케쥴링을 담당하는 것은 이벤트 루프이다.

setTimeout(콜백, 1000)

위와 같은 함수는 엔진이 알아서 1000초 후에 콜백 함수를 실행하는게 아니라,
환경이 1000초 후에 콜백을 이벤트 루프 큐에 넣고, 루프에서 틱이 발생하면 그 때 실행되는 것이다.
만약 이벤트 루프가 가득 차 있다면, 정확히 1초 후에 작동하지 않을 수도 있는 것이 바로 이 때문이다.

큐의 상황을 정확하게 예측할 수 없기에, 지정한 시간 이전에 일어나지 않을 것이라는 것만 보장됨

다만 ES6부터는 이벤트 루프 큐 또한 엔진의 관할이 되었다
바로 잡 큐의 등장이다.

잡 큐

잡 큐의 개념이 아직 확실하게 잡힌 것은 아니지만,
확실한건 현재 진행되고 있는 틱의 맨 끝 부분에서 실행되게끔 해준다.

console log("A")

setTimeout( function(){
  console.log("B")
} , 0)

schedule( function(){
  console.log("C")
  
  schedule( function(){
    console.log("D")
  })
})

실제 실행결과는 A-C-D-B이다.
왜냐면 잡큐는 현재 이벤트 루프 틱 마지막에서 시작하지만,
타이머는 다음 이벤트 루프 틱에서 실행하도록 스케줄링하기 때문에

콜백

콜백이란?

https://jason9319.tistory.com/406
여기가 진짜 잘 설명해놓은 듯

간단하게 하자면, 나중에 실행할 함수를 인자로 전달할 때 그 함수를 콜백 함수라고 하는데,
전달된 함수의 실행 순서를 안에서 조작할 수 있기에 비동기 프로그래밍이 가능하다.
ex)

console.log('1');

function InOrderPrint(callback){
  setTimeout(function(){
    console.log('2');
    callback();
  },1000);

InorderPorint(function() {
  console.log('3');
})

콜백의 단점

스케쥴링의 권한이 엔진에 있지 않기 때문에,
무엇이 먼저 실행되느냐에 대한 확신이 정확히 서지 않는다.

function result(data){
  console.log(a)
}

var a = 0;

ajax("url", result);
a++;

콘솔창 결과는 0일까? 1일까?

정답은 둘다일 수도 있다는 것이다.
동기로 일어날 지, 비동기로 일어날 지는 조건에 따라 다르다.

이를 해결하기 위해 나온 개념이 바로 프라미스이다.


프라미스

프라미스란?

먼저 콜백으로 add 함수 구현한 것을 봐보자

function add(getX,getT,cb){
  var x,y;
  // getX와 getY의 콜백 함수들은 각각의 x,y가 fetch되면 이벤트 루프에 들어감
  // 즉 getX와 getY 중에 뭐가 먼저 실행될 지 모르는 상태
  getX( function(xVal){
    x = xVal // 일단 x에다가 가져온 값 넣어주고 y 있는지 확인
    if(y != undefined) {
      cb(x + y) // 둘다 있으면 더함
    } 
  })
  getY(function(yVal){
    y = yVal
    if(x != undefined) {
      cb(x + y)
    }
  })
}

add(fetchX, fetchY, function(sum){
  console.log(sum)
})

나쁘진 않지만 콜백이 늘어날 수록 각각의 경우를 다르게 생각하며 처리해줘야해서
머리도 아프고 코드도 아름답지 못하다.

그렇다면 프라미스로 구현한다면?

function add(xPromise, yPromise){
  return Promise.all([xPromise, yPromise]) // 두 프라미스를 합친 xyPromise 생성
  // then을 호출해 value를 더한 결과값을 담은 resultPromise 생성
    .then(function(values) {
      return values[0] + values[1] 
    })
}
  
add(fetchX(), fetchY()) // 아래의 then은 resultPromise에 걸려있는 then임
  .then(function(sum){
    console.log(sum)
  }

다음과 같이 깔끔하게 작성된다.

프라미스의 then() 함수는 resolve일 때와 reject일 때 실행할 함수를 인자로 넘겨받는다.
또한, 프라미스는 resolved되면 상태가 그대로 유지된다. 이 불변성은 굉장히 중요함.

제어의 역전

원래는 작업하는 함수에게 콜백을 넘겨줬었다.
그럼 그 함수가 콜백 함수에 대한 제어를 할 수 있었는데 프라미스는 이가 역전된다.
즉, 작업하는 함수가 구독기를 넘겨주면, 이 구독기를 가지고 자유롭게 콜백과 같은 함수를 사용하면 된다.

아래의 예를 보자

function foo(x) {
  return listner // 이벤트 구독기 반환
}
var evt = foo(42) // evt에 구독기 넣어두고
evt.on("completion", function(){}) // 완료에 대한 알람 on
evt.on("failure", function(){}) // 실패에 대한 알람 on

원래 같으면 콜백을 foo에 전달해야되는데, 위에서는 이벤트 구독기를 foo가 반환하도록 했다.

var evt = foo(42)
bar(evt)
baz(evt)

따라서 다음과 같이 하나의 이벤트 구독기에 여러 함수가 관심을 갖는 것도 자유롭고
foo 또한 누가 자신을 구독하고 있는 지에 크게 신경쓰지 않아도 된다.
(콜백이었다면, 그 경우를 다 생각해서 코딩해줘야 됨)

프라미스 이벤트

프라미스는 위의 이벤트 구독기와 비슷하다.

function foo(x) {
  // 시간이 오래걸리는 일 시작..
  
  // 프라미스 생성 후 반환
  return new Promsie( function(resolve, reject) {} )
}

function bar(fooPromise) {
  fooPromise.then(
    function(){}, // fulfill 시
    function(){} // reject 시
  )
}

var p = foo(42)

bar(p)

프라미스 구별법 Thenable

///////////////////////
///////////////////////
///////////////////////
이건 좀 더 알아보자
///////////////////////
///////////////////////
///////////////////////

프라미스 믿음

제어의 역전 덕분에, 프라미스는 콜백의 몇 가지 문제들을 해결했다.

// A 출력 후, C 출력하는 콜백 스케줄링 해주는 콜백 스케줄링
p.then(function(){
  p.then(function(){
    console.log("C");
  })
  console.log("A")
})
// B 출력하는 콜백 스케줄링
p.then(function(){
  console.log("B")
})
// console.log("A") 실행 후 console.log("C") 콜백 스케줄
// console.log("B") 실행
// 스케줄 된 console.log("C") 실행
// A B C

콜백이라면 C가 B보다 먼저 찍히는 일이 발생할 수도 있었을 것이다.

용어 정리

resolve - 귀결
fulfill - 이룸
reject - 버림

왜 resolve라는게 있을까? 이룸, 버림 2개만으로도 충분할 것 같은데..

그 이유는 new Promise(function(resolve,reject){}) 에서
reject는 풀어보지 않고 바로 버리지만, resolve는 풀어보기 때문에 이룸인지 버림인지 모르기 때문이다.
그래서 귀결을 시도하는 것이기에 fulfill이 아닌 resolve이라는 용어를 쓴다.

따라서, then()에 제공하는 콜백명은 fulfilled(), rejeceted()가 어울린다.

에러 처리

사실 프라미스에서 에러 처리 문제가 존재하는 경우가 있다.

var p = Promise.resolve(42)

p.then(
  function fulfilled(msg){
    // 42는 문자열이 아니므로 에러가 나온다
    console.log(msg.toLowerCase())
  },
  function rejected(err){
    // 실행 x
  }
);

어떻게 보면 rejected로 에러 처리를 다했다고 생각하고 방심하겠지만,
fulfilled 안에서 예상치 못한 에러가 나오면 rejected로 잡을 수가 없다.

이 에러를 잡으려면 .then을 한번 더 붙이면 되긴 하는데,
거기서도 똑같은 문제가 발생하면 또 다시 .then을 붙여놨어야 잡을 수가 있다.
결국 계속 반복되는 문제가 생긴다..

try {
  // 에러 발생
} catch {
  // 에러 처리... 근데 여기서 에러 발생하면??
} // 처리 할 방법이 없네..

위 처럼 try..catch에도 있는 문제인데,
이를 해결하기 위해 전역 에러 처리기 같은 걸 만드는 중이라고 한다.
asynquence 프라미스 라이브러리에 구현이 되어있다고 한다 (나중에 써먹어보자)


제너레이터

자바스크립트에서는 함수가 실행되면 완료될 때까지 계속 실행됨
중간에 다른 코드가 끼어들어서 실행되는 법은 없음

다만 ES6부터는 제너레이터는 새로운 개념이 등장했는데

var x = 1

fucntion foo() {
  x++
  bar() // (1)
  console.log("x:", x)
}

function bar() {
  x++
}

foo() // x:3

코드를 보면 bar() 때문에 x가 3으로 찍혔다.
근데 만약, (1) 자리에 bar() 없이도 x++가 실행될 수 있는 방법이 있을까?

다음을 봐보자

var x = 1

function *foo(){
  x++
  yield
  console.log("x:", x)
}

function bar() {
  x++
}

var it = foo()
it.next() // x == 2
bar() // x == 3
it.next() // "x:3"

보다시피 foo는 실행시 끝까지 완료 되는 것이 아니라 yield 부분에서 잠깐 멈춘다.
next()를 이용해서 다시 재개했지만, 굳이 완료할 필요도 없다.

양방향 질문

function *foo(x) {
  var y = x * (yield "hello") // return "hello" 와 비슷한 느낌, 대신 대답을 요구함
  return y
}

var it = foo(6)
 
var res = it.next()
res.value // hello

res = it.next(7)
res.value // 42

이터레이터 부분만 보면

  1. 첫 번째 next()에서는 아무것도 전달하지 않고, iter에게 값을 요구한다.
  2. 그럼 iteryield 부분까지 도달하고, hello을 반환하며 값을 요구한다.
  3. 두 번째 next()는 7을 넘겨주며 다시 값을 요구한다.
  4. 그럼 iterreturn에 도달하고, y를 반환하며 마친다.

보다시피 핑퐁을 하듯, 질문과 대답을 주고 받으면서 진행되는 것을 볼 수가 있다.

인터리빙

var a = 1
var b = 2
function foo() {
  a++
  b = b * a
  a = b + 3
}
function bar() {
  b--
  a = * + b
  b = a * 2
}

foo 실행 사이 사이에 bar를 실행하는 건 불가능하다. 하지만 제너레이터라면?

var a = 1
var b = 2

function *foo() {
  a++
  yield
  b = b * a
  a = (yield b) + 3
}

function *bar() {
  b--
  yield
  a = (yield 8) + b
  b = a (yield 2)
}

이렇게 되면, 실행 결과가 foo->bar, bar->foo 2개가 아닌,
여러 가지 결과 값이 나올 수가 있다.

콜백과 제너레이터의 만남

다음과 같은 코드는 작동할 수가 없다

var data = ajax("url")
console.log(data)

왜냐하면 data가 동기적으로 불러와지지 않는다면 undefined이니까
근데 다음 코드를 한번 봐보자

function foo(x,y) {
  ajax("url", function(err,data){
    if(err) {
      it.throw(err)
    } else(data) {
      it.next(data)
    }
  })
}

function *main() {
  try {
    var text = yield foo(11,31)
    console.log(text)
  } catch(err) {
    console.log(err)
  }
}

var it = main()
// 모두 시작
it.next()

여기서 중요한 부분은

var text = yield foo(11,31)
console.log(text)

이 부분인데, 여기서 foo는 ajax를 호출하는데 ajax는 비동기 함수이다.
즉 위에서 작동하지 않는 코드의 예랑 비슷한 형태이다.

근데 얘는 작동함.. 왜?
바로 yield 키워드 덕분이다.
여기서 알아서 잠깐 멈췄다가, foo에서 data가 불러와지면 next(data) 실행으로
yield 표현식은 응답 데이터로 채워지게 된다.

그 후 제너레이터가 코드를 시작하면서 text변수에 데이터가 할당됨

마치 동기처럼 작성했는데, 완전한 비동기로 움직이고 있는 것이다.

근데 장점은 이뿐만이 아님

동기적 에러 처리

바로 에러 처리에도 유연하다는 것인데,
프라미스에서 봤듯, try..catch로는 비동기 에러를 (완벽히) 잡을 도리가 없다.

근데 제너레이터는 이것도 가능하게 해줌

if(err) {
  it.throw(err)
}

요 친구가 제너레이터로 err를 던지고, 그럼 제너레이터 안에서 catch로 넘어간다.
이게 가능한 이유는 yield에서 멈춰주기 때문에, 그동안 에러를 throw 할 시간이 생기는 것

0개의 댓글