JS Generator Chaining 이해하기

JS Generator Chaining 이해하기

코드 스피츠 77 es6+ 기초편 강의에서는 generator로 chaining을 구성하는 예제를 다룬다. 해당 강의인 4번째 강의는 이 예제 외에도 중요한 다른 예제들을 다루지만 이번 글에서는 generator chaining의 동작을 살펴보면서 generator의 동작 방식을 이해해 보겠다.

앞으로 다룰 예제는 크게 두가지 객체로 구성되어 있다. 우선 class문법으로 생성된 Stream 객체이다. 해당 객체는 generator들을 입력받아서 입력받은 순서대로 chaining을 걸어서 실행할 수 있게 해준다. 두 번째는 Stream에 입력될 generator 객체들이다. 이 generator 객체들은 꼭 Stream 객체에 입력되지 않더라도 독립적으로 작동하는 generator들이다.(물론 모든 generator가 숫자 데이터가 저장된 배열을 처리하는 generator라는 전제가 있다.) 그렇다면 generator chaining이 어떻게 작동하는지 예제를 보면서 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const Stream = class {
  static get(v) {
    return new Stream(v);
  }
  constructor(v) {
    this.v = v;
    this.filters = [];
  }
  add(gene, ...arg) {
    this.filters.push(v => gene(v, ...arg));
    console.log(this.filters.toString());
    return this;
  }
  *gene() {
    let v = this.v;
    for (const f of this.filters) {
      console.log(v);
      v = f(v);
    }
    console.log("yield gene", v);
    yield* v;
  }
};
cs

Stream 객체를 먼저 살펴보겠다. 우선 Stream객체는 생성될 때, chaining으로 처리할 숫자 배열 v를 받아서 저장한 뒤 제네레이터들을 저장할 배열 filters를 저장한다. Stream 객체에서 중요한 메서드는 generator를 저장하는 add 메서드와 generator를 실행하는 gene 메서드(제네레이터)이다. add 메서드는 제네레이터와 제네레이터에 입력할 변수들을(나머지 매개변수로 선언되었기 때문에 없어도 된다.) 매개변수로 입력받는다. 그 다음 v를 입력 받은 generator를 반환하는 arrow function을 filters에 저장한다. 그 다음 Stream 객체의 참조값 this를 반환하고 종료한다. gene 메서드는 filters에 저장된 arrow function을 하나씩 꺼내서 실행시키고 실행 결과로 받은 이터레이터(제네레이터를 처음 실행하면 generator suspend 상태가 되고 next 함수 호출를 실행할 수 있는 이터레이터를 반환하므로)를 다시 v값으로 할당한다. gene 메서드는 filters 안에 저장된 제네레이터들을 순서대로 이터레이터로 전달함으로써 체이닝을 만들다. 체이닝이 끝나면 만들어진 체이닝된 제네레이터를(v에 저장되어 있다.) yield*로 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const three = function* (data) {
  console.log(`three: ${data}`);
  for (const v of data) {
    console.log("three", three.cnt++);
    if (!(v % 3)) yield v;
  }
};
three.cnt = 0;
 
const even = function* (data) {
  console.log(`even: ${data}`);
  for (const v of data) {
    console.log("even", even.cnt++);
    if (!(v % 2)) yield v;
  }
};
even.cnt = 0;
 
