Typescript로 구현해 보는 KMeans (1) Javascript Iteration Protocol

2024. 1. 3. 18:00@dev.formegusto

iter bingle bingle

여러분은 자신의 개발 스토리에서 인상 깊었던 알고리즘이나 라이브러리가 있으신가요? 저는 지난 과거를 돌아보면 KMeans 알고리즘이 가장 먼저 떠오르네요. 제가 처음으로 접했던 머신러닝 알고리즘이었는데요. 수집, 가공, 처리 그리고 평가 등의 인공지능 프로세스에서 사용되는 개념들은 웹이 익숙했던 저의 세계관을 넓혀주는 계기가 되었었답니다.

📖 Learning Point

  • Iterator Pattern
  • Javascript Iteration Protocol
  • Typescript Iteration Protocol

저는 익힌 개념을 토대로 코드를 작성할 수 있을 때 해당 알고리즘을 완전히 이해했다고 생각하는 신념이 있는데요. 우연히 GoF(Gang of Four) 디자인 패턴에 관심을 가지던 시기에 KMeans의 개념이 어느 정도 익숙해진 상태여서 디자인 패턴을 활용해보고 싶었고 그 중에서도 반복적으로 진행되는 메커니즘에 Iterator Pattern을 적용할 수 있겠다 하고 생각해 보았답니다. ☺️☺️


🍭 Iterator Pattern

GoF 디자인 패턴에서 클래스나 객체들의 상호작용 방법을 정의하는 Behavior Pattern에 포함되는 Iterator Pattern은 오늘날의 for-each 문법 등에서 만나볼 수 있는 순회 가능한(Iterable) 데이터 컬렉션들의 기반이 되는 개념입니다.

순회 가능한 데이터 컬렉션

자바스크립트 생태계에서는 배열, 맵 혹은 문자열을 예로 들 수 있어요. 이들은 공통으로 여러 개의 요소를 포함한다는 특징을 가지고 있는데요. 저희는 이와 같은 컬렉션 안에 포함된 요소에 인덱스 혹은 키라고 불리는 포인터를 사용하여 접근합니다.

for(초기식; 조건식; 증감식);

이러한 객체들은 순회의 목적을 가졌을 때 필연적으로 반복문을 함께 사용하게 됩니다. 순차적으로 변화할 인덱스에 대한 정의를 작성한 초기식, 이와 연관된 반복 조건의 조건식 그리고 인덱스에 변화를 주는 표현식을 가지는 증감식을 포함합니다.

class Iterator {
  constructor(arr) {
    this.index = 0;
    this.arr = arr;
  }
  next() {
    return this.arr[this.index++];
  }
  hasNext() {
    return this.index <= this.arr.length;
  }
}
const arr = [1, 2, 3, 4];
const iter = new Iterator(arr);
for (let elem = iter.next(); iter.hasNext(); elem = iter.next())
  console.log(elem); // 1 2 3 4

Iterator Pattern은 이러한 초기식, 조건식 그리고 증감식에 관련된 메서드를 클래스 정의에 포함함으로써 반복문에 대한 휴먼 가이드라인(Human Guideline)을 제시해 줍니다.

class RangeIterator {
  constructor(startOrEnd, end, step = 1) {
    this.index = end ? startOrEnd : 0;
    this.end = end ? end : startOrEnd;
    this.step = step;
  }
  next() {
    const nowIndex = this.index;
    this.index += this.step;
    return nowIndex;
  }
  hasNext() {
    return this.index - this.step < this.end;
  }
}
const range = new RangeIterator(10, 100, 20);
for (let i = range.next(); range.hasNext(); i = range.next()) console.log(i);

반복적인 기능 및 출력값을 포함한 next 메서드와 다음 동작의 진행 여부를 출력하는 hasNext 메서드의 작성을 통해 순회 가능한 데이터 컬렉션처럼 객체의 기능이 동작할 수 있도록 해주는데요. 이는 순차적이고 반복적으로 수행해야 하는 코드를 반복문 코드 블록으로부터 분리함으로써 활용 관련 콘텐츠를 중점적으로 작성할 수 있도록 해줍니다.


🥨 Javascript Iteration Protocol

Javascript Iteration Protocol은 ES6 이후에 도입된 순회 가능한 데이터 컬렉션을 표현하는 규칙입니다. 이전에 문자열이나 배열 등의 저희가 잘 아는 데이터 컬렉션들은 각자 나름의 규칙을 가지고 순회를 했다고 하는데요.

