[React] use 훅이 바꿀 리액트 비동기 처리

intersoom·2023년 11월 15일
0

TSL

목록 보기
3/8
post-thumbnail

최근에 하루에 feconf 영상을 하나씩 정리해보다는 목표를 세우고 이행중이다.
그 첫 번째 영상은 해당 영상이다.

해당 영상의 내용 중 일부를 간략하게 정리해보겠다.

기본적인 비동기 함수 처리의 문제점

우리는 Data fetching을 위해서는 기본적으로 hook을 사용해야한다.
useEffect, useState를 사용해서 직접 구현할 수도 있겠지만, 더 편리하게 사용하기 위해서 useQuery 같은 라이브러리 사용한다.

더 나아가, 우리는 suspense를 사용함으로써 데이터가 로딩된 상태만 고려해도 되기까지 한다.

🤔 그렇다면, 문제점은 무엇일까?

😇 우리는 왜 fetch 함수를 바로 불러오지 못하고
😈 hook을 통해서 fetch 함수를 호출해야하는걸까?

답은 간단하다. fetch 함수는 async일 수 없다. 🥲

그렇다면, Promise 결과를 동기적으로 꺼낼 수 있는 함수가 있다면?!
우리가 원하는 바를 이룰 수 있을 것이다.
그것을 이루어지게 해줄 것이 🧙‍♀️ use 이다.

use란?

use는 React에서 새로 제공하고자 하는 기능이다.

기능

  • Promise의 일급 지원
  • async / await의 함수화
  • Suspense를 트리거 해주는 역할
    <Suspense fallback={<div>Loading...</div>}>
    	<MyApp /> // use(promise)
    </Suspense>
    MyApp이 렌더링 되는 과정에서 아직 resolve되지 않은 promiseuse를 하게 되면 Suspense fallback이 렌더링되는 형태

형태

function use<T>(promise: Promise<T>): T

특징

use의 가장 강력한 특징은 기존의 hook들과는 다르게 조건부로 호출이 가능하다는 점이다.

동작 원리

하지만,,,
해당 기능은 리액트에서 아직 구현체조차 정해지지 않은 기능이다 🥲

그렇다면, 이미 use와 같은 기능을 제공해주고 있는 다른 라이브러리들을 살펴보면 된다.

해당 코드는 jotai에서 use를 사용하고 있는 방식이다.

import React from 'react'

const use = React.use ||
(promise) => {
	if (promise.status === 'pending') {
		return promise
	} else if (promise.status === 'fulfilled') {
		return promise.value as T
	} else if (promise.status === 'rejected') {
		return promise.reason
	} else {
		// 생략 (Promise의 result를 읽기 위한 코드)
	}
}

해당 코드를 참고해서 리액트에서 공식적으로 해당 기능을 제공해주기 전까지 임의의 use를 만들어서 사용 가능하다.

예제를 통해 알아보는 use

기본적으로 작성된 코드를 보고
해당 코드의 문제점들을 파악한 후, 순차적으로 해결하는 방식으로 설명해주셨다.

구현 기능

먼저, 어떤 기능을 구현할 것인지 알아보자.
이는 유저의 인벤토리에서 아이템을 찾아주는 검색 기능이다.
그런데, 게임 클라이언트에서는 아이템을 코드로 표기하기 때문에 inventory를 불러오는 API의 응답에는 아이템이 코드로 표기 되어있다.
그래서 inventory의 아이템의 코드를 이름으로 변환해주는 기능이 추가적으로 구현되어야한다.

function useInventory({userId, search}) {
	const { inventory } = useUserInfo(userId);
	const normalItems = useNormalItems();
	const eventItems = useEventItems();

	return inventory
	.filter((item) => {
		if (!search) return true;
		if (Normal Item이면) normalItems에서 이름 체크;
		if (Event Item이면) eventItems에서 이름 체크;
	return false
	})
}

해당 코드에서 useUserInfo가 코드로 표기된 아이템들을 불러오는 hook이고, useNormalItems, useEventItems가 코드, 이름이 함께 작성되어있는 object를 불러오는 hook이다.

const useNormalItems = () => {
	return useQuery(fetchNormalItems);
}

기본적인 형태

function useInventory({userId, search}) {
	const { inventory } = useUserInfo(userId);
	const normalItems = useNormalItems();
	const eventItems = useEventItems();

	return inventory
	.filter((item) => {
		if (!search) return true;
		if (Normal Item이면) normalItems에서 이름 체크;
		if (Event Item이면) eventItems에서 이름 체크;
	return false
	})
}