const take = function* (data, n) {
  console.log(`take: ${data}`);
  for (const v of data) {
    console.log("take", take.cnt++);
    if (n--yield v;
    else break;
  }
};
take.cnt = 0;
 
for (const v of Stream.get([123456789101112])
  .add(three)
  .add(even)
  .add(take, 2)
  .gene())
  console.log(v);
cs

위의 3개의 generator 각각이 하는 일은 다음과 같다. three - 3의 배수인 값만 반환한다. even - 짝수인 값만 반환한다. take - n 번째까지의 값만 반환한다. 만약의 3개의 generator에 chanining을 걸면 어떻게 될까? 그리고 chanining하는 순서를 바꾸면 어떻게 될까? 우선 위의 예제 코드를 돌리면 6과 12가 출력된다. 대략적으로 chaining된 코드의 동작을 이해하면 이렇다. '3의 배수이면서 2의 배수인 숫자를 먼저 나오는 순서대로 2개까지만 출력하라'. 만약에 generator chaining을 이용하지 않고 위의 로직을 구성하려면 어떻게 해야할까? 아마 3중 for문과 조건문을 사용한 로직을 사용하거나 재귀 함수 로직으로 구성할 수 있을 것 같다. 하지만 이런 두가지 방법 보다 generator chining을 이용하는건 코드의 이해나 변경에 훨씬 용이하다. 그래서 유연함이 필요한 다중 loop로직이라면 generator로 구성하는 것이 더 좋은 것 같다. 코드 예제와 코드 결과값을 보면서 코드의 진헹을 좀 더 자세히 살펴보겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
const Stream = class {
  static get(v) {
    return new Stream(v);
  }
  constructor(v) {
    this.v = v;
    this.filters = [];
  }
  add(gene, ...arg) {
    this.filters.push((v) => gene(v, ...arg));
    console.log(this.filters.toString());
    return this;
  }
  *gene() {
    let v = this.v;
    for (const f of this.filters) {
      console.log(v);
      v = f(v);
    }
    console.log("yield gene", v);
    yield* v;
  }
};
 
const three = function* (data) {
  console.log(`three: ${data}`);
  for (const v of data) {
    console.log("three", three.cnt++);
    if (!(v % 3)) yield v;
  }
};
three.cnt = 0;
 
const even = function* (data) {
  console.log(`even: ${data}`);
  for (const v of data) {
    console.log("even", even.cnt++);
    if (!(v % 2)) yield v;
  }
};
even.cnt = 0;
 
const take = function* (data, n) {
  console.log(`take: ${data}`);
  for (const v of data) {
    console.log("take", take.cnt++);
    if (n--yield v;
    else break;
  }
};
take.cnt = 0;
 
for (const v of Stream.get([123456789101112])
  .add(three)
  .add(even)
  .add(take, 2)
  .gene())
  console.log(v);
cs

위의 예제 코드를 실행시켜 보면 아래와 같은 결과가 나온다. 아래의 결과를 4개로 나누어서 설명하겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  // 1. add 함수 내부
  v => gene(v, ...arg)
  v => gene(v, ...arg),v => gene(v, ...arg)
  v => gene(v, ...arg),v => gene(v, ...arg),v => gene(v, ...arg)
 
  // 2. *gene 제네레이터 내부
  [
    1,  2, 3, 4,  5,
    6,  7, 8, 9, 10,
    11, 12
  ]
  Object [Generator] {}
  Object [Generator] {}
  yield gene Object [Generator] {}
 
  // 3. three, even, take 각 함수의 내부
  take: [object Generator]
  even: [object Generator]
  three: 1,2,3,4,5,6,7,8,9,10,11,12
 
  // 4. chaining된 로직에 [1...12]의 배열이 입력되었을 때, for of 를 통해 console.log에서 출력되는 결과값
  6
  12
cs

  1. 우선 chaining은 three, even, take 제네레이터가 Stream 객체 내의 filters 배열에 저장됨으로 시작되었다. filters 배열에 3개의 제네레이터가 저장되었기 때문에 입력을 받아 제네레이터를 반환하는, 3개의 arrow function이 저장된다.


  2. gene 제네레이터가 실행되었다. v 값을 확인해보니 Stream 객체의 변수 v에 저장된 [1…12]의 배열이 그대로 출력되었다. for loop가 실행되면서 v = f(v);가 반복 실행된다. loop가 처음 시작될 때, 배열 [1…12]기 제네레이터 three에 매개변수로 전달되어서 실행된다. 따라서 three를 사용할 수 있는 이터레이터 객체가 반환되었고 다시 v에 저장되었다. 다음 loop에서 three 이터레이터 객체가 제네레이터 even에 매개 변수로 전달되어서 실행된다. 이번에도 even을 사용할 수 있는 이터레이터 객체가 반환되었고 다시 v에 저장되었다. 마지막 loop에서 even 이터레이터 객체가 제네레이터 take에 매개 변수로 전달된다. 제네레이터 실행시 반환하는 객체는 이터레이터일뿐만 아니라 이터러블이기 때문에 for of 의 대상이고 따라서 take 이터러블이터레이터 객체를 탐색한다.


  3. for of가 시작되었기 때문에 제네레이터 take가 실행되었고 take: [object Generator]를 출력한 뒤에 for of 가 실행되어서 even 이터러블이터레이터 객체를 탐색한다. 따라서 제네레이터 even이 또 실행된다. 실행되었기 때문에 even: [object Generator]를 콘솔에 출력한 뒤 even도 for of 로 three 이터러블이터레이터 객체를 탐색한다. 제네레이터 three가 실행되었기 때문에 three: 1,2,3,4,5,6,7,8,9,10,11,12를 출력한 뒤 3의 배수만 yield한다. yield된 값은 제네레이터 even의 for of의 값으로 전달된다. 제네레이터 even에서는 전달받은 3의 배수 중에서 짝수인 값을 yield한다. yield된 값은 제네레이터 take의 for of의 값으로 전달된다. 따라서 take는 3의 배수이면서 2의 배수인 6의 배수의 값을 2개만 yield한다.


※ 위의 코드 진행에서 유의해서 볼 점

제네레이터 three, even, take는 yield를 사용했지만 Stream객체의 gene generator는 yield를 사용했다. yield* 키위드는 yield* 뒤에 다른 실행된 제네레이터(이터러블이터레이터가 된)가 올 경우에 yield를 해당 제네레이터에 위임한다. 위의 코드에서 보자면 제네레이터는 three, even, take 내부에서 for of 를 사용하여 값을 탐색하기 때문에 이터러블 객체가 입력으로 들어온다는 것을 전제로 한다. 위의 예제에서는 각 제네레이터들이 실행되어서 이터러블이터레이터로 반환된 값을 다음 제네레이터에 넘기는 구조로 진행되기 때문에 yield 키워드를 사용하였지만 제네레이터 gene은 결국 generator의 실행을 시작해야 하기 때문에 yield* 키워드를 사용해서 제네레이터 체이닝이 진행되도록 한 것이다.

댓글