[Next.js] 공식 문서만 보고 Next.js 익히기(5)

찐새·2023년 12월 8일
3

공식 문서만 보고

목록 보기
9/9
post-thumbnail

공식 문서 보기를 돌같이 하는 버릇을 고치자!

11. Handling Errors

예기치 못한 에러 발생은 사용자에게 안 좋은 경험을 심어준다. 거기에 에러 스택까지 보여준다면? 사용자에게도, 개발자에게도 최악의 페이지가 될 것이다. Next.js에서는 파일로 에러를 핸들링하는 기능을 제공한다. 에러 처리가 필요한 폴더 하위에 error.tsx를 추가한다.

'use client';

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Optionally log the error to an error reporting service
    console.error(error);
  }, [error]);

  return (
    <main className="flex h-full flex-col items-center justify-center">
      <h2 className="text-center">Something went wrong!</h2>
      <button
        className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
        onClick={
          // Attempt to recover by trying to re-render the invoices route
          () => reset()
        }
      >
        Try again
      </button>
    </main>
  );
}

errorJS의 에러 객체이고, reset은 에러 경계로 되돌리는 함수이다. reset을 실행하면 에러가 발생한 라우트의 리렌더링을 시도하도록 유도할 수 있다.

error.tsx는 전체 에러를 핸들링하는 역할이라면, not-found.tsx는 404 error를 핸들링한다. not-found가 필요한 라우트 하위에 파일을 생성한다.

// app/dashboard/invoices/[id]/edit/not-found.tsx
import Link from 'next/link';
import { FaceFrownIcon } from '@heroicons/react/24/outline';

export default function NotFound() {
  return (
    <main className="flex h-full flex-col items-center justify-center gap-2">
      <FaceFrownIcon className="w-10 text-gray-400" />
      <h2 className="text-xl font-semibold">404 Not Found</h2>
      <p>Could not find the requested invoice.</p>
      <Link
        href="/dashboard/invoices"
        className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
      >
        Go Back
      </Link>
    </main>
  );
}

페이지에서는 notfound() 함수를 호출한다.

// app/dashboard/invoices/[id]/edit/page.tsx

import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
import { updateInvoice } from '@/app/lib/actions';
+ import { notFound } from 'next/navigation';

export default async function Page({ params }: { params: { id: string } }) {
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);

+  if (!invoice) {
+    notFound();
+  }

  // ...
}

12. Improving Accessibility

접근성은 장애인을 포함한 모든 사람이 사용할 수 있는 애플리케이션을 설계하고 구현하는 것을 말한다. 참고 web.dev - learn accessibility

12-1. Using the ESLint accessibility plugin in Next.js

Next.js는 기본적으로 eslint-plugin-jsx-a11y를 포함하고 있다. 이 플러그인은 aria-*, alt, role 등 접근성에 대한 이슈를 체크해준다. "lint": "next lint" 스크립트를 추가하여 검사할 수 있다. 예를 들어, 이미지의 alt가 없다면 검사했을 때의 경고 문구이다.

./app/ui/invoices/table.tsx
29:23  Warning: Image elements must have an alt prop, either with meaningful text, or an empty string for decorative images.  jsx-a11y/alt-text

12-2. Improving form accessibility

사용하고 있는 form에서는 접근성 향상을 위해 3가지를 이미 실천하고 있다.

  1. Semantic HTML : div 대신 input, option 등의 시멘틱 태그를 사용하여 사용자에게 양식을 탐색하고 이해하기 쉽게 만든다.
  2. Labelling : labelhtmlFor를 사용하면 사용자가 label를 클릭하여 해당 입력 필드에 집중할 수 있다.
  3. Focus Outline : 탭을 눌러 필드에 초점이 맞춰졌을 때 윤곽선이 표시되도록하여 키보드 및 화면 리더 사용자가 양식의 현재 위치를 이해하는 데 도움이 된다.

일반적인 접근성 설정을 했지만, form validation을 충족하지는 못한다. 현재 비어 있는 양식을 제출하면 에러가 발생하기 때문이다.

12-3. Form validation

클라이언트 측에서 간단한 대응은 inputrequired 속성을 추가하는 것이다.

<input
  id="amount"
  name="amount"
  type="number"
  placeholder="Enter USD amount"
  className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
+ required
/>

일반적으로는 괜찮지만, dev tool에서 해당 요소의 required 속성을 지우면 검증이 먹히지 않는다. Next.js에서는 서버 측 대안을 제시한다. 서버 측 검증에서는

  • 데이터를 데이터베이스로 보내기 전에 데이터가 예상되는 형식인지 확인한다.
  • 악의적인 사용자가 클라이언트 측 유효성 검사를 우회하는 위험을 줄일 수 있다.
  • 유효한 데이터로 간주되는 데이터에 대한 신뢰할 수 있는 단일 소스를 확보한다.