❓ Iteration Protocol

  • Iterable Protocol
  • Iterator Protocol

순회 관련 구조를 Iteration Protocol 통일화 시키면서 자바스크립트 생태계에서 순회 가능한 데이터 컬렉션의 구조를 정의하고 for-of, spread syntax 그리고 array destructuring assignment 와 같이 편리한 순회 문법을 만나볼 수 있게 되었습니다.

console.log(Array.prototype.hasOwnProperty(Symbol.iterator)); // true
console.log(String.prototype.hasOwnProperty(Symbol.iterator)); // true

이들은 모두 Well-known Symbol인 Symbol.iterator 메서드를 가지고 있는 것을 확인할 수 있는데요.

const arr = [1,2,3,4,5];
const arrIterator = arr[Symbol.iterator]();
console.log(Object.getPrototypeOf(arrIterator));
// Array Iterator {Symbol(Symbol.toStringTag): 'Array Iterator', next: ƒ}

Iterable Protocol 에서 요구하는 규칙으로, 이를 준수한 객체는 Symbol.iterator 메서드를 가지며 해당 메서드는 iterator 객체를 반환해야 합니다.

console.log(arrIterator.next()); // {value: 1, done: false}
// ...
console.log(arrIterator.next()); // {value: undefined, done: true}

Iterator Protocol 을 준수하는 iterator 객체는 next 메서드를 가지며 해당 메서드가 반환하는 Iterator Result 객체는 value와 done으로 구성되어 있습니다.

for (let iro = arrIterator.next(); !iro.done; iro = arrIterator.next())
  console.log(iro.value); // 1 2 3 4 5

Iterator Result 객체의 구성은 이전에 설명해 드린 Iterator Pattern의 구성과 관련이 깊습니다. next 메서드가 객체의 반복적인 기능을 수행하고 해당 라운드의 출력값을 반환한 값은 value 프로퍼티에 표현되었고 반복 조건을 명시한 hasNext 메서드는 done 프로퍼티로 대체 되었습니다.

💡 Iteration Protocol

  • Iterable Protocol - iterator 객체를 반환하는 Symbol.iterator 메서드 작성
  • Iterator Protocol - value와 done 프로퍼티를 포함하는 객체를 반환하는 next 메서드 작성

🍩 Custom Iteration Protocol

for r in range(10, 100, 20):
    print(r) # 10 30 50 70 90

Iterator Pattern 에서 은근슬쩍 소개해 드린 RangeIterator 라는 클래스는 파이썬의 range 함수를 레퍼런싱하여 제작했는데요. 시작값, 종료값 그리고 증감값을 매개변수로 받아 시작부터 종료 미만의 값들을 순차적으로 증감값만큼 변화시킨 결과를 반환해 줍니다.

💡 Iteration Protocol

  1. Iterator Class Definition
  2. Iterable Class Definition

해당 클래스에 Iteration Protocol을 적용하여 RangeIterable과 RangeIterator로 분리 하겠습니다. Iterable Protocol의 조건인 Symbol.iterator 메서드는 iterator 객체를 반환해야 한다는 조건을 가지기 때문에 Iterator 클래스를 우선 작성해 보도록 할게요.

class RangeIterator {
  constructor(params) {
    Object.assign(this, params);
  }
}

RangeIterator 객체는 사용자가 아닌 Iterable 객체로부터 생성되어 집니다. 이와 같은 과정을 보았을 때 Iterable 객체는 사용자의 초기 설정값을 포함하고 있으며 이를 Iterator 객체가 물려받고 사용하는 것으로 해당 객체의 생성 프로세스를 유추해 볼 수 있습니다.

next() {
  const value = this.index;
  const done = value >= this.end;
  this.index += this.step;
  return { value: done ? undefined : value, done };
}

next 메서드에는 이와 같이 기존에 Iterator Pattern 에서 작성한 메서드에 hasNext의 내용을 추가로 작성하여 완성해 주도록 합니다.

const iterator = new RangeIterator({ index: 10, end: 100, step: 20 });
console.log(iterator.next()); // { value: 10, done: false }
// ...
console.log(iterator.next()); // { value: 90, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

작성이 완료되었다면 직접 iterable 객체가 되었다는 마음가짐으로 iterator를 생성해 주고 next 메서드를 테스트해 주도록 합니다.

