🌐 Improving Accessibility

웹 접근성을 개선하여 모든 사용자가 쉽게 사용할 수 있는 애플리케이션을 만드는 방법을 알아보겠습니다.


📚 목차

  1. 웹 접근성이란?
  2. ESLint 접근성 플러그인 설정
  3. Form 접근성 향상
  4. Form 유효성 검사
  5. 실습: Aria 라벨 추가하기

1. 웹 접근성이란?

💡 접근성(Accessibility)은 모든 사용자가 웹 애플리케이션을 쉽게 사용할 수 있도록 설계하는 것을 의미합니다.

웹 접근성은 단순히 장애가 있는 사용자만을 위한 것이 아닙니다. 다음과 같은 다양한 요소들을 포함합니다:

  • 🎹 키보드 내비게이션
  • 🏷️ 시맨틱(Semantic) HTML
  • 🖼️ 이미지 대체 텍스트
  • 🎨 색상 대비
  • 🎬 비디오 자막

2. ESLint 접근성 플러그인 설정

Next.js는 기본적으로 eslint-plugin-jsx-a11y 플러그인을 포함하여 접근성 문제를 조기에 발견할 수 있게 도와줍니다.

2.1 설정 방법

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

2.2 설치 과정 스크린샷

⚠️ 주의사항: ESLint v9에서 오류가 발생하면 v8.57.0으로 다운그레이드하세요.

npm uninstall eslint
npm install -D eslint@8.57.0

2.3 접근성 검사 테스트

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`} 👈 이 라인을 주석처리
/>


3. Form 접근성 향상

현재 프로젝트에서는 이미 Form 접근성을 위해 다음 세 가지 요소를 구현하고 있습니다:

3.1 Semantic HTML 사용

// ✅ 좋은 예: 시맨틱 요소 사용
<input type="email" />
<select>
  <option>옵션1</option>
</select>

// ❌ 나쁜 예: 비시맨틱 요소 사용
<div onClick={handleClick}>클릭</div>

3.2 레이블 지정

<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"
/>

3.3 포커스 아웃라인

/* 포커스 시 아웃라인 표시 */
.form-input:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

💡 Tip: Tab 키를 눌러 포커스 이동을 테스트해보세요!


4. Form 유효성 검사

Form 검증은 사용자 경험과 데이터 무결성을 위해 필수적입니다.

4.1 클라이언트 사이드 검증

가장 간단한 방법은 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  // 👈 필수 속성 추가
/>

4.2 서버 사이드 검증

서버 사이드 검증의 장점:

  • ✅ 데이터베이스 전송 전 형식 확인
  • ✅ 악의적인 사용자의 클라이언트 검증 우회 방지
  • ✅ 데이터 유효성에 대한 단일 신뢰 소스

4.3 useActionState Hook 사용

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>
  );
}

📋 useActionState 매개변수

매개변수타입설명
fnFunction폼 제출 시 호출되는 함수
initialStateAny초기 상태값
permalinkString (선택사항)JavaScript 번들 로드 전 폼 제출 시 이동할 URL

📤 useActionState 반환값

반환값설명
state현재 상태 (첫 렌더링 시 initialState와 동일)
formActionForm의 action 속성에 전달할 액션

4.4 Form 컴포넌트 구현

"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>
  );
}

4.5 Zod를 사용한 스키마 검증

// /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;
};

4.6 서버 액션 구현

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');
}

4.7 오류 메시지 표시

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>
  );
}


5. 실습: Aria 라벨 추가하기

이번 실습에서는 edit-form.tsx 컴포넌트에 접근성을 향상시키는 요소들을 추가해보겠습니다.

5.1 구현 목표

  • useActionState를 추가하여 폼 상태 관리
  • updateInvoice 액션에 Zod 검증 추가
  • ✅ 오류 메시지 표시 및 ARIA 라벨 추가

5.2 Edit Form 컴포넌트

"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>
  );
}

5.3 Update Invoice 액션

// /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");
}
profile
프론트엔드 입문 개발자입니다.

0개의 댓글