Full output
Codex 원문 결과물
이메일 회원가입 폼의 접근성과 오류 처리 개선 / OpenAI / GPT-5.6 Terra / 실행일 2026-07-24
src/App.tsx
import { FormEvent, useEffect, useRef, useState } from "react";
type FieldName = "email" | "password" | "passwordConfirm";
type Errors = Partial<Record<FieldName, string>>;
const fieldLabels: Record<FieldName, string> = {
email: "이메일",
password: "비밀번호",
passwordConfirm: "비밀번호 확인",
};
export function register(email: string, _password: string): Promise<void> {
if (email === "taken@example.com") {
return Promise.reject(new Error("이미 사용 중인 이메일입니다."));
}
return Promise.resolve();
}
function validate(values: Record<FieldName, string>): Errors {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
return { email: "이메일 주소를 확인해 주세요." };
}
if (values.password.length < 8) {
return { password: "비밀번호는 8자 이상이어야 합니다." };
}
if (values.passwordConfirm !== values.password) {
return { passwordConfirm: "비밀번호가 일치하지 않습니다." };
}
return {};
}
export default function App() {
const [values, setValues] = useState<Record<FieldName, string>>({
email: "",
password: "",
passwordConfirm: "",
});
const [errors, setErrors] = useState<Errors>({});
const [successMessage, setSuccessMessage] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [focusTarget, setFocusTarget] = useState<FieldName | null>(null);
const inputRefs = useRef<Record<FieldName, HTMLInputElement | null>>({
email: null,
password: null,
passwordConfirm: null,
});
useEffect(() => {
if (focusTarget) {
inputRefs.current[focusTarget]?.focus();
setFocusTarget(null);
}
}, [focusTarget]);
function updateValue(field: FieldName, value: string) {
setValues((current) => ({ ...current, [field]: value }));
setSuccessMessage("");
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSuccessMessage("");
const validationErrors = validate(values);
const firstInvalidField = (Object.keys(validationErrors) as FieldName[])[0];
if (firstInvalidField) {
setErrors(validationErrors);
setFocusTarget(firstInvalidField);
return;
}
setErrors({});
setIsSubmitting(true);
try {
await register(values.email, values.password);
setValues((current) => ({
...current,
password: "",
passwordConfirm: "",
}));
setSuccessMessage("회원가입 요청을 완료했습니다.");
} catch (error) {
const message = error instanceof Error ? error.message : "이미 사용 중인 이메일입니다.";
setErrors({ email: message });
setFocusTarget("email");
} finally {
setIsSubmitting(false);
}
}
const errorEntries = (Object.entries(errors) as [FieldName, string][]);
return (
<main className="page">
<section className="signup" aria-labelledby="signup-title">
<h1 id="signup-title">회원가입</h1>
{successMessage && <p className="success" aria-live="polite">{successMessage}</p>}
{errorEntries.length > 0 && (
<div className="error-summary" role="alert" tabIndex={-1}>
<p>입력 내용을 확인해 주세요.</p>
<ul>
{errorEntries.map(([field, message]) => (
<li key={field}>{fieldLabels[field]}: {message}</li>
))}
</ul>
</div>
)}
<form noValidate onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="email">이메일</label>
<input
ref={(element) => { inputRefs.current.email = element; }}
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="name@example.com"
value={values.email}
onChange={(event) => updateValue("email", event.target.value)}
aria-invalid={errors.email ? true : undefined}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && <p className="field-error" id="email-error">{errors.email}</p>}
</div>
<div className="field">
<label htmlFor="password">비밀번호</label>
<input
ref={(element) => { inputRefs.current.password = element; }}
id="password"
name="password"
type="password"
autoComplete="new-password"
value={values.password}
onChange={(event) => updateValue("password", event.target.value)}
aria-invalid={errors.password ? true : undefined}
aria-describedby={errors.password ? "password-error" : undefined}
/>
{errors.password && <p className="field-error" id="password-error">{errors.password}</p>}
</div>
<div className="field">
<label htmlFor="passwordConfirm">비밀번호 확인</label>
<input
ref={(element) => { inputRefs.current.passwordConfirm = element; }}
id="passwordConfirm"
name="passwordConfirm"
type="password"
autoComplete="new-password"
value={values.passwordConfirm}
onChange={(event) => updateValue("passwordConfirm", event.target.value)}
aria-invalid={errors.passwordConfirm ? true : undefined}
aria-describedby={errors.passwordConfirm ? "password-confirm-error" : undefined}
/>
{errors.passwordConfirm && (
<p className="field-error" id="password-confirm-error">{errors.passwordConfirm}</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "처리 중…" : "가입하기"}
</button>
</form>
</section>
</main>
);
}
src/index.css
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
color: #1d2939;
background: #f8fafc;
font-family: system-ui, sans-serif;
}
button, input { font: inherit; }
.page { padding: 24px 16px; }
.signup {
width: min(100%, 34rem);
margin: 0 auto;
padding: clamp(20px, 5vw, 36px);
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 12px;
}
h1 { margin: 0 0 24px; font-size: clamp(1.5rem, 6vw, 2rem); }
form { display: grid; gap: 20px; }
.field { min-width: 0; display: grid; gap: 8px; }
label { font-weight: 700; }
input, button {
width: 100%;
min-height: 44px;
border-radius: 8px;
}
input { padding: 10px 12px; border: 1px solid #98a2b3; }
button { border: 0; padding: 10px 16px; color: #fff; background: #175cd3; cursor: pointer; }
button:disabled { cursor: wait; opacity: .7; }
.field-error, .error-summary p, .error-summary ul, .success { margin: 0; }
.field-error { color: #b42318; font-size: .875rem; }
.error-summary {
margin: 0 0 20px;
padding: 16px;
overflow-wrap: anywhere;
color: #7a271a;
background: #fef3f2;
border: 1px solid #fecdca;
border-radius: 8px;
}
.error-summary ul { padding-left: 20px; }
.success { margin: 0 0 20px; color: #067647; }
:focus-visible {
outline: 3px solid #84adff;
outline-offset: 2px;
}
@media (min-width: 768px) {
.page { padding: 56px 24px; }
}
src/App.test.tsx
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";
describe("회원가입 폼", () => {
it("잘못된 이메일을 알리고 이메일 필드로 포커스를 옮긴다", async () => {
const user = userEvent.setup();
render(<App />);
await user.click(screen.getByRole("button", { name: "가입하기" }));
const email = screen.getByLabelText("이메일");
expect(screen.getAllByText("이메일 주소를 확인해 주세요.")).toHaveLength(2);
expect(email.getAttribute("aria-invalid")).toBe("true");
expect(screen.getByRole("alert").textContent).toContain("이메일: 이메일 주소를 확인해 주세요.");
await waitFor(() => expect(document.activeElement).toBe(email));
});
it("비밀번호 불일치 시 비밀번호 확인 필드로 포커스를 옮긴다", async () => {
const user = userEvent.setup();
render(<App />);
await user.type(screen.getByLabelText("이메일"), "person@example.com");
await user.type(screen.getByLabelText("비밀번호", { exact: true }), "password1");
await user.type(screen.getByLabelText("비밀번호 확인"), "password2");
await user.click(screen.getByRole("button", { name: "가입하기" }));
const confirmation = screen.getByLabelText("비밀번호 확인");
expect(screen.getAllByText("비밀번호가 일치하지 않습니다.")).toHaveLength(2);
await waitFor(() => expect(document.activeElement).toBe(confirmation));
});
it("이미 사용 중인 이메일 서버 오류를 이메일과 alert에 표시한다", async () => {
const user = userEvent.setup();
render(<App />);
await user.type(screen.getByLabelText("이메일"), "taken@example.com");
await user.type(screen.getByLabelText("비밀번호", { exact: true }), "password1");
await user.type(screen.getByLabelText("비밀번호 확인"), "password1");
await user.click(screen.getByRole("button", { name: "가입하기" }));
const email = screen.getByLabelText("이메일");
expect(await screen.findAllByText("이미 사용 중인 이메일입니다.")).toHaveLength(2);
expect(screen.getByRole("alert").textContent).toContain("이메일: 이미 사용 중인 이메일입니다.");
expect(email.getAttribute("aria-invalid")).toBe("true");
await waitFor(() => expect(document.activeElement).toBe(email));
});
it("성공 시 polite live region을 알리고 비밀번호만 비운다", async () => {
const user = userEvent.setup();
render(<App />);
const email = screen.getByLabelText("이메일") as HTMLInputElement;
const password = screen.getByLabelText("비밀번호", { exact: true }) as HTMLInputElement;
const confirmation = screen.getByLabelText("비밀번호 확인") as HTMLInputElement;
await user.type(email, "person@example.com");
await user.type(password, "password1");
await user.type(confirmation, "password1");
await user.click(screen.getByRole("button", { name: "가입하기" }));
const status = await screen.findByText("회원가입 요청을 완료했습니다.");
expect(status.getAttribute("aria-live")).toBe("polite");
expect(email.value).toBe("person@example.com");
expect(password.value).toBe("");
expect(confirmation.value).toBe("");
});
});
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
},
});
package.json
{
"name": "samebrief",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --hostname 127.0.0.1",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"gray-matter": "^4.0.3",
"next": "^16.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.0.0",
"remark-gfm": "^4.0.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@playwright/test": "^1.49.1",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.1",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"eslint": "^9.17.0",
"eslint-config-next": "^16.0.1",
"jsdom": "^25.0.1",
"typescript": "^5.7.2",
"vitest": "^3.0.0"
}
}
npm test로 네 가지 렌더링 테스트를 실행합니다. 실제 <form>의 submit과 <button type="submit">만 사용했으므로 Tab·Enter·Space 조작이 기본적으로 가능합니다. 오류 상태는 상태 갱신 뒤 해당 입력에 포커스를 옮기는 효과로 확인하며, CSS의 width: min(100%, 34rem), min-width: 0, overflow-wrap: anywhere와 16px 모바일 여백으로 375px에서도 폼·입력·오류 요약이 넘치지 않도록 했습니다.