TypeScript :: 6. 반복기와 재생기

김진호·2022년 1월 27일
0

TypeScript

목록 보기
4/6
post-thumbnail

06-1 반복기 이해하기

실습 프로젝트 설정

이번 장에서 소개하는 예제는 node.js 프로젝트 설정과 tsconfig.json 파일에 별도의 설정이 필요하다.

먼저 ch06-1 디렉터리를 만들고 터미널에서 다음 명령을 실행한다.

각 줄의 명령은 package.json 파일을 생성하고, 관련 파일을 내려받고, 소스 파일을 저장할 src디렉터리를 생성한다.

npm init --y
npm i -D typescript ts-node @types/node
mkdir src

그 다음 02-1절에서 작성했던 tsconfig.json 파일을 복사해서 ch06-1 디렉터리에 가져오거나 tsc - -init 명령으로 tsconfig.json 파일을 생성하고 다음과 같은 내용으로 대체한다. 특히 이번 장에서 다루는 예제를 문제없이 실행하려면 tsconfig.json에서 downlevelIteration항목을 true로 설정해야 한다.

tsconfig.json

{
  "compilerOptions": {
    "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
    "module": "commonjs" /* Specify what module code is generated. */,
    "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
    "baseUrl": "." /* Specify the base directory to resolve non-relative module names. */,
    "sourceMap": true /* Create source map files for emitted JavaScript files. */,
    "outDir": "dist" /* Specify an output folder for all emitted files. */,
    "downlevelIteration": true /* Emit more compliant, but verbose and less performant JavaScript for iteration. */,**
    "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
    "strict": true /* Enable all strict type-checking options. */,
    "noImplicitAny": false /* Enable error reporting for expressions and declarations with an implied `any` type.. */,
    "skipLibCheck": true /* Skip type checking all .d.ts files. */,
    "paths": { "*": ["node_modules/*"] }
  },
  "include": ["src/**/*"]
}

이번 장은 각 절을 시작할 때 이와 같은 과정을 반복해서 프로젝트를 구성한다.

반복기와 반복기 제공자

앞서 05-1 절에서 for...in과 for...of 구문을 설명했다. 이 중 for...of 구문은 다음 코드처럼 타임에 무관하게 배열에 담긴 값을 차례로 얻는 데 활용된다.

const numArray: number = [1, 2, 3];
for (let value of numArray) console.log(value); // 1 2 3

const strArray: string[] = ["hello", "world", "!"];
for (let value of strArray) console.log(value); // hello world !

