0ju-log
💡 Conference

[Conference] FEConf 2023 - use 훅이 바꿀 리액트 비동기 처리의 미래 맛보기

⚙️ Promise 일급 지원 : use


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

promise를 파라미터로 받아 resolve된 값을 return하는 동기 함수

마치 await의 함수형처럼 기능을 함

// async function
const data = 
await
 promise;

// React Component / Hooks
const data = 
use
(promise);

use 훅이 suspense를 발동시키는 트리거의 역할을 수행

<Suspense fallback={<div>loading...</div>}>
	<MyApp /> // use(promise)
</Suspense>

왜 이름이 use일까?

use 훅이 가지고 있는 한 가지 특별함이 존재함 : 기존에 있던 훅들과는 달리 조건부로 호출이 가능

→ 훅의 사용으로 인한 여러 가지 문제점을 해결할 수 있을 것이라는 기대를 가짐

🖥️ Case Study


사례 - 유저 인벤토리 조회 : useInventory


react로 만든 admin tool에서 유저의 인벤토리 조회 가능

type UseInventoryArgs = {
	userId: string; // 유저의 아이디
	search?: string; // 검색 키워드
};

function useInventory(args: UseInventoryArgs)
function useInventory({ userId, search }) {
	const { inventory } = useUserInfo(userId);
	
	return inventory
		.filter ((item) => {
			if (!search) return true;
			return item.name.includes (search);
		})
}

useUserInfo 라는 커스텀 훅을 통해 유저의 전체정보를 받아옴

inventory를 filter를 통해 검색한 키워드에 해당하는 아이템 서치

problem 1 : item 안에 name 필드가 존재하지 않음

return inventory
		.filter ((item) => {
			if (!search) return true;

			if (Normal Item이면) 
normalItems
 에서 이름 체크;
			if (Event Item이면) 
eventItems
  에서 이름 체크;
			
return false;
		})

게임은 초기에 모든 리소스를 다운로드하는 방식으로 진행하지만, 이를 브라우저에게 적용시키기는 힘듦

  • 수십 MB의 거대한 사이즈 → 접속마다 다운로드하기 부담
  • 개발 환경에서 매 시간 업데이터 → 캐싱 효율성 X
  • 꾸준히 증가하는 데이터 총량 → 확장성

⇒ 각 resource를 따로따로 fetch하고, 이 데이터를 컴포넌트에서 사용하기 위해 hook을 추가로 정의

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

const fetchEventItems = /* 생략 */

// hooks


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

const useEventItems = /* 생략 */
function useInventory({ userId, search }) {
	const { inventory } = useUserInfo(userId);

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

	return inventory
		.filter ((item) => {
			if (!search) return true;
			return item.name.includes (search);
		})
}

Hook의 제약으로 인한 문제점


  • 불필요한 Blocking → TTI 증가 → UX ↓ TTI ? Time-to-Interactive, 사용자와 인터렉션이 가능할 때까지의 시간

    sol 1 : search keyword 사용 X, userId만을 가지고 조회하는 경우

    function useInventory({ userId, search }) {
    	const { inventory } = useUserInfo(userId);
    	const normalItems = useNormalItems();
    	const eventItems = useEventItems();
    		
    	return inventory.filter((...) => {
    			if (!search) return true;
    			if (Normal Item?) 
    normalItems
     에서 이름 체크;
    			if (Event Item?) 
    eventItems
      에서 이름 체크;
    			return false;
    		})
    }
    

    search 키워드를 넘기지 않음 → 커스텀 훅이 로딩되었으나 사용 X

  • 코드 응집도 저하 → DX ↓

    function useInventory({ userId, search }) {
    	const { inventory } = useUserInfo(userId);
    	const normalItems = useNormalItems();
    
    	const eventItems = useEventItems();
    
    		
    	return inventory.filter((...) => {
    			if (!search) return true;
    			if (Normal Item?) 
    normalItems
     에서 이름 체크;
    
    			if (Event Item?) 
    eventItems
      에서 이름 체크;
    
    			return false;
    		})
    }
    

    로딩을 하는 코드와 사용을 하는 코드의 위치가 꽤 멂

    하지만 이는 어쩔 수 없음 why ? hook은 항상 코드 최상단에서 호출해야 한다는 제약 조건 존재

