[Next.js App Router] 데이터 변경, 에러 핸들링, 웹 접근성 향상

이희령·2023년 11월 12일
0

[Chapter 12] Mutating Data

Server Actions란?

  • React Server Actions를 통해 서버에서 직접 비동기 코드를 실행할 수 있다.
  • Server Actions는 다양한 유형의 공격으로부터 데이터를 보호하고 권한 있는 액세스를 보장하는 등 효과적인 보안 솔루션을 제공한다.
  • Server Actions는 Next.js 캐싱 기능과도 깊이 연관되어 있는데, form이 Server Actions를 통해 제출되었을 때 데이터를 업데이트 할 뿐만 아니라 revalidatePath, revalidateTag 등의 API를 이용해서 관련된 캐시를 revalidate 할 수 있다.

form에서 Server Actions 사용하기

// Server Component
export default function Page() {
  // Action
  async function create(formData: FormData) {
    'use server';
 
    // Logic to mutate data...
  }
 
  // Invoke the action using the "action" attribute
  return <form action={create}>...</form>;
}
  • <form>action 속성을 추가함으로써 Server Actions를 호출할 수 있다.
  • action은 자동적으로 입력 데이터를 포함한 foramData 객체를 전달 받는다.
  • Server Actions를 서버 컴포넌트에서 호출하면 클라이언트에서 자바스크립트가 사용 불가능한 경우에도 form이 작동한다는 장점이 있다.

DB에 데이터 생성하기

1. server action 생성하기

// app/lib/actions.ts
export async function createInvoice(formData: FormData) {}

// app/ui/invoices/create-form.tsx
 <form action={createInvoice}>
  • lib 폴더에 actions.ts 파일을 생성한다.
  • 파일 최상단에 'use server' 명령어를 추가해서 해당 파일에서 export되는 모든 함수가 서버 함수로 작동하도록 한다.
  • actions.ts 파일에 createInvoice 함수를 생성해서 <form> 태그의 action 속성에 createInvoice 함수를 전달한다.

2. formData에서 데이터 추출하기

export async function createInvoice(formData: FormData) {
  const rawFormData = {
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  };
}
  • formData에서 데이터를 추출하기 위해서 다양한 메서드를 사용할 수 있는데 해당 예제에서는 get(name) 메서드를 사용해서 form에 입력한 데이터를 추출한다.

3. 데이터 유효성 검사하기

import { z } from 'zod';
 
const InvoiceSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});
 
const CreateInvoice = InvoiceSchema.omit({ id: true, date: true });
 
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
  // ...
}
  • 데이터를 DB에 전송하기 전에 데이터가 올바른 형식 및 타입으로 입력되었는지 확인해야 한다. 해당 예제에서는 유효성 검사 라이브러리인 zod를 사용해서 이를 수행한다.
  • zod를 사용해서 데이터 타입을 체크한 후 Schema와 일치하지 않을 경우 데이터 타입을 강제 변환한다.

4. DB에 데이터 삽입하기

import { sql } from '@vercel/postgres';

export async function createInvoice(formData: FormData) {
  // ...
  await sql`
    INSERT INTO invoices (customer_id, amount, status, date)
    VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
  `;
}
  • SQL 쿼리문을 작성해서 invoices DB에 form으로 입력 받은 데이터를 추가한다.

5. 데이터 갱신 및 리다이렉트

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createInvoice(formData: FormData) {
  // ...
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}
  • revalidatePath 함수를 이용해서 클라이언트의 캐시를 지우고 서버에 새로운 요청을 보내서 업데이트된 데이터를 새로 받아온다.
  • form 제출 후 데이터 생성에 성공하면 /dashboard/invoices 페이지로 리다이렉트한다.

DB의 데이터 업데이트하기

1. Dynamic Route Segment 생성하기

  • /invoices 폴더 안에 [id]라는 새로운 dynamic route와 edit 페이지를 생성한다. 해당 폴더 구조의 페이지의 경로는 /invoices/uuid/edit이 된다.

2. page params에서 invoice id 읽어오기

// app/dashboard/invoices/[id]/edit/page.tsx
export default async function Page({ params }: { params: { id: string } }) {
  const id = params.id;
  // ...
}
  • Page 컴포넌트는 searchParams 외에도 params를 props로 전달받는데, 이 params를 통해 id에 접근할 수 있다.

3. 상세 데이터 불러오기

export default async function Page({ params }: { params: { id: string } }) {
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);
  
  return (
    <Form invoice={invoice} customers={customers} />
    // ...
  )
}
  • /invoices/[id]/edit/page.tsx 파일에 Form 컴포넌트를 추가하는데 이 컴포넌트에서는 해당 invoice id를 가진 특정 데이터만 불러와서 form의 defaultValue를 미리 채워넣는다.
  • fetchInvoiceById() 함수에 params로 가져온 id를 전달해서 해당 invoice의 데이터를 불러온다.

4. server action에 id 전달하기

const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
 
return (
  <form action={updateInvoiceWithId}>
  // ...
);         
  • id를 argument로 전달하면 작동하지 않기 때문에 bind() 메서드를 이용해서 updateInvoice 함수에 id를 첫 번째 파라미터로 함께 전달한다.
  • 수정한 데이터를 제출하면 createInvoice action와 비슷한 로직으로 DB의 데이터가 업데이트된다.

