(번역) 고급 수준의 자바스크립트 제너레이터에 대한 설명

TapK·2024년 9월 18일
32
post-thumbnail

원문: 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);
});

풀 스트림은 무엇인가요?

풀 스트림언제 데이터를 요청할지 직접 제어할 수 있는 방식입니다. 곧 자바스크립트에서 제너레이터 코드를 볼 때, 풀 스트림에 대한 코드 예시를 보게 될 것입니다. 하지만 먼저 다른 개념을 이해할 필요가 있습니다.

Lazy vs Eager

프로그래밍에서 데이터는 두 가지 기본 방식으로 처리될 수 있습니다. 즉시 처리(eagerly) 또는 지연 처리(lazily) 방식입니다.

즉시처리(Eager)

즉시 처리방식은 결과가 그 순간에 필요하지 않더라도 데이터를 즉시 평가하는 방식을 말합니다. 푸시 스트림은 그 특성상 즉시 처리 방식으로 동작합니다 (다른 예: 배열 메서드, 프로미스)

// 배열 메서드를 사용한 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]

물론, "그런데 프로미스는 왜 즉시 평가라고 하시는 건가요? 결과가 늦게 나오잖아요."라는 의문을 품을 수 있습니다.

자바스크립트의 프로미스는 여러 가지 이유로 즉시 평가를 나타낸다고 할 수 있습니다.

  1. 즉시 실행: 프로미스가 새롭게 생성될 때 전달된 함수(실행자 함수)는 프로미스가 구성되자마자 즉시 실행됩니다.
  2. 되돌릴 수 없는 작업: 일단 실행자 함수가 실행되기 시작하면, 호출한 코드에 의해 중지되거나 일시 중지될 수 없습니다. 함수가 수행하는 작업의 결과(성공 또는 거부)는 가능한 한 빨리 처리되도록 자바스크립트 이벤트 루프에 대기열로 추가됩니다.
  3. 지연 옵션 없음: 프로미스에는 값이 필요할 때까지 실행자의 실행을 연기하거나 취소하는 내장 메커니즘이 없습니다.
  4. 부작용: 프로미스의 즉시 처리 특성은 실행자 함수에 포함된 부작용(예: API 호출, 타임아웃 또는 I/O 작업)이 프로미스 생성과 함께 즉시 발생한다는 것을 의미합니다.

다음 예는 프로미스가 즉시 실행되는 방법을 보여줍니다.

// 프로미스와 배열 메서드를 사용한 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)

지연 처리방식은 값이 필요할 때만 평가된다는 의미입니다. 풀 스트림은 지연 처리됩니다.

동기식 예로는 피연산자 선택 연산자를 들 수 있습니다.

// 논리 연산자를 사용한 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

isDataProcessedfalse이므로 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() - .return() 메서드는 제너레이터의 실행을 종료하고 지정된 값을 반환하며 finally 블록도 실행되도록 트리거합니다.
  • .throw() - .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 }

더 많은 숫자를 생성하는 함수를 완전히 이해할 수 있도록 각 단계를 세분화해 보겠습니다.

StepCode LineConsole OutputExplanation
1const it2 = moreNumbers(40)x를 40으로 설정하여 제너레이터를 초기화합니다.
2console.log(it2.next());{ value: 42, done: false }제너레이터가 시작되어 x을 40으로 기록한 다음 42(x + 2)를 산출합니다.
3console.log(it2.next(2012));{ value: 2052, done: false }y를 2012로 다시 시작하여 y를 로그하고 2052(x + y)를 산출합니다.
4console.log(it2.next());{ value: undefined, done: true }다시 시작하고 zundefined(새 입력 없음)로 기록한 후 완료합니다.

제너레이터 사용 사례

제너레이터는 세 가지 주요 사용 사례가 있습니다.

  1. 지연 평가 - 필요에 따라 데이터를 생성하거나 대규모 또는 무한 데이터 집합을 처리합니다.
  2. 비동기 프로그래밍 - 비동기 작업을 처리합니다.
  3. 반복기 - 복잡한 흐름을 위해 단계 중간에 멈출 수 있습니다.

앞서 디스크에서 파일을 푸시 스트림으로 읽는 예제를 살펴보았습니다. 다음은 제너레이터를 사용하여 읽은 데이터를 풀 스트림으로 변환하기 위해 작성하는 방법입니다.

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)가 어디에 위치해 있는지 알 수 없기 때문입니다. 제네레이터는 풀 스트림이기 때문에, 필요한 시점에만 역할 값을 요청하여 가져올 수 있습니다.

이 영상이 마음에 드셨다면 제 유튜브 채널도 마음에 드실 겁니다. 여기에서 확인하세요!

profile
누구나 읽기 편한 글을 위해

1개의 댓글

comment-user-thumbnail
2024년 9월 24일

이게 실제 사례에 Effect가 없네요 쩝
https://effect.website/

이외에도 많은 라이브러리들에서 이터레이터를 다룰 때, 지연평가를 필연적으로 다룰 수 밖에 없어서 그런지 제너레이터가 자주 나오더라고요

답글 달기