Skip to content

Kyunghwa Yoo

YOU DON'T KNOW JS - 비동기와 성능

javascript5 min read

콜백

콜백지옥

콜백지옥은 중첩/들여쓰기 뿐만이 문제점이 아니다. 문제는 하나의 단계마다 성공을 보장하지 않는다는 것이다. 성공하지 못했을 경우를 일일이 다 예외처리해준다면 코드가 너무 복잡해져 관리가 힘들어진다. 또한 콜백식 비동기 코드는 중요한 비동기 처리 부분을 다른 써드파티 서비스가 대부분 제어하게 된다. (ex. ajax) 이럴 경우 이 서드파티 서비스에 대한 신뢰도 문제가 발생한다. 그렇다고 이런 문제들을 해결하기 위해서 자체적으로 임시 유틸리티를 만들어 사용한다면 코드가 점점 비대해질 것이다. 이렇다보니 ES6에서 해결책인 프라미스가 나오게 되었다.

프라미스

프라미스: 프로그램의 진행을 다른 파트에 넘겨주지 않고도 개발자가 언제 작업이 끝날지 알 수 있고 그 다음에 무슨 일을 해야 할지 스스로 결정할 수 있는 체계

  • 동기인 작업과 비동기인 작업을 같이 해야 할 경우 둘 다 비동기로 만들어서 작업하는 것이 낫다.

  • 프라미스는 시간 의존적인(time-dependent) 상태를 외부로부터 캡슐화(원래 값을 이룰지 버릴지 기다림) 하기 때문에 프라미스 자체는 시간 독립적(time-independent) 이고 그래서 타이밍 또는 내부 결괏값에 상관없이 예측 가능한 방향으로 구성(조합) 할 수 있다.

  • 또한 프라미스는 일단 resolved 된 후에는 상태가 그대로 유지되며(즉, immutable 값이 된다) 몇 번이든 필요할 때마다 꺼내 쓸 수 있다.

  • 진짜 프라미스: then() 메서드를 가진 then-able 객체 또는 함수를 정의하여 판별하는 것으로 규정됐다.

  • duck typing: 어떤 값의 타입을 그 형태(어떤 프로퍼티가 있는가) 를 보고 짐작하는 type check 를 말한다. '오리처럼 보이는 동물이 오리 소리를 낸다면 오리가 분명하다' 는 것이다.

  • 콜백만 사용한 코드의 믿음성 문제

    • 너무 일찍 콜백을 호출

      • 프라미스는 바로 이루어져도 프라미스의 정의상 동기적으로 볼 수 없으니 영향 받을 일이 없다.
    • 너무 늦게 콜백을 호출 (또는 전혀 호출하지 않음)

      • 프라미스 then() 에 등록한 콜백은 새 프라미스가 생성되면서 resolve(), reject() 중 어느 한 쪽은 자동 호출하도록 스케줄링된다. 이렇게 스케줄링된 두 콜백은 다음 비동기 시점에 예상대로 실행될 것이다.

        1var p3 = new Promise(function(resolve, reject) {
        2 resolve('B');
        3});
        4
        5var p1 = new Promise(function(resolve, reject) {
        6 resolve(p3);
        7});
        8
        9var p2 = new Promise(function(resolve, reject) {
        10 resolve('A');
        11});
        12
        13p1.then(function(v) {
        14 console.log(v);
        15});
        16
        17p2.then(function(v) {
        18 console.log(v);
        19});
        20
        21// 결과: A B <- p1이 즉시 원시값으로 귀결되지 않고 다른 프라미스를 부르기 때문에 비동기 잡 큐에서 밀리게 된다.
      • 한번도 콜백을 안 호출할 경우 프라미스로 해결할 수 있다. 우선 프라미스 스스로 귀결 사실을 알리지 못하게 막을 방도는 없다. resolve, reject 가 모두 등록된 상태면 반드시 하나는 부른다. 만일 프라미스 스스로 어느쪽으로도 귀결되지 않으면 Race(경합) 이라는 상위 수준 추상화(Promise.race())를 통해 프라미스로 해결할 수 있다.

    • 너무 적게, 아니면 너무 많이 콜백을 호출

      • 프라미스는 정의상 단 한번만 귀결된다. 여러 차례 호출하려고 하면 오직 최초의 귀결만 취하고 이후의 시도는 무시한다.
    • 필요한 환경/인자를 정상적으로 콜백에 전달 못함

      • 명시적인 값으로 귀결되지 않으면 undefined 로 세팅될 뿐이다.
      • 인자를 여러 개 넘겨도 두 번째 이후 인자는 무시된다.
    • 발생 가능한 에러/예외를 무시함

      • 어떤 '이유'로 프라미스를 버리면 그 값은 reject 콜백으로 전달된다.
      • 프라미스가 생성 중 또는 resolve 중에 자바스크립트 에러가 발생하면 예외를 잡아 주어진 프라미스를 reject 한다.
      • then() 에 등록한 콜백에서 자바스크립트 에러가 발생하면 then() 이 반환한 또 다른 프라미스에서 TypeError 예외가 나면서 reject 된다.
    • 프라미스는 프라미스를 반환함으로써 비동기로 연쇄흐름을 제어할 수 있다.

      1var p = Promise.resolve(21);
      2
      3p.then(function(v) {
      4 console.log(v); // 21
      5
      6 return new Promise(function (resolve, reject) {
      7 resolve (v*2);
      8 });
      9})
      10.then(function(v) {
      11 console.log(v); // 42
      12
      13});
      • 흐름 제어를 연쇄할 수 있는 프라미스 고유 특징
        • then() 을 호출하면 그 결과 자동으로 새 프라미스를 생성하여 반환한다.
        • resolve/reject 처리기 안에서 어떤 값을 반환하거나 예외를 던지면 이에 따라 (연쇄 가능한) 새 프라미스가 resolve 된다.
        • resolve/reject 처리기가 반환한 프라미스는 풀린 상태로 그 resolve 값이 무엇이든 간에 결국 현재의 then() 에서 반환된, 연쇄된 프라미스의 resolve 값이 된다.
    • 프라미스의 첫 번째 파라미터를 프라미스가 이루어졌다 라는 뜻에서 resolve() 보다 fulfill() 이 더 정확하다고 저자는 생각한다.

    • try ... catch 문은 비동기에서는 사용할 수 없다. 프라미스에 catch() 문을 쓰더라도 catch() 문에서 에러가 나면 잡을 수 있는게 없다.