class RangeIterable {
  constructor(startOrEnd, end, step = 1) {
    this.index = end ? startOrEnd : 0;
    this.end = end ? end : startOrEnd;
    this.step = step;
  }
  [Symbol.iterator]() {
    return new RangeIterator(this);
  }
}

RangeIterable 클래스에는 작성한 사용자 매개변수에 따른 초기 설정값을 정의해주는 내용과 이전에 작성한 RangeIterator 클래스를 사용하여 iterator 객체를 생성하고 반환해 주는 Symbol.iterator 메서드를 작성해 줍니다.

const iterable = new RangeIterable(10, 100, 20);
for (let r of iterable) console.log(r); // 10 30 50 70 90

이제 작성된 Iterable 클래스를 이용해서 객체를 생성하여 Iteration Protocol을 준수한 객체만 사용가능한 문법에 적용시켜 주면, 위와 같이 기존에 설계한 대로 동작하는 것을 확인할 수 있습니다. 👏🏻👏🏻

🥚 Iterator With Iterable

💡 Iteration Protocol

  • 지속적인 순회를 진행할 수 있도록 Symbol.iterator 메서드는 매번 새로운 iterator를 생성하여 반환
  • 반복조건의 종료 시에 출력값은 undefined로 반환하도록iterator의 next메서드 반환문을 작성
  • 각 Protocol의 구조를 명확히 할 수 있도록 iterable과 iterator를 분리하여 작성

현재 제가 소개해 드리는 방법은 순회가능한 Standard Built-In Objects 에서 확인할 수 있는 Iteration Protocol의 흔적들을 토대로 특징을 유추하여 최대한 유사하게 동작하도록 작성을 하고 있는데요.

for (let elem of arrayIterator) console.log(elem); // 1 2 3 4 5
for (let r of rangeIterator) console.log(r);
// Uncaught TypeError: rangeIterator is not iterable

스스로 내린 규칙 외에도 이들의 Symbol.iterator 메서드가 반환하는 iterator 객체는 Iterable Protocol도 준수하고 있다는 것을 확인할 수 있었습니다.

console.log(Symbol.iterator in arrayIterator); // true
console.log(Symbol.iterator in rangeIterator); // false

이러한 형태의 iterator 객체는 내부에 Symbol.iterator 메서드를 포함하고 있기 때문에 iterator 이면서 iterable 인 객체라고 불립니다. 반대로 iterable 객체에 next 메서드를 추가한다면 iterable 이면서 iterator인 객체로도 볼 수가 있죠.

class RangeIterator {
  // ...
  [Symbol.iterator]() {
    return this;
  }
}
// ... 
const rangeIterator = iterable[Symbol.iterator]();
for (let r of rangeIterator) console.log(r); // 10 30 50 70 90

iterator 객체에 iterable protocol을 적용한다면 기존에 iterable 객체와 같이 설정값을 물려주는 형태로 새로운 iterator를 생성할 수도 있고 자기 자신을 반환시키는 내용으로도 작성할 수 있습니다.


🐣 Typescript Iteration Protocol

interface Iterator<T, TReturn = any, TNext = undefined> {
  next(...args: [] | [TNext]): IteratorResult<T, TReturn>;
  return?(value?: TReturn): IteratorResult<T, TReturn>;
  throw?(e?: any): IteratorResult<T, TReturn>;
}

interface Iterable<T> {
  [Symbol.iterator](): Iterator<T>;
}

type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>;

interface IterableIterator<T> extends Iterator<T> {
  [Symbol.iterator](): IterableIterator<T>;
}

Typescript 에서도 Iteration Protocol과 관련된 Iterable, Iterator 그리고 IteratorResult 타입이 작성되어 있습니다. 추가적으로 Iterable Protocol과 Iterator Protocol을 동시에 준수하는 객체를 위한 인터페이스도 확인할 수 있네요.

interface IRangeIterator extends IterableIterator<number> {}
interface IRangeIterable extends Iterable<number> {}

이는 제네릭에 입력한 타입을 토대로 Iterable 객체에 작성되어질 Symbol.iterator 메서드는 어떤 타입의 value 프로퍼티를 가지는 IteratorResult 객체를 반환하는 next 포함한 Iterator를 반환할 것 인지에 대해 연관성을 가지도록 작성할 수 있습니다.

abstract class RangeSettingValue {
  protected index!: number;
  protected end!: number;
  protected step!: number;
}

본격적인 Iterator와 Iterable의 Typescript 작성에 앞서 이들의 설정값을 표현하는 추상 클래스를 다음과 같이 작성해 주도록 합니다.

