React에서 비동기 폼 검증은 어렵다 — 이를 해결하는 예측 가능한 방법

발행: (2025년 12월 31일 오후 10:02 GMT+9)
12 min read
원문: Dev.to

Source: Dev.to

비동기 폼 검증이 React에서 어려운 이유 – 예측 가능한 해결책

React에서 폼 검증을 구현할 때, 특히 서버와의 비동기 검증을 포함해야 할 경우, 복잡함에 부딪히기 쉽습니다. 이 글에서는 예측 가능한 방식으로 비동기 검증을 처리하는 방법을 단계별로 살펴보겠습니다.


🎯 목표

  1. 사용자 경험을 해치지 않으면서 비동기 검증을 수행한다.
  2. 코드 가독성유지보수성을 높인다.
  3. React Hook FormYup(또는 Zod) 같은 스키마 기반 검증 라이브러리를 활용한다.

📦 사용된 라이브러리

라이브러리용도
react-hook-form폼 상태 관리 및 기본 검증
@hookform/resolversYup/Zod 스키마를 React Hook Form에 연결
yup (또는 zod)스키마 기반 동기/비동기 검증
axios서버 API 호출 (예시)

Tip: yupvalidate 메서드에 async 함수를 전달하면 비동기 검증을 자동으로 지원합니다.


🛠️ 기본 설정

npm install react-hook-form @hookform/resolvers yup axios

1️⃣ 스키마 정의 (Yup)

import * as yup from "yup";

const schema = yup.object().shape({
  username: yup
    .string()
    .required("사용자 이름은 필수입니다.")
    .min(3, "최소 3자 이상이어야 합니다.")
    .test(
      "unique-username",
      "이미 사용 중인 사용자 이름입니다.",
      async (value) => {
        // 비동기 API 호출 예시
        const response = await axios.get(`/api/users/exists?username=${value}`);
        return !response.data.exists;
      }
    ),
  email: yup
    .string()
    .required("이메일은 필수입니다.")
    .email("유효한 이메일 형식이 아닙니다.")
    .test(
      "unique-email",
      "이미 등록된 이메일입니다.",
      async (value) => {
        const response = await axios.get(`/api/users/exists?email=${value}`);
        return !response.data.exists;
      }
    ),
  password: yup
    .string()
    .required("비밀번호는 필수입니다.")
    .min(8, "비밀번호는 최소 8자여야 합니다."),
});

핵심: test 메서드 안에서 async 함수를 반환하면 Yup이 자동으로 Promise를 처리합니다.


2️⃣ React Hook Form에 스키마 연결

import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";

function RegisterForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: yupResolver(schema),
    mode: "onBlur", // 또는 "onChange"
  });

  const onSubmit = async (data) => {
    // 실제 회원가입 로직
    await axios.post("/api/users/register", data);
    alert("회원가입이 완료되었습니다!");
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* ... 입력 필드 ... */}
    </form>
  );
}

주요 포인트

옵션설명
mode: "onBlur"사용자가 필드에서 포커스를 뗄 때 검증을 실행합니다. 비동기 호출을 최소화할 수 있습니다.
isSubmitting폼이 제출 중일 때 버튼 비활성화 등에 활용합니다.
errors각 필드별 에러 메시지를 UI에 표시합니다.

3️⃣ UI에 에러 표시하기

<input
  type="text"
  {...register("username")}
  placeholder="사용자 이름"
/>
{errors.username && <p className="error">{errors.username.message}</p>}

<input
  type="email"
  {...register("email")}
  placeholder="이메일"
/>
{errors.email && <p className="error">{errors.email.message}</p>}

<input
  type="password"
  {...register("password")}
  placeholder="비밀번호"
/>
{errors.password && <p className="error">{errors.password.message}</p>}

<button type="submit" disabled={isSubmitting}>
  {isSubmitting ? "처리 중…" : "회원가입"}
</button>

4️⃣ 비동기 검증 최적화 팁

  1. 디바운스(debounce) 적용

    • 사용자가 입력을 멈춘 뒤 일정 시간(300ms 정도) 후에 API 호출을 수행하면 불필요한 요청을 줄일 수 있습니다.
    • lodash.debounce 혹은 useDebounce 커스텀 훅을 활용하세요.
  2. 캐시 활용

    • 이미 검증된 값은 메모리(예: Map)에 저장해 두고, 동일한 값에 대해 다시 호출하지 않도록 합니다.
  3. 취소 토큰(Cancel Token) 사용

    • 사용자가 입력을 바꾸면 이전 요청을 취소해 레이스 컨디션을 방지합니다. axios.CancelToken 혹은 AbortController를 사용하세요.
import { useRef } from "react";

function useAbortableFetch() {
  const controllerRef = useRef(null);

  const fetch = async (url) => {
    if (controllerRef.current) {
      controllerRef.current.abort(); // 이전 요청 취소
    }
    controllerRef.current = new AbortController();
    const response = await fetch(url, {
      signal: controllerRef.current.signal,
    });
    return response.json();
  };

  return fetch;
}

📊 전체 흐름 요약

  1. 스키마 정의 → Yup에 동기·비동기 규칙을 모두 선언.
  2. React Hook FormyupResolver 연결 → 폼 상태와 검증 로직을 자동으로 동기화.
  3. UI에서 register, errors, isSubmitting을 사용해 입력과 피드백을 관리.
  4. 옵션(mode, debounce, cache, abort)을 통해 비동기 호출을 최적화.

🏁 마무리

