[JS] 이터러블로 사고하기

Jiheon Kim·2023년 10월 28일
2

Javascript

목록 보기
4/6
post-thumbnail

✨시작

ES6부터 표준으로 도입된 이터레이터와 제네레이터에 대해서 알아보고
이러한 표준 인터페이스가 등장한 배경과 의미에 대해서 알아보자

💡 이터러블(Iterable)과 이터레이터(Iterator)

이터러블과 이터레이터는 ES6에서 정의한 표준 인터페이스이다
각각의 인터페이스에 대해서 알아보자

1) 이터러블 (Iterable)

이러터블Symbol.iterator 라는 심볼 타입의 key를 갖고 이터레이터 인터페이스 구현하고 있는 이터레이터 객체를 반환해야 한다

2) 이터레이터 (Iterator)

이터레이터next 라는 메소드를 갖고 IteratorResult 인터페이스를 구현하고 있는 이터레이터 결과 객체를 반환해야한다

3) 이터레이터 결과 (IteratorResult)

IteratorResultdone value 라는 이름의 key를 갖는 객체이고 최종적으로 이터레이터에 있는 next메소드가 호출되면 이터레이터 결과객체의 value 값이 반환되는데 done 속성이 true가 아닐 때 까지 계속 반복한다

Iterable ➡️ Iterator ➡️ IteratorResult 순으로 반환한다

const iterable = {
    [Symbol.iterator](){
        return {
            next(){ // 항상 false이므로 무한히 "hello"를 반환하는 이터레이터 
               return { done: false, value: "hello" };
            }
        }
    }
}
const iterable = { // 주로 이런식으로 만들어서 deps를 줄인다 
    [Symbol.iterator](){ return this },
    next(){
        return { done: false, value: "hello" };
     }
}

💡 왜 이터레이터를 쓸까?

1) 문법적 혜택

✨아래의 문법들은 내부적으로 이터레이터의 next 메소드를 호출한다

  • 구조 분해 할당 (Destructuring)
  • 전개 구문 (Spread)
  • 나머지 연산 (Rest parameter)
  • for...of 루프

이미 배열, 문자열, Set, Map 등 많은 내장 객체들은 Symbol.iterator 메서드를 구현하여 순회 가능하도록 만들어져 있다. 따라서 내가 정의한 객체가 이러한 이터레이터 인터페이스를 지켰을때 이러한 문법들을 사용할 수 있는 혜택이 생긴다.

2) 지연평가

for문과 while문과 같은 제어문은 말 그대로 문(statement)이다 문은 식과 다르게 값으로 평가되지 않는다. 식은 값을 반환하고 메모리에 남는 반면, 문은 작업을 수행하는 실행단위로 단순히 일련의 동작을 수행해서 마치 1회용과 같이 한번 실행하고 끝이다 그래서 기존의 문으로 사용하던 루프를 식으로 사용하고 싶은 관점에서 등장한 것이 이터레이터 이다
루프를 식으로 사용하면 기존의 문과 다르게 식은 메모리에 남아서 여러 번 사용하거나 중간에 멈추거나 할 수 있기 때문

const numbers = [1, 2, 3, 4, 5];
// 배열은 이터러블 인터페이스를 지키고 있기 때문에 Symbol.iterator 속성을 갖고있다 
const iterator = numbers[Symbol.iterator]();

// 언제든지 다음 값이 필요한 시점에 이터레이터의 next 메소드를 호출하기만 하면 된다
const result1 = iterator.next(); // {value: 1, done: false}
const result2 = iterator.next(); // {value: 2, done: false}
const result3 = iterator.next(); // {value: 3, done: false}

일반 자바스크립트 객체는 Symbol.iterator 속성을 갖고 있지 않아서 이터러블 객체가 아니다. 따라서 이터러블을 순회하는 방법으로는 순회할 수 없지만 직접 Symbol.iterator 을 정의해서 이터러블 인터페이스를 구현하거나 열거 가능한 속성들을 순회하는 for...in 루프를 사용하여 객체를 순회할 수 있다.

💡 이터레이터가 갖는 의미

1) 유연성과 확장성

이터러블 프로토콜을 따르는 함수를 사용하는 것은 이미 존재하는 이터러블 프로토콜을 따르는 함수들과의 조합뿐만 아닌 앞으로 만들어지는 많은 헬퍼 함수들과의 조합성이 좋아진다는 것을 의미한다
이를 통해 훨씬 유연하고 다형성이 높은 함수를 만들 수 있다

