원문: https://www.reactsquad.io/blog/understanding-generators-in-javascript
제너레이터는 자바스크립트 생태계에서 강력하지만 잘 사용되지 않는 기능입니다. 대부분의 제너레이터 튜토리얼은 표면적인 부분만 다루고 있지만, 이 튜토리얼에서는 심층적으로 다루며 제너레이터의 이론에 대해 더 깊이 있게 알아볼 것입니다.
하지만 먼저 이 튜토리얼이 실제로 작동하는 모습을 보고 싶다면 아래 동영상 버전을 확인하세요.
제너레이터는 주로 saga에서 많이 사용되지만, 그 외에도 다양한 활용 사례가 있습니다. 이 글에서는 그중 몇 가지를 소개할 것입니다. "제너레이터가 무엇인가?"에 대해 대답하자면 자바스크립트에서 제너레이터는 풀 스트림(pull stream) 입니다. 이 정의를 좀 더 자세히 살펴본 후, 몇 가지 예시를 알아보겠습니다. 먼저 '풀(pull)'과 '스트림(steeam)'이라는 두 가지 용어를 이해하는 것이 중요합니다.
스트림은 시간이 지남에 따라 발생하는 데이터입니다. 스트림에는 푸시 스트림과 풀 스트림의 두 가지 유형이 있습니다.
푸시 스트림은 언제 데이터가 전달되는지 제어할 수 없는 방식입니다. 푸시 스트림의 예는 다음과 같습니다.
아래 예시에서는 Node.js에서 디스크에서 큰 파일을 읽는 예시를 통해 자바스크립트에서 푸시 스트림을 살펴보겠습니다.
const fs = require("fs");
const readStream = fs.createReadStream("./largeFile.txt");
readStream.on("data", (chunk) => {
console.log("data received", chunk.length);
});
readStream.on("end", () => {
console.log("finished reading file");
});
readStream.on("error", (error) => {
console.log("an error occured while reading the file", error);
});
풀 스트림은 언제 데이터를 요청할지 직접 제어할 수 있는 방식입니다. 곧 자바스크립트에서 제너레이터 코드를 볼 때, 풀 스트림에 대한 코드 예시를 보게 될 것입니다. 하지만 먼저 다른 개념을 이해할 필요가 있습니다.
프로그래밍에서 데이터는 두 가지 기본 방식으로 처리될 수 있습니다. 즉시 처리(eagerly) 또는 지연 처리(lazily) 방식입니다.
즉시 처리방식은 결과가 그 순간에 필요하지 않더라도 데이터를 즉시 평가하는 방식을 말합니다. 푸시 스트림은 그 특성상 즉시 처리 방식으로 동작합니다 (다른 예: 배열 메서드, 프로미스)
// 배열 메서드를 사용한 Eager 평가
const numbers = [1, 2, 3, 4, 5];
// map은 배열의 모든 요소를 즉시 처리합니다.
const squares = numbers.map((num) => {
console.log(`Squaring ${num}`);
return num * num;
});
console.log("squares:", squares); // [1, 4, 9, 16, 25]
물론, "그런데 프로미스는 왜 즉시 평가라고 하시는 건가요? 결과가 늦게 나오잖아요."라는 의문을 품을 수 있습니다.
자바스크립트의 프로미스는 여러 가지 이유로 즉시 평가를 나타낸다고 할 수 있습니다.
다음 예는 프로미스가 즉시 실행되는 방법을 보여줍니다.
// 프로미스와 배열 메서드를 사용한 eager 평가
console.log("Before promise");
let promise = new Promise((resolve, reject) => {
console.log("Inside promise executor");
resolve("Resolved data");
});
console.log("After promise");
promise.then((result) => {
console.log(result);
});
다음의 결과가 출력됩니다.
$ node eager-promise-example.js
Before promise
Inside promise executor
After promise
Resolved data
지연 처리방식은 값이 필요할 때만 평가된다는 의미입니다. 풀 스트림은 지연 처리됩니다.
동기식 예로는 피연산자 선택 연산자를 들 수 있습니다.
// 논리 연산자를 사용한 Lazy 평가
function processData(data) {
console.log(`Processing ${data}`); // 로그아웃되지 않습니다. 🚫
return data * data;
}
console.log("Lazy evaluation starts");
const data = 5;
const isDataProcessed = false;
// 논리 AND 연산자를 사용한 Lazy 평가.
const result = isDataProcessed && processData(data);
console.log("Result:", result); // false
이 코드를 실행하면 다음과 같은 출력을 확인할 수 있습니다.
$ node lazy-evaluation-example.js
Lazy evaluation starts
Result: false
isDataProcessed
가 false
이므로 processData
함수가 실행되지 않고 콘솔에 "Processing 5"가 표시되지 않습니다. 이는 표현식이 결과를 얻는 데 필요한 것만 평가한다는 것을 보여줍니다.
제너레이터는 자바스크립트에서 풀 스트림입니다. 즉, 실행을 일시 중지했다가 나중에 다시 시작할 수 있는 특수한 종류의 함수입니다.
제너레이터 객체는 제너레이터 함수에 의해 반환되며 이터러블 프로토콜과 이터레이터 프로토콜을 모두 준수합니다.
function* myGenerator() {
yield "Hire senior";
yield "React engineers";
yield "at ReactSquad.io";
}
const iterator = myGenerator();
// 제너레이터를 이터레이터로 사용합니다.
console.log(iterator.next()); // { done: false, value: "Hire senior" }
console.log(iterator.next()); // { done: false, value: "React engineers" }
console.log(iterator.next()); // { done: false, value: "at ReactSquad.io" }
console.log(iterator.next()); // { done: true, value: undefined }
// 제너레이터를 이터러블로 사용합니다.
for (let string of myGenerator()) {
console.log(number); // "Hire senior" "React engineers" "at ReactSquad.io"
}
.next()
메서드 외에도 제너레이터에는 .return()
및 .throw()
메서드도 있습니다.
.return()
메서드는 제너레이터의 실행을 종료하고 지정된 값을 반환하며 finally
블록도 실행되도록 트리거합니다..throw()
메서드는 마지막 yield
가 호출된 지점에서 에러를 던질 수 있게 해줍니다. 이 에러는 잡혀서 처리될 수 있으며, 또는 제네레이터가 finally
블록을 통해 정리할 수 있습니다. 만약 에러가 처리되지 않으면 제네레이터가 중지되고 완료로 표시됩니다.function* numberGenerator() {
try {
yield 1;
yield 2;
yield 3;
} finally {
console.log("Cleanup complete");
}
}
const generator = numberGenerator();
// 제너레이터를 정상적으로 사용
console.log(generator.next()); // { done: false, value: 1 }
console.log(generator.next()); // { done: false, value: 2 }
// return()을 사용해서 제너레이터를 일찍 완료
console.log(generator.return(10)); // { done: true, value: 10 }
// return() 이후에는 더 이상 값을 반환하지 않음
console.log(generator.next()); // { done: true, value: undefined }
// 예제에서 throw에 대한 제너레이터 재설정
const newGenerator = numberGenerator();
console.log(newGenerator.next()); // { done: false, value: 1 }
// throw()를 사용해서 오류 알림
try {
newGenerator.throw(new Error("Something went wrong"));
} catch (e) {
console.log(e.message); // "Something went wrong"
}
// throw() 이후 제너레이터 종료
console.log(newGenerator.next()); // { done: true, value: undefined }
인자를 사용해서 next()
를 호출할 때 숫자나 다른 값을 제너레이터에 전달할 수도 있습니다.
다음 예제에서 언제, 어떤 로그가 출력될 지 예측해 보세요.
function* moreNumbers(x) {
console.log("x", x);
const y = yield x + 2;
console.log(<"y", y);
const z = yield x + y;
console.log("z", z);
}
const it2 = moreNumbers(40);
console.log(it2.next());
console.log(it2.next(2012));
console.log(it2.next());
이 예제는 제너레이터 함수 moreNumbers
가 일련의 .next()
호출 중에 수신한 입력에 따라 값을 조작하고 산출하는 방법을 보여줍니다.
결과를 살펴보고 예측한 결과가 맞는지 확인해 보세요.
const it2 = moreNumbers(40);
// x: 40
console.log(it2.next()); // { value: 42, done: false }
// y: 2012
console.log(it2.next(2012)); // { value: 2052, done: false }
// z: undefined
console.log(it2.next()); // { value: undefined, done: true }
더 많은 숫자를 생성하는 함수를 완전히 이해할 수 있도록 각 단계를 세분화해 보겠습니다.
Step | Code Line | Console Output | Explanation |
---|---|---|---|
1 | const it2 = moreNumbers(40) | x 를 40으로 설정하여 제너레이터를 초기화합니다. | |
2 | console.log(it2.next()); | { value: 42, done: false } | 제너레이터가 시작되어 x 을 40으로 기록한 다음 42(x + 2 )를 산출합니다. |
3 | console.log(it2.next(2012)); | { value: 2052, done: false } | y 를 2012로 다시 시작하여 y 를 로그하고 2052(x + y )를 산출합니다. |
4 | console.log(it2.next()); | { value: undefined, done: true } | 다시 시작하고 z 를 undefined (새 입력 없음)로 기록한 후 완료합니다. |
제너레이터는 세 가지 주요 사용 사례가 있습니다.
앞서 디스크에서 파일을 푸시 스트림으로 읽는 예제를 살펴보았습니다. 다음은 제너레이터를 사용하여 읽은 데이터를 풀 스트림으로 변환하기 위해 작성하는 방법입니다.
const fs = require("fs");
function getChunkFromStream(stream) {
return new Promise((resolve, reject) => {
stream.once("data", (chunk) => {
stream.pause();
resolve(chunk);
});
stream.once("end", () => {
resolve(null);
});
stream.once("error", (err) => {
reject(err);
});
stream.resume();
});
}
async function* readFileChunkByChunk(filePath) {
const stream = fs.createReadStream(filePath);
let chunk;
while ((chunk = await getChunkFromStream(stream))) {
yield chunk;
}
}
const generator = readFileChunkByChunk("./largeFile.txt");
(async () => {
for await (const chunk of generator) {
console.log("data received", chunk.length);
}
})();
saga는 비동기 I/O 연산을 처리하는 대표 예시입니다. 하지만 향후 Redux에 대한 시리즈 기사에서 saga를 사용하는 방법을 배우게 될 것입니다.
그리고 일반적으로 값을 언제 가져올지 제어하고 싶을 때 제너레이터를 사용합니다.
이 테스트 예제를 살펴보시기 바랍니다.
test('온보딩된 오너 사용자의 경우: 초대 링크 생성 UI를 표시하고 조직의 멤버들을 보여주며, 사용자가 자신의 역할을 변경할 수 있게 한다.', async ({ page }) => {
// 조직 내 역할에 대한 제너레이터
function* roleGenerator() {
const allRoles = Object.values(ORGANIZATION_MEMBERSHIP_ROLES);
for (const role of allRoles) {
yield role;
}
}
const roleIterator = roleGenerator();
const data = await setup({
page,
role: ORGANIZATION_MEMBERSHIP_ROLES.OWNER,
numberOfOtherTeamMembers: allRoles.length,
});
const { organization, sortedUsers, user } = data;
// 팀원 설정 페이지로 이동
await page.goto(`/organizations/${organization.slug}/settings/team-members`);
// 각 팀원을 반복하여 제너레이터를 사용하여 역할을 할당
for (let index = 0; index < sortedUsers.length; index++) {
const memberListItem = page.getByRole('list', { name: /team members/i }).getByRole('listitem').nth(index);
const otherUser = sortedUsers[index];
// 현재 사용자를 제외한 각 팀원의 역할을 변경
if (otherUser.id !== user.id) {
await memberListItem.getByRole('button', { name: /member/i }).click();
const role = roleIterator.next().value!;
await page.getByRole('option', { name: role }).getByRole('button').click();
await page.keyboard.press('Escape');
}
}
await teardown(data);
});
이 테스트에서는 조직 내 사용자의 역할 목록을 순차적으로 제공하는 roleGenerator
를 정의합니다. 이 접근 방식을 사용하면 역할 관리 기능의 일부로, 미리 정의된 역할 목록에서 각 사용자에게 고유한 역할을 동적으로 할당하는 것을 테스트할 수 있습니다.
이 테스트 예시에서 배열 대신 제네레이터가 사용된 이유는 sortedUsers
배열 내에서 메인 사용자(즉, user.id)가 어디에 위치해 있는지 알 수 없기 때문입니다. 제네레이터는 풀 스트림이기 때문에, 필요한 시점에만 역할 값을 요청하여 가져올 수 있습니다.
이 영상이 마음에 드셨다면 제 유튜브 채널도 마음에 드실 겁니다. 여기에서 확인하세요!
이게 실제 사례에 Effect가 없네요 쩝
https://effect.website/
이외에도 많은 라이브러리들에서 이터레이터를 다룰 때, 지연평가를 필연적으로 다룰 수 밖에 없어서 그런지 제너레이터가 자주 나오더라고요