use의 사용을 생각하지 않고 기본적으로 작성된 코드는 위와 같다.

문제점

하지만, Hook에는 다양한 제약이 존재한다.

그래서 발생하는 문제점들이 발생하는데, 이는 다음과 같다.
1. 불필요한 Blocking -> TTI 증가 -> UX 저하

TTI란?
(TTI: Time To Interactive)
유저가 인터랙션이 가능할 때까지의 시간을 의미한다.

  1. 코드 응집도 저하 → DX 저하

불필요한 Blocking

function useInventory({userId, search}) {
	const { inventory } = useUserInfo(userId);
	const normalItems = useNormalItems();
	const eventItems = useEventItems();

	return inventory
	.filter((item) => {
		if (!search) return true;
		if (Normal Item이면) normalItems에서 이름 체크;
		if (Event Item이면) eventItems에서 이름 체크;
	return false
	})
}

해당 코드에서 만약에 search의 값이 없는 경우(초기 상태)라면,

const normalItems = useNormalItems();
const eventItems = useEventItems();

해당 파트에서 Blocking이 발생하면서 TTI가 증가되었음에도 불구하고 불러온 값들이 전혀 쓰이지 않는다.
다시 말해, 불필요한 Blocking이 발생한다는 것이다.

코드 응집도 저하

function useInventory({userId, search}) {
	const { inventory } = useUserInfo(userId);
	const normalItems = useNormalItems(); // 로딩 
	const eventItems = useEventItems(); // 로딩

	return inventory
	.filter((item) => {
		if (!search) return true;
		if (Normal Item이면) normalItems에서 이름 체크; // 사용
		if (Event Item이면) eventItems에서 이름 체크; // 사용
	return false
	})
}

해당 코드를 보면, 로딩하는 파트사용하는 파트가 굉장히 떨어져있다. 이는 코드의 응집도가 굉장히 떨어진다.

더 나아가, 해당 코드는 예제를 위해서 제작된 코드라서 아이템 종류가 두 가지 밖에 없지만, 실제 현업에서 사용되는 코드에는 수백 종류의 리소스 + 수십 종류 페이지가 존재한다.

그렇게 된다면, 코드의 응집도는 4줄 정도 떨어져있는 것이 아니라, 100줄 이상 떨어져 있을 가능성도 존재하게 된다.

그렇다면, 이는 DX적으로 더욱이 심각한 문제가 될 것이다. 😡

종합

두 가지 문제점이 발생하는 원인을 살펴보면,
이는 Hook이 최상단에서 호출되어야한다는 제약 조건 때문에 발생하는 문제점이다.

해결

이를 🧙‍♀️ use 사용을 통해서 해결할 수 있다.

use도 훅인데, 이가 어떻게 가능할까?

Hook을 사용하지 못하는 곳

🔴 조건부, 반복문
🔴 return문 다음
🔴 이벤트 핸들러
🔴 클래스 컴포넌트
🔴 useMemo, useReducer, useEffect에 전달한 클로저

✅ use를 쓸 수 있는 곳

🟢 조건부, 반복문
🟢 return문 다음

이 두가지 차이점 덕분에 use를 활용하면, 실제로 리소스가 쓰이는 곳에서 use를 활용하여 데이터를 불러옴으로써 불필요한 Blocking, 응집도 저하 문제를 해결할 수 있다.

function useInventory({userId, search}) {
	const { inventory } = useUserInfo(userId);
	
	return inventory
	.filter((item) => {
		if (!search) return true;
		if (Normal Item이면) use(fetchNormalItems)에서 이름 체크; 
		if (Event Item이면) use(fethcEventItems)에서 이름 체크;
	return false
	})
}

결과

  • Top Level Hook 제거 → DX 향상
  • 필요한 순간 리소스 로딩 → UX 향상

use의 문제점

하지만, 이런 use에도 문제점은 존재한다.
그 문제들은 다음과 같다.

  1. 중복 fetch
  2. Request Waterfall

중복 fetch

function useInventory(...){
	...
	use(fetchNormalItems());
	...
}

🐛 위에서 작성한 코드의 버그는

resolve → 리렌더 → fetch → 🔄

해당 과정이 무한 반복된다는 것이다.