useFormState를 사용하여 서버 측 검증을 진행한다. 훅을 사용하므로 use client를 명시해 클라이언트 컴포넌트로 전환한다.

'use client';

// ...
import { useFormState } from 'react-dom';

useFormStateuseReducer와 유사한 실험적 기능이다. actioninitialState를 인자로 받고, statedispatch를 반환한다. <form action={}>dispatch를 주입한다.

// ...
import { useFormState } from 'react-dom';

export default function Form({ customers }: { customers: CustomerField[] }) {
  const initialState = { message: null, errors: {} };
  const [state, dispatch] = useFormState(createInvoice, initialState);

  return <form action={dispatch}>...</form>;
}

zod를 이용해 검증할 데이터 스키마를 추가한다.

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string({
    invalid_type_error: 'Please select a customer.',
  }),
  amount: z.coerce
    .number()
    .gt(0, { message: 'Please enter an amount greater than $0.' }),
  status: z.enum(['pending', 'paid'], {
    invalid_type_error: 'Please select an invoice status.',
  }),
  date: z.string(),
});

useFormState를 위해 createInvoiceprevState를 추가한다. 사용하지 않더라도 useFormState가 필수로 요구하기 때문이다.

// This is temporary until @types/react-dom is updated
export type State = {
  errors?: {
    customerId?: string[];
    amount?: string[];
    status?: string[];
  };
  message?: string | null;
};

export async function createInvoice(prevState: State, formData: FormData) {
  // ...
}

zodparse 대신 safeParse로 교체한다. safeParse는 성공 또는 실패 시의 객체를 반환하여 try/catch 구문에 넣지 않아도 유효성 검사를 처리할 수 있다.

export async function createInvoice(prevState: State, formData: FormData) {
  // Validate form fields using Zod
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });

  // If form validation fails, return errors early. Otherwise, continue.
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Create Invoice.',
    };
  }

  // ...
}

검증을 통과하지 못하면 useFormStatestate는 다음과 같은 에러 객체를 반환한다.

{
  errors: { customerId: Array(1), amount: Array(1), status: Array(1) },
  message: "Missing Fields. Failed to Create Invoice."
}

에러가 발생하면 화면에 표시하는 코드를 작성한다.

<form action={dispatch}>
  {/* ... */}
    <select
      id="customer"
      name="customerId"
      className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
      defaultValue=""
+     aria-describedby="customer-error"
    >
    {/* ... */}
    </select>
+   <div id="customer-error" aria-live="polite" aria-atomic="true">
+     {state.errors?.customerId &&
+       state.errors.customerId.map((error: string) => (
+         <p className="mt-2 text-sm text-red-500" key={error}>
+           {error}
+         </p>
+       ))}
+   </div>
    {/* ... */}
</form>

aria-describedby="customer-error"id="customer-error"와 관계를 형성해 오류가 발생하면 화면 판독기가 해당 설명을 읽게 된다. aria-live="polite"는 오류가 발생했음을 알릴 때 사용자를 방해하지 않고 유휴 상태일 때만 알린다.

13. Adding Authentication

Authentication(인증)은 웹앱의 핵심 중 하나로, 실제 유저와 시스템이 바라는 유저가 같은지 확인하는 단계이다. 인증의 방법에는 여러 가지가 있다. 아이디와 비밀번호 입력 후 인증 코드를 발급한다거나 Google이나 Naver 등의 서드 파티를 이용한다. 혹은 외부 앱을 이용한 2단계 인증(2FA)으로 보안을 강화할 수도 있다. 이러한 인증 방법은 로그인 정보에 접근하더라도 고유 토큰 없이는 액세스할 수 없도록 한다.

13-1. Authentication vs. Authorization

웹 개발에서 Authentication과 Authorization은 비슷하지만 서로 다른 역할을 하는 개념이다.

Authentication(인증)은 사용자가 자신이 말한 사람이 맞는지 확인하는 것이다.

Authorization(인가)은 사용자의 신원이 확인되면 애플리케이션의 어떤 부분에 대한 권한이 있는지 결정하는 것이다.

13-2. NextAuth.js

여기서 사용하는 인증 라이브러리는 NextAuth.js이다. 세션 관리, 로그인 및 로그아웃, 기타 인증과 관련된 복잡한 과정에 대한 솔루션을 제공한다. Next.js @14와 호환되는 버전을 npm install next-auth@beta로 설치한다.

