본문 바로가기

0/javascript

Learning JavaScript 요약 정리 (4) 12장 ~ 14장 (이터레이터와 제너레이터, 비동기 프로그래밍)

반응형

12장 이터레이터와 제너레이터

이터레이터

이터레이터는 '지금 어디 있는지' 파악할 수 있도록 돕는다는 면에서 일종의 책갈피와 비슷한 개념이다. 배열은 이터러블 객체의 좋은 예이다.

const it = book.values();
let current = it.next();

while(!current.done){
    console.log(current.value);
    current = it.next();
}

제너레이터

제너레이터란 이터레이터를 사용해 자신의 실행을 제어하는 함수이다.

제너레이터는 두 가지 새로운 개념을 도입했다. 함수의 실행을 개별적 단계로 나눔으로써 함수의 실행을 제어한다는 것과 실행 중인 함수와 통신한다는 것이다.

  • 제너레이터는 언제든 호출자에게 제어권을 넘길 수 있다.
  • 제너레이터는 호출한 즉시 실행되지는 않는다. 대신 이터레이터를 반환하고, 이터레이터의 next 메서드를 호출함에 따라 실행된다.

제너레이터를 만들 때는 function 키워드 위에 애스터리스크(*)를 붙인다. 이를 제외하면 문법은 일반적인 함수와 같다. 제너레이터에서는 return 외에 yield 키워드를 쓸 수 있다.

무지개 색깔을 반환하는 단순한 제너레이터 예제를 하나 만들어보자.

function* rainbow(){
    yield 'red';
    yield 'orange';
    yield 'yellow';
    yield 'green';
    yield 'blue';
    yield 'indigo';
    yield 'violet';
}

이제 이 제너레이터를 호출해보자.

const it = rainbow();

it.next(); // { value: "red", done: false }
it.next(); // { value: "orange", done: false }
it.next(); // { value: "yellow", done: false }
...
it.next(); // { value: "violet", done: false }
it.next(); // { value: undefined, done: true }

rainbow 제너레이터는 이터레이터를 반환하므로 for...of 루프에서 쓸 수 있다.

for(let color of rainbow()){
	console.log(color);
}

yield 표현식과 양방향 통신

표현식은 값으로 평가되고 yield는 표현식이므로 반드시 어떤 값으로 평가된다. yield 표현식의 값은 호출자가 제너레이터의 이터레이터에서 next를 호출할 때 제공하는 매개변수이다. 대화를 이어가는 제너레이터를 만들어보자.

function* interrogate(){
    const name = yield "What is your name?";
    const color = yield "What is your favorite color?";
    return `${name}'s favorite color is ${color}`;
}

이 제너레이터를 호출하면 이터레이터를 얻는다. 그리고 제너레이터의 어떤 부분도 아직 실행하지 않은 상태이다. next를 호출하면 제너레이터는 첫 번째 행을 실행하려한다. 하지만 그 행에는 yield 표현식이 들어 있으므로 제너레이터는 반드시 제어권을 호출자에게 넘겨야 한다. 제너레이터의 첫 번째 행이 완료되려면 호출자가 next를 다시 호출해야 한다.

const it = interrogate();
it.next(); // { value: "What is your name?", done: false }
it.next('Google'); // { value: "What is your favorite color?", done: false }
it.next('White'); // { value: "Google's favorite color is white', done: true }

이 제너레이터를 실행했을 때 일어나는 일을 묘사해보자.

function* interrogate(){ // let it = interrogate();
    const name = yield "What is your name?"; // it.next();
    const color = yield "What is your favorite color?"; // it.next('Google');
    return `${name}'s favorite color is ${color}`; // it.next('White');
}

이 예제를 보면 제너레이터를 활용하면 호출자가 함수의 실행을 제어할 수 있어서 아주 유용하게 쓸 수 있다는 것을 알았을 것이다. 호출자가 제너레이터에 정보를 전달하므로, 제너레이터는 그 정보에 따라 자신의 동작 방식 자체를 바꿀 수 있다.

* 제너레이터는 화살표 표기법으로 만들 수 없으며 반드시 function*을 써야한다.

제너레이터와 return

