웹 접근성을 개선하여 모든 사용자가 쉽게 사용할 수 있는 애플리케이션을 만드는 방법을 알아보겠습니다.
💡 접근성(Accessibility)은 모든 사용자가 웹 애플리케이션을 쉽게 사용할 수 있도록 설계하는 것을 의미합니다.
웹 접근성은 단순히 장애가 있는 사용자만을 위한 것이 아닙니다. 다음과 같은 다양한 요소들을 포함합니다:
Next.js는 기본적으로 eslint-plugin-jsx-a11y
플러그인을 포함하여 접근성 문제를 조기에 발견할 수 있게 도와줍니다.
1단계: package.json에 스크립트 추가
{
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start",
"seed": "node -r dotenv/config ./scripts/seed.js",
"lint": "next lint" // 👈 이 라인 추가
}
}
2단계: ESLint 실행
# pnpm 사용시
pnpm lint
# npm 사용시
npm run lint
⚠️ 주의사항: ESLint v9에서 오류가 발생하면 v8.57.0으로 다운그레이드하세요.
npm uninstall eslint npm install -D eslint@8.57.0
ESLint 설치 후 접근성 오류를 테스트해보겠습니다.
다음 코드에서 alt
속성을 제거하고 테스트해보세요:
// /app/ui/invoices/table.tsx
<Image
src={invoice.image_url}
className="mr-2 rounded-full"
width={28}
height={28}
// alt={`${invoice.name}'s profile picture`} 👈 이 라인을 주석처리
/>
현재 프로젝트에서는 이미 Form 접근성을 위해 다음 세 가지 요소를 구현하고 있습니다:
// ✅ 좋은 예: 시맨틱 요소 사용
<input type="email" />
<select>
<option>옵션1</option>
</select>
// ❌ 나쁜 예: 비시맨틱 요소 사용
<div onClick={handleClick}>클릭</div>
<label htmlFor="email" className="block text-sm font-medium">
이메일 주소
</label>
<input
id="email"
name="email"
type="email"
className="mt-1 block w-full rounded-md border-gray-300"
/>
/* 포커스 시 아웃라인 표시 */
.form-input:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
💡 Tip: Tab 키를 눌러 포커스 이동을 테스트해보세요!
Form 검증은 사용자 경험과 데이터 무결성을 위해 필수적입니다.
가장 간단한 방법은 HTML5의 기본 검증을 사용하는 것입니다:
<input
id="amount"
name="amount"
type="number"
step="0.01"
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 // 👈 필수 속성 추가
/>
서버 사이드 검증의 장점:
React의 useActionState
Hook을 사용하여 폼 상태를 관리합니다:
import { useActionState } from "react";
// useActionState 사용법 예시
async function increment(previousState, formData) {
return previousState + 1;
}
function StatefulForm() {
const [state, formAction] = useActionState(increment, 0);
return (
<form>
{state}
<button formAction={formAction}>Increment</button>
</form>
);
}
매개변수 | 타입 | 설명 |
---|---|---|
fn | Function | 폼 제출 시 호출되는 함수 |
initialState | Any | 초기 상태값 |
permalink | String (선택사항) | JavaScript 번들 로드 전 폼 제출 시 이동할 URL |
반환값 | 설명 |
---|---|
state | 현재 상태 (첫 렌더링 시 initialState와 동일) |
formAction | Form의 action 속성에 전달할 액션 |
"use client";
import { useActionState } from "react";
import { createInvoice, State } from "@/app/lib/actions";
export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState: State = { message: null, errors: {} };
const [state, formAction] = useActionState(createInvoice, initialState);
return (
<form action={formAction}>
{/* 폼 내용 */}
</form>
);
}
// /app/lib/actions.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(),
});
export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
export async function createInvoice(prevState: State, formData: FormData) {
// Zod를 사용한 폼 검증
const validatedFields = CreateInvoice.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 Create Invoice.',
};
}
// 데이터 준비
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
// 데이터베이스 삽입
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.',
};
}
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState: State = { message: null, errors: {} };
const [state, formAction] = useActionState(createInvoice, initialState);
return (
<form action={formAction}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
<div className="mb-4">
{/* 고객 선택 필드 */}
<select id="customer" name="customerId">
{/* 옵션들 */}
</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>
</div>
</div>
</form>
);
}
이번 실습에서는 edit-form.tsx
컴포넌트에 접근성을 향상시키는 요소들을 추가해보겠습니다.
useActionState
를 추가하여 폼 상태 관리updateInvoice
액션에 Zod 검증 추가 "use client";
import { CustomerField, InvoiceForm } from "@/app/lib/definitions";
import { Button } from "@/app/ui/button";
import { State, updateInvoice } from "@/app/lib/actions";
import { useFormState } from "react-dom";
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const initialState: State = { message: null, errors: {} };
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
const [state, formAction] = useFormState(updateInvoiceWithId, initialState);
return (
<form action={formAction}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* 숨겨진 ID 필드 */}
<input type="hidden" name="id" value={invoice.id} />
{/* 고객 선택 */}
<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={invoice.customer_id}
>
<option value="" disabled>
Select a customer
</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
</div>
{/* 🎯 ARIA 라벨과 오류 메시지 추가 */}
<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 className="mb-4">
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
Choose an amount
</label>
<div className="relative mt-2 rounded-md">
<input
id="amount"
name="amount"
type="number"
defaultValue={invoice.amount}
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"
/>
</div>
{/* 금액 오류 메시지 */}
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
{/* 상태 선택 */}
<div>
<label htmlFor="status" className="mb-2 block text-sm font-medium">
Set the invoice status
</label>
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
<div className="flex gap-4">
<div className="flex items-center">
<input
id="pending"
name="status"
type="radio"
value="pending"
defaultChecked={invoice.status === "pending"}
/>
<label htmlFor="pending" className="ml-2 flex items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600">
Pending
</label>
</div>
<div className="flex items-center">
<input
id="paid"
name="status"
type="radio"
value="paid"
defaultChecked={invoice.status === "paid"}
/>
<label htmlFor="paid" className="ml-2 flex items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white">
Paid
</label>
</div>
</div>
</div>
{/* 상태 오류 메시지 */}
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.status &&
state.errors.status.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-4">
<Link
href="/dashboard/invoices"
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
>
Cancel
</Link>
<Button type="submit">Edit Invoice</Button>
</div>
</form>
);
}
// /app/lib/actions.ts
export async function updateInvoice(
id: string,
prevState: State,
formData: FormData
) {
// Zod를 사용한 폼 검증
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");
}