💡 이는 간단하게 해결할 수 있다.

cache 기능을 활용하면 된다.

const fetchNormalItems = cache(()=>{
	return fetch('res/normal-items');
})

하지만, 이 또한 아직 React에서 추가되지 않은 기능이기 때문에
로데쉬 라이브러리의 lodash.memoize를 활용해서 같은 기능을 구현해줄 수 있다.

Request Waterfall

Request Waterfall이란?
웹 페이지 로딩 과정에서 발생하는 여러 HTTP 요청들이 어떻게 발생하고 완료되는지를 설명하는 개념

엄밀하게 따지면, request waterfall 현상에서 발생하는 문제이지만, 불필요하게 순차적으로 로딩 되는 문제를 발표자님께서는 편의상 request waterfall 문제점이라고 언급하셨다.

다시 위의 예제로 돌아가서,

function useInventory({userId, search}) {
	const { inventory } = useUserInfo(userId);
	
	return inventory
	.filter((item) => {
		if (!search) return true;
		if (Normal Item이면) use(fetchNormalItems)에서 이름 체크; 
		if (Event Item이면) use(fethcEventItems)에서 이름 체크;
	return false
	})
}

✅ normal itemsevent items는 동시에 로딩되는게 이상적이고
❌ 순차적으로 로딩되는 것은 안 좋다.

하지만, 해당 코드는 순차적으로 로딩되도록 작성되어있다.

💡 이를 해결하기 위해서
보통의 Data Fetching Library에서는 Prefetch, Parellel Query 사용한다.

발표자님께서는 이 중 Prefetch를 통한 해결책을 설명해주셨다.

해결 방식은 간단하다. fetch를 사전에 한 번더 해주면된다.

function useInventory({userId, search}) {
	const { inventory } = useUserInfo(userId);
	fetchNormalItems(); fetchEventItems(); // 여기!

	return inventory
	.filter((item) => {
		if (!search) return true;
		if (Normal Item이면) use(fetchNormalItems)에서 이름 체크; 
		if (Event Item이면) use(fetchEventItems)에서 이름 체크; 
	return false
	})
}

해당 부분에서는 Blocking이 발생하지 않기 때문에, waterfall request 문제는 해결할 수 있다.

하지만,

앞서 우리가 use 사용을 통해서 해결했던 응집성 문제가 재발생한다.

이는 Dynamic Prefetch를 통해서 해결할 수 있다.
prefetch 대상을 런타임에 결정하고 불러오는 것이다.

const fetchNormalItems = () => {
	현재 페이지에서 normalItem 사용했다고 localStorage에 기록
	return fetch('/res/normal-items');
}
document.onready = () => {
	const names = localStorage에서 사용 기록 읽기
	names.forEach((XXX) => fetchXXX())
}

해당 코드처럼 페이지 접속하면 발생하는 이벤트 핸들러에 prefetch 적용함으로써 useInventory 코드에서는 fetch 코드를 한 번만 작성해도 되게 된다.

⛔️ 발표 내용과 관련 없는 부분 ⛔️
나는 Dynamic Prefetch 부분 관련해서 헷갈리는 부분이 몇 가지 있어서 정리해보았다.

  • 🤔 어느 시점에 localStorage에 기록되는 것인가?
    → 최초로 fetchNormalItems을 호출한 시점
  • 🤔 그렇다면, 최초의 호출 시점에서는 Prefetch가 안되는가?
    → 맞다! 이 문제를 해결하기 위해서는 initial 값을 초기에 세팅해주면 된다

해당 파트는 추측으로 작성된 부분이기 때문에 참고만 부탁드립니다!

이렇게 발표의 흐름에 따라서 use 훅이 비동기 처리를 어떻게 변화시킬지에 대해서 정리해보았다.

이번 발표를 보고 느낀 것은 이렇게 비동기 함수 처리를 할 수도 있겠구나 뿐만 아니라, 문제를 해결하기 위해서 발표자님께서 접근하신 부분도 배울 수 있었다.

예를 들어서,
use 훅 지원이 안되네? 그러면 비슷한 기능을 가진 다른 라이브러리를 참고해서 직접 구현해봐야지!와 같은 방식

또한, 이 포스트에서는 다루지 않지만, RFC와 리액트의 미래에 대한 설명도 뒤에 더 해주시기 때문에 영상을 한 번 보시는 것을 추천드립니다!

0개의 댓글