2) 추상화

// 1️⃣ 일반 반복문 
const numbers = [1, 2, 3, 4, 5];
let i = 0;

while (i < numbers.length) {
  console.log(numbers[i++]); // 1, 2, 3, 4, 5
}
// 2️⃣ 이터레이터를 이용한 반복 
const numbers = [1, 2, 3, 4, 5];
const iterator = numbers[Symbol.iterator]();

let result = iterator.next();
while (!result.done) {
  console.log(result.value); // 1, 2, 3, 4, 5
  result = iterator.next();
}

기존의 while문은 조건에 맞는지 검사하여 while문 안쪽에서 반복할 때마다 처리해야 할 것들을 정의했다. 즉, 반복해야 하는 주체가 자기 자신을 설명하는게 아니라 while문에서 반복해야 하는 주체를 설명하고 있던 것

반면 이터레이터는 자기 자신이 언제까지 루프를 돌고, 돌면서 무엇을 반환 해야 할지를 알고 있는 객체이다. 이터레이터가 반복 자체를 하진 않지만 외부에서 해당하는 객체를 반복 하려고 할때 반복에 필요한 조건과 실행되는 값을 미리 정의해둔 객체를 의미한다.

기존의 루프는 많은 역활이나 책임을 제어문이 갖고 있던 것에 비해서 이터레이터를 통한 추상화를 통해 무엇을 얼마나 반복할지와 같은 정보를 이터레이터가 관리 하므로 언제라도 똑같은 루프를 돌 수 있다

💡 제너레이터 (Generator)

제너레이터 같은 함수를 학술적인 용어로 코루틴(Coroutine) 이라고한다.
코루틴은 서브 루틴을 일시 정지하고 재개할 수 있는 구성 요소를 말하는데
쉽게 말해 필요에 따라 일시 정지할 수 있는 함수를 말하는 것
제너레이터가 반환하는 값은 이터러블 이면서 동시에 이터레이터인데
yield 를 통해서 동기명령을 중간에 멈출 수 있는 특징이 있으며
function* 으로 정의해야만 해서 화살표 함수로는 정의할 수 없다

function* generator() {
    yield 1;
    yield 2;
    yield 3;
}
const gen = generator();

const result1 = gen.next() // {value: 1, done: false}
const result2 = gen.next() // {value: 2, done: false}
const result3 = gen.next() // {value: 3, done: false}

🔎제너레이터가 반환하는 객체는 이터러블이면서 이터레이터 이기 때문에 일반적인 이터레이터랑 똑같이 사용 가능하며 이터레이터를 더 간단하고 효율적으로 사용할 수 있게 한다

// 1️⃣ 이터레이터 구현 
const zeroSpanThree = {
    index: 0,
    [Symbol.iterator]() {
        return {
            next: () => {
                if (this.index < 3) {
                    return { value: this.index++, done: false };
                }
                return { done: true };
            }
        }
    },
};
for(x of zeroSpanThree) console.log(x); // 0, 1, 2
// 2️⃣ 제너레이터 구현 
const zeroSpanThree = {
    index: 0,
    *[Symbol.iterator]() {
        while (this.index < 3) {
            yield this.index++;
        }
    },
};
for(x of zeroSpanThree) console.log(x); // 0, 1, 2

💡 제네레이터가 갖는 의미

제네레이터는 이터러블하지 않는 일반객체와 같은 자료구조를 마치 이터러블하게 사용할 수 있게 해주는데 제네레이터 안에서 객체를 순회하면서 yield로 한 개씩 뽑아내면 아주 간단하게 이터러블한 객체로 만들 수 있다. 따라서 어떤 값이든 간에 이터러블화 하여 이후에 이터러블 프로그래밍으로 다룰 수 있다는 것

const range = {
    start: 10,
    end: 100,
    step: 2,
    done: false,
    *[Symbol.iterator]() {
        for (const prop of Object.keys(this)) {
            yield prop;
        }
    }
}
for(x of range) console.log(x); // start, end, step, done 

즉, 어떠한 자료구조든지 제너레이터라는 강력한 도구를 사용하여 모든 값, 모든 상황에서 이터레이터로 다룰 수 있는 이터레이터 프로그래밍이 가능하다

실제로 제너레이터는 함수형 프로그래밍과 매우 밀접한 관련이 있다.

💡 제네레이터의 지연성

1) 지연평가 (Lazy evaluation)