프라미스 패턴

Promise.all([])

  • 두 개 이상의 비동기 작업이 동시에 진행됨
  • Promise.all 에 전달하는 배열은 프라미스, 데너블, 원시값 모두 가능하다.
  • 하나라도 reject되면 다른 프라미스 결과도 reject 된다.
  • 빈 배열을 넘기면 바로 resolve된다.

Promise.race([])

  • 가장 처음으로 resolve(fulfill) 된 프라미스만 인정된다.
  • 원시값이 들어가면 무조건 1등으로 끝나니 그 즉시 인정된다.
  • 하나라도 버려지는 프라미스가 있으면 버려진다.
  • 빈 배열을 넘기게되면 race는 끝나지 않고 프로그램은 멈춘다. reject나 별다른 에러가 없기 때문에 빈 배열을 넘기면 안된다.

프라미스 한계

시퀀스 에러 처리

  • 프라미스 연쇄는 각 단계에서 자신의 에러를 감지하여 처리할 방법 자체가 없다.
  • catch()를 달아도 어느 단계에서 나름대로 에러 처리를 하면 catch()는 에러를 감지할 방법이 없다.

단일값

프라미스는 정의 상 하나의 resolve 아니면 하나의 reject 만을 가진다. 로직이 복잡해지면 문제가 될 수 있다. 여러 메시지를 object나 array로 감싸면 되지만 프라미스 연쇄 단계마다 그렇게 하기엔 매우 번거롭다.

단일 resolve

데이터 이벤트/스트림에 더 가까운, 다른 모델에 단일 resolve인 프라미스는 적합하지 않을 수 있다.

프라미스는 취소 불가

일단 프라미스를 생성하여 resolve/reject 를 등록하면 도중에 작업 자체를 의미없게 만드는 일이 발생하더라도 외부에서 프라미스 진행을 멈출 방법이 없다.

제너레이터

