useEffect 없이 비동기로 가져오는 데이터를 사용하기

박기완·2023년 4월 25일
0
post-thumbnail

정석

React에서 데이터를 비동기로 가져올 때 useEffect를 사용한다.

function GoodOldWayToFetch() {
  const [message, setMessage] = useState<string>()

  useEffect(() => {
    getMessage()
      .then((message) => {
      setMessage(message)
    })
  }, [])

  if (message === undefined) {
    return <Loading />
  }
  return <Message message={message} />
}

이건 react.dev에서 useEffect를 쓰는 것이라고 단언하는 너무나 자연스러운 정석 코드이다. 하지만 이 방법은 useEffect를 사용하므로 첫 렌더링 이후에 API를 요청한다.

고민

요청을 좀 더 빨리할 수 없을까? 물론 클라이언트에서 렌더링하는 어플리케이션에서 데이터 조회 이전 상태도 UI 대응이 분명히 필요한 상태이지만, 만약 이전 상태가 정말로 아무 의미를 가지지 않는다면 초기 상태 UI를 대응하는 것이 불필요한 일 아닐까?

그리고 한 번 조회하고 말 데이터를 항상 useStateuseEffect 쌍으로 관리하는 게 장황하게도 느껴졌다. 어플리케이션의 설정 값을 리모트에서 관리한다면 변하지 않을 그 값을 위해 useStateuseEffect 쌍이 컴포넌트 안에 등장하는 게 오히려 어색하다. 거의 불변의 설정인데 이것이 "상태"이고 동기화해야 할 Side "Effect"가 발생하는가? 데이터를 조회하는 코드가 컴포넌트 외부에 있고 조회한 데이터를 컴포넌트의 prop으로 사용할 수는 없을까?

사실 이건 아주 오래된 고민이었다. 오랫동안 다음 코드 같은 구조를 만들어보고 싶었지만 더 자세한 구현이 생각이 나지 않아 진전이 없었다.

const promise = getMessage()

function PromisedMessage() {
  // promise를 저글링
}

그런데 며칠 전에 React의 Suspenselazy를 공부하면서 내 고민에 쓸만하다는 생각이 들었다.
다음은 lazySuspense로 컴포넌트 모듈을 동적으로 로드하는 예제이다.

const Lazified = lazy(() => import('./SomeComponent')) 

function Container() {
  return (
  	<Suspense fallback={<Loading />}>
      <Lazified />
    </Suspense>
  )
}

보통은 모듈을 동적으로 가져와서 해당 모듈에서 기본 내보내기한 컴포넌트를 동적으로 사용할 때 lazySuspense를 사용한다.

약...간은 Hacky한 느낌

하지만 lazy의 파라미터는 그냥 Promise를 반환하는 함수이다. 그럼 이 함수 안에서 일반적인 비동기 코드도 실행할 수 있는 것 아닐까?

const LazifiedMessage = lazy(async () => {
  const message = await getMessage()
  
  const Component = () => <Message message={message} />
  
  return { default: Component }
})

잘 되는 것 같다! 그럼 이제 이걸 추상화해서 쉽게 쓸 수 있게 바꿔보자.

function lazify<C extends FC<any>, P>(
  Component: C,
  getProps: () => Promise<P>
): LazyExoticComponent<C extends FC<infer OP> ? FC<Omit<OP, keyof P>> : never> {
  return lazy(async () => {
    const asyncProps = await getProps();

    const LazifiedComponent = ((props) => {
      const finalProps = {
        ...asyncProps,
        ...props,
      } as C extends FC<infer OP> ? OP : never;

      return <Component {...finalProps} />;
    }) as C extends FC<infer OP> ? FC<Omit<OP, keyof P>> : never;
    LazifiedComponent.displayName = `Lazified${Component.displayName}`;

    return { default: LazifiedComponent };
  });
}

lazify 함수는 lazy 함수와 마찬가지로 일종의 Higher Order Function (HOC)이다. 문서 상단에 붉은 안내 문구처럼 class 컴포넌트가 있던 시절에 많이 사용하던 방법론이다. 컴포넌트의 코드를 캡슐화할 때 새로운 컴포넌트를 만들어 반환하는 대신 컴포넌트 안에 함수 호출 하나를 추가하게 되면서 HOC는 많이 쓰이지 않게 되었다. 나도 React Hook을 쓰면서 HOC는 거의 작성하지 않게 되었다. 오히려 HOC를 만들면서 했던 경험을 함수를 만드는 함수(팩토리 함수? 정확한 용어는 모르겠다.)를 작성하는데 썼다.

lazify 함수는 컴포넌트와 그 컴포넌트가 받는 props 일부를 계산하는 비동기 함수, 두 개의 파라미터를 받는다. 이를 바탕으로 새로운 컴포넌트를 만들어주는데, 새로운 컴포넌트는 비동기 함수가 제공하지 않는 props를 가지는 컴포넌트이다. 다음 코드처럼 쓸 수 있다.

const LazifiedComponentWithRemoteConfig = lazified(Component, async () => {
  const [config1, config2] = await Promise.all([getConfig1(), getConfig2()])
  return { config1, config2 }
})

function Parent() {
  return (
  	<Suspense fallback={<Loading />}>
      <span>
        여기 있는 모든 내용은 설정 정보를 모두 가져와서
        Lazified 컴포넌트를 렌더링할 수 있게 되기 전까지 보이지 않습니다.
      </span>

      <LazifiedComponentWithRemoteConfig config3="foo" />
    </Suspense>
  )
}

다시 고민

이 방법을 모든 API 요청 함수에 사용할 필요는 없는 것 같다. API에서 가져온 데이터는 사용자가 UI와 상호작용하면서 다른 파라미터로 데이터를 요청하게 될 수도 있고, 최신 상태로 업데이트가 필요할 수도 있다. 그런 상황에서 lazify는 대응할 수 있는 방법이 없기 때문에 왠만하면 원래의 useEffect를 사용하는 것이 좋다. 정말로 동기화해야 할 상태 변화가 있는 거니까.
만약 한 번 가져오면 어플리케이션 주기동안 새로고침할 필요없는 정적인 정보이면서 어플리케이션 UI를 그리는데 중요한 정보라면 이를 적용해 볼 수 있겠다. 해당 정보가 없다면 컴포넌트를 그리는 것이 의미가 없고, 정보를 로드하는데 실패하면 해당 컴포넌트를 아예 사용할 수 없어야 하는 그런 데이터... 지금은 인증이나 필수 사용자 정보 같은 것이 떠오르는데 실제로 적용해 본 적은 없어서 판단하기 힘들다.

필요한 지점을 적고 보니 굉장히 Next.js의 getServerSideProps에서 가져오는 데이터의 특성과 비슷한 느낌인데...

데이터를 조회하는 코드가 컴포넌트 외부에 있고 조회한 데이터를 컴포넌트의 prop으로 사용할 수는 없을까?

애초에 했던 고민이 getServerSideProps의 작동 방식과 정확히 일치한다. 사실 내 고민이 그냥 Next.js를 사용하지 않아서 생기는 것이었을까? 🤔

참고

후속 조사

글을 다 쓰고 나서야 이런 시도가 없는지 검색했다.

0개의 댓글