[Chapter 13] Handling Errors

server actions에 try/catch 추가하기

export async function createInvoice(formData: FormData) {
  // ...
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
  } catch (error) {
    return {
      message: 'Database Error: Failed to Create Invoice.',
    };
  }
  // ...
}
  • server actions 함수에 try/catch를 추가해서 에러를 정상적으로 처리할 수 있도록 한다.

error.tsx로 모든 에러 핸들링하기

"use client" 

export default function Error({ error, reset,}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
   return (
     // ...
     // invoicse 페이지를 리렌더링하여 복구를 시도한다.
     <button onClick={() => reset()}>
        Try again
      </button>
     )
     // ...
}
  • error.tsx 파일을 사용해서 해당 페이지의 모든 에러를 catch해서 사용자에게 fallback UI를 보여줄 수 있다.
  • error.tsx 파일은 클라이언트 컴포넌트여야 한다.
  • error.tsx 파일은 두 개의 props를 받는다.
    1. error: 자바스크립트의 Error object 인스턴스다.
    2. reset: error boundary를 리셋하는 함수다. 이 함수를 실행하면 해당 페이지를 리렌더링한다.

notFound 함수로 404 에러 핸들링하기

import { notFound } from 'next/navigation';
 
export default async function Page({ params }: { params: { id: string } }) 
  // ...
  if (!invoice) {
    notFound();
  }
  // ...
}
  • error.tsx가 모든 에러를 핸들링 할 수 있다면, notFound는 DB에 존재하지 않는 리소스를 요청했을 때 사용할 수 있다.
  • 404 에러의 경우에 사용자에게 에러 UI를 보여주고 싶다면 해당 라우트의 폴더에 not-found.tsx 파일을 생성한다.
  • notFound()error.tsx보다 우선시되기 때문에 보다 구체적인 에러를 핸들링하고 싶을 때 사용할 수 있다.

[Chapter 14] Improving Accessibility

접근성(Accessibility)이란?

  • 접근성은 장애인을 포함한 모든 사람이 웹 애플리케이션을 사용할 수 있도록 설계하고 구현하는 것을 의미한다.
  • 키보드 내비게이션, 시맨틱 HTML, 이미지, 색상, 동영상 등 다양한 영역을 포함한다.

form 접근성을 향상시키는 방법

  1. 시맨틱 HTML: <div> 대신 <input>, <option> 등의 시맨틱 HTML 요소를 사용한다. 이를 통해 보조 기술(AT)이 입력 요소에 초점을 맞추고 사용자에게 적절한 상황 정보를 제공하여 form을 탐색하고 이해하기 쉽게 한다.
  2. 라벨링: 각 필드에 label이나 htmlFor 속성을 추가함으로써 필드에 상세 정보를 제공할 수 있다. 맥락을 제공함으로써 AT 지원 기능을 향상시키고, 사용자가 라벨을 통해 해당 input 필드를 클릭할 수 있도록 하여 사용성을 향상시킨다.
  3. 포커스 아웃라인: input 필드에 포커스가 갔을 때 스타일 요소를 적용한다. 이는 페이지의 활성 요소를 시각적으로 나타내기 때문에 접근성에 매우 중요하며, 키보드와 스크린 리더 사용자 모두 form의 어느 필드에 위치해 있는지를 이해할 수 있도록 도와준다.
  4. form 유효성 검사: form에 값을 입력하지 않았거나 형식에 맞지 않는 값을 입력했을 때 사용자들에게 에러를 보여줄 수 있다.

client side 유효성 검사

<input
  id="amount"
  name="amount"
  type="number"
  placeholder="Enter USD amount"
  required
/>
  • <input><select> 요소에 required 속성을 추가한다.
  • 값을 입력하지 않고 폼을 제출할 경우 브라우저에서 에러 메세지를 볼 수 있다.

server side 유효성 검사

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>;
}
  • react-dom에서 제공하는 useFormState 훅을 사용한다.
  • <form> 태그에 action 속성에 전달하는 함수를 dispatch로 변경한다.
  • InvoiceSchema에 에러 메세지를 추가하고 createInvoice 함수에 prevState 파라미터를 추가하는 등 관련 코드를 수정한다. 링크 참고

FormHelperText 보여주기

export default function FormHelperText({
  errors,
  fieldName,
}: FormHelperTextProps) {
  const isError = !!errors;

  if (!isError) {
    return null;
  }

  return (
    <div
      id={`${fieldName}-error`}
      aria-live="polite"
      className="mt-2 text-sm text-red-500"
    >
      {errors.map((error: string) => (
        <p key={error}>{error}</p>
      ))}
    </div>
  );
}

// 컴포넌트 사용할 때
<FormHelperText errors={state.errors?.amount} fieldName="amount" />
  • useFormState가 return 한 값인 state를 이용해서 필드 값을 입력하지 않았을 경우 에러 메세지를 보여준다.
  • 공식문서에는 없는 부분이지만 동일한 코드를 여러 번 재사용하기 때문에 FormHelperText 컴포넌트를 생성했다.
profile
Small Steps make a Big Difference.🚶🏻‍♀️

0개의 댓글