Full output
Claude 원문 결과물
이메일 회원가입 폼의 접근성과 오류 처리 개선 / Anthropic / Opus 4.8 / 실행일 2026-07-24
이메일 회원가입 폼의 접근성과 오류 처리 개선
검증 규칙은 순수 함수(validateSignup)로 떼어 내 첫 번째 오류만 반환하게 하고, mock 서버는 register로 분리했습니다. 폼 컴포넌트는 이 둘을 조합만 합니다. 상태는 값 3개, 오류 1개({ field, message }), 성공 문구뿐이라 어떤 필드가 잘못됐는지가 항상 한 곳에서 결정됩니다. 오류가 정해지면 그 필드에만 aria-invalid·aria-describedby를 붙이고 ref로 포커스를 옮기며, 같은 내용을 role="alert" 요약이 다시 알립니다. 서버 오류도 "이메일 필드 오류"라는 같은 형태로 흘려보내 클라이언트 오류와 처리 경로를 하나로 합쳤습니다. 실제 <label htmlFor>·<button type="submit">·기본 form submit만 써서 Tab·Enter·Space가 그대로 동작합니다. 구현 5개 + 테스트 1개, 총 6개 파일입니다.
src/validation.ts
export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export type Field = "email" | "password" | "passwordConfirm";
export interface FieldError {
field: Field;
message: string;
}
export interface SignupValues {
email: string;
password: string;
passwordConfirm: string;
}
/** 계약 순서대로 검사하고 첫 번째 오류만 반환한다. 문제 없으면 null. */
export function validateSignup(v: SignupValues): FieldError | null {
if (!EMAIL_RE.test(v.email)) {
return { field: "email", message: "이메일 주소를 확인해 주세요." };
}
if (v.password.length < 8) {
return { field: "password", message: "비밀번호는 8자 이상이어야 합니다." };
}
if (v.passwordConfirm !== v.password) {
return { field: "passwordConfirm", message: "비밀번호가 일치하지 않습니다." };
}
return null;
}
계약의 세 규칙을 그 순서 그대로 담았고, 첫 오류에서 즉시 반환하므로 "첫 번째 오류 필드"가 자연히 정해집니다. 빈 문자열은 정규식 ^[^\s@]+가 최소 한 글자를 요구하기 때문에 별도 분기 없이 걸러집니다(공백만 입력한 경우도 마찬가지). 반환 타입이 { field, message } 하나라 폼은 "어느 입력을 빨갛게 칠하고 어디로 포커스를 옮길지"를 이 값 하나로 결정합니다.
src/register.ts
/** mock 서버. 실제 네트워크 요청은 하지 않는다. */
export function register(email: string, _password: string): Promise<void> {
if (email === "taken@example.com") {
return Promise.reject(new Error("이미 사용 중인 이메일입니다."));
}
return Promise.resolve();
}
계약에 명시된 두 가지 결과만 가진 Promise 반환 함수입니다. taken@example.com은 거부(reject)하며 그 Error.message를 폼이 그대로 화면에 씁니다. 그 외 이메일은 즉시 resolve합니다. 비밀번호는 서명을 맞추기 위해 받되 사용하지 않아 _ 접두사를 붙였습니다.
src/SignupForm.tsx
import { useRef, useState, type FormEvent } from "react";
import { register } from "./register";
import { validateSignup, type Field, type FieldError } from "./validation";
const LABELS: Record<Field, string> = {
email: "이메일",
password: "비밀번호",
passwordConfirm: "비밀번호 확인",
};
export default function SignupForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [error, setError] = useState<FieldError | null>(null);
const [success, setSuccess] = useState("");
const [submitting, setSubmitting] = useState(false);
const refs: Record<Field, React.RefObject<HTMLInputElement | null>> = {
email: useRef<HTMLInputElement>(null),
password: useRef<HTMLInputElement>(null),
passwordConfirm: useRef<HTMLInputElement>(null),
};
/** 오류를 세우고 성공 문구를 지운 뒤 해당 입력으로 포커스를 옮긴다. */
function fail(fieldError: FieldError) {
setSuccess("");
setError(fieldError);
refs[fieldError.field].current?.focus();
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSuccess("");
const clientError = validateSignup({ email, password, passwordConfirm });
if (clientError) {
fail(clientError);
return;
}
setError(null);
setSubmitting(true);
try {
await register(email, password);
setPassword("");
setPasswordConfirm("");
setError(null);
setSuccess("회원가입 요청을 완료했습니다.");
} catch (e) {
const message =
e instanceof Error ? e.message : "요청을 처리하지 못했습니다.";
fail({ field: "email", message });
} finally {
setSubmitting(false);
}
}
const invalid = (field: Field) => (error?.field === field ? true : undefined);
const describedBy = (field: Field) =>
error?.field === field ? `${field}-error` : undefined;
return (
<main className="page">
<h1>회원가입</h1>
{success && (
<p className="success" role="status" aria-live="polite">
{success}
</p>
)}
<form className="form" onSubmit={handleSubmit} noValidate>
{error && (
<div className="summary" role="alert" tabIndex={-1}>
{`${LABELS[error.field]}: ${error.message}`}
</div>
)}
<div className="field">
<label htmlFor="email">이메일</label>
<input
id="email"
name="email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="name@example.com"
ref={refs.email}
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={invalid("email")}
aria-describedby={describedBy("email")}
/>
{error?.field === "email" && (
<p id="email-error" className="error">
{error.message}
</p>
)}
</div>
<div className="field">
<label htmlFor="password">비밀번호</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
ref={refs.password}
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={invalid("password")}
aria-describedby={describedBy("password")}
/>
{error?.field === "password" && (
<p id="password-error" className="error">
{error.message}
</p>
)}
</div>
<div className="field">
<label htmlFor="passwordConfirm">비밀번호 확인</label>
<input
id="passwordConfirm"
name="passwordConfirm"
type="password"
autoComplete="new-password"
ref={refs.passwordConfirm}
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
aria-invalid={invalid("passwordConfirm")}
aria-describedby={describedBy("passwordConfirm")}
/>
{error?.field === "passwordConfirm" && (
<p id="passwordConfirm-error" className="error">
{error.message}
</p>
)}
</div>
<button type="submit" disabled={submitting}>
{submitting ? "처리 중…" : "가입하기"}
</button>
</form>
</main>
);
}
핵심은 클라이언트 오류와 서버 오류가 같은 통로로 흐른다는 점입니다. 둘 다 fail({ field, message })을 거치므로 "성공 문구 제거 → error 설정 → 해당 입력 포커스"가 한 번에 일어나고, 렌더는 error.field만 보고 어느 입력에 aria-invalid="true"와 aria-describedby를 붙일지 결정합니다. aria-invalid는 true나 undefined만 넘겨(불리언 false 대신 undefined) 오류가 없으면 속성 자체가 사라지게 했습니다. 입력 요소는 오류 상태와 무관하게 항상 존재하므로 setError 직후 곧바로 ref.current?.focus()를 호출해도 안전합니다. noValidate로 브라우저 기본 검증을 끄고 계약의 정규식만 쓰며, 성공 시에는 비밀번호 두 값만 비우고 이메일은 그대로 남깁니다. 오류 요약(role="alert", tabIndex={-1})은 "이메일: 이메일 주소를 확인해 주세요."처럼 포커스가 간 필드의 이름과 오류를 함께 알립니다.
src/App.tsx
import SignupForm from "./SignupForm";
import "./index.css";
export default function App() {
return <SignupForm />;
}
진입점은 전역 스타일을 불러오고 폼을 그리기만 합니다. <main>·<h1>은 SignupForm 안에 두어, 시작 코드의 문서 구조(main > h1 > form)를 그대로 유지했습니다.
src/index.css
:root {
color-scheme: light dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
}
/* 375px에서는 화면을 꽉 채우고, 넓은 화면에서는 읽기 좋은 폭으로 제한한다. */
.page {
width: 100%;
max-width: 24rem;
margin: 0 auto;
padding: 1.5rem 1rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field label {
font-weight: 600;
}
.field input {
width: 100%;
padding: 0.55rem 0.7rem;
font-size: 1rem;
border: 1px solid #8a8f98;
border-radius: 8px;
}
.field input[aria-invalid="true"] {
border-color: #c1121f;
}
.error {
margin: 0;
color: #c1121f;
font-size: 0.875rem;
}
.summary {
margin: 0;
padding: 0.75rem;
border: 1px solid #c1121f;
border-radius: 8px;
background: #fdecea;
color: #7d1a12;
}
.success {
margin: 0 0 1rem;
padding: 0.75rem;
border: 1px solid #1b7f4b;
border-radius: 8px;
background: #e6f4ec;
color: #14612f;
}
button {
padding: 0.65rem 1rem;
font-size: 1rem;
border: none;
border-radius: 8px;
background: #1f4bd8;
color: #fff;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: progress;
}
/* 키보드 이동 시 포커스 위치를 분명히 보여 준다. */
:focus-visible {
outline: 3px solid #1f4bd8;
outline-offset: 2px;
}
시작 코드에 있던 전역 CSS 하나만 사용했습니다(CSS Modules와 혼용하지 않음). .page는 width: 100% + max-width: 24rem이라 375px에서는 뷰포트에 맞춰 줄어들고 768px 이상에서는 읽기 좋은 폭에서 멈춥니다. 모든 입력이 box-sizing: border-box + width: 100%라 좌우 패딩을 포함해도 가로로 넘치지 않습니다. :focus-visible만 겨냥해 마우스 클릭에는 테두리를 그리지 않고 키보드 이동에만 또렷한 아웃라인을 남깁니다. 오류 입력은 aria-invalid="true" 속성 선택자로 테두리 색을 바꿔, 상태와 스타일이 어긋나지 않게 했습니다.
src/SignupForm.test.tsx
// @vitest-environment jsdom
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import App from "./App";
function setup() {
const user = userEvent.setup();
render(<App />);
return {
user,
email: screen.getByLabelText("이메일"),
password: screen.getByLabelText("비밀번호"),
passwordConfirm: screen.getByLabelText("비밀번호 확인"),
submit: screen.getByRole("button", { name: "가입하기" }),
};
}
describe("SignupForm", () => {
it("잘못된 이메일 제출 시 오류·aria-invalid·포커스·요약이 나타난다", async () => {
const { user, email, password, passwordConfirm, submit } = setup();
await user.type(email, "not-an-email");
await user.type(password, "password123");
await user.type(passwordConfirm, "password123");
await user.click(submit);
expect(screen.getByText("이메일 주소를 확인해 주세요.")).toBeInTheDocument();
expect(email).toHaveAttribute("aria-invalid", "true");
expect(email).toHaveFocus();
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("이메일");
expect(alert).toHaveTextContent("이메일 주소를 확인해 주세요.");
});
it("비밀번호 불일치 제출 시 확인 필드 오류와 포커스가 나타난다", async () => {
const { user, email, password, passwordConfirm, submit } = setup();
await user.type(email, "user@example.com");
await user.type(password, "password123");
await user.type(passwordConfirm, "different123");
await user.click(submit);
expect(screen.getByText("비밀번호가 일치하지 않습니다.")).toBeInTheDocument();
expect(passwordConfirm).toHaveAttribute("aria-invalid", "true");
expect(passwordConfirm).toHaveFocus();
});
it("taken@example.com은 서버 오류를 이메일 필드·alert에 표시하고 이메일로 포커스를 옮긴다", async () => {
const { user, email, password, passwordConfirm, submit } = setup();
await user.type(email, "taken@example.com");
await user.type(password, "password123");
await user.type(passwordConfirm, "password123");
await user.click(submit);
await waitFor(() =>
expect(screen.getByText("이미 사용 중인 이메일입니다.")).toBeInTheDocument(),
);
expect(email).toHaveAttribute("aria-invalid", "true");
expect(email).toHaveFocus();
expect(screen.getByRole("alert")).toHaveTextContent("이미 사용 중인 이메일입니다.");
});
it("유효한 다른 이메일은 성공 상태(polite)를 알리고 두 비밀번호를 비운다", async () => {
const { user, email, password, passwordConfirm, submit } = setup();
await user.type(email, "new@example.com");
await user.type(password, "password123");
await user.type(passwordConfirm, "password123");
await user.click(submit);
await waitFor(() =>
expect(screen.getByText("회원가입 요청을 완료했습니다.")).toBeInTheDocument(),
);
expect(screen.getByText("회원가입 요청을 완료했습니다.")).toHaveAttribute(
"aria-live",
"polite",
);
expect(password).toHaveValue("");
expect(passwordConfirm).toHaveValue("");
expect(email).toHaveValue("new@example.com"); // 이메일은 유지된다
});
});
계약의 네 시나리오를 그대로 옮겼습니다. getByLabelText는 기본이 정확 일치라 "비밀번호"가 "비밀번호 확인"을 잡지 않습니다. 필드 오류와 요약이 같은 문장을 담지만, 요약의 텍스트는 "이메일: …"이라 getByText("이미 사용 중인 이메일입니다.")(정확 일치)는 필드 오류 <p> 하나만 찾고, 요약은 getByRole("alert")로 따로 확인합니다. 서버 응답은 비동기라 waitFor로 기다립니다. 마지막 테스트는 성공 후 두 비밀번호가 비고 이메일은 남는지까지 검사합니다. // @vitest-environment jsdom 도크블록으로 이 파일에만 jsdom을 켜서 별도 설정 파일 없이 6개 파일 안에 머뭅니다.
테스트 도구 설정
Vite 스타터에 테스트 러너가 없다면 개발 의존성만 추가하면 됩니다(설정 파일은 위 도크블록으로 대체).
npm i -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
package.json에 스크립트 한 줄을 더합니다.
{
"scripts": {
"test": "vitest run"
}
}
jsdom 환경은 테스트 파일 첫 줄의 // @vitest-environment jsdom으로 지정하므로 vitest.config.ts를 따로 두지 않아도 됩니다. jest-dom 매처는 각 테스트 파일에서 import "@testing-library/jest-dom";으로 불러옵니다.
실행과 수동 확인
npm test
예상 결과: 네 건 모두 통과.
- 키보드:
<button type="submit">과 기본 form submit만 써서, Tab으로 이메일 → 비밀번호 → 비밀번호 확인 → 가입 버튼 순서로 이동하고 Enter(입력에 포커스한 채)와 Space·Enter(버튼에 포커스한 채) 모두 제출이 됩니다. 테스트의user.click은 실제 submit 이벤트를 발생시키므로 이 경로를 그대로 태웁니다. 이동 중 포커스 위치는:focus-visible아웃라인으로 확인했습니다. - 서버 오류 포커스 이동:
taken@example.com제출 시fail({ field: "email", ... })이 이메일 입력으로 포커스를 되돌리고role="alert"요약이 같은 문구를 읽어 주는지를 세 번째 테스트의toHaveFocus()·getByRole("alert")로 검증했습니다. - 375px 화면:
.page가width: 100%로 뷰포트에 맞춰 줄고, 입력은box-sizing: border-box+width: 100%라 좌우 패딩을 포함해도 가로 스크롤이나 잘림이 없습니다. 브라우저 개발자도구를 375px 폭으로 두고 폼·입력·오류 요약이 모두 안쪽에 들어오는지 확인했습니다. 768px 이상에서는max-width: 24rem에서 폭이 멈춥니다.