yield 문은, 설령 제너레이터의 마지막 문이더라도 제너레이터를 끝내지 않는다. 제너레이터에서 return 문을 사용하면 그 위치와 관계없이 done은 true가 되고, value 프로퍼티는 return이 반환하는 값이 된다. 예제를 살펴보자.

function* abc(){
    yield 'a';
    yield 'b';
    return 'c';
}

const it = abc();
it.next(); // { value:'a', done: false }
it.next(); // { value:'b', done: false }
it.next(); // { value:'c', done: true }

이런 동작 방식이 정확하기는 하지만, 제너레이터를 사용할 때는 보통 done이 true이면 value 프로퍼티에 주의를 기울이지 않는다는 점을 염두에 두어야한다. 예를 들어 이 제너레이터를 for...of 루프에서 사용하면 c는 절대 출력되지 않는다.

// "a"와 "b"는 출력되지만 "c"는 출력되지 않는다.
for(let l of abc()){
	console.log(l);
}

 

13장 함수와 추상적 사고

IIFE와 비동기적 코드

var i;
for (i=5; i>=0; i--){
	setTimeout(function(){
		console.log(i === 0 ? "go!" : i);
	}, (5-i)*1000);
}

이 코드의 출력 결과를 예상해보자! 

5, 4, 3, 2, 1, go! 라고 예상했다면, 아쉽지만 틀렸다. -1 이 6번 출력될 뿐이다.

어떻게 된 걸까? setTimeout에 전달된 함수가 루프 안에서 실행되지 않고 루프가 종료된 뒤에 실행됐기 때문이다. 따라서 루프의 i는 5에서 시작해 -1로 끝난다.

let을 사용해 블록 수준 스코프를 만들면 이 문제는 해결되지만, 비동기적 프로그래밍에 익숙하지 않다면 이 예제를 정확히 이해해야한다.

블록 스코프 변수가 도입되기 전에는 이런 문제를 해결하기 위해 함수를 하나 더 썼다. 함수를 하나 더 쓰면 스코프가 새로 만들어지고 각 단계에서 i의 값이 클로저에 캡처된다.

function loopBody(i){
    setTimeout(function(){
    	console.log(i === 0 ? "go" : i);
    }, (5-i)*1000);
}

var i;
for(i=5; i>=0; i--){
    loopBody(i);
}

루프의 각 단계에서 loopBody 함수가 호출된다. 자바스크립트는 매개변수를 값으로 넘기기 때문에 루프의 각 단계에서 함수에 전달되는 것은 변수 i가 아니라 i의 값이다. 즉 처음에는 5가, 두 번째에는 4가 전달된다.

하지만 루프에 한 번 쓰고 말 함수에 일일이 이름을 붙이는 건 성가신 일이다. 익명 함수를 만들어 즉시 호출하는 IIFE를 사용하는 게 더 낫다.

var i;

for(i=5; i>=0; i--){
	(function(i){
    	setTimeout(function(){
        	console.log(i === 0 ? "go" : i);
        });
    })(i);
}

// 위 함수는 아래 함수와 완전히 동일하다.

var i;

for(i=5; i>=0; i--){
	(loopBody(i))(i);
}

블록 스코프 변수를 사용하면 스코프 하나 떄문에 함수를 새로 만드는 번거로운 일을 하지 않아도 된다.

for(let i=5; i>=0; i--){
    setTimeout(function(){
    	console.log(i === 0 ? "go!" : i);
    }, (5-i)*1000);
}

let 키워드를 이런 식으로 사용하면 자바스크립트는 루프의 단계마다 변수 i의 복사본을 새로 만든다.

 

14장 비동기적 프로그래밍

자바스크립트 애플리케이션은 단일 스레드에서 동작한다. 즉, 자바스크립트는 한 번에 한 가지 일만 할 수 있다. 이 얘기를 듣고 할 수 있는 일이 제한된다고 느낄지도 모르지만, 사실 멀티스레드 프로그래밍에서 겪어야 하는 정말 골치 아픈 문제를 신경쓰지 않아도 된다는 장점도 있다.

