Next.js 오류 처리에서 흔히 겪는 문제와 해결 방법

Next.js 오류 처리에서 흔히 겪는 문제와 해결 방법

이번 글에서는 Next.js 애플리케이션을 개발하면서 개인적으로 겪었던 오류 처리 문제와 그에 대한 해결 방법을 소개하려고 합니다.

 

이 방법이 정답은 아니지만, 하나의 설계안으로 참고하시면 좋겠습니다.

환경 설정

  • Next.js v15.0.3
  • App Router 사용

1. Server Component에서 발생한 커스텀 에러가 Client Component의 Error Boundary에서 처리되지 않는 문제

잘못된 패턴 예시

최근 Next.js의 Server Component를 활용한 데이터 패칭 방식을 먼저 살펴보겠습니다.

const UsersContainer = async () => {
  const users = await getUsers();
  return <UsersPresentation users={users} />;
};

 

위 코드에서 getUsers 비동기 함수를 Server Component에서 실행하고, 그 결과를 Client Component인 UsersPresentation에 전달하고 있습니다.

getUsers 함수는 외부 API를 호출하여 users 데이터를 가져오며, API가 오류를 반환할 경우 HttpError라는 커스텀 에러를 발생시킵니다.

class HttpError extends Error {
  constructor(message, status) {
    super(message);
    this.status = status;
  }
}

const getUsers = async () => {
  const res = await fetch('https://example.com/users');

  if (!res.ok) {
    throw new HttpError('API Request Error', res.status);
  }

  const json = await res.json();
  return json;
};

 

UsersContainer에서 에러가 발생하면 app 디렉토리 하위에 위치한 error.tsx에서 이를 처리하게 됩니다.

error.tsx 파일은 자동으로 Error Boundary로 동작하며, 에러 발생 시 error.tsx의 내용을 대신 표시합니다.

 

예를 들어, UsersContainer에서 직접 에러를 catch하여 UsersPresentation으로 전달하고 에러를 표시할 수도 있지만, 이 글에서는 애플리케이션 전반에서 공통적으로 에러를 처리하기 위해 error.tsx를 사용하는 방식으로 설명하겠습니다.

 

'use client';

type Props = {
  error: Error;
};

export default function Error({ error }: Props) {
  if (error instanceof HttpError) {
    switch (error.status) {
      case 400:
        return 'Bad Request';
      case 401:
        return 'Unauthorized';
      default:
        return 'Unexpected';
    }
  }

  return 'Unexpected';
}

 

위 코드에서는 getUsers 함수가 발생시킨 HttpError의 상태에 따라 에러 메시지를 표시하도록 되어 있습니다.

 

하지만 실제로 실행하면, getUsers가 어떤 상태로 HttpError를 발생시키든 화면에는 "Unexpected"라는 문자열만 표시됩니다.

문제의 원인

기대와 다르게 동작하는 이유는, Server Component에서 발생한 커스텀 에러가 error.tsx로 전달될 때, 해당 에러는 기본 Error 클래스로 변환되기 때문입니다.

 

Server에서 발생한 에러가 Client로 전달될 때 경계가 존재하기 때문에, HttpError가 아닌 Error로 변경되는 것입니다. 따라서 if 조건문에 일치하지 않아 "Unexpected"가 출력됩니다.

 

error.tsx'use client'를 제거하고 Server Component로 처리하면 되지 않을까 생각할 수 있지만, error.tsx는 Client Component로 강제되기 때문에 그렇게 할 수 없습니다.


2. 프로덕션 모드에서 Server Component 에러의 내용이 Client Component에서 알 수 없는 문제

잘못된 패턴 예시

커스텀 에러를 발생시켜도 의미가 없으므로, 표준 Error 클래스의 메시지를 활용하는 방법을 시도해볼 수 있습니다.

const UsersContainer = async () => {
  try {
    const users = await getUsers();
    return <UsersPresentation users={users} />;
  } catch (error) {
    if (error instanceof HttpError) {
      throw new Error(`${error.status}/${error.message}`);
    }

    throw new Error(error.message);
  }
};

export default function Error({ error }: Props) {
  const [status, message] = error.message.split('/');

  switch (status) {
    case '400':
      return 'Bad Request';
    case '401':
      return 'Unauthorized';
    default:
      return 'Unexpected';
  }
}

 

위 코드는 UsersContainer에서 발생한 HttpError의 상태와 메시지를 표준 Error의 메시지에 포함시켜 다시 throw하는 방식입니다.

 

이 방식은 Next.js의 개발 모드에서는 정상적으로 동작하지만, next build && next start로 프로덕션 모드에서 실행하면 예상대로 동작하지 않습니다.

 

문제의 원인

프로덕션 모드에서 발생한 에러가 error.tsx로 전달될 때, 메시지는 다음과 같이 변환됩니다.

Error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.

 

이는 프로덕션 모드에서 Server 측의 에러 내용이 사용자에게 노출되지 않도록 Next.js가 에러 내용을 변환하기 때문입니다.


해결 방법

위 문제를 해결하기 위해 HttpErrorserialize, deserialize 메서드를 추가해보겠습니다.

 

이 메서드는 HttpError를 평범한 객체로 변환하거나, 다시 변환하는 역할을 합니다.

type SerializedHttpError = {
  message: string;
  status: number;
};

class HttpError extends Error {
  constructor(message, status) {
    super(message);
    this.status = status;
  }

  serialize() {
    return {
      message: this.message,
      status: this.status,
    };
  }

  static deserialize(data) {
    return new HttpError(data.message, data.status);
  }
}

 

UsersContainer에서 이 에러를 Client Component로 전달합니다.

const UsersContainer = async () => {
  try {
    const users = await getUsers();
    return <UsersPresentation users={users} />;
  } catch (error) {
    if (error instanceof HttpError) {
      return <ThrowHttpError serializedError={error.serialize()} />;
    }

    throw new Error(error.message);
  }
};

 

ThrowHttpError는 Client Component이며, 전달받은 에러를 다시 발생시킵니다.

'use client';

type Props = {
  serializedError: SerializedHttpError;
};

const ThrowHttpError = ({ serializedError }: Props) => {
  throw HttpError.deserialize(serializedError);
};

 

이를 통해 Server와 Client 경계를 넘지 않고 에러를 전달할 수 있으며, error.tsx에서 해당 에러를 그대로 처리할 수 있습니다.

export default function Error({ error }: Props) {
  if (error instanceof HttpError) {
    switch (error.status) {
      case 400:
        return 'Bad Request';
      case 401:
        return 'Unauthorized';
      default:
        return 'Unexpected';
    }
  }

  return 'Unexpected';
}

정리

Next.js의 오류 처리에서 자주 겪는 문제를 요약하면 다음과 같습니다:

  1. Server Component에서 발생한 커스텀 에러는 Client Component의 Error Boundary에서 기본 Error로 변환된다.
  2. 프로덕션 모드에서는 Server Component에서 발생한 에러의 내용이 Client Component에서는 알 수 없다.