[Typescript] 제너레이터 + 구조 분해 = 의도치 않은 이터레이터 종료?
📌 읽기 전에
📚 이 글은 JavaScript/TypeScript의 문법이나 이터레이션 프로토콜의 기본 개념을 설명하지 않습니다. 해당 개념이 익숙하지 않다면, 먼저 MDN의 이터레이션 문서를 참고하는 것을 권장 드립니다.
🖐️ 서론
FxTs 스타일의 리스트 프로세싱을 연습하며 고차 함수들을 직접 제너레이터 기반으로 구현하던 중, 구조 분해 할당 이후 이터레이터가 조기 종료되는 현상을 겪었습니다. 이 글은 그 원인을 파고든 트러블슈팅 기록입니다.
제너레이터 스펙을 정확히 알고 있지 못했기에 많은 시간이 걸렸습니다. 이 글이 같은 문제를 마주한 누군가에게 도움이 되기를 바랍니다.
🔧 FxIterable
- [이 코드 스타일은 멀티 패러다임 프로그래밍, FxTs라이브러리를 통해 학습하였습니다.]
- Fxiterable란 이터러블 인터페이스의 구현체입니다. 리스트 프로세싱 고차 함수를 메서드 체이닝 방식으로 핸들링할 수 있는 유틸리티 객체라고 볼 수 있습니다.
export class FxIterable<A> implements Iterable<A> { constructor(private iterable: Iterable<A>) {} map<B>(f: (value: A) => B): FxIterable<B> { return fx(mapByGenerator(f, this.iterable)); } // filter, reduce, forEach 모두 map과 동일한 방식 } export const fx = <A extends unknown>(iter: Iterable<A>): FxIterable<A> => { return new FxIterable<A>(iter); };
저는 map 함수를 이렇게 구현했습니다. 제너레이터를 사용한 구현입니다.
export function* mapByGenerator<A, B>(transform: (value: A) => B, iterable: Iterable<A>): IterableIterator<B> { for (const value of iterable) { yield transform(value); } }
FxIterable의 map 메서드를 사용하면 체이닝 방식 활용이 가능합니다.
test('lazy map', () => { const result: number[] = []; const fxIterable = fx([1, 2, 3, 4, 5]).map(double); const [first, second] = fxIterable; fxIterable.forEach((number) => { result.push(number); }); expect(first).toBe(2); expect(second).toBe(4); expect(result).toEqual([6, 8, 10]); });
- map에 의해 두 배로 평가된 값을 구조 분해 할당으로 두 개 꺼낸다.
- 남은 세 개의 평가는 forEach 메서드에서 평가된다.
그런데 결과는 예상과 달랐습니다.
- 세 개의 평가가 남아있어야 할 것 같은데 왜 결과가 없을까요?
🧪️ 트러블 슈팅
확인을 위해 forEach에 breakpoint를 걸어 확인했습니다.
- GeneratorState: “Closed”
- forEach에 진입하기 전 이미 GeneratorState는 closed 상태였습니다.
- first, second 즉 구조 분해가 정상적으로 이루어진 것으로 보아, 구조 분해 이후 모종의 이유로 이터레이션이 종료된 것 같습니다.
- 우선 이 문제가 제 우선 이 문제가 제너레이터에서만 발생하는지, 아니면 이터레이터 객체 전체에서 발생하는지 확인했습니다.
가설 1 이터레이터는 구조 분해를 거치면 종료된다.
- map 함수를 이터러블 이터레이터 타입에 맞춰 아래의 형태로 구현할 수 있습니다.
export const mapV2 = <A extends unknown, B extends unknown>( transform: (value: A) => B, iterable: Iterable<A>, ): IterableIterator<B> => { const iterator = iterable[Symbol.iterator](); return { next(): IteratorResult<B> { const { value, done } = iterator.next(); if (done) { return { value, done, }; } return { value: transform(value), done, }; }, [Symbol.iterator](): IterableIterator<B> { return this; }, }; };
export class FxIterable<A> implements Iterable<A> { // ..메서드 생략 // mapByGenerator -> mapV2로 수정 map<B>(f: (value: A) => B): FxIterable<B> { return fx(mapV2(f, this.iterable)); } }
- map의 구현 방식만 변경했는데 테스트가 통과되었습니다.
- 즉 남은 평가가 있는 이터레이터는 구조 분해를 했다고 자동 종료되지 않습니다.
비교를 위해 제너레이터 기반 구현과 같은 지점에 break point를 설정하고 한번 비교해 보려고 합니다.
- forEach 접근 전에 GeneratorState가 종료되었습니다.
- forEach 이전에 이터레이터를 평가하는 작업은 구조 분해밖에 없었습니다.
- 즉 구조 분해가 GeneratorState를 종료한다고 유추할 수 있습니다.
가설 2 GeneratorState는 구조 분해를 거치면 Closed가 된다.
디버거의 정보로 Closed가 되는 현상은 확인했습니다. 그 원인이 구조 분해가 맞는지, 근본 원인을 찾기 위해서 제너레이터와 이터러블 프로토콜에 대해서 조금 더 살펴보았습니다.
- ECMAScript 명세에서 근거를 찾을 수 있었습니다.
7.4.11 IteratorClose ( iteratorRecord, completion ) The abstract operation IteratorClose takes arguments iteratorRecord (an Iterator Record) and completion (a Completion Record) and returns a Completion Record. It is used to notify an iterator that it should perform any actions it would normally perform when it has reached its completed state. It performs the following steps when called:
... 생략 3. Let innerResult be Completion(GetMethod(iterator, "return")). 4. If innerResult is a normal completion, then a. Let return be innerResult.[[Value]]. b. If return is undefined, return ? completion. c. Set innerResult to Completion(Call(return, iterator)). ... 생략
- IteratorClose가 호출되면 -> return 메서드가 호출됩니다.
13.15.5.2 Runtime Semantics: DestructuringAssignmentEvaluation The syntax-directed operation DestructuringAssignmentEvaluation takes argument value (an ECMAScript language value) and returns either a normal completion containing unused or an abrupt completion. It is defined piecewise over the following productions:
ArrayAssignmentPattern : [ AssignmentElementList ]
- Let iteratorRecord be ? GetIterator(value, sync).
- Let result be Completion(IteratorDestructuringAssignmentEvaluation of AssignmentElementList with argument iteratorRecord).
- If iteratorRecord.[[Done]] is false, return ? IteratorClose(iteratorRecord, result).
- Return result.
ArrayAssignmentPattern의 3번 부분을 보면 이터레이터의 done이 false라도 (평가 대상이 남아있어도) IteratorClose를 호출하도록 되어있습니다.
- MDN에도 이와 비슷한 설명이 있습니다.
A function that accepts zero or one argument and returns an object conforming to the 'IteratorResult' iterface, typically with 'value' equal to the 'alue' passed in and 'done' equal to 'true'. Calling this method tells the iterator that the caller does not intend to make any more 'next()' calls and can perform any cleanup actions. When built-in language features call 'return()'for cleanup, 'value' is always 'undefined'.
내장 언어 기능이 클린업을 위해 return()을 호출하는 경우가 있음을 알 수 있습니다. 즉 구조 분해 이후 return 메서드가 호출됩니다.
그러면 이터레이터 객체에 return 메서드를 정의하면 제너레이터로 만든 이터레이터처럼 구조 분해 이후 종료될 것 같습니다.
const mapV2 = (): IterableIterator<> => { // 이터레이터 객체 반환 return { // 메서드 생략 (next(), [Symbol.iterator]()) // return 메서드 정의 return () { if (typeof iterator.return === 'function') { return iterator.return() as IteratorResult<B>; } return { done: true, value: undefined, }; }, }
아쉽게도 제너레이터와 같은 결과를 얻지 못했습니다.
하지만 for...of와 이터레이터 객체 내부의 return 메서드 두 지점에 break point를 설정해보면 구조 분해 종료 시 return 메서드가 호출되는건 맞습니다.
종료되지 않았던 이유는 이유는 GeneratorState처럼 명시적으로 close를 할 수 없기 때문입니다. iterator.return 메서드를 호출했다고 해서 직접 만든 next() 객체의 순회를 막을 수 없기 때문에 그렇습니다.
🧩제너레이터와 동일하게 만들기
아래 코드는 파악한 원인이 근본 원인이 맞는지 확인하기 위한 용도입니다.
export const mapV2 = <A extends unknown, B extends unknown>( transform: (value: A) => B, iterable: Iterable<A>, ): IterableIterator<B> => { const iterator = iterable[Symbol.iterator](); // 추가된 부분: _closed 변수를 이용해 return 호출 이후 이터레이션 종료를 표현했습니다. let _closed = false; return { next(): IteratorResult<B> { // 추가된 부분: _closed 변수를 이용해 return 호출 이후 이터레이션 종료를 표현했습니다. if (_closed) { return { done: true, value: undefined }; } const { value, done } = iterator.next(); if (done) { return { value, done, }; } return { value: transform(value), done, }; }, [Symbol.iterator](): IterableIterator<B> { return this; }, return() { // 추가된 부분: _closed 변수를 이용해 return 호출 이후 이터레이션 종료를 표현했습니다. _closed = true; return { done: true, value: undefined, }; }, }; };
호출 결과 제너레이터 객체와 동일합니다.
즉, ECMAScript 스펙대로 이터레이터는 구조 분해 이후 cleanup을 진행하며 return 메서드를 호출합니다.
구조 분해 시점에 이터레이터가 종료된다는 것을 고려해 테스트를 수정하면 테스트가 무사히 통과합니다.
test('lazy map', () => { const result: number[] = []; const fxIterable = fx([1, 2, 3, 4, 5]).map(double); const [first, second] = fxIterable; fxIterable.forEach((number) => { result.push(number); }); expect(first).toBe(2); expect(second).toBe(4); expect(result).toEqual([]); });
(참고) AI는 항상 정답을 제시해주지 않습니다.
- AI답변
- 이 답변은 원인과 실행 결과 모두 틀렸습니다. (실제 실행: 제너레이터 이터레이터 forEach 0회, 직접 구현 이터레이터 forEach 3회)
- claude의 답변이지만 gpt 역시 같은 맥락이었습니다.
🧭 끝으로
- 구조 분해 할당 이후 이터레이터는 클린업을 위해 IteratorClose를 호출합니다.
- 이에 따라 제너레이터가 만든 이터레이터는 구조 분해 할당 이후 상태를 종료합니다.
- 직접 만든 객체는 ECMAScript에서 정의된 이터레이터의 조건 중 일부가 구현된 결과이고, 실제 제너레이터가 만드는 이터레이터는 더 복잡하게 기능합니다.
사수셨던 선배 개발자님께 ‘근본 원인을 파악하고 해결하기 위해 노력해라’라는 조언을 많이 받았습니다. 잘 지키지는 못하지만 항상 의식하려고 합니다.
함수형을 좋아한다고 하면서도 내부 스펙까지 들여다볼 생각은 안 했던 것 같습니다. 나름 재미있었습니다. ECMAScript 원문 읽기는 제 생각보다 어렵기도 하고, 또 생각보다는 읽을 만한 것 같기도 합니다.
댓글 목록