자바스크립트의 비동기적 프로그래밍에는 뚜렷이 구분되는 세 가지 패러다임이 있다. 처음에는 콜백이 있었고, 프라미스가 뒤를 이었으며 마지막은 제너레이터다. (이후 await까지 현재는 네 가지이다.) 제너레이터가 콜백이나 프라미스보다 모든 면에서 더 좋다면 제너레이터에 대해서만 공부하고 나머지는 과거의 유산으로 치워 둘 수도 있겠지만, 그렇게 간단한 문제는 아니다. 제너레이터 자체는 비동기적 프로그래밍을 전혀 지원하지 않는다. 제너레이터를 비동기적으로 사용하려면 프라미스나 특수한 콜백과 함께 사용해야 한다. 프라미스 역시 콜백에 의존한다.

콜백

콜백은 자바스크립트에서 가장 오래된 비동기적 메커니즘이다. 하지만, 한 번에 여러가지를 기다려야 한다면 콜백을 관리하기가 상당히 어려워진다. 프로그래머들은 이런 코드를 콜백 헬이라 부른다.

const fs = require('fs');

fs.readFile('a.txt', function(err, dataA){
    if(err) console.error(err);
    fs.readFile('b.txt', function(err, dataB){
        if(err) console.error(err);
        	...
    });
});

 

프라미스

프라미스는 콜백을 예측 가능한 패턴으로 사용할 수 있게 하며, 프라미스 없이 콜백만 사용했을 때 나타나는 엉뚱한 현상이나 찾기 힘든 버그를 상당수 해결한다.

기본 개념은 간단하다. 프라미스 기반 비동기적 함수를 호출하면 그 함수는 Promise 인스턴스를 반환한다. 프라미스는 성공(fullfilled)하거나, 실패(rejected) 하거나 단 두 가지뿐이다. 또한, 성공이든 실패든 단 한 번만 일어난다. 프라미스가 성공하거나 실패하면 그 프라미스를 결정됐다(settled)고 한다.

프라미스는 객체이므로 어디든 전달할 수 있다는 점도 콜백에 비해 간편한 장점이다.