1// 클로저 적용
2var gimmeSomething = (function() {
3 var nextVal;
4
5 return function() {
6 if (nextVal === undefined) {
7 nextVal = 1;
8 } else {
9 nextVal = (3 * nextVal) + 6;
10 }
11
12 return nextVal;
13 };
14})();
15
16console.log(gimmeSomething());
17console.log(gimmeSomething());
18console.log(gimmeSomething());
19console.log(gimmeSomething());
20
21// 이터레이터 적용
22var something = (function() {
23 var nextVal;
24
25 return {
26 [Symbol.iterator]: function() { return this; },
27 next: function() {
28 if (nextVal === undefined) {
29 nextVal = 1;
30 } else {
31 nextVal = (3*nextVal) + 6;
32 }
33
34 return {
35 done: false,
36 value: nextVal
37 };
38 }
39 };
40})();
41
42for (var v of something) {
43 console.log(v);
44
45 if (v > 500) {
46 break;
47 }
48}
49
50// 제너레이터를 적용
51function *genSomething() {
52 var nextVal;
53
54 while(true) {
55 if (nextVal === undefined) {
56 nextVal = 1;
57 } else {
58 nextVal = (3*nextVal) + 6;
59 }
60
61 yield nextVal;
62 }
63}
64
65for (var genV of genSomething()) {
66 console.log(genV);
67
68 if (genV>500) {
69 break;
70 }
71}
72
73// try-catch 도 가능
74function *tryFinallySomething() {
75 try {
76 var nextVal;
77
78 while(true) {
79 if (nextVal === undefined) {
80 nextVal = 1;
81 } else {
82 nextVal = (3*nextVal) + 6;
83 }
84
85 yield nextVal;
86 }
87 } finally {
88 console.log('정리 완료');
89 }
90}
91
92var it = tryFinallySomething();
93for (var tryCatchV of it) {
94 console.log(tryCatchV);
95
96 if (v>500) {
97 console.log(it.return('HelloWorld').value);
98 }
99}
100
101function *errMain() {
102 var x = yield 'Hello World';
103 yield x.toLowerCase();
104}
105
106var errIt = errMain();
107console.log(errIt.next().value);
108try {
109 errIt.next(42);
110} catch (err) {
111 console.error(err);
112}
113
114function *throwMain() {
115 var x = yield 'Hello World';
116 console.log(x);
117}
118
119var throwIt = throwMain();
120throwIt.next();
121
122try {
123 throwIt.throw('Throw');
124} catch (err) {
125 console.error(err);
126}
  • 제너레이터는 ES6부터 도입된 새로운 유형의 함수로, 일반 함수처럼 완전-실행하지 않고 실행 도중 (상태 정보를 그대로 간직한 채) 멈출 수도 있고 멈춘 지점에서 나중에 다시 시작할 수도 있다.

  • yield 키워드로 멈추고 next로 다시 시작할 수 있다.

  • next() 메서드로 인터페이스하는 객체를 Iterator 라고 한다. 순회 가능하 Iterator를 포괄한 객체는 Iterable 이라고 한다.

  • 제너레이터를 비동기에 사용할 경우 본질적으로 비동기성을 하나의 구현 상세로 추상화했기 때문에 개발자가 동기/순차적으로 흐름 제어를 추론할 수 있다. 에러처리도 동기적인 모양새로 처리할 수 있기 때문에 코드 가독성, 추론성 면에서 매우 큰 강점이다.

  • yield *func(); 방식으로 제너레이터를 위임할 수 있다. 위임을 하는 목적은 주로 코드를 조직화하고 그렇게 해서 일반 함수 호출과 맞추기 위함이다.

  • 양방향 메시징 체계로도 사용가능하다.

    1function *foo() {
    2 console.log('*foo() 내부:', yield 'B');
    3 console.log('*foo() 내부:', yield 'C');
    4 return 'D';
    5}
    6
    7function *bar() {
    8 console.log('*bar() 내부:', yield 'A');
    9 console.log('*bar() 내부:', yield *foo());
    10 console.log('*bar() 내부:', yield 'E');
    11 return 'F';
    12}
    13
    14var it = bar();
    15
    16console.log('외부:', it.next().value);
    17console.log('외부:', it.next(1).value);
    18console.log('외부:', it.next(2).value);
    19console.log('외부:', it.next(3).value);
    20console.log('외부:', it.next(4).value);
  • 이터러블도 위임을 할 수 있다.

    1function *bar() {
    2 console.log('*bar() 내부:', yield 'A');
    3 console.log('*bar() 내부:', yield *['B', 'C', 'D']);
    4 console.log('*bar() 내부:', yield 'E');
    5 return 'F';
    6}
    7
    8var it = bar();
    9
    10console.log('외부:', it.next().value);
    11console.log('외부:', it.next(1).value);
    12console.log('외부:', it.next(2).value);
    13console.log('외부:', it.next(3).value);
    14console.log('외부:', it.next(4).value);
    15console.log('외부:', it.next(5).value);
  • 예외도 위임이 된다.

    1function *foo() {
    2 try {
    3 yield 'B';
    4 } catch (err) {
    5 console.log('*foo()에서 붙잡힌 에러:', err);
    6 }
    7
    8 yield 'C';
    9 throw 'D';
    10}
    11
    12function *bar() {
    13 yield 'A';
    14 try {
    15 yield *foo();
    16 } catch (err) {
    17 console.log('*bar()에서 붙잡힌 에러:', err);
    18 }
    19
    20 yield 'E';
    21 yield *baz();
    22 yield 'G';
    23}
    24
    25function *baz() {
    26 throw 'F';
    27}
    28
    29var it = bar();
    30
    31console.log('외부:', it.next().value);
    32console.log('외부:', it.next(1).value);
    33console.log('외부:', it.next(2).value);
    34console.log('외부:', it.next(3).value);
    35
    36try {
    37 console.log('외부:', it.next(4).value);
    38} catch (err) {
    39 console.log('외부에서 붙잡힌 에러:', err);
    40}