class RangeIterator extends RangeSettingValue implements IRangeIterator {
  constructor(params: RangeIterable) {
    super();
    Object.assign(this, params);
  }
  next(): IteratorResult<number> {
    const value = this.index;
    const done = value >= this.end;
    this.index += this.step;
    return done ? { value: undefined, done } : { value, done };
  }
  [Symbol.iterator](): IRangeIterator {
    return this;
  }
}

RangeIterator 클래스에 타입을 포함시킨다면 초기설정값을 포함하는 추상클래스를 상속받고 사전에 정의한 IRangeIterator 인터페이스를 구현하도록 정의할 수 있는데요. 이에 따라 생성자에는 super 키워드가 추가되었으며 next 메서드의 반환문이 기존에 Javascript 에서 작성한 내용과 달라진 것을 확인할 수 있습니다.

'{ value: number | undefined; done: boolean; }' 형식은 'IteratorResult<number, any>' 형식에 할당할 수 없습니다.
'{ value: number | undefined; done: boolean; }' 형식은 'IteratorReturnResult<any>' 형식에 할당할 수 없습니다.
'done' 속성의 형식이 호환되지 않습니다.
'boolean' 형식은 'true' 형식에 할당할 수 없습니다.ts(2322)

기존에 작성한 형식은 순회 종료 조건에 따른 분기를 value 프로퍼티에만 포함시켰으며 이와 같이 진행했을 때는 위와 같은 ts-error가 발생 했었는데요.

interface IteratorYieldResult<TYield> {
  done?: false;
  value: TYield;
}

interface IteratorReturnResult<TReturn> {
  done: true;
  value: TReturn;
}

해당 메세지는 IteratorResult 타입의 요구사항을 정확하게 작성하지 않아서 나타납니다. IteratorResult 타입은 done 프로퍼티가 false 값을 가지는 순회 진행 중의 상황과 true 값을 가지는 순회 종료 상황으로 나누어 상세히 구성되어 있는데요. 이는 단순히 boolean 값으로 반환되는 기존의 코드를 제시하였을 때는 IteratorYieldResult 객체에 대하여 true 값이 발생 및 IteratorReturnResult 객체에 대하여 false 값이 발생할 수도 있다는 타입 추론을 진행하게 합니다.

return done
  ? ({
      value: undefined,
      done,
    } as IteratorReturnResult<undefined>)
  : ({ value, done } as IteratorYieldResult<number>);

그래서 done 프로퍼티에 대한 분기를 정확하게 어떤 인터페이스의 IteratorResult 객체를 반환해 주는 상황인지에 대해 작성 해줘야하죠. 또한 저희는 순회 진행 상황에서 number를 반환하도록 제네릭 타입에 명시하였기 때문에 종료 상황에서 발생하는 undefined 값에 대한 처리를 고려하여 반환문을 더 큰 관점으로 묶어서 처리해 줘야 합니다.

class RangeIterable extends RangeSettingValue implements IRangeIterable {
  constructor(startOrEnd: number, end: number, step: number = 1) {
    super();
    this.index = end ? startOrEnd : 0;
    this.end = end ? end : startOrEnd;
    this.step = step;
  }
  [Symbol.iterator](): IRangeIterator {
    return new RangeIterator(this);
  }
}

RangeIterable 클래스에 타입을 포함시킨다면 초기설정값을 포함하는 추상클래스를 상속받고 사전에 정의한 IRangeIterable 인터페이스를 구현하도록 정의할 수 있습니다. 이에 따라 생성자 및 Symbol.iterator 메서드를 위한 타입 정의와 super 키워드가 추가해 줍니다.

const range = new RangeIterable(10, 100, 20);
const [elem1, elem2, elem3] = range;
console.log(elem1, elem2, elem3); // 10 30 50

Javascript로 작성한 내용을 Typescript로 전환하면서 Iteration Protocol과 관련된 타입들을 살펴볼 수 있었는데요. 이렇게 내장된 내용의 타입들을 보다 보면 알면 알수록 어렵게만 느껴지는 Javascript의 구조와 개발자분들의 의도가 하나씩 보이기 시작하는 것 같아요. 제가 생각하는 Typescript의 매력 중 하나랍니다. 다음 챕터에서는 스터디의 최종 목표인 KMeans를 Typescript로 작성하기 이전에 개념들을 살펴보는 내용으로 찾아뵐게요. 오늘도 수고하셨습니다. ☺️☺️