for...of 구문은 다른 프로그래밍 언어에서도 반복기(iterator 라는 주제로 흔히 찾아볼 수 있다. 프로그래밍 언어마다 조금씩 구현 방식이 다르긴 하지만, 대부분의 프로그래밍 언어에서 반복기는 다음과 같은 특징이 있는 객체이다.

- next라는 이름의 메서드를 제공한다.
- next 메서드는 value와 done이라는 두 개의 속성을 가진 객체를 반환한다.

다음 코드에서 createRangeIterable 함수는 next 메서드가 있는 객체를 반환하므로 이 함수는 반복기를 제공하는 역할을 한다. 이처럼 반복기를 제공하는 역할을 하는 함수를 반복기 제공자 라고 한다.

createRangeIterable.ts

export const createRangeIterable = (from: number, to: number) => {
  let currentValue = from;
  return {
    next() {
      const value = currentValue < to ? currentValue++ : undefined;
      const done = value == undefined;
      return { value, done };
    },
  };
};

다음 코드는 createRangeIterable 함수가 제공하는 반복기를 사용하는 예이다.

src/createRangeIterable-test.ts

import { createRangeIterable } from "./createRangeIterable";
const iterator = createRangeIterable(1, 3 + 1); // 반복기는 현재 동작하지 않는다.
while (true) {
  const { value, done } = iterator.next(); // 반복기를 동작시킨다.
  if (done) break;
  console.log(value); // 1 2 3
}

02행에서 createRangeIterator 함수를 호출해 반복기를 얻고 iterator 변수에 저장한다.

반복기는 이처럼 반복기 제공자를 호출해야만 얻을 수 있다. 03 ~ 07행은 반복기를 사용하는 코드이다. while 문에서 done값을 true로 반환할 때까지 iterator 변수의 next 메서드를 반복 호출하면서 반복기 제공자가 제공하는 value 값을 얻는다.

💡 사용자가 타입스크립트로 for..of 구문을 작성하면 TSC 컴파일러는 이처럼 반복기 제공자와 반복기를 사용하는 코드로 바꿔준다.

반복기는 왜 필요한가?

앞 코드의 실행 결과는 1부터 3까지 정수를 출력한다. 즉, iterator.next 메서드가 반복 호출될 때마다 각기 다른 값이 출력된다. 반복기 제공자가 생성한 값 1, 2, 3을 배열에 담아서 출력하지않고, 마치 for 문을 돌면서 값을 콘솔 출력문으로 찍어낸 듯한 모습이다. 반복기 제공자는 이처럼 어떤 범위의 값을 한꺼번에 생성해서 배열에 담지 않고 값이 필요할 때만 생성한다. 다음은 05장에서 보았던 range 함수이다.

export const range = (from, to) =>
  from < to ? [from, ...range(from + 1, to)] : [];

createRangeIterator 함수는 값이 필요한 시점에 비로소 생성하지만, range 함수는 값이 필요한 시점보다 이전에 미리 생성한다는 차이가 있다. 따라서 시스템 메모리의 효율성이라는 관점에서 보면 createRangeIterator 함수가 메모리를 훨씬 적게 소모한다.

for...of 구문과 [Symbol.iterator] 메서드

05-1절에서 살펴본 range 함수는 for...of 구문의 of 뒤에 올 수 있다.

import { range } from "./range";
for (let value of range(1, 3 + 1)) console.log(value); // 1 2 3

그러나 다음 코드처럼 앞에서 작성한 createRangeIterable 함수를 for...of 구문에 적용하면 [Symbol.iterator]() 메서드가 없다는 오류가 발생한다.

src/createRangeIterable-forOf.ts

import { createRangeIterable } from "./createRangeIterable";
const iterable = createRangeIterable(1, 3 + 1);
for (let value of iterable) console.log(value);

'{ 💡 next(): { value: number | undefined; done: boolean; }; }' 형식에는 반복기를 반환하는 'Symbol.iterator' 메서드가 있어야 합니다.ts(2488)

이 오류는 createRangeIterable 함수를 다음 RangeIterable처럼 클래스로 구현해야 한다는 것을 의미한다. RangeIterable 클래스는 03행에서 [Symbol.iterator]메서드를 구현하고 있다.

src/RangeIterable.ts

export class RangeIterable {
  constructor(public from: number, public to: number) {}

  [Symbol.iterator]() {
    const that = this;
    let currentValue = that.from;
    return {
      next() {
        const value = currentValue < that.to ? currentValue++ : undefined;
        const done = value == undefined;
        return { value, done };
      },
    };
  }
}

클래스의 메서드도 function 키워드로 만들어지는 함수다.


클래스의 메서드는 자바스크립트의 function 키워드가 생략되었을 뿐 사실상 function 키워드로 만들어지는 함수이다. function 키워드로 만들어지는 함수는 내부에서 this 키워드를 사용할 수 있다. RangeIterable.ts 코드에서 04행이 this 값을 that변수에 담고 있는데, 이것은 08행의 that.to 부분을 위한 것이다. 이것은 next 함수또한 function 키워드가 생략된 메서드이므로 컴파일러가 next의 this로 해석하지 않게하는 자바스크립트의 코드 트릭이다.

createRangeIterable 함수와 달리 RangeIterable 클래스는 다음 코드에서 보듯 range 함수처럼 for...of구문의 of 뒤에 올 수있다.

src/RangeIterable-test.ts

import { RangeIterable } from "./RangeIterable";
const iterator = new RangeIterable(1, 3 + 1);

for (let value of iterator) console.log(value); // 1 2 3

Iterable와 Iterator인터페이스

타입스크립트는 반복기 제공자에 Iterable와 Iterator 제네릭 인터페이스를 사용할 수 있다.
Iterable는 다음처럼 자신을 구현하는 클래스가 [Symbol.iterator] 메서드를 제공한다는 것을 명확하게 알려주는 역할을 한다.

class 구현 클래스 implements Iterable<생성할 값의 타입> {}

또한 Iterator는 반복기가 생성할 값의 타입을 명확하게 해준다.

[Symbol.iterator]() : Iterator<생성할 값의 타입>{}

다음 코드는 반복기 제공자를 타입스크립트가 제공하는 Iterable와 Iterator를 사용해 구현한 예이다.

src/StringIterable.ts

export class StringIterable implements Iterable<string> {
  constructor(
    private strings: string[] = [],
    private currentIndex: number = 0
  ) {}

  [Symbol.iterator](): Iterator<string> {
    const that = this;
    let currentIndex = that.currentIndex,
      length = that.strings.length;

    const iterator: Iterator<string> = {
      next(): { value: string; done: boolean } {
        const value =
          currentIndex < length ? that.strings[currentInex++] : undefined;
        const done = value == undefined;
        return { value, done };
      },
    };
    return iterator;
  }
}

다음처럼 테스트 코드를 작성해 하나씩 실행하면 StringIterable 클래스의 strings 속성에 담긴 배열의 아이템을 하나씩 출력한다.

src/StringIterable-test.ts

import { StringIterable } from "./StringIterable";
for (let value of new StringIterable(["hello", "world", "!"]))
  console.log(value);

지금까지 반복기 제공자와 이를 이용해 반복기를 얻고 사용하는 코드를 살펴봤다. 다음 절에서는 반복기를 쉽게 만들어주는 생성기 구문에 관해 알아보자.

06-2 생성기 이해하기

ESNext 자바스크립트와 타입스크립트는 yield라는 키워드를 제공한다. yield는 마치 return 키워드처럼 값을 반환한다. yield는 반드시 function 키워드를 사용한 함수에서만 호출이 가능하다. 이렇게 function 키워드로 만든 함수를 생성기(genertor)라고 한다.
다음 src/generator.ts 파일의 01행은 function* 키워드로 만든 generator함수가 있다.
generator 함수의 몸통은 05행에서 yield문을 3회 반복해서 호출하도록 구현되었다.

src/generator.ts

export function* generator() {
  console.log("genertor started...");
  let value = 1;
  while (value < 4) yield value++;
  console.log("genertor finished");
}

다음은 generator 함수를 테스트 하는 코드이다.
src/generator-test.ts

import { generator } from "./generator";
for (let value of generator()) console.log(value);

실행결과

genertor started...

1

2

3

genertor finished

실행결과가 흥미로운 것은 05장에서 배열을 대상으로 실행했던 for...of 구문과 같은 출력을 보인다는 점이다.

setInterval 함수와 생성기의 유사성

생성기가 동작하는 방식을 세미코루틴(semi-coroutine, 한 협동 루틴)이라고 한다. 세미코루틴은 타임스크립트처럼 단일 스레드(single-thread)로 동작하는 프로그래밍 언어가 마치 다중 스레드(multi-thread)로 동작하는 것처럼 보이게 하는 기능을 한다.
이제 자바스크립트가 기본으로 제공하는 setInterval 함수를 사용해 세미코루틴의 동작방식을 알아보자.
setInterval 함수는 지정한 주기로 콜백함수를 계속 호출해 준다.

const intervalID = setInterval(콜백 함수, 호출 주기)

setInterval 함수는 지정한 주기로 콜백 함수를 사용하면 멈출 수 있다.

clearInterval(intervalID);

다음 코드는 setInterval 함수를 사용해 1초 간격으로 1, 2, 3을 출력하는 예이다.
src/setInterval.ts

const period = 1000;
let count = 0;
console.log("program started...");

const id = setInterval(() => {
  if (count >= 3) {
    clearInterval(id);
    console.log("program finished...");
  } else {
    console.log(++count);
  }
}, period);

실행결과

program started...

1

2

3

program finished

프로그램의 출력 내용만 보면 앞에서 살펴본 생성기 방식과 구분할 수 없을 정도로 비슷하다.
그런데 setInterval 함수가 동작하는 구조는 C++언어의 스레드(thread)가 동작하는 방식과 흡사한 면이 있다.
즉, program started...를 출력하고 setInterval을 동작시킨 부분이 메인 스레드, setInterval의 콜백 함수는 작업 스레드를 떠올리게 한다.
생성기는 이처럼 일반적인 타입스크립트 코드와는 좀 다른 방식으로 동작하는것을 기억하면서 생성기 구문을 이해해보자.

세미코루틴과 코루틴의 차이


메모리나 CPU를 제작할 때 사용하는 소자를 반도체(semiconductor)라고 한다. 여기서 반도체란 전기를 절반만 통과시키는 도체라는 의미이다. 즉, 반대(anti)의 의미가 아니라 절반(semi)의 의미이다.

학문적으로 생성기를 세미코루틴(semi-coroutine)이라고 한다. 즉, 생성기는 절반만 코루틴이다. 코루틴은 1958년부터 많은 학자가 꾸준히 연구해 온 학문적인 주제이다. 클로저(Clojure)는 코루틴을 최초로 프로그래밍 문법으로 탑재한 언어이다. 구글에서 만든 Go 언어는 고루틴(goroutine)이라는 용어를 사용하지만 고루틴 또한 코루틴이다.

코루틴은 애플리케이션 레벨의 스레드이다. 스레드는 원래 운영체제가 제공하는 개수가 제한된 서비스이다. 스레드는 개수가 2,000개 정도로 제한되었으므로, 특정 애플리케이션에서 운영체제의 스레드를 과다하게 소비하면 운영체제에 무리는 주게 된다. 이것이 코루틴을 연구하기 시작한 이유이다. 운영체제에 부담을 주지 않으면서 애플리케이션에서 스레드를 마음껏 쓸 수 있게 하는 것이 코루틴의 목적이다.

그런데 코루틴은 스레드이므로 일정 주기에 따라 자동으로 반복해서 실행된다. 반면에 생성기는 절반만 코루틴이다. 즉, 박복해서 실행할 수 있지만 자동으로 실행되지 못하는 코루틴이다. 앞으로 배우면서 알게 되겠지만, 생성기는 사용하는 쪽 코드에서 생성기가 만들어 준 반복자의 next 메서드가 호출될 때만 한번 실행된다. 만약 next 메서드가 while문에서 반복해서 호출된다면, 생성기는 next호출 때 한 번 실행되고 곧바로 멈춘다. 이처럼 생성기는 자동으로 반복 실행되지 않으므로 세미 코루틴이라고 한다.

function* 키워드

앞에서 본 generator 함수는 지금까지 본 함수와 비교했을 때 다음 두 가지 차이가 있다.

- function* 키워드로 함수를 선언한다.
- 함수 몸통 안에 yield 문이 있다.

즉, function 키워드로 선언된 함수가 생성기인데, 생성기는 오직 function 키워드로 선언해야 하므로 화살표 함수로는 생성기를 만들 수 없다. 생성기는 반복기를 제공하는 반복기 제공자로서 동작한다.

function*은 키워드다


생성기는 function 키워드를 사용해 만드는 조금 다른 형태의 함수이다. 여기서 주의할 점은 function 키워드에 별표(*)를 붙인 것이 아니라 `function`이 키워드이다. 따라서 function 키워드를 사용하지 않는 화살표 함수 형태로는 생성기를 만들 수 없다. 참고로 function과 별표(*)사이에 공백은 없어도 되고 여러개 있어도 상관없다.

yield 키워드

생성기 함수 안에서는 yield는문을 사용할 수 있다. yield는 연산자(operator)형태로 동작하며 다음처럼 두 가지 기능을 한다.

1. 반복기를 자동으로 만들어 준다.
2. 반복기 제공자 역할도 수행한다.

이제 function* 키워드를 이용해 생성기 형태로 rangeGenerator라는 이름의 함수를 만들겠다.
src/rangeGenerator.ts

export function* rangeGenerator(from: number, to: number){
    let value = form;
    while(value < to >){
        yield value++;
    }
}

다음른 rangeFenerator를 테스트하는 코드이다. 앞서 본 반복기 제공자 관련 코드와 크게 다르지 않다.
src/rangeGenertor-test.ts

import { rangeGenerator } from "./rangeGenerator";

// while 패턴으로 동작하는 생성기
let iterator = rangeGenerator(1, 3 + 1);
while (1) {
  const { value, done } = iterator.next();
  if (done) break;
  console.log(value); // 1 2 3
}

// for...of 패턴으로 동작하는 생성기
for (let value of rangeGenerator(4, 6 + 1)) console.log(value); // 4 5 6

반복기 제공자의 메서드로 동작하는 생성기 구현

06-1절 말미에 StringIterable 클래스로 반복기 제공자를 구현했다. 그런데 생성기는 반복기를 제공하는 반복기 제공자로서 동작하므로, 생성기를 사용하면 StringIterable클래스를 다음처럼 간결하게 구현이 가능하다.
src/IterrableUsingGenerator.ts

export class IterableUsinggenerator<T> implements Iterable<T> {
        constructor(private values: T[] = [], private currentIndex : number = 0) {}
            [Symbol.iterator] = function* () {
                while(this.currentIndex < this.values.length>)
                    yield this.values[this.cureentIndex++]
            }
}

코드에서 03행을 function [Symbol.iterator] () 형식으로 구현할 수는 없다. `생성기를 클래스 메서드의 몸통이 되게 하려면 반드시 [Symbol.iterator] = function ()와 같이 사용`해야 한다.
다음 테스트 코드는 IterableUsingGenerator가 06-1절의 StringIterable과 똑같이 동작함을 보여준다.
src/IterableUsionggenerator-test.ts

import { IterableUsingGenerator } from "./IterableUsingGenerator";
for (let item of new IterableUsingGenerator([1, 2, 3])) console.log(item); // 1 2 3
for (let item of new IterableUsingGenerator(["hello", "world", "!"]))
  console.log(item); // hello world !

yield* 키워드

타입스크립트는 yield 키워드 뒤에 을 붙인 yield 키워드도 제공한다. yield는 단순히 값을 대상으로 동작하지만, yield*는 다른 생성기나 배열을 대상으로 동작한다.

src/yield-star.ts

function* gen12() {
  yield 1;
  yield 2;
}

export function* gen12345() {
  yield* gen12();
  yield* [3, 4];
  yield 5;
}

다음 코드에서 06행의 gen12345 함수는 1, 2, 3, 4, 5등 다섯개의 값을 생성하는 생성기이다.
이 생성기는 또 다른 생성기인 gen12 함수를 yield* 키워드로 호출해 값 1과 2를 생성하고, 3과 4는 배열에 있는 값을, 마지막 5는 단순히 yield 문으로 생성한다. 02, 03, 09행에서 yield의 피연산자(1, 2, 5)와 07, 08행에서 yield&의 피연산자를 비교하면 둘의 차이가 확연히 느껴진다.

다음 테스트 코드는 1부터 5까지 수를 출력한다. 이로부터 yield*의 동작 방식을 이해할 수 있다.
src/yield-star-test.ts

import { gen12345 } from "./yield-star";

for (let value of gen12345()) console.log(value); // 1 2 3 4 5

테스트 코드는 03행에서 gen12345 함수를 호출하므로 yield-start.ts의 07행이 호출되고, yield* 구문에 의해 다시 01행 함수 gen12가 호출되어 02행의 yield문이 값 1을 생성한다. 그리고 이상태로 코드는 정지한다.
그리고 다시 for문에 의해 yield-start.ts의 getn12()가 호출되고, 02행에서 정지가 풀리며 03행에 의해 값 2를 생성하고 다시 코드 진행을 멈춘다. 이후 다시 07행이 실행되지만 gen12함수에는 더 실행항 yield 문이 없으므로 08행이 실행되고 배열에 담긴 값 3을 생성하고 다시 멈춘다. 그리고 for문에 의해 08행이 다시 실행되면 이번엔 값 4를 생성하고 멈춘다. 최종으로 값 5가 생성되면 for문이 종료되어 프로그램이 끝난다.

yield 반환값

yield 연산자는 값을 반환한다. 다음 코드에서 05행은 yield 연산자의 반환값을 select라는 변수에 저장한다.
src/yield-return.ts

export function* gen() {
  let count = 5;
  let select = 0;
  while (count--) {
    select = yield `you select ${select}`;
  }
}

export const random = (max, min = 0) =>
  Math.round(Math.random() * (max - min)) + min;

yield 연산자의 반환값은 반복기의 next 메서드 호출 때 매개변수에 전달하는 값이다. 다음 테스트 코드에서 04행은 next 메서드 호출 때 난수를 생성해 전달한다.
src/yield-return-test.ts

import { random, gen } from "./yield-return";
const iter = gen();
while (true) {
  const { value, done } = iter.next(random(10, 1));
}

실행결과


you select 0

you select 7

you select 9

you select 2

you select 2

코드를 실행하면 첫 줄 외에 다른 줄은 모두 난수가 출력된다. 첫 줄은 항상 'you select 0'이 출력되는데, 이는 03행에서 select 변수를 0으로 설정했기 때문이다. 실행 결과는 이전에 next 메서드가 전달한 값이 다시 gen 함수의 내부 로직에 의해 현재의 value값이 되어 출력 된다.
지금까지 반복기와 반복기 제공자, 생성기에 관해 알아보았다. 다음 장에서는 Promise클래스와 async/await 구문에 관해 알아보자.

💡 이번장에서 설명한 내용은 함수형 프로그래밍 보다는 동시성 프로그래밍 영역에 더 가깝다. 스칼라와 같은 언어는 스트림이라는 타입을 이용해 05장에서 설명한 선어형 프로그래밍 스타일로 생성기를 동작시킬 수 있다. 반면 타입스크립트는 스트림이라는 기능을 제공하지 않으므로 생성기를 명령형 방식의 코드로만 작성할 수 있다.

즉, 타입스크립트 언어만의 관점에서 생성기는 함수형 프로그래밍 영역은 아니다. 하지만 08-5절에서 보듯 생성기를 구현할 때 함수형 프로그래밍이 사용될 수 있다.

profile
느린 걸음도 먼 길을 갈 수 있다.

0개의 댓글