프로그램 성능

웹워커

  • 브라우저 환경은 다수의 자바스크립트 엔진 인스턴스를 쉽게 내어줄 수 있고 인스턴스마다 개별 스레드를 배정하여 실행할 수도 있다. 이러한 프로그램의 독립적인 스레드 조각을 웹 워커 라고 한다.
  • 워커로 읽어들일 자바스크립트 파일의 URL을 지정하면 브라우저는 이 파일을 별도의 스레드에서 독립적인 프로그램으로 실행한다. 이렇게 URL로 생성한 워커를 Dedicated Worker 라고 한다.
  • 워커는 같은 워커끼리, 심지어는 메인 프로그램과도 스코프 및 자원ㄴ을 공유하지 않는다.
  • 워커는 전역 변수는 물론이고 페이지 DOM 등 자원에 접근 불가다. 하지만 워커는 네트워크 작업(Ajax, 웹소켓)과 타이머 설정이 가능하며 navigator, location, JSON, applicationCache 등 중요한 전역 변수/특성을 자체 복사하여 접근할 수 있다.
  • 워커에 추가 자바스크립트를 읽어들이려면 importScripts() 를 사용하면 된다. 스크립트는 동기적으로 읽기 때문에 importsScripts() 를 호출하면 해당 파일을 완전히 읽고 실행할 떄 까지 나머지 워커 코드는 실행이 중지된다.
  • 웝 워커의 주요 용도는 다음과 같다.
    • 처리 집약적 수학 계산
    • 대용량 데이터 세트 정렬
    • 데이터 작업 (압축, 오디오 분석, 이미지 픽셀 변환 등)
    • 트래픽 높은 네트워크 통신
  • 웹워커와 메인스레드 사이에서 어떤 객체를 전달하면 수신측에서는 structed clone 알고리즘으로 객체를 복사/복제 한다.
  • 방대한 데이터 세트를 전송해야한다면 Transferable 도 고려하는 것이 좋다. 데이터 자체는 그대로 두고 객체의 소유권만 전송하는 방식이다. Uint8Array 같은 타입화 배열이 있다. Transferable 을 지원하지 않는 브라우저는 structed clone 을 사용해야 하는데 성능저하가 있을 수 있다.
  • Shared Worker: 네트워크 소켓 접속 같은 시스템 자원의 점유율을 낮추기 위해 페이지 인스턴스가 서로 공유할 수 있는 하나의 중앙워커 역할을 한다.

SIMD (Single Instruction Multiple Data)

  • SIMD는 여러 데이터 비트를 병렬로 처리해서 성능 향상을 하기 위한 것이다.
  • 수학 계산에서 뚜렷한 성능을 보여줄 것으로 기대된다.

asm.js

  • 자바스크립트 언어에서 고도로 최적화 가능한 부분 집합을 말한다. 최적화하기 어려운 특정한 체계와 패턴(가비지콜렉션, 강제변환 등)을 방지한 asm.js 식 코드는 자바스크립트 엔진이 인식하여 아주 공격적으로 저수준 최적화를 하는 등의 특별한 조치를 한다.
  • asm.js 모듈은 단순히 렉시컬 스코프를 통해 전역 객체를 쓰는 대신 필요한 심볼을 가져오기 위해 엄격하게 규정된 네임스페이스를 분명히 전달한다.
  • Heap(메모리에 예약된 공간을 나타내는 기술용어로, 메모리를 추가하거나 과거에 점유한 메모리를 해제하지 않고도 변수가 사용할 수 있는 메모리 영역)을 반드시 선언하고 전달해서 asm.js 모듈이 메모리 천(가비지콜렉터가 메모리상의 객체 생성/제거 를 빈번하게 일으키는 현상)을 일으키지 않고 사전 예약된 공간을 사용하도록 해야 한다.
  • 모든 자바스크립트 프로그램에서 쓸 수 있는 최적화 도구라기보다는 게임 그래픽 처리 특유의 수학 연산 집약적인 특수 작업에 최적화된 도구이다.