function countdown(seconds){
	return new Promise(function(resolve, reject){
		for (let i=seconds; i>=0; i--){
			setTimeout(function(){
				if(i>0) console.log(i + '...');
				else resolve(console.log("GO!");
			}, (seconds-i)*1000);
		}
	});
}
const EventEmitter = require('events').EventEmitter;

class Countdown extends EventEmitter{
	constructor(seconds, superstitious){
    	super();
        this.seconds = seconds;
        this.superstitious = !!superstitious;
    }
    
    go(){
    	const countdown = this; // this는 특별한 변수이고 콜백 안에서는 값이 달라진다. 따라서 this의 현재 값을 다른 변수에 저장해야 프라미스 안에서 쓸 수 있다.
        
        return new Promise(function(resolve, reject){
        	for(let i=countdown.seconds; i>=0; i--){
            	setTimeout(function(){
                	if(countdown.superstitious && i === 13)
                		return reject(new Error("Oh my god");
                    countdown.emit('tick', i);
                    if(i===0) resolve();
                }, (countdown.seconds-i)*1000);
            }
        });
        
    }
	
}

가장 중요한 부분은 countdown.emit('tick', i)이다. 이 부분에서 tick 이벤트를 발생시키고, 필요하다면 프로그램의 다른 부분에서 이 이벤트를 주시할 수 있다.

const c = new Countdown(5);

c.on('tick', function(i){
	if(i>0) console.log(i + '...');
});

c.go()
    .then(function(){
        console.log('GO!');
    })
    .catch(function(err){
    	console.error(err.message);
    });

결정되지 않는 프라미스 방지하기

프라미스는 비동기적 코드를 단순화하고 콜백이 두 번 이상 실행되는 문제를 방지한다. 하지만 resolve나 reject를 호출하는 걸 잊어서 프라미스가 결정되지 않는 문제까지 자동으로 해결하지는 못한다. 

이를 방지하는 한 가지 방법은 프라미스에 타임아웃을 거는 것이다.

function addTimeout(fn, timeout){

    timeout = timeout ? timeout : 1000;
    
    return function(...args){
    	return new Promise(function(resolve, reject){
    		const tid = setTimeout(reject, timeout, new Error("promise timed out"));
            fn(...args)
            	.then(function(...args){
                    clearTimeout(tid);
                    resolve(...args);
                })
                .catch(function(...args){
                    clearTimeout(tid);
                    reject(...args);
                });
    	});
    }

}

제너레이터

제너레이터는 함수와 호출자 사이의 양방향 통신을 가능하게 한다. 제너레이터는 원래 동기적인 성격을 가졌지만, 프라미스와 결함하면 비동기 코드를 효율적으로 관리할 수 있다.

이를 위해서는 몇가지 선행작업이 필요한데 가장 먼저 할 일은 노드의 오류 우선 콜백을 프라미스로 바꾸는 것이다. 이 기능을 nfcall(Node function call) 함수로 만들겠다.

function nfcall(f, ...args){
    return new Promise(function(resolve, reject){
    	f.call(null, ...args, function(err, ...args){
        	if(err) return reject(err);
            resolve(args.length<2 ? args[0] : args);
        });
    });
}

setTimeout을 써야 하는데, setTimeout은 노드보다 먼저 나왔고 오류 우선 콜백의 패턴을 따르지 않는다. 그러니 같은 기능을 가진 ptimeout(promise timeout) 함수를 새로 만들어보자.

function ptimeout(delay){
    return new Promise(function(resolve, reject){
    	setTimeout(resolve, delay);
    });
}

다음에 필요한 것은 제너레이터 실행기이다. 제너레이터는 원래 동기적이다. 하지만 제너레이터는 호출자와 통신할 수 있으므로 제너레이터와의 통신을 관리하고 비동기적 호출을 처리하는 함수를 만들 수 있다. 이런 역할을 할 함수 grun(generator run)을 만들어보자.

function grun(g){
    const it = g();
    (function iterate(val){
    	const x = it.next(val);
        if(!x.done){
            if(x.value instanceof Promise){
            	x.value.then(iterate).catch(err => it.throw(err));
            }
            else{
            	setTimeout(iterate, 0, x.value);
            }
        }
    })();
}

grun은 기초적인 제너레이터 재귀 실행기이다.

  1. grun에 제너레이터 함수를 넘기면 해당 함수가 실행된다.
  2. 앞에서 배웠듯 yield로 값을 넘긴 제너레이터는 이터레이터에서 next를 호출할 때까지 기다린다. grun은 그 과정을 재귀적으로 반복한다.
  3. 이터레이터에서 프라미스를 반환하면 grun은 프라미스가 완료될 때까지 기다린 다음 이터레이터 실행을 재개한다.
  4. 이터레이터가 값을 반환하면 이터레이터 실행을 즉시 재개한다.

* grun에서 iterate를 바로 호출하지 않고 setTimeout을 거친 이유는 효율성 때문이다. 자바스크립트 엔진은 재귀 호출을 비동기적으로 실행할 때 메모리를 좀 더 빨리 회수한다.

다 왔다. 사용해보자!

function* theFutureIsNow(){

    const dataA = yield nfcall(fs.readFile, 'a.txt');
    const dataB = yield nfcall(fs.readFile, 'b.txt');
    const dataC = yield nfcall(fs.readFile, 'c.txt');
    
    yield ptimeout(60*1000);
    yield nfcall(fs.writeFile, 'd.txt', dataA+dataB+dataC);
    
}

직접 만들어 보는 것도 좋지만 더 좋은 것이 이미 만들어져 있는데 처음터 그 과정을 반복할 필요는 없다. co는 기능이 풍부하고 단단하게 잘 만들어진 제너레이터 실행기이다. 웹 사이트를 만들고 있다면 Koa 미들웨어를 한 번 살펴보길 권한다. Koa는 co와 함께 사용하도록 설계된 미들웨어이다. 

프라미스와 제너레이터 실행기를 결합하면 비동기적 실행의 장점을 그대로 유지하면서도 동기적인 사고방식으로 문제를 해결할 수 있다.

이제 그냥 await/async 쓰면 된다..

이 글은 이선 브라운. n.d. Learning JavaScript. n.p.: 한빛미디어(주).를 참고하여 정리한 글입니다. 

반응형