다음으로 openssl rand -base64 32로 비밀키를 생성하고, .env에 추가한다.

AUTH_SECRET=your-secret-key

루트 폴더에 auth.config.ts 파일을 생성해 NextAuth에 대한 옵션을 추가한다.

import type { NextAuthConfig } from 'next-auth';

export const authConfig = {
  pages: {
    signIn: '/login',
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
      if (isOnDashboard) {
        if (isLoggedIn) return true;
        return false; // Redirect unauthenticated users to login page
      } else if (isLoggedIn) {
        return Response.redirect(new URL('/dashboard', nextUrl));
      }
      return true;
    },
  },
  providers: [], // Add providers with an empty array for now
} satisfies NextAuthConfig;

pages 옵션을 통해 로그인, 로그아웃, 오류 페이지 경로 등을 지정할 수 있다. signIn: '/login'을 추가하면 NextAuth의 기본 페이지가 아닌 커스텀 로그인 페이지로 리디렉션한다(고 한다. 뭔 소린지 모르겠다.).

callbacksNext.jsmiddleware를 통해 로그인 요청이 완료되기 전 권한이 있는지 확인하는 데 사용한다. auth는 유저의 session, request 속성 등이 들어있다.

providers는 다양한 로그인 옵션을 나열하는 배열이다. NextAuth의 구성을 충족하기 위해 일단 빈 배열로 설정되어 있다.

authConfig 객체를 가져올 middleware.ts 파일도 루트 폴더에 생성한다.

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';

export default NextAuth(authConfig).auth;

export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

NextAuth 객체를 초기화하고, matcher에 따라 middleware를 실행한다. 배열에 담긴 문자열은 api_next/static, _next/image, .png로 끝나는 경로를 제외하고 middleware를 실행한다는 의미이다. 이렇게 하면 미들웨어가 보호된 경로에서 인증이 확인될 때까지 렌더링을 하지 않아 성능 향상의 이점이 생긴다.

패스워드는 보통 생성할 때 해싱하여 저장한다. 이 프로젝트에서는 bcrypt 패키지를 사용하여 해싱 처리했는데, 문제는 Node API에 의존하는 패키지이기 때문에 미들웨어에서는 실행할 수 없다는 점이다. 이를 위해 authConfig 객체를 퍼트리는 auth.ts 파일을 새로 만든다.

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
});

NextAuthproviders 옵션을 추가해야 한다. providers는 Google 또는 GitHub와 같은 다양한 로그인 옵션을 나열하는 배열이다. 여기서는 Credentials provider만 사용한다.

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [Credentials({})],
});

zod를 사용하여 이메일과 비밀번호 유효성을 검사한 후 유저 정보를 확인하자.

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import type { User } from '@/app/lib/definitions';
import bcrypt from 'bcrypt';

async function getUser(email: string): Promise<User | undefined> {
  try {
    const user = await sql<User>`SELECT * FROM users WHERE email=${email}`;
    return user.rows[0];
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw new Error('Failed to fetch user.');
  }
}

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsedCredentials = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);
        if (parsedCredentials.success) {
          const { email, password } = parsedCredentials.data;
          const user = await getUser(email);
          if (!user) return null;

          const passwordsMatch = await bcrypt.compare(password, user.password);

          if (passwordsMatch) return user;
        }
        return null;
      },
    }),
  ],
});

로그인 action을 작성한 다음 form을 수정한다.

// lib/actions

import { signIn } from '@/auth';
import { AuthError } from 'next-auth';

// ...

export async function authenticate(
  prevState: string | undefined,
  formData: FormData,
) {
  try {
    await signIn('credentials', formData);
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case 'CredentialsSignin':
          return 'Invalid credentials.';
        default:
          return 'Something went wrong.';
      }
    }
    throw error;
  }
}
// login-form

'use client';