제네레이터의 특징을 이용하여 연산이 실제로 필요한 시점까지 연산을 미루는 방식을 통한 지연성의 특징을 갖게 하여 더욱 효율적으로 연산을 할 수 있다.
이터레이터 자체로는 연산을 하지 않지만 이터레이터 프로토콜을 사용하는 쪽에서 next 메소드를 호출하면서 그때마다 이터레이터의 연산이 실행된다
즉 연산이 미리 이루어지지 않고, 연산을 미루면서 지연평가를 통해 불필요한 연산을 줄이면서 효율적인 연산이 가능하다.

const arr = [1, 2, 3, 4, 5, 6, 7, 8];

const result = arr
  .map((v) => v * v)  // ➡️ 가로연산 [1, 4, 9, 16, 25, 36, 49, 64]
  .filter((v) => v >= 10) // ➡️ 가로연산 [16, 25, 36, 49, 64]
  .slice(0, 3); // ➡️ 가로연산 [16, 25, 36]

이 코드는 배열 API인 map, filter를 사용하여 배열을 전부 순회하면서 요소를 모두 제곱하고 요소에서 10 이상의 값만 필터링하고 마지막으로 slice를 통해 배열을 자르고 있다. slice(0,3) 에서 결과적으로 3개의 요소만 반환하지만 arr의 요소가 많아질수록 map과 filter에서는 그만큼 반복해야 한다 따라서 arr가 커질수록 불필요한 연산을 더 많이 할 가능성이 높아진다.

const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 
             11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; 

const lazyMap = function* (f, iter) {
  for (const v of iter) {
    yield f(v);
  }
};
const lazyFilter = function* (f, iter) {
  for (const v of iter) {
    if (f(v)) yield v;
  }
};
const slice = function* (iter, n) {
  for (const v of iter) {
    if (n--) yield v;
    else break;
  }
};

const mapped = lazyMap((v) => v * v, arr); // ⬇️ 세로연산
const filtered = lazyFilter((v) => v >= 10, mapped); // ⬇️ 세로연산
const sliced = slice(filtered, 3); // ⬇️ 세로연산

// ✅실제 연산이 시작되는 시점으로 전개 구문을 통해서 이터레이터를 실행시킨다  
console.log([...sliced]); // [16, 25, 36]

지연평가를 통해서 수정된 코드는 연산이 실제로 필요한 시점까지 연산을 미룬다. ...sliced 전개구문을 통해서 slice 제너레이터의 next 메소드가 호출되면 slice는 자신이 인자로 받은 이터레이터 즉, filtered 의 next 메소드를 실행시키고 마찬가지로 mapped의 next를 호출하고 마지막으로 arr배열 에서 첫 번째값 1을 가져오면 전달된 함수로부터 연산을 처리하고 yield 되어 이번에는 위에서 아래로 내려가게 되어 마지막인 console.log() 함수까지 전달되어 출력되는 것
이러한 지연실행으로 연산을 미워서 배열 전체를 돌지 않고 처음에 넘겨준 배열의 요소를 한 개씩 돌면서 최소한의 연산만 하여 효율적으로 연산할 수 있게 된다

따라서 제너레이터를 이용하여 즉시 평가를 지연평가로 만들 수 있다

2) async/await

ES8에서 추가된 async/await 문법은 제너레이터로 구현가능하다
실제로 바벨과 같은 트랜스파일러에 ES8 이전 버전으로 변환시켜보면
제너레이터의 yield 키워드를 사용하여 await 을 구현하고 있다.

// app.ts
const fetchProduct = async () => {
  const data = await fetch("api/product/1");
  console.log(data);
};
// app.js
// ✅"target": "ES2016"
var __awaiter = function (thisArg, _arguments, P, generator) {
    // 생략... 
};
const fetchProduct = () => __awaiter(void 0, void 0, void 0, function* () {
    const data = yield fetch("api/product/1");
    console.log(data);
});

마무리

이터러블 프로그래밍에서는 문제 해결을 리스트적인 관점으로 바라보고 이터러블로 사고하는 방법을 조금 더 알게 된 것 같다.
이를 통해 코드를 더 선언적으로 작성하여 코드의 의도를 명확히 전달하고 예측 가능하며 확장성이 높은 프로그래밍 스타일을 채택할 수 있고 즉시 평가와 지연 평가를 적절하게 사용하면 데이터 처리를 효율적으로 수행할 수 있으며, 코드의 가독성과 유지 보수성을 향상시킬 수 있다.

profile
누군가는 해야하잖아

0개의 댓글