Javascript Generator는 어디에 활용할 수 있을까?

2023년 4월 27일
GeneratorJavascriptTypescript

제너레이터(Generator)는 javascript 함수의 한 종류로서, 하나의 값을 반환하는 일반 함수와 달리 여러 값을 반환할 수 있습니다. 물론 여러 값을 한 번에 반환하는 것은 아니고 순차적으로 반환하는 것인데, 고유 문법에 대한 이해가 필요합니다.

일반 함수를 실행하면 return을 통해 값이 반환되지만, generator 함수를 실행하면 generator 객체가 반환됩니다. 순차적이기 때문에 이 객체는 이터러블(iterable)입니다.

기본 문법 소개

generator는 function* 이라는 문법으로 생성되며 화살표 함수로 구현할 수 없습니다. return도 사용 가능하지만 yield 라는 특수 반환 키워드를 주로 사용합니다.

function* infiniteStream(): Generator<number, never, unknown> {
  let i = 1
  while (true) {
    yield i++
  }
}

위 코드는 1부터 차례대로 1씩 증가하면서 값을 반환합니다. 일반적인 반복문에서 while (true) 문은 스택오버플로우가 되지만 generator는 한 번 실행 당 한 번만 값을 반환하기 때문에 에러가 나지 않습니다.

const stream: Generator<number, never, unknown> = infiniteStream()
for (let i = 0; i < 5; i++) {
  console.log(stream.next().value) // 1, 2, 3, 4, 5
  console.log(stream.next().done) // false, false, false, false, false
}

generator 함수를 실행하여 객체를 생성합니다. generator 객체에 내장된 메소드인 next() 를 실행하면 valuedone이라는 두 변수가 반환되는데요. value는 yield 뒤에 넣은 값, done은 완료 여부를 반환합니다. done의 경우 마지막 yield에 도달할 시에 true를 반환합니다.

next 메소드는 또한 하나의 인자를 받을 수 있습니다. 이를 통해 함수 내부로 값을 전달할 수 있는데요,

function* printFullName(): Generator<void, void, string> {
  const firstName = yield
  const lastName = yield
  console.log(`Full Name: ${firstName} ${lastName}`)
}
 
const generator = printFullName()
generator.next() // { done: false, value: undefined }
generator.next('John') // { done: false, value: undefined }
generator.next('Doe') // Full Name: John Doe, { done: true, value: undefined }

변수에 yield를 할당하면, next 메소드 안에 인자로 값을 전달 시 해당 변수로 값이 할당됩니다. 여기서 주의해야 할 점은 next()최초 실행 시에는 인자를 전달하면 안됩니다. 이후 실행부터는 인자를 전달하지 않을 시 undefined로 대체됩니다.

Typescript

추가적으로 Generator 타입에 대해 설명하자면 T는 yield 뒤에 들어갈 값, TReturn은 return 뒤에 들어갈 값, TNext는 next 메소드에 넣을 수 있는 인자의 타입을 정의할 수 있습니다.

interface Generator<T = unknown, TReturn = any, TNext = unknown>
  extends Iterator<T, TReturn, TNext> {
  // NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
  next(...args: [] | [TNext]): IteratorResult<T, TReturn>
  return(value: TReturn): IteratorResult<T, TReturn>
  throw(e: any): IteratorResult<T, TReturn>
  [Symbol.iterator](): Generator<T, TReturn, TNext>
}

활용 사례

이 외에도 여러 기능과 문법이 있지만 이번 글은 구체적인 활용 사례에 대해 얘기해보려고 합니다. 일반적으로 generator는 함수를 재호출하는 일반 함수와 다르게 생성된 객체의 메소드를 재실행하는 방식이기 때문에 메모리 관리에 용이하다는 이점이 있다는 것을 감안하고 보시면 더 좋을 것 같습니다.

1. 랜덤 데이터

무작위로 랜덤 데이터를 가져올 때 generator가 유용하게 작동합니다. 다음은 500px/300~600px 사이의 이미지를 가져오는 간단한 예제입니다.

import React, { useState } from 'react'
 
function* getRandomImage(): Generator<number, any, unknown> {
  while (true) {
    yield Math.floor(Math.random() * 300 + 300)
  }
}
 
const Component: React.FC = () => {
  const [height, setHeight] = useState<number>(300)
  const [isLoading, setIsLoading] = useState<boolean>(false)
  const generator = getRandomImage()
 
  return (
    <div>
      <img
        src={`https://picsum.photos/500/${height}`}
        alt="generator example"
        onLoad={() => setIsLoading(false)}
      />
      <button
        onClick={() => {
          setHeight(generator.next().value)
          setIsLoading(true)
        }}
        disabled={isLoading}
      >
        Random
      </button>
      {isLoading && 'loading...'}
    </div>
  )
}
 
export default Component

버튼을 누를 때마다 generator 함수가 300~600사이의 값을 반환하면서 이미지의 높이가 계속 달라지는 것을 확인하실 수 있습니다.

2. 무한 스크롤

무한 스크롤은 반복적으로 유사한 작업을 수행하기 때문에 쉽게 구현할 수 있으면서도 generator의 동작 원리를 이해하는 데 좋은 예제가 됩니다.

이를 React Typescript로 간단하게 구현해보겠습니다.

import React, { useEffect, useRef, useState } from 'react'
 
async function* fetchData(
  category?: string
): AsyncGenerator<any[], any, unknown> {
  let page = 1
  while (true) {
    const res = await fetch(
      `https://jsonplaceholder.typicode.com/posts?_page=${page}_limit=20`
    )
    const data = await res.json()
    yield data
    page++
  }
}
 
const Component: React.FC = () => {
  const [page, setPage] = useState<number>(1)
  const [category, setCategory] = useState<string>('')
  const [list, setList] = useState<any[]>([])
  const ref = useRef<HTMLDivElement>(null)
  const [entry, setEntry] = useState<IntersectionObserverEntry>()
  let generator = fetchData(category)
 
  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => setEntry(entry))
    observer.observe(ref.current)
    return () => observer.disconnect()
  }, [])
 
  useEffect(() => {
    if (entry?.isIntersecting) {
      generator.next().then((data) => {
        setPage(page + 1)
        setList([...list, ...data.value])
      })
    }
  }, [entry])
 
  useEffect(() => {
    setPage(1)
    setList([])
    generator = fetchData(category)
  }, [category])
  return (
    <>
      <select
        value={category}
        name="category"
        onChange={(e) => setCategory(e.target.value)}
        className="text-neutral-900"
      >
        <option value="" disabled>
          Select
        </option>
        <option value="One">One</option>
        <option value="Two">Two</option>
      </select>
      {list.map((item, i) => (
        <h1 className="text-4xl" key={i}>
          {item.title}
        </h1>
      ))}
      <div ref={ref} />
    </>
  )
}
 
export default Component

page 변수를 generator 함수 내부에 추가합니다. 컴포넌트 내에서 generator 객체를 생성하고, 스크롤을 내릴 때마다 실행되면서 page 값이 1씩 증가합니다.

만약 category와 같은 필터 값이 추가되어 변경될 때마다 다시 1페이지부터 가져오게 하려면, generator 객체를 다시 생성하면 됩니다.

무한 스크롤 외에도 페이지네이션 역시 비슷한 원리로 구현 가능하기 때문에 참고삼아 직접 해보시길 바랍니다.


© 2023 kidow. All rights reserved.
안녕하세요?