import { lusitana } from '@/app/ui/fonts';
import {
  AtSymbolIcon,
  KeyIcon,
  ExclamationCircleIcon,
} from '@heroicons/react/24/outline';
import { ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@/app/ui/button';
import { useFormState, useFormStatus } from 'react-dom';
import { authenticate } from '@/app/lib/actions';

export default function LoginForm() {
  const [errorMessage, dispatch] = useFormState(authenticate, undefined);

  return (
    <form action={dispatch} className="space-y-3">
      <div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
        <h1 className={`${lusitana.className} mb-3 text-2xl`}>
          Please log in to continue.
        </h1>
        <div className="w-full">{/* ... */}</div>
        <LoginButton />
        <div
          className="flex h-8 items-end space-x-1"
          aria-live="polite"
          aria-atomic="true"
        >
          {errorMessage && (
            <>
              <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
              <p className="text-sm text-red-500">{errorMessage}</p>
            </>
          )}
        </div>
      </div>
    </form>
  );
}

function LoginButton() {
  const { pending } = useFormStatus();

  return (
    <Button className="mt-4 w-full" aria-disabled={pending}>
      Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
    </Button>
  );
}

로그아웃 기능도 추가한다.

import Link from 'next/link';
import NavLinks from '@/app/ui/dashboard/nav-links';
import AcmeLogo from '@/app/ui/acme-logo';
import { PowerIcon } from '@heroicons/react/24/outline';
import { signOut } from '@/auth';

export default function SideNav() {
  return (
    <div className="flex h-full flex-col px-3 py-4 md:px-2">
      // ...
      <div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
        <NavLinks />
        <div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
        <form
          action={async () => {
            'use server';
            await signOut();
          }}
        >
          <button className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
            <PowerIcon className="w-6" />
            <div className="hidden md:block">Sign Out</div>
          </button>
        </form>
      </div>
    </div>
  );
}

use server를 최상단이 아닌 action 속성 내에 선언하는 게 신기했다. 이게 그 유명한 밈으로 탄생한 부분인 듯하다.

아무튼 다 작성하고 나니 위 authConfig에서 궁금했던 signIn: '/login'의 정체를 알았다. 이것을 설정하지 않으면 비로그인 유저가 인증이 필요한 페이지로 접근했을 때 리디렉션하지 않고 not-found를 실행한다. 설정했다면 자동으로 로그인 페이지로 연결한다.

14. Adding Metadata

SEO와 콘텐츠 공유에 관련해서 웹앱의 metadata는 매우 중요한 요소이다. 최종장에서는 metadata에 대해서 학습한다.

14-1. What is metadata?

metadata는 웹 페이지에 대한 추가 세부 정보를 제공한다. 사용자에게는 표시되지 않지만, <head> 태그 내에 포함되어 백그라운드에서 동작한다. 이는 검색 엔진 등이 웹 페이지를 더 잘 이해하도록 돕는다. SEO를 향상시키는 데 중요한 역할을 하며, 적절한 metadata는 검색 엔진에서 웹 페이지의 순위를 높인다.

Next.js가 제공하는 Metadata API에는 두 가지 방식이 있다. 하나는 Config-based로, 정적인 metadata 객체를 export하거나 동적으로 metadata를 생성하는 함수layout.tsxpage.tsx에서 export하는 방식이다. 다른 하나는 File-based로, favicon.ico, opengraph-image.jpg, robots.txt, sitemap.xml 등 특수한 목적의 이름을 가진 파일들을 사용한다. 두 방식을 사용하면 Next.js가 자동으로 적절한 <head> 요소를 생성한다.

예시로, /public 폴더에 favicon.icoopengraph-image.jpg 파일이 들어있다. 이것을 /app 폴더로 옮기면 자동으로 head에 추가된다.

layout.tsxpage.tsx 파일 내에 metadata 객체를 포함할 수도 있다.

// app/layout.tsx

import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Acme Dashboard',
  description: 'The official Next.js Course Dashboard, built with App Router.',
  metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
};

export default function RootLayout() {
  // ...
}

특정 페이지에 추가하여 해당 페이지의 정보만 담을 수 있다.

// dashboard/invoices
import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Invoices | Acme Dashboard',
};

export default async function Page() {}

하지만 이렇게 입력하면 문제가 하나 있다. 회사명과 같은 주요 정보가 바뀌었을 때 모든 페이지의 metadata를 찾아 수정해야 한다. 그렇게 하는 대신 title.template을 사용하여 기본값을 정하고 페이지마다 다른 title을 부여할 수 있다.

// app/layout.tsx

import { Metadata } from 'next';

export const metadata: Metadata = {
  title: {
    template: '%s | Acme Dashboard',
    default: 'Acme Dashboard',
  },
  description: 'The official Next.js Learn Dashboard built with App Router.',
  metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
};

// dashboard/invoices

export const metadata: Metadata = {
  title: 'Invoices',
};
profile
프론트엔드 개발자가 되고 싶다

1개의 댓글

와... 책보다 더 자세하게 써주셨네요. 저도 공식문서 보고 차근차근 해봐야 겠습니다. 제가 느끼기엔 nextjs의 러닝커브가 정말 역대급인 것 같아요...ㅜㅜ

답글 달기