프론트엔드
이메일 회원가입 폼의 접근성과 오류 처리 개선
React와 TypeScript로 이메일·비밀번호·비밀번호 확인 회원가입 폼을 개선하고, 순서 있는 클라이언트 검증과 mock 서버 오류, 오류 필드 포커스 이동, 키보드 접근성, 모바일 대응, 렌더링 테스트를 구현한다.
Task
성공 조건
- 제출 때 이메일·비밀번호·비밀번호 확인을 계약 순서대로 검증하고, 첫 오류 입력에 aria-invalid, aria-describedby, 필드 가까운 오류 문구와 포커스를 적용한다.
- 오류 요약은 role=alert와 tabIndex=-1을 가지며, 포커스를 받은 필드 이름과 오류를 함께 표시한다.
- 유효한 값에서만 register(email, password)를 호출하고, taken@example.com 서버 오류는 이메일 필드와 오류 요약에 표시한 뒤 이메일로 포커스를 옮긴다.
- 성공 상태를 polite live region으로 알리고 이메일은 유지한 채 두 비밀번호 입력만 비운다.
- 실제 submit 버튼과 기본 form submit으로 키보드 조작을 지원하고, 375px에서 넘침 없이 보이며 768px 이상에서 읽기 좋은 폭을 유지한다.
- 잘못된 이메일, 비밀번호 불일치, 서버 오류, 성공 후 비밀번호 초기화를 렌더링 테스트로 검증한다.
Common conditions
핵심 조건
- React 19와 TypeScript, React 기본 상태만 사용하며 외부 UI·인증·소셜 로그인은 추가하지 않는다.
- 각 입력에 실제 label을 연결하고 오류 상태에서만 aria-invalid와 aria-describedby를 적용한다.
- 고정된 세 클라이언트 검증 문구와 taken@example.com mock 서버 오류 계약만 사용한다.
Model results
모델별 결과
검증과 mock 서버를 분리하고 단일 오류 객체로 필드 오류·요약·포커스 이동을 일관되게 처리한 6파일 구성의 접근성 중심 구현.
이메일 회원가입 폼의 접근성과 오류 처리 개선
검증 규칙은 순수 함수(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에서 폭이 멈춥니다.
Recommendation
이 과업의 추천 모델: Codex
두 결과 모두 핵심 접근성 계약을 충족하지만, Codex는 요구된 파일별 전체 내용을 5개 파일로 바로 적용할 수 있게 제시하고 네 가지 테스트를 간결하게 유지했다. 초기 편집 평가의 총점도 Codex가 가장 높다.
잘 맞는 경우
- 기존 React 시작 프로젝트에 최소 파일 수로 접근성 있는 회원가입 폼을 붙일 때
- 고정 오류 문구와 포커스 이동을 렌더링 테스트로 빠르게 검증할 때
주의할 점
- 기존 프로젝트의 CSS 및 테스트 설정과 충돌하지 않도록 전역 스타일과 개발 의존성을 병합해야 한다.
Verification record
검증 기록
정적 검토 프롬프트·메타데이터·원문 코드의 정적 대조. 제출된 렌더링 테스트는 이 아카이브에서 독립 실행하지 않았다. · 검토일 2026-07-24
- 오류·포커스·ARIA 계약확인
- 두 원문에서 검증 순서, label 연결, aria-invalid·aria-describedby, alert 요약과 오류 필드 포커스를 대조했다.
- 서버 오류와 성공 상태확인
- taken@example.com 오류, 성공 live region, 비밀번호 두 필드만 초기화하는 흐름과 네 가지 요구 테스트를 원문에서 확인했다.
Prompt and environment
공통 프롬프트와 실행 환경
프롬프트 전문 보기
아래 시작 코드를 React와 TypeScript로 개선해 주세요. 이메일·비밀번호·비밀번호 확인 필드를 가진 회원가입 폼에 클라이언트 검증, mock 서버 오류, 오류 시 포커스 이동, 키보드 조작, 모바일 대응을 구현합니다. 구현과 테스트를 합쳐 4~8개 파일 안에 머무르세요.
제출 시 아래 순서대로 검증하고, 첫 번째 오류 필드로 포커스를 이동합니다.
1. 이메일이 비어 있거나 ^[^\s@]+@[^\s@]+\.[^\s@]+$를 만족하지 않으면: 이메일 주소를 확인해 주세요.
2. 비밀번호가 8자 미만이면: 비밀번호는 8자 이상이어야 합니다.
3. 비밀번호 확인이 비밀번호와 다르면: 비밀번호가 일치하지 않습니다.
각 입력에는 연결된 실제 label이 있어야 하고, 오류 입력에는 aria-invalid="true"와 오류 문구를 가리키는 aria-describedby를 적용하세요. 오류 요약은 role="alert"와 tabIndex={-1}을 가지며, 포커스를 받은 오류 필드의 이름과 오류를 포함해야 합니다.
클라이언트 검증을 통과한 경우에만 register(email, password) mock 함수를 호출합니다. taken@example.com이면 Promise.reject(new Error("이미 사용 중인 이메일입니다.")), 그 외 이메일이면 Promise.resolve()를 반환합니다. 서버 오류 때 이메일 필드와 오류 요약을 표시하고 이메일로 포커스를 이동하세요. 성공하면 폼 위에 회원가입 요청을 완료했습니다.를 aria-live="polite"로 표시하고 비밀번호와 비밀번호 확인만 비우세요.
실제 button type="submit"과 기본 form submit만 사용해 Tab, Enter, Space로 조작할 수 있게 하세요. 375px 화면에서 폼·입력·오류 요약이 넘치거나 잘리지 않아야 하며, 768px 이상에는 읽기 좋은 최대 폭을 둡니다. 잘못된 이메일, 비밀번호 불일치, 서버 오류, 성공 상태를 렌더링 테스트로 검증하세요. 실제 네트워크 요청, 로그인 화면, 소셜 로그인, 명세 밖 검증은 추가하지 마세요.
- React 19 · TypeScript
- React 기본 상태 · 전역 CSS
- Vitest · React Testing Library · jsdom
- 실제 인증 API·외부 UI 라이브러리 없음