use로 Hook의 제약 벗어나기


Hook을 쓰지 못하는 곳

🔴 조건문, 반복문

🔴 return 문 다음

🔴 이벤트 핸들러

🔴 클래스 컴포넌트

🔴 useMemo, useReducer, useEffect에 전달한 클로저

use는 쓸 수 있는 곳

🟢 조건문, 반복문

🟢 return 문 다음

🔴 이벤트 핸들러

🔴 클래스 컴포넌트

🔴 useMemo, useReducer, useEffect에 전달한 클로저

Before

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

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

After

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

			if (Event Item?) use(fetchEventItems())
  에서 이름 체크;
			return false;
		})
}

결과


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

🙋 그러면 이제 use만 쓰면 되나요?


❌ use는 low-level API 이며, Data Fetching 라이브러리는 더 많은 기능을 제공함 

😉 따로 해결했던 문제


중복 fetch → cache ✅


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

normal item을 fetching하는 promise가 resolve됨

→ 리렌더링 발생 → 다시 fetching → ♾️

solution. Cache

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

일종의 메모이제이션 방식

  • 동일한 promise 인스턴스를 리턴함
  • cache API는 현재 experimental, lodash.memoize로도 같은 기능 사용 O

Request waterfall → prefetch ✅


// function useInventory
const { inventory } = useKingdom(userId);

return inventory.filter((...) => {
	if (!search) return true;
	if (Normal?) use(fetchNormalItems()) 
에서 이름 체크;

	if (Event?) use(fetchEventItems())
  에서 이름 체크;

	return false;
});

↑ use 훅을 사용함으로써, fetchNormalItems이 호출될 때, blocking 발생 그 후에 fetchEventItems이 호출될 때, blocking 발생

그러나, 굳이 불필요하게 순차적으로 로딩될 필요 X ← request waterfall 현상

request waterfall 해결 방법 : Prefetch, Parallel Query (여기서는 prefetch 사용)

solution. Prefetch

// function useInventory

const { inventory } = useKingdom(userId);

fetchNormalItems(); fetchEventItems();


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

말 그대로 사전에 fetching을 해줌

여기서 use를 사용하지 않았기 때문에 blocking 발생 X

⇒ request waterfall 해결…했지만

new problem : 다시 저하된 응집성


위 코드에서 다시 로딩을 하는 코드와 사용하는 코드의 위치가 멀어짐 → 응집성 ↓

solution. Dynamic Prefetch : prefetch 대상을 런타임에 결정

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

// 페이지 접속 시 발생하는 이벤트 핸들러에서 prefetch를 발생시키는 코드 작성
document.onready = () => {
	const names = 1ocalstorage에서 사용 기록 읽기
	names.forEach((XXX) => fetchXXX());
}

어떤 리소스를 fetching 할 지는 런타임 때 정해짐 → 각 fetching 코드는 단 한 번만 작성해도 충분해짐

Before - promise 간접 사용

항상 hooks을 통해 간접적으로 사용해야 했음

After - 직접 사용

hook 정의 필요 없어짐

⇒ Promise 일급 지원

🥺 use의 제약


미래 : React component, hook 안에서만 use를 사용할 수 있음

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

use를 호출하고 있는 함수 : filter에 전달하는 Closure 함수임 ( ≠ component, hook)

제약을 지키지 않는다면? “컴파일러” 에러를 발생시킬 수 있다. 컴파일러 : 성능 최적화를 자동으로 해주는 React Forget Compiler

  • async / await는 문법 요소 → 컴파일러 기반 최적화 가능
  • async Server Component 최적화 개발 중

반면, use는 함수지만, React 내에서는 문법 요소처럼 역할 하도록 강제

⇒ use의 문법적 제약 = 컴파일러 최적화를 위한 대비

BUT, 현재 react는 DX / UX를 모두 향상할 수 있는 방향 추구 중 → use가 제약될 날은 아직 멀었다!

🖋️ summary


  • 클라이언트 컴포넌트 Promise 일급 지원
  • 활용 사례 : DX(응집도) / UX(blocking) 개선

🔗 참고

https://www.youtube.com/watch?v=Hd1JeePasuw