React에서 비동기 폼 검증은 복잡해 보이지만, Yup + React Hook Form 조합을 사용하면 구조화된 스키마와 자동 비동기 처리 덕분에 깔끔하게 구현할 수 있습니다.

  • 예측 가능성: 검증 로직이 스키마 안에 모두 모여 있어 어디서든 재사용 가능.
  • 유연성: test 메서드 안에 자유롭게 비동기 API 호출을 넣을 수 있음.
  • 성능: mode, 디바운스, 캐시, 취소 토큰을 적절히 활용하면 불필요한 네트워크 요청을 최소화.

다음 단계: 프로젝트에 적용해보고, 필요에 따라 Zod 혹은 Superstruct 같은 다른 스키마 라이브러리로 교체해 보세요.

Happy coding! 🚀

핵심 문제: 검증이 결정론적이지 않음

대부분의 폼 솔루션은 상태 스냅샷이 아니라 이벤트를 기반으로 검증합니다.

예시 비동기 검증기

async function validateEmail(value: string) {
  return api.checkEmailAvailability(value);
}

사용자가 빠르게 입력한다고 가정해 보세요:

  1. taken@example.com을 입력 → 비동기 요청 A 시작
  2. john@example.com으로 변경 → 비동기 요청 B 시작
  3. 요청 A가 요청 B보다 나중에 완료 ❌

UI에 “이메일이 이미 사용 중입니다”라는 잘못된 메시지가 표시됩니다.
이전 비동기 결과가 최신 입력을 덮어쓰는 전형적인 레이스 컨디션이 발생합니다.

문제 #1: 비동기 검증 레이스 조건

목표: 최신 검증 결과만이 의미를 갖도록 합니다.

많은 폼 라이브러리:

  • 비동기 검증 실행을 추적하지 않음
  • 검증을 안정적인 값 스냅샷에 연결하지 않음
  • 오래된 결과가 승리하도록 허용

올바른 솔루션이 해야 할 일

  • 비동기 검증 시도를 추적
  • 오래된 비동기 결과 무시
  • 항상 일관된 값 스냅샷에 대해 검증

이러한 조치가 없으면 비동기 검증은 절대 예측 가능하지 않습니다.

문제 #2: 교차 필드 검증은 취약합니다

실제 폼에서는 여러 필드를 함께 검증해야 할 경우가 많습니다. 예시:

  • 비밀번호 확인은 비밀번호와 일치해야 함
  • 종료 날짜는 시작 날짜보다 이후여야 함
  • 특정 필드는 다른 필드가 활성화된 경우에만 필수

일반적인 접근 방식은 다른 필드를 감시하거나, 수동으로 재검증을 수행하거나, 숨겨진 의존성을 두는 것인데, 이는 디버깅이 어려운 암묵적인 동작을 초래합니다.

명시적인 교차 필드 검증

register("confirmPassword", {
  validate: (value, values) =>
    value !== values.password ? "Passwords do not match" : undefined,
});
  • 마법이 없음
  • 자동 재검증 없음
  • 숨겨진 의존성 없음 – 읽기 쉬운 로직만 존재합니다.

Problem #3: 제출은 변경과 다르게 동작합니다

“변경 시에는 검증이 작동하지만, 제출 시에는 다르게 동작합니다.”
이는 많은 라이브러리에서 다음과 같은 이유로 발생합니다:

  • 터치된 필드만 검증
  • 제출 시에는 터치되지 않은 종속 필드를 건너뜀

결과: 예상치 못한 제출 시 오류가 발생합니다.

이를 해결하는 간단한 규칙

제출 시, 등록된 모든 필드를 검증합니다. 언제나. 이렇게 하면 제출 동작이 예측 가능하고 올바르게 됩니다.

솔루션: 예측 가능하고 async‑first 폼 엔진

  • Validation timing is explicit (change | blur | submit)
  • Async validation is race‑condition safe
  • Validation always runs against value snapshots
  • Cross‑field validation is explicit
  • No automatic dependency re‑validation

Formora를 사용한 실제 예시

비동기 이메일 검증 + 비밀번호 확인

const form = useForm({
  initialValues: {
    email: "",
    password: "",
    confirmPassword: "",
  },
  validateOn: "change",
  asyncDebounceMs: 500,
});

{
  await new Promise((r) => setTimeout(r, 300));
  if (value.includes("taken")) return "Email already taken";
},
})}
/>

value !== values.password ? "Passwords do not match" : undefined,
})}
/>

이 예시가 제공하는 장점:

  • 오래된 오류가 표시되지 않는 비동기 검증
  • 명시적인 필드 간 로직
  • 예측 가능한 제출 동작
  • 깔끔하고 디버깅하기 쉬운 코드

다른 개발자들이 Formora를 활용할 수 있는 이유

Formora는 모든 폼 라이브러리를 대체하려는 것이 아니라, 다음과 같은 상황에서 빛을 발합니다:

  • 올바른 비동기 동작
  • 명시적인 검증 로직
  • 타입 안전하고 예측 가능한 상태
  • 실제 애플리케이션에서 디버깅 가능한 폼 동작

앱에 비동기 검증, 필드 간 규칙, 혹은 복잡한 제출 로직이 있다면, Formora는 견고하고 예측 가능한 기반을 제공합니다.

최종 생각

폼은 본질적으로 복잡해서가 아니라, 검증 정확성이 종종 무시되기 때문에 어려워집니다. 비동기 로직, 필드 간 관계, 실제 사용자 행동은 마법이 아니라 예측 가능성을 요구합니다. Formora는 이러한 문제들을 명시적이고 신뢰할 수 있게 해결하기 위해 만들어졌습니다.

유용한 링크

  • GitHub:
  • npm:
Back to Blog

관련 글

더 보기 »

SQL이 나를 불편하게 만든다.

제 실무적이며 이론적이지 않은 이해에 따르면, object‑oriented programming은 전통적인 functional paradigm에 대한 단순한 대안이 아니라 종종 ...처럼 느껴진다.