Next.js 13 App Router Client Component에서 Loading Convnetion 사용하기

Next.js 13 App Router Client Component에서 Loading Convnetion 사용하기

Loading convention

Next.js 13 app router(또는 app dir)의 loading convention을 사용하려 했다. 그러나 MUI를 사용하기 위해서 page는 클라이언트 컴포넌트가 되어야만 했다.
공식 문서를 보니, 예제들은 전부 서버 컴포넌트를 기준으로 하는 것 같았다. 다행히 React Suspense 기반이라는 내용이 친절하게 잘 적혀있어서 이를 바탕으로 해결 방법을 모색해 보았다.

React Suspense

리액트 서스펜스는 <Suspense> 컴포넌트를 사용하여 해당 컴포넌트의 children들은 <Suspense> 컴포넌트의 부모 컴포넌트에 필요한 리소스가 로드되기 전까지 fallback 프롭에 지정된 ReactNode를 렌더링하게 해준다. 따라서 서스펜스 컴포넌트를 사용한다면 useEffect에서 이것 저것 리소스 로딩 로직을 잔뜩 짜고, 이를 useState를 사용해 핸들링하고, 이걸 또 렌더링 부분에서 조건문(주로 삼항 연산자)을 사용해 렌더할 필요 없이 선언적으로 코딩할 수 있다.

도대체 어떻게 리소스가 로딩중이란 걸 알 수 있나

<Suspense> 컴포넌트(이하 서스펜스 컴포넌트)의 children에서 throw된 내용을 기반으로 로딩중인지, 완료되었는지, 아니면 에러가 발생했는지 구분한다고 한다.

  • “pending” 상태인 Promise가 throw됐다면 로딩 중. fallback 프로퍼티의 ReactNode 렌더.
  • Error가 throw됐다면 일반적인 에러 핸들링 로직대로 처리. 즉 throw.
  • 둘 다 아니라면 로딩 완료로 간주, fallback 대신 children을 렌더.

Next.js 13 App Router의 Client Component에서 로딩 상태 구현

앞서 공식 문서에서 Next.js 13 App Rouer의 loading 컨벤션이 내부적으로 레이아웃 -> 로딩(서스펜스 컴포넌트) -> 페이지 컴포넌트 순서로 렌더링된다는 것을 알았으니, 페이지에서 적절하게 서스펜스 컴포넌트가 작동할 수 있도록 throw Promise를 해주면 될 것 같았다…만
언제나 그렇듯 이 기능을 이 페이지에서만 쓸 것이 아니기 때문에 적절히 모듈로 만들어 둘 필요가 있었다.

그러던 중 발견한 카카오엔터 기술블로그. useFetch<I, T> 훅을 만들어서 쓰고 있더라.

결론부터 말하자면, 당연히 위 방법으로 잘 작동한다. 기술 정보가 필요한 사람은 여기까지만 읽어도 상관없다.

useFetch<I, T> 훅의 이해

훅은 다음과 같이 생겼다.

export function useFetch<I, T>(fetch: (arg: I) => Promise<T>, arg: I) {  
	function resolvePromise(result: T) {  
		setStatus("fulfilled");  
		setResult(result);  
	}  
	  
	function rejectPromise(error: Error) {  
		setStatus("error");  
		setError(error);  
	}  
	  
	const [promise, setPromise] = useState<Promise<void>>();  
	const [status, setStatus] = useState<"pending" | "fulfilled" | "error">("pending");  
	const [result, setResult] = useState<T>();  
	const [error, setError] = useState<Error>();  
	  
	useEffect(() => {  
		setStatus("pending");  
		setPromise(fetch(arg).then(resolvePromise, rejectPromise));  
	}, [arg]);  
	  
	if (status === "pending" && promise) {  
		throw promise;  
	}  
	if (status === "error") {  
		throw error;  
	}  
	return result;  
}

처음 봤을 땐 굉장히 혼란스러웠다. 무엇보다 아래에서 선언된 setStatus, setResult 등을 위에서 사용할 수 있는 것도 낯설었다. 타입스크립트는 스코프 내에서라면 함수 선언시 함수보다 다음 줄에서 초기화된 변수도 사용할 수 있다는 걸 이 코드 덕분에 알았다.

아무튼, 쭉 살펴보니…

  1. Promise<T>를 반환하는 I 형식의 인자 1개를 가지는 대리자를 첫 번째 인자로, 그리고 그 대리자에 첫 번째 인수에 들어갈 I 형식의 값을 두 번째 인자로 받는 훅이다.
  2. 두 번째 인자(arg)가 변하면 status"pending"으로 변경하고 promise에 첫 번째 인자의 Promise<T> 대리자를 호출하고 그 결과에 따라 resolvePromise 또는 rejectPromise를 호출하는 Promise<void>를 할당한다.1
  3. 위 단계에서 비동기로 fetch 대리자가 돌게 되고, 시간이 걸리는 작업이 진행된다.
  4. fetch 대리자가 즉시 처리되더라도, 일단 현재 status"pending"이므로 throw promise가 호출되어 Promise { <state>: "pending }"이 throw되며, 이는 곧 서스펜스 컴포넌트가 fallback 프로퍼티의 리액트 노드를 렌더링하게 한다. 즉 로딩 컴포넌트가 보이게 된다.
  5. 이제 fetch 대리자의 처리 결과에 따라 분기된다.
    1. 성공적으로 처리됐을 경우, resolvePromise가 호출되어 status"fulfilled"로 변경되고 resultT 형식의 fetch 대리자의 resolve된 값으로 설정된다. 상태가 변경됐으므로 훅을 사용한 컴포넌트가 다시 렌더되며 훅 역시 다시 호출되고, result가 반환된다. 재 렌더링 과정에서 Promise { <state>: "pending" }이 throw되지 않았으므로 서스펜스 컴포넌트의 fallback 프로퍼티는 렌더링되지 않고, children이 렌더링되어 로딩이 완료된 페이지가 보이게 된다.
    2. 성공적으로 처리되지 않았을 경우, rejectPromise가 호출되어 status"error"로 변경되고 error가 설정되며, 상태 변경에 따라 훅을 사용한 컴포넌트가 다시 렌더링되며 if (status === "error") 조건에 걸려 상태에 저장된 error를 던진다.

사실 글을 쓰기 시작할 때까지만 해도 제대로 이해가 안 되었던 상태인데, 하나씩 정리하며 다시 보니 완벽히 이해하게 됐다. 이래서 다른 사람들 코드를 많이 봐야 하나보다.


  1. Promise.then(resolve, reject) 역시 비동기로 실행된다. 즉 Promise를 반환한다. ↩︎

댓글

이 블로그의 인기 게시물

C# 남아도는 메모리에도 불구하고 OutOfMemoryException이 발생한다면?

USB를 뒤는 괜찮은데 앞에 꽂으면 인식이 힘들다?

MySQL 데이터 타입과 Java 데이터 타입 비교/매칭