벤치마킹과 튜닝

  • 시작시간부터 끝시간을 재는 방법은 형편없다.
  • 일정시간동안 측정하는 것은 평균을 낼 샘플이 아주 많아야 한다.
  • 그냥 Benchmark.js 를 가져다 쓰자.
  • [V8 엔진 성능 관련 주의사항](Optimization killers)
  • 너무 성능에 목숨걸지말자 브라우저 엔진마다 다 다르고 미시성능을 테스트한 결과가 맞다고 판단할 수 도 없다.

Tail Call Optimization (TCO)

  • 새 함수를 호출하려면 스택 프레임 이라는 호출 스택을 쌓기 위해 별도의 메모리 할당이 필요하다.

  • TCO 능력을 갖춘 엔진은 꼬리 위치에서 호출된다는 사실을 알 고 있어서 새로운 스택 프레임을 생성하지 않고 기존 스택 프레임을 재사용한다.

  • 속도도 빠르지만 메모리도 덜 쓰는 일석이조의 효과가 있다.

    1function foo(x) {
    2 return x;
    3}
    4
    5function bar(y) {
    6 return foo(y + 1); // 꼬리 호출
    7}
    8
    9function baz() {
    10 return 1 + bar(40); // 꼬리 호출 아님. 1을 더해줘야 되니까
    11}
    12
    13baz(); // 42

ES6 class

ES6부터 해결된 문제는 다음과 같다.

  • 더는 .prototype 레퍼런스로 코드를 메울 일이 거의 없다.
  • extends 덕분에 연결된 .prototype 객체를 대체하고자 Object.create()를 쓸 필요가 없고 .__proto__Object.setPrototypeOf()로 세팅하지 않아도 된다.
  • super() 라는 상대적 다형성 기능은 아주 유용해서 어던 메서드가 자신보다 한 수준 상위에 있는 동일 명칭의 메서드를 상대적으로 참조할 수 있게 됐다.
  • class 리터럴 구문에서 프로퍼티를 꼭 지정할 필요가 없다.(메서드에 한함)
  • extends는 Array나 RegExp 같은 내장 객체의 (서브) 타입까지도 아주 자연스럽게 확장하게 해준다.

class 구문은 기존의 [[Prototype]](위임) 체계에 기반을 둔 일종의 간편 구문이다.

하지만 class 구문은 다음과 같은 문제점이 발생할 수 있다.

1```javascript
2class C {
3 constructor(id) {
4 this.id = id; // 메소드를 인스턴스의 프로퍼티 값으로 가려지게 한다.
5 }
6 id() {
7 console.log('ID: ' + id);
8 }
9}
10
11var c1 = new C('c1');
12c1.id();
13```

또한, class 구문에서 super() 는 다음과 같은 문제를 발생시킬 수 있다.

1```javascript
2class P {
3 foo() {
4 console.log('P.foo');
5 }
6}
7
8class C extends P {
9 foo() {
10 super.foo();
11 }
12}
13
14var c1 = new C();
15c1.foo();
16
17var D = {
18 foo: function() {
19 console.log('D.foo');
20 }
21};
22
23var E = {
24 foo: C.prototype.foo
25};
26
27Object.setPrototypeOf(E, D); // E를 D에 위임 링크한다.
28
29E.foo(); // 'P.foo'
30
31// 해결책: 메소드를 복제하는 메소드를 만든다.
32// E = Object.create(D);
33// E.foo = C.prototype.foo.toMethod(E, 'foo');
34// E.foo(); // 'D.foo'
35```

ES6 class 의 가장 큰 문제점은 class 라는 구문이 마치 class를 선언하기만 하면 (나중에 인스턴스화할) 어떤 대상을 (여타 언어의 클래스처럼) 정적으로 정의하는 것 같은 착각을 불러 일으킨다는 사실이다. 그래서 객체가 직접적인 상호 작용이 가능한 실체라는 부분이 완전히 가려지게 된다. "동적인건 어려우니 정적인 듯 보이는 게 좋겠어! (어차피 정적일 수 없으니)" 라는 의미이다.


프라미스와 제너레이터 부분은 이해하기 어려운 부분이 많아 조금 더 비동기에 대한 이해도가 올라갔을 때 한 번 더 읽어야 겠다..

© 2020 by Kyunghwa Yoo.