이전 장에서는 오류(404 오류 포함)를 포착하고 사용자에게 대체 메시지를 표시하는 방법을 살펴보았다. 그러나 여전히 퍼즐의 또 다른 부분인 폼 유효성 검사에 대해 논의가 필요하다. 서버 액션으로 서버측 유효성 검사를 구현하는 방법과 접근성을 염두에 두고 useFormState 후크를 사용하여 폼 오류를 표시하는 방법을 살펴보겠다.
이번 장의 주제는 아래와 같다.
- 접근성 모범 사례를 구현하기 위해 Next.js와 함께 eslint-plugin-jsx-a11y를 사용하는 방법.
- 서버 측 폼 유효성 검사를 구현하는 방법.
- React useFormState 후크를 사용하여 폼 오류를 처리하고 사용자에게 표시하는 방법.
접근성이란?
접근성이란 장애가 있는 사용자를 포함하여 모든 사람이 사용할 수 있는 웹 애플리케이션을 설계하고 구현하는 것을 의미한다. 키보드 탐색, 의미론적 HTML, 이미지, 색상, 비디오 등과 같은 많은 영역을 다루는 광범위한 주제이다.
이 과정에서는 접근성에 대해 자세히 다루지는 않지만 Next.js에서 사용할 수 있는 접근성 기능과 애플리케이션의 접근성을 높이는 몇 가지 일반적인 방법에 대해 논의한다.
접근성에 대해 더 자세히 알아보려면 web.dev의 접근성 학습 과정을 추천한다.
Next.js에서 ESLint 접근성 플러그인 사용
기본적으로 Next.js에는 접근성 문제를 조기에 파악하는 데 도움이 되는 eslint-plugin-jsx-a11y 플러그인이 포함되어 있다. 예를 들어, 이 플러그인은 대체 텍스트가 없는 이미지가 있는 경우, aria-* 및 역할 속성을 잘못 사용하는 경우 등을 경고한다.
이것이 어떻게 작동하는지 보자!
package.json 파일에 다음 Lint를 스크립트로 추가한다.
/package.json
"scripts": {
"build": "next build",
"dev": "next dev",
"seed": "node -r dotenv/config ./scripts/seed.js",
"start": "next start",
"lint": "next lint"
},
그 다음 터미널에서 npm run lint 를 실행한다.
Terminal
npm run lint
다음과 같은 경고 문구를 볼 수 있다.
Terminal
✔ No ESLint warnings or errors
그런데 Alt 태그가 없는 이미지가 있으면 어떻게 될까? 알아 보자!
/app/ui/invoices/table.tsx로 이동하여 이미지에서 alt 태그를 제거하자. 편집기의 검색 기능을 사용하면 이미지 태그를 빠르게 찾을 수 있다.
/app/ui/invoices/table.tsx
<Image
src={invoice.image_url}
className="rounded-full"
width={28}
height={28}
alt={`${invoice.name}'s profile picture`} // Delete this line
/>
그 다음 npm run lint 를 다시 실행하면 아래와 같은 경고를 보게 된다.
Terminal
./app/ui/invoices/table.tsx
45:25 Warning: Image elements must have an alt prop,
either with meaningful text, or an empty string for decorative images. jsx-a11y/alt-text
Vercel에 애플리케이션을 배포하려고 하면 빌드 로그에도 경고가 표시된다. 이는 Lint가 빌드 프로세스의 일부로 실행되기 때문이다. 따라서 애플리케이션을 배포하기 전에 로컬에서 Lint를 실행하여 접근성 문제를 파악할 수 있다.
폼 접근성 개선
폼의 접근성을 개선하기 위해 이미 세 가지 작업을 수행하고 있다.
- 시맨틱 HTML: <div> 대신 시맨틱 엘리먼트(<input>, <option> 등)를 사용한다. 이를 통해 보조 기술(AT)은 입력 엘리먼트에 집중하고 사용자에게 적절한 상황 정보를 제공하여 폼을 더 쉽게 탐색하고 이해할 수 있다.
- 라벨링: <label> 및 htmlFor 속성을 포함하면 각 폼 필드에 설명 텍스트 라벨이 포함된다. 이는 컨텍스트를 제공하여 AT 지원을 향상시키고 사용자가 레이블을 클릭하여 해당 입력 필드에 집중할 수 있게 함으로써 유용성을 향상시킨다.
- 초점 윤곽선: 초점이 맞춰졌을 때 윤곽선을 표시하도록 필드의 스타일이 적절하게 지정되었다. 이는 페이지의 활성 엘리먼를 시각적으로 표시하여 키보드와 화면 판독기 사용자 모두가 폼의 위치를 이해하는 데 도움이 되므로 접근성에 매우 중요하다. 탭을 눌러 이를 확인할 수 있다.
이러한 방법은 많은 사용자가 폼에 더 쉽게 액세스할 수 있도록 하는 좋은 기반을 마련한다. 그러나 폼 유효성 검사 및 오류는 해결되지 않는다.
폼 유효성 검사
http://localhost:3000/dashboard/invoices/create로 이동하여 빈 폼을 제출해보면 어떤 일이 일어날까?.
오류가 발생했다! 이는 빈 폼 값을 서버 액으로 보내기 때문이다. 클라이언트나 서버에서 폼의 유효성을 검사하면 이를 방지할 수 있다.
클라이언트 측 검증
클라이언트에서 폼의 유효성을 검사할 수 있는 몇 가지 방법이 있다. 가장 간단한 방법은 폼의 <input> 및 <select> 엘리먼에 필수 속성을 추가하여 브라우저에서 제공하는 폼 유효성 검사에 의존하는 것이다. 예를 들어:
/app/ui/invoices/create-form.tsx
<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
/>
이제 빈 값이 포함된 폼을 제출하려고 하면 브라우저에 경고가 표시된다.
일부 AT는 브라우저 유효성 검사를 지원하므로 이 접근 방식은 일반적으로 괜찮다.
클라이언트 측 유효성 검사 대신 서버 측 유효성 검사로 대체 할 수 있다. 다음 섹션에서 어떻게 구현하는지 살펴보겠다. 지금은 필수 속성을 추가한 경우 해당 속성을 삭제하자.
서버측 검증
서버에서 폼의 유효성을 검사하여 다음을 수행할 수 있다.
- 데이터를 데이터베이스로 보내기 전에 데이터가 예상된 형식인지 확인한다.
- 악의적인 사용자가 클라이언트 측 유효성 검사를 우회하는 위험을 줄인다.
- 유효한 데이터로 간주되는 정보에 대한 하나의 진실 소스를 확보한다.
create-form.tsx 컴포넌트의 반응 돔에서 useFormState 후크를 가져온다. useFormState는 후크이므로 "use client" 지시어를 사용하여 폼을 클라이언트 컴포넌트로 전환해야 한다.
/app/ui/invoices/create-form.tsx
'use client';
// ...
import { useFormState } from 'react-dom';
폼 컴포넌트 내부에서 useFormState 후크는 다음과 같다.
- 두 개의 인수(action,initialState)를 사용한다.
- 두 가지 값을 반환한다: [상태, 디스패치] - 폼 상태 및 디스패치 함수(useReducer와 유사)
createInvoice 작업을 useFormState의 인수로 전달하고 <form action={}> 특성 내에서 디스패치를 호출다.
/app/ui/invoices/create-form.tsx
// ...
import { useFormState } from 'react-dom';
export default function Form({ customers }: { customers: CustomerField[] }) {
const [state, dispatch] = useFormState(createInvoice, initialState);
return <form action={dispatch}>...</form>;
}
initialState는 정의한 모든 것이 될 수 있다. 이 경우 메시지와 오류라는 두 개의 빈 키가 있는 객체를 만든다.
/app/ui/invoices/create-form.tsx
// ...
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>;
}
처음에는 혼란스러워 보일 수 있지만 서버 액을 업데이트하면 더 이해가 될 것이다. 지금 해보자.
action.ts 파일에서 Zod를 사용하여 폼 데이터의 유효성을 검사할 수 있다. 다음과 같이 FormSchema를 업데이트한다.
/app/lib/action.ts
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(),
});
- customerId - 유형 문자열을 예상하기 때문에 고객 필드가 비어 있으면 Zod는 이미 오류를 발생시킨다. 하지만 사용자가 고객을 선택하지 않으면 친근한 메시지가 나오도록 한다.
- 금액 - 금액 유형을 문자열에서 숫자로 강제 변환하므로 문자열이 비어 있으면 기본값은 0이다. Zod에게 .gt() 함수를 사용하여 항상 0보다 큰 양을 원한다고 말하도록 하자.
- 상태 - Zod는 "보류 중" 또는 "지불됨"을 예상하므로 상태 필드가 비어 있으면 이미 오류를 발생시킨다. 사용자가 상태를 선택하지 않은 경우에도 친근한 메시지가 나오도록 한다.
다음으로 두 매개변수를 허용하도록 createInvoice 작업을 업데이트한다.
/app/lib/actions.ts
// 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) {
// ...
}
- formData - 이전과 동일.
- prevState - useFormState 후크에서 전달된 상태를 포함한다. 이 작업에서는 이를 사용하지 않지만 필수 prop 이다.
그 다음 Zod parse() 함수를 safeParse()로 변경한다.
/app/lib/actions.ts
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'),
});
// ...
}
safeParse()는 성공 또는 오류 필드를 포함하는 객체를 반환한다. 이렇게 하면 이 논리를 try/catch 블록 안에 넣지 않고도 유효성 검사를 보다 원활하게 처리하는 데 도움이 된다.
데이터베이스에 정보를 보내기 전에 폼 필드가 조건부로 올바르게 검증되었는지 확인해보자.
/app/lib/actions.ts
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.',
};
}
// ...
}
validedFields가 성공하지 못하면 Zod의 오류 메시지와 함께 함수를 조기에 반환한다.
팁: console.logvalidedFields를 작성하고 빈 폼을 제출하여 그 모양을 확인하자.
마지막으로, try/catch 블록 외부에서 폼 유효성 검사를 별도로 처리하므로 데이터베이스 오류에 대해 특정 메시지를 반환할 수 있다. 최종 코드는 다음과 같다.
/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
// Validate form 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.',
};
}
// Prepare data for insertion into the database
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
// Insert data into the database
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
// If a database error occurs, return a more specific error.
return {
message: 'Database Error: Failed to Create Invoice.',
};
}
// Revalidate the cache for the invoices page and redirect the user.
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
이제 폼 컴포넌트에 오류를 표시해 보자. create-form.tsx 컴포넌트로 돌아가서 폼 상태를 사용하여 오류에 액세스할 수 있다.
각 특정 오류를 확인하는 삼항 연산자를 추가한다. 예를 들어 고객 필드 뒤에 다음을 추가할 수 있다.
/app/ui/invoices/create-form.tsx
<form action={dispatch}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
Choose customer
</label>
<div className="relative">
<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"
>
<option value="" disabled>
Select a customer
</option>
{customerNames.map((name) => (
<option key={name.id} value={name.id}>
{name.name}
</option>
))}
</select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
</div>
<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>
</div>
// ...
</div>
</form>
팁:
컴포넌트 내부의 console.log 상태를 확인하고 모든 것이 올바르게 연결되었는지 확인할 수 있다. 폼이 이제 클라이언트
컴포넌트이므로 개발자 도구에서 콘솔을 확인하자.
위 코드에서는 다음과 같은 aria 라벨도 추가한다.
- aria-describedby="customer-error": 이는 선택 엘리먼트와 오류 메시지 컨테이너 간의 관계를 설정한다. 이는 id="customer-error"인 컨테이너가 선택 엘리먼트를 설명함을 나타낸다. 화면 판독기는 사용자가 선택 상자와 상호 작용하여 오류를 알릴 때 이 설명을 읽는다.
- id="customer-error": 이 id 속성은 선택 입력에 대한 오류 메시지를 보유하는 HTML 엘리먼트를 고유하게 식별한다. 이는 aria-describedby가 관계를 설정하는 데 필요하다.
- aria-live="polite": 스크린 리더는 div 내부의 오류가 업데이트되면 정중하게 사용자에게 알려야 한다. 콘텐츠가 변경되면(예: 사용자가 오류를 수정하는 경우) 스크린 리더는 이러한 변경 사항을 알려 주지만, 방해하지 않도록 사용자가 유휴 상태일 때만 알리도록 한다.
연습문제: aria 라벨 추가하기
위의 예를 사용하여 나머지 폼 필드에 오류를 추가해보자. 또한 누락된 필드가 있는 경우 폼 하단에 메시지를 표시해야 힌다. UI는 다음과 같을 것이다.
준비가 되면 npm run lint를 실행하여 aria 레이블을 올바르게 사용하고 있는지 확인하자.
이 장에서 배운 지식을 활용하여 edit-form.tsx 컴포넌트에 폼 유효성 검사를 추가해보자.
다음을 수행해야 한다.
- edit-form.tsx 컴포넌트에 useFormState를 추가한다.
- Zod의 유효성 검사 오류를 처리하려면 updateInvoice 작업을 편집한다.
- 컴포넌트에 오류를 표시하고 aria 레이블을 추가하여 접근성을 향상시킨다.
준비가 되면 아래 코드 조각을 확장하여 해결 방법을 확인하자.
Invoice Form 편집:
/app/ui/invoices/edit-form.tsx
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const initialState = { message: null, errors: {} };
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
const [state, dispatch] = useFormState(updateInvoiceWithId, initialState);
return <form action={dispatch}></form>;
}
서버 액션:
/app/lib/actions.ts
export async function updateInvoice(
id: string,
prevState: State,
formData: FormData,
) {
const validatedFields = UpdateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Update Invoice.',
};
}
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
try {
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
} catch (error) {
return { message: 'Database Error: Failed to Update Invoice.' };
}
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
'Next.js 개발 가이드 > 06. Learn Next.js 공식 가이드' 카테고리의 다른 글
16. 메타데이터 추가 (0) | 2023.12.25 |
---|---|
15. 인증 (0) | 2023.12.25 |
13. 에러 처리 (0) | 2023.12.24 |
12. 데이터 변경 (0) | 2023.12.24 |
11. 검색, 페이지네이션 (0) | 2023.12.24 |