본문 바로가기

JavaScript

[You don`t Know JS 정리] ch.1 비동기성

Ch1. 비동기성: 지금과 나중

  • 프로그램에서 '지금'에 해당하는 부분 그리고 '나중'에 해당하는 부분 사이의 관계가 바로 비동기 프로그램의 핵심이다.

1.1 프로그램 덩이

  • 자바스크립트 프로그램은 보통 여러 개의 덩이들로 구성된다. 가장 일반적인 프로그램 덩이 단위는 함수다.
  • '나중'은 '지금'의 직후가 아니다. 지금 당장 끝낼 수 없는 작업은 비동기적으로 처리되므로 프로그램을 중단하지 않는다.
var data = ajax('http://naver.com);

console.log(data)
  • 위 코드에서 ajax는 비동기적으로 지금 요청하고 나중에 결과를 받는다.
  • 가장 간단한 방법은 콜백 함수라는 장치를 이용하는 것이다.
ajax("http://naver.com", function myCallback(data) {
  console.log(data)
})

1.1.1 비동기 콘솔

  • console.log는 환경에 따라 작동 방식이 다르도 종종 혼돈을 유발하니 무조건 콘솔창 결과에 의존하지 말고 자바스크립트 디버거의 중단점을 활용하자.

1.2 이벤트 루프

  • 실제로 자바스크립트에는 비동기라는 개념이 없다. 자바스크립트 엔진은 요청하면 프로그램을 주어진 시점에 실행할 뿐이다.
  • 자바스크립트 엔진은 혼자서는 안 되고 반드시 호스팅에서 실행된다. (브라우저, 노드) 여러 프로그램 덩이를 시간에 따라 매 순간 한번씩 엔진을 실행시키는 것은 이벤트 루프이다.
  • '이벤트'를 스케줄링하는 일은 언제나 엔진을 감싸고 있던 주위 환경의 몫이었다.
  • 아래는 이벤트 루프의 의사 코드다 (저자의 코드)
var eventLoop = []
var event

while (true) {
  if (eventLoop.length > 0) {
    event = eventLoop.shift()
    try {
      event()
    } catch (err) {
      reportError(err)
    }
  }
}
  • 위 코드에 while 무한 루프가 있는데 매 순회를 틱이라고 한다. 틱이 발생할 때마다 큐에 적재된 이벤트를 꺼내어 실행한다.

  • setTimeout은 콜백을 이벤트 루프 큐에 넣지 않는다. 단지 타이머를 설정하는 함수고, 타이머가 끝나면 환경이 콜백을 이벤트 루프에 삽입한 뒤 틱에서 콜백을 꺼내어 실행한다. 즉, 이벤트 루프가 이미 몇개의 원소들로 가득차 있다면 콜백은 기다리게 된다. setTimeout이 항상 완벽하게 정확한 타이밍으로 동작하지 않는것이 이때문이다.

  • ES6에 이르러서는 작동 방식이 정확히 규정되어 호스팅 환경이 아닌 자바스크립트 엔진의 관할이 되었다.

1.3 병렬 스레딩

  • 비동기와 병렬은 아무렇게나 섞어 쓰는 경우가 많지만 그 의미는 완전히 다르다. 비동기는 지금과 나중 사이의 간극에 관한 용어고 병렬은 동시에 일어나는 일들과 연관된다.
  • 프로세스와 스레드는 가장 많이 쓰는 병렬 컴퓨팅 도구로, 별개의 프로세서, 심지어는 물리적으로 분리된 컴퓨터에서도 독립적으로 실행되며 여러 스레드는 하나의 프로세스 메모리를 공유한다.
  • 반면 이벤트 루프는 작업 단위로 나누어 차례대로 실행하지만 공유 메모리에 병렬로 접근하거나 변경할 수는 없다. 병렬성과 직렬성이 나뉜 스레드에서 이벤트 루프를 협동하는 형태로 공존하는 모습이다.
  • 자바스크립트는 절대로 스레드 간에 데이터를 공유하는 법이 없으므로 비결정성의 수준은 문제가 되지 않는다. 하지만 자바스크립트가 항상 결정적인것도 아니다.

1.3.1 완전-실행

  • 자바스크립트의 작동 모드는 단일 스레드이다. 아래 코드를 예를 들자면
var a = 1;
var b = 2;

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

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

ajax('http://naver.com/1)
ajax('http://naver.com/2)
  • foo와 bar는 상대의 실행을 방해할 수 없으므로 이 프로그램의 결괏값은 먼저 실행되는 함수가 좌우한다.
// 결과 1
var a = 1
var b = 2

// foo
b = b * a
a = b + 3

// bar
a = 8 + b
b = a * 2

a // 11
b // 22
// 결과 2
var a = 1
var b = 2

// bar
a = 8 + b
b = a * 2

// foo
b = b * a
a = b + 3

a // 183
b // 180
  • 똑같은 코드인데 결괏값은 두 가지이므로 이 프로그램은 비결정적이다. 하지만 여기서 비결정성은 함수 순서에 따른것이지, 스레드처럼 표현식의 처리 순서 수준까지는 아니므로, 스레드보다는 결정적이라고 할 수 있다.
  • 자바스크립트에서는 함수 순서에 따른 비결정성을 흔히 경합 조건이라고 표현한다.

1.4 동시성

  • 동시성은 복수의 '프로세스'가 같은 시간 동안 동시에 실행됨을 의미하며, 각 프로세스 작업들이 병렬로 처리되는지와는 관계없다.
  • 예를 들어, 사용자가 스크롤바를 아래로 내리면 계속 갱신된 상태 리스트가 화면에 표시되는 웹 페이지를 만들고자 한다. 이런 기능은 2개의 분리된 '프로세스'를 동시에 실행할 수 있어야 제대로 기능한다. 첫 번째 프로세스는 사용자가 페이지를 스크롤바로 내리는 순간 발생하는 onscroll 이벤트에 반응한다. 두 번째 프로세스는 ajax 응답을 받는다.
  • onscroll 이벤트와 ajax 응답 이벤트는 동시에 발생할 수 있다. 자바스크립트는 한 번에 하나의 이벤트만 처리하므로 정확히 같은 시각에 실행되는 일은 결코 있을 수 없다.

onscroll, request1
onscroll, request2
response1
onscroll, request3
response2
response3
onscroll, request4
onscroll, request5
onscroll, request6
response4
onscroll, request7
response6
response5
response7

  • 단일 스레드 이벤트 루프는 동시성을 나타내는 하나의 표현 방식이다.

1.4.1 비상호 작용

  • 어떤 프로그램 내에서 복수의 '프로세스'가 단계/이벤트를 동시에 인터리빙 할 때 이들 프로세스 사이에 연관된 작업이 없다면 프로세스 간 상호 작용은 사실 의미가 없다. 즉, 비결정성은 완벽하게 수용이 가능하다.
var res = {};

function foo(results) {
  res.foo = results;
};

function bar(results) {
  res.bar = results
};

ajax('http://naver.com/1)
ajax('http://naver.com/2)
  • 위 코드는 foo와 bar 중 누가 먼저 실행되는 서로 영향을 끼치지 않으니 실행 순서를 문제 삼을 필요가 없다.

1.4.2 상호 작용

  • 동시 프로세스들은 필요할 때 스코프나 DOM을 통해 간접적으로 상호 작용을 한다. 이때 이미 한번 살펴봤던 것처럼 경합 조건이 발생하지 않도록 잘 조율해주어야 한다.
var res = []

function response(data) {
  res.push(data)
}

ajax("http://naver.com/1", response)
ajax("http://naver.com/2", response)
  • 위 코드에서 개발자 의도는 아마 1, 2 url 결과를 순서대로 res에 담고 싶었을 것이다. 하지만 코드 순서가 아니라 응답 순서대로 res 배열에 저장이 될 것이다. 따라서 이 순서를 해결하려면 상호 작용의 순서를 잘 조정해야 한다.
var res = []

function response(data) {
  if (data.url === "http://naver.com/1") {
    res[0] = data
  } else if (data.url === "http://naver.com/2") {
    res[1] = data
  }
}

// ...

1.4.3 협동

  • 협동적 동시성은 실행 시간이 오래 걸리는 '프로세스'를 여러 단계/배치로 쪼개어 다른 동시 '프로세스'가 각자 작업을 이벤트 루프 큐에 인터리빙 하도록 하는 게 목표다.
var res = []

function response(data) {
  res = res.concat(
    // 배열의 원소를 하나씩 변환한다.
    // 원래 값을 2배로 늘린다.
    data.map(function (val) {
      return val * 2
    })
  )
}

ajax("http://naver.com/1", response)
ajax("http://naver.com/2", response)
  • 위 코드에서 데이터가 천만개 정도라면 해당 함수를 실행하는 동안 페이지가 먹통이 된다. 좀 더 친화적이고 협동적인 동시 시스템이 되려면 각 결과를 비동기 배치로 처리하고 이벤트 루프에서 대기 중인 다른 이벤트와 함께 실행되게끔 해야 한다.
var res = []

function response(data) {
  var chunk = data.splice(0, 1000)

  res = res.concat(
    chunk.map(function (val) {
      return val * 2
    })
  )

  if (data.length > 0) {
    setTimeout(function () {
      response(data)
    }, 0)
  }
}

// ...
  • 위 코드는 최대 1000개 덩이 단위로 데이터 집합을 처리했다. 물론 더 많은 후속 '프로세스'를 처리해야 하지만 각 '프로세스' 처리 시간은 단축된다.

1.5 잡 큐

  • 잡 큐는 ES6부터 이벤트 루프 큐에 새롭게 도입된 개념이다. 주로 프라미스의 비동기 작동에서 가장 많이 보게 될 것이다.
  • 잡 큐는 이벤트 루프 큐에서 '매 틱의 끝자락에 매달려 있는 큐'라고 생각하면 알기 쉽다. 이벤트 루프 틱 도중 발생 가능한 비동기 특성이 내재된 액션으로 인해 전혀 새로운 이벤트가 이벤트 루프 큐에 추가되는 게 아니라 현재 틱의 잡 큐 끝 부분에 추가된다.
  • 자세한 내용은 3장에서 프라미스 비동기 작동을 다룰 때 설명한다.

1.6 문 순서

  • 자바스크립트 엔진은 반드시 프로그램에 표현된 문의 순서대로 실행하지 않는다.
  • 중요한 것은 여기서 설명하는 내용은 육안으로 확인할 수 없다는 점이다.
  • 자바스크립트 엔진은 코드를 컴파일한 뒤 문 순서를 재정렬하면서 실행 시간을 줄일 여지는 없는지 확인한다.