Next.js 프로젝트를 교육때 이후로 한 번도 안써봐서 일단 손이 가는 대로 프로젝트를 구현하고 있었다.
그렇다보니 이렇게 사용자에게 비동기 데이터 패칭해서 가져온 데이터를 보여주는데까지의 시간이 너무 길었다. 그래서 Next.js의 streaming을 이용해 사용자가 보여지기까지의 과정을 좀 더 효율적으로 바꿔보려했다.
Next.js에서 React의 Suspense를 이용하면 위에서 말한 과정의 4번을 크게 개선할 수 있다. 참고로 React 18 부터 서버 컴포넌트에도 Suspense가 적용됐다고 한다.
서버 데이터 패칭은 서버 UI가 그려지기 전에 이루어진다(프리 패칭). 그리고 Suspense를 이용하면 streaming을 이용해 비동기 데이터 패칭이 완료되기 전에 이미 완성된 UI를 사용자가 먼저 볼 수 있다. 그리고 비동기 데이터 패칭이 완료되면 Suspense의 Fallback대신 데이터 바인딩이 완료된 컴포넌트를 볼 수 있다.
따라서 플로우를 보면
1. 서버 사이드에서 프리 패칭을 하고, UI를 렌더링하고, 클라이언트로 보낸다
2. 클라이언트에서 HTML과 CSS를 이용해 UI를 그린다.
3. Hydration을 이용해 인터렉션이 가능케한다.
이렇게 세 단계로 줄고, 서버에서 프리 패칭한 데이터가 바인딩된 컴포넌트는 2번 이후에 아무때나 보여지게 된다.
따라서 클라이언트에서 useEffect()로 비동기 데이터 패칭을 하는 시간을 엄청나게 줄일 수 있게 된다.
import Expense from "./Expense";
import { ExpenseData } from "@/types/expense";
import { getExpensesByDate } from "@/apis/services/expense";
import { Suspense } from "react";
export async function ExpenseContainerServerComponent() {
const expenses = await getExpensesByDate();
return (
<div>
<ul className="flex flex-col gap-2">
{expenses.map((expense: ExpenseData) => (
<Expense key={expense.id} expense={expense} />
))}
</ul>
</div>
);
}
export default async function ExpenseContainer() {
return (
<Suspense fallback={<div>데이터를 불러오는 중 입니다...</div>}>
<ExpenseContainerServerComponent />
</Suspense>
);
}
error.js는 페이지 단위로 에러를 처리해주기 때문에 하나의 컴포넌트에서 에러 발생시 비효율적인 상황이 발생할 수 있다. 예를들어 비동기 데이터 패칭 컴포넌트에서 데이터 패칭이 실패했다고 가정하자. 그럼 해당 컴포넌트만 회복시켜주면 되는데 error.js는 페이지 전체에 영향을 준다. 따라서 개인적으로 에러 바운더리와 error.js 두개를 상황에 맞게 나누어 써야한다 생각한다.
"use client";
import { Component, ErrorInfo, ReactNode } from "react";
type Props = {
children: ReactNode;
onReset: () => void;
};
type State = {
hasError: boolean;
};
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.log(error.message, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false });
this.props.onReset();
};
render() {
if (this.state.hasError) {
return (
<div>
<h1>데이터를 불러오지 못했습니다...</h1>
<button onClick={this.handleReset}>Reset</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
에러 바운더리 코드를 보면 클라이언트에서 라이프 사이클을 이용하는 방식이기 때문에 'use client'가 필수다. 따라서 서버에서 에러 바운더리를 사용하지 못한다.
이것 저것 삽질하다가 한 문서를 찾았고, 나름 해석해서 구현을 해봤다.
https://github.com/reactjs/rfcs/blob/ba9bd5744cb922184ec9390515910cd104a30c6e/text/0215-server-errors-in-react-18.md
"use client";
import { Suspense } from "react";
import { ExpenseContainerOnServer } from "./ExpenseContainerOnServer";
export default function ExpenseContainerOnClient() {
return (
<Suspense fallback={<div>데이터를 불러오는 중 입니다...</div>}>
<ExpenseContainerOnServer />
</Suspense>
);
}
import { ExpenseData } from "@/types/expense";
import Expense from "./Expense";
import { getExpensesByDate } from "@/apis/services/expense";
import ExpenseContainerFetchErrorFallback from "./ExpenseContainerFetchErrorFallBack";
export async function ExpenseContainerOnServer() {
try {
const expenses = await getExpensesByDate();
throw new Error("에러 발생");
return (
<div>
<ul className="flex flex-col gap-2">
{expenses.map((expense: ExpenseData) => (
<Expense key={expense.id} expense={expense} />
))}
</ul>
</div>
);
} catch (error) {
return <ExpenseContainerFetchErrorFallback />;
}
}
import { getExpensesByDate } from "@/apis/services/expense";
import { ExpenseData } from "@/types/expense";
import { useState } from "react";
import Expense from "./Expense";
export default function ExpenseContainerFetchErrorFallback() {
const [expenses, setExpenses] = useState<ExpenseData[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(true);
const handleReset = () => {
setIsLoading(true);
setIsError(false);
getExpensesByDate()
.then((response) => {
if (response) {
setExpenses(response);
}
})
.catch(() => {
setIsError(true);
})
.finally(() => {
setIsLoading(false);
});
};
if (isError) {
return (
<div>
<div>데이터를 가져오지 못했습니다.</div>
<button onClick={handleReset}>재요청</button>
</div>
);
}
if (isLoading) {
return <div>데이터를 불러오는 중 입니다...</div>;
}
return (
<ul className="flex flex-col gap-2">
{expenses.map((expense: ExpenseData) => (
<Expense key={expense.id} expense={expense} />
))}
</ul>
);
}
https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming
https://github.com/reactjs/rfcs/blob/ba9bd5744cb922184ec9390515910cd104a30c6e/text/0215-server-errors-in-react-18.md