— javascript — 5 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});45var p1 = new Promise(function(resolve, reject) {6 resolve(p3);7});89var p2 = new Promise(function(resolve, reject) {10 resolve('A');11});1213p1.then(function(v) {14 console.log(v);15});1617p2.then(function(v) {18 console.log(v);19});2021// 결과: A B <- p1이 즉시 원시값으로 귀결되지 않고 다른 프라미스를 부르기 때문에 비동기 잡 큐에서 밀리게 된다.
한번도 콜백을 안 호출할 경우 프라미스로 해결할 수 있다. 우선 프라미스 스스로 귀결 사실을 알리지 못하게 막을 방도는 없다. resolve, reject 가 모두 등록된 상태면 반드시 하나는 부른다. 만일 프라미스 스스로 어느쪽으로도 귀결되지 않으면 Race(경합) 이라는 상위 수준 추상화(Promise.race()
)를 통해 프라미스로 해결할 수 있다.
너무 적게, 아니면 너무 많이 콜백을 호출
필요한 환경/인자를 정상적으로 콜백에 전달 못함
발생 가능한 에러/예외를 무시함
프라미스는 프라미스를 반환함으로써 비동기로 연쇄흐름을 제어할 수 있다.
1var p = Promise.resolve(21);23p.then(function(v) {4 console.log(v); // 2156 return new Promise(function (resolve, reject) {7 resolve (v*2);8 });9})10.then(function(v) {11 console.log(v); // 421213});
프라미스의 첫 번째 파라미터를 프라미스가 이루어졌다 라는 뜻에서 resolve()
보다 fulfill()
이 더 정확하다고 저자는 생각한다.
try ... catch
문은 비동기에서는 사용할 수 없다. 프라미스에 catch()
문을 쓰더라도 catch()
문에서 에러가 나면 잡을 수 있는게 없다.
catch()
를 달아도 어느 단계에서 나름대로 에러 처리를 하면 catch()
는 에러를 감지할 방법이 없다.프라미스는 정의 상 하나의 resolve 아니면 하나의 reject 만을 가진다. 로직이 복잡해지면 문제가 될 수 있다. 여러 메시지를 object나 array로 감싸면 되지만 프라미스 연쇄 단계마다 그렇게 하기엔 매우 번거롭다.
데이터 이벤트/스트림에 더 가까운, 다른 모델에 단일 resolve인 프라미스는 적합하지 않을 수 있다.
일단 프라미스를 생성하여 resolve/reject 를 등록하면 도중에 작업 자체를 의미없게 만드는 일이 발생하더라도 외부에서 프라미스 진행을 멈출 방법이 없다.
1// 클로저 적용2var gimmeSomething = (function() {3 var nextVal;45 return function() {6 if (nextVal === undefined) {7 nextVal = 1;8 } else {9 nextVal = (3 * nextVal) + 6;10 }1112 return nextVal;13 };14})();1516console.log(gimmeSomething());17console.log(gimmeSomething());18console.log(gimmeSomething());19console.log(gimmeSomething());2021// 이터레이터 적용22var something = (function() {23 var nextVal;2425 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 }3334 return {35 done: false,36 value: nextVal37 };38 }39 };40})();4142for (var v of something) {43 console.log(v);4445 if (v > 500) {46 break;47 }48}4950// 제너레이터를 적용51function *genSomething() {52 var nextVal;5354 while(true) {55 if (nextVal === undefined) {56 nextVal = 1;57 } else {58 nextVal = (3*nextVal) + 6;59 }6061 yield nextVal;62 }63}6465for (var genV of genSomething()) {66 console.log(genV);6768 if (genV>500) {69 break;70 }71}7273// try-catch 도 가능74function *tryFinallySomething() {75 try {76 var nextVal;7778 while(true) {79 if (nextVal === undefined) {80 nextVal = 1;81 } else {82 nextVal = (3*nextVal) + 6;83 }8485 yield nextVal;86 }87 } finally {88 console.log('정리 완료');89 }90}9192var it = tryFinallySomething();93for (var tryCatchV of it) {94 console.log(tryCatchV);9596 if (v>500) {97 console.log(it.return('HelloWorld').value);98 }99}100101function *errMain() {102 var x = yield 'Hello World';103 yield x.toLowerCase();104}105106var errIt = errMain();107console.log(errIt.next().value);108try {109 errIt.next(42);110} catch (err) {111 console.error(err);112}113114function *throwMain() {115 var x = yield 'Hello World';116 console.log(x);117}118119var throwIt = throwMain();120throwIt.next();121122try {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}67function *bar() {8 console.log('*bar() 내부:', yield 'A');9 console.log('*bar() 내부:', yield *foo());10 console.log('*bar() 내부:', yield 'E');11 return 'F';12}1314var it = bar();1516console.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}78var it = bar();910console.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 }78 yield 'C';9 throw 'D';10}1112function *bar() {13 yield 'A';14 try {15 yield *foo();16 } catch (err) {17 console.log('*bar()에서 붙잡힌 에러:', err);18 }1920 yield 'E';21 yield *baz();22 yield 'G';23}2425function *baz() {26 throw 'F';27}2829var it = bar();3031console.log('외부:', it.next().value);32console.log('외부:', it.next(1).value);33console.log('외부:', it.next(2).value);34console.log('외부:', it.next(3).value);3536try {37 console.log('외부:', it.next(4).value);38} catch (err) {39 console.log('외부에서 붙잡힌 에러:', err);40}
importScripts()
를 사용하면 된다. 스크립트는 동기적으로 읽기 때문에 importsScripts()
를 호출하면 해당 파일을 완전히 읽고 실행할 떄 까지 나머지 워커 코드는 실행이 중지된다.새 함수를 호출하려면 스택 프레임 이라는 호출 스택을 쌓기 위해 별도의 메모리 할당이 필요하다.
TCO 능력을 갖춘 엔진은 꼬리 위치에서 호출된다는 사실을 알 고 있어서 새로운 스택 프레임을 생성하지 않고 기존 스택 프레임을 재사용한다.
속도도 빠르지만 메모리도 덜 쓰는 일석이조의 효과가 있다.
1function foo(x) {2 return x;3}45function bar(y) {6 return foo(y + 1); // 꼬리 호출7}89function baz() {10 return 1 + bar(40); // 꼬리 호출 아님. 1을 더해줘야 되니까11}1213baz(); // 42
ES6부터 해결된 문제는 다음과 같다.
extends
덕분에 연결된 .prototype 객체를 대체하고자 Object.create()
를 쓸 필요가 없고 .__proto__
나 Object.setPrototypeOf()
로 세팅하지 않아도 된다.super()
라는 상대적 다형성 기능은 아주 유용해서 어던 메서드가 자신보다 한 수준 상위에 있는 동일 명칭의 메서드를 상대적으로 참조할 수 있게 됐다.class
리터럴 구문에서 프로퍼티를 꼭 지정할 필요가 없다.(메서드에 한함) extends
는 Array나 RegExp 같은 내장 객체의 (서브) 타입까지도 아주 자연스럽게 확장하게 해준다. class 구문은 기존의 [[Prototype]](위임) 체계에 기반을 둔 일종의 간편 구문이다.
하지만 class 구문은 다음과 같은 문제점이 발생할 수 있다.
1```javascript2class C {3 constructor(id) {4 this.id = id; // 메소드를 인스턴스의 프로퍼티 값으로 가려지게 한다.5 }6 id() {7 console.log('ID: ' + id);8 }9}1011var c1 = new C('c1');12c1.id();13```
또한, class 구문에서 super() 는 다음과 같은 문제를 발생시킬 수 있다.
1```javascript2class P {3 foo() {4 console.log('P.foo');5 }6}78class C extends P {9 foo() {10 super.foo();11 }12}1314var c1 = new C();15c1.foo();1617var D = {18 foo: function() {19 console.log('D.foo');20 }21};2223var E = {24 foo: C.prototype.foo25};2627Object.setPrototypeOf(E, D); // E를 D에 위임 링크한다.2829E.foo(); // 'P.foo'3031// 해결책: 메소드를 복제하는 메소드를 만든다.32// E = Object.create(D);33// E.foo = C.prototype.foo.toMethod(E, 'foo');34// E.foo(); // 'D.foo'35```
ES6 class 의 가장 큰 문제점은 class 라는 구문이 마치 class를 선언하기만 하면 (나중에 인스턴스화할) 어떤 대상을 (여타 언어의 클래스처럼) 정적으로 정의하는 것 같은 착각을 불러 일으킨다는 사실이다. 그래서 객체가 직접적인 상호 작용이 가능한 실체라는 부분이 완전히 가려지게 된다. "동적인건 어려우니 정적인 듯 보이는 게 좋겠어! (어차피 정적일 수 없으니)" 라는 의미이다.
프라미스와 제너레이터 부분은 이해하기 어려운 부분이 많아 조금 더 비동기에 대한 이해도가 올라갔을 때 한 번 더 읽어야 겠다..