비교 요약으로 돌아가기

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에서도 폼·입력·오류 요약이 넘치지 않도록 했습니다.