Next.js 서버 컴포넌트 렌더링 에러 재시도 해보기
2025.04.18
Next.js의 App Router에서는 서버에서 실행되는 컴포넌트, 즉 서버 컴포넌트를 사용할 수 있다. 서버 컴포넌트는 서버에서 실행되어 그 결과를 바탕으로 HTML을 생성하고, 클라이언트로 전달해 렌더링하는 방식이다. 이 구조는 초기 렌더링 속도에 유리하다는 장점이 있다. 그리고 서버 컴포넌트 안에서는 데이터를 요청하거나, 비동기 작업을 수행하는 것도 가능하다.
사이드 프로젝트에서 데이터 요청이 포함된 구간을 서버 컴포넌트로 리팩토링 하던 도중, 한 가지 궁금한 점이 생겼다. 서버 컴포넌트에서 요청에 실패했을 때에는 어떻게 처리를 해야할까?
서버 컴포넌트에서 요청이 실패할 경우에는 페이지가 정상적으로 렌더링되지 않는다. Next.js에서는 이런 상황을 대비해 error.tsx
를 제공하여, 페이지 렌더링 중 에러가 발생했을 때 보여줄 화면을 정의할 수 있도록 지원해준다. 또한, ErrorBoundary
를 사용해서 하위 컴포넌트의 에러를 잡아 fallback UI 를 보여줄 수 있다. 하지만 서버 컴포넌트를 다시 렌더링하는 방법은 별도로 제공하지 않는다.
그래서 렌더링에 실패한 서버 컴포넌트를 어떻게 핸들링하면 좋을지, 실패한 요청을 다시 시도하는 기능을 구헌할 수 있는지 고민했던 과정을 정리하려고 한다.
error.tsx와 ErrorBoundary
다음과 같은 컴포넌트가 있다. RandomNumber
는 숫자를 랜덤으로 받아와 Number
컴포넌트를 출력하는 서버 컴포넌트이다. Number
컴포넌트는 그냥 숫자를 받아 출력하는 단순한 컴포넌트다.
RandomNuber.tsx
import { getRandomNumber } from "@/utils/getRandomNumber";import Number from "./Number";export interface RandomNumberProps {className?: string;}export default async function RandomNumber({ className }: RandomNumberProps) {const number = await getRandomNumber();return <Number className={className} number={number} />;}
데이터 요청은 외부 요청 대신, 1.5초 뒤에 랜덤 숫자를 뽑아 반환하도록 하여 임시로 구현했다.
getRandomNumber.ts
async function getRandomNumber() {await new Promise<void>((res) => {setTimeout(() => res(), 1500);});const rand = Math.floor(Math.random() * 10);return rand;}
레이아웃을 다음과 같이 구성했다. 초록색 화면은 정적인 컨텐츠이고, 오른쪽 아래 테두리 영역은 데이터를 받아와 보여주는 영역이다.
page.tsx
export default async function Home() {return (<div><main className="grid grid-cols-2 gap-4 h-80 p-4"><div className="col-span-2 bg-green-400"></div><div className="col-span-1 bg-green-400"></div><div className="col-span-1 border-1"><RandomNumber className="w-full h-full flex justify-center items-center" /></div></main></div>);}
그리고 데이터 요청 실패를 구현하기 위해, 5 이상일 때에는 통과, 5 미만일 때에는 에러를 던져 실패로 처리하는 로직을 적용했다.
getRandomNumber.ts
export async function getRandomNumber() {...const rand = Math.floor(Math.random() * 10);if (rand > 4) return rand;else throw new Error("error");}
만약 요청에 실패해서 페이지에서 에러가 발생하면, Next.js의 기본 에러 화면이 출력된다.
이 때, 컴포넌트 내부에서 try/catch
구문을 사용하는 등으로 에러를 잡으면 좋겠지만, 예외 처리를 못한 경우가 있을 수 있다. 이 경우에는 error.tsx
를 작성하여, 해당 페이지에서 에러가 발생했을 때 보여줄 화면을 정의할 수 있다(공식 문서 - Error handling).
error.tsx
"use client";export default function Error({error,reset}: {error: Error & { digest?: string };reset: () => void;}) {return (<div><h2>Something went wrong!</h2><button onClick={() => reset()}>Try again</button></div>);}
파일 이름을 error.tsx
로 작성하고 내부에 컴포넌트를 작성한다. 이 컴포넌트는 클라이언트 컴포넌트여야 하기 때문에 use client
를 작성해주어야 하며, 에러의 내용과 리셋 함수를 제공하여 로깅, 재시도 등을 할 수 있다. 페이지 내부에서 캐치하지 못한 에러가 발생할 경우 Next.js가 해당 컴포넌트를 출력한다.
error.tsx
는 페이지 단위로 에러 메세지를 출력한다. 그런데 페이지가 아니라, 에러가 발생한 영역이나 컴포넌트 단위로 핸들링하고 싶을수도 있다. 이 때에는, 클래스 컴포넌트의 getDerivedStateFromError
와 componentDidCatch
메서드를 사용하면, 특정 범위의 에러 처리를 수행할 수 있다.
ErrorBoundary.tsx
"use client";import { Component, ErrorInfo } from "react";export interface ErrorBoundaryProps {children: React.ReactNode;fallback: React.ReactNode;}export interface ErrorBoundaryState {error: Error | undefined;}export default class ErrorBoundary extends Component<ErrorBoundaryProps,ErrorBoundaryState> {constructor(props: ErrorBoundaryProps) {super(props);this.state = {error: undefined};}static getDerivedStateFromError(error: Error) {return { error };}componentDidCatch(error: Error, errorInfo: ErrorInfo): void {console.error(error, errorInfo);}render() {if (this.state.error) {return this.props.fallback;}return this.props.children;}}
getDerivedStateFromError
와 componentDidCatch
는 하위 컴포넌트, 즉 자식 컴포넌트에서 에러가 발생했을 때 에러를 감지한다. getDerivedStateFromError
는 에러가 발생하여 컴포넌트의 상태를 변경할 때, componentDidCatch
는 로깅할 때 사용할 수 있다. 단, 자기 자신에게서 발생한 에러는 감지하지 못한다. 이 메서드들을 활용해서 자식 컴포넌트에서 발생한 에러를 잡을 수 있고, 필요할 경우 화면을 대체하여 보여줄 수 있다. 클래스 컴포넌트는 클라이언트 컴포넌트만 가능하므로, use client
를 명시해주어야 한다.
<ErrorBoundaryfallback={<div className="w-full h-full flex justify-center items-center bg-red-400">Error</div>}><RandomNumber className="w-full h-full flex justify-center items-center" /></ErrorBoundary>
해당 컴포넌트로 감지하고 싶은 영역을 감싸면, 이후 내부 트리에서 에러가 발생했을 때 이를 감지하고 화면을 대체하여 보여준다.
이렇게 error.tsx
와 ErrorBoundary
를 사용하면, 에러가 발생했을 때 페이지별, 혹은 컴포넌트 단위로 제어할 수 있게 된다.
하지만 각각의 한계도 있는데, error.tsx
는 에러가 발생한 페이지를 대체해버리고, reset
함수를 제공해주긴 하지만 에러가 발생한 곳이 서버 컴포넌트일 경우 reset
함수로는 해결되지 않을 수 있다. reset
함수는 에러 상태를 초기화해 컴포넌트를 다시 렌더링하게 유도하는 용도이지만, 서버 컴포넌트는 클라이언트에서 다시 렌더링되지 않기 때문이다.
그리고 ErrorBoundary
는 컴포넌트 단위로 에러 제어가 가능하지만 reset
함수를 제공해주지 않는다. 직접 구현한다고 하더라도, RandomNumber
는 서버 컴포넌트이기 때문에 ErrorBoundary
의 hasError
상태를 false
로 바꿨다가 true
로 돌려서 다시 출력한다고 해도, 이미 서버에서 렌더링된 결과를 보여주기 때문에 똑같은 에러 화면이 출력된다.
그렇다면 error.tsx
와 ErrorBoundary
의 기능을 결합해서, 서버 컴포넌트가 랜더링이 실패했을 때 해당 영역만 데이터를 다시 요청하고 렌더링 하는 방법은 없을까?
클라이언트 컴포넌트로 처리
요청에 실패했을 때 에러를 처리하고 다시 요청하는 방법으로 생각나는 가장 쉬운 것은, 요청을 클라이언트 컴포넌트로 변경해서 처리하는 방법이다.
ClientRandomNumber.tsx
"use client";import { useCallback, useEffect, useState } from "react";import { getRandomNumber } from "@/utils/getRandomNumber";import Number from "./Number";export interface ClientRandomNumberProps {className?: string;}export default function ClientRandomNumber({className}: ClientRandomNumberProps) {const [number, setNumber] = useState<number>();const [loading, setLoading] = useState(false);const [error, setError] = useState<Error>();const fetch = useCallback(async () => {try {setLoading(true);setError(undefined);const response = await getRandomNumber();setNumber(response);} catch (e) {setError(e as Error);} finally {setLoading(false);}}, []);useEffect(() => {fetch();}, [fetch]);if (loading) {return <div className={className}>Loading...</div>;}if (number === undefined || error) {return (<div className={`${className ?? ""} bg-red-400`}><button type="button" onClick={fetch}>Refresh</button></div>);}return <Number className={className} number={number} />;}
컴포넌트가 마운트됬을 때 데이터를 불러오도록 한다. 그동안 로딩 표현을 보여주고, 결과에 따라 결과 화면을 출력하거나, 에러가 발생했을 때에는 이를 처리하고 재요청 UI를 노출하도록 하는 것이다. useState
를 사용한다면 손쉽게 구현할 수 있다.
그런데 이건 클라이언트 컴포넌트를 사용하는 방식이고, 여기서 고민하던 서버 컴포넌트에서 에러가 발생했을 때 해당 부분만 다시 요청하고 렌더링 하는 방식과는 방향이 다르다.
물론 필요한 상황에서는 충분히 유용하고 사용 가능한 방법이라고 생각하지만, 내가 알아보고자 했던 것과는 거리가 있다.
컴포넌트 두 개 만들기
다음 접근 방법은, 바로 컴포넌트를 "두 개" 만드는 것이다.
동일한 요청을 하고 같은 결과물을 보여주는 컴포넌트를 서버 컴포넌트와 클라이언트 컴포넌트로 각각 작성해서, 서버 컴포넌트에서 먼저 데이터를 요청하고, 실패할 경우 클라이언트 컴포넌트를 렌더링 하는 방식이다.
ServerRandomNumber.tsx
import { getRandomNumber } from "@/utils/getRandomNumber";import ClientRandomNumber from "./ClientRandomNumber";import Number from "./Number";export interface ServerRandomNumberProps {className?: string;}export default async function ServerRandomNumber({className}: ServerRandomNumberProps) {let number: Awaited<ReturnType<typeof getRandomNumber>> | undefined;try {number = await getRandomNumber();} catch {return <ClientRandomNumber className={className} />;}return <Number className={className} number={number} />;}
먼저 ServerRandomNumber
컴포넌트에서 데이터를 요청한다. 요청이 정상적으로 실행됬을 경우 결과물을 출력한다. 그리고 요청에 실패하면, 이전에 작성했던 ClientRandomNumber
컴포넌트를 반환하여 클라이언트에서 처리하도록 한다.
이렇게 하면 최초에는 서버에서 요청을 시도하고, 실패했을 때 클라이언트 컴포넌트를 렌더링하여 재요청 로직을 수행하거나 로딩 표시 등을 클라이언트 쪽에서 처리할 수 있게 된다. 서버 컴포넌트 자체를 재랜더링 하는 대신, 동일한 작동을 하는 클라이언트 컴포넌트가 이를 대신 처리하도록 하는 방법이다.
원하는 기능을 어느 정도 구현할 수 있었지만, 한계도 있었다. 해당 패턴을 사용하기 위해서는 서버 컴포넌트와 동일한 기능을 가진 클라이언트 컴포넌트를 매번 따로 작성해야 한다. 현실적으로 데이터를 요청하는 컴포넌트가 한 두개가 아닐 것이기 때문에, 모든 영역에 적용하는 데에는 유지보수와 재사용 측면에서 상당히 번거로울 것 같다고 생각했다.
그렇다면 이걸 추상화해서, 간단하게 사용할 방법이 있을까?
패턴 추상화 해보기
결과부터 말하자면 성공하지 못했다. 서버 컴포넌트에서 클라이언트 컴포넌트로 속성을 넘기는 것에 대한 제약 사항이 존재했기 때문이다. 그러나 시도했던 흔적을 남기기 위해 기록했다.
매번 컴포넌트를 두 개 씩 작성할 수는 없을 것 같다. 그래서 데이터를 불러오는 로직과, 해당 데이터를 필요로 하는 컴포넌트를 전달해서, 위에서 작성했던 서버 컴포넌트와 클라이언트 컴포넌트를 추상화 하는것을 시도했다.
컴포넌트를 렌더링해서 전달하거나 컴포넌트 자체를 전달하는 것에는 조금 제약이 있다. 렌더링 하려고 하는 컴포넌트는 응답으로 받아온 데이터가 필요하기 때문에, 데이터 요청과 렌더링은 한 곳에서 이루어져야 한다. 그래야 컴포넌트에 데이터를 전달할 수 있기 때문이다. 그리고 컴포넌트에 요청에 대한 결과 외에, 다른 속성(className
등)을 할당해야 할 수도 있기 때문이다.
그래서 컴포넌트를 직접 전달하는것 보다, 데이터를 파라미터로 전달하는 renderProps
패턴을 사용해 보기로 했다.
ClientRender.tsx
"use client";import { useCallback, useState } from "react";export default function ClientRender<T>({fetcher,render}: {fetcher: () => Promise<T>;render: (data: T) => React.ReactNode;}) {const [data, setData] = useState<T | undefined>(undefined);const [error, setError] = useState<Error | undefined>(undefined);const [loading, setLoading] = useState(false);const fetch = useCallback(async () => {try {setLoading(true);setError(undefined);const response = await fetcher();setData(response);} catch (e) {setError(e as Error);} finally {setLoading(false);}}, [fetcher]);if (loading) {return (<div className="w-full h-full flex justify-center items-center">Loading...</div>);}if (!data || error) {return (<div className="w-full h-full flex justify-center items-center bg-red-400"><button type="button" onClick={fetch}>Refresh</button></div>);}return render(data);}
컴포넌트의 props
로 데이터를 받아오는 로직과, 해당 데이터를 기반으로 렌더링 할 render
함수를 받는다. 로딩 표현을 위한 상태와 데이터, 에러를 저장하는 상태를 선언하고, 데이터 패칭 함수를 선언한다. 그리고 데이터가 없거나 에러가 발생했을 때, 재요청 버튼을 노출시키고 재시도를 유도한다. 데이터를 정상적으로 불러오면, 전달받은 render
함수를 실행하도록 했다.
서버 컴포넌트 역시 동일한 방식으로 추상화했다.
ServerRender.tsx
import ClientRender from "./ClientRender";export default async function ServerRender<T>({fetcher,render}: {fetcher: () => Promise<T>;render: (data: T) => React.ReactNode;}) {let data: T | undefined = undefined;try {data = await fetcher();} catch {return <ClientRender fetcher={fetcher} render={render} />;}return render(data);}
전달받은 함수를 통해 데이터 요청을 하고, 성공했을 경우 render
함수를 실행하여 결과를 반환한다. 실패했다면 클라이언트 컴포넌트를 대신 반환하여 클라이언트에서 처리하도록 했다.
마지막으로, 원하는 곳에 ServerRender
컴포넌트를 위치시키고, 데이터 요청 함수와 렌더 함수를 전달했다.
page.tsx
import ServerRender from "@/components/ServerRender";import Number from "@/components/Number";import { getRandomNumber } from "@/utils/getRandomNumber";export default async function Home() {return (...<ServerRenderfetcher={getRandomNumber}render={(data) => (<NumberclassName="w-full h-full flex justify-center items-center"number={data}/>)}/>...);}
그리고 실행 결과는 바로..!
이 에러는 말 그대로, 서버 컴포넌트에서 정의된 함수를 클라이언트 컴포넌트로 직접 전달할 수 없다는 내용이다.
Next.js의 서버 컴포넌트는 브라우저가 아닌 서버에서 실행되며, HTML을 생성해서 클라이언트로 전달하는 구조이다. 서버와 클라이언트의 경계를 명확히 구분하기 때문에, 클라이언트 컴포넌트가 서버 컴포넌트로부터 props
를 전달받는 경우, Next.js는 그 데이터를 직렬화(serialization) 해서 브라우저로 전송하게 된다.
즉, 서버 컴포넌트에서 클라이언트 컴포넌트로 전달되는 props
는 반드시 serializable해야 한다. React 공식 문서 - Serializable arguments and return values에서 자세한 목록을 확인할 수 있으며, "Functions, including component functions or any other function that is not a Server Function" 문구에 나와있듯, 일반 함수와, 컴포넌트를 포함한 함수는 지원하지 않는다고 명시되어 있다.
그래서 여기서 시도해보려고 했던 fetcher
, render
함수를 전달하는 방식은 에러가 발생하며 작동하지 않았다.
클라이언트 컴포넌트로 모든 함수를 무조건 넘겨주지 못하는건 아닌데, 만약 함수가 Next.js의 서버 액션이라면 use server
디랙티브를 통해 해당 함수가 서버에서 실행될 함수임을 명시해주면 가능하다. 하지만 보통 form action이나 서버 함수를 클라이언트에서 호출하기 위한 용도로 사용되기 때문에, 여기서 사용하려는 구조처럼 클라이언트 컴포넌트로 함수를 props
로 넘겨 실행하려는 상황에서는 적합하지 않다.
결과적으로, renderProps
를 사용해서 추상화 하는 해당 방식은 불가능하다는 결론에 도달했다.
그러나 서버 컴포넌트와 클라이언트 컴포넌트를 조합하는 형태의 추상화는 실패했지만, ClientRender
처럼 재시도 로직을 컴포넌트 형태로 추상화 한 구조는 클라이언트 환경에서는 사용할 수 있을 것 같다.
물론, 상태와 로직만 재사용하려면 커스텀 훅으로도 충분히 구현할 수 있다.
function useApi<T>(fetcher: () => Promise<T>) {const [loading, setLoading] = useState(false);const [data, setData] = useState<T>();const [error, setError] = useState<Error>();const fetch = useCallback(async () => {try {setLoading(true);setError(undefined);const res = await fetcher();setData(res);} catch (e) {setError(e as Error);} finally {setLoading(false);}}, [fetcher]);return [fetch, data, loading, error] as const;}
하지만 로딩 표현과 재요청 버튼 등의 UI를 포함한 구조를 반복적으로 사용해야 하는 경우에는, ClientRender
처럼 컴포넌트 형태로 정리한 것을 사용하는게 더 편하고 실용적일 수 있다.
원하는 목표는 달성하지 못했지만, 재시도 패턴을 컴포넌트 형태로 정리해볼 수 있었다는 점에서 다른 방향에서의 성과가 있었던 작업이었다고 생각한다.
ErrorBoundary와 router.refresh
렌더링에 실패한 서버 컴포넌트를 다시 시도하는 로직을 구현할 수 없는지 알아보던 도중, Error handling and retry with React Server Components 글을 찾을 수 있었다. react-error-boundary
라이브러리의 ErrorBoundary
와 함께 router.refresh
메서드를 사용해서, 서버 컴포넌트를 리렌더링 하는 내용이다.
router.refresh
메서드는 이름 그대로 새로 고침을 하는 메서드로, Next.js의 useRouter
를 통해 router
객체에 접근할 수 있다. 비슷하게 새로 고침은 window.location.reload
로도 수행할 수 있는데, 두 메서드에는 중요한 차이점이 존재한다고 한다.
window.location.reload
는 브라우저에서 제공하는 기본 기능으로, HTML을 포함하여 전체 페이지를 강제로 다시 로드한다. 이 방식은 상태나 스크롤 위치 등이 초기화되고 화면이 깜빡일 수 있다.
반면, router.refresh
는 Next.js에서만 동작하며, 서버에 새 요청을 하고 데이터 요청을 다시 가져와 서버 컴포넌트를 다시 렌더링한다. 그 과정에서 클라이언트는 리액트나 브라우저의 상태를 잃지 않고 유지되며, 다시 받아온 서버 컴포넌트 페이로드를 병합하여 데이터만 갱신되는 방식이다.
그래서 위 글을 참고하여, 기존에 작성한 ErrorBoundary
와 router.refresh
를 사용하면, 원하는 기능을 구현할 수 있을거란 판단이 들었다.
먼저 기존 ErrorBoundary
컴포넌트를 수정했다. fallback
으로 React.ReactNode
대신 컴포넌트를 직접 받게 하여, 발생한 에러와 상태를 리셋하는 메서드를 전달하도록 했다(다시 말하지만, renderProps
는 불가능하다).
ErrorBoundary.tsx
"use client";import { Component, ErrorInfo } from "react";export interface ErrorBoundaryProps {...Fallback: React.ComponentType<{ reset: () => void; error: Error }>;}...export default class ErrorBoundary extends Component<ErrorBoundaryProps,ErrorBoundaryState> {constructor(props: ErrorBoundaryProps) {super(props);this.state = {error: undefined};this.reset = this.reset.bind(this);}...reset() {this.setState({ error: undefined });}render() {const { Fallback, children } = this.props;if (this.state.error) {return <Fallback reset={this.reset} error={this.state.error} />;}return children;}}
그리고, 에러 발생 시, 에러 메세지를 출력하고 새로 고침을 시도하는 Fallback
컴포넌트를 작성했다.
"use client";import { useRouter } from "next/navigation";import { startTransition, useState } from "react";export interface FallbackProps {reset: () => void;error: Error;}export default function Fallback({ reset, error }: FallbackProps) {const router = useRouter();const [refreshing, setRefreshing] = useState(false);function retry() {setRefreshing(true);startTransition(() => {router.refresh();reset();setRefreshing(false);});}return (<div className="w-full h-full flex justify-center items-center bg-red-400">{refreshing ? (<div>loading</div>) : (<button type="button" onClick={retry}>{error.message} Retry</button>)}</div>);}
버튼을 누르면 새로 고침 중임을 표현하고 router.refresh
를 실행해 새로 고침을 시도한다. 그리고 ErrorBoundary
의 에러를 초기화하고, 상태를 원래대로 되돌린다.
router.refresh
는 서버에 새 요청을 보내 서버 컴포넌트를 다시 가져오는 동작을 수행하는, 일종의 비동기 동작이다. 그러나 Promise
를 반환하지 않아 await
로 기다릴 수 없고, 가볍게 처리되는 동작도 아니다. 그래서 실행 시 사용자의 인터랙션을 막거나, UI가 일시적으로 멈춘 것 처럼 보일 수 있으며, 또한 reset()
과 setRefreshing(false)
가 먼저 실행될 수 있어 정상적으로 렌더링이 되지 않을 수 있다. 그래서 이를 방지하기 위해 startTransition
을 사용했다.
리액트 18부터 도입된 startTransition
은 트랜지션 내부의 작업을 우선순위를 낮춰 나중에 처리할 수 있도록 만든 API다. 데이터를 불러오거나 연산 비용이 큰 작업을 지연시키고 유휴시간에 처리하여, 복잡한 화면 갱신이나 서버 요청 같은 작업이 사용자 인터랙션을 방해하지 않도록 만든다고 한다. 그래서 router.refresh
를 통해 서버 컴포넌트를 새로 가져왔을 때, 그 후에 발생하는 리렌더링 작업이 내부에서 스케줄링된다. 또한, startTransition
안에서 하는 상태 업데이트 또한 우선순위가 낮은 작업으로 처리되어 늦게 반영된다. 그 결과 router.refresh
로 인한 화면 갱신이 끝나고 난 이후에 상태가 변경되어, 새로운 서버 컴포넌트가 자연스럽게 화면에 반영된다.
startTransition
안의 코드가 순차적으로 실행된다는 보장은 없다. 하지만 실제로startTransition
을 사용하지 않고 바로 수행했을 때에는, 새로 고침 되기 전에 상태가 변경되어 에러가 계속 발생하는 것을 확인할 수 있었다.
작업한 내용을 바탕으로, ErrorBoundary
로 RandomNumber
컴포넌트를 감싸고, Fallback
컴포넌트를 할당했다.
export default async function Home() {return (...<ErrorBoundary Fallback={Fallback}><RandomNumber className="w-full h-full flex justify-center items-center" /></ErrorBoundary>...);}
실행 결과, 에러가 발생했을 때 Fallback
컴포넌트가 뜨고, Refresh를 누르면 새로운 서버 컴포넌트를 받아와 출력하는 것을 확인했다.
이를 통해, 목표로 하던 서버 컴포넌트를 다시 렌더링하는 기능을 구현할 수 있었다.
하지만, router.refresh
는 클라이언트의 상태를 유지한다고 하더라도 결국 페이지를 새로 고침 하는 동작이다. 실행한 시점에 페이지 내에 존재하는 서버 컴포넌트들을 다시 렌더링하고, 서버에서 새로운 데이터를 받아와 반영하는 작업이 이루어진다. 따라서 새로 고침을 수행하는 순간, 해당 페이지 내에 존재하는 다른 서버 컴포넌트에도 영향이 갈 수 있다.
기존 컴포넌트 아래에 동일한 컴포넌트를 추가해보자.
<div className="col-span-1 border-1"><ErrorBoundary Fallback={Fallback}><RandomNumber className="w-full h-full flex justify-center items-center" /></ErrorBoundary></div><div className="col-span-2 border-1"><ErrorBoundary Fallback={Fallback}><RandomNumber className="w-full h-full flex justify-center items-center" /></ErrorBoundary></div>
하나는 요청에 실패하여 Retry 버튼이 노출되었고, 하나는 요청에 성공하여 숫자를 출력하고 있다.
이 때, 에러가 난 부분에 대해서 다시 시도하기 위해 Retry 버튼을 눌러 새로 고침을 수행했다. 무슨 일이 일어날까?
에러가 난 빨간색 컴포넌트를 통해 새로 고침을 진행했는데, 아래 컴포넌트의 숫자가 변경되었다. 페이지를 새로 고치는 과정에서, 페이지의 모든 서버 컴포넌트를 리렌더링하여 새로운 데이터를 반영한 것이다.
결국, 이 방식도 특정 컴포넌트만 선택적으로 갱신하는 것이 아니라, 페이지 전체의 서버 컴포넌트를 다시 불러오는 방식이라는 것이었다. 그래서, 컴포넌트 단위로 갱신하는 것과는 거리가 조금 있다.
마치며
Next.js의 서버 컴포넌트에서 에러가 발생했을 때, 단순히 에러를 보여주는 것만으론 부족하다고 느꼈다. 그래서 ‘그럼 다시 시도는 어떻게 하지?’라는 궁금증에서 출발하여, 여러 방법을 생각해보고 테스트 해본 내용을 정리해 보았다.
몇 가지 방법을 시도해봤는데, 클라이언트 컴포넌트는 상황에 따라서 선택할 수 있는 방법이었지만 원하는 방식은 아니었고, 기대했던 방식은 서버 컴포넌트 특성상의 제약때문에 구현할 수 없었다. 그나마 제일 원하던 방식에 근접한 것은 ErrorBoundary
와 router.refresh
를 조합하는 방식이었던 것 같다. 더 좋은 방법이 있을 것 같기도 하지만, 당장은 떠오르지 않는다. 아마 Next.js를 잘 사용하다 보면 깨달을 수 있지 않을까 싶다.
정답을 찾지는 못했지만 그래도 여러 가지 시도를 해볼 수 있었고, 덕분에 startTransition
을 추가로 공부하거나 window.location.reload
와 router.refresh
의 차이점을 정리하는 등, 좋은 경험이 되었다고 생각한다.
결국 중요한 건 “어떤 컴포넌트가 데이터를 책임질지”를 명확히 정하고, 실패했을 때 사용자 경험을 어떻게 설계할 것인가에 대해 고민하는 일이 아닐까 생각한다. 그것에 따라서 에러를 처리하는 방식이나 이후의 처리가 달라질 것이니 말이다.